Files
foxhunt/scripts/measure_passive_mm.py
jgrusewski f6745400ef feat(scripts): passive market-making markout/adverse-selection harness
Measures passive-MM economics on clean ES MBP-10: half-spread earned vs
adverse-selection markout at horizons, plus OFI-conditioned selective quoting.
Verdict on 2024 ES: adverse selection ~0.4 tick eats ~80% of the 0.5-tick
half-spread => naive passive MM is break-even (optimistic, win-every-queue;
realistically negative). OFI-conditioned selective quoting shows a consistent
edge in Q2 (+$0.5/fill) but not Q1 => promising but unconfirmed; decisive
next test is a realistic queue/fill model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 19:21:30 +02:00

192 lines
8.7 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
"""Measure PASSIVE market-making economics on ES MBP-10 (markout / adverse selection).
Companion to scripts/measure_ofi_ic.py. The OFI study showed a spread-CROSSING
directional strategy is structurally unprofitable (predicted move ~0.01-0.06 ticks
vs 1-tick cost). The exploitable structure (OFI→impact, R²~0.5-0.7) lives in the
spread, capturable only by PROVIDING liquidity. This harness measures whether
earning the spread is viable after adverse selection.
Method (standard market-making markout / adverse-selection study):
* Decode clean MBP-10; maintain best bid/ask/mid from levels[0].
* Each TRADE (action='T') fills a hypothetical PASSIVE order at the touch:
- side=ASK (sell aggressor, hits bid) -> we BUY at bid (pos=+1, long)
- side=BID (buy aggressor, lifts ask) -> we SELL at ask (pos=-1, short)
(optimistic: assumes we win the queue on every trade — gives the UPPER BOUND
per-fill economics; if even this is unprofitable, passive MM is dead.)
* Per fill, PnL(Δ) in ticks = pos·(mid_{t+Δ} fill_px)/tick fee
= half_spread (≈0.5 tick) + markout fee
markout = pos·(mid_{t+Δ} mid_t) (NEGATIVE = adverse selection).
* Viable iff half_spread + markout fee > 0.
* OFI-CONDITIONING: split fills by whether recent OFI agrees with our position
(long & OFI>0, or short & OFI<0). If OFI-aligned fills have far better markout,
OFI-conditioned quoting (rest only on the favorable side) is the real strategy —
using the tiny OFI signal for adverse-selection AVOIDANCE, not for crossing.
Usage:
python3 scripts/measure_passive_mm.py <file.dbn.zst> [--n-cap N] [--fee-ticks F] [--ofi-window-sec W]
python3 scripts/measure_passive_mm.py --self-test
"""
import argparse
import sys
import time
import numpy as np
TICK = 0.25
TICK_USD = 12.50
PX_SCALE = 1e9
def compute_ofi(bid_px, bid_sz, ask_px, ask_sz):
"""Best-level OFI per consecutive event (Cont-Kukanov-Stoikov). len-1, aligned to n>=1."""
pb_n, pb_p = bid_px[1:], bid_px[:-1]
qb_n, qb_p = bid_sz[1:].astype(np.float64), bid_sz[:-1].astype(np.float64)
pa_n, pa_p = ask_px[1:], ask_px[:-1]
qa_n, qa_p = ask_sz[1:].astype(np.float64), ask_sz[:-1].astype(np.float64)
e_bid = qb_n * (pb_n >= pb_p) - qb_p * (pb_n <= pb_p)
e_ask = qa_p * (pa_n >= pa_p) - qa_n * (pa_n <= pa_p)
return e_bid + e_ask
def self_test():
# sell aggressor (agg=-1) → passive BUY at bid; bid=100 ask=100.25 mid=100.125;
# future mid 100.25 → markout +0.5 tick, half_spread +0.5 tick, net +1.0 tick.
bid, ask = 100.0, 100.25
mid = (bid + ask) / 2
pos = +1 # passive buy
fill_px = bid
mid_fut = 100.25
half = pos * (mid - fill_px)
markout = pos * (mid_fut - mid)
net_ticks = pos * (mid_fut - fill_px) / TICK
assert abs(half / TICK - 0.5) < 1e-9, half
assert abs(markout / TICK - 0.5) < 1e-9, markout
assert abs(net_ticks - 1.0) < 1e-9, net_ticks
print(f"passive-MM self-test PASS: half={half/TICK:.2f}t markout={markout/TICK:.2f}t net={net_ticks:.2f}t")
return 0
def decode(path, n_cap):
import zstandard
import databento_dbn as d
ts = np.zeros(n_cap, np.int64)
bpx = np.zeros(n_cap, np.int64); apx = np.zeros(n_cap, np.int64)
bsz = np.zeros(n_cap, np.int64); asz = np.zeros(n_cap, np.int64)
inst = np.zeros(n_cap, np.int32)
is_trade = np.zeros(n_cap, np.bool_)
agg = np.zeros(n_cap, np.int8) # +1 buy-aggressor(side=BID), -1 sell-aggressor(side=ASK)
dec = d.DBNDecoder(); dctx = zstandard.ZstdDecompressor()
i = 0; t0 = time.time(); done = False
with open(path, "rb") as fh:
reader = dctx.stream_reader(fh)
while not done:
chunk = reader.read(1 << 22)
if not chunk:
break
dec.write(chunk)
for rec in dec.decode():
if "MBP10" not in type(rec).__name__:
continue
l0 = rec.levels[0]
ts[i] = rec.ts_event
bpx[i] = l0.bid_px; apx[i] = l0.ask_px
bsz[i] = l0.bid_sz; asz[i] = l0.ask_sz
inst[i] = rec.instrument_id
a = str(rec.action); s = str(rec.side)
if a.endswith("TRADE") or a == "T":
is_trade[i] = True
if s.endswith("BID") or s == "B":
agg[i] = 1
elif s.endswith("ASK") or s == "A":
agg[i] = -1
i += 1
if i >= n_cap:
done = True
break
dt = time.time() - t0
return ts[:i], bpx[:i], apx[:i], bsz[:i], asz[:i], inst[:i], is_trade[:i], agg[:i], dt
def analyze(path, n_cap, fee_ticks, ofi_window_sec):
ts, bpx, apx, bsz, asz, inst, is_trade, agg, dt = decode(path, n_cap)
n = len(ts)
ids, cnts = np.unique(inst, return_counts=True)
dom = ids[cnts.argmax()]
m = inst == dom
ts, bpx, apx, bsz, asz, is_trade, agg = ts[m], bpx[m], apx[m], bsz[m], asz[m], is_trade[m], agg[m]
valid = (bpx > 0) & (apx > 0) & (bpx < apx)
ts, bpx, apx, bsz, asz, is_trade, agg = (a[valid] for a in (ts, bpx, apx, bsz, asz, is_trade, agg))
mid = (bpx + apx) / 2.0 / PX_SCALE
print(f"\n=== {path.split('/')[-1]} ===")
print(f"decoded {n} events in {dt:.1f}s | dominant={dom}{valid.sum()} clean front-month events")
# event-level OFI (aligned to events 1..), prepend 0 for event 0
ofi = np.empty(len(ts)); ofi[0] = 0.0
ofi[1:] = compute_ofi(bpx, bsz, apx, asz)
cum_ofi = np.cumsum(ofi)
# passive fills = trades with a known aggressor
fill = is_trade & (agg != 0)
fi = np.flatnonzero(fill)
pos = (-agg[fi]).astype(np.float64) # passive position: opposite the aggressor
fill_px = np.where(pos > 0, bpx[fi], apx[fi]) / PX_SCALE
mid_fill = mid[fi]
half_ticks = pos * (mid_fill - fill_px) / TICK
nbuy = int((pos > 0).sum()); nsell = int((pos < 0).sum())
print(f"passive fills: {len(fi)} (buy={nbuy} sell={nsell}) | "
f"avg half-spread earned = {half_ticks.mean():.3f} ticks (${half_ticks.mean()*TICK_USD:.2f})")
# OFI in the window before each fill (adverse-selection avoidance signal)
w_ns = int(ofi_window_sec * 1e9)
j0 = np.searchsorted(ts, ts[fi] - w_ns, side="left")
ofi_w = cum_ofi[fi] - cum_ofi[j0]
aligned = np.sign(ofi_w) == np.sign(pos) # OFI agrees with our passive direction
print(f"OFI-window={ofi_window_sec}s | OFI-aligned fills: {100*aligned.mean():.1f}% "
f"fee={fee_ticks} ticks/side (${fee_ticks*TICK_USD:.2f})")
horizons = [0.1, 1.0, 5.0, 10.0, 60.0]
print(f"\nMARKOUT (ticks) and NET per-fill PnL = half_spread + markout fee")
print(f"{'Δ(s)':>6} {'markout':>9} {'net_all':>9} {'net_$':>8} {'net_aligned':>12} {'net_adverse':>12} {'sel_$/fill':>11}")
rows = []
for d_s in horizons:
idx2 = np.searchsorted(ts, ts[fi] + int(d_s * 1e9), side="left")
idx2 = np.clip(idx2, 0, len(mid) - 1)
mid_fut = mid[idx2]
markout = pos * (mid_fut - mid_fill) / TICK
net = half_ticks + markout - fee_ticks
net_aligned = net[aligned].mean() if aligned.any() else float("nan")
net_adverse = net[~aligned].mean() if (~aligned).any() else float("nan")
# "selective" MM: only take OFI-aligned fills
sel_usd = (net[aligned].mean() * TICK_USD) if aligned.any() else float("nan")
rows.append((d_s, markout.mean(), net.mean()))
print(f"{d_s:>6} {markout.mean():>9.4f} {net.mean():>9.4f} {net.mean()*TICK_USD:>7.2f}$ "
f"{net_aligned:>12.4f} {net_adverse:>12.4f} {sel_usd:>10.2f}$")
print("\nINTERPRETATION:")
print(" net_all > 0 → naive passive MM (quote both sides at touch) is viable at that horizon.")
print(" net_aligned >> net_adverse → OFI-conditioned quoting (rest only on the favorable")
print(" side / pull the adverse side) converts the tiny OFI signal into MM edge.")
print(" net_all < 0 but net_aligned > 0 → naive MM loses, but SELECTIVE OFI-driven MM works.")
return rows
def main():
ap = argparse.ArgumentParser()
ap.add_argument("file", nargs="?")
ap.add_argument("--n-cap", type=int, default=8_000_000)
ap.add_argument("--fee-ticks", type=float, default=0.1, help="per-side fee in ticks (~$1.25=0.1t)")
ap.add_argument("--ofi-window-sec", type=float, default=1.0)
ap.add_argument("--self-test", action="store_true")
args = ap.parse_args()
if args.self_test:
return self_test()
if not args.file:
ap.error("file required (or --self-test)")
analyze(args.file, args.n_cap, args.fee_ticks, args.ofi_window_sec)
return 0
if __name__ == "__main__":
sys.exit(main())