Tests minutes-to-days directional predictability on ES OHLCV-1m where moves dwarf the spread: momentum/reversion IC (Pearson/Spearman/NW-t/OOS) + a non-overlapping daily contrarian backtest (IS/OOS Sharpe). Verdict on 2y ES (2024-25): intraday IC~0; daily mean-reversion IC looks large (-0.44 Spearman @20d) but is overlap-inflated + regime artifact -- honest non-overlapping backtest sign-flips IS<->OOS => no stable edge. Confirms no robustly capturable directional edge on 2y ES across all measured horizons. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
211 lines
9.4 KiB
Python
211 lines
9.4 KiB
Python
#!/usr/bin/env python3
|
||
"""Lower-frequency directional IC on ES OHLCV-1m bars (minutes → days).
|
||
|
||
After measuring that seconds-horizon ES is structurally unprofitable for a
|
||
non-colocated participant (crossing edge ~100x too small vs spread; passive MM
|
||
adversely selected — pearl_ofi_edge_uncapturable_by_crossing,
|
||
pearl_passive_mm_knife_edge_ofi_conditioning_promising), this tests the remaining
|
||
thesis: at minutes-to-days horizons the directional move DWARFS the 1-tick spread,
|
||
so crossing cost stops being the killer. The question becomes purely: does a cheap
|
||
directional predictor have positive OUT-OF-SAMPLE Information Coefficient?
|
||
|
||
Tests two regimes on front-month ES 1-min OHLCV (2024-Q1 .. 2026-Q1, ~2y):
|
||
* INTRADAY: momentum/reversion (trailing return over L minutes) vs forward
|
||
return over H minutes; contiguity-guarded (no windows spanning session/roll gaps).
|
||
* DAILY: time-series momentum (Moskowitz-Ooi-Pedersen) — trailing return over
|
||
L days vs forward return over H days.
|
||
For each predictor×horizon: Pearson/Spearman IC, sign-accuracy, Newey-West t-stat,
|
||
OOS IC (train 2024 / test 2025+), and median |forward move| in ticks (to confirm
|
||
moves >> the 1-tick = $12.50 crossing cost).
|
||
|
||
Usage: python3 scripts/measure_lowfreq_ic.py [dir] [--self-test]
|
||
"""
|
||
import argparse
|
||
import glob
|
||
import sys
|
||
|
||
import numpy as np
|
||
|
||
TICK = 0.25
|
||
PX_SCALE = 1e9
|
||
|
||
|
||
def newey_west_tstat(x, y, lags):
|
||
n = len(x)
|
||
if n < lags + 3:
|
||
return float("nan")
|
||
X = np.column_stack([np.ones(n), x])
|
||
xtx_inv = np.linalg.inv(X.T @ X)
|
||
beta = xtx_inv @ (X.T @ y)
|
||
u = (y - X @ beta)[:, None] * X
|
||
S = u.T @ u
|
||
for l in range(1, lags + 1):
|
||
w = 1.0 - l / (lags + 1.0)
|
||
G = u[l:].T @ u[:-l]
|
||
S += w * (G + G.T)
|
||
cov = xtx_inv @ S @ xtx_inv
|
||
se = np.sqrt(cov[1, 1])
|
||
return float(beta[1] / se) if se > 0 else float("nan")
|
||
|
||
|
||
def load_ohlcv(d):
|
||
import zstandard
|
||
import databento_dbn as dbn
|
||
files = sorted(glob.glob(f"{d}/*.dbn.zst"))
|
||
ts_all, close_all, q_all = [], [], []
|
||
for qi, f in enumerate(files):
|
||
dec = dbn.DBNDecoder(); dctx = zstandard.ZstdDecompressor()
|
||
ts, cl, inst = [], [], []
|
||
with open(f, "rb") as fh:
|
||
r = dctx.stream_reader(fh)
|
||
while True:
|
||
c = r.read(1 << 20)
|
||
if not c:
|
||
break
|
||
dec.write(c)
|
||
for rec in dec.decode():
|
||
if "OHLCV" not in type(rec).__name__:
|
||
continue
|
||
ts.append(rec.ts_event); cl.append(rec.close); inst.append(rec.instrument_id)
|
||
ts = np.array(ts, np.int64); cl = np.array(cl, np.int64); inst = np.array(inst, np.int32)
|
||
if len(ts) == 0:
|
||
print(f" {f.split('/')[-1]}: EMPTY (no OHLCV) — skipped")
|
||
continue
|
||
ids, cnts = np.unique(inst, return_counts=True)
|
||
dom = ids[cnts.argmax()]
|
||
m = inst == dom
|
||
order = np.argsort(ts[m], kind="stable")
|
||
ts_all.append(ts[m][order]); close_all.append(cl[m][order] / PX_SCALE)
|
||
q_all.append(np.full(m.sum(), qi))
|
||
print(f" {f.split('/')[-1]}: {m.sum()} front-month 1m bars (dom={dom})")
|
||
return np.concatenate(ts_all), np.concatenate(close_all), np.concatenate(q_all)
|
||
|
||
|
||
def ic_block(name, ts, close, q, lookbacks, horizons, bar_unit_s, oos_split_ts):
|
||
from scipy.stats import pearsonr, spearmanr
|
||
logc = np.log(close)
|
||
n = len(close)
|
||
print(f"\n=== {name} ({n} bars) ===")
|
||
print(f"{'L':>4} {'H':>4} {'n':>7} {'IC_pear':>9} {'IC_spear':>9} {'sign%':>7} "
|
||
f"{'NW_t':>7} {'OOS_IC':>8} {'|fwd|ticks':>11}")
|
||
for L in lookbacks:
|
||
for H in horizons:
|
||
# predictor: trailing return over L bars; forward: return over H bars
|
||
# contiguity: bar index window must not span a quarter (roll) boundary
|
||
idx = np.arange(L, n - H)
|
||
mom = logc[idx] - logc[idx - L]
|
||
fwd = logc[idx + H] - logc[idx]
|
||
same_q = (q[idx - L] == q[idx]) & (q[idx + H] == q[idx])
|
||
mom, fwd, ii = mom[same_q], fwd[same_q], idx[same_q]
|
||
if len(mom) < 100 or mom.std() == 0 or fwd.std() == 0:
|
||
continue
|
||
ic = pearsonr(mom, fwd)[0]
|
||
ics = spearmanr(mom, fwd)[0]
|
||
sign = np.mean(np.sign(mom) == np.sign(fwd))
|
||
nwt = newey_west_tstat(mom, fwd, lags=H)
|
||
oos = ts[ii] >= oos_split_ts
|
||
oos_ic = (pearsonr(mom[oos], fwd[oos])[0]
|
||
if oos.sum() > 50 and mom[oos].std() > 0 else float("nan"))
|
||
fwd_ticks = np.median(np.abs(np.exp(fwd) - 1.0) * close[ii]) / TICK
|
||
print(f"{L:>4} {H:>4} {len(mom):>7} {ic:>9.4f} {ics:>9.4f} {100*sign:>6.2f}% "
|
||
f"{nwt:>7.2f} {'n/a' if np.isnan(oos_ic) else f'{oos_ic:>8.4f}'} {fwd_ticks:>11.2f}")
|
||
|
||
|
||
def to_daily(ts, close, q):
|
||
day = ts // (86400 * 10**9)
|
||
ud, last = np.unique(day, return_index=False), None
|
||
# last bar per day (ts sorted ascending overall within concat? sort to be safe)
|
||
order = np.argsort(ts, kind="stable")
|
||
ts, close, q, day = ts[order], close[order], q[order], day[order]
|
||
udays, first = np.unique(day, return_index=True)
|
||
last_idx = np.append(first[1:] - 1, len(day) - 1)
|
||
return ts[last_idx], close[last_idx], q[last_idx]
|
||
|
||
|
||
def self_test():
|
||
# varying-momentum series: per-bar return increases linearly, so trailing
|
||
# 1-bar return (= r[t]) and forward 1-bar return (= r[t+1]) are perfectly
|
||
# rank-correlated → IC≈1. (A constant step would be near-constant → undefined.)
|
||
r = 0.0001 * np.arange(1, 52)
|
||
close = np.exp(np.cumsum(np.r_[0.0, r]))
|
||
q = np.zeros(len(close)); ts = np.arange(len(close)) * 60 * 10**9
|
||
logc = np.log(close)
|
||
L = H = 1
|
||
idx = np.arange(L, len(close) - H)
|
||
mom = logc[idx] - logc[idx - L]; fwd = logc[idx + H] - logc[idx]
|
||
from scipy.stats import pearsonr
|
||
ic = pearsonr(mom, fwd)[0]
|
||
assert ic > 0.99, ic
|
||
print(f"lowfreq self-test PASS: trending series momentum IC={ic:.3f}")
|
||
return 0
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("dir", nargs="?", default="test_data/futures-baseline/ES.FUT")
|
||
ap.add_argument("--self-test", action="store_true")
|
||
args = ap.parse_args()
|
||
if args.self_test:
|
||
return self_test()
|
||
ts, close, q = load_ohlcv(args.dir)
|
||
order = np.argsort(ts, kind="stable")
|
||
ts, close, q = ts[order], close[order], q[order]
|
||
span_days = (ts[-1] - ts[0]) / 1e9 / 86400
|
||
# OOS split at 2025-01-01 UTC
|
||
split = 1735689600 * 10**9
|
||
print(f"\nloaded {len(close)} front-month 1m bars, span {span_days:.0f} days, "
|
||
f"{int((ts<split).sum())} train(<2025) / {int((ts>=split).sum())} test(>=2025)")
|
||
|
||
# INTRADAY (minutes), contiguity-guarded
|
||
ic_block("INTRADAY momentum (1m bars; L,H in minutes)", ts, close, q,
|
||
lookbacks=[5, 15, 30, 60, 120], horizons=[5, 15, 30, 60], bar_unit_s=60,
|
||
oos_split_ts=split)
|
||
|
||
# DAILY time-series momentum
|
||
dts, dclose, dq = to_daily(ts, close, q)
|
||
print(f"\n[daily] {len(dclose)} daily bars")
|
||
ic_block("DAILY time-series momentum (L,H in days)", dts, dclose, dq,
|
||
lookbacks=[1, 2, 5, 10, 20, 60], horizons=[1, 5, 10, 20], bar_unit_s=86400,
|
||
oos_split_ts=split)
|
||
|
||
# Daily contrarian strategy backtest (non-overlapping daily P&L = honest test).
|
||
# signal_t = -sign(trailing L-day return); position held, earns next-day return.
|
||
# Daily P&L is non-overlapping by construction, sidestepping the overlap inflation
|
||
# that exaggerates the multi-day IC above.
|
||
print("\n=== DAILY CONTRARIAN BACKTEST (pos = -sign(trailing L-day ret), daily rebalanced) ===")
|
||
dlogc = np.log(dclose)
|
||
dr = np.diff(dlogc, prepend=dlogc[0]) # daily log returns
|
||
same_day_q = np.r_[True, dq[1:] == dq[:-1]] # not a roll boundary
|
||
split = 1735689600 * 10**9
|
||
print(f"{'L(d)':>5} {'Sharpe_all':>11} {'Sharpe_IS24':>12} {'Sharpe_OOS25':>13} "
|
||
f"{'ann_ret%':>9} {'turnover/yr':>11} {'hitrate':>8}")
|
||
for L in [2, 5, 10, 20]:
|
||
sig = -np.sign(dlogc[L:] - dlogc[:-L]) # contrarian signal at day t (uses past L days)
|
||
# position at day t earns return at day t+1
|
||
pos = sig[:-1]
|
||
ret = dr[L + 1:] # next-day returns aligned to pos
|
||
qok = same_day_q[L + 1:] # exclude roll-boundary next-days
|
||
tsd = dts[L + 1:]
|
||
pos, ret, tsd = pos[qok], ret[qok], tsd[qok]
|
||
pnl = pos * ret
|
||
if len(pnl) < 30 or pnl.std() == 0:
|
||
continue
|
||
def sharpe(p):
|
||
return p.mean() / p.std() * np.sqrt(252) if len(p) > 5 and p.std() > 0 else float("nan")
|
||
ism = tsd < split; oosm = tsd >= split
|
||
turn = np.mean(np.abs(np.diff(pos)) > 0) * 252
|
||
hit = np.mean(np.sign(pnl) > 0)
|
||
print(f"{L:>5} {sharpe(pnl):>11.2f} {sharpe(pnl[ism]):>12.2f} {sharpe(pnl[oosm]):>13.2f} "
|
||
f"{pnl.mean()*252*100:>8.2f}% {turn:>11.0f} {100*hit:>6.1f}%")
|
||
print("Cost: 20d-horizon turnover is low; 1-tick round-trip (~0.005% of price) is negligible.")
|
||
print("HONEST READ: trust OOS Sharpe (non-overlapping). >0.5 OOS on thin 2y bull data is")
|
||
print("suggestive, NOT proven — needs full history + purged CV + regime stratification.")
|
||
|
||
print("\nNOTE: at these horizons |fwd|ticks >> 1-tick ($12.50) crossing cost, so cost")
|
||
print("is not the binding constraint — the question is purely whether OOS_IC > 0 and stable.")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|