research(ortho): caccia all'ortogonale a TP01 — relative-value BTC/ETH reale ma NON deployabile (hedge mono-regime)
18 agenti su book market-neutral a 2 gambe BTC/ETH (eseguibili a $600, a differenza di XS01), giudicati sul MARGINALE vs TP01 (altlib.marginal_vs_tp01), non sullo Sharpe assoluto. Lab: ortholib.py (eval_book leak-free a 2 gambe + causalità + eseguibilità@600), ortho_score.py (giudice), meta_ortho.py (corr mutua + persistenza multi-cut), sleeve_rv.py (curated, SELECTION- BIASED, non deployare). Esito: 17/18 "ADDS" -> gonfiato dall'hold-out corto fisso-2025 (finestra ETH-bleed dove TP01 è debole). Diagnosi orchestratore: collassano a 8 bet (corr 0.43); persistenza multi-cut e selezione walk-forward smascherano i 2025-only (kalman/xs2). Scettico indipendente: basket selection-free ha uplift pre-2025 +0.027 = 49° percentile di asset-rumore corr-zero (matematica di diversificazione, non segnale); corr(Sharpe-TP01, uplift) -0.87 (è un HEDGE dei drawdown di TP01); muore a 0.30% RT. Verdetto: NIENTE in live. Resta solo TP01. Lezione: lo scorer marginale va indurito (multi-cut + null-asset-rumore + distinguere hedge da alpha). Diario 2026-06-21-ortho-tp01-relative-value.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
# 2026-06-21 — Caccia all'ORTOGONALE a TP01: relative-value BTC/ETH (eseguibile a $600)
|
||||
|
||||
## Perché (richiesta utente: "cerca ortogonale a TP01")
|
||||
|
||||
La flotta cieca (stesso giorno) ha confermato: niente di NUOVO in direzionale BTC/ETH — tutto è
|
||||
trend-beta di TP01 (soffitto ~1.3). L'unica via a un nuovo slot LIVE è un meccanismo **ortogonale**
|
||||
(bassa correlazione, alpha residua). Il più promettente **eseguibile al capitale reale ~$600** è un
|
||||
**book RELATIVE-VALUE a 2 gambe BTC/ETH** (long una / short l'altra), grosso modo market-neutral →
|
||||
correlazione naturale bassa col trend, e a 2 gambe è eseguibile (a differenza del book a 19 gambe di
|
||||
XS01 che serve ~$20k).
|
||||
|
||||
## Setup — ortho-lab + giudice MARGINALE (non Sharpe assoluto)
|
||||
|
||||
`scripts/research/ortho/ortholib.py`: BTC/ETH 1d allineati su date comuni; `eval_book(book_fn)` con
|
||||
`book(btc,eth)->(w_btc,w_eth)`, **shift di entrambe le gambe** (no leak), fee su entrambe, serie netta
|
||||
**giornaliera**; guardia di causalità online; check **eseguibilità a $600** (max gamba ≤ 0.5 = cap
|
||||
$300/asset). Il giudice è `altlib.marginal_vs_tp01`: **corr a TP01, uplift OOS del blend, alpha
|
||||
residua, robust_oos** (clean-year + jackknife drop-month). Verdetto = ADDS, **non** Sharpe assoluto.
|
||||
`ortho_score.py` (giudice), `meta_ortho.py` (corr mutua + persistenza multi-cut), `sleeve_rv.py`.
|
||||
|
||||
Sanity: ratio-momentum → ADDS (corr 0.05); ratio-mean-reversion → DILUTES. L'harness discrimina.
|
||||
|
||||
## Flotta — 18 agenti relative-value (~40 min)
|
||||
|
||||
18 ipotesi distinte: ratio-momentum multi-orizzonte, XS a 2 asset, beta-neutral residuo, Donchian
|
||||
sul ratio, EMA-cross, accel, carry lento, Kalman-spread, gate-correlazione, gate-vol, inverse-vol,
|
||||
rebalance-harvest, lead-lag, **DVOL-spread**, **VRP relativo**, dispersione, ensemble.
|
||||
|
||||
**Esito grezzo: 18 riportati, 17 "ADDS / earns_slot".** → **bandiera rossa**: non esistono 17 alpha.
|
||||
Gli agenti stessi l'hanno annotato ("hold-out corto ~537g", "uplift dipende dal regime ETH-bleed
|
||||
2025", "forward-monitor non full-weight").
|
||||
|
||||
## Diagnosi dell'orchestratore — il "17 slot" è gonfiato
|
||||
|
||||
1. **Una scommessa o tante?** corr mutua media **0.43** → collassano a **8 rappresentanti**
|
||||
de-correlati. Non 17, non 1.
|
||||
2. **Persistente o solo finestra 2025?** `marginal_vs_tp01` fissa l'hold-out al 2025-01-01 = proprio
|
||||
la finestra dove ETH ha perso vs BTC e TP01 è debole. Ri-misurando l'uplift a **più cut**
|
||||
(2022/23/24/25): il basket selection-free era +0.06/+0.06/+0.11/+0.38 (positivo ovunque ma
|
||||
crescente verso il 2025). Smaschera anche i **falsi** che il robust_oos fisso-2025 non vede:
|
||||
`kalman_spread` (−0.14/−0.16/−0.10 poi +0.37) e `xs2_zscore` sono **2025-only**.
|
||||
3. **Selezione walk-forward (senza hindsight):** scegliere i top-4 per uplift sul **solo passato** e
|
||||
testare in avanti → uplift **−0.07** (sel <2023) / +0.05 (<2024) / +0.43 (<2025). **Scegliere la
|
||||
variante vincente in anticipo è inaffidabile**; il mio "curated 4" è in parte hindsight.
|
||||
|
||||
## Verifica avversariale (scettico indipendente) — REFUTED
|
||||
|
||||
Sul **basket selection-free** (equal-weight di tutti i book market-neutral, NESSUN cherry-picking):
|
||||
- standalone Sharpe **0.61**, maxDD 15%, **corr a TP01 0.05** (genuinamente ortogonale).
|
||||
- **uplift full +0.078 = pre-2025 +0.027 / solo-2025+ +0.401.** Il pre-2025 **+0.027 sta al 49°
|
||||
percentile di 500 asset-rumore a corr-zero** (+0.029 per costruzione) → è **matematica di
|
||||
diversificazione, non segnale**.
|
||||
- **corr(Sharpe annuo TP01, uplift annuo basket) = −0.87**; condizionato: TP01 su → +0.014, TP01 giù
|
||||
→ +0.369. **È un hedge dei drawdown di TP01, non un premio autonomo.** Paga nel 2022 (orso) e
|
||||
2025-26 (ETH-bleed) — i due anni peggiori di TP01 — rumore altrove (2023 −0.06, 2024 −0.12).
|
||||
- Block-bootstrap P(uplift>0): full 90%, **pre-2025 66% (testa o croce)**, 2025+ 99%.
|
||||
- Fee: a **0.30% RT il pre-2025 va NEGATIVO** (−0.021); sopravvive solo il numero del regime 2025.
|
||||
- Eseguibilità OK ($264/gamba, turnover 12/yr) — non è quello il problema.
|
||||
|
||||
## Verdetto
|
||||
|
||||
**Niente di questa flotta merita uno slot LIVE.** Il meccanismo relative-value BTC/ETH è REALE e
|
||||
genuinamente ortogonale (corr ~0.05), ma è un **hedge della debolezza di TP01 travestito da alpha**:
|
||||
il suo contributo pre-2025 è indistinguibile da un asset-rumore a corr-zero (49° percentile del null)
|
||||
e muore a fee realistiche; l'unico payoff vero è una singola finestra di 537 giorni (2025-26).
|
||||
Deployarlo = deployare un backtest mono-regime. **Resta live solo TP01** (l'unica cosa che supera
|
||||
tutto questo scrutinio). Coerente con XS01 (stessa famiglia cross-sectional): diversificatore
|
||||
da monitorare, non alpha da eseguire — e la versione a 2 asset è ancora più sottile della 19-gambe.
|
||||
|
||||
### Valore metodologico (cosa resta, ed è importante)
|
||||
|
||||
- **Il marginal scorer fisso-2025 è ingannabile** (17/18 "ADDS"). Ciò che ha ucciso i falsi positivi:
|
||||
**persistenza multi-cut** + **selezione walk-forward** + **bootstrap vs null a corr-zero**. Lezione
|
||||
da cablare nello scorer: testare PIÙ cut e confrontare l'uplift col **null di un asset-rumore
|
||||
ortogonale** (un'asset scorrelato con drift positivo "aggiunge" +0.03 per pura matematica — non è
|
||||
un edge). Un basso-corr che paga solo quando il core è debole è un **hedge**, va prezzato come tale.
|
||||
- Lab riusabile: `ortholib`/`ortho_score`/`meta_ortho` (giudice marginale + persistenza). I 18 book +
|
||||
`sleeve_rv.py` (curated, **selection-biased — non deployare**) restano come riferimento.
|
||||
|
||||
File: `scripts/research/ortho/{ortholib,ortho_score,meta_ortho,sleeve_rv}.py`,
|
||||
`agents/agent_00..17_*.py`, `ortho_leaderboard.json`, skeptic `skeptic_{basket,regime,null}.py`.
|
||||
@@ -0,0 +1,76 @@
|
||||
"""agent_00_ratio_mom_blend — Multi-horizon ETH/BTC ratio-momentum, market-neutral.
|
||||
|
||||
ANGLE [family=rv, slug=ratio_mom_blend]: the 2-asset executable cousin of XS01.
|
||||
We trade the RELATIVE strength of ETH vs BTC: build the log price ratio s = log(ETH/BTC),
|
||||
measure its momentum over a BLEND of horizons (~20/60/120d), average the per-horizon
|
||||
z-scores (multi-orizzonte like TP01), squash with tanh to size, and go MARKET-NEUTRAL:
|
||||
w_eth = +g, w_btc = -g (long the stronger leg, short the weaker, gross ~2g)
|
||||
The book is then SPREAD-VOL-TARGETED: scale g so the realized vol of the ETH-BTC spread
|
||||
return hits a target, capping each leg at the live notional cap (0.5 of equity).
|
||||
|
||||
Because the book is ~beta-neutral to the BTC+ETH market (net exposure ~0), it is
|
||||
structurally uncorrelated to TP01 (a long-flat trend on the market SUM) — that is the
|
||||
whole point: residual relative-value alpha, not trend-beta.
|
||||
|
||||
CAUSAL: every value at i uses only rows 0..i (rolling means/std, no shift(-k), no global
|
||||
fit). EXECUTABLE: per-leg |w| <= 0.5. MARKET-NEUTRAL: w_eth == -w_btc by construction.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
import ortholib as ol
|
||||
|
||||
# ---- knobs (a PLATEAU point, not a lucky cell — see notes) ----------------
|
||||
HORIZONS = (20, 60, 120, 240) # momentum lookbacks (days) — multi-orizzonte blend
|
||||
ZWIN = 252 # window to z-score each horizon's momentum (causal)
|
||||
TANH_K = 1.3 # tanh slope (signal -> size)
|
||||
TARGET_SPREAD_VOL = 0.15 # annualized target vol of the ETH-BTC spread return
|
||||
VOL_WIN = 45 # realized-vol window (days) for spread-vol targeting
|
||||
LEG_CAP = 0.5 # live per-leg notional cap (fraction of equity)
|
||||
|
||||
|
||||
def _mom_z(logp: np.ndarray, h: int, zwin: int) -> np.ndarray:
|
||||
"""Causal z-scored h-day log momentum of a log-price series."""
|
||||
s = np.full(len(logp), np.nan)
|
||||
s[h:] = logp[h:] - logp[:-h] # h-day log change, known at i
|
||||
return ol.zscore(s, zwin) # standardize cross-time (causal rolling)
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
bc = btc["close"].values.astype(float)
|
||||
ec = eth["close"].values.astype(float)
|
||||
n = len(bc)
|
||||
|
||||
# relative price ratio in logs: positive momentum => ETH outperforming BTC
|
||||
logratio = np.log(ec) - np.log(bc)
|
||||
|
||||
# blended multi-horizon z-scored momentum (mean of per-horizon z-scores).
|
||||
# warnings silenced: early bars (before any horizon is populated) are all-NaN
|
||||
# columns -> nanmean warns; we map those to 0 (flat) anyway.
|
||||
zs = np.vstack([_mom_z(logratio, h, ZWIN) for h in HORIZONS])
|
||||
with np.errstate(invalid="ignore"):
|
||||
import warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", category=RuntimeWarning)
|
||||
sig = np.nanmean(zs, axis=0)
|
||||
sig = np.nan_to_num(sig, nan=0.0)
|
||||
|
||||
# squash to a directional size in [-1, 1]
|
||||
g_dir = np.tanh(TANH_K * sig)
|
||||
|
||||
# spread-vol target: scale by target/realized vol of the spread return r_eth - r_btc
|
||||
rb = ol.simple_returns(bc)
|
||||
re = ol.simple_returns(ec)
|
||||
spread_ret = re - rb
|
||||
spvol = ol.realized_vol(spread_ret, VOL_WIN, 365.25) # annualized, causal
|
||||
scal = np.where((spvol > 0) & np.isfinite(spvol), TARGET_SPREAD_VOL / spvol, 0.0)
|
||||
|
||||
g = g_dir * scal
|
||||
# per-leg cap (g is the magnitude on EACH leg; both legs share it)
|
||||
g = np.clip(g, -LEG_CAP, LEG_CAP)
|
||||
g = np.nan_to_num(g, nan=0.0)
|
||||
|
||||
w_eth = g
|
||||
w_btc = -g
|
||||
return w_btc, w_eth
|
||||
@@ -0,0 +1,100 @@
|
||||
"""agent_01_xs2_zscore — 2-asset cross-sectional z-score momentum (XS01 on BTC/ETH).
|
||||
|
||||
ANGLE [family=rv, slug=xs2_zscore]
|
||||
----------------------------------
|
||||
The XS01 cross-sectional-momentum mechanism, shrunk to the executable BTC/ETH pair:
|
||||
1. for EACH asset, compute its OWN trailing momentum (trailing return over a lookback),
|
||||
2. z-score EACH asset's own momentum across time (causal rolling z),
|
||||
3. go LONG the higher-z leg / SHORT the lower-z leg -> a market-neutral spread,
|
||||
4. vol-target the SPREAD to ~constant risk, cap each leg at the live $300/asset notional.
|
||||
|
||||
Why it should be ORTHOGONAL to TP01: the book is always the BTC-vs-ETH SPREAD (long one /
|
||||
short the other in equal notional), so its market beta is ~0. TP01 is a long-flat trend on
|
||||
the SUM of the two assets. A spread bet shares almost no variance with a trend-on-the-sum bet
|
||||
-> realised corr ~0.04 full / ~0.10 hold-out. The edge it harvests is RELATIVE momentum
|
||||
(which of BTC/ETH is currently stronger vs its own history), a different premium from the
|
||||
market's overall direction.
|
||||
|
||||
ROBUSTNESS (anti-overfit, the lessons of the 2026-06-20 sweep, in code)
|
||||
-----------------------------------------------------------------------
|
||||
A single (lookback, z-window) cell can pass robust_oos by luck. To avoid sitting on a fragile
|
||||
point we use a small DIVERSIFIED ENSEMBLE and aggregate by SIGN VOTE: each (lb, zw) member
|
||||
votes long/flat/short via sign(z_btc - z_eth); the book direction is the AVERAGE of those
|
||||
votes (a graded conviction in [-1, 1]). The sign-vote aggregation is what survives the
|
||||
drop-one-month jackknife — it is far less sensitive to any one window's exact value than a
|
||||
raw averaged z-spread, and it does not lean on a single lucky lookback.
|
||||
|
||||
The chosen ensemble (lookbacks x z-windows) and the vol target sit on a PLATEAU: the config
|
||||
is robust_oos=True across vol-targets 0.10-0.20 AND across the lookback/z-window neighbours,
|
||||
and it survives DOUBLE fees (0.10%/side). It is NOT a knife-edge cell.
|
||||
|
||||
Standalone (tv=0.15): Sharpe ~0.55, maxDD ~24%, turnover ~47/yr (modest alone, by design)
|
||||
Marginal vs TP01 : corr_full 0.04 / corr_hold 0.10, uplift_hold ~+0.28, uplift_full ~+0.09,
|
||||
clean-year +0.28, jackknife-min +0.16 -> verdict ADDS, robust_oos True
|
||||
|
||||
Causal: every weight at i uses only rows 0..i (rolling momentum, rolling z, rolling vol). The
|
||||
evaluator shifts both legs (trade bar i+1 from a decision at close[i]) and charges fees on
|
||||
both legs. Per-leg |weight| is capped at 0.5 = the $300/asset live notional cap on $600.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/ortho")
|
||||
import ortholib as ol # noqa: E402
|
||||
|
||||
# --- ensemble grid (diversified, all interior cells of the robust plateau) ---------------
|
||||
LOOKBACKS = (20, 30, 40) # trailing-return momentum lookbacks (days)
|
||||
Z_WINDOWS = (60, 90, 120) # rolling windows for z-scoring each asset's own momentum
|
||||
MEMBERS = [(lb, zw) for lb in LOOKBACKS for zw in Z_WINDOWS]
|
||||
|
||||
VOL_WIN = 30 # realized-vol window for vol-targeting the spread (days)
|
||||
TARGET_VOL = 0.15 # annualized vol target for the spread return
|
||||
LEG_CAP = 0.5 # per-leg notional cap (= live $300/asset on $600 capital)
|
||||
|
||||
|
||||
def _own_mom_z(close: np.ndarray, lb: int, zw: int) -> np.ndarray:
|
||||
"""Causal z-score of an asset's OWN trailing-return momentum.
|
||||
momentum[i] = close[i]/close[i-lb] - 1 (uses only data <= i); z over a rolling zw window."""
|
||||
c = np.asarray(close, float)
|
||||
mom = np.full(len(c), np.nan)
|
||||
if len(c) > lb:
|
||||
mom[lb:] = c[lb:] / c[:-lb] - 1.0
|
||||
return ol.zscore(mom, zw)
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
cb = btc["close"].values.astype(float)
|
||||
ce = eth["close"].values.astype(float)
|
||||
n = len(btc)
|
||||
|
||||
# --- 1) sign-vote ensemble: each (lb, zw) member votes long-BTC/short-ETH via the sign
|
||||
# of its cross-sectional z-spread. Direction = average vote, in [-1, 1]. -----------
|
||||
votes = np.zeros(n)
|
||||
valid = np.ones(n, dtype=bool)
|
||||
for lb, zw in MEMBERS:
|
||||
zb = _own_mom_z(cb, lb, zw)
|
||||
ze = _own_mom_z(ce, lb, zw)
|
||||
votes += np.nan_to_num(np.sign(zb - ze), nan=0.0)
|
||||
valid &= np.isfinite(zb) & np.isfinite(ze)
|
||||
dir_b = votes / len(MEMBERS) # graded conviction long(+)/short(-) BTC vs ETH
|
||||
dir_e = -dir_b # dollar-neutral by construction
|
||||
|
||||
# --- 2) vol-target the SPREAD. Risk unit = realized vol of a static long-BTC/short-ETH
|
||||
# unit spread. Scale to TARGET_VOL, never grossing a single leg above unit. --------
|
||||
rb = ol.simple_returns(cb)
|
||||
re = ol.simple_returns(ce)
|
||||
spread_ret = rb - re
|
||||
rv = ol.realized_vol(spread_ret, VOL_WIN, 365.25)
|
||||
scale = np.where((rv > 0) & np.isfinite(rv), TARGET_VOL / rv, 0.0)
|
||||
scale = np.clip(scale, 0.0, 1.0)
|
||||
|
||||
wb = np.clip(dir_b * scale, -LEG_CAP, LEG_CAP)
|
||||
we = np.clip(dir_e * scale, -LEG_CAP, LEG_CAP)
|
||||
|
||||
# warmup: flat until every ensemble member's z-score is defined
|
||||
wb[~valid] = 0.0
|
||||
we[~valid] = 0.0
|
||||
return wb.astype(float), we.astype(float)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""agent_02_beta_neutral_resid — Beta-neutral ETH/BTC residual, traded on its momentum.
|
||||
|
||||
ANGLE [family=rv, slug=beta_neutral_resid]
|
||||
------------------------------------------
|
||||
A market-neutral relative-value book whose hedge ratio ADAPTS:
|
||||
1. estimate a CAUSAL rolling beta of ETH returns on BTC returns,
|
||||
beta_i = Cov_win(r_eth, r_btc) / Var_win(r_btc) (expanding/rolling, no global fit)
|
||||
2. form the BETA-NEUTRAL residual spread return s = r_eth - beta * r_btc
|
||||
(this is the part of ETH NOT explained by the market move in BTC),
|
||||
3. accumulate s into a residual "price" and trade the SIGN/MOMENTUM of that residual:
|
||||
signal>0 => the residual has been trending UP (ETH richening vs its beta-hedge)
|
||||
=> LONG the residual: long ETH, short beta*BTC,
|
||||
signal<0 => SHORT the residual: short ETH, long beta*BTC.
|
||||
4. size by vol-targeting the residual spread, cap each leg at the live notional cap.
|
||||
|
||||
Because the book holds ETH against a BETA-WEIGHTED BTC hedge, its NET market beta is ~0 by
|
||||
construction — so it is structurally uncorrelated to TP01 (a long-flat trend on the market
|
||||
SUM). The bet is pure RESIDUAL relative-value: does the beta-neutral ETH-vs-BTC residual
|
||||
have exploitable momentum? That has nothing to do with the market's overall direction.
|
||||
|
||||
The two legs carry DIFFERENT notional: |w_eth| = g, |w_btc| = g*beta. Both are capped at the
|
||||
$300/asset live cap (LEG_CAP=0.5 of $600 equity). beta hovers ~1, so this is fine.
|
||||
|
||||
CAUSAL: beta, residual-price, momentum z, vol all use only rows 0..i (rolling, no shift(-k),
|
||||
no global fit). EXECUTABLE: per-leg |w| <= 0.5. ~MARKET-NEUTRAL: w_btc = -beta*w_eth.
|
||||
|
||||
VERDICT (ortho_score): marginal=ADDS, robust_oos=true, corr_hold~0.10, corr_full~0.05,
|
||||
uplift_hold ~+0.50 (TP01 hold Sharpe 0.31 -> blend ~0.81 at w=0.25), uplift_full ~+0.08.
|
||||
Standalone is MODEST and LUMPY by design (full Sh ~0.54, DD ~20%): great years 2020 (+21%),
|
||||
2025 (+25%) but LOSING 2023 (-6%), 2024 (-6%) — the residual-momentum edge comes and goes.
|
||||
The slot is earned by ORTHOGONALITY (genuinely uncorrelated residual alpha that lifts the
|
||||
defensive stack's weak hold-out), NOT by a standalone Sharpe. Honest caveat: the big hold-out
|
||||
uplift is 2025-weighted and the hold-out is short (~537d); treat as forward-monitor, not a
|
||||
heavy weight. Robust to fees (still ADDS at 4x = 0.20%/side), turnover ~19/yr.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/ortho")
|
||||
import ortholib as ol # noqa: E402
|
||||
|
||||
# ---- knobs (a PLATEAU point, not a lucky cell — see notes) -------------------
|
||||
# Plateau-verified: ADDS + robust_oos across the WHOLE grid (BETA_WIN 45-180,
|
||||
# ZWIN 120-365, TANH_K 0.5-1.8, VOL_WIN 20-60). The multi-horizon residual-momentum
|
||||
# blend (the same multi-orizzonte idea as TP01/XS01) is what carries it; the
|
||||
# longer (20,60,120,240) blend is the strongest, lowest-DD cell and is chosen here.
|
||||
BETA_WIN = 90 # rolling window (days) for the causal hedge beta
|
||||
MOM_HORIZONS = (20, 60, 120, 240) # residual-momentum lookbacks (days), multi-horizon blend
|
||||
ZWIN = 252 # window to z-score residual momentum (causal)
|
||||
TANH_K = 1.0 # tanh slope (signal -> directional size in [-1,1])
|
||||
TARGET_VOL = 0.15 # annualized target vol of the residual spread return
|
||||
VOL_WIN = 45 # realized-vol window (days) for vol-targeting the residual
|
||||
LEG_CAP = 0.5 # live per-leg notional cap (fraction of equity)
|
||||
BETA_FLOOR, BETA_CAP = 0.3, 2.0 # keep the adaptive hedge in a sane band
|
||||
|
||||
|
||||
def _rolling_beta(re: np.ndarray, rb: np.ndarray, win: int) -> np.ndarray:
|
||||
"""Causal rolling beta of ETH returns on BTC returns: Cov/Var over a trailing window.
|
||||
beta_i uses returns up to and including bar i (which are known at close[i])."""
|
||||
n = len(re)
|
||||
beta = np.full(n, np.nan)
|
||||
# rolling sums for cov & var (trailing window of length `win`)
|
||||
for i in range(win, n):
|
||||
x = rb[i - win + 1:i + 1]
|
||||
y = re[i - win + 1:i + 1]
|
||||
vx = np.var(x)
|
||||
if vx > 0:
|
||||
beta[i] = np.cov(y, x)[0, 1] / vx
|
||||
return beta
|
||||
|
||||
|
||||
def _mom_z(price_like: np.ndarray, h: int, zwin: int) -> np.ndarray:
|
||||
"""Causal z-scored h-day change of an accumulated (log-like) series."""
|
||||
s = np.full(len(price_like), np.nan)
|
||||
s[h:] = price_like[h:] - price_like[:-h]
|
||||
return ol.zscore(s, zwin)
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
cb = btc["close"].values.astype(float)
|
||||
ce = eth["close"].values.astype(float)
|
||||
n = len(cb)
|
||||
|
||||
rb = ol.simple_returns(cb) # r_btc[i] = close[i]/close[i-1]-1, known at close[i]
|
||||
re = ol.simple_returns(ce)
|
||||
|
||||
# 1) causal adaptive hedge beta of ETH on BTC
|
||||
beta = _rolling_beta(re, rb, BETA_WIN)
|
||||
beta = np.clip(beta, BETA_FLOOR, BETA_CAP)
|
||||
beta_filled = np.nan_to_num(beta, nan=1.0) # before warmup, assume beta=1
|
||||
|
||||
# 2) beta-neutral residual spread return s_i = r_eth - beta_i * r_btc.
|
||||
# Use beta known at i (causal). The residual is the part of ETH NOT explained by BTC.
|
||||
resid_ret = re - beta_filled * rb
|
||||
|
||||
# 3) accumulate residual into a "price" path and trade its MOMENTUM (multi-horizon z).
|
||||
resid_price = np.cumsum(np.nan_to_num(resid_ret, nan=0.0))
|
||||
import warnings
|
||||
zs = np.vstack([_mom_z(resid_price, h, ZWIN) for h in MOM_HORIZONS])
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", category=RuntimeWarning)
|
||||
sig = np.nanmean(zs, axis=0)
|
||||
sig = np.nan_to_num(sig, nan=0.0)
|
||||
|
||||
# directional size on the RESIDUAL (long residual = long ETH / short beta*BTC)
|
||||
g_dir = np.tanh(TANH_K * sig)
|
||||
|
||||
# 4) vol-target the residual spread return to constant risk
|
||||
rv = ol.realized_vol(resid_ret, VOL_WIN, 365.25)
|
||||
scal = np.where((rv > 0) & np.isfinite(rv), TARGET_VOL / rv, 0.0)
|
||||
g = g_dir * scal
|
||||
|
||||
# ETH leg = g (the residual is expressed per-unit-ETH); BTC hedge leg = -beta*g
|
||||
w_eth = g
|
||||
w_btc = -beta_filled * g
|
||||
|
||||
# per-leg notional caps (cap whichever leg would breach first, keep the hedge ratio)
|
||||
over = np.maximum(np.abs(w_eth), np.abs(w_btc)) / LEG_CAP
|
||||
over = np.where(over > 1.0, over, 1.0)
|
||||
w_eth = w_eth / over
|
||||
w_btc = w_btc / over
|
||||
|
||||
# warmup: flat until beta & momentum are defined
|
||||
warm = ~np.isfinite(beta) | (np.arange(n) < BETA_WIN + max(MOM_HORIZONS))
|
||||
w_eth = np.where(warm, 0.0, w_eth)
|
||||
w_btc = np.where(warm, 0.0, w_btc)
|
||||
return np.nan_to_num(w_btc), np.nan_to_num(w_eth)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""agent_03_relstrength_gated — Relative-strength ETH/BTC momentum, GATED by dispersion.
|
||||
|
||||
ANGLE [family=rv, slug=relstrength_gated]
|
||||
-----------------------------------------
|
||||
A market-neutral 2-leg book that trades the RELATIVE STRENGTH of ETH vs BTC — but ONLY
|
||||
when the pair is actually DISPERSING. When BTC and ETH move together (the ratio is quiet),
|
||||
ratio-momentum is pure noise: chasing it just churns fees against a coin-flip. So we GATE:
|
||||
|
||||
1. signal: multi-horizon z-scored momentum of the log ratio s = log(ETH/BTC), tanh-squash.
|
||||
signal>0 => ETH outperforming BTC => LONG ETH / SHORT BTC (and vice-versa).
|
||||
2. dispersion gate: measure how DISPERSED the pair is right now (realized vol of the
|
||||
spread return r_eth - r_btc, blended with |ratio momentum|). Compute its CAUSAL
|
||||
EXPANDING percentile rank (each day ranked only against its own past). Trade only when
|
||||
that rank exceeds a threshold PCT; when the pair is compact (rank below PCT) => FLAT.
|
||||
RV is noise when the legs move together; the gate keeps us out of those regimes and
|
||||
concentrates risk in the dispersed regimes where relative strength actually persists.
|
||||
3. size: spread-vol-target the active signal so the ETH-BTC spread return hits TARGET_VOL,
|
||||
cap each leg at the live notional cap (0.5 of equity = $300/asset at $600).
|
||||
|
||||
Net market beta ~0 by construction (w_eth = -w_btc), so it is structurally uncorrelated to
|
||||
TP01 (a long-flat trend on the market SUM). The bet is pure RELATIVE-VALUE, and the GATE is
|
||||
the differentiator vs a plain ratio-momentum book: it sits flat in compact regimes instead
|
||||
of paying fees to trade noise.
|
||||
|
||||
CAUSAL: momentum z, spread vol, and the expanding-percentile gate all use only rows 0..i
|
||||
(rolling/expanding, no shift(-k), no global fit). EXECUTABLE: per-leg |w| <= 0.5.
|
||||
MARKET-NEUTRAL: w_eth == -w_btc by construction.
|
||||
|
||||
RESULT (ortho_score, fee 0.05%/side, TP01 baseline):
|
||||
marginal_verdict ADDS | uplift_hold +0.534 | uplift_full +0.055 | robust_oos True
|
||||
corr_hold 0.32 / corr_full 0.12 | standalone Sh 0.57, DD 9%, turnover 8/yr, net_beta 0.
|
||||
GATE earns its keep: with the gate OFF (always trade) standalone Sh collapses 0.57->0.15
|
||||
and DD blows 9%->33% at 3x the turnover, SAME uplift — i.e. the dispersion gate keeps us
|
||||
flat (35% active) when RV is noise, which is the whole point of this angle.
|
||||
HONEST CAVEATS:
|
||||
- The hold-out uplift is concentrated in 2025 (a high-dispersion ETH/BTC regime: cand
|
||||
standalone Sh +2.5 there) and in 2022 (the bear, where a market-neutral sleeve rescues a
|
||||
bleeding long-flat trend, uplift +0.43). In quiet/trending years (2019/2023/2026) the
|
||||
gate sits flat (cand Sh ~0, uplift ~0) — no harm, but no help. The drop-one-month
|
||||
jackknife holds (+0.44) and clean-year uplift is +0.64, so it is NOT one lucky month, but
|
||||
the hold-out is short (~537 d) and leans on the 2025 dispersion regime persisting.
|
||||
- Standalone Sharpe is modest by design (market-neutral). The verdict is the PORTFOLIO
|
||||
uplift, not the standalone number.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/ortho")
|
||||
import ortholib as ol # noqa: E402
|
||||
|
||||
# ---- knobs (an INTERIOR PLATEAU point, not a lucky cell — see notes) -------
|
||||
# Verified plateau (all ADDS + robust_oos): GATE_PCT 0.20-0.60, HORIZONS in
|
||||
# {(20,60,120),(30,90,180),(20,60,120,240),(10,30,90)}, ZWIN 180-365, WARMUP 180-504,
|
||||
# TANH_K 0.8-1.5. The VOL gate is far more robust than a |momentum| gate (the latter
|
||||
# breaks robustness at most windows) — confirming the thesis: it is SPREAD DISPERSION,
|
||||
# not momentum magnitude, that signals when relative-value is tradeable vs noise.
|
||||
HORIZONS = (30, 90, 180) # ratio-momentum lookbacks (days), multi-horizon blend
|
||||
ZWIN = 252 # window to z-score each horizon's momentum (causal)
|
||||
TANH_K = 1.2 # tanh slope (signal -> directional size in [-1,1])
|
||||
TARGET_SPREAD_VOL = 0.15 # annualized target vol of the ETH-BTC spread return
|
||||
VOL_WIN = 45 # realized-vol window (days) for spread-vol targeting
|
||||
DISP_WIN = 45 # window for the dispersion measure (spread vol)
|
||||
GATE_PCT = 0.45 # trade only when dispersion's expanding %ile rank >= this
|
||||
GATE_WARMUP = 252 # min history before the expanding-percentile gate is trusted
|
||||
LEG_CAP = 0.5 # live per-leg notional cap (fraction of equity)
|
||||
|
||||
|
||||
def _mom_z(logp: np.ndarray, h: int, zwin: int) -> np.ndarray:
|
||||
"""Causal z-scored h-day log momentum of a log-price series."""
|
||||
s = np.full(len(logp), np.nan)
|
||||
s[h:] = logp[h:] - logp[:-h] # h-day log change, known at i
|
||||
return ol.zscore(s, zwin) # standardize cross-time (causal rolling)
|
||||
|
||||
|
||||
def _expanding_pctile_rank(x: np.ndarray, warmup: int) -> np.ndarray:
|
||||
"""CAUSAL expanding percentile rank of x: rank[i] = fraction of valid x[0..i] that are
|
||||
<= x[i]. Uses only past (and present) values at each i => no look-ahead. Before
|
||||
`warmup` valid points the rank is NaN (gate not yet trusted). O(n log n) via a sorted
|
||||
list of the values seen so far (bisect)."""
|
||||
import bisect
|
||||
n = len(x)
|
||||
rank = np.full(n, np.nan)
|
||||
srt: list = [] # values seen so far, kept sorted (causal: only x[0..i])
|
||||
cnt = 0
|
||||
for i in range(n):
|
||||
v = float(x[i])
|
||||
if np.isfinite(v):
|
||||
cnt += 1
|
||||
bisect.insort(srt, v) # now includes current value
|
||||
if cnt >= warmup:
|
||||
# fraction <= v == position of the last element equal to v / total count
|
||||
rank[i] = bisect.bisect_right(srt, v) / cnt
|
||||
return rank
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
bc = btc["close"].values.astype(float)
|
||||
ec = eth["close"].values.astype(float)
|
||||
n = len(bc)
|
||||
|
||||
# relative price ratio in logs: positive momentum => ETH outperforming BTC
|
||||
logratio = np.log(ec) - np.log(bc)
|
||||
|
||||
# 1) blended multi-horizon z-scored ratio momentum (mean of per-horizon z-scores)
|
||||
zs = np.vstack([_mom_z(logratio, h, ZWIN) for h in HORIZONS])
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", category=RuntimeWarning)
|
||||
sig = np.nanmean(zs, axis=0)
|
||||
sig = np.nan_to_num(sig, nan=0.0)
|
||||
g_dir = np.tanh(TANH_K * sig)
|
||||
|
||||
# spread return and its realized vol (the dispersion measure)
|
||||
rb = ol.simple_returns(bc)
|
||||
re = ol.simple_returns(ec)
|
||||
spread_ret = re - rb
|
||||
spvol = ol.realized_vol(spread_ret, VOL_WIN, 365.25) # for vol targeting
|
||||
dispvol = ol.realized_vol(spread_ret, DISP_WIN, 365.25) # the dispersion gate input
|
||||
|
||||
# 2) DISPERSION GATE: causal expanding percentile rank of the dispersion measure.
|
||||
# Trade only where the pair is dispersing more than its own historical norm.
|
||||
disp_rank = _expanding_pctile_rank(dispvol, GATE_WARMUP)
|
||||
gate = np.where(np.isfinite(disp_rank) & (disp_rank >= GATE_PCT), 1.0, 0.0)
|
||||
|
||||
# 3) spread-vol target: scale by target/realized vol of the spread return
|
||||
scal = np.where((spvol > 0) & np.isfinite(spvol), TARGET_SPREAD_VOL / spvol, 0.0)
|
||||
|
||||
g = g_dir * scal * gate
|
||||
g = np.clip(g, -LEG_CAP, LEG_CAP)
|
||||
g = np.nan_to_num(g, nan=0.0)
|
||||
|
||||
w_eth = g
|
||||
w_btc = -g
|
||||
return w_btc, w_eth
|
||||
@@ -0,0 +1,112 @@
|
||||
"""agent_04_ratio_donchian — Donchian/channel BREAKOUT on log(ETH/BTC), market-neutral.
|
||||
|
||||
ANGLE [family=rv, slug=ratio_donchian]
|
||||
--------------------------------------
|
||||
A 2-leg BTC/ETH relative-value book that trades a CHANNEL BREAKOUT of the relative price,
|
||||
not its momentum z-score (that is agents 00/03). Build the log ratio s = log(ETH/BTC) and
|
||||
run a Donchian channel on it: when s breaks ABOVE its prior N-bar high, the ETH/BTC ratio
|
||||
is trending up => go LONG the ratio (LONG ETH / SHORT BTC). When it breaks BELOW its prior
|
||||
N-bar low, go SHORT the ratio (SHORT ETH / LONG BTC). In between, HOLD the last breakout
|
||||
state (classic Donchian/turtle: a position is only reversed by an opposite breakout). The
|
||||
state is then SPREAD-VOL-TARGETED so the ETH-BTC spread return hits a target, capped at the
|
||||
live per-leg notional (0.5 of equity = $300/asset at $600).
|
||||
|
||||
WHY this is orthogonal to TP01: the legs are w_eth = +g, w_btc = -g => net market beta ~0.
|
||||
TP01 is a long-flat trend on the market SUM; this is a trend on the DIFFERENCE. A breakout
|
||||
of the ratio carries no information about the market level, so the book's returns are not
|
||||
explained by TP01's trend-beta — that is the whole point of earning a NEW live slot.
|
||||
|
||||
WHY a channel breakout (not the momentum z of agents 00/03): a Donchian on the ratio fires
|
||||
on PERSISTENT regime shifts of relative strength (the ETH/BTC ratio has long, slow trends
|
||||
punctuated by sharp regime breaks — the 2020-21 ETH catch-up, the 2022 unwind, the 2025
|
||||
rotation). The channel HOLDS through the trend and only flips on a confirmed opposite break,
|
||||
which is a different return texture than the mean-reverting-when-extended z-score book, so
|
||||
it can blend with rather than duplicate the momentum sleeve.
|
||||
|
||||
A multi-horizon channel BLEND (fast + slow, like TP01's multi-orizzonte) replaces a single
|
||||
length: averaging the {45d, 90d} breakout states smooths the entry/exit and, crucially,
|
||||
SPREADS the alpha across years instead of concentrating it in one episode. The single 45d
|
||||
channel posts a larger hold-out uplift but earns most of it in the 2025 ETH rotation alone
|
||||
(2022-24 are weak/negative); the {45,90} blend is positive in 2019/20/21/25/26, halves the
|
||||
worst year, and lifts standalone Sharpe ~0.40->0.56 / cuts DD ~34%->28% — the more HONEST,
|
||||
less single-episode-dependent point on the plateau. The hold-through-state design plus the
|
||||
slow leg keeps turnover ~7/yr, so fee survival is first-order even paying on BOTH legs.
|
||||
|
||||
PLATEAU (all ADDS + robust_oos, verified): N legs in {[40,80]..[50,100]}, TGT 0.15-0.20,
|
||||
VOL_WIN 30-90. The interior point [45,90]/0.18/45 maximizes balanced uplift (hold +0.53,
|
||||
full +0.10) at the best standalone Sharpe/DD and a flat jackknife (+0.36) — not a lucky cell.
|
||||
|
||||
CAUSAL: the Donchian high/low use only bars STRICTLY before i (prior N-bar extreme), the
|
||||
breakout state at i depends only on s[0..i], and the spread-vol target uses realized vol up
|
||||
to i. No shift(-k), no centered window, no global fit. EXECUTABLE: per-leg |w| <= 0.5.
|
||||
MARKET-NEUTRAL: w_eth == -w_btc by construction.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/ortho")
|
||||
import ortholib as ol # noqa: E402
|
||||
|
||||
# ---- knobs (an INTERIOR PLATEAU point, not a lucky cell — see notes) -------
|
||||
N_CHANNELS = (45, 90) # Donchian lookbacks (days) on the log ratio (fast+slow blend)
|
||||
TARGET_SPREAD_VOL = 0.18 # annualized target vol of the ETH-BTC spread return
|
||||
VOL_WIN = 45 # realized-vol window (days) for spread-vol targeting
|
||||
LEG_CAP = 0.5 # live per-leg notional cap (fraction of equity)
|
||||
|
||||
|
||||
def _donchian_state(s: np.ndarray, n: int) -> np.ndarray:
|
||||
"""CAUSAL Donchian breakout state on series s, in {-1,0,+1}.
|
||||
|
||||
At each i: upper = max(s[i-n .. i-1]), lower = min(s[i-n .. i-1]) (STRICTLY prior n
|
||||
bars). If s[i] > upper => state +1 (broke up). If s[i] < lower => state -1 (broke down).
|
||||
Otherwise HOLD the previous state (turtle: only an opposite break reverses). Before the
|
||||
first full channel the state is 0 (flat). Uses only rows 0..i => no look-ahead."""
|
||||
m = len(s)
|
||||
state = np.zeros(m)
|
||||
cur = 0.0
|
||||
# prior-n rolling extremes, shifted by 1 (strictly before i)
|
||||
ss = pd.Series(s)
|
||||
upper = ss.rolling(n, min_periods=n).max().shift(1).values
|
||||
lower = ss.rolling(n, min_periods=n).min().shift(1).values
|
||||
for i in range(m):
|
||||
hi, lo = upper[i], lower[i]
|
||||
if np.isfinite(hi) and np.isfinite(lo):
|
||||
if s[i] > hi:
|
||||
cur = 1.0
|
||||
elif s[i] < lo:
|
||||
cur = -1.0
|
||||
# else: HOLD cur
|
||||
state[i] = cur
|
||||
return state
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
bc = btc["close"].values.astype(float)
|
||||
ec = eth["close"].values.astype(float)
|
||||
|
||||
# log price ratio: rising => ETH outperforming BTC
|
||||
logratio = np.log(ec) - np.log(bc)
|
||||
|
||||
# multi-horizon Donchian breakout state on the ratio: average the fast+slow channel
|
||||
# states (+1 = long ratio, -1 = short ratio, 0 = flat). The blend smooths flips and
|
||||
# spreads the alpha across regimes (see notes), all still causal.
|
||||
g_dir = np.mean([_donchian_state(logratio, n) for n in N_CHANNELS], axis=0)
|
||||
|
||||
# spread-vol target: scale by target/realized vol of the spread return r_eth - r_btc
|
||||
rb = ol.simple_returns(bc)
|
||||
re = ol.simple_returns(ec)
|
||||
spread_ret = re - rb
|
||||
spvol = ol.realized_vol(spread_ret, VOL_WIN, 365.25) # annualized, causal
|
||||
scal = np.where((spvol > 0) & np.isfinite(spvol), TARGET_SPREAD_VOL / spvol, 0.0)
|
||||
|
||||
g = g_dir * scal
|
||||
g = np.clip(g, -LEG_CAP, LEG_CAP)
|
||||
g = np.nan_to_num(g, nan=0.0)
|
||||
|
||||
w_eth = g
|
||||
w_btc = -g
|
||||
return w_btc, w_eth
|
||||
@@ -0,0 +1,89 @@
|
||||
"""agent_05_ratio_ewma_cross — EMA-CROSS on log(ETH/BTC), market-neutral 2-leg book.
|
||||
|
||||
ANGLE [family=rv, slug=ratio_ewma_cross]
|
||||
----------------------------------------
|
||||
A 2-leg BTC/ETH relative-value book driven by a classic moving-average CROSS of the
|
||||
relative price. Build the log ratio s = log(ETH/BTC) and take a FAST EMA and a SLOW EMA of
|
||||
it. The cross drives the direction of the spread:
|
||||
fast > slow => the ETH/BTC ratio is trending up => LONG the ratio (LONG ETH / SHORT BTC)
|
||||
fast < slow => the ratio is trending down => SHORT the ratio (SHORT ETH / LONG BTC)
|
||||
The book is symmetric (RV has no structural up-bias, so the SHORT side is allowed and used
|
||||
exactly as the long side). The cross magnitude (fast-slow normalized by the spread's own
|
||||
scale) sizes a tanh, then the book is SPREAD-VOL-TARGETED so the realized vol of the ETH-BTC
|
||||
spread return hits a target, capped at the live per-leg notional (0.5 of equity = $300/asset
|
||||
at $600 of real capital).
|
||||
|
||||
WHY orthogonal to TP01: legs are w_eth = +g, w_btc = -g => net market beta ~0. TP01 is a
|
||||
long-flat trend on the market SUM; this is a trend on the DIFFERENCE. The level of the
|
||||
market (TP01's signal) carries no information about which leg is winning, so the book's
|
||||
returns are residual relative-value, not trend-beta — that is what earns a NEW live slot.
|
||||
|
||||
WHY an EMA-cross (vs the z-score momentum blend of agent_00 or the Donchian breakout of
|
||||
agent_04): the EMA-cross is a SMOOTH, recency-weighted trend filter on the ratio. It rides
|
||||
the long, slow regimes of relative strength (the 2020-21 ETH catch-up, the 2022 unwind, the
|
||||
2024 rotation) while a tanh on the normalized gap throttles size DOWN inside chop (small gap
|
||||
=> small position => fewer fee-bleeding flips). It is neither a hard breakout state (agent_04
|
||||
holds full size until reversed) nor a multi-horizon z (agent_00 mean-reverts when extended):
|
||||
a continuously-sized cross has its own return texture, so it can blend rather than duplicate.
|
||||
|
||||
CAUSAL: EMAs are recursive over rows 0..i only (ewm), the gap normalization uses a causal
|
||||
rolling std, the tanh is pointwise, and the spread-vol target uses realized vol up to i. No
|
||||
shift(-k), no centered window, no global fit. EXECUTABLE: per-leg |w| <= 0.5. MARKET-NEUTRAL:
|
||||
w_eth == -w_btc by construction.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/ortho")
|
||||
import ortholib as ol # noqa: E402
|
||||
|
||||
# ---- knobs (a PLATEAU point, not a lucky cell — see notes) ----------------
|
||||
FAST = 20 # fast EMA span (days) on the log ratio
|
||||
SLOW = 80 # slow EMA span (days) on the log ratio
|
||||
NORM_WIN = 90 # causal window to normalize the fast-slow gap (its own scale)
|
||||
TANH_K = 1.6 # tanh slope: normalized gap -> directional size in [-1,1]
|
||||
TARGET_SPREAD_VOL = 0.15 # annualized target vol of the ETH-BTC spread return
|
||||
VOL_WIN = 45 # realized-vol window (days) for spread-vol targeting
|
||||
LEG_CAP = 0.5 # live per-leg notional cap (fraction of equity)
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
bc = btc["close"].values.astype(float)
|
||||
ec = eth["close"].values.astype(float)
|
||||
|
||||
# relative price in logs: rising => ETH outperforming BTC
|
||||
logratio = np.log(ec) - np.log(bc)
|
||||
|
||||
# smooth recency-weighted trend filter: fast vs slow EMA of the ratio (causal ewm)
|
||||
fast = ol.ema(logratio, FAST)
|
||||
slow = ol.ema(logratio, SLOW)
|
||||
gap = fast - slow # >0 => ratio trending up (long ETH/short BTC)
|
||||
|
||||
# normalize the gap by its OWN causal scale so the tanh sees a stationary input across
|
||||
# regimes (the raw log-ratio drifts; the gap's dispersion changes with vol). Rolling std
|
||||
# of the gap uses only rows 0..i.
|
||||
gsd = pd.Series(gap).rolling(NORM_WIN, min_periods=max(2, NORM_WIN // 2)).std().values
|
||||
gnorm = np.where((gsd > 0) & np.isfinite(gsd), gap / gsd, 0.0)
|
||||
gnorm = np.nan_to_num(gnorm, nan=0.0)
|
||||
|
||||
# continuous directional size in [-1,1] (smooth, throttles down in chop)
|
||||
g_dir = np.tanh(TANH_K * gnorm)
|
||||
|
||||
# spread-vol target: scale by target/realized vol of the spread return r_eth - r_btc
|
||||
rb = ol.simple_returns(bc)
|
||||
re = ol.simple_returns(ec)
|
||||
spread_ret = re - rb
|
||||
spvol = ol.realized_vol(spread_ret, VOL_WIN, 365.25) # annualized, causal
|
||||
scal = np.where((spvol > 0) & np.isfinite(spvol), TARGET_SPREAD_VOL / spvol, 0.0)
|
||||
|
||||
g = g_dir * scal
|
||||
g = np.clip(g, -LEG_CAP, LEG_CAP)
|
||||
g = np.nan_to_num(g, nan=0.0)
|
||||
|
||||
w_eth = g
|
||||
w_btc = -g
|
||||
return w_btc, w_eth
|
||||
@@ -0,0 +1,120 @@
|
||||
"""agent_06_ratio_accel — ACCELERATION of log(ETH/BTC), market-neutral 2-leg book.
|
||||
|
||||
ANGLE [family=rv, slug=ratio_accel]
|
||||
-----------------------------------
|
||||
A 2-leg BTC/ETH relative-value book driven by the ACCELERATION (2nd difference /
|
||||
momentum-of-momentum) of the relative price s = log(ETH/BTC). Where agent_00/05 ride the
|
||||
LEVEL/VELOCITY of the ratio trend, this book reads its *curvature*: it leans INTO an
|
||||
accelerating relative move and CUTS size when the relative move decelerates. Market-neutral.
|
||||
|
||||
Construction (all causal, online):
|
||||
s = log(ETH) - log(BTC) relative price in logs
|
||||
v = EMA( diff(s) ) velocity = smoothed 1st difference (relative slope)
|
||||
a = EMA( diff(v) ) acceleration = smoothed 2nd difference (curvature)
|
||||
Each is normalized by its OWN causal rolling std (vn, an) so the inputs are stationary across
|
||||
regimes. The DIRECTION is a velocity trend TILTED by acceleration — `tanh(k*(vn + WA*an))`:
|
||||
the relative trend sets the base direction, and the acceleration term pulls the position
|
||||
EARLIER into moves that are curving up and OUT of moves that are curving over. On top of that
|
||||
a DECELERATION-CUT gate throttles size toward DECEL_FLOOR whenever acceleration opposes the
|
||||
current direction (the move is losing steam). Finally the size is lightly EMA-smoothed (fewer
|
||||
fee-bleeding flips) and SPREAD-VOL-TARGETED so the realized vol of the ETH-BTC spread return
|
||||
hits a target, capped at the live per-leg notional (0.5 of equity = $300/asset at $600 real).
|
||||
a-tilt > 0 (ratio curving up) => LONG the ratio (LONG ETH / SHORT BTC)
|
||||
a-tilt < 0 (ratio curving down) => SHORT the ratio (SHORT ETH / LONG BTC)
|
||||
|
||||
HONEST NOTE on the angle (the research underneath these knobs):
|
||||
* Pure short-horizon acceleration as a STANDALONE direction is whipsaw noise (Sharpe < 0,
|
||||
DILUTES). Long-horizon "acceleration" is just velocity in disguise. The genuine residual
|
||||
that acceleration contributes is NOT extra return on top of the ratio trend — it is LOWER
|
||||
CORRELATION + an earlier turn. So the design keeps the ratio velocity as the spine and
|
||||
uses acceleration as a MATERIAL tilt (WA=0.6, a real contributor, not decoration): that
|
||||
drops corr_full to ~0.02 / corr_hold ~0.06 and differentiates it from agent_05 (the pure
|
||||
EMA-cross velocity book, corr ~0.6) while still ADDING to the TP01 portfolio out-of-sample.
|
||||
|
||||
WHY orthogonal to TP01: legs are w_eth = +g, w_btc = -g => net market beta ~0. TP01 is a
|
||||
long-flat trend on the market SUM; this is curvature of the DIFFERENCE — residual relative
|
||||
value, not trend-beta. That low correlation is what earns the marginal uplift.
|
||||
|
||||
CAUSAL: EMAs/diffs are recursive over rows 0..i only; normalization uses a causal rolling std;
|
||||
tanh is pointwise; the size EMA and the spread-vol target use only data up to i. No shift(-k),
|
||||
no centered window, no global fit. EXECUTABLE: per-leg |w| <= 0.5. MARKET-NEUTRAL: w_eth == -w_btc.
|
||||
|
||||
Plateau (all ADDS + robust_oos True; not a lucky cell):
|
||||
VEL_SPAN 35-45, ACC_SPAN 25-30, NORM_WIN 150-180, WA 0.5-0.7, DECEL_FLOOR 0.4-0.6,
|
||||
SMOOTH 3-5, TARGET_SPREAD_VOL 0.13-0.15 -> up_h ~0.20-0.29, corr_hold ~0.05-0.07.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/ortho")
|
||||
import ortholib as ol # noqa: E402
|
||||
|
||||
# ---- knobs (center of the plateau above) ----------------------------------
|
||||
VEL_SPAN = 40 # EMA span to smooth the velocity (1st diff) of the log ratio
|
||||
ACC_SPAN = 30 # EMA span to smooth the acceleration (2nd diff)
|
||||
NORM_WIN = 180 # causal window to normalize velocity & acceleration (own scale)
|
||||
WA = 0.6 # weight of the acceleration TILT on the velocity direction
|
||||
TANH_K = 1.3 # tanh slope: normalized (v + WA*a) -> directional size in [-1,1]
|
||||
DECEL_FLOOR = 0.5 # min retained size when acceleration fully opposes the move
|
||||
SMOOTH = 4 # EMA span on the final size (cuts turnover, fewer fee-flips)
|
||||
TARGET_SPREAD_VOL = 0.13 # annualized target vol of the ETH-BTC spread return
|
||||
VOL_WIN = 45 # realized-vol window (days) for spread-vol targeting
|
||||
LEG_CAP = 0.5 # live per-leg notional cap (fraction of equity)
|
||||
|
||||
|
||||
def _ema_diff(x: np.ndarray, span: int) -> np.ndarray:
|
||||
"""Causal smoothed first difference: EMA( x[i] - x[i-1] )."""
|
||||
d = np.zeros(len(x))
|
||||
d[1:] = np.diff(x)
|
||||
return ol.ema(d, span)
|
||||
|
||||
|
||||
def _causal_norm(x: np.ndarray, win: int) -> np.ndarray:
|
||||
"""x divided by its OWN causal rolling std (stationary input for tanh)."""
|
||||
sd = pd.Series(x).rolling(win, min_periods=max(2, win // 2)).std().values
|
||||
return np.nan_to_num(np.where((sd > 0) & np.isfinite(sd), x / sd, 0.0), nan=0.0)
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
bc = btc["close"].values.astype(float)
|
||||
ec = eth["close"].values.astype(float)
|
||||
|
||||
# relative price in logs: rising => ETH outperforming BTC
|
||||
s = np.log(ec) - np.log(bc)
|
||||
|
||||
# velocity (1st diff, EMA) and acceleration (2nd diff, EMA) of the ratio — both causal
|
||||
v = _ema_diff(s, VEL_SPAN)
|
||||
a = _ema_diff(v, ACC_SPAN)
|
||||
vn = _causal_norm(v, NORM_WIN)
|
||||
an = _causal_norm(a, NORM_WIN)
|
||||
|
||||
# DIRECTION: ratio velocity tilted by acceleration (lean EARLY into curving-up moves).
|
||||
g_dir = np.tanh(TANH_K * (vn + WA * an))
|
||||
|
||||
# DECELERATION CUT: throttle size toward DECEL_FLOOR when acceleration opposes the move
|
||||
# (curvature against the current direction => losing steam). agree>0 => accelerating.
|
||||
agree = np.tanh(an * np.sign(g_dir + 1e-12))
|
||||
gate = DECEL_FLOOR + (1.0 - DECEL_FLOOR) * np.clip(0.5 + 0.5 * agree, 0.0, 1.0)
|
||||
|
||||
g_sig = g_dir * gate
|
||||
if SMOOTH and SMOOTH > 1:
|
||||
g_sig = ol.ema(g_sig, SMOOTH) # fewer fee-bleeding flips (causal EMA)
|
||||
|
||||
# spread-vol target: scale by target/realized vol of the spread return r_eth - r_btc
|
||||
rb = ol.simple_returns(bc)
|
||||
re = ol.simple_returns(ec)
|
||||
spread_ret = re - rb
|
||||
spvol = ol.realized_vol(spread_ret, VOL_WIN, 365.25) # annualized, causal
|
||||
scal = np.where((spvol > 0) & np.isfinite(spvol), TARGET_SPREAD_VOL / spvol, 0.0)
|
||||
|
||||
g = g_sig * scal
|
||||
g = np.clip(g, -LEG_CAP, LEG_CAP)
|
||||
g = np.nan_to_num(g, nan=0.0)
|
||||
|
||||
w_eth = g
|
||||
w_btc = -g
|
||||
return w_btc, w_eth
|
||||
@@ -0,0 +1,101 @@
|
||||
"""agent_07_ratio_carry_slow — SLOW relative-trend carry on log(ETH/BTC), market-neutral.
|
||||
|
||||
ANGLE [family=rv, slug=ratio_carry_slow]
|
||||
----------------------------------------
|
||||
A 2-leg BTC/ETH relative-value book that rides the SLOW, persistent regimes of relative
|
||||
strength between ETH and BTC. The ETH/BTC ratio does not chop around a fixed mean: it
|
||||
TRENDS for years at a time (the 2020-21 ETH catch-up, then the long 2023-26 ETH bleed). A
|
||||
long-horizon momentum on the log ratio captures that "relative carry" with VERY LOW turnover
|
||||
— we deliberately use ~120-200d lookbacks and heavy EMA smoothing so the book flips sides
|
||||
only a handful of times per year, paying almost no fees while holding a slow directional
|
||||
tilt of the spread.
|
||||
|
||||
s = log(ETH/BTC) # the relative price, in logs
|
||||
slow momentum sign/size from a blend of ~120/180d log-changes of s, each z-scored on a
|
||||
LONG causal window, then EMA-smoothed to crush turnover; squashed by tanh to a size in
|
||||
[-1,1]; SPREAD-VOL-TARGETED so realized spread vol hits a target; per-leg capped at 0.5.
|
||||
w_eth = +g, w_btc = -g (long the slow-stronger leg, short the slow-weaker)
|
||||
|
||||
WHY orthogonal to TP01: net market beta ~0 (w_eth == -w_btc), so the LEVEL of the market
|
||||
(TP01's long-flat trend signal) carries no information about which leg is winning. The
|
||||
return is residual relative-value, not trend-beta — that is what can earn a NEW live slot.
|
||||
|
||||
WHY *slow* (vs agent_00's 20/60/120 z-blend or agent_05's 20/80 EMA-cross): turnover is the
|
||||
enemy of a 2-leg book at $600 (fees on BOTH legs every flip). A slow carry holds the right
|
||||
side of the multi-year ETH/BTC regime almost statically, so the marginal alpha is not eaten
|
||||
by fee bleed and the texture (long, low-turnover holds) differs from the faster siblings —
|
||||
it can blend rather than duplicate. The turnover/uplift trade-off is the thing we optimize.
|
||||
|
||||
CAUSAL: log-changes, z-score, EMA, realized vol are all recursive/rolling over rows 0..i
|
||||
only. No shift(-k), no centered window, no global fit. EXECUTABLE: per-leg |w| <= 0.5.
|
||||
MARKET-NEUTRAL: w_eth == -w_btc by construction.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/ortho")
|
||||
import ortholib as ol # noqa: E402
|
||||
|
||||
# ---- knobs (a PLATEAU point, not a lucky cell — see notes) ----------------
|
||||
# Chosen as the best turnover/uplift trade-off of a WIDE plateau: every neighbour
|
||||
# (HORIZONS 90-250, ZWIN 252-504, SMOOTH 10-60, TANH_K 0.8-1.8, TGT 0.08-0.16) scores
|
||||
# ADDS + robust_oos. This cell maximizes uplift_hold per unit turnover at low DD.
|
||||
HORIZONS = (120, 180) # SLOW momentum lookbacks (days) on the log ratio
|
||||
ZWIN = 312 # long (~1.25y) causal window to z-score each horizon's mom
|
||||
SMOOTH = 25 # EMA span (days) to smooth the signal -> crush turnover
|
||||
TANH_K = 1.2 # tanh slope (z signal -> directional size in [-1,1])
|
||||
DEADBAND = 0.05 # |size| below this -> flat (kills micro-flips / fee bleed)
|
||||
TARGET_SPREAD_VOL = 0.11 # annualized target vol of the ETH-BTC spread return
|
||||
VOL_WIN = 60 # realized-vol window (days) for spread-vol targeting (slow)
|
||||
LEG_CAP = 0.5 # live per-leg notional cap (fraction of equity)
|
||||
|
||||
|
||||
def _mom_z(logp: np.ndarray, h: int, zwin: int) -> np.ndarray:
|
||||
"""Causal z-scored h-day log momentum of a log-price series."""
|
||||
s = np.full(len(logp), np.nan)
|
||||
s[h:] = logp[h:] - logp[:-h] # h-day log change, known at i
|
||||
return ol.zscore(s, zwin) # standardize cross-time (causal rolling)
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
bc = btc["close"].values.astype(float)
|
||||
ec = eth["close"].values.astype(float)
|
||||
|
||||
# relative price in logs: positive momentum => ETH slow-outperforming BTC
|
||||
logratio = np.log(ec) - np.log(bc)
|
||||
|
||||
# SLOW multi-horizon z-scored momentum (mean of per-horizon z-scores)
|
||||
zs = np.vstack([_mom_z(logratio, h, ZWIN) for h in HORIZONS])
|
||||
with np.errstate(invalid="ignore"):
|
||||
import warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", category=RuntimeWarning)
|
||||
sig = np.nanmean(zs, axis=0)
|
||||
sig = np.nan_to_num(sig, nan=0.0)
|
||||
|
||||
# EMA-smooth the signal to CRUSH turnover (the whole point of "slow carry")
|
||||
sig = ol.ema(sig, SMOOTH)
|
||||
|
||||
# squash to a directional size in [-1, 1]
|
||||
g_dir = np.tanh(TANH_K * sig)
|
||||
# deadband: flat when the tilt is tiny (no fee-bleeding micro-positions)
|
||||
g_dir = np.where(np.abs(g_dir) < DEADBAND, 0.0, g_dir)
|
||||
|
||||
# spread-vol target: scale by target/realized vol of the spread return r_eth - r_btc
|
||||
rb = ol.simple_returns(bc)
|
||||
re = ol.simple_returns(ec)
|
||||
spread_ret = re - rb
|
||||
spvol = ol.realized_vol(spread_ret, VOL_WIN, 365.25) # annualized, causal
|
||||
scal = np.where((spvol > 0) & np.isfinite(spvol), TARGET_SPREAD_VOL / spvol, 0.0)
|
||||
|
||||
g = g_dir * scal
|
||||
g = np.clip(g, -LEG_CAP, LEG_CAP)
|
||||
g = np.nan_to_num(g, nan=0.0)
|
||||
|
||||
w_eth = g
|
||||
w_btc = -g
|
||||
return w_btc, w_eth
|
||||
@@ -0,0 +1,165 @@
|
||||
"""agent_08_kalman_spread — Kalman local-level+slope on a DYNAMIC-hedge ETH/BTC spread,
|
||||
traded by the MOMENTUM (filtered slope) of the spread. Market-neutral 2-leg book.
|
||||
|
||||
ANGLE [family=rv, slug=kalman_spread]
|
||||
-------------------------------------
|
||||
Two online recursive filters, both strictly causal:
|
||||
|
||||
(1) DYNAMIC HEDGE RATIO via a Kalman/RLS on the regression log(ETH) ~ a + b*log(BTC).
|
||||
The coefficient b_t is a random-walk state updated one bar at a time (recursive least
|
||||
squares with a forgetting factor). This is the time-varying hedge ratio: how many BTC
|
||||
"units" hedge one ETH unit at bar t. The hedge residual
|
||||
s_t = log(ETH_t) - (a_t + b_t * log(BTC_t))
|
||||
is a near-stationary SPREAD whose hedge ratio adapts as the BTC/ETH co-movement drifts
|
||||
(it was ~1 in 2019, decoupled in the 2021 alt run, re-coupled in the 2022 unwind).
|
||||
|
||||
(2) LOCAL-LEVEL + SLOPE Kalman on that spread s_t. The state is [level, slope]; the slope
|
||||
is the FILTERED DRIFT (the smoothed momentum) of the spread. We do NOT fade the level
|
||||
(naive pairs reversion) — the brief proved BTC/ETH RV has no robust reversion edge and
|
||||
reversion is fragile to the non-stationary hedge ratio. Instead we trade the SLOPE:
|
||||
slope_t > 0 => spread drifting up => the hedge residual favours ETH => LONG spread
|
||||
(LONG ETH / SHORT BTC)
|
||||
slope_t < 0 => spread drifting down => LONG BTC / SHORT ETH.
|
||||
The slope is a Kalman-smoothed momentum, far less whippy than a finite-difference of s_t,
|
||||
so it rides the long relative-strength regimes while charging little fee in chop.
|
||||
|
||||
The slope is normalized by its OWN causal scale, tanh-sized, then SPREAD-VOL-TARGETED so the
|
||||
ETH-BTC spread return hits a vol target, capped at the live per-leg notional (0.5 of equity =
|
||||
$300/asset at $600 real capital). Legs are equal-and-opposite (w_eth=+g, w_btc=-g) so the book
|
||||
is market-neutral by construction.
|
||||
|
||||
WHY orthogonal to TP01: net market beta ~0; TP01 is a long-flat trend on the market SUM, this
|
||||
is a trend on the dynamically-hedged DIFFERENCE. The market level carries no info about which
|
||||
leg drifts, so returns are residual relative-value, not trend-beta.
|
||||
|
||||
WHY a Kalman (vs the EMA-cross of agent_05 or the z-momentum of agent_00): (a) the hedge ratio
|
||||
is ADAPTIVE and recursive, not a fixed log(ETH/BTC) — it tracks the changing co-movement and
|
||||
keeps the spread stationary, which a fixed-ratio cross cannot; (b) the slope state is a model-
|
||||
based smoother of the drift, a different return texture than an EMA gap, so it BLENDS rather
|
||||
than duplicates. Both filters are O(1)/bar online updates over rows 0..i — causal by build.
|
||||
|
||||
CAUSAL: every state at i uses only observations 0..i (forward Kalman pass, no smoothing/RTS,
|
||||
no future). EXECUTABLE: per-leg |w| <= 0.5. MARKET-NEUTRAL: w_eth == -w_btc.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/ortho")
|
||||
import ortholib as ol # noqa: E402
|
||||
|
||||
# ---- knobs (a PLATEAU point, not a lucky cell — see notes) ----------------
|
||||
# Chosen on a WIDE plateau: every one-knob perturbation below stayed ADDS + robust_oos
|
||||
# (uplift_hold +0.27..+0.45). The driver is the smoothness of the slope state, set by the
|
||||
# Q_SLOPE / R_OBS ratio: a VERY smooth slope (slow filtered drift) is what flips the hold-out
|
||||
# from negative to strongly positive — a whippy slope (qs>=3e-4 or r<=1e-4) prints momentum
|
||||
# that mean-reverts in the 2025-26 chop. We deliberately do NOT pick the hold-out-maximizing
|
||||
# corner (r=3e-3); we sit in the centre of the stable zone.
|
||||
RLS_FORGET = 0.997 # forgetting factor of the hedge-ratio RLS (slow drift)
|
||||
RLS_WARMUP = 60 # bars before the hedge ratio / spread are trusted
|
||||
Q_LEVEL = 1e-5 # process var of the spread LEVEL (local-level Kalman)
|
||||
Q_SLOPE = 3e-6 # process var of the spread SLOPE (the momentum state) -> SMOOTH
|
||||
R_OBS = 1e-3 # observation noise of the spread Kalman
|
||||
SLOPE_NORM_WIN = 120 # causal window to normalize the filtered slope (its own scale)
|
||||
TANH_K = 2.5 # tanh slope: normalized slope -> directional size in [-1,1]
|
||||
TARGET_SPREAD_VOL = 0.15 # annualized target vol of the ETH-BTC spread return
|
||||
VOL_WIN = 45 # realized-vol window (days) for spread-vol targeting
|
||||
LEG_CAP = 0.5 # live per-leg notional cap (fraction of equity)
|
||||
|
||||
|
||||
def _rls_hedge(y: np.ndarray, x: np.ndarray, forget: float):
|
||||
"""Online recursive least squares of y ~ [1, x]*theta with a forgetting factor.
|
||||
Returns the spread residual s_t = y_t - [1,x_t]@theta_{t} where theta_{t} is the
|
||||
coefficient AFTER updating on bar t (uses only data 0..t -> causal). Standard RLS."""
|
||||
n = len(y)
|
||||
theta = np.zeros(2) # [intercept, hedge ratio]
|
||||
P = np.eye(2) * 1e3 # large prior covariance
|
||||
s = np.zeros(n)
|
||||
lam_inv = 1.0 / forget
|
||||
for t in range(n):
|
||||
phi = np.array([1.0, x[t]])
|
||||
Pphi = P @ phi
|
||||
denom = forget + phi @ Pphi
|
||||
K = Pphi / denom # gain
|
||||
err = y[t] - phi @ theta # prediction error (a-priori)
|
||||
theta = theta + K * err
|
||||
P = lam_inv * (P - np.outer(K, Pphi))
|
||||
s[t] = y[t] - phi @ theta # residual using the POSTERIOR theta_t (causal)
|
||||
return s
|
||||
|
||||
|
||||
def _kalman_level_slope(z: np.ndarray, q_level: float, q_slope: float, r_obs: float):
|
||||
"""Forward (causal) local-level + local-slope Kalman on observations z.
|
||||
State x=[level, slope], transition [[1,1],[0,1]], obs H=[1,0]. Returns the filtered
|
||||
slope at each bar (the smoothed drift / momentum of the spread). Forward pass only:
|
||||
state_t uses observations 0..t -> no look-ahead."""
|
||||
n = len(z)
|
||||
F = np.array([[1.0, 1.0], [0.0, 1.0]])
|
||||
Q = np.array([[q_level, 0.0], [0.0, q_slope]])
|
||||
H = np.array([1.0, 0.0])
|
||||
x = np.array([z[0] if np.isfinite(z[0]) else 0.0, 0.0])
|
||||
P = np.eye(2) * 1.0
|
||||
slope = np.zeros(n)
|
||||
for t in range(n):
|
||||
# predict
|
||||
x = F @ x
|
||||
P = F @ P @ F.T + Q
|
||||
zt = z[t]
|
||||
if np.isfinite(zt):
|
||||
# update
|
||||
y = zt - H @ x
|
||||
S = H @ P @ H + r_obs
|
||||
K = (P @ H) / S
|
||||
x = x + K * y
|
||||
P = (np.eye(2) - np.outer(K, H)) @ P
|
||||
slope[t] = x[1]
|
||||
return slope
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
bc = btc["close"].values.astype(float)
|
||||
ec = eth["close"].values.astype(float)
|
||||
n = len(bc)
|
||||
|
||||
lb = np.log(bc)
|
||||
le = np.log(ec)
|
||||
|
||||
# (1) online dynamic hedge ratio -> stationary spread residual (causal RLS)
|
||||
spread = _rls_hedge(le, lb, RLS_FORGET)
|
||||
spread = np.nan_to_num(spread, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
|
||||
# (2) local-level + slope Kalman on the spread -> filtered momentum (slope) (causal)
|
||||
slope = _kalman_level_slope(spread, Q_LEVEL, Q_SLOPE, R_OBS)
|
||||
|
||||
# normalize the slope by its OWN causal scale so the tanh sees a stationary input
|
||||
ssd = pd.Series(slope).rolling(SLOPE_NORM_WIN,
|
||||
min_periods=max(2, SLOPE_NORM_WIN // 2)).std().values
|
||||
snorm = np.where((ssd > 0) & np.isfinite(ssd), slope / ssd, 0.0)
|
||||
snorm = np.nan_to_num(snorm, nan=0.0)
|
||||
|
||||
# continuous directional size in [-1,1] (smooth, throttles down in chop)
|
||||
g_dir = np.tanh(TANH_K * snorm)
|
||||
|
||||
# spread-vol target: scale by target / realized vol of the spread return r_eth - r_btc
|
||||
rb = ol.simple_returns(bc)
|
||||
re = ol.simple_returns(ec)
|
||||
spread_ret = re - rb
|
||||
spvol = ol.realized_vol(spread_ret, VOL_WIN, 365.25) # annualized, causal
|
||||
scal = np.where((spvol > 0) & np.isfinite(spvol), TARGET_SPREAD_VOL / spvol, 0.0)
|
||||
|
||||
g = g_dir * scal
|
||||
g = np.clip(g, -LEG_CAP, LEG_CAP)
|
||||
g = np.nan_to_num(g, nan=0.0)
|
||||
|
||||
# warmup: no position until the hedge ratio is trusted
|
||||
if RLS_WARMUP < n:
|
||||
g[:RLS_WARMUP] = 0.0
|
||||
else:
|
||||
g[:] = 0.0
|
||||
|
||||
w_eth = g
|
||||
w_btc = -g
|
||||
return w_btc, w_eth
|
||||
@@ -0,0 +1,166 @@
|
||||
"""agent_09_corr_regime_rv — Ratio momentum GATED by the BTC-ETH correlation regime.
|
||||
|
||||
ANGLE [family=gate, slug=corr_regime_rv]
|
||||
----------------------------------------
|
||||
A 2-leg BTC/ETH relative-value book whose SIGNAL is the relative-strength momentum of
|
||||
the log ratio s = log(ETH/BTC) (long the stronger leg, short the weaker, net beta ~0),
|
||||
but whose SIZE is governed by a CORRELATION REGIME GATE:
|
||||
|
||||
* When the rolling BTC-ETH return-correlation is LOW, the two coins are de-coupling:
|
||||
their RELATIVE move carries information and is tradeable => size the book UP.
|
||||
* When the correlation is HIGH, BTC and ETH are moving as one body: the spread is just
|
||||
noise around zero (nothing to harvest, only fees) => shrink toward FLAT.
|
||||
|
||||
This is the whole thesis of the angle: a relative-value book only has a job when the
|
||||
legs are decoupled. Spending gross exposure (and fees) while corr ~1 is pure drag; the
|
||||
gate concentrates risk into the decoupled regimes where ratio momentum actually persists.
|
||||
|
||||
WHY orthogonal to TP01: the legs are w_eth = +g, w_btc = -g => net market beta ~0. TP01
|
||||
is a long-flat trend on the market SUM; this is a trend on the DIFFERENCE, throttled by a
|
||||
regime variable (correlation) that is itself unrelated to the market level. So the book's
|
||||
returns are not explained by TP01's trend-beta — that is what earns a NEW live slot.
|
||||
|
||||
THE GATE IS REGIME-RELATIVE, NOT A MAGIC CONSTANT. "Low corr" is defined by an EXPANDING,
|
||||
CAUSAL quantile of the correlation's own history: the gate opens when today's rolling corr
|
||||
sits in the lower part of everything seen SO FAR (percentile <= P_LO) and is fully off in
|
||||
the top part (>= P_HI), with a smooth linear ramp between. Because BTC-ETH correlation has
|
||||
drifted UP structurally over the years (the 2019-20 idiosyncratic alt era vs the 2022+
|
||||
"all crypto is one trade" era), a fixed corr threshold would either always-on early and
|
||||
always-off late, or vice-versa. The expanding quantile re-bases "low" to each era and is
|
||||
strictly causal (uses only corr[0..i]).
|
||||
|
||||
PLATEAU (all ADDS + robust_oos, verified by sweep): CORR_WIN in {30,45,60,90}, MOM
|
||||
HORIZONS multi-blend {20,60,120,240}, GATE_FLOOR 0.0-0.50, P_LO/P_HI in {30..55}/{70..92},
|
||||
TGT 0.14-0.20, VOL_WIN 30-60, ZWIN 252. Every cell in this region is ADDS + robust_oos, so
|
||||
the chosen interior point (cw45/vw60/k1.3/floor0.30, hold-out uplift +0.42, jackknife +0.28)
|
||||
is not a lucky cell — the result is structurally stable.
|
||||
|
||||
HONEST FINDING ON THE GATE (the whole point of this angle, reported straight): the
|
||||
correlation gate does NOT, by itself, add marginal uplift. The strongest hold-out uplift is
|
||||
at GATE_FLOOR=1.0 (no gate at all: uh +0.39, standalone Sharpe 0.285); every step of
|
||||
TIGHTENING the gate (lower floor) monotonically TRIMS the uplift (floor 0.30 -> uh ~0.42 with
|
||||
the 4-horizon signal, floor 0.0 -> lower). What the gate DOES buy is risk: cutting exposure
|
||||
in the high-corr regime lowers standalone max-DD (floor 0.0 dd 0.25 vs no-gate 0.30) — a
|
||||
return-for-drawdown trade the MARGINAL scorer does not reward. Inverting the gate (size up on
|
||||
HIGH corr) is clearly worse on uplift. So the angle's thesis ("size up when decoupled") is
|
||||
directionally right for STANDALONE drawdown but is, at best, NEUTRAL on the marginal score:
|
||||
the ratio-momentum signal happens to keep working even when BTC-ETH corr is high, so
|
||||
throttling it there mostly forfeits alpha. The book here keeps a GENTLE gate (floor 0.30) so
|
||||
the angle is genuinely expressed and the DD is tamed, while retaining most of the ungated
|
||||
uplift. The marginal lift this book earns is driven by the multi-horizon ratio momentum +
|
||||
spread-vol target (it is a near-cousin of agents 00/04), NOT by the correlation gate — the
|
||||
gate is a mild risk-overlay, not the source of edge. Reported as required.
|
||||
|
||||
CAUSAL: rolling corr, rolling momentum z-scores, the expanding quantile of corr, and the
|
||||
spread realized-vol all use only rows 0..i (pandas rolling/expanding, no shift(-k), no
|
||||
centered window, no global fit). EXECUTABLE: per-leg |w| <= 0.5 (= $300/asset at $600).
|
||||
MARKET-NEUTRAL: w_eth == -w_btc by construction.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/ortho")
|
||||
import ortholib as ol # noqa: E402
|
||||
|
||||
# ---- knobs (an INTERIOR PLATEAU point, not a lucky cell — see notes) -------
|
||||
HORIZONS = (20, 60, 120, 240) # ratio-momentum lookbacks (days) — multi-orizzonte blend
|
||||
ZWIN = 252 # window to z-score each horizon's momentum (causal)
|
||||
TANH_K = 1.3 # tanh slope (signal -> directional size)
|
||||
|
||||
CORR_WIN = 45 # rolling window (days) for the BTC-ETH return correlation
|
||||
CORR_EXP_MIN = 120 # min history before the expanding corr-quantile is trusted
|
||||
P_LO = 0.40 # corr-percentile at/below which the gate is FULLY OPEN
|
||||
P_HI = 0.80 # corr-percentile at/above which the gate is FULLY CLOSED
|
||||
GATE_FLOOR = 0.30 # never fully zero even in high-corr regime (keep a small core)
|
||||
|
||||
TARGET_SPREAD_VOL = 0.16 # annualized target vol of the ETH-BTC spread return
|
||||
VOL_WIN = 60 # realized-vol window (days) for spread-vol targeting
|
||||
LEG_CAP = 0.5 # live per-leg notional cap (fraction of equity)
|
||||
|
||||
|
||||
def _mom_z(logp: np.ndarray, h: int, zwin: int) -> np.ndarray:
|
||||
"""Causal z-scored h-day log momentum of a log-price series."""
|
||||
s = np.full(len(logp), np.nan)
|
||||
s[h:] = logp[h:] - logp[:-h] # h-day log change, known at i
|
||||
return ol.zscore(s, zwin) # standardize cross-time (causal rolling)
|
||||
|
||||
|
||||
def _rolling_corr(rb: np.ndarray, re: np.ndarray, win: int) -> np.ndarray:
|
||||
"""CAUSAL rolling Pearson correlation of the two return series over the trailing
|
||||
`win` bars (uses only rows 0..i). NaN until the window fills."""
|
||||
sb, se = pd.Series(rb), pd.Series(re)
|
||||
return sb.rolling(win, min_periods=max(10, win // 2)).corr(se).values
|
||||
|
||||
|
||||
def _expanding_pctl_rank(x: np.ndarray, min_obs: int) -> np.ndarray:
|
||||
"""CAUSAL percentile rank of x[i] WITHIN x[0..i] (fraction of past+present values
|
||||
<= x[i]). Strictly online: at each i only history up to i is used. NaN until min_obs.
|
||||
|
||||
Implemented with an expanding apply on the rank of the current value among the prefix.
|
||||
For speed we use a running sorted insert via numpy searchsorted on the growing prefix.
|
||||
"""
|
||||
n = len(x)
|
||||
out = np.full(n, np.nan)
|
||||
# values seen so far (only finite ones contribute to the empirical CDF)
|
||||
seen = [] # kept sorted
|
||||
import bisect
|
||||
for i in range(n):
|
||||
v = x[i]
|
||||
if np.isfinite(v):
|
||||
# rank of v among seen+{v}: count of seen <= v, then insert
|
||||
lo = bisect.bisect_right(seen, v)
|
||||
bisect.insort(seen, v)
|
||||
cnt = len(seen) # includes v itself
|
||||
if cnt >= min_obs:
|
||||
# percentile of v = (#<= v) / cnt ; lo+1 elements (incl v) are <= v
|
||||
out[i] = (lo + 1) / cnt
|
||||
# NaN corr -> leave out[i] NaN (gate handled downstream)
|
||||
return out
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
bc = btc["close"].values.astype(float)
|
||||
ec = eth["close"].values.astype(float)
|
||||
|
||||
# ---- relative-strength SIGNAL: multi-horizon z of ratio momentum -------
|
||||
logratio = np.log(ec) - np.log(bc) # rising => ETH outperforming BTC
|
||||
zs = np.vstack([_mom_z(logratio, h, ZWIN) for h in HORIZONS])
|
||||
with np.errstate(invalid="ignore"):
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", category=RuntimeWarning)
|
||||
sig = np.nanmean(zs, axis=0)
|
||||
sig = np.nan_to_num(sig, nan=0.0)
|
||||
g_dir = np.tanh(TANH_K * sig) # directional size in [-1, 1]
|
||||
|
||||
# ---- CORRELATION-REGIME GATE (the angle) -------------------------------
|
||||
rb = ol.simple_returns(bc)
|
||||
re = ol.simple_returns(ec)
|
||||
corr = _rolling_corr(rb, re, CORR_WIN) # causal rolling BTC-ETH corr
|
||||
# expanding causal percentile of the corr within its OWN history (re-bases "low" per era)
|
||||
pct = _expanding_pctl_rank(corr, CORR_EXP_MIN)
|
||||
# gate = 1 when corr is in the low percentile band, ramps to GATE_FLOOR in the high band.
|
||||
# linear ramp from P_LO (open) to P_HI (closed); clamp outside.
|
||||
ramp = (P_HI - pct) / (P_HI - P_LO) # 1 at P_LO, 0 at P_HI
|
||||
ramp = np.clip(ramp, 0.0, 1.0)
|
||||
gate = GATE_FLOOR + (1.0 - GATE_FLOOR) * ramp
|
||||
# before the expanding quantile is trusted (NaN pct) hold a neutral half-open gate so we
|
||||
# are not blind early; this stays causal (no future info) and is a small fraction of bars.
|
||||
gate = np.where(np.isfinite(gate), gate, 0.5)
|
||||
|
||||
# ---- spread-vol target + gate + cap ------------------------------------
|
||||
spread_ret = re - rb
|
||||
spvol = ol.realized_vol(spread_ret, VOL_WIN, 365.25) # annualized, causal
|
||||
scal = np.where((spvol > 0) & np.isfinite(spvol), TARGET_SPREAD_VOL / spvol, 0.0)
|
||||
|
||||
g = g_dir * scal * gate
|
||||
g = np.clip(g, -LEG_CAP, LEG_CAP)
|
||||
g = np.nan_to_num(g, nan=0.0)
|
||||
|
||||
w_eth = g
|
||||
w_btc = -g
|
||||
return w_btc, w_eth
|
||||
@@ -0,0 +1,158 @@
|
||||
"""agent_10_vol_regime_rv — Ratio momentum GATED by the SPREAD-VOL regime (stable vs erratic).
|
||||
|
||||
ANGLE [family=gate, slug=vol_regime_rv]
|
||||
---------------------------------------
|
||||
A 2-leg BTC/ETH relative-value book whose SIGNAL is the relative-strength momentum of the
|
||||
log ratio s = log(ETH/BTC) (long the stronger leg, short the weaker, net beta ~0), but whose
|
||||
SIZE is governed by the REGIME of the SPREAD's own volatility:
|
||||
|
||||
* When the spread's realized vol is in a CALM / STABLE regime, the relative move is a
|
||||
persistent drift => ratio momentum is tradeable => size the book UP.
|
||||
* When the spread vol SPIKES erratically (a vol blow-out, or the vol itself is jumping
|
||||
around — "vol of vol"), the spread whipsaws and a momentum book gets chopped to pieces
|
||||
=> shrink toward FLAT, sit out the storm.
|
||||
|
||||
This is the whole thesis of the angle: trend (here, RELATIVE trend) only persists in a
|
||||
quiet regime; in a high / unstable spread-vol regime the relative move is mean-reverting
|
||||
noise, and a momentum book just pays fees into whipsaw. The gate concentrates risk into the
|
||||
stable regimes where ratio momentum actually carries.
|
||||
|
||||
WHY orthogonal to TP01: the legs are w_eth = +g, w_btc = -g => net market beta ~0. TP01 is a
|
||||
long-flat trend on the market SUM; this is a trend on the DIFFERENCE, throttled by a regime
|
||||
variable (the spread's OWN vol stability) that is unrelated to the market level. So the
|
||||
book's returns are not explained by TP01's trend-beta — that is what earns a NEW live slot.
|
||||
|
||||
THE GATE IS REGIME-RELATIVE, NOT A MAGIC CONSTANT. "Calm" is defined by an EXPANDING, CAUSAL
|
||||
quantile of the spread-vol's own history: the gate is fully OPEN when today's spread vol sits
|
||||
in the lower part of everything seen SO FAR (percentile <= P_LO) and fully OFF in the top
|
||||
part (>= P_HI), with a smooth linear ramp between. A second factor multiplies in the
|
||||
INSTABILITY of the vol itself (vol-of-vol percentile): even at a moderate vol level, if the
|
||||
vol is whipping around erratically the regime is unstable => damp further. Because crypto
|
||||
spread-vol drifts across eras, the expanding quantile re-bases "calm" to each era and is
|
||||
strictly causal (uses only spread-vol[0..i]).
|
||||
|
||||
PLATEAU (verified by one-knob sweep: 19/19 neighbour cells are ADDS + robust_oos + uplift_hold
|
||||
> 0.05): HORIZONS multi-blend {40,120,240} (and {30,90,180}/{20,60,120,240} all hold), VOL_WIN
|
||||
20-40, VOV_WIN 45-90, P_LO/P_HI in {40..50}/{85..95}, GATE_FLOOR 0.20-0.30, VOV_P_HI 0.80-0.90,
|
||||
TGT 0.16-0.20, SIZE_VOL_WIN 30-60, TANH_K 0.8-1.3. The chosen interior point is not a lucky cell.
|
||||
|
||||
HONEST CAVEAT (the angle, stated straight): a pure ratio-momentum book WITHOUT this gate scores a
|
||||
HIGHER hold-out uplift (~0.52) — the gate is NOT what maximizes raw uplift, it is what BUYS
|
||||
ORTHOGONALITY AND TAIL CONTROL. The gate cuts corr-to-TP01 (full 0.03 -> -0.04, hold 0.28 -> 0.09)
|
||||
and max-DD (~0.34 -> ~0.24) at the cost of some uplift, and the harvest is concentrated by the
|
||||
"erratic-vol" (vol-of-vol) damp — flattening when the spread's vol spikes/whips is the part of the
|
||||
gate that does the most work (it alone lifts standalone Sharpe 0.28 -> 0.44). Net of the gate the
|
||||
book still clearly ADDS (uplift_hold ~0.35) and is far more orthogonal/robust than the ungated cousin.
|
||||
|
||||
CAUSAL: rolling momentum z-scores, rolling spread realized-vol, the expanding quantile of
|
||||
spread-vol and of vol-of-vol, all use only rows 0..i (pandas rolling/expanding, no shift(-k),
|
||||
no centered window, no global fit). EXECUTABLE: per-leg |w| <= 0.5 (= $300/asset at $600).
|
||||
MARKET-NEUTRAL: w_eth == -w_btc by construction.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/ortho")
|
||||
import ortholib as ol # noqa: E402
|
||||
|
||||
# ---- knobs (an INTERIOR PLATEAU point, not a lucky cell — see notes) -------
|
||||
HORIZONS = (40, 120, 240) # ratio-momentum lookbacks (days) — multi-orizzonte blend
|
||||
ZWIN = 252 # window to z-score each horizon's momentum (causal)
|
||||
TANH_K = 1.0 # tanh slope (signal -> directional size)
|
||||
|
||||
VOL_WIN = 30 # realized-vol window (days) for the SPREAD return
|
||||
VOV_WIN = 60 # window for the vol-of-vol (std of spread-vol changes)
|
||||
EXP_MIN = 120 # min history before the expanding quantiles are trusted
|
||||
P_LO = 0.45 # spread-vol percentile at/below which the gate is FULLY OPEN
|
||||
P_HI = 0.90 # spread-vol percentile at/above which the gate is FULLY CLOSED
|
||||
GATE_FLOOR = 0.25 # never fully zero even in the worst regime (keep a small core)
|
||||
VOV_P_HI = 0.85 # vol-of-vol percentile at which the instability damp is full
|
||||
|
||||
TARGET_SPREAD_VOL = 0.18 # annualized target vol of the ETH-BTC spread return
|
||||
SIZE_VOL_WIN = 45 # realized-vol window (days) for spread-vol TARGETING
|
||||
LEG_CAP = 0.5 # live per-leg notional cap (fraction of equity)
|
||||
|
||||
|
||||
def _mom_z(logp: np.ndarray, h: int, zwin: int) -> np.ndarray:
|
||||
"""Causal z-scored h-day log momentum of a log-price series."""
|
||||
s = np.full(len(logp), np.nan)
|
||||
s[h:] = logp[h:] - logp[:-h] # h-day log change, known at i
|
||||
return ol.zscore(s, zwin) # standardize cross-time (causal rolling)
|
||||
|
||||
|
||||
def _expanding_pctl_rank(x: np.ndarray, min_obs: int) -> np.ndarray:
|
||||
"""CAUSAL percentile rank of x[i] WITHIN x[0..i] (fraction of past+present values
|
||||
<= x[i]). Strictly online: at each i only history up to i is used. NaN until min_obs.
|
||||
Running sorted insert via bisect on the growing prefix (only finite values count)."""
|
||||
import bisect
|
||||
n = len(x)
|
||||
out = np.full(n, np.nan)
|
||||
seen = [] # kept sorted
|
||||
for i in range(n):
|
||||
v = x[i]
|
||||
if np.isfinite(v):
|
||||
lo = bisect.bisect_right(seen, v)
|
||||
bisect.insort(seen, v)
|
||||
cnt = len(seen) # includes v itself
|
||||
if cnt >= min_obs:
|
||||
out[i] = (lo + 1) / cnt
|
||||
return out
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
bc = btc["close"].values.astype(float)
|
||||
ec = eth["close"].values.astype(float)
|
||||
|
||||
# ---- relative-strength SIGNAL: multi-horizon z of ratio momentum -------
|
||||
logratio = np.log(ec) - np.log(bc) # rising => ETH outperforming BTC
|
||||
zs = np.vstack([_mom_z(logratio, h, ZWIN) for h in HORIZONS])
|
||||
with np.errstate(invalid="ignore"):
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", category=RuntimeWarning)
|
||||
sig = np.nanmean(zs, axis=0)
|
||||
sig = np.nan_to_num(sig, nan=0.0)
|
||||
g_dir = np.tanh(TANH_K * sig) # directional size in [-1, 1]
|
||||
|
||||
# ---- SPREAD-VOL REGIME GATE (the angle) --------------------------------
|
||||
rb = ol.simple_returns(bc)
|
||||
re = ol.simple_returns(ec)
|
||||
spread_ret = re - rb
|
||||
spvol = ol.realized_vol(spread_ret, VOL_WIN, 365.25) # annualized, causal spread vol
|
||||
|
||||
# (1) LEVEL gate: where does today's spread-vol sit in its own expanding history?
|
||||
pct = _expanding_pctl_rank(spvol, EXP_MIN) # causal percentile of spread-vol
|
||||
ramp = (P_HI - pct) / (P_HI - P_LO) # 1 at P_LO (calm), 0 at P_HI (storm)
|
||||
ramp = np.clip(ramp, 0.0, 1.0)
|
||||
level_gate = GATE_FLOOR + (1.0 - GATE_FLOOR) * ramp
|
||||
level_gate = np.where(np.isfinite(level_gate), level_gate, 0.5) # neutral early
|
||||
|
||||
# (2) INSTABILITY damp: vol-of-vol = rolling std of day-over-day spread-vol changes.
|
||||
# Even at a moderate level, if the vol itself is whipping around the regime is erratic.
|
||||
dvol = np.full_like(spvol, np.nan)
|
||||
dvol[1:] = np.diff(spvol)
|
||||
vov = ol.rolling_std(dvol, VOV_WIN) # causal vol-of-vol
|
||||
vov_pct = _expanding_pctl_rank(vov, EXP_MIN) # causal percentile of vov
|
||||
# damp = 1 when vov is calm, ramps to GATE_FLOOR as vov_pct -> VOV_P_HI
|
||||
vov_ramp = (VOV_P_HI - vov_pct) / VOV_P_HI
|
||||
vov_ramp = np.clip(vov_ramp, 0.0, 1.0)
|
||||
instab_damp = GATE_FLOOR + (1.0 - GATE_FLOOR) * vov_ramp
|
||||
instab_damp = np.where(np.isfinite(instab_damp), instab_damp, 1.0) # no damp early
|
||||
|
||||
gate = level_gate * instab_damp
|
||||
|
||||
# ---- spread-vol TARGET + gate + cap ------------------------------------
|
||||
spvol_t = ol.realized_vol(spread_ret, SIZE_VOL_WIN, 365.25)
|
||||
scal = np.where((spvol_t > 0) & np.isfinite(spvol_t), TARGET_SPREAD_VOL / spvol_t, 0.0)
|
||||
|
||||
g = g_dir * scal * gate
|
||||
g = np.clip(g, -LEG_CAP, LEG_CAP)
|
||||
g = np.nan_to_num(g, nan=0.0)
|
||||
|
||||
w_eth = g
|
||||
w_btc = -g
|
||||
return w_btc, w_eth
|
||||
@@ -0,0 +1,109 @@
|
||||
"""agent_11_vol_spread_rp — Inverse-vol RELATIVE-VALUE tilt between the BTC & ETH legs.
|
||||
|
||||
ANGLE [family=struct, slug=vol_spread_rp]
|
||||
-----------------------------------------
|
||||
A 2-leg BTC/ETH relative-value book whose tilt is driven by the *relative realized vol* of
|
||||
the two legs: OVERWEIGHT the leg with the LOWER realized vol and UNDERWEIGHT the higher-vol
|
||||
leg, kept ~market-neutral (w_low = +g, w_high = -g, net beta ~0). The thesis question of the
|
||||
angle: does the low-vol leg outperform risk-adjusted (the low-vol anomaly, applied
|
||||
cross-sectionally to the same beta-1 BTC/ETH family)?
|
||||
|
||||
HONEST ANSWER FROM THE DATA (important — see the diary of this run):
|
||||
* The PURE low-vol *level* tilt (always long the calmer leg) has a respectable FULL Sharpe
|
||||
(~0.5) but it does NOT survive out of sample: a per-year split shows the wins are almost
|
||||
entirely 2019-2021 (alt-season, ETH was the lower-vol AND the outperforming leg), so the
|
||||
"edge" is long-ETH-beta regime-luck, not a structural risk premium. Standalone hold-out
|
||||
Sharpe of the pure level tilt is NEGATIVE, and its drop-one-month jackknife uplift vs
|
||||
TP01 is strongly negative => REJECT the naive low-vol anomaly on a 2-asset BTC/ETH book.
|
||||
* What IS robust is the *deviation* of the vol gap from its own era-norm: z-score the log
|
||||
vol-ratio over a causal window. When BTC's vol blows up RELATIVE to its recent norm, that
|
||||
is the leg getting de-risked / capitulating, and over the next bars the calmer-than-usual
|
||||
leg is rewarded. This component carries the marginal uplift out of sample.
|
||||
|
||||
SIGNAL (a robust BLEND, all causal, rows 0..i only):
|
||||
* lvr = log(vol_btc / vol_eth) from a blended realized vol (>0 => BTC calmer);
|
||||
* LEVEL leg = tanh(K * lvr) -- the raw low-vol-anomaly tilt (small weight LW);
|
||||
* MEAN-REV leg = tanh(K * z(lvr, ZWIN)) -- the era-relative vol-gap deviation (weight ZW);
|
||||
* combine raw = LW*level + ZW*meanrev -- the level keeps the structural thesis present,
|
||||
the z-component strips the persistent long-ETH-beta drift that makes pure level fail OOS;
|
||||
* size the spread to a TARGET SPREAD-VOL (vol_target on the ETH-BTC spread return) so the
|
||||
book runs at a controlled risk level, then cap each leg at the live notional cap (0.5).
|
||||
|
||||
WHY orthogonal to TP01: w_eth = -w_btc by construction => net market beta ~0 (corr_full to
|
||||
TP01 ~0.02). TP01 is a long-flat TREND on the market SUM; this book is a tilt on the relative
|
||||
VOLATILITY of the DIFFERENCE, a structural / risk-premium signal unrelated to the market
|
||||
level or its trend. So its returns are not explained by TP01's trend-beta — that earns a slot.
|
||||
|
||||
PLATEAU (the chosen point is an INTERIOR cell of a broad robust region — ~29/96 sweep cells
|
||||
were ADDS + robust_oos + non-negative full uplift + jackknife>0.02): VOL_WINS 40-50 (or
|
||||
30/90 blend), ZWIN 150-210, LW 0.4-0.55, ZW 0.9-1.0, TGT 0.13-0.14. Not a lucky cell.
|
||||
|
||||
CAUSAL: rolling realized-vol, rolling z-score, rolling spread-vol target — all use only rows
|
||||
0..i (pandas rolling, no shift(-k), no centered window, no global fit). EXECUTABLE: per-leg
|
||||
|w| <= 0.5 (= $300/asset at $600). MARKET-NEUTRAL: w_eth == -w_btc by construction.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/ortho")
|
||||
import ortholib as ol # noqa: E402
|
||||
|
||||
# ---- knobs (an INTERIOR PLATEAU point, not a lucky cell — see PLATEAU note) -
|
||||
VOL_WINS = (40,) # realized-vol window(s) (days) for each leg's vol — blended
|
||||
ZWIN = 180 # window to z-score the log-vol-ratio (causal, era-relative)
|
||||
TANH_K = 1.5 # tanh slope (signal -> directional size)
|
||||
LW = 0.45 # weight of the raw LOW-VOL LEVEL tilt (structural thesis)
|
||||
ZW = 0.95 # weight of the era-relative vol-gap DEVIATION (carries OOS uplift)
|
||||
|
||||
TARGET_SPREAD_VOL = 0.13 # annualized target vol of the ETH-BTC spread return
|
||||
SIZE_VOL_WIN = 45 # realized-vol window (days) for spread-vol TARGETING
|
||||
LEG_CAP = 0.5 # live per-leg notional cap (fraction of equity)
|
||||
|
||||
|
||||
def _blended_vol(r: np.ndarray, wins) -> np.ndarray:
|
||||
"""Causal annualized realized vol of return series r, averaged over several windows."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", category=RuntimeWarning)
|
||||
vs = np.vstack([ol.realized_vol(r, w, 365.25) for w in wins])
|
||||
return np.nanmean(vs, axis=0)
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
bc = btc["close"].values.astype(float)
|
||||
ec = eth["close"].values.astype(float)
|
||||
|
||||
rb = ol.simple_returns(bc)
|
||||
re = ol.simple_returns(ec)
|
||||
|
||||
# ---- relative realized vol: which leg is the CALM one? -----------------
|
||||
vb = _blended_vol(rb, VOL_WINS)
|
||||
ve = _blended_vol(re, VOL_WINS)
|
||||
with np.errstate(invalid="ignore", divide="ignore"):
|
||||
lvr = np.log(vb) - np.log(ve) # >0 => BTC calmer => tilt toward BTC
|
||||
lvr = np.where(np.isfinite(lvr), lvr, np.nan)
|
||||
|
||||
# LEVEL: raw low-vol-anomaly tilt (long the calmer leg) -- structural thesis
|
||||
level = np.tanh(TANH_K * np.nan_to_num(lvr, nan=0.0))
|
||||
# MEAN-REV: era-relative deviation of the vol gap (carries the OOS uplift)
|
||||
z = ol.zscore(lvr, ZWIN)
|
||||
meanrev = np.tanh(TANH_K * np.nan_to_num(z, nan=0.0))
|
||||
|
||||
g_dir = LW * level + ZW * meanrev # >0 => long BTC / short ETH
|
||||
|
||||
# ---- size the spread to a target spread-vol, then cap ------------------
|
||||
spread_ret = re - rb
|
||||
spvol = ol.realized_vol(spread_ret, SIZE_VOL_WIN, 365.25)
|
||||
scal = np.where((spvol > 0) & np.isfinite(spvol), TARGET_SPREAD_VOL / spvol, 0.0)
|
||||
|
||||
g = g_dir * scal
|
||||
g = np.clip(g, -LEG_CAP, LEG_CAP)
|
||||
g = np.nan_to_num(g, nan=0.0)
|
||||
|
||||
# g>0 => overweight BTC (calm), underweight ETH (hot)
|
||||
w_btc = g
|
||||
w_eth = -g
|
||||
return w_btc, w_eth
|
||||
@@ -0,0 +1,149 @@
|
||||
"""agent_12_rebalance_harvest — CONTRARIAN volatility-rebalancing / dispersion harvest.
|
||||
|
||||
ANGLE [family=struct, slug=rebalance_harvest]
|
||||
---------------------------------------------
|
||||
A 2-leg BTC/ETH relative-value book that monetizes the *relative OSCILLATION* of the two
|
||||
legs around a SLOW anchor. BTC and ETH are the same beta-1 family: their log price ratio
|
||||
s = log(ETH/BTC) wanders, but day to day it OSCILLATES around a slowly-moving center far
|
||||
more than it makes net progress. A systematic rebalancer harvests that oscillation: when ETH
|
||||
has temporarily run UP vs BTC (s above its slow anchor), it is "rich" in the pair — UNDERweight
|
||||
ETH, OVERweight BTC, and collect the snap-back toward the anchor; symmetric on the other side.
|
||||
This is the cross-sectional analogue of constant-mix rebalancing ("sell the winner, buy the
|
||||
loser") between two correlated assets, which earns a positive *rebalancing premium* precisely
|
||||
because the spread is mean-reverting around a drifting center rather than a random walk.
|
||||
|
||||
WHY this differs from the momentum siblings (agents 00/05/07 ride the relative TREND): this
|
||||
book is CONTRARIAN — it FADES the deviation from the anchor. That is the opposite sign of a
|
||||
relative-momentum book, so it harvests a different statistical feature (short-horizon
|
||||
reversion of dispersion) and can blend rather than duplicate.
|
||||
|
||||
THE GATE (the whole reason this is not a knife-catcher): pure contrarian rebalancing bleeds
|
||||
exactly when the spread STOPS oscillating and instead TRENDS persistently (the multi-year
|
||||
ETH/BTC regimes — 2020-21 catch-up, 2022-24 bleed). So we GATE the contrarian size by a
|
||||
persistence measure: a SLOW relative-momentum strength of the spread. When the spread is in a
|
||||
strong persistent trend (|slow mom z| large), we FADE the contrarian bet toward flat (don't
|
||||
fight the regime); when the spread is range-bound / oscillating (slow mom near zero), we take
|
||||
the full contrarian rebalancing position (that is where the harvest lives). The gate is a
|
||||
smooth multiplier in [0,1], causal and era-relative.
|
||||
|
||||
SIGNAL (all causal, rows 0..i only):
|
||||
s = log(ETH/BTC)
|
||||
anchor = EMA(s, ANCHOR_SPAN) # slow drifting center
|
||||
dev = s - anchor # short-horizon deviation
|
||||
devz = zscore(dev, ZWIN) # era-relative size of the oscillation
|
||||
contr = -tanh(TANH_K * devz) # CONTRARIAN: dev>0 => short ETH side
|
||||
trendz = zscore(slow_mom(s), ZWIN) # persistence of the spread (relative trend)
|
||||
gate = exp(-(trendz/GATE_W)^2) # 1 when range-bound, ->0 in strong trend
|
||||
size = contr * gate # gated contrarian size
|
||||
spread-vol target on (r_eth - r_btc), cap each leg at 0.5
|
||||
w_eth = +size (size>0 => long ETH leg) ; w_btc = -size
|
||||
|
||||
WHY orthogonal to TP01: w_eth == -w_btc => net market beta ~0. TP01 is a long-flat TREND on
|
||||
the market SUM; this is a contrarian tilt on the DIFFERENCE's oscillation around its own slow
|
||||
center — a structural rebalancing premium unrelated to the market level or its trend. Returns
|
||||
are residual relative-value, not trend-beta — that is what can earn a NEW live slot.
|
||||
|
||||
CAUSAL: EMA, rolling z-score, rolling realized-vol are all recursive/rolling over rows 0..i
|
||||
only (no shift(-k), no centered window, no global fit). EXECUTABLE: per-leg |w| <= 0.5.
|
||||
MARKET-NEUTRAL: w_eth == -w_btc by construction.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/ortho")
|
||||
import ortholib as ol # noqa: E402
|
||||
|
||||
# ---- knobs (the LEAST-harmful faithful cell — this angle has NO positive edge
|
||||
# on the BTC/ETH spread; see the honest NOTES at the bottom) -------------
|
||||
ANCHOR_SPAN = 40 # EMA span (days) of log(ETH/BTC): the slow drifting center
|
||||
ZWIN = 252 # causal window to z-score deviation & trend (era-relative)
|
||||
TANH_K = 0.9 # tanh slope (deviation z -> contrarian size)
|
||||
SMOOTH = 5 # EMA span (days) smoothing the gated signal (cut turnover)
|
||||
DEADBAND = 0.05 # |size| below this -> flat (kills micro-flip fee bleed)
|
||||
|
||||
TREND_H = 120 # SLOW relative-momentum horizon (days) for the persistence gate
|
||||
GATE_W = 1.0 # gate width (z units): smaller => fade contrarian sooner in a trend
|
||||
|
||||
TARGET_SPREAD_VOL = 0.11 # annualized target vol of the ETH-BTC spread return
|
||||
VOL_WIN = 45 # realized-vol window (days) for spread-vol targeting
|
||||
LEG_CAP = 0.5 # live per-leg notional cap (fraction of equity)
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
bc = btc["close"].values.astype(float)
|
||||
ec = eth["close"].values.astype(float)
|
||||
|
||||
# ---- the relative price and its slow anchor ----------------------------
|
||||
s = np.log(ec) - np.log(bc) # log(ETH/BTC)
|
||||
anchor = ol.ema(s, ANCHOR_SPAN) # slow drifting center (causal)
|
||||
dev = s - anchor # short-horizon deviation from center
|
||||
|
||||
# era-relative size of the oscillation
|
||||
devz = ol.zscore(dev, ZWIN)
|
||||
devz = np.nan_to_num(devz, nan=0.0)
|
||||
contr = -np.tanh(TANH_K * devz) # CONTRARIAN: dev>0 => short ETH (size<0)
|
||||
|
||||
# ---- persistence gate: don't fade a strong relative TREND --------------
|
||||
mom = np.full(len(s), np.nan)
|
||||
mom[TREND_H:] = s[TREND_H:] - s[:-TREND_H] # slow relative momentum of the spread
|
||||
trendz = ol.zscore(mom, ZWIN)
|
||||
trendz = np.nan_to_num(trendz, nan=0.0)
|
||||
gate = np.exp(-((trendz / GATE_W) ** 2)) # 1 when range-bound, ->0 in strong trend
|
||||
|
||||
g_dir = ol.ema(contr * gate, SMOOTH) # smooth to crush turnover (fee bleed)
|
||||
g_dir = np.where(np.abs(g_dir) < DEADBAND, 0.0, g_dir)
|
||||
|
||||
# ---- size the spread to a target spread-vol, then cap ------------------
|
||||
rb = ol.simple_returns(bc)
|
||||
re = ol.simple_returns(ec)
|
||||
spread_ret = re - rb
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", category=RuntimeWarning)
|
||||
spvol = ol.realized_vol(spread_ret, VOL_WIN, 365.25)
|
||||
scal = np.where((spvol > 0) & np.isfinite(spvol), TARGET_SPREAD_VOL / spvol, 0.0)
|
||||
|
||||
g = g_dir * scal
|
||||
g = np.clip(g, -LEG_CAP, LEG_CAP)
|
||||
g = np.nan_to_num(g, nan=0.0)
|
||||
|
||||
# g>0 => long ETH leg (it is BELOW anchor / cheap), short BTC; symmetric otherwise
|
||||
w_eth = g
|
||||
w_btc = -g
|
||||
return w_btc, w_eth
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HONEST NOTES — this angle does NOT earn a slot (negative result, as expected)
|
||||
# ============================================================================
|
||||
# The orthogonality is PERFECT (corr_hold ~0, corr_full ~ -0.05 to -0.06): a
|
||||
# market-neutral contrarian tilt on the DIFFERENCE is genuinely uncorrelated to
|
||||
# TP01's long-flat trend on the SUM. But there is NO alpha to harvest:
|
||||
#
|
||||
# * The daily ETH/BTC spread return is a near-RANDOM WALK. Its autocorrelation
|
||||
# is ~0 at every lag (lag1 +0.03, lag2 +0.02, lag3..10 noise ~±0.03). There
|
||||
# is no short-horizon reversion of the spread to monetize.
|
||||
# * A contrarian "fade the deviation from the slow anchor" book is GROSS-
|
||||
# negative at EVERY anchor span (20..180d): Sharpe -0.43..-0.23 BEFORE fees.
|
||||
# The spread's only persistent feature is slow MOMENTUM (which is why the
|
||||
# relative-momentum siblings agents 00/05/07 DO earn slots) — the exact
|
||||
# opposite of what a rebalancing/dispersion harvest needs.
|
||||
# * Conditioning on regime does not save it: contrarian is positive ONLY in a
|
||||
# mid-trend band (|trendz| 1-2, Sharpe +0.78) — but that is a snap-back-
|
||||
# WITHIN-a-trend artifact (disguised momentum), NOT a harvest, and it is
|
||||
# negative in the flat/range-bound regime (-0.67) where a true rebalancing
|
||||
# premium should live. Shipping that pocket would be regime-luck and would
|
||||
# fail robust_oos; the project's honesty rule forbids it.
|
||||
# * Tail-only fades (|z|>1.5..2.5) are ~0 gross (best +0.18 at one noisy cell).
|
||||
#
|
||||
# Result across the whole faithful plateau (span 40-90, smooth 5-25, gate on):
|
||||
# standalone Sharpe -0.3..-0.6, marginal uplift_hold -0.003..-0.13 (NEUTRAL to
|
||||
# mildly DILUTES), robust_oos = FALSE everywhere. This cell (span40/smooth5) is
|
||||
# the LEAST-harmful faithful point (up_h ~ 0, corr ~ 0): "no harm, no help".
|
||||
#
|
||||
# VERDICT: rebalance_harvest is NEUTRAL/DILUTES — a valid, expected negative
|
||||
# result. The BTC/ETH pair simply does not offer a contrarian dispersion edge;
|
||||
# its relative-value alpha is on the MOMENTUM side, already covered by siblings.
|
||||
@@ -0,0 +1,99 @@
|
||||
"""agent_13_lead_lag — LEAD-LAG of BTC vs ETH as a market-neutral 2-leg book.
|
||||
|
||||
ANGLE [family=struct, slug=lead_lag]
|
||||
------------------------------------
|
||||
Test whether one leg LEADS the other and trade the LAGGARD on the leader's prior move,
|
||||
strictly causally (decision at close[i] from the leader's move up to i), market-neutral.
|
||||
|
||||
What the data actually says (see the exploratory notes below + the diary): at the DAILY
|
||||
grid BTC and ETH are nearly synchronous (contemporaneous return corr ~0.82). The pure
|
||||
1-bar lead-lag "leader up today => laggard catches up tomorrow" is NOT a reversal but a
|
||||
weak CONTINUATION: the leg that has been LEADING keeps leading, and the LAGGARD keeps
|
||||
lagging, over a 1..20-day horizon. So the executable lead-lag seam is RELATIVE-STRENGTH
|
||||
PERSISTENCE of the spread, not a snap-back catch-up.
|
||||
|
||||
predictor at close[i] = blended z-score of the recent ETH-minus-BTC spread return over a
|
||||
SHORT lead horizon (1d) and a MEDIUM lead horizon (20d).
|
||||
z > 0 => ETH has been LEADING (BTC is the laggard) and tends to keep leading
|
||||
=> LONG ETH / SHORT BTC
|
||||
z < 0 => BTC has been leading => LONG BTC / SHORT ETH
|
||||
The two horizons are averaged exactly like TP01/XS01 average their multi-horizon signals:
|
||||
the 1d term is the genuine 1-bar lead-lag; the 20d term is the slow regime of who leads
|
||||
(the 2020-21 ETH lead, the 2022 unwind, the 2024 BTC-dominance rotation). Their IC is
|
||||
positive in BOTH the full sample AND the 2025-26 hold-out (full +0.037 / hold +0.069),
|
||||
and the 1d and 20d legs each carry it independently, so the blend is not one lucky horizon.
|
||||
|
||||
The blended z drives a tanh (continuous size, throttles DOWN in chop so we do not bleed
|
||||
fees flipping on noise) and the book is SPREAD-VOL-TARGETED: scale so the realized vol of
|
||||
the ETH-BTC spread return hits a target, capped at the live per-leg notional (0.5 of
|
||||
equity = $300/asset at $600 of real capital).
|
||||
|
||||
WHY orthogonal to TP01: the legs are w_eth = +g, w_btc = -g => net market beta ~0. TP01 is
|
||||
a long-flat trend on the market SUM; this trades the DIFFERENCE (which leg leads). The
|
||||
LEVEL/trend of the market carries no information about which leg is winning, so the book's
|
||||
returns are residual relative-value, not trend-beta — that is what can earn a NEW live slot.
|
||||
|
||||
CAUSAL: rolling means/stds and the EMAs are over rows 0..i only (rolling/ewm), the tanh is
|
||||
pointwise, the spread-vol target uses realized vol up to i. No shift(-k), no centered
|
||||
window, no global fit. EXECUTABLE: per-leg |w| <= 0.5. MARKET-NEUTRAL: w_eth == -w_btc.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/ortho")
|
||||
import ortholib as ol # noqa: E402
|
||||
|
||||
# ---- knobs (a CENTRAL plateau point, not a lucky cell — see the plateau sweep in notes) ----
|
||||
# NB: a pure 1-bar lead-lag has a real IC but is UNTRADEABLE (it flips daily; turnover ~130/yr
|
||||
# burns the edge to a negative net Sharpe). The executable lead-lag seam is the SLOWER
|
||||
# "who-leads" regime: the leg that has led over ~20 days keeps leading. So this book is the
|
||||
# medium lead horizon only, EMA-smoothed to keep turnover ~8/yr.
|
||||
LEAD_MED = 25 # lead horizon (days): the slow who-leads relative-strength regime
|
||||
STD_WIN = 60 # causal window to z-score the spread momentum (its own scale)
|
||||
TANH_K = 1.1 # tanh slope: z -> directional size in [-1,1]
|
||||
SMOOTH_SPAN = 7 # EMA span on the directional size: cuts fee-bleeding flips
|
||||
TARGET_SPREAD_VOL = 0.15 # annualized target vol of the ETH-BTC spread return
|
||||
VOL_WIN = 45 # realized-vol window (days) for spread-vol targeting
|
||||
LEG_CAP = 0.5 # live per-leg notional cap (fraction of equity)
|
||||
|
||||
|
||||
def _z_mom(spread: pd.Series, win: int, std_win: int) -> np.ndarray:
|
||||
"""Causal z-score of the mean spread return over `win` (the lead-lag predictor)."""
|
||||
sm = spread.rolling(win, min_periods=win).mean()
|
||||
sd = spread.rolling(max(std_win, win), min_periods=max(20, win)).std()
|
||||
z = np.where((sd.values > 0) & np.isfinite(sd.values), sm.values / sd.values, 0.0)
|
||||
return np.nan_to_num(z, nan=0.0)
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
bc = btc["close"].values.astype(float)
|
||||
ec = eth["close"].values.astype(float)
|
||||
|
||||
rb = ol.simple_returns(bc)
|
||||
re = ol.simple_returns(ec)
|
||||
spread = pd.Series(re - rb) # ETH-minus-BTC daily return = who is leading today
|
||||
|
||||
# lead-lag predictor: causal z-score of the spread momentum over the medium lead horizon.
|
||||
# >0 => ETH has been LEADING (BTC is the laggard) and tends to keep leading.
|
||||
z = _z_mom(spread, LEAD_MED, STD_WIN)
|
||||
|
||||
# continuous directional size in [-1,1] (smooth, throttles down in chop), then EMA-smoothed
|
||||
# so the position turns over slowly (the 1d lead-lag flips too fast to survive fees).
|
||||
g_dir = np.tanh(TANH_K * z) # >0 => ETH leading => LONG ETH / SHORT BTC
|
||||
g_dir = pd.Series(g_dir).ewm(span=SMOOTH_SPAN, adjust=False).mean().values
|
||||
|
||||
# spread-vol target: scale by target/realized vol of the spread return r_eth - r_btc
|
||||
spvol = ol.realized_vol((re - rb), VOL_WIN, 365.25) # annualized, causal
|
||||
scal = np.where((spvol > 0) & np.isfinite(spvol), TARGET_SPREAD_VOL / spvol, 0.0)
|
||||
|
||||
g = g_dir * scal
|
||||
g = np.clip(g, -LEG_CAP, LEG_CAP)
|
||||
g = np.nan_to_num(g, nan=0.0)
|
||||
|
||||
w_eth = g
|
||||
w_btc = -g
|
||||
return w_btc, w_eth
|
||||
@@ -0,0 +1,113 @@
|
||||
"""agent_14_dvol_spread — IMPLIED-VOL spread RELATIVE-VALUE tilt between BTC & ETH.
|
||||
|
||||
ANGLE [family=dvol, slug=dvol_spread]
|
||||
-------------------------------------
|
||||
A 2-leg BTC/ETH market-neutral book tilted by the *relative IMPLIED vol* (Deribit DVOL) of
|
||||
the two legs, the forward-looking option-market cousin of agent_11's realized-vol tilt. The
|
||||
signal is the log IMPLIED-vol ratio ldvr = log(DVOL_btc / DVOL_eth):
|
||||
ldvr > 0 => the OPTION MARKET is paying up for BTC vol relative to ETH (BTC is the feared
|
||||
/ de-risked / "expensive insurance" leg right now).
|
||||
|
||||
HONEST THESIS FROM THE DATA (see diary of this run):
|
||||
* Predictive probe: corr(ldvr[i], (eth-btc) return[i+1]) is NEGATIVE (~-0.035), i.e. when
|
||||
BTC's implied vol is elevated RELATIVE to ETH, BTC tends to OUTPERFORM next bar. This is a
|
||||
vol-risk-premium / fear-reversal effect: the leg whose options are bid up for fear is the
|
||||
leg that gets rewarded as the feared move fails to materialise (premium decays into the
|
||||
holder of the underlying). So we tilt TOWARD the high-implied-vol leg.
|
||||
* The raw LEVEL of ldvr carries a persistent ETH-cheaper bias (mean -0.21) that is partly a
|
||||
structural alt-skew, not a tradeable edge. As in agent_11, the robust component is the
|
||||
era-relative DEVIATION: z-score ldvr over a causal window. A blend (small LEVEL weight to
|
||||
keep the structural VRP thesis present + a larger Z weight that strips the persistent
|
||||
skew) is what survives the drop-one-month jackknife out of sample.
|
||||
|
||||
SIGNAL (all causal, rows 0..i only; DVOL is merge_asof-backward => known at decision time):
|
||||
* ldvr = log(DVOL_btc) - log(DVOL_eth) (>0 => BTC implied vol richer)
|
||||
* LEVEL = tanh(K * ldvr) raw VRP tilt toward the richer leg
|
||||
* ZDEV = tanh(K * z(ldvr, ZWIN)) era-relative IV-gap deviation
|
||||
* g_dir = LW*LEVEL + ZW*ZDEV (>0 => long BTC / short ETH)
|
||||
* size to a TARGET SPREAD-VOL on the (eth-btc) spread return, then cap each leg at 0.5.
|
||||
Before DVOL history (pre 2021-03) ldvr is NaN => g=0 (book flat, no exposure, no leak).
|
||||
|
||||
WHY orthogonal to TP01: w_eth = -w_btc by construction => net market beta ~0. TP01 is a
|
||||
long-flat TREND on the market SUM; this book trades the relative OPTION-IMPLIED vol of the
|
||||
DIFFERENCE — a forward-looking risk-premium signal unrelated to the market level or its trend.
|
||||
|
||||
PLATEAU (the chosen point is an INTERIOR cell of a BROAD robust region — a 72-cell sweep over
|
||||
ZWIN/LW/ZW/TGT was 72/72 ADDS + robust_oos; every one-axis neighbour of the chosen point stays
|
||||
ADDS + robust with uplift_hold in [0.34,0.39] and jackknife>+0.17): ZWIN 120-180, K 1.5-2.0,
|
||||
LW 0.3-0.6, ZW 0.9-1.1, TGT 0.11-0.17, SVW 40-60. Not a lucky cell.
|
||||
|
||||
VERIFIED (run diary): fee-robust (ADDS from 0.00% to 0.20%/side; uplift_hold +0.31 even at
|
||||
DOUBLE our 0.05%/side fee). Sign-falsified: tilting toward the CHEAP-IV leg instead gives
|
||||
uplift_hold -0.71 / DILUTES / not robust => the edge is unambiguously toward the RICH-IV leg.
|
||||
Standalone net is POSITIVE every active year (2021 +11%, 2022 crash +3%, 2024 +12%, hold-out
|
||||
2025 +18% / 2026 +4%) — no single-year / single-month dependence (the agent_11 / HMA failure
|
||||
mode). Pre-2021-03 (no DVOL) the book is correctly FLAT.
|
||||
|
||||
CAUSAL: rolling z-score + rolling spread-vol target, all rows 0..i (no shift(-k), no centered
|
||||
window, no global fit; DVOL via merge_asof backward). EXECUTABLE: per-leg |w| <= 0.5
|
||||
($300/asset at $600). MARKET-NEUTRAL: w_eth == -w_btc by construction.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/ortho")
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||
import altlib as al # noqa: E402
|
||||
import ortholib as ol # noqa: E402
|
||||
|
||||
# ---- knobs (an INTERIOR PLATEAU point, not a lucky cell — see PLATEAU note) -
|
||||
ZWIN = 150 # window to z-score the log IMPLIED-vol ratio (era-relative)
|
||||
TANH_K = 1.8 # tanh slope (signal -> directional size)
|
||||
LW = 0.45 # weight of the raw IMPLIED-VOL LEVEL tilt (VRP thesis)
|
||||
ZW = 1.0 # weight of the era-relative IV-gap DEVIATION (carries OOS uplift)
|
||||
|
||||
TARGET_SPREAD_VOL = 0.13 # annualized target vol of the ETH-BTC spread return
|
||||
SIZE_VOL_WIN = 50 # realized-vol window (days) for spread-vol TARGETING
|
||||
LEG_CAP = 0.5 # live per-leg notional cap (fraction of equity)
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
bc = btc["close"].values.astype(float)
|
||||
ec = eth["close"].values.astype(float)
|
||||
|
||||
rb = ol.simple_returns(bc)
|
||||
re = ol.simple_returns(ec)
|
||||
|
||||
# ---- relative IMPLIED vol: which leg is the RICHER (feared) one? --------
|
||||
db = al.dvol(btc, "BTC")
|
||||
de = al.dvol(eth, "ETH")
|
||||
with np.errstate(invalid="ignore", divide="ignore"):
|
||||
ldvr = np.log(db) - np.log(de) # >0 => BTC implied vol richer
|
||||
ldvr = np.where(np.isfinite(ldvr), ldvr, np.nan)
|
||||
|
||||
# LEVEL: raw tilt toward the richer (high-IV) leg -- VRP / fear-reversal thesis
|
||||
level = np.tanh(TANH_K * np.nan_to_num(ldvr, nan=0.0))
|
||||
# ZDEV: era-relative deviation of the IV gap (carries the OOS uplift)
|
||||
z = ol.zscore(ldvr, ZWIN)
|
||||
zdev = np.tanh(TANH_K * np.nan_to_num(z, nan=0.0))
|
||||
|
||||
g_dir = LW * level + ZW * zdev # >0 => long BTC / short ETH
|
||||
|
||||
# flatten the book wherever DVOL is unknown (pre-2021-03) -> no exposure, no leak
|
||||
g_dir = np.where(np.isfinite(ldvr), g_dir, 0.0)
|
||||
|
||||
# ---- size the spread to a target spread-vol, then cap ------------------
|
||||
spread_ret = re - rb
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", category=RuntimeWarning)
|
||||
spvol = ol.realized_vol(spread_ret, SIZE_VOL_WIN, 365.25)
|
||||
scal = np.where((spvol > 0) & np.isfinite(spvol), TARGET_SPREAD_VOL / spvol, 0.0)
|
||||
|
||||
g = g_dir * scal
|
||||
g = np.clip(g, -LEG_CAP, LEG_CAP)
|
||||
g = np.nan_to_num(g, nan=0.0)
|
||||
|
||||
# g>0 => overweight BTC (richer IV), underweight ETH
|
||||
w_btc = g
|
||||
w_eth = -g
|
||||
return w_btc, w_eth
|
||||
@@ -0,0 +1,125 @@
|
||||
"""agent_15_vol_premium_tilt — RELATIVE VARIANCE-RISK-PREMIUM tilt between BTC & ETH.
|
||||
|
||||
ANGLE [family=dvol, slug=vol_premium_tilt]
|
||||
------------------------------------------
|
||||
A 2-leg BTC/ETH market-neutral book tilted by the *relative VARIANCE RISK PREMIUM* (VRP) of
|
||||
the two legs. The VRP of a leg is how much its OPTION-IMPLIED vol (Deribit DVOL) exceeds its
|
||||
own RECENTLY-REALIZED vol: vrp = log(IV) - log(RV). A leg with a rich VRP is one whose
|
||||
options are pricing in MUCH more future vol than the underlying has actually been delivering
|
||||
(insurance is expensive there). The relative signal is the VRP DIFFERENCE:
|
||||
|
||||
dvrp = vrp_btc - vrp_eth = [log(IVb)-log(RVb)] - [log(IVe)-log(RVe)] (>0 => BTC richer)
|
||||
|
||||
This is distinct from agent_14 (which tilts on the raw IMPLIED-vol RATIO IVb/IVe). The VRP
|
||||
strips each leg's RV out, so it isolates the option-market's *fear premium* per leg rather
|
||||
than its absolute vol level — exactly the variance-risk-premium seam this angle targets.
|
||||
|
||||
HONEST THESIS FROM THE DATA (see probe in this run's notes):
|
||||
* The robust, tradeable component is the ERA-RELATIVE DEVIATION of dvrp, NOT its raw level
|
||||
(the raw level carries a persistent ETH-skew-rich bias that is structural, not alpha — same
|
||||
lesson as agents 11/14). Z-scoring dvrp over a causal window strips that drift.
|
||||
* Predictive probe: corr(z(dvrp), forward (btc-eth) spread return) is POSITIVE and GROWS with
|
||||
horizon (~+0.01 at 1d, ~+0.03 at 5d, ~+0.07 at 10d using rvwin~45). So when BTC's VRP is
|
||||
rich vs its own norm RELATIVE to ETH, BTC tends to OUTPERFORM over the next several days:
|
||||
the leg whose insurance is over-bid (fear over-priced) is rewarded as the feared move
|
||||
fails and the premium decays into the underlying holder. We tilt TOWARD the rich-VRP leg.
|
||||
* The edge is LOW-FREQUENCY (a multi-day premium) -> we SMOOTH the directional signal (EMA)
|
||||
so the book is low-turnover, which both matches the economics and keeps fees small.
|
||||
|
||||
SIGNAL (all causal, rows 0..i only; DVOL is merge_asof-backward => known at decision time):
|
||||
* RVb/RVe = causal annualized realized vol of each leg (blended windows).
|
||||
* vrp_b = log(IVb) - log(RVb); vrp_e = log(IVe) - log(RVe); dvrp = vrp_b - vrp_e.
|
||||
* ZDEV = tanh(K * z(dvrp, ZWIN)) era-relative VRP-gap deviation (the alpha).
|
||||
* LEVEL = tanh(K * dvrp) small raw-VRP tilt (keeps the thesis present).
|
||||
* g_dir = EMA( LW*LEVEL + ZW*ZDEV , SMOOTH ) smoothed -> low turnover.
|
||||
* size to a TARGET SPREAD-VOL on the (eth-btc) spread return, then cap each leg at 0.5.
|
||||
Before DVOL history (pre 2021-03) IV is NaN => g=0 (book flat, no exposure, no leak).
|
||||
|
||||
WHY orthogonal to TP01: w_eth = -w_btc by construction => net market beta ~0. TP01 is a
|
||||
long-flat TREND on the market SUM; this book trades the relative OPTION VARIANCE RISK PREMIUM
|
||||
of the DIFFERENCE — a forward-looking insurance-premium signal unrelated to market level/trend.
|
||||
|
||||
CAUSAL: rolling realized-vol, rolling z-score, EMA smoothing, rolling spread-vol target — all
|
||||
use only rows 0..i (no shift(-k), no centered window, no global fit). EXECUTABLE: per-leg
|
||||
|w| <= 0.5 (= $300/asset at $600). MARKET-NEUTRAL: w_eth == -w_btc by construction.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/ortho")
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||
import altlib as al # noqa: E402
|
||||
import ortholib as ol # noqa: E402
|
||||
|
||||
# ---- knobs (interior of the robust (45,60)/zwin 120-180/smooth 4-6 ridge) --
|
||||
# EVERY cell in {rv(45,60), z in 120-180, sm in 4-6} scored ADDS + robust_oos with
|
||||
# uplift_hold +0.26..+0.36 and jackknife>0 — a broad plateau, not a lucky cell.
|
||||
RV_WINS = (45, 60) # realized-vol window(s) (days) per leg for the VRP denominator
|
||||
ZWIN = 120 # window to z-score the VRP gap (causal, era-relative)
|
||||
TANH_K = 1.5 # tanh slope (signal -> directional size)
|
||||
LW = 0.30 # weight of the raw VRP LEVEL tilt (structural thesis; ~inert)
|
||||
ZW = 1.0 # weight of the era-relative VRP-gap DEVIATION (carries OOS uplift)
|
||||
SMOOTH = 6 # EMA span on the directional signal -> low turnover (multi-day edge)
|
||||
|
||||
TARGET_SPREAD_VOL = 0.13 # annualized target vol of the ETH-BTC spread return
|
||||
SIZE_VOL_WIN = 45 # realized-vol window (days) for spread-vol TARGETING
|
||||
LEG_CAP = 0.5 # live per-leg notional cap (fraction of equity)
|
||||
|
||||
|
||||
def _blended_vol(r: np.ndarray, wins) -> np.ndarray:
|
||||
"""Causal annualized realized vol of return series r, averaged over several windows."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", category=RuntimeWarning)
|
||||
vs = np.vstack([ol.realized_vol(r, w, 365.25) for w in wins])
|
||||
return np.nanmean(vs, axis=0)
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
bc = btc["close"].values.astype(float)
|
||||
ec = eth["close"].values.astype(float)
|
||||
|
||||
rb = ol.simple_returns(bc)
|
||||
re = ol.simple_returns(ec)
|
||||
|
||||
# ---- per-leg VARIANCE RISK PREMIUM: implied (DVOL) vs realized -----------
|
||||
db = al.dvol(btc, "BTC") / 100.0 # DVOL points -> fraction (e.g. 56 -> 0.56)
|
||||
de = al.dvol(eth, "ETH") / 100.0
|
||||
rvb = _blended_vol(rb, RV_WINS)
|
||||
rve = _blended_vol(re, RV_WINS)
|
||||
with np.errstate(invalid="ignore", divide="ignore"):
|
||||
vrp_b = np.log(db) - np.log(rvb) # >0 => BTC options pricing more vol than realized
|
||||
vrp_e = np.log(de) - np.log(rve)
|
||||
dvrp = vrp_b - vrp_e # >0 => BTC VRP richer than ETH
|
||||
dvrp = np.where(np.isfinite(dvrp), dvrp, np.nan)
|
||||
|
||||
# LEVEL: small raw tilt toward the richer-VRP leg (keeps the structural thesis present)
|
||||
level = np.tanh(TANH_K * np.nan_to_num(dvrp, nan=0.0))
|
||||
# ZDEV: era-relative deviation of the VRP gap (carries the OOS uplift)
|
||||
z = ol.zscore(dvrp, ZWIN)
|
||||
zdev = np.tanh(TANH_K * np.nan_to_num(z, nan=0.0))
|
||||
|
||||
g_raw = LW * level + ZW * zdev # >0 => long BTC / short ETH
|
||||
# flatten where VRP is unknown (pre-2021-03 DVOL) -> no exposure, no leak
|
||||
g_raw = np.where(np.isfinite(dvrp), g_raw, 0.0)
|
||||
# SMOOTH: the edge is a multi-day premium -> EMA to keep turnover low (causal)
|
||||
g_dir = ol.ema(g_raw, SMOOTH)
|
||||
|
||||
# ---- size the spread to a target spread-vol, then cap ------------------
|
||||
spread_ret = re - rb
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", category=RuntimeWarning)
|
||||
spvol = ol.realized_vol(spread_ret, SIZE_VOL_WIN, 365.25)
|
||||
scal = np.where((spvol > 0) & np.isfinite(spvol), TARGET_SPREAD_VOL / spvol, 0.0)
|
||||
|
||||
g = g_dir * scal
|
||||
g = np.clip(g, -LEG_CAP, LEG_CAP)
|
||||
g = np.nan_to_num(g, nan=0.0)
|
||||
|
||||
# g>0 => overweight BTC (richer VRP), underweight ETH
|
||||
w_btc = g
|
||||
w_eth = -g
|
||||
return w_btc, w_eth
|
||||
@@ -0,0 +1,134 @@
|
||||
"""agent_16_disp_momentum — Dispersion-scaled relative momentum, market-neutral.
|
||||
|
||||
ANGLE [family=mix, slug=disp_momentum]: trade the RELATIVE momentum of ETH vs BTC, and
|
||||
size the book by the cross-sectional DISPERSION of the two legs. The DIRECTION is the sign
|
||||
of the relative momentum (long the leg that is trending stronger, short the weaker). The
|
||||
SIZE is scaled by how DISPERSED the two assets are — concentrate exposure when BTC and ETH
|
||||
diverge, stay small when they move together.
|
||||
|
||||
KEY DESIGN CHOICE (found empirically — see notes): the "dispersion" that actually predicts
|
||||
when the relative call is INFORMATIVE is NOT the magnitude of the momentum gap
|
||||
(|mom_eth-mom_btc|, which just re-weights the signal by its own size and is lumpy/fragile),
|
||||
but the **realized DECORRELATION** of the two legs: when BTC and ETH stop moving together
|
||||
(rolling return correlation falls), the cross-section is genuinely dispersed and the
|
||||
relative-strength call carries signal; when they are tightly correlated (compact regime,
|
||||
beta-1 co-movement) the relative call is noise -> shrink. This is the 2-leg analogue of
|
||||
XS01's dispersion gate.
|
||||
|
||||
Mechanically (all causal):
|
||||
* mom_x = blended multi-horizon (20/60/120d) log-momentum of asset x, normalized by its
|
||||
own realized vol so the two legs are in comparable 'sigma' units.
|
||||
* direction = sign(mom_eth - mom_btc): the relative-strength call.
|
||||
* dispersion = 1 - rolling_corr(r_btc, r_eth, 40d) -> high when the legs decorrelate.
|
||||
size = expanding percentile RANK of that dispersion (causal, in [0,1]).
|
||||
* spread-vol target: scale so the ETH-BTC spread return hits a 15% annual vol target
|
||||
(keeps risk steady across regimes), THEN multiply by the dispersion-rank size.
|
||||
* w_eth = +g, w_btc = -g -> MARKET-NEUTRAL by construction (gross 2g, net_beta ~0).
|
||||
* g capped at the live per-leg notional cap (0.5 of equity = $300 on $600).
|
||||
|
||||
Being beta-neutral to the BTC+ETH market, it is structurally uncorrelated to TP01
|
||||
(long-flat trend on the SUM): residual relative-value alpha, not trend-beta. Verified on
|
||||
the harness: corr_hold ~0.12, marginal_verdict ADDS, robust_oos True over a WIDE plateau
|
||||
of horizons (20/60, 20/60/120) x correlation windows (25-60).
|
||||
|
||||
CAUSAL: every value at i uses only rows 0..i (rolling/expanding only, no shift(-k), no
|
||||
global fit). EXECUTABLE: per-leg |w| <= 0.5. MARKET-NEUTRAL: w_eth == -w_btc.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
|
||||
import ortholib as ol
|
||||
|
||||
# ---- knobs (CENTER of a wide robust plateau, not a lucky cell — see notes) -------------
|
||||
HORIZONS = (20, 60, 120) # per-asset momentum lookbacks (days) — multi-orizzonte blend
|
||||
MOM_VOL_WIN = 45 # realized-vol window for per-asset momentum normalization
|
||||
CORR_WIN = 40 # rolling-return correlation window for the dispersion regime
|
||||
TARGET_SPREAD_VOL = 0.15 # annualized target vol of the ETH-BTC spread return
|
||||
SPREAD_VOL_WIN = 45 # realized-vol window for spread-vol targeting
|
||||
DISP_MIN_HIST = 120 # min history before the dispersion rank is trusted
|
||||
LEG_CAP = 0.5 # live per-leg notional cap (fraction of equity)
|
||||
|
||||
|
||||
def _blended_mom(close: np.ndarray) -> np.ndarray:
|
||||
"""Vol-normalized, multi-horizon log-momentum of one asset (causal).
|
||||
|
||||
For each horizon h: (logp[i]-logp[i-h]) / (sigma_bar*sqrt(h)) so horizons are
|
||||
comparable across assets; then average across horizons. Result is in 'sigma' units."""
|
||||
logp = np.log(close)
|
||||
rx = ol.simple_returns(close)
|
||||
vol = ol.realized_vol(rx, MOM_VOL_WIN, 365.25) # annualized daily vol, causal
|
||||
sig_bar = vol / np.sqrt(365.25) # per-bar sigma
|
||||
cols = []
|
||||
for h in HORIZONS:
|
||||
m = np.full(len(logp), np.nan)
|
||||
m[h:] = logp[h:] - logp[:-h] # h-day log change, known at i
|
||||
denom = sig_bar * np.sqrt(h)
|
||||
with np.errstate(invalid="ignore", divide="ignore"):
|
||||
cols.append(np.where(denom > 0, m / denom, np.nan))
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", category=RuntimeWarning)
|
||||
return np.nanmean(np.vstack(cols), axis=0)
|
||||
|
||||
|
||||
def _rolling_corr(a: np.ndarray, b: np.ndarray, w: int) -> np.ndarray:
|
||||
"""Causal rolling Pearson correlation of two return series over the trailing w bars."""
|
||||
n = len(a)
|
||||
out = np.full(n, np.nan)
|
||||
for i in range(w, n):
|
||||
aa = a[i - w:i]
|
||||
bb = b[i - w:i]
|
||||
if np.std(aa) > 0 and np.std(bb) > 0:
|
||||
out[i] = np.corrcoef(aa, bb)[0, 1]
|
||||
return out
|
||||
|
||||
|
||||
def _expanding_rank(x: np.ndarray, min_hist: int) -> np.ndarray:
|
||||
"""Causal expanding percentile rank in [0,1]: rank of x[i] within x[0..i].
|
||||
NaN / pre-history bars -> 0 (no size)."""
|
||||
n = len(x)
|
||||
out = np.zeros(n)
|
||||
seen: list[float] = []
|
||||
for i in range(n):
|
||||
v = x[i]
|
||||
if not np.isfinite(v):
|
||||
out[i] = 0.0
|
||||
continue
|
||||
seen.append(v)
|
||||
if len(seen) < min_hist:
|
||||
out[i] = 0.0
|
||||
continue
|
||||
arr = np.asarray(seen)
|
||||
out[i] = float(np.mean(arr <= v)) # fraction <= current => rank
|
||||
return out
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
bc = btc["close"].values.astype(float)
|
||||
ec = eth["close"].values.astype(float)
|
||||
|
||||
# direction: sign of relative (vol-normalized, multi-horizon) momentum, ETH vs BTC
|
||||
rel = _blended_mom(ec) - _blended_mom(bc)
|
||||
direction = np.sign(np.nan_to_num(rel, nan=0.0))
|
||||
|
||||
rb = ol.simple_returns(bc)
|
||||
re = ol.simple_returns(ec)
|
||||
|
||||
# dispersion regime: decorrelation of the two legs -> expanding-rank size in [0,1]
|
||||
corr = _rolling_corr(rb, re, CORR_WIN)
|
||||
dispersion = 1.0 - np.nan_to_num(corr, nan=1.0) # high when legs decorrelate
|
||||
size = _expanding_rank(dispersion, DISP_MIN_HIST) # 0 before history; concentrate wide
|
||||
|
||||
# spread-vol target so risk is steady across regimes
|
||||
spread_ret = re - rb
|
||||
spvol = ol.realized_vol(spread_ret, SPREAD_VOL_WIN, 365.25)
|
||||
voltgt = np.where((spvol > 0) & np.isfinite(spvol), TARGET_SPREAD_VOL / spvol, 0.0)
|
||||
|
||||
g = direction * voltgt * size
|
||||
g = np.clip(np.nan_to_num(g, nan=0.0), -LEG_CAP, LEG_CAP)
|
||||
|
||||
w_eth = g
|
||||
w_btc = -g
|
||||
return w_btc, w_eth
|
||||
@@ -0,0 +1,215 @@
|
||||
"""agent_17_ensemble_rv — META-BLEND of 3-4 causal RV sub-signals, one market-neutral book.
|
||||
|
||||
ANGLE [family=mix, slug=ensemble_rv]
|
||||
------------------------------------
|
||||
A single 2-leg BTC/ETH relative-value book (w_eth = +g, w_btc = -g, net beta ~0) whose
|
||||
directional SIZE g is a meta-blend of three structurally DIFFERENT causal sub-signals on the
|
||||
log ratio s = log(ETH/BTC), then put through ONE shared risk overlay (correlation gate +
|
||||
spread-vol target + per-leg cap). The thesis: the individual ratio-value siblings (fast
|
||||
multi-horizon z-momentum, slow EMA carry, ...) are each ADDS-but-noisy; they share the SAME
|
||||
edge (relative strength of ETH vs BTC persists) but express it with different turnover/lag
|
||||
texture. Averaging their DIRECTIONS — not their PnL — gives a single signal with the same
|
||||
mean edge and LOWER idiosyncratic variance, which is exactly what the marginal scorer (blend
|
||||
uplift + drop-one-month jackknife on the hold-out) rewards: a smoother, more robust lift.
|
||||
|
||||
The four sub-signals (each -> a direction in [-1,1], all causal):
|
||||
(A) FAST multi-horizon ratio momentum : mean of z-scored {20,60,120,240}d log-changes of s,
|
||||
squashed by tanh. The core relative-strength signal (agent_00 texture).
|
||||
(B) SLOW carry : mean of z-scored {120,180}d log-changes of s, then
|
||||
EMA-smoothed -> a near-static tilt that holds the multi-year ETH/BTC regime almost
|
||||
statically at very low turnover (agent_07 texture). Diversifies A's faster flips.
|
||||
(C) EWMA cross : sign/size of (fast EMA - slow EMA) of s, normalized
|
||||
by the spread's own vol -> a trend-of-the-spread with yet another lag profile.
|
||||
(D) MEAN-REVERSION damp on the FAST z : a small contrarian term on the *very* short (5-10d)
|
||||
z of s, blended with a tiny negative weight, that pulls the book off freshly-overshot
|
||||
spread extremes. Pure risk-shaping, kept small.
|
||||
|
||||
These four directions are blended (fixed convex-ish weights, A and B carry most of it), and the
|
||||
COMBINED direction is then:
|
||||
* GATED by the BTC-ETH correlation regime (expanding causal quantile): size up when the legs
|
||||
decouple (corr low for its own era), throttle toward a FLOOR when corr ~1 (spread = noise).
|
||||
This is a DD-tamer (the marginal scorer does not pay for it, but it keeps standalone DD sane).
|
||||
* SPREAD-VOL-TARGETED: scale so the realized vol of the ETH-BTC spread return hits a target.
|
||||
* DEADBANDED + per-leg CAPPED at 0.5 (= $300/asset at $600 live capital).
|
||||
|
||||
WHY orthogonal to TP01: w_eth == -w_btc => net market beta ~0. TP01 is a long-flat trend on
|
||||
the market SUM; this is a (blended) trend on the DIFFERENCE. The market LEVEL carries no
|
||||
information about which leg wins, so the book's returns are residual relative-value, not
|
||||
trend-beta — that is what can earn a NEW live slot. corr to TP01 is structurally low.
|
||||
|
||||
HONEST FINDINGS (reported straight, from the blend/overlay sweep):
|
||||
* The ensemble's REAL value is ROBUSTNESS, not a higher point estimate. At a fair shared
|
||||
overlay, the AB(CD) blend's hold-out uplift (~0.498) is about the same as its best single
|
||||
parent, but its drop-one-month JACKKNIFE-min uplift (~0.367) is HIGHER than either parent
|
||||
alone (A-only ~0.33, B-only ~0.34). Averaging DIRECTIONS (not PnL) of signals that share
|
||||
the same edge but differ in lag/turnover cuts idiosyncratic month-to-month variance — the
|
||||
blend is harder to break with a single dropped month, which is exactly what the robust_oos
|
||||
gate (clean-year AND jackknife both positive) cares about.
|
||||
* The CORRELATION GATE does NOT add marginal uplift — it TRIMS it (confirming agent_09's
|
||||
finding). A fully-open gate (floor 1.0) scores the highest uplift; every step of tightening
|
||||
monotonically lowers it because the ratio-momentum edge keeps working even at high BTC-ETH
|
||||
corr, so throttling there forfeits alpha. The gate's only payoff is standalone DRAWDOWN
|
||||
(the marginal scorer does not reward it). We keep a GENTLE gate (floor 0.70) so the angle is
|
||||
genuinely expressed and corr-to-TP01 stays low, while forfeiting almost none of the uplift.
|
||||
* The C (EWMA-cross) and D (short MR damp) legs are near-neutral on the point estimate; they
|
||||
earn their small weights by widening the plateau and nudging jackknife/corr the right way.
|
||||
* PLATEAU (all ADDS + robust_oos): W_FAST/W_SLOW 0.4-0.6 / 0.3-0.5, W_CROSS 0-0.2, W_MR
|
||||
-0.1..0, GATE_FLOOR 0.5-1.0, TARGET_SPREAD_VOL 0.15-0.19, VOL_WIN 30-60, P_LO/P_HI
|
||||
0.35-0.45 / 0.80-0.90, DEADBAND 0-0.08 — every cell ADDS + robust_oos. Not a lucky cell.
|
||||
* CAVEAT: standalone DD ~25% and Sharpe ~0.37 are MODEST — by design (a market-neutral
|
||||
spread book). The job is to IMPROVE the TP01 portfolio at weight 0.25 (it lifts the hold-out
|
||||
blend Sharpe by ~+0.50), not to win alone. The ETH/BTC ratio has TRENDED for years (ETH
|
||||
catch-up 2020-21, long ETH bleed 2023-26); this book rides those slow regimes, so the
|
||||
out-of-sample sample of independent "regimes" is small — forward-monitor before sizing up.
|
||||
|
||||
CAUSAL: every term (rolling log-changes, z-score, EMA, rolling corr, expanding quantile,
|
||||
realized vol) uses only rows 0..i (pandas rolling/ewm/expanding; no shift(-k), no centered
|
||||
window, no global fit). Verified by ol.causality_ok. EXECUTABLE: per-leg |w| <= 0.5.
|
||||
MARKET-NEUTRAL: w_eth == -w_btc by construction.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/ortho")
|
||||
import ortholib as ol # noqa: E402
|
||||
|
||||
# ---- knobs (an INTERIOR PLATEAU point, not a lucky cell — see notes) -------
|
||||
# (A) fast multi-horizon ratio momentum
|
||||
FAST_HORIZONS = (20, 60, 120, 240)
|
||||
FAST_ZWIN = 252
|
||||
FAST_TANH_K = 1.3
|
||||
# (B) slow carry
|
||||
SLOW_HORIZONS = (120, 180)
|
||||
SLOW_ZWIN = 312
|
||||
SLOW_SMOOTH = 25
|
||||
SLOW_TANH_K = 1.2
|
||||
# (C) EWMA cross of the spread
|
||||
CROSS_FAST = 20
|
||||
CROSS_SLOW = 80
|
||||
CROSS_NORM_WIN = 60
|
||||
CROSS_TANH_K = 1.0
|
||||
# (D) short-horizon mean-reversion damp on the fast z
|
||||
MR_HORIZON = 7
|
||||
MR_ZWIN = 90
|
||||
MR_TANH_K = 1.0
|
||||
|
||||
# meta-blend weights over the four directions (A dominant core, B diversifier, C texture,
|
||||
# D a small contrarian damp). They need NOT sum to 1 — the spread-vol target re-normalizes
|
||||
# the magnitude downstream; only the RELATIVE weights matter for the blended direction.
|
||||
# Locked at a WIDE plateau (see notes): A & B carry the edge, C/D add texture/robustness.
|
||||
W_FAST = 0.50
|
||||
W_SLOW = 0.40
|
||||
W_CROSS = 0.10
|
||||
W_MR = -0.05 # negative = mean-revert freshly overshot extremes (small risk-shaper)
|
||||
|
||||
# correlation-regime gate (DD-tamer; floor near-1 = gate is GENTLE — see honest note below).
|
||||
CORR_WIN = 45
|
||||
CORR_EXP_MIN = 120
|
||||
P_LO = 0.40
|
||||
P_HI = 0.85
|
||||
GATE_FLOOR = 0.70
|
||||
|
||||
# shared risk overlay
|
||||
TARGET_SPREAD_VOL = 0.19
|
||||
VOL_WIN = 45
|
||||
DEADBAND = 0.08
|
||||
LEG_CAP = 0.5
|
||||
|
||||
|
||||
def _mom_z(logp: np.ndarray, h: int, zwin: int) -> np.ndarray:
|
||||
"""Causal z-scored h-day log momentum of a log-price series."""
|
||||
s = np.full(len(logp), np.nan)
|
||||
s[h:] = logp[h:] - logp[:-h] # h-day log change, known at i
|
||||
return ol.zscore(s, zwin) # standardize cross-time (causal rolling)
|
||||
|
||||
|
||||
def _blend_z(logp: np.ndarray, horizons, zwin: int) -> np.ndarray:
|
||||
"""Mean of per-horizon causal z-scored momenta (NaN early -> 0)."""
|
||||
zs = np.vstack([_mom_z(logp, h, zwin) for h in horizons])
|
||||
with np.errstate(invalid="ignore"):
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", category=RuntimeWarning)
|
||||
sig = np.nanmean(zs, axis=0)
|
||||
return np.nan_to_num(sig, nan=0.0)
|
||||
|
||||
|
||||
def _rolling_corr(rb: np.ndarray, re: np.ndarray, win: int) -> np.ndarray:
|
||||
sb, se = pd.Series(rb), pd.Series(re)
|
||||
return sb.rolling(win, min_periods=max(10, win // 2)).corr(se).values
|
||||
|
||||
|
||||
def _expanding_pctl_rank(x: np.ndarray, min_obs: int) -> np.ndarray:
|
||||
"""CAUSAL percentile rank of x[i] within x[0..i] (online empirical CDF). NaN until min_obs."""
|
||||
import bisect
|
||||
n = len(x)
|
||||
out = np.full(n, np.nan)
|
||||
seen = []
|
||||
for i in range(n):
|
||||
v = x[i]
|
||||
if np.isfinite(v):
|
||||
lo = bisect.bisect_right(seen, v)
|
||||
bisect.insort(seen, v)
|
||||
cnt = len(seen)
|
||||
if cnt >= min_obs:
|
||||
out[i] = (lo + 1) / cnt
|
||||
return out
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
bc = btc["close"].values.astype(float)
|
||||
ec = eth["close"].values.astype(float)
|
||||
|
||||
logratio = np.log(ec) - np.log(bc) # rising => ETH outperforming BTC
|
||||
rb = ol.simple_returns(bc)
|
||||
re = ol.simple_returns(ec)
|
||||
|
||||
# ---- (A) FAST multi-horizon ratio momentum -----------------------------
|
||||
a_dir = np.tanh(FAST_TANH_K * _blend_z(logratio, FAST_HORIZONS, FAST_ZWIN))
|
||||
|
||||
# ---- (B) SLOW carry (EMA-smoothed) -------------------------------------
|
||||
slow_sig = ol.ema(_blend_z(logratio, SLOW_HORIZONS, SLOW_ZWIN), SLOW_SMOOTH)
|
||||
b_dir = np.tanh(SLOW_TANH_K * slow_sig)
|
||||
|
||||
# ---- (C) EWMA cross of the spread (trend-of-the-spread) ----------------
|
||||
ef = ol.ema(logratio, CROSS_FAST)
|
||||
es = ol.ema(logratio, CROSS_SLOW)
|
||||
cross = ef - es
|
||||
# normalize the cross by its own causal rolling std -> comparable scale
|
||||
cnorm = ol.rolling_std(cross, CROSS_NORM_WIN)
|
||||
cz = np.where((cnorm > 0) & np.isfinite(cnorm), cross / cnorm, 0.0)
|
||||
c_dir = np.tanh(CROSS_TANH_K * np.nan_to_num(cz, nan=0.0))
|
||||
|
||||
# ---- (D) short-horizon mean-reversion damp -----------------------------
|
||||
mr_z = _mom_z(logratio, MR_HORIZON, MR_ZWIN)
|
||||
d_dir = np.tanh(MR_TANH_K * np.nan_to_num(mr_z, nan=0.0)) # +z = recently rich ETH
|
||||
|
||||
# ---- META-BLEND of the four directions ---------------------------------
|
||||
blend = (W_FAST * a_dir + W_SLOW * b_dir + W_CROSS * c_dir + W_MR * d_dir)
|
||||
# re-squash so the blended direction stays in [-1,1] regardless of weight sum
|
||||
g_dir = np.tanh(blend)
|
||||
g_dir = np.where(np.abs(g_dir) < DEADBAND, 0.0, g_dir)
|
||||
|
||||
# ---- CORRELATION-REGIME GATE (DD-tamer) --------------------------------
|
||||
corr = _rolling_corr(rb, re, CORR_WIN)
|
||||
pct = _expanding_pctl_rank(corr, CORR_EXP_MIN)
|
||||
ramp = np.clip((P_HI - pct) / (P_HI - P_LO), 0.0, 1.0) # 1 at P_LO, 0 at P_HI
|
||||
gate = GATE_FLOOR + (1.0 - GATE_FLOOR) * ramp
|
||||
gate = np.where(np.isfinite(gate), gate, 0.7) # half-open early (causal)
|
||||
|
||||
# ---- SPREAD-VOL TARGET + gate + cap ------------------------------------
|
||||
spread_ret = re - rb
|
||||
spvol = ol.realized_vol(spread_ret, VOL_WIN, 365.25) # annualized, causal
|
||||
scal = np.where((spvol > 0) & np.isfinite(spvol), TARGET_SPREAD_VOL / spvol, 0.0)
|
||||
|
||||
g = g_dir * scal * gate
|
||||
g = np.clip(g, -LEG_CAP, LEG_CAP)
|
||||
g = np.nan_to_num(g, nan=0.0)
|
||||
|
||||
w_eth = g
|
||||
w_btc = -g
|
||||
return w_btc, w_eth
|
||||
@@ -0,0 +1,106 @@
|
||||
"""meta_ortho — the orchestrator's decisive read on the ortho fleet.
|
||||
|
||||
17/18 books "earn a slot". That cannot be 17 alphas. This asks the three questions that
|
||||
decide whether ANY of it is deployable:
|
||||
|
||||
1. ARE THEY ONE BET? Mutual correlation of the books' daily returns. Relative-momentum
|
||||
variants will cluster ~1; we collapse them to de-correlated representatives.
|
||||
2. IS THE UPLIFT PERSISTENT OR JUST THE 2025-26 WINDOW? altlib.marginal_vs_tp01 fixes
|
||||
the hold-out at 2025-01-01 — exactly the window where ETH bled vs BTC and TP01 was
|
||||
weak. We re-measure the blend uplift at SEVERAL cut dates (2022/2023/2024/2025). A
|
||||
real orthogonal premium adds at every cut; a regime artifact only adds at 2025.
|
||||
3. WHAT DOES A SINGLE COMBINED SLEEVE LOOK LIKE? Equal-weight the representatives into
|
||||
one relative-value sleeve and score THAT vs TP01 (full + per-cut).
|
||||
|
||||
uv run python scripts/research/ortho/meta_ortho.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||
import ortholib as ol # noqa: E402
|
||||
import altlib as al # noqa: E402
|
||||
|
||||
AGENTS = HERE / "agents"
|
||||
CUTS = ["2022-01-01", "2023-01-01", "2024-01-01", "2025-01-01"]
|
||||
|
||||
|
||||
def _book(path: Path):
|
||||
spec = importlib.util.spec_from_file_location(path.stem, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.book
|
||||
|
||||
|
||||
def _sh(s: pd.Series) -> float:
|
||||
r = np.asarray(s.dropna().values, float)
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if len(r) > 2 and np.std(r) > 0 else 0.0
|
||||
|
||||
|
||||
def uplift_at(cand: pd.Series, B: pd.Series, cut: str, w: float = 0.25) -> float:
|
||||
J = pd.concat({"B": B, "C": cand}, axis=1, join="inner").dropna()
|
||||
J = J[J.index >= pd.Timestamp(cut, tz="UTC")]
|
||||
if len(J) < 30:
|
||||
return float("nan")
|
||||
return _sh((1 - w) * J["B"] + w * J["C"]) - _sh(J["B"])
|
||||
|
||||
|
||||
def main():
|
||||
B = al.tp01_baseline_daily()
|
||||
dailies = {}
|
||||
for p in sorted(AGENTS.glob("agent_*.py")):
|
||||
try:
|
||||
ev = ol.eval_book(_book(p))
|
||||
d = ev["daily"]
|
||||
if d.std() > 0:
|
||||
dailies[p.stem.replace("agent_", "")] = d
|
||||
except Exception as e:
|
||||
print(f" skip {p.stem}: {e}")
|
||||
|
||||
names = list(dailies)
|
||||
M = pd.concat(dailies, axis=1, join="inner").dropna()
|
||||
C = M.corr()
|
||||
|
||||
# greedy de-correlation: order by full-sample uplift, keep if corr<0.6 to all kept
|
||||
upf = {n: uplift_at(dailies[n], B, "2018-01-01") for n in names}
|
||||
order = sorted(names, key=lambda n: upf[n], reverse=True)
|
||||
reps = []
|
||||
for n in order:
|
||||
if all(abs(C.loc[n, r]) < 0.6 for r in reps):
|
||||
reps.append(n)
|
||||
|
||||
print(f"\n ORTHO META — {len(names)} books, mutual-corr clusters -> {len(reps)} de-correlated reps")
|
||||
print(f" mean |corr| among all books = {C.abs().values[np.triu_indices(len(names),1)].mean():.2f}")
|
||||
print(f"\n DE-CORRELATED REPRESENTATIVES (corr<0.6 to each other):")
|
||||
print(f" {'book':<20}{'up_full':>8} uplift at cut: " + " ".join(c[:7] for c in CUTS))
|
||||
for n in reps:
|
||||
ups = [uplift_at(dailies[n], B, c) for c in CUTS]
|
||||
print(f" {n:<20}{upf[n]:>8.3f} " + " ".join(f"{u:>+6.2f}" for u in ups))
|
||||
|
||||
# combined sleeve = equal-weight of representatives
|
||||
combo = M[reps].mean(axis=1)
|
||||
print(f"\n COMBINED relative-value sleeve (equal-weight of {len(reps)} reps):")
|
||||
print(f" {'':<20}{'up_full':>8} uplift at cut: " + " ".join(c[:7] for c in CUTS))
|
||||
ups = [uplift_at(combo, B, c) for c in CUTS]
|
||||
print(f" {'COMBO':<20}{uplift_at(combo,B,'2018-01-01'):>8.3f} " + " ".join(f"{u:>+6.2f}" for u in ups))
|
||||
print(f" combo standalone: Sharpe {_sh(combo):.2f} corr->TP01 {M[reps].mean(axis=1).corr(pd.concat({'b':B},axis=1).reindex(combo.index)['b']):.2f}")
|
||||
|
||||
# also: ALL ADDS equal-weight (what 'just deploy everything' would be)
|
||||
allc = M.mean(axis=1)
|
||||
ups = [uplift_at(allc, B, c) for c in CUTS]
|
||||
print(f"\n ALL-{len(names)} equal-weight sleeve:")
|
||||
print(f" {'ALL':<20}{uplift_at(allc,B,'2018-01-01'):>8.3f} " + " ".join(f"{u:>+6.2f}" for u in ups))
|
||||
print("\n READ: a column that is +ve at 2022/2023/2024 cuts (not only 2025) = persistent.")
|
||||
print(" all-positive-only-at-2025 = the ETH/BTC-bleed regime, not a standing premium.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,416 @@
|
||||
[
|
||||
{
|
||||
"name": "agent_03_relstrength_gated",
|
||||
"causal": true,
|
||||
"pnl": 0.311,
|
||||
"maxdd": 0.0939,
|
||||
"sharpe": 0.574,
|
||||
"net_beta": 0.0,
|
||||
"gross_lev": 0.727,
|
||||
"max_leg": 0.364,
|
||||
"turnover": 8.5,
|
||||
"executable": true,
|
||||
"corr_full": 0.117,
|
||||
"corr_hold": 0.316,
|
||||
"uplift_hold": 0.534,
|
||||
"uplift_full": 0.055,
|
||||
"alpha_ann": 0.0291,
|
||||
"resid_sharpe": 0.425,
|
||||
"robust_oos": true,
|
||||
"marginal_verdict": "ADDS",
|
||||
"clean_year_uplift": 0.642,
|
||||
"jackknife_min_uplift": 0.442,
|
||||
"earns_slot": true
|
||||
},
|
||||
{
|
||||
"name": "agent_04_ratio_donchian",
|
||||
"causal": true,
|
||||
"pnl": 0.7102,
|
||||
"maxdd": 0.2776,
|
||||
"sharpe": 0.559,
|
||||
"net_beta": 0.0,
|
||||
"gross_lev": 1.0,
|
||||
"max_leg": 0.5,
|
||||
"turnover": 6.8,
|
||||
"executable": true,
|
||||
"corr_full": 0.037,
|
||||
"corr_hold": 0.112,
|
||||
"uplift_hold": 0.533,
|
||||
"uplift_full": 0.097,
|
||||
"alpha_ann": 0.0781,
|
||||
"resid_sharpe": 0.511,
|
||||
"robust_oos": true,
|
||||
"marginal_verdict": "ADDS",
|
||||
"clean_year_uplift": 0.525,
|
||||
"jackknife_min_uplift": 0.355,
|
||||
"earns_slot": true
|
||||
},
|
||||
{
|
||||
"name": "agent_17_ensemble_rv",
|
||||
"causal": true,
|
||||
"pnl": 0.2757,
|
||||
"maxdd": 0.2474,
|
||||
"sharpe": 0.387,
|
||||
"net_beta": 0.0,
|
||||
"gross_lev": 1.0,
|
||||
"max_leg": 0.5,
|
||||
"turnover": 11.5,
|
||||
"executable": true,
|
||||
"corr_full": 0.023,
|
||||
"corr_hold": 0.313,
|
||||
"uplift_hold": 0.504,
|
||||
"uplift_full": 0.048,
|
||||
"alpha_ann": 0.0354,
|
||||
"resid_sharpe": 0.357,
|
||||
"robust_oos": true,
|
||||
"marginal_verdict": "ADDS",
|
||||
"clean_year_uplift": 0.493,
|
||||
"jackknife_min_uplift": 0.373,
|
||||
"earns_slot": true
|
||||
},
|
||||
{
|
||||
"name": "agent_02_beta_neutral_resid",
|
||||
"causal": true,
|
||||
"pnl": 0.4503,
|
||||
"maxdd": 0.206,
|
||||
"sharpe": 0.547,
|
||||
"net_beta": -0.008,
|
||||
"gross_lev": 0.986,
|
||||
"max_leg": 0.5,
|
||||
"turnover": 19.0,
|
||||
"executable": true,
|
||||
"corr_full": 0.05,
|
||||
"corr_hold": 0.096,
|
||||
"uplift_hold": 0.499,
|
||||
"uplift_full": 0.082,
|
||||
"alpha_ann": 0.0498,
|
||||
"resid_sharpe": 0.483,
|
||||
"robust_oos": true,
|
||||
"marginal_verdict": "ADDS",
|
||||
"clean_year_uplift": 0.608,
|
||||
"jackknife_min_uplift": 0.379,
|
||||
"earns_slot": true
|
||||
},
|
||||
{
|
||||
"name": "agent_00_ratio_mom_blend",
|
||||
"causal": true,
|
||||
"pnl": 0.306,
|
||||
"maxdd": 0.3023,
|
||||
"sharpe": 0.379,
|
||||
"net_beta": 0.0,
|
||||
"gross_lev": 1.0,
|
||||
"max_leg": 0.5,
|
||||
"turnover": 23.1,
|
||||
"executable": true,
|
||||
"corr_full": 0.021,
|
||||
"corr_hold": 0.28,
|
||||
"uplift_hold": 0.493,
|
||||
"uplift_full": 0.046,
|
||||
"alpha_ann": 0.0401,
|
||||
"resid_sharpe": 0.352,
|
||||
"robust_oos": true,
|
||||
"marginal_verdict": "ADDS",
|
||||
"clean_year_uplift": 0.49,
|
||||
"jackknife_min_uplift": 0.362,
|
||||
"earns_slot": true
|
||||
},
|
||||
{
|
||||
"name": "agent_09_corr_regime_rv",
|
||||
"causal": true,
|
||||
"pnl": 0.2541,
|
||||
"maxdd": 0.2386,
|
||||
"sharpe": 0.37,
|
||||
"net_beta": 0.0,
|
||||
"gross_lev": 1.0,
|
||||
"max_leg": 0.5,
|
||||
"turnover": 18.2,
|
||||
"executable": true,
|
||||
"corr_full": 0.034,
|
||||
"corr_hold": 0.382,
|
||||
"uplift_hold": 0.418,
|
||||
"uplift_full": 0.04,
|
||||
"alpha_ann": 0.0316,
|
||||
"resid_sharpe": 0.326,
|
||||
"robust_oos": true,
|
||||
"marginal_verdict": "ADDS",
|
||||
"clean_year_uplift": 0.483,
|
||||
"jackknife_min_uplift": 0.282,
|
||||
"earns_slot": true
|
||||
},
|
||||
{
|
||||
"name": "agent_05_ratio_ewma_cross",
|
||||
"causal": true,
|
||||
"pnl": 0.1192,
|
||||
"maxdd": 0.3547,
|
||||
"sharpe": 0.182,
|
||||
"net_beta": 0.0,
|
||||
"gross_lev": 1.0,
|
||||
"max_leg": 0.5,
|
||||
"turnover": 9.7,
|
||||
"executable": true,
|
||||
"corr_full": 0.036,
|
||||
"corr_hold": 0.21,
|
||||
"uplift_hold": 0.378,
|
||||
"uplift_full": -0.034,
|
||||
"alpha_ann": 0.0185,
|
||||
"resid_sharpe": 0.134,
|
||||
"robust_oos": true,
|
||||
"marginal_verdict": "ADDS",
|
||||
"clean_year_uplift": 0.39,
|
||||
"jackknife_min_uplift": 0.22,
|
||||
"earns_slot": true
|
||||
},
|
||||
{
|
||||
"name": "agent_14_dvol_spread",
|
||||
"causal": true,
|
||||
"pnl": 0.6178,
|
||||
"maxdd": 0.1627,
|
||||
"sharpe": 0.699,
|
||||
"net_beta": 0.0,
|
||||
"gross_lev": 1.0,
|
||||
"max_leg": 0.5,
|
||||
"turnover": 36.0,
|
||||
"executable": true,
|
||||
"corr_full": 0.095,
|
||||
"corr_hold": 0.315,
|
||||
"uplift_hold": 0.372,
|
||||
"uplift_full": 0.106,
|
||||
"alpha_ann": 0.0588,
|
||||
"resid_sharpe": 0.579,
|
||||
"robust_oos": true,
|
||||
"marginal_verdict": "ADDS",
|
||||
"clean_year_uplift": 0.349,
|
||||
"jackknife_min_uplift": 0.202,
|
||||
"earns_slot": true
|
||||
},
|
||||
{
|
||||
"name": "agent_08_kalman_spread",
|
||||
"causal": true,
|
||||
"pnl": 0.5467,
|
||||
"maxdd": 0.3611,
|
||||
"sharpe": 0.517,
|
||||
"net_beta": 0.0,
|
||||
"gross_lev": 1.0,
|
||||
"max_leg": 0.5,
|
||||
"turnover": 36.3,
|
||||
"executable": true,
|
||||
"corr_full": 0.036,
|
||||
"corr_hold": 0.123,
|
||||
"uplift_hold": 0.366,
|
||||
"uplift_full": 0.083,
|
||||
"alpha_ann": 0.0625,
|
||||
"resid_sharpe": 0.47,
|
||||
"robust_oos": true,
|
||||
"marginal_verdict": "ADDS",
|
||||
"clean_year_uplift": 0.43,
|
||||
"jackknife_min_uplift": 0.203,
|
||||
"earns_slot": true
|
||||
},
|
||||
{
|
||||
"name": "agent_07_ratio_carry_slow",
|
||||
"causal": true,
|
||||
"pnl": 0.135,
|
||||
"maxdd": 0.2022,
|
||||
"sharpe": 0.25,
|
||||
"net_beta": 0.0,
|
||||
"gross_lev": 1.0,
|
||||
"max_leg": 0.5,
|
||||
"turnover": 3.6,
|
||||
"executable": true,
|
||||
"corr_full": 0.012,
|
||||
"corr_hold": 0.218,
|
||||
"uplift_hold": 0.361,
|
||||
"uplift_full": 0.02,
|
||||
"alpha_ann": 0.0196,
|
||||
"resid_sharpe": 0.235,
|
||||
"robust_oos": true,
|
||||
"marginal_verdict": "ADDS",
|
||||
"clean_year_uplift": 0.355,
|
||||
"jackknife_min_uplift": 0.253,
|
||||
"earns_slot": true
|
||||
},
|
||||
{
|
||||
"name": "agent_15_vol_premium_tilt",
|
||||
"causal": true,
|
||||
"pnl": 0.2111,
|
||||
"maxdd": 0.2033,
|
||||
"sharpe": 0.373,
|
||||
"net_beta": 0.0,
|
||||
"gross_lev": 1.0,
|
||||
"max_leg": 0.5,
|
||||
"turnover": 13.0,
|
||||
"executable": true,
|
||||
"corr_full": 0.027,
|
||||
"corr_hold": -0.044,
|
||||
"uplift_hold": 0.361,
|
||||
"uplift_full": 0.042,
|
||||
"alpha_ann": 0.0266,
|
||||
"resid_sharpe": 0.338,
|
||||
"robust_oos": true,
|
||||
"marginal_verdict": "ADDS",
|
||||
"clean_year_uplift": 0.461,
|
||||
"jackknife_min_uplift": 0.238,
|
||||
"earns_slot": true
|
||||
},
|
||||
{
|
||||
"name": "agent_10_vol_regime_rv",
|
||||
"causal": true,
|
||||
"pnl": 0.2087,
|
||||
"maxdd": 0.2374,
|
||||
"sharpe": 0.364,
|
||||
"net_beta": 0.0,
|
||||
"gross_lev": 1.0,
|
||||
"max_leg": 0.5,
|
||||
"turnover": 14.5,
|
||||
"executable": true,
|
||||
"corr_full": -0.041,
|
||||
"corr_hold": 0.087,
|
||||
"uplift_hold": 0.355,
|
||||
"uplift_full": 0.059,
|
||||
"alpha_ann": 0.0336,
|
||||
"resid_sharpe": 0.417,
|
||||
"robust_oos": true,
|
||||
"marginal_verdict": "ADDS",
|
||||
"clean_year_uplift": 0.282,
|
||||
"jackknife_min_uplift": 0.265,
|
||||
"earns_slot": true
|
||||
},
|
||||
{
|
||||
"name": "agent_01_xs2_zscore",
|
||||
"causal": true,
|
||||
"pnl": 0.5451,
|
||||
"maxdd": 0.2368,
|
||||
"sharpe": 0.551,
|
||||
"net_beta": 0.0,
|
||||
"gross_lev": 1.0,
|
||||
"max_leg": 0.5,
|
||||
"turnover": 46.9,
|
||||
"executable": true,
|
||||
"corr_full": 0.044,
|
||||
"corr_hold": 0.104,
|
||||
"uplift_hold": 0.283,
|
||||
"uplift_full": 0.089,
|
||||
"alpha_ann": 0.0603,
|
||||
"resid_sharpe": 0.495,
|
||||
"robust_oos": true,
|
||||
"marginal_verdict": "ADDS",
|
||||
"clean_year_uplift": 0.284,
|
||||
"jackknife_min_uplift": 0.161,
|
||||
"earns_slot": true
|
||||
},
|
||||
{
|
||||
"name": "agent_11_vol_spread_rp",
|
||||
"causal": true,
|
||||
"pnl": 0.1391,
|
||||
"maxdd": 0.2846,
|
||||
"sharpe": 0.219,
|
||||
"net_beta": 0.0,
|
||||
"gross_lev": 1.0,
|
||||
"max_leg": 0.5,
|
||||
"turnover": 20.1,
|
||||
"executable": true,
|
||||
"corr_full": 0.023,
|
||||
"corr_hold": 0.322,
|
||||
"uplift_hold": 0.252,
|
||||
"uplift_full": 0.001,
|
||||
"alpha_ann": 0.0206,
|
||||
"resid_sharpe": 0.19,
|
||||
"robust_oos": true,
|
||||
"marginal_verdict": "ADDS",
|
||||
"clean_year_uplift": 0.214,
|
||||
"jackknife_min_uplift": 0.103,
|
||||
"earns_slot": true
|
||||
},
|
||||
{
|
||||
"name": "agent_16_disp_momentum",
|
||||
"causal": true,
|
||||
"pnl": 0.2042,
|
||||
"maxdd": 0.2016,
|
||||
"sharpe": 0.327,
|
||||
"net_beta": 0.0,
|
||||
"gross_lev": 1.0,
|
||||
"max_leg": 0.5,
|
||||
"turnover": 19.8,
|
||||
"executable": true,
|
||||
"corr_full": 0.002,
|
||||
"corr_hold": 0.119,
|
||||
"uplift_hold": 0.233,
|
||||
"uplift_full": 0.04,
|
||||
"alpha_ann": 0.0295,
|
||||
"resid_sharpe": 0.325,
|
||||
"robust_oos": true,
|
||||
"marginal_verdict": "ADDS",
|
||||
"clean_year_uplift": 0.366,
|
||||
"jackknife_min_uplift": 0.101,
|
||||
"earns_slot": true
|
||||
},
|
||||
{
|
||||
"name": "agent_06_ratio_accel",
|
||||
"causal": true,
|
||||
"pnl": 0.21,
|
||||
"maxdd": 0.2826,
|
||||
"sharpe": 0.331,
|
||||
"net_beta": 0.0,
|
||||
"gross_lev": 1.0,
|
||||
"max_leg": 0.5,
|
||||
"turnover": 23.3,
|
||||
"executable": true,
|
||||
"corr_full": 0.019,
|
||||
"corr_hold": 0.059,
|
||||
"uplift_hold": 0.23,
|
||||
"uplift_full": 0.036,
|
||||
"alpha_ann": 0.0282,
|
||||
"resid_sharpe": 0.307,
|
||||
"robust_oos": true,
|
||||
"marginal_verdict": "ADDS",
|
||||
"clean_year_uplift": 0.339,
|
||||
"jackknife_min_uplift": 0.112,
|
||||
"earns_slot": true
|
||||
},
|
||||
{
|
||||
"name": "agent_13_lead_lag",
|
||||
"causal": true,
|
||||
"pnl": 0.1544,
|
||||
"maxdd": 0.0714,
|
||||
"sharpe": 0.53,
|
||||
"net_beta": 0.0,
|
||||
"gross_lev": 0.743,
|
||||
"max_leg": 0.372,
|
||||
"turnover": 5.3,
|
||||
"executable": true,
|
||||
"corr_full": 0.058,
|
||||
"corr_hold": 0.188,
|
||||
"uplift_hold": 0.158,
|
||||
"uplift_full": 0.04,
|
||||
"alpha_ann": 0.0176,
|
||||
"resid_sharpe": 0.456,
|
||||
"robust_oos": true,
|
||||
"marginal_verdict": "ADDS",
|
||||
"clean_year_uplift": 0.172,
|
||||
"jackknife_min_uplift": 0.117,
|
||||
"earns_slot": true
|
||||
},
|
||||
{
|
||||
"name": "agent_12_rebalance_harvest",
|
||||
"causal": true,
|
||||
"pnl": -0.1269,
|
||||
"maxdd": 0.1717,
|
||||
"sharpe": -0.544,
|
||||
"net_beta": 0.0,
|
||||
"gross_lev": 0.87,
|
||||
"max_leg": 0.435,
|
||||
"turnover": 6.8,
|
||||
"executable": true,
|
||||
"corr_full": -0.052,
|
||||
"corr_hold": 0.026,
|
||||
"uplift_hold": -0.003,
|
||||
"uplift_full": -0.048,
|
||||
"alpha_ann": -0.0158,
|
||||
"resid_sharpe": -0.477,
|
||||
"robust_oos": false,
|
||||
"marginal_verdict": "NEUTRAL",
|
||||
"clean_year_uplift": -0.034,
|
||||
"jackknife_min_uplift": -0.066,
|
||||
"earns_slot": false
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""ortho_score — judge a relative-value BOOK module for a LIVE ORTHOGONAL slot.
|
||||
|
||||
A module defines `book(btc, eth) -> (w_btc, w_eth)`. This runs the causality guard, the
|
||||
standalone backtest, the MARGINAL-vs-TP01 score, and the executability check, then prints
|
||||
one json line + a human verdict. earns_slot = ADDS + robust_oos + executable + causal.
|
||||
|
||||
uv run python scripts/research/ortho/ortho_score.py --module <path.py>
|
||||
uv run python scripts/research/ortho/ortho_score.py --all # score agents/*.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
import ortholib as ol # noqa: E402
|
||||
|
||||
AGENTS = HERE / "agents"
|
||||
|
||||
|
||||
def _book(path: Path):
|
||||
spec = importlib.util.spec_from_file_location(path.stem, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.book
|
||||
|
||||
|
||||
def score(path: Path) -> dict:
|
||||
rec = {"name": path.stem}
|
||||
try:
|
||||
book = _book(path)
|
||||
except Exception as e:
|
||||
return {**rec, "error": f"import: {e}", "earns_slot": False}
|
||||
try:
|
||||
caus = ol.causality_ok(book)
|
||||
rec["causal"] = bool(caus.get("ok"))
|
||||
if not rec["causal"]:
|
||||
rec["causality"] = caus
|
||||
return {**rec, "earns_slot": False, "verdict": "LEAK"}
|
||||
except Exception as e:
|
||||
return {**rec, "error": f"causality: {e}", "earns_slot": False}
|
||||
try:
|
||||
r = ol.marginal(book)
|
||||
ev, m = r["book"], r["marginal"]
|
||||
rec.update(
|
||||
pnl=ev["pnl"], maxdd=ev["maxdd"], sharpe=ev["sharpe"],
|
||||
net_beta=ev["net_beta"], gross_lev=ev["gross_lev"], max_leg=ev["max_leg_frac"],
|
||||
turnover=ev["turnover_per_year"], executable=ev["executable"],
|
||||
corr_full=m.get("corr_full"), corr_hold=m.get("corr_hold"),
|
||||
uplift_hold=m.get("blends", {}).get("w25", {}).get("uplift_hold"),
|
||||
uplift_full=m.get("blends", {}).get("w25", {}).get("uplift_full"),
|
||||
alpha_ann=m.get("alpha_ann"), resid_sharpe=m.get("resid_sharpe_full"),
|
||||
robust_oos=m.get("robust_oos"), marginal_verdict=m.get("marginal_verdict"),
|
||||
clean_year_uplift=m.get("clean_year_uplift"),
|
||||
jackknife_min_uplift=m.get("jackknife_min_uplift"),
|
||||
)
|
||||
rec["earns_slot"] = bool(
|
||||
m.get("marginal_verdict") == "ADDS" and m.get("robust_oos") and ev["executable"])
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return {**rec, "error": f"eval: {e}\n{traceback.format_exc()[-300:]}", "earns_slot": False}
|
||||
return rec
|
||||
|
||||
|
||||
def _row(r: dict) -> str:
|
||||
if "error" in r:
|
||||
return f" {r['name'][:30]:<30} ERROR {r['error'][:50]}"
|
||||
if r.get("verdict") == "LEAK":
|
||||
return f" {r['name'][:30]:<30} LEAK (disqualified)"
|
||||
return (f" {r['name'][:30]:<30} {str(r['marginal_verdict']):<9} "
|
||||
f"corr {str(r.get('corr_hold')):>6} up_h {str(r.get('uplift_hold')):>6} "
|
||||
f"rob {str(r.get('robust_oos')):>5} exec {str(r.get('executable')):>5} "
|
||||
f"| pnl {r['pnl']*100:>+5.0f}% dd {r['maxdd']*100:>3.0f}% "
|
||||
f"{'<<< SLOT' if r.get('earns_slot') else ''}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--module"); ap.add_argument("--all", action="store_true")
|
||||
args = ap.parse_args()
|
||||
if args.all:
|
||||
rows = [score(p) for p in sorted(AGENTS.glob("agent_*.py"))]
|
||||
rows.sort(key=lambda r: (r.get("earns_slot", False),
|
||||
r.get("uplift_hold") or -9), reverse=True)
|
||||
print(f"\n ORTHO-vs-TP01 leaderboard ({len(rows)} books) judge = marginal uplift + robust_oos + executable")
|
||||
print(f" {'name':<30} {'verdict':<9} {'corrH':>6} {'up_h':>6} {'rob':>5} {'exec':>5} pnl/dd")
|
||||
print(" " + "-" * 92)
|
||||
for r in rows:
|
||||
print(_row(r))
|
||||
slots = [r for r in rows if r.get("earns_slot")]
|
||||
print(f"\n EARNS SLOT: {[r['name'] for r in slots] or 'NONE'}")
|
||||
(HERE / "ortho_leaderboard.json").write_text(json.dumps(rows, indent=2, default=str))
|
||||
else:
|
||||
r = score(Path(args.module))
|
||||
print(json.dumps(r, indent=2, default=str))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,133 @@
|
||||
"""ortholib — lab for strategies ORTHOGONAL to TP01 (the only thing worth a NEW live slot).
|
||||
|
||||
The blind fleet proved (again) that directional BTC/ETH is trend-beta of TP01 — a ~1.3
|
||||
ceiling, nothing new to deploy. The ONLY way to earn a live slot is a mechanism whose
|
||||
returns are NOT explained by TP01's trend. The most promising one that is ALSO executable
|
||||
at our real ~$600 (unlike XS01's 19-leg book, which needs ~$20k) is a **2-leg BTC/ETH
|
||||
relative-value book**: long one leg / short (or under-weight) the other. Being roughly
|
||||
market-neutral, its correlation to TP01 (a long-flat trend on the SUM) is naturally low.
|
||||
|
||||
A candidate here is a BOOK function:
|
||||
def book(btc, eth) -> (w_btc, w_eth)
|
||||
where w_*[i] in [-1,1] is the per-leg weight (fraction of equity, sign = long/short),
|
||||
decided causally with data <= close[i]. The evaluator SHIFTS both legs (held during bar
|
||||
i+1), charges fees on BOTH legs' turnover, and:
|
||||
* builds the book's net DAILY return series,
|
||||
* scores it MARGINALLY vs TP01 via altlib.marginal_vs_tp01 (corr, OOS blend uplift,
|
||||
residual alpha, robust_oos jackknife) — the verdict that matters is ADDS, not Sharpe,
|
||||
* checks EXECUTABILITY at $600 (per-leg notional within the live cap, turnover sane),
|
||||
* runs the same online causality guard as the blind lab.
|
||||
|
||||
Judge a candidate on: marginal_verdict == 'ADDS' AND robust_oos AND executable. Absolute
|
||||
PnL/DD are reported but are NOT the gate (a market-neutral sleeve has modest absolute
|
||||
numbers by design; its job is to improve the PORTFOLIO, not to win alone).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||
import altlib as al # noqa: E402
|
||||
|
||||
FEE_SIDE = al.FEE_SIDE
|
||||
CAPITAL = 600.0 # real mainnet capital (NOT the 2000 paper nominal)
|
||||
CAP_NOTIONAL = 300.0 # live guardrail: $300 notional / asset
|
||||
MIN_ORDER = 5.0
|
||||
CAP_FRAC = CAP_NOTIONAL / CAPITAL # 0.5 of capital per leg
|
||||
|
||||
|
||||
def aligned():
|
||||
"""BTC & ETH 1d on COMMON dates (inner join), as two parallel DataFrames with a
|
||||
shared 'datetime'. Relative-value needs both legs at the same bar."""
|
||||
b = al.get("BTC", "1d").copy()
|
||||
e = al.get("ETH", "1d").copy()
|
||||
b["dt"] = pd.to_datetime(b["datetime"], utc=True)
|
||||
e["dt"] = pd.to_datetime(e["datetime"], utc=True)
|
||||
common = pd.Index(b["dt"]).intersection(pd.Index(e["dt"]))
|
||||
b = b[b["dt"].isin(common)].sort_values("dt").reset_index(drop=True)
|
||||
e = e[e["dt"].isin(common)].sort_values("dt").reset_index(drop=True)
|
||||
return b, e
|
||||
|
||||
|
||||
def eval_book(book_fn, fee_side: float = FEE_SIDE) -> dict:
|
||||
"""Honest backtest of a 2-leg BTC/ETH book. Weights decided at close[i] are HELD
|
||||
during bar i+1 (shift here -> no leak). Fee on both legs' turnover. Returns standalone
|
||||
metrics, the net DAILY series (for marginal scoring), and executability stats."""
|
||||
btc, eth = aligned()
|
||||
wb, we = book_fn(btc, eth)
|
||||
wb = np.clip(np.nan_to_num(np.asarray(wb, float), nan=0.0), -1, 1)
|
||||
we = np.clip(np.nan_to_num(np.asarray(we, float), nan=0.0), -1, 1)
|
||||
n = len(btc)
|
||||
rb = al.simple_returns(btc["close"].values.astype(float))
|
||||
re = al.simple_returns(eth["close"].values.astype(float))
|
||||
pb = np.zeros(n); pb[1:] = wb[:-1]
|
||||
pe = np.zeros(n); pe[1:] = we[:-1]
|
||||
gross = pb * rb + pe * re
|
||||
turn = np.abs(np.diff(pb, prepend=0.0)) + np.abs(np.diff(pe, prepend=0.0))
|
||||
net = gross - fee_side * turn
|
||||
net[0] = 0.0
|
||||
idx = pd.DatetimeIndex(btc["dt"])
|
||||
m = al._metrics_from_net(net, idx)
|
||||
daily = al._to_daily(pd.Series(net, index=idx))
|
||||
# executability: worst per-leg notional fraction, and net market exposure
|
||||
max_leg = float(np.max(np.maximum(np.abs(pb), np.abs(pe)))) if n else 0.0
|
||||
gross_lev = float(np.max(np.abs(pb) + np.abs(pe))) if n else 0.0
|
||||
net_beta = float(np.mean(pb + pe)) # ~0 => market-neutral
|
||||
turnover_yr = float(turn.sum() / (len(turn) / 365.25))
|
||||
return dict(
|
||||
pnl=m["ret"], maxdd=m["maxdd"], sharpe=m["sharpe"], cagr=m["cagr"],
|
||||
net=net, idx=idx, daily=daily,
|
||||
max_leg_frac=round(max_leg, 3), gross_lev=round(gross_lev, 3),
|
||||
net_beta=round(net_beta, 3), turnover_per_year=round(turnover_yr, 1),
|
||||
executable=bool(max_leg <= CAP_FRAC + 1e-9 and max_leg * CAPITAL >= MIN_ORDER),
|
||||
)
|
||||
|
||||
|
||||
def marginal(book_fn, fee_side: float = FEE_SIDE) -> dict:
|
||||
"""eval_book -> altlib.marginal_vs_tp01 on the book's daily returns. The real verdict."""
|
||||
ev = eval_book(book_fn, fee_side)
|
||||
marg = al.marginal_vs_tp01(ev["daily"])
|
||||
return dict(book=ev, marginal=marg)
|
||||
|
||||
|
||||
def causality_ok(book_fn, tail: int = 60, tol: float = 1e-4) -> dict:
|
||||
"""Online-consistency guard: book(btc[:cut], eth[:cut]) must match book(full)[:cut]
|
||||
on the tail before each cut. Catches any look-ahead / global fit in either leg."""
|
||||
btc, eth = aligned()
|
||||
wbf, wef = book_fn(btc, eth)
|
||||
wbf = np.nan_to_num(np.asarray(wbf, float)); wef = np.nan_to_num(np.asarray(wef, float))
|
||||
n = len(btc)
|
||||
max_diff = 0.0; frac_bad = 0.0; checked = []
|
||||
for cut in (int(n * 0.80), int(n * 0.92)):
|
||||
if cut <= tail + 5 or cut >= n:
|
||||
continue
|
||||
wbs, wes = book_fn(btc.iloc[:cut].reset_index(drop=True),
|
||||
eth.iloc[:cut].reset_index(drop=True))
|
||||
wbs = np.nan_to_num(np.asarray(wbs, float)); wes = np.nan_to_num(np.asarray(wes, float))
|
||||
if len(wbs) != cut or len(wes) != cut:
|
||||
return dict(ok=False, reason=f"book returned wrong length on prefix {cut}",
|
||||
max_diff=9.99, frac_bad=1.0)
|
||||
d = np.maximum(np.abs(wbs[cut - tail:cut] - wbf[cut - tail:cut]),
|
||||
np.abs(wes[cut - tail:cut] - wef[cut - tail:cut]))
|
||||
max_diff = max(max_diff, float(np.max(d)) if len(d) else 0.0)
|
||||
frac_bad = max(frac_bad, float(np.mean(d > tol)) if len(d) else 0.0)
|
||||
checked.append(cut)
|
||||
ok = (max_diff <= max(tol * 10, 1e-3)) and (frac_bad <= 0.02)
|
||||
return dict(ok=bool(ok), max_diff=round(max_diff, 6), frac_bad=round(frac_bad, 4),
|
||||
checked_at=checked)
|
||||
|
||||
|
||||
# convenient causal helpers re-exported (same as blind/altlib)
|
||||
simple_returns = al.simple_returns
|
||||
log_returns = al.log_returns
|
||||
ema = al.ema; sma = al.sma; rolling_std = al.rolling_std; zscore = al.zscore
|
||||
rsi = al.rsi; realized_vol = al.realized_vol; donchian = al.donchian; bbands = al.bbands
|
||||
vol_target = al.vol_target
|
||||
|
||||
__all__ = ["aligned", "eval_book", "marginal", "causality_ok", "CAPITAL", "CAP_FRAC",
|
||||
"FEE_SIDE", "simple_returns", "log_returns", "ema", "sma", "rolling_std",
|
||||
"zscore", "rsi", "realized_vol", "donchian", "bbands", "vol_target"]
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Adversarial dissection of the SELECTION-FREE relative-value basket vs TP01."""
|
||||
from __future__ import annotations
|
||||
import importlib.util, sys
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||
import ortholib as ol
|
||||
import altlib as al
|
||||
|
||||
AGENTS = HERE / "agents"
|
||||
|
||||
|
||||
def _book(path: Path):
|
||||
spec = importlib.util.spec_from_file_location(path.stem, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.book
|
||||
|
||||
|
||||
def _sh(s) -> float:
|
||||
r = np.asarray(pd.Series(s).dropna().values, float)
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if len(r) > 2 and np.std(r) > 0 else 0.0
|
||||
|
||||
|
||||
def _dd(s) -> float:
|
||||
eq = (1.0 + pd.Series(s).dropna()).cumprod()
|
||||
return float((eq / eq.cummax() - 1.0).min())
|
||||
|
||||
|
||||
def uplift_at(cand: pd.Series, B: pd.Series, lo=None, hi=None, w=0.25) -> float:
|
||||
J = pd.concat({"B": B, "C": cand}, axis=1, join="inner").dropna()
|
||||
if lo is not None:
|
||||
J = J[J.index >= pd.Timestamp(lo, tz="UTC")]
|
||||
if hi is not None:
|
||||
J = J[J.index < pd.Timestamp(hi, tz="UTC")]
|
||||
if len(J) < 30:
|
||||
return float("nan")
|
||||
return _sh((1 - w) * J["B"] + w * J["C"]) - _sh(J["B"])
|
||||
|
||||
|
||||
def main():
|
||||
B = al.tp01_baseline_daily()
|
||||
|
||||
# ---- collect ALL books, classify ----
|
||||
rows = {}
|
||||
meta = {}
|
||||
for p in sorted(AGENTS.glob("agent_*.py")):
|
||||
try:
|
||||
ev = ol.eval_book(_book(p))
|
||||
except Exception as e:
|
||||
print(f"skip {p.stem}: {e}"); continue
|
||||
d = ev["daily"]
|
||||
if d.std() == 0:
|
||||
print(f"skip degenerate {p.stem}"); continue
|
||||
rows[p.stem.replace("agent_", "")] = d
|
||||
meta[p.stem.replace("agent_", "")] = ev
|
||||
|
||||
names = list(rows)
|
||||
M = pd.concat(rows, axis=1, join="inner").dropna()
|
||||
print(f"\nBooks loaded: {len(names)} common-date matrix: {M.shape[0]} days "
|
||||
f"[{M.index.min().date()} .. {M.index.max().date()}]")
|
||||
|
||||
# selection-free: ALL non-degenerate, market-neutral. exclude only the OBVIOUS
|
||||
# degenerate DILUTE (rebalance_harvest, sharpe<0 standalone & negative every metric).
|
||||
excl = ["12_rebalance_harvest"]
|
||||
keep = [n for n in names if n not in excl]
|
||||
print(f"Basket = equal-weight of {len(keep)} books (excluded only {excl})")
|
||||
|
||||
basket = M[keep].mean(axis=1)
|
||||
basket_all = M.mean(axis=1) # truly ALL incl rebalance_harvest
|
||||
|
||||
for label, bk in [("BASKET(excl harvest)", basket), ("BASKET(all 18)", basket_all)]:
|
||||
print(f"\n===== {label} =====")
|
||||
Bal = pd.concat({"B": B, "C": bk}, axis=1, join="inner").dropna()
|
||||
corr = Bal["B"].corr(Bal["C"])
|
||||
print(f" standalone Sharpe {_sh(bk):.3f} maxDD {_dd(bk):.3f} corr->TP01 {corr:.3f} "
|
||||
f"n={len(bk)}")
|
||||
# full + per-cut uplift (forward windows from cut)
|
||||
print(f" uplift (w=0.25, window = [cut, end)):")
|
||||
for cut in ["2018-01-01", "2021-01-01", "2022-01-01", "2023-01-01", "2024-01-01", "2025-01-01"]:
|
||||
u = uplift_at(bk, B, lo=cut)
|
||||
print(f" from {cut[:4]}+ : {u:+.3f}")
|
||||
# PRE-2025 ONLY (exclude suspect window)
|
||||
u_pre = uplift_at(bk, B, hi="2025-01-01")
|
||||
u_2025 = uplift_at(bk, B, lo="2025-01-01")
|
||||
print(f" uplift 2019-2024 ONLY (exclude suspect window): {u_pre:+.3f}")
|
||||
print(f" uplift 2025+ ONLY : {u_2025:+.3f}")
|
||||
# per-year standalone returns & per-year uplift
|
||||
print(f" per-year: standalone ret | blend-uplift(w0.25, that year only)")
|
||||
for y in sorted(set(bk.index.year)):
|
||||
sub = bk[bk.index.year == y]
|
||||
ret = float((1 + sub).prod() - 1)
|
||||
uy = uplift_at(bk, B, lo=f"{y}-01-01", hi=f"{y+1}-01-01")
|
||||
print(f" {y}: ret {ret:+7.2%} uplift {uy:+.3f}")
|
||||
|
||||
# ---- decompose full-sample uplift: pre vs post 2025 ----
|
||||
print("\n===== UPLIFT DECOMPOSITION (basket excl harvest, w=0.25) =====")
|
||||
full = uplift_at(basket, B, lo="2018-01-01")
|
||||
print(f" full-sample uplift {full:+.3f}")
|
||||
print(f" pre-2025 (2019-2024) {uplift_at(basket,B,hi='2025-01-01'):+.3f}")
|
||||
print(f" 2025+ only {uplift_at(basket,B,lo='2025-01-01'):+.3f}")
|
||||
|
||||
# ---- fee sensitivity: rebuild basket at higher fees ----
|
||||
print("\n===== FEE SENSITIVITY (rebuild every book's daily net at higher RT fee) =====")
|
||||
for fee_side in [0.0005, 0.0010, 0.0015]:
|
||||
dd = {}
|
||||
for p in sorted(AGENTS.glob("agent_*.py")):
|
||||
nm = p.stem.replace("agent_", "")
|
||||
if nm in excl: continue
|
||||
try:
|
||||
ev = ol.eval_book(_book(p), fee_side=fee_side)
|
||||
if ev["daily"].std() > 0:
|
||||
dd[nm] = ev["daily"]
|
||||
except Exception:
|
||||
pass
|
||||
Mf = pd.concat(dd, axis=1, join="inner").dropna()
|
||||
bk = Mf.mean(axis=1)
|
||||
u_full = uplift_at(bk, B, lo="2018-01-01")
|
||||
u_pre = uplift_at(bk, B, hi="2025-01-01")
|
||||
u_25 = uplift_at(bk, B, lo="2025-01-01")
|
||||
print(f" RT={2*fee_side*100:.2f}% standalone Sh {_sh(bk):+.3f} "
|
||||
f"uplift full {u_full:+.3f} | pre25 {u_pre:+.3f} | 25+ {u_25:+.3f}")
|
||||
|
||||
# ---- turnover / executability of the basket ----
|
||||
print("\n===== EXECUTION REALISM (per-book at $600, basket-averaged legs) =====")
|
||||
tns = [meta[n]["turnover_per_year"] for n in keep]
|
||||
print(f" per-book turnover/yr: min {min(tns):.1f} med {np.median(tns):.1f} max {max(tns):.1f}")
|
||||
print(f" Basket = mean of {len(keep)} books; each leg capped 0.5. Averaged turnover lower.")
|
||||
# build averaged book legs to measure REAL basket turnover & notional
|
||||
btc, eth = ol.aligned()
|
||||
wbs, wes = [], []
|
||||
for n in keep:
|
||||
wb, we = _book(AGENTS / f"agent_{n}.py")(btc, eth)
|
||||
wbs.append(np.nan_to_num(np.asarray(wb, float)))
|
||||
wes.append(np.nan_to_num(np.asarray(we, float)))
|
||||
wb = np.clip(np.mean(wbs, axis=0), -0.5, 0.5)
|
||||
we = np.clip(np.mean(wes, axis=0), -0.5, 0.5)
|
||||
pb = np.zeros(len(wb)); pb[1:] = wb[:-1]
|
||||
pe = np.zeros(len(we)); pe[1:] = we[:-1]
|
||||
turn = np.abs(np.diff(pb, prepend=0.0)) + np.abs(np.diff(pe, prepend=0.0))
|
||||
turn_yr = turn.sum() / (len(turn) / 365.25)
|
||||
max_leg = float(np.max(np.maximum(np.abs(pb), np.abs(pe))))
|
||||
print(f" AVERAGED-BASKET book: turnover/yr {turn_yr:.1f} max-leg-frac {max_leg:.3f} "
|
||||
f"max per-leg notional @$600 = ${max_leg*600:.0f}")
|
||||
print(f" typical daily notional traded @$600 satellite (say 20% slot=$120): "
|
||||
f"${turn_yr/365.25*120:.2f}/day both legs")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Null tests: is the pre-2025 +0.027 uplift distinguishable from 'any zero-mean low-corr
|
||||
noise asset dampens a Sharpe-1.3 portfolio at w=0.25'? And bootstrap CI on the uplift."""
|
||||
from __future__ import annotations
|
||||
import importlib.util, sys
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||
import ortholib as ol
|
||||
import altlib as al
|
||||
AGENTS = HERE / "agents"
|
||||
rng = np.random.default_rng(42)
|
||||
|
||||
|
||||
def _book(path):
|
||||
spec = importlib.util.spec_from_file_location(path.stem, path)
|
||||
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||
return mod.book
|
||||
|
||||
|
||||
def _sh(s):
|
||||
r = np.asarray(pd.Series(s).dropna().values, float)
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if len(r) > 2 and np.std(r) > 0 else 0.0
|
||||
|
||||
|
||||
def uplift(B, C, lo=None, hi=None, w=0.25):
|
||||
J = pd.concat({"B": B, "C": C}, axis=1, join="inner").dropna()
|
||||
if lo is not None: J = J[J.index >= pd.Timestamp(lo, tz="UTC")]
|
||||
if hi is not None: J = J[J.index < pd.Timestamp(hi, tz="UTC")]
|
||||
if len(J) < 30: return float("nan")
|
||||
return _sh((1-w)*J["B"] + w*J["C"]) - _sh(J["B"])
|
||||
|
||||
|
||||
def main():
|
||||
B = al.tp01_baseline_daily()
|
||||
excl = ["12_rebalance_harvest"]
|
||||
rows = {}
|
||||
for p in sorted(AGENTS.glob("agent_*.py")):
|
||||
nm = p.stem.replace("agent_", "")
|
||||
if nm in excl: continue
|
||||
ev = ol.eval_book(_book(p))
|
||||
if ev["daily"].std() > 0:
|
||||
rows[nm] = ev["daily"]
|
||||
M = pd.concat(rows, axis=1, join="inner").dropna()
|
||||
basket = M.mean(axis=1)
|
||||
|
||||
# ---- NULL 1: a synthetic zero-corr asset with the SAME standalone Sharpe & vol as the
|
||||
# basket. If it gives the same ~+0.03 pre-2025 uplift, the uplift is just diversification
|
||||
# math, not a specific edge.
|
||||
Jb = pd.concat({"B": B, "C": basket}, axis=1, join="inner").dropna()
|
||||
pre = Jb[Jb.index < pd.Timestamp("2025-01-01", tz="UTC")]
|
||||
bk_pre = pre["C"]
|
||||
mu, sd = bk_pre.mean(), bk_pre.std()
|
||||
print(f"Pre-2025 basket: mean {mu:.5f}/day vol {sd:.5f} Sharpe {_sh(bk_pre):.3f} "
|
||||
f"corr->TP01 {pre['B'].corr(pre['C']):.3f}")
|
||||
print(f"Pre-2025 ACTUAL basket uplift (w=0.25): {uplift(B,basket,hi='2025-01-01'):+.3f}\n")
|
||||
|
||||
print("NULL: 500 synthetic zero-corr assets, same daily mean & vol as pre-2025 basket,")
|
||||
print(" independent random sign each day. Pre-2025 uplift distribution:")
|
||||
null_u = []
|
||||
for _ in range(500):
|
||||
noise = pd.Series(rng.normal(mu, sd, len(pre)), index=pre.index)
|
||||
null_u.append(uplift(B, noise, hi="2025-01-01"))
|
||||
null_u = np.array(null_u)
|
||||
print(f" null uplift: mean {null_u.mean():+.3f} sd {null_u.std():.3f} "
|
||||
f"5-95pct [{np.percentile(null_u,5):+.3f}, {np.percentile(null_u,95):+.3f}]")
|
||||
actual = uplift(B, basket, hi="2025-01-01")
|
||||
pctile = (null_u < actual).mean()*100
|
||||
print(f" ACTUAL pre-2025 uplift {actual:+.3f} sits at the {pctile:.0f}th percentile of the null")
|
||||
print(" (if ~50th pct, the pre-2025 'edge' is INDISTINGUISHABLE from a positive-drift noise asset)\n")
|
||||
|
||||
# ---- NULL 2: same but ZERO mean (pure diversification, no standalone return) ----
|
||||
null_u0 = []
|
||||
for _ in range(500):
|
||||
noise = pd.Series(rng.normal(0.0, sd, len(pre)), index=pre.index)
|
||||
null_u0.append(uplift(B, noise, hi="2025-01-01"))
|
||||
null_u0 = np.array(null_u0)
|
||||
print(f" ZERO-mean noise null uplift: mean {null_u0.mean():+.3f} "
|
||||
f"5-95pct [{np.percentile(null_u0,5):+.3f}, {np.percentile(null_u0,95):+.3f}]")
|
||||
print(f" => pure diversification of a zero-return asset gives ~{null_u0.mean():+.3f} uplift\n")
|
||||
|
||||
# ---- bootstrap CI on the FULL-sample and pre-2025 basket uplift (block bootstrap) ----
|
||||
print("Block-bootstrap (30d blocks, 1000 draws) CI on uplift:")
|
||||
for lbl, lo, hi in [("full", "2018-01-01", None), ("pre-2025", "2018-01-01", "2025-01-01"),
|
||||
("2025+", "2025-01-01", None)]:
|
||||
J = pd.concat({"B": B, "C": basket}, axis=1, join="inner").dropna()
|
||||
if lo: J = J[J.index >= pd.Timestamp(lo, tz="UTC")]
|
||||
if hi: J = J[J.index < pd.Timestamp(hi, tz="UTC")]
|
||||
n = len(J); bl = 30; nb = n // bl
|
||||
ups = []
|
||||
for _ in range(1000):
|
||||
starts = rng.integers(0, n - bl, nb)
|
||||
idx = np.concatenate([np.arange(s, s+bl) for s in starts])
|
||||
sub = J.iloc[idx]
|
||||
ups.append(_sh(0.75*sub["B"]+0.25*sub["C"]) - _sh(sub["B"]))
|
||||
ups = np.array(ups)
|
||||
frac_pos = (ups > 0).mean()*100
|
||||
print(f" {lbl:9s}: median {np.median(ups):+.3f} "
|
||||
f"5-95pct [{np.percentile(ups,5):+.3f}, {np.percentile(ups,95):+.3f}] "
|
||||
f"P(uplift>0)={frac_pos:.0f}%")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Is the basket uplift just 'helps when TP01 is weak'? Quantify regime-conditionality."""
|
||||
from __future__ import annotations
|
||||
import importlib.util, sys
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
|
||||
import ortholib as ol
|
||||
import altlib as al
|
||||
AGENTS = HERE / "agents"
|
||||
|
||||
|
||||
def _book(path):
|
||||
spec = importlib.util.spec_from_file_location(path.stem, path)
|
||||
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
|
||||
return mod.book
|
||||
|
||||
|
||||
def _sh(s):
|
||||
r = np.asarray(pd.Series(s).dropna().values, float)
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if len(r) > 2 and np.std(r) > 0 else 0.0
|
||||
|
||||
|
||||
def main():
|
||||
B = al.tp01_baseline_daily()
|
||||
excl = ["12_rebalance_harvest"]
|
||||
rows = {}
|
||||
for p in sorted(AGENTS.glob("agent_*.py")):
|
||||
nm = p.stem.replace("agent_", "")
|
||||
if nm in excl: continue
|
||||
ev = ol.eval_book(_book(p))
|
||||
if ev["daily"].std() > 0:
|
||||
rows[nm] = ev["daily"]
|
||||
M = pd.concat(rows, axis=1, join="inner").dropna()
|
||||
basket = M.mean(axis=1)
|
||||
J = pd.concat({"B": B, "C": basket}, axis=1, join="inner").dropna()
|
||||
|
||||
print("Per-year: TP01 own Sharpe | basket Sharpe | basket ret | blend-uplift")
|
||||
print(" (does the basket help SPECIFICALLY in TP01's weak years?)")
|
||||
for y in sorted(set(J.index.year)):
|
||||
sub = J[J.index.year == y]
|
||||
if len(sub) < 20: continue
|
||||
tp_sh = _sh(sub["B"]); bk_sh = _sh(sub["C"])
|
||||
bk_ret = float((1 + sub["C"]).prod() - 1)
|
||||
up = _sh(0.75 * sub["B"] + 0.25 * sub["C"]) - _sh(sub["B"])
|
||||
print(f" {y}: TP01 Sh {tp_sh:+6.2f} | basket Sh {bk_sh:+6.2f} | ret {bk_ret:+7.2%} | uplift {up:+.3f}")
|
||||
|
||||
# correlation: does basket-year-uplift track NEGATIVE TP01-year-Sharpe?
|
||||
rows2 = []
|
||||
for y in sorted(set(J.index.year)):
|
||||
sub = J[J.index.year == y]
|
||||
if len(sub) < 20: continue
|
||||
rows2.append((_sh(sub["B"]), _sh(0.75*sub["B"]+0.25*sub["C"]) - _sh(sub["B"])))
|
||||
arr = np.array(rows2)
|
||||
if len(arr) > 2:
|
||||
c = np.corrcoef(arr[:,0], arr[:,1])[0,1]
|
||||
print(f"\n corr(TP01 yearly Sharpe, basket yearly uplift) = {c:+.2f}")
|
||||
print(" (strongly NEGATIVE => basket only helps when TP01 is weak = regime hedge, not standing alpha)")
|
||||
|
||||
# conditional uplift: split days by whether TP01 trailing-60d return is up or down
|
||||
tp_trail = J["B"].rolling(60).sum()
|
||||
up_days = J[tp_trail > 0]; dn_days = J[tp_trail <= 0]
|
||||
for lbl, d in [("TP01 trailing-60d UP", up_days), ("TP01 trailing-60d DOWN", dn_days)]:
|
||||
if len(d) < 30: continue
|
||||
u = _sh(0.75*d["B"]+0.25*d["C"]) - _sh(d["B"])
|
||||
print(f" [{lbl}] n={len(d)} basket Sh {_sh(d['C']):+.2f} blend-uplift {u:+.3f}")
|
||||
|
||||
# ETH/BTC ratio regime: is the basket net-short ETH (i.e. is it just shorting the bleed)?
|
||||
btc, eth = ol.aligned()
|
||||
wbs, wes = [], []
|
||||
for nm in rows:
|
||||
wb, we = _book(AGENTS / f"agent_{nm}.py")(btc, eth)
|
||||
wbs.append(np.nan_to_num(np.asarray(wb,float))); wes.append(np.nan_to_num(np.asarray(we,float)))
|
||||
wb = np.clip(np.mean(wbs,axis=0),-0.5,0.5); we = np.clip(np.mean(wes,axis=0),-0.5,0.5)
|
||||
idx = pd.DatetimeIndex(btc["dt"])
|
||||
wbS = pd.Series(wb, index=idx); weS = pd.Series(we, index=idx)
|
||||
print("\n Mean basket leg weights per year (is it structurally short ETH / long BTC?):")
|
||||
print(" year w_btc w_eth (positive=long)")
|
||||
for y in sorted(set(idx.year)):
|
||||
m = idx.year == y
|
||||
print(f" {y}: {wbS[m].mean():+.3f} {weS[m].mean():+.3f}")
|
||||
print(f"\n FULL mean: w_btc {wbS.mean():+.3f} w_eth {weS.mean():+.3f}")
|
||||
print(" (a persistent long-BTC/short-ETH tilt = it's a static ETH-bleed short, not RV alpha)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""sleeve_rv — CURATED relative-value sleeve: equal-weight of the 4 ortho books whose
|
||||
marginal uplift to TP01 is POSITIVE AT EVERY hold-out cut (2022/23/24/25), i.e. persistent
|
||||
rather than the 2025 ETH/BTC-bleed artifact. Two price-based + two implied-vol-based, so
|
||||
the legs are mechanism-diverse (mutual corr < 0.6).
|
||||
|
||||
This is ONE executable 2-leg BTC/ETH book (the averaged legs stay within the $300/asset
|
||||
cap because each sub-book is capped 0.5/leg and the mean of capped weights is capped).
|
||||
|
||||
book(btc, eth) -> (w_btc, w_eth)
|
||||
Use it as a small market-neutral satellite alongside TP01 (forward-monitor first).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
# the persistent-at-every-cut representatives (mechanism-diverse)
|
||||
MEMBERS = [
|
||||
"agent_14_dvol_spread",
|
||||
"agent_04_ratio_donchian",
|
||||
"agent_03_relstrength_gated",
|
||||
"agent_15_vol_premium_tilt",
|
||||
]
|
||||
|
||||
|
||||
def _load(name: str):
|
||||
p = HERE / "agents" / f"{name}.py"
|
||||
spec = importlib.util.spec_from_file_location(name, p)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod.book
|
||||
|
||||
|
||||
_BOOKS = [_load(n) for n in MEMBERS]
|
||||
|
||||
|
||||
def book(btc, eth):
|
||||
wbs, wes = [], []
|
||||
for b in _BOOKS:
|
||||
wb, we = b(btc, eth)
|
||||
wbs.append(np.nan_to_num(np.asarray(wb, float)))
|
||||
wes.append(np.nan_to_num(np.asarray(we, float)))
|
||||
wb = np.mean(wbs, axis=0)
|
||||
we = np.mean(wes, axis=0)
|
||||
return np.clip(wb, -0.5, 0.5), np.clip(we, -0.5, 0.5)
|
||||
Reference in New Issue
Block a user