Files
foxhunt/scripts/measure_lowfreq_ic.py
jgrusewski 292147090f feat(scripts): lower-frequency directional IC harness (intraday + daily + backtest)
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>
2026-06-05 19:58:18 +02:00

211 lines
9.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())