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>
This commit is contained in:
191
scripts/measure_passive_mm.py
Normal file
191
scripts/measure_passive_mm.py
Normal file
@@ -0,0 +1,191 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user