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>
139 lines
6.0 KiB
Python
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())
|