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:
Adriano Dal Pastro
2026-06-21 12:35:48 +00:00
parent 1afb1014c9
commit 0adc69a357
27 changed files with 3539 additions and 0 deletions
@@ -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
+106
View File
@@ -0,0 +1,106 @@
"""meta_ortho — the orchestrator's decisive read on the ortho fleet.
17/18 books "earn a slot". That cannot be 17 alphas. This asks the three questions that
decide whether ANY of it is deployable:
1. ARE THEY ONE BET? Mutual correlation of the books' daily returns. Relative-momentum
variants will cluster ~1; we collapse them to de-correlated representatives.
2. IS THE UPLIFT PERSISTENT OR JUST THE 2025-26 WINDOW? altlib.marginal_vs_tp01 fixes
the hold-out at 2025-01-01 — exactly the window where ETH bled vs BTC and TP01 was
weak. We re-measure the blend uplift at SEVERAL cut dates (2022/2023/2024/2025). A
real orthogonal premium adds at every cut; a regime artifact only adds at 2025.
3. WHAT DOES A SINGLE COMBINED SLEEVE LOOK LIKE? Equal-weight the representatives into
one relative-value sleeve and score THAT vs TP01 (full + per-cut).
uv run python scripts/research/ortho/meta_ortho.py
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import numpy as np
import pandas as pd
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
import ortholib as ol # noqa: E402
import altlib as al # noqa: E402
AGENTS = HERE / "agents"
CUTS = ["2022-01-01", "2023-01-01", "2024-01-01", "2025-01-01"]
def _book(path: Path):
spec = importlib.util.spec_from_file_location(path.stem, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.book
def _sh(s: pd.Series) -> float:
r = np.asarray(s.dropna().values, float)
return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if len(r) > 2 and np.std(r) > 0 else 0.0
def uplift_at(cand: pd.Series, B: pd.Series, cut: str, w: float = 0.25) -> float:
J = pd.concat({"B": B, "C": cand}, axis=1, join="inner").dropna()
J = J[J.index >= pd.Timestamp(cut, tz="UTC")]
if len(J) < 30:
return float("nan")
return _sh((1 - w) * J["B"] + w * J["C"]) - _sh(J["B"])
def main():
B = al.tp01_baseline_daily()
dailies = {}
for p in sorted(AGENTS.glob("agent_*.py")):
try:
ev = ol.eval_book(_book(p))
d = ev["daily"]
if d.std() > 0:
dailies[p.stem.replace("agent_", "")] = d
except Exception as e:
print(f" skip {p.stem}: {e}")
names = list(dailies)
M = pd.concat(dailies, axis=1, join="inner").dropna()
C = M.corr()
# greedy de-correlation: order by full-sample uplift, keep if corr<0.6 to all kept
upf = {n: uplift_at(dailies[n], B, "2018-01-01") for n in names}
order = sorted(names, key=lambda n: upf[n], reverse=True)
reps = []
for n in order:
if all(abs(C.loc[n, r]) < 0.6 for r in reps):
reps.append(n)
print(f"\n ORTHO META — {len(names)} books, mutual-corr clusters -> {len(reps)} de-correlated reps")
print(f" mean |corr| among all books = {C.abs().values[np.triu_indices(len(names),1)].mean():.2f}")
print(f"\n DE-CORRELATED REPRESENTATIVES (corr<0.6 to each other):")
print(f" {'book':<20}{'up_full':>8} uplift at cut: " + " ".join(c[:7] for c in CUTS))
for n in reps:
ups = [uplift_at(dailies[n], B, c) for c in CUTS]
print(f" {n:<20}{upf[n]:>8.3f} " + " ".join(f"{u:>+6.2f}" for u in ups))
# combined sleeve = equal-weight of representatives
combo = M[reps].mean(axis=1)
print(f"\n COMBINED relative-value sleeve (equal-weight of {len(reps)} reps):")
print(f" {'':<20}{'up_full':>8} uplift at cut: " + " ".join(c[:7] for c in CUTS))
ups = [uplift_at(combo, B, c) for c in CUTS]
print(f" {'COMBO':<20}{uplift_at(combo,B,'2018-01-01'):>8.3f} " + " ".join(f"{u:>+6.2f}" for u in ups))
print(f" combo standalone: Sharpe {_sh(combo):.2f} corr->TP01 {M[reps].mean(axis=1).corr(pd.concat({'b':B},axis=1).reindex(combo.index)['b']):.2f}")
# also: ALL ADDS equal-weight (what 'just deploy everything' would be)
allc = M.mean(axis=1)
ups = [uplift_at(allc, B, c) for c in CUTS]
print(f"\n ALL-{len(names)} equal-weight sleeve:")
print(f" {'ALL':<20}{uplift_at(allc,B,'2018-01-01'):>8.3f} " + " ".join(f"{u:>+6.2f}" for u in ups))
print("\n READ: a column that is +ve at 2022/2023/2024 cuts (not only 2025) = persistent.")
print(" all-positive-only-at-2025 = the ETH/BTC-bleed regime, not a standing premium.")
if __name__ == "__main__":
main()
@@ -0,0 +1,416 @@
[
{
"name": "agent_03_relstrength_gated",
"causal": true,
"pnl": 0.311,
"maxdd": 0.0939,
"sharpe": 0.574,
"net_beta": 0.0,
"gross_lev": 0.727,
"max_leg": 0.364,
"turnover": 8.5,
"executable": true,
"corr_full": 0.117,
"corr_hold": 0.316,
"uplift_hold": 0.534,
"uplift_full": 0.055,
"alpha_ann": 0.0291,
"resid_sharpe": 0.425,
"robust_oos": true,
"marginal_verdict": "ADDS",
"clean_year_uplift": 0.642,
"jackknife_min_uplift": 0.442,
"earns_slot": true
},
{
"name": "agent_04_ratio_donchian",
"causal": true,
"pnl": 0.7102,
"maxdd": 0.2776,
"sharpe": 0.559,
"net_beta": 0.0,
"gross_lev": 1.0,
"max_leg": 0.5,
"turnover": 6.8,
"executable": true,
"corr_full": 0.037,
"corr_hold": 0.112,
"uplift_hold": 0.533,
"uplift_full": 0.097,
"alpha_ann": 0.0781,
"resid_sharpe": 0.511,
"robust_oos": true,
"marginal_verdict": "ADDS",
"clean_year_uplift": 0.525,
"jackknife_min_uplift": 0.355,
"earns_slot": true
},
{
"name": "agent_17_ensemble_rv",
"causal": true,
"pnl": 0.2757,
"maxdd": 0.2474,
"sharpe": 0.387,
"net_beta": 0.0,
"gross_lev": 1.0,
"max_leg": 0.5,
"turnover": 11.5,
"executable": true,
"corr_full": 0.023,
"corr_hold": 0.313,
"uplift_hold": 0.504,
"uplift_full": 0.048,
"alpha_ann": 0.0354,
"resid_sharpe": 0.357,
"robust_oos": true,
"marginal_verdict": "ADDS",
"clean_year_uplift": 0.493,
"jackknife_min_uplift": 0.373,
"earns_slot": true
},
{
"name": "agent_02_beta_neutral_resid",
"causal": true,
"pnl": 0.4503,
"maxdd": 0.206,
"sharpe": 0.547,
"net_beta": -0.008,
"gross_lev": 0.986,
"max_leg": 0.5,
"turnover": 19.0,
"executable": true,
"corr_full": 0.05,
"corr_hold": 0.096,
"uplift_hold": 0.499,
"uplift_full": 0.082,
"alpha_ann": 0.0498,
"resid_sharpe": 0.483,
"robust_oos": true,
"marginal_verdict": "ADDS",
"clean_year_uplift": 0.608,
"jackknife_min_uplift": 0.379,
"earns_slot": true
},
{
"name": "agent_00_ratio_mom_blend",
"causal": true,
"pnl": 0.306,
"maxdd": 0.3023,
"sharpe": 0.379,
"net_beta": 0.0,
"gross_lev": 1.0,
"max_leg": 0.5,
"turnover": 23.1,
"executable": true,
"corr_full": 0.021,
"corr_hold": 0.28,
"uplift_hold": 0.493,
"uplift_full": 0.046,
"alpha_ann": 0.0401,
"resid_sharpe": 0.352,
"robust_oos": true,
"marginal_verdict": "ADDS",
"clean_year_uplift": 0.49,
"jackknife_min_uplift": 0.362,
"earns_slot": true
},
{
"name": "agent_09_corr_regime_rv",
"causal": true,
"pnl": 0.2541,
"maxdd": 0.2386,
"sharpe": 0.37,
"net_beta": 0.0,
"gross_lev": 1.0,
"max_leg": 0.5,
"turnover": 18.2,
"executable": true,
"corr_full": 0.034,
"corr_hold": 0.382,
"uplift_hold": 0.418,
"uplift_full": 0.04,
"alpha_ann": 0.0316,
"resid_sharpe": 0.326,
"robust_oos": true,
"marginal_verdict": "ADDS",
"clean_year_uplift": 0.483,
"jackknife_min_uplift": 0.282,
"earns_slot": true
},
{
"name": "agent_05_ratio_ewma_cross",
"causal": true,
"pnl": 0.1192,
"maxdd": 0.3547,
"sharpe": 0.182,
"net_beta": 0.0,
"gross_lev": 1.0,
"max_leg": 0.5,
"turnover": 9.7,
"executable": true,
"corr_full": 0.036,
"corr_hold": 0.21,
"uplift_hold": 0.378,
"uplift_full": -0.034,
"alpha_ann": 0.0185,
"resid_sharpe": 0.134,
"robust_oos": true,
"marginal_verdict": "ADDS",
"clean_year_uplift": 0.39,
"jackknife_min_uplift": 0.22,
"earns_slot": true
},
{
"name": "agent_14_dvol_spread",
"causal": true,
"pnl": 0.6178,
"maxdd": 0.1627,
"sharpe": 0.699,
"net_beta": 0.0,
"gross_lev": 1.0,
"max_leg": 0.5,
"turnover": 36.0,
"executable": true,
"corr_full": 0.095,
"corr_hold": 0.315,
"uplift_hold": 0.372,
"uplift_full": 0.106,
"alpha_ann": 0.0588,
"resid_sharpe": 0.579,
"robust_oos": true,
"marginal_verdict": "ADDS",
"clean_year_uplift": 0.349,
"jackknife_min_uplift": 0.202,
"earns_slot": true
},
{
"name": "agent_08_kalman_spread",
"causal": true,
"pnl": 0.5467,
"maxdd": 0.3611,
"sharpe": 0.517,
"net_beta": 0.0,
"gross_lev": 1.0,
"max_leg": 0.5,
"turnover": 36.3,
"executable": true,
"corr_full": 0.036,
"corr_hold": 0.123,
"uplift_hold": 0.366,
"uplift_full": 0.083,
"alpha_ann": 0.0625,
"resid_sharpe": 0.47,
"robust_oos": true,
"marginal_verdict": "ADDS",
"clean_year_uplift": 0.43,
"jackknife_min_uplift": 0.203,
"earns_slot": true
},
{
"name": "agent_07_ratio_carry_slow",
"causal": true,
"pnl": 0.135,
"maxdd": 0.2022,
"sharpe": 0.25,
"net_beta": 0.0,
"gross_lev": 1.0,
"max_leg": 0.5,
"turnover": 3.6,
"executable": true,
"corr_full": 0.012,
"corr_hold": 0.218,
"uplift_hold": 0.361,
"uplift_full": 0.02,
"alpha_ann": 0.0196,
"resid_sharpe": 0.235,
"robust_oos": true,
"marginal_verdict": "ADDS",
"clean_year_uplift": 0.355,
"jackknife_min_uplift": 0.253,
"earns_slot": true
},
{
"name": "agent_15_vol_premium_tilt",
"causal": true,
"pnl": 0.2111,
"maxdd": 0.2033,
"sharpe": 0.373,
"net_beta": 0.0,
"gross_lev": 1.0,
"max_leg": 0.5,
"turnover": 13.0,
"executable": true,
"corr_full": 0.027,
"corr_hold": -0.044,
"uplift_hold": 0.361,
"uplift_full": 0.042,
"alpha_ann": 0.0266,
"resid_sharpe": 0.338,
"robust_oos": true,
"marginal_verdict": "ADDS",
"clean_year_uplift": 0.461,
"jackknife_min_uplift": 0.238,
"earns_slot": true
},
{
"name": "agent_10_vol_regime_rv",
"causal": true,
"pnl": 0.2087,
"maxdd": 0.2374,
"sharpe": 0.364,
"net_beta": 0.0,
"gross_lev": 1.0,
"max_leg": 0.5,
"turnover": 14.5,
"executable": true,
"corr_full": -0.041,
"corr_hold": 0.087,
"uplift_hold": 0.355,
"uplift_full": 0.059,
"alpha_ann": 0.0336,
"resid_sharpe": 0.417,
"robust_oos": true,
"marginal_verdict": "ADDS",
"clean_year_uplift": 0.282,
"jackknife_min_uplift": 0.265,
"earns_slot": true
},
{
"name": "agent_01_xs2_zscore",
"causal": true,
"pnl": 0.5451,
"maxdd": 0.2368,
"sharpe": 0.551,
"net_beta": 0.0,
"gross_lev": 1.0,
"max_leg": 0.5,
"turnover": 46.9,
"executable": true,
"corr_full": 0.044,
"corr_hold": 0.104,
"uplift_hold": 0.283,
"uplift_full": 0.089,
"alpha_ann": 0.0603,
"resid_sharpe": 0.495,
"robust_oos": true,
"marginal_verdict": "ADDS",
"clean_year_uplift": 0.284,
"jackknife_min_uplift": 0.161,
"earns_slot": true
},
{
"name": "agent_11_vol_spread_rp",
"causal": true,
"pnl": 0.1391,
"maxdd": 0.2846,
"sharpe": 0.219,
"net_beta": 0.0,
"gross_lev": 1.0,
"max_leg": 0.5,
"turnover": 20.1,
"executable": true,
"corr_full": 0.023,
"corr_hold": 0.322,
"uplift_hold": 0.252,
"uplift_full": 0.001,
"alpha_ann": 0.0206,
"resid_sharpe": 0.19,
"robust_oos": true,
"marginal_verdict": "ADDS",
"clean_year_uplift": 0.214,
"jackknife_min_uplift": 0.103,
"earns_slot": true
},
{
"name": "agent_16_disp_momentum",
"causal": true,
"pnl": 0.2042,
"maxdd": 0.2016,
"sharpe": 0.327,
"net_beta": 0.0,
"gross_lev": 1.0,
"max_leg": 0.5,
"turnover": 19.8,
"executable": true,
"corr_full": 0.002,
"corr_hold": 0.119,
"uplift_hold": 0.233,
"uplift_full": 0.04,
"alpha_ann": 0.0295,
"resid_sharpe": 0.325,
"robust_oos": true,
"marginal_verdict": "ADDS",
"clean_year_uplift": 0.366,
"jackknife_min_uplift": 0.101,
"earns_slot": true
},
{
"name": "agent_06_ratio_accel",
"causal": true,
"pnl": 0.21,
"maxdd": 0.2826,
"sharpe": 0.331,
"net_beta": 0.0,
"gross_lev": 1.0,
"max_leg": 0.5,
"turnover": 23.3,
"executable": true,
"corr_full": 0.019,
"corr_hold": 0.059,
"uplift_hold": 0.23,
"uplift_full": 0.036,
"alpha_ann": 0.0282,
"resid_sharpe": 0.307,
"robust_oos": true,
"marginal_verdict": "ADDS",
"clean_year_uplift": 0.339,
"jackknife_min_uplift": 0.112,
"earns_slot": true
},
{
"name": "agent_13_lead_lag",
"causal": true,
"pnl": 0.1544,
"maxdd": 0.0714,
"sharpe": 0.53,
"net_beta": 0.0,
"gross_lev": 0.743,
"max_leg": 0.372,
"turnover": 5.3,
"executable": true,
"corr_full": 0.058,
"corr_hold": 0.188,
"uplift_hold": 0.158,
"uplift_full": 0.04,
"alpha_ann": 0.0176,
"resid_sharpe": 0.456,
"robust_oos": true,
"marginal_verdict": "ADDS",
"clean_year_uplift": 0.172,
"jackknife_min_uplift": 0.117,
"earns_slot": true
},
{
"name": "agent_12_rebalance_harvest",
"causal": true,
"pnl": -0.1269,
"maxdd": 0.1717,
"sharpe": -0.544,
"net_beta": 0.0,
"gross_lev": 0.87,
"max_leg": 0.435,
"turnover": 6.8,
"executable": true,
"corr_full": -0.052,
"corr_hold": 0.026,
"uplift_hold": -0.003,
"uplift_full": -0.048,
"alpha_ann": -0.0158,
"resid_sharpe": -0.477,
"robust_oos": false,
"marginal_verdict": "NEUTRAL",
"clean_year_uplift": -0.034,
"jackknife_min_uplift": -0.066,
"earns_slot": false
}
]
+103
View File
@@ -0,0 +1,103 @@
"""ortho_score — judge a relative-value BOOK module for a LIVE ORTHOGONAL slot.
A module defines `book(btc, eth) -> (w_btc, w_eth)`. This runs the causality guard, the
standalone backtest, the MARGINAL-vs-TP01 score, and the executability check, then prints
one json line + a human verdict. earns_slot = ADDS + robust_oos + executable + causal.
uv run python scripts/research/ortho/ortho_score.py --module <path.py>
uv run python scripts/research/ortho/ortho_score.py --all # score agents/*.py
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import ortholib as ol # noqa: E402
AGENTS = HERE / "agents"
def _book(path: Path):
spec = importlib.util.spec_from_file_location(path.stem, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.book
def score(path: Path) -> dict:
rec = {"name": path.stem}
try:
book = _book(path)
except Exception as e:
return {**rec, "error": f"import: {e}", "earns_slot": False}
try:
caus = ol.causality_ok(book)
rec["causal"] = bool(caus.get("ok"))
if not rec["causal"]:
rec["causality"] = caus
return {**rec, "earns_slot": False, "verdict": "LEAK"}
except Exception as e:
return {**rec, "error": f"causality: {e}", "earns_slot": False}
try:
r = ol.marginal(book)
ev, m = r["book"], r["marginal"]
rec.update(
pnl=ev["pnl"], maxdd=ev["maxdd"], sharpe=ev["sharpe"],
net_beta=ev["net_beta"], gross_lev=ev["gross_lev"], max_leg=ev["max_leg_frac"],
turnover=ev["turnover_per_year"], executable=ev["executable"],
corr_full=m.get("corr_full"), corr_hold=m.get("corr_hold"),
uplift_hold=m.get("blends", {}).get("w25", {}).get("uplift_hold"),
uplift_full=m.get("blends", {}).get("w25", {}).get("uplift_full"),
alpha_ann=m.get("alpha_ann"), resid_sharpe=m.get("resid_sharpe_full"),
robust_oos=m.get("robust_oos"), marginal_verdict=m.get("marginal_verdict"),
clean_year_uplift=m.get("clean_year_uplift"),
jackknife_min_uplift=m.get("jackknife_min_uplift"),
)
rec["earns_slot"] = bool(
m.get("marginal_verdict") == "ADDS" and m.get("robust_oos") and ev["executable"])
except Exception as e:
import traceback
return {**rec, "error": f"eval: {e}\n{traceback.format_exc()[-300:]}", "earns_slot": False}
return rec
def _row(r: dict) -> str:
if "error" in r:
return f" {r['name'][:30]:<30} ERROR {r['error'][:50]}"
if r.get("verdict") == "LEAK":
return f" {r['name'][:30]:<30} LEAK (disqualified)"
return (f" {r['name'][:30]:<30} {str(r['marginal_verdict']):<9} "
f"corr {str(r.get('corr_hold')):>6} up_h {str(r.get('uplift_hold')):>6} "
f"rob {str(r.get('robust_oos')):>5} exec {str(r.get('executable')):>5} "
f"| pnl {r['pnl']*100:>+5.0f}% dd {r['maxdd']*100:>3.0f}% "
f"{'<<< SLOT' if r.get('earns_slot') else ''}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--module"); ap.add_argument("--all", action="store_true")
args = ap.parse_args()
if args.all:
rows = [score(p) for p in sorted(AGENTS.glob("agent_*.py"))]
rows.sort(key=lambda r: (r.get("earns_slot", False),
r.get("uplift_hold") or -9), reverse=True)
print(f"\n ORTHO-vs-TP01 leaderboard ({len(rows)} books) judge = marginal uplift + robust_oos + executable")
print(f" {'name':<30} {'verdict':<9} {'corrH':>6} {'up_h':>6} {'rob':>5} {'exec':>5} pnl/dd")
print(" " + "-" * 92)
for r in rows:
print(_row(r))
slots = [r for r in rows if r.get("earns_slot")]
print(f"\n EARNS SLOT: {[r['name'] for r in slots] or 'NONE'}")
(HERE / "ortho_leaderboard.json").write_text(json.dumps(rows, indent=2, default=str))
else:
r = score(Path(args.module))
print(json.dumps(r, indent=2, default=str))
if __name__ == "__main__":
main()
+133
View File
@@ -0,0 +1,133 @@
"""ortholib — lab for strategies ORTHOGONAL to TP01 (the only thing worth a NEW live slot).
The blind fleet proved (again) that directional BTC/ETH is trend-beta of TP01 — a ~1.3
ceiling, nothing new to deploy. The ONLY way to earn a live slot is a mechanism whose
returns are NOT explained by TP01's trend. The most promising one that is ALSO executable
at our real ~$600 (unlike XS01's 19-leg book, which needs ~$20k) is a **2-leg BTC/ETH
relative-value book**: long one leg / short (or under-weight) the other. Being roughly
market-neutral, its correlation to TP01 (a long-flat trend on the SUM) is naturally low.
A candidate here is a BOOK function:
def book(btc, eth) -> (w_btc, w_eth)
where w_*[i] in [-1,1] is the per-leg weight (fraction of equity, sign = long/short),
decided causally with data <= close[i]. The evaluator SHIFTS both legs (held during bar
i+1), charges fees on BOTH legs' turnover, and:
* builds the book's net DAILY return series,
* scores it MARGINALLY vs TP01 via altlib.marginal_vs_tp01 (corr, OOS blend uplift,
residual alpha, robust_oos jackknife) — the verdict that matters is ADDS, not Sharpe,
* checks EXECUTABILITY at $600 (per-leg notional within the live cap, turnover sane),
* runs the same online causality guard as the blind lab.
Judge a candidate on: marginal_verdict == 'ADDS' AND robust_oos AND executable. Absolute
PnL/DD are reported but are NOT the gate (a market-neutral sleeve has modest absolute
numbers by design; its job is to improve the PORTFOLIO, not to win alone).
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
import altlib as al # noqa: E402
FEE_SIDE = al.FEE_SIDE
CAPITAL = 600.0 # real mainnet capital (NOT the 2000 paper nominal)
CAP_NOTIONAL = 300.0 # live guardrail: $300 notional / asset
MIN_ORDER = 5.0
CAP_FRAC = CAP_NOTIONAL / CAPITAL # 0.5 of capital per leg
def aligned():
"""BTC & ETH 1d on COMMON dates (inner join), as two parallel DataFrames with a
shared 'datetime'. Relative-value needs both legs at the same bar."""
b = al.get("BTC", "1d").copy()
e = al.get("ETH", "1d").copy()
b["dt"] = pd.to_datetime(b["datetime"], utc=True)
e["dt"] = pd.to_datetime(e["datetime"], utc=True)
common = pd.Index(b["dt"]).intersection(pd.Index(e["dt"]))
b = b[b["dt"].isin(common)].sort_values("dt").reset_index(drop=True)
e = e[e["dt"].isin(common)].sort_values("dt").reset_index(drop=True)
return b, e
def eval_book(book_fn, fee_side: float = FEE_SIDE) -> dict:
"""Honest backtest of a 2-leg BTC/ETH book. Weights decided at close[i] are HELD
during bar i+1 (shift here -> no leak). Fee on both legs' turnover. Returns standalone
metrics, the net DAILY series (for marginal scoring), and executability stats."""
btc, eth = aligned()
wb, we = book_fn(btc, eth)
wb = np.clip(np.nan_to_num(np.asarray(wb, float), nan=0.0), -1, 1)
we = np.clip(np.nan_to_num(np.asarray(we, float), nan=0.0), -1, 1)
n = len(btc)
rb = al.simple_returns(btc["close"].values.astype(float))
re = al.simple_returns(eth["close"].values.astype(float))
pb = np.zeros(n); pb[1:] = wb[:-1]
pe = np.zeros(n); pe[1:] = we[:-1]
gross = pb * rb + pe * re
turn = np.abs(np.diff(pb, prepend=0.0)) + np.abs(np.diff(pe, prepend=0.0))
net = gross - fee_side * turn
net[0] = 0.0
idx = pd.DatetimeIndex(btc["dt"])
m = al._metrics_from_net(net, idx)
daily = al._to_daily(pd.Series(net, index=idx))
# executability: worst per-leg notional fraction, and net market exposure
max_leg = float(np.max(np.maximum(np.abs(pb), np.abs(pe)))) if n else 0.0
gross_lev = float(np.max(np.abs(pb) + np.abs(pe))) if n else 0.0
net_beta = float(np.mean(pb + pe)) # ~0 => market-neutral
turnover_yr = float(turn.sum() / (len(turn) / 365.25))
return dict(
pnl=m["ret"], maxdd=m["maxdd"], sharpe=m["sharpe"], cagr=m["cagr"],
net=net, idx=idx, daily=daily,
max_leg_frac=round(max_leg, 3), gross_lev=round(gross_lev, 3),
net_beta=round(net_beta, 3), turnover_per_year=round(turnover_yr, 1),
executable=bool(max_leg <= CAP_FRAC + 1e-9 and max_leg * CAPITAL >= MIN_ORDER),
)
def marginal(book_fn, fee_side: float = FEE_SIDE) -> dict:
"""eval_book -> altlib.marginal_vs_tp01 on the book's daily returns. The real verdict."""
ev = eval_book(book_fn, fee_side)
marg = al.marginal_vs_tp01(ev["daily"])
return dict(book=ev, marginal=marg)
def causality_ok(book_fn, tail: int = 60, tol: float = 1e-4) -> dict:
"""Online-consistency guard: book(btc[:cut], eth[:cut]) must match book(full)[:cut]
on the tail before each cut. Catches any look-ahead / global fit in either leg."""
btc, eth = aligned()
wbf, wef = book_fn(btc, eth)
wbf = np.nan_to_num(np.asarray(wbf, float)); wef = np.nan_to_num(np.asarray(wef, float))
n = len(btc)
max_diff = 0.0; frac_bad = 0.0; checked = []
for cut in (int(n * 0.80), int(n * 0.92)):
if cut <= tail + 5 or cut >= n:
continue
wbs, wes = book_fn(btc.iloc[:cut].reset_index(drop=True),
eth.iloc[:cut].reset_index(drop=True))
wbs = np.nan_to_num(np.asarray(wbs, float)); wes = np.nan_to_num(np.asarray(wes, float))
if len(wbs) != cut or len(wes) != cut:
return dict(ok=False, reason=f"book returned wrong length on prefix {cut}",
max_diff=9.99, frac_bad=1.0)
d = np.maximum(np.abs(wbs[cut - tail:cut] - wbf[cut - tail:cut]),
np.abs(wes[cut - tail:cut] - wef[cut - tail:cut]))
max_diff = max(max_diff, float(np.max(d)) if len(d) else 0.0)
frac_bad = max(frac_bad, float(np.mean(d > tol)) if len(d) else 0.0)
checked.append(cut)
ok = (max_diff <= max(tol * 10, 1e-3)) and (frac_bad <= 0.02)
return dict(ok=bool(ok), max_diff=round(max_diff, 6), frac_bad=round(frac_bad, 4),
checked_at=checked)
# convenient causal helpers re-exported (same as blind/altlib)
simple_returns = al.simple_returns
log_returns = al.log_returns
ema = al.ema; sma = al.sma; rolling_std = al.rolling_std; zscore = al.zscore
rsi = al.rsi; realized_vol = al.realized_vol; donchian = al.donchian; bbands = al.bbands
vol_target = al.vol_target
__all__ = ["aligned", "eval_book", "marginal", "causality_ok", "CAPITAL", "CAP_FRAC",
"FEE_SIDE", "simple_returns", "log_returns", "ema", "sma", "rolling_std",
"zscore", "rsi", "realized_vol", "donchian", "bbands", "vol_target"]
+154
View File
@@ -0,0 +1,154 @@
"""Adversarial dissection of the SELECTION-FREE relative-value basket vs TP01."""
from __future__ import annotations
import importlib.util, sys
from pathlib import Path
import numpy as np
import pandas as pd
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
import ortholib as ol
import altlib as al
AGENTS = HERE / "agents"
def _book(path: Path):
spec = importlib.util.spec_from_file_location(path.stem, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.book
def _sh(s) -> float:
r = np.asarray(pd.Series(s).dropna().values, float)
return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if len(r) > 2 and np.std(r) > 0 else 0.0
def _dd(s) -> float:
eq = (1.0 + pd.Series(s).dropna()).cumprod()
return float((eq / eq.cummax() - 1.0).min())
def uplift_at(cand: pd.Series, B: pd.Series, lo=None, hi=None, w=0.25) -> float:
J = pd.concat({"B": B, "C": cand}, axis=1, join="inner").dropna()
if lo is not None:
J = J[J.index >= pd.Timestamp(lo, tz="UTC")]
if hi is not None:
J = J[J.index < pd.Timestamp(hi, tz="UTC")]
if len(J) < 30:
return float("nan")
return _sh((1 - w) * J["B"] + w * J["C"]) - _sh(J["B"])
def main():
B = al.tp01_baseline_daily()
# ---- collect ALL books, classify ----
rows = {}
meta = {}
for p in sorted(AGENTS.glob("agent_*.py")):
try:
ev = ol.eval_book(_book(p))
except Exception as e:
print(f"skip {p.stem}: {e}"); continue
d = ev["daily"]
if d.std() == 0:
print(f"skip degenerate {p.stem}"); continue
rows[p.stem.replace("agent_", "")] = d
meta[p.stem.replace("agent_", "")] = ev
names = list(rows)
M = pd.concat(rows, axis=1, join="inner").dropna()
print(f"\nBooks loaded: {len(names)} common-date matrix: {M.shape[0]} days "
f"[{M.index.min().date()} .. {M.index.max().date()}]")
# selection-free: ALL non-degenerate, market-neutral. exclude only the OBVIOUS
# degenerate DILUTE (rebalance_harvest, sharpe<0 standalone & negative every metric).
excl = ["12_rebalance_harvest"]
keep = [n for n in names if n not in excl]
print(f"Basket = equal-weight of {len(keep)} books (excluded only {excl})")
basket = M[keep].mean(axis=1)
basket_all = M.mean(axis=1) # truly ALL incl rebalance_harvest
for label, bk in [("BASKET(excl harvest)", basket), ("BASKET(all 18)", basket_all)]:
print(f"\n===== {label} =====")
Bal = pd.concat({"B": B, "C": bk}, axis=1, join="inner").dropna()
corr = Bal["B"].corr(Bal["C"])
print(f" standalone Sharpe {_sh(bk):.3f} maxDD {_dd(bk):.3f} corr->TP01 {corr:.3f} "
f"n={len(bk)}")
# full + per-cut uplift (forward windows from cut)
print(f" uplift (w=0.25, window = [cut, end)):")
for cut in ["2018-01-01", "2021-01-01", "2022-01-01", "2023-01-01", "2024-01-01", "2025-01-01"]:
u = uplift_at(bk, B, lo=cut)
print(f" from {cut[:4]}+ : {u:+.3f}")
# PRE-2025 ONLY (exclude suspect window)
u_pre = uplift_at(bk, B, hi="2025-01-01")
u_2025 = uplift_at(bk, B, lo="2025-01-01")
print(f" uplift 2019-2024 ONLY (exclude suspect window): {u_pre:+.3f}")
print(f" uplift 2025+ ONLY : {u_2025:+.3f}")
# per-year standalone returns & per-year uplift
print(f" per-year: standalone ret | blend-uplift(w0.25, that year only)")
for y in sorted(set(bk.index.year)):
sub = bk[bk.index.year == y]
ret = float((1 + sub).prod() - 1)
uy = uplift_at(bk, B, lo=f"{y}-01-01", hi=f"{y+1}-01-01")
print(f" {y}: ret {ret:+7.2%} uplift {uy:+.3f}")
# ---- decompose full-sample uplift: pre vs post 2025 ----
print("\n===== UPLIFT DECOMPOSITION (basket excl harvest, w=0.25) =====")
full = uplift_at(basket, B, lo="2018-01-01")
print(f" full-sample uplift {full:+.3f}")
print(f" pre-2025 (2019-2024) {uplift_at(basket,B,hi='2025-01-01'):+.3f}")
print(f" 2025+ only {uplift_at(basket,B,lo='2025-01-01'):+.3f}")
# ---- fee sensitivity: rebuild basket at higher fees ----
print("\n===== FEE SENSITIVITY (rebuild every book's daily net at higher RT fee) =====")
for fee_side in [0.0005, 0.0010, 0.0015]:
dd = {}
for p in sorted(AGENTS.glob("agent_*.py")):
nm = p.stem.replace("agent_", "")
if nm in excl: continue
try:
ev = ol.eval_book(_book(p), fee_side=fee_side)
if ev["daily"].std() > 0:
dd[nm] = ev["daily"]
except Exception:
pass
Mf = pd.concat(dd, axis=1, join="inner").dropna()
bk = Mf.mean(axis=1)
u_full = uplift_at(bk, B, lo="2018-01-01")
u_pre = uplift_at(bk, B, hi="2025-01-01")
u_25 = uplift_at(bk, B, lo="2025-01-01")
print(f" RT={2*fee_side*100:.2f}% standalone Sh {_sh(bk):+.3f} "
f"uplift full {u_full:+.3f} | pre25 {u_pre:+.3f} | 25+ {u_25:+.3f}")
# ---- turnover / executability of the basket ----
print("\n===== EXECUTION REALISM (per-book at $600, basket-averaged legs) =====")
tns = [meta[n]["turnover_per_year"] for n in keep]
print(f" per-book turnover/yr: min {min(tns):.1f} med {np.median(tns):.1f} max {max(tns):.1f}")
print(f" Basket = mean of {len(keep)} books; each leg capped 0.5. Averaged turnover lower.")
# build averaged book legs to measure REAL basket turnover & notional
btc, eth = ol.aligned()
wbs, wes = [], []
for n in keep:
wb, we = _book(AGENTS / f"agent_{n}.py")(btc, eth)
wbs.append(np.nan_to_num(np.asarray(wb, float)))
wes.append(np.nan_to_num(np.asarray(we, float)))
wb = np.clip(np.mean(wbs, axis=0), -0.5, 0.5)
we = np.clip(np.mean(wes, axis=0), -0.5, 0.5)
pb = np.zeros(len(wb)); pb[1:] = wb[:-1]
pe = np.zeros(len(we)); pe[1:] = we[:-1]
turn = np.abs(np.diff(pb, prepend=0.0)) + np.abs(np.diff(pe, prepend=0.0))
turn_yr = turn.sum() / (len(turn) / 365.25)
max_leg = float(np.max(np.maximum(np.abs(pb), np.abs(pe))))
print(f" AVERAGED-BASKET book: turnover/yr {turn_yr:.1f} max-leg-frac {max_leg:.3f} "
f"max per-leg notional @$600 = ${max_leg*600:.0f}")
print(f" typical daily notional traded @$600 satellite (say 20% slot=$120): "
f"${turn_yr/365.25*120:.2f}/day both legs")
if __name__ == "__main__":
main()
+107
View File
@@ -0,0 +1,107 @@
"""Null tests: is the pre-2025 +0.027 uplift distinguishable from 'any zero-mean low-corr
noise asset dampens a Sharpe-1.3 portfolio at w=0.25'? And bootstrap CI on the uplift."""
from __future__ import annotations
import importlib.util, sys
from pathlib import Path
import numpy as np
import pandas as pd
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
import ortholib as ol
import altlib as al
AGENTS = HERE / "agents"
rng = np.random.default_rng(42)
def _book(path):
spec = importlib.util.spec_from_file_location(path.stem, path)
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
return mod.book
def _sh(s):
r = np.asarray(pd.Series(s).dropna().values, float)
return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if len(r) > 2 and np.std(r) > 0 else 0.0
def uplift(B, C, lo=None, hi=None, w=0.25):
J = pd.concat({"B": B, "C": C}, axis=1, join="inner").dropna()
if lo is not None: J = J[J.index >= pd.Timestamp(lo, tz="UTC")]
if hi is not None: J = J[J.index < pd.Timestamp(hi, tz="UTC")]
if len(J) < 30: return float("nan")
return _sh((1-w)*J["B"] + w*J["C"]) - _sh(J["B"])
def main():
B = al.tp01_baseline_daily()
excl = ["12_rebalance_harvest"]
rows = {}
for p in sorted(AGENTS.glob("agent_*.py")):
nm = p.stem.replace("agent_", "")
if nm in excl: continue
ev = ol.eval_book(_book(p))
if ev["daily"].std() > 0:
rows[nm] = ev["daily"]
M = pd.concat(rows, axis=1, join="inner").dropna()
basket = M.mean(axis=1)
# ---- NULL 1: a synthetic zero-corr asset with the SAME standalone Sharpe & vol as the
# basket. If it gives the same ~+0.03 pre-2025 uplift, the uplift is just diversification
# math, not a specific edge.
Jb = pd.concat({"B": B, "C": basket}, axis=1, join="inner").dropna()
pre = Jb[Jb.index < pd.Timestamp("2025-01-01", tz="UTC")]
bk_pre = pre["C"]
mu, sd = bk_pre.mean(), bk_pre.std()
print(f"Pre-2025 basket: mean {mu:.5f}/day vol {sd:.5f} Sharpe {_sh(bk_pre):.3f} "
f"corr->TP01 {pre['B'].corr(pre['C']):.3f}")
print(f"Pre-2025 ACTUAL basket uplift (w=0.25): {uplift(B,basket,hi='2025-01-01'):+.3f}\n")
print("NULL: 500 synthetic zero-corr assets, same daily mean & vol as pre-2025 basket,")
print(" independent random sign each day. Pre-2025 uplift distribution:")
null_u = []
for _ in range(500):
noise = pd.Series(rng.normal(mu, sd, len(pre)), index=pre.index)
null_u.append(uplift(B, noise, hi="2025-01-01"))
null_u = np.array(null_u)
print(f" null uplift: mean {null_u.mean():+.3f} sd {null_u.std():.3f} "
f"5-95pct [{np.percentile(null_u,5):+.3f}, {np.percentile(null_u,95):+.3f}]")
actual = uplift(B, basket, hi="2025-01-01")
pctile = (null_u < actual).mean()*100
print(f" ACTUAL pre-2025 uplift {actual:+.3f} sits at the {pctile:.0f}th percentile of the null")
print(" (if ~50th pct, the pre-2025 'edge' is INDISTINGUISHABLE from a positive-drift noise asset)\n")
# ---- NULL 2: same but ZERO mean (pure diversification, no standalone return) ----
null_u0 = []
for _ in range(500):
noise = pd.Series(rng.normal(0.0, sd, len(pre)), index=pre.index)
null_u0.append(uplift(B, noise, hi="2025-01-01"))
null_u0 = np.array(null_u0)
print(f" ZERO-mean noise null uplift: mean {null_u0.mean():+.3f} "
f"5-95pct [{np.percentile(null_u0,5):+.3f}, {np.percentile(null_u0,95):+.3f}]")
print(f" => pure diversification of a zero-return asset gives ~{null_u0.mean():+.3f} uplift\n")
# ---- bootstrap CI on the FULL-sample and pre-2025 basket uplift (block bootstrap) ----
print("Block-bootstrap (30d blocks, 1000 draws) CI on uplift:")
for lbl, lo, hi in [("full", "2018-01-01", None), ("pre-2025", "2018-01-01", "2025-01-01"),
("2025+", "2025-01-01", None)]:
J = pd.concat({"B": B, "C": basket}, axis=1, join="inner").dropna()
if lo: J = J[J.index >= pd.Timestamp(lo, tz="UTC")]
if hi: J = J[J.index < pd.Timestamp(hi, tz="UTC")]
n = len(J); bl = 30; nb = n // bl
ups = []
for _ in range(1000):
starts = rng.integers(0, n - bl, nb)
idx = np.concatenate([np.arange(s, s+bl) for s in starts])
sub = J.iloc[idx]
ups.append(_sh(0.75*sub["B"]+0.25*sub["C"]) - _sh(sub["B"]))
ups = np.array(ups)
frac_pos = (ups > 0).mean()*100
print(f" {lbl:9s}: median {np.median(ups):+.3f} "
f"5-95pct [{np.percentile(ups,5):+.3f}, {np.percentile(ups,95):+.3f}] "
f"P(uplift>0)={frac_pos:.0f}%")
if __name__ == "__main__":
main()
+90
View File
@@ -0,0 +1,90 @@
"""Is the basket uplift just 'helps when TP01 is weak'? Quantify regime-conditionality."""
from __future__ import annotations
import importlib.util, sys
from pathlib import Path
import numpy as np
import pandas as pd
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt")
import ortholib as ol
import altlib as al
AGENTS = HERE / "agents"
def _book(path):
spec = importlib.util.spec_from_file_location(path.stem, path)
mod = importlib.util.module_from_spec(spec); spec.loader.exec_module(mod)
return mod.book
def _sh(s):
r = np.asarray(pd.Series(s).dropna().values, float)
return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if len(r) > 2 and np.std(r) > 0 else 0.0
def main():
B = al.tp01_baseline_daily()
excl = ["12_rebalance_harvest"]
rows = {}
for p in sorted(AGENTS.glob("agent_*.py")):
nm = p.stem.replace("agent_", "")
if nm in excl: continue
ev = ol.eval_book(_book(p))
if ev["daily"].std() > 0:
rows[nm] = ev["daily"]
M = pd.concat(rows, axis=1, join="inner").dropna()
basket = M.mean(axis=1)
J = pd.concat({"B": B, "C": basket}, axis=1, join="inner").dropna()
print("Per-year: TP01 own Sharpe | basket Sharpe | basket ret | blend-uplift")
print(" (does the basket help SPECIFICALLY in TP01's weak years?)")
for y in sorted(set(J.index.year)):
sub = J[J.index.year == y]
if len(sub) < 20: continue
tp_sh = _sh(sub["B"]); bk_sh = _sh(sub["C"])
bk_ret = float((1 + sub["C"]).prod() - 1)
up = _sh(0.75 * sub["B"] + 0.25 * sub["C"]) - _sh(sub["B"])
print(f" {y}: TP01 Sh {tp_sh:+6.2f} | basket Sh {bk_sh:+6.2f} | ret {bk_ret:+7.2%} | uplift {up:+.3f}")
# correlation: does basket-year-uplift track NEGATIVE TP01-year-Sharpe?
rows2 = []
for y in sorted(set(J.index.year)):
sub = J[J.index.year == y]
if len(sub) < 20: continue
rows2.append((_sh(sub["B"]), _sh(0.75*sub["B"]+0.25*sub["C"]) - _sh(sub["B"])))
arr = np.array(rows2)
if len(arr) > 2:
c = np.corrcoef(arr[:,0], arr[:,1])[0,1]
print(f"\n corr(TP01 yearly Sharpe, basket yearly uplift) = {c:+.2f}")
print(" (strongly NEGATIVE => basket only helps when TP01 is weak = regime hedge, not standing alpha)")
# conditional uplift: split days by whether TP01 trailing-60d return is up or down
tp_trail = J["B"].rolling(60).sum()
up_days = J[tp_trail > 0]; dn_days = J[tp_trail <= 0]
for lbl, d in [("TP01 trailing-60d UP", up_days), ("TP01 trailing-60d DOWN", dn_days)]:
if len(d) < 30: continue
u = _sh(0.75*d["B"]+0.25*d["C"]) - _sh(d["B"])
print(f" [{lbl}] n={len(d)} basket Sh {_sh(d['C']):+.2f} blend-uplift {u:+.3f}")
# ETH/BTC ratio regime: is the basket net-short ETH (i.e. is it just shorting the bleed)?
btc, eth = ol.aligned()
wbs, wes = [], []
for nm in rows:
wb, we = _book(AGENTS / f"agent_{nm}.py")(btc, eth)
wbs.append(np.nan_to_num(np.asarray(wb,float))); wes.append(np.nan_to_num(np.asarray(we,float)))
wb = np.clip(np.mean(wbs,axis=0),-0.5,0.5); we = np.clip(np.mean(wes,axis=0),-0.5,0.5)
idx = pd.DatetimeIndex(btc["dt"])
wbS = pd.Series(wb, index=idx); weS = pd.Series(we, index=idx)
print("\n Mean basket leg weights per year (is it structurally short ETH / long BTC?):")
print(" year w_btc w_eth (positive=long)")
for y in sorted(set(idx.year)):
m = idx.year == y
print(f" {y}: {wbS[m].mean():+.3f} {weS[m].mean():+.3f}")
print(f"\n FULL mean: w_btc {wbS.mean():+.3f} w_eth {weS.mean():+.3f}")
print(" (a persistent long-BTC/short-ETH tilt = it's a static ETH-bleed short, not RV alpha)")
if __name__ == "__main__":
main()
+51
View File
@@ -0,0 +1,51 @@
"""sleeve_rv — CURATED relative-value sleeve: equal-weight of the 4 ortho books whose
marginal uplift to TP01 is POSITIVE AT EVERY hold-out cut (2022/23/24/25), i.e. persistent
rather than the 2025 ETH/BTC-bleed artifact. Two price-based + two implied-vol-based, so
the legs are mechanism-diverse (mutual corr < 0.6).
This is ONE executable 2-leg BTC/ETH book (the averaged legs stay within the $300/asset
cap because each sub-book is capped 0.5/leg and the mean of capped weights is capped).
book(btc, eth) -> (w_btc, w_eth)
Use it as a small market-neutral satellite alongside TP01 (forward-monitor first).
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import numpy as np
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
# the persistent-at-every-cut representatives (mechanism-diverse)
MEMBERS = [
"agent_14_dvol_spread",
"agent_04_ratio_donchian",
"agent_03_relstrength_gated",
"agent_15_vol_premium_tilt",
]
def _load(name: str):
p = HERE / "agents" / f"{name}.py"
spec = importlib.util.spec_from_file_location(name, p)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.book
_BOOKS = [_load(n) for n in MEMBERS]
def book(btc, eth):
wbs, wes = [], []
for b in _BOOKS:
wb, we = b(btc, eth)
wbs.append(np.nan_to_num(np.asarray(wb, float)))
wes.append(np.nan_to_num(np.asarray(we, float)))
wb = np.mean(wbs, axis=0)
we = np.mean(wes, axis=0)
return np.clip(wb, -0.5, 0.5), np.clip(we, -0.5, 0.5)