diff --git a/scripts/measure_crossasset.py b/scripts/measure_crossasset.py new file mode 100644 index 000000000..ed1490e46 --- /dev/null +++ b/scripts/measure_crossasset.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Cross-asset trend / lead-lag edge on the 4 CME futures we have OHLCV for +(6E Euro-FX, ES S&P, NQ Nasdaq, ZN 10Y-Treasury), 2024-2025. + +Rationale (pearl_lowfreq_no_robust_edge_on_2y_es + the omnisearch synthesis): +ES microstructure is dead (efficiency + speed game); ES daily had no stable edge. +The remaining non-speed thesis is cross-asset, where ML/systematic edge actually +lives. Time-series momentum (Moskowitz-Ooi-Pedersen) is the canonical robust +systematic edge and is STRONGEST in FX/rates (6E/ZN) — not equity indices. A +DIVERSIFIED trend portfolio across uncorrelated asset classes is the highest-odds +cheap test. Honest IS(2024)/OOS(2025) Sharpe on non-overlapping daily P&L. + +Usage: python3 scripts/measure_crossasset.py [--dir test_data/futures-baseline] +""" +import argparse +import os +import sys + +import numpy as np + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from measure_lowfreq_ic import load_ohlcv, to_daily # noqa: E402 + +SPLIT = 1735689600 * 10**9 # 2025-01-01 UTC +INSTR = ["6E", "ES", "NQ", "ZN"] + + +def daily_series(base, inst): + ts, close, q = load_ohlcv(f"{base}/{inst}.FUT") + order = np.argsort(ts, kind="stable") + ts, close, q = ts[order], close[order], q[order] + dts, dclose, dq = to_daily(ts, close, q) + day = (dts // (86400 * 10**9)).astype(np.int64) + return day, dts, dclose, dq + + +def sharpe(p): + p = p[np.isfinite(p)] + return p.mean() / p.std() * np.sqrt(252) if len(p) > 5 and p.std() > 0 else float("nan") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--dir", default="test_data/futures-baseline") + args = ap.parse_args() + + series = {} + for inst in INSTR: + day, dts, dclose, dq = daily_series(args.dir, inst) + series[inst] = (day, dts, dclose, dq) + + # align on common days + common = None + for inst in INSTR: + d = set(series[inst][0].tolist()) + common = d if common is None else (common & d) + common = np.array(sorted(common)) + print(f"\ncommon trading days across {INSTR}: {len(common)} " + f"(IS<2025: {int((common*86400*10**9 < SPLIT).sum())}, OOS: {int((common*86400*10**9 >= SPLIT).sum())})") + + # build aligned close + return + roll-mask matrices [days x instr] + C = np.full((len(common), len(INSTR)), np.nan) + Q = np.full((len(common), len(INSTR)), -1, dtype=np.int64) + idxmap = {d: i for i, d in enumerate(common)} + for j, inst in enumerate(INSTR): + day, dts, dclose, dq = series[inst] + for k in range(len(day)): + if day[k] in idxmap: + C[idxmap[day[k]], j] = dclose[k] + Q[idxmap[day[k]], j] = dq[k] + logC = np.log(C) + R = np.vstack([np.zeros((1, len(INSTR))), np.diff(logC, axis=0)]) # daily log returns + roll = np.vstack([np.zeros((1, len(INSTR)), bool), Q[1:] != Q[:-1]]) # roll boundary per instr + day_ns = common * 86400 * 10**9 + oos = day_ns >= SPLIT + + # ---- per-instrument time-series momentum ---- + print("\n=== PER-INSTRUMENT time-series momentum (pos=sign(trailing L-day ret), daily P&L) ===") + print(f"{'inst':>5} {'L':>4} {'IC(Ld→1d)':>10} {'Sharpe_all':>11} {'Sharpe_IS24':>12} {'Sharpe_OOS25':>13}") + from scipy.stats import spearmanr + inst_pnl = {} # (inst,L) -> daily pnl series for portfolio + for j, inst in enumerate(INSTR): + for L in [5, 10, 20, 60]: + sig = np.sign(logC[L:, j] - logC[:-L, j]) # momentum sign at day t + pos = sig[:-1] + ret = R[L + 1:, j] + ok = ~roll[L + 1:, j] & np.isfinite(ret) & np.isfinite(pos) + pos, ret = pos[ok], ret[ok] + oo = oos[L + 1:][ok] + pnl = pos * ret + ic = spearmanr(logC[L:-1, j] - logC[:-L - 1, j], R[L + 1:, j])[0] + inst_pnl[(inst, L)] = (pnl, oo) + print(f"{inst:>5} {L:>4} {ic:>10.3f} {sharpe(pnl):>11.2f} " + f"{sharpe(pnl[~oo]):>12.2f} {sharpe(pnl[oo]):>13.2f}") + + # ---- diversified trend portfolio (vol-scaled by IS std, equal weight) ---- + print("\n=== DIVERSIFIED TREND PORTFOLIO (equal-weight, IS-vol-scaled, per lookback) ===") + print(f"{'L':>4} {'Sharpe_all':>11} {'Sharpe_IS24':>12} {'Sharpe_OOS25':>13}") + for L in [5, 10, 20, 60]: + # build aligned per-day portfolio pnl across instruments + port = np.zeros(len(common) - L - 1) + oo = oos[L + 1:] + for j, inst in enumerate(INSTR): + sig = np.sign(logC[L:, j] - logC[:-L, j])[:-1] + ret = R[L + 1:, j] + bad = roll[L + 1:, j] | ~np.isfinite(ret) + pnl = np.where(bad, 0.0, sig * ret) + is_std = pnl[~oo].std() + if is_std > 0: + port += pnl / is_std # vol-scale by IS std (no OOS lookahead) + port /= len(INSTR) + print(f"{L:>4} {sharpe(port):>11.2f} {sharpe(port[~oo]):>12.2f} {sharpe(port[oo]):>13.2f}") + + # ---- cross-asset structure ---- + print("\n=== CONTEMPORANEOUS daily return correlation (risk-on/off sanity) ===") + Rclean = np.where(roll | ~np.isfinite(R), np.nan, R) + cc = np.ma.corrcoef(np.ma.masked_invalid(Rclean).T) + print(" " + " ".join(f"{i:>6}" for i in INSTR)) + for a in range(len(INSTR)): + print(f"{INSTR[a]:>6} " + " ".join(f"{cc[a, b]:>6.2f}" for b in range(len(INSTR)))) + + print("\n=== CROSS-ASSET LEAD-LAG IC = corr(ret_X[t], ret_Y[t+1]) (does X lead Y?) ===") + print(" X\\Y " + " ".join(f"{i:>6}" for i in INSTR)) + for a in range(len(INSTR)): + row = [] + for b in range(len(INSTR)): + x = Rclean[:-1, a]; y = Rclean[1:, b] + m = np.isfinite(x) & np.isfinite(y) + row.append(spearmanr(x[m], y[m])[0] if m.sum() > 50 else float("nan")) + print(f"{INSTR[a]:>6} " + " ".join(f"{v:>6.3f}" for v in row)) + + print("\nHONEST READ: 2y/2 regimes, 4 instruments — suggestive only. A diversified-trend") + print("OOS Sharpe > ~0.7 would justify acquiring a broad futures universe + decades + purged CV.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())