Files
foxhunt/scripts/measure_crossasset.py
jgrusewski 44cbeef321 feat(scripts): cross-asset trend/lead-lag harness (6E/ES/NQ/ZN OHLCV)
Per-instrument time-series momentum + diversified trend portfolio + lead-lag,
honest IS(2024)/OOS(2025) Sharpe on non-overlapping daily P&L.

Verdict on 2y/4-instrument data: diversified trend portfolio IS Sharpe +1.98
(L10) -> OOS -0.50; per-instrument TSMOM sign-flips IS<->OOS; lead-lag ICs <0.11.
No robust edge. Conclusion: 4 instruments x 2y is underpowered by 1-2 orders of
magnitude for systematic edge discovery -- the bottleneck is data/universe, not
model. Real progress needs a broad futures universe + decades, or a less-efficient
order-book market (crypto).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 20:39:31 +02:00

139 lines
6.0 KiB
Python

#!/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())