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,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
|
||||
Reference in New Issue
Block a user