// Late-session momentum, gated on dealer gamma — the one intraday futures rule with strong evidence. // Baltussen, Da, Lammers & Martens (JFE 2021): trade 15:30->16:00 ET in the direction of the day so far. // Their key conditioner: it works when dealer net gamma exposure is NEGATIVE (beta 6.63, t 4.78) and // is absent when positive (beta 0.82, t 1.03). Gamma comes from the desk's own QQQ archive, using the // PRIOR day's file so nothing looks ahead. import fs from "node:fs"; import zlib from "node:zlib"; const GLBX = "/fp-data/glbx", GEXDIR = "/fp-data/gex/history"; const SPEC = { NQ: [0.25, 5, 14], ES: [0.25, 12.5, 29] }; // tick, $/tick, $ round-turn cost incl 1 tick slip each way const YEARS = ["2023", "2024", "2025", "2026"]; const etFmt = new Intl.DateTimeFormat("en-US", { timeZone: "America/New_York", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }); const offCache = new Map(); function etOf(ms) { const day = Math.floor(ms / 86400_000); let off = offCache.get(day); if (off === undefined) { const p = Object.fromEntries(etFmt.formatToParts(ms).map((x) => [x.type, x.value])); off = Math.round((Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour % 24, +p.minute) - Math.floor(ms / 60000) * 60000) / 60000); offCache.set(day, off); } const d = new Date(ms + off * 60000); return { date: d.toISOString().slice(0, 10), min: d.getUTCHours() * 60 + d.getUTCMinutes() }; } const mean = (x) => x.reduce((a, b) => a + b, 0) / (x.length || 1); const sd = (x) => { const m = mean(x); return Math.sqrt(mean(x.map((v) => (v - m) ** 2))); }; // ── dealer gamma per date (QQQ, summed across strikes) ────────────────────── const nge = new Map(); for (const f of fs.readdirSync(GEXDIR).filter((x) => x.endsWith(".json.gz")).sort()) { try { const j = JSON.parse(zlib.gunzipSync(fs.readFileSync(`${GEXDIR}/${f}`))); const rows = Array.isArray(j.qqq) ? j.qqq : []; if (!rows.length) continue; let g = 0; for (const r of rows) g += (+r.call_gex || 0) + (+r.put_gex || 0); nge.set(j.date ?? f.slice(0, 10), g); } catch { /* skip unreadable day */ } } const gexDates = [...nge.keys()].sort(); console.log(`gamma archive: ${gexDates.length} days, ${gexDates[0]} → ${gexDates.at(-1)}`); console.log(` negative-gamma days: ${gexDates.filter((d) => nge.get(d) < 0).length} · positive: ${gexDates.filter((d) => nge.get(d) >= 0).length}\n`); const prevGamma = (date) => { let lo = 0, hi = gexDates.length - 1, best = null; while (lo <= hi) { const m = (lo + hi) >> 1; if (gexDates[m] < date) { best = gexDates[m]; lo = m + 1; } else hi = m - 1; } return best ? nge.get(best) : null; }; for (const [sym, [tick, perTick, cost]] of Object.entries(SPEC)) { const day = new Map(); for (const y of YEARS) { const f = `${GLBX}/${sym}/1m/${y}.csv`; if (!fs.existsSync(f)) continue; const lines = fs.readFileSync(f, "utf8").split("\n"); for (let i = 1; i < lines.length; i++) { const L = lines[i]; if (!L) continue; const c = L.split(","); const t = Date.parse(c[0].slice(0, 19) + "Z"), cl = +c[7]; if (!Number.isFinite(t) || !Number.isFinite(cl)) continue; const { date, min } = etOf(t); if (min < 9 * 60 + 30 || min > 16 * 60) continue; let d = day.get(date); if (!d) { d = new Map(); day.set(date, d); } d.set(min, cl); } } const dates = [...day.keys()].sort().filter((d) => d >= gexDates[0]); const rows = []; for (let i = 1; i < dates.length; i++) { const prev = day.get(dates[i - 1]), cur = day.get(dates[i]); const prevClose = prev.get(16 * 60) ?? prev.get(15 * 60 + 59); const p1530 = cur.get(15 * 60 + 30), close = cur.get(16 * 60) ?? cur.get(15 * 60 + 59); if (![prevClose, p1530, close].every(Number.isFinite)) continue; const g = prevGamma(dates[i]); if (g == null) continue; rows.push({ date: dates[i], side: Math.sign(p1530 / prevClose - 1), moveUsd: ((close - p1530) / tick) * perTick, gamma: g }); } const report = (label, rs) => { if (rs.length < 30) { console.log(` ${label.padEnd(30)} n ${rs.length} — too few`); return; } const pnl = rs.map((r) => r.side * r.moveUsd - cost); const t = mean(pnl) / (sd(pnl) / Math.sqrt(pnl.length)); console.log(` ${label.padEnd(30)} n ${String(rs.length).padStart(4)} · gross $${mean(rs.map((r) => r.side * r.moveUsd)).toFixed(2)} · net $${mean(pnl).toFixed(2)}/contract · win ${(100 * pnl.filter((x) => x > 0).length / pnl.length).toFixed(1)}% · t ${t.toFixed(2)} · ann.SR ${((mean(pnl) / (sd(pnl) || 1)) * Math.sqrt(252)).toFixed(2)}`); }; console.log(`${sym} — last half hour in the direction of the day so far (${rows.length} days, cost $${cost}/contract):`); report("all days", rows); report("prior-day gamma NEGATIVE", rows.filter((r) => r.gamma < 0)); report("prior-day gamma POSITIVE", rows.filter((r) => r.gamma >= 0)); const q = [...rows.map((r) => r.gamma)].sort((a, b) => a - b); const p33 = q[Math.floor(q.length / 3)], p67 = q[Math.floor((2 * q.length) / 3)]; report("most-negative gamma tercile", rows.filter((r) => r.gamma <= p33)); report("middle tercile", rows.filter((r) => r.gamma > p33 && r.gamma < p67)); report("most-positive gamma tercile", rows.filter((r) => r.gamma >= p67)); console.log(""); }