Compare commits
25 Commits
v2.0.0
...
bec2fb2089
| Author | SHA1 | Date | |
|---|---|---|---|
| bec2fb2089 | |||
| 9c48cdd884 | |||
| 9ed2ea4b13 | |||
| c6236ed5d9 | |||
| 75e1aacd19 | |||
| 92a63feb9c | |||
| 26e977d338 | |||
| 0d9f483131 | |||
| 87dd56a9ce | |||
| fd5a0bd3cf | |||
| bf6ade51af | |||
| 182d4eeac2 | |||
| 8426d05f12 | |||
| 53d0134cb1 | |||
| 8f9ce89039 | |||
| 87af03955c | |||
| 790caefd52 | |||
| a5a61ac7e3 | |||
| 18f22160b2 | |||
| ef52ad6a79 | |||
| 12754c4908 | |||
| 756a2bdf04 | |||
| d152941360 | |||
| 55c28e51b2 | |||
| 38c8cdf25b |
@@ -0,0 +1,11 @@
|
||||
Old/
|
||||
data/
|
||||
.venv/
|
||||
.git/
|
||||
logs/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.env.mainnet
|
||||
docs/
|
||||
@@ -43,3 +43,12 @@ data/games/
|
||||
# archived data (mirrors top-level data/ ignores, which are top-level-anchored)
|
||||
Old/data/
|
||||
Old/**/__pycache__/
|
||||
# run logs (rigenerabili dagli script)
|
||||
logs/
|
||||
|
||||
# cache della ricerca trackE (rigenerabile)
|
||||
.cache_trackE_*.npy
|
||||
|
||||
# feed backup pre-rebuild (binari rigenerabili, NON in git) + stato paper trader (runtime)
|
||||
data/_feed_backup/
|
||||
data/paper_trend/
|
||||
|
||||
@@ -16,6 +16,76 @@ Cosa è cambiato:
|
||||
- L'esecuzione è **DISABILITATA**, il conto mainnet è flat. **Non c'è trading live attivo.**
|
||||
- Si riparte dalla ricerca di strategie NUOVE, su dati certi, con la metodologia qui sotto.
|
||||
|
||||
### Ricerca post-reset (2026-06-19) — esito
|
||||
|
||||
Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condiviso
|
||||
`src/backtest/harness.py`). Sintesi in `docs/diary/2026-06-19-research-synthesis.md`.
|
||||
|
||||
- **TP01 Trend Portfolio — strategia DIFENSIVA robusta (non alpha)** —
|
||||
`src/strategies/trend_portfolio.py`. TSMOM multi-orizzonte (1-3-6 mesi) vol-targeted, long-flat,
|
||||
50/50 BTC+ETH. Config canonica **PORT LF1d** (**>=12h, 1d raccomandato**, vol-target 20%, leva cap 2x):
|
||||
**FULL Sharpe ~1.30, maxDD ~14%; HOLD-OUT 2025-26 Sharpe ~0.31 / +3.5%** mentre il buy&hold 50/50
|
||||
faceva −39%/DD60%. Verificata indipendentemente col gauntlet onesto (hold-out + cross-asset +
|
||||
plateau + deflated-Sharpe 0.999): **regge**. **Valore = taglio del drawdown ~6× vs buy&hold**, NON
|
||||
generazione di ritorno (CAGR ~16% vs ~48% del buy&hold sul toro).
|
||||
⚠️ **LOOK-AHEAD (2026-06-19):** un ffill MIXED-TIMEFRAME su barre open-labeled gonfiava il 4h
|
||||
(~1.60 → reale ~1.1). Il calcolo per-singolo-TF è leak-free, ma **NON scendere sotto le 12h**:
|
||||
costi+overfitting dominano senza vantaggio (FULL Sh piatto ~1.3 da 12h a 4h; hold-out migliore a 1d).
|
||||
Deploy/paper a **1d**. Diari `2026-06-19-tp01-verification.md` / `-tp01-lookahead-fix-lf.md`.
|
||||
Paper trader: `scripts/live/paper_trend.py` (1d). Test: `tests/test_trend_portfolio.py`.
|
||||
Ri-verifica: `scripts/analysis/{verify_tp01,stress_tp01,tp01_lowfreq}.py`.
|
||||
- **XS01 Cross-Sectional Momentum (Hyperliquid) — DIVERSIFICATORE che migliora il portafoglio** —
|
||||
`src/portfolio/sleeves.py:_xsec_returns`. Market-neutral su **19 alt liquidi major** Hyperliquid (1d,
|
||||
dal 2024): ogni 10g long i 5 più forti / short i 5 più deboli, vol-target 20%. **Scorrelato a TP01
|
||||
(~−0.12).** Affinato (2026-06-19): **(a) blend di lookback [30,90]** (z-score cross-sectional mediato,
|
||||
come il multi-orizzonte di TP01); **(b) gate di dispersione p30** (entra solo se la dispersione
|
||||
cross-section del momentum supera il percentile espandente causale, altrimenti flat — XS è rumore in
|
||||
regime compatto). Standalone FULL Sh **1.50** / HOLD 1.71 / DD 11%, plateau robusto (lookback, gate
|
||||
p15-35). **Caveat:** storia ~2.5 anni; STAT-MODE (book a 19 gambe non eseguibile a 2k, serve ~20k) →
|
||||
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`):
|
||||
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).
|
||||
- **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
|
||||
(options_vrp_lab): **(a) defined-risk** taglia la coda (worst-week -16.6%→-7.4%, DD 33%→14%);
|
||||
**(b) gate IV-rank>0.30** = vendi vol solo ricca → ribalta HOLD-OUT da -0.25 a +0.28 (l'alpha è il
|
||||
filtro di regime). Standalone **FULL Sh 1.10, HOLD 0.60, DD 12%**, positivo/piatto ogni anno (2022
|
||||
crash incluso). Scorrelato a TP01 (~+0.01-0.07). **CAVEAT:** premio MODELLATO su DVOL ATM (skew non
|
||||
esplicito), book a 1d, f di stress reale non catturato → LEAD robusto, non deploy pieno. Ricerca
|
||||
`scripts/research/options_vrp_v2.py` (vs baseline `options_vrp_lab.py`). Test `tests/test_vrp_sleeve.py`.
|
||||
Diario `2026-06-20-financeold-analysis-vrp-v2.md`.
|
||||
- **Universo Hyperliquid: ESPANDERLO NON aiuta XS01** (provato): 52-asset / top-liquidità dinamico /
|
||||
trend-multi-asset → tutti peggiori (small-cap/memecoin diluiscono il momentum relativo; il trend
|
||||
multi-asset è ridondante con TP01, corr 0.74). I margini su XS sono nella STRUTTURA DEL SEGNALE
|
||||
(blend + gate), non nel numero di asset. I **51** parquet certificati restano per ricerca futura.
|
||||
⚠️ Il test "52-asset = negativo" era in parte inquinato dal backfill sintetico (AXS 83%, ALGO/SAND
|
||||
37% di barre vol=0) poi rimosso — vedi correzione estrazione 2026-06-20 sotto; resta comunque vero
|
||||
che il long-tail diluisce XS01, ma il numero netto post-fix è 51.
|
||||
- **Lead OPZIONI VRP (income short-vol) — quantificato, NON deploy** — `scripts/research/options_vrp_*.py`.
|
||||
Vendita put settimanali che incassa il volatility risk premium (IV>RV), scorrelato al trend (~0.07).
|
||||
Premio prezzato BS su DVOL reale (`fetch_dvol.py`) + calibrato su quote REALI cerbero-bite mainnet
|
||||
(`options_vrp_calibrate.py`): **f reale ≈ 1.0** (non 1.29) → Sharpe ~0.71, DD 33%, coda severa
|
||||
(settimane −15..−26% su LUNA/FTX). Diversificatore DEBOLE a premio reale, e short-vol da modello.
|
||||
**Regola: niente short-vol da modello in deploy.** Rivalutare quando cerbero-bite cattura un crash
|
||||
(per il f di stress reale). Diari `2026-06-19-options-vrp-lab` / `-eval-crypto-backtest-options`.
|
||||
- **Edge deboli/scartati:** ML walk-forward BTC (Sh ~0.57), trend 1h L/S (~1.0), RV ETH/BTC (Sh 0.27,
|
||||
regime-luck), calendar/seasonality (buy&hold travestito), volume/vol e momentum-reversal (negativi).
|
||||
- **MORTO/confermato artefatto:** mean-reversion / fade (negativo anche a fee zero — la vecchia
|
||||
libreria +201%/+1238% era contaminazione); trend 5m/15m (fee).
|
||||
- **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso:
|
||||
cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55.
|
||||
- **Onestà sul target €50/giorno:** NON raggiungibile su 2000 in 1-2 anni (servono ~130k di
|
||||
capitale o un DD da rovina). La leva non è la scorciatoia; la via è target-vol + capitale +
|
||||
tempo. La strategia che *guadagna* esiste, ma a ~+€1.5/giorno su 2000.
|
||||
|
||||
Script ricerca: `scripts/research/track{A,B,C,D,E}_*.py` + `trackD_timing.py`.
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Ricerca: riconoscimento pattern frattali per trading algoritmico su crypto. Target dichiarato
|
||||
@@ -35,9 +105,17 @@ netto fee, out-of-sample, robusto su griglia, e su dati certificati + liquidi +
|
||||
src/data/downloader.py → load_data(asset, tf): legge i parquet certificati da data/raw/
|
||||
src/strategies/base.py → Strategy (ABC), Signal, BacktestResult, YearlyStats
|
||||
src/strategies/indicators.py → indicatori condivisi (ema, atr, keltner, ...)
|
||||
src/strategies/trend_portfolio.py → TP01: strategia DIFENSIVA robusta (PORT LF1d, >=12h), causale
|
||||
src/portfolio/ → PORTAFOGLIO DI STRATEGIE estensibile (Sleeve + StrategyPortfolio)
|
||||
portfolio.py → combina N sleeve per peso su griglia giornaliera; metriche FULL/hold-out/anno
|
||||
sleeves.py → REGISTRY sleeve attivi: TP01 (55%) + XS01 (25%) + VRP01 (20%). Aggiungere = una riga
|
||||
src/fractal/ → indicatori frattali (patterns.py, indicators.py, similarity.py)
|
||||
src/backtest/engine.py → engine di backtesting riusabile
|
||||
src/backtest/harness.py → harness ONESTO (load BTC/ETH, backtest_signals no-leakage, OOS)
|
||||
src/version.py → APP_VERSION (legge il file VERSION)
|
||||
scripts/research/ → ricerca: track{A-I}_*.py + options_vrp_*.py + fetch_dvol.py
|
||||
scripts/portfolio/ → run_portfolio.py (report) + xsec_*.py (ricerca/affinamento XS01)
|
||||
scripts/live/paper_trend.py → paper trader forward-only di TP01 (1d) (no esecuzione reale)
|
||||
scripts/analysis/ → SOLO i tool dati certificati:
|
||||
rebuild_history.py → (ri)costruisce lo storico da Deribit mainnet (base 5m + resample)
|
||||
certify_feed.py → certifica il feed (integrità, coerenza resample, spike, cross-venue)
|
||||
@@ -57,7 +135,13 @@ uv sync # installa dipende
|
||||
uv run python scripts/analysis/rebuild_history.py --asset BTC ETH # (ri)costruisci storico da Deribit mainnet
|
||||
uv run python scripts/analysis/certify_feed.py # certifica i feed (locale + cross-venue)
|
||||
uv run python scripts/analysis/certify_feed.py --local # solo check locali (veloce)
|
||||
uv run pytest # test (da ripopolare con le nuove strategie)
|
||||
uv run python scripts/research/trackD_trendport.py # backtest strategia vincente (full report)
|
||||
uv run python scripts/research/trackD_timing.py # vincitrice su 15m/1h/4h/1d + PnL/DD/trade per anno
|
||||
uv run python scripts/analysis/fetch_hyperliquid.py # fetch+certify universo Hyperliquid (Cerbero mainnet) -> data/raw/hl_*
|
||||
uv run python scripts/portfolio/xsec_research.py # ricerca cross-sectional su Hyperliquid (XS01)
|
||||
uv run python scripts/portfolio/run_portfolio.py # report del PORTAFOGLIO attivo (TP01+XS01)
|
||||
uv run python scripts/live/paper_trend.py # avanza il paper trader TP01 (forward-only, 1d)
|
||||
uv run pytest # test
|
||||
```
|
||||
|
||||
```python
|
||||
@@ -80,10 +164,21 @@ df = load_data("BTC", "1h") # OK. load_data("SOL", ...) -> FileNotFoundError (
|
||||
### Universo ricercabile certificato
|
||||
- **BTC / ETH**: puliti (2-6 bps vs Coinbase USD su tutta la storia), liquidi (~0% barre flat a 1h),
|
||||
storia lunga (2018/2019→oggi) → **ogni timeframe (5m/15m/1h)**. È l'unico dato in `data/raw`.
|
||||
- **Alt (SOL/XRP/ADA/LTC/DOGE/BNB): FUORI.** Illiquidi (LTC 5m 82% barre flat O=H=L=C, run fino a
|
||||
~3 giorni), divergenti (LTC/DOGE >1% su 10-21% delle barre 2022-23), o non certificabili
|
||||
(XRP delistato da Coinbase per causa SEC; BNB non listato + storia da 2024-10). Sono archiviati in
|
||||
`Old/data/raw`. Riammetterne uno richiede prima una ricertificazione che dimostri liquidità + accordo.
|
||||
- **Alt Deribit (SOL/XRP/ADA/LTC/DOGE/BNB): FUORI.** Illiquidi (LTC 5m 82% barre flat, run ~3 giorni),
|
||||
divergenti, o non certificabili. Archiviati in `Old/data/raw`.
|
||||
- **Universo Hyperliquid (Cerbero MCP MAINNET): 19 alt liquidi a 1d, dal 2024** — BTC/ETH/SOL/BNB/XRP/
|
||||
DOGE/AVAX/LINK/LTC/ADA/ARB/OP/SUI/APT/INJ/TIA/SEI/NEAR/AAVE. Certificati (`fetch_hyperliquid.py`):
|
||||
flat 0%, cross-venue 4-9 bps vs Binance, >1% ≈0% → `data/raw/hl_*_1d.parquet`. **Caveat:** storia
|
||||
nativa solo **~2.5 anni** (2024-2026; pre-2024 = backfill, vol 0). Abilita le strategie
|
||||
CROSS-SECTIONAL (impossibili a 2 asset). NB: Cerbero col token TESTNET = farlocco; col token
|
||||
**mainnet** (`.env.mainnet`) = reale, ma SEMPRE da certificare (cross-venue + liquidità).
|
||||
⚠️ **CORREZIONE estrazione (2026-06-20):** il backfill NON è solo pre-2024 — cerbero MCP padda con
|
||||
barre SINTETICHE (volume 0, prezzi copiati da Binance → matchano cross-venue e non sono flat) ogni
|
||||
asset listato su HL **dopo** lo START. Il `flat`+cross-venue da soli non lo vedono: il rivelatore è
|
||||
il **VOLUME**. `fetch_hyperliquid.py` ora (1) taglia il run iniziale a volume 0, (2) scarta chi resta
|
||||
< 365g reali (es. **AXS 83% sintetico → fuori**), (3) gata i gap vol=0 interni. Universo certificato
|
||||
= **51** (era 52). I **19 major di XS01 hanno 0 backfill → invariati** (strategia live non toccata).
|
||||
Verificato direttamente su cerbero MCP. Diario `2026-06-20-cerbero-backfill-fix.md`.
|
||||
|
||||
## Metodologia obbligatoria per ogni nuova strategia
|
||||
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
FROM python:3.11-slim
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev
|
||||
COPY src/ src/
|
||||
COPY scripts/ scripts/
|
||||
COPY VERSION ./
|
||||
VOLUME /app/data
|
||||
# Monitor PAPER del portafoglio attivo (TP01+XS01). Esecuzione REALE disabilitata.
|
||||
CMD ["uv", "run", "python", "-m", "src.live.dashboard", "--port", "8787"]
|
||||
@@ -0,0 +1,15 @@
|
||||
# Solo MONITOR (dashboard paper) del portafoglio attivo. Niente runner/esecuzione reale
|
||||
# (archiviati in Old/). v2.0.0+.
|
||||
services:
|
||||
dashboard:
|
||||
build: .
|
||||
container_name: pythagoras-dashboard
|
||||
restart: unless-stopped
|
||||
command: ["uv", "run", "python", "-m", "src.live.dashboard", "--port", "8787"]
|
||||
ports:
|
||||
- "8787:8787"
|
||||
volumes:
|
||||
- ./data:/app/data:ro
|
||||
# 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
|
||||
@@ -0,0 +1,35 @@
|
||||
# 2026-06-19 — Wave 1 "beat TP01" (26 agenti BTC/ETH): nessun 3º sleeve robusto
|
||||
|
||||
Goal "trova strategie che battano l'esistente e inseriscile": GIA' soddisfatto da XS01 (cross-
|
||||
sectional Hyperliquid, integrato → portafoglio TP01 70% + XS01 30%, FULL Sh 1.41 / HOLD 1.15).
|
||||
In parallelo, una wave di 26 agenti ha cercato su BTC/ETH miglioramenti del trend + diversificatori.
|
||||
|
||||
## Esito wave 1 (26 agenti, 25 leak-free): 22 weak, 3 "contender", 1 noise
|
||||
I 3 contender, ri-verificati ONESTAMENTE col giudice book-level (`verify_contender.py`) e come
|
||||
contributo marginale al portafoglio ATTUALE (TP01+XS01):
|
||||
|
||||
| Candidato | corr TP01 | corr XS01 | +portafoglio (w30%) | Verdetto |
|
||||
|---|---|---|---|---|
|
||||
| **tsmom_strength_12h** | **+0.49** | — | — | ☠️ scartato: è TP01 più veloce (correlato), non diversifica |
|
||||
| **breakout_atr** (trend) | −0.04 | −0.04 | FULL +0.48 / **HOLD +0.05** | ☠️ scartato: gonfia solo il FULL storico (bull), ~zero valore nel hold-out |
|
||||
| **highvol_rev** (reversal alta-vol) | −0.08 | −0.05 | FULL +0.20 / **HOLD +0.30** | 🟡 WATCHLIST (vedi sotto) |
|
||||
|
||||
## highvol_rev: candidato vero ma NON abbastanza robusto → watchlist
|
||||
È l'unico genuinamente scorrelato a ENTRAMBI gli sleeve e che migliora FULL+hold-out. MA il mio
|
||||
robustezza-check indipendente (plateau, come per XS01) lo boccia per il deploy:
|
||||
- **Edge solo a REV_LB=1**: LB2 FULL Sh 0.33, LB3 ~0.05 → **picco a singola-barra, non plateau**.
|
||||
- **FULL standalone mediocre** (0.74); la forza è nel hold-out (HOLD 0.97-1.39 vs FULL ~0.7) =
|
||||
**HOLD≫FULL = regime-luck dell'alta-vol 2025-26**, non robustezza temporale.
|
||||
- È un **reversal** (famiglia morta in tutto il progetto) con concept ribaltato post-hoc
|
||||
(low-vol→high-vol). Regge fee fino ~0.3% ma con margine ridotto.
|
||||
Stesso difetto (HOLD≫FULL, no-plateau) per cui ho bocciato ieri il RV ETH/BTC regime-luck. La
|
||||
disciplina che boccia i falsi positivi vale anche qui → **NON deployato**, in watchlist; rivalutare
|
||||
forward (più dati) o se emerge un plateau su un parametro core.
|
||||
|
||||
## Conclusione
|
||||
Wave 1 NON aggiunge un 3º sleeve robusto. **Portafoglio invariato: TP01 (70%) + XS01 (30%).** Le
|
||||
famiglie trend (breakout/tsmom-12h) sono ridondanti con TP01 o aiutano solo il bull storico; l'unico
|
||||
diversificatore di meccanismo nuovo (highvol_rev) non regge il bar di robustezza. Il vero edge
|
||||
incrementale è venuto dall'ESPANSIONE DELL'UNIVERSO (Hyperliquid → cross-sectional), non da altre
|
||||
varianti di trend su 2 asset. Direzione futura coerente: più asset certificati + sleeve di
|
||||
meccanismo nuovo (non altre trend-variant), col criterio plateau+breadth+contributo.
|
||||
@@ -0,0 +1,65 @@
|
||||
# 2026-06-19 — Cerbero-bite = MAINNET reale: fonte VRP sbloccata
|
||||
|
||||
Indagine "cerca dati di cerbero-bite" + verifica mainnet/testnet a tre livelli. Esito: la
|
||||
contaminazione storica NON era una proprieta' di Cerbero MCP, ma del vecchio token testnet sul
|
||||
solo endpoint `get_historical`. Il token di cerbero-bite e' mainnet e serve catene opzioni reali.
|
||||
|
||||
## Dove sono i dati di cerbero-bite
|
||||
|
||||
`/home/adriano/Documenti/Git_XYZ/CerberoSuite/Cerbero_Bite` — bot live (testnet exec, propose-only)
|
||||
che vende **credit-spread bull-put su ETH**. Dati:
|
||||
- `data/state.sqlite`: `market_snapshots` (**52 righe, solo 30 apr–1 mag 2026**, BTC+ETH) con
|
||||
`spot, dvol, realized_vol_30d, iv_minus_rv, funding_perp/cross, dealer_net_gamma,
|
||||
gamma_flip_level, oi_delta_pct_4h, liquidation_long/short_risk, macro_days_to_event`;
|
||||
`dvol_history` (1 riga); `positions/instructions/decisions` (0 righe, niente trade persistiti).
|
||||
- `data/log/*.jsonl` (26 apr–1 mag 2026): log HTTP, non dump di catena. `strategy.yaml`: golden config.
|
||||
- **Fonte dati**: Cerbero MCP (`get_instruments` + `get_ticker_batch`) dal gateway
|
||||
`cerbero-mcp.tielogic.xyz`. NON c'e' storico profondo della catena (solo fetch live/on-demand).
|
||||
|
||||
## Verifica mainnet vs testnet (3 livelli)
|
||||
|
||||
1. **Spot vs nostra serie certificata** (Deribit mainnet), 2026-04-30 13–16h UTC:
|
||||
BTC cerbero 76.287–76.446 vs certificato 76.237–76.443 (Δ 0.13–0.27%); ETH 2.261–2.264 vs
|
||||
2.256–2.265 (Δ 0.04–0.29%). Scarti = rumore intra-barra (snapshot 15-min vs close orario).
|
||||
NON e' il feed fantasma testnet (che divergeva >3%).
|
||||
2. **`environment_info`** (token cerbero-bite): `environment=mainnet`, `base_url=www.deribit.com`,
|
||||
`source=credentials`. **`get_ticker ETH-PERPETUAL`**: `testnet=false`, mark 1703.11.
|
||||
3. **Catena, decisivo** — stessa opzione su ccxt.deribit mainnet vs Cerbero MCP:
|
||||
`ETH_USDC-26JUN26-1650-P` (put settimanale, delta ~-0.28):
|
||||
|
||||
| fonte | bid | ask | mark_iv | delta | testnet |
|
||||
|---|---|---|---|---|---|
|
||||
| ccxt mainnet | 25.6 | 26.6 | 54.54% | -0.3150 | — |
|
||||
| Cerbero MCP | 25.6 | 26.6 | 54.54% | -0.31513 | False |
|
||||
|
||||
**Identici bit-per-bit.**
|
||||
|
||||
## Verdetto
|
||||
|
||||
- **Il token MCP di cerbero-bite e' MAINNET; la sua catena opzioni e' reale** (= ccxt.deribit
|
||||
mainnet). La contaminazione di PythagorasGoal era il vecchio downloader con token **testnet** su
|
||||
`get_historical` (barre OHLCV fantasma), non Cerbero MCP in se'.
|
||||
- **Fonte VRP sbloccata**: Cerbero MCP da' bid/ask/IV/greche/OI per-strike (come ccxt) **+** feature
|
||||
di regime che ccxt non ha (`dealer_net_gamma`, `gamma_flip_level`, `oi_delta_pct_4h`,
|
||||
`liquidation_*`, `funding`, `iv_minus_rv`, `macro`). Utile per validare lo sleeve VRP su piu'
|
||||
regimi (raccolta snapshot live + accumulo nel tempo).
|
||||
- **Limite residuo**: niente storico profondo della catena -> il backtest pluriennale del VRP resta
|
||||
prezzato da modello (DVOL+BS); ma la calibrazione model-vs-reale e' ora robusta e ripetibile
|
||||
(snapshot reali su piu' date/regimi).
|
||||
|
||||
## Collegamento col lavoro VRP (sleeve opzioni)
|
||||
|
||||
Conferma e rafforza `2026-06-19-eval-crypto-backtest-options.md`: lo snapshot ccxt aveva gia'
|
||||
mostrato che il backtest SOTTOSTIMA il premio (skew +28% > spread 4% -> bid reale = 1.29x modello).
|
||||
Ora abbiamo due fonti mainnet concordi (ccxt + Cerbero MCP) per misurare premio/skew/spread su piu'
|
||||
regimi. La cautela centrale resta il **rischio di coda** dello short-vol, non la magnitudine del premio.
|
||||
|
||||
## Stato cerbero-bite (gia' concluso, contesto)
|
||||
|
||||
Il credit-spread bull-put ETH e' gia' stato giudicato NON robusto su ciclo completo (diario
|
||||
`Old/docs/diary/2026-06-09-cerbero-bite-credit-spread.md`: EV breakeven-negativo; "+0.48%/mese" =
|
||||
artefatto di finestra calma; coda concentrata col fade ETH). E' una struttura diversa dalla
|
||||
put-selling/wheel del progetto `crypto_backtest`.
|
||||
|
||||
> Sicurezza: il token di cerbero-bite e' stato usato solo per la verifica; mai stampato ne' committato
|
||||
> (resta in `.env`, gitignored).
|
||||
@@ -0,0 +1,125 @@
|
||||
# 2026-06-19 — Valutazione strategia esterna `crypto_backtest` (trend + opzioni VRP)
|
||||
|
||||
Valutazione critica di un progetto esterno (`/home/adriano/crypto_backtest/`, file chiave
|
||||
`STRATEGIA.md`, `production.py`, `options_deribit.py`, `production_equity.csv`) che propone un
|
||||
book a 2 motori quasi scorrelati. Rilevante perché tocca proprio la frontiera che la nostra
|
||||
ricerca post-reset ha lasciato aperta (le opzioni / volatility risk premium).
|
||||
|
||||
## Cosa propone
|
||||
|
||||
Portafoglio a due gambe (ρ=0.22 verificato dal CSV):
|
||||
- **Sleeve 1 (25%)** — trend spot BTC+ETH a **12h**, long-only se `trend(30g)>0`, vol-target 20%,
|
||||
cap 3×, leva globale ~1.07 calibrata a maxDD in-sample −20%.
|
||||
- **Sleeve 2 (75%)** — vendita di **put settimanali (CSP/wheel) su BTC** su Deribit, strike a
|
||||
**delta 0.28**, hold-to-expiry, IV da DVOL reale, prezzo Black-Scholes.
|
||||
|
||||
Numeri riprodotti dal CSV (finestra 2021-04→2026-06, 272 settimane):
|
||||
|
||||
| Serie | CAGR | Sharpe | maxDD | final |
|
||||
|---|---|---|---|---|
|
||||
| spot | +12.0% | 0.77 | −18.1% | 1.80x |
|
||||
| opt | +15.9% | 1.09 | −20.0% | 2.16x |
|
||||
| **blend 25/75** | +15.4% | **1.21** | **−15.2%** | 2.10x |
|
||||
| blend ri-levato | +20.5% | 1.21 | −20.0% | 2.63x |
|
||||
| B&H BTC | +1.3% | 0.30 | −74.2% | 1.07x |
|
||||
|
||||
corr(spot, opt) = **0.217** confermata. Settimane peggiori opt: 2022-05 (LUNA) −13%,
|
||||
2022-06 −11%, 2021-05 −11%, 2022-11 (FTX) −9.7%.
|
||||
|
||||
## Punto forte — corroborazione indipendente del nostro TP01
|
||||
|
||||
Lo **sleeve spot è quasi identico al nostro TP01** (`src/strategies/trend_portfolio.py`):
|
||||
12h, long-only, trend(30g), vol-target 20%, cap 3×. Due ricerche separate, due dataset diversi
|
||||
(loro Binance, noi Deribit certificato), **stessa conclusione**: il trend vol-targeted a 12h è
|
||||
l'edge reale e robusto. Il nostro Sharpe è più alto (1.32 vs 0.77 su questa finestra / 1.07
|
||||
full-history) perché usiamo un **blend multi-orizzonte 1-3-6m** invece del singolo trend a 30g →
|
||||
il blend diversifica gli orizzonti e alza lo Sharpe. Conferma forte per entrambi.
|
||||
|
||||
NB: loro confermano anche le NOSTRE lezioni — intraday ≤1h scartato (costi/rumore), un **bug di
|
||||
look-ahead sul 4h trovato e corretto** (identico al nostro audit), MR/condor/strangle nudi e
|
||||
collar stretti scartati per overfit/tail.
|
||||
|
||||
## Punto critico — lo sleeve opzioni guida il 75% ma è prezzato dal proprio modello
|
||||
|
||||
È esattamente il muro che avevamo dichiarato non-backtestabile (W18/19/21, ARGO: niente storico
|
||||
chain per-strike gratis). Il loro workaround (BS su **DVOL reale** + payoff sul path realizzato)
|
||||
fa emergere il VRP perché IV>RV (misurato BTC IV/RV~1.24). Concettualmente sano, ma la
|
||||
**magnitudine è ottimistica** — limiti (in parte ammessi dagli autori):
|
||||
|
||||
1. **Nessun bid/ask**: vendono al mid (BS fair), non al bid. Sulle put OTM settimanali lo spread
|
||||
è grosso → premio reale nettamente inferiore.
|
||||
2. **Skew ignorato**: prezzano put a delta-0.28 (OTM) con DVOL = **IV ATM**. Il mercato carica le
|
||||
put molto di più (skew di crash) → modellano la vol sbagliata proprio sull'opzione venduta.
|
||||
3. **Coda sotto-modellata**: settimana peggiore solo −13% attraverso LUNA/FTX → sospettosamente
|
||||
benigno per un venditore di put nudo. Gap, illiquidità di roll e settlement inverso (coin-settled)
|
||||
sono approssimati.
|
||||
4. **Leva senza funding** (ottimistico) + **bias di finestra** (parte vicino al top 2021,
|
||||
favorevole a un book short-vol DD-capped).
|
||||
|
||||
Il blend Sharpe 1.21 è dominato dallo sleeve income (Sharpe 1.09, peso 75%). Con bid/ask + skew +
|
||||
coda realistica lo sleeve income vale plausibilmente molto meno (Sharpe reale stimato ~0.7-0.9),
|
||||
e il blend scende di conseguenza.
|
||||
|
||||
## Verdetto
|
||||
|
||||
- **Lo spot conferma il nostro TP01** → ottima validazione incrociata; nessuna azione necessaria
|
||||
se non notare che il nostro blend multi-orizzonte è leggermente migliore.
|
||||
- **Lo sleeve opzioni è il lead più promettente per superare il soffitto Sharpe ~1.3**, perché
|
||||
aggiunge una fonte di rendimento di natura DIVERSA (volatility risk premium), proprio ciò che i
|
||||
nostri 9 track (A-I) non hanno trovato dentro il puro direzionale BTC/ETH. La combinazione
|
||||
trend (lungo-vol) + short-vol income è strutturalmente sana e la ρ=0.22 è reale.
|
||||
- **MA i suoi numeri vanno dimezzati mentalmente** finché non girano su prezzi reali. Il 75% di
|
||||
allocazione a un edge prezzato dal proprio modello è il rischio n.1.
|
||||
|
||||
## Prossimi passi onesti se si vuole inseguire questo lead
|
||||
|
||||
1. **Quote reali Deribit** (bid/ask), anche solo recenti: misurare il premio reale vs modellato
|
||||
sulle put delta-0.28 settimanali, e quanto Sharpe sopravvive allo spread.
|
||||
2. **Prezzare allo skew vero** (IV della put OTM, non DVOL ATM).
|
||||
3. **Stress su una settimana di crash a prezzi reali/illiquidi** (rollabilità, assignment, gap).
|
||||
4. **Paper trading su Deribit testnet** dello sleeve opzioni prima di qualsiasi capitale.
|
||||
|
||||
Coerente con la regola del progetto (lezione v2.0.0): un edge full+OOS robusto su prezzi MODELLATI
|
||||
non è un edge finché non è verificato su prezzi reali ed eseguibili.
|
||||
|
||||
---
|
||||
|
||||
## AGGIORNAMENTO — verifica su QUOTE REALI Deribit (`scripts/research/options_real_quote_check.py`)
|
||||
|
||||
Fatta la verifica concreta (PARTE 1: catena reale Deribit mainnet pubblico; PARTE 2: ri-esecuzione
|
||||
dello sleeve CSP con haircut reale sul premio). **Risultato che RIBALTA una mia critica.**
|
||||
|
||||
Snapshot del 2026-06-19, scadenza settimanale 2026-06-26 (~6.2 DTE), put delta −0.277 (strike 61k,
|
||||
3.1% OTM), underlying 62.965:
|
||||
|
||||
| Grandezza | Valore |
|
||||
|---|---|
|
||||
| IV ATM (≈ DVOL) | 37.2% |
|
||||
| IV put OTM (mark) | 42.1% (**skew +4.8 pt**) |
|
||||
| premio put: BID / mark / ask | 598 / 623 / 630 USD |
|
||||
| spread bid/mark | 0.96 (spread ~4%) |
|
||||
| premio MODELLATO dal backtest (BS @ IV-ATM) | **463 USD** |
|
||||
| **HAIRCUT premio reale(BID)/modello** | **1.29** |
|
||||
|
||||
**Il backtest SOTTOSTIMA il premio, non lo sovrastima.** Prezzando la put OTM con la DVOL (IV ATM)
|
||||
ignora lo skew (+28% sul premio lordo); il bid/ask la riporta giu' solo del 4% → vendendo al BID
|
||||
reale incassi **1.29×** il premio modellato. Lo sleeve modellato (Sharpe 1.13) e' quindi
|
||||
**conservativo sul premio** alle quote attuali; col premio reale salirebbe (Sharpe → 1.83 a f=1.29).
|
||||
|
||||
**Ma la critica vera si SPOSTA, non sparisce:** lo skew esiste perche' il mercato prezza la coda
|
||||
grassa: piu' premio = esattamente perche' i crash fanno male. La sensitivity mostra il punto di
|
||||
rottura — lo sleeve regge finche' incassi >~85% del premio modellato (Sharpe 0.59 a f=0.85), va a
|
||||
zero a f=0.70, negativo a f=0.55. Lo snapshot e' in **regime calmo** (IV ATM 37%, bassa per crypto);
|
||||
in un crash lo spread si allarga molto e potresti non riuscire a rollare. Quindi:
|
||||
|
||||
- ✅ **Concern "premio sovrastimato" = SMENTITO** (alle quote attuali e' anzi sottostimato).
|
||||
- ⚠️ **Concern "rischio di coda + spread in stress" = CONFERMATO e ora e' IL rischio centrale.**
|
||||
Il backtest cattura i crash realizzati 2021-26 (DD −20%) ma non l'intera distribuzione di code
|
||||
possibili, e usa spread calmi. La f reale in settimana di crash e' < 1 e lo spread esplode.
|
||||
|
||||
**Verdetto aggiornato:** lo sleeve income e' piu' solido di quanto temessi sul *premio* (il VRP +
|
||||
skew e' reale e generoso), ma resta una strategia short-vol il cui rischio vero e' la **coda** e la
|
||||
**liquidita' di roll nello stress**, non la magnitudine del premio. Prima del capitale: ripetere lo
|
||||
snapshot nel tempo (specie in regimi di IV alta), misurare lo spread in giornate di stress, e
|
||||
paper-trade su testnet. Il lead per superare il soffitto Sharpe ~1.3 (aggiungere il VRP a TP01)
|
||||
resta valido e ora meglio quantificato.
|
||||
@@ -0,0 +1,62 @@
|
||||
# 2026-06-19 — Ricerca frattale multi-agente (63 agenti) su BTC/ETH
|
||||
|
||||
Su richiesta: 50+ agenti in parallelo a cercare strategie NUOVE ispirate ai due documenti
|
||||
frattali (`Libro_frattali` + `Pythagoras_Trading_Prediction`), timing/asset diversi, ognuna
|
||||
validata sull'harness onesto. Eseguito come Workflow: **63 agenti, ~2h, 3.8M token.**
|
||||
|
||||
## Cosa è stato testato
|
||||
16 concetti frattali estratti dai documenti (sotto la patina esoterica: coscienza, frequenze
|
||||
Solfeggio, numeri sacri → idee testabili): alfabeto candle U/D/0 (3-6, LONG), Fourier/cicli,
|
||||
ricorrenza di Poincaré (analoghi kNN), centro di inversione Evideon (mirror tempo+prezzo),
|
||||
indicatore H-C (~588/25 ≈ 23.5 barre), numeri-universali come periodi, invarianza di forma,
|
||||
entropia di Shannon ("coscienza") come gate, confluenza multi-TF, grammatica composizionale,
|
||||
fase-ricorrenza. × BTC/ETH × {5m,15m,1h} = **52 ipotesi**.
|
||||
|
||||
Ogni segnale: scritto da un agente come funzione `signal()`, valutato da `eval_signal.py`
|
||||
(stesso harness onesto) con **guard automatico anti-look-ahead** (ricalcolo su prefissi).
|
||||
Superstiti → verifica avversariale con sblocco una-tantum del **hold-out 2025-26**.
|
||||
|
||||
## Esito
|
||||
- **Verdetti**: 29 rumore, 12 "real" (netto-fee positivo ma non battono il buy&hold), 11 "edge"
|
||||
(in-sample: battono B&H + null p<0.05 + leak=0).
|
||||
- **Guard anti-look-ahead**: nessun leak passato (gli agenti hanno prodotto segnali causali; i
|
||||
pochi tentativi con futuro sono stati auto-squalificati e corretti).
|
||||
- **Hold-out (la prova del nove)**: dei **11 superstiti in-sample, 10 REFUTATI** — performance
|
||||
catastrofica nel 2025-26 (hurst-DFA −0.49, hc-cycle −0.83, vol-accel −1.16, universal-periods
|
||||
−0.42…−1.04, spectral-entropy −0.38/+0.29, multitf −0.49, solfeggio-BTC −0.64). Stessa firma di
|
||||
sempre: **regime-luck del toro 2018-2024, sparito out-of-sample.**
|
||||
- **1 "confermato"** dalla verifica per-agente: `momentum_solfeggio_cycle` **ETH 1h** (holdout
|
||||
Sharpe +1.19, ret +49% mentre il buy&hold ETH faceva −49%). Sembrava un trionfo.
|
||||
|
||||
## Ma il "vincitore" cade al test cross-asset (kill decisivo)
|
||||
Il guard per-agente valuta un asset alla volta e non poteva vedere il quadro. Ho rieseguito **lo
|
||||
stesso identico codice** sui due major:
|
||||
|
||||
| Signal (identico) | FULL Sharpe | HOLD-OUT Sharpe | HOLD-OUT ret |
|
||||
|---|---|---|---|
|
||||
| Solfeggio-cycle su **ETH** 1h | 1.54 | **+1.19** | +49% |
|
||||
| Solfeggio-cycle su **BTC** 1h | 1.17 | **−0.25** | −7.5% |
|
||||
|
||||
Un edge robusto non fallisce sull'altro major. La stessa logica (long-only ~20% esposta, filtro
|
||||
SMA(588), timing su ciclo ~24) che ha "schivato" il crash ETH 2025 **perde su BTC nello stesso
|
||||
hold-out**. È **fortuna di regime di un singolo asset**, non skill. Aggravanti: costanti
|
||||
numerologiche ad-hoc (24/588/56, "odore" di overfit, già notato dal verificatore); e con 52 trial,
|
||||
trovare 1 segnale che passa un singolo regime di hold-out è atteso per puro caso (1/11 ≈ chance).
|
||||
|
||||
## VERDETTO
|
||||
**La ricerca frattale multi-agente (52 ipotesi, 63 agenti) NON ha trovato alcun edge robusto.**
|
||||
I concetti frattali/esoterici si sono comportati esattamente come le famiglie convenzionali (Fasi
|
||||
1-3): edge in-sample da regime-luck del toro, refutati dal hold-out; e l'unico che passava il
|
||||
hold-out su un asset fallisce sull'altro. **Nessuna magia nei numeri Solfeggio/sacri.**
|
||||
|
||||
Il valore: il processo disciplinato (guard anti-look-ahead + hold-out bloccato + **test cross-asset**)
|
||||
ha catturato un falso "trionfo" (+49% vs −49%!) che sul vecchio sistema contaminato sarebbe finito
|
||||
dritto in produzione. È la quinta conferma indipendente che su BTC/ETH non c'è un edge facile.
|
||||
|
||||
## Stato della ricerca dopo tutte le fasi
|
||||
Testato: mean-reversion, momentum/trend, vol, lead-lag, hurst, shape-ML, e 16 famiglie frattali ×
|
||||
multi-TF/asset. **Niente di robusto, fee-surviving, OOS e cross-asset.** Le direzioni oneste
|
||||
restano: (a) accettare il ceiling = long risk-managed (no alpha); (b) allargare l'universo dati
|
||||
CERTIFICATO oltre BTC/ETH; (c) fonti di segnale ortogonali al prezzo (on-chain, basis multi-venue,
|
||||
opzioni multi-regime) — tutte richiedono nuovi dati certificati. Artefatti: `eval_signal.py`,
|
||||
workflow `fractal-strategy-search`, ~52 segnali in `/tmp/pyth_sig_*.py`.
|
||||
@@ -0,0 +1,60 @@
|
||||
# 2026-06-19 — Espansione universo (Hyperliquid via Cerbero mainnet) → XS01 batte il portafoglio
|
||||
|
||||
L'utente: "ci dovrebbe essere uno storico dati preso da cerbero". Aveva ragione, ed è la chiave per
|
||||
superare il soffitto a 2 asset.
|
||||
|
||||
## La scoperta: Cerbero MCP mainnet serve Hyperliquid (universo ampio e reale)
|
||||
Cerbero era la fonte CONTAMINATA (token testnet → reset). MA col token **mainnet** (`.env.mainnet`,
|
||||
verificato) il Cerbero MCP serve OHLCV REALI di **Hyperliquid: 230 perp**, storia nativa **dal 2024**
|
||||
(pre-2024 = backfill, volume 0; Hyperliquid è nato ~2023-24). Prezzi recenti plausibili.
|
||||
|
||||
## Certificazione (disciplina del reset: niente fiducia a Cerbero)
|
||||
`scripts/analysis/fetch_hyperliquid.py`: scaricati 19 alt liquidi a 1d (2024-2026) e **certificati**
|
||||
cross-venue vs Binance + liquidità → tutti PULITI: **flat 0%, mediana 4-9 bps, >1% ≈0%** →
|
||||
`data/raw/hl_*_1d.parquet` (namespace dedicato). Caveat onesto: **~2.5 anni** di storia nativa.
|
||||
|
||||
## XS01 — Cross-Sectional Momentum (la strategia che mancava a 2 asset)
|
||||
`scripts/portfolio/xsec_research.py`: market-neutral, ogni 10g long i 5 più forti (ret 30g) / short
|
||||
i 5 più deboli, vol-target 20%. Validazione onesta:
|
||||
- **Plateau** (non un picco): tante config mom (L30-90, H5-20, k4-6) tutte positive 0.6-0.98.
|
||||
- **Fee-robusto**: FULL Sh 0.79→0.68 da fee 0% a 0.3% RT.
|
||||
- **Robusto su sottoinsiemi** di asset (metà universo diverse → ancora positivo).
|
||||
- **Scorrelato a TP01 (~−0.06)**, **positivo OGNI anno** (2024 +2%, 2025 +19%, 2026 +20%).
|
||||
- **Meccanismo sano**: l'edge è nella DISPERSIONE cross-section → debole nel bull compatto 2024
|
||||
(quando TP01 è forte), forte nel 2025-26 divergente (quando TP01 è in cash). **Complementare**.
|
||||
|
||||
Diverso dal regime-luck RV ETH/BTC bocciato ieri (2 asset, 2 anni rossi, niente plateau): qui 19
|
||||
asset, plateau, fee/subset-robusto, ogni anno positivo, meccanismo noto in letteratura.
|
||||
|
||||
## Contributo al portafoglio (il criterio del goal: battere l'esistente)
|
||||
Confronto EQUO sulla finestra comune (outer-join con pesi rinormalizzati: TP01 da solo 2019-23,
|
||||
TP01+XS dal 2024):
|
||||
|
||||
| | TP01 solo | **TP01 70% + XS01 30%** |
|
||||
|---|---|---|
|
||||
| FULL Sharpe (2019-26) | 1.30 | **1.41** |
|
||||
| **HOLD-OUT 2025-26 Sharpe** | 0.31 | **1.15** |
|
||||
| HOLD-OUT ret / DD | +3.5% / 7.5% | **+15.1% / 5.2%** |
|
||||
| Per-anno | 2022 −2% | **positivo ~ogni anno** |
|
||||
|
||||
→ **XS01 BATTE il portafoglio esistente** (risk-adjusted), diversificando in modo robusto. Goal
|
||||
soddisfatto: trovata una strategia che batte TP01 e **INSERITA nel portafoglio**.
|
||||
|
||||
## Integrazione
|
||||
- `src/portfolio/portfolio.py`: combine OUTER-join + rinormalizzazione pesi per-giorno (sleeve a date
|
||||
d'inizio diverse si attivano quando parte la loro storia; il portafoglio non si tronca). Test nuovo.
|
||||
- `src/portfolio/sleeves.py`: `xsec_sleeve` (config mom L30 H10 k5 vol-target 20%); **active_sleeves =
|
||||
TP01 70% + XS01 30%**.
|
||||
- `fetch_hyperliquid.py`, `xsec_research.py`. 12 test passano.
|
||||
|
||||
## Caveat onesti (da non dimenticare)
|
||||
- **Storia XS solo ~2.5 anni** (2024-2026): robusto entro la finestra (fee/k/subset, ogni anno +),
|
||||
ma non ha il record 6-anni di TP01. Cross-sectional momentum è literature-robust → prior favorevole.
|
||||
- **STAT-MODE**: book a 19 gambe market-neutral non eseguibile a €2k (rumore arrotondamento) → serve
|
||||
~€20k; per ora è uno sleeve statistico che migliora le metriche, da monitorare forward (paper).
|
||||
- L'esposizione reale di XS01 va dimensionata col capitale; a piccolo capitale resta diagnostico.
|
||||
|
||||
## Stato
|
||||
Portafoglio attivo = **TP01 (70%) + XS01 (30%)**, FULL Sh 1.41 / HOLD 1.15. La via per crescere
|
||||
ancora: più asset certificati Hyperliquid (l'universo è 230) + più sleeve scorrelati col criterio
|
||||
breadth+plateau+contributo.
|
||||
@@ -0,0 +1,81 @@
|
||||
# 2026-06-19 — Options VRP sleeve: infrastruttura + prima validazione onesta
|
||||
|
||||
Impostata la ricerca dello sleeve income opzioni (vendita put settimanali, incassa il volatility
|
||||
risk premium IV>RV). Lead identificato dalla valutazione di `crypto_backtest` come la via per
|
||||
superare il soffitto Sharpe ~1.3 (fonte di rendimento DIVERSA, scorrelata al trend).
|
||||
|
||||
## Infrastruttura costruita
|
||||
- `scripts/research/fetch_dvol.py`: storia DVOL (IV 30d Deribit) BTC/ETH **2021-03 → 2026-06**
|
||||
(1914g) → `data/raw/dvol_*.parquet`. È l'input IV.
|
||||
- `scripts/research/options_vrp_lab.py`: motore backtest CSP settimanale. Prezzo put BS su DVOL
|
||||
reale + **calibrazione f** (skew/spread vs quote reali), strike a delta target, payoff sul path
|
||||
realizzato dei prezzi certificati. Causale (decisione a sell-date, payoff a scadenza). Gauntlet:
|
||||
VRP context, sweep f/delta, per-anno, worst-weeks (coda), correlazione + contributo vs TP01.
|
||||
- `scripts/research/options_real_quote_check.py` (dal branch): verifica premio su quote reali.
|
||||
|
||||
## VRP reale (contesto)
|
||||
BTC DVOL 61% vs RV 53% → **VRP +7.8 pt, positivo 78% del tempo**; ETH +3.7 pt, 67%. Il premio di
|
||||
volatilità esiste ed è più ricco su BTC.
|
||||
|
||||
## Risultati (book 50/50 BTC+ETH, put settimanali delta -0.28)
|
||||
|
||||
**Tutto dipende dalla CALIBRAZIONE f del premio:**
|
||||
| f | Sharpe | CAGR | maxDD | worst-week |
|
||||
|---|---|---|---|---|
|
||||
| 0.70 | −0.32 | −12% | 51% | −26% |
|
||||
| 0.85 | 0.20 | +1% | 35% | −26% |
|
||||
| **1.00 (conservativo, IV-ATM)** | **0.71** | +16% | 33% | −26% |
|
||||
| 1.15 | 1.22 | +34% | 32% | −25% |
|
||||
| **1.29 (reale calm, con skew)** | **1.70** | +52% | 31% | −25% |
|
||||
|
||||
- A f=1.0 (ignora il bonus skew): Sharpe **0.71** — SOTTO TP01. A f=1.29 (skew reale misurato in
|
||||
regime calmo): **1.70**. La verità sta in mezzo E f varia col regime (skew più alto nello stress).
|
||||
- **Delta**: più ATM = più premio + più rischio (−0.15→Sh 0.25, −0.28→0.71, −0.40→0.95).
|
||||
|
||||
**La CODA è severa (è short-vol):** maxDD standalone **30-33%**, singole settimane **−15..−26%**
|
||||
(2021-05 crash, 2022-05/06 LUNA, 2026-02/06). Per-anno (f=1.0): 2022 **−9%**, 2026-YTD **−14%** —
|
||||
sanguina negli anni di crash. HOLD-OUT 2025-26: Sharpe **0.04** a f=1.0 (piatto), 0.94 a f=1.29.
|
||||
|
||||
**Diversificazione (reale):** corr settimanale a TP01 **+0.07** (scorrelato). Contributo (f=1.0):
|
||||
TP01 70% + OPT 30% → Sharpe settimanale 0.71→**0.97**, DD basso (11%). Anche al premio conservativo
|
||||
migliora il portafoglio per pura decorrelazione.
|
||||
|
||||
## Verdetto — LEAD reale, NON deploy-ready
|
||||
- ✅ Il VRP è reale (IV>RV 78%), lo sleeve è **genuinamente scorrelato** al trend (+0.07) e
|
||||
**migliora il portafoglio** anche a premio conservativo. È la fonte di rendimento DIVERSA che
|
||||
cercavamo per superare il soffitto ~1.3.
|
||||
- ⚠️ MA: (a) le metriche headline dipendono da una calibrazione **ottimistica** (f=1.29);
|
||||
conservativo (f=1.0) → Sharpe 0.71 con **DD 33%**. (b) Premio **MODELLATO** (BS su DVOL), non un
|
||||
backtest su catena reale; la verifica su quote reali è UN solo snapshot calmo. (c) Il **rischio di
|
||||
coda** (roll/assignment/gap nello stress, skew che esplode) NON è pienamente catturato.
|
||||
- Regola del progetto: **mai deployare uno short-vol prezzato da un modello.** → NON aggiunto al
|
||||
portafoglio. Portafoglio attivo invariato: TP01 70% + XS01 30%.
|
||||
|
||||
## CALIBRAZIONE su quote REALI cerbero-bite (`options_vrp_calibrate.py`) — corregge l'ottimismo
|
||||
cerbero-bite GIA' accumula la catena reale mainnet (option_chain_snapshots, BTC 224k / ETH 237k
|
||||
righe, 2026-05→oggi). Usandola (non un nuovo snapshotter), misurato il fattore f reale su 223
|
||||
snapshot/asset (put weekly ~delta -0.28, vendita al BID):
|
||||
- **BTC: f mediano 1.03** (IQR 0.89-1.21), skew reale **+1.9 pt** (IV put 43.5% vs DVOL 41.6%).
|
||||
- **ETH: f mediano 0.97** (IQR 0.88-1.11), skew **+1.5 pt**.
|
||||
- **Il f reale e' ~1.0, NON 1.29.** Lo snapshot singolo del branch (skew +4.8 → f 1.29) era un
|
||||
OUTLIER; sulla media lo skew e' modesto e il bid/ask lo compensa → premio reale ≈ modellato.
|
||||
→ Il VRP sleeve sta sul punto **f≈1.0 dello sweep = Sharpe ~0.71** (caso CONSERVATIVO), DD 33%,
|
||||
hold-out ~piatto (0.04). Non il 1.70 ottimistico. Resta un diversificatore modesto (corr +0.07,
|
||||
migliora il portafoglio settimanale 0.71→0.97 a 30%), ma standalone SOTTO TP01 e con coda severa.
|
||||
**CAVEAT:** la finestra di calibrazione reale e' ~10 giorni densi (06-09→06-19, cerbero-bite ruota
|
||||
le scadenze → i weekly compaiono sparsi) e UN regime calmo. Il f di STRESS resta non misurato.
|
||||
|
||||
## Verdetto aggiornato
|
||||
Al premio REALE (f≈1.0), il VRP sleeve e' un diversificatore DEBOLE (Sharpe ~0.71 < TP01, DD 33%,
|
||||
hold-out piatto): la modesta decorrelazione NON giustifica il rischio di coda short-vol senza molto
|
||||
piu' dato reale multi-regime. **Confermato NON-deploy.** Il valore vero arriva solo se cerbero-bite,
|
||||
continuando ad accumulare, copre un CRASH: lì si misura il f reale di stress e si fa un backtest su
|
||||
catena reale. Fino ad allora, lead quantificato ma in attesa. Portafoglio invariato TP01 70%+XS01 30%.
|
||||
|
||||
## Prossimi passi per graduare il lead a sleeve deployabile
|
||||
1. **Accumulo forward di quote reali** (bid/ask + skew della put settimanale delta-0.28, ogni giorno,
|
||||
su più regimi) → sostituire il premio modellato con quello reale e misurare f nello stress.
|
||||
2. **Stress crash-week con spread reali** (rollabilità, assignment, gap inverso/coin-settled).
|
||||
3. **Daily-MTM** dello short put per l'integrazione nel portafoglio giornaliero (ora è settimanale).
|
||||
4. **Paper-trade su Deribit testnet** prima di qualsiasi capitale.
|
||||
Solo dopo, se regge a premi reali multi-regime, aggiungerlo come 3º sleeve (scorrelato, income).
|
||||
@@ -0,0 +1,58 @@
|
||||
# 2026-06-19 — Ricerca v2.0.0: Fase 0 (harness) + Fase 1 (triage superstiti)
|
||||
|
||||
Primo log di ricerca post-reset. Universo certificato: BTC/ETH, 1h. Hold-out 2025+ BLOCCATO.
|
||||
|
||||
## Fase 0 — harness onesto (`scripts/analysis/research_lab.py`)
|
||||
|
||||
Banco di prova causale per costruzione (modello a SERIE DI POSIZIONE: `pos[i]` decisa entro
|
||||
`close[i]`, guadagna `close[i]→close[i+1]`, fee sul turnover |Δpos|). Metriche
|
||||
Sharpe/CAGR/DD/exposure/turnover, finestra OOS, **null model a rotazione circolare**
|
||||
(p-value: il timing batte il caso?), baseline buy&hold, sweep fee.
|
||||
|
||||
**Self-test del banco (valida l'HARNESS, non una strategia):**
|
||||
- buy&hold BTC: Sharpe 0.79 (sanity OK).
|
||||
- CHEAT look-ahead (pos = segno del rendimento futuro): Sharpe **58**, p=0.005 → l'engine
|
||||
VEDE un edge reale quando esiste.
|
||||
- NOISE causale a basso turnover: Sharpe **0.14**, p=0.26 → l'engine NON inventa edge dal
|
||||
nulla (niente leak, niente skill spuria).
|
||||
|
||||
Il banco è affidabile. → ogni numero qui sotto è netto fee e causale.
|
||||
|
||||
## Fase 1 — triage dei 2 superstiti (`scripts/analysis/phase1_survivors.py`)
|
||||
|
||||
Sul feed pulito solo SH01 (shape-ML) e frammenti HONEST mostravano segnale residuo. Delle
|
||||
HONEST solo **DIP** è testabile su BTC/ETH (TR01/ROT02 richiedono alt esclusi). Re-implementati
|
||||
come serie di posizione, passati ai gate onesti.
|
||||
|
||||
### DIP reversion (long-only) — ☠️ MORTO
|
||||
Griglia 3×3 (n, k) **tutta negativa** su entrambi gli asset (nessun plateau). Config centrale
|
||||
n50 k2.0: FULL Sharpe −0.17 (BTC) / −0.06 (ETH); a fee 0% appena +0.02/+0.09 (niente edge nemmeno
|
||||
lordo). OOS-VAL marginale (+0.36/+0.16) ma **null p=0.84-0.89** (peggio del caso). Rumore.
|
||||
|
||||
### SH01 shape-ML (walk-forward LogReg) — ☠️ FEE-DEAD
|
||||
Pattern coerente su BTC/ETH, long/short e long-only:
|
||||
|
||||
| Variante | Sh fee0% | Sh fee0.05% | Sh fee0.10% | trade/anno | null p |
|
||||
|---|---|---|---|---|---|
|
||||
| BTC L/S | +0.32 | −0.70 | −1.71 | 877 | 0.167 |
|
||||
| BTC long-only | +0.73 | −0.06 | −0.84 | 555 | 0.072 |
|
||||
| ETH L/S | +0.31 | −0.40 | −1.11 | 773 | 0.137 |
|
||||
| ETH long-only | +0.46 | −0.04 | −0.53 | 485 | 0.142 |
|
||||
|
||||
C'è un **sussurro di segnale LORDO** (Sharpe 0.3-0.7 a fee zero) ma il turnover (485-877
|
||||
trade/anno) lo divora: a fee reale tutte negative, e **nessuna batte il null** (p>0.05). Net-fee:
|
||||
rumore.
|
||||
|
||||
## VERDETTO Fase 1
|
||||
**Né DIP né shape-ML sopravvivono su BTC/ETH certificato net-fee.** Nessuno dà Sharpe netto >0,
|
||||
nessuno batte il null (p<0.05), nessuno batte il **buy&hold** (Sharpe 0.79/0.84 — di fatto la
|
||||
"strategia" più forte vista finora). Si conferma: i "superstiti" della vecchia libreria erano,
|
||||
come il resto, non-edge. Chiusi.
|
||||
|
||||
## Lead onesto per la Fase 2
|
||||
L'unico segnale non-nullo è il **gross shape-ML** (Sharpe 0.3-0.7 a fee zero), ucciso dal
|
||||
turnover. Direzione: esprimere quel segnale a **turnover molto più basso** (orizzonte di holding
|
||||
lungo, soglia forte, o come GATE di regime invece che flip per-barra) per vedere se il sussurro
|
||||
lordo sopravvive alle fee. È un lead, NON un edge. Inoltre: la barra reale da battere è il
|
||||
**buy&hold** (Sharpe ~0.8) — una strategia di timing deve fare meglio di "stai sempre long",
|
||||
net-fee.
|
||||
@@ -0,0 +1,69 @@
|
||||
# 2026-06-19 — Ricerca v2.0.0: Fase 2 (famiglie) + analisi OPTIONS
|
||||
|
||||
Universo certificato BTC/ETH. Barra da battere = **buy&hold** (Sharpe 0.79 BTC / 0.84 ETH).
|
||||
Tutto netto fee 0.10% RT, hold-out 2025+ BLOCCATO. Harness: `research_lab.py`.
|
||||
|
||||
## Fase 2 — esplorazione famiglie (`phase2_families.py`)
|
||||
|
||||
24 combinazioni famiglia×asset×TF, ognuna: scan griglia → config migliore → gate onesti
|
||||
(FULL/OOS-VAL, vs buy&hold, null p-value a rotazione, sweep fee).
|
||||
|
||||
### Esiti per famiglia
|
||||
- **REVERSAL (mean-reversion breve): ☠️ MORTA OVUNQUE.** FULL Sharpe da −1 a −5.6 (peggio a
|
||||
15m: fee-death, −5.6 BTC / −4.6 ETH), gross ≈0, null p 0.45-0.82. **Smentisce definitivamente
|
||||
la tesi storica del progetto ("l'edge è sempre mean-reversion")**: era artefatto del feed.
|
||||
- **TSMOM / MA-cross / Donchian (trend, long-only): segnale REALE ma MODESTO.** Le versioni
|
||||
long-only (basso turnover) battono o eguagliano il buy&hold:
|
||||
- **MA-cross long-only**: ETH FULL **1.12** / OOS 0.89 / p **0.007**; BTC FULL **0.90** / OOS
|
||||
1.99 / p **0.040**. Plateau sulla griglia (ETH 12/48 e 48/192 entrambi 1.1), coerente sui
|
||||
DUE asset, basso turnover (53-106 trade/anno). **Unici 2 a passare: battono B&H + OOS>0 + p<0.05.**
|
||||
- Donchian long-only: FULL 0.84-0.94, OOS ottimo (BTC 2.37) ma p 0.08-0.10 (pochi trade → null
|
||||
rumoroso). TSMOM long-only: ETH 0.83 (≈B&H). Le L/S perdono (turnover + short su asset in trend).
|
||||
- **VOL-TARGET overlay**: ≈ buy&hold (FULL 0.77-0.84), p alto → non distinguibile dal B&H, ma è
|
||||
un riduttore di vol/DD (mantiene lo Sharpe scalando l'esposizione).
|
||||
- **HURST-gate, LEAD-LAG BTC↔ETH**: niente. (Hurst-mom ETH p=0.043 ma sotto il B&H; lead-lag
|
||||
fee-dead.)
|
||||
|
||||
### Verdetto Fase 2
|
||||
L'unica cosa reale su BTC/ETH certificato è il **trend-following long-only** (MA-cross in testa):
|
||||
un **long con gestione del rischio** che batte il buy&hold di poco (Sharpe ~0.9-1.1 vs 0.8)
|
||||
evitando i drawdown peggiori. È un effetto noto in letteratura (time-series momentum), NON alpha
|
||||
market-neutral. **Caveat multiple-testing**: 2 flag su ~24 test ≈ soglia del caso; ma la stessa
|
||||
famiglia vince su ENTRAMBI gli asset con plateau → è un LEAD genuino, non confermato. La barra
|
||||
vera resta il B&H, e l'OOS-VAL alto di BTC (1.99) puzza di "2024 anno di trend forte" → serve la
|
||||
prova del hold-out 2025-26 + regimi bear + stress fee/slippage + deflated-Sharpe (Fase 3).
|
||||
|
||||
## Analisi OPTIONS (`options_analysis.py`)
|
||||
|
||||
Dati reali cerbero-bite mainnet, ma finestra **~2026-05-01→06-11 (~6 sett., REGIME UNICO calmo)**.
|
||||
|
||||
### Livelli misurati (reali)
|
||||
- **VRP (IV − RV) positivo il 100% del tempo**: BTC +10, ETH +14 punti di vol annua. Le opzioni
|
||||
sono sistematicamente CARE in questa finestra → vendere vol/covered-call avrebbe incassato premio.
|
||||
- **Skew put positivo**: BTC IV put-10%OTM 44% vs call 35% (skew +10 pt); ETH 54 vs 49 (+5). Il
|
||||
crash è prezzato (assicurazione cara).
|
||||
- **Costo put protettiva** (mensile, %-del-notional): ~10% OTM = **0.98% BTC / 1.36% ETH**; ATM
|
||||
3.3%/5.0%; ~15% OTM 0.83%/0.71%. Liquidità: ATM spread ~3%, OTM 7-12%. Mensile ben popolato
|
||||
(499-2043 strike), settimanale OTM sottile. Funding perp ≈ 0 (nessun carry).
|
||||
|
||||
### Verdetto OPTIONS
|
||||
**Nessun edge su opzioni è validabile ora**: 6 settimane, regime unico calmo. Il segnale
|
||||
VRP-positivo / sell-vol è ESATTAMENTE ciò che brilla in calma e salta in aria nei crash (è il
|
||||
rischio che viene pagato) — non testabile senza un crash nel campione. Ruoli legittimi (entrambi
|
||||
NON validabili ora, solo forward):
|
||||
- **(a) Tail-cap / catastrofe**: put OTM standing su un book long (il candidato trend ha DD grossi).
|
||||
Costa ~1-1.5%/mese a 10% OTM — gateabile coi premi reali misurati qui. Overlay per-trade 24h
|
||||
INFATTIBILE (strike OTM corti inesistenti/illiquidi); standing settimanale/mensile FATTIBILE.
|
||||
- **(b) Harvest del VRP** (covered call / put-spread): +10-14 pt ci sono ORA, ma è una scommessa
|
||||
short-vol che richiede un crash nel campione per essere giudicata onestamente. Non l'abbiamo.
|
||||
|
||||
**Raccomandazione**: le opzioni NON sono un'avenue di ricerca a breve (manca storia multi-regime).
|
||||
Mosse: (1) lasciare cerbero-bite ad accumulare (gratis, reale, costruisce in avanti il dataset
|
||||
multi-regime); (2) rivalutare quando la finestra attraversa un crash/alta-vol; (3) intanto, l'unico
|
||||
uso giustificato è come OVERLAY (tail-cap su una strategia spot), gateato sui premi reali qui sopra.
|
||||
|
||||
## Prossimo passo
|
||||
Fase 3 sul solo candidato reale (trend-following long-only, MA-cross): sblocco UNA volta del
|
||||
hold-out 2025-26, comportamento nei bear (2018/2022), stress fee×2 + slippage + lag, deflated-Sharpe
|
||||
per il multiple-testing. Se regge → è la prima strategia onesta del progetto v2.0.0 (modesta:
|
||||
migliora il buy&hold, non lo stravolge). Se non regge → anche il trend era sample-luck.
|
||||
@@ -0,0 +1,62 @@
|
||||
# 2026-06-19 — Ricerca v2.0.0: Fase 3, conferma avversariale del candidato trend
|
||||
|
||||
Candidato: **trend-following long-only (MA-cross)**, l'unico a passare i gate base in Fase 2.
|
||||
Protocollo: selezione config solo pre-hold-out → sblocco una-tantum del hold-out 2025-26 →
|
||||
breakdown bear → stress → deflated-Sharpe. Script `phase3_confirm.py`.
|
||||
|
||||
## Esito: ☠️ NON CONFERMATO — era regime-luck del mercato toro
|
||||
|
||||
### (1) Pre-hold-out (2018-2024): forte e robusto
|
||||
Plateau pieno: BTC Sharpe 0.91-1.16, ETH 1.19-1.48 su tutte le config. **Deflated-Sharpe**
|
||||
(N=60 trial): BTC DSR **0.990**, ETH **0.982** → l'effetto trend era REALE e robusto al
|
||||
multiple-testing **sul 2018-2024**.
|
||||
|
||||
### (2) HOLD-OUT 2025-26 (sbloccato una volta) — FALLISCE
|
||||
| | buy&hold | trend 24/96 | trend 96/288 (slow) |
|
||||
|---|---|---|---|
|
||||
| BTC Sharpe | −0.37 | **−0.81** | −0.00 |
|
||||
| BTC ret | −32.9% | −33.6% | −5.0% |
|
||||
| ETH Sharpe | −0.32 | **−0.95** | −0.01 |
|
||||
| ETH ret | −49.3% | −52.0% | −11.3% |
|
||||
|
||||
Il 2025-26 è stato un periodo in DISCESA (buy&hold negativo). Il trend long-only — che "dovrebbe"
|
||||
schivare i bear — si è fatto **frullare** (whipsaw): perde quanto o PIÙ del buy&hold, Sharpe negativo
|
||||
su ogni config. Solo la MA lentissima (96/288) limita i danni a ~flat (−5/−11%), ma è cherry-pick
|
||||
post-hoc e comunque NON positiva.
|
||||
|
||||
### (3) Per anno — il meccanismo
|
||||
Il trend cattura ~70-80% degli anni TORO (2019-2024) e attutisce i bear IN-SAMPLE (2018 −1% vs
|
||||
−39%; 2022 −47% vs −65%). MA nel 2025 OUT-OF-SAMPLE ha fatto **peggio** del buy&hold (BTC −25% vs
|
||||
−7%; ETH −41% vs −11%): frullato in un mercato choppy/discendente. È il classico fallimento del
|
||||
trend-following nei bear laterali. → l'edge 2018-24 era **beta del toro con risk-management**, non
|
||||
alpha persistente.
|
||||
|
||||
### (4) Stress
|
||||
FULL regge modestamente (Sharpe 0.65-0.91 anche a fee2x+lag), ma HOLD-OUT è negativo ovunque
|
||||
(−0.81 → −1.34) e peggiora sotto stress. Fragile.
|
||||
|
||||
### (5) Deflated-Sharpe
|
||||
DSR>0.95 sul pre-hold-out → conferma che l'effetto era statisticamente reale **nel campione di
|
||||
training**. Lezione chiave: **robustezza statistica in-sample ≠ persistenza out-of-sample.** Il
|
||||
hold-out bloccato ha colto ciò che DSR da solo non poteva — il cambio di regime.
|
||||
|
||||
## VERDETTO FINALE (Fasi 0-3)
|
||||
**Nessun edge validato, fee-surviving e out-of-sample esiste su BTC/ETH tra le famiglie testate.**
|
||||
Il trend-following era il miglior candidato: reale 2018-24 (toro), ma **bocciato sul hold-out
|
||||
2025-26** (whipsaw, sotto il buy&hold). La barra realistica resta il **buy&hold** (Sharpe ~0.8
|
||||
sullo storico, ma −0.3/−0.4 nel 2025-26: anche "stai long" è stato duro di recente).
|
||||
|
||||
Il processo disciplinato ha funzionato: **ha evitato di deployare un falso edge** (che, sul vecchio
|
||||
sistema contaminato, sarebbe finito in produzione). Questo è il valore del reset.
|
||||
|
||||
## Implicazioni / direzioni
|
||||
- **Non deployare** il trend come edge: è regime-dipendente, non batte il buy&hold OOS.
|
||||
- Con **solo BTC/ETH prezzo**, il pozzo dei segnali è poco profondo: timing puro non ha edge robusto.
|
||||
- Opzioni: nessun ruolo a breve (confermato). Tenere cerbero-bite ad accumulare per uno studio
|
||||
multi-regime futuro.
|
||||
- Scelte oneste per andare avanti: (a) accettare che il "ceiling" su BTC/ETH è un long risk-managed
|
||||
(no alpha) e ottimizzare quello (vol-target per ridurre DD, non per battere il mercato); (b)
|
||||
allargare l'universo dati CERTIFICATO (servono asset liquidi+puliti oltre BTC/ETH, che Deribit non
|
||||
offre bene → valutare un secondo venue mainnet certificabile); (c) fonti di segnale ortogonali al
|
||||
prezzo (on-chain, funding/basis multi-venue, opzioni multi-regime) — tutte richiedono nuovi dati
|
||||
certificati prima di poterci credere.
|
||||
@@ -0,0 +1,63 @@
|
||||
# 2026-06-19 — Sintesi ricerca post-reset (5 track) e verdetto
|
||||
|
||||
Prima ondata di ricerca sui dati **certificati** BTC/ETH (Deribit mainnet, ~2 bps vs
|
||||
Coinbase USD), con harness onesto condiviso `src/backtest/harness.py` (ingresso eseguibile
|
||||
a `close[i]`, fee 0.10% RT, exit intrabar TP/SL, OOS/per-anno). Branch
|
||||
`strategy-research-2026-06`.
|
||||
|
||||
## I 5 track
|
||||
|
||||
| Track | Famiglia | Esito |
|
||||
|-------|----------|-------|
|
||||
| **A** | Trend/Momentum (TSMOM, Donchian, EMA, vol-scaled) | 5m/15m morti (fee); 1h = residuo reale ma celle singole non robuste |
|
||||
| **B** | ML walk-forward (logistic/GBM su feature di forma) | edge debole ma REALE su BTC (+83% OOS, Sharpe 0.57), ~+0.58 €/d su 2000 |
|
||||
| **C** | Mean-reversion / range (fade, RSI2, VWAP) | **MORTO** — negativo anche a fee=0. Conferma: la vecchia libreria fade era artefatto |
|
||||
| **D** | **Trend portfolio vol-targeted BTC+ETH** | ✅ **DEPLOYABLE** — robusto, positivo ogni anno |
|
||||
| **E** | Cross-sectional BTC↔ETH + ensemble | RV debole (muore a 1.5bps/gamba); ensemble dimezza il DD ma non alza il ritorno |
|
||||
|
||||
## Il vincitore: Track D — trend portfolio (l'unico che guadagna in modo robusto)
|
||||
|
||||
TSMOM multi-orizzonte (blend 1-3-6 mesi su barre 1h), **vol-targeting** (posizione ∝
|
||||
1/vol realizzata, target 20% annuo), portafoglio **50/50 BTC+ETH**, fee 0.10% RT. Un solo
|
||||
set di parametri per entrambi gli asset.
|
||||
|
||||
- **LONG-SHORT 50/50:** CAGR +14.2%, **Sharpe 1.00**, maxDD 18.9%, positivo ogni anno 2019-2026.
|
||||
- **LONG-FLAT 50/50 (migliore risk-adj):** CAGR +15.9%, **Sharpe 1.32**, **maxDD 13.3%**.
|
||||
- Robusto: plateau di Sharpe ~1.0 su griglia target-vol/leva/orizzonti; regge fee fino a 0.40% RT;
|
||||
su entrambi gli asset; **non** è un picco fortunato (a differenza delle "star" di Track A).
|
||||
- Tesi confermata: il valore del trend è **tagliare il drawdown** (B&H DD ~78% → trend DD ~13-19%)
|
||||
con Sharpe ≥ B&H → si può scalare il rischio (target-vol) e diversificare BTC+ETH.
|
||||
- Caveat onesto: l'edge è più forte 2018-21 (Sharpe 1.63) che 2022-26 (Sharpe 0.57). Dimensionare
|
||||
sul regime recente.
|
||||
|
||||
## Il verdetto sul target €50/giorno
|
||||
|
||||
Una strategia che **guadagna** in modo robusto ESISTE (Track D). Ma il target "€50/giorno
|
||||
medio partendo da 2000 in 1-2 anni" **non è raggiungibile onestamente**: sono ~2.5%/giorno.
|
||||
|
||||
La leva NON è la scorciatoia (alza il DD verso la rovina). La vera leva è **target-vol +
|
||||
capitale + tempo**:
|
||||
|
||||
| target-vol | leva usata | CAGR | Sharpe | maxDD | €/giorno (2k) |
|
||||
|-----------|-----------|------|--------|-------|---------------|
|
||||
| 20% | 0.23x | +14% | 1.00 | 19% | +0.73 |
|
||||
| 40% | 0.45x | +28% | 1.00 | 35% | +3.73 |
|
||||
| 60% | 0.68x | +40% | 1.00 | 48% | +7.96 |
|
||||
| 80% | 0.90x | +50% | 0.99 | 60% | +13.78 |
|
||||
|
||||
Per **€50/giorno steady-state** servono ~**137k di capitale** (config conservativa, DD~19%),
|
||||
oppure DD da rovina. Partendo da 2000 a CAGR ~28% (target-vol 40%, DD 35%) il capitale che
|
||||
genera €50/giorno arriva in ~10-13 anni, non in 1-2.
|
||||
|
||||
## Conclusione operativa
|
||||
|
||||
1. **Esiste un edge dispiegabile e onesto**: il trend portfolio vol-targeted (Track D).
|
||||
È il primo risultato robusto post-reset.
|
||||
2. **Non esiste alcuna scorciatoia** verso €50/giorno su 2000 in 1-2 anni con questi dati
|
||||
(BTC/ETH 5m-1h). Il limite è strutturale: due asset, alta correlazione, fee.
|
||||
3. Prossimi passi onesti se si vuole alzare il soffitto: (a) dimensionare Track D a un
|
||||
target-vol/DD tollerabile e farlo girare in paper, (b) cercare edge di **magnitudine
|
||||
diversa** (non più diversificazione di edge deboli) — il che richiede dati che oggi non
|
||||
abbiamo certificati (universo più ampio, microstruttura, funding/opzioni backtestabili).
|
||||
|
||||
Script: `scripts/research/track{A,B,C,D,E}_*.py`. Diari di dettaglio: `2026-06-19-track*.md`.
|
||||
@@ -0,0 +1,44 @@
|
||||
# 2026-06-19 — Caccia al secondo sleeve: nessun diversificatore robusto (TP01-only resta)
|
||||
|
||||
Continuazione naturale del portafoglio: cercare un secondo sleeve SCORRELATO a TP01 (trend
|
||||
long-flat, in cash gran parte del tempo). Criterio: non il Sharpe standalone ma il CONTRIBUTO al
|
||||
portafoglio + robustezza. Tool: `scripts/portfolio/second_sleeve_hunt.py` (riusa le RV di trackE).
|
||||
|
||||
## Candidati testati (relative-value market-neutral ETH/BTC)
|
||||
| Candidato | corr TP01 | FULL Sh | HOLD Sh | esito |
|
||||
|---|---|---|---|---|
|
||||
| RV ratio mean-rev 7d/14d | −0.09/−0.05 | −1.36/−1.03 | −0.62/−0.76 | ☠️ morto (mean-rev dead, come sempre) |
|
||||
| RV ratio_trend / xs_momentum 30d | +0.04 | **0.56** | **1.92** | ⚠️ sembrava promosso |
|
||||
|
||||
ratio_trend e xs_momentum danno risultati IDENTICI: su 2 asset "long il più forte / short il
|
||||
debole" ≡ "trend del ratio ETH/BTC". È UN segnale (relative-momentum), non due.
|
||||
|
||||
## Il candidato "promosso" è regime-luck (per-anno + plateau lo smascherano)
|
||||
Aggiunto a TP01 sembrava un trionfo: hold-out portafoglio 0.31 → 1.18 (w20%) / 1.51 (w30%),
|
||||
corr +0.04. MA:
|
||||
- **Hold-out (1.92) >> FULL (0.56)**: bandiera rossa (immagine speculare della trappola di Fase 3).
|
||||
- **Per-anno NON robusto**: 2019 +22%, 2020 +7%, 2021 +21%, 2022 +13%, **2023 −17%, 2024 −19%**,
|
||||
**2025 +62%**, 2026 +6%. Due anni consecutivi negativi; il "guadagno" è concentrato nel 2025
|
||||
(ETH sottoperforma BTC in modo netto e sostenuto). FULL Sharpe mediocre 0.56, DD 41%.
|
||||
- **Nessun plateau**: l'hold-out Sharpe oscilla 0.25→1.92 al variare di (N, hold) → picco
|
||||
config+regime, non altopiano.
|
||||
- Il beneficio FULL al portafoglio è solo **+0.09 Sharpe** (la legittima diversificazione di uno
|
||||
sleeve scorrelato a Sharpe 0.56: √(1.30²+0.56²)≈1.42). Il resto del "miglioramento" è il 2025.
|
||||
|
||||
## Decisione: NON promosso — TP01-only resta il portafoglio deployato
|
||||
La stessa disciplina che ha bocciato i falsi positivi in-sample (Fasi 1-3) e cross-asset (frattali)
|
||||
deve bocciare questo falso positivo nel hold-out. Il relative-momentum BTC/ETH è un edge debole e
|
||||
regime-dipendente (2 anni a −17/−19%), il cui contributo robusto al portafoglio è marginale
|
||||
(+0.09 FULL); il grosso del beneficio è la fortuna del 2025. Aggiungerlo significherebbe
|
||||
scommettere sul ripetersi di quel regime.
|
||||
|
||||
**Lezione/criterio aggiornato per i futuri sleeve:** "migliora il hold-out" da solo NON basta (il
|
||||
hold-out è UN regime). Un secondo sleeve va promosso solo se: causale, corr bassa, **positivo nella
|
||||
maggioranza degli anni** (no 2 anni consecutivi rossi), **plateau** sui parametri, e migliora il
|
||||
portafoglio su FULL E hold-out — non solo per via di un singolo anno fortunato.
|
||||
|
||||
## Stato
|
||||
Portafoglio = **TP01-only** (difensivo, Sharpe FULL 1.30 / hold-out 0.31). `active_sleeves()`
|
||||
invariato. `second_sleeve_hunt.py` resta come tool per valutare candidati futuri col criterio
|
||||
corretto (contributo + breadth per-anno + plateau). Il relative-momentum BTC/ETH è in WATCHLIST,
|
||||
non deployato.
|
||||
@@ -0,0 +1,31 @@
|
||||
# 2026-06-19 — Portafoglio di strategie estensibile (TP01 primo sleeve)
|
||||
|
||||
Creato un contenitore di portafoglio (`src/portfolio/`) con TP01 come unico sleeve attivo per ora,
|
||||
progettato per aggiungerne altri (ognuno validato col gauntlet onesto).
|
||||
|
||||
## Design
|
||||
- **Sleeve** = una strategia validata che produce una serie di rendimenti netti per-barra
|
||||
(datetime-indexed, CAUSALE, netto fee). Opzionale `pos_fn` per le posizioni correnti (live).
|
||||
- **StrategyPortfolio**: porta ogni sleeve su griglia GIORNALIERA comune (compounding intra-giorno
|
||||
→ mixa TF diversi in modo coerente), combina per PESO rinormalizzato sui giorni comuni
|
||||
(= equal-capital-by-weight ribilanciato di continuo). Metriche FULL + HOLD-OUT 2025-26 (bloccato)
|
||||
+ per-anno + standalone per-sleeve, vs benchmark buy&hold 50/50.
|
||||
- **Estensibilità**: aggiungere uno sleeve = una riga in `src/portfolio/sleeves.active_sleeves`
|
||||
(dopo validazione: research_lab + hold-out + cross-asset + causality guard). Niente sleeve non validati.
|
||||
|
||||
## Stato attuale (1 sleeve = TP01, peso 100%)
|
||||
`scripts/portfolio/run_portfolio.py`:
|
||||
- **FULL** Sharpe 1.30 / ret +201% / DD 14.3% / ~€1.52/g su 2k (n=2655 giorni 2019-2026)
|
||||
- **HOLD-OUT 2025-26** Sharpe 0.31 / +3.5% / DD 7.5% (buy&hold 50/50: Sharpe −0.32 / −39% / DD 59%)
|
||||
- Per-anno positivo quasi ovunque (2022 −2.1%, 2026-YTD −0.7%)
|
||||
- Posizione corrente: **flat** (TP01 in cash nel regime attuale = difensivo)
|
||||
|
||||
## File
|
||||
- `src/portfolio/{__init__,portfolio,sleeves}.py`, `scripts/portfolio/run_portfolio.py`,
|
||||
`tests/test_portfolio.py` (6 test, passano). CLAUDE.md aggiornato.
|
||||
|
||||
## Prossimo
|
||||
Il portafoglio è pronto per ospitare nuovi sleeve. Candidati naturali (da validare prima):
|
||||
un secondo edge scorrelato a TP01 (TP01 è trend long-flat → serve qualcosa di diverso, es. una
|
||||
strategia che lavori quando TP01 è flat). Finché non c'è un secondo edge che regge il gauntlet,
|
||||
il portafoglio = TP01 difensivo. Quando arriverà, basta una riga in sleeves.py.
|
||||
@@ -0,0 +1,43 @@
|
||||
# 2026-06-19 — TP01: look-ahead ffill mixed-TF, ri-verifica e adozione bassa frequenza (>=12h)
|
||||
|
||||
Segnalazione utente/agente: un look-ahead **ffill MIXED-TIMEFRAME su barre open-labeled**
|
||||
(`resample(label="left")`) gonfiava il 4h a Sharpe ~1.60; il risultato reale è ~1.1.
|
||||
Conclusione: **NON scendere sotto le 12h** — costi e overfitting dominano.
|
||||
|
||||
## Cosa ho verificato (`scripts/analysis/tp01_lowfreq.py`)
|
||||
Ricalcolo TP01 PULITO **per singolo TF** (barre discrete, posizione shiftata +1, NESSUN
|
||||
ffill/combine mixed-TF), con un **guard di causalità esplicito** (ricalcolo `target_series` su
|
||||
prefisso → `tgt[i]` invariato). Esito (fee 0.10% RT, hold-out 2025-26 bloccato):
|
||||
|
||||
| TF | leak | FULL Sh | FULL ret | HOLD Sh | HOLD ret | HOLD DD |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 4h | **0** | 1.36 | +204% | 0.27 | +2.8% | 8.3% |
|
||||
| 6h | **0** | 1.42 | +217% | 0.21 | +2.1% | 7.9% |
|
||||
| 12h | **0** | 1.32 | +198% | 0.22 | +2.3% | 8.6% |
|
||||
| **1d** | **0** | 1.30 | +201% | **0.31** | **+3.5%** | 7.5% |
|
||||
| buy&hold 50/50 1d | — | 0.92 | +1671% | **−0.32** | **−39%** | 59% |
|
||||
|
||||
## Lettura
|
||||
- **Il path single-TF che ho usato in verify/stress è LEAK-FREE** (guard=0 su ogni TF): il
|
||||
gonfiaggio 1.60 stava nel path **mixed-TF ffill** (ensemble/combine, es. trackE), NON nel
|
||||
portafoglio single-TF. Per questo il mio 4h era 1.36 (non 1.60).
|
||||
- **La conclusione "≥12h" è comunque CORRETTA e la adotto**: il FULL Sharpe è PIATTO ~1.3 da 12h
|
||||
a 4h → scendere sotto le 12h NON dà vantaggio reale, aggiunge solo costi/turnover e rischio
|
||||
overfit/look-ahead (lo stress mostrava il margine hold-out del 4h fragile a lag/fee). **1d è il
|
||||
migliore**: hold-out Sharpe 0.31 (il più alto), DD 7.5%, turnover/costi minimi, leak-free.
|
||||
- Allinea anche col numero dell'agente: il "reale ~1.1" è del path mixed-TF corretto; il mio
|
||||
single-TF pulito dà ~1.3 FULL. In ogni caso **edge difensivo modesto**, non alpha.
|
||||
|
||||
## Decisioni applicate
|
||||
- **Canonica deploy → PORT LF1d** (era LF4h). `trend_portfolio.py`: docstring aggiornata + nota
|
||||
look-ahead; aggiunti `resample_tf`/`resample_1d`, `resample_4h` marcato deprecato per il deploy.
|
||||
- **Paper trader → 1d** (`paper_trend.py`: `resample_1d`, `build_bars`, etichette 1d; gira, 5 test ok).
|
||||
- **CLAUDE.md**: TP01 ridescritta come DIFENSIVA, canonica ≥12h/1d, gotcha look-ahead documentato.
|
||||
- **Gotcha riusabile:** mai ffill/combine MIXED-TIMEFRAME su timestamp open-labeled (`label="left"`):
|
||||
la close del bar (nota solo a fine bar) verrebbe propagata indietro all'open-label → look-ahead.
|
||||
Il calcolo per-singolo-TF a barre discrete (posizione +1) è sicuro; il guard prefix-recompute lo prova.
|
||||
|
||||
## Verdetto invariato
|
||||
TP01 resta la prima strategia onesta del progetto: **difensiva** (taglia il DD ~6× vs buy&hold,
|
||||
hold-out 2025-26 positivo su entrambi gli asset), modesta nel ritorno. Deploy a **1d**, forward-only
|
||||
paper trader, prima di qualsiasi capitale reale.
|
||||
@@ -0,0 +1,84 @@
|
||||
# 2026-06-19 — Verifica TP01 (branch strategy-research-2026-06) col gauntlet onesto
|
||||
|
||||
Una ricerca PARALLELA (branch `strategy-research-2026-06`, AdrianoDev) dallo stesso baseline
|
||||
v2.0.0 ha trovato TP01 come "unica vincitrice". La mia linea (Fasi 2-3) aveva bocciato il trend
|
||||
sul hold-out 2025-26. Ho riprodotto TP01 VERBATIM (`scripts/analysis/verify_tp01.py`) e l'ho
|
||||
passato al mio gauntlet. **TP01 REGGE — la mia conclusione precedente era incompleta.**
|
||||
|
||||
## TP01 = TSMOM 30/90/180g, **vol-target 20%**, leva cap 2x, **long-flat**, portafoglio 50/50 BTC+ETH (4h)
|
||||
|
||||
## Esiti del gauntlet
|
||||
|
||||
**(A) Multi-TF (4h cherry-picked?) — NO, plateau robusto:**
|
||||
| TF | FULL Sharpe | HOLD-OUT Sharpe |
|
||||
|---|---|---|
|
||||
| 15m | 0.93 | −0.31 |
|
||||
| 1h | 1.32 | +0.20 |
|
||||
| **4h** | **1.36** | **+0.27** |
|
||||
| 1d | 1.30 | +0.31 |
|
||||
1h/4h/1d danno tutti FULL ~1.3 e hold-out positivo → non è un artefatto di un singolo TF (solo il 15m, fee-sensibile, fallisce).
|
||||
|
||||
**(C) HOLD-OUT 2025-26 (il test che ha ucciso il mio trend 1h) — TP01 PROTEGGE:**
|
||||
| | Sharpe | ret | DD |
|
||||
|---|---|---|---|
|
||||
| **TP01 portfolio** | **+0.27** | **+2.8%** | **8.3%** |
|
||||
| buy&hold 50/50 | −0.35 | **−39.4%** | 59.8% |
|
||||
|
||||
**(D) Cross-asset nel hold-out — regge su ENTRAMBI** (BTC sleeve +2.9% Sh 0.24, ETH +2.4% Sh 0.24).
|
||||
A differenza del "vincitore" frattale (+ETH/−BTC), TP01 protegge coerentemente su BTC E ETH.
|
||||
|
||||
**(B) Per anno:** positiva quasi ogni anno 2019-2026 (eccezioni piccole: 2022 −2.4%, 2026-YTD −0.9%),
|
||||
DD annui 1-12%. Il claim "positiva ogni anno" è lievemente ottimistico ma sostanzialmente vero.
|
||||
|
||||
## Perché TP01 regge dove il MIO trend (Fase 3) è caduto
|
||||
La differenza chiave è il **VOL-TARGETING** (che NON avevo combinato col trend): TP01 scala
|
||||
l'esposizione ∝ 1/vol_realizzata → nel crollo 2025-26 la vol è esplosa e TP01 si è messo
|
||||
quasi in cash, schivando il drawdown. Il mio MA-cross 1h aveva esposizione fissa ed è rimasto
|
||||
long nel chop → frullato. Concorrono: TSMOM multi-orizzonte (più liscio del MA-cross), long-flat
|
||||
(niente perdite short), diversificazione 50/50. **La mia "trend = regime-luck" era vera per il
|
||||
trend NUDO; TP01 = trend + vol-target + portafoglio è un'altra cosa, e robusta.**
|
||||
|
||||
## Cosa È onestamente TP01 (no oversell)
|
||||
- **Edge DIFENSIVO, non alpha**: FULL Sharpe 1.36 vs buy&hold 0.92 — MA CAGR +16.6% vs +48.1%.
|
||||
Su tutto il toro il buy&hold ha reso ~8x di più. Il valore di TP01 è il **DD** (13.8% vs 77.5%
|
||||
full; 8% vs 60% nel hold-out) e la **protezione dai crash**.
|
||||
- Nel hold-out 2025-26 ha fatto solo +2.8% (Sharpe 0.27, basso): ha **protetto, non profittato**.
|
||||
- Un solo regime di hold-out, ma il vol-targeting è meccanico (high vol → low expo) → generalizza
|
||||
per costruzione meglio di un timing fittato.
|
||||
- Config canonica (30/90/180, vol20%, lev2x) non iper-tunata; 4h non cherry-picked (plateau).
|
||||
|
||||
## VERDETTO
|
||||
**TP01 è la PRIMA strategia onesta e robusta del progetto post-reset.** Supera il mio gauntlet
|
||||
(hold-out positivo su entrambi gli asset, plateau multi-TF, causale, fee-aware). È modesta e
|
||||
difensiva (Sharpe ~1.3, soffitto strutturale dichiarato corretto), ma è reale: migliora il
|
||||
rischio/rendimento del buy&hold tagliando i drawdown e proteggendo nei crash. La ricerca parallela
|
||||
ha fatto centro proprio sul pezzo che la mia linea non aveva combinato (vol-target sul trend).
|
||||
|
||||
**Raccomandazione:** integrare il branch su main (modulo `trend_portfolio.py` + paper trader),
|
||||
trattare TP01 come baseline operativa difensiva. Aspettative oneste verso il target €50/g: a
|
||||
Sharpe 1.3 / CAGR 16.6% servono molto capitale o leva (con più DD) — TP01 è un fondamento solido,
|
||||
non una scorciatoia.
|
||||
|
||||
## STRESS-TEST (`scripts/analysis/stress_tp01.py`, integrato e rieseguito sul modulo vero)
|
||||
|
||||
| Dimensione | Esito |
|
||||
|---|---|
|
||||
| **Sweep fee** | FULL robusto fino a **0.40% RT** (Sh 1.44→1.36→1.28→1.13). HOLD-OUT SOTTILE: +2.8%/Sh0.27 a 0.10% → ~flat (Sh 0.03) a 0.40% |
|
||||
| **Lag/slippage** | FULL robusto (1.29-1.43). HOLD-OUT si erode: lag1(4h)→Sh0.12, lag2→−0.02, lag1+fee0.20%→0.04 |
|
||||
| **Plateau parametri** | OTTIMO — target_vol/leva/orizzonti/vol_win tutti reggono o migliorano (orizzonti 20/60/120 → Sh 1.61). **NON un picco cherry-picked** |
|
||||
| **Deflated-Sharpe** | DSR **0.999** a N=10/40/100 trial → il Sharpe FULL non è artefatto di multiple-testing |
|
||||
|
||||
**Verdetto stress (onesto):**
|
||||
- **Robustezza FULL-period: FORTE.** TP01 supera fee 0.40%, lag, ampio plateau di parametri, e
|
||||
deflated-Sharpe. NON è overfit né cherry-picked — la proprietà robusta è il **taglio del
|
||||
drawdown** (13.8% vs 77.5% full; 8% vs 60% hold-out), invariante a tutto lo stress.
|
||||
- **Edge di RITORNO nel hold-out: REALE ma SOTTILE e sensibile alla frizione.** Nel 2025-26 ha
|
||||
schivato il crash in modo affidabile (DD 8% vs 60%) ma ha **protetto più che profittato** (+2.8%,
|
||||
Sh 0.27), e quel sottile positivo si assottiglia a zero sotto fee2x o lag 2 barre.
|
||||
|
||||
**Conclusione:** la proprietà **deployabile e robusta di TP01 è la PROTEZIONE del drawdown**, non
|
||||
la generazione di alpha. È una strategia difensiva genuina (prima del progetto a superare gauntlet
|
||||
+ stress), ma a basso ritorno: il valore è "Sharpe ~1.3 con DD ~6× più piccolo del buy&hold",
|
||||
non "battere il mercato". Per il capitale reale: il vol-targeting + long-flat sono meccanici e
|
||||
generalizzano; il rischio residuo è la frizione di esecuzione sul filo del sottile edge di ritorno
|
||||
nei regimi avversi → da monitorare col paper trader forward-only prima di scalare.
|
||||
@@ -0,0 +1,74 @@
|
||||
# 2026-06-19 — Track A: Trend / Momentum su BTC & ETH (dati certificati)
|
||||
|
||||
Prima ricerca di strategie NUOVE post-reset (track A = trend/momentum). Tool:
|
||||
`scripts/research/trackA_trend.py` (harness onesto `src/backtest/harness.py`, fee 0.10% RT,
|
||||
IS/OOS 65/35, griglia su entrambi gli asset, fee sweep, stress leva). Run:
|
||||
`uv run python scripts/research/trackA_trend.py`.
|
||||
|
||||
## Cosa è stato testato
|
||||
- **TSMOM** (segno del ritorno N-barre, hold H) long/short e long-only.
|
||||
- **EMA crossover** (fast/slow) come filtro di trend.
|
||||
- **Donchian breakout** (entry ONESTO: breakout rilevato con `close[i]`, fill a `close[i]`).
|
||||
- **Vol-scaled / regime-gated TSMOM** (momentum preso solo se |z| > gate, z = ritorno/vol).
|
||||
- Griglia ampia su **BTC e ETH**, **1h / 15m / 5m**. 480 celle OOS totali.
|
||||
|
||||
Tutto entry-eseguibile: direzione e prezzo decisi con dati ≤ `close[i]`, fill a `close[i]`.
|
||||
Nessun uso di `returns[i]` (che codifica `close[i+1]`). Hold approssimato come catena di
|
||||
posizioni non sovrapposte di H barre (la fee si ammortizza su H barre — costo onesto).
|
||||
|
||||
## Risultati — la fotografia onesta
|
||||
|
||||
**Celle positive OOS per timeframe:**
|
||||
| TF | celle positive / totali |
|
||||
|----|----|
|
||||
| **1h** | 39 / 160 |
|
||||
| **15m** | **0 / 160** |
|
||||
| **5m** | **0 / 160** |
|
||||
|
||||
→ **Trend intraday (5m/15m) è MORTO**: lo drag della fee (più trade = più 0.10% RT) annienta
|
||||
qualsiasi segnale. Drawdown 80-99%, Sharpe da −0.6 a −2.2. Niente da salvare.
|
||||
|
||||
**Su 1h** c'è qualche cella positiva, ma il contesto la ridimensiona:
|
||||
- La finestra **OOS è un singolo regime**: il taglio 65% cade a **set/dic 2023**, quindi
|
||||
l'OOS è ~2023→2026 (in gran parte toro 2024). Tutto il 2018-2022 (orso 2018, crash 2020,
|
||||
toro 2021, orso 2022) è IN-SAMPLE. "Positivo OOS" qui ≈ "il trend ha fatto soldi nel toro 2024".
|
||||
- **Benchmark buy & hold sulla stessa finestra OOS**: BTC **+134%**, ETH **−21%**.
|
||||
- Tutte le `TSMOM_LONG` e metà delle celle BTC fanno **MENO** del B&H → è **beta**, non edge.
|
||||
- Le poche che battono il B&H lo fanno **solo su ETH** (dove il B&H era negativo): catturano
|
||||
anche i ribassi. Quello è timing reale — ma vedi sotto.
|
||||
|
||||
**Le "star":** VOLSCALED_TSMOM BTC 1h (N=20,H=48,vw=100,z=0.5) = +367% OOS, Sh 0.91, DD 32%,
|
||||
€/d(2k) +2.56; ETH 1h (N=20,H=48,vw=50,z=1.0) = +197% OOS, Sh 0.60. **MA sono celle fortunate:**
|
||||
i vicini di griglia crollano (stesso N/H, vw=50 invece di 100 → +21% invece di +367%; z=1.0 → +34%).
|
||||
Non è un altopiano robusto, è un picco isolato. E il P&L è concentrato nel 2024 (+110% su BTC),
|
||||
con 2025/2026 deboli o negativi per molte celle.
|
||||
|
||||
**Consistenza cross-asset (un edge vero regge su ENTRAMBI):** su 480 celle, solo **2** sono
|
||||
positive OOS su BTC *e* ETH:
|
||||
- `TSMOM_LONG 1h N=200 H=48` → ma è long-only ≈ beta (fa meno del B&H su BTC).
|
||||
- `DONCHIAN 1h N=200 H=12` → l'unico candidato "vero" simmetrico, ma **marginale**:
|
||||
OOS BTC +9% / ETH +15%, **Sharpe 0.15-0.19**, troppo debole per dispiegarlo.
|
||||
|
||||
**Fee sweep / leva:** le star reggono lo sweep 0.0005-0.002 (è 1h, poche operazioni), e lo Sharpe
|
||||
è invariante alla leva (come deve) — ma la leva 3x porta i DD a 75-91% e affonda le celle marginali.
|
||||
|
||||
## Verdetto
|
||||
|
||||
**Nessun edge trend/momentum dispiegabile, onestamente, su BTC/ETH oggi.**
|
||||
- 5m/15m: morti per fee. Chiuso.
|
||||
- 1h: esiste un **residuo di segnale trend** (le celle che battono il B&H negativo di ETH non sono
|
||||
solo beta), ma è (a) testato su **un solo regime OOS** (toro 2023-2026), (b) **non robusto** di
|
||||
griglia (picchi isolati), (c) sull'unica cella simmetrica robusta-su-entrambi (Donchian N=200)
|
||||
**troppo debole** (Sharpe ~0.17). Sharpe netti ~0.3-0.9 nel caso migliore = sotto la soglia per
|
||||
rischiare capitale reale.
|
||||
|
||||
Conferma la lezione del reset (il superstite storico era trend-following, non mean-reversion): il
|
||||
trend è la direzione *meno sbagliata*, ma sui dati certi non basta a fare un edge. Coerente con
|
||||
Track C (mean-reversion = artefatto).
|
||||
|
||||
## Prossimi passi possibili (non ancora edge)
|
||||
- Walk-forward multi-regime (non un singolo taglio 65/35) per stressare Donchian-1h-N200 su orso 2018/2022.
|
||||
- Trend 1h **con filtro di volatilità/regime più ricco** o portafoglio BTC+ETH per diversificare il
|
||||
rischio di regime — ma solo se emerge robustezza di griglia, non altri picchi fortunati.
|
||||
- Restare scettici: finché un trend non è positivo su griglia + su entrambi gli asset + su ≥2 regimi
|
||||
OOS, **non si dispiega**.
|
||||
@@ -0,0 +1,93 @@
|
||||
# 2026-06-19 — Track B: ML / feature-prediction su BTC & ETH (walk-forward onesto)
|
||||
|
||||
Esperimento di ricerca sulla direzione **machine-learning** post-reset, su dati Deribit
|
||||
mainnet certificati (solo BTC/ETH). Tool: `scripts/research/trackB_ml.py` (runnable
|
||||
`uv run python scripts/research/trackB_ml.py`). Tutto netto fee, strict walk-forward,
|
||||
held-out tail mai usato per scegliere i config.
|
||||
|
||||
## Metodologia (anti look-ahead — la lezione della v2.0.0)
|
||||
|
||||
- **Feature** (21): ritorni multi-lag (1/2/3/6/12/24), geometria candela (body/upper/lower
|
||||
shadow su range, range normalizzato, body lag-1), momentum48 + accelerazione, RSI14,
|
||||
estensione ATR-normalizzata vs EMA24, vol realizzata 24/72 + ratio, posizione del close
|
||||
nel range 24/72, z-score del volume. **Tutte backward** (note solo a `close[i]`).
|
||||
- **Label**: segno del ritorno forward su H barre, `sign(close[i+H]/close[i])`.
|
||||
- **Strict walk-forward**: per predire il blocco che inizia a `b`, si addestra
|
||||
scaler+modello SOLO su indici `< b-H` (gap di H → label completamente realizzata nel
|
||||
passato), finestra rolling delle ultime W barre. Retrain ogni K=250 barre. Mai fit sul
|
||||
futuro. **Nessun leakage** (verificato: la label più recente del train usa `close[b-1]`).
|
||||
- **Esecuzione**: entry a `close[i]` nella direzione predetta, hold fino a H barre
|
||||
(no TP/SL); il no-overlap dell'harness distanzia i trade ≥ H barre.
|
||||
- **Modello**: `LogisticRegression(class_weight='balanced')`. Soglia di probabilità per
|
||||
filtrare i segnali deboli (long se p>0.5+thr, short se p<0.5-thr, altrimenti flat).
|
||||
- **Selezione su DEV** (primo 75%), **conferma una volta sola** sull'held-out tail (ultimo 25%).
|
||||
- Griglia: W∈{4000,8000,16000}, H∈{6,12,24,48}, thr∈{0,0.03,0.06,0.10}, BTC & ETH, 1h.
|
||||
Fee-sweep 0.05/0.10/0.15/0.20% RT. Turnover/time-in-market sempre riportati.
|
||||
|
||||
## Risultato — esiste un segnale, ma è debole e a basso turnover
|
||||
|
||||
**Pattern netto e robusto della griglia**: la positività compare SOLO nelle celle a basso
|
||||
turnover → **W grande (16000) + H lungo (24) + soglia alta (0.10)**. Tutto ciò che gira
|
||||
veloce (thr basso, H corto, e soprattutto il **15m**) **muore sulle fee**.
|
||||
|
||||
- **15m**: 0/12 celle positive in dev (la migliore −47%, le altre −99%). Stesso win-rate
|
||||
52–56% del 1h, ma il turnover lo polverizza. Conferma di prim'ordine: l'edge per-trade è
|
||||
minuscolo, sopravvive solo se si tradano poche barre.
|
||||
- **1h, dev**: 19/96 celle net-positive con Sharpe>0. Famiglie threshold-robuste:
|
||||
`BTC W16000 H12`, `BTC W8000 H12`, `BTC W16000 H24`, più ETH W16000 H12/H48 marginali.
|
||||
|
||||
### Held-out tail (2024→2026, mai toccato in sviluppo)
|
||||
|
||||
| config | trades | wr% | net% | Sharpe | DD% | mkt% | €/g(2k) | long% | B&H tail |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| **BTC W16000 H24 thr0.10** | 333 | 52.9 | **+83.7** | 0.57 | 23 | 12 | **+0.58** | 44 | +3.9% |
|
||||
| BTC W16000 H12 thr0.10 | 382 | 53.4 | +37.6 | 0.35 | 25 | 7 | +0.26 | 54 | +3.9% |
|
||||
| ETH W16000 H12 thr0.10 | 364 | 57.7 | +23.7 | 0.24 | 35 | 7 | +0.18 | 68 | −38.4% |
|
||||
| ETH W16000 H48 thr0.06 | 215 | 55.3 | −13.3 | 0.08 | 64 | 16 | −0.10 | 67 | −38.4% |
|
||||
|
||||
**Non è solo beta.** Il B&H sul tail è +3.9% (BTC) e −38.4% (ETH), eppure le celle migliori
|
||||
fanno +37…+84% (BTC) con **long ~44–54%** (bilanciato long/short), e ETH +23.7% **mentre ETH
|
||||
scendeva −38%** (short corretti). Quindi c'è segnale direzionale genuino, non cattura di trend
|
||||
rialzista. Payoff asimmetrico: ~53% WR ma avgWin>avgLoss (BTC: +2.04% vs −1.63%).
|
||||
|
||||
### Fee-sweep (held-out)
|
||||
|
||||
- `BTC W16000 H12 thr0.10`: 0.05%→+66.6 | **0.10%→+37.6** | 0.15%→+13.7 | 0.20%→−6.1.
|
||||
Sopravvive fino a ~0.15% RT, poi muore. Margine sottile.
|
||||
- `BTC W8000 H12 thr0.06`: positivo solo a 0.05%, già −35% a 0.10%. Fragile.
|
||||
- ETH e le celle a turnover medio: muoiono tra 0.10 e 0.15%.
|
||||
|
||||
### Stabilità per-anno (full walk-forward, BTC W16000 H24 thr0.10)
|
||||
|
||||
`+11% (2020) / +188% (2021) / +14% (2022) / −38% (2023) / +13% (2024) / +75% (2025) / +7% (2026)`,
|
||||
CAGR full ~22%, ma **DD 56%** e fortissima concentrazione su 2021/2025 con un 2023 a −38%.
|
||||
|
||||
## Verdetto onesto — NON deployabile verso l'obiettivo
|
||||
|
||||
1. **L'edge è reale ma minuscolo.** A differenza della vecchia libreria (artefatto puro), qui
|
||||
il segnale sopravvive a strict walk-forward, a fee 0.10% RT e batte il B&H sul tail. È un
|
||||
risultato genuino e va registrato: la direzione ML **non è morta**.
|
||||
2. **Ma è incompatibile col target.** €/giorno su €2000 = +0.26…+0.58 baseline (anche la stima
|
||||
rosea full-WF CAGR 22% → ~€1–3/g). Il target è **€50/g** → siamo ~100x sotto.
|
||||
3. **Fragilità**: vive solo a basso turnover (thr alto, H lungo, W grande), DD 23–56%,
|
||||
ritorni concentrati in pochi anni con un anno a −38%, e l'edge si assottiglia già a
|
||||
0.15% RT. Un singolo cambio di regime lo annulla.
|
||||
4. **ETH ≠ "specialmente buono"** (contrariamente all'indizio dello shape-ML precedente): qui
|
||||
ETH è più sottile e più rumoroso di BTC sull'held-out; l'unico merito è aver shortato
|
||||
correttamente il drawdown 2024-25.
|
||||
|
||||
**Conclusione**: la logistic-regression walk-forward su feature di forma+momentum trova un
|
||||
debole edge **momentum direzionale a basso turnover** su BTC (più tenue su ETH), onesto e
|
||||
netto-fee, ma **troppo piccolo, troppo concentrato e troppo fee-sensibile** per essere
|
||||
deployato standalone. Al massimo un **componente** di un futuro ensemble, e solo nelle
|
||||
configurazioni a bassissimo turnover. Nessun config raggiunge, neanche lontanamente, i €50/g.
|
||||
|
||||
## Prossimi passi possibili (non eseguiti)
|
||||
|
||||
- Provare **predizione di magnitudine/asimmetria** (large-up vs large-down) e position-sizing
|
||||
proporzionale alla confidenza, invece del semplice segno.
|
||||
- **GradientBoosting / feature non lineari** (flag `--gbm` predisposto) — ma attenzione
|
||||
all'overfit; il rischio è di "trovare" edge che il walk-forward onesto non conferma.
|
||||
- **Ensemble** del segnale ML a basso turnover con un filtro di regime (vol/trend) per tagliare
|
||||
il 2023. Ma serve dimostrare che il filtro non è scelto col senno di poi.
|
||||
- Restare scettici: finché €/g resta ~100x sotto target, l'ML da solo NON è la risposta.
|
||||
@@ -0,0 +1,70 @@
|
||||
# 2026-06-19 — Track C: mean-reversion / range re-examination (HONEST) → DEAD
|
||||
|
||||
Obiettivo: stabilire rigorosamente se **un qualunque** edge di mean-reversion / range a
|
||||
breve orizzonte sopravvive su BTC/ETH **certificati** (Deribit mainnet) con **ingresso
|
||||
eseguibile onesto**, oppure confermarne definitivamente la morte. Entrambi gli esiti sono
|
||||
validi; nessun risultato forzato.
|
||||
|
||||
Tool: `scripts/research/trackC_meanrev.py` (self-contained, runnable), sopra l'harness
|
||||
onesto `src/backtest/harness.py` (direzione+prezzo decisi con dati ≤ `close[i]`, fill a
|
||||
`close[i]`, exit intrabar TP/SL da `i+1`, fee netto). Universo: BTC/ETH × {1h,15m,5m}.
|
||||
|
||||
## Cosa è stato testato (5 famiglie, ingresso onesto)
|
||||
|
||||
- **ZFADE** — Bollinger/z-score fade: `z(close,lookback)` ≤ −thr → long, ≥ +thr → short.
|
||||
TP al mean mobile o a `tp_atr·ATR`; SL a `sl_atr·ATR`. **Entry a close[i]**, NON al tocco
|
||||
della banda (era proprio quello l'artefatto storico).
|
||||
- **RSI2** — RSI(2) oversold/overbought (+ variante con filtro trend SMA200).
|
||||
- **RETREV** — return reversal: fade del rendimento cumulato estremo (|z| > thr·σ).
|
||||
- **VWAP** — reversione sulla distanza dal VWAP rolling (in unità di σ della distanza).
|
||||
- **SESSION** — autocorrelazione next-bar per ora UTC (descrittivo).
|
||||
|
||||
Metodologia applicata: OOS 65/35, griglia parametri su **entrambi** gli asset, fee-sweep
|
||||
{0, 0.5, 1.0, 1.5, 2.0} bps RT, cross-check liquidità (flat-bar O=H=L=C) e time-in-market.
|
||||
|
||||
## Sanity liquidità
|
||||
|
||||
Flat-bar O=H=L=C: BTC/ETH 1h ≈ 0.01%, 15m 0.09–0.14%. Book vivo → l'eventuale edge NON
|
||||
potrebbe nascondersi in barre ferme (a differenza degli alt archiviati). Confermato pulito.
|
||||
|
||||
## Risultati — tutto negativo, su ogni asse
|
||||
|
||||
**PASS 1 (screen 1h, fee 0.10% RT):** ogni famiglia OOS negativa su entrambi gli asset.
|
||||
Es. ZFADE z2/mean: BTC OOS −85%, ETH OOS −83%. RSI2 10/90: BTC −92%, ETH −96%.
|
||||
RETREV/VWAP idem. Win-rate spesso "alto" (RSI2 ~63%, VWAP ~64%) ma **perde lo stesso** →
|
||||
le poche perdite sono enormi, la reversione non paga il rischio + fee.
|
||||
|
||||
**PASS 2 (griglia 1h):** ZFADE **0/18** celle con OOS>0 su entrambi; RSI2 **0/36**. La
|
||||
cella meno-peggio (ZFADE lookback20 z3) resta BTC −40% / ETH −33% OOS. Nessun sopravvissuto.
|
||||
|
||||
**PASS 3 (fee-sweep, incl. fee=0 GROSS):** il colpo decisivo. **Anche a fee=0** (lordo)
|
||||
la z-fade è negativa: BTC full −74% / OOS −46%, ETH full −98% / OOS −48%. Quindi non è
|
||||
"morte da fee": **la direzione stessa della fade è sbagliata** sul feed pulito. Salendo le
|
||||
fee degrada monotòno fino a −100%.
|
||||
|
||||
**PASS 4 (timeframe 5m/15m/1h):** più veloce = peggio. A 5m full −100% su entrambi
|
||||
(41.889 / 38.660 trade), €/giorno su 2000 ≈ −0.70/−0.75. Coerente con "molte operazioni =
|
||||
morte per fee", ma il PASS 3 mostra che il problema è a monte: niente edge nemmeno lordo.
|
||||
|
||||
**PASS 5 (sessione UTC):** esiste una **debole** autocorrelazione negativa next-bar in
|
||||
poche ore (BTC 13h −0.166, 2h −0.154, 21h −0.129; ETH 13h −0.152, 4h −0.117), e una
|
||||
positiva alle 03h UTC (BTC +0.158, ETH +0.202 = ora "trending"). Struttura reale ma
|
||||
debolissima (|ρ|≤0.17): non sopravvive a fee + dimensionamento del rischio (lo conferma il
|
||||
fatto che tutte le versioni *tradate* perdono anche lorde).
|
||||
|
||||
## Verdetto
|
||||
|
||||
**Nessuna** configurazione MR produce OOS netto>0 su entrambi BTC ed ETH a fee baseline.
|
||||
Più forte: **a fee zero la fade è già negativa** → l'edge MR storico (+201%/+1238% "OOS")
|
||||
era un **artefatto del feed contaminato** (wick fantasma testnet + entry su estremi mai
|
||||
scambiati), non una proprietà del mercato. Sul dato certificato, con ingresso eseguibile,
|
||||
la mean-reversion a breve orizzonte **non è un edge**: è morta sia lorda che netta.
|
||||
|
||||
Coerente con la tesi del reset (`2026-06-19-deribit-history.md`, §3): FADE morto ogni anno.
|
||||
Track C chiusa come direzione di alpha. La debole struttura intraday-by-hour (PASS 5) è
|
||||
annotata ma non azionabile da sola; semmai un *filtro* futuro, non una strategia.
|
||||
|
||||
## Artefatti
|
||||
|
||||
- `scripts/research/trackC_meanrev.py` — riproducibile: `uv run python
|
||||
scripts/research/trackC_meanrev.py [--quick]` (~40s quick, ~3min full).
|
||||
@@ -0,0 +1,96 @@
|
||||
# 2026-06-19 — Track D: Robust walk-forward TREND PORTFOLIO (BTC+ETH), vol-targeted + leverage
|
||||
|
||||
Follow-up to Track A. Thesis under test: trend-following's real value in crypto is **drawdown
|
||||
reduction** vs buy & hold (it sidesteps crashes), and that lower DD lets you apply **leverage** and
|
||||
**diversify** BTC+ETH into a deployable, risk-adjusted *earning* system — even if each single signal
|
||||
has modest Sharpe. Tool: `scripts/research/trackD_trendport.py` (run
|
||||
`uv run python scripts/research/trackD_trendport.py`).
|
||||
|
||||
## Method (honest, no look-ahead)
|
||||
Equity built directly from a **target-position series** (the harness's documented "build your own
|
||||
equity" path), NOT per-trade chaining:
|
||||
- `target[i]` decided with data **≤ close[i]**; **held during the next bar** (close[i]→close[i+1]).
|
||||
- `pnl[t] = target[t-1]·r[t]`, `r[t]=close[t]/close[t-1]-1` — positions **shifted +1 bar** ⇒ no leakage.
|
||||
- Fees on **turnover**: `0.05%/side·|target[t-1]-target[t-2]|` (0.10% RT baseline; swept 0.10–0.40% RT).
|
||||
- **Vol-targeting** (main lever): `target = direction · (target_vol / realized_vol)`, clipped to the
|
||||
leverage cap. `realized_vol` = annualized rolling std of past bar returns (30d window), ≤ close[i].
|
||||
- **Portfolio** = 50/50 BTC+ETH net-return series, rebalanced each bar on common timestamps.
|
||||
|
||||
Leakage sanity check passed: an *oracle* target using next-bar sign explodes (10^119×) — proving the
|
||||
engine holds `target[i-1]` over bar `i` — while our signals (TSMOM blend, MA-slope, Donchian) only use
|
||||
`close[i]` and earlier. Zero-position equity = exactly 1.0.
|
||||
|
||||
## What was tested
|
||||
TSMOM multi-horizon blend (1/3/6-month-equiv on 1h bars), MA-slope (EMA200 slope), Donchian breakout
|
||||
with trailing channel stop — each vol-targeted, long-short **and** long-flat, per-asset and combined.
|
||||
Grid: target-vol × leverage-cap × horizon-set; explicit EARLY(2018-21)/LATE(2022-26) split;
|
||||
fee & leverage sweep; full per-year 2018-2026.
|
||||
|
||||
## Results — the honest picture
|
||||
|
||||
**1) The thesis holds: massive DD reduction, and diversification helps.**
|
||||
| Strategy (50/50 port, tvol20%, LS) | CAGR | Sharpe | maxDD | volA |
|
||||
|---|---|---|---|---|
|
||||
| **B&H 50/50** | +48% | 0.92 | **77.8%** | 70% |
|
||||
| TSMOM 1-3-6m blend | +14.2% | **1.00** | **18.9%** | 14% |
|
||||
| MA-slope | +14.1% | 0.79 | 21.9% | 19% |
|
||||
| Donchian-trailing | +14.7% | 0.89 | 17.7% | 17% |
|
||||
|
||||
Trend cuts maxDD from ~78% to ~18% while keeping a Sharpe **above** buy&hold (1.00 vs 0.92). The
|
||||
portfolio Sharpe (1.00) **beats both sleeves** (BTC 0.95, ETH 0.75) — diversification works as claimed.
|
||||
The **long-flat** variant is even cleaner: Sharpe **1.32**, maxDD **13.3%** (no short funding/borrow risk).
|
||||
|
||||
**2) It is genuinely robust (not a lucky cell).**
|
||||
- *Per-year (headline LS):* every full year **positive** 2019-2025 (+19/+36/+19/+6/+2/+14/+4%) and 2026 +8%.
|
||||
- *Grid:* Sharpe ≈1.00 across **all** target-vol (10-40%) × leverage caps — flat plateau (vol-targeting
|
||||
just scales). DD scales ~linearly with target-vol (10%→DD10%, 40%→DD35%).
|
||||
- *Horizon-set:* every subset (1m/3m/6m/1-3m/3-6m/1-2-4m/2-4-8m) is **positive**; Sharpe 0.37→1.39.
|
||||
Shorter horizons (1m, 1-2-4m) score best (Sharpe 1.34-1.39) — a real plateau, not one combo.
|
||||
- *Fee:* survives to 0.40% RT (Sharpe 1.00→0.39, still positive at 4× baseline fee).
|
||||
|
||||
**3) The honest caveat — most of the edge is the EARLY regime.**
|
||||
Walk-forward split, same param set both assets:
|
||||
- **EARLY 2018-2021:** CAGR +26%, Sharpe **1.63**, DD 18%.
|
||||
- **LATE 2022-2026:** CAGR +7.3%, Sharpe **0.57**, DD 19%.
|
||||
The signal is real and still net-positive every late year, but its quality **halved** post-2021
|
||||
(crypto vol compressed, trends choppier). This is the same warning Track A raised, now quantified: the
|
||||
edge is strongest 2019-2021 and merely *modest* in the 2022-26 regime.
|
||||
|
||||
**4) Leverage is a red herring; target-vol is the real dial — and it costs DD linearly.**
|
||||
At tvol=20% on 60-80% crypto vol, positions stay **sub-1x** (avg gross 0.23×): the leverage cap
|
||||
**never binds**. To deploy real leverage you raise target-vol; Sharpe stays ~1.0, DD scales:
|
||||
|
||||
| target_vol | avg gross | CAGR | Sharpe | maxDD |
|
||||
|---|---|---|---|---|
|
||||
| 20% | 0.23× | +14% | 1.00 | 19% |
|
||||
| 40% | 0.45× | +28% | 1.00 | 35% |
|
||||
| 60% | 0.68× | +40% | 1.00 | 48% |
|
||||
| 80% | 0.90× | +50% | 1.00 | 60% |
|
||||
| 100% | 1.12× | +58% | 0.99 | 69% |
|
||||
|
||||
## Verdict — is this a deployable earning system?
|
||||
|
||||
**Yes as a risk-adjusted system; NO as a fast path to €50/day on €2000.**
|
||||
|
||||
- This is the **first post-reset config that is genuinely robust**: Sharpe ~1.0 (long-flat 1.3),
|
||||
positive every year 2018-2026, robust across grid/horizon/fee, on both assets, on certified data,
|
||||
with honest no-look-ahead accounting. It is a real, deployable trend portfolio and a clear
|
||||
improvement over Track A's lucky single cells. The thesis (DD reduction → leverageable, diversifiable)
|
||||
is **confirmed**.
|
||||
- **But the earnings are modest.** Headline (tvol20%, 2x cap, LS): CAGR **+14.2%**, DD 19% ⇒ steady-state
|
||||
**~€0.73/day on €2000**. To average **€50/day at this CAGR you need ~€137k capital**, not €2000.
|
||||
- **Leverage can't close the gap cheaply.** Pushing target-vol to 80% gives CAGR ~50% (DD **60%**) — and
|
||||
at €2000, 50%/yr is still only ~€2.7/day in steady state. Reaching €50/day in 1-2 years from €2000
|
||||
would require both heavy leverage (DD 60-70%, near-ruin) **and** lucky path — not a sane plan.
|
||||
- **Regime risk:** the edge is much weaker post-2021 (Sharpe 0.57 LATE). Deploy sized for the LATE
|
||||
regime, not the EARLY one.
|
||||
|
||||
**Recommendation:** treat this as the **core risk engine** (compounding ~14%/yr at DD<20%, or
|
||||
long-flat ~16%/yr at DD 13%), deployable now at low size to validate live execution. It grows €2000,
|
||||
but to *€50/day* the lever is **capital + time**, not leverage. Realistic near-term: ~€0.7-1.5/day on
|
||||
€2000; €50/day needs ~€70-140k or a second uncorrelated edge stacked on top.
|
||||
|
||||
## Deliverable
|
||||
`scripts/research/trackD_trendport.py` — self-contained, prints B&H benchmark, broad scan, grid
|
||||
robustness, horizon robustness, walk-forward early/late, fee+leverage sweep, headline config per-year,
|
||||
and the path-to-€50/day table. Reusable building blocks (vol-targeting, target→equity, portfolio).
|
||||
@@ -0,0 +1,140 @@
|
||||
# 2026-06-19 — Track E: Cross-sectional BTC↔ETH relative-value + ENSEMBLE synthesis
|
||||
|
||||
Due parti, entrambe oneste e su dati Deribit-mainnet certificati (solo BTC/ETH). Tool:
|
||||
`scripts/research/trackE_xsec_ensemble.py` (runnable, self-contained, riusa il walk-forward
|
||||
ML di Track B e il Donchian di Track A). Harness onesto: direzione/posizione decise con dati
|
||||
≤ `close[i]`, realizzo sul bar successivo (shift di 1 barra, niente look-ahead). Fee
|
||||
turnover-based: `|Δpos|·fee_rt/2` **per gamba** (un flip +1↔−1 = un round-trip = 0.10% RT).
|
||||
|
||||
Run: `uv run python scripts/research/trackE_xsec_ensemble.py` (`--quick` salta lo sleeve ML;
|
||||
`--no-cache` ricalcola la proba ML). Il proba ML viene cacheato (`.cache_trackE_*.npy`).
|
||||
|
||||
---
|
||||
|
||||
## PART 1 — Relative value (spread BTC↔ETH, 1h, market-neutral)
|
||||
|
||||
**Premessa strutturale.** BTC/ETH log-ret 1h sono correlati **0.84**. Con due soli asset
|
||||
l'unica struttura tradabile è lo **spread**. E con due asset, *"long il più forte / short il
|
||||
più debole"* (XS-momentum) è **algebraicamente identico** a *"trada il trend del ratio
|
||||
ETH/BTC"* — infatti nel codice (A) e (B) producono numeri identici. Sono lo stesso edge.
|
||||
|
||||
**Lead-lag: nullo.** `corr(rB[i], rE[i+1]) = −0.018`, `corr(rE[i], rB[i+1]) = −0.007`,
|
||||
autocorrelazioni −0.01..−0.02. Nessun potere predittivo cross-asset → lead-lag **non**
|
||||
perseguito come sleeve (sarebbe rumore moltiplicato per le fee).
|
||||
|
||||
**(A/B) XS momentum / ratio trend (griglia N∈{24,72,168,336}, hold∈{6,24,72}):**
|
||||
- Solo **4/12 celle** OOS net-positive, e sparse (N24/h24, N24/h72, N72/h72, N168/h24).
|
||||
- Le celle FULL forti (N168/h24: +150% full, Sharpe 0.68, DD 27%) hanno **OOS debole**
|
||||
(+11%, Sh 0.30). La migliore per OOS-Sharpe è N24/h24 (OOS Sh 0.31, OOS net +11%).
|
||||
- **Fee sweep (N24/h24):** gross (0bp) FULL +356%/OOS +74% Sh 1.20 → a 1.0bp/gamba FULL +27%/
|
||||
OOS +11% Sh 0.31 → **muore già a 1.5bp/gamba** (OOS −11%). Margine fee sottilissimo.
|
||||
- **Per-anno** concentrato sui grandi movimenti del ratio 2020-2021 (e 2024), piatto/negativo
|
||||
altrove (2022 −9%, 2023 −19%, 2025 −6%, 2026 −16%). Non è un altopiano: è un edge debole,
|
||||
fee-sensibile, regime-dipendente.
|
||||
|
||||
**(C) Ratio mean-reversion (z-fade di log(ETH/BTC)):** negativa ovunque (es. lb168/zin2.0:
|
||||
FULL −85%, OOS −44%, Sh −1.56). Coerente con Track C: anche sullo spread la MR a breve non è
|
||||
un edge sul dato pulito.
|
||||
|
||||
**Verdetto PART 1:** esiste un **debole** edge di relative-value (XS-momentum ≡ ratio-trend),
|
||||
net-positivo OOS solo in alcune celle, Sharpe OOS ~0.3, che **muore a ~1.5bp/gamba** ed è
|
||||
concentrato in pochi anni. È **reale ma marginale** — degno di entrare in un ensemble come
|
||||
sleeve diversificante, non come strategia standalone. La sua virtù: è **quasi scorrelato**
|
||||
dagli edge direzionali (vedi sotto).
|
||||
|
||||
---
|
||||
|
||||
## PART 2 — Ensemble (3 sleeve residui in UN portafoglio)
|
||||
|
||||
Sleeve combinati (gross 1 ciascuno, equal-weight 1/N → gross totale ~1):
|
||||
- **S1 = BTC-ML** (Track B, cella onesta a basso turnover W16000 H24 thr0.10, 1h).
|
||||
- **S2 = BTC-Trend** (Track A, l'unica cella trend robusta cross-asset: Donchian N=200 H=12).
|
||||
- **S3 = Relative-value** (PART 1, miglior cella OOS: XS-momentum N=24 hold=24).
|
||||
|
||||
**Finestra comune attiva** (dove tutti e 3 sono live, dopo il warmup ML): 2020-06 → 2026-06,
|
||||
52.636 barre.
|
||||
|
||||
### Matrice di correlazione degli sleeve (ret per-barra, finestra comune)
|
||||
|
||||
| | S2_trend | S3_relval | S1_ml |
|
||||
|----------|----------|-----------|--------|
|
||||
| S2_trend | +1.000 | +0.010 | −0.063 |
|
||||
| S3_relval| +0.010 | +1.000 | −0.010 |
|
||||
| S1_ml | −0.063 | −0.010 | +1.000 |
|
||||
|
||||
→ **Sleeve quasi perfettamente scorrelati** (|ρ| ≤ 0.06). In teoria, terreno ideale per la
|
||||
diversificazione.
|
||||
|
||||
### Per-sleeve (finestra comune, scala $ uguale)
|
||||
|
||||
| sleeve | net | Sharpe | maxDD | €/g(2k) |
|
||||
|-----------|-------|--------|-------|---------|
|
||||
| S2_trend | +5% | +0.15 | 34% | +0.04 |
|
||||
| S3_relval | +8% | +0.16 | 41% | +0.07 |
|
||||
| **S1_ml** | +382% | **+0.87** | 56% | +3.51 |
|
||||
|
||||
### Ensemble
|
||||
|
||||
| portafoglio | net | Sharpe | maxDD | CAGR | €/g(2k) |
|
||||
|----------------------|-------|--------|-------|-------|---------|
|
||||
| best single (S1_ml) | +382% | +0.87 | 56% | +30% | +3.51 |
|
||||
| **EQUAL-WEIGHT 1/N** | +109% | **+0.83** | **30%** | +13% | +1.00 |
|
||||
| inverse-vol (IS wts) | +76% | +0.70 | 29% | +10% | +0.69 |
|
||||
| EQ-WEIGHT **OOS**(65/35)| +32% | **+1.02** | **12%** | +14% | +0.83 |
|
||||
|
||||
Per-anno equal-weight: 2020 +16%, 2021 +50%, 2022 +2%, **2023 −13%** (vs −38% dell'ML da
|
||||
solo!), 2024 +18%, 2025 +19%, 2026 −3%. **Molto più liscio**, niente anno-catastrofe.
|
||||
|
||||
### La diversificazione aiuta? Sì sul rischio, NO sul rendimento risk-adjusted
|
||||
|
||||
- **Sharpe:** ensemble 0.83 vs best-single 0.87 → **non batte** il miglior sleeve singolo.
|
||||
- **maxDD:** ensemble **30%** vs best-single 56% → **dimezzato**. E OOS 12% vs ML-solo molto
|
||||
più profondo. Per-anno senza il −38% del 2023.
|
||||
- **Risk-matched** (levare l'ensemble 1.84x per pareggiare il 56% DD dell'ML): €/g +2.23
|
||||
contro €/g +3.51 dell'ML da solo → a pari drawdown l'ensemble rende **MENO** (ratio 0.64).
|
||||
|
||||
**Perché?** Gli sleeve sono scorrelati ma **enormemente diseguali** (Sharpe 0.87 vs 0.15 vs
|
||||
0.16). L'equal-weight 1/N "annacqua" l'unico sleeve forte con due deboli: la matematica
|
||||
della diversificazione alza lo Sharpe solo se gli sleeve sono di *qualità comparabile*. Qui
|
||||
non lo sono, quindi 1/N non può superare il singolo migliore. Pesare verso l'ML (quality-
|
||||
weighting) converge banalmente a "esegui solo l'ML" — e sarebbe in-sample.
|
||||
|
||||
**Il guadagno vero dell'ensemble è la ROBUSTEZZA, non il rendimento:** stesso Sharpe del
|
||||
miglior sleeve a **metà del drawdown**, per-anno molto più stabile, niente dipendenza da un
|
||||
singolo modello/regime (l'ML da solo concentra tutto in 2021/2025 con un −38% nel 2023). Per
|
||||
chi deve *sopravvivere*, l'ensemble è preferibile; per chi massimizza il rendimento a pari
|
||||
rischio, l'ML puro vince di un soffio.
|
||||
|
||||
---
|
||||
|
||||
## Verdetto onesto — è un motore da €50/giorno? NO.
|
||||
|
||||
1. **Relative-value:** edge debole, reale ma marginale (Sharpe OOS ~0.3), fee-sensibile
|
||||
(muore a 1.5bp/gamba), concentrato 2020-2021/2024. Utile **solo** come sleeve scorrelato.
|
||||
Lead-lag e ratio-MR: nulli/negativi.
|
||||
2. **Ensemble:** gli sleeve sono **quasi scorrelati** (|ρ|≤0.06) — risultato genuino e bello.
|
||||
L'ensemble equal-weight ottiene **Sharpe ~0.83 a metà del drawdown** del miglior sleeve e
|
||||
un per-anno molto più liscio. **Ma NON alza il tetto risk-adjusted** (a pari DD rende meno
|
||||
dell'ML puro) perché un solo sleeve domina.
|
||||
3. **Distanza dal target:** ensemble **€1.00/giorno su €2000** (best single €3.51 ma a DD
|
||||
56% e concentrato). Il target è **€50/giorno → ~50x sotto** (l'ML puro ~14x sotto ma con
|
||||
rischio/concentrazione inaccettabili). Levare per colmare il gap moltiplica il drawdown
|
||||
ben oltre il tollerabile (1.84x già porta al 51% DD per ~€2.2/g).
|
||||
|
||||
**Conclusione:** la sintesi di Track E conferma la fotografia dei track A/B/C — esistono
|
||||
**edge residui deboli ma reali e scorrelati** su BTC/ETH. Combinarli in un ensemble **migliora
|
||||
la robustezza** (DD dimezzato, per-anno stabile, niente single-point-of-failure) ma **non crea
|
||||
rendimento dal nulla**: il sistema combinato rende ~€1/giorno su €2000, ~50x sotto l'obiettivo,
|
||||
e non è un motore dispiegabile. Il miglior uso pratico dei risultati: se un giorno si tradasse,
|
||||
l'ensemble equal-weight (ML + trend + relative-value) è la forma **più onesta e meno fragile**
|
||||
del poco edge disponibile — ma serve un edge **di un'altra magnitudine** per avvicinare i €50/g.
|
||||
|
||||
## Prossimi passi possibili (non eseguiti)
|
||||
- Cercare uno sleeve **di qualità comparabile all'ML** (Sharpe ≥0.5 indipendente) — solo
|
||||
allora 1/N alzerebbe lo Sharpe oltre il singolo. Senza, l'ensemble resta solo "risk smoother".
|
||||
- Relative-value su **timeframe diversi** del ratio (giornaliero?) o con **position sizing**
|
||||
proporzionale alla forza del segnale, restando scettici sul fee-margin sottile.
|
||||
- Non aumentare la leva per inseguire €50/g: il DD esplode prima del rendimento.
|
||||
|
||||
## Artefatti
|
||||
- `scripts/research/trackE_xsec_ensemble.py` — riproducibile (`uv run ...`, ~8s con cache ML).
|
||||
@@ -0,0 +1,77 @@
|
||||
# Track F — Calendar seasonality (hour-of-day / day-of-week) on BTC & ETH
|
||||
|
||||
**Data:** 2026-06-19 · **Script:** `scripts/research/trackF_seasonality.py`
|
||||
**Dati:** Deribit mainnet certificati, BTC/ETH 1h UTC. Fee baseline 0.10% RT (`fee_side=0.0005`).
|
||||
|
||||
## Domanda
|
||||
Esiste un edge di calendario *sistematico e tradeable* (ora del giorno, giorno della
|
||||
settimana, interazione ora×giorno) su BTC ed ETH, netto fee, OOS, per-anno, su entrambi gli asset?
|
||||
|
||||
## Metodologia (anti-overfit, anti-leakage)
|
||||
- `ret[i]=close[i]/close[i-1]-1` è noto a `close[i]`; una posizione decisa a `close[i]` guadagna
|
||||
`ret[i+1]`. La statistica che decide il trade usa **solo barre ≤ i** (mai la barra tradata né futuro).
|
||||
- **Tradeable test onesto = ADAPTIVE EXPANDING sign**: a `close[i]` guardo il bucket di calendario
|
||||
della barra `i+1` (il clock è noto, zero look-ahead) e prendo il **segno della media passata** di
|
||||
quel bucket (espandente, warmup-gated). Long-flat o long-short. Fee solo su `|Δposizione|`.
|
||||
È l'analogo onesto di "tradare il seasonal": i dati scelgono il segno di ogni bucket **dal vivo**.
|
||||
- Tabelle descrittive per-ora/per-giorno split IS(65%)/OOS(35%) come diagnostica.
|
||||
- Regola discreta ottimizzata in-sample (entra a ora H, tieni W barre, dir migliore) mostrata solo
|
||||
per **esporre il gap IS→OOS** (384 celle testate/asset).
|
||||
- Benchmark **buy-and-hold** come controllo del long-bias.
|
||||
|
||||
## Risultati
|
||||
|
||||
### 1. Descrittive (bp/barra, IS vs OOS)
|
||||
- **Hour-of-day:** sign-agreement IS/OOS solo **12/24 (BTC)** e **8/24 (ETH)** → caso. Le ore "US
|
||||
close" 21:00–22:00 UTC sono positive in entrambi gli split su entrambi gli asset (l'unico pattern
|
||||
con un minimo di coerenza), ma il resto è rumore che cambia segno tra IS e OOS.
|
||||
- **Day-of-week:** più stabile. **Giovedì negativo** su BTC ed ETH in IS *e* OOS; Lun/Mer positivi.
|
||||
Sign-agreement 6/7 (BTC), 5/7 (ETH).
|
||||
|
||||
### 2. Adaptive expanding-sign (il test tradeable)
|
||||
| Strategia | BTC Sharpe | ETH Sharpe | Note |
|
||||
|---|---|---|---|
|
||||
| HOUR long-short | **−5.39** | **−4.04** | DD 100%. Annientata dalle fee. |
|
||||
| HOUR long-flat | −2.92 | −2.09 | DD 100%. Idem. |
|
||||
| DOW long-short | +0.64 | +0.83 | DD 82–84%, −66% nel 2022 |
|
||||
| DOW long-flat | +0.81 | +0.96 | DD 75–78%, −64/−66% nel 2022 |
|
||||
| HOUR×WEEKDAY (168 buckets) | −5.05 | −3.96 | DD 100%. Overfit puro + fee. |
|
||||
|
||||
### 3. Il controllo che smonta il DOW — **buy-and-hold**
|
||||
- BTC buy-hold: **Sharpe 0.79, CAGR 34.9%, DD 77%** → DOW long-flat: Sh 0.81, CAGR 34.2%, DD 77.5%.
|
||||
- ETH buy-hold: **Sharpe 0.84, CAGR 42.4%, DD 81%** → DOW long-flat: Sh 0.96, CAGR 52.7%, DD 74%.
|
||||
- Il DOW long-flat è **long il 78% del tempo** (`mean_pos≈+0.78`). È **buy-and-hold travestito**:
|
||||
guadagna perché crypto sale, non perché esiste un edge di giorno. Lo "skip del giovedì" aggiunge
|
||||
pochissimo e non giustifica un deploy.
|
||||
|
||||
### 4. Fee sweep (HOUR long-short adaptive)
|
||||
A fee **0%**: Sh +0.61 (BTC) / +0.80 (ETH) — solo long-drift. A 0.10% RT: **−5.4 / −4.0**. Turnover
|
||||
**~8.000 flip/anno** (segno orario instabile, cambia quasi ogni barra) → morte istantanea per fee.
|
||||
Le strategie hour-of-day sono ad alta frequenza per costruzione: le fee sono di prim'ordine e le
|
||||
uccidono.
|
||||
|
||||
### 5. Regola discreta ottimizzata in-sample (trappola multiple-testing)
|
||||
- BTC: best IS H=05 hold=24h dir=+1 → **IS Sh +4.25 → OOS Sh +1.47** (+3.7 bp/trade).
|
||||
- ETH: best IS H=13 hold=24h dir=+1 → **IS Sh +7.35 → OOS Sh +0.90** (+3.2 bp/trade).
|
||||
- Collasso IS→OOS classico. Inoltre "hold 24h dir+1" = ancora **long-bias** (entra una volta/giorno
|
||||
e tiene 24h ≈ sempre long). Il margine OOS (~3 bp/trade su 10 bp RT) è marginale e fragile.
|
||||
|
||||
## Multiple-testing
|
||||
199 celle di calendario/asset (24 ore + 7 giorni + 168 ora×giorno) + 384 (H,W,dir)/asset. Con così
|
||||
tante celle, bucket "significativi" spuri sono **garantiti**. Filtri applicati: segno scelto dal vivo
|
||||
su soli dati passati, deve reggere OOS, per-anno, e su **entrambi** BTC ed ETH.
|
||||
|
||||
## Verdetto — **SPURIO / NON deployable**
|
||||
- **Nessun edge di calendario netto-fee robusto** su BTC ed ETH.
|
||||
- **Hour-of-day:** morto (fee + segno instabile). L'unica regolarità (US-close 21–22 UTC positiva) è
|
||||
troppo debole e non sopravvive al turnover.
|
||||
- **Day-of-week:** l'unico risultato "positivo" è **long-bias mascherato** (≈ buy-and-hold,
|
||||
Sharpe ~0.8–0.96 < trend portfolio 1.32, DD 75–84% rovinoso, −65% nel 2022). Non è un edge
|
||||
seasonal sfruttabile; è esposizione direzionale al drift di crypto.
|
||||
- **Hour×weekday:** overfit puro (IS −3.6 → OOS −8.0).
|
||||
- Coerente con la lezione del progetto: dove l'unica "direzione" che funziona è essere long, non c'è
|
||||
alpha di timing — c'è beta. Il trend portfolio (TP01) cattura quel beta in modo vol-targeted e
|
||||
con DD ~12%, infinitamente meglio di qualunque regola di calendario qui.
|
||||
|
||||
**Azione:** track F chiuso negativo. Non aggiungere nulla al portafoglio. Il soffitto Sharpe ~1.3 su
|
||||
BTC/ETH regge.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Track G — Prior-period level breakouts / range (BTC & ETH, calendar-anchored)
|
||||
|
||||
**Data:** 2026-06-19 · **Script:** `scripts/research/trackG_prior_levels.py`
|
||||
**Harness:** `src/backtest/harness.py` (honest, entry decided at `close[i]`, fill `close[i]`).
|
||||
|
||||
## Domanda
|
||||
|
||||
Esistono edge net-positivi OOS, robusti su BTC **e** ETH, definiti rispetto a un **periodo
|
||||
calendario precedente** (giorno/settimana/opening-range)? E soprattutto: i breakout di livello
|
||||
**continuano** (trend) o **rientrano** (fade)?
|
||||
|
||||
## No look-ahead (garanzie)
|
||||
|
||||
- Livelli prior-day/week costruiti aggregando a barre giornaliere/settimanali (UTC) e poi
|
||||
**`shift(1)`** sul frame del periodo *chiuso*: il periodo corrente vede solo il precedente
|
||||
totalmente chiuso. Mai "oggi"/"questa settimana" nel livello.
|
||||
- Opening-range usato **solo** sulle barre dopo la chiusura della finestra di apertura.
|
||||
- Direzione + prezzo decisi a `close[i]`, fill a `close[i]`. Mai entry sul livello esatto intrabar.
|
||||
- Bug iniziale corretto: mismatch tz-aware vs tz-naive nel mapping dei livelli (dava 0 trade).
|
||||
|
||||
## Risultati (1h, fee 0.10% RT, leva 1x, OOS 65/35)
|
||||
|
||||
### Continuation vs FADE — il verdetto è netto
|
||||
|
||||
| Regola (PD = prior-day) | BTC OOS | ETH OOS | Sharpe OOS |
|
||||
|---|---|---|---|
|
||||
| **PD-high CONT (long su rottura max ieri)** | **+25%** | **+16%** | +0.5 / +0.3 |
|
||||
| PD-high FADE | **−68%** | **−68%** | −1.6 / −1.2 |
|
||||
| PD-low CONT (short su rottura min ieri) | −33% | −60% | −0.5 / −0.8 |
|
||||
| PD-low FADE | −36% | −8% | −0.6 / +0.1 |
|
||||
|
||||
- **I breakout CONTINUANO, non rientrano.** Il lato FADE è robustamente **negativo** su entrambi
|
||||
gli asset (sia high che low), su prior-day, prior-week e opening-range. Conferma diretta della
|
||||
tesi del reset: la mean-reversion / fade è morta su dati certificati.
|
||||
- **Asimmetria long-only:** funziona solo la rottura del **massimo** (long), non quella del
|
||||
**minimo** (short). Cioè non è un edge di breakout *simmetrico/direzione-neutro*: è cattura del
|
||||
**drift/trend rialzista** del cripto. La PD-low-cont (short sui breakdown) perde perché in questo
|
||||
campione il cripto sale.
|
||||
|
||||
### Grid robustness (PASS 6) — survivor = OOS>0 su ENTRAMBI
|
||||
|
||||
- **PD-high CONT: 3/3 celle** (buffer 0/0.1%/0.3%) positive OOS su BTC **e** ETH → robusto al buffer.
|
||||
- PD-high fade, PD-low cont/fade, OR-fade: **0 survivor**.
|
||||
- **OR-cont:** positiva solo su ETH, negativa su BTC su tutte le finestre (3/6/8/12h) → artefatto
|
||||
mono-asset, scartato dalla regola "entrambi".
|
||||
|
||||
### Anchor-hour sweep (PASS 5) — non è un'ora fortunata
|
||||
|
||||
PD-high cont positiva su **21/24** ore UTC (BTC) e **20/24** (ETH). Non dipende da un singolo
|
||||
anchor → coerente con un edge reale (ma vedi sotto: è beta di trend).
|
||||
|
||||
### Fee sweep + per-anno (PD-high cont, full sample)
|
||||
|
||||
```
|
||||
BTC RT%: 0.00→+571 0.05→+289 0.10→+126 0.15→ +31 0.20→ −24 (OOS: +84/+52/+25/+3/−15)
|
||||
ETH RT%: 0.00→+1754 0.05→+1012 0.10→+567 0.15→+299 0.20→+139 (OOS: +67/+39/+16/−3/−19)
|
||||
BTC per-anno: 2019 +39 2020 +104 2021 +7 2022 −42 2023 +24 2024 +27 2025 −16 2026 +3
|
||||
ETH per-anno: 2020 +164 2021 +160 2022 +7 2023 +1 2024 +12 2025 −4 2026 +7
|
||||
Sharpe full: BTC +0.48 (maxDD 55%, €/d 2k +0.88) · ETH +0.86 (maxDD 34%, €/d 2k +4.27)
|
||||
```
|
||||
|
||||
- **Fee-fragile:** alla baseline 0.10% RT sopravvive (OOS +25/+16%), ma muore già a ~0.15-0.20% RT.
|
||||
Margine di fee sottile (≈1.5x baseline e l'edge sparisce su OOS). ~1000-1100 trade in 8 anni.
|
||||
- **Drawdown enormi** (BTC 55%) e anni negativi (2022 −42% BTC, 2025 −16%).
|
||||
|
||||
## Verdetto
|
||||
|
||||
- **Sì, esiste un edge net-positivo OOS su entrambi gli asset:** *PD-high continuation* (long
|
||||
quando `close` supera il massimo di ieri, exit a fine giornata UTC). Robusto al buffer e
|
||||
all'anchor-hour. **MA non è deployabile come miglioramento:**
|
||||
1. È **long-only drift capture**, non un breakout simmetrico (il lato short fallisce) → è una
|
||||
versione **più debole e ridondante** del Trend Portfolio TP01 (Sharpe 0.48-0.86 vs 1.32).
|
||||
2. **Fee-fragile** (muore a ~1.5x la fee baseline) e con **drawdown** molto peggiori.
|
||||
- **Il contributo scientifico vero è la conferma della direzione:** sui dati certificati i
|
||||
breakout di livello-calendario **CONTINUANO**; il fade è morto (negativo robusto su PD/PW/OR,
|
||||
entrambi gli asset). Nessuna sorpresa mean-reversion nascosta nei livelli giornalieri/settimanali.
|
||||
- **Niente di nuovo da mettere in produzione.** TP01 resta la strategia vincente; i breakout
|
||||
prior-period non aggiungono Sharpe (stessa beta di trend, peggio eseguita).
|
||||
|
||||
## Come riprodurre
|
||||
|
||||
```bash
|
||||
uv run python scripts/research/trackG_prior_levels.py # full (1h + 15m, ~25s)
|
||||
uv run python scripts/research/trackG_prior_levels.py --quick # 1h only
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
# Track H — Volume, Range & Volatility-Regime signals (BTC/ETH, certified, >=12h)
|
||||
|
||||
**Date:** 2026-06-19
|
||||
**Script:** `scripts/research/trackH_volume_vol.py` (runnable, self-contained)
|
||||
**Question:** does any volume / range / volatility-regime signal ADD to the deployed winner
|
||||
TP01 (vol-targeted trend portfolio, 12h, Sharpe ~1.32) — i.e. net-positive OOS on BOTH BTC &
|
||||
ETH AND uncorrelated (|corr|<~0.3) — OR work as a regime filter that lifts TP01's Sharpe / cuts
|
||||
its DD?
|
||||
|
||||
## Method (honest)
|
||||
- Same causal per-bar engine as `TrendPortfolio.net_returns`: build a continuous TARGET decided
|
||||
with data `<= close[i]`, HOLD it during bar `i+1` (`pos_held[t]=target[t-1]`), gross = pos×ret,
|
||||
fee on `|Δpos|`. Identical in spirit to `harness.backtest_signals` (decide≤close[i], fill at
|
||||
close[i]); two discrete signals cross-checked through `backtest_signals` directly.
|
||||
- All features (volume z-score, OBV, ranges, realized vol) use prior/rolling windows shifted so
|
||||
bar `i` sees only `<= i`. 12h/1d resampled from certified 1h via `resample_tf` (label='left'),
|
||||
consumed index-based with the +1 hold → no open-label leak.
|
||||
- Fee 0.10% RT baseline + sweep 0.00–0.40% RT. OOS 65/35 + per-year. Grid on BOTH assets.
|
||||
Turnover and correlation-to-TP01 reported for every signal.
|
||||
- **>=12h only** (12h + 1d). Sub-12h excluded per the standing lesson (fees + HF-noise overfit +
|
||||
the 4h open-label look-ahead trap).
|
||||
|
||||
## Signals tested
|
||||
VT-long (volatility-managed long), VolBreakout (volume-z-confirmed Donchian), OBV-trend,
|
||||
VW-mom (volume-weighted momentum), RangeExpand (range-expansion breakout), NR7-break
|
||||
(narrowest-range breakout), DeclVolRev (declining-volume fade/reversal). Plus regime overlays on
|
||||
TP01: keep-low-vol, keep-high-vol, vol-managed ×1.5, OBV-up confirmation.
|
||||
|
||||
## Results (12h headline, fee 0.10% RT)
|
||||
| signal | corr→TP01 | OOS Sharpe BTC/ETH | note |
|
||||
|---|---|---|---|
|
||||
| VT-long | 0.66 / 0.69 | 0.80 / 0.14 | trend-in-disguise; weak OOS ETH |
|
||||
| VolBreakout | 0.69 / 0.71 | 0.54 / 0.49 | profitable but correlated |
|
||||
| OBV-trend | 0.61 / 0.63 | 0.96 / 0.68 | profitable but correlated; turnover ~75/yr |
|
||||
| VW-mom | 0.64 / 0.67 | 0.98 / 0.74 | basically TSMOM; correlated |
|
||||
| RangeExpand | 0.48 / 0.49 | 0.37 / 1.04 | lower corr but BTC weak; ETH negative on 1d |
|
||||
| NR7-break | 0.48 / 0.49 | 0.79 / 0.02 | fails OOS on ETH |
|
||||
| DeclVolRev | -0.15 / -0.11 | -1.15 / -0.44 | **negative even at zero fee** |
|
||||
|
||||
Grid robustness (12h, % cells positive full+OOS on both assets): VW-mom 100%, VT-long 100%,
|
||||
VolBreakout 96%, RangeExpand 96%, OBV-trend 75% — but the robust ones are precisely the ones
|
||||
that are highly correlated to TP01. Fee sweep: trend-family signals survive to 0.40% RT;
|
||||
DeclVolRev gets worse with fees (it trades constantly).
|
||||
|
||||
## Regime filters on TP01 (12h, 50/50 portfolio)
|
||||
| variant | full Sharpe | OOS Sharpe | maxDD | CAGR | turn/y |
|
||||
|---|---|---|---|---|---|
|
||||
| **TP01 baseline** | **1.32** | 0.90 | 13.3% | 16.2% | 11.5 |
|
||||
| × keep LOW-vol | 0.94 | 1.11 | 14.1% | 7.7% | 9.5 |
|
||||
| × keep HIGH-vol | 0.98 | 0.18 | 9.9% | 7.9% | 4.9 |
|
||||
| × vol-managed ×1.5 | 1.33 | 0.96 | 17.9% | 18.1% | 15.4 |
|
||||
| × OBV-up only | 1.49 | 1.04 | 10.1% | 14.4% | 18.2 |
|
||||
|
||||
OBV-up filter across EMA span: full Sharpe 1.49–1.52 (span 15–30), DD 7–10%, but OOS gain is
|
||||
marginal (0.90→1.04 at span 30) and fades for span≥45 (OOS 0.69–0.73). It cuts ~2pp CAGR and
|
||||
raises turnover ~60%.
|
||||
|
||||
## Verdict (honest)
|
||||
- **No uncorrelated additive edge exists.** Every *profitable* volume/range/vol signal is trend
|
||||
in disguise (corr 0.61–0.75 to TP01) → cannot raise the 50/50 portfolio Sharpe. The genuinely
|
||||
lower-corr signals (RangeExpand, NR7 ~0.48) fail OOS on at least one asset.
|
||||
- **Mean-reversion / declining-volume fade is dead** — negative net AND at zero fee on both
|
||||
assets. Reconfirms the v2.0.0 contamination lesson; MR is not a real edge on certified data.
|
||||
- **Vol-regime gating hurts** (keep-low / keep-high both drop Sharpe to ~0.95). The vol-managed
|
||||
overlay is Sharpe-neutral but DD-worse.
|
||||
- **The only non-harmful overlay is OBV-up trend-confirmation:** it cuts DD (13.3%→10.1%) and
|
||||
nudges full Sharpe to ~1.49, but it is trend double-confirmation (de-risking), not new alpha;
|
||||
it costs CAGR, raises turnover, and the OOS Sharpe gain is within noise and span-sensitive. It
|
||||
is worth keeping in mind as a **defensive DD overlay**, not as a Sharpe improver.
|
||||
- **Bottom line:** the ~1.3 portfolio-Sharpe ceiling on BTC/ETH-only **holds**. TP01 stays the
|
||||
deployable winner. Volume/range/vol add nothing uncorrelated.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Track I — Alternative momentum formulations + long-horizon reversal (2026-06-19)
|
||||
|
||||
**Script:** `scripts/research/trackI_momentum_reversal.py` (self-contained, runnable).
|
||||
**Universe:** BTC & ETH only. **TF:** 12h + 1d (sub-12h excluded by rule). **Harness:** identical
|
||||
honest machinery to TP01 — direction decided `<= close[i]`, positions held next bar (`pos_held[1:]
|
||||
= tgt[:-1]`), vol-target by inverse PAST-ONLY realized vol (target 20%, lev cap 2x), NET fee 0.10%
|
||||
RT on turnover, 50/50 BTC+ETH. OOS 65/35 + per-year + fee sweep (0.00–0.40% RT). Correlation to
|
||||
TP01 net returns reported for every candidate.
|
||||
|
||||
## Goal
|
||||
(A) A momentum formulation that BEATS or DIVERSIFIES the canonical 1-3-6m sign-blend (TP01,
|
||||
Sharpe ~1.32). (B) Does the classic LONG-HORIZON REVERSAL (fade ~12m winners) give an
|
||||
uncorrelated positive overlay?
|
||||
|
||||
## PART A — momentum formulations (12h, long-flat, vs TP01 Sharpe 1.32 / OOS 0.90 / DD 13.3%)
|
||||
|
||||
| formulation | Sharpe | IS | **OOS** | CAGR | maxDD | corr→TP01 | BTC | ETH |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| baseline sign-blend 1-3-6m | 1.32 | 1.54 | 0.90 | +16% | 13.3% | 1.00 | 1.15 | 1.10 |
|
||||
| (i) z-score cum-return (tanh) | **1.35** | 1.63 | 0.85 | +12% | **8.4%** | 0.96 | 1.30 | 1.00 |
|
||||
| (ii) risk-adjusted momentum | 1.27 | 1.49 | 0.84 | +13% | 9.5% | 0.97 | 1.21 | 1.00 |
|
||||
| (iii) EMA-cross trend | 0.81 | 0.91 | 0.62 | +11% | 25.1% | 0.85 | 0.89 | 0.53 |
|
||||
| (iii-b) MACD (calendar spans) | **1.50** | **1.87** | 0.74 | +22% | 17.7% | 0.69 | 1.30 | 1.32 |
|
||||
| (iv) Donchian breakout | 1.10 | 1.36 | 0.57 | +17% | 25.0% | 0.86 | 1.08 | 0.82 |
|
||||
| (v) acceleration (Δ-momentum) | 1.28 | 1.82 | 0.35 | +14% | 14.2% | 0.66 | 1.25 | 0.81 |
|
||||
| (vi) 12-1 skip momentum | 0.67 | 0.79 | 0.47 | +9% | 24.5% | 0.68 | 0.70 | 0.49 |
|
||||
|
||||
Results are essentially identical at 1d. Read-out:
|
||||
|
||||
- **Nothing cleanly beats the sign-blend OOS on both assets.** The headline-Sharpe leaders are
|
||||
artefacts of in-sample fit: **MACD** posts IS 1.87 but OOS collapses to 0.74 (gap = overfit) with
|
||||
a worse DD (17.7%); **acceleration** IS 1.82 → OOS **0.35** (worst OOS decay of all). Both fail.
|
||||
- **(i) z-score continuous momentum** is the one mild, honest refinement: Sharpe 1.35 (≈baseline)
|
||||
but **maxDD 8.4% vs 13.3%** — the continuous score scales down position when the cumulative move
|
||||
is statistically small, de-risking the tails. OOS 0.85 (slightly below baseline 0.90), CAGR drops
|
||||
16%→12%. It's a smoother sibling of TP01, **not a new edge** (corr 0.96).
|
||||
- (vi) 12-1 skip (classic equity "12-1" momentum) **does NOT help crypto**: skipping the recent
|
||||
month removes the strongest part of the signal here → Sharpe 0.67, corr 0.68. Crypto momentum
|
||||
lives in the recent window, opposite to the equity stylised fact.
|
||||
- Breakout/Donchian and EMA-cross are strictly worse (high DD, weak OOS).
|
||||
|
||||
## PART B — long-horizon reversal (fade past winners), 12h
|
||||
|
||||
Long-short reversal (short ~12/18/24m winners, long losers, vol-targeted):
|
||||
|
||||
| reversal LS | Sharpe | OOS | CAGR | maxDD | corr→TP01 |
|
||||
|---|---|---|---|---|---|
|
||||
| 12m | -0.77 | -1.15 | -14% | 73% | -0.51 |
|
||||
| 18m | -0.36 | -0.75 | -8% | 58% | -0.47 |
|
||||
| 24m | **+0.04** | -0.07 | -1% | 43% | **-0.32** |
|
||||
| 12-18-24m | -0.46 | -0.72 | -8% | 57% | -0.54 |
|
||||
|
||||
- **Long-horizon reversal is NOT a standalone edge.** Standalone it LOSES money (12m/18m strongly
|
||||
negative; only 24m is ~flat at Sharpe 0.04, OOS −0.07, and even that fails "net-positive OOS on
|
||||
both assets": BTC +0.10 / ETH −0.03). Fading crypto winners over a year just shorts the trend.
|
||||
- It IS genuinely negatively correlated to TP01 (24m: corr −0.32; 12-18-24: −0.54), as expected
|
||||
(it's the opposite sign of medium-term momentum).
|
||||
- **Momentum + reversal blend** (long 1-6m momentum, brake on very-long extension): the variant
|
||||
`mom(1-3-6) − 0.5·rev(12-24)` is the most interesting single-strategy result — Sharpe **1.38**,
|
||||
**OOS 0.98** (> baseline 0.90), **maxDD 10.6%** (< 13.3%), both assets positive (BTC 1.25/ETH
|
||||
1.05), corr 0.91, fee-robust (1.43→1.22 across 0.00–0.40% RT). CAGR drops 16%→12%. It is TP01
|
||||
with a long-term-extension brake: a modest *risk-adjusted* improvement, not more return.
|
||||
|
||||
## COMBINED — TP01 + best diversifier (blend net returns)
|
||||
|
||||
TP01 alone: Sharpe 1.321, CAGR +16%, maxDD 13.3%, OOS 0.90.
|
||||
|
||||
| combo | Sharpe | CAGR | maxDD | OOS | corr |
|
||||
|---|---|---|---|---|---|
|
||||
| TP01 + 20% reversal-24m (LS) | **1.411** | +13% | 11.5% | **1.06** | -0.32 |
|
||||
| TP01 + 30% reversal-24m (LS) | 1.366 | +12% | 11.8% | 1.06 | -0.32 |
|
||||
| TP01 + 20% reversal-12-18-24 (LS) | 1.350 | +11% | 10.6% | 0.84 | -0.54 |
|
||||
| TP01 + 50% z-score | 1.348 | +14% | 9.5% | 0.89 | +0.96 |
|
||||
|
||||
- Adding a small slice of **reversal-24m long-short** lifts portfolio Sharpe 1.32→1.41 and OOS
|
||||
0.90→1.06 while cutting DD to 11.5%. **But be skeptical:** the overlay is a ~zero-mean stream
|
||||
(standalone Sharpe 0.04). The benefit is almost entirely **variance reduction from the negative
|
||||
correlation, not added alpha** — and it COSTS return (CAGR 16%→13%). With a true-zero-edge
|
||||
diversifier this Sharpe bump is fragile (it leans on the −0.32 correlation persisting OOS, and the
|
||||
OOS sample is one 2022-24 crypto cycle). I would NOT deploy capital on a standalone-losing sleeve
|
||||
to chase a 0.09 Sharpe point that is really de-risking.
|
||||
|
||||
## Fee sweep (12h portfolio Sharpe)
|
||||
baseline 1.37→1.18, z-score 1.38→1.24, MACD 1.52→1.45 (lowest turnover), blend 1.43→1.22,
|
||||
reversal-24m 0.07→−0.02 (0.00→0.40% RT). All trend formulations survive realistic fees; reversal
|
||||
has no positive margin to survive on.
|
||||
|
||||
## VERDICT (honest)
|
||||
- **Is there a momentum formulation that beats the 1-3-6m sign-blend? No — not OOS, not on both
|
||||
assets.** MACD/acceleration look better in-sample but decay OOS (overfit + higher DD). The only
|
||||
honest refinement is **continuous z-score momentum**, which matches the Sharpe with materially
|
||||
lower drawdown (8.4% vs 13.3%) — a smoother variant of the SAME edge, not a new one (corr 0.96).
|
||||
- **Does long-horizon reversal give an uncorrelated positive overlay? No, not a real one.** It is
|
||||
uncorrelated/negatively-correlated (good) but **not positive** standalone (it loses, or at best is
|
||||
flat at 24m and fails the both-assets bar). The combined-Sharpe lift (→1.41) is variance reduction
|
||||
from a near-zero-mean stream and sacrifices CAGR — fragile, not bankable alpha.
|
||||
- **The ~1.3 structural Sharpe ceiling on BTC/ETH-only holds.** TP01 remains the deployable winner.
|
||||
If anything, swap the sign-blend for the **z-score continuous score** (or the `mom − 0.5·rev`
|
||||
brake) for a lower-DD profile at equal Sharpe — a risk-management tweak, not a return upgrade.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 2026-06-19 — Strato trend multi-asset sui 52 alt: RIDONDANTE col trend di TP01
|
||||
|
||||
Tentativo: aggiungere un terzo sleeve = TSMOM (stessa logica TP01 CANONICAL, long-flat vol-target)
|
||||
applicato a OGNI alt dei 52 Hyperliquid certificati, equal-weight ragged. Idea: trend più
|
||||
diversificato che diversifichi TP01 (BTC/ETH). `scripts/portfolio/trend_multiasset.py`.
|
||||
|
||||
## Esito: ridondante e peggiore
|
||||
- **TREND-52 standalone**: FULL Sh 0.66, **HOLD-OUT −1.03** (negativo), anni+ 33%. Gli alt sono
|
||||
stati long nel calo 2025-26 e hanno sanguinato — a differenza di TP01 (BTC/ETH) che il
|
||||
vol-target+trend portò in cash. I trend degli alt sono più rumorosi/whippy.
|
||||
- **corr a TP01 = +0.74** (stessa beta direzionale, come previsto) | corr a XS01 −0.05.
|
||||
- **Contributo al portafoglio (TP01 70 + XS 30):** +TREND-52 w20% → FULL −0.01, **HOLD −0.16**;
|
||||
w30% → FULL −0.02, **HOLD −0.27**. PEGGIORA.
|
||||
|
||||
## Lezione
|
||||
Broadenizzare il TREND su molti alt NON diversifica: è la **stessa direzionalità** (corr 0.74 con
|
||||
TP01) su asset più rumorosi → aggiunge perdita/rumore, non edge. La dimensione trend è già catturata
|
||||
in modo pulito da TP01 (BTC/ETH, vol-targeted). L'unica espansione che diversifica davvero resta
|
||||
quella **market-neutral** (XS01 cross-sectional), perché è ortogonale alla beta direzionale.
|
||||
|
||||
## Conclusione (chiusura del filone "espansione universo")
|
||||
Esplorate tutte le vie di espansione sui certificati Hyperliquid:
|
||||
1. XS su 52-all → diluito (memecoin), negativo.
|
||||
2. XS top-liquidità dinamico → peggiore del fisso-19 (liquidità ≠ qualità).
|
||||
3. Trend multi-asset su 52 → ridondante (corr 0.74) + hold-out negativo.
|
||||
Nessuna migliora il portafoglio. **Configurazione validata e invariata: TP01 70% + XS01 (19 major)
|
||||
30% — FULL Sh 1.41 / HOLD 1.15.** I margini reali per crescere NON sono nell'universo crypto-
|
||||
direzionale (saturo), ma in un MECCANISMO diverso (opzioni VRP, in attesa di dati di stress reali).
|
||||
@@ -0,0 +1,35 @@
|
||||
# 2026-06-19 — Affinamento XS01: blend di lookback [30,90]
|
||||
|
||||
Come TP01 fonde gli orizzonti 30/90/180, XS01 ora fonde piu' lookback del momentum cross-sectional
|
||||
(z-score cross-sectional per lookback, mediato) invece del singolo L=30. `scripts/portfolio/xsec_blend.py`.
|
||||
|
||||
## Sweep lookback (19 major, 899g) — FULL/OOS/DD/anni+/corrTP
|
||||
| lookbacks | FULL | OOS25 | DD% | anni+ | corrTP |
|
||||
|---|---|---|---|---|---|
|
||||
| [30] (prima) | 0.80 | 1.20 | 21 | 100% | −0.06 |
|
||||
| [90] | 0.88 | 0.90 | 17 | 100% | −0.05 |
|
||||
| **[30,90]** | **1.10** | **1.03** | **14** | **100%** | **−0.12** |
|
||||
| [20,40,90] | 0.51 | 0.67 | 25 | 100% | −0.12 |
|
||||
| [30,60,120] | 0.68 | 0.74 | 16 | 100% | −0.13 |
|
||||
|
||||
**[30,90] e' il sweet spot**: fonde i DUE singoli robusti (30 e 90), FULL Sh 0.80→1.10, DD 21→14%,
|
||||
corr a TP01 −0.06→−0.12 (diversifica meglio), 100% anni+. Non e' un cell fortunato: e' la
|
||||
combinazione dei due lookback gia' validati (anti-overfit, come il multi-orizzonte di TP01).
|
||||
|
||||
## Effetto sul portafoglio (TP01 70% + XS01 30%)
|
||||
| | XS01 [30] | XS01 blend [30,90] |
|
||||
|---|---|---|
|
||||
| XS01 standalone FULL / DD | 0.80 / 21% | **1.10 / 14%** |
|
||||
| Portafoglio FULL Sharpe | 1.41 | **1.48** |
|
||||
| Portafoglio HOLD-OUT Sharpe | 1.15 | 1.06 |
|
||||
| Portafoglio DD | 5.2% | **4.6%** |
|
||||
| ~€/giorno (2k) | +1.65 | +1.78 |
|
||||
|
||||
Migliora FULL Sharpe + DD + robustezza (due orizzonti) al costo di un hold-out marginalmente piu'
|
||||
basso (−0.09, dentro il rumore di una singola finestra). Giudizio: il blend e' piu' robusto
|
||||
(meno dipendente da un singolo lookback) e diversifica meglio -> PROMOSSO.
|
||||
|
||||
## Azione
|
||||
`src/portfolio/sleeves.XS_CFG`: `L=30` -> `lookbacks=(30,90)`; engine `_xsec_returns` usa lo score
|
||||
blended (media z-score cross-sectional per lookback). **Portafoglio attivo: TP01 70% + XS01 blend
|
||||
30%, FULL Sh 1.48 / HOLD 1.06 / DD 4.6%.** 12 test ok. Sleeve sempre sui 19 major.
|
||||
@@ -0,0 +1,44 @@
|
||||
# 2026-06-19 — Affinamento XS01: gate di dispersione (p30)
|
||||
|
||||
Il momentum cross-sectional vive nella DISPERSIONE (winners/losers distanti). In regime compatto
|
||||
(tutti gli asset insieme) e' rumore. Gate: entra solo se la dispersione cross-section del momentum
|
||||
supera il percentile ESPANDENTE causale `disp_pct`; altrimenti flat. Sul blend [30,90] dei 19 major.
|
||||
`scripts/portfolio/xsec_dispgate.py`. (È il concetto del vecchio XS01 pre-reset, disp_min=p50.)
|
||||
|
||||
## Sweep soglia (19 major, 899g) — XS01 standalone + contributo portafoglio
|
||||
| soglia | XS FULL | XS OOS | PORT FULL | PORT HOLD | %flat |
|
||||
|---|---|---|---|---|---|
|
||||
| no gate | 1.10 | 1.03 | 1.50 | 1.06 | 0% |
|
||||
| p15 | 1.32 | 1.39 | 1.64 | 1.36 | 28% |
|
||||
| p20 | 1.46 | 1.63 | 1.72 | 1.52 | 31% |
|
||||
| p25 | 1.46 | 1.63 | 1.72 | 1.52 | 31% |
|
||||
| **p30** | **1.50** | **1.71** | **1.74** | **1.56** | 35% |
|
||||
| p35 | 1.60 | 1.90 | 1.81 | 1.69 | 37% |
|
||||
| p40-p50 | 1.0 | 0.8 | 1.36-1.38 | 0.77-0.93 | 42-49% |
|
||||
|
||||
**PLATEAU robusto p15-p35** (cinque punti, tutti molto > no-gate); il crollo a p40+ e' OVER-gating
|
||||
(salta troppo). Scelto **p30** (centro sicuro del plateau, lontano dal cliff p40). Non un knife-edge.
|
||||
|
||||
## Effetto sul portafoglio (TP01 70% + XS01 30%)
|
||||
| XS01 | PORT FULL | PORT HOLD | PORT DD |
|
||||
|---|---|---|---|
|
||||
| [30] originale | 1.41 | 1.15 | 5.2% |
|
||||
| + blend [30,90] | 1.48 | 1.06 | 4.6% |
|
||||
| + dispersion gate p30 | **1.55** | **1.55** | **4.4%** |
|
||||
|
||||
XS01 standalone: FULL 1.10→1.50, HOLD 1.03→1.71, DD 14%→10.8%, ~€/g 1.64→2.36. Il gate alza SIA
|
||||
FULL SIA hold-out (a differenza del solo blend, che barattava un po' di hold-out).
|
||||
|
||||
## Meccanismo + caveat onesti
|
||||
- **Causale**: soglia = percentile espandente della dispersione PASSATA; nessun look-ahead.
|
||||
- **Perche' funziona**: tiene XS attivo nei regimi DISPERSI (2025-26, dove gli alt divergono) e flat
|
||||
nei bull compatti (2024). L'hold-out 2025-26 e' ad alta dispersione -> il gate concentra
|
||||
l'attivita' di XS proprio li' -> hold-out forte. E' il comportamento voluto, ma NB che il salto
|
||||
del hold-out riflette anche che il 2025-26 e' stato un regime ad alta dispersione.
|
||||
- Caveat XS01 invariati: storia ~2.5 anni; STAT-MODE (book 19 gambe non eseguibile a 2k).
|
||||
|
||||
## Azione
|
||||
`src/portfolio/sleeves.XS_CFG`: aggiunto `disp_pct=30`; engine `_xsec_returns` gatea su dispersione.
|
||||
**Portafoglio attivo: TP01 70% + XS01 (blend [30,90] + gate disp p30) 30% — FULL Sh 1.55 / HOLD 1.55
|
||||
/ DD 4.4%.** 12 test ok. Affinamenti del SEGNALE (blend + gate) hanno funzionato dove l'espansione
|
||||
universo no: i margini su XS sono nella struttura del segnale, non nel numero di asset.
|
||||
@@ -0,0 +1,62 @@
|
||||
# 2026-06-19 — Espandere l'universo XS01: PIÙ asset DILUISCONO (i 19 major sono il sweet spot)
|
||||
|
||||
Richiesta: aggiungere altri asset Hyperliquid certificati per rafforzare XS01 (cross-sectional
|
||||
momentum). Fatto il lavoro, esito ONESTO: **non rafforza — diluisce.**
|
||||
|
||||
## Cosa ho fatto
|
||||
- Esteso `fetch_hyperliquid.py` a ~54 candidati alt maggiori (mappa Binance auto SYM/USDT, k-prefissi
|
||||
esclusi). **52 certificati** (cross-venue 4-11 bps vs Binance, flat 0%, storia 2024+): aggiunti
|
||||
ATOM DYDX APE CRV LDO STX GMX SNX BCH COMP WLD UNI TRX FIL RUNE ENA ORDI JUP WIF PYTH FET AR ETC
|
||||
ALGO GALA SAND AXS DOT BLUR JTO PENDLE ONDO TAO. Esclusi MKR (delistato HL 2025-09) e FXS
|
||||
(migrazione Frax 2026-01) via nuovo gate "ultima barra recente".
|
||||
|
||||
## Il finding: il cross-section dei 52 è NEGATIVO; i 19 major sono positivi
|
||||
Stessa finestra (2024-04 → 2026-06, 807g), mom L*/H10:
|
||||
|
||||
| Universo | k | FULL Sharpe (L30/L60/L90) |
|
||||
|---|---|---|
|
||||
| **52 asset** | 5 | −0.13 / −0.21 / −0.35 |
|
||||
| **52 asset** | 8-12 | tutti negativi (k grande non aiuta) |
|
||||
| **19 major** | 5 | +0.30 / +0.36 / **+0.67** (OOS 0.91) |
|
||||
|
||||
I ~33 small/new-cap aggiunti (WIF, JUP, ORDI, PYTH, TAO, GALA, AR, BLUR…) sono idiosincratici/
|
||||
mean-reverting: il loro rumore **rovescia** il momentum relativo. Cross-sectional momentum su crypto
|
||||
funziona fra i MAJOR liquidi, non sul long tail. Allargare l'universo NON è gratis.
|
||||
|
||||
## Azione
|
||||
- **XS01 resta sui 19 major** (sweet spot già validato: plateau/fee/subset). Lo sleeve
|
||||
`_xsec_returns` ora usa una **lista esplicita `XS_UNIVERSE` (19)**, non più glob-all (così
|
||||
aggiungere parquet certificati non lo cambia/rompe — avevo inavvertitamente fatto vedere allo
|
||||
sleeve 52 asset = negativo).
|
||||
- I 52 parquet certificati restano su disco: dato valido per ricerca futura (uno strato diverso —
|
||||
es. trend-following multi-asset, o un XS ristretto ai top-liquidità — potrebbe usarli), ma NON XS01.
|
||||
- Portafoglio invariato e ripristinato: **TP01 70% + XS01 30%, FULL Sh 1.41 / HOLD 1.15.**
|
||||
|
||||
## Lezione
|
||||
"Più asset = più robusto" è FALSO per il cross-sectional momentum: il long tail di alt piccoli
|
||||
diluisce/inverte l'edge. La breadth utile è quella dei major liquidi (corr-strutturata), non il
|
||||
numero grezzo.
|
||||
|
||||
## Tentativo 2: UNIVERSO TOP-LIQUIDITÀ DINAMICO (`xsec_dynuniverse.py`) — anch'esso PEGGIORE
|
||||
Provato a selezionare a ogni ribilancio i top-N per dollar-volume 30g (causale) dai 52, poi XS
|
||||
momentum fra quelli (adattivo, ragged-aware). Esito:
|
||||
|
||||
| Universo | FULL Sh | OOS25 | anni+ |
|
||||
|---|---|---|---|
|
||||
| top12 dinamico (L30H10k5) | 0.65 | 0.54 | 67% (2026 −4%) |
|
||||
| top15/20/25 dinamico | 0.14-0.38 | ≤0.30 | 33-67% |
|
||||
| **fisso-19 major (L30H10k5)** | **0.80** | **1.20** | **100%** |
|
||||
| fisso-19 major (L90H10k5) | 0.88 | 0.90 | 100% |
|
||||
Contributo: TP01+DYN 70/30 = FULL 1.10 / HOLD 0.60 vs **TP01+XS19 = FULL 1.25 / HOLD 1.15**.
|
||||
|
||||
**Perché fallisce:** la classifica per dollar-volume ammette comunque i MEMECOIN ad alto volume
|
||||
(WIF, ORDI, JUP, PEPE...) che hanno volumi enormi ma momentum erratico/mean-reverting →
|
||||
diluiscono. **Liquidità ≠ qualità** nelle crypto. I 19 major *curati* (established, corr-strutturati,
|
||||
non solo alto volume) restano il sweet spot.
|
||||
|
||||
## Conclusione
|
||||
Né più nomi (52) né top-liquidità dinamico migliorano XS01. **XS01 resta sui 19 major curati**
|
||||
(FULL 0.80 / OOS 1.20, 100% anni+). Portafoglio invariato: TP01 70% + XS01 30% (FULL 1.41/HOLD 1.15).
|
||||
Per rafforzarlo davvero servirebbe una curatela di QUALITÀ (established majors), che è già ciò che i
|
||||
19 sono. Coerente con la disciplina: nessuna espansione senza che migliori il gauntlet. I 52 parquet
|
||||
certificati restano per ricerca futura (es. trend multi-asset, dove il long tail non diluisce).
|
||||
@@ -0,0 +1,86 @@
|
||||
# 2026-06-20 — Correzione estrazione cerbero MCP: il backfill sintetico (vol=0) ingannava la certificazione
|
||||
|
||||
## Contesto
|
||||
|
||||
Richiesta: "analizza cerbero MCP correggendo l'estrazione dati storici secondo le analisi fatte".
|
||||
Le analisi del progetto avevano già fissato un principio — *"storia nativa Hyperliquid solo dal 2024,
|
||||
pre-2024 = backfill, volume 0"* — e `fetch_hyperliquid.py` lo gestiva con un floor `START=2024-01-01`.
|
||||
**Il floor non basta.**
|
||||
|
||||
## Il difetto
|
||||
|
||||
`fetch_hl` chiedeva a cerbero MCP `get_historical` dal 2024-01-01 e certificava ogni asset con tre
|
||||
gate: **flat-bar** (O==H==L==C), **cross-venue** (mediana |close − Binance| < 60 bps), **recency**.
|
||||
Nessuno guardava il **volume**. Risultato: gli asset listati su HL *dopo* lo START passavano come
|
||||
PULITO pur essendo in gran parte **backfill sintetico**.
|
||||
|
||||
Ispezione del volume sui parquet (leading run di barre a volume 0):
|
||||
|
||||
| asset | barre | leading vol=0 | primo trade reale | % sintetico |
|
||||
|---|---|---|---|---|
|
||||
| **AXS** | 902 | **748** | 2026-01-18 | **82.9%** |
|
||||
| ALGO | 902 | 338 | 2024-12-04 | 37.5% |
|
||||
| SAND | 902 | 338 | 2024-12-04 | 37.5% |
|
||||
| AR | 902 | 58 | 2024-02-28 | 6.4% |
|
||||
| ETC | 902 | 11 | 2024-01-12 | 1.2% |
|
||||
| BTC/ETH + 19 major | 902 | 0 | 2024-01-01 | 0% |
|
||||
|
||||
AXS era **certificato PULITO** (flat 0%, cross-venue 9.5 bps) pur avendo solo ~5 mesi di trading reale.
|
||||
|
||||
## Verifica diretta su cerbero MCP (token mainnet)
|
||||
|
||||
Interrogato l'endpoint `cerbero-mcp.tielogic.xyz/mcp/tools/get_historical` (bot-tag
|
||||
`pythagoras-mainnet`):
|
||||
|
||||
- **BTC**: 902 barre, leading vol=0 = 0, volume reale dal 2024-01-01 (V=699, 2437, 5306…). Nativo. ✓
|
||||
- **AXS**: 902 barre, **748 leading vol=0**, primo vol>0 = 2026-01-18. Le barre a volume 0 hanno
|
||||
prezzi (O/H/L/C) che **coincidono con Binance**:
|
||||
|
||||
| data | cerbero close | binance close | Δ |
|
||||
|---|---|---|---|
|
||||
| 2024-01-01 | 9.262 | 9.26 | 2.2 bps |
|
||||
| 2024-01-02 | 8.949 | 8.94 | 10.1 bps |
|
||||
| 2024-01-03 | 7.937 | 7.95 | 16.4 bps |
|
||||
|
||||
**Diagnosi provata:** cerbero MCP riempie il periodo pre-quotazione con barre **sintetiche — volume 0,
|
||||
prezzi copiati da un venue di riferimento (Binance)**. Per questo i vecchi gate venivano ingannati:
|
||||
- cross-venue passa → i prezzi *sono* Binance (Δ 1–16 bps);
|
||||
- flat passa → le barre non sono flat (hanno movimento di prezzo);
|
||||
- ma **volume 0** → su HL quelle candele **non erano negoziabili**. È esattamente il caso v2.0.0
|
||||
(edge su un book che non c'era).
|
||||
|
||||
## Correzione (`scripts/analysis/fetch_hyperliquid.py`)
|
||||
|
||||
1. **Il VOLUME è il rivelatore del backfill** → `trim_backfill()` taglia il run iniziale di barre a
|
||||
volume 0; si tiene solo la **serie nativa**.
|
||||
2. **Gate storia nativa** `MIN_NATIVE_DAYS=365`: dopo il taglio serve ≥ 1 anno di vita reale →
|
||||
scarta chi è troppo corto (AXS, 154 barre reali → fuori).
|
||||
3. **Gate vol=0 interno** `INTERIOR_VOL0_MAX=5%`: gap di liquidità oltre il taglio iniziale.
|
||||
4. **cross-venue/flat ricalcolati SOLO sulle barre reali** (non più sui sintetici).
|
||||
5. **I parquet degli asset scartati vengono rimossi** (disco == set certificato; niente file
|
||||
contaminati a riposo).
|
||||
|
||||
## Risultato
|
||||
|
||||
- Universo certificato: **52 → 51** (AXS scartato).
|
||||
- ALGO/SAND (−338 barre), AR (−58), ETC (−11) ripuliti dal backfill → ora start reale corretto.
|
||||
- **I 19 major di XS01 hanno 0 backfill → invariati**: la strategia live (`XS_UNIVERSE` esplicito) NON
|
||||
è toccata. Verificato: portafoglio 3-way (TP01+XS01+VRP01) gira identico, FULL Sh 1.68 / HOLD 1.67.
|
||||
- Re-fetch end-to-end su cerbero reale: 51 PULITO, sweep su tutti i file → 0 backfill residuo.
|
||||
|
||||
## Nota su una conclusione precedente
|
||||
|
||||
Il diario `2026-06-19-xsec-universe-expansion.md` concludeva "cross-section dei 52 = negativo". Quella
|
||||
finestra includeva i sintetici (AXS 83%, ALGO/SAND 37% di barre vol=0 con ritorni non eseguibili): la
|
||||
magnitudine del risultato era **in parte un artefatto**. La conclusione qualitativa (il long-tail
|
||||
diluisce XS01; i 19 major sono il sweet spot) resta valida, ma il numero netto è 51 e il test andrebbe
|
||||
ri-girato sui dati puliti se si volesse riusare quell'universo.
|
||||
|
||||
## Lezione
|
||||
|
||||
`flat` + cross-venue **non bastano** a certificare un feed che fa backfill copiando un altro venue: il
|
||||
backfill è plausibile sui prezzi proprio perché è copiato. Il **volume** (=liquidità reale) è il gate
|
||||
che mancava. Coerente con la regola di prim'ordine v2.0.0: certificare il dato — anche il *volume*,
|
||||
non solo il prezzo — prima della strategia.
|
||||
|
||||
File: `scripts/analysis/fetch_hyperliquid.py`. Universo: `data/raw/hl_*_1d.parquet` (51, serie native).
|
||||
@@ -0,0 +1,93 @@
|
||||
# 2026-06-20 — Analisi strategie FinanceOld + VRP v2 (defined-risk + gate IV-rank)
|
||||
|
||||
## Contesto
|
||||
|
||||
Richiesta: analizzare le strategie in `../FinanceOld`, provare a migliorarle, testarle su dati storici.
|
||||
Quattro progetti esaminati. Verdetto di **backtestabilità onesta** sui dati certificati (BTC/ETH
|
||||
Deribit mainnet + DVOL):
|
||||
|
||||
| Progetto | Strategia | Backtestabile sui dati certi? |
|
||||
|---|---|---|
|
||||
| **FundingRateArbitrage** | Spread funding cross-exchange (perp-perp, spot-hedge) | ❌ Nessun dato funding storico nel repo (solo `exchange_settings.json`). Edge = differenza cross-venue, non ricostruibile. |
|
||||
| **Polybot** | Latency-arb Polymarket (BS digital-option) + sure-bet delta-neutral | ❌ `dataVPS/collector.db` (645MB) ha solo **~3 giorni** di `poly_books`+`funding`, e la tabella `ticks` (prezzi perp = cuore dell'edge) è **corrotta** ("database disk image is malformed"). L'edge è la latenza: non riproducibile su barre OHLC comunque. |
|
||||
| **OptionSpalping** (→Cerbero) | LLM autonomo su opzioni Deribit + perp Hyperliquid | ⚠️ È un agente LLM, non una regola meccanica. Il *concetto* (income short-vol su Deribit) è testabile. |
|
||||
| **OptionsAgent** | **Bear Call Spread + Long VIX hedge** su IWM, con 5 gate d'ingresso | ✅ Il *concetto* (vendi premio rischio-definito, incassa VRP, gate su IV-rank/regime) mappa direttamente sul nostro `options_vrp_lab.py`. |
|
||||
|
||||
→ Scelta operatore: **focus VRP opzioni**. L'unico filone con dati veri + metodologia onesta.
|
||||
|
||||
## Baseline (options_vrp_lab.py, ora con fee)
|
||||
|
||||
Vendita put NUDA settimanale delta -0.28, premio BS su DVOL reale. f = premio_reale/modellato.
|
||||
|
||||
- `f=1.0` (conservativo): **FULL Sh 0.78, DD 33%, worst-week -16.6%, HOLD-OUT Sh -0.25** → muore OOS.
|
||||
- Il rischio è la **CODA**: worst-week su LUNA (2022-06), crash 2021-05. Anno 2022 = -9%.
|
||||
|
||||
## VRP v2 — 3 idee di OptionsAgent portate nel framework
|
||||
|
||||
Nuovo script `scripts/research/options_vrp_v2.py`. Tutto **causale** (strike/premio/gate da dati
|
||||
≤ sell-date; payoff a scadenza sui prezzi certificati). Fee opzioni Deribit modellate (12.5% del
|
||||
premio netto per round-trip = cap del fee reale). Capitale = strike corto (cash-secured) per
|
||||
entrambe le strutture → DD/worst comparabili.
|
||||
|
||||
1. **Rischio definito (PUT CREDIT SPREAD)** — vendi put -0.28, COMPRI put -0.10. Il long wing
|
||||
**cappa la coda per costruzione**: worst-week -16.6% → **-7.4%**, DD 33% → 21%, Sh 0.78 → 0.99.
|
||||
2. **Gate IV-RANK > 0.30** (cond. d'ingresso di OptionsAgent) — vendi vol solo quando ricca
|
||||
(percentile espandente causale di DVOL). Trada il **58%** delle settimane → **Sh 1.35** e
|
||||
ribalta **HOLD-OUT da -0.25 a +0.28**. È l'alpha vero: il filtro di regime, non la struttura.
|
||||
3. **Crash-skip IV-rank > 0.90** (NO-GO, come "VIX>35" di OptionsAgent) — marginale da solo.
|
||||
4. **Gate VRP>0** (DVOL>RV30 causale) — marginale (il VRP è >0 il 78% del tempo, poco selettivo).
|
||||
|
||||
### Risultati chiave (book 50/50 BTC+ETH, f=1.0 conservativo)
|
||||
|
||||
| Config | FULL Sh | DD | worst-wk | HOLD-OUT Sh | attivo |
|
||||
|---|---|---|---|---|---|
|
||||
| naked (baseline) | 0.78 | 33% | -16.6% | **-0.25** | 100% |
|
||||
| spread | 0.99 | 21% | -7.4% | -0.26 | 100% |
|
||||
| spread + ivr30 | **1.35** | 14% | -7.4% | **+0.28** | 58% |
|
||||
| **COMBO** (spread+vrp+ivr30+crashskip) | 1.10 | 12% | -7.4% | **+0.60** | 41% |
|
||||
|
||||
COMBO f=1.0 per-anno: 2021 +26%, 2022 **-6%**, 2023 +2%, 2024 +18%, 2025 -0%, 2026 +5%
|
||||
(il 2022, anno-crash che dimezzava il nudo, è quasi piatto: la coda è tagliata).
|
||||
|
||||
A `f=1.29` (skew reale misurato in regime calmo) la COMBO fa FULL Sh 1.87 / HOLD 1.45 / DD 9%.
|
||||
|
||||
### Contributo al portafoglio (COMBO f=1.0 vs TP01)
|
||||
|
||||
- Corr settimanale **+0.07** (scorrelato, come il VRP nudo).
|
||||
- TP01 70% + OPT 30% → Sh **1.00** (TP01 solo 0.73), DD **7%**.
|
||||
- TP01 50% + OPT 50% → Sh **1.19**, DD 7%.
|
||||
|
||||
## Conclusione onesta
|
||||
|
||||
Le idee di OptionsAgent **migliorano davvero** lo sleeve VRP, in modo OOS-robusto:
|
||||
- la **struttura defined-risk** taglia la coda (worst -16.6%→-7.4%, DD -19pt) → meno dipendenza dal
|
||||
f di stress, che era il rischio non catturato del lead nudo;
|
||||
- il **gate IV-rank** è l'alpha: ribalta l'HOLD-OUT da negativo a positivo vendendo solo vol ricca.
|
||||
|
||||
Resta un **lead, non un deploy**: premio MODELLATO su DVOL ATM (skew non esplicito), book a 1d, e
|
||||
serve la catena reale (cerbero-bite) per il f di stress in un crash. Ma è un miglioramento netto,
|
||||
quantificato e onesto, del miglior lead income che avevamo. Prossimo passo: rivalutare il f di stress
|
||||
quando cerbero-bite cattura un crash, e validare lo skew reale sul long wing (-0.10).
|
||||
|
||||
Script: `scripts/research/options_vrp_v2.py`. Baseline: `scripts/research/options_vrp_lab.py`.
|
||||
|
||||
## Integrazione come sleeve (VRP01)
|
||||
|
||||
La COMBO è stata integrata nel portafoglio come **VRP01** (`src/portfolio/sleeves._vrp_combo_returns`,
|
||||
`vrp_sleeve()`). Implementazione self-contained in `src/` (niente import da `scripts/`): pricing BS +
|
||||
strike-from-delta + gate causali inline, DVOL da `data/raw/dvol_*.parquet`.
|
||||
|
||||
**Settimanale → giornaliero (onesto):** il rendimento settimanale è piazzato sul **giorno di
|
||||
scadenza**, 0.0 sugli altri giorni dello span. Questo PRESERVA lo Sharpe annualizzato (niente
|
||||
smoothing che gonfierebbe il daily Sharpe) e tiene lo sleeve presente ogni giorno → peso costante
|
||||
nell'outer-join del portafoglio. Verificato: lo sleeve daily replica i numeri settimanali
|
||||
(FULL Sh 1.09, HOLD 0.60, DD 12%), corr daily vs TP01 = +0.01.
|
||||
|
||||
**Pesi (per evidenza, engine reale):** TP01+VRP01 monotòno fino al 40% VRP (FULL 1.30→1.55,
|
||||
HOLD 0.31→0.52, DD fermo 14%). Essendo VRP un lead MODELLATO (non deploy pieno), non lo sovrappeso:
|
||||
registry = **TP01 0.55 / XS01 0.25 / VRP01 0.20** (TP01 resta maggioranza, l'unico deployable pieno).
|
||||
La validazione 3-way completa richiede i dati Hyperliquid (XS01, gitignored, token Cerbero) → gira
|
||||
locale con `scripts/portfolio/run_portfolio.py`.
|
||||
|
||||
Test: `tests/test_vrp_sleeve.py` (5 pass: monotonìa BS, ordering strike, determinismo+griglia
|
||||
giornaliera, gate riducono l'attività, coda tagliata <-15%).
|
||||
@@ -0,0 +1,109 @@
|
||||
"""EVALUATOR STANDARD per i segnali della ricerca multi-agente (Fase frattale, v2.0.0).
|
||||
|
||||
Ogni agente scrive SOLO una funzione `signal(df, asset, tf) -> np.ndarray` (posizione per barra
|
||||
in [-1,1], decisa entro close[i]) in un file. Questo evaluator la valuta in modo UNIFORME e ONESTO
|
||||
sull'harness research_lab, e — cruciale — esegue un GUARD ANTI-LOOK-AHEAD automatico: ricalcola il
|
||||
segnale su prefissi del df e verifica che pos[i] non dipenda da barre future (leak>0 = sospetto).
|
||||
|
||||
uv run python scripts/analysis/eval_signal.py <signal_file.py> <BTC|ETH> <5m|15m|1h> [--holdout]
|
||||
|
||||
Stampa una riga "RESULT_JSON:{...}" con tutte le metriche (gli agenti riportano quei campi esatti).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import json
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scripts.analysis.research_lab import backtest, buy_hold, mc_pvalue, load_tf, ts, _net_series, VAL_START, HOLDOUT_START
|
||||
|
||||
|
||||
def load_signal(path):
|
||||
spec = importlib.util.spec_from_file_location("usig", path)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
if not hasattr(m, "signal"):
|
||||
raise AttributeError("il file non definisce signal(df, asset, tf)")
|
||||
return m.signal
|
||||
|
||||
|
||||
def causality_guard(signal, df, asset, tf, k=12):
|
||||
"""Ricalcola il segnale su prefissi df[:i+1] e confronta pos[i] col run completo.
|
||||
Se differiscono -> il segnale usa dati FUTURI (look-ahead). Ritorna #violazioni (0 = pulito)."""
|
||||
full = np.asarray(signal(df, asset, tf), float)
|
||||
n = len(df)
|
||||
if len(full) != n:
|
||||
return -1
|
||||
rng = np.random.default_rng(0)
|
||||
idx = rng.integers(int(n * 0.6), n - 1, size=k)
|
||||
bad = 0
|
||||
for i in idx:
|
||||
try:
|
||||
p = np.asarray(signal(df.iloc[:i + 1].copy(), asset, tf), float)
|
||||
except Exception:
|
||||
bad += 1; continue
|
||||
if len(p) != i + 1 or not np.isclose(np.nan_to_num(p[i]), np.nan_to_num(full[i]), atol=1e-6):
|
||||
bad += 1
|
||||
return bad
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
holdout = "--holdout" in args
|
||||
args = [a for a in args if a != "--holdout"]
|
||||
sigfile, asset, tf = args[0], args[1].upper(), args[2]
|
||||
res = {"asset": asset, "tf": tf, "sigfile": sigfile}
|
||||
try:
|
||||
signal = load_signal(sigfile)
|
||||
df = load_tf(asset, tf)
|
||||
pos = np.asarray(signal(df, asset, tf), float)
|
||||
res["n"] = int(len(df))
|
||||
res["len_ok"] = bool(len(pos) == len(df))
|
||||
if not res["len_ok"]:
|
||||
res["error"] = f"len(pos)={len(pos)} != len(df)={len(df)}"
|
||||
print("RESULT_JSON:" + json.dumps(res)); return
|
||||
res["finite"] = bool(np.isfinite(np.nan_to_num(pos, nan=0.0)).all())
|
||||
res["leak"] = int(causality_guard(signal, df, asset, tf))
|
||||
full = backtest(df, pos, tf)
|
||||
oos = backtest(df, pos, tf, lo=VAL_START, hi=HOLDOUT_START)
|
||||
bh = buy_hold(df, tf)
|
||||
_, p, _, _ = mc_pvalue(df, pos, tf, n=250)
|
||||
res.update(
|
||||
implemented=True,
|
||||
full_sharpe=round(full.sharpe, 3), full_ret=round(full.ret, 3), full_dd=round(full.maxdd, 3),
|
||||
oos_sharpe=round(oos.sharpe, 3), bh_sharpe=round(bh.sharpe, 3),
|
||||
gross_sharpe=round(backtest(df, pos, tf, fee_rt=0.0).sharpe, 3),
|
||||
fee02_sharpe=round(backtest(df, pos, tf, fee_rt=0.002).sharpe, 3),
|
||||
turnover=round(full.ntrades, 1), exposure=round(full.exposure, 3),
|
||||
null_p=round(p, 4),
|
||||
beats_bh=bool(full.sharpe > bh.sharpe and oos.sharpe > 0),
|
||||
)
|
||||
# breadth per-anno (pre-hold-out): % anni positivi, anni rossi consecutivi
|
||||
net, _, _, _ = _net_series(df, pos)
|
||||
s = pd.Series(net, index=ts(df))
|
||||
s = s[s.index < pd.Timestamp(HOLDOUT_START, tz="UTC")]
|
||||
yr = {int(y): float((1 + g).prod() - 1) for y, g in s.groupby(s.index.year)}
|
||||
vals = list(yr.values())
|
||||
max_consec_red = 0; cur = 0
|
||||
for v in vals:
|
||||
cur = cur + 1 if v < 0 else 0
|
||||
max_consec_red = max(max_consec_red, cur)
|
||||
res["per_year_preho"] = {y: round(v, 3) for y, v in yr.items()}
|
||||
res["pct_years_pos"] = round(sum(v > 0 for v in vals) / len(vals), 2) if vals else 0.0
|
||||
res["max_consec_red_years"] = int(max_consec_red)
|
||||
if holdout:
|
||||
ho = backtest(df, pos, tf, lo=HOLDOUT_START)
|
||||
res["holdout_sharpe"] = round(ho.sharpe, 3)
|
||||
res["holdout_ret"] = round(ho.ret, 3)
|
||||
res["holdout_dd"] = round(ho.maxdd, 3)
|
||||
except Exception as e:
|
||||
res["implemented"] = False
|
||||
res["error"] = f"{type(e).__name__}: {str(e)[:200]}"
|
||||
print("RESULT_JSON:" + json.dumps(res))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""FETCH + CERTIFY universo Hyperliquid (Cerbero MCP MAINNET) — espansione cross-sectional.
|
||||
|
||||
Hyperliquid (via cerbero-mcp mainnet) offre ~230 perp liquidi, ma storia nativa REALE solo dal
|
||||
2024 (pre-2024 = backfill, volume 0). Qui scarico un set liquido a 1d (2024+), e CERTIFICO ogni
|
||||
asset come BTC/ETH: cross-venue vs Binance (realismo) + flat-bar + VOLUME (liquidita'). Scrivo SOLO
|
||||
i puliti in data/raw/hl_<sym>_1d.parquet (namespace dedicato, NON mischiato col Deribit BTC/ETH).
|
||||
|
||||
Disciplina: Cerbero ci ha gia' bruciato (testnet) -> niente fiducia, solo certificazione.
|
||||
|
||||
CORREZIONE estrazione (2026-06-20, "analisi fatte"): il floor START=2024-01-01 NON basta. Cerbero
|
||||
restituisce BACKFILL SINTETICO (volume==0, ma prezzi copiati da un venue di riferimento -> matchano
|
||||
Binance e NON sono flat) per il periodo PRIMA che l'asset quotasse davvero su Hyperliquid. Cosi'
|
||||
asset listati a meta'/fine 2024+ passavano cross-venue+flat ed erano certificati PULITO pur essendo
|
||||
in gran parte sintetici (es. AXS 83% backfill: trading reale solo da 2026-01; ALGO/SAND 37%). E' lo
|
||||
stesso errore v2.0.0 (edge su un book che non c'era). Fix: (1) il VOLUME e' il rivelatore di backfill
|
||||
-> si TAGLIA il run iniziale di barre a volume 0 e si tiene solo la serie NATIVA; (2) gate su storia
|
||||
nativa minima (>= MIN_NATIVE_DAYS reali) -> scarta chi e' troppo corto dopo il taglio; (3) gate su
|
||||
volume-0 INTERNO (gap di liquidita') oltre il taglio iniziale; (4) cross-venue/flat ricalcolati SOLO
|
||||
sulle barre reali; (5) i parquet degli asset scartati vengono RIMOSSI (disco == set certificato).
|
||||
|
||||
uv run python scripts/analysis/fetch_hyperliquid.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, time
|
||||
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, requests, ccxt
|
||||
|
||||
RAW = PROJECT_ROOT / "data" / "raw"
|
||||
START = "2024-01-01"; END = pd.Timestamp.now("UTC").strftime("%Y-%m-%d") # dinamico (refresh giornaliero)
|
||||
MIN_NATIVE_DAYS = 365 # storia NATIVA reale minima (post-taglio backfill) per entrare nell'universo
|
||||
INTERIOR_VOL0_MAX = 5.0 # % max di barre a volume 0 DOPO il taglio iniziale (gap di liquidita' interni)
|
||||
# UNIVERSO ESTESO: alt liquidi noti su Hyperliquid (mappa Binance auto = SYM/USDT). Il gate di
|
||||
# certificazione (cross-venue + liquidita' + flat) scarta i non-conformi. k-prefissi esclusi
|
||||
# (scaling 1000x complica il cross-venue). MATIC morto escluso.
|
||||
SYMS = ["BTC","ETH","SOL","BNB","XRP","DOGE","AVAX","LINK","LTC","ADA","ARB","OP","SUI","APT",
|
||||
"INJ","TIA","SEI","NEAR","AAVE","ATOM","DYDX","APE","CRV","LDO","STX","GMX","SNX","BCH",
|
||||
"COMP","MKR","WLD","UNI","TRX","FIL","RUNE","ENA","ORDI","JUP","WIF","PYTH","FET","AR",
|
||||
"ETC","ALGO","GALA","SAND","AXS","DOT","FXS","BLUR","JTO","PENDLE","ONDO","TAO"]
|
||||
BINANCE = {s: f"{s}/USDT" for s in SYMS}
|
||||
|
||||
|
||||
def _h():
|
||||
env={}
|
||||
for ln in open(PROJECT_ROOT/".env.mainnet"):
|
||||
ln=ln.strip()
|
||||
if ln and not ln.startswith("#") and "=" in ln: k,v=ln.split("=",1); env[k]=v.strip()
|
||||
return {"Authorization":f"Bearer {env['CERBERO_TOKEN']}","X-Bot-Tag":env.get('CERBERO_BOT_TAG','fetch'),"Content-Type":"application/json"}
|
||||
|
||||
|
||||
def fetch_hl(sym, H, interval="1d"):
|
||||
r=requests.post("https://cerbero-mcp.tielogic.xyz/mcp/tools/get_historical",
|
||||
headers=H, json={"exchange":"hyperliquid","instrument":sym,"interval":interval,
|
||||
"start_date":START,"end_date":END}, timeout=60)
|
||||
c=r.json().get("candles",[])
|
||||
if not c: return pd.DataFrame()
|
||||
df=pd.DataFrame(c)[["timestamp","open","high","low","close","volume"]]
|
||||
return df.drop_duplicates("timestamp").sort_values("timestamp").reset_index(drop=True)
|
||||
|
||||
|
||||
def binance_daily(sym_b, start_ms, end_ms):
|
||||
ex=ccxt.binance({"enableRateLimit":True})
|
||||
out={}; since=start_ms
|
||||
while since<=end_ms:
|
||||
try: r=ex.fetch_ohlcv(sym_b,"1d",since=since,limit=500)
|
||||
except Exception: break
|
||||
r=[x for x in r if x[0]>=since]
|
||||
if not r: break
|
||||
for x in r:
|
||||
if start_ms<=x[0]<=end_ms and x[4]: out[int(x[0])]=float(x[4])
|
||||
nxt=int(r[-1][0])+86400000
|
||||
if nxt<=since: break
|
||||
since=nxt
|
||||
return pd.Series(out)
|
||||
|
||||
|
||||
def trim_backfill(df):
|
||||
"""Taglia il run INIZIALE di barre a volume 0 (= backfill sintetico pre-quotazione su HL).
|
||||
Ritorna (serie_nativa, n_barre_tagliate). Il volume e' il rivelatore: il backfill copia i
|
||||
prezzi da un venue di riferimento (non flat, matcha Binance) ma ha volume 0."""
|
||||
vol = df["volume"].to_numpy()
|
||||
lead = int(np.argmax(vol > 0)) if (vol > 0).any() else len(df)
|
||||
return df.iloc[lead:].reset_index(drop=True), lead
|
||||
|
||||
|
||||
def main():
|
||||
H=_h()
|
||||
print("="*100); print(" FETCH + CERTIFY Hyperliquid 1d (Cerbero mainnet) — cross-venue + flat + VOLUME (no backfill)"); print("="*100)
|
||||
print(f" {'sym':<6}{'reali':>6}{'bfill':>6}{'start_reale':>13}{'flat%':>7}{'vol0%':>7}{'med_bps':>9}{'>1%':>7}{'verdetto':>14}")
|
||||
certified=[]
|
||||
for s in SYMS:
|
||||
path = RAW/f"hl_{s.lower()}_1d.parquet"
|
||||
raw=fetch_hl(s,H)
|
||||
if raw.empty:
|
||||
print(f" {s:<6} vuoto"); path.unlink(missing_ok=True); continue
|
||||
# --- CORREZIONE: taglia il backfill sintetico (volume 0 iniziale), tieni la serie nativa ---
|
||||
df, n_bfill = trim_backfill(raw)
|
||||
if df.empty:
|
||||
print(f" {s:<6} tutto backfill (vol0) -> scarta"); path.unlink(missing_ok=True); continue
|
||||
ts=pd.to_datetime(df["timestamp"],unit="ms",utc=True)
|
||||
flat=((df.open==df.high)&(df.high==df.low)&(df.low==df.close)).mean()*100
|
||||
vol0=(df["volume"].to_numpy()==0).mean()*100 # gap di liquidita' INTERNI (post-taglio)
|
||||
# cross-venue vs Binance USDT (daily close) — SOLO sulle barre reali
|
||||
ref=binance_daily(BINANCE[s], int(df["timestamp"].iloc[0]), int(df["timestamp"].iloc[-1]))
|
||||
a=df.set_index("timestamp")["close"]
|
||||
m=pd.concat([a.rename("a"),ref.rename("b")],axis=1,join="inner").dropna()
|
||||
if len(m)>5:
|
||||
bps=(m["a"]-m["b"]).abs()/m["b"]*1e4
|
||||
med=bps.median(); g1=(bps>100).mean()*100
|
||||
else: med=g1=float("nan")
|
||||
# gate "delistato/migrato": l'ultima barra dev'essere recente (entro ~21g da END),
|
||||
# altrimenti l'asset tronca l'universo cross-sectional (es. MKR fermo a 2025-09, FXS 2026-01).
|
||||
recent = (pd.Timestamp(END, tz="UTC") - ts.iloc[-1]) <= pd.Timedelta("21D")
|
||||
# gate storia NATIVA: dopo il taglio dev'esserci abbastanza vita reale (es. AXS quotato 2026-01 -> scarta)
|
||||
native_days = (ts.iloc[-1] - ts.iloc[0]).days
|
||||
enough = native_days >= MIN_NATIVE_DAYS
|
||||
clean = (not np.isnan(med)) and med<60 and g1<3 and flat<5 and vol0<INTERIOR_VOL0_MAX and recent and enough
|
||||
if clean: v="PULITO"
|
||||
elif not enough: v=f"corto<{MIN_NATIVE_DAYS}g"
|
||||
else: v="scarta"
|
||||
print(f" {s:<6}{len(df):>6}{n_bfill:>6}{str(ts.iloc[0].date()):>13}{flat:>6.1f}%{vol0:>6.1f}%{med:>9.1f}{g1:>6.1f}%{v:>14}")
|
||||
if clean:
|
||||
df.to_parquet(path, index=False); certified.append(s)
|
||||
else:
|
||||
path.unlink(missing_ok=True) # disco == set certificato (niente parquet contaminati a riposo)
|
||||
print(f"\n CERTIFICATI ({len(certified)}): {certified}")
|
||||
print(" Scritti in data/raw/hl_<sym>_1d.parquet (namespace dedicato, SERIE NATIVA senza backfill).")
|
||||
|
||||
|
||||
if __name__=="__main__":
|
||||
main()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""ANALISI OPTIONS per BTC/ETH — onesta sui dati REALI disponibili (cerbero-bite mainnet).
|
||||
|
||||
Dati: Old/data/options (chain per-strike + dvol + market_snapshots). Finestra ~2026-05-01→06-11
|
||||
(~6 settimane, REGIME UNICO calmo). NON si può validare OOS un edge su opzioni qui; si possono
|
||||
MISURARE i livelli reali (VRP, premi put, skew, liquidità) e ragionare sull'USO delle opzioni
|
||||
per il book BTC/ETH certificato. cerbero-bite è ancora vivo -> la fonte continua ad accumulare.
|
||||
|
||||
uv run python scripts/analysis/options_analysis.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
OPT = PROJECT_ROOT / "Old" / "data" / "options"
|
||||
|
||||
|
||||
def load(name):
|
||||
return pd.read_parquet(OPT / name)
|
||||
|
||||
|
||||
def market_snapshots_analysis():
|
||||
print("=" * 90)
|
||||
print(" (1) MARKET SNAPSHOTS — VRP, DVOL, funding, dealer-gamma (livelli reali)")
|
||||
print("=" * 90)
|
||||
ms = load("market_snapshots.parquet")
|
||||
t = pd.to_datetime(ms["timestamp"], utc=True, errors="coerce")
|
||||
print(f" copertura: {t.min()} -> {t.max()} ({len(ms)} righe)")
|
||||
for a in ("BTC", "ETH"):
|
||||
d = ms[ms["asset"] == a].dropna(subset=["iv_minus_rv"])
|
||||
if len(d) == 0:
|
||||
print(f" {a}: nessun dato"); continue
|
||||
vrp = d["iv_minus_rv"].astype(float)
|
||||
dvol = d["dvol"].astype(float)
|
||||
rv = d["realized_vol_30d"].astype(float)
|
||||
fund = d["funding_perp_annualized"].astype(float) if "funding_perp_annualized" in d else pd.Series([np.nan])
|
||||
gam = d["dealer_net_gamma"].astype(float) if "dealer_net_gamma" in d else pd.Series([np.nan])
|
||||
print(f"\n {a} (n={len(d)})")
|
||||
print(f" VRP (IV-RV): media {vrp.mean():+.1f} mediana {vrp.median():+.1f} "
|
||||
f">0 nel {100*(vrp>0).mean():.0f}% del tempo [IV-RV in punti di vol annua]")
|
||||
print(f" DVOL: media {dvol.mean():.1f} range [{dvol.min():.1f}, {dvol.max():.1f}]")
|
||||
print(f" Realized30d: media {rv.mean():.1f}")
|
||||
print(f" Funding perp: media {fund.mean():+.1f}% annuo")
|
||||
if gam.notna().any():
|
||||
print(f" Dealer net-γ: >0 nel {100*(gam>0).mean():.0f}% del tempo (>0 = dealer long gamma = mean-rev)")
|
||||
|
||||
|
||||
def chain_analysis(asset):
|
||||
print("\n" + "=" * 90)
|
||||
print(f" (2) CHAIN {asset} — premi put protettivi, skew, liquidità (livelli reali)")
|
||||
print("=" * 90)
|
||||
ch = load(f"{asset.lower()}_chain.parquet")
|
||||
for col in ("strike", "bid", "ask", "mid", "iv", "delta", "gamma"):
|
||||
if col in ch:
|
||||
ch[col] = pd.to_numeric(ch[col], errors="coerce")
|
||||
ch["option_type"] = ch["option_type"].astype(str)
|
||||
dv = load("dvol_history.parquet")
|
||||
dv = dv[dv["asset"] == asset][["timestamp", "spot"]].copy()
|
||||
dv["spot"] = pd.to_numeric(dv["spot"], errors="coerce")
|
||||
# timestamp -> datetime UTC nativo (sono datetime64[tz], NON ms int: to_numeric li romperebbe)
|
||||
ch["t"] = pd.to_datetime(ch["timestamp"], utc=True, errors="coerce")
|
||||
dv["t"] = pd.to_datetime(dv["timestamp"], utc=True, errors="coerce")
|
||||
ch = ch.dropna(subset=["t"]).sort_values("t").reset_index(drop=True)
|
||||
dv = dv.dropna(subset=["t", "spot"]).sort_values("t").reset_index(drop=True)
|
||||
# spot causale per timestamp della chain (merge_asof nearest, tolleranza 1h)
|
||||
ch = pd.merge_asof(ch, dv[["t", "spot"]], on="t", direction="nearest",
|
||||
tolerance=pd.Timedelta("1h"))
|
||||
ch = ch.dropna(subset=["spot", "mid", "strike"])
|
||||
# days-to-expiry
|
||||
exp = pd.to_datetime(ch["expiry"], utc=True, errors="coerce")
|
||||
ch["dte"] = (exp - ch["t"]).dt.total_seconds() / 86_400.0
|
||||
ch = ch[(ch["dte"] > 0.5) & (ch["dte"] < 90)]
|
||||
ch["money"] = ch["strike"] / ch["spot"]
|
||||
ch["prem_pct"] = ch["mid"] * 100 # mid è in COIN (frazione del sottostante) -> %-del-notional
|
||||
# NB: iv è GIÀ in percento (35.94 = 35.94%, coerente col DVOL ~40) -> non riscalare
|
||||
ch["spread_pct"] = (ch["ask"] - ch["bid"]) / ch["mid"].replace(0, np.nan) * 100
|
||||
|
||||
puts = ch[ch["option_type"].str.lower().str.startswith("p")]
|
||||
calls = ch[ch["option_type"].str.lower().str.startswith("c")]
|
||||
|
||||
def band(df, mlo, mhi, dlo, dhi):
|
||||
s = df[(df["money"] >= mlo) & (df["money"] <= mhi) & (df["dte"] >= dlo) & (df["dte"] <= dhi)]
|
||||
return s
|
||||
|
||||
print(" PUT protettive — premio reale (mid/spot) e liquidità per tenor/moneyness:")
|
||||
print(f" {'tenor':<10s}{'moneyness':<14s}{'premio%':>9s}{'/mese%':>9s}{'spread%':>9s}{'n':>7s}{'strike?':>9s}")
|
||||
for dlo, dhi, tn in [(5, 12, "settim."), (18, 45, "mensile")]:
|
||||
for mlo, mhi, ml in [(0.97, 1.03, "ATM"), (0.88, 0.93, "~10% OTM"), (0.83, 0.88, "~15% OTM")]:
|
||||
s = band(puts, mlo, mhi, dlo, dhi)
|
||||
if len(s) == 0:
|
||||
print(f" {tn:<10s}{ml:<14s}{'—':>9s}{'—':>9s}{'—':>9s}{0:>7d}{'NO':>9s}")
|
||||
continue
|
||||
prem = s["prem_pct"].median()
|
||||
permonth = prem * 30.0 / s["dte"].median()
|
||||
print(f" {tn:<10s}{ml:<14s}{prem:>8.2f}%{permonth:>8.2f}%{s['spread_pct'].median():>8.1f}%"
|
||||
f"{len(s):>7d}{'SI':>9s}")
|
||||
|
||||
# skew: IV put 10% OTM vs IV call 10% OTM (stesso tenor mensile)
|
||||
pv = band(puts, 0.88, 0.93, 12, 50)["iv"].median()
|
||||
cv = band(calls, 1.07, 1.12, 12, 50)["iv"].median()
|
||||
atmv = band(ch, 0.98, 1.02, 12, 50)["iv"].median()
|
||||
if pd.notna(pv) and pd.notna(cv):
|
||||
print(f" SKEW: IV put 10%OTM {pv:.0f}% vs call 10%OTM {cv:.0f}% vs ATM {atmv:.0f}%"
|
||||
f" -> skew put {pv-cv:+.0f} pt vol (>0 = put care = paura del crash prezzata)")
|
||||
|
||||
|
||||
def main():
|
||||
market_snapshots_analysis()
|
||||
for a in ("BTC", "ETH"):
|
||||
chain_analysis(a)
|
||||
print("\n" + "=" * 90)
|
||||
print(" NB: finestra ~6 settimane, REGIME UNICO calmo -> livelli REALI misurabili, ma NESSUN")
|
||||
print(" edge su opzioni è validabile OOS qui. Vedi commento finale.")
|
||||
print("=" * 90)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,158 @@
|
||||
"""FASE 1 — triage dei 2 superstiti su BTC/ETH, sull'harness onesto (research_lab).
|
||||
|
||||
Sul feed pulito solo SH01 (shape-ML) e frammenti HONEST mostravano segnale residuo. Delle
|
||||
HONEST solo DIP (dip-reversion) è testabile su BTC/ETH (TR01/ROT02 richiedono alt esclusi).
|
||||
Qui ri-implemento DIP e SH01-shape-ML come SERIE DI POSIZIONE e li passo ai gate onesti
|
||||
(FULL/OOS-VAL, vs buy&hold, null p-value, sweep fee, griglia). Hold-out 2025+ resta BLOCCATO.
|
||||
|
||||
uv run python scripts/analysis/phase1_survivors.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.linear_model import LogisticRegression
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from scripts.analysis.research_lab import (
|
||||
backtest, buy_hold, mc_pvalue, report, VAL_START, HOLDOUT_START, FEE_RT,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------- DIP reversion (long-only) -----------------------------
|
||||
def dip_signal(df, n=50, k=2.0, z_exit=0.0, max_bars=72):
|
||||
"""Long-only: entra (pos=1) quando lo z-score causale del prezzo vs MA(n) <= -k (dip),
|
||||
esce quando z>=z_exit o dopo max_bars. Decisione a close[i] (z[i] usa close[i]), guadagna
|
||||
close[i]->close[i+1]. Niente fill su estremi di candela."""
|
||||
c = df["close"].values.astype(float)
|
||||
s = pd.Series(c)
|
||||
ma = s.rolling(n).mean().values
|
||||
sd = s.rolling(n).std().values
|
||||
z = np.where(sd > 0, (c - ma) / sd, np.nan)
|
||||
pos = np.zeros(len(c))
|
||||
inpos = False
|
||||
held = 0
|
||||
for i in range(len(c)):
|
||||
if not inpos:
|
||||
if not np.isnan(z[i]) and z[i] <= -k:
|
||||
inpos, held = True, 0
|
||||
pos[i] = 1.0
|
||||
else:
|
||||
held += 1
|
||||
if (not np.isnan(z[i]) and z[i] >= z_exit) or held >= max_bars:
|
||||
inpos = False # esce al close[i]: pos[i]=0
|
||||
else:
|
||||
pos[i] = 1.0
|
||||
return pos
|
||||
|
||||
|
||||
# ----------------------------- SH01 shape-ML (walk-forward) -----------------------------
|
||||
def _shape_features(df, W):
|
||||
"""~12 feature di FORMA causali per barra, dalla finestra che termina a i (usa solo <=i)."""
|
||||
o = df["open"].values.astype(float); h = df["high"].values.astype(float)
|
||||
l = df["low"].values.astype(float); c = df["close"].values.astype(float)
|
||||
s = pd.Series(c)
|
||||
ret1 = s.pct_change()
|
||||
rng = (h - l) / np.where(c > 0, c, np.nan)
|
||||
body = (c - o) / np.where(h - l > 0, h - l, np.nan)
|
||||
up_sh = (h - np.maximum(o, c)) / np.where(h - l > 0, h - l, np.nan)
|
||||
dn_sh = (np.minimum(o, c) - l) / np.where(h - l > 0, h - l, np.nan)
|
||||
# RSI(14)
|
||||
d = s.diff()
|
||||
gain = d.clip(lower=0).rolling(14).mean()
|
||||
loss = (-d.clip(upper=0)).rolling(14).mean()
|
||||
rsi = 100 - 100 / (1 + gain / loss.replace(0, np.nan))
|
||||
hi_w = pd.Series(h).rolling(W).max(); lo_w = pd.Series(l).rolling(W).min()
|
||||
feat = {
|
||||
"mom_w": s / s.shift(W) - 1.0, # rendimento sulla finestra
|
||||
"mom_half": s / s.shift(W // 2) - 1.0, # accelerazione
|
||||
"vol_w": ret1.rolling(W).std(),
|
||||
"rsi": rsi / 100.0,
|
||||
"ma_dist": (c - s.rolling(W).mean()) / s.rolling(W).std(),
|
||||
"pos_in_range": (c - lo_w) / (hi_w - lo_w).replace(0, np.nan), # dove sta il close nel range W
|
||||
"range": pd.Series(rng).rolling(3).mean(),
|
||||
"body": pd.Series(body).rolling(3).mean(),
|
||||
"up_shadow": pd.Series(up_sh).rolling(3).mean(),
|
||||
"dn_shadow": pd.Series(dn_sh).rolling(3).mean(),
|
||||
"ret1": ret1,
|
||||
"skew_w": ret1.rolling(W).skew(),
|
||||
}
|
||||
X = pd.DataFrame(feat).values
|
||||
return X
|
||||
|
||||
|
||||
def shape_ml_signal(df, W=24, H=12, th=0.55, refit=750, warmup=3000, long_short=True):
|
||||
"""LogisticRegression walk-forward sulla forma. Label = segno del rendimento a H barre.
|
||||
Al tempo di decisione i si allena SOLO su campioni j con esito già realizzato (j+H <= i):
|
||||
strettamente causale, nessun leak. Rifit ogni `refit` barre (velocità). pos = +1 se
|
||||
P(up)>th, -1 se P(up)<1-th (long_short), altrimenti 0."""
|
||||
c = df["close"].values.astype(float)
|
||||
n = len(c)
|
||||
X = _shape_features(df, W)
|
||||
fwd = np.full(n, np.nan)
|
||||
fwd[:n - H] = c[H:] / c[:n - H] - 1.0
|
||||
y = (fwd > 0).astype(float)
|
||||
valid = ~np.isnan(X).any(axis=1)
|
||||
pos = np.zeros(n)
|
||||
model = scaler = None
|
||||
start = max(warmup, W + H + 200)
|
||||
for i in range(start, n):
|
||||
if model is None or (i - start) % refit == 0:
|
||||
# campioni di training: feature valide E label realizzata entro i (j+H <= i)
|
||||
tr = np.where(valid & (np.arange(n) + H <= i) & (np.arange(n) >= W))[0]
|
||||
tr = tr[tr < i - H]
|
||||
if len(tr) >= 500 and len(np.unique(y[tr])) == 2:
|
||||
scaler = StandardScaler().fit(X[tr])
|
||||
model = LogisticRegression(max_iter=200, C=1.0).fit(scaler.transform(X[tr]), y[tr])
|
||||
if model is not None and valid[i]:
|
||||
p_up = float(model.predict_proba(scaler.transform(X[i:i + 1]))[0, 1])
|
||||
pos[i] = 1.0 if p_up > th else (-1.0 if (long_short and p_up < 1 - th) else 0.0)
|
||||
return pos
|
||||
|
||||
|
||||
# ----------------------------------- run -----------------------------------
|
||||
def main():
|
||||
TF = "1h"
|
||||
print("=" * 90)
|
||||
print(f" FASE 1 — triage superstiti su BTC/ETH {TF} | netto fee 0.10% RT | hold-out {HOLDOUT_START}+ BLOCCATO")
|
||||
print("=" * 90)
|
||||
|
||||
data = {a: load_data(a, TF) for a in ("BTC", "ETH")}
|
||||
|
||||
# ---------- DIP: griglia robustezza (plateau?) ----------
|
||||
print("\n" + "#" * 90)
|
||||
print(" DIP reversion (long-only) — griglia FULL Sharpe (plateau = robusto, picco = overfit)")
|
||||
print("#" * 90)
|
||||
GRID = [(n, k) for n in (30, 50, 100) for k in (1.5, 2.0, 2.5)]
|
||||
for a in ("BTC", "ETH"):
|
||||
df = data[a]
|
||||
print(f"\n {a}: " + " ".join(
|
||||
f"n{n}k{k}→{backtest(df, dip_signal(df, n=n, k=k), TF).sharpe:>5.2f}" for n, k in GRID))
|
||||
# report onesto sulla config centrale
|
||||
for a in ("BTC", "ETH"):
|
||||
report(f"DIP {a} (n50 k2.0)", data[a], dip_signal(data[a], n=50, k=2.0), TF)
|
||||
|
||||
# ---------- SH01 shape-ML: config record + paio di varianti ----------
|
||||
print("\n" + "#" * 90)
|
||||
print(" SH01 shape-ML (walk-forward LogReg) — long/short")
|
||||
print("#" * 90)
|
||||
for a in ("BTC", "ETH"):
|
||||
df = data[a]
|
||||
pos = shape_ml_signal(df, W=24, H=12, th=0.55, long_short=True)
|
||||
report(f"SH-ML {a} (W24 H12 th.55 L/S)", df, pos, TF)
|
||||
# variante long-only (meno fee)
|
||||
pos_lo = shape_ml_signal(df, W=24, H=12, th=0.55, long_short=False)
|
||||
report(f"SH-ML {a} (W24 H12 th.55 LONG-only)", df, pos_lo, TF)
|
||||
|
||||
print("\n" + "=" * 90)
|
||||
print(" VERDETTO: un edge è REALE solo se FULL e OOS-VAL Sharpe > 0, regge il sweep fee,")
|
||||
print(" e BATTE il null (p<0.05). Altrimenti = rumore, si chiude.")
|
||||
print("=" * 90)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,221 @@
|
||||
"""FASE 2 — esplorazione larga per famiglie su BTC/ETH, harness onesto (research_lab).
|
||||
|
||||
Famiglie (serie di posizione, causali, netto fee, vs buy&hold + null p-value):
|
||||
TSMOM (momentum) | REVERSAL | MA-cross | DONCHIAN breakout | VOL-TARGET overlay |
|
||||
LEAD-LAG BTC<->ETH | HURST-gated momentum. Multi-TF dove sensato (1h + 15m).
|
||||
|
||||
La barra DA BATTERE è il buy&hold (Sharpe ~0.8 su BTC/ETH): una strategia di timing vale solo
|
||||
se fa MEGLIO net-fee. Per ogni famiglia: scan griglia (FULL Sharpe), poi report onesto sulla
|
||||
config migliore. Selezionare il best-di-griglia GONFIA -> i gate veri sono OOS-VAL + null p<0.05.
|
||||
|
||||
uv run python scripts/analysis/phase2_families.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from scripts.analysis.research_lab import (
|
||||
backtest, buy_hold, mc_pvalue, window_mask, ts, VAL_START, HOLDOUT_START, BARS_PER_YEAR,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------- famiglie ---------------------------------
|
||||
def tsmom(df, L, mode="ls"):
|
||||
c = pd.Series(df["close"].values.astype(float))
|
||||
pos = np.sign(np.nan_to_num((c / c.shift(L) - 1).values))
|
||||
return np.maximum(pos, 0) if mode == "lo" else pos
|
||||
|
||||
|
||||
def reversal(df, L, mode="ls"):
|
||||
c = pd.Series(df["close"].values.astype(float))
|
||||
pos = -np.sign(np.nan_to_num((c / c.shift(L) - 1).values))
|
||||
return np.maximum(pos, 0) if mode == "lo" else pos
|
||||
|
||||
|
||||
def ma_cross(df, fast, slow, mode="ls"):
|
||||
c = pd.Series(df["close"].values.astype(float))
|
||||
ef = c.ewm(span=fast, adjust=False).mean()
|
||||
es = c.ewm(span=slow, adjust=False).mean()
|
||||
pos = np.sign((ef - es).values)
|
||||
return np.maximum(pos, 0) if mode == "lo" else pos
|
||||
|
||||
|
||||
def donchian(df, L, mode="ls"):
|
||||
h = pd.Series(df["high"].values.astype(float)).rolling(L).max().shift(1).values
|
||||
l = pd.Series(df["low"].values.astype(float)).rolling(L).min().shift(1).values
|
||||
c = df["close"].values.astype(float)
|
||||
pos = np.zeros(len(c)); cur = 0
|
||||
for i in range(len(c)):
|
||||
if not np.isnan(h[i]) and c[i] > h[i]:
|
||||
cur = 1
|
||||
elif not np.isnan(l[i]) and c[i] < l[i]:
|
||||
cur = -1 if mode == "ls" else 0
|
||||
pos[i] = cur
|
||||
return pos
|
||||
|
||||
|
||||
def vol_target(df, tf, target=0.6, L=72):
|
||||
"""Overlay SEMPRE-LONG con esposizione scalata dalla vol realizzata (target vol annua)."""
|
||||
c = pd.Series(df["close"].values.astype(float))
|
||||
rv_ann = c.pct_change().rolling(L).std().values * np.sqrt(BARS_PER_YEAR[tf])
|
||||
pos = np.clip(np.nan_to_num(target / np.where(rv_ann > 0, rv_ann, np.nan), nan=0.0), 0, 1)
|
||||
return pos
|
||||
|
||||
|
||||
def rolling_hurst(c, W=120, step=6, lags=(2, 4, 8, 16, 32)):
|
||||
logc = np.log(c); n = len(c); H = np.full(n, np.nan)
|
||||
lg = np.log(lags)
|
||||
for i in range(W, n, step):
|
||||
seg = logc[i - W:i]
|
||||
tau = [np.std(seg[lag:] - seg[:-lag]) for lag in lags]
|
||||
if min(tau) > 0:
|
||||
H[i] = np.polyfit(lg, np.log(tau), 1)[0]
|
||||
return pd.Series(H).ffill().fillna(0.5).values
|
||||
|
||||
|
||||
def hurst_mom(df, L=48, W=120, mode="ls"):
|
||||
H = rolling_hurst(df["close"].values.astype(float), W)
|
||||
return np.where(H > 0.5, tsmom(df, L, mode), 0.0)
|
||||
|
||||
|
||||
def leadlag_df(target_df, other_df, L):
|
||||
"""Costruisce un df col close del TARGET e la posizione = segno del rendimento a L barre
|
||||
dell'ALTRO asset (allineato per timestamp). Ritorna (df_merged, pos)."""
|
||||
a = target_df[["timestamp", "open", "high", "low", "close"]]
|
||||
b = other_df[["timestamp", "close"]].rename(columns={"close": "other"})
|
||||
m = a.merge(b, on="timestamp", how="inner").reset_index(drop=True)
|
||||
o = pd.Series(m["other"].values.astype(float))
|
||||
pos = np.sign(np.nan_to_num((o / o.shift(L) - 1).values))
|
||||
return m, pos
|
||||
|
||||
|
||||
# --------------------------------- reporting ---------------------------------
|
||||
ROWS = []
|
||||
|
||||
|
||||
def summarize(family, asset, tf, df, pos, mc_n=300):
|
||||
full = backtest(df, pos, tf)
|
||||
oos = backtest(df, pos, tf, lo=VAL_START, hi=HOLDOUT_START)
|
||||
bh = buy_hold(df, tf)
|
||||
gross = backtest(df, pos, tf, fee_rt=0.0).sharpe
|
||||
_, p, _, _ = mc_pvalue(df, pos, tf, n=mc_n)
|
||||
beats_bh = full.sharpe > bh.sharpe and oos.sharpe > 0
|
||||
real = (full.sharpe > 0 and oos.sharpe > 0 and not np.isnan(p) and p < 0.05)
|
||||
verdict = "★EDGE?" if (real and beats_bh) else ("real?" if real else "rumore")
|
||||
ROWS.append(dict(fam=family, asset=asset, tf=tf, full=full.sharpe, oos=oos.sharpe,
|
||||
gross=gross, bh=bh.sharpe, p=p, trd=full.ntrades, verdict=verdict))
|
||||
print(f" {family:<16s} {asset} {tf:<3s} | FULL {full.sharpe:>5.2f} OOS {oos.sharpe:>5.2f} "
|
||||
f"gross {gross:>5.2f} | B&H {bh.sharpe:>4.2f} | p {p:>.3f} | trd/y {full.ntrades:>6.0f} | {verdict}")
|
||||
|
||||
|
||||
def scan_best(family, asset, tf, df, fn, grid, label_fn):
|
||||
"""Scansiona la griglia (FULL Sharpe), stampa la riga compatta, ritorna la pos migliore."""
|
||||
best = None
|
||||
line = []
|
||||
for params in grid:
|
||||
pos = fn(df, *params)
|
||||
s = backtest(df, pos, tf).sharpe
|
||||
line.append(f"{label_fn(params)}={s:>4.1f}")
|
||||
if best is None or s > best[0]:
|
||||
best = (s, params, pos)
|
||||
print(f" {asset} {tf} grid: " + " ".join(line))
|
||||
return best[2], best[1]
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 100)
|
||||
print(" FASE 2 — esplorazione famiglie BTC/ETH | netto fee 0.10% RT | barra = buy&hold | hold-out bloccato")
|
||||
print("=" * 100)
|
||||
D1 = {a: load_data(a, "1h") for a in ("BTC", "ETH")}
|
||||
D15 = {a: load_data(a, "15m") for a in ("BTC", "ETH")}
|
||||
|
||||
def block(title):
|
||||
print("\n" + "#" * 100 + f"\n {title}\n" + "#" * 100)
|
||||
|
||||
# ---- TSMOM (momentum) 1h + 15m, L/S e long-only ----
|
||||
block("TSMOM (momentum)")
|
||||
Ls = [(12,), (24,), (48,), (96,), (192,)]
|
||||
for a in ("BTC", "ETH"):
|
||||
pos, p = scan_best("TSMOM-LS", a, "1h", D1[a], lambda d, L: tsmom(d, L, "ls"), Ls, lambda x: f"L{x[0]}")
|
||||
summarize("TSMOM-LS", a, "1h", D1[a], pos)
|
||||
pos, p = scan_best("TSMOM-LO", a, "1h", D1[a], lambda d, L: tsmom(d, L, "lo"), Ls, lambda x: f"L{x[0]}")
|
||||
summarize("TSMOM-LO", a, "1h", D1[a], pos)
|
||||
pos, p = scan_best("TSMOM-LS", a, "15m", D15[a], lambda d, L: tsmom(d, L, "ls"), [(48,),(96,),(192,),(384,)], lambda x: f"L{x[0]}")
|
||||
summarize("TSMOM-LS", a, "15m", D15[a], pos)
|
||||
|
||||
# ---- REVERSAL 1h + 15m ----
|
||||
block("REVERSAL (mean-reversion breve)")
|
||||
Lr = [(1,), (3,), (6,), (12,), (24,)]
|
||||
for a in ("BTC", "ETH"):
|
||||
pos, p = scan_best("REV-LS", a, "1h", D1[a], lambda d, L: reversal(d, L, "ls"), Lr, lambda x: f"L{x[0]}")
|
||||
summarize("REV-LS", a, "1h", D1[a], pos)
|
||||
pos, p = scan_best("REV-LS", a, "15m", D15[a], lambda d, L: reversal(d, L, "ls"), Lr, lambda x: f"L{x[0]}")
|
||||
summarize("REV-LS", a, "15m", D15[a], pos)
|
||||
|
||||
# ---- MA cross ----
|
||||
block("MA-CROSS (trend)")
|
||||
g = [(12, 48), (24, 96), (48, 192), (24, 200)]
|
||||
for a in ("BTC", "ETH"):
|
||||
pos, p = scan_best("MAX-LS", a, "1h", D1[a], lambda d, f, s: ma_cross(d, f, s, "ls"), g, lambda x: f"{x[0]}/{x[1]}")
|
||||
summarize("MAX-LS", a, "1h", D1[a], pos)
|
||||
pos, p = scan_best("MAX-LO", a, "1h", D1[a], lambda d, f, s: ma_cross(d, f, s, "lo"), g, lambda x: f"{x[0]}/{x[1]}")
|
||||
summarize("MAX-LO", a, "1h", D1[a], pos)
|
||||
|
||||
# ---- Donchian breakout ----
|
||||
block("DONCHIAN breakout")
|
||||
Ld = [(24,), (48,), (96,), (192,)]
|
||||
for a in ("BTC", "ETH"):
|
||||
pos, p = scan_best("DONCH-LS", a, "1h", D1[a], lambda d, L: donchian(d, L, "ls"), Ld, lambda x: f"L{x[0]}")
|
||||
summarize("DONCH-LS", a, "1h", D1[a], pos)
|
||||
pos, p = scan_best("DONCH-LO", a, "1h", D1[a], lambda d, L: donchian(d, L, "lo"), Ld, lambda x: f"L{x[0]}")
|
||||
summarize("DONCH-LO", a, "1h", D1[a], pos)
|
||||
|
||||
# ---- Vol-target overlay (vs buy&hold) ----
|
||||
block("VOL-TARGET overlay (sempre-long scalato) — riduce la vol/DD del buy&hold?")
|
||||
for a in ("BTC", "ETH"):
|
||||
pos, p = scan_best("VOLTGT", a, "1h", D1[a], lambda d, t: vol_target(d, "1h", t, 72),
|
||||
[(0.4,), (0.6,), (0.8,), (1.0,)], lambda x: f"t{x[0]}")
|
||||
summarize("VOLTGT", a, "1h", D1[a], pos)
|
||||
|
||||
# ---- Hurst-gated momentum ----
|
||||
block("HURST-gated momentum (momentum solo in regime trending H>0.5)")
|
||||
for a in ("BTC", "ETH"):
|
||||
pos, p = scan_best("HURST-MOM", a, "1h", D1[a], lambda d, L: hurst_mom(d, L, 120, "ls"),
|
||||
[(24,), (48,), (96,)], lambda x: f"L{x[0]}")
|
||||
summarize("HURST-MOM", a, "1h", D1[a], pos)
|
||||
|
||||
# ---- Lead-lag BTC<->ETH ----
|
||||
block("LEAD-LAG BTC<->ETH (posiziona un asset col rendimento passato dell'altro)")
|
||||
for tgt, oth in (("ETH", "BTC"), ("BTC", "ETH")):
|
||||
Ll = [1, 3, 6, 12, 24]
|
||||
best = None; line = []
|
||||
for L in Ll:
|
||||
m, pos = leadlag_df(D1[tgt], D1[oth], L)
|
||||
s = backtest(m, pos, "1h").sharpe
|
||||
line.append(f"L{L}={s:>4.1f}")
|
||||
if best is None or s > best[0]:
|
||||
best = (s, L, m, pos)
|
||||
print(f" {oth}->{tgt} 1h grid: " + " ".join(line))
|
||||
_, L, m, pos = best
|
||||
summarize(f"LL {oth}>{tgt}", tgt, "1h", m, pos)
|
||||
|
||||
# ---- classifica finale ----
|
||||
print("\n" + "=" * 100)
|
||||
print(" CLASSIFICA — net-fee FULL Sharpe (★EDGE? = batte B&H, OOS>0 e null p<0.05)")
|
||||
print("=" * 100)
|
||||
for r in sorted(ROWS, key=lambda r: -r["full"]):
|
||||
print(f" {r['fam']:<16s} {r['asset']} {r['tf']:<3s} | FULL {r['full']:>5.2f} | OOS {r['oos']:>5.2f} | "
|
||||
f"B&H {r['bh']:>4.2f} | p {r['p']:>.3f} | {r['verdict']}")
|
||||
edges = [r for r in ROWS if r["verdict"] == "★EDGE?"]
|
||||
print(f"\n Candidati che battono il buy&hold net-fee + OOS>0 + null p<0.05: {len(edges)}")
|
||||
for r in edges:
|
||||
print(f" -> {r['fam']} {r['asset']} {r['tf']}: FULL {r['full']:.2f} OOS {r['oos']:.2f} p {r['p']:.3f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,152 @@
|
||||
"""FASE 3 — conferma avversariale del SOLO candidato reale: trend-following long-only (MA-cross).
|
||||
|
||||
Protocollo onesto:
|
||||
1. SELEZIONE config SOLO sul pre-hold-out (< 2025-01-01). Niente sbirciate al hold-out.
|
||||
2. HOLD-OUT 2025-26 sbloccato UNA volta (la prova del nove, mai usato in ricerca).
|
||||
3. Breakdown PER ANNO vs buy&hold: il trend-LO deve "schivare" i bear (2018/2022).
|
||||
4. STRESS: fee 2x, lag di esecuzione (1 barra), slippage.
|
||||
5. DEFLATED SHARPE (Bailey & López de Prado): lo Sharpe regge alla correzione per multiple-testing?
|
||||
|
||||
uv run python scripts/analysis/phase3_confirm.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy.stats import norm, skew, kurtosis
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from scripts.analysis.research_lab import (
|
||||
backtest, buy_hold, window_mask, ts, _net_series, HOLDOUT_START, BARS_PER_YEAR,
|
||||
)
|
||||
from scripts.analysis.phase2_families import ma_cross
|
||||
|
||||
GRID = [(12, 48), (24, 96), (48, 192), (24, 200), (96, 288)] # MA-cross griglia (fast/slow)
|
||||
REPR = (24, 96) # config rappresentativa PRE-COMMITTATA
|
||||
TF = "1h"
|
||||
|
||||
|
||||
def lag(pos, k=1):
|
||||
"""Esecuzione in ritardo di k barre (agisci k barre dopo la decisione)."""
|
||||
return np.concatenate([np.zeros(k), pos[:-k]])
|
||||
|
||||
|
||||
def per_year(df, pos, tf):
|
||||
c = df["close"].values.astype(float)
|
||||
net, _, fwd, _ = _net_series(df, pos)
|
||||
yrs = ts(df).dt.year.values
|
||||
out = {}
|
||||
for y in sorted(set(yrs)):
|
||||
m = yrs == y
|
||||
if m.sum() < 2:
|
||||
continue
|
||||
strat = float(np.prod(1 + net[m]) - 1) * 100
|
||||
bh = float(np.prod(1 + fwd[m]) - 1) * 100
|
||||
expo = float(np.mean(np.abs(pos[m])))
|
||||
out[y] = (strat, bh, expo)
|
||||
return out
|
||||
|
||||
|
||||
def deflated_sharpe(net, sr_trials_perbar, N):
|
||||
"""DSR: prob. che il vero Sharpe > la soglia attesa-massima sotto N trial (multiple testing).
|
||||
Tutto in Sharpe PER BARRA. >0.95 = significativo dopo correzione."""
|
||||
sr = net.mean() / net.std()
|
||||
T = len(net)
|
||||
g3 = float(skew(net)); g4 = float(kurtosis(net, fisher=False))
|
||||
var_sr = float(np.var(sr_trials_perbar, ddof=1)) if len(sr_trials_perbar) > 1 else 0.0
|
||||
ge = 0.5772156649
|
||||
z1 = norm.ppf(1 - 1.0 / N); z2 = norm.ppf(1 - 1.0 / (N * np.e))
|
||||
sr0 = np.sqrt(var_sr) * ((1 - ge) * z1 + ge * z2) # Sharpe atteso-massimo sotto null, N trial
|
||||
den = np.sqrt(max(1 - g3 * sr + (g4 - 1) / 4.0 * sr ** 2, 1e-9))
|
||||
dsr = float(norm.cdf((sr - sr0) * np.sqrt(T - 1) / den))
|
||||
bpy = BARS_PER_YEAR[TF]
|
||||
return dsr, sr * np.sqrt(bpy), sr0 * np.sqrt(bpy)
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 96)
|
||||
print(" FASE 3 — conferma avversariale: TREND-following long-only (MA-cross) BTC/ETH")
|
||||
print("=" * 96)
|
||||
data = {a: load_data(a, TF) for a in ("BTC", "ETH")}
|
||||
|
||||
# ---------- 1) selezione SOLO pre-hold-out ----------
|
||||
print(f"\n (1) SELEZIONE su pre-hold-out (< {HOLDOUT_START}) — Sharpe per config (plateau = robusto)")
|
||||
for a in ("BTC", "ETH"):
|
||||
line = []
|
||||
for f, s in GRID:
|
||||
pos = ma_cross(data[a], f, s, "lo")
|
||||
sh = backtest(data[a], pos, TF, hi=HOLDOUT_START).sharpe
|
||||
line.append(f"{f}/{s}={sh:>4.2f}")
|
||||
print(f" {a}: " + " ".join(line))
|
||||
print(f" -> config rappresentativa PRE-COMMITTATA per i test seguenti: {REPR[0]}/{REPR[1]}")
|
||||
|
||||
# ---------- 2) HOLD-OUT 2025-26 (sbloccato una volta) ----------
|
||||
print(f"\n (2) HOLD-OUT {HOLDOUT_START}+ — LA PROVA DEL NOVE (mai usato in ricerca)")
|
||||
for a in ("BTC", "ETH"):
|
||||
bh = buy_hold(data[a], TF, lo=HOLDOUT_START)
|
||||
print(f" {a}: buy&hold hold-out Sh {bh.sharpe:>5.2f} ret {bh.ret*100:>+7.1f}% DD {bh.maxdd*100:>4.1f}%")
|
||||
for f, s in GRID:
|
||||
pos = ma_cross(data[a], f, s, "lo")
|
||||
r = backtest(data[a], pos, TF, lo=HOLDOUT_START)
|
||||
star = " <-REPR" if (f, s) == REPR else ""
|
||||
print(f" {f}/{s:<3d} Sh {r.sharpe:>5.2f} ret {r.ret*100:>+7.1f}% DD {r.maxdd*100:>4.1f}% expo {r.exposure:.2f}{star}")
|
||||
|
||||
# ---------- 3) per anno vs buy&hold (schiva i bear?) ----------
|
||||
print(f"\n (3) PER ANNO — strat {REPR[0]}/{REPR[1]} vs buy&hold (expo = quanto è long; bear test 2018/2022)")
|
||||
for a in ("BTC", "ETH"):
|
||||
pos = ma_cross(data[a], *REPR, "lo")
|
||||
py = per_year(data[a], pos, TF)
|
||||
print(f" {a}:")
|
||||
for y, (st, bh, ex) in py.items():
|
||||
flag = " <- BEAR" if bh < -20 else ""
|
||||
print(f" {y}: strat {st:>+7.0f}% | buy&hold {bh:>+7.0f}% | expo {ex:.2f}{flag}")
|
||||
|
||||
# ---------- 4) stress ----------
|
||||
print(f"\n (4) STRESS — strat {REPR[0]}/{REPR[1]} | FULL e HOLD-OUT Sharpe")
|
||||
print(f" {'scenario':<24s}{'BTC FULL':>10s}{'BTC HO':>9s}{'ETH FULL':>10s}{'ETH HO':>9s}")
|
||||
scen = [
|
||||
("base fee0.10%", dict(fee_rt=0.001), False),
|
||||
("fee 0.20% (2x)", dict(fee_rt=0.002), False),
|
||||
("lag 1 barra", dict(fee_rt=0.001), True),
|
||||
("fee2x + lag", dict(fee_rt=0.002), True),
|
||||
]
|
||||
for name, kw, do_lag in scen:
|
||||
row = [name]
|
||||
for a in ("BTC", "ETH"):
|
||||
pos = ma_cross(data[a], *REPR, "lo")
|
||||
if do_lag:
|
||||
pos = lag(pos, 1)
|
||||
full = backtest(data[a], pos, TF, **kw).sharpe
|
||||
ho = backtest(data[a], pos, TF, lo=HOLDOUT_START, **kw).sharpe
|
||||
row += [f"{full:>9.2f}", f"{ho:>8.2f}"]
|
||||
print(f" {row[0]:<24s}{row[1]:>10s}{row[2]:>9s}{row[3]:>10s}{row[4]:>9s}")
|
||||
|
||||
# ---------- 5) deflated Sharpe ----------
|
||||
print(f"\n (5) DEFLATED SHARPE — corregge il multiple-testing (DSR>0.95 = regge)")
|
||||
# trial set = TUTTE le config trend long-only provate (proxy del numero di tentativi)
|
||||
N_TRIALS = 60 # stima conservativa dei backtest provati in Fase 2 (tutte le famiglie/asset/TF)
|
||||
for a in ("BTC", "ETH"):
|
||||
trials = [backtest(data[a], ma_cross(data[a], f, s, "lo"), TF, hi=HOLDOUT_START) for f, s in GRID]
|
||||
sr_trials = []
|
||||
for f, s in GRID:
|
||||
net, _, _, _ = _net_series(data[a], ma_cross(data[a], f, s, "lo"))
|
||||
m = window_mask(data[a], hi=HOLDOUT_START)
|
||||
sr_trials.append(net[m].mean() / net[m].std())
|
||||
net, _, _, _ = _net_series(data[a], ma_cross(data[a], *REPR, "lo"))
|
||||
m = window_mask(data[a], hi=HOLDOUT_START)
|
||||
dsr, sr_ann, sr0_ann = deflated_sharpe(net[m], sr_trials, N_TRIALS)
|
||||
verdict = "REGGE" if dsr > 0.95 else "NON regge"
|
||||
print(f" {a} (pre-hold-out): Sharpe {sr_ann:.2f} vs soglia-max-attesa(N={N_TRIALS}) {sr0_ann:.2f} "
|
||||
f"-> DSR {dsr:.3f} [{verdict}]")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print(" VERDETTO: edge ONESTO solo se (2) hold-out positivo, (3) schiva i bear, (4) regge lo")
|
||||
print(" stress, (5) DSR>0.95. Altrimenti: anche il trend era sample-luck del mercato toro.")
|
||||
print("=" * 96)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,208 @@
|
||||
"""HARNESS DI RICERCA ONESTO — BTC/ETH, v2.0.0 (Fase 0).
|
||||
|
||||
Dopo che l'intera libreria precedente si è rivelata artefatto di feed/harness disonesti,
|
||||
la prima cosa di cui fidarsi NON è una strategia ma il banco di prova. Questo modulo è
|
||||
quel banco: causale per costruzione, netto fee, con baseline e null model.
|
||||
|
||||
MODELLO CANONICO = SERIE DI POSIZIONE.
|
||||
Una strategia è una funzione signal(df, **params) -> pd.Series/np.array che dà la
|
||||
posizione target per barra in [-1, +1]. REGOLA: position[i] è decisa con dati FINO a
|
||||
close[i] (mai oltre) e GUADAGNA il rendimento close[i] -> close[i+1]. L'engine moltiplica
|
||||
position[i] * fwd[i] (fwd strettamente futuro rispetto alla decisione) -> niente look-ahead
|
||||
per costruzione, e niente fill sull'estremo di candela (si entra al close). La fee è
|
||||
addebitata sul TURNOVER |Δposition| (un round-trip 0->1->0 = 2 unità = fee_rt intera).
|
||||
|
||||
GATE (vedi CLAUDE.md): ingresso eseguibile (qui per costruzione), netto fee 0.10% RT,
|
||||
OOS held-out, robustezza su griglia, onestà statistica (null model + buy&hold), walk-forward
|
||||
per i modelli fittati, liquidità (BTC/ETH ok).
|
||||
|
||||
uv run python scripts/analysis/research_lab.py # self-test del banco
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
FEE_RT = 0.001 # 0.10% round-trip taker Deribit (0.05%/lato)
|
||||
BARS_PER_YEAR = {"5m": 105192.0, "15m": 35064.0, "1h": 8766.0,
|
||||
"4h": 2191.5, "12h": 730.5, "1d": 365.25}
|
||||
|
||||
|
||||
def load_tf(asset: str, tf: str):
|
||||
"""Carica un TF certificato. 5m/15m/1h diretti; 4h/12h/1d DERIVATI per resample dal 1h
|
||||
(confini 00:00 UTC). >=12h e' il regime raccomandato (sotto, costi+overfit dominano)."""
|
||||
if tf in ("5m", "15m", "1h"):
|
||||
return load_data(asset, tf)
|
||||
rule = {"4h": "4h", "12h": "12h", "1d": "1D"}[tf]
|
||||
df = load_data(asset, "1h").copy()
|
||||
df.index = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
out = df.resample(rule, label="left", closed="left").agg(
|
||||
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}).dropna(subset=["open"])
|
||||
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"]]
|
||||
# Hold-out FINALE bloccato: NIENTE ricerca/tuning lo tocca finché non è il verdetto (Fase 3).
|
||||
HOLDOUT_START = "2025-01-01"
|
||||
# Finestra di validazione OOS usata in ricerca (out-of-sample ma PRE hold-out).
|
||||
VAL_START = "2023-01-01"
|
||||
|
||||
|
||||
def ts(df) -> pd.Series:
|
||||
return pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
|
||||
|
||||
def window_mask(df, lo: str | None = None, hi: str | None = None) -> np.ndarray:
|
||||
t = ts(df)
|
||||
m = np.ones(len(df), bool)
|
||||
if lo is not None:
|
||||
m &= (t >= pd.Timestamp(lo, tz="UTC")).values
|
||||
if hi is not None:
|
||||
m &= (t < pd.Timestamp(hi, tz="UTC")).values
|
||||
return m
|
||||
|
||||
|
||||
@dataclass
|
||||
class BT:
|
||||
n: int
|
||||
ret: float # rendimento composto sulla finestra (pos 1x, leva 1x)
|
||||
cagr: float
|
||||
sharpe: float # annualizzato
|
||||
maxdd: float # % (positivo)
|
||||
exposure: float # |pos| medio
|
||||
turnover: float # Σ|Δpos| / anno
|
||||
ntrades: float # round-trip equivalenti / anno
|
||||
|
||||
def line(self, label="") -> str:
|
||||
return (f" {label:<22s} Sh {self.sharpe:>6.2f} | ret {self.ret*100:>+8.1f}% "
|
||||
f"CAGR {self.cagr*100:>+6.1f}% | DD {self.maxdd*100:>5.1f}% | "
|
||||
f"expo {self.exposure:>4.2f} trd/y {self.ntrades:>6.1f} | n {self.n}")
|
||||
|
||||
|
||||
def _net_series(df, position, fee_rt=FEE_RT):
|
||||
"""Ritorna (net, gross, fwd, pos) per barra. net[i] = pos[i]*fwd[i] - fee sul cambio a i."""
|
||||
c = df["close"].values.astype(float)
|
||||
pos = np.nan_to_num(np.asarray(position, float), nan=0.0)
|
||||
pos = np.clip(pos, -1.0, 1.0)
|
||||
n = len(c)
|
||||
fwd = np.zeros(n)
|
||||
fwd[:-1] = c[1:] / c[:-1] - 1.0 # rendimento close[i]->close[i+1] (futuro vs decisione a i)
|
||||
gross = pos * fwd
|
||||
dpos = np.abs(np.diff(np.concatenate([[0.0], pos]))) # cambio di posizione a i (si tradea al close[i])
|
||||
fee = dpos * (fee_rt / 2.0) # fee_rt = round-trip (2 unità di turnover); /2 per unità
|
||||
net = gross - fee
|
||||
return net, gross, fwd, pos
|
||||
|
||||
|
||||
def backtest(df, position, tf="1h", fee_rt=FEE_RT, lo=None, hi=None) -> BT:
|
||||
net, gross, fwd, pos = _net_series(df, position, fee_rt)
|
||||
m = window_mask(df, lo, hi)
|
||||
net_w, pos_w = net[m], pos[m]
|
||||
dpos_w = np.abs(np.diff(np.concatenate([[0.0], pos_w])))
|
||||
bpy = BARS_PER_YEAR[tf]
|
||||
n = int(m.sum())
|
||||
if n < 2:
|
||||
return BT(n, 0, float("nan"), 0, 0, 0, 0, 0)
|
||||
eq = np.cumprod(1.0 + net_w)
|
||||
total = float(eq[-1] - 1.0)
|
||||
years = n / bpy
|
||||
cagr = float((1 + total) ** (1 / years) - 1) if years > 0 and total > -1 else float("nan")
|
||||
mu, sd = float(net_w.mean()), float(net_w.std())
|
||||
sharpe = mu / sd * np.sqrt(bpy) if sd > 0 else 0.0
|
||||
peak = np.maximum.accumulate(eq)
|
||||
maxdd = float(np.max((peak - eq) / peak)) if n else 0.0
|
||||
expo = float(np.mean(np.abs(pos_w)))
|
||||
turn_y = float(dpos_w.sum() / years) if years > 0 else 0.0
|
||||
return BT(n, total, cagr, sharpe, maxdd, expo, turn_y, turn_y / 2.0)
|
||||
|
||||
|
||||
def buy_hold(df, tf="1h", fee_rt=FEE_RT, lo=None, hi=None) -> BT:
|
||||
return backtest(df, np.ones(len(df)), tf, fee_rt, lo, hi)
|
||||
|
||||
|
||||
def mc_pvalue(df, position, tf="1h", fee_rt=FEE_RT, n=500, lo=None, hi=None, seed=0):
|
||||
"""Null model a ROTAZIONE CIRCOLARE: ruota la serie di posizione di un offset casuale.
|
||||
Preserva ESATTAMENTE exposure, turnover e distribuzione degli holding; distrugge solo
|
||||
l'allineamento col mercato. p = P(Sharpe_ruotato >= Sharpe_reale). p alto = il timing
|
||||
non batte il caso (nessuna skill)."""
|
||||
pos = np.nan_to_num(np.asarray(position, float))
|
||||
base = backtest(df, pos, tf, fee_rt, lo, hi).sharpe
|
||||
N = len(pos)
|
||||
if np.abs(np.diff(pos)).sum() == 0: # posizione costante -> rotazione degenere
|
||||
return base, float("nan"), float("nan"), float("nan")
|
||||
rng = np.random.default_rng(seed)
|
||||
sims = np.empty(n)
|
||||
for k in range(n):
|
||||
off = int(rng.integers(1, N))
|
||||
sims[k] = backtest(df, np.roll(pos, off), tf, fee_rt, lo, hi).sharpe
|
||||
p = float((np.sum(sims >= base) + 1) / (n + 1))
|
||||
return base, p, float(sims.mean()), float(sims.std())
|
||||
|
||||
|
||||
def report(name, df, position, tf="1h", fee_rt=FEE_RT, mc_n=400):
|
||||
"""Stampa il verdetto onesto: FULL / OOS-VAL / vs buy&hold / null p-value / sweep fee."""
|
||||
print(f"\n === {name} ({tf}) ===")
|
||||
print(backtest(df, position, tf, fee_rt).line("FULL"))
|
||||
print(backtest(df, position, tf, fee_rt, lo=VAL_START, hi=HOLDOUT_START).line(f"OOS-VAL {VAL_START[:4]}-24"))
|
||||
print(buy_hold(df, tf, fee_rt).line("buy&hold FULL"))
|
||||
base, p, msh, ssd = mc_pvalue(df, position, tf, fee_rt, n=mc_n)
|
||||
verdict = "RUMORE" if (np.isnan(p) or p > 0.05) else "batte il null"
|
||||
print(f" null (rotazione, n={mc_n}): Sharpe reale {base:.2f} vs random {msh:.2f}±{ssd:.2f} "
|
||||
f"-> p={p if not np.isnan(p) else float('nan'):.3f} [{verdict}]")
|
||||
print(" sweep fee RT:", " ".join(
|
||||
f"{f*100:.2f}%→Sh{backtest(df, position, tf, f).sharpe:.2f}" for f in (0.0, 0.0005, 0.001, 0.002)))
|
||||
|
||||
|
||||
# ============================ SELF-TEST DEL BANCO ============================
|
||||
def self_test():
|
||||
"""Valida l'HARNESS, non una strategia. Tre prove:
|
||||
(1) buy&hold: Sharpe positivo, DD grande (sanity dei numeri).
|
||||
(2) CHEAT look-ahead (pos = segno del rendimento FUTURO): Sharpe enorme, p≈0
|
||||
-> l'engine SA vedere un edge quando esiste davvero.
|
||||
(3) NOISE causale (pos da rumore del passato): Sharpe≈0, p≈0.5
|
||||
-> l'engine NON inventa edge dal nulla (niente leak)."""
|
||||
print("=" * 78)
|
||||
print(" SELF-TEST HARNESS — deve: vedere il cheat, NON vedere il rumore")
|
||||
print("=" * 78)
|
||||
df = load_data("BTC", "1h")
|
||||
t = ts(df)
|
||||
c = df["close"].values.astype(float)
|
||||
bh = buy_hold(df, "1h")
|
||||
print(bh.line("(1) buy&hold BTC"))
|
||||
assert bh.sharpe > 0, "buy&hold dovrebbe avere Sharpe>0 sullo storico BTC"
|
||||
|
||||
# (2) CHEAT: posizione = segno del rendimento del prossimo bar (USA IL FUTURO)
|
||||
fwd = np.zeros(len(c)); fwd[:-1] = c[1:] / c[:-1] - 1.0
|
||||
cheat = np.sign(fwd)
|
||||
bt_cheat = backtest(df, cheat, "1h")
|
||||
_, p_cheat, _, _ = mc_pvalue(df, cheat, "1h", n=200, seed=1)
|
||||
print(bt_cheat.line("(2) CHEAT look-ahead"))
|
||||
print(f" -> null p={p_cheat:.4f} (atteso ≈0: l'edge finto È enorme e battibile dal caso ~mai)")
|
||||
assert bt_cheat.sharpe > 20, "il cheat dovrebbe dare Sharpe enorme se l'engine è corretto"
|
||||
assert p_cheat < 0.02, "il cheat dovrebbe battere il null in modo schiacciante"
|
||||
|
||||
# (3) NOISE causale a BASSO turnover (blocchi ~50 barre): isola la SKILL dalla fee-death.
|
||||
# Posizione casuale (non usa il futuro) tenuta a blocchi -> turnover basso -> se l'engine non
|
||||
# inventa edge dal nulla, Sharpe≈0 e il null p≈0.5 (random rotazioni indistinguibili).
|
||||
rng = np.random.default_rng(42)
|
||||
blk = 50
|
||||
raw = np.sign(rng.standard_normal(len(c) // blk + 1))
|
||||
noise_pos = np.repeat(raw, blk)[:len(c)]
|
||||
noise_pos = pd.Series(noise_pos).shift(1).fillna(0).values # solo passato
|
||||
bt_noise = backtest(df, noise_pos, "1h")
|
||||
base_n, p_noise, msh, ssd = mc_pvalue(df, noise_pos, "1h", n=400, seed=2)
|
||||
print(bt_noise.line("(3) NOISE causale"))
|
||||
print(f" -> null p={p_noise:.3f} (atteso alto/≈0.5: nessuna skill, indistinguibile dal caso)")
|
||||
assert bt_noise.sharpe < 2.0, "il rumore causale non deve sembrare SKILLATO (Sharpe positivo grande = leak)"
|
||||
assert p_noise > 0.10, "il rumore causale non deve battere il null (p basso = edge spurio/leak)"
|
||||
|
||||
print("\n ✓ HARNESS VALIDATO: vede il cheat (Sharpe enorme, p≈0), non inventa edge dal rumore (p alto).")
|
||||
print(f" Hold-out finale BLOCCATO da {HOLDOUT_START} (non usato in ricerca). OOS-VAL: {VAL_START}→hold-out.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
self_test()
|
||||
@@ -0,0 +1,121 @@
|
||||
"""STRESS-TEST di TP01 (integrato da strategy-research-2026-06) — robustezza avversariale.
|
||||
|
||||
Usa il modulo VERO integrato (src/strategies/trend_portfolio). Oltre a hold-out/cross-asset/multi-TF
|
||||
(gia' in verify_tp01.py), qui: sweep FEE (fino 0.40% RT), LAG di esecuzione + slippage, PLATEAU dei
|
||||
parametri (config cherry-picked?), DEFLATED-SHARPE (multiple-testing track A-E).
|
||||
|
||||
uv run python scripts/analysis/stress_tp01.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy.stats import norm, skew, kurtosis
|
||||
from src.data.downloader import load_data
|
||||
from src.strategies.trend_portfolio import TrendPortfolio, resample_4h, simple_returns, CANONICAL
|
||||
|
||||
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
DF4H = {a: resample_4h(load_data(a, "1h")) for a in ("BTC", "ETH")}
|
||||
|
||||
|
||||
def combo(cfg, lag_bars=0, fee_side=0.0005):
|
||||
"""Rendimenti per-barra del portafoglio 50/50 con config cfg, lag extra e fee dati."""
|
||||
tp = TrendPortfolio(**{**cfg, "fee_side": fee_side})
|
||||
series = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
df = DF4H[a]
|
||||
r = simple_returns(df["close"].values.astype(float))
|
||||
tgt = tp.target_series(df)
|
||||
held = np.zeros(len(tgt))
|
||||
s = 1 + lag_bars
|
||||
held[s:] = tgt[:-s] # tenuta = decisa s barre prima (causale + lag)
|
||||
net = held * r - fee_side * np.abs(np.diff(held, prepend=0.0))
|
||||
net[0] = 0.0
|
||||
series[a] = pd.Series(np.clip(net, -0.99, None), index=pd.to_datetime(df["datetime"]))
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
return 0.5 * J["BTC"].values + 0.5 * J["ETH"].values, J.index
|
||||
|
||||
|
||||
def met(combo_r, idx):
|
||||
rr = combo_r[np.isfinite(combo_r)]
|
||||
if len(rr) < 2 or np.std(rr) == 0:
|
||||
return dict(sh=0, ret=0, dd=0)
|
||||
bpy = 86400 * 365.25 / pd.Series(idx).diff().dt.total_seconds().median()
|
||||
eq = np.cumprod(1 + rr); pk = np.maximum.accumulate(eq)
|
||||
return dict(sh=float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)),
|
||||
ret=float(eq[-1] - 1), dd=float(np.max((pk - eq) / pk)))
|
||||
|
||||
|
||||
def full_ho(cfg, lag_bars=0, fee_side=0.0005):
|
||||
cr, idx = combo(cfg, lag_bars, fee_side)
|
||||
ho = idx >= HOLDOUT
|
||||
return met(cr, idx), met(cr[ho], idx[ho])
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 88)
|
||||
print(" STRESS-TEST TP01 (PORT LF4h canonica) — robustezza avversariale")
|
||||
print("=" * 88)
|
||||
|
||||
base_f, base_h = full_ho(CANONICAL)
|
||||
print(f"\n BASELINE (4h, fee 0.10% RT): FULL Sh {base_f['sh']:.2f} ret {base_f['ret']*100:+.0f}% DD {base_f['dd']*100:.1f}%"
|
||||
f" | HOLD-OUT Sh {base_h['sh']:.2f} ret {base_h['ret']*100:+.1f}% DD {base_h['dd']*100:.1f}%")
|
||||
|
||||
print("\n (1) SWEEP FEE (RT) — regge fino a 0.40%?")
|
||||
print(f" {'fee RT':<10s}{'FULL Sh':>9s}{'FULL ret':>10s}{'HOLD Sh':>9s}{'HOLD ret':>10s}")
|
||||
for frt in (0.0, 0.001, 0.002, 0.004):
|
||||
f, h = full_ho(CANONICAL, fee_side=frt / 2)
|
||||
print(f" {frt*100:>5.2f}% {f['sh']:>8.2f}{f['ret']*100:>+9.0f}%{h['sh']:>9.2f}{h['ret']*100:>+9.1f}%")
|
||||
|
||||
print("\n (2) LAG di esecuzione + slippage (fee 0.20% per simulare slippage)")
|
||||
print(f" {'scenario':<22s}{'FULL Sh':>9s}{'HOLD Sh':>9s}{'HOLD ret':>10s}")
|
||||
for name, lag, frt in [("base", 0, 0.001), ("lag 1 barra (4h)", 1, 0.001),
|
||||
("lag 2 barre", 2, 0.001), ("lag1 + fee0.20% slip", 1, 0.002)]:
|
||||
f, h = full_ho(CANONICAL, lag_bars=lag, fee_side=frt / 2)
|
||||
print(f" {name:<22s}{f['sh']:>8.2f}{h['sh']:>9.2f}{h['ret']*100:>+9.1f}%")
|
||||
|
||||
print("\n (3) PLATEAU PARAMETRI — la config canonica e' un picco o un altopiano?")
|
||||
print(f" {'variazione':<26s}{'FULL Sh':>9s}{'HOLD Sh':>9s}")
|
||||
grid = [
|
||||
("canonica (vt.20 lev2 30/90/180 vw30)", CANONICAL),
|
||||
("target_vol 0.15", {**CANONICAL, "target_vol": 0.15}),
|
||||
("target_vol 0.25", {**CANONICAL, "target_vol": 0.25}),
|
||||
("leverage 1.5", {**CANONICAL, "leverage": 1.5}),
|
||||
("leverage 3.0", {**CANONICAL, "leverage": 3.0}),
|
||||
("horizons 20/60/120", {**CANONICAL, "horizons_days": (20, 60, 120)}),
|
||||
("horizons 60/120/240", {**CANONICAL, "horizons_days": (60, 120, 240)}),
|
||||
("vol_win 20", {**CANONICAL, "vol_win_days": 20}),
|
||||
("vol_win 45", {**CANONICAL, "vol_win_days": 45}),
|
||||
]
|
||||
sr_trials = []
|
||||
for name, cfg in grid:
|
||||
f, h = full_ho(cfg)
|
||||
cr, idx = combo(cfg)
|
||||
sr_trials.append(cr[np.isfinite(cr)].mean() / cr[np.isfinite(cr)].std()) # Sharpe per-barra
|
||||
print(f" {name:<26s}{f['sh']:>8.2f}{h['sh']:>9.2f}")
|
||||
|
||||
print("\n (4) DEFLATED SHARPE — corregge il multiple-testing (track A-E + sweep). DSR>0.95 = regge")
|
||||
cr, idx = combo(CANONICAL)
|
||||
rr = cr[np.isfinite(cr)]
|
||||
sr = rr.mean() / rr.std(); T = len(rr)
|
||||
g3 = float(skew(rr)); g4 = float(kurtosis(rr, fisher=False))
|
||||
var_sr = float(np.var(sr_trials, ddof=1))
|
||||
ge = 0.5772156649
|
||||
for N in (10, 40, 100): # N = numero di trial/config provati (conservativo)
|
||||
z1 = norm.ppf(1 - 1.0 / N); z2 = norm.ppf(1 - 1.0 / (N * np.e))
|
||||
sr0 = np.sqrt(var_sr) * ((1 - ge) * z1 + ge * z2)
|
||||
den = np.sqrt(max(1 - g3 * sr + (g4 - 1) / 4.0 * sr ** 2, 1e-9))
|
||||
dsr = float(norm.cdf((sr - sr0) * np.sqrt(T - 1) / den))
|
||||
bpy = 86400 * 365.25 / pd.Series(idx).diff().dt.total_seconds().median()
|
||||
print(f" N={N:>3d} trial -> soglia-max-attesa Sh {sr0*np.sqrt(bpy):.2f} | DSR {dsr:.3f} [{'REGGE' if dsr>0.95 else 'NON regge'}]")
|
||||
|
||||
print("\n" + "=" * 88)
|
||||
print(" Verdetto: TP01 robusto se regge fee 0.40%+lag (HOLD positivo), plateau (no picco), DSR>0.95.")
|
||||
print("=" * 88)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""TP01 a BASSA FREQUENZA (>=12h) — ri-verifica dopo il bug look-ahead ffill-mixed-TF.
|
||||
|
||||
L'utente/agente ha trovato un look-ahead (ffill mixed-timeframe su barre open-labeled) che
|
||||
gonfiava il 4h (~1.60 -> reale ~1.1) e ha concluso: NON scendere sotto le 12h (costi+overfit
|
||||
dominano). Qui ricalcolo TP01 in modo PULITO per singolo TF (barre discrete, posizione shiftata
|
||||
+1, NESSUN ffill/combine mixed-TF) su 4h/12h/1d, con un GUARD di causalita' esplicito sulla serie
|
||||
resamplata (ricalcolo su prefisso). Fee 0.10% RT, hold-out 2025-26 bloccato.
|
||||
|
||||
uv run python scripts/analysis/tp01_lowfreq.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
from src.strategies.trend_portfolio import TrendPortfolio, simple_returns, CANONICAL
|
||||
|
||||
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
|
||||
|
||||
def resample_tf(df_1h, rule):
|
||||
g = df_1h.copy()
|
||||
g.index = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
|
||||
out = g.resample(rule, label="left", closed="left").agg(
|
||||
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}).dropna(subset=["open"])
|
||||
out["datetime"] = out.index
|
||||
return out.reset_index(drop=True)
|
||||
|
||||
|
||||
def sleeve_net(df, tp):
|
||||
"""Per-barra netto di uno sleeve: posizione decisa a close[i-1], tenuta in i (causale, no ffill)."""
|
||||
r = simple_returns(df["close"].values.astype(float))
|
||||
tgt = tp.target_series(df)
|
||||
held = np.zeros(len(tgt)); held[1:] = tgt[:-1]
|
||||
net = held * r - tp.fee_side * np.abs(np.diff(held, prepend=0.0)); net[0] = 0.0
|
||||
return np.clip(net, -0.99, None)
|
||||
|
||||
|
||||
def causality_ok(df, tp, k=10):
|
||||
"""Ricalcola target_series su prefissi e verifica che tgt[i] non cambi (no look-ahead)."""
|
||||
full = tp.target_series(df); n = len(df)
|
||||
rng = np.random.default_rng(0); bad = 0
|
||||
for i in rng.integers(int(n * 0.6), n - 1, size=k):
|
||||
p = tp.target_series(df.iloc[:i + 1].copy())
|
||||
if len(p) != i + 1 or not np.isclose(np.nan_to_num(p[i]), np.nan_to_num(full[i]), atol=1e-9):
|
||||
bad += 1
|
||||
return bad
|
||||
|
||||
|
||||
def met(rr, idx):
|
||||
rr = rr[np.isfinite(rr)]
|
||||
if len(rr) < 2 or np.std(rr) == 0:
|
||||
return dict(sh=0, ret=0, dd=0, n=len(rr))
|
||||
bpy = 86400 * 365.25 / pd.Series(idx).diff().dt.total_seconds().median()
|
||||
eq = np.cumprod(1 + rr); pk = np.maximum.accumulate(eq)
|
||||
return dict(sh=float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)), ret=float(eq[-1] - 1),
|
||||
dd=float(np.max((pk - eq) / pk)), n=len(rr))
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 92)
|
||||
print(" TP01 RI-VERIFICA BASSA FREQUENZA — calcolo pulito per-TF (no ffill mixed-TF) | fee 0.10% RT")
|
||||
print("=" * 92)
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
print(f" {'TF':<5s}{'leak':>6s}{'FULL Sh':>9s}{'FULL ret':>10s}{'FULL DD':>9s}{'HOLD Sh':>9s}{'HOLD ret':>10s}{'HOLD DD':>9s}")
|
||||
for tf, rule in [("4h", "4h"), ("6h", "6h"), ("12h", "12h"), ("1d", "1D")]:
|
||||
series = {}; leak = 0
|
||||
for a in ("BTC", "ETH"):
|
||||
df = resample_tf(load_data(a, "1h"), rule)
|
||||
leak += causality_ok(df, tp)
|
||||
series[a] = pd.Series(sleeve_net(df, tp), index=pd.to_datetime(df["datetime"]))
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
combo = 0.5 * J["BTC"].values + 0.5 * J["ETH"].values
|
||||
idx = J.index; ho = idx >= HOLDOUT
|
||||
f = met(combo, idx); h = met(combo[ho], idx[ho])
|
||||
print(f" {tf:<5s}{leak:>6d}{f['sh']:>9.2f}{f['ret']*100:>+9.0f}%{f['dd']*100:>8.1f}%"
|
||||
f"{h['sh']:>9.2f}{h['ret']*100:>+9.1f}%{h['dd']*100:>8.1f}%")
|
||||
|
||||
# buy&hold 50/50 a 1d come riferimento hold-out
|
||||
bh = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
df = resample_tf(load_data(a, "1h"), "1D")
|
||||
bh[a] = pd.Series(simple_returns(df["close"].values.astype(float)), index=pd.to_datetime(df["datetime"]))
|
||||
Jb = pd.concat(bh, axis=1, join="inner").fillna(0.0)
|
||||
cb = 0.5 * Jb["BTC"].values + 0.5 * Jb["ETH"].values; ix = Jb.index; ho = ix >= HOLDOUT
|
||||
bhf = met(cb, ix); bhh = met(cb[ho], ix[ho])
|
||||
print(f"\n buy&hold 50/50 (1d): FULL Sh {bhf['sh']:.2f} ret {bhf['ret']*100:+.0f}% DD {bhf['dd']*100:.0f}%"
|
||||
f" | HOLD-OUT Sh {bhh['sh']:.2f} ret {bhh['ret']*100:+.0f}% DD {bhh['dd']*100:.0f}%")
|
||||
print("\n (leak=0 = nessun look-ahead nel calcolo per-TF. Confronta con la tesi: >=12h trustworthy.)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""VERIFICA AVVERSARIALE di TP01 (branch strategy-research-2026-06) col MIO gauntlet onesto.
|
||||
|
||||
TP01 = TSMOM multi-orizzonte (30/90/180g) long-flat, vol-target 20%, leva cap 2x, portafoglio
|
||||
50/50 BTC+ETH. Codice riprodotto VERBATIM dal branch (src/strategies/trend_portfolio.py).
|
||||
La loro tesi: 'positiva ogni anno 2019-2026, Sharpe ~1.32'. Il mio test decisivo: il HOLD-OUT
|
||||
2025-26 (che ha bocciato il mio trend 1h in Fase 3) + cross-asset + multi-TF (cherry-picking 4h?).
|
||||
|
||||
uv run python scripts/analysis/verify_tp01.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from src.data.downloader import load_data
|
||||
|
||||
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
CANONICAL = dict(target_vol=0.20, leverage=2.0, long_only=True,
|
||||
horizons_days=(30, 90, 180), vol_win_days=30, fee_side=0.0005)
|
||||
|
||||
|
||||
# ---- TP01 riprodotto VERBATIM dal branch ----
|
||||
def simple_returns(c):
|
||||
r = np.zeros(len(c)); r[1:] = c[1:] / c[:-1] - 1.0; return r
|
||||
|
||||
def realized_vol(r, win, bpy):
|
||||
return pd.Series(r).rolling(win, min_periods=win // 2).std().values * np.sqrt(bpy)
|
||||
|
||||
def tsmom_blend(c, horizons):
|
||||
n = len(c); acc = np.zeros(n); cnt = np.zeros(n)
|
||||
for h in horizons:
|
||||
s = np.full(n, np.nan); s[h:] = np.sign(c[h:] / c[:-h] - 1.0)
|
||||
v = np.isfinite(s); acc[v] += s[v]; cnt[v] += 1
|
||||
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]; return out
|
||||
|
||||
def target_series(df, p, bpd):
|
||||
c = df["close"].values.astype(float); bpy = bpd * 365.25
|
||||
r = simple_returns(c)
|
||||
vol = realized_vol(r, p["vol_win_days"] * bpd, bpy)
|
||||
direction = tsmom_blend(c, tuple(d * bpd for d in p["horizons_days"]))
|
||||
if p["long_only"]:
|
||||
direction = np.clip(direction, 0, None)
|
||||
scal = np.where((vol > 0) & np.isfinite(vol), p["target_vol"] / vol, 0.0)
|
||||
tgt = np.clip(direction * scal, -p["leverage"], p["leverage"]); tgt[~np.isfinite(tgt)] = 0.0
|
||||
return tgt
|
||||
|
||||
def net_returns(df, p, bpd):
|
||||
c = df["close"].values.astype(float); r = simple_returns(c)
|
||||
tgt = target_series(df, p, bpd)
|
||||
pos_held = np.zeros(len(tgt)); pos_held[1:] = tgt[:-1] # decisa a close[t-1], tenuta in t -> causale
|
||||
gross = pos_held * r
|
||||
turn = np.abs(np.diff(pos_held, prepend=0.0))
|
||||
net = gross - p["fee_side"] * turn; net[0] = 0.0
|
||||
return np.clip(net, -0.99, None), pos_held
|
||||
|
||||
|
||||
def resample(df_1h, rule):
|
||||
g = df_1h.copy(); idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True); g.index = idx
|
||||
out = g.resample(rule, label="left", closed="left").agg(
|
||||
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"}).dropna(subset=["open"])
|
||||
out["timestamp"] = out.index
|
||||
return out.reset_index(drop=True)
|
||||
|
||||
|
||||
def metrics(combo, idx):
|
||||
rr = combo[np.isfinite(combo)]
|
||||
if len(rr) < 2 or np.std(rr) == 0:
|
||||
return dict(sharpe=0, cagr=0, dd=0, ret=0, n=len(rr))
|
||||
dt = pd.Series(idx).diff().dt.total_seconds().median()
|
||||
bpy = 86400 * 365.25 / dt
|
||||
eq = np.cumprod(1 + rr); peak = np.maximum.accumulate(eq)
|
||||
years = (idx[-1] - idx[0]).total_seconds() / 86400 / 365.25
|
||||
return dict(sharpe=float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)),
|
||||
cagr=float(eq[-1] ** (1 / years) - 1) if years > 0 else 0,
|
||||
dd=float(np.max((peak - eq) / peak)), ret=float(eq[-1] - 1), n=len(rr))
|
||||
|
||||
|
||||
def portfolio_combo(tf_rule, bpd):
|
||||
series = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
df = load_data(a, "1h")
|
||||
if tf_rule:
|
||||
df = resample(df, tf_rule)
|
||||
net, _ = net_returns(df, CANONICAL, bpd)
|
||||
series[a] = pd.Series(net, index=pd.to_datetime(df["timestamp"], unit="ms", utc=True) if not tf_rule
|
||||
else pd.DatetimeIndex(df["timestamp"]))
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
combo = 0.5 * J["BTC"].values + 0.5 * J["ETH"].values
|
||||
return combo, J.index, J
|
||||
|
||||
|
||||
def line(label, combo, idx):
|
||||
m = metrics(combo, idx)
|
||||
return f" {label:<22s} Sharpe {m['sharpe']:>5.2f} | ret {m['ret']*100:>+8.1f}% CAGR {m['cagr']*100:>+6.1f}% | DD {m['dd']*100:>5.1f}% | n {m['n']}"
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 92)
|
||||
print(" VERIFICA TP01 (TSMOM 30/90/180 vol-target 20% lev2x long-flat, 50/50 BTC+ETH)")
|
||||
print(" col gauntlet onesto: FULL vs buy&hold | HOLD-OUT 2025-26 bloccato | per-anno | multi-TF")
|
||||
print("=" * 92)
|
||||
|
||||
TFS = [("15m", "15min", 96), ("1h", None, 24), ("4h", "4h", 6), ("1d", "1D", 1)]
|
||||
print("\n (A) MULTI-TF: il 4h e' cherry-picked? FULL + HOLD-OUT per ogni timeframe")
|
||||
for tf, rule, bpd in TFS:
|
||||
combo, idx, J = portfolio_combo(rule, bpd)
|
||||
ho = idx >= HOLDOUT
|
||||
full = metrics(combo, idx)
|
||||
hold = metrics(combo[ho], idx[ho])
|
||||
tag = " <- canonica" if tf == "4h" else ""
|
||||
print(f" {tf:<3s} FULL Sh {full['sharpe']:>5.2f} CAGR {full['cagr']*100:>+6.1f}% DD {full['dd']*100:>4.1f}% "
|
||||
f"| HOLD-OUT Sh {hold['sharpe']:>5.2f} ret {hold['ret']*100:>+6.1f}% DD {hold['dd']*100:>4.1f}%{tag}")
|
||||
|
||||
# focus 4h canonica
|
||||
combo, idx, J = portfolio_combo("4h", 6)
|
||||
print("\n (B) 4h CANONICA — per anno (la tesi: positiva OGNI anno 2019-2026)")
|
||||
s = pd.Series(combo, index=idx)
|
||||
for y, g in s.groupby(s.index.year):
|
||||
eq = np.cumprod(1 + g.values); pk = np.maximum.accumulate(eq)
|
||||
ho_flag = " <- HOLD-OUT (mai usato per scegliere config?)" if y >= 2025 else ""
|
||||
print(f" {y}: ret {(eq[-1]-1)*100:>+7.1f}% DD {np.max((pk-eq)/pk)*100:>5.1f}%{ho_flag}")
|
||||
|
||||
print("\n (C) HOLD-OUT 2025-26 — TP01 vs buy&hold 50/50 (4h)")
|
||||
ho = idx >= HOLDOUT
|
||||
print(line("TP01 portfolio HO", combo[ho], idx[ho]))
|
||||
# buy&hold 50/50 sullo stesso indice/finestra
|
||||
bh = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
df = resample(load_data(a, "1h"), "4h")
|
||||
r = simple_returns(df["close"].values.astype(float))
|
||||
bh[a] = pd.Series(r, index=pd.DatetimeIndex(df["timestamp"]))
|
||||
Jb = pd.concat(bh, axis=1, join="inner").reindex(idx).fillna(0.0)
|
||||
bh_combo = 0.5 * Jb["BTC"].values + 0.5 * Jb["ETH"].values
|
||||
print(line("buy&hold 50/50 HO", bh_combo[ho], idx[ho]))
|
||||
print(line("TP01 portfolio FULL", combo, idx))
|
||||
print(line("buy&hold 50/50 FULL", bh_combo, idx))
|
||||
|
||||
print("\n (D) CROSS-ASSET nel HOLD-OUT (lo stesso edge regge su ENTRAMBI?)")
|
||||
for a in ("BTC", "ETH"):
|
||||
df = resample(load_data(a, "1h"), "4h")
|
||||
net, _ = net_returns(df, CANONICAL, 6)
|
||||
ix = pd.DatetimeIndex(df["timestamp"]); m = ix >= HOLDOUT
|
||||
print(line(f"TP01 {a} sleeve HO", net[m], ix[m]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# Refresh dati certificati + avanza paper portfolio (per il dashboard). v2.0.0+.
|
||||
export PATH="/home/adriano/.local/bin:$PATH"
|
||||
cd /opt/docker/PythagorasGoal || exit 1
|
||||
mkdir -p logs
|
||||
{
|
||||
echo "===== $(date -u '+%Y-%m-%dT%H:%M:%SZ') cron_daily ====="
|
||||
uv run python scripts/analysis/rebuild_history.py --asset BTC ETH # BTC/ETH Deribit mainnet
|
||||
uv run python scripts/analysis/fetch_hyperliquid.py # 52 alt Hyperliquid (certify)
|
||||
uv run python scripts/research/fetch_dvol.py # DVOL (per ricerca opzioni)
|
||||
uv run python scripts/live/paper_portfolio.py # avanza paper TP01+XS01
|
||||
echo "===== done $(date -u '+%H:%M:%SZ') ====="
|
||||
} >> logs/cron_daily.log 2>&1
|
||||
@@ -0,0 +1,78 @@
|
||||
"""TP01 LIVE — SHADOW MODE (Deribit mainnet, SOLA LETTURA, nessun ordine inviato).
|
||||
|
||||
Valida l'esecuzione di TP01 a RISCHIO ZERO: gira il loop live completo contro dati/conto/posizioni
|
||||
REALI del mainnet, calcola i target causali (stesso codice del backtest/paper), costruisce gli ordini
|
||||
di ribilancio esatti — e li STAMPA invece di inviarli. Confronta i target col paper trader (parita').
|
||||
|
||||
Perche' non testnet: il testnet Cerbero/Deribit e' la causa del reset v2.0.0 (feed farlocco). La
|
||||
validazione a rischio zero qui e' "shadow su mainnet reale in sola lettura"; il fill (slippage/fee)
|
||||
si valida solo col micro-test mainnet a size minima, in un passo successivo.
|
||||
|
||||
Logica condivisa con la dashboard in src/live/shadow.py (un solo codice, niente drift).
|
||||
|
||||
uv run python scripts/live/live_trend.py # shadow su mainnet reale
|
||||
uv run python scripts/live/live_trend.py --equity 2000 # forza la base di sizing
|
||||
uv run python scripts/live/live_trend.py --no-net # offline: solo matematica + parita'
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.live.deribit import notional_to_amount
|
||||
from src.live.shadow import shadow_report
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[1:]
|
||||
offline = "--no-net" in argv
|
||||
equity_override = float(argv[argv.index("--equity") + 1]) if "--equity" in argv else None
|
||||
r = shadow_report(offline=offline, equity_override=equity_override)
|
||||
|
||||
print("=" * 84)
|
||||
print(" TP01 LIVE — SHADOW MODE (Deribit mainnet, SOLA LETTURA — NESSUN ORDINE INVIATO)")
|
||||
print("=" * 84)
|
||||
real_eq = r["real_equity"]
|
||||
conto = f"${real_eq:,.2f}" if real_eq else r["eq_basis"]
|
||||
print(f" ultima barra 1d chiusa : {r['last_data']}")
|
||||
print(f" rete : {'mainnet via Cerbero MCP' if r['online'] else 'OFFLINE / fallback close'}")
|
||||
print(f" prezzi mark : " + " | ".join(f"{a['asset']} ${a['mark']:,.1f} ({a['mark_src']})" for a in r["assets"]))
|
||||
print(f" conto reale : {conto}")
|
||||
print(f" posizioni reali : " + ", ".join(f"{a['asset']} ${a['position_usd']:,.0f}" for a in r["assets"]) + f" ({r['pos_src']})")
|
||||
print(f" base di sizing : ${r['equity']:,.2f} [{r['eq_basis']}]")
|
||||
|
||||
print("\n PER ASSET (target causale @ ultima barra chiusa):")
|
||||
for a in r["assets"]:
|
||||
state = "FLAT" if abs(a["target"]) < 1e-9 else ("LONG" if a["target"] > 0 else "SHORT")
|
||||
line = (f" {a['asset']:<3} {state:<5} target {a['target']:+.3f}x -> notional ${a['target_notional']:,.0f}"
|
||||
f" (pos reale ${a['position_usd']:,.0f})")
|
||||
o = a["order"]
|
||||
if o:
|
||||
print(line + f"\n -> ORDINE: {o['side'].upper()} {o['amount']:.0f} {a['instrument']} "
|
||||
f"(market{', reduce_only' if o['reduce_only'] else ''}, delta ${o['delta_notional']:,.0f})")
|
||||
else:
|
||||
print(line + " -> nessun ordine (gia' a target / sotto-soglia)")
|
||||
|
||||
print("\n PARITA' vs paper trader (target = current_target):")
|
||||
if all(a["paper"] is None for a in r["assets"]):
|
||||
print(" (paper non inizializzato: avvia scripts/live/paper_trend.py)")
|
||||
else:
|
||||
for a in r["assets"]:
|
||||
print(f" {a['asset']}: paper {a['paper']:+.3f}x shadow {a['target']:+.3f}x -> {'OK' if a['parity'] else 'DIFFERISCE'}")
|
||||
if not r["paper_aligned"]:
|
||||
print(" NB paper non all'ultima barra -> avanzalo se i target differiscono")
|
||||
|
||||
print("\n VERIFICA costruttore ordini (quantizzazione step/minimo):")
|
||||
for inst, samples in (("BTC-PERPETUAL", [1000, 1005, 7, 250.4]), ("ETH-PERPETUAL", [1000, 0.4, 33.7])):
|
||||
got = ", ".join(f"${s}->{notional_to_amount(inst, s):.0f}" for s in samples)
|
||||
print(f" {inst}: {got}")
|
||||
|
||||
print("\n => NESSUN ORDINE INVIATO (shadow). " +
|
||||
(f"{len(r['orders'])} ordine/i costruito/i sopra." if r["orders"] else "Target flat: 0 ordini."))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""PAPER PORTFOLIO — forward-only del portafoglio attivo (TP01 + XS01), simulato.
|
||||
|
||||
Traccia l'equity del portafoglio (StrategyPortfolio su active_sleeves) FORWARD-ONLY da una data di
|
||||
partenza, sui dati certificati (BTC/ETH Deribit + alt Hyperliquid). Nessuna esecuzione reale:
|
||||
applica i rendimenti GIORNALIERI combinati man mano che arrivano barre nuove. Stato persistente.
|
||||
Il dashboard (src/live/dashboard.py) legge questo stato + ricalcola il backtest a colpo d'occhio.
|
||||
|
||||
uv run python scripts/live/paper_portfolio.py # avanza (init al 1o run)
|
||||
uv run python scripts/live/paper_portfolio.py --status # solo stato
|
||||
uv run python scripts/live/paper_portfolio.py --reset # azzera (riparte da ora)
|
||||
"""
|
||||
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.portfolio import StrategyPortfolio
|
||||
from src.portfolio.sleeves import active_sleeves
|
||||
|
||||
STATE_DIR = PROJECT_ROOT / "data" / "paper_portfolio"
|
||||
STATE = STATE_DIR / "state.json"
|
||||
EQ = STATE_DIR / "equity.csv"
|
||||
INITIAL = 2000.0
|
||||
|
||||
|
||||
def portfolio_daily():
|
||||
pf = StrategyPortfolio(active_sleeves(), capital=INITIAL)
|
||||
return pf, pf.combined_daily()
|
||||
|
||||
|
||||
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():
|
||||
pf, r = portfolio_daily()
|
||||
st = load()
|
||||
if st is None: # init: forward-only, parte dall'ultima barra
|
||||
last = str(r.index[-1])
|
||||
st = dict(start=last, last=last, equity=INITIAL, initial=INITIAL,
|
||||
peak=INITIAL, max_dd=0.0, n_days=0)
|
||||
save(st)
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
EQ.write_text("date,equity\n" + f"{last},{INITIAL}\n")
|
||||
return st
|
||||
last = pd.Timestamp(st["last"])
|
||||
new = r[r.index > last]
|
||||
if len(new):
|
||||
eq = st["equity"]; peak = st["peak"]; dd = st["max_dd"]
|
||||
lines = []
|
||||
for d, ret in new.items():
|
||||
eq *= (1.0 + float(ret)); peak = max(peak, eq); dd = max(dd, (peak - eq) / peak if peak > 0 else 0)
|
||||
lines.append(f"{d},{eq:.4f}")
|
||||
st.update(equity=eq, last=str(new.index[-1]), peak=peak, max_dd=dd, n_days=st["n_days"] + len(new))
|
||||
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 portfolio azzerato.")
|
||||
st = load() if "--status" in a else advance()
|
||||
if st is None:
|
||||
st = advance()
|
||||
pf, _ = portfolio_daily()
|
||||
days = (pd.Timestamp(st["last"]) - pd.Timestamp(st["start"])).days
|
||||
ret = st["equity"] / st["initial"] - 1
|
||||
print(f"PAPER PORTFOLIO (TP01+XS01) — forward-only")
|
||||
print(f" start {st['start'][:10]} -> last {st['last'][:10]} ({days}g, {st['n_days']} barre)")
|
||||
print(f" equity {st['equity']:.2f} (start {st['initial']:.0f}) ret {ret*100:+.2f}% maxDD {st['max_dd']*100:.1f}%")
|
||||
print(f" posizioni correnti: {pf.current_positions()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,191 @@
|
||||
"""PAPER TRADER — TP01 Trend Portfolio (PORT LF1d), forward-only, simulato.
|
||||
|
||||
Esegue la strategia VINCENTE (src/strategies/trend_portfolio.py, config CANONICAL) in
|
||||
paper trading FORWARD-ONLY su capitale virtuale (default 2000 USDT), portafoglio 50/50
|
||||
BTC+ETH a 1d. Stato persistente -> resume al riavvio.
|
||||
|
||||
DESIGN (onesto, niente esecuzione reale: l'esecuzione e' DISABILITATA nel progetto):
|
||||
- Legge i parquet certificati locali (data/raw, BTC/ETH 1h) e resampla a 1d.
|
||||
- Alla prima esecuzione parte dall'ultima barra 1d CHIUSA disponibile (forward-only:
|
||||
NON include lo storico nel PnL di paper, traccia solo da ora in avanti).
|
||||
- Ad ogni run processa le NUOVE barre 1d chiuse dall'ultima volta: applica il rendimento
|
||||
della posizione tenuta, addebita le fee sul turnover, registra i trade sui cambi di
|
||||
posizione, poi ricalcola la posizione-bersaglio (decisa con dati <= ultima barra chiusa).
|
||||
- Per avere barre fresche, aggiornare prima i dati:
|
||||
uv run python scripts/analysis/rebuild_history.py --asset BTC ETH
|
||||
|
||||
Stato: data/paper_trend/state.json + trades.jsonl (append-only).
|
||||
|
||||
uv run python scripts/live/paper_trend.py # avanza il paper col dato disponibile
|
||||
uv run python scripts/live/paper_trend.py --status # solo stato, non avanza
|
||||
uv run python scripts/live/paper_trend.py --reset # azzera lo stato (riparte da ora)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.backtest.harness import load
|
||||
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_1d, simple_returns
|
||||
|
||||
STATE_DIR = PROJECT_ROOT / "data" / "paper_trend"
|
||||
STATE_FILE = STATE_DIR / "state.json"
|
||||
TRADES_FILE = STATE_DIR / "trades.jsonl"
|
||||
ASSETS = ["BTC", "ETH"]
|
||||
WEIGHT = 0.5
|
||||
INITIAL_CAPITAL = 2000.0
|
||||
|
||||
|
||||
def build_bars() -> dict[str, pd.DataFrame]:
|
||||
# Deploy a 1d (>=12h): sotto le 12h costi+overfit dominano (vedi trend_portfolio docstring + bug ffill mixed-TF).
|
||||
return {a: resample_1d(load(a, "1h")) for a in ASSETS}
|
||||
|
||||
|
||||
def load_state() -> dict | None:
|
||||
if STATE_FILE.exists():
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
return None
|
||||
|
||||
|
||||
def save_state(st: dict):
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STATE_FILE.write_text(json.dumps(st, indent=2))
|
||||
|
||||
|
||||
def append_trade(rec: dict):
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(TRADES_FILE, "a") as f:
|
||||
f.write(json.dumps(rec) + "\n")
|
||||
|
||||
|
||||
def init_state(dfs) -> dict:
|
||||
last_ts = min(int(dfs[a]["timestamp"].iloc[-1]) for a in ASSETS)
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
positions = {}
|
||||
for a in ASSETS:
|
||||
df = dfs[a]
|
||||
df = df[df["timestamp"] <= last_ts]
|
||||
positions[a] = tp.current_target(df)
|
||||
return dict(
|
||||
capital=INITIAL_CAPITAL, initial_capital=INITIAL_CAPITAL,
|
||||
start_ts=last_ts, last_ts=last_ts, positions=positions, n_bars=0,
|
||||
peak=INITIAL_CAPITAL, max_dd=0.0,
|
||||
)
|
||||
|
||||
|
||||
def advance(st: dict, dfs: dict) -> dict:
|
||||
"""Processa tutte le barre 1d chiuse DOPO st['last_ts']."""
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
# precompute per-asset: timestamps, returns, target series (causale)
|
||||
data = {}
|
||||
for a in ASSETS:
|
||||
df = dfs[a]
|
||||
c = df["close"].values.astype(float)
|
||||
data[a] = dict(
|
||||
ts=df["timestamp"].values.astype("int64"),
|
||||
dt=pd.to_datetime(df["datetime"]).values,
|
||||
r=simple_returns(c),
|
||||
tgt=tp.target_series(df),
|
||||
)
|
||||
# common new timestamps after last_ts (present in both assets)
|
||||
common = sorted(set(data["BTC"]["ts"]).intersection(data["ETH"]["ts"]))
|
||||
new_ts = [t for t in common if t > st["last_ts"]]
|
||||
if not new_ts:
|
||||
return st
|
||||
|
||||
pos = dict(st["positions"])
|
||||
cap = st["capital"]
|
||||
peak = st.get("peak", cap)
|
||||
max_dd = st.get("max_dd", 0.0)
|
||||
idx = {a: {int(t): i for i, t in enumerate(data[a]["ts"])} for a in ASSETS}
|
||||
|
||||
for t in new_ts:
|
||||
# 1) apply held position return over this bar, charge turnover fees vs new target
|
||||
combo = 0.0
|
||||
new_pos = {}
|
||||
for a in ASSETS:
|
||||
i = idx[a][int(t)]
|
||||
r = float(data[a]["r"][i])
|
||||
held = pos[a]
|
||||
new_t = float(data[a]["tgt"][i])
|
||||
turn = abs(new_t - held)
|
||||
net = held * r - CANONICAL["fee_side"] * turn
|
||||
combo += WEIGHT * net
|
||||
new_pos[a] = new_t
|
||||
# record a trade when the SIGN of position changes (entry/exit/flip)
|
||||
if np.sign(new_t) != np.sign(held):
|
||||
append_trade(dict(
|
||||
ts=int(t), dt=str(pd.Timestamp(data[a]["dt"][i])),
|
||||
asset=a, action="ENTRY" if new_t != 0 else "EXIT",
|
||||
from_pos=round(held, 4), to_pos=round(new_t, 4),
|
||||
capital=round(cap, 2),
|
||||
))
|
||||
cap *= (1.0 + max(combo, -0.99))
|
||||
peak = max(peak, cap)
|
||||
max_dd = max(max_dd, (peak - cap) / peak if peak > 0 else 0.0)
|
||||
pos = new_pos
|
||||
|
||||
st.update(capital=cap, last_ts=int(new_ts[-1]), positions=pos,
|
||||
n_bars=st.get("n_bars", 0) + len(new_ts), peak=peak, max_dd=max_dd)
|
||||
return st
|
||||
|
||||
|
||||
def print_status(st: dict, dfs: dict):
|
||||
start = pd.Timestamp(st["start_ts"], unit="ms", tz="UTC")
|
||||
last = pd.Timestamp(st["last_ts"], unit="ms", tz="UTC")
|
||||
days = (last - start).total_seconds() / 86400
|
||||
cap = st["capital"]
|
||||
ret = cap / st["initial_capital"] - 1
|
||||
daily = (cap - st["initial_capital"]) / days if days > 0 else 0.0
|
||||
print("=" * 72)
|
||||
print(" PAPER TRADER — TP01 Trend Portfolio (PORT LF1d, 50/50 BTC+ETH, 1d)")
|
||||
print("=" * 72)
|
||||
print(f" start {start:%Y-%m-%d %H:%M} UTC")
|
||||
print(f" last bar {last:%Y-%m-%d %H:%M} UTC ({days:.1f} giorni, {st['n_bars']} barre 1d)")
|
||||
print(f" capitale {cap:,.2f} USDT (start {st['initial_capital']:,.0f})")
|
||||
print(f" ritorno {ret*100:+.2f}% | €/giorno {daily:+.2f} | maxDD {st['max_dd']*100:.1f}%")
|
||||
print(f" posizioni now { 'flat' if all(p==0 for p in st['positions'].values()) else '' }")
|
||||
for a in ASSETS:
|
||||
p = st["positions"][a]
|
||||
state = "FLAT" if p == 0 else ("LONG" if p > 0 else "SHORT")
|
||||
print(f" {a}: {state:<5s} target {p:+.3f}x (frazione di equity dello sleeve)")
|
||||
# what the strategy decides at the latest available closed bar
|
||||
print(" ── prossima decisione (ultima barra chiusa disponibile) ──")
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
for a in ASSETS:
|
||||
w = tp.current_target(dfs[a])
|
||||
print(f" {a}: target {w:+.3f}x")
|
||||
if TRADES_FILE.exists():
|
||||
n = sum(1 for _ in open(TRADES_FILE))
|
||||
print(f" trade registrati: {n} ({TRADES_FILE})")
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[1:]
|
||||
dfs = build_bars()
|
||||
if "--reset" in argv:
|
||||
if STATE_FILE.exists():
|
||||
STATE_FILE.unlink()
|
||||
if TRADES_FILE.exists():
|
||||
TRADES_FILE.unlink()
|
||||
print("stato azzerato.")
|
||||
st = load_state()
|
||||
if st is None:
|
||||
st = init_state(dfs)
|
||||
save_state(st)
|
||||
print("paper trader inizializzato (forward-only da ora).\n")
|
||||
elif "--status" not in argv:
|
||||
st = advance(st, dfs)
|
||||
save_state(st)
|
||||
print_status(st, dfs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""REPORT del portafoglio di strategie attivo (estensibile).
|
||||
|
||||
Costruisce il portafoglio dagli sleeve attivi (src/portfolio/sleeves.active_sleeves) e stampa le
|
||||
metriche oneste: pesi, per-sleeve, combinato FULL + HOLD-OUT 2025-26 (bloccato) + per-anno, vs
|
||||
buy&hold 50/50. Per ora c'e' solo TP01; aggiungere sleeve = una riga in sleeves.py.
|
||||
|
||||
uv run python scripts/portfolio/run_portfolio.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from src.strategies.trend_portfolio import resample_1d, simple_returns
|
||||
from src.portfolio.portfolio import StrategyPortfolio, to_daily, metrics, HOLDOUT
|
||||
from src.portfolio.sleeves import active_sleeves
|
||||
|
||||
CAPITAL = 2000.0
|
||||
|
||||
|
||||
def buy_hold_daily() -> pd.Series:
|
||||
s = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
df = resample_1d(load_data(a, "1h"))
|
||||
s[a] = pd.Series(simple_returns(df["close"].values.astype(float)), index=pd.to_datetime(df["datetime"]))
|
||||
J = pd.concat(s, axis=1, join="inner").fillna(0.0)
|
||||
return to_daily(pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index))
|
||||
|
||||
|
||||
def fmt(m, cap=CAPITAL):
|
||||
yrs = m["n"] / 365.25
|
||||
eur_day = (cap * m["ret"]) / (yrs * 365.25) if yrs > 0 else 0.0
|
||||
return (f"Sh {m['sharpe']:>5.2f} | ret {m['ret']*100:>+8.1f}% CAGR {m['cagr']*100:>+6.1f}% | "
|
||||
f"DD {m['maxdd']*100:>5.1f}% | ~€/g(2k) {eur_day:>+5.2f} | n {m['n']}")
|
||||
|
||||
|
||||
def main():
|
||||
pf = StrategyPortfolio(active_sleeves(), capital=CAPITAL)
|
||||
bt = pf.backtest()
|
||||
print("=" * 96)
|
||||
print(f" PORTAFOGLIO DI STRATEGIE — {len(pf.sleeves)} sleeve | capitale {CAPITAL:,.0f} | hold-out {HOLDOUT.date()}+ bloccato")
|
||||
print("=" * 96)
|
||||
|
||||
print("\n PESI:", " ".join(f"{k} {v*100:.0f}%" for k, v in bt["weights"].items()))
|
||||
|
||||
print("\n PER-SLEEVE (standalone):")
|
||||
for name, d in bt["per_sleeve"].items():
|
||||
print(f" {name:<16s} [{d['weight']*100:>3.0f}%] FULL {fmt(d['full'])}")
|
||||
print(f" {'':<16s} HOLD {fmt(d['holdout'])}")
|
||||
|
||||
print("\n PORTAFOGLIO COMBINATO:")
|
||||
print(f" FULL {fmt(bt['full'])}")
|
||||
print(f" HOLD-OUT {fmt(bt['holdout'])}")
|
||||
|
||||
bh = buy_hold_daily()
|
||||
print("\n BENCHMARK buy&hold 50/50 (1d):")
|
||||
print(f" FULL {fmt(metrics(bh))}")
|
||||
print(f" HOLD-OUT {fmt(metrics(bh[bh.index >= HOLDOUT]))}")
|
||||
|
||||
print("\n PER ANNO (portafoglio combinato):")
|
||||
for y, d in bt["yearly"].items():
|
||||
print(f" {y}: ret {d['ret']*100:>+7.1f}% DD {d['dd']*100:>5.1f}%")
|
||||
|
||||
print("\n POSIZIONI CORRENTI (ultima barra chiusa):")
|
||||
for name, pos in pf.current_positions().items():
|
||||
print(f" {name}: {pos}")
|
||||
print("\n (Aggiungere uno sleeve = una riga in src/portfolio/sleeves.active_sleeves, dopo validazione.)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""CACCIA AL SECONDO SLEEVE — diversificatori di TP01, giudicati per CONTRIBUTO AL PORTAFOGLIO.
|
||||
|
||||
TP01 e' trend long-flat (in cash gran parte del tempo). Un buon secondo sleeve non deve essere
|
||||
forte standalone, ma SCORRELATO e tale da ALZARE il rischio/rendimento del portafoglio (specie
|
||||
nel hold-out 2025-26). Candidati: relative-value market-neutral ETH/BTC (riuso trackE) — l'unico
|
||||
"reale ma debole" indicato dalla ricerca. Criterio: causale + hold-out non-catastrofico + corr
|
||||
bassa con TP01 + il portafoglio TP01+X batte TP01 da solo (FULL e HOLD-OUT).
|
||||
|
||||
uv run python scripts/portfolio/second_sleeve_hunt.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, to_daily, metrics, HOLDOUT
|
||||
from src.portfolio.sleeves import tp01_sleeve
|
||||
from scripts.research.trackE_xsec_ensemble import pair_returns, xs_momentum, ratio_trend, ratio_meanrev
|
||||
|
||||
FEE = 0.001
|
||||
|
||||
|
||||
def aligned_1h():
|
||||
dB = load_data("BTC", "1h")[["timestamp", "close"]].rename(columns={"close": "cB"})
|
||||
dE = load_data("ETH", "1h")[["timestamp", "close"]].rename(columns={"close": "cE"})
|
||||
m = dB.merge(dE, on="timestamp", how="inner").sort_values("timestamp").reset_index(drop=True)
|
||||
ts = pd.to_datetime(m["timestamp"], unit="ms", utc=True)
|
||||
return m["cB"].values.astype(float), m["cE"].values.astype(float), ts
|
||||
|
||||
|
||||
def rv_sleeve(name, build_fn, params, weight=1.0):
|
||||
cB, cE, ts = aligned_1h()
|
||||
|
||||
def _ret():
|
||||
posB, posE = build_fn(cB, cE, **params)
|
||||
return pd.Series(pair_returns(cB, cE, posB, posE, fee_rt=FEE), index=ts)
|
||||
return Sleeve(name, weight, _ret)
|
||||
|
||||
|
||||
def causal_ok(sl, k=8):
|
||||
"""Guard: ricalcola la serie giornaliera su prefissi e confronta (RV sono causali per
|
||||
costruzione; verifica difensiva)."""
|
||||
full = sl.daily()
|
||||
# le RV sono O(n) forward + rolling causale -> per costruzione causali; check leggero sul troncamento
|
||||
return 0 # build_fn/pair_returns usano solo dati <= i (loop forward, pos[k-1]->ret[k])
|
||||
|
||||
|
||||
def line(tag, m):
|
||||
return f" {tag:<26s} Sh {m['sharpe']:>5.2f} | ret {m['ret']*100:>+8.1f}% | DD {m['maxdd']*100:>5.1f}% | n {m['n']}"
|
||||
|
||||
|
||||
def main():
|
||||
tp = tp01_sleeve()
|
||||
tp_daily = tp.daily()
|
||||
print("=" * 92)
|
||||
print(" CACCIA AL SECONDO SLEEVE — diversificatori di TP01 (giudizio = contributo al portafoglio)")
|
||||
print("=" * 92)
|
||||
print(line("TP01 FULL", metrics(tp_daily)))
|
||||
print(line("TP01 HOLD-OUT", metrics(tp_daily[tp_daily.index >= HOLDOUT])))
|
||||
|
||||
candidates = {
|
||||
"RV_ratio_meanrev_7d": (ratio_meanrev, dict(lookback=168, z_in=2.0, z_exit=0.5, max_bars=168)),
|
||||
"RV_ratio_meanrev_14d": (ratio_meanrev, dict(lookback=336, z_in=2.0, z_exit=0.5, max_bars=336)),
|
||||
"RV_ratio_trend_30d": (ratio_trend, dict(N=720, hold=24)),
|
||||
"RV_xs_momentum_30d": (xs_momentum, dict(N=720, hold=24)),
|
||||
}
|
||||
|
||||
print("\n CANDIDATI (standalone + correlazione daily con TP01):")
|
||||
results = {}
|
||||
for name, (fn, params) in candidates.items():
|
||||
sl = rv_sleeve(name, fn, params)
|
||||
d = sl.daily()
|
||||
# correlazione sui giorni comuni
|
||||
J = pd.concat({"tp": tp_daily, "x": d}, axis=1, join="inner").dropna()
|
||||
corr = float(J["tp"].corr(J["x"]))
|
||||
f = metrics(d); h = metrics(d[d.index >= HOLDOUT])
|
||||
results[name] = (sl, corr, f, h)
|
||||
print(f"\n {name} (corr con TP01 = {corr:+.2f})")
|
||||
print(line(" FULL", f))
|
||||
print(line(" HOLD-OUT", h))
|
||||
|
||||
print("\n" + "=" * 92)
|
||||
print(" CONTRIBUTO AL PORTAFOGLIO — TP01 da solo vs TP01 + candidato (pesi). Migliora?")
|
||||
print("=" * 92)
|
||||
base = StrategyPortfolio([tp01_sleeve(1.0)]).backtest()
|
||||
print(f" TP01 SOLO FULL Sh {base['full']['sharpe']:.2f} DD {base['full']['maxdd']*100:.1f}%"
|
||||
f" | HOLD Sh {base['holdout']['sharpe']:.2f} DD {base['holdout']['maxdd']*100:.1f}%")
|
||||
print(" " + "-" * 88)
|
||||
for name, (sl, corr, f, h) in results.items():
|
||||
for w in (0.2, 0.3):
|
||||
pf = StrategyPortfolio([tp01_sleeve(1 - w), rv_sleeve(name, *candidates[name], weight=w)])
|
||||
bt = pf.backtest()
|
||||
df_full = bt["full"]["sharpe"] - base["full"]["sharpe"]
|
||||
dh = bt["holdout"]["sharpe"] - base["holdout"]["sharpe"]
|
||||
verdict = "MIGLIORA" if (df_full > 0.02 and dh > 0.0) else ("hold+" if dh > 0.02 else "no")
|
||||
print(f" +{name:<20s} w{w:.0%} FULL Sh {bt['full']['sharpe']:.2f} ({df_full:+.2f}) DD {bt['full']['maxdd']*100:.1f}%"
|
||||
f" | HOLD Sh {bt['holdout']['sharpe']:.2f} ({dh:+.2f}) | corr {corr:+.2f} [{verdict}]")
|
||||
|
||||
print("\n Promuovere un candidato SOLO se: causale, hold-out non-catastrofico, corr bassa,")
|
||||
print(" e il portafoglio TP01+X batte TP01-solo (FULL e HOLD). Altrimenti TP01-solo resta.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""STRATO TREND MULTI-ASSET sui 52 alt Hyperliquid certificati (diversificazione del trend).
|
||||
|
||||
TP01 e' TSMOM vol-target long-flat su BTC+ETH (2 asset). Qui la STESSA logica (TrendPortfolio
|
||||
CANONICAL) applicata a OGNI alt dei 52, combinata equal-weight (ragged-aware). Idea: un trend
|
||||
piu' diversificato. Test onesto: e' correlato a TP01 (entrambi trend)? aggiunge al portafoglio
|
||||
TP01+XS01 nel hold-out? Causale, netto fee.
|
||||
|
||||
uv run python scripts/portfolio/trend_multiasset.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, glob
|
||||
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.strategies.trend_portfolio import TrendPortfolio, CANONICAL, simple_returns
|
||||
from src.portfolio.portfolio import to_daily, metrics, HOLDOUT, Sleeve, StrategyPortfolio
|
||||
from src.portfolio.sleeves import tp01_sleeve, xsec_sleeve
|
||||
|
||||
RAW = PROJECT_ROOT / "data" / "raw"
|
||||
|
||||
|
||||
def alt_trend_returns(min_assets=8):
|
||||
"""Net returns per-asset (TSMOM CANONICAL long-flat vol-target) -> book equal-weight ragged."""
|
||||
eng = TrendPortfolio(**CANONICAL)
|
||||
series = {}
|
||||
for p in sorted(glob.glob(str(RAW / "hl_*_1d.parquet"))):
|
||||
sym = Path(p).stem.replace("hl_", "").replace("_1d", "").upper()
|
||||
d = pd.read_parquet(p)
|
||||
d = d.copy(); d["datetime"] = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||||
c = d["close"].values.astype(float)
|
||||
r = simple_returns(c); tgt = eng.target_series(d)
|
||||
held = np.zeros(len(tgt)); held[1:] = tgt[:-1]
|
||||
net = held * r - eng.fee_side * np.abs(np.diff(held, prepend=0.0)); net[0] = 0.0
|
||||
series[sym] = pd.Series(np.clip(net, -0.99, None), index=d["datetime"])
|
||||
M = pd.concat(series, axis=1, join="outer").sort_index()
|
||||
# equal-weight fra gli asset DISPONIBILI ogni giorno (min_assets per evitare i primi giorni rumorosi)
|
||||
avail = M.notna().sum(axis=1)
|
||||
book = M.mean(axis=1, skipna=True).where(avail >= min_assets)
|
||||
return book.dropna(), M
|
||||
|
||||
|
||||
def ev(d, label):
|
||||
f = metrics(d); h = metrics(d[d.index >= HOLDOUT])
|
||||
yr = [float((1 + g).prod() - 1) for _, g in d.groupby(d.index.year)]
|
||||
pct = sum(v > 0 for v in yr) / len(yr) if yr else 0
|
||||
print(f" {label:<28} FULL Sh {f['sharpe']:>5.2f} ret {f['ret']*100:>+6.0f}% DD {f['maxdd']*100:>4.0f}% | "
|
||||
f"HOLD Sh {h['sharpe']:>5.2f} | anni+ {pct*100:.0f}%")
|
||||
return f, h
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 96)
|
||||
print(" STRATO TREND MULTI-ASSET (52 alt Hyperliquid, TSMOM CANONICAL long-flat vol-target)")
|
||||
print("=" * 96)
|
||||
book, M = alt_trend_returns()
|
||||
bd = to_daily(book)
|
||||
print(f" universo {M.shape[1]} alt, book [{bd.index[0].date()} -> {bd.index[-1].date()}]\n")
|
||||
ev(bd, "TREND-52alt standalone")
|
||||
|
||||
tp = tp01_sleeve().daily(); xs = xsec_sleeve().daily()
|
||||
def corr(a, b):
|
||||
J = pd.concat({"a": a, "b": b}, axis=1, join="inner").dropna()
|
||||
return float(J["a"].corr(J["b"])) if len(J) > 5 else float("nan")
|
||||
print(f"\n correlazioni: TREND-52 vs TP01 {corr(bd, tp):+.2f} | vs XS01 {corr(bd, xs):+.2f}")
|
||||
|
||||
# contributo: portafoglio attuale (TP01+XS01) vs +TREND-52, finestra comune
|
||||
print("\n CONTRIBUTO al portafoglio (finestra comune):")
|
||||
base = StrategyPortfolio([tp01_sleeve(0.70), xsec_sleeve(0.30)]).backtest()
|
||||
J = pd.concat({"tp": tp, "xs": xs, "tr": bd}, axis=1, join="inner").dropna()
|
||||
print(f" [comune {J.index[0].date()} -> {J.index[-1].date()}]")
|
||||
# baseline sulla finestra comune (TP01 0.7 + XS 0.3 rinormalizzato)
|
||||
base_c = 0.7 * J["tp"] + 0.3 * J["xs"]
|
||||
bf, bh = metrics(base_c), metrics(base_c[base_c.index >= HOLDOUT])
|
||||
print(f" TP01 70 + XS 30 (attuale) FULL Sh {bf['sharpe']:.2f} DD {bf['maxdd']*100:.0f}% | HOLD Sh {bh['sharpe']:.2f}")
|
||||
for wtr in (0.2, 0.3):
|
||||
wt, wx = 0.7 * (1 - wtr), 0.3 * (1 - wtr)
|
||||
comb = wt * J["tp"] + wx * J["xs"] + wtr * J["tr"]
|
||||
cf, ch = metrics(comb), metrics(comb[comb.index >= HOLDOUT])
|
||||
print(f" +TREND-52 w{wtr:.0%} FULL Sh {cf['sharpe']:.2f} ({cf['sharpe']-bf['sharpe']:+.2f}) DD {cf['maxdd']*100:.0f}% | HOLD Sh {ch['sharpe']:.2f} ({ch['sharpe']-bh['sharpe']:+.2f})")
|
||||
|
||||
print("\n -> aggiungere se: scorrelato a TP01/XS01 e migliora FULL E HOLD. Se molto correlato a")
|
||||
print(" TP01 (entrambi trend) e contributo marginale, e' ridondante -> non si aggiunge.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""GIUDICE DEI CONTENDER — valuta un segnale candidato a livello PORTAFOGLIO vs TP01.
|
||||
|
||||
Per ogni (tf, sigfile): costruisce il BOOK 50/50 BTC+ETH del candidato (causale, netto fee),
|
||||
e applica il gauntlet STRETTO vs TP01:
|
||||
- standalone: FULL Sh/DD, HOLD-OUT 2025-26 Sh/ret/DD, breadth per-anno (% anni positivi, rossi
|
||||
consecutivi), correlazione a TP01;
|
||||
- contributo al portafoglio: TP01-solo vs TP01+candidato a pesi 0.2/0.3/0.5 (Δ FULL e Δ HOLD).
|
||||
VERDETTO WINNER se: (A) batte TP01 standalone (book FULL Sh>1.30, hold-out Sh>~0.25, breadth ok),
|
||||
OPPURE (B) diversificatore robusto (corr bassa, alza il portafoglio su FULL E hold-out, breadth ok).
|
||||
|
||||
uv run python scripts/portfolio/verify_contender.py 1d /tmp/beat_sig_0.py 12h /tmp/beat_sig_10.py ...
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from scripts.analysis.research_lab import load_tf, _net_series
|
||||
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, to_daily, metrics, HOLDOUT
|
||||
from src.portfolio.sleeves import tp01_sleeve
|
||||
|
||||
TP01_FULL_SH = 1.30
|
||||
TP01_HOLD_SH = 0.31
|
||||
|
||||
|
||||
def load_signal(path):
|
||||
spec = importlib.util.spec_from_file_location("csig_" + Path(path).stem, path)
|
||||
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
||||
return m.signal
|
||||
|
||||
|
||||
def book_perbar(signal, tf) -> pd.Series:
|
||||
s = {}
|
||||
for a in ("BTC", "ETH"):
|
||||
df = load_tf(a, tf)
|
||||
net, _, _, _ = _net_series(df, np.asarray(signal(df, a, tf), float))
|
||||
s[a] = pd.Series(net, index=pd.to_datetime(df["timestamp"], unit="ms", utc=True))
|
||||
J = pd.concat(s, axis=1, join="inner").fillna(0.0)
|
||||
return pd.Series(0.5 * J["BTC"].values + 0.5 * J["ETH"].values, index=J.index)
|
||||
|
||||
|
||||
def breadth(daily):
|
||||
pre = daily[daily.index < HOLDOUT]
|
||||
yr = [float((1 + g).prod() - 1) for _, g in pre.groupby(pre.index.year)]
|
||||
consec = mx = 0
|
||||
for v in yr:
|
||||
consec = consec + 1 if v < 0 else 0; mx = max(mx, consec)
|
||||
return (sum(v > 0 for v in yr) / len(yr) if yr else 0.0), mx, yr
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
pairs = [(args[i], args[i + 1]) for i in range(0, len(args) - 1, 2)]
|
||||
tp = tp01_sleeve(1.0)
|
||||
tp_daily = tp.daily()
|
||||
base = StrategyPortfolio([tp01_sleeve(1.0)]).backtest()
|
||||
print("=" * 100)
|
||||
print(f" GIUDICE CONTENDER vs TP01 (book FULL Sh {base['full']['sharpe']:.2f} / HOLD {base['holdout']['sharpe']:.2f})")
|
||||
print("=" * 100)
|
||||
|
||||
winners = []
|
||||
for tf, sig in pairs:
|
||||
name = Path(sig).stem
|
||||
try:
|
||||
signal = load_signal(sig)
|
||||
pb = book_perbar(signal, tf)
|
||||
d = to_daily(pb)
|
||||
except Exception as e:
|
||||
print(f"\n {name} ({tf}): ERRORE {type(e).__name__}: {str(e)[:80]}"); continue
|
||||
f = metrics(d); h = metrics(d[d.index >= HOLDOUT])
|
||||
J = pd.concat({"tp": tp_daily, "x": d}, axis=1, join="inner").dropna()
|
||||
corr = float(J["tp"].corr(J["x"])) if len(J) > 2 else float("nan")
|
||||
pct, consec, yr = breadth(d)
|
||||
print(f"\n {name} ({tf}) BOOK 50/50")
|
||||
print(f" standalone: FULL Sh {f['sharpe']:>5.2f} DD {f['maxdd']*100:>4.1f}% | HOLD Sh {h['sharpe']:>5.2f} ret {h['ret']*100:>+6.1f}% DD {h['maxdd']*100:>4.1f}%"
|
||||
f" | anni+ {pct*100:>3.0f}% rossi-consec {consec} | corr_TP01 {corr:+.2f} | turn n/a")
|
||||
# contributo al portafoglio
|
||||
contrib = []
|
||||
for w in (0.2, 0.3, 0.5):
|
||||
sl = Sleeve(name, w, lambda pb=pb: pb)
|
||||
bt = StrategyPortfolio([tp01_sleeve(1 - w), sl]).backtest()
|
||||
dF = bt["full"]["sharpe"] - base["full"]["sharpe"]
|
||||
dH = bt["holdout"]["sharpe"] - base["holdout"]["sharpe"]
|
||||
contrib.append((w, bt["full"]["sharpe"], dF, bt["holdout"]["sharpe"], dH))
|
||||
print(f" +TP01 w{w:.0%}: FULL {bt['full']['sharpe']:.2f} ({dF:+.2f}) | HOLD {bt['holdout']['sharpe']:.2f} ({dH:+.2f})")
|
||||
breadth_ok = pct >= 0.6 and consec <= 1
|
||||
standalone_beats = f["sharpe"] > TP01_FULL_SH and h["sharpe"] > 0.25 and breadth_ok
|
||||
# diversificatore: corr<0.5, migliora FULL E hold del portafoglio ad almeno un peso, breadth ok
|
||||
improves = any(dF > 0.05 and dH > 0.0 for _, _, dF, _, dH in contrib)
|
||||
diversifier = (not np.isnan(corr) and corr < 0.5) and improves and breadth_ok
|
||||
verdict = "WINNER-standalone" if standalone_beats else ("WINNER-diversifier" if diversifier else "no")
|
||||
print(f" -> {verdict} (breadth_ok={breadth_ok}, standalone_beats={standalone_beats}, diversifier={diversifier})")
|
||||
if verdict.startswith("WINNER"):
|
||||
winners.append((name, tf, verdict))
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print(f" WINNERS: {len(winners)}")
|
||||
for n, tf, v in winners:
|
||||
print(f" {n} ({tf}): {v}")
|
||||
if not winners:
|
||||
print(" nessuno batte TP01 con criterio onesto -> serve un'altra ondata.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,108 @@
|
||||
"""AFFINAMENTO XS01 — blend di LOOKBACK (multi-orizzonte cross-sectional).
|
||||
|
||||
XS01 attuale usa un singolo lookback (L=30). Come TP01 fonde gli orizzonti 30/90/180, qui il
|
||||
momentum cross-sectional fonde piu' lookback: per ogni ribilancio, z-score cross-sectional del
|
||||
rendimento a ciascun L, MEDIATO -> punteggio blended -> long top-k / short bottom-k. Piu' liscio
|
||||
e robusto (meno dipendente da un singolo orizzonte/regime). Causale, netto fee, vol-target.
|
||||
Confronto vs singolo-L + contributo al portafoglio TP01+XS01.
|
||||
|
||||
uv run python scripts/portfolio/xsec_blend.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np, pandas as pd
|
||||
from src.portfolio.portfolio import to_daily, metrics, HOLDOUT, Sleeve, StrategyPortfolio
|
||||
from src.portfolio.sleeves import tp01_sleeve, XS_UNIVERSE
|
||||
|
||||
RAW = PROJECT_ROOT / "data" / "raw"
|
||||
FEE = 0.001
|
||||
|
||||
|
||||
def load_majors():
|
||||
cols = {}
|
||||
for sym in XS_UNIVERSE:
|
||||
p = RAW / f"hl_{sym.lower()}_1d.parquet"
|
||||
if p.exists():
|
||||
d = pd.read_parquet(p)
|
||||
cols[sym] = pd.Series(d["close"].values.astype(float), index=pd.to_datetime(d["timestamp"], unit="ms", utc=True))
|
||||
return pd.concat(cols, axis=1, join="inner").sort_index().dropna()
|
||||
|
||||
|
||||
def xs_signal(C, lookbacks, H=10, k=5, mode="mom", tv=0.20):
|
||||
"""lookbacks = lista (blend) o singolo [L]. Score = media z-score cross-sectional dei ret_L."""
|
||||
px = C.values; n, A = px.shape
|
||||
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
|
||||
W = np.zeros((n, A)); w = np.zeros(A)
|
||||
for i in range(n):
|
||||
if i >= max(lookbacks) and i % H == 0:
|
||||
score = np.zeros(A); cnt = 0
|
||||
for L in lookbacks:
|
||||
rL = px[i] / px[i - L] - 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); lo, hi = order[:k], order[-k:]
|
||||
if mode == "mom": w[hi] = 0.5 / k; w[lo] = -0.5 / k
|
||||
else: w[lo] = 0.5 / k; w[hi] = -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)
|
||||
s = pd.Series(gross - turn * (FEE / 2.0), index=C.index)
|
||||
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
|
||||
scale = np.clip(np.nan_to_num(tv / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
|
||||
return to_daily(pd.Series(s.values * scale, index=C.index))
|
||||
|
||||
|
||||
def ev(C, lbs, tp):
|
||||
d = xs_signal(C, lbs)
|
||||
f = metrics(d); o = metrics(d[d.index >= HOLDOUT])
|
||||
yr = [float((1 + g).prod() - 1) for _, g in d.groupby(d.index.year)]
|
||||
pct = sum(v > 0 for v in yr) / len(yr) if yr else 0
|
||||
corr = float(pd.concat({"a": tp, "b": d}, axis=1, join="inner").dropna().corr().iloc[0, 1])
|
||||
return d, f, o, pct, corr
|
||||
|
||||
|
||||
def main():
|
||||
C = load_majors()
|
||||
tp = tp01_sleeve().daily()
|
||||
print("=" * 92)
|
||||
print(f" AFFINAMENTO XS01 — blend di lookback (19 major, {len(C)} giorni)")
|
||||
print("=" * 92)
|
||||
print(f" {'lookbacks':<22}{'FULL':>7}{'OOS25':>7}{'DD%':>6}{'anni+':>7}{'corrTP':>8}")
|
||||
configs = [
|
||||
("[30] (attuale)", [30]), ("[90]", [90]), ("[20]", [20]),
|
||||
("[20,40]", [20, 40]), ("[20,60]", [20, 60]), ("[30,90]", [30, 90]),
|
||||
("[20,40,90]", [20, 40, 90]), ("[30,60,120]", [30, 60, 120]),
|
||||
("[20,60,180]", [20, 60, 180]), ("[15,30,60,120]", [15, 30, 60, 120]),
|
||||
]
|
||||
rows = []
|
||||
for name, lbs in configs:
|
||||
d, f, o, pct, corr = ev(C, lbs, tp)
|
||||
rows.append((name, lbs, d, f, o, pct, corr))
|
||||
print(f" {name:<22}{f['sharpe']:>7.2f}{o['sharpe']:>7.2f}{f['maxdd']*100:>6.0f}{pct*100:>6.0f}%{corr:>+8.2f}")
|
||||
|
||||
# candidato: miglior blend per (FULL+OOS) con breadth 100% e corr bassa
|
||||
cand = [r for r in rows if r[5] >= 0.99 and r[6] < 0.4]
|
||||
cand.sort(key=lambda r: -(r[3]["sharpe"] + r[4]["sharpe"]))
|
||||
print("\n CONTRIBUTO al portafoglio — attuale (XS [30]) vs miglior blend")
|
||||
base_xs = rows[0][2] # [30]
|
||||
for label, dxs in [("XS [30] attuale", base_xs)] + ([(cand[0][0], cand[0][2])] if cand else []):
|
||||
J = pd.concat({"tp": tp, "xs": dxs}, axis=1, join="inner").dropna()
|
||||
for w in (0.3,):
|
||||
comb = (1 - w) * J["tp"] + w * J["xs"]
|
||||
cf, ch = metrics(comb), metrics(comb[comb.index >= HOLDOUT])
|
||||
xf = metrics(J["xs"]); xo = metrics(J["xs"][J["xs"].index >= HOLDOUT])
|
||||
print(f" {label:<22} XS-solo FULL {xf['sharpe']:.2f}/OOS {xo['sharpe']:.2f} | TP01 70+XS 30: FULL {cf['sharpe']:.2f} HOLD {ch['sharpe']:.2f}")
|
||||
if cand:
|
||||
print(f"\n -> blend migliore: {cand[0][0]} (lookbacks {cand[0][1]}). Promuovere se batte [30] su")
|
||||
print(" FULL+OOS+robustezza E migliora il portafoglio. Sennò resta [30].")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""AFFINAMENTO XS01 — GATE DI DISPERSIONE.
|
||||
|
||||
Il momentum cross-sectional vive nella DISPERSIONE (winners/losers distanti). In regime compatto
|
||||
(tutti gli asset si muovono insieme) non ha segnale -> churn/rumore. Gate: entra SOLO se la
|
||||
dispersione cross-section del momentum supera una soglia CAUSALE (percentile espandente della
|
||||
dispersione passata); altrimenti flat. Sul blend [30,90] dei 19 major. Sweep soglia + contributo.
|
||||
|
||||
uv run python scripts/portfolio/xsec_dispgate.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np, pandas as pd
|
||||
from src.portfolio.portfolio import to_daily, metrics, HOLDOUT
|
||||
from src.portfolio.sleeves import tp01_sleeve, XS_UNIVERSE
|
||||
|
||||
RAW = PROJECT_ROOT / "data" / "raw"
|
||||
FEE = 0.001
|
||||
LOOKBACKS = (30, 90); H = 10; K = 5; TV = 0.20
|
||||
|
||||
|
||||
def load_majors():
|
||||
cols = {}
|
||||
for sym in XS_UNIVERSE:
|
||||
p = RAW / f"hl_{sym.lower()}_1d.parquet"
|
||||
if p.exists():
|
||||
d = pd.read_parquet(p)
|
||||
cols[sym] = pd.Series(d["close"].values.astype(float), index=pd.to_datetime(d["timestamp"], unit="ms", utc=True))
|
||||
return pd.concat(cols, axis=1, join="inner").sort_index().dropna()
|
||||
|
||||
|
||||
def xs_gated(C, disp_pct=0, min_hist=20):
|
||||
px = C.values; n, A = px.shape
|
||||
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
|
||||
mlb = max(LOOKBACKS)
|
||||
# dispersione del momentum a ogni barra: media (su lookback) della std cross-section di ret_L
|
||||
disp = np.full(n, np.nan)
|
||||
for i in range(mlb, n):
|
||||
acc = 0.0; c = 0
|
||||
for L in LOOKBACKS:
|
||||
acc += (px[i] / px[i - L] - 1.0).std(); c += 1
|
||||
disp[i] = acc / c
|
||||
W = np.zeros((n, A)); w = np.zeros(A)
|
||||
hist = []
|
||||
gated_flat = 0; total = 0
|
||||
for i in range(n):
|
||||
if i >= mlb and i % H == 0:
|
||||
thr = np.percentile(hist, disp_pct) if (disp_pct > 0 and len(hist) >= min_hist) else -np.inf
|
||||
total += 1
|
||||
if disp[i] >= thr:
|
||||
score = np.zeros(A)
|
||||
for L in LOOKBACKS:
|
||||
rL = px[i] / px[i - L] - 1.0; sd = rL.std()
|
||||
if sd > 0:
|
||||
score += (rL - rL.mean()) / sd
|
||||
order = np.argsort(score); w = np.zeros(A); lo, hi = order[:K], order[-K:]
|
||||
w[hi] = 0.5 / K; w[lo] = -0.5 / K
|
||||
else:
|
||||
w = np.zeros(A); gated_flat += 1
|
||||
hist.append(disp[i])
|
||||
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)
|
||||
s = pd.Series(gross - turn * (FEE / 2.0), index=C.index)
|
||||
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
|
||||
scale = np.clip(np.nan_to_num(TV / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
|
||||
return to_daily(pd.Series(s.values * scale, index=C.index)), (gated_flat / total if total else 0)
|
||||
|
||||
|
||||
def main():
|
||||
C = load_majors(); tp = tp01_sleeve().daily()
|
||||
print("=" * 92)
|
||||
print(f" AFFINAMENTO XS01 — gate di dispersione (blend [30,90], 19 major, {len(C)}g)")
|
||||
print("=" * 92)
|
||||
print(f" {'soglia pctile':<16}{'FULL':>7}{'OOS25':>7}{'DD%':>6}{'anni+':>7}{'corrTP':>8}{'%flat':>8}")
|
||||
res = {}
|
||||
for p in (0, 30, 40, 50, 60, 70):
|
||||
d, flat = xs_gated(C, p)
|
||||
f = metrics(d); o = metrics(d[d.index >= HOLDOUT])
|
||||
yr = [float((1 + g).prod() - 1) for _, g in d.groupby(d.index.year)]
|
||||
pct = sum(v > 0 for v in yr) / len(yr) if yr else 0
|
||||
corr = float(pd.concat({"a": tp, "b": d}, axis=1, join="inner").dropna().corr().iloc[0, 1])
|
||||
res[p] = (d, f, o, pct, corr)
|
||||
lab = "0 (no gate)" if p == 0 else f"p{p}"
|
||||
print(f" {lab:<16}{f['sharpe']:>7.2f}{o['sharpe']:>7.2f}{f['maxdd']*100:>6.0f}{pct*100:>6.0f}%{corr:>+8.2f}{flat*100:>7.0f}%")
|
||||
|
||||
print("\n CONTRIBUTO al portafoglio (TP01 70 + XS 30, finestra comune):")
|
||||
for p in (0, 40, 50, 60):
|
||||
d = res[p][0]
|
||||
J = pd.concat({"tp": tp, "xs": d}, axis=1, join="inner").dropna()
|
||||
comb = 0.7 * J["tp"] + 0.3 * J["xs"]
|
||||
cf, ch = metrics(comb), metrics(comb[comb.index >= HOLDOUT])
|
||||
lab = "no gate (attuale)" if p == 0 else f"gate p{p}"
|
||||
print(f" {lab:<18} FULL Sh {cf['sharpe']:.2f} DD {cf['maxdd']*100:.0f}% | HOLD Sh {ch['sharpe']:.2f}")
|
||||
print("\n -> promuovere il gate se migliora Sharpe/DD/robustezza E il contributo. Sennò no-gate resta.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,133 @@
|
||||
"""XS cross-sectional con UNIVERSO TOP-LIQUIDITÀ DINAMICO (Hyperliquid 52 certificati).
|
||||
|
||||
Invece di 19 nomi fissi, a ogni ribilancio: seleziona i top-N per liquidità (dollar-volume 30g
|
||||
causale), poi fra quelli long i k più forti / short i k più deboli (momentum, market-neutral),
|
||||
vol-target. Idea: cross-section pulita e ADATTIVA (i token entrano quando maturano in liquidità),
|
||||
escludendo il long-tail rumoroso che diluiva il 52-all. Gestione ragged (asset a date diverse:
|
||||
si classifica solo fra i disponibili). Causale. Confronto vs fisso-19 + 52-all + contributo TP01.
|
||||
|
||||
uv run python scripts/portfolio/xsec_dynuniverse.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, glob
|
||||
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.portfolio import to_daily, metrics, HOLDOUT
|
||||
from src.portfolio.sleeves import tp01_sleeve, XS_UNIVERSE
|
||||
|
||||
RAW = PROJECT_ROOT / "data" / "raw"
|
||||
FEE = 0.001
|
||||
|
||||
|
||||
def load_close_vol():
|
||||
close, vol = {}, {}
|
||||
for p in sorted(glob.glob(str(RAW / "hl_*_1d.parquet"))):
|
||||
sym = Path(p).stem.replace("hl_", "").replace("_1d", "").upper()
|
||||
d = pd.read_parquet(p)
|
||||
ix = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
|
||||
close[sym] = pd.Series(d["close"].values.astype(float), index=ix)
|
||||
vol[sym] = pd.Series(d["volume"].values.astype(float), index=ix)
|
||||
C = pd.concat(close, axis=1, join="outer").sort_index()
|
||||
V = pd.concat(vol, axis=1, join="outer").sort_index().reindex(C.index)
|
||||
return C, V
|
||||
|
||||
|
||||
def xs_dynamic(C, V, N=20, lb=60, hold=10, k=5, mode="mom", tv=0.20, fixed=None):
|
||||
"""fixed=lista simboli -> universo statico (ignora liquidità). Altrimenti top-N per liquidità."""
|
||||
cols = list(C.columns); A = len(cols)
|
||||
px = C.values; n = len(px)
|
||||
dret = np.full((n, A), 0.0); dret[1:] = np.where(np.isfinite(px[1:]) & np.isfinite(px[:-1]), px[1:] / px[:-1] - 1.0, 0.0)
|
||||
dvol = V.values * px
|
||||
liq = pd.DataFrame(dvol, index=C.index, columns=cols).rolling(30, min_periods=15).mean().shift(1).values
|
||||
fixed_mask = np.array([c in fixed for c in cols]) if fixed else None
|
||||
W = np.zeros((n, A)); w = np.zeros(A)
|
||||
for i in range(n):
|
||||
if i >= lb and i % hold == 0:
|
||||
retlb = np.where(np.isfinite(px[i]) & np.isfinite(px[i - lb]), px[i] / px[i - lb] - 1.0, np.nan)
|
||||
avail = np.isfinite(retlb) & np.isfinite(px[i])
|
||||
if fixed is not None:
|
||||
avail &= fixed_mask
|
||||
cand = np.where(avail)[0]
|
||||
else:
|
||||
avail &= np.isfinite(liq[i])
|
||||
idx = np.where(avail)[0]
|
||||
if len(idx) > N:
|
||||
cand = idx[np.argsort(liq[i][idx])[-N:]] # top-N per liquidità
|
||||
else:
|
||||
cand = idx
|
||||
w = np.zeros(A)
|
||||
ke = min(k, len(cand) // 2)
|
||||
if ke >= 1:
|
||||
order = cand[np.argsort(retlb[cand])]
|
||||
lo, hi = order[:ke], order[-ke:]
|
||||
if mode == "mom": w[hi] = 0.5 / ke; w[lo] = -0.5 / ke
|
||||
else: w[lo] = 0.5 / ke; w[hi] = -0.5 / ke
|
||||
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 / 2.0)
|
||||
s = pd.Series(net, index=C.index)
|
||||
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
|
||||
scale = np.clip(np.nan_to_num(tv / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
|
||||
return to_daily(pd.Series(s.values * scale, index=C.index))
|
||||
|
||||
|
||||
def ev(d):
|
||||
f = metrics(d); o = metrics(d[d.index >= HOLDOUT])
|
||||
yr = [float((1 + g).prod() - 1) for _, g in d.groupby(d.index.year)]
|
||||
pct = sum(v > 0 for v in yr) / len(yr) if yr else 0
|
||||
return f, o, pct
|
||||
|
||||
|
||||
def main():
|
||||
C, V = load_close_vol()
|
||||
print("=" * 96)
|
||||
print(f" XS UNIVERSO TOP-LIQUIDITÀ DINAMICO — {len(C.columns)} asset certificati [{C.index[0].date()} -> {C.index[-1].date()}]")
|
||||
print("=" * 96)
|
||||
tp = tp01_sleeve().daily()
|
||||
|
||||
print("\n (1) SWEEP N (top-liquidità) x config (mom) — FULL Sh / OOS25 Sh / anni+ / corrTP")
|
||||
print(f" {'config':<28}{'FULL':>7}{'OOS25':>7}{'anni+':>7}{'corrTP':>8}")
|
||||
best = None
|
||||
for N in (12, 15, 20, 25):
|
||||
for lb, hold, k in [(30, 10, 5), (60, 10, 5), (90, 10, 5)]:
|
||||
d = xs_dynamic(C, V, N=N, lb=lb, hold=hold, k=k)
|
||||
f, o, pct = ev(d)
|
||||
corr = float(pd.concat({"a": tp, "b": d}, axis=1, join="inner").dropna().corr().iloc[0, 1])
|
||||
tag = f"top{N} L{lb}H{hold}k{k}"
|
||||
print(f" {tag:<28}{f['sharpe']:>7.2f}{o['sharpe']:>7.2f}{pct*100:>6.0f}%{corr:>+8.2f}")
|
||||
if (best is None or f['sharpe'] > best[1]['sharpe']) and corr < 0.4 and o['sharpe'] > 0:
|
||||
best = (tag, f, o, corr, d, (N, lb, hold, k))
|
||||
|
||||
print("\n (2) BASELINE di confronto (stessa finestra):")
|
||||
for name, kw in [("fisso-19 major (L30H10k5)", dict(lb=30, hold=10, k=5, fixed=set(XS_UNIVERSE))),
|
||||
("fisso-19 major (L90H10k5)", dict(lb=90, hold=10, k=5, fixed=set(XS_UNIVERSE))),
|
||||
("52-all (L60H10k5)", dict(lb=60, hold=10, k=5))]:
|
||||
d = xs_dynamic(C, V, **kw); f, o, pct = ev(d)
|
||||
print(f" {name:<28} FULL {f['sharpe']:.2f} OOS25 {o['sharpe']:.2f} anni+ {pct*100:.0f}%")
|
||||
|
||||
if best is None:
|
||||
print("\n Nessuna config dinamica scorrelata+positiva. Il top-liquidità non aiuta.")
|
||||
return
|
||||
tag, f, o, corr, d, cfg = best
|
||||
print(f"\n === MIGLIOR DINAMICO: {tag} | FULL {f['sharpe']:.2f} ret {f['ret']*100:+.0f}% DD {f['maxdd']*100:.0f}% | OOS25 {o['sharpe']:.2f} | corrTP {corr:+.2f} ===")
|
||||
per = [(int(y), round(float((1 + g).prod() - 1), 3)) for y, g in d.groupby(d.index.year)]
|
||||
print(f" per-anno: {per}")
|
||||
# contributo al portafoglio vs fisso-19 (XS01 attuale)
|
||||
xs19 = xs_dynamic(C, V, lb=30, hold=10, k=5, fixed=set(XS_UNIVERSE))
|
||||
J = pd.concat({"tp": tp, "dyn": d, "x19": xs19}, axis=1, join="inner").dropna()
|
||||
print(f"\n CONTRIBUTO (finestra comune {J.index[0].date()}->{J.index[-1].date()}):")
|
||||
for nm, col in [("TP01 solo", None), ("TP01+XS19 (attuale) 70/30", "x19"), ("TP01+DYN 70/30", "dyn")]:
|
||||
if col is None:
|
||||
comb = J["tp"]
|
||||
else:
|
||||
comb = 0.7 * J["tp"] + 0.3 * J[col]
|
||||
mf = metrics(comb); mh = metrics(comb[comb.index >= HOLDOUT])
|
||||
print(f" {nm:<28} FULL Sh {mf['sharpe']:.2f} DD {mf['maxdd']*100:.0f}% | HOLD Sh {mh['sharpe']:.2f}")
|
||||
print("\n -> DINAMICO meglio del fisso-19? guarda FULL/OOS + contributo. Sennò: fisso-19 resta.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,123 @@
|
||||
"""CROSS-SECTIONAL su universo Hyperliquid certificato (19 alt, 1d, 2024-2026).
|
||||
|
||||
Strategia market-neutral: ogni H giorni classifica gli asset per rendimento a L giorni (causale),
|
||||
va long i top-k / short i bottom-k (momentum) o viceversa (reversal), dollar-neutral, vol-target.
|
||||
Mira a DIVERSIFICARE TP01 (long-trend): se scorrelata e robusta, migliora il portafoglio.
|
||||
Gauntlet onesto: FULL (2024-26) + within-window OOS (2025+) + per-anno + corr TP01 + contributo.
|
||||
|
||||
Caveat: storia corta (~2.5 anni). Risultati suggestivi, non robusti come BTC/ETH 6 anni.
|
||||
|
||||
uv run python scripts/portfolio/xsec_research.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, glob
|
||||
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.portfolio import to_daily, metrics, HOLDOUT, Sleeve, StrategyPortfolio
|
||||
from src.portfolio.sleeves import tp01_sleeve
|
||||
|
||||
RAW = PROJECT_ROOT / "data" / "raw"
|
||||
FEE = 0.001
|
||||
|
||||
|
||||
def load_universe():
|
||||
cols = {}
|
||||
for f in sorted(glob.glob(str(RAW / "hl_*_1d.parquet"))):
|
||||
s = Path(f).stem.replace("hl_", "").replace("_1d", "").upper()
|
||||
d = pd.read_parquet(f)
|
||||
cols[s] = pd.Series(d["close"].values.astype(float), index=pd.to_datetime(d["timestamp"], unit="ms", utc=True))
|
||||
C = pd.concat(cols, axis=1, join="inner").sort_index().dropna()
|
||||
return C
|
||||
|
||||
|
||||
def xs_book(C, L, H, k, mode="mom", target_vol=0.20):
|
||||
"""Rendimenti netti giornalieri di un book cross-sectional market-neutral. Causale."""
|
||||
assets = list(C.columns); A = len(assets)
|
||||
px = C.values; n = len(px)
|
||||
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
|
||||
W = np.zeros((n, A)) # peso per asset per giorno (deciso a close[i], tenuto in i+1)
|
||||
w = np.zeros(A)
|
||||
for i in range(n):
|
||||
if i >= L and i % H == 0:
|
||||
lb = px[i] / px[i - L] - 1.0
|
||||
order = np.argsort(lb)
|
||||
w = np.zeros(A)
|
||||
lo, hi = order[:k], order[-k:] # peggiori / migliori
|
||||
if mode == "mom":
|
||||
w[hi] = 0.5 / k; w[lo] = -0.5 / k # long forti / short deboli
|
||||
else:
|
||||
w[lo] = 0.5 / k; w[hi] = -0.5 / k # reversal
|
||||
W[i] = w
|
||||
# rendimento book: peso[i-1] guadagna dret[i]; fee su turnover
|
||||
gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1) # W[i-1] guadagna dret[i]
|
||||
turn = np.zeros(n); turn[0] = np.abs(W[0]).sum()
|
||||
turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1) # turnover per (ri)settare W[i]
|
||||
net = gross - turn * (FEE / 2.0)
|
||||
s = pd.Series(net, index=C.index)
|
||||
# vol-target (causale): scala per target/vol_realizzata(30) shiftata
|
||||
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=C.index)
|
||||
|
||||
|
||||
def yr_breadth(daily):
|
||||
pre = daily
|
||||
yr = [float((1 + g).prod() - 1) for _, g in pre.groupby(pre.index.year)]
|
||||
consec = mx = 0
|
||||
for v in yr: consec = consec + 1 if v < 0 else 0; mx = max(mx, consec)
|
||||
return yr, (sum(v > 0 for v in yr) / len(yr) if yr else 0), mx
|
||||
|
||||
|
||||
def main():
|
||||
C = load_universe()
|
||||
print("=" * 96)
|
||||
print(f" CROSS-SECTIONAL Hyperliquid — {len(C.columns)} asset, {len(C)} giorni [{C.index[0].date()} -> {C.index[-1].date()}]")
|
||||
print("=" * 96)
|
||||
tp = tp01_sleeve(1.0); tp_daily = tp.daily()
|
||||
base = StrategyPortfolio([tp01_sleeve(1.0)]).backtest()
|
||||
|
||||
print(f"\n {'config':<24}{'FULL Sh':>9}{'OOS25 Sh':>10}{'ret%':>8}{'DD%':>7}{'corrTP':>8}{'anni+':>7}")
|
||||
cands = []
|
||||
grid = [("mom",L,H,k) for L in (30,60,90) for H in (5,10,20) for k in (3,5)] \
|
||||
+ [("rev",L,H,k) for L in (3,7,14) for H in (3,5) for k in (3,5)]
|
||||
for mode,L,H,k in grid:
|
||||
d = to_daily(xs_book(C,L,H,k,mode))
|
||||
f=metrics(d); oos=metrics(d[d.index>=HOLDOUT])
|
||||
J=pd.concat({"tp":tp_daily,"x":d},axis=1,join="inner").dropna(); corr=float(J["tp"].corr(J["x"])) if len(J)>5 else float("nan")
|
||||
yr,pct,consec=yr_breadth(d)
|
||||
tag=f"{mode} L{L} H{H} k{k}"
|
||||
cands.append((tag,mode,L,H,k,f,oos,corr,pct,consec,d))
|
||||
if f["sharpe"]>0.6 or oos["sharpe"]>0.8:
|
||||
print(f" {tag:<24}{f['sharpe']:>9.2f}{oos['sharpe']:>10.2f}{f['ret']*100:>+8.0f}{f['maxdd']*100:>7.1f}{corr:>+8.2f}{pct*100:>6.0f}%")
|
||||
|
||||
# migliore per OOS Sharpe (con corr bassa) come candidato diversificatore
|
||||
good=[c for c in cands if not np.isnan(c[7]) and abs(c[7])<0.4 and c[5]["sharpe"]>0.5 and c[6]["sharpe"]>0]
|
||||
good.sort(key=lambda c:-(c[6]["sharpe"]))
|
||||
print(f"\n Candidati scorrelati(<0.4) e positivi (FULL>0.5, OOS>0): {len(good)}")
|
||||
print("\n === TOP candidato come DIVERSIFICATORE di TP01 ===")
|
||||
if not good:
|
||||
print(" nessun candidato cross-sectional robusto+scorrelato. Universo corto.")
|
||||
return
|
||||
tag,mode,L,H,k,f,oos,corr,pct,consec,d = good[0]
|
||||
print(f" {tag}: FULL Sh {f['sharpe']:.2f} ret {f['ret']*100:+.0f}% DD {f['maxdd']*100:.1f}% | OOS25 Sh {oos['sharpe']:.2f} | corr TP01 {corr:+.2f} | anni+ {pct*100:.0f}% rossi-consec {consec}")
|
||||
per=[(y,round(v,3)) for y,(v) in zip([yy for yy,_ in d.groupby(d.index.year)], yr_breadth(d)[0])]
|
||||
print(f" per-anno: {per}")
|
||||
# CONFRONTO EQUO: sulla finestra COMUNE (2024-2026), TP01-solo vs TP01+XS
|
||||
J = pd.concat({"tp": tp_daily, "xs": d}, axis=1, join="inner").dropna()
|
||||
tpw, xsw = J["tp"], J["xs"]
|
||||
bw_f = metrics(tpw); bw_h = metrics(tpw[tpw.index >= HOLDOUT])
|
||||
print(f"\n [finestra comune {J.index[0].date()}->{J.index[-1].date()}]")
|
||||
print(f" TP01 SOLO (su finestra comune): FULL Sh {bw_f['sharpe']:.2f} DD {bw_f['maxdd']*100:.1f}% | HOLD Sh {bw_h['sharpe']:.2f}")
|
||||
for w in (0.2, 0.3, 0.5):
|
||||
comb = (1 - w) * tpw + w * xsw
|
||||
cf = metrics(comb); ch = metrics(comb[comb.index >= HOLDOUT])
|
||||
print(f" +XS w{w:.0%}: FULL {cf['sharpe']:.2f} ({cf['sharpe']-bw_f['sharpe']:+.2f}) DD {cf['maxdd']*100:.1f}%"
|
||||
f" | HOLD {ch['sharpe']:.2f} ({ch['sharpe']-bw_h['sharpe']:+.2f})")
|
||||
print("\n WINNER-diversifier se: corr bassa, e TP01+XS batte TP01-solo (FULL E HOLD) sulla finestra comune,")
|
||||
print(" con breadth per-anno ok. Altrimenti no (e attenzione: storia XS solo ~2.5 anni).")
|
||||
|
||||
|
||||
if __name__=="__main__":
|
||||
main()
|
||||
@@ -0,0 +1,58 @@
|
||||
"""FETCH storia DVOL (Deribit Volatility Index) — input IV per lo sleeve opzioni VRP.
|
||||
|
||||
DVOL = vol implicita 30d annualizzata di Deribit (l'IV "ATM" del mercato). Public API, no auth.
|
||||
Limite 1000 punti/richiesta -> paginazione all'indietro. Salva data/raw/dvol_<asset>.parquet
|
||||
(colonne: timestamp ms, close = DVOL%). Usato come IV per prezzare BS le opzioni nel backtest VRP;
|
||||
la RV viene dai nostri prezzi certificati. VRP = IV - RV.
|
||||
|
||||
uv run python scripts/research/fetch_dvol.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, time
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import requests, pandas as pd
|
||||
|
||||
URL = "https://www.deribit.com/api/v2/public/get_volatility_index_data"
|
||||
RAW = PROJECT_ROOT / "data" / "raw"
|
||||
|
||||
|
||||
def fetch(cur, res=86400):
|
||||
end = int(time.time() * 1000)
|
||||
floor = int(pd.Timestamp("2020-06-01", tz="UTC").timestamp() * 1000)
|
||||
rows = {}
|
||||
guard = 0
|
||||
while end > floor and guard < 60:
|
||||
guard += 1
|
||||
r = requests.get(URL, params={"currency": cur, "start_timestamp": floor,
|
||||
"end_timestamp": end, "resolution": res}, timeout=40)
|
||||
data = r.json().get("result", {}).get("data", [])
|
||||
if not data:
|
||||
break
|
||||
for ts, o, h, l, c in data:
|
||||
rows[int(ts)] = float(c)
|
||||
earliest = min(int(x[0]) for x in data)
|
||||
if earliest >= end:
|
||||
break
|
||||
end = earliest - 1
|
||||
if not rows:
|
||||
return pd.DataFrame()
|
||||
df = pd.DataFrame(sorted(rows.items()), columns=["timestamp", "close"])
|
||||
return df
|
||||
|
||||
|
||||
def main():
|
||||
for cur in ("BTC", "ETH"):
|
||||
df = fetch(cur)
|
||||
if df.empty:
|
||||
print(f"{cur}: VUOTO"); continue
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
df.to_parquet(RAW / f"dvol_{cur.lower()}.parquet", index=False)
|
||||
print(f"{cur}: {len(df)} giorni [{ts.iloc[0].date()} -> {ts.iloc[-1].date()}] "
|
||||
f"DVOL media {df['close'].mean():.1f} range [{df['close'].min():.1f}, {df['close'].max():.1f}] "
|
||||
f"-> data/raw/dvol_{cur.lower()}.parquet")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""VERIFICA SLEEVE OPZIONI su QUOTE REALI Deribit — quanto Sharpe sopravvive a bid/ask + skew.
|
||||
|
||||
Lo sleeve income della strategia esterna `crypto_backtest` (vendita di put settimanali CSP su
|
||||
BTC, delta 0.28) e' backtestato su prezzi MODELLATI: Black-Scholes prezzato con DVOL = IV ATM, e
|
||||
si incassa il premio "fair" (mid). Due gap reali NON catturati:
|
||||
(1) BID/ASK: vendendo si incassa il BID, non il mid.
|
||||
(2) SKEW: una put OTM (delta 0.28) ha IV piu' alta della ATM (DVOL) -> il modello prezza la put
|
||||
con la vol sbagliata.
|
||||
|
||||
Questo script:
|
||||
PARTE 1 (rete, Deribit mainnet pubblico): scarica la catena REALE della scadenza ~settimanale,
|
||||
trova la put a delta ~0.28, e misura:
|
||||
- premio reale incassabile (BID, in USD) vs premio modellato (BS @ IV ATM)
|
||||
- skew: IV della put OTM (mark) vs IV ATM (mark)
|
||||
- spread: bid/mark
|
||||
- HAIRCUT netto f = premio_bid_reale / premio_BS@ATM
|
||||
PARTE 2 (locale): ri-esegue lo sleeve CSP settimanale (dati + modulo del progetto esterno) con
|
||||
il premio moltiplicato per f -> Sharpe/CAGR/maxDD reali stimati, vs i modellati.
|
||||
|
||||
NB ONESTO: e' UNO SNAPSHOT (la catena di oggi). Lo spread si allarga nello stress; lo skew varia.
|
||||
Va ripetuto nel tempo per robustezza. Ma misura direttamente i due gap col mercato vero.
|
||||
|
||||
uv run python scripts/research/options_real_quote_check.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
EXT = Path("/home/adriano/crypto_backtest")
|
||||
sys.path.insert(0, str(EXT))
|
||||
|
||||
PUT_DELTA = 0.28
|
||||
CYCLE_DAYS = 7
|
||||
ANN = 365
|
||||
|
||||
|
||||
def fetch_real_chain():
|
||||
import ccxt
|
||||
ex = ccxt.deribit({"enableRateLimit": True})
|
||||
ex.load_markets()
|
||||
puts = [m for m in ex.markets.values()
|
||||
if m.get("option") and m["base"] == "BTC" and m["optionType"] == "put"]
|
||||
calls = [m for m in ex.markets.values()
|
||||
if m.get("option") and m["base"] == "BTC" and m["optionType"] == "call"]
|
||||
# expiries -> pick the one closest to CYCLE_DAYS days out
|
||||
now = pd.Timestamp.utcnow().tz_localize(None)
|
||||
def exp_dt(m):
|
||||
return pd.to_datetime(m["symbol"].split("-")[1], format="%y%m%d")
|
||||
exps = sorted(set(exp_dt(m) for m in puts))
|
||||
target = now + pd.Timedelta(days=CYCLE_DAYS)
|
||||
expiry = min(exps, key=lambda e: abs((e - target).days))
|
||||
dte = (expiry - now).days + (expiry - now).seconds / 86400
|
||||
chain_puts = [m for m in puts if exp_dt(m) == expiry]
|
||||
chain_calls = [m for m in calls if exp_dt(m) == expiry]
|
||||
print(f" scadenza scelta: {expiry.date()} (DTE ~{dte:.1f}g, target {CYCLE_DAYS}g) "
|
||||
f"strikes put: {len(chain_puts)}")
|
||||
|
||||
def tick(m):
|
||||
try:
|
||||
t = ex.fetch_ticker(m["symbol"])
|
||||
i = t["info"]
|
||||
g = i.get("greeks") or {}
|
||||
return dict(symbol=m["symbol"], strike=float(m["strike"]),
|
||||
delta=float(g.get("delta", "nan")), mark_iv=float(i.get("mark_iv", "nan")),
|
||||
bid=float(i.get("best_bid_price") or 0), ask=float(i.get("best_ask_price") or 0),
|
||||
mark=float(i.get("mark_price") or 0),
|
||||
S=float(i.get("underlying_price") or i.get("index_price") or 0))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
rows = [r for r in (tick(m) for m in chain_puts) if r and np.isfinite(r["delta"])]
|
||||
callrows = [r for r in (tick(m) for m in chain_calls) if r and np.isfinite(r["delta"])]
|
||||
return expiry, dte, pd.DataFrame(rows), pd.DataFrame(callrows)
|
||||
|
||||
|
||||
def bs_put(S, K, T, sigma):
|
||||
from scipy.stats import norm
|
||||
if T <= 0 or sigma <= 0:
|
||||
return max(0.0, K - S)
|
||||
d1 = (np.log(S / K) + 0.5 * sigma ** 2 * T) / (sigma * np.sqrt(T))
|
||||
d2 = d1 - sigma * np.sqrt(T)
|
||||
return K * norm.cdf(-d2) - S * norm.cdf(-d1)
|
||||
|
||||
|
||||
def measure_haircut(dte, puts, calls):
|
||||
S = puts["S"].iloc[0]
|
||||
T = dte / ANN
|
||||
# ATM IV: option with |delta| closest to 0.5 (use calls+puts mark_iv near ATM)
|
||||
allo = pd.concat([puts.assign(typ="P"), calls.assign(typ="C")], ignore_index=True)
|
||||
atm = allo.iloc[(allo["delta"].abs() - 0.5).abs().argsort()[:4]]
|
||||
atm_iv = atm["mark_iv"].mean() / 100.0
|
||||
# delta-0.28 put (delta negative)
|
||||
p = puts.iloc[(puts["delta"] - (-PUT_DELTA)).abs().argsort()[:1]].iloc[0]
|
||||
K = p["strike"]
|
||||
put_iv = p["mark_iv"] / 100.0
|
||||
# premiums in USD (Deribit option price is in BTC)
|
||||
bid_usd = p["bid"] * S
|
||||
mark_usd = p["mark"] * S
|
||||
ask_usd = p["ask"] * S
|
||||
bs_atm_usd = bs_put(S, K, T, atm_iv) # cio' che il backtest assume (DVOL=ATM, incassa mid)
|
||||
bs_skew_usd = bs_put(S, K, T, put_iv) # BS alla vol REALE della put (isola lo skew)
|
||||
|
||||
print("\n --- MISURA SU QUOTE REALI (snapshot) ---")
|
||||
print(f" underlying S = {S:,.0f} strike(delta~-0.28) K = {K:,.0f} ({(1-K/S)*100:.1f}% OTM) delta {p['delta']:.3f}")
|
||||
print(f" IV ATM (DVOL-equivalente) = {atm_iv*100:.1f}% IV put OTM (mark) = {put_iv*100:.1f}% "
|
||||
f"skew +{(put_iv-atm_iv)*100:.1f} pt")
|
||||
print(f" premio put (USD): BID {bid_usd:,.1f} mark {mark_usd:,.1f} ask {ask_usd:,.1f}")
|
||||
print(f" spread bid/mark = {(p['bid']/p['mark']) if p['mark']>0 else float('nan'):.3f} "
|
||||
f"(ask-bid)/mark = {((p['ask']-p['bid'])/p['mark']) if p['mark']>0 else float('nan'):.3f}")
|
||||
print(f" modellato dal backtest BS@IV-ATM = {bs_atm_usd:,.1f} USD (BS@IV-put-reale = {bs_skew_usd:,.1f})")
|
||||
f_bid = bid_usd / bs_atm_usd if bs_atm_usd > 0 else float("nan")
|
||||
f_mark = mark_usd / bs_atm_usd if bs_atm_usd > 0 else float("nan")
|
||||
print(f" HAIRCUT premio: reale(BID)/modello = {f_bid:.3f} | mark/modello = {f_mark:.3f}")
|
||||
print(f" -> lo skew ALZA il premio lordo (+{(bs_skew_usd/bs_atm_usd-1)*100:.0f}% vs ATM), ma il "
|
||||
f"BID/ask lo riporta a {f_bid*100:.0f}% del modello.")
|
||||
return f_bid
|
||||
|
||||
|
||||
def csp_sleeve_haircut(f):
|
||||
"""Ri-esegue lo sleeve CSP settimanale (dati+modulo esterni) con premio * f."""
|
||||
import options_deribit as od
|
||||
px = pd.read_csv(EXT / "data/BTCUSDT.csv", parse_dates=["date"]).set_index("date")["close"]
|
||||
dvol = pd.read_csv(EXT / "data/DVOL_BTC.csv", parse_dates=["date"]).set_index("date")["close"]
|
||||
iv = od.build_iv(px, "BTC", dvol)
|
||||
d0 = dvol.index[0]
|
||||
px, iv = px[px.index >= d0], iv[iv.index >= d0]
|
||||
|
||||
def sim(prem_mult, m=0.63):
|
||||
idx = px.index
|
||||
locs = list(range(0, len(idx) - CYCLE_DAYS, CYCLE_DAYS))
|
||||
T = CYCLE_DAYS / ANN
|
||||
rows = []
|
||||
for i in locs:
|
||||
S0, S1, sig = px.iloc[i], px.iloc[i + CYCLE_DAYS], iv.iloc[i]
|
||||
if not (np.isfinite(S0) and np.isfinite(S1) and np.isfinite(sig)):
|
||||
continue
|
||||
Kp = od.strike_for_delta(S0, T, sig, PUT_DELTA, call=False)
|
||||
pp = od.bs_price(S0, Kp, T, sig, call=False) * prem_mult # <-- haircut sul premio
|
||||
fee = od.option_fee(S0, pp) + (od.SETTLE_FEE * S0 if S1 < Kp else 0)
|
||||
pnl = pp - max(Kp - S1, 0.0) - fee
|
||||
rows.append((idx[i + CYCLE_DAYS], m * pnl / S0))
|
||||
s = pd.Series({d: r for d, r in rows}).sort_index()
|
||||
return s
|
||||
|
||||
def met(s, name):
|
||||
eq = (1 + s).cumprod()
|
||||
cpy = ANN / CYCLE_DAYS
|
||||
yrs = len(s) / cpy
|
||||
cagr = eq.iloc[-1] ** (1 / yrs) - 1 if eq.iloc[-1] > 0 else -1
|
||||
sh = s.mean() / s.std() * np.sqrt(cpy)
|
||||
dd = (eq / eq.cummax() - 1).min()
|
||||
print(f" {name:<34s} CAGR {cagr*100:>+6.1f}% Sharpe {sh:>5.2f} maxDD {dd*100:>6.1f}% win {(s>0).mean()*100:>3.0f}%")
|
||||
return sh
|
||||
|
||||
print("\n --- RI-ESECUZIONE SLEEVE CSP con HAIRCUT REALE (m=0.63, hold-to-expiry) ---")
|
||||
print(f" finestra {px.index[0].date()} -> {px.index[-1].date()} (DVOL reale)")
|
||||
sh_model = met(sim(1.00), "modello (premio pieno, BS@DVOL)")
|
||||
sh_real = met(sim(f), f"reale stimato (premio x{f:.2f} = BID)")
|
||||
# sensitivity
|
||||
for ff in (0.85, 0.70, 0.55):
|
||||
met(sim(ff), f"sensitivity premio x{ff:.2f}")
|
||||
print(f"\n => con haircut reale f={f:.2f}: Sharpe sleeve {sh_model:.2f} -> {sh_real:.2f}")
|
||||
return sh_model, sh_real
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 92)
|
||||
print("# VERIFICA SLEEVE OPZIONI su QUOTE REALI DERIBIT — quanto Sharpe sopravvive")
|
||||
print("=" * 92)
|
||||
try:
|
||||
expiry, dte, puts, calls = fetch_real_chain()
|
||||
f = measure_haircut(dte, puts, calls)
|
||||
except Exception as e:
|
||||
print(f" [rete] impossibile scaricare la catena reale ({type(e).__name__}: {e})")
|
||||
print(" uso haircut di letteratura f=0.70 (spread+skew tipici su put OTM settimanali)")
|
||||
f = 0.70
|
||||
f = float(np.clip(f, 0.3, 1.2))
|
||||
csp_sleeve_haircut(f)
|
||||
print("\n CAVEAT: snapshot singolo; spread peggiora nello stress; ripetere nel tempo + testnet.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,83 @@
|
||||
"""CALIBRAZIONE VRP su quote REALI cerbero-bite — misura f e skew, non li assume.
|
||||
|
||||
cerbero-bite accumula la catena Deribit mainnet reale (option_chain_snapshots). Qui, per ogni
|
||||
snapshot, prendo la put piu' vicina a delta -0.28 (DTE settimanale), confronto il BID REALE
|
||||
(vendita conservativa) col premio MODELLATO (BS su DVOL, IV-ATM) -> fattore f = reale/modellato,
|
||||
e skew = IV_put_reale - DVOL. Pinna empiricamente dove sta il VRP sleeve sullo sweep f.
|
||||
|
||||
Input: /tmp/cb_puts.csv (export da cerbero-bite). Finestra ~2026-05 -> oggi (un regime, mainnet).
|
||||
|
||||
uv run python scripts/research/options_vrp_calibrate.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np, pandas as pd
|
||||
from scripts.research.options_vrp_lab import bs_put
|
||||
from scripts.analysis.research_lab import load_tf
|
||||
|
||||
CSV = "/tmp/cb_puts.csv"
|
||||
|
||||
|
||||
def spot_series(asset):
|
||||
px = load_tf(asset, "1h")
|
||||
return pd.Series(px["close"].values.astype(float),
|
||||
index=pd.to_datetime(px["timestamp"], unit="ms", utc=True)).sort_index()
|
||||
|
||||
|
||||
def dvol_series(asset):
|
||||
d = pd.read_parquet(PROJECT_ROOT / "data" / "raw" / f"dvol_{asset.lower()}.parquet")
|
||||
return pd.Series(d["close"].values.astype(float),
|
||||
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)).sort_index()
|
||||
|
||||
|
||||
def main():
|
||||
df = pd.read_csv(CSV, names=["ts", "asset", "strike", "expiry", "bid", "mid", "iv", "delta"])
|
||||
df["ts"] = pd.to_datetime(df["ts"], utc=True, errors="coerce")
|
||||
df["expiry"] = pd.to_datetime(df["expiry"], utc=True, errors="coerce")
|
||||
for c in ("strike", "bid", "mid", "iv", "delta"):
|
||||
df[c] = pd.to_numeric(df[c], errors="coerce")
|
||||
df = df.dropna(subset=["ts", "expiry", "strike", "bid", "iv", "delta"])
|
||||
df["dte"] = (df["expiry"] - df["ts"]).dt.total_seconds() / 86400.0
|
||||
df = df[(df["dte"] >= 4) & (df["dte"] <= 10) & (df["bid"] > 0)]
|
||||
|
||||
print("=" * 92)
|
||||
print(" CALIBRAZIONE VRP su QUOTE REALI (cerbero-bite mainnet) — put weekly ~delta -0.28")
|
||||
print("=" * 92)
|
||||
for asset in ("BTC", "ETH"):
|
||||
d = df[df["asset"] == asset].copy()
|
||||
if d.empty:
|
||||
print(f"\n {asset}: nessun dato"); continue
|
||||
# per snapshot, la put piu' vicina a delta -0.28
|
||||
d["dd"] = (d["delta"] - (-0.28)).abs()
|
||||
pick = d.sort_values("dd").groupby("ts").first().reset_index().sort_values("ts")
|
||||
S = spot_series(asset); V = dvol_series(asset)
|
||||
Sdf = pd.DataFrame({"ts": S.index.as_unit("ns"), "spot": S.values}).sort_values("ts")
|
||||
Vdf = pd.DataFrame({"ts": V.index.as_unit("ns"), "dvol": V.values}).sort_values("ts")
|
||||
pick = pick.sort_values("ts").reset_index(drop=True)
|
||||
pts = pick[["ts"]].copy()
|
||||
pts["ts"] = pts["ts"].dt.as_unit("ns")
|
||||
pick["spot"] = pd.merge_asof(pts, Sdf, on="ts", direction="nearest", tolerance=pd.Timedelta("2h"))["spot"].values
|
||||
pick["dvol"] = pd.merge_asof(pts, Vdf, on="ts", direction="nearest", tolerance=pd.Timedelta("2D"))["dvol"].values
|
||||
pick = pick.dropna(subset=["spot", "dvol"])
|
||||
# premio reale (vendo al BID, in coin -> frazione del sottostante) vs modellato BS@DVOL
|
||||
pick["real_pct"] = pick["bid"] * 100.0
|
||||
pick["model_pct"] = pick.apply(lambda r: bs_put(r["spot"], r["strike"], r["dte"] / 365.25, r["dvol"] / 100.0) / r["spot"] * 100.0, axis=1)
|
||||
pick = pick[pick["model_pct"] > 0]
|
||||
pick["f"] = pick["real_pct"] / pick["model_pct"]
|
||||
pick["skew"] = pick["iv"] - pick["dvol"]
|
||||
print(f"\n {asset} (snapshot validi={len(pick)}, {pick['ts'].iloc[0].date()} -> {pick['ts'].iloc[-1].date()})")
|
||||
print(f" delta medio {pick['delta'].mean():+.2f} | DTE medio {pick['dte'].mean():.1f}g | moneyness medio {(pick['strike']/pick['spot']).mean():.3f}")
|
||||
print(f" IV put reale {pick['iv'].mean():.1f}% vs DVOL {pick['dvol'].mean():.1f}% -> SKEW medio {pick['skew'].mean():+.1f} pt")
|
||||
print(f" premio reale(BID) {pick['real_pct'].mean():.2f}% vs modellato(IV-ATM) {pick['model_pct'].mean():.2f}%")
|
||||
print(f" FATTORE f = reale/modellato: mediana {pick['f'].median():.2f} IQR [{pick['f'].quantile(.25):.2f}, {pick['f'].quantile(.75):.2f}] (range {pick['f'].min():.2f}-{pick['f'].max():.2f})")
|
||||
|
||||
print("\n -> f e' il punto reale sullo sweep di options_vrp_lab (Sh: f1.0=0.71, f1.29=1.70).")
|
||||
print(" CAVEAT: finestra mag-giu 2026 = UN regime (niente crash) -> f calmo. In stress lo skew")
|
||||
print(" sale (piu' premio) MA la coda colpisce: il f di stress va misurato quando arriva un crash.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""OPTIONS VRP LAB — sleeve income: vendita put settimanali (CSP) che incassa il VRP (IV>RV).
|
||||
|
||||
Aggira il muro "niente catena storica gratis" come crypto_backtest: prezza le put con Black-Scholes
|
||||
sulla DVOL REALE (IV storica Deribit, data/raw/dvol_*.parquet) + CALIBRAZIONE su quote reali
|
||||
(fattore f: la verifica su quote reali ha trovato premio reale ~1.29x il modellato a IV-ATM per via
|
||||
dello skew, al netto dello spread). Payoff sul path REALIZZATO dei prezzi certificati. Causale: la
|
||||
decisione (strike/premio) usa solo dati <= sell-date; il payoff realizza a scadenza.
|
||||
|
||||
Onesto: e' SHORT-VOL, il rischio vero e' la CODA (crash). Riporto worst-weeks (LUNA/FTX), per-anno,
|
||||
sweep su f (sensitivity del premio reale) e delta. NON e' un deploy: e' la prima validazione del lead.
|
||||
|
||||
uv run python scripts/research/options_vrp_lab.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np, pandas as pd
|
||||
from scipy.stats import norm
|
||||
from scripts.analysis.research_lab import load_tf
|
||||
|
||||
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
WK_PER_YEAR = 365.25 / 7.0
|
||||
|
||||
|
||||
def bs_put(S, K, T, sig):
|
||||
if T <= 0 or sig <= 0:
|
||||
return max(K - S, 0.0)
|
||||
d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T))
|
||||
d2 = d1 - sig * np.sqrt(T)
|
||||
return K * norm.cdf(-d2) - S * norm.cdf(-d1) # r=0
|
||||
|
||||
|
||||
def strike_from_delta(S, T, sig, target_delta=-0.28):
|
||||
# delta_put = -N(-d1) = target -> d1 = -N^{-1}(-target)
|
||||
d1 = -norm.ppf(-target_delta)
|
||||
return S * np.exp(0.5 * sig ** 2 * T - d1 * sig * np.sqrt(T))
|
||||
|
||||
|
||||
def load_series(asset):
|
||||
px = load_tf(asset, "1d")
|
||||
s = pd.Series(px["close"].values.astype(float), index=pd.to_datetime(px["timestamp"], unit="ms", utc=True))
|
||||
dv = pd.read_parquet(PROJECT_ROOT / "data" / "raw" / f"dvol_{asset.lower()}.parquet")
|
||||
d = pd.Series(dv["close"].values.astype(float), index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True))
|
||||
J = pd.concat({"px": s, "dvol": d}, axis=1, join="inner").sort_index().dropna()
|
||||
return J
|
||||
|
||||
|
||||
def put_sell_weekly(asset, delta=-0.28, f=1.0, tenor_d=7):
|
||||
"""Vendita CSP settimanale. Ritorna serie di rendimenti SETTIMANALI (su collaterale K) indicizzata
|
||||
alla data di scadenza. Causale: strike/premio da DVOL e prezzo a sell-date; payoff a scadenza."""
|
||||
J = load_series(asset)
|
||||
px = J["px"].values; dv = J["dvol"].values / 100.0; idx = J.index
|
||||
n = len(px); T = tenor_d / 365.25
|
||||
rets = {}
|
||||
i = 30
|
||||
while i + tenor_d < n:
|
||||
S0 = px[i]; sig = dv[i]
|
||||
K = strike_from_delta(S0, T, sig, delta)
|
||||
prem = bs_put(S0, K, T, sig) * f
|
||||
S1 = px[i + tenor_d]
|
||||
pnl = prem - max(0.0, K - S1) # short put: incassi premio, paghi se finisce ITM
|
||||
rets[idx[i + tenor_d]] = pnl / K # rendimento su collaterale cash-secured
|
||||
i += tenor_d
|
||||
return pd.Series(rets)
|
||||
|
||||
|
||||
def m_weekly(r):
|
||||
r = r.dropna()
|
||||
if len(r) < 3 or r.std() == 0:
|
||||
return dict(sh=0, cagr=0, dd=0, n=len(r))
|
||||
eq = np.cumprod(1 + r.values); pk = np.maximum.accumulate(eq)
|
||||
yrs = len(r) / WK_PER_YEAR
|
||||
return dict(sh=float(r.mean() / r.std() * np.sqrt(WK_PER_YEAR)),
|
||||
cagr=float(eq[-1] ** (1 / yrs) - 1) if yrs > 0 and eq[-1] > 0 else 0,
|
||||
dd=float(np.max((pk - eq) / pk)), n=len(r))
|
||||
|
||||
|
||||
def per_year(r):
|
||||
out = {}
|
||||
for y, g in r.groupby(r.index.year):
|
||||
eq = np.cumprod(1 + g.values)
|
||||
out[int(y)] = float(eq[-1] - 1)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 96)
|
||||
print(" OPTIONS VRP LAB — vendita put settimanali (CSP), premio BS su DVOL reale + calibrazione f")
|
||||
print("=" * 96)
|
||||
|
||||
# contesto VRP: IV (DVOL) vs RV realizzata
|
||||
for a in ("BTC", "ETH"):
|
||||
J = load_series(a)
|
||||
rv = J["px"].pct_change().rolling(30).std() * np.sqrt(365.25) * 100
|
||||
vrp = (J["dvol"] - rv).dropna()
|
||||
print(f" {a}: DVOL media {J['dvol'].mean():.0f}% | RV30 media {rv.mean():.0f}% | VRP media {vrp.mean():+.1f} pt, >0 nel {100*(vrp>0).mean():.0f}% del tempo")
|
||||
|
||||
print("\n (1) SWEEP CALIBRAZIONE f (delta -0.28, weekly) — book 50/50 BTC+ETH")
|
||||
print(f" {'f':>6}{'Sh':>7}{'CAGR':>8}{'maxDD':>8}{'worst-wk':>10}")
|
||||
for f in (0.70, 0.85, 1.0, 1.15, 1.29):
|
||||
rB = put_sell_weekly("BTC", f=f); rE = put_sell_weekly("ETH", f=f)
|
||||
book = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1)
|
||||
mm = m_weekly(book); worst = book.min()
|
||||
tag = " <- reale(calm)" if f == 1.29 else (" <- conservativo" if f == 1.0 else "")
|
||||
print(f" {f:>6.2f}{mm['sh']:>7.2f}{mm['cagr']*100:>+7.0f}%{mm['dd']*100:>7.1f}%{worst*100:>+9.1f}%{tag}")
|
||||
|
||||
print("\n (2) SWEEP DELTA (f=1.0 conservativo) — book 50/50")
|
||||
print(f" {'delta':>7}{'Sh':>7}{'CAGR':>8}{'maxDD':>8}")
|
||||
for dl in (-0.15, -0.28, -0.40):
|
||||
rB = put_sell_weekly("BTC", delta=dl); rE = put_sell_weekly("ETH", delta=dl)
|
||||
book = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1)
|
||||
mm = m_weekly(book)
|
||||
print(f" {dl:>7.2f}{mm['sh']:>7.2f}{mm['cagr']*100:>+7.0f}%{mm['dd']*100:>7.1f}%")
|
||||
|
||||
# config centrale: delta -0.28, f=1.0 (conservativo) e f=1.29 (reale misurato)
|
||||
print("\n (3) PER ANNO + WORST WEEKS (delta -0.28, book 50/50) — il rischio e' la CODA")
|
||||
for f in (1.0, 1.29):
|
||||
rB = put_sell_weekly("BTC", f=f); rE = put_sell_weekly("ETH", f=f)
|
||||
book = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1)
|
||||
py = per_year(book)
|
||||
worst = book.nsmallest(5)
|
||||
print(f"\n f={f}: per-anno " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in py.items()))
|
||||
print(f" worst weeks: " + " ".join(f"{d.date()}:{v*100:.0f}%" for d, v in worst.items()))
|
||||
full = m_weekly(book); ho = m_weekly(book[book.index >= HOLDOUT])
|
||||
print(f" FULL Sh {full['sh']:.2f} CAGR {full['cagr']*100:+.0f}% DD {full['dd']*100:.0f}% | HOLD-OUT Sh {ho['sh']:.2f}")
|
||||
|
||||
# correlazione e contributo vs TP01 (resampling settimanale)
|
||||
print("\n (4) CORRELAZIONE + CONTRIBUTO vs TP01 (settimanale; f=1.0 conservativo)")
|
||||
from src.portfolio.sleeves import tp01_sleeve
|
||||
tp = tp01_sleeve().daily()
|
||||
tp_wk = (1 + tp).resample("7D").prod() - 1
|
||||
rB = put_sell_weekly("BTC"); rE = put_sell_weekly("ETH")
|
||||
opt = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1)
|
||||
opt_wk = opt.copy(); opt_wk.index = opt_wk.index.to_period("W").to_timestamp()
|
||||
tp_wk2 = tp_wk.copy(); tp_wk2.index = tp_wk2.index.to_period("W").to_timestamp()
|
||||
Jc = pd.concat({"tp": tp_wk2, "opt": opt_wk}, axis=1, join="inner").dropna()
|
||||
corr = float(Jc["tp"].corr(Jc["opt"])) if len(Jc) > 5 else float("nan")
|
||||
print(f" corr settimanale opt vs TP01 = {corr:+.2f} (atteso ~0.2)")
|
||||
for w in (0.3, 0.5):
|
||||
comb = (1 - w) * Jc["tp"] + w * Jc["opt"]
|
||||
mt = m_weekly(Jc["tp"]); mc = m_weekly(comb)
|
||||
print(f" TP01 {1-w:.0%} + OPT {w:.0%}: Sh {mc['sh']:.2f} (TP01-solo {mt['sh']:.2f}) DD {mc['dd']*100:.0f}%")
|
||||
print("\n NB onesto: short-vol -> guarda i worst-weeks e gli anni di crash. Premio MODELLATO; il")
|
||||
print(" rischio coda/roll in stress NON e' pienamente catturato. Lead, non deploy.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,180 @@
|
||||
"""OPTIONS VRP v2 — migliora lo sleeve short-vol con le idee di FinanceOld/OptionsAgent.
|
||||
|
||||
Baseline (options_vrp_lab): vendita put NUDA settimanale delta -0.28, premio BS su DVOL reale.
|
||||
f=1.0 -> Sh 0.71, DD 33%, worst-week -26%, HOLD-OUT Sh 0.04 (muore OOS). Il rischio e' la CODA.
|
||||
|
||||
OptionsAgent (Bear Call Spread + VIX hedge su IWM) porta 3 idee testabili qui:
|
||||
(A) RISCHIO DEFINITO: invece della put nuda, PUT CREDIT SPREAD (vendi put delta -0.28, COMPRI put
|
||||
piu' OTM delta -0.10). Cap la coda: max perdita = width - premio netto. Capitale = width (margine
|
||||
reale di un defined-risk). Lo Sharpe e' scale-free; DD/worst-week sul width (capitale vero a rischio).
|
||||
(B) GATE VRP/IV-RANK: vendi vol SOLO quando e' ricca. Gate causale su:
|
||||
- vrp: DVOL[i] - RV30(causale) > 0 (premio > vol realizzata recente)
|
||||
- ivr: IV-rank = percentile espandente di DVOL[i] in DVOL[:i] > soglia
|
||||
"Solo se IV Rank > 30%" e' una delle 5 condizioni d'ingresso di OptionsAgent.
|
||||
(C) CRASH-SKIP: vai flat se DVOL gia' esploso sopra un percentile alto (vol-spike = NO-GO, come
|
||||
"VIX>35 -> NO-GO" di OptionsAgent). Evita di vendere nel pieno del crash.
|
||||
|
||||
Tutto CAUSALE: strike/premio/gate usano solo dati <= sell-date; payoff realizza a scadenza sui prezzi
|
||||
certificati. Fee Deribit opzioni: 0.03% del NOTIONAL per gamba (cap 12.5% del premio) -> qui modellate
|
||||
come costo per-trade sul premio. NON deploy: lead quantificato e onesto.
|
||||
|
||||
uv run python scripts/research/options_vrp_v2.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np, pandas as pd
|
||||
from scripts.research.options_vrp_lab import bs_put, strike_from_delta, load_series, m_weekly, per_year
|
||||
|
||||
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
WK_PER_YEAR = 365.25 / 7.0
|
||||
# fee Deribit opzioni: 0.0003 * spot per contratto, cap 12.5% del premio. Per uno spread sono 2 gambe.
|
||||
# Modellata come frazione del premio netto incassato (conservativa: usa il cap come ordine di grandezza).
|
||||
FEE_FRAC_OF_PREMIUM = 0.125 # 12.5% del premio netto, per ROUND-TRIP delle gambe (worst-case del cap)
|
||||
|
||||
|
||||
def _rv30(px: np.ndarray, i: int) -> float:
|
||||
"""RV annualizzata causale dagli ultimi 30 rendimenti giornalieri (fino a i incluso)."""
|
||||
if i < 31:
|
||||
return np.nan
|
||||
r = np.diff(np.log(px[i - 30:i + 1]))
|
||||
return float(np.std(r) * np.sqrt(365.25))
|
||||
|
||||
|
||||
def _ivrank(dv: np.ndarray, i: int) -> float:
|
||||
"""IV-rank causale: percentile di dv[i] nella storia espandente dv[:i]."""
|
||||
if i < 60:
|
||||
return np.nan
|
||||
hist = dv[:i]
|
||||
return float((hist < dv[i]).mean())
|
||||
|
||||
|
||||
def vrp_spread_weekly(asset, short_delta=-0.28, long_delta=-0.10, f=1.0, tenor_d=7,
|
||||
defined_risk=True, gate_vrp=False, gate_ivr=0.0, crash_skip=1.01,
|
||||
with_fee=True):
|
||||
"""Vendita settimanale di put credit spread (o nuda se defined_risk=False), con gate causali.
|
||||
Ritorna serie di rendimenti settimanali su CAPITALE A RISCHIO (width per lo spread, K per la nuda)."""
|
||||
J = load_series(asset)
|
||||
px = J["px"].values; dv_pct = J["dvol"].values; dv = dv_pct / 100.0; idx = J.index
|
||||
n = len(px); T = tenor_d / 365.25
|
||||
rets = {}
|
||||
i = 60 # serve storia per RV/IV-rank
|
||||
while i + tenor_d < n:
|
||||
S0 = px[i]; sig = dv[i]
|
||||
# --- GATE causali (decisi a sell-date) ---
|
||||
skip = False
|
||||
if gate_vrp:
|
||||
rv = _rv30(px, i)
|
||||
if not np.isnan(rv) and (sig - rv) <= 0: # VRP non positivo -> non vendere
|
||||
skip = True
|
||||
if gate_ivr > 0:
|
||||
ivr = _ivrank(dv, i)
|
||||
if not np.isnan(ivr) and ivr < gate_ivr: # IV troppo bassa -> non vendere
|
||||
skip = True
|
||||
if crash_skip < 1.0:
|
||||
ivr = _ivrank(dv, i)
|
||||
if not np.isnan(ivr) and ivr > crash_skip: # vol gia' esplosa -> NO-GO
|
||||
skip = True
|
||||
if skip:
|
||||
rets[idx[i + tenor_d]] = 0.0 # flat: nessun rischio quella settimana
|
||||
i += tenor_d
|
||||
continue
|
||||
# --- struttura ---
|
||||
Ks = strike_from_delta(S0, T, sig, short_delta) # put venduta
|
||||
prem_s = bs_put(S0, Ks, T, sig) * f
|
||||
S1 = px[i + tenor_d]
|
||||
if defined_risk:
|
||||
Kl = strike_from_delta(S0, T, sig, long_delta) # put comprata (piu' OTM, strike piu' basso)
|
||||
prem_l = bs_put(S0, Kl, T, sig) * f
|
||||
net_prem = prem_s - prem_l
|
||||
width = Ks - Kl
|
||||
payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1) # quanto pago netto a scadenza
|
||||
pnl = net_prem - payoff
|
||||
cap = Ks # cash-secured: stesso capitale del baseline nudo -> DD/worst comparabili,
|
||||
# il long wing CAPPA la coda (la differenza dal nudo e' solo la coda tagliata)
|
||||
else:
|
||||
pnl = prem_s - max(0.0, Ks - S1)
|
||||
cap = Ks
|
||||
net_prem = prem_s
|
||||
if with_fee:
|
||||
pnl -= FEE_FRAC_OF_PREMIUM * abs(net_prem)
|
||||
rets[idx[i + tenor_d]] = pnl / cap
|
||||
i += tenor_d
|
||||
return pd.Series(rets)
|
||||
|
||||
|
||||
def book(fn, **kw):
|
||||
rB = fn("BTC", **kw); rE = fn("ETH", **kw)
|
||||
return pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1)
|
||||
|
||||
|
||||
def report(name, b):
|
||||
full = m_weekly(b); ho = m_weekly(b[b.index >= HOLDOUT])
|
||||
worst = b.min(); active = float((b != 0).mean())
|
||||
py = per_year(b)
|
||||
print(f" {name:<34} FULL Sh {full['sh']:>5.2f} CAGR {full['cagr']*100:>+4.0f}% DD {full['dd']*100:>3.0f}% "
|
||||
f"worst {worst*100:>+5.1f}% | HOLD Sh {ho['sh']:>5.2f} | attivo {active*100:>3.0f}%")
|
||||
return full, ho, py
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 104)
|
||||
print(" OPTIONS VRP v2 — defined-risk spread + gate VRP/IV-rank + crash-skip (idee OptionsAgent)")
|
||||
print("=" * 104)
|
||||
print(" Fee opzioni Deribit modellate: 12.5%% del premio netto per round-trip (cap del fee reale).\n")
|
||||
|
||||
print(" (0) BASELINE — put NUDA delta -0.28 (riproduce options_vrp_lab, ora CON fee)")
|
||||
report("naked f=1.0 (no gate)", book(vrp_spread_weekly, defined_risk=False, f=1.0))
|
||||
report("naked f=1.29 (reale-calm)", book(vrp_spread_weekly, defined_risk=False, f=1.29))
|
||||
|
||||
print("\n (1) RISCHIO DEFINITO — put credit spread -0.28/-0.10 (cap coda), capitale=width")
|
||||
for f in (1.0, 1.29):
|
||||
report(f"spread f={f}", book(vrp_spread_weekly, defined_risk=True, f=f))
|
||||
|
||||
print("\n (2) + GATE VRP>0 (vendi solo se DVOL>RV30 causale)")
|
||||
for f in (1.0, 1.29):
|
||||
report(f"spread+vrp f={f}", book(vrp_spread_weekly, defined_risk=True, f=f, gate_vrp=True))
|
||||
|
||||
print("\n (3) + GATE IV-RANK > 0.30 (vendi solo vol ricca; cond. d'ingresso OptionsAgent)")
|
||||
for f in (1.0, 1.29):
|
||||
report(f"spread+ivr30 f={f}", book(vrp_spread_weekly, defined_risk=True, f=f, gate_ivr=0.30))
|
||||
|
||||
print("\n (4) + CRASH-SKIP IV-rank>0.90 (NO-GO se vol gia' esplosa)")
|
||||
for f in (1.0, 1.29):
|
||||
report(f"spread+crashskip f={f}", book(vrp_spread_weekly, defined_risk=True, f=f, crash_skip=0.90))
|
||||
|
||||
print("\n (5) COMBO — spread + vrp + ivr30 + crash-skip (tutti i filtri, f=1.0 conservativo)")
|
||||
full, ho, py = report("COMBO f=1.0", book(vrp_spread_weekly, defined_risk=True, f=1.0,
|
||||
gate_vrp=True, gate_ivr=0.30, crash_skip=0.90))
|
||||
print(" per-anno: " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in py.items()))
|
||||
full, ho, py = report("COMBO f=1.29", book(vrp_spread_weekly, defined_risk=True, f=1.29,
|
||||
gate_vrp=True, gate_ivr=0.30, crash_skip=0.90))
|
||||
print(" per-anno: " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in py.items()))
|
||||
|
||||
# contributo al portafoglio TP01
|
||||
print("\n (6) CORRELAZIONE + CONTRIBUTO vs TP01 (COMBO f=1.0)")
|
||||
from src.portfolio.sleeves import tp01_sleeve
|
||||
tp = tp01_sleeve().daily()
|
||||
tp_wk = (1 + tp).resample("7D").prod() - 1
|
||||
opt = book(vrp_spread_weekly, defined_risk=True, f=1.0, gate_vrp=True, gate_ivr=0.30, crash_skip=0.90)
|
||||
opt_wk = opt.copy(); opt_wk.index = opt_wk.index.to_period("W").to_timestamp()
|
||||
tp_wk2 = tp_wk.copy(); tp_wk2.index = tp_wk2.index.to_period("W").to_timestamp()
|
||||
Jc = pd.concat({"tp": tp_wk2, "opt": opt_wk}, axis=1, join="inner").dropna()
|
||||
corr = float(Jc["tp"].corr(Jc["opt"])) if len(Jc) > 5 else float("nan")
|
||||
print(f" corr settimanale opt vs TP01 = {corr:+.2f}")
|
||||
for w in (0.3, 0.5):
|
||||
comb = (1 - w) * Jc["tp"] + w * Jc["opt"]
|
||||
mt = m_weekly(Jc["tp"]); mc = m_weekly(comb)
|
||||
print(f" TP01 {1-w:.0%} + OPT {w:.0%}: Sh {mc['sh']:.2f} (TP01-solo {mt['sh']:.2f}) DD {mc['dd']*100:.0f}%")
|
||||
|
||||
print("\n NB onesto: capitale=strike corto (cash-secured) per entrambe -> DD/worst comparabili al nudo.")
|
||||
print(" Il defined-risk CAPPA la coda (-16.6%->-7.4% worst, DD 33%->14-21%) RIDUCENDO la dipendenza")
|
||||
print(" dal f di stress (la coda e' tagliata per costruzione). Il gate IV-rank e' l'alpha: vendere")
|
||||
print(" solo vol ricca (58%% delle settimane) ribalta l'HOLD-OUT da -0.25 a +0.28 (f=1.0). Premio")
|
||||
print(" MODELLATO su DVOL ATM (no skew). Lead quantificato, non deploy (serve catena reale + f di stress).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,320 @@
|
||||
"""TRACK A — TREND / MOMENTUM research on certified BTC/ETH (Deribit mainnet).
|
||||
|
||||
Honest harness only (src.backtest.harness). Rules enforced:
|
||||
* Direction & entry price decided with data <= close[i]; fill at close[i].
|
||||
* Net of fees (0.10% RT baseline) + fee sweep + leverage stress.
|
||||
* IS / OOS split (65/35). Grid robustness across params AND both assets.
|
||||
|
||||
Run: uv run python scripts/research/trackA_trend.py
|
||||
|
||||
This script is deliberately skeptical: it prints full grids so the reader can see
|
||||
whether an "edge" is a single lucky cell or a robust neighborhood. The verdict at the
|
||||
end is printed from the actual numbers, not asserted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from src.backtest.harness import load, backtest_signals, oos_split
|
||||
|
||||
ASSETS = ["BTC", "ETH"]
|
||||
TFS = ["1h", "15m", "5m"]
|
||||
FEE = 0.001
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal builders. Each returns a list[dict|None] of length len(df).
|
||||
# All features use ONLY data up to and including close[i]. Entry fills at close[i].
|
||||
# Position is approximated as a chained, non-overlapping hold of `hold` bars whose
|
||||
# direction is recomputed at each (free) bar -> amortizes fee over `hold` bars while
|
||||
# staying honest about responsiveness.
|
||||
# ---------------------------------------------------------------------------
|
||||
def sig_tsmom(df, lookback, hold, long_only=False):
|
||||
c = df["close"].values
|
||||
n = len(c)
|
||||
ent = [None] * n
|
||||
dirs = np.where(c[lookback:] > c[:-lookback], 1, -1)
|
||||
for k, d in enumerate(dirs):
|
||||
if long_only and d < 0:
|
||||
continue
|
||||
ent[lookback + k] = {"dir": int(d), "max_bars": hold}
|
||||
return ent
|
||||
|
||||
|
||||
def _ema(x, span):
|
||||
return pd.Series(x).ewm(span=span, adjust=False).mean().values
|
||||
|
||||
|
||||
def sig_ema_cross(df, fast, slow, hold, long_only=False):
|
||||
c = df["close"].values
|
||||
n = len(c)
|
||||
ef = _ema(c, fast)
|
||||
es = _ema(c, slow)
|
||||
ent = [None] * n
|
||||
for i in range(slow, n):
|
||||
d = 1 if ef[i] > es[i] else -1
|
||||
if long_only and d < 0:
|
||||
ent[i] = None
|
||||
continue
|
||||
ent[i] = {"dir": d, "max_bars": hold}
|
||||
return ent
|
||||
|
||||
|
||||
def sig_donchian(df, lookback, hold, long_only=False):
|
||||
"""Breakout: close[i] strictly above prior `lookback` highs -> long; below lows -> short.
|
||||
Detection AND entry both at close[i] (honest)."""
|
||||
c = df["close"].values
|
||||
h = df["high"].values
|
||||
l = df["low"].values
|
||||
n = len(c)
|
||||
ent = [None] * n
|
||||
# prior-window high/low EXCLUDING current bar (shift by 1) -> honest
|
||||
hh = pd.Series(h).rolling(lookback).max().shift(1).values
|
||||
ll = pd.Series(l).rolling(lookback).min().shift(1).values
|
||||
for i in range(lookback, n):
|
||||
if not np.isfinite(hh[i]):
|
||||
continue
|
||||
if c[i] > hh[i]:
|
||||
d = 1
|
||||
elif c[i] < ll[i]:
|
||||
d = -1
|
||||
else:
|
||||
continue
|
||||
if long_only and d < 0:
|
||||
continue
|
||||
ent[i] = {"dir": d, "max_bars": hold}
|
||||
return ent
|
||||
|
||||
|
||||
def sig_vol_scaled_tsmom(df, lookback, hold, vol_win, z_gate):
|
||||
"""Momentum gated by trend strength: only take a position when |past return| exceeds
|
||||
z_gate * rolling stdev of bar returns (regime gate). Honest: all <= close[i]."""
|
||||
c = df["close"].values
|
||||
n = len(c)
|
||||
logret = np.zeros(n)
|
||||
logret[1:] = np.diff(np.log(c))
|
||||
vol = pd.Series(logret).rolling(vol_win).std().values
|
||||
ent = [None] * n
|
||||
start = max(lookback, vol_win) + 1
|
||||
for i in range(start, n):
|
||||
r = np.log(c[i] / c[i - lookback])
|
||||
v = vol[i] * np.sqrt(lookback)
|
||||
if not np.isfinite(v) or v == 0:
|
||||
continue
|
||||
z = r / v
|
||||
if abs(z) < z_gate:
|
||||
continue
|
||||
d = 1 if z > 0 else -1
|
||||
ent[i] = {"dir": d, "max_bars": hold}
|
||||
return ent
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Evaluation helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def eval_is_oos(df, entries, asset, tf, fee=FEE, lev=1.0):
|
||||
cut = oos_split(df, 0.65)
|
||||
full = backtest_signals(df, entries, fee_rt=fee, leverage=lev, asset=asset, tf=tf)
|
||||
ent_is = [e if i < cut else None for i, e in enumerate(entries)]
|
||||
ent_oos = [e if i >= cut else None for i, e in enumerate(entries)]
|
||||
m_is = backtest_signals(df, ent_is, fee_rt=fee, leverage=lev, asset=asset, tf=tf)
|
||||
m_oos = backtest_signals(df, ent_oos, fee_rt=fee, leverage=lev, asset=asset, tf=tf)
|
||||
return full, m_is, m_oos
|
||||
|
||||
|
||||
def buy_hold(df, cut=None):
|
||||
c = df["close"].values
|
||||
if cut is None:
|
||||
cut = oos_split(df, 0.65)
|
||||
return c[-1] / c[0] - 1, c[-1] / c[cut] - 1 # (full, oos)
|
||||
|
||||
|
||||
def print_benchmarks():
|
||||
print("\n" + "=" * 110)
|
||||
print("# BUY & HOLD BENCHMARK (the bar any long/short trend edge must clear)")
|
||||
print("# NOTE: OOS window is the LAST 35% = ~late-2023 -> 2026, a single (mostly bull) regime.")
|
||||
print("# 2018-2022 (bear+crash+bull+bear) is ENTIRELY in-sample. 'positive OOS' is weak evidence.")
|
||||
print("=" * 110)
|
||||
for tf in TFS:
|
||||
for asset in ASSETS:
|
||||
df = load(asset, tf)
|
||||
cut = oos_split(df, 0.65)
|
||||
bf, bo = buy_hold(df, cut)
|
||||
print(f" {asset} {tf:>3s} OOS starts {df['datetime'].iloc[cut].date()} "
|
||||
f"B&H full={bf*100:>+7.0f}% B&H OOS={bo*100:>+7.0f}%")
|
||||
|
||||
|
||||
def line(label, m):
|
||||
print(f" {label:<30s} tr={m.n_trades:>6d} wr={m.win_rate:>4.1f}% "
|
||||
f"ret={m.net_return*100:>+8.0f}% CAGR={m.cagr*100:>+6.1f}% "
|
||||
f"Sh={m.sharpe:>5.2f} DD={m.max_dd*100:>4.1f}% mkt={m.time_in_market*100:>3.0f}% "
|
||||
f"€/d={m.daily_profit(2000):>+6.2f}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Experiments
|
||||
# ---------------------------------------------------------------------------
|
||||
def run_grid(name, builder, param_grid, builder_kwargs_fn, tfs=TFS, assets=ASSETS):
|
||||
"""Generic grid runner. Prints OOS-focused table. Returns list of result dicts."""
|
||||
print("\n" + "=" * 110)
|
||||
print(f"# {name}")
|
||||
print("=" * 110)
|
||||
results = []
|
||||
for tf in tfs:
|
||||
for asset in assets:
|
||||
df = load(asset, tf)
|
||||
print(f"\n -- {asset} {tf} (n={len(df)}) --")
|
||||
for params in param_grid:
|
||||
ent = builder(df, **builder_kwargs_fn(params))
|
||||
full, m_is, m_oos = eval_is_oos(df, ent, asset, tf)
|
||||
tag = ",".join(f"{k}={v}" for k, v in params.items())
|
||||
line(f"{tag} [OOS]", m_oos)
|
||||
results.append(dict(name=name, asset=asset, tf=tf, params=params,
|
||||
full=full, is_=m_is, oos=m_oos))
|
||||
return results
|
||||
|
||||
|
||||
def summarize_survivors(all_results):
|
||||
print("\n" + "#" * 110)
|
||||
print("# SURVIVOR SCREEN — positive OOS net return AND positive full-sample, Sharpe(OOS)>0")
|
||||
print("#" * 110)
|
||||
survivors = [r for r in all_results
|
||||
if r["oos"].net_return > 0 and r["full"].net_return > 0
|
||||
and r["oos"].sharpe > 0 and r["oos"].n_trades >= 20]
|
||||
if not survivors:
|
||||
print(" NONE. No config is net-positive OOS with positive full-sample and Sharpe>0.")
|
||||
return []
|
||||
survivors.sort(key=lambda r: r["oos"].sharpe, reverse=True)
|
||||
# precompute B&H OOS per (asset,tf)
|
||||
bh = {}
|
||||
for tf in TFS:
|
||||
for a in ASSETS:
|
||||
bh[(a, tf)] = buy_hold(load(a, tf))[1]
|
||||
print(" (BEATS B&H = OOS return exceeds buy&hold over same OOS window; otherwise it's just beta)")
|
||||
for r in survivors[:40]:
|
||||
tag = ",".join(f"{k}={v}" for k, v in r["params"].items())
|
||||
bho = bh[(r["asset"], r["tf"])]
|
||||
beat = "BEATS B&H" if r["oos"].net_return > bho else "<= B&H (beta)"
|
||||
print(f" {r['name'][:18]:<18s} {r['asset']} {r['tf']:>3s} {tag:<28s} "
|
||||
f"OOS: ret={r['oos'].net_return*100:>+7.0f}% Sh={r['oos'].sharpe:>4.2f} "
|
||||
f"DD={r['oos'].max_dd*100:>4.0f}% €/d={r['oos'].daily_profit(2000):>+5.2f} | "
|
||||
f"B&H={bho*100:>+5.0f}% {beat}")
|
||||
return survivors
|
||||
|
||||
|
||||
def robustness_report(survivors):
|
||||
"""For top survivors, check fee sweep + leverage stress + cross-asset consistency."""
|
||||
if not survivors:
|
||||
return
|
||||
print("\n" + "#" * 110)
|
||||
print("# ROBUSTNESS: fee sweep (0.0005/0.001/0.0015/0.002) + leverage (1x/2x/3x) on top survivors")
|
||||
print("#" * 110)
|
||||
seen = set()
|
||||
for r in survivors[:8]:
|
||||
key = (r["name"], r["asset"], r["tf"], tuple(r["params"].items()))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
df = load(r["asset"], r["tf"])
|
||||
# rebuild entries
|
||||
builder = BUILDERS[r["name"]]
|
||||
ent = builder(df, **KW_FN[r["name"]](r["params"]))
|
||||
tag = ",".join(f"{k}={v}" for k, v in r["params"].items())
|
||||
print(f"\n {r['name']} {r['asset']} {r['tf']} {tag}")
|
||||
print(" fee sweep (OOS net return):")
|
||||
for fee in (0.0005, 0.001, 0.0015, 0.002):
|
||||
_, _, m_oos = eval_is_oos(df, ent, r["asset"], r["tf"], fee=fee)
|
||||
flag = "" if m_oos.net_return > 0 else " <-- DIES"
|
||||
print(f" fee={fee:.4f}: OOS ret={m_oos.net_return*100:>+8.0f}% Sh={m_oos.sharpe:>4.2f}{flag}")
|
||||
print(" leverage stress (OOS, fee=0.001):")
|
||||
for lev in (1.0, 2.0, 3.0):
|
||||
_, _, m_oos = eval_is_oos(df, ent, r["asset"], r["tf"], lev=lev)
|
||||
print(f" {lev:.0f}x: OOS ret={m_oos.net_return*100:>+8.0f}% "
|
||||
f"Sh={m_oos.sharpe:>4.2f} DD={m_oos.max_dd*100:>4.0f}% €/d={m_oos.daily_profit(2000):>+5.2f}")
|
||||
# yearly OOS
|
||||
_, _, m_oos = eval_is_oos(df, ent, r["asset"], r["tf"])
|
||||
print(" OOS yearly:")
|
||||
for y in sorted(m_oos.yearly):
|
||||
print(f" {y}: {m_oos.yearly[y]*100:>+7.1f}%")
|
||||
|
||||
|
||||
# registry so robustness_report can rebuild entries
|
||||
BUILDERS = {
|
||||
"TSMOM": sig_tsmom,
|
||||
"TSMOM_LONG": sig_tsmom,
|
||||
"EMA_CROSS": sig_ema_cross,
|
||||
"DONCHIAN": sig_donchian,
|
||||
"VOLSCALED_TSMOM": sig_vol_scaled_tsmom,
|
||||
}
|
||||
KW_FN = {
|
||||
"TSMOM": lambda p: dict(lookback=p["N"], hold=p["H"]),
|
||||
"TSMOM_LONG": lambda p: dict(lookback=p["N"], hold=p["H"], long_only=True),
|
||||
"EMA_CROSS": lambda p: dict(fast=p["f"], slow=p["s"], hold=p["H"]),
|
||||
"DONCHIAN": lambda p: dict(lookback=p["N"], hold=p["H"]),
|
||||
"VOLSCALED_TSMOM": lambda p: dict(lookback=p["N"], hold=p["H"], vol_win=p["vw"], z_gate=p["z"]),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
pd.set_option("display.width", 200)
|
||||
print_benchmarks()
|
||||
all_results = []
|
||||
|
||||
# ---- 1. TSMOM (long/short) ----
|
||||
tsmom_grid = [dict(N=n, H=h) for n in (10, 20, 50, 100, 200) for h in (6, 12, 24, 48)]
|
||||
all_results += run_grid("TSMOM", sig_tsmom, tsmom_grid,
|
||||
KW_FN["TSMOM"])
|
||||
|
||||
# ---- 2. TSMOM long-only (crypto has strong upward drift; honest to test) ----
|
||||
all_results += run_grid("TSMOM_LONG", lambda df, **k: sig_tsmom(df, long_only=True, **k),
|
||||
[dict(N=n, H=h) for n in (20, 50, 100, 200) for h in (12, 24, 48)],
|
||||
KW_FN["TSMOM"])
|
||||
|
||||
# ---- 3. EMA crossover ----
|
||||
ema_grid = [dict(f=f, s=s, H=h)
|
||||
for (f, s) in ((10, 30), (20, 50), (20, 100), (50, 200))
|
||||
for h in (12, 24, 48)]
|
||||
all_results += run_grid("EMA_CROSS", sig_ema_cross, ema_grid, KW_FN["EMA_CROSS"])
|
||||
|
||||
# ---- 4. Donchian breakout ----
|
||||
don_grid = [dict(N=n, H=h) for n in (20, 50, 100, 200) for h in (12, 24, 48)]
|
||||
all_results += run_grid("DONCHIAN", sig_donchian, don_grid, KW_FN["DONCHIAN"])
|
||||
|
||||
# ---- 5. Vol-scaled / regime-gated TSMOM ----
|
||||
vs_grid = [dict(N=n, H=h, vw=vw, z=z)
|
||||
for n in (20, 50, 100) for h in (24, 48)
|
||||
for vw in (50, 100) for z in (0.5, 1.0)]
|
||||
all_results += run_grid("VOLSCALED_TSMOM", sig_vol_scaled_tsmom, vs_grid,
|
||||
KW_FN["VOLSCALED_TSMOM"])
|
||||
|
||||
# ---- survivor screen + robustness ----
|
||||
survivors = summarize_survivors(all_results)
|
||||
robustness_report(survivors)
|
||||
|
||||
# ---- cross-asset robustness note ----
|
||||
print("\n" + "#" * 110)
|
||||
print("# CROSS-ASSET / CROSS-TF CONSISTENCY of survivors (a real edge holds on BOTH BTC & ETH)")
|
||||
print("#" * 110)
|
||||
from collections import defaultdict
|
||||
by_strat = defaultdict(list)
|
||||
for r in survivors:
|
||||
by_strat[(r["name"], r["tf"], tuple(r["params"].items()))].append(r["asset"])
|
||||
both = [(k, v) for k, v in by_strat.items() if set(v) >= {"BTC", "ETH"}]
|
||||
if not both:
|
||||
print(" No single (strategy, tf, params) cell is an OOS survivor on BOTH BTC and ETH.")
|
||||
print(" => any apparent edge is asset/regime-specific, not a robust trend edge.")
|
||||
else:
|
||||
for (name, tf, params), assets in both:
|
||||
print(f" {name} {tf} {dict(params)} survives on: {assets}")
|
||||
|
||||
print("\nDONE. Read the survivor screen + robustness above for the honest verdict.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,398 @@
|
||||
"""TRACK B — Machine-learning / feature-prediction on BTC & ETH (Deribit-certified).
|
||||
|
||||
Honest, strict walk-forward ML research. The whole point is to NOT repeat the death of
|
||||
the old library (look-ahead). Everything here obeys:
|
||||
|
||||
* Features for bar i use ONLY data <= close[i] (all rolling windows are backward).
|
||||
* Labels (sign of forward return over H bars) use close[i+H]; in walk-forward we only
|
||||
train on samples whose label is FULLY realized in the past relative to the prediction
|
||||
bar (a gap of H is enforced between train-end and the prediction block).
|
||||
* Scaler + model are fit ONLY on past data, retrained periodically, never on the future.
|
||||
* Net of fees (fee_rt sweep 0.0005 .. 0.002, baseline 0.001). Turnover reported.
|
||||
* Grid over W (lookback for training), H (horizon), threshold, asset, tf.
|
||||
* A final held-out segment (last HELD_OUT_FRAC) is NEVER used to choose configs;
|
||||
configs are selected on the DEV portion, then confirmed once on the held-out tail.
|
||||
|
||||
Run: uv run python scripts/research/trackB_ml.py
|
||||
uv run python scripts/research/trackB_ml.py --quick (smaller grid, faster)
|
||||
uv run python scripts/research/trackB_ml.py --gbm (also try GradientBoosting)
|
||||
|
||||
Entry convention (harness): for a signalled bar i we open at close[i] in the predicted
|
||||
direction and hold up to H bars (max_bars=H, no TP/SL) — a pure test of directional sign.
|
||||
No-overlap is enforced by the harness, so trades are naturally spaced >= H bars.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.linear_model import LogisticRegression
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
|
||||
from src.backtest.harness import backtest_signals, load
|
||||
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
HELD_OUT_FRAC = 0.25 # final tail reserved for confirmation only
|
||||
RETRAIN_K = 250 # retrain every K bars (block prediction)
|
||||
MIN_TRAIN = 400 # minimum usable training samples
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature engineering — ALL backward-looking (safe at close[i])
|
||||
# ---------------------------------------------------------------------------
|
||||
def _rsi(close: pd.Series, n: int = 14) -> pd.Series:
|
||||
d = close.diff()
|
||||
up = d.clip(lower=0).ewm(alpha=1 / n, adjust=False).mean()
|
||||
dn = (-d.clip(upper=0)).ewm(alpha=1 / n, adjust=False).mean()
|
||||
rs = up / dn.replace(0, np.nan)
|
||||
return (100 - 100 / (1 + rs)).fillna(50.0)
|
||||
|
||||
|
||||
def _atr(df: pd.DataFrame, n: int = 14) -> pd.Series:
|
||||
h, l, c = df["high"], df["low"], df["close"]
|
||||
pc = c.shift(1)
|
||||
tr = pd.concat([(h - l), (h - pc).abs(), (l - pc).abs()], axis=1).max(axis=1)
|
||||
return tr.ewm(alpha=1 / n, adjust=False).mean()
|
||||
|
||||
|
||||
def build_features(df: pd.DataFrame) -> tuple[np.ndarray, list[str], np.ndarray]:
|
||||
"""Return (X, names, warmup_valid_mask). Every column known at close[i]."""
|
||||
c = df["close"].astype(float)
|
||||
h = df["high"].astype(float)
|
||||
l = df["low"].astype(float)
|
||||
o = df["open"].astype(float)
|
||||
v = df["volume"].astype(float)
|
||||
logc = np.log(c)
|
||||
|
||||
feats: dict[str, pd.Series] = {}
|
||||
|
||||
# multi-lag simple returns (ret[i] uses close[i],close[i-k] -> known at i)
|
||||
for k in (1, 2, 3, 6, 12, 24):
|
||||
feats[f"ret{k}"] = c.pct_change(k)
|
||||
|
||||
# candle geometry (current bar fully known at its close)
|
||||
rng = (h - l).replace(0, np.nan)
|
||||
feats["body"] = (c - o) / rng
|
||||
feats["upsh"] = (h - np.maximum(c, o)) / rng
|
||||
feats["dnsh"] = (np.minimum(c, o) - l) / rng
|
||||
feats["range_n"] = (h - l) / c
|
||||
# one-lag candle geometry
|
||||
feats["body1"] = ((c - o) / rng).shift(1)
|
||||
|
||||
# momentum/acceleration
|
||||
feats["mom48"] = c.pct_change(48)
|
||||
feats["accel"] = c.pct_change(6) - c.pct_change(12)
|
||||
|
||||
# RSI
|
||||
feats["rsi14"] = _rsi(c, 14) / 100.0
|
||||
|
||||
# ATR-normalized extension from a trend baseline
|
||||
ema = c.ewm(span=24, adjust=False).mean()
|
||||
atr = _atr(df, 14)
|
||||
feats["ext_atr"] = (c - ema) / atr.replace(0, np.nan)
|
||||
|
||||
# realized vol (std of 1-bar returns)
|
||||
r1 = c.pct_change()
|
||||
feats["rvol24"] = r1.rolling(24).std()
|
||||
feats["rvol72"] = r1.rolling(72).std()
|
||||
feats["vol_ratio"] = feats["rvol24"] / feats["rvol72"].replace(0, np.nan)
|
||||
|
||||
# position of close within recent window (0=low,1=high)
|
||||
for w in (24, 72):
|
||||
lo = l.rolling(w).min()
|
||||
hi = h.rolling(w).max()
|
||||
feats[f"pos{w}"] = (c - lo) / (hi - lo).replace(0, np.nan)
|
||||
|
||||
# volume z-score
|
||||
vlog = np.log1p(v)
|
||||
feats["volz"] = (vlog - vlog.rolling(72).mean()) / vlog.rolling(72).std().replace(0, np.nan)
|
||||
|
||||
names = list(feats.keys())
|
||||
X = np.column_stack([feats[k].to_numpy(dtype=float) for k in names])
|
||||
valid = np.isfinite(X).all(axis=1)
|
||||
return X, names, valid
|
||||
|
||||
|
||||
def forward_labels(df: pd.DataFrame, H: int):
|
||||
"""label[i] = 1 if close[i+H] > close[i] else 0 ; fwd[i] = forward return."""
|
||||
c = df["close"].to_numpy(float)
|
||||
n = len(c)
|
||||
fwd = np.full(n, np.nan)
|
||||
fwd[: n - H] = c[H:] / c[: n - H] - 1.0
|
||||
y = (fwd > 0).astype(float)
|
||||
lab_valid = np.isfinite(fwd)
|
||||
return y, fwd, lab_valid
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strict walk-forward probability
|
||||
# ---------------------------------------------------------------------------
|
||||
def walk_forward_proba(X, y, feat_valid, lab_valid, warmup, W, H, K, model_factory):
|
||||
"""Return proba_up[i] for all i (NaN where not predicted). No leakage:
|
||||
when predicting block starting at b, training labels must be realized: i + H <= b-1,
|
||||
i.e. train indices < b - H. Training window is the last W such indices."""
|
||||
n = len(y)
|
||||
proba = np.full(n, np.nan)
|
||||
start = warmup + W + H
|
||||
b = start
|
||||
while b < n:
|
||||
end_block = min(b + K, n)
|
||||
train_hi = b - H # exclusive; ensures label realized by b-1
|
||||
train_lo = max(warmup, train_hi - W)
|
||||
idx = np.arange(train_lo, train_hi)
|
||||
idx = idx[feat_valid[idx] & lab_valid[idx]]
|
||||
if len(idx) >= MIN_TRAIN:
|
||||
ytr = y[idx]
|
||||
if np.unique(ytr).size == 2:
|
||||
Xtr = X[idx]
|
||||
sc = StandardScaler().fit(Xtr)
|
||||
model = model_factory()
|
||||
model.fit(sc.transform(Xtr), ytr)
|
||||
# predict the block (features known at each bar's own close)
|
||||
blk = np.arange(b, end_block)
|
||||
fv = feat_valid[blk]
|
||||
if fv.any():
|
||||
pb = model.predict_proba(sc.transform(X[blk[fv]]))[:, 1]
|
||||
proba[blk[fv]] = pb
|
||||
b = end_block
|
||||
return proba
|
||||
|
||||
|
||||
def proba_to_entries(proba, threshold, H, n):
|
||||
"""Long if proba>0.5+thr, short if proba<0.5-thr, else flat. Hold H bars."""
|
||||
entries = [None] * n
|
||||
hi = 0.5 + threshold
|
||||
lo = 0.5 - threshold
|
||||
for i in range(n):
|
||||
p = proba[i]
|
||||
if not np.isfinite(p):
|
||||
continue
|
||||
if p > hi:
|
||||
entries[i] = {"dir": 1, "tp": None, "sl": None, "max_bars": H}
|
||||
elif p < lo:
|
||||
entries[i] = {"dir": -1, "tp": None, "sl": None, "max_bars": H}
|
||||
return entries
|
||||
|
||||
|
||||
def mask_entries(entries, lo, hi):
|
||||
"""Keep only entries with index in [lo, hi); others -> None (for IS/OOS split)."""
|
||||
out = [None] * len(entries)
|
||||
for i in range(lo, min(hi, len(entries))):
|
||||
out[i] = entries[i]
|
||||
return out
|
||||
|
||||
|
||||
def trade_stats(df, entries, H):
|
||||
"""Replicate harness no-overlap to get per-trade gross returns -> avg win/loss + long frac."""
|
||||
c = df["close"].to_numpy(float)
|
||||
n = len(c)
|
||||
grosses = []
|
||||
dirs = []
|
||||
busy = -1
|
||||
for i in range(n):
|
||||
e = entries[i]
|
||||
if e is None or i <= busy:
|
||||
continue
|
||||
j = min(i + H, n - 1)
|
||||
g = (c[j] - c[i]) / c[i] * e["dir"]
|
||||
grosses.append(g)
|
||||
dirs.append(e["dir"])
|
||||
busy = j
|
||||
g = np.array(grosses)
|
||||
if len(g) == 0:
|
||||
return 0, 0.0, 0.0, 0.0, 0.0
|
||||
wins = g[g > 0]
|
||||
losses = g[g <= 0]
|
||||
avg_w = wins.mean() if len(wins) else 0.0
|
||||
avg_l = losses.mean() if len(losses) else 0.0
|
||||
long_frac = float(np.mean(np.array(dirs) > 0))
|
||||
return len(g), avg_w, avg_l, g.mean(), long_frac
|
||||
|
||||
|
||||
def buy_hold(df, lo, hi):
|
||||
"""Buy & hold net return over [lo,hi) bars (beta benchmark)."""
|
||||
c = df["close"].to_numpy(float)
|
||||
hi = min(hi, len(c))
|
||||
if hi - lo < 2:
|
||||
return 0.0
|
||||
return c[hi - 1] / c[lo] - 1.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Driver
|
||||
# ---------------------------------------------------------------------------
|
||||
def run():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--quick", action="store_true", help="smaller grid (faster)")
|
||||
ap.add_argument("--gbm", action="store_true", help="also try GradientBoosting on best LR cells")
|
||||
ap.add_argument("--tf", default="1h")
|
||||
args = ap.parse_args()
|
||||
|
||||
assets = ["BTC", "ETH"]
|
||||
tf = args.tf
|
||||
if args.quick:
|
||||
Ws = [8000]
|
||||
Hs = [12, 24]
|
||||
thresholds = [0.0, 0.05, 0.10]
|
||||
else:
|
||||
Ws = [4000, 8000, 16000]
|
||||
Hs = [6, 12, 24, 48]
|
||||
thresholds = [0.0, 0.03, 0.06, 0.10]
|
||||
|
||||
def lr_factory():
|
||||
return LogisticRegression(C=1.0, max_iter=300, class_weight="balanced")
|
||||
|
||||
print("=" * 100)
|
||||
print(f"TRACK B — walk-forward ML tf={tf} retrain_K={RETRAIN_K} held_out_tail={HELD_OUT_FRAC:.0%}")
|
||||
print(f" Ws={Ws} Hs={Hs} thresholds={thresholds} model=LogisticRegression(balanced)")
|
||||
print("=" * 100)
|
||||
|
||||
# cache features per asset
|
||||
cache = {}
|
||||
for a in assets:
|
||||
df = load(a, tf)
|
||||
X, names, fvalid = build_features(df)
|
||||
warmup = int(np.argmax(fvalid)) if fvalid.any() else 0
|
||||
cache[a] = (df, X, names, fvalid, warmup)
|
||||
print(f"features ({len(names)}): {names}\n")
|
||||
|
||||
# ---- DEV grid search (configs chosen ONLY on dev portion) ----------------
|
||||
results = [] # dict rows
|
||||
t0 = time.time()
|
||||
for a in assets:
|
||||
df, X, names, fvalid, warmup = cache[a]
|
||||
n = len(df)
|
||||
dev_hi = int(n * (1 - HELD_OUT_FRAC)) # dev = [0, dev_hi), held = [dev_hi, n)
|
||||
for W in Ws:
|
||||
for H in Hs:
|
||||
y, _fwd, lvalid = forward_labels(df, H)
|
||||
proba = walk_forward_proba(X, y, fvalid, lvalid, warmup, W, H,
|
||||
RETRAIN_K, lr_factory)
|
||||
for thr in thresholds:
|
||||
ent_full = proba_to_entries(proba, thr, H, n)
|
||||
ent_dev = mask_entries(ent_full, warmup, dev_hi)
|
||||
m = backtest_signals(df, ent_dev, fee_rt=0.001, asset=a, tf=tf)
|
||||
nt, aw, al, gmean, lf = trade_stats(df, ent_dev, H)
|
||||
results.append(dict(asset=a, W=W, H=H, thr=thr, seg="DEV",
|
||||
m=m, nt=nt, aw=aw, al=al, gmean=gmean,
|
||||
proba=proba))
|
||||
print(f" [{a}] dev grid done ({time.time()-t0:.0f}s)")
|
||||
|
||||
# print dev table
|
||||
print("\n--- DEV walk-forward (config selection set) ---")
|
||||
hdr = f"{'asset':5} {'W':>6} {'H':>3} {'thr':>5} {'trd':>5} {'wr%':>5} {'net%':>8} {'CAGR%':>7} {'Shrp':>6} {'DD%':>5} {'mkt%':>5} {'avgW%':>6} {'avgL%':>6} {'€/d':>6}"
|
||||
print(hdr)
|
||||
for r in sorted(results, key=lambda r: -r["m"].sharpe):
|
||||
m = r["m"]
|
||||
print(f"{r['asset']:5} {r['W']:>6} {r['H']:>3} {r['thr']:>5.2f} {m.n_trades:>5} "
|
||||
f"{m.win_rate:>5.1f} {m.net_return*100:>+8.1f} {m.cagr*100:>+7.1f} {m.sharpe:>6.2f} "
|
||||
f"{m.max_dd*100:>5.1f} {m.time_in_market*100:>5.0f} {r['aw']*100:>+6.2f} {r['al']*100:>+6.2f} "
|
||||
f"{m.daily_profit(2000):>+6.2f}")
|
||||
|
||||
# ---- selection: positive net AND sharpe>0 on dev, then robustness ----------
|
||||
pos = [r for r in results if r["m"].net_return > 0 and r["m"].sharpe > 0 and r["m"].n_trades >= 30]
|
||||
pos.sort(key=lambda r: -r["m"].sharpe)
|
||||
print(f"\n{len(pos)}/{len(results)} dev cells net-positive with Sharpe>0 & >=30 trades.")
|
||||
|
||||
# robustness: a config family (asset,W,H) is robust if positive across thresholds
|
||||
fam = {}
|
||||
for r in results:
|
||||
fam.setdefault((r["asset"], r["W"], r["H"]), []).append(r)
|
||||
robust_fams = []
|
||||
for key, rs in fam.items():
|
||||
npos = sum(1 for r in rs if r["m"].net_return > 0 and r["m"].sharpe > 0)
|
||||
if npos >= max(2, int(0.6 * len(rs))):
|
||||
robust_fams.append((key, npos, len(rs)))
|
||||
robust_fams.sort(key=lambda x: -x[1])
|
||||
print("\nThreshold-robust (asset,W,H) families [>=60% thresholds net+ & Sharpe>0]:")
|
||||
if not robust_fams:
|
||||
print(" NONE.")
|
||||
for key, npos, tot in robust_fams:
|
||||
print(f" {key}: {npos}/{tot} thresholds positive")
|
||||
|
||||
# ---- HELD-OUT confirmation on best robust cells ---------------------------
|
||||
print("\n" + "=" * 100)
|
||||
print("HELD-OUT TAIL CONFIRMATION (never used for selection)")
|
||||
print("=" * 100)
|
||||
# choose up to 6 best dev cells that belong to a robust family
|
||||
robust_keys = {k for k, _, _ in robust_fams}
|
||||
cand = [r for r in pos if (r["asset"], r["W"], r["H"]) in robust_keys][:6]
|
||||
if not cand:
|
||||
cand = pos[:6]
|
||||
if not cand:
|
||||
print("No positive dev cells to confirm. ML did not beat fees on dev.")
|
||||
print(hdr)
|
||||
held_rows = []
|
||||
for r in cand:
|
||||
a, W, H, thr = r["asset"], r["W"], r["H"], r["thr"]
|
||||
df = cache[a][0]
|
||||
n = len(df)
|
||||
dev_hi = int(n * (1 - HELD_OUT_FRAC))
|
||||
ent_full = proba_to_entries(r["proba"], thr, H, n)
|
||||
ent_held = mask_entries(ent_full, dev_hi, n)
|
||||
m = backtest_signals(df, ent_held, fee_rt=0.001, asset=a, tf=tf)
|
||||
nt, aw, al, gmean, lf = trade_stats(df, ent_held, H)
|
||||
bh = buy_hold(df, dev_hi, n)
|
||||
held_rows.append((r, m, aw, al, lf, bh))
|
||||
print(f"{a:5} {W:>6} {H:>3} {thr:>5.2f} {m.n_trades:>5} {m.win_rate:>5.1f} "
|
||||
f"{m.net_return*100:>+8.1f} {m.cagr*100:>+7.1f} {m.sharpe:>6.2f} {m.max_dd*100:>5.1f} "
|
||||
f"{m.time_in_market*100:>5.0f} {aw*100:>+6.2f} {al*100:>+6.2f} {m.daily_profit(2000):>+6.2f} "
|
||||
f"long={lf*100:>3.0f}% B&H={bh*100:>+7.1f}%")
|
||||
|
||||
# ---- FEE SWEEP on the held-out winners ------------------------------------
|
||||
print("\n--- FEE SWEEP (held-out tail) on confirmed cells ---")
|
||||
fees = [0.0005, 0.001, 0.0015, 0.002]
|
||||
print(" (B&H = buy&hold over held-out tail; if net% << B&H the 'edge' is just beta)")
|
||||
for r, _, _, _, _, _ in held_rows[:4]:
|
||||
a, W, H, thr = r["asset"], r["W"], r["H"], r["thr"]
|
||||
df = cache[a][0]
|
||||
n = len(df)
|
||||
dev_hi = int(n * (1 - HELD_OUT_FRAC))
|
||||
ent_held = mask_entries(proba_to_entries(r["proba"], thr, H, n), dev_hi, n)
|
||||
line = f" {a} W{W} H{H} thr{thr:.2f}: "
|
||||
for f in fees:
|
||||
m = backtest_signals(df, ent_held, fee_rt=f, asset=a, tf=tf)
|
||||
line += f"[{f*100:.2f}%]net={m.net_return*100:>+6.1f}% Shrp={m.sharpe:>+4.2f} "
|
||||
print(line)
|
||||
|
||||
# ---- per-year on the single best held-out cell ----------------------------
|
||||
if held_rows:
|
||||
held_rows.sort(key=lambda x: -x[1].sharpe)
|
||||
r, m, aw, al, lf, bh = held_rows[0]
|
||||
a, W, H, thr = r["asset"], r["W"], r["H"], r["thr"]
|
||||
print(f"\n--- Per-year (best held-out): {a} W{W} H{H} thr{thr:.2f} ---")
|
||||
df = cache[a][0]
|
||||
n = len(df)
|
||||
dev_hi = int(n * (1 - HELD_OUT_FRAC))
|
||||
# full walk-forward per-year (dev+held) to see regime stability
|
||||
mfull = backtest_signals(df, mask_entries(proba_to_entries(r["proba"], thr, H, n),
|
||||
cache[a][4], n), fee_rt=0.001, asset=a, tf=tf)
|
||||
mfull.print_summary(f"{a} W{W}H{H}thr{thr:.2f} FULL-WF")
|
||||
mfull.print_yearly()
|
||||
|
||||
print(f"\nTotal runtime {time.time()-t0:.0f}s")
|
||||
print("\n" + "=" * 100)
|
||||
print("VERDICT (see docs/diary/2026-06-19-trackB-ml.md for the full write-up)")
|
||||
print("=" * 100)
|
||||
print(
|
||||
" * A weak but REAL low-turnover directional signal exists on BTC (thinner on ETH):\n"
|
||||
" large train window (W~16000) + long horizon (H~24) + high prob threshold (~0.10).\n"
|
||||
" * It beats fees at 0.10% RT AND beats buy&hold on the held-out tail with a balanced\n"
|
||||
" long/short mix (so it is NOT just bull-market beta). Payoff: ~53% WR, avgWin>avgLoss.\n"
|
||||
" * BUT: high-turnover cells (low thr / short H / 15m) ALL die on fees -> the edge is small.\n"
|
||||
" Returns concentrate in a few years (2021,2025) with a -38% year (2023); DD 23-56%.\n"
|
||||
" * EUR/day on 2000 ~= +0.3..+0.6 baseline. Target is 50/day -> ~100x short. NOT deployable\n"
|
||||
" standalone; at best a small component, and only the lowest-turnover configs are honest."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,380 @@
|
||||
"""TRACK C — Mean-reversion / range re-examination on CLEAN BTC/ETH (Deribit mainnet).
|
||||
|
||||
HONEST harness only. The OLD 'fade' library (Bollinger fade, Donchian fade, return
|
||||
reversal) was an ARTIFACT of look-ahead + ghost wicks on a contaminated feed; on the
|
||||
rebuilt+certified data those are negative every year. This script asks, skeptically:
|
||||
|
||||
Does ANY short-horizon mean-reversion / range edge survive on clean BTC/ETH with a
|
||||
genuinely EXECUTABLE entry (direction + price decided with data <= close[i],
|
||||
fill at close[i]), net of realistic Deribit fees, out-of-sample and grid-robust?
|
||||
|
||||
Methodology enforced here:
|
||||
* Entry decided with data through close[i]; fill at close[i] (harness guarantees it).
|
||||
No entering "at the band edge" / candle extreme only known intrabar.
|
||||
* NET fees fee_rt=0.001 baseline + sweep {0.0005, 0.0015, 0.002}.
|
||||
* OOS 65/35 split + parameter grid across BOTH BTC & ETH.
|
||||
* Liquidity/plausibility cross-check: time-in-market, avg bars, and whether the edge
|
||||
concentrates in flat (O=H=L=C heavy) periods.
|
||||
|
||||
Run:
|
||||
uv run python scripts/research/trackC_meanrev.py # full (slow, all TFs)
|
||||
uv run python scripts/research/trackC_meanrev.py --quick # 1h + 15m only
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from src.backtest.harness import load, backtest_signals, oos_split, Metrics
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Indicator helpers — ALL causal: value at index i uses ONLY data through i.
|
||||
# ===========================================================================
|
||||
def zscore(close: np.ndarray, lookback: int) -> np.ndarray:
|
||||
s = pd.Series(close)
|
||||
ma = s.rolling(lookback).mean()
|
||||
sd = s.rolling(lookback).std(ddof=0)
|
||||
z = (s - ma) / sd
|
||||
return z.values, ma.values, sd.values
|
||||
|
||||
|
||||
def rsi(close: np.ndarray, period: int) -> np.ndarray:
|
||||
s = pd.Series(close)
|
||||
d = s.diff()
|
||||
up = d.clip(lower=0.0)
|
||||
dn = (-d).clip(lower=0.0)
|
||||
# Wilder smoothing via ewm alpha=1/period (causal)
|
||||
ru = up.ewm(alpha=1.0 / period, adjust=False).mean()
|
||||
rd = dn.ewm(alpha=1.0 / period, adjust=False).mean()
|
||||
rs = ru / rd.replace(0, np.nan)
|
||||
out = 100 - 100 / (1 + rs)
|
||||
return out.values
|
||||
|
||||
|
||||
def atr(df: pd.DataFrame, period: int) -> 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 / period, adjust=False).mean().values
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Signal generators — each returns a list[dict|None] length len(df).
|
||||
# Direction/levels decided strictly with data through close[i].
|
||||
# ===========================================================================
|
||||
def sig_zfade(df, lookback=20, z=2.0, tp_mode="mean", tp_atr=1.0, sl_atr=2.0,
|
||||
max_bars=24, atr_p=14):
|
||||
"""Bollinger / z-score fade. z<-thr -> long (reversion up); z>+thr -> short.
|
||||
TP at the moving mean (tp_mode='mean') or at tp_atr*ATR toward the mean.
|
||||
SL at sl_atr*ATR beyond entry. Entry at close[i]."""
|
||||
c = df["close"].values
|
||||
z_arr, ma, _ = zscore(c, lookback)
|
||||
a = atr(df, atr_p)
|
||||
n = len(c)
|
||||
out = [None] * n
|
||||
for i in range(lookback, n):
|
||||
zi = z_arr[i]
|
||||
if not np.isfinite(zi) or not np.isfinite(a[i]):
|
||||
continue
|
||||
px = c[i]
|
||||
if zi <= -z:
|
||||
direction = 1
|
||||
tp = ma[i] if tp_mode == "mean" else px + tp_atr * a[i]
|
||||
sl = px - sl_atr * a[i] if sl_atr else None
|
||||
elif zi >= z:
|
||||
direction = -1
|
||||
tp = ma[i] if tp_mode == "mean" else px - tp_atr * a[i]
|
||||
sl = px + sl_atr * a[i] if sl_atr else None
|
||||
else:
|
||||
continue
|
||||
# guardrail: never set TP on wrong side of entry
|
||||
if direction == 1 and tp <= px:
|
||||
tp = px + tp_atr * a[i]
|
||||
if direction == -1 and tp >= px:
|
||||
tp = px - tp_atr * a[i]
|
||||
out[i] = {"dir": direction, "tp": tp, "sl": sl, "max_bars": max_bars}
|
||||
return out
|
||||
|
||||
|
||||
def sig_rsi2(df, period=2, lo=10, hi=90, tp_atr=1.0, sl_atr=2.0, max_bars=12,
|
||||
atr_p=14, sma_filter=0):
|
||||
"""RSI(2)-style oversold/overbought reversion. RSI<lo -> long, RSI>hi -> short.
|
||||
Optional trend filter: only long above SMA(sma_filter), only short below."""
|
||||
c = df["close"].values
|
||||
r = rsi(c, period)
|
||||
a = atr(df, atr_p)
|
||||
sma = pd.Series(c).rolling(sma_filter).mean().values if sma_filter else None
|
||||
n = len(c)
|
||||
out = [None] * n
|
||||
for i in range(max(period, atr_p, sma_filter), n):
|
||||
ri = r[i]
|
||||
if not np.isfinite(ri) or not np.isfinite(a[i]):
|
||||
continue
|
||||
px = c[i]
|
||||
if ri <= lo:
|
||||
if sma is not None and not (px > sma[i]):
|
||||
continue
|
||||
out[i] = {"dir": 1, "tp": px + tp_atr * a[i],
|
||||
"sl": px - sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
elif ri >= hi:
|
||||
if sma is not None and not (px < sma[i]):
|
||||
continue
|
||||
out[i] = {"dir": -1, "tp": px - tp_atr * a[i],
|
||||
"sl": px + sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
return out
|
||||
|
||||
|
||||
def sig_retrev(df, ret_lb=1, thr_sigma=2.0, vol_lb=50, tp_atr=1.0, sl_atr=2.0,
|
||||
max_bars=6, atr_p=14):
|
||||
"""Return reversal: fade an extreme cumulative return over the last ret_lb bars.
|
||||
Extreme = |ret| > thr_sigma * rolling std of that return. Entry at close[i]."""
|
||||
c = df["close"].values
|
||||
s = pd.Series(c)
|
||||
ret = np.log(s / s.shift(ret_lb))
|
||||
sd = ret.rolling(vol_lb).std(ddof=0)
|
||||
a = atr(df, atr_p)
|
||||
n = len(c)
|
||||
out = [None] * n
|
||||
rv = ret.values
|
||||
sv = sd.values
|
||||
for i in range(vol_lb + ret_lb, n):
|
||||
if not np.isfinite(rv[i]) or not np.isfinite(sv[i]) or sv[i] == 0 or not np.isfinite(a[i]):
|
||||
continue
|
||||
z = rv[i] / sv[i]
|
||||
px = c[i]
|
||||
if z <= -thr_sigma:
|
||||
out[i] = {"dir": 1, "tp": px + tp_atr * a[i],
|
||||
"sl": px - sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
elif z >= thr_sigma:
|
||||
out[i] = {"dir": -1, "tp": px - tp_atr * a[i],
|
||||
"sl": px + sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
return out
|
||||
|
||||
|
||||
def sig_vwap(df, sess_bars=24, thr=2.0, tp_atr=1.0, sl_atr=2.0, max_bars=12, atr_p=14):
|
||||
"""Rolling-VWAP distance reversion. Distance in std-of-distance units over a
|
||||
rolling session window. Far above VWAP -> short, far below -> long. Entry close[i]."""
|
||||
c = df["close"].values
|
||||
v = df["volume"].values.astype(float)
|
||||
tp = (df["high"].values + df["low"].values + c) / 3.0
|
||||
pv = pd.Series(tp * v)
|
||||
vol = pd.Series(v)
|
||||
vwap = (pv.rolling(sess_bars).sum() / vol.rolling(sess_bars).sum()).values
|
||||
dist = pd.Series(c - vwap)
|
||||
dsd = dist.rolling(sess_bars).std(ddof=0).values
|
||||
a = atr(df, atr_p)
|
||||
n = len(c)
|
||||
out = [None] * n
|
||||
for i in range(sess_bars * 2, n):
|
||||
if not np.isfinite(vwap[i]) or not np.isfinite(dsd[i]) or dsd[i] == 0 or not np.isfinite(a[i]):
|
||||
continue
|
||||
z = (c[i] - vwap[i]) / dsd[i]
|
||||
px = c[i]
|
||||
if z <= -thr:
|
||||
out[i] = {"dir": 1, "tp": px + tp_atr * a[i],
|
||||
"sl": px - sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
elif z >= thr:
|
||||
out[i] = {"dir": -1, "tp": px - tp_atr * a[i],
|
||||
"sl": px + sl_atr * a[i] if sl_atr else None, "max_bars": max_bars}
|
||||
return out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Evaluation utilities
|
||||
# ===========================================================================
|
||||
def flat_fraction(df: pd.DataFrame) -> float:
|
||||
o, h, l, c = df["open"], df["high"], df["low"], df["close"]
|
||||
return float(((h == l) & (o == c)).mean())
|
||||
|
||||
|
||||
def run_split(df, sigfn, params, fee_rt=0.001, leverage=1.0):
|
||||
"""Run full / IS / OOS for a single config. Returns (full, is_, oos)."""
|
||||
entries = sigfn(df, **params)
|
||||
cut = oos_split(df, 0.65)
|
||||
full = backtest_signals(df, entries, fee_rt=fee_rt, leverage=leverage)
|
||||
df_is = df.iloc[:cut].reset_index(drop=True)
|
||||
df_oos = df.iloc[cut:].reset_index(drop=True)
|
||||
is_ = backtest_signals(df_is, sigfn(df_is, **params), fee_rt=fee_rt, leverage=leverage)
|
||||
oos = backtest_signals(df_oos, sigfn(df_oos, **params), fee_rt=fee_rt, leverage=leverage)
|
||||
return full, is_, oos
|
||||
|
||||
|
||||
def hdr(title):
|
||||
print("\n" + "=" * 92)
|
||||
print(title)
|
||||
print("=" * 92)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Main
|
||||
# ===========================================================================
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--quick", action="store_true", help="1h+15m only (skip slow 5m)")
|
||||
args = ap.parse_args()
|
||||
|
||||
t0 = time.time()
|
||||
tfs = ["1h", "15m"] if args.quick else ["1h", "15m", "5m"]
|
||||
assets = ["BTC", "ETH"]
|
||||
|
||||
# preload + liquidity sanity
|
||||
data = {}
|
||||
hdr("DATA / LIQUIDITY SANITY (flat-bar fraction O=H=L=C; should be ~0 on clean BTC/ETH)")
|
||||
for a in assets:
|
||||
for tf in tfs:
|
||||
df = load(a, tf)
|
||||
data[(a, tf)] = df
|
||||
print(f" {a} {tf:>3s}: {len(df):>7d} bars {df['datetime'].iloc[0].date()}→"
|
||||
f"{df['datetime'].iloc[-1].date()} flat={flat_fraction(df)*100:5.2f}%")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 1 — broad screen per family on 1h, both assets (IS/OOS).
|
||||
# -------------------------------------------------------------------
|
||||
hdr("PASS 1 — FAMILY SCREEN on 1h (honest entry, fee_rt=0.001, lev=1). "
|
||||
"Look for OOS>0 on BOTH assets.")
|
||||
families = {
|
||||
"ZFADE z2/mean ": (sig_zfade, dict(lookback=20, z=2.0, tp_mode="mean", sl_atr=2.0, max_bars=24)),
|
||||
"ZFADE z2.5/atr": (sig_zfade, dict(lookback=20, z=2.5, tp_mode="atr", tp_atr=1.5, sl_atr=2.0, max_bars=24)),
|
||||
"ZFADE z3/mean ": (sig_zfade, dict(lookback=40, z=3.0, tp_mode="mean", sl_atr=3.0, max_bars=48)),
|
||||
"RSI2 10/90 ": (sig_rsi2, dict(period=2, lo=10, hi=90, tp_atr=1.0, sl_atr=2.0, max_bars=12)),
|
||||
"RSI2 5/95 ": (sig_rsi2, dict(period=2, lo=5, hi=95, tp_atr=1.5, sl_atr=2.5, max_bars=12)),
|
||||
"RSI2 +trend ": (sig_rsi2, dict(period=2, lo=10, hi=90, tp_atr=1.0, sl_atr=2.0, max_bars=12, sma_filter=200)),
|
||||
"RETREV 2sig/6b ": (sig_retrev, dict(ret_lb=1, thr_sigma=2.0, tp_atr=1.0, sl_atr=2.0, max_bars=6)),
|
||||
"RETREV 3sig/12b": (sig_retrev, dict(ret_lb=3, thr_sigma=3.0, tp_atr=1.5, sl_atr=2.5, max_bars=12)),
|
||||
"VWAP 2/sess24": (sig_vwap, dict(sess_bars=24, thr=2.0, tp_atr=1.0, sl_atr=2.0, max_bars=12)),
|
||||
}
|
||||
for name, (fn, params) in families.items():
|
||||
line = f" {name} | "
|
||||
for a in assets:
|
||||
df = data[(a, "1h")]
|
||||
full, is_, oos = run_split(df, fn, params)
|
||||
line += (f"{a}: IS={is_.net_return*100:>+6.0f}% OOS={oos.net_return*100:>+6.0f}% "
|
||||
f"(tr={oos.n_trades:>4d} wr={oos.win_rate:>4.1f} shrp={oos.sharpe:>+4.1f} "
|
||||
f"mkt={oos.time_in_market*100:>3.0f}% ab={oos.avg_bars:>4.1f}) ")
|
||||
print(line)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 2 — parameter GRID on the two most-promising families (z-fade, rsi2),
|
||||
# require OOS>0 on BOTH assets to count a cell as "surviving".
|
||||
# -------------------------------------------------------------------
|
||||
hdr("PASS 2 — GRID ROBUSTNESS (1h). A cell 'survives' only if OOS net>0 on BOTH BTC AND ETH.")
|
||||
|
||||
def grid(fn, base, sweep, tf="1h"):
|
||||
keys = list(sweep.keys())
|
||||
survivors = []
|
||||
total = 0
|
||||
rows = []
|
||||
from itertools import product
|
||||
for combo in product(*[sweep[k] for k in keys]):
|
||||
params = dict(base)
|
||||
params.update(dict(zip(keys, combo)))
|
||||
total += 1
|
||||
res = {}
|
||||
for a in assets:
|
||||
_, is_, oos = run_split(data[(a, tf)], fn, params)
|
||||
res[a] = (is_, oos)
|
||||
ok = all(res[a][1].net_return > 0 for a in assets)
|
||||
both_oos = np.mean([res[a][1].net_return for a in assets]) * 100
|
||||
rows.append((params, res, ok))
|
||||
if ok:
|
||||
survivors.append((params, res))
|
||||
print(f" {fn.__name__}: {len(survivors)}/{total} cells with OOS>0 on BOTH assets")
|
||||
# show best few by mean OOS
|
||||
rows.sort(key=lambda r: np.mean([r[1][a][1].net_return for a in assets]), reverse=True)
|
||||
for params, res, ok in rows[:6]:
|
||||
tag = "OK " if ok else " -"
|
||||
pp = {k: params[k] for k in sweep}
|
||||
s = f" {tag} {pp} | "
|
||||
for a in assets:
|
||||
oos = res[a][1]
|
||||
s += f"{a} OOS={oos.net_return*100:>+6.0f}% (wr={oos.win_rate:>4.1f} shrp={oos.sharpe:>+4.1f}) "
|
||||
print(s)
|
||||
return survivors
|
||||
|
||||
zsurv = grid(sig_zfade,
|
||||
dict(tp_mode="mean", max_bars=24),
|
||||
dict(lookback=[20, 40, 60], z=[2.0, 2.5, 3.0], sl_atr=[2.0, 3.0]))
|
||||
rsurv = grid(sig_rsi2,
|
||||
dict(period=2, tp_atr=1.0),
|
||||
dict(lo=[5, 10, 15], hi=[85, 90, 95], sl_atr=[2.0, 3.0], max_bars=[6, 12]))
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 3 — FEE SWEEP on whatever looks least-bad (z-fade z2/mean) to show fee
|
||||
# sensitivity (MR is high-frequency: fees are first-order).
|
||||
# -------------------------------------------------------------------
|
||||
hdr("PASS 3 — FEE SWEEP (z-fade lookback=20 z=2 mean, 1h). fee=0 is GROSS: is there\n"
|
||||
" ANY edge before fees, or is the fade direction itself wrong on clean data?")
|
||||
fees = [0.0, 0.0005, 0.001, 0.0015, 0.002]
|
||||
base = dict(lookback=20, z=2.0, tp_mode="mean", sl_atr=2.0, max_bars=24)
|
||||
for a in assets:
|
||||
df = data[(a, "1h")]
|
||||
line = f" {a}: "
|
||||
for f in fees:
|
||||
full, is_, oos = run_split(df, sig_zfade, base, fee_rt=f)
|
||||
line += f"fee={f*1000:.1f}bp→ full={full.net_return*100:>+6.0f}% OOS={oos.net_return*100:>+6.0f}% "
|
||||
print(line)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 4 — faster TFs (15m, 5m) on the canonical z-fade, to test the "more MR
|
||||
# opportunities" hypothesis vs the "fee death" reality.
|
||||
# -------------------------------------------------------------------
|
||||
hdr("PASS 4 — z-fade across timeframes (lookback=20 z=2 mean). Faster TF = more fees.")
|
||||
for tf in tfs:
|
||||
for a in assets:
|
||||
df = data[(a, tf)]
|
||||
full, is_, oos = run_split(df, sig_zfade, base)
|
||||
print(f" {a} {tf:>3s}: full={full.net_return*100:>+7.0f}% IS={is_.net_return*100:>+7.0f}% "
|
||||
f"OOS={oos.net_return*100:>+7.0f}% tr={full.n_trades:>5d} wr={full.win_rate:>4.1f}% "
|
||||
f"shrp={full.sharpe:>+4.1f} mkt={full.time_in_market*100:>3.0f}% €/d={full.daily_profit(2000):>+5.2f}")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PASS 5 — SESSION / overnight effect (UTC hour-of-day) on 1h returns.
|
||||
# Pure descriptive: is there a systematically mean-reverting hour bucket?
|
||||
# -------------------------------------------------------------------
|
||||
hdr("PASS 5 — UTC hour-of-day next-bar return autocorrelation (descriptive, no trade).")
|
||||
for a in assets:
|
||||
df = data[(a, "1h")]
|
||||
c = df["close"].values
|
||||
ret = pd.Series(np.log(c[1:] / c[:-1])) # ret[k] = log(c[k+1]/c[k])
|
||||
prev = ret.shift(1)
|
||||
hours = df["datetime"].dt.hour.values[1:1 + len(ret)]
|
||||
tmp = pd.DataFrame({"h": hours[:len(ret)], "r": ret.values, "p": prev.values}).dropna()
|
||||
# autocorr of consecutive bar returns per hour bucket (negative = mean-reverting)
|
||||
ac = tmp.groupby("h").apply(lambda g: g["r"].corr(g["p"]) if len(g) > 30 else np.nan)
|
||||
worst = ac.nsmallest(3)
|
||||
best = ac.nlargest(3)
|
||||
print(f" {a}: most mean-reverting UTC hours (neg autocorr): "
|
||||
+ ", ".join(f"{int(h)}h={v:+.3f}" for h, v in worst.items())
|
||||
+ " | most trending: "
|
||||
+ ", ".join(f"{int(h)}h={v:+.3f}" for h, v in best.items()))
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# VERDICT
|
||||
# -------------------------------------------------------------------
|
||||
hdr("VERDICT")
|
||||
n_surv = len(zsurv) + len(rsurv)
|
||||
if n_surv == 0:
|
||||
print(" No grid cell produced OOS net>0 on BOTH BTC and ETH at baseline fees.")
|
||||
print(" => Consistent with the reset thesis: the old MR 'edge' was a feed artifact.")
|
||||
print(" On clean Deribit data with honest executable entry, short-horizon MR is NOT")
|
||||
print(" a robust net-positive edge. (See per-pass tables above for the evidence.)")
|
||||
else:
|
||||
print(f" {n_surv} grid cell(s) survived OOS>0 on both assets. Inspect above; then stress")
|
||||
print(" with fee sweep / faster TFs before believing. Surviving configs:")
|
||||
for params, res in (zsurv + rsurv):
|
||||
ms = np.mean([res[a][1].net_return for a in assets]) * 100
|
||||
print(f" {params} meanOOS={ms:+.0f}%")
|
||||
print(f"\n (elapsed {time.time()-t0:.0f}s)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,118 @@
|
||||
"""ADVERSARIAL LOOK-AHEAD / EXECUTION-LAG AUDIT of the trend portfolio across timeframes.
|
||||
|
||||
Motivation (2026-06-19): a look-ahead bug (ffill mixed-timeframe on open-labeled bars) can
|
||||
inflate sub-daily Sharpe (e.g. 4h to ~1.6 vs a real ~1.1). This audit stress-tests OUR pipeline:
|
||||
|
||||
1. EXECUTION LAG: standard book holds the position decided at close[i] during bar i+1.
|
||||
We re-run with an EXTRA bar of delay (held during i+2) — i.e. you cannot trade exactly at
|
||||
the close; there is one bar of slippage/latency. A genuine slow-trend edge barely moves; a
|
||||
timing artifact collapses. We sweep lag = 1 (standard) and 2 (conservative).
|
||||
2. RELABEL TEST: resample with label='left' (open-labeled, our default) vs label='right'
|
||||
(close-labeled). The realized Sharpe must be (near) identical; a large gap => the labeling
|
||||
leaks information.
|
||||
|
||||
Conclusion target: identify the timeframe below which costs + lag dominate (don't deploy there).
|
||||
|
||||
Run: uv run python scripts/research/trackD_lookahead_audit.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from src.backtest.harness import load
|
||||
from src.strategies.trend_portfolio import simple_returns, realized_vol, tsmom_blend
|
||||
|
||||
ASSETS = ["BTC", "ETH"]
|
||||
FEE_SIDE = 0.0005
|
||||
TARGET_VOL = 0.20
|
||||
LEVERAGE = 2.0
|
||||
LONG_ONLY = True
|
||||
TFS = {"4h": ("4h", 6), "6h": ("6h", 4), "8h": ("8h", 3), "12h": ("12h", 2), "1d": ("1D", 1)}
|
||||
|
||||
|
||||
def resample(df1h: pd.DataFrame, rule: str, label: str) -> pd.DataFrame:
|
||||
g = df1h.copy()
|
||||
idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
|
||||
idx.name = "dt"
|
||||
g.index = idx
|
||||
out = g.resample(rule, label=label, closed="left").agg(
|
||||
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
|
||||
out = out.dropna(subset=["open"])
|
||||
out["datetime"] = out.index
|
||||
return out.reset_index(drop=True)
|
||||
|
||||
|
||||
def target_series(c, bpd):
|
||||
bpy = bpd * 365.25
|
||||
r = simple_returns(c)
|
||||
vol = realized_vol(r, 30 * bpd, bpy)
|
||||
direction = np.clip(tsmom_blend(c, (30 * bpd, 90 * bpd, 180 * bpd)), 0, None) if LONG_ONLY \
|
||||
else tsmom_blend(c, (30 * bpd, 90 * bpd, 180 * bpd))
|
||||
scal = np.where((vol > 0) & np.isfinite(vol), TARGET_VOL / vol, 0.0)
|
||||
tgt = np.clip(direction * scal, -LEVERAGE, LEVERAGE)
|
||||
tgt[~np.isfinite(tgt)] = 0.0
|
||||
return tgt, r
|
||||
|
||||
|
||||
def sleeve_net(df, bpd, lag):
|
||||
"""net[t] uses position decided at close[t-lag] (lag>=1). lag=1 = standard, lag=2 = +1 delay."""
|
||||
c = df["close"].values.astype(float)
|
||||
tgt, r = target_series(c, bpd)
|
||||
pos = np.zeros(len(tgt))
|
||||
pos[lag:] = tgt[:-lag]
|
||||
gross = pos * r
|
||||
turn = np.abs(np.diff(pos, prepend=0.0))
|
||||
net = gross - FEE_SIDE * turn
|
||||
net[:lag] = 0.0
|
||||
return np.clip(net, -0.99, None), pd.to_datetime(df["datetime"])
|
||||
|
||||
|
||||
def portfolio_metrics(dfs, bpd, lag):
|
||||
series = {}
|
||||
for a in ASSETS:
|
||||
net, ts = sleeve_net(dfs[a], bpd, lag)
|
||||
series[a] = pd.Series(net, index=pd.to_datetime(ts.values))
|
||||
J = pd.concat(series, axis=1, join="inner").dropna()
|
||||
combo = 0.5 * J["BTC"].values + 0.5 * J["ETH"].values
|
||||
bpy = bpd * 365.25
|
||||
sh = float(np.mean(combo) / np.std(combo) * np.sqrt(bpy)) if np.std(combo) > 0 else 0.0
|
||||
eq = np.cumprod(1.0 + np.clip(combo, -0.99, None))
|
||||
dd = float(np.max((np.maximum.accumulate(eq) - eq) / np.maximum.accumulate(eq)))
|
||||
yrs = (J.index[-1] - J.index[0]).days / 365.25
|
||||
cagr = eq[-1] ** (1 / yrs) - 1
|
||||
return sh, dd, cagr
|
||||
|
||||
|
||||
def main():
|
||||
raw = {a: load(a, "1h") for a in ASSETS}
|
||||
print("=" * 96)
|
||||
print("# LOOK-AHEAD / EXECUTION-LAG AUDIT — trend portfolio (long-flat, tvol20, lev2), per timeframe")
|
||||
print("# lag1 = standard (decision held next bar). lag2 = +1 bar execution delay (conservative).")
|
||||
print("# left/right = resample label (open vs close). Big gap => labeling leak.")
|
||||
print("=" * 96)
|
||||
print(f" {'TF':<5s}{'Sh lag1(L)':>12s}{'Sh lag2(L)':>12s}{'Sh lag1(R)':>12s}"
|
||||
f"{'CAGR l1':>10s}{'CAGR l2':>10s}{'DD l1':>8s}{'lag-decay':>11s}")
|
||||
for tf, (rule, bpd) in TFS.items():
|
||||
dfsL = {a: resample(raw[a], rule, "left") for a in ASSETS}
|
||||
dfsR = {a: resample(raw[a], rule, "right") for a in ASSETS}
|
||||
sh1L, dd1, cagr1 = portfolio_metrics(dfsL, bpd, 1)
|
||||
sh2L, _, cagr2 = portfolio_metrics(dfsL, bpd, 2)
|
||||
sh1R, _, _ = portfolio_metrics(dfsR, bpd, 1)
|
||||
decay = (sh1L - sh2L) / sh1L * 100 if sh1L else 0.0
|
||||
flag = " <-- robust" if sh2L >= 0.9 * sh1L and abs(sh1L - sh1R) < 0.1 else ""
|
||||
print(f" {tf:<5s}{sh1L:>12.2f}{sh2L:>12.2f}{sh1R:>12.2f}"
|
||||
f"{cagr1*100:>+9.1f}%{cagr2*100:>+9.1f}%{dd1*100:>7.1f}%{decay:>+10.0f}%{flag}")
|
||||
print("\n Interpretation:")
|
||||
print(" - If Sh lag2 << Sh lag1 (big lag-decay), the edge needs to trade AT the close -> sub-TF")
|
||||
print(" timing artifact / cost-fragile. Robust slow-trend should barely move with +1 bar.")
|
||||
print(" - If Sh lag1(left) != Sh lag1(right), the bar LABELING leaks -> look-ahead. Should match.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,176 @@
|
||||
"""TRACK D on DIFFERENT TIMEFRAMES — per-year PnL and per-year max drawdown.
|
||||
|
||||
Takes the winning config (TSMOM 1-3-6 month blend, vol-target 20%, leverage cap 2x,
|
||||
50/50 BTC+ETH portfolio) and runs it across timeframes 15m / 1h / 4h / 1d.
|
||||
|
||||
Honesty preserved: same building blocks as trackD_trendport.py (positions shifted +1 bar,
|
||||
fee 0.10% RT on turnover, vol-targeting on past-only realized vol). Horizons are kept
|
||||
CALENDAR-consistent across TFs (1/3/6 months -> bars = months*30*bars_per_day), so we test
|
||||
the SAME economic strategy sampled at different frequencies, not different strategies.
|
||||
|
||||
4h/1d are RESAMPLED from the certified 1h feed (00:00 UTC boundaries).
|
||||
|
||||
Run: uv run python scripts/research/trackD_timing.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from src.backtest.harness import load
|
||||
from scripts.research.trackD_trendport import (
|
||||
simple_returns, realized_vol, sig_tsmom_blend, build_target,
|
||||
equity_from_target,
|
||||
)
|
||||
|
||||
ASSETS = ["BTC", "ETH"]
|
||||
FEE_SIDE = 0.0005 # 0.05%/side = 0.10% RT
|
||||
TARGET_VOL = 0.20
|
||||
LEVERAGE = 2.0
|
||||
|
||||
# timeframe -> (load_tf, resample_rule_or_None, bars_per_day)
|
||||
TIMEFRAMES = {
|
||||
"15m": ("15m", None, 96),
|
||||
"1h": ("1h", None, 24),
|
||||
"4h": ("1h", "4h", 6),
|
||||
"1d": ("1h", "1D", 1),
|
||||
}
|
||||
|
||||
|
||||
def resample_ohlc(df: pd.DataFrame, rule: str) -> pd.DataFrame:
|
||||
g = df.copy()
|
||||
idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
|
||||
idx.name = "dt"
|
||||
g.index = idx
|
||||
out = g.resample(rule, label="left", closed="left").agg(
|
||||
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
|
||||
out = out.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 get_df(tf_key: str, asset: str) -> pd.DataFrame:
|
||||
load_tf, rule, _ = TIMEFRAMES[tf_key]
|
||||
df = load(asset, load_tf)
|
||||
if rule:
|
||||
df = resample_ohlc(df, rule)
|
||||
return df
|
||||
|
||||
|
||||
def run_asset(df, bars_per_day, target_vol=TARGET_VOL, leverage=LEVERAGE,
|
||||
long_only=False, fee_side=FEE_SIDE):
|
||||
c = df["close"].values.astype(float)
|
||||
r = simple_returns(c)
|
||||
bpy = bars_per_day * 365.25
|
||||
# recompute building blocks at this TF's bar frequency
|
||||
h1, h3, h6 = 30 * bars_per_day, 90 * bars_per_day, 180 * bars_per_day
|
||||
vol_win = 30 * bars_per_day
|
||||
# realized_vol / tsmom use BARS_PER_YEAR from trackD (1h) for annualization of vol;
|
||||
# we must annualize with THIS tf's bpy -> compute vol locally
|
||||
vol = pd.Series(r).rolling(vol_win, min_periods=vol_win // 2).std().values * np.sqrt(bpy)
|
||||
direction = sig_tsmom_blend(c, horizons=(h1, h3, h6))
|
||||
tgt = build_target(direction, vol, target_vol, leverage, long_only)
|
||||
equity, net = equity_from_target(tgt, r, fee_side)
|
||||
# discrete position SIGN for trade counting (entry = sign change to a new non-zero state)
|
||||
sign = np.sign(tgt)
|
||||
return dict(net=net, ts=df["datetime"], equity=equity, bpy=bpy, sign=sign, target=tgt)
|
||||
|
||||
|
||||
def portfolio_series(sleeves):
|
||||
a = pd.Series(sleeves["BTC"]["net"], index=pd.to_datetime(sleeves["BTC"]["ts"].values))
|
||||
b = pd.Series(sleeves["ETH"]["net"], index=pd.to_datetime(sleeves["ETH"]["ts"].values))
|
||||
j = pd.concat([a.rename("a"), b.rename("b")], axis=1, join="inner").fillna(0.0)
|
||||
combo = 0.5 * j["a"].values + 0.5 * j["b"].values
|
||||
idx = pd.to_datetime(j.index)
|
||||
equity = np.cumprod(1.0 + np.clip(combo, -0.99, None))
|
||||
return idx, combo, equity
|
||||
|
||||
|
||||
def overall_metrics(idx, combo, equity, bpy):
|
||||
rr = combo[np.isfinite(combo)]
|
||||
sharpe = float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)) if np.std(rr) > 0 else 0.0
|
||||
peak = np.maximum.accumulate(equity)
|
||||
dd = float(np.max((peak - equity) / peak))
|
||||
span_days = (idx[-1] - idx[0]).total_seconds() / 86400
|
||||
years = span_days / 365.25
|
||||
total = equity[-1] / equity[0]
|
||||
cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0
|
||||
daily_2k = (2000 * total - 2000) / span_days if span_days > 0 else 0.0
|
||||
return dict(sharpe=sharpe, max_dd=dd, cagr=cagr, total=total - 1, daily_2k=daily_2k)
|
||||
|
||||
|
||||
def per_year(idx, equity):
|
||||
"""Return {year: (pnl_pct, maxdd_pct)} where maxdd is the worst drawdown WITHIN the year."""
|
||||
eq = pd.Series(equity, index=idx)
|
||||
out = {}
|
||||
for y, g in eq.groupby(eq.index.year):
|
||||
if len(g) < 2:
|
||||
continue
|
||||
pnl = g.iloc[-1] / g.iloc[0] - 1.0
|
||||
v = g.values
|
||||
peak = np.maximum.accumulate(v)
|
||||
ddy = float(np.max((peak - v) / peak))
|
||||
out[int(y)] = (float(pnl), ddy)
|
||||
return out
|
||||
|
||||
|
||||
def trades_per_year(sleeves):
|
||||
"""Count entries per year, summed across both sleeves. An 'entry' = the position SIGN
|
||||
changing to a new non-zero value (flat->long, flat->short, or a direction flip)."""
|
||||
counts: dict[int, int] = {}
|
||||
for a in ASSETS:
|
||||
sign = sleeves[a]["sign"]
|
||||
ts = pd.to_datetime(sleeves[a]["ts"].values)
|
||||
for i in range(1, len(sign)):
|
||||
s, prev = sign[i], sign[i - 1]
|
||||
if s != 0 and s != prev: # entry: from flat or opposite into a non-zero state
|
||||
counts[ts[i].year] = counts.get(ts[i].year, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
ALL_YEARS = list(range(2018, 2027))
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 118)
|
||||
print("# TRACK D WINNER ACROSS TIMEFRAMES — TSMOM 1-3-6m blend, vol-target 20%, lev 2x, 50/50 BTC+ETH")
|
||||
print("# fee 0.10% RT on turnover, positions +1 bar (no look-ahead). 4h/1d resampled from certified 1h.")
|
||||
print("=" * 118)
|
||||
|
||||
for mode_long_only, mode_name in ((False, "LONG-SHORT"), (True, "LONG-FLAT")):
|
||||
print("\n" + "#" * 118)
|
||||
print(f"# MODE = {mode_name}")
|
||||
print("#" * 118)
|
||||
for tf_key in TIMEFRAMES:
|
||||
bpd = TIMEFRAMES[tf_key][2]
|
||||
sleeves = {a: run_asset(get_df(tf_key, a), bpd, long_only=mode_long_only)
|
||||
for a in ASSETS}
|
||||
idx, combo, equity = portfolio_series(sleeves)
|
||||
ov = overall_metrics(idx, combo, equity, sleeves["BTC"]["bpy"])
|
||||
py = per_year(idx, equity)
|
||||
|
||||
tpy = trades_per_year(sleeves)
|
||||
total_trades = sum(tpy.values())
|
||||
print(f"\n ── TF {tf_key:<3s} │ ret {ov['total']*100:>+8.0f}% CAGR {ov['cagr']*100:>+6.1f}% "
|
||||
f"Sharpe {ov['sharpe']:>4.2f} maxDD {ov['max_dd']*100:>4.1f}% "
|
||||
f"€/day(2k) {ov['daily_2k']:>+6.2f} trades {total_trades}")
|
||||
# per-year PnL / DD / trades rows
|
||||
print(f" {'PnL %':<8s}" + "".join(
|
||||
(" . " if y not in py else f"{py[y][0]*100:>+7.0f}") for y in ALL_YEARS))
|
||||
print(f" {'maxDD %':<8s}" + "".join(
|
||||
(" . " if y not in py else f"{py[y][1]*100:>7.1f}") for y in ALL_YEARS))
|
||||
print(f" {'trades':<8s}" + "".join(
|
||||
(" . " if y not in py else f"{tpy.get(y,0):>7d}") for y in ALL_YEARS))
|
||||
# year header for reference
|
||||
print("\n " + "year ".ljust(8) + "".join(f"{y:>7d}" for y in ALL_YEARS))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,460 @@
|
||||
"""TRACK D — ROBUST WALK-FORWARD TREND PORTFOLIO (BTC+ETH), vol-targeted + leverage.
|
||||
|
||||
Thesis under test: trend-following's real value in crypto is DRAWDOWN REDUCTION vs
|
||||
buy & hold (it sidesteps crashes). That lower DD lets us apply LEVERAGE and DIVERSIFY
|
||||
across BTC+ETH to build a deployable, risk-adjusted EARNING system, even if each single
|
||||
signal has only a modest Sharpe. Question: does a properly-built, anti-overfit trend
|
||||
portfolio actually EARN robustly across regimes 2018-2026?
|
||||
|
||||
METHOD (strict, honest):
|
||||
* NO LOOK-AHEAD. We build equity directly from a TARGET-POSITION series.
|
||||
- target[i] is decided using ONLY data <= close[i].
|
||||
- target[i] is HELD during the next bar (close[i] -> close[i+1]).
|
||||
- bar return r[t] = close[t]/close[t-1] - 1 (uses close[t], close[t-1]; both <= t).
|
||||
- pnl on bar t = target[t-1] * r[t] (shift positions by 1 -> no leakage).
|
||||
- fees: fee_per_side * |target[t-1] - target[t-2]| (turnover cost, charged on rebalances).
|
||||
This is the harness's documented "build your own equity from a position series" path.
|
||||
* VOL-TARGETING: position = directional_signal * (target_vol / realized_vol), capped at
|
||||
leverage. realized_vol uses past returns only (rolling std up to close[i]). This is the
|
||||
main lever — it lets a modest signal run at a controlled risk level.
|
||||
* WALK-FORWARD / MULTI-REGIME: per-year returns for ALL years 2018-2026. Plus an explicit
|
||||
EARLY (2018-2021) tune / LATE (2022-2026) confirm split. ONE robust param set, both assets.
|
||||
* PORTFOLIO: equal-weight BTC+ETH sleeves, rebalanced each bar. Report combined Sharpe/DD/CAGR.
|
||||
* GRID ROBUSTNESS: chosen config must be positive across a neighborhood AND across regimes.
|
||||
* FEE & LEVERAGE SWEEP: fee/side 0.0005..0.002 (0.10..0.40% RT); leverage cap 1x..3x.
|
||||
|
||||
Run: uv run python scripts/research/trackD_trendport.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from src.backtest.harness import load
|
||||
|
||||
ASSETS = ["BTC", "ETH"]
|
||||
TF = "1h"
|
||||
BARS_PER_YEAR = 24 * 365.25 # 1h bars
|
||||
FEE_SIDE = 0.0005 # 0.05% per side = 0.10% round trip (Deribit taker)
|
||||
|
||||
# horizons in 1h bars ~ 1 / 3 / 6 "months" (30d months)
|
||||
H1, H3, H6 = 30 * 24, 90 * 24, 180 * 24
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core building blocks (all <= close[i])
|
||||
# ---------------------------------------------------------------------------
|
||||
def simple_returns(c: np.ndarray) -> np.ndarray:
|
||||
r = np.zeros(len(c))
|
||||
r[1:] = c[1:] / c[:-1] - 1.0
|
||||
return r
|
||||
|
||||
|
||||
def realized_vol(r: np.ndarray, win: int) -> np.ndarray:
|
||||
"""Annualized realized vol from bar returns up to and including i (no leakage)."""
|
||||
vol = pd.Series(r).rolling(win, min_periods=win // 2).std().values
|
||||
return vol * np.sqrt(BARS_PER_YEAR)
|
||||
|
||||
|
||||
def sig_tsmom_blend(c: np.ndarray, horizons=(H1, H3, H6)) -> np.ndarray:
|
||||
"""Multi-horizon TSMOM: average of sign(close[i]/close[i-h]-1) over horizons -> [-1,1]."""
|
||||
n = len(c)
|
||||
acc = np.zeros(n)
|
||||
cnt = np.zeros(n)
|
||||
for h in horizons:
|
||||
s = np.full(n, np.nan)
|
||||
s[h:] = np.sign(c[h:] / c[:-h] - 1.0)
|
||||
valid = np.isfinite(s)
|
||||
acc[valid] += s[valid]
|
||||
cnt[valid] += 1
|
||||
out = np.zeros(n)
|
||||
nz = cnt > 0
|
||||
out[nz] = acc[nz] / cnt[nz]
|
||||
return out
|
||||
|
||||
|
||||
def sig_ma_slope(c: np.ndarray, span: int, slope_win: int = 24) -> np.ndarray:
|
||||
"""Sign of the slope of an EMA: ema[i] vs ema[i-slope_win]. -> {-1,0,+1}."""
|
||||
ema = pd.Series(c).ewm(span=span, adjust=False).mean().values
|
||||
n = len(c)
|
||||
out = np.zeros(n)
|
||||
out[slope_win:] = np.sign(ema[slope_win:] - ema[:-slope_win])
|
||||
return out
|
||||
|
||||
|
||||
def sig_donchian_state(c, h, l, n_break: int, n_exit: int) -> np.ndarray:
|
||||
"""Donchian breakout with trailing (channel) stop, returns a stateful {-1,0,+1} series.
|
||||
Long when close[i] > prior n_break high; exit/flip via prior n_exit low channel (trailing).
|
||||
Detection uses prior-window extremes EXCLUDING current bar (shift 1) and close[i] -> honest."""
|
||||
hh = pd.Series(h).rolling(n_break).max().shift(1).values
|
||||
ll = pd.Series(l).rolling(n_break).min().shift(1).values
|
||||
xh = pd.Series(h).rolling(n_exit).max().shift(1).values # trailing exit for shorts
|
||||
xl = pd.Series(l).rolling(n_exit).min().shift(1).values # trailing exit for longs
|
||||
n = len(c)
|
||||
state = np.zeros(n)
|
||||
pos = 0
|
||||
for i in range(n):
|
||||
if not np.isfinite(hh[i]):
|
||||
state[i] = 0
|
||||
continue
|
||||
if pos == 1:
|
||||
if c[i] < xl[i]:
|
||||
pos = 0
|
||||
elif pos == -1:
|
||||
if c[i] > xh[i]:
|
||||
pos = 0
|
||||
if pos == 0:
|
||||
if c[i] > hh[i]:
|
||||
pos = 1
|
||||
elif c[i] < ll[i]:
|
||||
pos = -1
|
||||
state[i] = pos
|
||||
return state
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Position construction (vol-targeting + leverage cap + long/flat option)
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_target(direction: np.ndarray, vol: np.ndarray, target_vol: float,
|
||||
leverage: float, long_only: bool) -> np.ndarray:
|
||||
"""target[i] = direction[i] * (target_vol / vol[i]), clipped to [-leverage, leverage].
|
||||
direction[i] in [-1,1]; vol[i] annualized realized vol (<= close[i]). long_only clips <0 to 0."""
|
||||
d = direction.copy()
|
||||
if long_only:
|
||||
d = np.clip(d, 0, None)
|
||||
scal = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 0.0)
|
||||
tgt = d * scal
|
||||
tgt = np.clip(tgt, -leverage, leverage)
|
||||
tgt[~np.isfinite(tgt)] = 0.0
|
||||
return tgt
|
||||
|
||||
|
||||
def equity_from_target(target: np.ndarray, r: np.ndarray, fee_side: float):
|
||||
"""Build equity from a target-position series with NO look-ahead.
|
||||
pos held during bar t = target[t-1]; pnl[t] = target[t-1]*r[t]; fee on turnover."""
|
||||
n = len(target)
|
||||
pos_held = np.zeros(n)
|
||||
pos_held[1:] = target[:-1] # held during bar t = decided at close[t-1]
|
||||
gross = pos_held * r
|
||||
turn = np.abs(np.diff(pos_held, prepend=0.0))
|
||||
net = gross - fee_side * turn
|
||||
net[0] = 0.0
|
||||
net = np.clip(net, -0.99, None) # cannot lose more than capital on a bar
|
||||
equity = np.cumprod(1.0 + net)
|
||||
return equity, net
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
def metrics(equity: np.ndarray, net: np.ndarray, ts: pd.Series) -> dict:
|
||||
rr = net[np.isfinite(net)]
|
||||
sharpe = float(np.mean(rr) / np.std(rr) * np.sqrt(BARS_PER_YEAR)) if np.std(rr) > 0 else 0.0
|
||||
peak = np.maximum.accumulate(equity)
|
||||
dd = float(np.max((peak - equity) / peak))
|
||||
span_days = (ts.iloc[-1] - ts.iloc[0]).total_seconds() / 86400
|
||||
years = span_days / 365.25
|
||||
total = equity[-1] / equity[0]
|
||||
cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0
|
||||
eq_s = pd.Series(equity, index=ts)
|
||||
yearly = {}
|
||||
for y, g in eq_s.groupby(eq_s.index.year):
|
||||
if len(g) > 1 and g.iloc[0] > 0:
|
||||
yearly[int(y)] = float(g.iloc[-1] / g.iloc[0] - 1)
|
||||
daily_2k = (2000 * total - 2000) / span_days if span_days > 0 else 0.0
|
||||
return dict(sharpe=sharpe, max_dd=dd, cagr=cagr, total=total - 1,
|
||||
yearly=yearly, daily_2k=daily_2k, vol_ann=float(np.std(rr) * np.sqrt(BARS_PER_YEAR)))
|
||||
|
||||
|
||||
def avg_gross(target: np.ndarray) -> float:
|
||||
"""Average absolute position = average gross leverage actually deployed."""
|
||||
t = target[np.isfinite(target)]
|
||||
return float(np.mean(np.abs(t))) if len(t) else 0.0
|
||||
|
||||
|
||||
def fmt(m, label):
|
||||
return (f" {label:<34s} ret={m['total']*100:>+9.0f}% CAGR={m['cagr']*100:>+6.1f}% "
|
||||
f"Sh={m['sharpe']:>5.2f} DD={m['max_dd']*100:>4.1f}% volA={m['vol_ann']*100:>4.0f}% "
|
||||
f"€/d(2k)={m['daily_2k']:>+7.2f}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategy assembly
|
||||
# ---------------------------------------------------------------------------
|
||||
def make_direction(df: pd.DataFrame, kind: str, params: dict) -> np.ndarray:
|
||||
c = df["close"].values.astype(float)
|
||||
if kind == "TSMOM":
|
||||
return sig_tsmom_blend(c, params.get("horizons", (H1, H3, H6)))
|
||||
if kind == "MASLOPE":
|
||||
return sig_ma_slope(c, params["span"], params.get("slope_win", 24))
|
||||
if kind == "DONCHIAN":
|
||||
h = df["high"].values.astype(float)
|
||||
l = df["low"].values.astype(float)
|
||||
return sig_donchian_state(c, h, l, params["n_break"], params["n_exit"])
|
||||
raise ValueError(kind)
|
||||
|
||||
|
||||
def run_asset(df, kind, params, target_vol, leverage, long_only, fee_side=FEE_SIDE):
|
||||
c = df["close"].values.astype(float)
|
||||
r = simple_returns(c)
|
||||
vol = realized_vol(r, params.get("vol_win", 30 * 24))
|
||||
direction = make_direction(df, kind, params)
|
||||
tgt = build_target(direction, vol, target_vol, leverage, long_only)
|
||||
equity, net = equity_from_target(tgt, r, fee_side)
|
||||
ts = df["datetime"]
|
||||
m = metrics(equity, net, ts)
|
||||
m["target"] = tgt
|
||||
m["net"] = net
|
||||
m["ts"] = ts
|
||||
m["equity"] = equity
|
||||
return m
|
||||
|
||||
|
||||
def buy_hold(df):
|
||||
c = df["close"].values.astype(float)
|
||||
r = simple_returns(c)
|
||||
equity = np.cumprod(1.0 + np.clip(r, -0.99, None))
|
||||
return metrics(equity, r, df["datetime"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Portfolio (equal-weight BTC+ETH, rebalanced each bar on common timestamps)
|
||||
# ---------------------------------------------------------------------------
|
||||
def portfolio(net_btc_df, net_eth_df, w=(0.5, 0.5)):
|
||||
"""Combine two per-bar net-return series aligned on common timestamps."""
|
||||
a = pd.Series(net_btc_df["net"], index=net_btc_df["ts"].values)
|
||||
b = pd.Series(net_eth_df["net"], index=net_eth_df["ts"].values)
|
||||
j = pd.concat([a.rename("a"), b.rename("b")], axis=1, join="inner").fillna(0.0)
|
||||
combo = w[0] * j["a"].values + w[1] * j["b"].values
|
||||
equity = np.cumprod(1.0 + np.clip(combo, -0.99, None))
|
||||
ts = pd.Series(pd.to_datetime(j.index))
|
||||
return metrics(equity, combo, ts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reporting helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
ALL_YEARS = list(range(2018, 2027))
|
||||
|
||||
|
||||
def print_yearly_row(label, m):
|
||||
cells = []
|
||||
for y in ALL_YEARS:
|
||||
v = m["yearly"].get(y)
|
||||
cells.append(" . " if v is None else f"{v*100:>+6.0f}%")
|
||||
print(f" {label:<26s} " + " ".join(cells))
|
||||
|
||||
|
||||
def yearly_header():
|
||||
print(f" {'config':<26s} " + " ".join(f"{y:>7d}" for y in ALL_YEARS))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Experiments
|
||||
# ---------------------------------------------------------------------------
|
||||
def main():
|
||||
pd.set_option("display.width", 220)
|
||||
dfs = {a: load(a, TF) for a in ASSETS}
|
||||
|
||||
print("=" * 130)
|
||||
print("# TRACK D — VOL-TARGETED TREND PORTFOLIO (BTC+ETH, 1h, Deribit certified)")
|
||||
print("# Equity built from target-position series; positions shifted +1 bar (no look-ahead);")
|
||||
print("# fee = 0.05%/side (0.10% RT) on turnover. Vol-targeting scales by inverse realized vol.")
|
||||
print("=" * 130)
|
||||
|
||||
print("\n# BUY & HOLD BENCHMARK (the DD/return bar trend must beat on risk-adjusted basis)")
|
||||
yearly_header()
|
||||
bh = {}
|
||||
for a in ASSETS:
|
||||
bh[a] = buy_hold(dfs[a])
|
||||
print(fmt(bh[a], f"B&H {a}"))
|
||||
print_yearly_row(f"B&H {a} yearly", bh[a])
|
||||
bh_port = portfolio({"net": simple_returns(dfs["BTC"]["close"].values), "ts": dfs["BTC"]["datetime"]},
|
||||
{"net": simple_returns(dfs["ETH"]["close"].values), "ts": dfs["ETH"]["datetime"]})
|
||||
print(fmt(bh_port, "B&H 50/50 BTC+ETH"))
|
||||
print_yearly_row("B&H port yearly", bh_port)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 1. BROAD SCAN: strategies x vol-target x leverage x long-only, per asset & portfolio
|
||||
# ----------------------------------------------------------------------
|
||||
print("\n" + "=" * 130)
|
||||
print("# 1) BROAD SCAN — per-asset & 50/50 portfolio, vol-target=20%, leverage cap 2x")
|
||||
print("# (TSMOM 1-3-6m blend / MA-slope / Donchian-trailing; long-short vs long-flat)")
|
||||
print("=" * 130)
|
||||
|
||||
strat_defs = [
|
||||
("TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24)),
|
||||
("MASLOPE", dict(span=200, slope_win=48, vol_win=30 * 24)),
|
||||
("DONCHIAN", dict(n_break=200, n_exit=100, vol_win=30 * 24)),
|
||||
]
|
||||
for long_only in (False, True):
|
||||
mode = "LONG-FLAT" if long_only else "LONG-SHORT"
|
||||
print(f"\n --- {mode} ---")
|
||||
for kind, params in strat_defs:
|
||||
sleeves = {}
|
||||
for a in ASSETS:
|
||||
m = run_asset(dfs[a], kind, params, target_vol=0.20, leverage=2.0, long_only=long_only)
|
||||
sleeves[a] = m
|
||||
print(fmt(m, f"{kind} {a}"))
|
||||
port = portfolio(sleeves["BTC"], sleeves["ETH"])
|
||||
print(fmt(port, f"{kind} PORTFOLIO 50/50"))
|
||||
print_yearly_row(f"{kind} port yearly", port)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 2. GRID ROBUSTNESS on the portfolio: vol-target x leverage x vol-window
|
||||
# using the multi-horizon TSMOM blend (the most diversified trend signal)
|
||||
# ----------------------------------------------------------------------
|
||||
print("\n" + "=" * 130)
|
||||
print("# 2) GRID ROBUSTNESS — TSMOM 1-3-6m blend, 50/50 portfolio (LONG-SHORT)")
|
||||
print("# Sweep target-vol x leverage-cap. A real config is positive across the neighborhood.")
|
||||
print("=" * 130)
|
||||
hdr = " " + "tvol\\lev".ljust(8) + "".join(f"{lev:.0f}x".rjust(26) for lev in (1.0, 1.5, 2.0, 3.0))
|
||||
print(hdr)
|
||||
grid = {}
|
||||
for tvol in (0.10, 0.15, 0.20, 0.30, 0.40):
|
||||
row = f" {tvol*100:>6.0f}% "
|
||||
for lev in (1.0, 1.5, 2.0, 3.0):
|
||||
sleeves = {}
|
||||
for a in ASSETS:
|
||||
sleeves[a] = run_asset(dfs[a], "TSMOM",
|
||||
dict(horizons=(H1, H3, H6), vol_win=30 * 24),
|
||||
target_vol=tvol, leverage=lev, long_only=False)
|
||||
port = portfolio(sleeves["BTC"], sleeves["ETH"])
|
||||
grid[(tvol, lev)] = port
|
||||
row += f" Sh{port['sharpe']:>4.2f} DD{port['max_dd']*100:>3.0f} C{port['cagr']*100:>+4.0f}"
|
||||
print(row)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 3. HORIZON-SET robustness (is the 1-3-6m blend a plateau or a lucky combo?)
|
||||
# ----------------------------------------------------------------------
|
||||
print("\n" + "=" * 130)
|
||||
print("# 3) HORIZON-SET ROBUSTNESS — TSMOM blend, portfolio, tvol=20% lev=2x (LONG-SHORT)")
|
||||
print("=" * 130)
|
||||
horizon_sets = {
|
||||
"1m only": (H1,), "3m only": (H3,), "6m only": (H6,),
|
||||
"1-3m": (H1, H3), "3-6m": (H3, H6), "1-3-6m": (H1, H3, H6),
|
||||
"1-2-4m": (30 * 24, 60 * 24, 120 * 24), "2-4-8m": (60 * 24, 120 * 24, 240 * 24),
|
||||
}
|
||||
yearly_header()
|
||||
for name, hs in horizon_sets.items():
|
||||
sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=hs, vol_win=30 * 24),
|
||||
target_vol=0.20, leverage=2.0, long_only=False) for a in ASSETS}
|
||||
port = portfolio(sleeves["BTC"], sleeves["ETH"])
|
||||
print(fmt(port, f"TSMOM {name}"))
|
||||
print()
|
||||
for name, hs in horizon_sets.items():
|
||||
sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=hs, vol_win=30 * 24),
|
||||
target_vol=0.20, leverage=2.0, long_only=False) for a in ASSETS}
|
||||
port = portfolio(sleeves["BTC"], sleeves["ETH"])
|
||||
print_yearly_row(f"{name}", port)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 4. WALK-FORWARD: EARLY (<=2021) tune / LATE (>=2022) confirm
|
||||
# Same single param set for BOTH assets; we just split the equity by date.
|
||||
# ----------------------------------------------------------------------
|
||||
print("\n" + "=" * 130)
|
||||
print("# 4) WALK-FORWARD — split portfolio equity into EARLY (2018-2021) vs LATE (2022-2026)")
|
||||
print("# One param set, both assets. Both halves must earn for the edge to be regime-robust.")
|
||||
print("=" * 130)
|
||||
cfg = dict(kind="TSMOM", params=dict(horizons=(H1, H3, H6), vol_win=30 * 24),
|
||||
target_vol=0.20, leverage=2.0, long_only=False)
|
||||
sleeves = {a: run_asset(dfs[a], cfg["kind"], cfg["params"], cfg["target_vol"],
|
||||
cfg["leverage"], cfg["long_only"]) for a in ASSETS}
|
||||
a = pd.Series(sleeves["BTC"]["net"], index=sleeves["BTC"]["ts"].values)
|
||||
b = pd.Series(sleeves["ETH"]["net"], index=sleeves["ETH"]["ts"].values)
|
||||
j = pd.concat([a.rename("a"), b.rename("b")], axis=1, join="inner").fillna(0.0)
|
||||
combo = 0.5 * j["a"].values + 0.5 * j["b"].values
|
||||
idx = pd.to_datetime(j.index)
|
||||
for lab, mask in (("EARLY 2018-2021", idx.year <= 2021), ("LATE 2022-2026", idx.year >= 2022)):
|
||||
sub = combo[mask]
|
||||
eq = np.cumprod(1.0 + np.clip(sub, -0.99, None))
|
||||
m = metrics(eq, sub, pd.Series(idx[mask]))
|
||||
print(fmt(m, lab))
|
||||
print_yearly_row(f"{lab} yearly", m)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 5. FEE & LEVERAGE SWEEP on the headline portfolio config
|
||||
# ----------------------------------------------------------------------
|
||||
print("\n" + "=" * 130)
|
||||
print("# 5) FEE & LEVERAGE SWEEP — TSMOM 1-3-6m blend portfolio, tvol=20%")
|
||||
print("=" * 130)
|
||||
print(" fee sweep (leverage cap 2x):")
|
||||
for fee in (0.0005, 0.00075, 0.001, 0.0015, 0.002):
|
||||
sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24),
|
||||
target_vol=0.20, leverage=2.0, long_only=False, fee_side=fee)
|
||||
for a in ASSETS}
|
||||
port = portfolio(sleeves["BTC"], sleeves["ETH"])
|
||||
print(fmt(port, f"fee/side={fee:.5f} (RT={2*fee*100:.2f}%)"))
|
||||
print(" leverage sweep (fee 0.05%/side):")
|
||||
for lev in (1.0, 1.5, 2.0, 2.5, 3.0):
|
||||
sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24),
|
||||
target_vol=0.20, leverage=lev, long_only=False)
|
||||
for a in ASSETS}
|
||||
port = portfolio(sleeves["BTC"], sleeves["ETH"])
|
||||
print(fmt(port, f"leverage cap={lev:.1f}x"))
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 6. HEADLINE ROBUST CONFIG — full per-year table + sleeves + portfolio
|
||||
# ----------------------------------------------------------------------
|
||||
print("\n" + "=" * 130)
|
||||
print("# 6) HEADLINE ROBUST CONFIG: TSMOM 1-3-6m blend, vol-target 20%, leverage cap 2x, LONG-SHORT")
|
||||
print("=" * 130)
|
||||
yearly_header()
|
||||
sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24),
|
||||
target_vol=0.20, leverage=2.0, long_only=False) for a in ASSETS}
|
||||
for a in ASSETS:
|
||||
print(fmt(sleeves[a], f"sleeve {a}"))
|
||||
print_yearly_row(f"sleeve {a} yearly", sleeves[a])
|
||||
port = portfolio(sleeves["BTC"], sleeves["ETH"])
|
||||
print(fmt(port, "PORTFOLIO 50/50"))
|
||||
print_yearly_row("PORTFOLIO yearly", port)
|
||||
|
||||
# also long-flat headline (deployable variant — no shorts/funding complexity)
|
||||
print()
|
||||
sleeves_lf = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24),
|
||||
target_vol=0.20, leverage=2.0, long_only=True) for a in ASSETS}
|
||||
port_lf = portfolio(sleeves_lf["BTC"], sleeves_lf["ETH"])
|
||||
print(fmt(port_lf, "PORTFOLIO 50/50 LONG-FLAT"))
|
||||
print_yearly_row("PORTFOLIO LF yearly", port_lf)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 7. €/DAY ON 2000 — what leverage gets us toward 50/day, and the DD it costs
|
||||
# ----------------------------------------------------------------------
|
||||
print("\n" + "=" * 130)
|
||||
print("# 7) PATH TO ~50 EUR/day on 2000 — the REAL lever is TARGET-VOL, not the leverage cap.")
|
||||
print("# At tvol=20%% on 60-80%% crypto vol, positions stay sub-1x: the leverage cap NEVER binds.")
|
||||
print("# To deploy real leverage you raise target-vol; Sharpe is ~constant, DD scales ~linearly.")
|
||||
print("# 'avg gross' = mean |position| = leverage actually used. (cap fixed at 3x here)")
|
||||
print("=" * 130)
|
||||
print(f" {'target_vol':<12s}{'avgGross':>10s}{'CAGR':>9s}{'Sharpe':>9s}{'maxDD':>8s}"
|
||||
f"{'€/day(2k,avg)':>16s}{'final/2k':>12s}")
|
||||
for tvol in (0.20, 0.40, 0.60, 0.80, 1.00):
|
||||
sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24),
|
||||
target_vol=tvol, leverage=3.0, long_only=False) for a in ASSETS}
|
||||
port = portfolio(sleeves["BTC"], sleeves["ETH"])
|
||||
ag = 0.5 * (avg_gross(sleeves["BTC"]["target"]) + avg_gross(sleeves["ETH"]["target"]))
|
||||
print(f" {tvol*100:>8.0f}% {ag:>9.2f}x{port['cagr']*100:>+8.1f}%{port['sharpe']:>9.2f}"
|
||||
f"{port['max_dd']*100:>7.1f}%{port['daily_2k']:>+16.2f}{(1+port['total']):>12.1f}x")
|
||||
# steady-state €/day at current capital under headline CAGR
|
||||
print("\n Steady-state €/day implied by headline CAGR (NOT path-dependent), at various capital:")
|
||||
sleeves = {a: run_asset(dfs[a], "TSMOM", dict(horizons=(H1, H3, H6), vol_win=30 * 24),
|
||||
target_vol=0.20, leverage=2.0, long_only=False) for a in ASSETS}
|
||||
port = portfolio(sleeves["BTC"], sleeves["ETH"])
|
||||
g = port["cagr"]
|
||||
daily_rate = (1 + g) ** (1 / 365.25) - 1
|
||||
for cap in (2000, 5000, 10000, 50000, 100000):
|
||||
print(f" capital={cap:>7d} ~€/day = {cap*daily_rate:>+8.2f} (CAGR={g*100:+.1f}%)")
|
||||
need = 50.0 / daily_rate if daily_rate > 0 else float("inf")
|
||||
print(f"\n To average ~50 EUR/day at this CAGR you'd need ~{need:,.0f} capital "
|
||||
f"(at leverage 2x, maxDD~{port['max_dd']*100:.0f}%).")
|
||||
|
||||
print("\nDONE. See the report/diary for the honest verdict.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,526 @@
|
||||
"""TRACK E — CROSS-SECTIONAL BTC↔ETH relative-value + ENSEMBLE synthesis.
|
||||
|
||||
Two parts, both on certified Deribit-mainnet data (only BTC/ETH), both honest:
|
||||
|
||||
PART 1 — RELATIVE VALUE (market-neutral-ish spread trading on TWO assets):
|
||||
* XS relative momentum: go long the stronger asset, short the weaker (dollar-neutral).
|
||||
* ETH/BTC ratio TREND (z-momentum) and ratio MEAN-REVERSION (z-fade of log-ratio).
|
||||
* Lead-lag (descriptive): does BTC's last-bar move predict ETH's next bar (and vice versa)?
|
||||
All positions are decided with data <= close[i] and HELD over the NEXT bar (i->i+1):
|
||||
realized PnL on bar k uses position set at k-1 -> strict 1-bar shift, NO look-ahead.
|
||||
Fees are turnover-based: |Δpos| * fee_rt/2 PER LEG (a +1↔-1 flip = one round trip = fee_rt).
|
||||
|
||||
PART 2 — ENSEMBLE:
|
||||
Combine the genuinely-positive residual sleeves into ONE portfolio equity curve:
|
||||
(S1) BTC low-turnover ML momentum (trackB best honest cell: W16000 H24 thr0.10, 1h)
|
||||
(S2) Trend-1h, the only cross-asset-robust trend cell from trackA (Donchian N=200 H=12)
|
||||
(S3) the best relative-value sleeve found in PART 1 (if any net-positive OOS)
|
||||
Report combined Sharpe / maxDD / CAGR / EUR-per-day-on-2000 AND the sleeve correlation
|
||||
matrix. A real ensemble edge must be net-positive OOS and LOWER drawdown than its parts.
|
||||
|
||||
Run: uv run python scripts/research/trackE_xsec_ensemble.py
|
||||
uv run python scripts/research/trackE_xsec_ensemble.py --quick (skip slow ML sleeve)
|
||||
uv run python scripts/research/trackE_xsec_ensemble.py --no-cache (recompute ML proba)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from src.backtest.harness import load, backtest_signals, oos_split
|
||||
# reuse trackB ML machinery (strict walk-forward, no leakage) and trackA donchian
|
||||
from scripts.research.trackB_ml import (
|
||||
build_features, forward_labels, walk_forward_proba, proba_to_entries, mask_entries,
|
||||
RETRAIN_K,
|
||||
)
|
||||
from scripts.research.trackA_trend import sig_donchian
|
||||
from sklearn.linear_model import LogisticRegression
|
||||
|
||||
FEE = 0.001 # 0.10% round-trip baseline (per leg for the pair)
|
||||
BARS_PER_YEAR_1H = 24 * 365.25
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Generic honest stats on a per-bar RETURN series (returns realized bar (k-1)->k)
|
||||
# ===========================================================================
|
||||
def equity_from_returns(rets: np.ndarray) -> np.ndarray:
|
||||
eq = np.cumprod(1.0 + np.nan_to_num(rets))
|
||||
return eq
|
||||
|
||||
|
||||
def sharpe(rets: np.ndarray, bpy: float = BARS_PER_YEAR_1H) -> float:
|
||||
r = rets[np.isfinite(rets)]
|
||||
if len(r) < 3 or np.std(r) == 0:
|
||||
return 0.0
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(bpy))
|
||||
|
||||
|
||||
def max_dd(equity: np.ndarray) -> float:
|
||||
peak = np.maximum.accumulate(equity)
|
||||
dd = (peak - equity) / peak
|
||||
return float(np.max(dd)) if len(dd) else 0.0
|
||||
|
||||
|
||||
def cagr(equity: np.ndarray, ts: pd.Series) -> float:
|
||||
if len(equity) < 2:
|
||||
return 0.0
|
||||
days = (ts.iloc[-1] - ts.iloc[0]).total_seconds() / 86400
|
||||
years = days / 365.25 if days > 0 else 1.0
|
||||
if years <= 0 or equity[-1] <= 0:
|
||||
return -1.0
|
||||
return float(equity[-1] ** (1 / years) - 1)
|
||||
|
||||
|
||||
def daily_profit(equity: np.ndarray, ts: pd.Series, capital: float = 2000.0) -> float:
|
||||
if len(equity) < 2:
|
||||
return 0.0
|
||||
days = (ts.iloc[-1] - ts.iloc[0]).total_seconds() / 86400
|
||||
if days <= 0:
|
||||
return 0.0
|
||||
final = capital * equity[-1] / equity[0]
|
||||
return (final - capital) / days
|
||||
|
||||
|
||||
def yearly_returns(rets: np.ndarray, ts: pd.Series) -> dict:
|
||||
eq = equity_from_returns(rets)
|
||||
s = pd.Series(eq, index=pd.DatetimeIndex(ts))
|
||||
out = {}
|
||||
for y, g in s.groupby(s.index.year):
|
||||
if len(g) > 1 and g.iloc[0] > 0:
|
||||
out[int(y)] = float(g.iloc[-1] / g.iloc[0] - 1)
|
||||
return out
|
||||
|
||||
|
||||
def stat_block(rets: np.ndarray, ts: pd.Series, bpy: float = BARS_PER_YEAR_1H) -> dict:
|
||||
eq = equity_from_returns(rets)
|
||||
return dict(
|
||||
net=float(eq[-1] - 1.0), sharpe=sharpe(rets, bpy), max_dd=max_dd(eq),
|
||||
cagr=cagr(eq, ts), eur_day=daily_profit(eq, ts), equity=eq,
|
||||
turnover=float(np.mean(np.abs(np.diff(np.sign(rets) != 0)))), # placeholder, unused
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# RELATIVE-VALUE ENGINE — two legs, turnover-based fees, strict 1-bar shift.
|
||||
# pos arrays are decided at close[i] (data<=i). Realized return on bar k uses pos[k-1].
|
||||
# ===========================================================================
|
||||
def pair_returns(cB: np.ndarray, cE: np.ndarray, posB: np.ndarray, posE: np.ndarray,
|
||||
fee_rt: float = FEE) -> np.ndarray:
|
||||
"""Per-bar net return series for a two-leg book. rets[k] realized on bar (k-1)->k.
|
||||
Fee = (|ΔposB| + |ΔposE|) * fee_rt/2 charged when the position is (re)set."""
|
||||
n = len(cB)
|
||||
aretB = np.zeros(n); aretE = np.zeros(n)
|
||||
aretB[1:] = cB[1:] / cB[:-1] - 1.0
|
||||
aretE[1:] = cE[1:] / cE[:-1] - 1.0
|
||||
rets = np.zeros(n)
|
||||
for k in range(1, n):
|
||||
gross = posB[k - 1] * aretB[k] + posE[k - 1] * aretE[k]
|
||||
pBp = posB[k - 2] if k >= 2 else 0.0
|
||||
pEp = posE[k - 2] if k >= 2 else 0.0
|
||||
turn = abs(posB[k - 1] - pBp) + abs(posE[k - 1] - pEp)
|
||||
rets[k] = gross - turn * fee_rt / 2.0
|
||||
return rets
|
||||
|
||||
|
||||
# --- signal builders: return (posB, posE) arrays, leg notional `leg` (gross = 2*leg) ---
|
||||
def xs_momentum(cB, cE, N, hold, leg=0.5):
|
||||
"""Cross-sectional momentum: long the asset with higher N-bar return, short the other."""
|
||||
n = len(cB)
|
||||
posB = np.zeros(n); posE = np.zeros(n)
|
||||
curB = curE = 0.0
|
||||
for i in range(n):
|
||||
if i >= N and (i % hold == 0):
|
||||
mB = cB[i] / cB[i - N] - 1.0
|
||||
mE = cE[i] / cE[i - N] - 1.0
|
||||
d = 1 if mB > mE else -1 # +1 => BTC stronger -> long BTC short ETH
|
||||
curB = leg * d; curE = -leg * d
|
||||
posB[i] = curB; posE[i] = curE
|
||||
return posB, posE
|
||||
|
||||
|
||||
def ratio_trend(cB, cE, N, hold, leg=0.5):
|
||||
"""Trend on ETH/BTC ratio: ratio rising over N bars -> long ratio (long ETH, short BTC)."""
|
||||
ratio = cE / cB
|
||||
n = len(cB)
|
||||
posB = np.zeros(n); posE = np.zeros(n)
|
||||
curB = curE = 0.0
|
||||
for i in range(n):
|
||||
if i >= N and (i % hold == 0):
|
||||
d = 1 if ratio[i] > ratio[i - N] else -1 # +1 => ratio up -> long ratio
|
||||
curE = leg * d; curB = -leg * d
|
||||
posB[i] = curB; posE[i] = curE
|
||||
return posB, posE
|
||||
|
||||
|
||||
def ratio_meanrev(cB, cE, lookback, z_in, z_exit, max_bars, leg=0.5):
|
||||
"""Mean-reversion (z-fade) on log(ETH/BTC). z>+z_in -> short ratio; z<-z_in -> long ratio.
|
||||
Exit when |z|<z_exit (reverted to mean) or after max_bars. Stateful, honest at close[i]."""
|
||||
logr = np.log(cE / cB)
|
||||
s = pd.Series(logr)
|
||||
ma = s.rolling(lookback).mean().values
|
||||
sd = s.rolling(lookback).std(ddof=0).values
|
||||
z = (logr - ma) / sd
|
||||
n = len(cB)
|
||||
posB = np.zeros(n); posE = np.zeros(n)
|
||||
state = 0 # +1 long ratio, -1 short ratio, 0 flat
|
||||
bars_in = 0
|
||||
for i in range(n):
|
||||
if not np.isfinite(z[i]):
|
||||
posB[i] = 0.0; posE[i] = 0.0; continue
|
||||
if state == 0:
|
||||
if z[i] >= z_in:
|
||||
state = -1; bars_in = 0 # ratio too high -> short ratio
|
||||
elif z[i] <= -z_in:
|
||||
state = 1; bars_in = 0 # ratio too low -> long ratio
|
||||
else:
|
||||
bars_in += 1
|
||||
if abs(z[i]) <= z_exit or bars_in >= max_bars or (state == 1 and z[i] >= z_in) \
|
||||
or (state == -1 and z[i] <= -z_in):
|
||||
state = 0
|
||||
posE[i] = leg * state; posB[i] = -leg * state
|
||||
return posB, posE
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# OOS / fee-sweep helpers for the relative-value sleeves
|
||||
# ===========================================================================
|
||||
def rv_eval(cB, cE, ts, build_fn, params, fee_rt=FEE, frac=0.65):
|
||||
posB, posE = build_fn(cB, cE, **params)
|
||||
rets = pair_returns(cB, cE, posB, posE, fee_rt=fee_rt)
|
||||
cut = int(len(cB) * frac)
|
||||
full = stat_block(rets, ts)
|
||||
is_ = stat_block(rets[:cut], ts.iloc[:cut])
|
||||
oos = stat_block(rets[cut:], ts.iloc[cut:])
|
||||
# turnover: average per-bar leg turnover (both legs)
|
||||
turn = (np.abs(np.diff(posB, prepend=0)) + np.abs(np.diff(posE, prepend=0)))
|
||||
tstats = dict(rets=rets, posB=posB, posE=posE,
|
||||
trades=int((turn > 1e-9).sum()), avg_turn=float(turn.mean()))
|
||||
return full, is_, oos, tstats
|
||||
|
||||
|
||||
def fmt(s):
|
||||
return (f"net={s['net']*100:>+8.0f}% Sh={s['sharpe']:>+5.2f} DD={s['max_dd']*100:>4.0f}% "
|
||||
f"CAGR={s['cagr']*100:>+6.1f}% €/d={s['eur_day']:>+6.2f}")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PART 1
|
||||
# ===========================================================================
|
||||
def part1_relative_value(quick=False):
|
||||
print("=" * 104)
|
||||
print("PART 1 — CROSS-SECTIONAL / RELATIVE-VALUE (BTC↔ETH, 1h, market-neutral spread)")
|
||||
print("=" * 104)
|
||||
b = load("BTC", "1h"); e = load("ETH", "1h")
|
||||
m = pd.merge(b[["timestamp", "close"]], e[["timestamp", "close"]],
|
||||
on="timestamp", suffixes=("_b", "_e")).reset_index(drop=True)
|
||||
ts = pd.to_datetime(m["timestamp"], unit="ms", utc=True)
|
||||
cB = m["close_b"].to_numpy(float); cE = m["close_e"].to_numpy(float)
|
||||
cut = int(len(m) * 0.65)
|
||||
print(f" common 1h bars: {len(m)} {ts.iloc[0].date()} → {ts.iloc[-1].date()} "
|
||||
f"(OOS starts {ts.iloc[cut].date()})")
|
||||
rb = np.log(cB[1:] / cB[:-1]); re = np.log(cE[1:] / cE[:-1])
|
||||
print(f" contemporaneous corr(BTC,ETH 1h logret) = {np.corrcoef(rb, re)[0,1]:.3f} "
|
||||
f"(very high → the only tradable structure is the SPREAD)")
|
||||
|
||||
# ---- LEAD-LAG (descriptive, both directions, IS vs OOS) ----
|
||||
print("\n -- LEAD-LAG (descriptive: does last-bar move of X predict next bar of Y?) --")
|
||||
def ll(a_prev, b_next):
|
||||
a = a_prev[np.isfinite(a_prev) & np.isfinite(b_next)]
|
||||
bb = b_next[np.isfinite(a_prev) & np.isfinite(b_next)]
|
||||
return np.corrcoef(a, bb)[0, 1] if len(a) > 30 else np.nan
|
||||
print(f" corr(rB[i], rE[i+1]) = {ll(rb[:-1], re[1:]):+.4f} "
|
||||
f"corr(rE[i], rB[i+1]) = {ll(re[:-1], rb[1:]):+.4f}")
|
||||
print(f" corr(rB[i], rB[i+1]) = {ll(rb[:-1], rb[1:]):+.4f} "
|
||||
f"corr(rE[i], rE[i+1]) = {ll(re[:-1], re[1:]):+.4f}")
|
||||
print(" → |lead-lag| ~0.01-0.02: NO exploitable cross-predictive edge. Not pursued as a sleeve.")
|
||||
|
||||
results = {}
|
||||
|
||||
# ---- A) XS relative momentum grid ----
|
||||
print("\n -- (A) XS RELATIVE MOMENTUM: long stronger / short weaker (dollar-neutral, gross=1) --")
|
||||
print(" param FULL | OOS")
|
||||
Ns = [24, 72, 168, 336] if not quick else [72, 168]
|
||||
holds = [6, 24, 72] if not quick else [24, 72]
|
||||
best_xs = None
|
||||
for N in Ns:
|
||||
for hold in holds:
|
||||
full, is_, oos, tstat = rv_eval(cB, cE, ts, xs_momentum, dict(N=N, hold=hold))
|
||||
ok = oos["net"] > 0 and oos["sharpe"] > 0
|
||||
print(f" N={N:>3} hold={hold:>2} | {fmt(full)} | OOS {fmt(oos)} "
|
||||
f"tr={tstat['trades']:>4} {'OK' if ok else ''}")
|
||||
if oos["net"] > 0 and (best_xs is None or oos["sharpe"] > best_xs[2]["sharpe"]):
|
||||
best_xs = (dict(N=N, hold=hold), full, oos, tstat, "xs_momentum")
|
||||
results["xs_momentum"] = best_xs
|
||||
|
||||
# ---- B) ETH/BTC ratio TREND grid ----
|
||||
print("\n -- (B) ETH/BTC RATIO TREND: long ratio when rising over N (long ETH/short BTC) --")
|
||||
print(" NOTE: with only TWO assets this is ALGEBRAICALLY IDENTICAL to (A) — 'long the")
|
||||
print(" stronger' ≡ 'trade the ratio trend'. Shown separately only to make that explicit.")
|
||||
best_rt = None
|
||||
for N in Ns:
|
||||
for hold in holds:
|
||||
full, is_, oos, tstat = rv_eval(cB, cE, ts, ratio_trend, dict(N=N, hold=hold))
|
||||
ok = oos["net"] > 0 and oos["sharpe"] > 0
|
||||
print(f" N={N:>3} hold={hold:>2} | {fmt(full)} | OOS {fmt(oos)} "
|
||||
f"tr={tstat['trades']:>4} {'OK' if ok else ''}")
|
||||
if oos["net"] > 0 and (best_rt is None or oos["sharpe"] > best_rt[2]["sharpe"]):
|
||||
best_rt = (dict(N=N, hold=hold), full, oos, tstat, "ratio_trend")
|
||||
results["ratio_trend"] = best_rt
|
||||
|
||||
# ---- C) ETH/BTC ratio MEAN-REVERSION grid ----
|
||||
print("\n -- (C) ETH/BTC RATIO MEAN-REVERSION: z-fade of log(ETH/BTC) --")
|
||||
best_mr = None
|
||||
LBs = [48, 168, 336] if not quick else [168]
|
||||
zins = [1.5, 2.0, 2.5] if not quick else [2.0]
|
||||
for lb in LBs:
|
||||
for zin in zins:
|
||||
full, is_, oos, tstat = rv_eval(cB, cE, ts, ratio_meanrev,
|
||||
dict(lookback=lb, z_in=zin, z_exit=0.5, max_bars=72))
|
||||
ok = oos["net"] > 0 and oos["sharpe"] > 0
|
||||
print(f" lb={lb:>3} zin={zin} | {fmt(full)} | OOS {fmt(oos)} "
|
||||
f"tr={tstat['trades']:>4} {'OK' if ok else ''}")
|
||||
if oos["net"] > 0 and (best_mr is None or oos["sharpe"] > best_mr[2]["sharpe"]):
|
||||
best_mr = (dict(lookback=lb, z_in=zin, z_exit=0.5, max_bars=72),
|
||||
full, oos, tstat, "ratio_meanrev")
|
||||
results["ratio_meanrev"] = best_mr
|
||||
|
||||
# ---- choose the single best RV sleeve (positive OOS, highest OOS Sharpe) ----
|
||||
cands = [v for v in results.values() if v is not None]
|
||||
cands.sort(key=lambda v: v[2]["sharpe"], reverse=True)
|
||||
best = cands[0] if cands else None
|
||||
|
||||
print("\n -- RELATIVE-VALUE SUMMARY (best per family that is OOS net-positive) --")
|
||||
for fam in ("xs_momentum", "ratio_trend", "ratio_meanrev"):
|
||||
v = results[fam]
|
||||
if v is None:
|
||||
print(f" {fam:<14}: no OOS net-positive cell.")
|
||||
else:
|
||||
params, full, oos, tstat, _ = v
|
||||
print(f" {fam:<14}: {params} FULL {fmt(full)} | OOS {fmt(oos)}")
|
||||
|
||||
if best is None:
|
||||
print("\n >> NO relative-value sleeve is OOS net-positive. No RV edge to add to the ensemble.")
|
||||
return None, (cB, cE, ts)
|
||||
|
||||
params, full, oos, tstat, fam = best
|
||||
print(f"\n >> BEST RV sleeve: {fam} {params} (OOS Sharpe {oos['sharpe']:+.2f})")
|
||||
|
||||
# ---- per-year + fee sweep + grid-neighbourhood robustness on the winner ----
|
||||
build_fn = {"xs_momentum": xs_momentum, "ratio_trend": ratio_trend,
|
||||
"ratio_meanrev": ratio_meanrev}[fam]
|
||||
fullr, _, _, _ = rv_eval(cB, cE, ts, build_fn, params)
|
||||
print("\n per-year (full):")
|
||||
yr = yearly_returns(fullr["rets"] if False else pair_returns(cB, cE,
|
||||
*build_fn(cB, cE, **params)), ts)
|
||||
for y in sorted(yr):
|
||||
print(f" {y}: {yr[y]*100:>+7.1f}%")
|
||||
print("\n fee sweep (full-sample net, baseline 0.10% RT/leg):")
|
||||
for f in (0.0, 0.0005, 0.001, 0.0015, 0.002):
|
||||
fr, _, fo, _ = rv_eval(cB, cE, ts, build_fn, params, fee_rt=f)
|
||||
print(f" fee={f*1000:.1f}bp/leg → FULL net={fr['net']*100:>+7.0f}% "
|
||||
f"OOS net={fo['net']*100:>+7.0f}% (Sh {fo['sharpe']:+.2f})")
|
||||
|
||||
return best, (cB, cE, ts)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# PART 2 — ENSEMBLE
|
||||
# ===========================================================================
|
||||
def lr_factory():
|
||||
return LogisticRegression(C=1.0, max_iter=300, class_weight="balanced")
|
||||
|
||||
|
||||
def ml_sleeve_btc(cache=True, no_cache=False):
|
||||
"""BTC low-turnover ML momentum sleeve (trackB best honest cell W16000 H24 thr0.10)."""
|
||||
W, H, thr = 16000, 24, 0.10
|
||||
df = load("BTC", "1h")
|
||||
cpath = Path(__file__).resolve().parent / ".cache_trackE_btc_ml_proba.npy"
|
||||
proba = None
|
||||
if cache and not no_cache and cpath.exists():
|
||||
arr = np.load(cpath)
|
||||
if len(arr) == len(df):
|
||||
proba = arr
|
||||
print(f" [S1 ML] loaded cached proba ({cpath.name})")
|
||||
if proba is None:
|
||||
print(f" [S1 ML] walk-forward LogisticRegression W{W} H{H} (slow ~1-2min)...")
|
||||
t0 = time.time()
|
||||
X, names, fvalid = build_features(df)
|
||||
warmup = int(np.argmax(fvalid)) if fvalid.any() else 0
|
||||
y, _fwd, lvalid = forward_labels(df, H)
|
||||
proba = walk_forward_proba(X, y, fvalid, lvalid, warmup, W, H, RETRAIN_K, lr_factory)
|
||||
np.save(cpath, proba)
|
||||
print(f" [S1 ML] done ({time.time()-t0:.0f}s), cached.")
|
||||
n = len(df)
|
||||
entries = proba_to_entries(proba, thr, H, n)
|
||||
m = backtest_signals(df, entries, fee_rt=FEE, asset="BTC", tf="1h")
|
||||
return m, df, f"BTC-ML W{W}H{H}thr{thr}"
|
||||
|
||||
|
||||
def trend_sleeve_btc():
|
||||
"""Trend-1h sleeve: Donchian N=200 H=12 on BTC (the only cross-asset-robust trend cell)."""
|
||||
df = load("BTC", "1h")
|
||||
entries = sig_donchian(df, lookback=200, hold=12)
|
||||
m = backtest_signals(df, entries, fee_rt=FEE, asset="BTC", tf="1h")
|
||||
return m, df, "BTC-Trend Donchian200/12"
|
||||
|
||||
|
||||
def metrics_to_returns(m):
|
||||
"""Per-bar return series from a harness Metrics equity, indexed by its timestamps."""
|
||||
eq = m.equity.astype(float)
|
||||
ts = m.eq_index
|
||||
rets = np.zeros(len(eq))
|
||||
rets[1:] = eq[1:] / np.where(eq[:-1] == 0, np.nan, eq[:-1]) - 1.0
|
||||
rets = np.nan_to_num(rets)
|
||||
return pd.Series(rets, index=pd.DatetimeIndex(ts))
|
||||
|
||||
|
||||
def part2_ensemble(rv_best, rv_data, quick=False, no_cache=False):
|
||||
print("\n" + "=" * 104)
|
||||
print("PART 2 — ENSEMBLE (combine weakly-correlated residual sleeves into one portfolio)")
|
||||
print("=" * 104)
|
||||
|
||||
sleeves = {} # name -> pd.Series of per-bar returns indexed by ts
|
||||
|
||||
# S2 trend (fast, always)
|
||||
mt, dft, tname = trend_sleeve_btc()
|
||||
sleeves["S2_trend"] = metrics_to_returns(mt)
|
||||
print(f" [S2] {tname:<28} net={mt.net_return*100:>+7.0f}% Sh={mt.sharpe:+.2f} "
|
||||
f"DD={mt.max_dd*100:.0f}% €/d={mt.daily_profit(2000):+.2f}")
|
||||
|
||||
# S3 relative value (from PART 1)
|
||||
if rv_best is not None:
|
||||
params, full, oos, tstat, fam = rv_best
|
||||
cB, cE, ts = rv_data
|
||||
build_fn = {"xs_momentum": xs_momentum, "ratio_trend": ratio_trend,
|
||||
"ratio_meanrev": ratio_meanrev}[fam]
|
||||
posB, posE = build_fn(cB, cE, **params)
|
||||
rv_rets = pair_returns(cB, cE, posB, posE, fee_rt=FEE)
|
||||
sleeves["S3_relval"] = pd.Series(rv_rets, index=pd.DatetimeIndex(ts))
|
||||
print(f" [S3] RV {fam} {params} net={full['net']*100:>+7.0f}% "
|
||||
f"Sh={full['sharpe']:+.2f} DD={full['max_dd']*100:.0f}% €/d={full['eur_day']:+.2f}")
|
||||
else:
|
||||
print(" [S3] no relative-value sleeve (none was OOS net-positive in PART 1).")
|
||||
|
||||
# S1 ML (slow; skipped in --quick)
|
||||
if not quick:
|
||||
m1, df1, mlname = ml_sleeve_btc(no_cache=no_cache)
|
||||
sleeves["S1_ml"] = metrics_to_returns(m1)
|
||||
print(f" [S1] {mlname:<28} net={m1.net_return*100:>+7.0f}% Sh={m1.sharpe:+.2f} "
|
||||
f"DD={m1.max_dd*100:.0f}% €/d={m1.daily_profit(2000):+.2f}")
|
||||
else:
|
||||
print(" [S1] ML sleeve SKIPPED (--quick).")
|
||||
|
||||
# ---- align all sleeves on a common 1h timeline (BTC clock) ----
|
||||
master = sleeves["S2_trend"].index
|
||||
aligned = pd.DataFrame(index=master)
|
||||
for name, s in sleeves.items():
|
||||
aligned[name] = s.reindex(master).fillna(0.0)
|
||||
|
||||
# the portfolio is only meaningful where the slowest sleeve is live.
|
||||
# find first bar where each sleeve has produced non-zero activity, take the max.
|
||||
starts = {}
|
||||
for name in aligned.columns:
|
||||
nz = np.nonzero(aligned[name].to_numpy() != 0.0)[0]
|
||||
starts[name] = nz[0] if len(nz) else len(aligned)
|
||||
start = max(starts.values())
|
||||
aligned = aligned.iloc[start:]
|
||||
ts_a = pd.Series(aligned.index)
|
||||
print(f"\n Common active window: {aligned.index[0].date()} → {aligned.index[-1].date()} "
|
||||
f"({len(aligned)} bars). Sleeves: {list(aligned.columns)}")
|
||||
|
||||
# ---- sleeve correlation matrix (per-bar returns over common window) ----
|
||||
print("\n SLEEVE CORRELATION MATRIX (per-bar returns, common window):")
|
||||
corr = aligned.corr()
|
||||
cols = list(aligned.columns)
|
||||
print(" " + "".join(f"{c:>10}" for c in cols))
|
||||
for c in cols:
|
||||
print(f" {c:>9} " + "".join(f"{corr.loc[c, c2]:>+10.3f}" for c2 in cols))
|
||||
|
||||
# ---- per-sleeve stats on the COMMON window (apples-to-apples) ----
|
||||
print("\n PER-SLEEVE (common window, equal $ scale):")
|
||||
sl_stats = {}
|
||||
for c in cols:
|
||||
st = stat_block(aligned[c].to_numpy(), ts_a)
|
||||
sl_stats[c] = st
|
||||
print(f" {c:>9}: {fmt(st)}")
|
||||
|
||||
# ---- ensemble: equal-weight (honest, no in-sample tuning) ----
|
||||
w = 1.0 / len(cols)
|
||||
ens_eq_w = aligned.to_numpy() @ (np.ones(len(cols)) * w)
|
||||
ens = stat_block(ens_eq_w, ts_a)
|
||||
|
||||
# ---- ensemble: inverse-vol weights (flagged: weights use full-sample vol = mild IS) ----
|
||||
vols = np.array([np.std(aligned[c].to_numpy()) for c in cols])
|
||||
iv = (1.0 / np.where(vols == 0, np.nan, vols))
|
||||
iv = np.nan_to_num(iv); iv = iv / iv.sum()
|
||||
ens_iv = stat_block(aligned.to_numpy() @ iv, ts_a)
|
||||
|
||||
print("\n ENSEMBLE PORTFOLIO (common window):")
|
||||
best_single = max(sl_stats.values(), key=lambda s: s["sharpe"])
|
||||
best_single_name = max(sl_stats, key=lambda c: sl_stats[c]["sharpe"])
|
||||
print(f" best single sleeve : {best_single_name} {fmt(best_single)}")
|
||||
print(f" EQUAL-WEIGHT (1/N) : {fmt(ens)}")
|
||||
print(f" inverse-vol (IS wts): {fmt(ens_iv)} [weights use full-sample vol — mild in-sample]")
|
||||
|
||||
# ---- OOS check on the ensemble (65/35 of the common window) ----
|
||||
cut = int(len(ens_eq_w) * 0.65)
|
||||
ens_is = stat_block(ens_eq_w[:cut], ts_a.iloc[:cut])
|
||||
ens_oos = stat_block(ens_eq_w[cut:], ts_a.iloc[cut:])
|
||||
print(f"\n EQUAL-WEIGHT IS : {fmt(ens_is)}")
|
||||
print(f" EQUAL-WEIGHT OOS : {fmt(ens_oos)} (OOS starts {ts_a.iloc[cut].date()})")
|
||||
|
||||
# per-year of the equal-weight ensemble
|
||||
print("\n Equal-weight ensemble per-year:")
|
||||
for y, v in sorted(yearly_returns(ens_eq_w, ts_a).items()):
|
||||
print(f" {y}: {v*100:>+7.1f}%")
|
||||
|
||||
# ---- verdict on diversification ----
|
||||
print("\n DIVERSIFICATION CHECK:")
|
||||
print(f" ensemble Sharpe {ens['sharpe']:+.2f} vs best single {best_single['sharpe']:+.2f} "
|
||||
f"({'BEATS' if ens['sharpe'] > best_single['sharpe'] else 'does NOT beat'} best single)")
|
||||
print(f" ensemble maxDD {ens['max_dd']*100:.0f}% vs best single {best_single['max_dd']*100:.0f}% "
|
||||
f"({'LOWER' if ens['max_dd'] < best_single['max_dd'] else 'NOT lower'} than best single)")
|
||||
# RISK-MATCHED: lever the ensemble to the best-single maxDD, compare €/day at equal risk.
|
||||
# (Sharpe is leverage-invariant; this isolates 'more return per unit of drawdown'.)
|
||||
if ens["max_dd"] > 0 and best_single["eur_day"] != 0:
|
||||
lev = best_single["max_dd"] / ens["max_dd"]
|
||||
rm = stat_block(ens_eq_w * lev, ts_a)
|
||||
print(f" RISK-MATCHED: lever ensemble {lev:.2f}x to ~{best_single['max_dd']*100:.0f}% DD "
|
||||
f"→ €/d={rm['eur_day']:+.2f} (DD {rm['max_dd']*100:.0f}%) vs best-single €/d={best_single['eur_day']:+.2f}")
|
||||
print(f" → at equal drawdown the ensemble earns "
|
||||
f"{'MORE' if rm['eur_day'] > best_single['eur_day'] else 'LESS'} than the best single sleeve "
|
||||
f"(ratio {rm['eur_day']/best_single['eur_day']:.2f}); this tracks the Sharpe ratio.")
|
||||
if ens["eur_day"] > 0:
|
||||
print(f" ensemble €/day(2k) {ens['eur_day']:+.2f} vs target ~50.00 "
|
||||
f"→ ~{(50.0/ens['eur_day']):.0f}x short of the goal.")
|
||||
else:
|
||||
print(" ensemble €/day(2k) <= 0 → no earning engine.")
|
||||
|
||||
return ens, sl_stats, corr
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--quick", action="store_true", help="skip slow ML sleeve + smaller RV grid")
|
||||
ap.add_argument("--no-cache", action="store_true", help="recompute ML walk-forward proba")
|
||||
args = ap.parse_args()
|
||||
|
||||
t0 = time.time()
|
||||
rv_best, rv_data = part1_relative_value(quick=args.quick)
|
||||
part2_ensemble(rv_best, rv_data, quick=args.quick, no_cache=args.no_cache)
|
||||
print(f"\n(elapsed {time.time()-t0:.0f}s)")
|
||||
print("\n" + "=" * 104)
|
||||
print("See docs/diary/2026-06-19-trackE-xsec-ensemble.md for the full honest write-up.")
|
||||
print("=" * 104)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,365 @@
|
||||
"""TRACK F — CALENDAR SEASONALITY on BTC & ETH (hour-of-day, day-of-week, interactions).
|
||||
|
||||
Honest test of whether there is a SYSTEMATIC, TRADEABLE calendar edge on the certified
|
||||
Deribit-mainnet BTC/ETH feeds. Seasonality is the easiest place on earth to overfit
|
||||
(24 hours x 7 weekdays = 168 buckets => you WILL find "significant" cells by chance), so
|
||||
every claim here is held to the project's anti-look-ahead, OOS, per-year, both-assets bar.
|
||||
|
||||
METHODOLOGY (no shortcuts):
|
||||
- ret[i] = close[i]/close[i-1]-1 is known at close[i]. A position decided at close[i]
|
||||
earns ret[i+1]. We NEVER include the bar being traded (or any future bar) in the
|
||||
statistic that decides the trade.
|
||||
- DESCRIPTIVE tables (per-hour / per-weekday mean returns) are split IS(65%)/OOS(35%).
|
||||
They are diagnostics, not trades.
|
||||
- TRADEABLE rule = ADAPTIVE EXPANDING sign: at close[i] we look up the calendar bucket
|
||||
of bar i+1 (the clock is known with zero look-ahead) and take the SIGN of that bucket's
|
||||
mean return computed ONLY on bars <= i (expanding, warmup-gated). Long-flat or
|
||||
long-short. Fees charged only on |Δposition| (turnover-aware). This lets the data pick
|
||||
each bucket's sign LIVE — the honest analogue of "trade the seasonal".
|
||||
- Also an in-sample-optimised discrete rule (enter at hour H, hold W bars, best dir) is
|
||||
shown ONLY to demonstrate the overfit gap IS->OOS.
|
||||
- NET fees fee_side baseline 0.0005 (=0.10% RT); swept 0.0005/0.00075/0.001.
|
||||
- A survivor must be net-positive OOS AND across years AND on BOTH BTC & ETH.
|
||||
|
||||
Run: uv run python scripts/research/trackF_seasonality.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from src.backtest.harness import load # noqa: E402
|
||||
|
||||
ASSETS = ["BTC", "ETH"]
|
||||
TF = "1h"
|
||||
FEE_SIDE = 0.0005 # 0.05%/side = 0.10% round-trip
|
||||
BARS_PER_DAY = 24
|
||||
BPY = BARS_PER_DAY * 365.25
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def prep(asset: str, tf: str = TF):
|
||||
df = load(asset, tf)
|
||||
c = df["close"].values.astype(float)
|
||||
ret = np.empty(len(c))
|
||||
ret[0] = 0.0
|
||||
ret[1:] = c[1:] / c[:-1] - 1.0
|
||||
dt = pd.to_datetime(df["datetime"])
|
||||
return dict(
|
||||
df=df, ret=ret,
|
||||
hour=dt.dt.hour.values.astype(int),
|
||||
dow=dt.dt.dayofweek.values.astype(int), # 0=Mon..6=Sun
|
||||
ts=dt,
|
||||
)
|
||||
|
||||
|
||||
def metrics_from_pnl(pnl: np.ndarray, ts: pd.Series):
|
||||
"""pnl[i] = realized per-bar net return of the strategy (already fee-adjusted)."""
|
||||
eq = np.cumprod(1.0 + np.clip(pnl, -0.99, None))
|
||||
r = pnl[np.isfinite(pnl)]
|
||||
sharpe = float(np.mean(r) / np.std(r) * np.sqrt(BPY)) if np.std(r) > 0 else 0.0
|
||||
peak = np.maximum.accumulate(eq)
|
||||
maxdd = float(np.max((peak - eq) / peak)) if len(eq) else 0.0
|
||||
span_days = (ts.iloc[-1] - ts.iloc[0]).total_seconds() / 86400
|
||||
years = span_days / 365.25 if span_days > 0 else 1.0
|
||||
total = eq[-1] / eq[0] if len(eq) else 1.0
|
||||
cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0
|
||||
daily_2k = (2000 * total - 2000) / span_days if span_days > 0 else 0.0
|
||||
return dict(sharpe=sharpe, maxdd=maxdd, cagr=cagr, total=total - 1.0,
|
||||
daily_2k=daily_2k, eq=eq)
|
||||
|
||||
|
||||
def per_year_pnl(pnl: np.ndarray, ts: pd.Series):
|
||||
s = pd.Series(pnl, index=ts.values)
|
||||
out = {}
|
||||
for y, g in s.groupby(s.index.year):
|
||||
eq = np.cumprod(1.0 + np.clip(g.values, -0.99, None))
|
||||
out[int(y)] = float(eq[-1] - 1.0)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. DESCRIPTIVE seasonality tables (diagnostics, IS vs OOS)
|
||||
# ---------------------------------------------------------------------------
|
||||
def descriptive(data, frac=0.65):
|
||||
n = len(data["ret"])
|
||||
cut = int(n * frac)
|
||||
ret, hour, dow = data["ret"], data["hour"], data["dow"]
|
||||
rows_h, rows_d = {}, {}
|
||||
for h in range(24):
|
||||
m_is = ret[:cut][hour[:cut] == h]
|
||||
m_oos = ret[cut:][hour[cut:] == h]
|
||||
rows_h[h] = (m_is.mean() * 1e4, m_oos.mean() * 1e4,
|
||||
np.sign(m_is.mean()) == np.sign(m_oos.mean()))
|
||||
for d in range(7):
|
||||
m_is = ret[:cut][dow[:cut] == d]
|
||||
m_oos = ret[cut:][dow[cut:] == d]
|
||||
rows_d[d] = (m_is.mean() * 1e4, m_oos.mean() * 1e4,
|
||||
np.sign(m_is.mean()) == np.sign(m_oos.mean()))
|
||||
return rows_h, rows_d
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. ADAPTIVE EXPANDING-sign seasonal strategy (the honest tradeable test)
|
||||
# ---------------------------------------------------------------------------
|
||||
def adaptive_seasonal(data, bucket="hour", mode="longshort",
|
||||
warmup=200, fee_side=FEE_SIDE):
|
||||
"""Position at close[i] = sign of the EXPANDING past mean return of bar (i+1)'s
|
||||
calendar bucket, using only bars <= i. earns ret[i+1]. Fee on |Δposition|."""
|
||||
ret = data["ret"]
|
||||
key = data[bucket]
|
||||
n = len(ret)
|
||||
nbuck = int(key.max()) + 1
|
||||
sums = np.zeros(nbuck)
|
||||
counts = np.zeros(nbuck)
|
||||
pos = np.zeros(n)
|
||||
for i in range(1, n - 1):
|
||||
b = key[i]
|
||||
sums[b] += ret[i]
|
||||
counts[b] += 1
|
||||
nb = key[i + 1]
|
||||
if counts[nb] >= warmup:
|
||||
m = sums[nb] / counts[nb]
|
||||
if m > 0:
|
||||
pos[i] = 1.0
|
||||
else:
|
||||
pos[i] = -1.0 if mode == "longshort" else 0.0
|
||||
# pnl[i] earned over bar i+1
|
||||
pnl = np.zeros(n)
|
||||
prev = 0.0
|
||||
for i in range(1, n - 1):
|
||||
turn = abs(pos[i] - prev)
|
||||
pnl[i] = pos[i] * ret[i + 1] - fee_side * turn
|
||||
prev = pos[i]
|
||||
return pnl, pos
|
||||
|
||||
|
||||
def adaptive_hourxdow(data, mode="longshort", warmup=120, fee_side=FEE_SIDE):
|
||||
ret, hour, dow = data["ret"], data["hour"], data["dow"]
|
||||
key = hour * 7 + dow # 168 buckets
|
||||
n = len(ret)
|
||||
sums = np.zeros(168)
|
||||
counts = np.zeros(168)
|
||||
pos = np.zeros(n)
|
||||
for i in range(1, n - 1):
|
||||
b = key[i]
|
||||
sums[b] += ret[i]
|
||||
counts[b] += 1
|
||||
nb = key[i + 1]
|
||||
if counts[nb] >= warmup:
|
||||
m = sums[nb] / counts[nb]
|
||||
if m > 0:
|
||||
pos[i] = 1.0
|
||||
else:
|
||||
pos[i] = -1.0 if mode == "longshort" else 0.0
|
||||
pnl = np.zeros(n)
|
||||
prev = 0.0
|
||||
for i in range(1, n - 1):
|
||||
turn = abs(pos[i] - prev)
|
||||
pnl[i] = pos[i] * ret[i + 1] - fee_side * turn
|
||||
prev = pos[i]
|
||||
return pnl, pos
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. In-sample-optimised DISCRETE rule (to expose the overfit gap)
|
||||
# ---------------------------------------------------------------------------
|
||||
def discrete_hour_rule_scan(data, frac=0.65, fee_side=FEE_SIDE):
|
||||
"""Scan IS for best (entry_hour, hold_window, direction) by IS Sharpe; report OOS.
|
||||
|
||||
A trade: enter at close of bar whose hour==H (decided with data<=close[i]), hold W
|
||||
bars, exit at close. One trade per day. Fee charged round-trip on each trade.
|
||||
"""
|
||||
ret, hour, ts = data["ret"], data["hour"], data["ts"]
|
||||
n = len(ret)
|
||||
cut = int(n * frac)
|
||||
|
||||
def rule_pnl(H, W, direction, lo, hi):
|
||||
pnl = np.zeros(n)
|
||||
i = lo
|
||||
last_exit = lo - 1
|
||||
while i < hi:
|
||||
if hour[i] == H and i > last_exit:
|
||||
# cumulative return over the next W bars: prod(1+ret[i+1..i+W]) - 1
|
||||
end = min(i + W, n - 1)
|
||||
gross = np.prod(1.0 + ret[i + 1:end + 1]) - 1.0
|
||||
pnl[i] = direction * gross - 2 * fee_side
|
||||
last_exit = end
|
||||
i = end
|
||||
else:
|
||||
i += 1
|
||||
return pnl
|
||||
|
||||
best = None
|
||||
n_tested = 0
|
||||
for H in range(24):
|
||||
for W in (1, 2, 3, 4, 6, 8, 12, 24):
|
||||
for direction in (+1, -1):
|
||||
n_tested += 1
|
||||
pnl_is = rule_pnl(H, W, direction, 1, cut)
|
||||
r = pnl_is[pnl_is != 0.0]
|
||||
if len(r) < 50:
|
||||
continue
|
||||
sh = np.mean(r) / np.std(r) * np.sqrt(BPY) if np.std(r) > 0 else 0.0
|
||||
if best is None or sh > best[0]:
|
||||
best = (sh, H, W, direction)
|
||||
sh, H, W, direction = best
|
||||
pnl_oos = rule_pnl(H, W, direction, cut, n)
|
||||
r_oos = pnl_oos[pnl_oos != 0.0]
|
||||
sh_oos = (np.mean(r_oos) / np.std(r_oos) * np.sqrt(BPY)) if (len(r_oos) and np.std(r_oos) > 0) else 0.0
|
||||
return dict(n_tested=n_tested, H=H, W=W, dir=direction, sh_is=sh,
|
||||
sh_oos=sh_oos, n_is=int((rule_pnl(H, W, direction, 1, cut) != 0).sum()),
|
||||
n_oos=len(r_oos), oos_mean_bp=r_oos.mean() * 1e4 if len(r_oos) else 0.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reporting
|
||||
# ---------------------------------------------------------------------------
|
||||
def split_metrics(pnl, ts, frac=0.65):
|
||||
n = len(pnl)
|
||||
cut = int(n * frac)
|
||||
m_is = metrics_from_pnl(pnl[:cut], ts.iloc[:cut])
|
||||
m_oos = metrics_from_pnl(pnl[cut:], ts.iloc[cut:])
|
||||
m_all = metrics_from_pnl(pnl, ts)
|
||||
return m_is, m_oos, m_all
|
||||
|
||||
|
||||
def turnover_per_year(pos, ts):
|
||||
s = pd.Series(np.abs(np.diff(pos, prepend=0.0)), index=ts.values)
|
||||
return s.groupby(s.index.year).sum().to_dict()
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 100)
|
||||
print("# TRACK F — CALENDAR SEASONALITY (hour-of-day / day-of-week / hour×weekday)")
|
||||
print("# certified Deribit-mainnet BTC & ETH, 1h UTC. fee_side=0.0005 (0.10% RT).")
|
||||
print("# No look-ahead: bucket stats use only bars <= i; position earns ret[i+1].")
|
||||
print("=" * 100)
|
||||
|
||||
data = {a: prep(a) for a in ASSETS}
|
||||
|
||||
# --- DESCRIPTIVE ---------------------------------------------------------
|
||||
print("\n" + "#" * 100)
|
||||
print("# 1. DESCRIPTIVE per-bucket mean returns (basis points/bar). IS=first 65%, OOS=last 35%.")
|
||||
print("# 'sign?' = IS and OOS agree on sign. Diagnostics only (NOT trades, no fees).")
|
||||
print("#" * 100)
|
||||
for a in ASSETS:
|
||||
rows_h, rows_d = descriptive(data[a])
|
||||
print(f"\n ── {a} HOUR-OF-DAY (UTC) mean bp/hr ─────────────────────────────")
|
||||
print(" hr : IS_bp OOS_bp sign?")
|
||||
agree_h = 0
|
||||
for h in range(24):
|
||||
iv, ov, ag = rows_h[h]
|
||||
agree_h += int(ag)
|
||||
flag = " <-- US open" if h in (13, 14) else (" <-- US close" if h in (20, 21) else "")
|
||||
print(f" {h:>2d} : {iv:>+6.2f} {ov:>+6.2f} {'Y' if ag else '.'}{flag}")
|
||||
print(f" hour sign-agreement IS/OOS: {agree_h}/24")
|
||||
print(f"\n ── {a} DAY-OF-WEEK mean bp/bar (0=Mon..6=Sun) ──────────────────")
|
||||
names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
agree_d = 0
|
||||
for d in range(7):
|
||||
iv, ov, ag = rows_d[d]
|
||||
agree_d += int(ag)
|
||||
print(f" {names[d]} : {iv:>+6.3f} {ov:>+6.3f} {'Y' if ag else '.'}")
|
||||
print(f" weekday sign-agreement IS/OOS: {agree_d}/7")
|
||||
|
||||
# --- ADAPTIVE EXPANDING-SIGN (the honest tradeable test) ----------------
|
||||
print("\n" + "#" * 100)
|
||||
print("# 2. ADAPTIVE EXPANDING-SIGN seasonal strategies (HONEST tradeable test).")
|
||||
print("# sign of bucket's PAST-ONLY mean decides position; fee on turnover.")
|
||||
print("#" * 100)
|
||||
configs = [
|
||||
("HOUR long-short", "hour", "longshort", 200),
|
||||
("HOUR long-flat ", "hour", "longflat", 200),
|
||||
("DOW long-short", "dow", "longshort", 60),
|
||||
("DOW long-flat ", "dow", "longflat", 60),
|
||||
]
|
||||
for label, bucket, mode, warmup in configs:
|
||||
print(f"\n ── {label} ────────────────────────────────────────────────────")
|
||||
for a in ASSETS:
|
||||
pnl, pos = adaptive_seasonal(data[a], bucket=bucket, mode=mode, warmup=warmup)
|
||||
ts = data[a]["ts"]
|
||||
m_is, m_oos, m_all = split_metrics(pnl, ts)
|
||||
py = per_year_pnl(pnl, ts)
|
||||
yrs = "".join(f"{py.get(y, float('nan'))*100:>+6.0f}" for y in range(2019, 2027))
|
||||
print(f" {a}: ALL Sh={m_all['sharpe']:>+5.2f} CAGR={m_all['cagr']*100:>+6.1f}% "
|
||||
f"DD={m_all['maxdd']*100:>4.1f}% €/d={m_all['daily_2k']:>+5.2f} | "
|
||||
f"IS Sh={m_is['sharpe']:>+5.2f} OOS Sh={m_oos['sharpe']:>+5.2f}")
|
||||
print(f" per-year %: {yrs} (2019..2026)")
|
||||
|
||||
# buy-and-hold benchmark — the key control: does any 'seasonal' beat just being long?
|
||||
print(f"\n ── BUY-AND-HOLD benchmark (the control for long-bias) ──")
|
||||
for a in ASSETS:
|
||||
ret = data[a]["ret"].copy()
|
||||
ret[0] = 0.0
|
||||
m = metrics_from_pnl(ret, data[a]["ts"])
|
||||
print(f" {a}: Sh={m['sharpe']:>+5.2f} CAGR={m['cagr']*100:>+6.1f}% DD={m['maxdd']*100:>4.1f}% "
|
||||
f" <- compare to DOW long-flat above (it's nearly identical = no edge, just long)")
|
||||
|
||||
# hour x weekday interaction (168 buckets — extreme overfit risk)
|
||||
print(f"\n ── HOUR×WEEKDAY long-short (168 buckets, warmup 120) — overfit canary ──")
|
||||
for a in ASSETS:
|
||||
pnl, pos = adaptive_hourxdow(data[a], mode="longshort", warmup=120)
|
||||
ts = data[a]["ts"]
|
||||
m_is, m_oos, m_all = split_metrics(pnl, ts)
|
||||
print(f" {a}: ALL Sh={m_all['sharpe']:>+5.2f} CAGR={m_all['cagr']*100:>+6.1f}% "
|
||||
f"DD={m_all['maxdd']*100:>4.1f}% | IS Sh={m_is['sharpe']:>+5.2f} OOS Sh={m_oos['sharpe']:>+5.2f}")
|
||||
|
||||
# --- FEE SWEEP on the best adaptive config -------------------------------
|
||||
print("\n" + "#" * 100)
|
||||
print("# 3. FEE SWEEP — HOUR long-short adaptive (turnover-aware). Are survivors fee-robust?")
|
||||
print("#" * 100)
|
||||
for fee in (0.0, 0.0005, 0.00075, 0.001):
|
||||
line = f" fee_side={fee:.5f} (RT {fee*2*100:.2f}%): "
|
||||
for a in ASSETS:
|
||||
pnl, _ = adaptive_seasonal(data[a], bucket="hour", mode="longshort",
|
||||
warmup=200, fee_side=fee)
|
||||
m = metrics_from_pnl(pnl, data[a]["ts"])
|
||||
line += f"{a} Sh={m['sharpe']:>+5.2f} CAGR={m['cagr']*100:>+6.1f}% "
|
||||
print(line)
|
||||
|
||||
# --- TURNOVER (fees are first-order for hour strategies) -----------------
|
||||
print("\n" + "#" * 100)
|
||||
print("# 4. TURNOVER (HOUR long-short adaptive): position flips/year (each flip costs ~fee).")
|
||||
print("#" * 100)
|
||||
for a in ASSETS:
|
||||
_, pos = adaptive_seasonal(data[a], bucket="hour", mode="longshort", warmup=200)
|
||||
tpy = turnover_per_year(pos, data[a]["ts"])
|
||||
s = " ".join(f"{y}:{int(v)}" for y, v in sorted(tpy.items()))
|
||||
print(f" {a} turnover units/yr: {s}")
|
||||
|
||||
# --- IN-SAMPLE-OPTIMISED DISCRETE RULE (overfit demonstration) ----------
|
||||
print("\n" + "#" * 100)
|
||||
print("# 5. IN-SAMPLE-OPTIMISED discrete rule (enter hour H, hold W, best dir).")
|
||||
print("# Picked by IS Sharpe, reported OOS. Demonstrates the multiple-testing trap.")
|
||||
print("#" * 100)
|
||||
for a in ASSETS:
|
||||
r = discrete_hour_rule_scan(data[a])
|
||||
print(f" {a}: tested {r['n_tested']} (H,W,dir) cells -> best IS "
|
||||
f"H={r['H']:02d} hold={r['W']}h dir={r['dir']:+d} "
|
||||
f"IS Sh={r['sh_is']:>+5.2f} (n={r['n_is']}) -> OOS Sh={r['sh_oos']:>+5.2f} "
|
||||
f"(n={r['n_oos']}, mean {r['oos_mean_bp']:>+.1f} bp/trade)")
|
||||
|
||||
# --- VERDICT -------------------------------------------------------------
|
||||
print("\n" + "#" * 100)
|
||||
print("# MULTIPLE-TESTING CAVEAT")
|
||||
print("#" * 100)
|
||||
print("""
|
||||
Buckets examined: 24 hours + 7 weekdays + 168 hour×weekday = 199 calendar cells PER ASSET,
|
||||
each tested IS and OOS, plus discrete grid = 24×8×2 = 384 (H,W,dir) cells per asset.
|
||||
With that many cells, spurious 'significant' buckets are GUARANTEED. The honest filters
|
||||
applied here: (a) adaptive sign chosen live on PAST data only (no cherry-picking),
|
||||
(b) must hold OOS, (c) must hold per-year, (d) must hold on BOTH BTC AND ETH.
|
||||
Read the IS->OOS Sharpe collapse and the per-year sign flips above as the real verdict.
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,478 @@
|
||||
"""TRACK G — PRIOR-PERIOD LEVEL BREAKOUTS / RANGE on CLEAN BTC/ETH (Deribit mainnet).
|
||||
|
||||
HONEST harness only. We test rules defined RELATIVE TO A PRIOR CALENDAR PERIOD:
|
||||
* prior-DAY high/low breakout (continuation AND fade)
|
||||
* opening-range breakout (first N UTC hours -> break for rest of day)
|
||||
* prior-day CLOSE / gap / range-position / prior-day return-sign filter
|
||||
* prior-WEEK high/low breakout
|
||||
* time-anchored entries (act at a given UTC hour vs prior-day level), exit EOD/fixed/TP-SL
|
||||
|
||||
The single question: on clean BTC/ETH, with a genuinely EXECUTABLE entry (direction and
|
||||
price decided with data <= close[i], fill at close[i], NEVER entering at the exact level
|
||||
intrabar), net of realistic Deribit fees, OOS and grid-robust on BOTH assets —
|
||||
do prior-period breakouts CONTINUE (trend) or REVERT (fade)? Is there a deployable edge?
|
||||
|
||||
NO LOOK-AHEAD GUARANTEES:
|
||||
* Prior-period levels are built by aggregating to daily/weekly bars and SHIFTING by one
|
||||
full period (shift(1) on the closed-period frame). 'Today'/'this-week' is NEVER part of
|
||||
the level. The prior period is fully closed before any bar of the current period.
|
||||
* Opening-range levels are used ONLY on bars AFTER the open window has fully closed.
|
||||
* Direction + price decided at close[i]; fill at close[i] (harness enforces).
|
||||
|
||||
Run:
|
||||
uv run python scripts/research/trackG_prior_levels.py # full
|
||||
uv run python scripts/research/trackG_prior_levels.py --quick # 1h only, fewer grids
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from itertools import product
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from src.backtest.harness import load, backtest_signals, oos_split
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Causal helpers
|
||||
# ===========================================================================
|
||||
def atr(df: pd.DataFrame, period: 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 / period, adjust=False).mean().values
|
||||
|
||||
|
||||
def prior_period_levels(df: pd.DataFrame, period: str = "D") -> dict:
|
||||
"""Return prior-period high/low/close/open/range arrays aligned to each intraday bar.
|
||||
|
||||
period='D': prior calendar day (UTC). period='W': prior ISO week (anchored Mon 00:00 UTC).
|
||||
Uses shift(1) on the CLOSED-period frame: the level for the current period only sees the
|
||||
fully-closed previous period -> no look-ahead.
|
||||
"""
|
||||
dt = df["datetime"]
|
||||
if period == "D":
|
||||
key = dt.dt.floor("D")
|
||||
elif period == "W":
|
||||
key = dt.dt.floor("D") - pd.to_timedelta(dt.dt.weekday, unit="D")
|
||||
else:
|
||||
raise ValueError(period)
|
||||
|
||||
key = key.reset_index(drop=True)
|
||||
agg = pd.DataFrame({
|
||||
"key": key,
|
||||
"high": df["high"].values, "low": df["low"].values,
|
||||
"close": df["close"].values, "open": df["open"].values,
|
||||
})
|
||||
g = agg.groupby("key").agg(high=("high", "max"), low=("low", "min"),
|
||||
close=("close", "last"), open=("open", "first")).sort_index()
|
||||
gp = g.shift(1) # prior, fully-closed period
|
||||
km = key.map # map current-period key -> prior-period aggregate
|
||||
ph = km(gp["high"]).values.astype(float)
|
||||
pl = km(gp["low"]).values.astype(float)
|
||||
pc = km(gp["close"]).values.astype(float)
|
||||
po = km(gp["open"]).values.astype(float)
|
||||
pret = (gp["close"] / gp["open"] - 1.0) # prior-period return (sign filter)
|
||||
prv = key.map(pret).values.astype(float)
|
||||
return {"ph": ph, "pl": pl, "pc": pc, "po": po, "prange": ph - pl, "pret": prv}
|
||||
|
||||
|
||||
def opening_range(df: pd.DataFrame, n_open_hours: int) -> dict:
|
||||
"""Opening-range high/low for the first n_open_hours of each UTC day, plus a per-bar
|
||||
flag of whether the open window has CLOSED (hour >= n_open_hours)."""
|
||||
dt = df["datetime"]
|
||||
date = dt.dt.floor("D")
|
||||
hour = dt.dt.hour
|
||||
date = date.reset_index(drop=True)
|
||||
in_open = (hour < n_open_hours).values
|
||||
o = pd.DataFrame({"date": date, "high": df["high"].values, "low": df["low"].values})
|
||||
o_open = o[in_open]
|
||||
org = o_open.groupby("date").agg(orh=("high", "max"), orl=("low", "min"))
|
||||
orh = date.map(org["orh"]).values.astype(float)
|
||||
orl = date.map(org["orl"]).values.astype(float)
|
||||
closed = (hour >= n_open_hours).values
|
||||
return {"orh": orh, "orl": orl, "closed": closed}
|
||||
|
||||
|
||||
def bars_left_in_day(df: pd.DataFrame) -> np.ndarray:
|
||||
date = df["datetime"].dt.floor("D")
|
||||
grp = df.groupby(date)
|
||||
idx_in_day = grp.cumcount().values
|
||||
size = grp["close"].transform("size").values
|
||||
return (size - idx_in_day - 1).astype(int)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Signal generators -> list[dict|None] length len(df). Decisions use data <= close[i].
|
||||
# ===========================================================================
|
||||
def sig_prior_break(df, period="D", level="high", side="cont", anchor_hour=None,
|
||||
exit_mode="eod", max_bars=24, tp_atr=0.0, sl_atr=0.0, atr_p=14,
|
||||
buffer=0.0):
|
||||
"""Prior-period level breakout.
|
||||
level='high': trigger when close[i] > prior_high*(1+buffer)
|
||||
level='low' : trigger when close[i] < prior_low *(1-buffer)
|
||||
side='cont' : trade IN the breakout direction (high->long, low->short)
|
||||
side='fade' : trade AGAINST it (high->short, low->long)
|
||||
anchor_hour : if set, only evaluate on bars at that UTC hour (time-anchored)
|
||||
exit_mode : 'eod' (close at end of UTC day), 'bars' (max_bars), TP/SL via *_atr.
|
||||
"""
|
||||
lv = prior_period_levels(df, period)
|
||||
c = df["close"].values
|
||||
a = atr(df, atr_p) if (tp_atr or sl_atr) else None
|
||||
bl = bars_left_in_day(df) if exit_mode == "eod" else None
|
||||
hour = df["datetime"].dt.hour.values
|
||||
n = len(c)
|
||||
out = [None] * n
|
||||
ref = lv["ph"] if level == "high" else lv["pl"]
|
||||
for i in range(n):
|
||||
if anchor_hour is not None and hour[i] != anchor_hour:
|
||||
continue
|
||||
r = ref[i]
|
||||
if not np.isfinite(r):
|
||||
continue
|
||||
px = c[i]
|
||||
if level == "high":
|
||||
if not (px > r * (1.0 + buffer)):
|
||||
continue
|
||||
brk_dir = 1
|
||||
else:
|
||||
if not (px < r * (1.0 - buffer)):
|
||||
continue
|
||||
brk_dir = -1
|
||||
direction = brk_dir if side == "cont" else -brk_dir
|
||||
if exit_mode == "eod":
|
||||
mb = max(int(bl[i]), 1)
|
||||
else:
|
||||
mb = max_bars
|
||||
tp = sl = None
|
||||
if a is not None and np.isfinite(a[i]):
|
||||
if tp_atr:
|
||||
tp = px + direction * tp_atr * a[i]
|
||||
if sl_atr:
|
||||
sl = px - direction * sl_atr * a[i]
|
||||
out[i] = {"dir": direction, "tp": tp, "sl": sl, "max_bars": mb}
|
||||
return out
|
||||
|
||||
|
||||
def sig_or_break(df, n_open_hours=6, side="cont", exit_mode="eod", max_bars=12,
|
||||
tp_atr=0.0, sl_atr=0.0, atr_p=14, buffer=0.0):
|
||||
"""Opening-range breakout: after the first n_open_hours close, trade a break of the
|
||||
OR high (long if cont) or OR low (short if cont). Only the FIRST break per day fires
|
||||
(the harness keeps the position busy until exit)."""
|
||||
orr = opening_range(df, n_open_hours)
|
||||
c = df["close"].values
|
||||
a = atr(df, atr_p) if (tp_atr or sl_atr) else None
|
||||
bl = bars_left_in_day(df) if exit_mode == "eod" else None
|
||||
n = len(c)
|
||||
out = [None] * n
|
||||
orh, orl, closed = orr["orh"], orr["orl"], orr["closed"]
|
||||
for i in range(n):
|
||||
if not closed[i] or not np.isfinite(orh[i]):
|
||||
continue
|
||||
px = c[i]
|
||||
if px > orh[i]:
|
||||
brk = 1
|
||||
elif px < orl[i]:
|
||||
brk = -1
|
||||
else:
|
||||
continue
|
||||
direction = brk if side == "cont" else -brk
|
||||
if exit_mode == "eod":
|
||||
mb = max(int(bl[i]), 1)
|
||||
else:
|
||||
mb = max_bars
|
||||
tp = sl = None
|
||||
if a is not None and np.isfinite(a[i]):
|
||||
if tp_atr:
|
||||
tp = px + direction * tp_atr * a[i]
|
||||
if sl_atr:
|
||||
sl = px - direction * sl_atr * a[i]
|
||||
out[i] = {"dir": direction, "tp": tp, "sl": sl, "max_bars": mb}
|
||||
return out
|
||||
|
||||
|
||||
def sig_gap(df, side="cont", anchor_hour=0, thr=0.0, exit_mode="eod", max_bars=24,
|
||||
ret_filter=0):
|
||||
"""Gap vs prior-day CLOSE, evaluated at a given UTC hour (default the first bar of the
|
||||
day). gap = close[i]/prior_close - 1. If gap>thr -> up-gap; gap<-thr -> down-gap.
|
||||
side='cont' trades in the gap direction; 'fade' against. ret_filter: +1 only when
|
||||
prior-day return positive, -1 only when negative, 0 no filter."""
|
||||
lv = prior_period_levels(df, "D")
|
||||
c = df["close"].values
|
||||
bl = bars_left_in_day(df) if exit_mode == "eod" else None
|
||||
hour = df["datetime"].dt.hour.values
|
||||
pc, pret = lv["pc"], lv["pret"]
|
||||
n = len(c)
|
||||
out = [None] * n
|
||||
for i in range(n):
|
||||
if hour[i] != anchor_hour or not np.isfinite(pc[i]):
|
||||
continue
|
||||
gap = c[i] / pc[i] - 1.0
|
||||
if gap > thr:
|
||||
g = 1
|
||||
elif gap < -thr:
|
||||
g = -1
|
||||
else:
|
||||
continue
|
||||
if ret_filter and np.isfinite(pret[i]):
|
||||
if ret_filter > 0 and not (pret[i] > 0):
|
||||
continue
|
||||
if ret_filter < 0 and not (pret[i] < 0):
|
||||
continue
|
||||
direction = g if side == "cont" else -g
|
||||
mb = max(int(bl[i]), 1) if exit_mode == "eod" else max_bars
|
||||
out[i] = {"dir": direction, "tp": None, "sl": None, "max_bars": mb}
|
||||
return out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Evaluation
|
||||
# ===========================================================================
|
||||
def run_split(df, sigfn, params, fee_rt=0.001, leverage=1.0, frac=0.65):
|
||||
cut = oos_split(df, frac)
|
||||
full = backtest_signals(df, sigfn(df, **params), fee_rt=fee_rt, leverage=leverage)
|
||||
di = df.iloc[:cut].reset_index(drop=True)
|
||||
do = df.iloc[cut:].reset_index(drop=True)
|
||||
is_ = backtest_signals(di, sigfn(di, **params), fee_rt=fee_rt, leverage=leverage)
|
||||
oos = backtest_signals(do, sigfn(do, **params), fee_rt=fee_rt, leverage=leverage)
|
||||
return full, is_, oos
|
||||
|
||||
|
||||
def hdr(t):
|
||||
print("\n" + "=" * 100)
|
||||
print(t)
|
||||
print("=" * 100)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Main
|
||||
# ===========================================================================
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--quick", action="store_true")
|
||||
args = ap.parse_args()
|
||||
t0 = time.time()
|
||||
assets = ["BTC", "ETH"]
|
||||
tfs = ["1h"] if args.quick else ["1h", "15m"]
|
||||
|
||||
data = {}
|
||||
hdr("DATA")
|
||||
for a in assets:
|
||||
for tf in tfs:
|
||||
df = load(a, tf)
|
||||
data[(a, tf)] = df
|
||||
print(f" {a} {tf:>3s}: {len(df):>7d} bars {df['datetime'].iloc[0].date()}"
|
||||
f"->{df['datetime'].iloc[-1].date()}")
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# PASS 1 — PRIOR-DAY BREAKOUT: continuation vs fade, any-bar (first break/day),
|
||||
# EOD exit. THE core question: do prior-day breakouts continue or revert?
|
||||
# ---------------------------------------------------------------------
|
||||
hdr("PASS 1 — PRIOR-DAY HIGH/LOW breakout, any-bar first-break, EOD exit (1h, fee=0.001)\n"
|
||||
" CONTINUATION vs FADE side-by-side. OOS net must be >0 on BOTH to matter.")
|
||||
print(f" {'rule':<26s} | "
|
||||
f"{'BTC IS / OOS (tr, wr, shrp)':<40s} | {'ETH IS / OOS (tr, wr, shrp)':<40s}")
|
||||
for level in ["high", "low"]:
|
||||
for side in ["cont", "fade"]:
|
||||
name = f"PD {level:<4s} {side}"
|
||||
line = f" {name:<26s} | "
|
||||
for a in assets:
|
||||
df = data[(a, "1h")]
|
||||
_, is_, oos = run_split(df, sig_prior_break,
|
||||
dict(period="D", level=level, side=side,
|
||||
exit_mode="eod"))
|
||||
line += (f"{is_.net_return*100:>+6.0f}/{oos.net_return*100:>+6.0f}% "
|
||||
f"(t{oos.n_trades:>4d} w{oos.win_rate:>4.1f} s{oos.sharpe:>+4.1f}) | ")
|
||||
print(line)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# PASS 2 — OPENING-RANGE breakout (continuation vs fade), various open windows.
|
||||
# ---------------------------------------------------------------------
|
||||
hdr("PASS 2 — OPENING-RANGE breakout (first N UTC hours), EOD exit (1h, fee=0.001).\n"
|
||||
" CONTINUATION vs FADE. Survivor = OOS>0 on BOTH assets.")
|
||||
for nopen in ([6] if args.quick else [3, 6, 8, 12]):
|
||||
for side in ["cont", "fade"]:
|
||||
name = f"OR N={nopen:<2d} {side}"
|
||||
line = f" {name:<26s} | "
|
||||
for a in assets:
|
||||
df = data[(a, "1h")]
|
||||
_, is_, oos = run_split(df, sig_or_break,
|
||||
dict(n_open_hours=nopen, side=side, exit_mode="eod"))
|
||||
line += (f"{a} OOS={oos.net_return*100:>+6.0f}% "
|
||||
f"(t{oos.n_trades:>4d} w{oos.win_rate:>4.1f} s{oos.sharpe:>+4.1f}) | ")
|
||||
print(line)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# PASS 3 — GAP vs prior close at day open (hour 0), continuation vs fade,
|
||||
# with optional prior-day return-sign filter.
|
||||
# ---------------------------------------------------------------------
|
||||
hdr("PASS 3 — GAP vs prior-day CLOSE at hour 0, EOD exit (1h, fee=0.001).\n"
|
||||
" continuation vs fade; thr = min |gap|.")
|
||||
for thr in ([0.0] if args.quick else [0.0, 0.005, 0.01]):
|
||||
for side in ["cont", "fade"]:
|
||||
name = f"GAP thr={thr*100:.1f}% {side}"
|
||||
line = f" {name:<26s} | "
|
||||
for a in assets:
|
||||
df = data[(a, "1h")]
|
||||
_, is_, oos = run_split(df, sig_gap,
|
||||
dict(side=side, anchor_hour=0, thr=thr, exit_mode="eod"))
|
||||
line += (f"{a} OOS={oos.net_return*100:>+6.0f}% "
|
||||
f"(t{oos.n_trades:>4d} w{oos.win_rate:>4.1f} s{oos.sharpe:>+4.1f}) | ")
|
||||
print(line)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# PASS 4 — PRIOR-WEEK high/low breakout (continuation vs fade), EOD exit.
|
||||
# ---------------------------------------------------------------------
|
||||
hdr("PASS 4 — PRIOR-WEEK HIGH/LOW breakout, any-bar first-break, EOD exit (1h, fee=0.001).")
|
||||
for level in ["high", "low"]:
|
||||
for side in ["cont", "fade"]:
|
||||
name = f"PW {level:<4s} {side}"
|
||||
line = f" {name:<26s} | "
|
||||
for a in assets:
|
||||
df = data[(a, "1h")]
|
||||
_, is_, oos = run_split(df, sig_prior_break,
|
||||
dict(period="W", level=level, side=side,
|
||||
exit_mode="eod"))
|
||||
line += (f"{a} IS={is_.net_return*100:>+6.0f}% OOS={oos.net_return*100:>+6.0f}% "
|
||||
f"(t{oos.n_trades:>4d} s{oos.sharpe:>+4.1f}) | ")
|
||||
print(line)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# PASS 5 — TIME-ANCHORED prior-day breakout: sweep the anchor hour to expose
|
||||
# whether any apparent edge is just a lucky single hour.
|
||||
# ---------------------------------------------------------------------
|
||||
hdr("PASS 5 — TIME-ANCHORED PD-high CONTINUATION across UTC anchor hours (1h, EOD exit).\n"
|
||||
" A real edge is NOT a single lucky hour. (full-sample net per hour.)")
|
||||
hours = list(range(0, 24, 1 if not args.quick else 3))
|
||||
for a in assets:
|
||||
df = data[(a, "1h")]
|
||||
cells = []
|
||||
for hh in hours:
|
||||
full, _, _ = run_split(df, sig_prior_break,
|
||||
dict(period="D", level="high", side="cont",
|
||||
anchor_hour=hh, exit_mode="eod"))
|
||||
cells.append((hh, full.net_return * 100, full.sharpe, full.n_trades))
|
||||
pos = sum(1 for _, r, _, _ in cells if r > 0)
|
||||
print(f" {a}: {pos}/{len(cells)} anchor-hours net>0 (full). "
|
||||
f"best={max(cells, key=lambda x: x[1])[0]}h "
|
||||
f"({max(c[1] for c in cells):+.0f}%) worst={min(c[1] for c in cells):+.0f}%")
|
||||
line = " " + " ".join(f"{hh:02d}h:{r:>+5.0f}" for hh, r, _, _ in cells)
|
||||
print(line)
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# PASS 6 — GRID ROBUSTNESS on the best family from PASS 1-4. We grid the
|
||||
# PD-low CONTINUATION and FADE plus OR breakout, require OOS>0 on BOTH assets.
|
||||
# ---------------------------------------------------------------------
|
||||
hdr("PASS 6 — GRID ROBUSTNESS. Cell SURVIVES only if OOS net>0 on BOTH BTC AND ETH.")
|
||||
|
||||
def grid(label, fn, base, sweep, tf="1h", fee=0.001):
|
||||
keys = list(sweep.keys())
|
||||
rows, surv = [], []
|
||||
for combo in product(*[sweep[k] for k in keys]):
|
||||
params = dict(base); params.update(dict(zip(keys, combo)))
|
||||
res = {}
|
||||
for a in assets:
|
||||
_, is_, oos = run_split(data[(a, tf)], fn, params, fee_rt=fee)
|
||||
res[a] = oos
|
||||
ok = all(res[a].net_return > 0 for a in assets)
|
||||
rows.append((params, res, ok))
|
||||
if ok:
|
||||
surv.append((params, res))
|
||||
print(f" [{label}] {len(surv)}/{len(rows)} cells OOS>0 on BOTH assets")
|
||||
rows.sort(key=lambda r: np.mean([r[1][a].net_return for a in assets]), reverse=True)
|
||||
for params, res, ok in rows[:5]:
|
||||
tag = "OK " if ok else " -"
|
||||
pp = {k: params[k] for k in sweep}
|
||||
s = f" {tag}{pp} | "
|
||||
for a in assets:
|
||||
s += f"{a} OOS={res[a].net_return*100:>+6.0f}% (s{res[a].sharpe:>+4.1f}) "
|
||||
print(s)
|
||||
return surv
|
||||
|
||||
sweeps = []
|
||||
sweeps.append(grid("PD-low cont", sig_prior_break,
|
||||
dict(period="D", level="low", side="cont", exit_mode="eod"),
|
||||
dict(buffer=[0.0, 0.001, 0.003], anchor_hour=[None])))
|
||||
sweeps.append(grid("PD-low fade", sig_prior_break,
|
||||
dict(period="D", level="low", side="fade", exit_mode="eod"),
|
||||
dict(buffer=[0.0, 0.001, 0.003], anchor_hour=[None])))
|
||||
sweeps.append(grid("PD-high cont", sig_prior_break,
|
||||
dict(period="D", level="high", side="cont", exit_mode="eod"),
|
||||
dict(buffer=[0.0, 0.001, 0.003], anchor_hour=[None])))
|
||||
sweeps.append(grid("PD-high fade", sig_prior_break,
|
||||
dict(period="D", level="high", side="fade", exit_mode="eod"),
|
||||
dict(buffer=[0.0, 0.001, 0.003], anchor_hour=[None])))
|
||||
if not args.quick:
|
||||
sweeps.append(grid("OR cont", sig_or_break,
|
||||
dict(side="cont", exit_mode="eod"),
|
||||
dict(n_open_hours=[3, 6, 8, 12])))
|
||||
sweeps.append(grid("OR fade", sig_or_break,
|
||||
dict(side="fade", exit_mode="eod"),
|
||||
dict(n_open_hours=[3, 6, 8, 12])))
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# PASS 7 — FEE SWEEP + per-year on the single best surviving rule (if any),
|
||||
# else on the least-bad PD rule, to show fee sensitivity and year stability.
|
||||
# ---------------------------------------------------------------------
|
||||
hdr("PASS 7 — FEE SWEEP + PER-YEAR on the best PD rule. fee=0 is GROSS (is the SIGN of\n"
|
||||
" the edge even right before fees?).")
|
||||
# pick best rule: scan the 4 PD sides at default, mean OOS over assets
|
||||
candidates = [
|
||||
("PD low cont", dict(period="D", level="low", side="cont", exit_mode="eod")),
|
||||
("PD low fade", dict(period="D", level="low", side="fade", exit_mode="eod")),
|
||||
("PD high cont", dict(period="D", level="high", side="cont", exit_mode="eod")),
|
||||
("PD high fade", dict(period="D", level="high", side="fade", exit_mode="eod")),
|
||||
]
|
||||
scored = []
|
||||
for nm, p in candidates:
|
||||
m = np.mean([run_split(data[(a, "1h")], sig_prior_break, p)[2].net_return for a in assets])
|
||||
scored.append((m, nm, p))
|
||||
scored.sort(reverse=True)
|
||||
best_nm, best_p = scored[0][1], scored[0][2]
|
||||
print(f" best-by-meanOOS PD rule: {best_nm} (meanOOS={scored[0][0]*100:+.0f}%)")
|
||||
fees = [0.0, 0.0005, 0.001, 0.0015, 0.002]
|
||||
for a in assets:
|
||||
df = data[(a, "1h")]
|
||||
line = f" {a} fee-sweep (RT%): "
|
||||
for f in fees:
|
||||
full, _, oos = run_split(df, sig_prior_break, best_p, fee_rt=f)
|
||||
line += f"{f*100:.2f}%[full={full.net_return*100:>+5.0f}/OOS={oos.net_return*100:>+5.0f}] "
|
||||
print(line)
|
||||
print(" per-year (full sample, fee=0.001):")
|
||||
for a in assets:
|
||||
df = data[(a, "1h")]
|
||||
full, _, _ = run_split(df, sig_prior_break, best_p)
|
||||
yrs = " ".join(f"{y}:{full.yearly[y]*100:>+5.0f}%" for y in sorted(full.yearly))
|
||||
print(f" {a}: trades={full.n_trades} Sharpe={full.sharpe:+.2f} "
|
||||
f"maxDD={full.max_dd*100:.0f}% EUR/d(2k)={full.daily_profit(2000):+.2f}")
|
||||
print(f" {yrs}")
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# VERDICT
|
||||
# ---------------------------------------------------------------------
|
||||
hdr("VERDICT")
|
||||
total_surv = sum(len(s) for s in sweeps)
|
||||
if total_surv == 0:
|
||||
print(" ZERO grid cells produced OOS net>0 on BOTH BTC and ETH at baseline fees.")
|
||||
print(" => No robust prior-period breakout/fade edge on clean BTC/ETH. The continuation-")
|
||||
print(" vs-fade tables above show which SIDE (if any) is even net-positive in-sample;")
|
||||
print(" consult PASS 1-5 for direction. Not deployable.")
|
||||
else:
|
||||
print(f" {total_surv} grid cell(s) survived OOS>0 on both assets. Inspect PASS 6/7 and")
|
||||
print(" stress with fee sweep + per-year before trusting. List of survivors:")
|
||||
for s in sweeps:
|
||||
for params, res in s:
|
||||
ms = np.mean([res[a].net_return for a in assets]) * 100
|
||||
print(f" {params} meanOOS={ms:+.0f}%")
|
||||
print(f"\n (elapsed {time.time()-t0:.0f}s)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,602 @@
|
||||
"""TRACK H — VOLUME, RANGE & VOLATILITY-REGIME signals on CLEAN BTC/ETH (Deribit mainnet).
|
||||
|
||||
The single question: net of realistic Deribit fees, OOS-robust on BOTH BTC & ETH, on >=12h
|
||||
timeframes (the only honest regime — sub-12h is fees + HF-noise overfit + the open-label
|
||||
look-ahead trap), is there ANY volume / range / volatility-regime signal that is
|
||||
|
||||
(a) net-positive OOS on both assets standalone, AND
|
||||
(b) uncorrelated (|corr| < ~0.3) to the deployed winner TP01, AND/OR
|
||||
(c) usable as a REGIME FILTER that lifts TP01's Sharpe above ~1.32 or cuts its DD?
|
||||
|
||||
HONESTY / NO LOOK-AHEAD:
|
||||
* Everything runs on the SAME causal per-bar engine used by TP01 (net_returns): we build a
|
||||
continuous TARGET position decided with data <= close[i], then HOLD it during bar i+1
|
||||
(pos_held[t] = target[t-1]). Gross = pos_held * simple_return[t]; fee charged on |Δpos|.
|
||||
This is identical in spirit to the harness `backtest_signals` (decide<=close[i], fill at
|
||||
close[i]); we cross-check two discrete signals through `backtest_signals` too.
|
||||
* Volume / range / vol features for bar i use ONLY bars <= i (rolling, prior-window, shift).
|
||||
* 12h / 1d frames are resampled from the certified 1h feed via resample_tf (label='left',
|
||||
closed='left') and consumed index-based with the +1 bar hold -> the open-label is never
|
||||
leaked (verified in trackD_lookahead_audit.py: Sharpe is label-invariant under this hold).
|
||||
|
||||
Run:
|
||||
uv run python scripts/research/trackH_volume_vol.py # full (12h + 1d)
|
||||
uv run python scripts/research/trackH_volume_vol.py --quick # 12h only, fewer grids
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from src.backtest.harness import load, backtest_signals
|
||||
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_tf
|
||||
|
||||
ASSETS = ["BTC", "ETH"]
|
||||
FEE_SIDE = 0.0005 # 0.05%/side = 0.10% RT (Deribit taker)
|
||||
OOS_FRAC = 0.65
|
||||
TF_BPD = {"12h": 2, "1d": 1}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Causal feature helpers (all use data <= i)
|
||||
# ===========================================================================
|
||||
def simple_returns(c: np.ndarray) -> np.ndarray:
|
||||
r = np.zeros(len(c))
|
||||
r[1:] = c[1:] / c[:-1] - 1.0
|
||||
return r
|
||||
|
||||
|
||||
def realized_vol(r: np.ndarray, win: int, bpy: float) -> np.ndarray:
|
||||
return pd.Series(r).rolling(win, min_periods=max(2, win // 2)).std().values * np.sqrt(bpy)
|
||||
|
||||
|
||||
def roll_max_prior(x: np.ndarray, win: int) -> np.ndarray:
|
||||
"""Max over the PRIOR `win` bars (excludes current bar i)."""
|
||||
return pd.Series(x).shift(1).rolling(win, min_periods=win).max().values
|
||||
|
||||
|
||||
def roll_min_prior(x: np.ndarray, win: int) -> np.ndarray:
|
||||
return pd.Series(x).shift(1).rolling(win, min_periods=win).min().values
|
||||
|
||||
|
||||
def roll_mean_prior(x: np.ndarray, win: int) -> np.ndarray:
|
||||
return pd.Series(x).shift(1).rolling(win, min_periods=win).mean().values
|
||||
|
||||
|
||||
def vol_zscore(vol: np.ndarray, win: int) -> np.ndarray:
|
||||
"""z-score of current volume vs PRIOR `win` bars (uses <= i)."""
|
||||
s = pd.Series(vol)
|
||||
m = s.shift(1).rolling(win, min_periods=win).mean()
|
||||
sd = s.shift(1).rolling(win, min_periods=win).std()
|
||||
return ((s - m) / sd).values
|
||||
|
||||
|
||||
def atr(df: pd.DataFrame, period: int) -> 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 / period, adjust=False).mean().values
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Per-bar net-returns engine (causal, fee on turnover) — identical to TP01.net_returns
|
||||
# ===========================================================================
|
||||
def net_from_target(target: np.ndarray, r: np.ndarray, fee_side: float):
|
||||
"""target[i] decided with data <= close[i] -> HELD during bar i+1."""
|
||||
target = np.nan_to_num(target, nan=0.0)
|
||||
pos = np.zeros(len(target))
|
||||
pos[1:] = target[:-1]
|
||||
gross = pos * r
|
||||
turn = np.abs(np.diff(pos, prepend=0.0))
|
||||
net = gross - fee_side * turn
|
||||
net[0] = 0.0
|
||||
net = np.clip(net, -0.99, None)
|
||||
return net, pos, turn
|
||||
|
||||
|
||||
def metrics(net: np.ndarray, idx: pd.DatetimeIndex, turn: np.ndarray, bpy: float) -> dict:
|
||||
rr = net[np.isfinite(net)]
|
||||
sharpe = float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)) if np.std(rr) > 0 else 0.0
|
||||
equity = np.cumprod(1.0 + np.clip(net, -0.99, None))
|
||||
peak = np.maximum.accumulate(equity)
|
||||
dd = float(np.max((peak - equity) / peak)) if len(equity) else 0.0
|
||||
span_days = (idx[-1] - idx[0]).total_seconds() / 86400
|
||||
years = span_days / 365.25 if span_days > 0 else 1.0
|
||||
total = equity[-1] / equity[0] if len(equity) else 1.0
|
||||
cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0
|
||||
ann_turn = float(np.sum(turn)) / years if years > 0 else 0.0
|
||||
return dict(sharpe=sharpe, max_dd=dd, cagr=cagr, total=total - 1,
|
||||
ann_turnover=ann_turn, equity=equity, years=years)
|
||||
|
||||
|
||||
def per_year(net: np.ndarray, idx: pd.DatetimeIndex) -> dict:
|
||||
eq = pd.Series(np.cumprod(1.0 + np.clip(net, -0.99, None)), index=idx)
|
||||
out = {}
|
||||
for y, g in eq.groupby(eq.index.year):
|
||||
if len(g) > 1 and g.iloc[0] > 0:
|
||||
out[int(y)] = float(g.iloc[-1] / g.iloc[0] - 1)
|
||||
return out
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# SIGNALS — each returns a continuous TARGET array (frac of equity, +/-), causal.
|
||||
# ===========================================================================
|
||||
def sig_vt_long(df, bpd, target_vol=0.20, vol_win_days=30, lev=2.0, **_):
|
||||
"""Volatility-managed LONG: always long, sized to a vol target (no trend at all).
|
||||
Tests Moreira-Muir 'volatility-managed' alpha vs plain buy-and-hold."""
|
||||
c = df["close"].values.astype(float)
|
||||
r = simple_returns(c)
|
||||
bpy = bpd * 365.25
|
||||
vol = realized_vol(r, vol_win_days * bpd, bpy)
|
||||
tgt = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 0.0)
|
||||
return np.clip(tgt, 0, lev)
|
||||
|
||||
|
||||
def sig_vol_breakout(df, bpd, don=20, zwin=20, zk=1.0, long_short=False, **_):
|
||||
"""Volume-confirmed Donchian breakout (continuation). Long when close > prior-`don`-bar high
|
||||
AND volume z-score > zk; stay long until close < prior-`don`-bar low (then flat/short)."""
|
||||
c = df["close"].values.astype(float)
|
||||
h = df["high"].values.astype(float)
|
||||
l = df["low"].values.astype(float)
|
||||
vol = df["volume"].values.astype(float)
|
||||
hi = roll_max_prior(h, don)
|
||||
lo = roll_min_prior(l, don)
|
||||
z = vol_zscore(vol, zwin)
|
||||
up = (c > hi) & (z > zk)
|
||||
dn = (c < lo) & (z > zk)
|
||||
state = np.zeros(len(c))
|
||||
s = 0.0
|
||||
for i in range(len(c)):
|
||||
if up[i]:
|
||||
s = 1.0
|
||||
elif dn[i]:
|
||||
s = -1.0 if long_short else 0.0
|
||||
elif s == 1.0 and c[i] < lo[i]: # trailing exit for longs
|
||||
s = -1.0 if long_short else 0.0
|
||||
elif s == -1.0 and c[i] > hi[i]:
|
||||
s = 1.0
|
||||
state[i] = s
|
||||
return state
|
||||
|
||||
|
||||
def sig_obv_trend(df, bpd, ma=30, long_short=False, **_):
|
||||
"""OBV trend: OBV = cumsum(sign(ret)*volume); long when OBV > its EMA(ma), else flat/short."""
|
||||
c = df["close"].values.astype(float)
|
||||
vol = df["volume"].values.astype(float)
|
||||
r = simple_returns(c)
|
||||
obv = np.cumsum(np.sign(r) * vol)
|
||||
ema = pd.Series(obv).ewm(span=ma, adjust=False).mean().values
|
||||
d = np.where(obv > ema, 1.0, (-1.0 if long_short else 0.0))
|
||||
return d
|
||||
|
||||
|
||||
def sig_vw_momentum(df, bpd, mom_win=30, vol_win_days=30, target_vol=0.20, lev=2.0,
|
||||
long_only=True, **_):
|
||||
"""Volume-weighted momentum: sign of volume-weighted mean return over `mom_win` bars,
|
||||
vol-targeted. Compare to plain TSMOM (does weighting by volume add anything?)."""
|
||||
c = df["close"].values.astype(float)
|
||||
vol = df["volume"].values.astype(float)
|
||||
r = simple_returns(c)
|
||||
rw = r * vol
|
||||
num = pd.Series(rw).rolling(mom_win, min_periods=mom_win).sum().values
|
||||
den = pd.Series(vol).rolling(mom_win, min_periods=mom_win).sum().values
|
||||
vwret = np.where(den > 0, num / den, 0.0)
|
||||
direction = np.sign(vwret)
|
||||
if long_only:
|
||||
direction = np.clip(direction, 0, None)
|
||||
bpy = bpd * 365.25
|
||||
rv = realized_vol(r, vol_win_days * bpd, bpy)
|
||||
scal = np.where((rv > 0) & np.isfinite(rv), target_vol / rv, 0.0)
|
||||
return np.clip(direction * scal, -lev, lev)
|
||||
|
||||
|
||||
def sig_range_expansion(df, bpd, rng_win=20, k=1.5, hold=5, long_short=False, **_):
|
||||
"""Range-expansion breakout: when today's range > k * avg(prior `rng_win` ranges) and the
|
||||
bar closed in the upper/lower half, go with the close direction; hold `hold` bars."""
|
||||
c = df["close"].values.astype(float)
|
||||
h = df["high"].values.astype(float)
|
||||
l = df["low"].values.astype(float)
|
||||
rng = h - l
|
||||
avg = roll_mean_prior(rng, rng_win)
|
||||
expand = rng > k * avg
|
||||
pos_in_bar = np.where(rng > 0, (c - l) / rng, 0.5)
|
||||
long_trig = expand & (pos_in_bar > 0.6)
|
||||
short_trig = expand & (pos_in_bar < 0.4)
|
||||
state = np.zeros(len(c))
|
||||
hold_left = 0
|
||||
cur = 0.0
|
||||
for i in range(len(c)):
|
||||
if hold_left > 0:
|
||||
hold_left -= 1
|
||||
else:
|
||||
cur = 0.0
|
||||
if long_trig[i]:
|
||||
cur = 1.0
|
||||
hold_left = hold
|
||||
elif short_trig[i] and long_short:
|
||||
cur = -1.0
|
||||
hold_left = hold
|
||||
state[i] = cur
|
||||
return state
|
||||
|
||||
|
||||
def sig_nr_breakout(df, bpd, nr=7, hold=5, long_short=False, **_):
|
||||
"""NR-N breakout (daily-style): when the current bar's range is the narrowest of the last
|
||||
`nr` bars, take the breakout of the prior bar's high/low on the next bars; hold `hold`."""
|
||||
c = df["close"].values.astype(float)
|
||||
h = df["high"].values.astype(float)
|
||||
l = df["low"].values.astype(float)
|
||||
rng = h - l
|
||||
is_nr = pd.Series(rng).rolling(nr, min_periods=nr).apply(
|
||||
lambda w: 1.0 if w[-1] == np.min(w) else 0.0, raw=True).values
|
||||
state = np.zeros(len(c))
|
||||
cur = 0.0
|
||||
hold_left = 0
|
||||
armed = False
|
||||
arm_hi = arm_lo = np.nan
|
||||
for i in range(len(c)):
|
||||
if hold_left > 0:
|
||||
hold_left -= 1
|
||||
else:
|
||||
cur = 0.0
|
||||
if armed:
|
||||
if c[i] > arm_hi:
|
||||
cur = 1.0
|
||||
hold_left = hold
|
||||
armed = False
|
||||
elif c[i] < arm_lo and long_short:
|
||||
cur = -1.0
|
||||
hold_left = hold
|
||||
armed = False
|
||||
if is_nr[i] == 1.0:
|
||||
armed = True
|
||||
arm_hi = h[i]
|
||||
arm_lo = l[i]
|
||||
state[i] = cur
|
||||
return state
|
||||
|
||||
|
||||
def sig_decl_vol_reversal(df, bpd, mom_win=10, vwin=10, **_):
|
||||
"""Declining-volume reversal (fade): after an up-move on DECLINING volume, fade (short);
|
||||
after a down-move on declining volume, go long. Pure contrarian, vol-confirmed exhaustion."""
|
||||
c = df["close"].values.astype(float)
|
||||
vol = df["volume"].values.astype(float)
|
||||
ret = pd.Series(c).pct_change(mom_win).values
|
||||
vtrend = vol - roll_mean_prior(vol, vwin)
|
||||
declining = vtrend < 0
|
||||
state = np.zeros(len(c))
|
||||
state[(ret > 0) & declining] = -1.0
|
||||
state[(ret < 0) & declining] = 1.0
|
||||
return state
|
||||
|
||||
|
||||
SIGNALS = {
|
||||
"VT-long": (sig_vt_long, dict(target_vol=0.20, vol_win_days=30, lev=2.0)),
|
||||
"VolBreakout": (sig_vol_breakout, dict(don=20, zwin=20, zk=1.0)),
|
||||
"OBV-trend": (sig_obv_trend, dict(ma=30)),
|
||||
"VW-mom": (sig_vw_momentum, dict(mom_win=30, vol_win_days=30)),
|
||||
"RangeExpand": (sig_range_expansion, dict(rng_win=20, k=1.5, hold=5)),
|
||||
"NR7-break": (sig_nr_breakout, dict(nr=7, hold=5)),
|
||||
"DeclVolRev": (sig_decl_vol_reversal, dict(mom_win=10, vwin=10)),
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Evaluation
|
||||
# ===========================================================================
|
||||
def eval_signal(fn, params, tf, asset, fee_side=FEE_SIDE):
|
||||
df = resample_tf(load(asset, "1h"), tf)
|
||||
bpd = TF_BPD[tf]
|
||||
bpy = bpd * 365.25
|
||||
c = df["close"].values.astype(float)
|
||||
r = simple_returns(c)
|
||||
idx = pd.to_datetime(df["datetime"].values)
|
||||
tgt = fn(df, bpd, **params)
|
||||
net, pos, turn = net_from_target(tgt, r, fee_side)
|
||||
m = metrics(net, idx, turn, bpy)
|
||||
# OOS split
|
||||
cut = int(len(net) * OOS_FRAC)
|
||||
mi = metrics(net[:cut], idx[:cut], turn[:cut], bpy)
|
||||
mo = metrics(net[cut:], idx[cut:], turn[cut:], bpy)
|
||||
return dict(net=net, idx=idx, full=m, is_=mi, oos=mo, py=per_year(net, idx))
|
||||
|
||||
|
||||
def tp01_net(asset, tf):
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
df = resample_tf(load(asset, "1h"), tf)
|
||||
net, ts = tp.net_returns(df)
|
||||
return pd.Series(net, index=pd.to_datetime(ts.values))
|
||||
|
||||
|
||||
def corr_to_tp01(net, idx, tp_series):
|
||||
s = pd.Series(net, index=idx)
|
||||
j = pd.concat([s.rename("a"), tp_series.rename("b")], axis=1, join="inner").fillna(0.0)
|
||||
if j["a"].std() == 0 or j["b"].std() == 0:
|
||||
return 0.0
|
||||
return float(j["a"].corr(j["b"]))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Reports
|
||||
# ===========================================================================
|
||||
def report_headline(tf, quick):
|
||||
print("\n" + "=" * 120)
|
||||
print(f"# HEADLINE — TF {tf} | standalone signals, full / IS / OOS, turnover, corr→TP01 (fee 0.10% RT)")
|
||||
print("=" * 120)
|
||||
tp = {a: tp01_net(a, tf) for a in ASSETS}
|
||||
print(f" {'signal':<14s}{'asset':<6s}"
|
||||
f"{'fullShrp':>9s}{'fullCAGR':>9s}{'fullDD':>7s}"
|
||||
f"{'IS_Shrp':>8s}{'OOS_Shrp':>9s}{'OOS_ret':>8s}{'turn/y':>8s}{'corrTP':>8s}")
|
||||
results = {}
|
||||
for name, (fn, params) in SIGNALS.items():
|
||||
for a in ASSETS:
|
||||
res = eval_signal(fn, params, tf, a)
|
||||
cr = corr_to_tp01(res["net"], res["idx"], tp[a])
|
||||
results[(name, a)] = (res, cr)
|
||||
print(f" {name:<14s}{a:<6s}"
|
||||
f"{res['full']['sharpe']:>9.2f}{res['full']['cagr']*100:>8.1f}%"
|
||||
f"{res['full']['max_dd']*100:>6.1f}%"
|
||||
f"{res['is_']['sharpe']:>8.2f}{res['oos']['sharpe']:>9.2f}"
|
||||
f"{res['oos']['total']*100:>7.1f}%{res['full']['ann_turnover']:>8.1f}{cr:>8.2f}")
|
||||
return results, tp
|
||||
|
||||
|
||||
def report_peryear(results):
|
||||
print("\n" + "-" * 120)
|
||||
print("# PER-YEAR net return (%) — only signals with OOS Sharpe>0 on BOTH assets shown")
|
||||
print("-" * 120)
|
||||
years = list(range(2018, 2027))
|
||||
# which signals pass OOS>0 both assets
|
||||
good = []
|
||||
for name in SIGNALS:
|
||||
if all(results[(name, a)][0]["oos"]["sharpe"] > 0 for a in ASSETS):
|
||||
good.append(name)
|
||||
if not good:
|
||||
print(" (none — no signal has positive OOS Sharpe on BOTH assets)")
|
||||
return good
|
||||
print(" " + " " * 22 + "".join(f"{y:>7d}" for y in years))
|
||||
for name in good:
|
||||
for a in ASSETS:
|
||||
py = results[(name, a)][0]["py"]
|
||||
row = "".join((" . " if y not in py else f"{py[y]*100:>+7.0f}") for y in years)
|
||||
print(f" {name+' '+a:<22s}{row}")
|
||||
return good
|
||||
|
||||
|
||||
def report_grid(quick):
|
||||
print("\n" + "=" * 120)
|
||||
print("# GRID ROBUSTNESS (TF 12h) — fraction of cells with positive full+OOS Sharpe on BOTH assets")
|
||||
print("=" * 120)
|
||||
tf = "12h"
|
||||
grids = {
|
||||
"VolBreakout": ("sig", sig_vol_breakout,
|
||||
dict(don=[10, 20, 40] if not quick else [20],
|
||||
zwin=[10, 20, 40], zk=[0.5, 1.0, 2.0])),
|
||||
"OBV-trend": ("sig", sig_obv_trend, dict(ma=[15, 30, 60, 100])),
|
||||
"VW-mom": ("sig", sig_vw_momentum,
|
||||
dict(mom_win=[15, 30, 60, 90], long_only=[True])),
|
||||
"RangeExpand": ("sig", sig_range_expansion,
|
||||
dict(rng_win=[10, 20, 40], k=[1.3, 1.5, 2.0], hold=[3, 5, 10])),
|
||||
"VT-long": ("sig", sig_vt_long, dict(target_vol=[0.15, 0.20, 0.30],
|
||||
vol_win_days=[15, 30, 60])),
|
||||
}
|
||||
from itertools import product
|
||||
for name, (_, fn, axes) in grids.items():
|
||||
keys = list(axes.keys())
|
||||
combos = list(product(*[axes[k] for k in keys]))
|
||||
npos = 0
|
||||
best = (-9, None)
|
||||
for combo in combos:
|
||||
params = dict(zip(keys, combo))
|
||||
ok = True
|
||||
sh_sum = 0.0
|
||||
for a in ASSETS:
|
||||
res = eval_signal(fn, params, tf, a)
|
||||
if not (res["full"]["sharpe"] > 0 and res["oos"]["sharpe"] > 0):
|
||||
ok = False
|
||||
sh_sum += res["oos"]["sharpe"]
|
||||
if ok:
|
||||
npos += 1
|
||||
if sh_sum > best[0]:
|
||||
best = (sh_sum, params)
|
||||
print(f" {name:<14s} positive both(full&OOS): {npos:>3d}/{len(combos):<3d} "
|
||||
f"({npos/len(combos)*100:>4.0f}%) best OOS-sum cfg: {best[1]}")
|
||||
|
||||
|
||||
def report_feesweep():
|
||||
print("\n" + "=" * 120)
|
||||
print("# FEE SWEEP (TF 12h) — OOS Sharpe (BTC/ETH) vs round-trip fee for the headline signals")
|
||||
print("=" * 120)
|
||||
tf = "12h"
|
||||
fees = [0.0, 0.0005, 0.001, 0.0015, 0.002] # per side; RT = 2x
|
||||
print(f" {'signal':<14s}" + "".join(f" RT{f*2*100:>4.2f}%" for f in fees))
|
||||
for name, (fn, params) in SIGNALS.items():
|
||||
cells = []
|
||||
for f in fees:
|
||||
shs = []
|
||||
for a in ASSETS:
|
||||
res = eval_signal(fn, params, tf, a, fee_side=f)
|
||||
shs.append(res["oos"]["sharpe"])
|
||||
cells.append(f"{shs[0]:>4.2f}/{shs[1]:<4.2f}")
|
||||
print(f" {name:<14s}" + "".join(f" {c:>9s}" for c in cells))
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# REGIME FILTER on TP01 — does a vol/volume regime mask lift Sharpe or cut DD?
|
||||
# ===========================================================================
|
||||
def vol_regime_mask(df, bpd, win_days=30, mode="low", q=0.5):
|
||||
"""Boolean per-bar mask (decided <= close[i]) for a realized-vol regime.
|
||||
mode='low': keep exposure when vol <= rolling median; 'high': when vol > median."""
|
||||
c = df["close"].values.astype(float)
|
||||
r = simple_returns(c)
|
||||
bpy = bpd * 365.25
|
||||
vol = realized_vol(r, win_days * bpd, bpy)
|
||||
# causal expanding/rolling quantile threshold (use a long rolling window, prior bars)
|
||||
thr = pd.Series(vol).shift(1).rolling(180 * bpd, min_periods=30 * bpd).quantile(q).values
|
||||
if mode == "low":
|
||||
mask = vol <= thr
|
||||
else:
|
||||
mask = vol > thr
|
||||
return np.nan_to_num(mask.astype(float), nan=1.0) # default keep before warmup
|
||||
|
||||
|
||||
def vol_managed_mask(df, bpd, win_days=30, target_vol=0.20, cap=1.5):
|
||||
"""Continuous vol-scaling multiplier on TP01: scale exposure by target_vol/realized_vol,
|
||||
capped — an explicit volatility-managed overlay distinct from TP01's own sizing."""
|
||||
c = df["close"].values.astype(float)
|
||||
r = simple_returns(c)
|
||||
bpy = bpd * 365.25
|
||||
vol = realized_vol(r, win_days * bpd, bpy)
|
||||
mult = np.where((vol > 0) & np.isfinite(vol), target_vol / vol, 1.0)
|
||||
return np.clip(mult, 0.0, cap)
|
||||
|
||||
|
||||
def report_regime_filter(tf="12h"):
|
||||
print("\n" + "=" * 120)
|
||||
print(f"# REGIME FILTER on TP01 (TF {tf}) — apply a vol mask/overlay to TP01 target, 50/50 portfolio")
|
||||
print("=" * 120)
|
||||
bpd = TF_BPD[tf]
|
||||
bpy = bpd * 365.25
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
|
||||
def portfolio(transform):
|
||||
"""transform(df,target)->target'; returns combined 50/50 net series + idx."""
|
||||
series = {}
|
||||
for a in ASSETS:
|
||||
df = resample_tf(load(a, "1h"), tf)
|
||||
r = simple_returns(df["close"].values.astype(float))
|
||||
tgt = tp.target_series(df)
|
||||
tgt2 = transform(df, tgt)
|
||||
net, _, _ = net_from_target(tgt2, r, CANONICAL["fee_side"])
|
||||
series[a] = pd.Series(net, index=pd.to_datetime(df["datetime"].values))
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
combo = 0.5 * J[ASSETS[0]].values + 0.5 * J[ASSETS[1]].values
|
||||
return combo, J.index
|
||||
|
||||
variants = {
|
||||
"TP01 baseline": lambda df, t: t,
|
||||
"× keep LOW-vol": lambda df, t: t * vol_regime_mask(df, bpd, mode="low", q=0.5),
|
||||
"× keep HIGH-vol": lambda df, t: t * vol_regime_mask(df, bpd, mode="high", q=0.5),
|
||||
"× keep LOW-vol q.7": lambda df, t: t * vol_regime_mask(df, bpd, mode="low", q=0.7),
|
||||
"× vol-managed x1.5": lambda df, t: t * vol_managed_mask(df, bpd, cap=1.5) /
|
||||
np.maximum(vol_managed_mask(df, bpd, cap=1.5).mean(), 1e-9),
|
||||
"× obv-up only": lambda df, t: t * (np.where(
|
||||
np.cumsum(np.sign(simple_returns(df['close'].values.astype(float))) * df['volume'].values)
|
||||
> pd.Series(np.cumsum(np.sign(simple_returns(df['close'].values.astype(float)))
|
||||
* df['volume'].values)).ewm(span=30, adjust=False).mean().values,
|
||||
1.0, 0.0)),
|
||||
}
|
||||
print(f" {'variant':<22s}{'fullShrp':>9s}{'IS_Shrp':>8s}{'OOS_Shrp':>9s}"
|
||||
f"{'CAGR':>8s}{'maxDD':>8s}{'turn/y':>9s}")
|
||||
for name, tr in variants.items():
|
||||
combo, idx = portfolio(tr)
|
||||
m = metrics(combo, idx, np.zeros_like(combo), bpy)
|
||||
cut = int(len(combo) * OOS_FRAC)
|
||||
mi = metrics(combo[:cut], idx[:cut], np.zeros_like(combo[:cut]), bpy)
|
||||
mo = metrics(combo[cut:], idx[cut:], np.zeros_like(combo[cut:]), bpy)
|
||||
tt = 0.0
|
||||
for a in ASSETS:
|
||||
df = resample_tf(load(a, "1h"), tf)
|
||||
tgt2 = tr(df, tp.target_series(df))
|
||||
tt += np.sum(np.abs(np.diff(np.nan_to_num(tgt2), prepend=0.0)))
|
||||
ann_tt = tt / m["years"] / 2.0
|
||||
print(f" {name:<22s}{m['sharpe']:>9.2f}{mi['sharpe']:>8.2f}{mo['sharpe']:>9.2f}"
|
||||
f"{m['cagr']*100:>7.1f}%{m['max_dd']*100:>7.1f}%{ann_tt:>9.1f}")
|
||||
|
||||
# robustness of the OBV-up filter across EMA spans (is 1.49 luck or stable?)
|
||||
print("\n OBV-up filter robustness across EMA span (full / OOS Sharpe, maxDD):")
|
||||
for span in [15, 20, 30, 45, 60, 90]:
|
||||
def tr(df, t, sp=span):
|
||||
c = df['close'].values.astype(float)
|
||||
v = df['volume'].values.astype(float)
|
||||
obv = np.cumsum(np.sign(simple_returns(c)) * v)
|
||||
ema = pd.Series(obv).ewm(span=sp, adjust=False).mean().values
|
||||
return t * np.where(obv > ema, 1.0, 0.0)
|
||||
combo, idx = portfolio(tr)
|
||||
m = metrics(combo, idx, np.zeros_like(combo), bpy)
|
||||
cut = int(len(combo) * OOS_FRAC)
|
||||
mo = metrics(combo[cut:], idx[cut:], np.zeros_like(combo[cut:]), bpy)
|
||||
py = per_year(combo, idx)
|
||||
neg_years = sum(1 for y, v in py.items() if v < 0)
|
||||
print(f" span {span:>3d}: full {m['sharpe']:>4.2f} OOS {mo['sharpe']:>4.2f} "
|
||||
f"DD {m['max_dd']*100:>4.1f}% CAGR {m['cagr']*100:>5.1f}% neg-years {neg_years}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--quick", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
print("#" * 120)
|
||||
print("# TRACK H — VOLUME / RANGE / VOLATILITY-REGIME on certified BTC/ETH (Deribit mainnet)")
|
||||
print("# Honest engine: target decided <=close[i], held bar i+1; fee on |Δpos|; OOS 65/35; >=12h only.")
|
||||
print("#" * 120)
|
||||
|
||||
tfs = ["12h"] if args.quick else ["12h", "1d"]
|
||||
for tf in tfs:
|
||||
results, tp = report_headline(tf, args.quick)
|
||||
report_peryear(results)
|
||||
if tf == "12h":
|
||||
crosscheck_backtest_signals()
|
||||
report_grid(args.quick)
|
||||
report_feesweep()
|
||||
report_regime_filter("12h")
|
||||
|
||||
print("\n" + "#" * 120)
|
||||
print("# VERDICT (track H) — honest reading of the tables above")
|
||||
print("#" * 120)
|
||||
for line in [
|
||||
"1. NO uncorrelated additive edge found. Every PROFITABLE volume/range/vol signal",
|
||||
" (VolBreakout, OBV-trend, VW-mom, VT-long) is trend-in-disguise: corr-to-TP01 0.61-0.75.",
|
||||
" They do not diversify TP01 -> cannot raise the 50/50 portfolio Sharpe.",
|
||||
"2. The genuinely LOWER-corr signals (RangeExpand ~0.48, NR7 ~0.48) FAIL OOS on >=1 asset",
|
||||
" (NR7 ETH OOS Sharpe ~0.0/-0.03; RangeExpand BTC weak, ETH negative on 1d). Not deployable.",
|
||||
"3. Declining-volume / fade (mean-reversion) is firmly NEGATIVE net of fees on both assets",
|
||||
" and at ZERO fee -> confirms the v2.0.0 lesson: MR edge was feed contamination, it is dead.",
|
||||
"4. Vol-REGIME gating of TP01 (keep low-vol / keep high-vol) HURTS Sharpe (1.32 -> 0.94/0.98).",
|
||||
" A vol-managed x1.5 overlay leaves Sharpe ~flat (1.33) but raises DD (17.9%). No win.",
|
||||
"5. The ONLY non-harmful overlay is an OBV-up trend-CONFIRMATION filter (keep TP01 long only",
|
||||
" while OBV>EMA): full Sharpe 1.32->1.49, maxDD 13.3%->10.1%, but CAGR 16.2%->14.4%, turnover",
|
||||
" +60%, and OOS gain is marginal (0.90->1.04) and span-sensitive (fades for EMA>45). It is",
|
||||
" trend double-confirmation (de-risking), NOT new alpha. Worth noting as a DEFENSIVE overlay",
|
||||
" if cutting DD matters more than CAGR; it does NOT robustly raise the portfolio Sharpe.",
|
||||
"BOTTOM LINE: the ~1.3 portfolio-Sharpe ceiling on BTC/ETH-only HOLDS. Volume/range/vol add",
|
||||
"nothing uncorrelated. TP01 stays the deployable winner.",
|
||||
]:
|
||||
print(" " + line)
|
||||
print("#" * 120)
|
||||
|
||||
|
||||
def crosscheck_backtest_signals():
|
||||
"""Cross-check two DISCRETE signals through the canonical harness `backtest_signals`
|
||||
(decide<=close[i], fill at close[i]) to confirm the per-bar engine isn't flattering them."""
|
||||
print("\n" + "-" * 120)
|
||||
print("# CROSS-CHECK via harness.backtest_signals (discrete entries, fee 0.10% RT, TF 12h)")
|
||||
print("-" * 120)
|
||||
tf = "12h"
|
||||
for a in ASSETS:
|
||||
df = resample_tf(load(a, "1h"), tf)
|
||||
h = df["high"].values.astype(float)
|
||||
l = df["low"].values.astype(float)
|
||||
c = df["close"].values.astype(float)
|
||||
rng = h - l
|
||||
avg = roll_mean_prior(rng, 20)
|
||||
pos_in_bar = np.where(rng > 0, (c - l) / rng, 0.5)
|
||||
expand = rng > 1.5 * avg
|
||||
entries = [None] * len(df)
|
||||
for i in range(len(df)):
|
||||
if expand[i] and pos_in_bar[i] > 0.6:
|
||||
entries[i] = dict(dir=1, tp=None, sl=None, max_bars=5)
|
||||
m = backtest_signals(df, entries, fee_rt=0.001, leverage=1.0, asset=a, tf=tf)
|
||||
m.print_summary(f"RangeExpand(L,5b) {a}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,420 @@
|
||||
"""TRACK I — ALTERNATIVE MOMENTUM FORMULATIONS + LONG-HORIZON REVERSAL (BTC & ETH, >=12h).
|
||||
|
||||
Goal:
|
||||
(A) Find a momentum formulation that BEATS or DIVERSIFIES the canonical TP01 sign-blend
|
||||
(TSMOM 1-3-6m, vol-targeted, 50/50 BTC+ETH, 12h, Sharpe ~1.32).
|
||||
(B) Test the classic LONG-HORIZON REVERSAL effect (fade 12/18/24-month winners) as a
|
||||
potentially UNCORRELATED positive overlay, and a momentum+reversal blend.
|
||||
|
||||
Honest harness (mirrors src/strategies/trend_portfolio.py exactly):
|
||||
- direction decided with data <= close[i]; positions HELD next bar (pos_held[1:] = tgt[:-1]);
|
||||
- vol-target by inverse PAST-ONLY realized vol (target_vol/vol), leverage-capped;
|
||||
- NET fees 0.10% RT (0.05%/side) on turnover; fee sweep included;
|
||||
- 12h / 1d only (sub-12h is dominated by costs/overfit and a prior 4h look-ahead bug);
|
||||
- OOS 65/35 split + per-year; robustness across lookbacks AND both assets;
|
||||
- correlation vs TP01 net returns reported for EVERY candidate.
|
||||
|
||||
A candidate is INTERESTING only if net-positive OOS on BOTH assets AND either
|
||||
(higher portfolio Sharpe than TP01 ~1.32) OR (|corr to TP01| < ~0.3 and positive).
|
||||
|
||||
Run: uv run python scripts/research/trackI_momentum_reversal.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from src.backtest.harness import load
|
||||
from src.strategies.trend_portfolio import resample_tf, simple_returns, realized_vol
|
||||
|
||||
ASSETS = ["BTC", "ETH"]
|
||||
FEE_SIDE = 0.0005 # 0.05%/side = 0.10% RT
|
||||
TARGET_VOL = 0.20
|
||||
LEVERAGE = 2.0
|
||||
VOL_WIN_DAYS = 30
|
||||
OOS_FRAC = 0.65
|
||||
MONTH = 30 # days per "month" (calendar-consistent across TFs)
|
||||
|
||||
# tf -> bars_per_day
|
||||
TF_BPD = {"12h": 2, "1d": 1}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# data
|
||||
# ---------------------------------------------------------------------------
|
||||
def get_df(asset: str, tf: str) -> pd.DataFrame:
|
||||
df = load(asset, "1h")
|
||||
rule = {"12h": "12h", "1d": "1D"}[tf]
|
||||
return resample_tf(df, rule)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# vol-target machinery (identical convention to TP01)
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_target(direction, vol, long_only):
|
||||
d = np.clip(direction, 0, None) if long_only else direction
|
||||
scal = np.where((vol > 0) & np.isfinite(vol), TARGET_VOL / vol, 0.0)
|
||||
tgt = np.clip(d * scal, -LEVERAGE, LEVERAGE)
|
||||
tgt[~np.isfinite(tgt)] = 0.0
|
||||
return tgt
|
||||
|
||||
|
||||
def net_from_target(tgt, r, fee_side=FEE_SIDE):
|
||||
pos_held = np.zeros(len(tgt))
|
||||
pos_held[1:] = tgt[:-1]
|
||||
gross = pos_held * r
|
||||
turn = np.abs(np.diff(pos_held, prepend=0.0))
|
||||
net = gross - fee_side * turn
|
||||
net[0] = 0.0
|
||||
return np.clip(net, -0.99, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DIRECTION FORMULATIONS (each returns array in roughly [-1, 1], causal, decided <= close[i])
|
||||
# ---------------------------------------------------------------------------
|
||||
def _log_mom(c, h):
|
||||
"""log return over h bars; nan before h."""
|
||||
m = np.full(len(c), np.nan)
|
||||
m[h:] = np.log(c[h:] / c[:-h])
|
||||
return m
|
||||
|
||||
|
||||
def dir_signblend(c, bpd, horizons_m=(1, 3, 6)):
|
||||
"""TP01 baseline: mean of sign(log return) over horizons."""
|
||||
n = len(c)
|
||||
acc = np.zeros(n); cnt = np.zeros(n)
|
||||
for hm in horizons_m:
|
||||
h = hm * MONTH * bpd
|
||||
s = np.full(n, np.nan)
|
||||
s[h:] = np.sign(c[h:] / c[:-h] - 1.0)
|
||||
v = np.isfinite(s); acc[v] += s[v]; cnt[v] += 1
|
||||
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
|
||||
return out
|
||||
|
||||
|
||||
def dir_zscore(c, bpd, horizons_m=(1, 3, 6), std_win_m=12):
|
||||
"""(i) Continuous momentum: z-scored cumulative log-return, tanh-bounded, multi-horizon avg."""
|
||||
n = len(c); w = std_win_m * MONTH * bpd
|
||||
acc = np.zeros(n); cnt = np.zeros(n)
|
||||
for hm in horizons_m:
|
||||
h = hm * MONTH * bpd
|
||||
m = _log_mom(c, h)
|
||||
s = pd.Series(m)
|
||||
sd = s.rolling(w, min_periods=w // 3).std().values
|
||||
z = np.where((sd > 0) & np.isfinite(sd), m / sd, np.nan)
|
||||
d = np.tanh(z)
|
||||
v = np.isfinite(d); acc[v] += d[v]; cnt[v] += 1
|
||||
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
|
||||
return out
|
||||
|
||||
|
||||
def dir_riskadj(c, bpd, horizons_m=(1, 3, 6)):
|
||||
"""(ii) Risk-adjusted momentum: h-horizon return / vol-of-that-horizon, tanh, multi-horizon."""
|
||||
n = len(c); r = simple_returns(c)
|
||||
acc = np.zeros(n); cnt = np.zeros(n)
|
||||
for hm in horizons_m:
|
||||
h = hm * MONTH * bpd
|
||||
ret = np.full(n, np.nan); ret[h:] = c[h:] / c[:-h] - 1.0
|
||||
# vol of the h-bar return = per-bar std over last h bars * sqrt(h)
|
||||
sd = pd.Series(r).rolling(h, min_periods=h // 2).std().values * np.sqrt(h)
|
||||
ra = np.where((sd > 0) & np.isfinite(sd), ret / sd, np.nan)
|
||||
d = np.tanh(ra)
|
||||
v = np.isfinite(d); acc[v] += d[v]; cnt[v] += 1
|
||||
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
|
||||
return out
|
||||
|
||||
|
||||
def _ema(c, span):
|
||||
return pd.Series(c).ewm(span=span, adjust=False).mean().values
|
||||
|
||||
|
||||
def dir_emacross(c, bpd, pairs_m=((1, 3), (2, 6), (3, 9))):
|
||||
"""(iii) EMA-cross trend: mean of sign(ema_fast - ema_slow) over calendar-day pairs."""
|
||||
n = len(c)
|
||||
acc = np.zeros(n); cnt = np.zeros(n)
|
||||
for fm, sm in pairs_m:
|
||||
ef = _ema(c, fm * MONTH * bpd)
|
||||
es = _ema(c, sm * MONTH * bpd)
|
||||
warm = sm * MONTH * bpd
|
||||
d = np.sign(ef - es)
|
||||
d[:warm] = np.nan
|
||||
v = np.isfinite(d); acc[v] += d[v]; cnt[v] += 1
|
||||
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
|
||||
return out
|
||||
|
||||
|
||||
def dir_macd(c, bpd):
|
||||
"""(iii-b) Classic MACD with calendar spans (fast~1m, slow~2m, signal~0.75m): sign(macd-signal)."""
|
||||
n = len(c)
|
||||
fast = int(round(1.0 * MONTH * bpd)); slow = int(round(2.0 * MONTH * bpd))
|
||||
sig = int(round(0.75 * MONTH * bpd))
|
||||
macd = _ema(c, fast) - _ema(c, slow)
|
||||
signal = pd.Series(macd).ewm(span=sig, adjust=False).mean().values
|
||||
d = np.sign(macd - signal)
|
||||
d[:slow] = 0.0
|
||||
return d
|
||||
|
||||
|
||||
def dir_donchian(c, bpd, n_m=2):
|
||||
"""(iv) Donchian breakout (>=12h): +1 if close > prior-N max, -1 if < prior-N min, else hold."""
|
||||
n = len(c); N = n_m * MONTH * bpd
|
||||
hi = pd.Series(c).rolling(N, min_periods=N).max().shift(1).values
|
||||
lo = pd.Series(c).rolling(N, min_periods=N).min().shift(1).values
|
||||
d = np.zeros(n); state = 0.0
|
||||
for i in range(n):
|
||||
if np.isfinite(hi[i]) and c[i] >= hi[i]:
|
||||
state = 1.0
|
||||
elif np.isfinite(lo[i]) and c[i] <= lo[i]:
|
||||
state = -1.0
|
||||
d[i] = state
|
||||
return d
|
||||
|
||||
|
||||
def dir_accel(c, bpd, horizons_m=(3, 6), lag_m=1):
|
||||
"""(v) Acceleration: sign of CHANGE in momentum (mom[i] - mom[i-lag]) i.e. 2nd derivative."""
|
||||
n = len(c); lag = lag_m * MONTH * bpd
|
||||
acc = np.zeros(n); cnt = np.zeros(n)
|
||||
for hm in horizons_m:
|
||||
h = hm * MONTH * bpd
|
||||
m = _log_mom(c, h)
|
||||
dm = np.full(n, np.nan)
|
||||
dm[lag:] = m[lag:] - m[:-lag]
|
||||
d = np.sign(dm)
|
||||
v = np.isfinite(d); acc[v] += d[v]; cnt[v] += 1
|
||||
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
|
||||
return out
|
||||
|
||||
|
||||
def dir_mom12_1(c, bpd, lookbacks_m=(6, 12), skip_m=1):
|
||||
"""(vi) 12-1 momentum: return from (i-L) to (i-skip), skipping the most-recent `skip` month.
|
||||
For index i (>=L): sign( c[i-skip] / c[i-L] - 1 ). Causal (uses data <= close[i-skip])."""
|
||||
n = len(c); skip = skip_m * MONTH * bpd
|
||||
acc = np.zeros(n); cnt = np.zeros(n)
|
||||
for Lm in lookbacks_m:
|
||||
L = Lm * MONTH * bpd
|
||||
s = np.full(n, np.nan)
|
||||
# i runs L..n-1: c[i-skip] = c[L-skip : n-skip], c[i-L] = c[0 : n-L]
|
||||
s[L:] = np.sign(c[L - skip:n - skip] / c[:n - L] - 1.0)
|
||||
v = np.isfinite(s); acc[v] += s[v]; cnt[v] += 1
|
||||
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
|
||||
return out
|
||||
|
||||
|
||||
def make_reversal(lookbacks_m):
|
||||
"""(B) long-horizon reversal: -sign of long-horizon return (short past winners)."""
|
||||
def fn(c, bpd):
|
||||
n = len(c)
|
||||
acc = np.zeros(n); cnt = np.zeros(n)
|
||||
for Lm in lookbacks_m:
|
||||
L = Lm * MONTH * bpd
|
||||
s = np.full(n, np.nan)
|
||||
s[L:] = -np.sign(c[L:] / c[:-L] - 1.0)
|
||||
v = np.isfinite(s); acc[v] += s[v]; cnt[v] += 1
|
||||
out = np.zeros(n); nz = cnt > 0; out[nz] = acc[nz] / cnt[nz]
|
||||
return out
|
||||
return fn
|
||||
|
||||
|
||||
def make_mom_minus_rev(mom_m, rev_m, rev_w=0.5):
|
||||
"""Blend: long medium-term momentum + fade very-long-term extension (weighted)."""
|
||||
def fn(c, bpd):
|
||||
n = len(c)
|
||||
mom = dir_signblend(c, bpd, horizons_m=mom_m)
|
||||
rev_fn = make_reversal(rev_m)
|
||||
rev = rev_fn(c, bpd)
|
||||
return np.clip(mom + rev_w * rev, -1.0, 1.0)
|
||||
return fn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run a formulation -> per-asset net series, combined portfolio series, metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
def asset_net_series(asset, tf, dir_fn, long_only, fee_side=FEE_SIDE):
|
||||
df = get_df(asset, tf); bpd = TF_BPD[tf]
|
||||
c = df["close"].values.astype(float)
|
||||
r = simple_returns(c)
|
||||
bpy = bpd * 365.25
|
||||
vol = realized_vol(r, VOL_WIN_DAYS * bpd, bpy)
|
||||
direction = dir_fn(c, bpd)
|
||||
tgt = build_target(direction, vol, long_only)
|
||||
net = net_from_target(tgt, r, fee_side)
|
||||
return pd.Series(net, index=pd.to_datetime(df["datetime"].values))
|
||||
|
||||
|
||||
def portfolio_combo(tf, dir_fn, long_only, fee_side=FEE_SIDE):
|
||||
s = {a: asset_net_series(a, tf, dir_fn, long_only, fee_side) for a in ASSETS}
|
||||
J = pd.concat(s, axis=1, join="inner").fillna(0.0)
|
||||
combo = 0.5 * J[ASSETS[0]].values + 0.5 * J[ASSETS[1]].values
|
||||
return pd.Series(combo, index=J.index), s
|
||||
|
||||
|
||||
def sharpe_of(series, bpy):
|
||||
r = series.values[np.isfinite(series.values)]
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(bpy)) if len(r) and np.std(r) > 0 else 0.0
|
||||
|
||||
|
||||
def metrics_of(combo: pd.Series, bpy):
|
||||
idx = combo.index
|
||||
equity = np.cumprod(1.0 + np.clip(combo.values, -0.99, None))
|
||||
sharpe = sharpe_of(combo, bpy)
|
||||
peak = np.maximum.accumulate(equity)
|
||||
dd = float(np.max((peak - equity) / peak))
|
||||
years = (idx[-1] - idx[0]).total_seconds() / 86400 / 365.25
|
||||
total = equity[-1] / equity[0]
|
||||
cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0
|
||||
eq = pd.Series(equity, index=idx)
|
||||
yearly = {}
|
||||
for y, g in eq.groupby(eq.index.year):
|
||||
if len(g) > 1 and g.iloc[0] > 0:
|
||||
v = g.values; pk = np.maximum.accumulate(v)
|
||||
yearly[int(y)] = (float(g.iloc[-1] / g.iloc[0] - 1), float(np.max((pk - v) / pk)))
|
||||
# OOS split
|
||||
k = int(len(combo) * OOS_FRAC)
|
||||
is_sh = sharpe_of(combo.iloc[:k], bpy)
|
||||
oos_sh = sharpe_of(combo.iloc[k:], bpy)
|
||||
return dict(sharpe=sharpe, max_dd=dd, cagr=cagr, total=total - 1,
|
||||
yearly=yearly, is_sharpe=is_sh, oos_sharpe=oos_sh, equity=eq)
|
||||
|
||||
|
||||
ALL_YEARS = list(range(2018, 2027))
|
||||
|
||||
|
||||
def fmt_yearly(yearly):
|
||||
return "".join((" . " if y not in yearly else f"{yearly[y][0]*100:>+6.0f}") for y in ALL_YEARS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main
|
||||
# ---------------------------------------------------------------------------
|
||||
PART_A = [
|
||||
("baseline signblend 1-3-6m", dir_signblend),
|
||||
("(i) z-score cum-ret", dir_zscore),
|
||||
("(ii) risk-adj momentum", dir_riskadj),
|
||||
("(iii) EMA-cross trend", dir_emacross),
|
||||
("(iii-b) MACD", dir_macd),
|
||||
("(iv) Donchian breakout", dir_donchian),
|
||||
("(v) acceleration", dir_accel),
|
||||
("(vi) 12-1 skip momentum", dir_mom12_1),
|
||||
]
|
||||
|
||||
|
||||
def report_block(title, items, tf, long_only, tp_combo, bpy):
|
||||
mode = "LONG-FLAT" if long_only else "LONG-SHORT"
|
||||
print(f"\n{'='*112}\n {title} | TF={tf} mode={mode}\n{'='*112}")
|
||||
print(f" {'formulation':<26s} {'Shrp':>5s} {'IS':>5s} {'OOS':>5s} {'CAGR':>6s} "
|
||||
f"{'maxDD':>6s} {'corrTP':>7s} {'aBTC':>5s} {'aETH':>5s} per-year PnL%")
|
||||
print(f" {'':<26s} {'':>5s} {'':>5s} {'':>5s} {'':>6s} {'':>6s} {'':>7s} {'':>5s} {'':>5s} "
|
||||
+ "".join(f"{y%100:>6d}" for y in ALL_YEARS))
|
||||
results = {}
|
||||
for name, fn in items:
|
||||
combo, sleeves = portfolio_combo(tf, fn, long_only)
|
||||
m = metrics_of(combo, bpy)
|
||||
# per-asset standalone Sharpe
|
||||
a_sh = {a: sharpe_of(sleeves[a], bpy) for a in ASSETS}
|
||||
# correlation to TP01 (aligned inner)
|
||||
J = pd.concat([combo.rename("x"), tp_combo.rename("t")], axis=1, join="inner").dropna()
|
||||
corr = float(np.corrcoef(J["x"], J["t"])[0, 1]) if len(J) > 2 else float("nan")
|
||||
print(f" {name:<26s} {m['sharpe']:>5.2f} {m['is_sharpe']:>5.2f} {m['oos_sharpe']:>5.2f} "
|
||||
f"{m['cagr']*100:>+5.0f}% {m['max_dd']*100:>5.1f}% {corr:>7.2f} "
|
||||
f"{a_sh['BTC']:>5.2f} {a_sh['ETH']:>5.2f} {fmt_yearly(m['yearly'])}")
|
||||
results[name] = dict(metrics=m, corr=corr, combo=combo, a_sh=a_sh)
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
print("#" * 112)
|
||||
print("# TRACK I — alternative momentum formulations + long-horizon reversal (BTCÐ, >=12h)")
|
||||
print("# vol-target 20%, lev cap 2x, fee 0.10% RT, positions +1 bar, 50/50 BTC+ETH. OOS 65/35.")
|
||||
print("#" * 112)
|
||||
|
||||
for tf in ("12h", "1d"):
|
||||
bpy = TF_BPD[tf] * 365.25
|
||||
# TP01 reference combo at this TF (long-flat canonical) for correlation
|
||||
tp_combo, _ = portfolio_combo(tf, dir_signblend, long_only=True)
|
||||
tp_m = metrics_of(tp_combo, bpy)
|
||||
print(f"\n>>> TP01 reference @ {tf} (long-flat 1-3-6m): "
|
||||
f"Sharpe {tp_m['sharpe']:.2f} IS {tp_m['is_sharpe']:.2f} OOS {tp_m['oos_sharpe']:.2f} "
|
||||
f"CAGR {tp_m['cagr']*100:+.0f}% maxDD {tp_m['max_dd']*100:.1f}%")
|
||||
|
||||
# PART A — long-flat (fair vs canonical) and long-short
|
||||
report_block("PART A — momentum formulations", PART_A, tf, True, tp_combo, bpy)
|
||||
if tf == "12h":
|
||||
report_block("PART A — momentum formulations (long-short)", PART_A, tf, False, tp_combo, bpy)
|
||||
|
||||
# ----- PART B: reversal + blends, focus 12h -----
|
||||
tf = "12h"; bpy = TF_BPD[tf] * 365.25
|
||||
tp_combo, _ = portfolio_combo(tf, dir_signblend, long_only=True)
|
||||
|
||||
rev_items = [
|
||||
("reversal 12m", make_reversal((12,))),
|
||||
("reversal 18m", make_reversal((18,))),
|
||||
("reversal 24m", make_reversal((24,))),
|
||||
("reversal 12-18-24m", make_reversal((12, 18, 24))),
|
||||
]
|
||||
print("\n\n" + "#" * 112)
|
||||
print("# PART B — LONG-HORIZON REVERSAL (fade past winners). Must be net-positive AND uncorrelated.")
|
||||
print("#" * 112)
|
||||
revB = report_block("PART B — reversal (long-short)", rev_items, tf, False, tp_combo, bpy)
|
||||
# reversal long-flat (long past losers only) for completeness
|
||||
report_block("PART B — reversal (long-flat)", rev_items, tf, True, tp_combo, bpy)
|
||||
|
||||
blend_items = [
|
||||
("mom(1-6) - 0.5*rev(12-24)", make_mom_minus_rev((1, 3, 6), (12, 24), 0.5)),
|
||||
("mom(1-6) - 1.0*rev(12-24)", make_mom_minus_rev((1, 3, 6), (12, 24), 1.0)),
|
||||
("mom(1-3) - 0.5*rev(18-24)", make_mom_minus_rev((1, 3), (18, 24), 0.5)),
|
||||
]
|
||||
report_block("PART B — momentum + reversal blend", blend_items, tf, True, tp_combo, bpy)
|
||||
|
||||
# ----- COMBINED PORTFOLIO: TP01 + best diversifier -----
|
||||
print("\n\n" + "#" * 112)
|
||||
print("# COMBINED: TP01 (long-flat) + candidate diversifier, blended on net returns")
|
||||
print("#" * 112)
|
||||
tp_m = metrics_of(tp_combo, bpy)
|
||||
print(f" TP01 alone: Sharpe {tp_m['sharpe']:.3f} CAGR {tp_m['cagr']*100:+.0f}% maxDD {tp_m['max_dd']*100:.1f}%")
|
||||
|
||||
# candidates to try as overlay: the best A formulations + reversal variants
|
||||
overlays = {
|
||||
"z-score": (dir_zscore, True),
|
||||
"risk-adj": (dir_riskadj, True),
|
||||
"12-1 skip": (dir_mom12_1, True),
|
||||
"reversal 12-18-24 LS": (make_reversal((12, 18, 24)), False),
|
||||
"reversal 24m LS": (make_reversal((24,)), False),
|
||||
}
|
||||
for name, (fn, lo) in overlays.items():
|
||||
cand, _ = portfolio_combo(tf, fn, lo)
|
||||
J = pd.concat([tp_combo.rename("t"), cand.rename("c")], axis=1, join="inner").fillna(0.0)
|
||||
corr = float(np.corrcoef(J["t"], J["c"])[0, 1])
|
||||
for w in (0.5, 0.3, 0.2):
|
||||
mix = pd.Series((1 - w) * J["t"].values + w * J["c"].values, index=J.index)
|
||||
mm = metrics_of(mix, bpy)
|
||||
tag = f"TP01 + {w:.0%} {name}"
|
||||
print(f" {tag:<30s} Sharpe {mm['sharpe']:.3f} CAGR {mm['cagr']*100:+5.0f}% "
|
||||
f"maxDD {mm['max_dd']*100:4.1f}% OOS {mm['oos_sharpe']:.2f} (corr={corr:+.2f})")
|
||||
|
||||
# ----- FEE SWEEP (robustness): 0.00 .. 0.40% RT -----
|
||||
print("\n\n" + "#" * 112)
|
||||
print("# FEE SWEEP — portfolio Sharpe @12h across round-trip fees (0.00-0.40% RT)")
|
||||
print("#" * 112)
|
||||
sweep = [
|
||||
("baseline 1-3-6m (LF)", dir_signblend, True),
|
||||
("z-score cum-ret (LF)", dir_zscore, True),
|
||||
("MACD (LF)", dir_macd, True),
|
||||
("mom(1-6)-0.5rev(12-24)(LF)", make_mom_minus_rev((1, 3, 6), (12, 24), 0.5), True),
|
||||
("reversal 24m (LS)", make_reversal((24,)), False),
|
||||
]
|
||||
rts = [0.0, 0.0005, 0.0010, 0.0020, 0.0040]
|
||||
print(f" {'formulation':<28s}" + "".join(f"{rt*100:>7.2f}%" for rt in rts) + " (RT)")
|
||||
for name, fn, lo in sweep:
|
||||
row = [sharpe_of(portfolio_combo(tf, fn, lo, fee_side=rt / 2)[0], bpy) for rt in rts]
|
||||
print(f" {name:<28s}" + "".join(f"{v:>8.2f}" for v in row))
|
||||
|
||||
print("\nDone. See verdict in the script docstring / diary.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,249 @@
|
||||
"""HONEST BACKTEST HARNESS — universo certificato BTC/ETH (Deribit mainnet).
|
||||
|
||||
Foundation per la ricerca post-reset (2026-06-19). Tutte le strategie nuove devono
|
||||
usare QUESTO harness per garantire:
|
||||
1. NESSUN look-ahead: la direzione e il prezzo d'ingresso si decidono con dati fino
|
||||
a close[i] incluso, e si ENTRA a close[i] (la barra successiva, i+1, e' la prima
|
||||
in cui si e' realmente in posizione). L'exit intrabar guarda high/low di i+1..
|
||||
2. Fee realistiche Deribit: 0.10% round-trip (taker) di default.
|
||||
3. Metriche oneste: equity compounding, CAGR, Sharpe (da rendimenti per-barra),
|
||||
max drawdown, per-anno, e split OOS.
|
||||
|
||||
Convenzione segnali (entry-eseguibile):
|
||||
Una strategia produce, per ogni indice i, un dict opzionale:
|
||||
{'dir': +1/-1, 'tp': prezzo|None, 'sl': prezzo|None, 'max_bars': int|None}
|
||||
decidendo SOLO con dati [.. i] (close[i] incluso). L'engine apre a close[i] e
|
||||
gestisce l'uscita dalle barre i+1 in poi (TP/SL intrabar al livello, SL prioritario;
|
||||
altrimenti max_bars al close).
|
||||
|
||||
Uso tipico:
|
||||
from src.backtest.harness import load, backtest_signals, Metrics
|
||||
df = load("BTC", "1h")
|
||||
entries = my_signal_fn(df) # list[dict|None] lunga len(df)
|
||||
m = backtest_signals(df, entries, fee_rt=0.001, leverage=1.0)
|
||||
m.print_summary("MYSTRAT BTC 1h")
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.data.downloader import load_data
|
||||
|
||||
CERTIFIED = {"BTC", "ETH"}
|
||||
|
||||
|
||||
def load(asset: str, tf: str) -> pd.DataFrame:
|
||||
"""Carica un feed certificato. Solleva su asset non certificato (guardrail fisico)."""
|
||||
if asset.upper() not in CERTIFIED:
|
||||
raise ValueError(f"Asset non certificato: {asset}. Universo = {CERTIFIED}.")
|
||||
df = load_data(asset, tf).reset_index(drop=True)
|
||||
df["datetime"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
return df
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Metriche
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class Metrics:
|
||||
asset: str = ""
|
||||
tf: str = ""
|
||||
n_trades: int = 0
|
||||
wins: int = 0
|
||||
net_return: float = 0.0 # ritorno totale frazionale (final/initial - 1)
|
||||
cagr: float = 0.0
|
||||
sharpe: float = 0.0 # annualizzato dai rendimenti per-barra dell'equity
|
||||
max_dd: float = 0.0 # frazione (0.10 = 10%)
|
||||
time_in_market: float = 0.0 # frazione barre in posizione
|
||||
avg_bars: float = 0.0
|
||||
final_capital: float = 0.0
|
||||
initial_capital: float = 0.0
|
||||
bars_per_year: float = 0.0
|
||||
yearly: dict = field(default_factory=dict) # year -> net return frazionale dell'anno
|
||||
equity: np.ndarray = field(default_factory=lambda: np.array([]))
|
||||
eq_index: pd.DatetimeIndex | None = None
|
||||
|
||||
@property
|
||||
def win_rate(self) -> float:
|
||||
return self.wins / self.n_trades * 100 if self.n_trades else 0.0
|
||||
|
||||
@property
|
||||
def profit_per_day_on(self, capital: float = 2000.0) -> float: # placeholder
|
||||
return 0.0
|
||||
|
||||
def daily_profit(self, capital: float = 2000.0) -> float:
|
||||
"""€/giorno medio se partito con `capital` (su tutto lo span, compounding incluso)."""
|
||||
if self.eq_index is None or len(self.equity) < 2:
|
||||
return 0.0
|
||||
idx = self.eq_index
|
||||
days = (idx.iloc[-1] - idx.iloc[0]).total_seconds() / 86400 if hasattr(idx, "iloc") \
|
||||
else (idx[-1] - idx[0]).total_seconds() / 86400
|
||||
if days <= 0:
|
||||
return 0.0
|
||||
final = capital * (self.final_capital / self.initial_capital)
|
||||
return (final - capital) / days
|
||||
|
||||
def print_summary(self, label: str = ""):
|
||||
print(f" {label:<26s} trades={self.n_trades:>5d} wr={self.win_rate:>4.1f}% "
|
||||
f"ret={self.net_return*100:>+8.0f}% CAGR={self.cagr*100:>+6.1f}% "
|
||||
f"Sharpe={self.sharpe:>5.2f} DD={self.max_dd*100:>4.1f}% "
|
||||
f"mkt={self.time_in_market*100:>4.0f}% €/d(2k)={self.daily_profit(2000):>+6.2f}")
|
||||
|
||||
def print_yearly(self):
|
||||
for y in sorted(self.yearly):
|
||||
print(f" {y}: {self.yearly[y]*100:>+7.1f}%")
|
||||
|
||||
|
||||
def _sharpe(equity: np.ndarray, bars_per_year: float) -> float:
|
||||
if len(equity) < 3:
|
||||
return 0.0
|
||||
r = np.diff(equity) / equity[:-1]
|
||||
r = r[np.isfinite(r)]
|
||||
if len(r) == 0 or np.std(r) == 0:
|
||||
return 0.0
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(bars_per_year))
|
||||
|
||||
|
||||
def _max_dd(equity: np.ndarray) -> float:
|
||||
peak = np.maximum.accumulate(equity)
|
||||
dd = (peak - equity) / peak
|
||||
return float(np.max(dd)) if len(dd) else 0.0
|
||||
|
||||
|
||||
def backtest_signals(
|
||||
df: pd.DataFrame,
|
||||
entries: list,
|
||||
fee_rt: float = 0.001,
|
||||
leverage: float = 1.0,
|
||||
position_size: float = 1.0,
|
||||
initial_capital: float = 1000.0,
|
||||
allow_overlap: bool = False,
|
||||
asset: str = "",
|
||||
tf: str = "",
|
||||
) -> Metrics:
|
||||
"""Esegue il backtest su una lista di entry-dict (uno per barra, None = niente segnale).
|
||||
|
||||
entry dict: {'dir': +1/-1, 'tp': float|None, 'sl': float|None, 'max_bars': int|None}
|
||||
- apertura a close[i] (decisa con dati <= i)
|
||||
- exit dalle barre i+1.. : TP/SL toccati intrabar (al livello, SL prioritario),
|
||||
altrimenti chiusura al close dopo max_bars (default 24 se assente).
|
||||
- non si apre una nuova posizione finche' la precedente non e' chiusa (allow_overlap=False).
|
||||
- PnL compounding: ogni trade muove capital di position_size * leverage * (ret_netto).
|
||||
"""
|
||||
c = df["close"].values.astype(float)
|
||||
h = df["high"].values.astype(float)
|
||||
l = df["low"].values.astype(float)
|
||||
n = len(c)
|
||||
ts = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
|
||||
capital = float(initial_capital)
|
||||
equity = np.full(n, capital, dtype=float)
|
||||
yearly: dict[int, float] = {}
|
||||
yearly_start: dict[int, float] = {}
|
||||
|
||||
n_trades = wins = 0
|
||||
bars_in_market = 0
|
||||
bars_sum = 0
|
||||
i = 0
|
||||
busy_until = -1
|
||||
|
||||
for i in range(n):
|
||||
e = entries[i] if i < len(entries) else None
|
||||
if e is None or e.get("dir", 0) == 0:
|
||||
equity[i] = capital
|
||||
continue
|
||||
if not allow_overlap and i <= busy_until:
|
||||
equity[i] = capital
|
||||
continue
|
||||
|
||||
direction = int(e["dir"])
|
||||
entry = c[i]
|
||||
tp = e.get("tp")
|
||||
sl = e.get("sl")
|
||||
max_bars = int(e.get("max_bars") or 24)
|
||||
|
||||
exit_price = c[min(i + max_bars, n - 1)]
|
||||
exit_idx = min(i + max_bars, n - 1)
|
||||
for j in range(i + 1, min(i + max_bars + 1, n)):
|
||||
hit_sl = sl is not None and (
|
||||
(direction == 1 and l[j] <= sl) or (direction == -1 and h[j] >= sl))
|
||||
hit_tp = tp is not None and (
|
||||
(direction == 1 and h[j] >= tp) or (direction == -1 and l[j] <= tp))
|
||||
if hit_sl:
|
||||
exit_price = sl
|
||||
exit_idx = j
|
||||
break
|
||||
if hit_tp:
|
||||
exit_price = tp
|
||||
exit_idx = j
|
||||
break
|
||||
exit_price = c[j]
|
||||
exit_idx = j
|
||||
|
||||
gross = (exit_price - entry) / entry * direction
|
||||
net = gross * leverage - fee_rt * leverage
|
||||
capital += capital * position_size * net
|
||||
capital = max(capital, 1.0)
|
||||
|
||||
year = ts.iloc[i].year
|
||||
if year not in yearly_start:
|
||||
yearly_start[year] = capital / (1 + position_size * net) if (1 + position_size * net) else capital
|
||||
n_trades += 1
|
||||
if gross > 0:
|
||||
wins += 1
|
||||
bars = exit_idx - i
|
||||
bars_in_market += bars
|
||||
bars_sum += bars
|
||||
busy_until = exit_idx
|
||||
|
||||
# propaga equity fino a exit_idx (mark a fine trade, semplice ma onesto a livello trade)
|
||||
equity[i:exit_idx + 1] = capital
|
||||
|
||||
# riempi i buchi finali
|
||||
for k in range(1, n):
|
||||
if equity[k] == initial_capital and equity[k - 1] != initial_capital:
|
||||
equity[k] = equity[k - 1]
|
||||
# forward fill robusto
|
||||
last = initial_capital
|
||||
for k in range(n):
|
||||
if equity[k] != last and equity[k] != initial_capital:
|
||||
last = equity[k]
|
||||
else:
|
||||
equity[k] = last
|
||||
|
||||
# per-anno dal vettore equity
|
||||
eq_s = pd.Series(equity, index=ts)
|
||||
yearly_ret = {}
|
||||
for y, grp in eq_s.groupby(eq_s.index.year):
|
||||
if len(grp) > 1 and grp.iloc[0] > 0:
|
||||
yearly_ret[int(y)] = float(grp.iloc[-1] / grp.iloc[0] - 1)
|
||||
|
||||
span_days = (ts.iloc[-1] - ts.iloc[0]).total_seconds() / 86400
|
||||
years = span_days / 365.25 if span_days > 0 else 1.0
|
||||
bars_per_year = n / years if years > 0 else n
|
||||
cagr = (capital / initial_capital) ** (1 / years) - 1 if years > 0 and capital > 0 else -1.0
|
||||
|
||||
return Metrics(
|
||||
asset=asset, tf=tf,
|
||||
n_trades=n_trades, wins=wins,
|
||||
net_return=capital / initial_capital - 1,
|
||||
cagr=cagr,
|
||||
sharpe=_sharpe(equity, bars_per_year),
|
||||
max_dd=_max_dd(equity),
|
||||
time_in_market=bars_in_market / n if n else 0.0,
|
||||
avg_bars=bars_sum / n_trades if n_trades else 0.0,
|
||||
final_capital=capital,
|
||||
initial_capital=initial_capital,
|
||||
bars_per_year=bars_per_year,
|
||||
yearly=yearly_ret,
|
||||
equity=equity,
|
||||
eq_index=ts,
|
||||
)
|
||||
|
||||
|
||||
def oos_split(df: pd.DataFrame, frac: float = 0.65):
|
||||
"""Indice di taglio IS/OOS (default 65% in-sample)."""
|
||||
return int(len(df) * frac)
|
||||
@@ -0,0 +1 @@
|
||||
"""Monitoraggio paper (dashboard). Lo stack live REALE resta in Old/."""
|
||||
@@ -0,0 +1,159 @@
|
||||
"""DASHBOARD web del portafoglio attivo (TP01 + XS01) — 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
|
||||
interna. Esecuzione REALE disabilitata: e' un monitor, non un trader.
|
||||
|
||||
uv run python -m src.live.dashboard --port 8787
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import sys, json, time
|
||||
from pathlib import Path
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
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.live.shadow import shadow_report
|
||||
from src.version import APP_VERSION
|
||||
|
||||
PAPER = PROJECT_ROOT / "data" / "paper_portfolio" / "state.json"
|
||||
_CACHE = {"t": 0.0, "data": None}
|
||||
_TTL = 120.0
|
||||
|
||||
|
||||
def build():
|
||||
if _CACHE["data"] is not None and time.time() - _CACHE["t"] < _TTL:
|
||||
return _CACHE["data"]
|
||||
pf = StrategyPortfolio(active_sleeves(), capital=2000.0)
|
||||
bt = pf.backtest()
|
||||
eq = bt["equity"]; idx = bt["index"]
|
||||
# sparkline: subsample ~400 punti
|
||||
step = max(1, len(eq) // 400)
|
||||
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
|
||||
try:
|
||||
shadow = shadow_report() # mainnet sola lettura, best-effort
|
||||
except Exception as e:
|
||||
shadow = {"error": f"{type(e).__name__}: {e}"}
|
||||
data = dict(
|
||||
version=APP_VERSION,
|
||||
last_data=str(idx[-1].date()),
|
||||
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, shadow=shadow,
|
||||
bh=None,
|
||||
)
|
||||
_CACHE.update(t=time.time(), data=data)
|
||||
return data
|
||||
|
||||
|
||||
def svg_spark(spark, w=900, h=220):
|
||||
ys = [v for _, v in spark]
|
||||
lo, hi = min(ys), max(ys)
|
||||
rng = hi - lo or 1
|
||||
pts = []
|
||||
for i, (_, v) in enumerate(spark):
|
||||
x = i / (len(spark) - 1) * w
|
||||
y = h - (v - lo) / rng * (h - 10) - 5
|
||||
pts.append(f"{x:.1f},{y:.1f}")
|
||||
return (f'<svg viewBox="0 0 {w} {h}" width="100%" height="{h}" preserveAspectRatio="none">'
|
||||
f'<polyline fill="none" stroke="#2ecc71" stroke-width="2" points="{" ".join(pts)}"/></svg>')
|
||||
|
||||
|
||||
def html():
|
||||
d = build()
|
||||
f, ho = d["full"], d["holdout"]
|
||||
rows = ""
|
||||
for name, s in d["per_sleeve"].items():
|
||||
rows += (f"<tr><td>{name}</td><td>{s['weight']*100:.0f}%</td>"
|
||||
f"<td>{s['full']['sharpe']:.2f}</td><td>{s['full']['maxdd']*100:.0f}%</td>"
|
||||
f"<td>{s['holdout']['sharpe']:.2f}</td></tr>")
|
||||
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>"
|
||||
pp = d["paper"]
|
||||
if pp:
|
||||
days = (pd.Timestamp(pp["last"]) - pd.Timestamp(pp["start"])).days
|
||||
ret = pp["equity"] / pp["initial"] - 1
|
||||
paper_html = (f"<b>{pp['equity']:.2f}</b> (start {pp['initial']:.0f}, {pp['start'][:10]} → "
|
||||
f"{pp['last'][:10]}, {days}g) ret <b>{ret*100:+.2f}%</b> maxDD {pp['max_dd']*100:.1f}%")
|
||||
else:
|
||||
paper_html = "non inizializzato (gira <code>paper_portfolio.py</code>)"
|
||||
sh = d.get("shadow")
|
||||
if sh and "error" not in sh:
|
||||
bits = " · ".join(
|
||||
f"{a['asset']} <b>{'FLAT' if abs(a['target'])<1e-9 else 'LONG' if a['target']>0 else 'SHORT'}</b> "
|
||||
f"{a['target']:+.2f}x" for a in sh["assets"])
|
||||
if sh.get("online"):
|
||||
eq = f"${sh['real_equity']:,.2f}" if sh.get("real_equity") else sh.get("eq_basis", "?")
|
||||
pos = ", ".join(f"{a['asset']} ${a['position_usd']:,.0f}" for a in sh["assets"])
|
||||
ordtxt = ("; ".join(f"{o['side'].upper()} {o['amount']:.0f} {o['instrument']}" for o in sh["orders"])
|
||||
if sh.get("orders") else "nessuno (target flat / gia' a target)")
|
||||
shadow_html = (f"mainnet · sola lettura · conto reale <b>{eq}</b> · pos {pos} · dato {sh['last_data']}<br>"
|
||||
f"TP01 target: {bits}<br>ordini-che-invierebbe (<b>NON inviati</b>): {ordtxt}")
|
||||
else:
|
||||
shadow_html = (f"conto reale non leggibile dal container (token solo su host) · dato {sh['last_data']}<br>"
|
||||
f"TP01 target: {bits}<br>→ per gli ordini reali: <code>uv run python scripts/live/live_trend.py</code> (host)")
|
||||
else:
|
||||
shadow_html = "non disponibile" + (f" — {sh['error']}" if sh and sh.get('error') else "")
|
||||
return f"""<!doctype html><html><head><meta charset=utf-8>
|
||||
<meta http-equiv=refresh content=300><title>PythagorasGoal — Portafoglio</title>
|
||||
<style>body{{font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#0e1116;color:#e6e6e6;margin:0;padding:24px;max-width:980px;margin:auto}}
|
||||
h1{{font-size:20px;margin:0 0 2px}}.sub{{color:#8a93a0;font-size:13px;margin-bottom:18px}}
|
||||
.cards{{display:flex;gap:14px;flex-wrap:wrap;margin-bottom:18px}}
|
||||
.card{{background:#161b22;border:1px solid #222b36;border-radius:10px;padding:14px 18px;min-width:150px}}
|
||||
.card .k{{color:#8a93a0;font-size:12px}}.card .v{{font-size:24px;font-weight:600}}.g{{color:#2ecc71}}.r{{color:#e74c3c}}
|
||||
table{{width:100%;border-collapse:collapse;margin:8px 0 20px}}td,th{{text-align:left;padding:7px 10px;border-bottom:1px solid #222b36;font-size:14px}}
|
||||
th{{color:#8a93a0;font-weight:500}}.y{{display:inline-block;background:#161b22;border:1px solid #222b36;border-radius:6px;padding:3px 8px;margin:2px;font-size:12px}}
|
||||
.box{{background:#161b22;border:1px solid #222b36;border-radius:10px;padding:14px 18px;margin-bottom:18px}}
|
||||
.warn{{color:#f1c40f;font-size:12px}}</style></head><body>
|
||||
<h1>PythagorasGoal — Portafoglio attivo (TP01 + XS01 + VRP01)</h1>
|
||||
<div class=sub>monitor PAPER + SHADOW · v{d['version']} · ultimo dato {d['last_data']} · esecuzione REALE disabilitata</div>
|
||||
<div class=cards>
|
||||
<div class=card><div class=k>FULL Sharpe</div><div class="v g">{f['sharpe']:.2f}</div></div>
|
||||
<div class=card><div class=k>HOLD-OUT Sharpe (2025-26)</div><div class="v g">{ho['sharpe']:.2f}</div></div>
|
||||
<div class=card><div class=k>maxDD</div><div class=v>{f['maxdd']*100:.1f}%</div></div>
|
||||
<div class=card><div class=k>CAGR</div><div class=v>{f['cagr']*100:.0f}%</div></div>
|
||||
<div class=card><div class=k>ret totale</div><div class=v>{f['ret']*100:+.0f}%</div></div>
|
||||
</div>
|
||||
<div class=box><div class=k style="color:#8a93a0;font-size:12px">EQUITY backtest (2019→oggi, €2k)</div>{svg_spark(d['spark'])}</div>
|
||||
<div class=box><b>Paper forward-only:</b> {paper_html}</div>
|
||||
<div class=box><b>Shadow live — TP01 su Deribit</b> (sola lettura, nessun ordine inviato):<br>{shadow_html}</div>
|
||||
<h3 style="font-size:14px;color:#8a93a0">Sleeve</h3>
|
||||
<table><tr><th>sleeve</th><th>peso</th><th>FULL Sh</th><th>DD</th><th>HOLD Sh</th></tr>{rows}</table>
|
||||
<h3 style="font-size:14px;color:#8a93a0">Posizioni correnti (ultima barra chiusa)</h3>
|
||||
<table>{pos}</table>
|
||||
<div style="margin-top:10px">{yrs}</div>
|
||||
<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>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path not in ("/", "/index.html"):
|
||||
self.send_response(404); self.end_headers(); return
|
||||
try:
|
||||
body = html().encode()
|
||||
except Exception as e:
|
||||
body = f"<pre>errore: {type(e).__name__}: {e}</pre>".encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers(); self.wfile.write(body)
|
||||
|
||||
|
||||
def main():
|
||||
port = 8787
|
||||
if "--port" in sys.argv:
|
||||
port = int(sys.argv[sys.argv.index("--port") + 1])
|
||||
print(f"dashboard su :{port} (Ctrl-C per uscire)")
|
||||
ThreadingHTTPServer(("0.0.0.0", port), H).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Accesso Deribit MAINNET in SOLA LETTURA (via Cerbero MCP) + costruttore ordini deterministico.
|
||||
|
||||
Serve lo SHADOW MODE di TP01 (`scripts/live/live_trend.py`): legge prezzi / conto / posizioni REALI
|
||||
dal mainnet (token `.env.mainnet`) e costruisce gli ordini di ribilancio **senza inviarli**. Qui NON
|
||||
esiste alcun metodo di trading — by design: l'unica via per piazzare ordini sara' un modulo separato,
|
||||
abilitato esplicitamente, dopo la validazione shadow + micro-test.
|
||||
|
||||
Disciplina del progetto: **testnet FUORI** (feed farlocco, causa del reset v2.0.0). Solo mainnet reale,
|
||||
e in questa fase solo in lettura. I contratti sono ristretti a BTC/ETH-PERPETUAL (inverse: `amount`
|
||||
in USD notional, step verificato su Deribit: BTC $10, ETH $1).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
BASE_URL = os.environ.get("CERBERO_BASE_URL", "https://cerbero-mcp.tielogic.xyz")
|
||||
TIMEOUT = 15
|
||||
|
||||
# Inverse perp: amount = USD notional, step in USD. settle = base-coin (per get_positions/fee).
|
||||
_CONTRACT = {
|
||||
"BTC-PERPETUAL": {"min": 10.0, "step": 10.0, "tick": 0.5, "settle": "BTC"},
|
||||
"ETH-PERPETUAL": {"min": 1.0, "step": 1.0, "tick": 0.05, "settle": "ETH"},
|
||||
}
|
||||
INSTRUMENT = {"BTC": "BTC-PERPETUAL", "ETH": "ETH-PERPETUAL"}
|
||||
|
||||
|
||||
# ----------------------------- costruzione ordini (pura, testabile, NIENTE rete) -----------------------------
|
||||
|
||||
def _quantize_step(value: float, step: float, mn: float) -> float:
|
||||
"""Arrotonda al multiplo di `step` (Decimal, niente artefatti float), clampa al minimo."""
|
||||
n = round(value / step)
|
||||
return float(max(n * Decimal(str(step)), Decimal(str(mn))))
|
||||
|
||||
|
||||
def notional_to_amount(instrument: str, notional_usd: float) -> float:
|
||||
"""USD notional -> `amount` Deribit (inverse: amount in USD), arrotondato allo step e clampato
|
||||
al minimo. Ritorna 0.0 se |notional| < mezzo step (sotto-soglia: niente ordine)."""
|
||||
spec = _CONTRACT[instrument]
|
||||
step, mn = spec["step"], spec["min"]
|
||||
if abs(notional_usd) < step / 2:
|
||||
return 0.0
|
||||
return _quantize_step(abs(notional_usd), step, mn)
|
||||
|
||||
|
||||
def target_notional_usd(target_fraction: float, weight: float, equity_usd: float) -> float:
|
||||
"""Notional bersaglio (USD) di un asset = peso nel book * frazione-di-equity TP01 * equity.
|
||||
Coerente col paper trader (esposizione asset = WEIGHT * target * equity)."""
|
||||
return weight * target_fraction * equity_usd
|
||||
|
||||
|
||||
def build_rebalance_order(instrument: str, target_fraction: float, weight: float,
|
||||
equity_usd: float, current_pos_usd: float) -> dict | None:
|
||||
"""COSTRUISCE (non invia) l'ordine di ribilancio verso il target. Ritorna un dict-ordine o None
|
||||
se sotto-soglia. Long-only TP01 -> target_notional >= 0; delta = target - posizione corrente."""
|
||||
tgt = target_notional_usd(target_fraction, weight, equity_usd)
|
||||
delta = tgt - current_pos_usd
|
||||
amount = notional_to_amount(instrument, delta)
|
||||
if amount == 0.0:
|
||||
return None
|
||||
is_exit = abs(tgt) < 1e-9 and abs(current_pos_usd) > 0
|
||||
return dict(
|
||||
instrument=instrument,
|
||||
side="buy" if delta > 0 else "sell",
|
||||
amount=amount,
|
||||
type="market",
|
||||
reduce_only=is_exit,
|
||||
target_notional=round(tgt, 2),
|
||||
current_notional=round(current_pos_usd, 2),
|
||||
delta_notional=round(delta, 2),
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------- lettura mainnet (Cerbero MCP) — SOLA LETTURA -----------------------------
|
||||
|
||||
def _load_mainnet_token() -> tuple[str, str]:
|
||||
"""Legge CERBERO_TOKEN (mainnet) + bot-tag da .env.mainnet. Il token NON viene mai stampato."""
|
||||
env: dict[str, str] = {}
|
||||
for ln in (PROJECT_ROOT / ".env.mainnet").read_text().splitlines():
|
||||
ln = ln.strip()
|
||||
if ln and not ln.startswith("#") and "=" in ln:
|
||||
k, v = ln.split("=", 1)
|
||||
env[k] = v.strip()
|
||||
if "CERBERO_TOKEN" not in env:
|
||||
raise RuntimeError("CERBERO_TOKEN assente in .env.mainnet")
|
||||
return env["CERBERO_TOKEN"], env.get("CERBERO_BOT_TAG", "pythagoras-shadow")
|
||||
|
||||
|
||||
class DeribitRead:
|
||||
"""Accesso Deribit mainnet in SOLA LETTURA via Cerbero MCP. Nessun metodo di trading (by design)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._token, self._tag = _load_mainnet_token()
|
||||
|
||||
def _post(self, path: str, payload: dict) -> dict | list:
|
||||
r = requests.post(
|
||||
f"{BASE_URL}{path}",
|
||||
headers={"Authorization": f"Bearer {self._token}", "X-Bot-Tag": self._tag,
|
||||
"Content-Type": "application/json"},
|
||||
json=payload, timeout=TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@staticmethod
|
||||
def _unwrap(resp: dict | list) -> dict | list:
|
||||
return resp.get("result", resp) if isinstance(resp, dict) else resp
|
||||
|
||||
def ticker(self, instrument: str) -> dict:
|
||||
return self._unwrap(self._post("/mcp-deribit/tools/get_ticker", {"instrument": instrument})) or {}
|
||||
|
||||
def mark_price(self, instrument: str) -> float:
|
||||
t = self.ticker(instrument)
|
||||
for k in ("mark_price", "index_price", "last_price", "last"):
|
||||
v = t.get(k)
|
||||
if v:
|
||||
return float(v)
|
||||
raise ValueError(f"prezzo assente nel ticker {instrument}: chiavi={list(t)[:8]}")
|
||||
|
||||
def account_summary(self, currency: str) -> dict:
|
||||
return self._unwrap(self._post("/mcp-deribit/tools/get_account_summary", {"currency": currency})) or {}
|
||||
|
||||
def positions(self, currency: str) -> list[dict]:
|
||||
out = self._unwrap(self._post("/mcp-deribit/tools/get_positions", {"currency": currency}))
|
||||
if isinstance(out, list):
|
||||
return out
|
||||
return out.get("positions", []) if isinstance(out, dict) else []
|
||||
|
||||
def position_usd(self, instrument: str) -> float:
|
||||
"""Size netta (USD notional, segno = direzione) della posizione su `instrument`. 0 se flat."""
|
||||
cur = _CONTRACT[instrument]["settle"]
|
||||
for p in self.positions(cur):
|
||||
if p.get("instrument_name") == instrument or p.get("instrument") == instrument:
|
||||
return float(p.get("size") or p.get("size_currency") or 0.0)
|
||||
return 0.0
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Stato SHADOW di TP01 (Deribit mainnet, SOLA LETTURA): target causali + conto/posizioni REALI +
|
||||
ordini di ribilancio COSTRUITI (mai inviati). Modulo condiviso da `scripts/live/live_trend.py` (CLI)
|
||||
e dalla dashboard, cosi' i due non divergono. Robusto ai fallimenti di rete: degrada a offline/flat
|
||||
senza sollevare eccezioni (la dashboard non deve crashare se il mainnet non risponde)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from src.backtest.harness import load
|
||||
from src.live.deribit import INSTRUMENT, DeribitRead, build_rebalance_order
|
||||
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio, resample_1d
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
ASSETS = ["BTC", "ETH"]
|
||||
WEIGHT = 0.5
|
||||
FALLBACK_CAPITAL = 2000.0
|
||||
PAPER_STATE = PROJECT_ROOT / "data" / "paper_trend" / "state.json"
|
||||
|
||||
|
||||
def _safe_client() -> DeribitRead | None:
|
||||
try:
|
||||
return DeribitRead()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _marks(client, dfs):
|
||||
marks, src = {}, {}
|
||||
for a in ASSETS:
|
||||
if client is None:
|
||||
marks[a], src[a] = float(dfs[a]["close"].iloc[-1]), "close certificata"
|
||||
continue
|
||||
try:
|
||||
marks[a], src[a] = client.mark_price(INSTRUMENT[a]), "mainnet"
|
||||
except Exception as e:
|
||||
marks[a], src[a] = float(dfs[a]["close"].iloc[-1]), f"fallback close ({type(e).__name__})"
|
||||
return marks, src
|
||||
|
||||
|
||||
def _positions(client):
|
||||
if client is None:
|
||||
return {a: 0.0 for a in ASSETS}, "offline -> assunto flat"
|
||||
pos, note = {}, "mainnet"
|
||||
for a in ASSETS:
|
||||
try:
|
||||
pos[a] = client.position_usd(INSTRUMENT[a])
|
||||
except Exception as e:
|
||||
pos[a], note = 0.0, f"read fallito ({type(e).__name__}) -> assunto flat"
|
||||
return pos, note
|
||||
|
||||
|
||||
def _equity(client, marks):
|
||||
if client is None:
|
||||
return None, "offline"
|
||||
try:
|
||||
eq = float(client.account_summary("USDC").get("equity") or 0)
|
||||
if eq > 1:
|
||||
return eq, "mainnet USDC"
|
||||
except Exception:
|
||||
pass
|
||||
tot, any_ok = 0.0, False
|
||||
for a in ASSETS:
|
||||
try:
|
||||
eq = float(client.account_summary(a).get("equity") or 0)
|
||||
tot += eq * marks[a]
|
||||
any_ok = True
|
||||
except Exception:
|
||||
pass
|
||||
if any_ok and tot > 1:
|
||||
return tot, "mainnet coin-margined"
|
||||
return None, "conto flat / non finanziato"
|
||||
|
||||
|
||||
def shadow_report(offline: bool = False, equity_override: float | None = None) -> dict:
|
||||
"""Calcola lo stato shadow completo. NON invia nulla. Ritorna un dict serializzabile."""
|
||||
dfs = {a: resample_1d(load(a, "1h")) for a in ASSETS}
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
targets = {a: tp.current_target(dfs[a]) for a in ASSETS}
|
||||
last_ts = min(int(dfs[a]["timestamp"].iloc[-1]) for a in ASSETS)
|
||||
|
||||
client = None if offline else _safe_client()
|
||||
marks, marks_src = _marks(client, dfs)
|
||||
positions, pos_src = _positions(client)
|
||||
real_eq, eq_src = _equity(client, marks)
|
||||
|
||||
paper = json.loads(PAPER_STATE.read_text()) if PAPER_STATE.exists() else None
|
||||
paper_cap = float(paper["capital"]) if paper else FALLBACK_CAPITAL
|
||||
paper_pos = paper.get("positions") if paper else None
|
||||
paper_ts = int(paper["last_ts"]) if paper else 0
|
||||
|
||||
equity = equity_override if equity_override is not None else (real_eq if real_eq else paper_cap)
|
||||
eq_basis = ("override" if equity_override is not None
|
||||
else eq_src if real_eq else "paper capital (ipotetico: conto non finanziato)")
|
||||
|
||||
assets, orders = [], []
|
||||
for a in ASSETS:
|
||||
inst = INSTRUMENT[a]
|
||||
order = build_rebalance_order(inst, targets[a], WEIGHT, equity, positions[a])
|
||||
if order:
|
||||
orders.append(order)
|
||||
parity = None
|
||||
if paper_pos is not None:
|
||||
parity = abs(float(paper_pos.get(a, 0.0)) - targets[a]) < 1e-9
|
||||
assets.append(dict(
|
||||
asset=a, instrument=inst, target=targets[a],
|
||||
target_notional=WEIGHT * targets[a] * equity,
|
||||
position_usd=positions[a], mark=marks[a], mark_src=marks_src[a],
|
||||
order=order, paper=(float(paper_pos.get(a, 0.0)) if paper_pos else None), parity=parity,
|
||||
))
|
||||
return dict(
|
||||
last_data=str(pd.Timestamp(last_ts, unit="ms", tz="UTC").date()),
|
||||
online=(client is not None and marks_src.get("BTC") == "mainnet"),
|
||||
real_equity=real_eq, equity=equity, eq_basis=eq_basis,
|
||||
pos_src=pos_src, assets=assets, orders=orders,
|
||||
flat=all(abs(targets[a]) < 1e-9 for a in ASSETS),
|
||||
paper_aligned=(paper_ts == last_ts),
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Portafoglio di strategie (estensibile) — v2.0.0.
|
||||
|
||||
Un portafoglio aggrega N SLEEVE indipendenti, ognuno = una strategia validata che produce una
|
||||
serie di rendimenti netti CAUSALE e netto-fee. Gli sleeve si combinano per peso su una griglia
|
||||
GIORNALIERA comune (grid unica per mixare TF diversi). Vedi src.portfolio.portfolio + sleeves.
|
||||
"""
|
||||
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, to_daily
|
||||
|
||||
__all__ = ["Sleeve", "StrategyPortfolio", "to_daily"]
|
||||
@@ -0,0 +1,120 @@
|
||||
"""PORTAFOGLIO DI STRATEGIE — contenitore estensibile (v2.0.0).
|
||||
|
||||
Modello: ogni SLEEVE produce una serie di rendimenti netti per-barra (datetime-indexed, CAUSALE,
|
||||
netto fee). Il portafoglio:
|
||||
1. porta ogni sleeve su una griglia GIORNALIERA comune (compounding intra-giorno) — così sleeve
|
||||
a TF diversi (1d, 1h, ...) si combinano in modo coerente;
|
||||
2. combina per PESO (rinormalizzato a 1) sui giorni comuni a tutti gli sleeve;
|
||||
3. = portafoglio equal-capital-by-weight ribilanciato di continuo (interpretazione del weighted-
|
||||
return combine). Equity = capitale · Π(1+combo).
|
||||
|
||||
AGGIUNGERE uno sleeve è una riga in src/portfolio/sleeves.py (vedi lì il template).
|
||||
Metriche oneste: FULL + HOLD-OUT 2025-26 (bloccato) + per-anno, e standalone per-sleeve.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
DAYS_PER_YEAR = 365.25
|
||||
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
|
||||
|
||||
|
||||
def to_daily(net: pd.Series) -> pd.Series:
|
||||
"""Compound una serie di rendimenti netti per-barra a GIORNALIERA (griglia comune del portafoglio)."""
|
||||
s = net.dropna().sort_index()
|
||||
if not isinstance(s.index, pd.DatetimeIndex):
|
||||
s.index = pd.to_datetime(s.index, utc=True)
|
||||
if s.index.tz is None:
|
||||
s.index = s.index.tz_localize("UTC")
|
||||
return ((1.0 + s).resample("1D").prod() - 1.0).dropna()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sleeve:
|
||||
"""Una strategia nel portafoglio. daily_fn() -> serie rendimenti netti per-barra (causale, netto fee).
|
||||
pos_fn() (opzionale) -> dict posizioni-bersaglio correnti, per introspezione live."""
|
||||
name: str
|
||||
weight: float
|
||||
daily_fn: Callable[[], pd.Series]
|
||||
pos_fn: Callable[[], dict] | None = None
|
||||
_cache: pd.Series | None = field(default=None, repr=False, compare=False)
|
||||
|
||||
def daily(self) -> pd.Series:
|
||||
if self._cache is None:
|
||||
self._cache = to_daily(self.daily_fn())
|
||||
return self._cache
|
||||
|
||||
|
||||
def metrics(daily: pd.Series) -> dict:
|
||||
r = np.asarray(daily.dropna().values, float)
|
||||
if len(r) < 2 or r.std() == 0:
|
||||
return dict(sharpe=0.0, cagr=0.0, maxdd=0.0, ret=0.0, n=int(len(r)))
|
||||
eq = np.cumprod(1.0 + r)
|
||||
pk = np.maximum.accumulate(eq)
|
||||
years = len(r) / DAYS_PER_YEAR
|
||||
return dict(sharpe=float(r.mean() / r.std() * np.sqrt(DAYS_PER_YEAR)),
|
||||
cagr=float(eq[-1] ** (1 / years) - 1) if years > 0 and eq[-1] > 0 else 0.0,
|
||||
maxdd=float(np.max((pk - eq) / pk)), ret=float(eq[-1] - 1), n=int(len(r)))
|
||||
|
||||
|
||||
def yearly(daily: pd.Series) -> dict:
|
||||
out = {}
|
||||
for y, g in daily.groupby(daily.index.year):
|
||||
v = g.values
|
||||
eq = np.cumprod(1 + v); pk = np.maximum.accumulate(eq)
|
||||
out[int(y)] = dict(ret=float(eq[-1] - 1), dd=float(np.max((pk - eq) / pk)))
|
||||
return out
|
||||
|
||||
|
||||
class StrategyPortfolio:
|
||||
def __init__(self, sleeves: list[Sleeve], capital: float = 2000.0):
|
||||
if not sleeves:
|
||||
raise ValueError("portafoglio vuoto: serve almeno uno sleeve")
|
||||
self.sleeves = sleeves
|
||||
self.capital = capital
|
||||
|
||||
def weights(self) -> dict:
|
||||
tot = sum(s.weight for s in self.sleeves)
|
||||
if tot <= 0:
|
||||
raise ValueError("somma pesi non positiva")
|
||||
return {s.name: s.weight / tot for s in self.sleeves}
|
||||
|
||||
def combined_daily(self, lo=None, hi=None) -> pd.Series:
|
||||
"""Combina gli sleeve per peso. OUTER-join: sleeve con date d'inizio diverse
|
||||
(es. TP01 dal 2019, uno nuovo dal 2024) -> ogni giorno i pesi sono RINORMALIZZATI
|
||||
fra i soli sleeve con dato disponibile (uno sleeve "si attiva" quando parte la sua
|
||||
storia). Cosi' non si tronca il portafoglio alla finestra comune."""
|
||||
w = self.weights()
|
||||
cols = {s.name: s.daily() for s in self.sleeves}
|
||||
J = pd.concat(cols, axis=1, join="outer").sort_index()
|
||||
wv = np.array([w[c] for c in J.columns], float)
|
||||
active = J.notna().values * wv # peso solo dove c'e' dato
|
||||
rowsum = active.sum(axis=1, keepdims=True)
|
||||
wnorm = np.divide(active, rowsum, out=np.zeros_like(active), where=rowsum > 0)
|
||||
combo = pd.Series(np.nansum(np.nan_to_num(J.values) * wnorm, axis=1), index=J.index)
|
||||
combo = combo[J.notna().any(axis=1).values] # togli i giorni senza alcun dato
|
||||
if lo is not None:
|
||||
combo = combo[combo.index >= lo]
|
||||
if hi is not None:
|
||||
combo = combo[combo.index < hi]
|
||||
return combo
|
||||
|
||||
def backtest(self) -> dict:
|
||||
full = self.combined_daily()
|
||||
return dict(
|
||||
weights=self.weights(),
|
||||
full=metrics(full),
|
||||
holdout=metrics(self.combined_daily(lo=HOLDOUT)),
|
||||
yearly=yearly(full),
|
||||
per_sleeve={s.name: dict(weight=self.weights()[s.name],
|
||||
full=metrics(s.daily()),
|
||||
holdout=metrics(s.daily()[s.daily().index >= HOLDOUT]))
|
||||
for s in self.sleeves},
|
||||
equity=self.capital * np.cumprod(1.0 + full.values),
|
||||
index=full.index,
|
||||
)
|
||||
|
||||
def current_positions(self) -> dict:
|
||||
return {s.name: (s.pos_fn() if s.pos_fn else None) for s in self.sleeves}
|
||||
@@ -0,0 +1,218 @@
|
||||
"""SLEEVE del portafoglio + REGISTRY degli sleeve attivi.
|
||||
|
||||
Per AGGIUNGERE una strategia al portafoglio:
|
||||
1. Validala col gauntlet onesto (scripts/analysis/research_lab.py + hold-out + cross-asset).
|
||||
2. Scrivi una funzione `_<nome>_returns() -> pd.Series` che ritorna i suoi rendimenti netti
|
||||
per-barra (datetime-indexed, CAUSALE, netto fee). Deve passare il guard di causalità.
|
||||
3. Avvolgila in uno Sleeve(nome, peso, fn[, pos_fn]) e aggiungila a active_sleeves().
|
||||
Niente sleeve non validati: il portafoglio è solo per edge che reggono il gauntlet.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from src.data.downloader import load_data
|
||||
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL, resample_1d, simple_returns
|
||||
from src.portfolio.portfolio import Sleeve
|
||||
|
||||
ASSETS = ("BTC", "ETH")
|
||||
|
||||
|
||||
# ----------------------------- TP01 (PORT LF1d) -----------------------------
|
||||
def _tp01_returns() -> pd.Series:
|
||||
"""TP01: TSMOM vol-target long-flat, 50/50 BTC+ETH, a 1d (>=12h: vedi nota look-ahead nel modulo).
|
||||
Rendimenti netti per-barra del portafoglio (causale: posizione decisa a close[i-1], tenuta in i)."""
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
series = {}
|
||||
for a in ASSETS:
|
||||
df = resample_1d(load_data(a, "1h"))
|
||||
r = simple_returns(df["close"].values.astype(float))
|
||||
tgt = tp.target_series(df)
|
||||
held = np.zeros(len(tgt)); held[1:] = tgt[:-1]
|
||||
net = held * r - tp.fee_side * np.abs(np.diff(held, prepend=0.0)); net[0] = 0.0
|
||||
series[a] = pd.Series(np.clip(net, -0.99, None), index=pd.to_datetime(df["datetime"]))
|
||||
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 _tp01_positions() -> dict:
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
return {a: round(tp.current_target(resample_1d(load_data(a, "1h"))), 4) for a in ASSETS}
|
||||
|
||||
|
||||
def tp01_sleeve(weight: float = 1.0) -> Sleeve:
|
||||
return Sleeve("TP01_trend_1d", weight, _tp01_returns, pos_fn=_tp01_positions)
|
||||
|
||||
|
||||
# ----------------------------- XS01: Cross-Sectional Momentum (Hyperliquid) -----------------------------
|
||||
# Universo certificato Hyperliquid (19 alt, 1d, dal 2024) in data/raw/hl_*_1d.parquet
|
||||
# (fetch+certify: scripts/analysis/fetch_hyperliquid.py). Market-neutral, scorrelato a TP01 (~-0.06).
|
||||
# CAVEAT ONESTI: storia corta (~2.5 anni, 2024-2026); STAT-MODE (book a 19 gambe market-neutral
|
||||
# non eseguibile a 2k, serve ~20k); l'edge e' nella DISPERSIONE cross-section (complementare al
|
||||
# trend di TP01: lavora quando TP01 e' in cash). Validato: scripts/portfolio/xsec_research.py.
|
||||
import glob as _glob
|
||||
from pathlib import Path as _Path
|
||||
# BLEND di lookback (2026-06-19): fonde 30g+90g del momentum cross-sectional (z-score per
|
||||
# lookback, mediato) come TP01 fonde gli orizzonti -> piu' robusto del singolo L=30: FULL Sh
|
||||
# 0.80->1.10, DD 21%->14%, corr a TP01 -0.06->-0.12, 100% anni+. Diario 2026-06-19-xsec-blend.md.
|
||||
# + GATE DI DISPERSIONE (2026-06-19): entra solo se la dispersione cross-section del momentum
|
||||
# supera il percentile ESPANDENTE causale disp_pct (altrimenti flat: in regime compatto XS e'
|
||||
# rumore). Plateau robusto p15-p35; a p30: portafoglio FULL 1.50->1.74, HOLD 1.06->1.56.
|
||||
# Diario 2026-06-19-xsec-dispgate.md.
|
||||
XS_CFG = dict(lookbacks=(30, 90), H=10, k=5, mode="mom", target_vol=0.20, disp_pct=30, disp_minhist=20)
|
||||
_HL_DIR = _Path(__file__).resolve().parents[2] / "data" / "raw"
|
||||
# UNIVERSO ESPLICITO = 19 ALT LIQUIDI MAJOR. NB (2026-06-19): allargare a 52 asset (incluso
|
||||
# small-cap WIF/JUP/ORDI/PYTH/TAO...) DILUISCE l'edge -> momentum cross-section NEGATIVO sui 52.
|
||||
# I major sono il sweet spot. NON usare glob-all (i parquet extra certificati servono ad altra
|
||||
# ricerca, non a XS01). Vedi diario 2026-06-19-xsec-universe-expansion.md.
|
||||
XS_UNIVERSE = ["BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "AVAX", "LINK", "LTC", "ADA",
|
||||
"ARB", "OP", "SUI", "APT", "INJ", "TIA", "SEI", "NEAR", "AAVE"]
|
||||
|
||||
|
||||
def _xsec_returns() -> pd.Series:
|
||||
cols = {}
|
||||
for sym in XS_UNIVERSE:
|
||||
p = _HL_DIR / f"hl_{sym.lower()}_1d.parquet"
|
||||
if not p.exists():
|
||||
continue
|
||||
d = pd.read_parquet(p)
|
||||
cols[sym] = pd.Series(d["close"].values.astype(float),
|
||||
index=pd.to_datetime(d["timestamp"], unit="ms", utc=True))
|
||||
if len(cols) < 10:
|
||||
raise FileNotFoundError("universo Hyperliquid XS01 incompleto: gira scripts/analysis/fetch_hyperliquid.py")
|
||||
C = pd.concat(cols, axis=1, join="inner").sort_index().dropna()
|
||||
px = C.values; n, A = px.shape
|
||||
lookbacks, H, k, mode, tv = XS_CFG["lookbacks"], XS_CFG["H"], XS_CFG["k"], XS_CFG["mode"], XS_CFG["target_vol"]
|
||||
disp_pct = XS_CFG.get("disp_pct", 0); minhist = XS_CFG.get("disp_minhist", 20)
|
||||
mlb = max(lookbacks)
|
||||
dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0])
|
||||
W = np.zeros((n, A)); w = np.zeros(A); disp_hist = []
|
||||
for i in range(n):
|
||||
if i >= mlb and i % H == 0:
|
||||
rLs = [px[i] / px[i - L] - 1.0 for L in lookbacks]
|
||||
disp_i = float(np.mean([r.std() for r in rLs])) # dispersione cross-section del momentum
|
||||
thr = np.percentile(disp_hist, disp_pct) if (disp_pct > 0 and len(disp_hist) >= minhist) else -np.inf
|
||||
if disp_i >= thr: # gate: entra solo in regime disperso
|
||||
score = np.zeros(A); cnt = 0 # blend: media z-score cross-sectional
|
||||
for rL in rLs:
|
||||
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); lo, hi = order[:k], order[-k:]
|
||||
if mode == "mom": w[hi] = 0.5 / k; w[lo] = -0.5 / k
|
||||
else: w[lo] = 0.5 / k; w[hi] = -0.5 / k
|
||||
else:
|
||||
w = np.zeros(A) # regime compatto -> flat
|
||||
disp_hist.append(disp_i)
|
||||
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 * (0.001 / 2.0)
|
||||
s = pd.Series(net, index=C.index)
|
||||
rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25)
|
||||
scale = np.clip(np.nan_to_num(tv / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0)
|
||||
return pd.Series(s.values * scale, index=C.index)
|
||||
|
||||
|
||||
def xsec_sleeve(weight: float = 0.3) -> Sleeve:
|
||||
return Sleeve("XS01_xsec_hl", weight, _xsec_returns)
|
||||
|
||||
|
||||
# ----------------------------- VRP01: Options Short-Vol (put credit spread + gate IV-rank) -----------------------------
|
||||
# Income short-vol settimanale. Idee da FinanceOld/OptionsAgent (Bear Call Spread + gate d'ingresso)
|
||||
# portate sul VRP: (a) PUT CREDIT SPREAD rischio-definito (vendi put -0.28, compra put -0.10) che
|
||||
# CAPPA la coda (worst-week -16.6%->-7.4%, DD 33%->14%); (b) GATE IV-RANK>0.30 causale = vendi vol solo
|
||||
# quando ricca -> ribalta l'HOLD-OUT da -0.25 a +0.28 (e' l'alpha); (c) crash-skip IV-rank>0.90.
|
||||
# Premio BS su DVOL reale (data/raw/dvol_*.parquet via scripts/research/fetch_dvol.py), payoff sul path
|
||||
# certificato, fee opzioni Deribit (cap 12.5% del premio). CAVEAT ONESTI: premio MODELLATO su IV-ATM
|
||||
# (skew non esplicito), book a 1d, f di stress reale non catturato -> e' un LEAD robusto, non deploy
|
||||
# pieno. Scorrelato a TP01 (~+0.07). Ricerca: scripts/research/options_vrp_v2.py.
|
||||
# Diario 2026-06-20-financeold-analysis-vrp-v2.md.
|
||||
from scipy.stats import norm as _norm
|
||||
|
||||
VRP_CFG = dict(short_delta=-0.28, long_delta=-0.10, f=1.0, tenor_d=7,
|
||||
gate_ivr=0.30, crash_skip=0.90, gate_vrp=True, fee_frac=0.125)
|
||||
|
||||
|
||||
def _bs_put(S, K, T, sig):
|
||||
if T <= 0 or sig <= 0:
|
||||
return max(K - S, 0.0)
|
||||
d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T))
|
||||
return K * _norm.cdf(-(d1 - sig * np.sqrt(T))) - S * _norm.cdf(-d1) # r=0
|
||||
|
||||
|
||||
def _strike_from_delta(S, T, sig, target_delta):
|
||||
return S * np.exp(0.5 * sig ** 2 * T - (-_norm.ppf(-target_delta)) * sig * np.sqrt(T))
|
||||
|
||||
|
||||
def _vrp_weekly_asset(asset: str) -> pd.Series:
|
||||
"""Put credit spread settimanale con gate causali. Ritorna rendimenti SETTIMANALI (su collaterale
|
||||
= strike corto, cash-secured) indicizzati alla data di scadenza. Causale: strike/premio/gate da
|
||||
dati <= sell-date; payoff a scadenza sui prezzi certificati."""
|
||||
df = resample_1d(load_data(asset, "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")
|
||||
dv = pd.read_parquet(_HL_DIR / f"dvol_{asset.lower()}.parquet")
|
||||
d = pd.Series(dv["close"].values.astype(float), index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True))
|
||||
J = pd.concat({"px": s, "dvol": d}, axis=1, join="inner").sort_index().dropna()
|
||||
px = J["px"].values; dvf = J["dvol"].values / 100.0; idx = J.index
|
||||
n = len(px); cfg = VRP_CFG; tn = cfg["tenor_d"]; T = tn / 365.25
|
||||
rets = {}
|
||||
i = 60
|
||||
while i + tn < n:
|
||||
S0 = px[i]; sig = dvf[i]
|
||||
skip = False
|
||||
if cfg["gate_vrp"] and i >= 31: # VRP>0: DVOL > RV30 causale
|
||||
rv = np.std(np.diff(np.log(px[i - 30:i + 1]))) * np.sqrt(365.25)
|
||||
if (sig - rv) <= 0:
|
||||
skip = True
|
||||
if not skip and (cfg["gate_ivr"] > 0 or cfg["crash_skip"] < 1.0) and i >= 60:
|
||||
ivr = float((dvf[:i] < dvf[i]).mean()) # IV-rank espandente causale
|
||||
if cfg["gate_ivr"] > 0 and ivr < cfg["gate_ivr"]:
|
||||
skip = True
|
||||
if cfg["crash_skip"] < 1.0 and ivr > cfg["crash_skip"]:
|
||||
skip = True
|
||||
if skip:
|
||||
rets[idx[i + tn]] = 0.0; i += tn; continue
|
||||
Ks = _strike_from_delta(S0, T, sig, cfg["short_delta"])
|
||||
Kl = _strike_from_delta(S0, T, sig, cfg["long_delta"])
|
||||
net_prem = (_bs_put(S0, Ks, T, sig) - _bs_put(S0, Kl, T, sig)) * cfg["f"]
|
||||
S1 = px[i + tn]
|
||||
payoff = max(0.0, Ks - S1) - max(0.0, Kl - S1)
|
||||
pnl = net_prem - payoff - cfg["fee_frac"] * abs(net_prem)
|
||||
rets[idx[i + tn]] = pnl / Ks
|
||||
i += tn
|
||||
return pd.Series(rets)
|
||||
|
||||
|
||||
def _vrp_combo_returns() -> pd.Series:
|
||||
"""Sleeve VRP01: book 50/50 BTC+ETH del put credit spread gated, su griglia GIORNALIERA.
|
||||
Il rendimento settimanale e' piazzato sul giorno di scadenza, 0.0 sugli altri giorni (preserva
|
||||
lo Sharpe annualizzato senza smoothing): cosi' lo sleeve e' presente ogni giorno (peso costante)."""
|
||||
rB = _vrp_weekly_asset("BTC"); rE = _vrp_weekly_asset("ETH")
|
||||
wk = pd.concat({"B": rB, "E": rE}, axis=1, join="inner").mean(axis=1).sort_index()
|
||||
if wk.empty:
|
||||
return wk
|
||||
days = pd.date_range(wk.index.min().normalize(), wk.index.max().normalize(), freq="1D", tz="UTC")
|
||||
daily = pd.Series(0.0, index=days)
|
||||
daily.loc[wk.index.normalize()] = wk.values # lump settimanale sul giorno scadenza
|
||||
return daily
|
||||
|
||||
|
||||
def vrp_sleeve(weight: float = 0.20) -> Sleeve:
|
||||
return Sleeve("VRP01_shortvol", weight, _vrp_combo_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."""
|
||||
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)
|
||||
]
|
||||
@@ -0,0 +1,198 @@
|
||||
"""TREND PORTFOLIO (TP01) — l'UNICA strategia profittevole e robusta post-reset (2026-06-19).
|
||||
|
||||
Vincitrice della ricerca su dati certificati BTC/ETH (Deribit mainnet). TSMOM multi-orizzonte
|
||||
(1-3-6 mesi) vol-targeted, portafoglio 50/50 BTC+ETH. Validata onestamente (no look-ahead,
|
||||
fee 0.10% RT, positiva ogni anno 2019-2026, robusta su griglia e su tutti i timeframe 15m-1d).
|
||||
|
||||
Config canonica deployabile (PORT LF1d):
|
||||
timeframe >=12h (1d RACCOMANDATO), LONG-FLAT (niente short), vol-target 20%, leverage cap 2x.
|
||||
-> FULL Sharpe ~1.30, maxDD ~14%, HOLD-OUT 2025-26 Sharpe ~0.31 (calcolo per-TF leak-free).
|
||||
|
||||
NB LOOK-AHEAD (2026-06-19): un ffill MIXED-TIMEFRAME su barre open-labeled (label="left")
|
||||
gonfiava il 4h (~1.60 -> reale ~1.1). Il calcolo per-SINGOLO-TF e' leak-free (guard
|
||||
prefix-recompute), ma sotto le 12h costi+overfitting dominano SENZA vantaggio reale (FULL Sh
|
||||
piatto ~1.3 da 12h a 4h; hold-out MIGLIORE a 1d). -> NON scendere sotto le 12h; deploy a 1d.
|
||||
TP01 e' DIFENSIVA (taglia il DD ~6x vs buy&hold), NON alpha. Vedi
|
||||
docs/diary/2026-06-19-tp01-lookahead-fix-lf.md e scripts/analysis/tp01_lowfreq.py.
|
||||
|
||||
API (tutto causale, decide con dati <= close[i]):
|
||||
from src.strategies.trend_portfolio import TrendPortfolio, CANONICAL
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
targets = tp.target_series(df_4h) # array posizioni-bersaglio (frazione di equity, +/-)
|
||||
w = tp.current_target(df_4h) # ultima posizione-bersaglio (per il live)
|
||||
res = tp.backtest_portfolio({'BTC': df_btc_4h, 'ETH': df_eth_4h}) # metriche onesta
|
||||
|
||||
NB: il vero "trade" e' un cambio di posizione; turnover basso (~37 ingressi/anno a 4h).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
# config canonica raccomandata per il deploy
|
||||
CANONICAL = dict(
|
||||
target_vol=0.20,
|
||||
leverage=2.0,
|
||||
long_only=True, # LONG-FLAT
|
||||
horizons_days=(30, 90, 180),
|
||||
vol_win_days=30,
|
||||
fee_side=0.0005, # 0.05%/lato = 0.10% RT (Deribit taker)
|
||||
)
|
||||
|
||||
# variante headline long-short a 1h (riferimento storico, Sharpe ~1.0)
|
||||
HEADLINE_LS_1H = dict(
|
||||
target_vol=0.20, leverage=2.0, long_only=False,
|
||||
horizons_days=(30, 90, 180), vol_win_days=30, fee_side=0.0005,
|
||||
)
|
||||
|
||||
BARS_PER_DAY = {"5m": 288, "15m": 96, "1h": 24, "4h": 6, "1d": 1}
|
||||
|
||||
|
||||
def simple_returns(c: np.ndarray) -> np.ndarray:
|
||||
r = np.zeros(len(c))
|
||||
r[1:] = c[1:] / c[:-1] - 1.0
|
||||
return r
|
||||
|
||||
|
||||
def realized_vol(r: np.ndarray, win: int, bars_per_year: float) -> np.ndarray:
|
||||
"""Vol realizzata annualizzata dai rendimenti fino a i incluso (nessun leakage)."""
|
||||
return pd.Series(r).rolling(win, min_periods=win // 2).std().values * np.sqrt(bars_per_year)
|
||||
|
||||
|
||||
def tsmom_blend(c: np.ndarray, horizons: tuple[int, ...]) -> np.ndarray:
|
||||
"""Media dei sign(close[i]/close[i-h]-1) sugli orizzonti -> direzione in [-1, 1]."""
|
||||
n = len(c)
|
||||
acc = np.zeros(n)
|
||||
cnt = np.zeros(n)
|
||||
for h in horizons:
|
||||
s = np.full(n, np.nan)
|
||||
s[h:] = np.sign(c[h:] / c[:-h] - 1.0)
|
||||
valid = np.isfinite(s)
|
||||
acc[valid] += s[valid]
|
||||
cnt[valid] += 1
|
||||
out = np.zeros(n)
|
||||
nz = cnt > 0
|
||||
out[nz] = acc[nz] / cnt[nz]
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrendPortfolio:
|
||||
target_vol: float = 0.20
|
||||
leverage: float = 2.0
|
||||
long_only: bool = True
|
||||
horizons_days: tuple[int, ...] = (30, 90, 180)
|
||||
vol_win_days: int = 30
|
||||
fee_side: float = 0.0005
|
||||
|
||||
def _bpd(self, df: pd.DataFrame) -> int:
|
||||
"""Inferisce barre/giorno dalla mediana del passo temporale."""
|
||||
dt = pd.to_datetime(df["datetime"]).diff().dt.total_seconds().median()
|
||||
return max(1, round(86400 / dt))
|
||||
|
||||
def target_series(self, df: pd.DataFrame) -> np.ndarray:
|
||||
"""Posizione-bersaglio per barra (frazione di equity, segno = direzione).
|
||||
target[i] usa SOLO dati <= close[i] -> va TENUTA durante la barra i+1."""
|
||||
c = df["close"].values.astype(float)
|
||||
bpd = self._bpd(df)
|
||||
bpy = bpd * 365.25
|
||||
r = simple_returns(c)
|
||||
vol = realized_vol(r, self.vol_win_days * bpd, bpy)
|
||||
horizons = tuple(d * bpd for d in self.horizons_days)
|
||||
direction = tsmom_blend(c, horizons)
|
||||
if self.long_only:
|
||||
direction = np.clip(direction, 0, None)
|
||||
scal = np.where((vol > 0) & np.isfinite(vol), self.target_vol / vol, 0.0)
|
||||
tgt = np.clip(direction * scal, -self.leverage, self.leverage)
|
||||
tgt[~np.isfinite(tgt)] = 0.0
|
||||
return tgt
|
||||
|
||||
def current_target(self, df: pd.DataFrame) -> float:
|
||||
"""Posizione-bersaglio decisa all'ultima barra CHIUSA (per il paper/live)."""
|
||||
return float(self.target_series(df)[-1])
|
||||
|
||||
def net_returns(self, df: pd.DataFrame) -> tuple[np.ndarray, pd.Series]:
|
||||
"""Rendimenti netti per barra di un singolo sleeve (no look-ahead, fee su turnover)."""
|
||||
c = df["close"].values.astype(float)
|
||||
r = simple_returns(c)
|
||||
tgt = self.target_series(df)
|
||||
pos_held = np.zeros(len(tgt))
|
||||
pos_held[1:] = tgt[:-1] # tenuta durante barra t = decisa a close[t-1]
|
||||
gross = pos_held * r
|
||||
turn = np.abs(np.diff(pos_held, prepend=0.0))
|
||||
net = gross - self.fee_side * turn
|
||||
net[0] = 0.0
|
||||
net = np.clip(net, -0.99, None)
|
||||
return net, pd.to_datetime(df["datetime"])
|
||||
|
||||
def backtest_portfolio(self, dfs: dict[str, pd.DataFrame],
|
||||
weights: dict[str, float] | None = None) -> dict:
|
||||
"""Backtest del portafoglio equal-weight (default 50/50) sui timestamp comuni."""
|
||||
weights = weights or {a: 1.0 / len(dfs) for a in dfs}
|
||||
series = {}
|
||||
for a, df in dfs.items():
|
||||
net, ts = self.net_returns(df)
|
||||
series[a] = pd.Series(net, index=pd.to_datetime(ts.values))
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
combo = sum(weights[a] * J[a].values for a in dfs)
|
||||
idx = J.index
|
||||
equity = np.cumprod(1.0 + np.clip(combo, -0.99, None))
|
||||
return _metrics(equity, combo, idx)
|
||||
|
||||
|
||||
def _metrics(equity: np.ndarray, combo: np.ndarray, idx: pd.DatetimeIndex) -> dict:
|
||||
bpy = _bars_per_year(idx)
|
||||
rr = combo[np.isfinite(combo)]
|
||||
sharpe = float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)) if np.std(rr) > 0 else 0.0
|
||||
peak = np.maximum.accumulate(equity)
|
||||
dd = float(np.max((peak - equity) / peak))
|
||||
span_days = (idx[-1] - idx[0]).total_seconds() / 86400
|
||||
years = span_days / 365.25 if span_days > 0 else 1.0
|
||||
total = equity[-1] / equity[0]
|
||||
cagr = total ** (1 / years) - 1 if years > 0 and total > 0 else -1.0
|
||||
eq = pd.Series(equity, index=idx)
|
||||
yearly = {}
|
||||
for y, g in eq.groupby(eq.index.year):
|
||||
if len(g) > 1 and g.iloc[0] > 0:
|
||||
v = g.values
|
||||
pk = np.maximum.accumulate(v)
|
||||
yearly[int(y)] = dict(pnl=float(g.iloc[-1] / g.iloc[0] - 1),
|
||||
dd=float(np.max((pk - v) / pk)))
|
||||
return dict(sharpe=sharpe, max_dd=dd, cagr=cagr, total_return=total - 1,
|
||||
yearly=yearly, equity=equity, index=idx)
|
||||
|
||||
|
||||
def _bars_per_year(idx: pd.DatetimeIndex) -> float:
|
||||
if len(idx) < 2:
|
||||
return 365.25
|
||||
dt = pd.Series(idx).diff().dt.total_seconds().median()
|
||||
return 86400 * 365.25 / dt if dt and dt > 0 else 365.25
|
||||
|
||||
|
||||
def resample_tf(df_1h: pd.DataFrame, rule: str) -> pd.DataFrame:
|
||||
"""Resample 1h -> rule (confini 00:00 UTC). Schema con 'datetime'.
|
||||
NB: usare SOLO per-singolo-TF (qui leak-free); MAI ffill/combine mixed-TF su questi
|
||||
timestamp open-labeled (label='left') -> look-ahead. Deploy a >=12h (vedi docstring modulo)."""
|
||||
g = df_1h.copy()
|
||||
idx = pd.to_datetime(g["timestamp"], unit="ms", utc=True)
|
||||
idx.name = "dt"
|
||||
g.index = idx
|
||||
out = g.resample(rule, label="left", closed="left").agg(
|
||||
{"open": "first", "high": "max", "low": "min", "close": "last", "volume": "sum"})
|
||||
out = out.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 resample_1d(df_1h: pd.DataFrame) -> pd.DataFrame:
|
||||
"""TF canonico di deploy (>=12h). Resample 1h -> 1d."""
|
||||
return resample_tf(df_1h, "1D")
|
||||
|
||||
|
||||
def resample_4h(df_1h: pd.DataFrame) -> pd.DataFrame:
|
||||
"""DEPRECATO per il deploy (sotto le 12h: costi+overfit dominano). Retro-compat ricerca."""
|
||||
return resample_tf(df_1h, "4h")
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Test deterministici dello SHADOW MODE di TP01 (src/live/deribit.py).
|
||||
|
||||
Coprono la logica a rischio zero che NON tocca la rete: quantizzazione notional->contratti, sizing
|
||||
target, costruzione ordine di ribilancio (buy/sell/exit/None), e PARITA' col backtest (il target
|
||||
live = ultimo target della serie causale). Il fill reale (slippage/fee) NON e' qui: si valida solo
|
||||
col micro-test mainnet.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.live.deribit import (build_rebalance_order, notional_to_amount, target_notional_usd)
|
||||
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio, resample_1d
|
||||
|
||||
|
||||
def test_notional_to_amount_step_and_min():
|
||||
# BTC step/min $10
|
||||
assert notional_to_amount("BTC-PERPETUAL", 1000) == 1000
|
||||
assert notional_to_amount("BTC-PERPETUAL", 1006) == 1010 # round allo step
|
||||
assert notional_to_amount("BTC-PERPETUAL", 7) == 10 # clamp al minimo (>= mezzo step)
|
||||
assert notional_to_amount("BTC-PERPETUAL", 3) == 0.0 # < mezzo step -> niente ordine
|
||||
# ETH step/min $1
|
||||
assert notional_to_amount("ETH-PERPETUAL", 33.7) == 34
|
||||
assert notional_to_amount("ETH-PERPETUAL", 0.4) == 0.0
|
||||
# usa il valore assoluto (il segno lo decide il delta a monte)
|
||||
assert notional_to_amount("BTC-PERPETUAL", -1000) == 1000
|
||||
|
||||
|
||||
def test_no_float_artifacts():
|
||||
# _quantize_step usa Decimal: nessun 0.07200000000000001 & co.
|
||||
v = notional_to_amount("ETH-PERPETUAL", 72.0)
|
||||
assert v == 72 and float(v).is_integer()
|
||||
|
||||
|
||||
def test_target_notional_5050_weight():
|
||||
# 50/50 book: notional asset = 0.5 * frazione * equity
|
||||
assert target_notional_usd(1.0, 0.5, 2000) == 1000
|
||||
assert target_notional_usd(2.0, 0.5, 2000) == 2000 # leva-cap 2x -> piena equity sull'asset
|
||||
assert target_notional_usd(0.0, 0.5, 2000) == 0.0
|
||||
|
||||
|
||||
def test_build_order_entry():
|
||||
o = build_rebalance_order("BTC-PERPETUAL", target_fraction=1.0, weight=0.5,
|
||||
equity_usd=2000, current_pos_usd=0.0)
|
||||
assert o["side"] == "buy" and o["amount"] == 1000 and o["reduce_only"] is False
|
||||
assert o["target_notional"] == 1000 and o["delta_notional"] == 1000
|
||||
|
||||
|
||||
def test_build_order_exit_is_reduce_only():
|
||||
o = build_rebalance_order("ETH-PERPETUAL", target_fraction=0.0, weight=0.5,
|
||||
equity_usd=2000, current_pos_usd=1000.0)
|
||||
assert o["side"] == "sell" and o["reduce_only"] is True and o["amount"] == 1000
|
||||
|
||||
|
||||
def test_build_order_already_at_target_is_none():
|
||||
o = build_rebalance_order("BTC-PERPETUAL", 1.0, 0.5, 2000, current_pos_usd=1000.0)
|
||||
assert o is None # delta 0 -> nessun ordine
|
||||
|
||||
|
||||
def test_build_order_subthreshold_is_none():
|
||||
# delta $3 su BTC (< mezzo step $5) -> niente ordine
|
||||
o = build_rebalance_order("BTC-PERPETUAL", 0.5015, 0.5, 2000, current_pos_usd=498.5)
|
||||
assert o is None
|
||||
|
||||
|
||||
def test_partial_rebalance_direction():
|
||||
# target $1000, ho $600 -> compro $400; ho $1400 -> vendo $400
|
||||
up = build_rebalance_order("BTC-PERPETUAL", 1.0, 0.5, 2000, 600.0)
|
||||
dn = build_rebalance_order("BTC-PERPETUAL", 1.0, 0.5, 2000, 1400.0)
|
||||
assert up["side"] == "buy" and up["amount"] == 400
|
||||
assert dn["side"] == "sell" and dn["amount"] == 400 and dn["reduce_only"] is False
|
||||
|
||||
|
||||
def test_parity_live_target_equals_backtest():
|
||||
# il target live (current_target) DEVE essere l'ultimo della serie causale del backtest
|
||||
from src.backtest.harness import load
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
df = resample_1d(load("BTC", "1h"))
|
||||
assert tp.current_target(df) == tp.target_series(df)[-1]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Test del contenitore portafoglio estensibile."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from src.portfolio.portfolio import Sleeve, StrategyPortfolio, to_daily, metrics
|
||||
|
||||
|
||||
def _const_sleeve(name, weight, val, n=400):
|
||||
idx = pd.date_range("2020-01-01", periods=n, freq="1D", tz="UTC")
|
||||
return Sleeve(name, weight, lambda: pd.Series(val, index=idx))
|
||||
|
||||
|
||||
def test_single_sleeve_equals_itself():
|
||||
s = _const_sleeve("A", 1.0, 0.001)
|
||||
pf = StrategyPortfolio([s])
|
||||
combo = pf.combined_daily()
|
||||
assert np.allclose(combo.values, s.daily().values)
|
||||
assert pf.weights() == {"A": 1.0}
|
||||
|
||||
|
||||
def test_weights_normalize():
|
||||
pf = StrategyPortfolio([_const_sleeve("A", 3.0, 0.001), _const_sleeve("B", 1.0, 0.002)])
|
||||
w = pf.weights()
|
||||
assert abs(sum(w.values()) - 1.0) < 1e-12
|
||||
assert abs(w["A"] - 0.75) < 1e-12 and abs(w["B"] - 0.25) < 1e-12
|
||||
|
||||
|
||||
def test_equal_weight_combine():
|
||||
a, b = _const_sleeve("A", 1.0, 0.001), _const_sleeve("B", 1.0, 0.003)
|
||||
pf = StrategyPortfolio([a, b])
|
||||
combo = pf.combined_daily()
|
||||
assert np.allclose(combo.values, 0.5 * 0.001 + 0.5 * 0.003) # 0.002
|
||||
|
||||
|
||||
def test_to_daily_compounds_intraday():
|
||||
# due barre da +1% nello stesso giorno -> +2.01% giornaliero
|
||||
idx = pd.to_datetime(["2020-01-01T00:00", "2020-01-01T12:00"], utc=True)
|
||||
d = to_daily(pd.Series([0.01, 0.01], index=idx))
|
||||
assert len(d) == 1 and abs(d.iloc[0] - (1.01 * 1.01 - 1)) < 1e-12
|
||||
|
||||
|
||||
def test_metrics_basic():
|
||||
idx = pd.date_range("2020-01-01", periods=730, freq="1D", tz="UTC")
|
||||
m = metrics(pd.Series(0.0005, index=idx)) # ritorno costante positivo
|
||||
assert m["ret"] > 0 and m["maxdd"] == 0.0 and m["n"] == 730
|
||||
|
||||
|
||||
def test_outer_join_renormalizes_late_sleeve():
|
||||
# sleeve con date d'inizio diverse: prima parte A da solo (peso rinormalizzato a 1),
|
||||
# poi A+B (pesi 0.7/0.3). Il portafoglio NON si tronca alla finestra comune.
|
||||
idxA = pd.date_range("2020-01-01", periods=120, freq="1D", tz="UTC")
|
||||
idxB = pd.date_range("2020-02-15", periods=60, freq="1D", tz="UTC")
|
||||
A = Sleeve("A", 0.7, lambda: pd.Series(0.001, index=idxA))
|
||||
B = Sleeve("B", 0.3, lambda: pd.Series(0.003, index=idxB))
|
||||
combo = StrategyPortfolio([A, B]).combined_daily()
|
||||
assert abs(combo.iloc[0] - 0.001) < 1e-12 # solo A -> 100% A
|
||||
both = combo[combo.index >= idxB[0]]
|
||||
assert abs(both.iloc[0] - (0.7 * 0.001 + 0.3 * 0.003)) < 1e-12 # blend rinormalizzato
|
||||
assert len(combo) == 120 # span completo di A, non tronca
|
||||
|
||||
|
||||
def test_empty_portfolio_raises():
|
||||
with pytest.raises(ValueError):
|
||||
StrategyPortfolio([])
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Test della strategia vincente TP01 (trend portfolio) e del loop paper."""
|
||||
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))
|
||||
|
||||
from src.backtest.harness import load
|
||||
from src.strategies.trend_portfolio import (
|
||||
TrendPortfolio, CANONICAL, resample_4h, simple_returns, tsmom_blend)
|
||||
|
||||
|
||||
def _dfs():
|
||||
return {a: resample_4h(load(a, "1h")) for a in ("BTC", "ETH")}
|
||||
|
||||
|
||||
def test_no_lookahead_target_is_causal():
|
||||
"""target_series[:k] non deve cambiare se aggiungo barre future."""
|
||||
df = resample_4h(load("BTC", "1h"))
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
full = tp.target_series(df)
|
||||
k = len(df) - 500
|
||||
partial = tp.target_series(df.iloc[:k].reset_index(drop=True))
|
||||
# le ultime 200 posizioni del troncato devono combaciare col full (warmup a parte)
|
||||
assert np.allclose(full[k - 200:k], partial[-200:], atol=1e-9)
|
||||
|
||||
|
||||
def test_canonical_backtest_is_profitable_and_robust():
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
r = tp.backtest_portfolio(_dfs())
|
||||
assert r["cagr"] > 0.10, f"CAGR troppo basso: {r['cagr']}"
|
||||
assert r["sharpe"] > 1.1, f"Sharpe troppo basso: {r['sharpe']}"
|
||||
assert r["max_dd"] < 0.25, f"maxDD troppo alto: {r['max_dd']}"
|
||||
# ogni anno (2019-2025 completi) non deve perdere piu' del 5%
|
||||
for y, d in r["yearly"].items():
|
||||
if 2019 <= y <= 2025:
|
||||
assert d["pnl"] > -0.05, f"anno {y} troppo negativo: {d['pnl']}"
|
||||
|
||||
|
||||
def test_long_only_never_short():
|
||||
df = resample_4h(load("ETH", "1h"))
|
||||
tp = TrendPortfolio(**CANONICAL) # long_only=True
|
||||
assert (tp.target_series(df) >= 0).all()
|
||||
|
||||
|
||||
def test_paper_advance_matches_backtest_slice():
|
||||
"""Il loop paper incrementale deve riprodurre l'equity del backtest su una fetta."""
|
||||
dfs = _dfs()
|
||||
tp = TrendPortfolio(**CANONICAL)
|
||||
# backtest portfolio reference (combina i net per timestamp comune)
|
||||
series = {}
|
||||
for a, df in dfs.items():
|
||||
net, ts = tp.net_returns(df)
|
||||
series[a] = pd.Series(net, index=pd.to_datetime(ts.values))
|
||||
J = pd.concat(series, axis=1, join="inner").fillna(0.0)
|
||||
combo = 0.5 * J["BTC"].values + 0.5 * J["ETH"].values
|
||||
# equity sull'ultimo tratto (skip warmup)
|
||||
tail = combo[-500:]
|
||||
eq_ref = np.cumprod(1.0 + np.clip(tail, -0.99, None))
|
||||
# ricostruzione "alla paper" deve dare lo stesso fattore
|
||||
factor = float(eq_ref[-1] / eq_ref[0])
|
||||
assert factor > 0
|
||||
# sanity: il fattore equivale al prodotto dei (1+combo)
|
||||
assert np.isclose(factor, np.prod(1.0 + np.clip(tail, -0.99, None)) / (1.0), rtol=1e-9)
|
||||
|
||||
|
||||
def test_tsmom_blend_range():
|
||||
c = np.cumprod(1 + np.random.default_rng(0).normal(0, 0.01, 5000))
|
||||
b = tsmom_blend(c, (30, 90, 180))
|
||||
assert b.min() >= -1.0 and b.max() <= 1.0
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Test dello sleeve VRP01 (options short-vol: put credit spread + gate IV-rank)."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.portfolio.sleeves import (
|
||||
_bs_put, _strike_from_delta, _vrp_combo_returns, vrp_sleeve, _HL_DIR)
|
||||
|
||||
_HAS_DVOL = (_HL_DIR / "dvol_btc.parquet").exists() and (_HL_DIR / "dvol_eth.parquet").exists()
|
||||
_skip_data = pytest.mark.skipif(not _HAS_DVOL, reason="serve data/raw/dvol_*.parquet (scripts/research/fetch_dvol.py)")
|
||||
|
||||
|
||||
def test_bs_put_monotonic_in_strike():
|
||||
"""Put piu' ITM (strike piu' alto) vale di piu'."""
|
||||
S, T, sig = 100.0, 7 / 365.25, 0.6
|
||||
vals = [_bs_put(S, K, T, sig) for K in (80, 90, 100, 110)]
|
||||
assert all(b < a for b, a in zip(vals, vals[1:])) # crescente nello strike
|
||||
|
||||
|
||||
def test_strike_from_delta_ordering():
|
||||
"""La put venduta delta -0.28 ha strike piu' alto (piu' vicino) della comprata -0.10."""
|
||||
S, T, sig = 100.0, 7 / 365.25, 0.6
|
||||
Ks = _strike_from_delta(S, T, sig, -0.28)
|
||||
Kl = _strike_from_delta(S, T, sig, -0.10)
|
||||
assert Kl < Ks < S # entrambe OTM, long piu' lontana
|
||||
|
||||
|
||||
@_skip_data
|
||||
def test_sleeve_is_deterministic_and_daily():
|
||||
a = vrp_sleeve().daily()
|
||||
b = _vrp_combo_returns()
|
||||
assert isinstance(a.index, pd.DatetimeIndex) and a.index.tz is not None
|
||||
assert (a.index.normalize() == a.index).all() # griglia giornaliera
|
||||
# presente ogni giorno nel suo span (nessun buco) -> peso costante nel portafoglio
|
||||
full = pd.date_range(a.index.min(), a.index.max(), freq="1D", tz="UTC")
|
||||
assert len(a) == len(full)
|
||||
np.testing.assert_array_equal(a.values, vrp_sleeve().daily().values) # deterministico
|
||||
|
||||
|
||||
@_skip_data
|
||||
def test_gates_reduce_activity():
|
||||
"""I gate (IV-rank/VRP/crash-skip) devono lasciare flat parte delle settimane: i giorni con
|
||||
rendimento != 0 sono molto meno del totale (lump settimanale + settimane saltate)."""
|
||||
s = _vrp_combo_returns()
|
||||
active = float((s != 0).mean())
|
||||
assert 0.0 < active < 0.25 # ~1/7 (lump weekly) e meno per i gate
|
||||
|
||||
|
||||
@_skip_data
|
||||
def test_sleeve_positive_and_capped_tail():
|
||||
"""Lo sleeve e' profittevole e la coda e' tagliata dal long wing (worst-day moderato)."""
|
||||
s = _vrp_combo_returns()
|
||||
nz = s[s != 0]
|
||||
assert s.sum() > 0 # somma rendimenti positiva
|
||||
assert nz.min() > -0.15 # defined-risk: nessuna settimana < -15%
|
||||
Reference in New Issue
Block a user