Files
PythagorasGoal/scripts/research/trackE_xsec_ensemble.py
T
Adriano Dal Pastro d152941360 integra(TP01): merge ricerca branch strategy-research-2026-06 (squash) — strategia vincente + harness + track A-E
Integra il lavoro della linea di ricerca parallela (AdrianoDev), verificato indipendentemente
col mio gauntlet onesto (regge il hold-out 2025-26 su entrambi gli asset, plateau 1h/4h/1d):
- src/strategies/trend_portfolio.py  TP01 (TSMOM 30/90/180 vol-target 20% lev2x long-flat, 50/50 BTC+ETH)
- src/backtest/harness.py            harness onesto (load + backtest_signals no-leakage + OOS)
- scripts/research/track{A,B,C,D,E}_*.py + trackD_timing.py  (le 5 track della ricerca)
- scripts/live/paper_trend.py        paper trader forward-only di TP01 (no esecuzione reale)
- tests/test_trend_portfolio.py (5 test, passano) + 6 diari trackA-E + synthesis
- CLAUDE.md aggiornato con l'esito ricerca (TP01 vincente, mean-rev morto, onesta su €50/g)

Squash (non merge) per NON portare in git i ~68MB di data/_feed_backup/*.bak che il branch
aveva committato per errore: esclusi + data/_feed_backup/ e data/paper_trend/ ora gitignorati.
Storia granulare del branch conservata sul ref origin/strategy-research-2026-06.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:55:04 +00:00

527 lines
24 KiB
Python

"""TRACK E — CROSS-SECTIONAL BTC↔ETH relative-value + ENSEMBLE synthesis.
Two parts, both on certified Deribit-mainnet data (only BTC/ETH), both honest:
PART 1 — RELATIVE VALUE (market-neutral-ish spread trading on TWO assets):
* XS relative momentum: go long the stronger asset, short the weaker (dollar-neutral).
* ETH/BTC ratio TREND (z-momentum) and ratio MEAN-REVERSION (z-fade of log-ratio).
* Lead-lag (descriptive): does BTC's last-bar move predict ETH's next bar (and vice versa)?
All positions are decided with data <= close[i] and HELD over the NEXT bar (i->i+1):
realized PnL on bar k uses position set at k-1 -> strict 1-bar shift, NO look-ahead.
Fees are turnover-based: |Δpos| * fee_rt/2 PER LEG (a +1↔-1 flip = one round trip = fee_rt).
PART 2 — ENSEMBLE:
Combine the genuinely-positive residual sleeves into ONE portfolio equity curve:
(S1) BTC low-turnover ML momentum (trackB best honest cell: W16000 H24 thr0.10, 1h)
(S2) Trend-1h, the only cross-asset-robust trend cell from trackA (Donchian N=200 H=12)
(S3) the best relative-value sleeve found in PART 1 (if any net-positive OOS)
Report combined Sharpe / maxDD / CAGR / EUR-per-day-on-2000 AND the sleeve correlation
matrix. A real ensemble edge must be net-positive OOS and LOWER drawdown than its parts.
Run: uv run python scripts/research/trackE_xsec_ensemble.py
uv run python scripts/research/trackE_xsec_ensemble.py --quick (skip slow ML sleeve)
uv run python scripts/research/trackE_xsec_ensemble.py --no-cache (recompute ML proba)
"""
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
import numpy as np
import pandas as pd
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from src.backtest.harness import load, backtest_signals, oos_split
# reuse trackB ML machinery (strict walk-forward, no leakage) and trackA donchian
from scripts.research.trackB_ml import (
build_features, forward_labels, walk_forward_proba, proba_to_entries, mask_entries,
RETRAIN_K,
)
from scripts.research.trackA_trend import sig_donchian
from sklearn.linear_model import LogisticRegression
FEE = 0.001 # 0.10% round-trip baseline (per leg for the pair)
BARS_PER_YEAR_1H = 24 * 365.25
# ===========================================================================
# Generic honest stats on a per-bar RETURN series (returns realized bar (k-1)->k)
# ===========================================================================
def equity_from_returns(rets: np.ndarray) -> np.ndarray:
eq = np.cumprod(1.0 + np.nan_to_num(rets))
return eq
def sharpe(rets: np.ndarray, bpy: float = BARS_PER_YEAR_1H) -> float:
r = rets[np.isfinite(rets)]
if len(r) < 3 or np.std(r) == 0:
return 0.0
return float(np.mean(r) / np.std(r) * np.sqrt(bpy))
def max_dd(equity: np.ndarray) -> float:
peak = np.maximum.accumulate(equity)
dd = (peak - equity) / peak
return float(np.max(dd)) if len(dd) else 0.0
def cagr(equity: np.ndarray, ts: pd.Series) -> float:
if len(equity) < 2:
return 0.0
days = (ts.iloc[-1] - ts.iloc[0]).total_seconds() / 86400
years = days / 365.25 if days > 0 else 1.0
if years <= 0 or equity[-1] <= 0:
return -1.0
return float(equity[-1] ** (1 / years) - 1)
def daily_profit(equity: np.ndarray, ts: pd.Series, capital: float = 2000.0) -> float:
if len(equity) < 2:
return 0.0
days = (ts.iloc[-1] - ts.iloc[0]).total_seconds() / 86400
if days <= 0:
return 0.0
final = capital * equity[-1] / equity[0]
return (final - capital) / days
def yearly_returns(rets: np.ndarray, ts: pd.Series) -> dict:
eq = equity_from_returns(rets)
s = pd.Series(eq, index=pd.DatetimeIndex(ts))
out = {}
for y, g in s.groupby(s.index.year):
if len(g) > 1 and g.iloc[0] > 0:
out[int(y)] = float(g.iloc[-1] / g.iloc[0] - 1)
return out
def stat_block(rets: np.ndarray, ts: pd.Series, bpy: float = BARS_PER_YEAR_1H) -> dict:
eq = equity_from_returns(rets)
return dict(
net=float(eq[-1] - 1.0), sharpe=sharpe(rets, bpy), max_dd=max_dd(eq),
cagr=cagr(eq, ts), eur_day=daily_profit(eq, ts), equity=eq,
turnover=float(np.mean(np.abs(np.diff(np.sign(rets) != 0)))), # placeholder, unused
)
# ===========================================================================
# RELATIVE-VALUE ENGINE — two legs, turnover-based fees, strict 1-bar shift.
# pos arrays are decided at close[i] (data<=i). Realized return on bar k uses pos[k-1].
# ===========================================================================
def pair_returns(cB: np.ndarray, cE: np.ndarray, posB: np.ndarray, posE: np.ndarray,
fee_rt: float = FEE) -> np.ndarray:
"""Per-bar net return series for a two-leg book. rets[k] realized on bar (k-1)->k.
Fee = (|ΔposB| + |ΔposE|) * fee_rt/2 charged when the position is (re)set."""
n = len(cB)
aretB = np.zeros(n); aretE = np.zeros(n)
aretB[1:] = cB[1:] / cB[:-1] - 1.0
aretE[1:] = cE[1:] / cE[:-1] - 1.0
rets = np.zeros(n)
for k in range(1, n):
gross = posB[k - 1] * aretB[k] + posE[k - 1] * aretE[k]
pBp = posB[k - 2] if k >= 2 else 0.0
pEp = posE[k - 2] if k >= 2 else 0.0
turn = abs(posB[k - 1] - pBp) + abs(posE[k - 1] - pEp)
rets[k] = gross - turn * fee_rt / 2.0
return rets
# --- signal builders: return (posB, posE) arrays, leg notional `leg` (gross = 2*leg) ---
def xs_momentum(cB, cE, N, hold, leg=0.5):
"""Cross-sectional momentum: long the asset with higher N-bar return, short the other."""
n = len(cB)
posB = np.zeros(n); posE = np.zeros(n)
curB = curE = 0.0
for i in range(n):
if i >= N and (i % hold == 0):
mB = cB[i] / cB[i - N] - 1.0
mE = cE[i] / cE[i - N] - 1.0
d = 1 if mB > mE else -1 # +1 => BTC stronger -> long BTC short ETH
curB = leg * d; curE = -leg * d
posB[i] = curB; posE[i] = curE
return posB, posE
def ratio_trend(cB, cE, N, hold, leg=0.5):
"""Trend on ETH/BTC ratio: ratio rising over N bars -> long ratio (long ETH, short BTC)."""
ratio = cE / cB
n = len(cB)
posB = np.zeros(n); posE = np.zeros(n)
curB = curE = 0.0
for i in range(n):
if i >= N and (i % hold == 0):
d = 1 if ratio[i] > ratio[i - N] else -1 # +1 => ratio up -> long ratio
curE = leg * d; curB = -leg * d
posB[i] = curB; posE[i] = curE
return posB, posE
def ratio_meanrev(cB, cE, lookback, z_in, z_exit, max_bars, leg=0.5):
"""Mean-reversion (z-fade) on log(ETH/BTC). z>+z_in -> short ratio; z<-z_in -> long ratio.
Exit when |z|<z_exit (reverted to mean) or after max_bars. Stateful, honest at close[i]."""
logr = np.log(cE / cB)
s = pd.Series(logr)
ma = s.rolling(lookback).mean().values
sd = s.rolling(lookback).std(ddof=0).values
z = (logr - ma) / sd
n = len(cB)
posB = np.zeros(n); posE = np.zeros(n)
state = 0 # +1 long ratio, -1 short ratio, 0 flat
bars_in = 0
for i in range(n):
if not np.isfinite(z[i]):
posB[i] = 0.0; posE[i] = 0.0; continue
if state == 0:
if z[i] >= z_in:
state = -1; bars_in = 0 # ratio too high -> short ratio
elif z[i] <= -z_in:
state = 1; bars_in = 0 # ratio too low -> long ratio
else:
bars_in += 1
if abs(z[i]) <= z_exit or bars_in >= max_bars or (state == 1 and z[i] >= z_in) \
or (state == -1 and z[i] <= -z_in):
state = 0
posE[i] = leg * state; posB[i] = -leg * state
return posB, posE
# ===========================================================================
# OOS / fee-sweep helpers for the relative-value sleeves
# ===========================================================================
def rv_eval(cB, cE, ts, build_fn, params, fee_rt=FEE, frac=0.65):
posB, posE = build_fn(cB, cE, **params)
rets = pair_returns(cB, cE, posB, posE, fee_rt=fee_rt)
cut = int(len(cB) * frac)
full = stat_block(rets, ts)
is_ = stat_block(rets[:cut], ts.iloc[:cut])
oos = stat_block(rets[cut:], ts.iloc[cut:])
# turnover: average per-bar leg turnover (both legs)
turn = (np.abs(np.diff(posB, prepend=0)) + np.abs(np.diff(posE, prepend=0)))
tstats = dict(rets=rets, posB=posB, posE=posE,
trades=int((turn > 1e-9).sum()), avg_turn=float(turn.mean()))
return full, is_, oos, tstats
def fmt(s):
return (f"net={s['net']*100:>+8.0f}% Sh={s['sharpe']:>+5.2f} DD={s['max_dd']*100:>4.0f}% "
f"CAGR={s['cagr']*100:>+6.1f}% €/d={s['eur_day']:>+6.2f}")
# ===========================================================================
# PART 1
# ===========================================================================
def part1_relative_value(quick=False):
print("=" * 104)
print("PART 1 — CROSS-SECTIONAL / RELATIVE-VALUE (BTC↔ETH, 1h, market-neutral spread)")
print("=" * 104)
b = load("BTC", "1h"); e = load("ETH", "1h")
m = pd.merge(b[["timestamp", "close"]], e[["timestamp", "close"]],
on="timestamp", suffixes=("_b", "_e")).reset_index(drop=True)
ts = pd.to_datetime(m["timestamp"], unit="ms", utc=True)
cB = m["close_b"].to_numpy(float); cE = m["close_e"].to_numpy(float)
cut = int(len(m) * 0.65)
print(f" common 1h bars: {len(m)} {ts.iloc[0].date()}{ts.iloc[-1].date()} "
f"(OOS starts {ts.iloc[cut].date()})")
rb = np.log(cB[1:] / cB[:-1]); re = np.log(cE[1:] / cE[:-1])
print(f" contemporaneous corr(BTC,ETH 1h logret) = {np.corrcoef(rb, re)[0,1]:.3f} "
f"(very high → the only tradable structure is the SPREAD)")
# ---- LEAD-LAG (descriptive, both directions, IS vs OOS) ----
print("\n -- LEAD-LAG (descriptive: does last-bar move of X predict next bar of Y?) --")
def ll(a_prev, b_next):
a = a_prev[np.isfinite(a_prev) & np.isfinite(b_next)]
bb = b_next[np.isfinite(a_prev) & np.isfinite(b_next)]
return np.corrcoef(a, bb)[0, 1] if len(a) > 30 else np.nan
print(f" corr(rB[i], rE[i+1]) = {ll(rb[:-1], re[1:]):+.4f} "
f"corr(rE[i], rB[i+1]) = {ll(re[:-1], rb[1:]):+.4f}")
print(f" corr(rB[i], rB[i+1]) = {ll(rb[:-1], rb[1:]):+.4f} "
f"corr(rE[i], rE[i+1]) = {ll(re[:-1], re[1:]):+.4f}")
print(" → |lead-lag| ~0.01-0.02: NO exploitable cross-predictive edge. Not pursued as a sleeve.")
results = {}
# ---- A) XS relative momentum grid ----
print("\n -- (A) XS RELATIVE MOMENTUM: long stronger / short weaker (dollar-neutral, gross=1) --")
print(" param FULL | OOS")
Ns = [24, 72, 168, 336] if not quick else [72, 168]
holds = [6, 24, 72] if not quick else [24, 72]
best_xs = None
for N in Ns:
for hold in holds:
full, is_, oos, tstat = rv_eval(cB, cE, ts, xs_momentum, dict(N=N, hold=hold))
ok = oos["net"] > 0 and oos["sharpe"] > 0
print(f" N={N:>3} hold={hold:>2} | {fmt(full)} | OOS {fmt(oos)} "
f"tr={tstat['trades']:>4} {'OK' if ok else ''}")
if oos["net"] > 0 and (best_xs is None or oos["sharpe"] > best_xs[2]["sharpe"]):
best_xs = (dict(N=N, hold=hold), full, oos, tstat, "xs_momentum")
results["xs_momentum"] = best_xs
# ---- B) ETH/BTC ratio TREND grid ----
print("\n -- (B) ETH/BTC RATIO TREND: long ratio when rising over N (long ETH/short BTC) --")
print(" NOTE: with only TWO assets this is ALGEBRAICALLY IDENTICAL to (A) — 'long the")
print(" stronger' ≡ 'trade the ratio trend'. Shown separately only to make that explicit.")
best_rt = None
for N in Ns:
for hold in holds:
full, is_, oos, tstat = rv_eval(cB, cE, ts, ratio_trend, dict(N=N, hold=hold))
ok = oos["net"] > 0 and oos["sharpe"] > 0
print(f" N={N:>3} hold={hold:>2} | {fmt(full)} | OOS {fmt(oos)} "
f"tr={tstat['trades']:>4} {'OK' if ok else ''}")
if oos["net"] > 0 and (best_rt is None or oos["sharpe"] > best_rt[2]["sharpe"]):
best_rt = (dict(N=N, hold=hold), full, oos, tstat, "ratio_trend")
results["ratio_trend"] = best_rt
# ---- C) ETH/BTC ratio MEAN-REVERSION grid ----
print("\n -- (C) ETH/BTC RATIO MEAN-REVERSION: z-fade of log(ETH/BTC) --")
best_mr = None
LBs = [48, 168, 336] if not quick else [168]
zins = [1.5, 2.0, 2.5] if not quick else [2.0]
for lb in LBs:
for zin in zins:
full, is_, oos, tstat = rv_eval(cB, cE, ts, ratio_meanrev,
dict(lookback=lb, z_in=zin, z_exit=0.5, max_bars=72))
ok = oos["net"] > 0 and oos["sharpe"] > 0
print(f" lb={lb:>3} zin={zin} | {fmt(full)} | OOS {fmt(oos)} "
f"tr={tstat['trades']:>4} {'OK' if ok else ''}")
if oos["net"] > 0 and (best_mr is None or oos["sharpe"] > best_mr[2]["sharpe"]):
best_mr = (dict(lookback=lb, z_in=zin, z_exit=0.5, max_bars=72),
full, oos, tstat, "ratio_meanrev")
results["ratio_meanrev"] = best_mr
# ---- choose the single best RV sleeve (positive OOS, highest OOS Sharpe) ----
cands = [v for v in results.values() if v is not None]
cands.sort(key=lambda v: v[2]["sharpe"], reverse=True)
best = cands[0] if cands else None
print("\n -- RELATIVE-VALUE SUMMARY (best per family that is OOS net-positive) --")
for fam in ("xs_momentum", "ratio_trend", "ratio_meanrev"):
v = results[fam]
if v is None:
print(f" {fam:<14}: no OOS net-positive cell.")
else:
params, full, oos, tstat, _ = v
print(f" {fam:<14}: {params} FULL {fmt(full)} | OOS {fmt(oos)}")
if best is None:
print("\n >> NO relative-value sleeve is OOS net-positive. No RV edge to add to the ensemble.")
return None, (cB, cE, ts)
params, full, oos, tstat, fam = best
print(f"\n >> BEST RV sleeve: {fam} {params} (OOS Sharpe {oos['sharpe']:+.2f})")
# ---- per-year + fee sweep + grid-neighbourhood robustness on the winner ----
build_fn = {"xs_momentum": xs_momentum, "ratio_trend": ratio_trend,
"ratio_meanrev": ratio_meanrev}[fam]
fullr, _, _, _ = rv_eval(cB, cE, ts, build_fn, params)
print("\n per-year (full):")
yr = yearly_returns(fullr["rets"] if False else pair_returns(cB, cE,
*build_fn(cB, cE, **params)), ts)
for y in sorted(yr):
print(f" {y}: {yr[y]*100:>+7.1f}%")
print("\n fee sweep (full-sample net, baseline 0.10% RT/leg):")
for f in (0.0, 0.0005, 0.001, 0.0015, 0.002):
fr, _, fo, _ = rv_eval(cB, cE, ts, build_fn, params, fee_rt=f)
print(f" fee={f*1000:.1f}bp/leg → FULL net={fr['net']*100:>+7.0f}% "
f"OOS net={fo['net']*100:>+7.0f}% (Sh {fo['sharpe']:+.2f})")
return best, (cB, cE, ts)
# ===========================================================================
# PART 2 — ENSEMBLE
# ===========================================================================
def lr_factory():
return LogisticRegression(C=1.0, max_iter=300, class_weight="balanced")
def ml_sleeve_btc(cache=True, no_cache=False):
"""BTC low-turnover ML momentum sleeve (trackB best honest cell W16000 H24 thr0.10)."""
W, H, thr = 16000, 24, 0.10
df = load("BTC", "1h")
cpath = Path(__file__).resolve().parent / ".cache_trackE_btc_ml_proba.npy"
proba = None
if cache and not no_cache and cpath.exists():
arr = np.load(cpath)
if len(arr) == len(df):
proba = arr
print(f" [S1 ML] loaded cached proba ({cpath.name})")
if proba is None:
print(f" [S1 ML] walk-forward LogisticRegression W{W} H{H} (slow ~1-2min)...")
t0 = time.time()
X, names, fvalid = build_features(df)
warmup = int(np.argmax(fvalid)) if fvalid.any() else 0
y, _fwd, lvalid = forward_labels(df, H)
proba = walk_forward_proba(X, y, fvalid, lvalid, warmup, W, H, RETRAIN_K, lr_factory)
np.save(cpath, proba)
print(f" [S1 ML] done ({time.time()-t0:.0f}s), cached.")
n = len(df)
entries = proba_to_entries(proba, thr, H, n)
m = backtest_signals(df, entries, fee_rt=FEE, asset="BTC", tf="1h")
return m, df, f"BTC-ML W{W}H{H}thr{thr}"
def trend_sleeve_btc():
"""Trend-1h sleeve: Donchian N=200 H=12 on BTC (the only cross-asset-robust trend cell)."""
df = load("BTC", "1h")
entries = sig_donchian(df, lookback=200, hold=12)
m = backtest_signals(df, entries, fee_rt=FEE, asset="BTC", tf="1h")
return m, df, "BTC-Trend Donchian200/12"
def metrics_to_returns(m):
"""Per-bar return series from a harness Metrics equity, indexed by its timestamps."""
eq = m.equity.astype(float)
ts = m.eq_index
rets = np.zeros(len(eq))
rets[1:] = eq[1:] / np.where(eq[:-1] == 0, np.nan, eq[:-1]) - 1.0
rets = np.nan_to_num(rets)
return pd.Series(rets, index=pd.DatetimeIndex(ts))
def part2_ensemble(rv_best, rv_data, quick=False, no_cache=False):
print("\n" + "=" * 104)
print("PART 2 — ENSEMBLE (combine weakly-correlated residual sleeves into one portfolio)")
print("=" * 104)
sleeves = {} # name -> pd.Series of per-bar returns indexed by ts
# S2 trend (fast, always)
mt, dft, tname = trend_sleeve_btc()
sleeves["S2_trend"] = metrics_to_returns(mt)
print(f" [S2] {tname:<28} net={mt.net_return*100:>+7.0f}% Sh={mt.sharpe:+.2f} "
f"DD={mt.max_dd*100:.0f}% €/d={mt.daily_profit(2000):+.2f}")
# S3 relative value (from PART 1)
if rv_best is not None:
params, full, oos, tstat, fam = rv_best
cB, cE, ts = rv_data
build_fn = {"xs_momentum": xs_momentum, "ratio_trend": ratio_trend,
"ratio_meanrev": ratio_meanrev}[fam]
posB, posE = build_fn(cB, cE, **params)
rv_rets = pair_returns(cB, cE, posB, posE, fee_rt=FEE)
sleeves["S3_relval"] = pd.Series(rv_rets, index=pd.DatetimeIndex(ts))
print(f" [S3] RV {fam} {params} net={full['net']*100:>+7.0f}% "
f"Sh={full['sharpe']:+.2f} DD={full['max_dd']*100:.0f}% €/d={full['eur_day']:+.2f}")
else:
print(" [S3] no relative-value sleeve (none was OOS net-positive in PART 1).")
# S1 ML (slow; skipped in --quick)
if not quick:
m1, df1, mlname = ml_sleeve_btc(no_cache=no_cache)
sleeves["S1_ml"] = metrics_to_returns(m1)
print(f" [S1] {mlname:<28} net={m1.net_return*100:>+7.0f}% Sh={m1.sharpe:+.2f} "
f"DD={m1.max_dd*100:.0f}% €/d={m1.daily_profit(2000):+.2f}")
else:
print(" [S1] ML sleeve SKIPPED (--quick).")
# ---- align all sleeves on a common 1h timeline (BTC clock) ----
master = sleeves["S2_trend"].index
aligned = pd.DataFrame(index=master)
for name, s in sleeves.items():
aligned[name] = s.reindex(master).fillna(0.0)
# the portfolio is only meaningful where the slowest sleeve is live.
# find first bar where each sleeve has produced non-zero activity, take the max.
starts = {}
for name in aligned.columns:
nz = np.nonzero(aligned[name].to_numpy() != 0.0)[0]
starts[name] = nz[0] if len(nz) else len(aligned)
start = max(starts.values())
aligned = aligned.iloc[start:]
ts_a = pd.Series(aligned.index)
print(f"\n Common active window: {aligned.index[0].date()}{aligned.index[-1].date()} "
f"({len(aligned)} bars). Sleeves: {list(aligned.columns)}")
# ---- sleeve correlation matrix (per-bar returns over common window) ----
print("\n SLEEVE CORRELATION MATRIX (per-bar returns, common window):")
corr = aligned.corr()
cols = list(aligned.columns)
print(" " + "".join(f"{c:>10}" for c in cols))
for c in cols:
print(f" {c:>9} " + "".join(f"{corr.loc[c, c2]:>+10.3f}" for c2 in cols))
# ---- per-sleeve stats on the COMMON window (apples-to-apples) ----
print("\n PER-SLEEVE (common window, equal $ scale):")
sl_stats = {}
for c in cols:
st = stat_block(aligned[c].to_numpy(), ts_a)
sl_stats[c] = st
print(f" {c:>9}: {fmt(st)}")
# ---- ensemble: equal-weight (honest, no in-sample tuning) ----
w = 1.0 / len(cols)
ens_eq_w = aligned.to_numpy() @ (np.ones(len(cols)) * w)
ens = stat_block(ens_eq_w, ts_a)
# ---- ensemble: inverse-vol weights (flagged: weights use full-sample vol = mild IS) ----
vols = np.array([np.std(aligned[c].to_numpy()) for c in cols])
iv = (1.0 / np.where(vols == 0, np.nan, vols))
iv = np.nan_to_num(iv); iv = iv / iv.sum()
ens_iv = stat_block(aligned.to_numpy() @ iv, ts_a)
print("\n ENSEMBLE PORTFOLIO (common window):")
best_single = max(sl_stats.values(), key=lambda s: s["sharpe"])
best_single_name = max(sl_stats, key=lambda c: sl_stats[c]["sharpe"])
print(f" best single sleeve : {best_single_name} {fmt(best_single)}")
print(f" EQUAL-WEIGHT (1/N) : {fmt(ens)}")
print(f" inverse-vol (IS wts): {fmt(ens_iv)} [weights use full-sample vol — mild in-sample]")
# ---- OOS check on the ensemble (65/35 of the common window) ----
cut = int(len(ens_eq_w) * 0.65)
ens_is = stat_block(ens_eq_w[:cut], ts_a.iloc[:cut])
ens_oos = stat_block(ens_eq_w[cut:], ts_a.iloc[cut:])
print(f"\n EQUAL-WEIGHT IS : {fmt(ens_is)}")
print(f" EQUAL-WEIGHT OOS : {fmt(ens_oos)} (OOS starts {ts_a.iloc[cut].date()})")
# per-year of the equal-weight ensemble
print("\n Equal-weight ensemble per-year:")
for y, v in sorted(yearly_returns(ens_eq_w, ts_a).items()):
print(f" {y}: {v*100:>+7.1f}%")
# ---- verdict on diversification ----
print("\n DIVERSIFICATION CHECK:")
print(f" ensemble Sharpe {ens['sharpe']:+.2f} vs best single {best_single['sharpe']:+.2f} "
f"({'BEATS' if ens['sharpe'] > best_single['sharpe'] else 'does NOT beat'} best single)")
print(f" ensemble maxDD {ens['max_dd']*100:.0f}% vs best single {best_single['max_dd']*100:.0f}% "
f"({'LOWER' if ens['max_dd'] < best_single['max_dd'] else 'NOT lower'} than best single)")
# RISK-MATCHED: lever the ensemble to the best-single maxDD, compare €/day at equal risk.
# (Sharpe is leverage-invariant; this isolates 'more return per unit of drawdown'.)
if ens["max_dd"] > 0 and best_single["eur_day"] != 0:
lev = best_single["max_dd"] / ens["max_dd"]
rm = stat_block(ens_eq_w * lev, ts_a)
print(f" RISK-MATCHED: lever ensemble {lev:.2f}x to ~{best_single['max_dd']*100:.0f}% DD "
f"→ €/d={rm['eur_day']:+.2f} (DD {rm['max_dd']*100:.0f}%) vs best-single €/d={best_single['eur_day']:+.2f}")
print(f" → at equal drawdown the ensemble earns "
f"{'MORE' if rm['eur_day'] > best_single['eur_day'] else 'LESS'} than the best single sleeve "
f"(ratio {rm['eur_day']/best_single['eur_day']:.2f}); this tracks the Sharpe ratio.")
if ens["eur_day"] > 0:
print(f" ensemble €/day(2k) {ens['eur_day']:+.2f} vs target ~50.00 "
f"→ ~{(50.0/ens['eur_day']):.0f}x short of the goal.")
else:
print(" ensemble €/day(2k) <= 0 → no earning engine.")
return ens, sl_stats, corr
# ===========================================================================
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--quick", action="store_true", help="skip slow ML sleeve + smaller RV grid")
ap.add_argument("--no-cache", action="store_true", help="recompute ML walk-forward proba")
args = ap.parse_args()
t0 = time.time()
rv_best, rv_data = part1_relative_value(quick=args.quick)
part2_ensemble(rv_best, rv_data, quick=args.quick, no_cache=args.no_cache)
print(f"\n(elapsed {time.time()-t0:.0f}s)")
print("\n" + "=" * 104)
print("See docs/diary/2026-06-19-trackE-xsec-ensemble.md for the full honest write-up.")
print("=" * 104)
if __name__ == "__main__":
main()