From 0adc69a3570ab6d9e49058a62f13dac47a6e86a0 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sun, 21 Jun 2026 12:35:48 +0000 Subject: [PATCH] =?UTF-8?q?research(ortho):=20caccia=20all'ortogonale=20a?= =?UTF-8?q?=20TP01=20=E2=80=94=20relative-value=20BTC/ETH=20reale=20ma=20N?= =?UTF-8?q?ON=20deployabile=20(hedge=20mono-regime)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../2026-06-21-ortho-tp01-relative-value.md | 81 ++++ .../ortho/agents/agent_00_ratio_mom_blend.py | 76 ++++ .../ortho/agents/agent_01_xs2_zscore.py | 100 +++++ .../agents/agent_02_beta_neutral_resid.py | 130 ++++++ .../agents/agent_03_relstrength_gated.py | 137 ++++++ .../ortho/agents/agent_04_ratio_donchian.py | 112 +++++ .../ortho/agents/agent_05_ratio_ewma_cross.py | 89 ++++ .../ortho/agents/agent_06_ratio_accel.py | 120 +++++ .../ortho/agents/agent_07_ratio_carry_slow.py | 101 +++++ .../ortho/agents/agent_08_kalman_spread.py | 165 +++++++ .../ortho/agents/agent_09_corr_regime_rv.py | 166 +++++++ .../ortho/agents/agent_10_vol_regime_rv.py | 158 +++++++ .../ortho/agents/agent_11_vol_spread_rp.py | 109 +++++ .../agents/agent_12_rebalance_harvest.py | 149 +++++++ .../ortho/agents/agent_13_lead_lag.py | 99 +++++ .../ortho/agents/agent_14_dvol_spread.py | 113 +++++ .../ortho/agents/agent_15_vol_premium_tilt.py | 125 ++++++ .../ortho/agents/agent_16_disp_momentum.py | 134 ++++++ .../ortho/agents/agent_17_ensemble_rv.py | 215 +++++++++ scripts/research/ortho/meta_ortho.py | 106 +++++ scripts/research/ortho/ortho_leaderboard.json | 416 ++++++++++++++++++ scripts/research/ortho/ortho_score.py | 103 +++++ scripts/research/ortho/ortholib.py | 133 ++++++ scripts/research/ortho/skeptic_basket.py | 154 +++++++ scripts/research/ortho/skeptic_null.py | 107 +++++ scripts/research/ortho/skeptic_regime.py | 90 ++++ scripts/research/ortho/sleeve_rv.py | 51 +++ 27 files changed, 3539 insertions(+) create mode 100644 docs/diary/2026-06-21-ortho-tp01-relative-value.md create mode 100644 scripts/research/ortho/agents/agent_00_ratio_mom_blend.py create mode 100644 scripts/research/ortho/agents/agent_01_xs2_zscore.py create mode 100644 scripts/research/ortho/agents/agent_02_beta_neutral_resid.py create mode 100644 scripts/research/ortho/agents/agent_03_relstrength_gated.py create mode 100644 scripts/research/ortho/agents/agent_04_ratio_donchian.py create mode 100644 scripts/research/ortho/agents/agent_05_ratio_ewma_cross.py create mode 100644 scripts/research/ortho/agents/agent_06_ratio_accel.py create mode 100644 scripts/research/ortho/agents/agent_07_ratio_carry_slow.py create mode 100644 scripts/research/ortho/agents/agent_08_kalman_spread.py create mode 100644 scripts/research/ortho/agents/agent_09_corr_regime_rv.py create mode 100644 scripts/research/ortho/agents/agent_10_vol_regime_rv.py create mode 100644 scripts/research/ortho/agents/agent_11_vol_spread_rp.py create mode 100644 scripts/research/ortho/agents/agent_12_rebalance_harvest.py create mode 100644 scripts/research/ortho/agents/agent_13_lead_lag.py create mode 100644 scripts/research/ortho/agents/agent_14_dvol_spread.py create mode 100644 scripts/research/ortho/agents/agent_15_vol_premium_tilt.py create mode 100644 scripts/research/ortho/agents/agent_16_disp_momentum.py create mode 100644 scripts/research/ortho/agents/agent_17_ensemble_rv.py create mode 100644 scripts/research/ortho/meta_ortho.py create mode 100644 scripts/research/ortho/ortho_leaderboard.json create mode 100644 scripts/research/ortho/ortho_score.py create mode 100644 scripts/research/ortho/ortholib.py create mode 100644 scripts/research/ortho/skeptic_basket.py create mode 100644 scripts/research/ortho/skeptic_null.py create mode 100644 scripts/research/ortho/skeptic_regime.py create mode 100644 scripts/research/ortho/sleeve_rv.py diff --git a/docs/diary/2026-06-21-ortho-tp01-relative-value.md b/docs/diary/2026-06-21-ortho-tp01-relative-value.md new file mode 100644 index 0000000..b113553 --- /dev/null +++ b/docs/diary/2026-06-21-ortho-tp01-relative-value.md @@ -0,0 +1,81 @@ +# 2026-06-21 — Caccia all'ORTOGONALE a TP01: relative-value BTC/ETH (eseguibile a $600) + +## Perché (richiesta utente: "cerca ortogonale a TP01") + +La flotta cieca (stesso giorno) ha confermato: niente di NUOVO in direzionale BTC/ETH — tutto è +trend-beta di TP01 (soffitto ~1.3). L'unica via a un nuovo slot LIVE è un meccanismo **ortogonale** +(bassa correlazione, alpha residua). Il più promettente **eseguibile al capitale reale ~$600** è un +**book RELATIVE-VALUE a 2 gambe BTC/ETH** (long una / short l'altra), grosso modo market-neutral → +correlazione naturale bassa col trend, e a 2 gambe è eseguibile (a differenza del book a 19 gambe di +XS01 che serve ~$20k). + +## Setup — ortho-lab + giudice MARGINALE (non Sharpe assoluto) + +`scripts/research/ortho/ortholib.py`: BTC/ETH 1d allineati su date comuni; `eval_book(book_fn)` con +`book(btc,eth)->(w_btc,w_eth)`, **shift di entrambe le gambe** (no leak), fee su entrambe, serie netta +**giornaliera**; guardia di causalità online; check **eseguibilità a $600** (max gamba ≤ 0.5 = cap +$300/asset). Il giudice è `altlib.marginal_vs_tp01`: **corr a TP01, uplift OOS del blend, alpha +residua, robust_oos** (clean-year + jackknife drop-month). Verdetto = ADDS, **non** Sharpe assoluto. +`ortho_score.py` (giudice), `meta_ortho.py` (corr mutua + persistenza multi-cut), `sleeve_rv.py`. + +Sanity: ratio-momentum → ADDS (corr 0.05); ratio-mean-reversion → DILUTES. L'harness discrimina. + +## Flotta — 18 agenti relative-value (~40 min) + +18 ipotesi distinte: ratio-momentum multi-orizzonte, XS a 2 asset, beta-neutral residuo, Donchian +sul ratio, EMA-cross, accel, carry lento, Kalman-spread, gate-correlazione, gate-vol, inverse-vol, +rebalance-harvest, lead-lag, **DVOL-spread**, **VRP relativo**, dispersione, ensemble. + +**Esito grezzo: 18 riportati, 17 "ADDS / earns_slot".** → **bandiera rossa**: non esistono 17 alpha. +Gli agenti stessi l'hanno annotato ("hold-out corto ~537g", "uplift dipende dal regime ETH-bleed +2025", "forward-monitor non full-weight"). + +## Diagnosi dell'orchestratore — il "17 slot" è gonfiato + +1. **Una scommessa o tante?** corr mutua media **0.43** → collassano a **8 rappresentanti** + de-correlati. Non 17, non 1. +2. **Persistente o solo finestra 2025?** `marginal_vs_tp01` fissa l'hold-out al 2025-01-01 = proprio + la finestra dove ETH ha perso vs BTC e TP01 è debole. Ri-misurando l'uplift a **più cut** + (2022/23/24/25): il basket selection-free era +0.06/+0.06/+0.11/+0.38 (positivo ovunque ma + crescente verso il 2025). Smaschera anche i **falsi** che il robust_oos fisso-2025 non vede: + `kalman_spread` (−0.14/−0.16/−0.10 poi +0.37) e `xs2_zscore` sono **2025-only**. +3. **Selezione walk-forward (senza hindsight):** scegliere i top-4 per uplift sul **solo passato** e + testare in avanti → uplift **−0.07** (sel <2023) / +0.05 (<2024) / +0.43 (<2025). **Scegliere la + variante vincente in anticipo è inaffidabile**; il mio "curated 4" è in parte hindsight. + +## Verifica avversariale (scettico indipendente) — REFUTED + +Sul **basket selection-free** (equal-weight di tutti i book market-neutral, NESSUN cherry-picking): +- standalone Sharpe **0.61**, maxDD 15%, **corr a TP01 0.05** (genuinamente ortogonale). +- **uplift full +0.078 = pre-2025 +0.027 / solo-2025+ +0.401.** Il pre-2025 **+0.027 sta al 49° + percentile di 500 asset-rumore a corr-zero** (+0.029 per costruzione) → è **matematica di + diversificazione, non segnale**. +- **corr(Sharpe annuo TP01, uplift annuo basket) = −0.87**; condizionato: TP01 su → +0.014, TP01 giù + → +0.369. **È un hedge dei drawdown di TP01, non un premio autonomo.** Paga nel 2022 (orso) e + 2025-26 (ETH-bleed) — i due anni peggiori di TP01 — rumore altrove (2023 −0.06, 2024 −0.12). +- Block-bootstrap P(uplift>0): full 90%, **pre-2025 66% (testa o croce)**, 2025+ 99%. +- Fee: a **0.30% RT il pre-2025 va NEGATIVO** (−0.021); sopravvive solo il numero del regime 2025. +- Eseguibilità OK ($264/gamba, turnover 12/yr) — non è quello il problema. + +## Verdetto + +**Niente di questa flotta merita uno slot LIVE.** Il meccanismo relative-value BTC/ETH è REALE e +genuinamente ortogonale (corr ~0.05), ma è un **hedge della debolezza di TP01 travestito da alpha**: +il suo contributo pre-2025 è indistinguibile da un asset-rumore a corr-zero (49° percentile del null) +e muore a fee realistiche; l'unico payoff vero è una singola finestra di 537 giorni (2025-26). +Deployarlo = deployare un backtest mono-regime. **Resta live solo TP01** (l'unica cosa che supera +tutto questo scrutinio). Coerente con XS01 (stessa famiglia cross-sectional): diversificatore +da monitorare, non alpha da eseguire — e la versione a 2 asset è ancora più sottile della 19-gambe. + +### Valore metodologico (cosa resta, ed è importante) + +- **Il marginal scorer fisso-2025 è ingannabile** (17/18 "ADDS"). Ciò che ha ucciso i falsi positivi: + **persistenza multi-cut** + **selezione walk-forward** + **bootstrap vs null a corr-zero**. Lezione + da cablare nello scorer: testare PIÙ cut e confrontare l'uplift col **null di un asset-rumore + ortogonale** (un'asset scorrelato con drift positivo "aggiunge" +0.03 per pura matematica — non è + un edge). Un basso-corr che paga solo quando il core è debole è un **hedge**, va prezzato come tale. +- Lab riusabile: `ortholib`/`ortho_score`/`meta_ortho` (giudice marginale + persistenza). I 18 book + + `sleeve_rv.py` (curated, **selection-biased — non deployare**) restano come riferimento. + +File: `scripts/research/ortho/{ortholib,ortho_score,meta_ortho,sleeve_rv}.py`, +`agents/agent_00..17_*.py`, `ortho_leaderboard.json`, skeptic `skeptic_{basket,regime,null}.py`. diff --git a/scripts/research/ortho/agents/agent_00_ratio_mom_blend.py b/scripts/research/ortho/agents/agent_00_ratio_mom_blend.py new file mode 100644 index 0000000..d783e3c --- /dev/null +++ b/scripts/research/ortho/agents/agent_00_ratio_mom_blend.py @@ -0,0 +1,76 @@ +"""agent_00_ratio_mom_blend — Multi-horizon ETH/BTC ratio-momentum, market-neutral. + +ANGLE [family=rv, slug=ratio_mom_blend]: the 2-asset executable cousin of XS01. +We trade the RELATIVE strength of ETH vs BTC: build the log price ratio s = log(ETH/BTC), +measure its momentum over a BLEND of horizons (~20/60/120d), average the per-horizon +z-scores (multi-orizzonte like TP01), squash with tanh to size, and go MARKET-NEUTRAL: + w_eth = +g, w_btc = -g (long the stronger leg, short the weaker, gross ~2g) +The book is then SPREAD-VOL-TARGETED: scale g so the realized vol of the ETH-BTC spread +return hits a target, capping each leg at the live notional cap (0.5 of equity). + +Because the book is ~beta-neutral to the BTC+ETH market (net exposure ~0), it is +structurally uncorrelated to TP01 (a long-flat trend on the market SUM) — that is the +whole point: residual relative-value alpha, not trend-beta. + +CAUSAL: every value at i uses only rows 0..i (rolling means/std, no shift(-k), no global +fit). EXECUTABLE: per-leg |w| <= 0.5. MARKET-NEUTRAL: w_eth == -w_btc by construction. +""" +from __future__ import annotations + +import numpy as np + +import ortholib as ol + +# ---- knobs (a PLATEAU point, not a lucky cell — see notes) ---------------- +HORIZONS = (20, 60, 120, 240) # momentum lookbacks (days) — multi-orizzonte blend +ZWIN = 252 # window to z-score each horizon's momentum (causal) +TANH_K = 1.3 # tanh slope (signal -> size) +TARGET_SPREAD_VOL = 0.15 # annualized target vol of the ETH-BTC spread return +VOL_WIN = 45 # realized-vol window (days) for spread-vol targeting +LEG_CAP = 0.5 # live per-leg notional cap (fraction of equity) + + +def _mom_z(logp: np.ndarray, h: int, zwin: int) -> np.ndarray: + """Causal z-scored h-day log momentum of a log-price series.""" + s = np.full(len(logp), np.nan) + s[h:] = logp[h:] - logp[:-h] # h-day log change, known at i + return ol.zscore(s, zwin) # standardize cross-time (causal rolling) + + +def book(btc, eth): + bc = btc["close"].values.astype(float) + ec = eth["close"].values.astype(float) + n = len(bc) + + # relative price ratio in logs: positive momentum => ETH outperforming BTC + logratio = np.log(ec) - np.log(bc) + + # blended multi-horizon z-scored momentum (mean of per-horizon z-scores). + # warnings silenced: early bars (before any horizon is populated) are all-NaN + # columns -> nanmean warns; we map those to 0 (flat) anyway. + zs = np.vstack([_mom_z(logratio, h, ZWIN) for h in HORIZONS]) + with np.errstate(invalid="ignore"): + import warnings + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=RuntimeWarning) + sig = np.nanmean(zs, axis=0) + sig = np.nan_to_num(sig, nan=0.0) + + # squash to a directional size in [-1, 1] + g_dir = np.tanh(TANH_K * sig) + + # spread-vol target: scale by target/realized vol of the spread return r_eth - r_btc + rb = ol.simple_returns(bc) + re = ol.simple_returns(ec) + spread_ret = re - rb + spvol = ol.realized_vol(spread_ret, VOL_WIN, 365.25) # annualized, causal + scal = np.where((spvol > 0) & np.isfinite(spvol), TARGET_SPREAD_VOL / spvol, 0.0) + + g = g_dir * scal + # per-leg cap (g is the magnitude on EACH leg; both legs share it) + g = np.clip(g, -LEG_CAP, LEG_CAP) + g = np.nan_to_num(g, nan=0.0) + + w_eth = g + w_btc = -g + return w_btc, w_eth diff --git a/scripts/research/ortho/agents/agent_01_xs2_zscore.py b/scripts/research/ortho/agents/agent_01_xs2_zscore.py new file mode 100644 index 0000000..024de32 --- /dev/null +++ b/scripts/research/ortho/agents/agent_01_xs2_zscore.py @@ -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) diff --git a/scripts/research/ortho/agents/agent_02_beta_neutral_resid.py b/scripts/research/ortho/agents/agent_02_beta_neutral_resid.py new file mode 100644 index 0000000..fb40d57 --- /dev/null +++ b/scripts/research/ortho/agents/agent_02_beta_neutral_resid.py @@ -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) diff --git a/scripts/research/ortho/agents/agent_03_relstrength_gated.py b/scripts/research/ortho/agents/agent_03_relstrength_gated.py new file mode 100644 index 0000000..992f599 --- /dev/null +++ b/scripts/research/ortho/agents/agent_03_relstrength_gated.py @@ -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 diff --git a/scripts/research/ortho/agents/agent_04_ratio_donchian.py b/scripts/research/ortho/agents/agent_04_ratio_donchian.py new file mode 100644 index 0000000..10cb7eb --- /dev/null +++ b/scripts/research/ortho/agents/agent_04_ratio_donchian.py @@ -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 diff --git a/scripts/research/ortho/agents/agent_05_ratio_ewma_cross.py b/scripts/research/ortho/agents/agent_05_ratio_ewma_cross.py new file mode 100644 index 0000000..2d27c1a --- /dev/null +++ b/scripts/research/ortho/agents/agent_05_ratio_ewma_cross.py @@ -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 diff --git a/scripts/research/ortho/agents/agent_06_ratio_accel.py b/scripts/research/ortho/agents/agent_06_ratio_accel.py new file mode 100644 index 0000000..0e1d4d5 --- /dev/null +++ b/scripts/research/ortho/agents/agent_06_ratio_accel.py @@ -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 diff --git a/scripts/research/ortho/agents/agent_07_ratio_carry_slow.py b/scripts/research/ortho/agents/agent_07_ratio_carry_slow.py new file mode 100644 index 0000000..5bbe137 --- /dev/null +++ b/scripts/research/ortho/agents/agent_07_ratio_carry_slow.py @@ -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 diff --git a/scripts/research/ortho/agents/agent_08_kalman_spread.py b/scripts/research/ortho/agents/agent_08_kalman_spread.py new file mode 100644 index 0000000..db2f3ea --- /dev/null +++ b/scripts/research/ortho/agents/agent_08_kalman_spread.py @@ -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 diff --git a/scripts/research/ortho/agents/agent_09_corr_regime_rv.py b/scripts/research/ortho/agents/agent_09_corr_regime_rv.py new file mode 100644 index 0000000..c94e4a2 --- /dev/null +++ b/scripts/research/ortho/agents/agent_09_corr_regime_rv.py @@ -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 diff --git a/scripts/research/ortho/agents/agent_10_vol_regime_rv.py b/scripts/research/ortho/agents/agent_10_vol_regime_rv.py new file mode 100644 index 0000000..9ad8e86 --- /dev/null +++ b/scripts/research/ortho/agents/agent_10_vol_regime_rv.py @@ -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 diff --git a/scripts/research/ortho/agents/agent_11_vol_spread_rp.py b/scripts/research/ortho/agents/agent_11_vol_spread_rp.py new file mode 100644 index 0000000..62fcfbc --- /dev/null +++ b/scripts/research/ortho/agents/agent_11_vol_spread_rp.py @@ -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 diff --git a/scripts/research/ortho/agents/agent_12_rebalance_harvest.py b/scripts/research/ortho/agents/agent_12_rebalance_harvest.py new file mode 100644 index 0000000..0417aa5 --- /dev/null +++ b/scripts/research/ortho/agents/agent_12_rebalance_harvest.py @@ -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. diff --git a/scripts/research/ortho/agents/agent_13_lead_lag.py b/scripts/research/ortho/agents/agent_13_lead_lag.py new file mode 100644 index 0000000..e3ad740 --- /dev/null +++ b/scripts/research/ortho/agents/agent_13_lead_lag.py @@ -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 diff --git a/scripts/research/ortho/agents/agent_14_dvol_spread.py b/scripts/research/ortho/agents/agent_14_dvol_spread.py new file mode 100644 index 0000000..8855a5f --- /dev/null +++ b/scripts/research/ortho/agents/agent_14_dvol_spread.py @@ -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 diff --git a/scripts/research/ortho/agents/agent_15_vol_premium_tilt.py b/scripts/research/ortho/agents/agent_15_vol_premium_tilt.py new file mode 100644 index 0000000..e0ab094 --- /dev/null +++ b/scripts/research/ortho/agents/agent_15_vol_premium_tilt.py @@ -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 diff --git a/scripts/research/ortho/agents/agent_16_disp_momentum.py b/scripts/research/ortho/agents/agent_16_disp_momentum.py new file mode 100644 index 0000000..208d9ac --- /dev/null +++ b/scripts/research/ortho/agents/agent_16_disp_momentum.py @@ -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 diff --git a/scripts/research/ortho/agents/agent_17_ensemble_rv.py b/scripts/research/ortho/agents/agent_17_ensemble_rv.py new file mode 100644 index 0000000..5838269 --- /dev/null +++ b/scripts/research/ortho/agents/agent_17_ensemble_rv.py @@ -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 diff --git a/scripts/research/ortho/meta_ortho.py b/scripts/research/ortho/meta_ortho.py new file mode 100644 index 0000000..f7fe975 --- /dev/null +++ b/scripts/research/ortho/meta_ortho.py @@ -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() diff --git a/scripts/research/ortho/ortho_leaderboard.json b/scripts/research/ortho/ortho_leaderboard.json new file mode 100644 index 0000000..afbbb7a --- /dev/null +++ b/scripts/research/ortho/ortho_leaderboard.json @@ -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 + } +] \ No newline at end of file diff --git a/scripts/research/ortho/ortho_score.py b/scripts/research/ortho/ortho_score.py new file mode 100644 index 0000000..7673583 --- /dev/null +++ b/scripts/research/ortho/ortho_score.py @@ -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 + 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() diff --git a/scripts/research/ortho/ortholib.py b/scripts/research/ortho/ortholib.py new file mode 100644 index 0000000..9fadfd4 --- /dev/null +++ b/scripts/research/ortho/ortholib.py @@ -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"] diff --git a/scripts/research/ortho/skeptic_basket.py b/scripts/research/ortho/skeptic_basket.py new file mode 100644 index 0000000..da6c062 --- /dev/null +++ b/scripts/research/ortho/skeptic_basket.py @@ -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() diff --git a/scripts/research/ortho/skeptic_null.py b/scripts/research/ortho/skeptic_null.py new file mode 100644 index 0000000..10a2792 --- /dev/null +++ b/scripts/research/ortho/skeptic_null.py @@ -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() diff --git a/scripts/research/ortho/skeptic_regime.py b/scripts/research/ortho/skeptic_regime.py new file mode 100644 index 0000000..7d8980a --- /dev/null +++ b/scripts/research/ortho/skeptic_regime.py @@ -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() diff --git a/scripts/research/ortho/sleeve_rv.py b/scripts/research/ortho/sleeve_rv.py new file mode 100644 index 0000000..74e6a17 --- /dev/null +++ b/scripts/research/ortho/sleeve_rv.py @@ -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)