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>
This commit is contained in:
138
scripts/measure_crossasset.py
Normal file
138
scripts/measure_crossasset.py
Normal file
@@ -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())
|
||||
Reference in New Issue
Block a user