Order flow imbalance: does signed volume predict returns?

Order flow imbalance is the excess of buyer-initiated over seller-initiated volume, and it is the workhorse microstructure signal. It correlates strongly with contemporaneous price moves and, in real markets at short horizons, is mildly predictive of the next move.

In this recipe we build both flavours:

  1. trade OFI: audit signing rules against ground truth, using Lee-Ready via an ASOF join to the prevailing quote with the tick test as fallback, then sign the tape and bucket signed volume per minute,
  2. quote OFI (Cont, Kukanov and Stoikov 2014): order-book pressure from changes in best bid and ask prices and sizes, via lag() windows,
  3. an honest look at contemporaneous versus predictive correlation with 1-minute returns.
import numpy as np
import pandas as pd
import pyarrow as pa

import h5i_db
from h5i_db import col, lit, sql_expr, time_bucket, when
import cookbook_utils as cu

db = h5i_db.Database(cu.fresh_db("alpha_ofi"), create=True)

1. The data#

Two sessions of top-of-book quotes from cu.make_quotes, one row per change in the best bid or offer.

column type meaning
ts timestamp[us, tz=UTC] quote timestamp, ascending
symbol string ticker, AAPL or MSFT
bid, ask float64 best bid and offer
bid_size, ask_size int64 displayed depth at each side
quotes = cu.make_quotes(symbols=["AAPL", "MSFT"], days=2)
print(f"quotes: {quotes.num_rows:,} rows x {quotes.num_columns} columns")
quotes.to_pandas().head()
output
quotes: 237,391 rows x 6 columns
ts symbol bid ask bid_size ask_size
0 2026-06-01 13:30:00.017430+00:00 MSFT 219.72 219.76 900 300
1 2026-06-01 13:30:00.129783+00:00 AAPL 86.27 86.30 400 700
2 2026-06-01 13:30:00.133269+00:00 AAPL 86.28 86.29 200 500
3 2026-06-01 13:30:00.179061+00:00 AAPL 86.28 86.29 200 500
4 2026-06-01 13:30:00.184339+00:00 MSFT 219.73 219.77 1400 500

One generator caveat shapes what follows. make_trades prints follow a random walk only loosely coupled to make_quotes mids, which would make quote-based trade signing meaningless.

So we keep the synthetic quote stream and print our own tape off it, the way a matching engine would. A random 25% of quote updates trigger a trade 1 to 50 ms later, buyers lift the offer, sellers hit the bid, and the true aggressor side is recorded.

The tape is price-consistent with the quotes by construction, so every signing rule below can be scored against ground truth. That is a luxury real data never affords. The printed tape has columns ts, symbol, price, size and side.

q = quotes.to_pandas()

rng = np.random.default_rng(21)
picks = q[rng.random(len(q)) < 0.25].copy()
side = rng.choice(np.array([1, -1]), size=len(picks))
picks["price"] = np.where(side > 0, picks["ask"], picks["bid"])
picks["ts"] = (
    picks["ts"] + pd.to_timedelta(rng.integers(1_000, 50_000, len(picks)), unit="us")
).astype("datetime64[us, UTC]")
picks["size"] = np.maximum(100, (rng.lognormal(4.0, 1.2, len(picks)) // 100 * 100)).astype("int64")
picks["side"] = np.where(side > 0, "B", "S")

trade_schema = pa.schema(
    [
        pa.field("ts", pa.timestamp("us", tz="UTC"), nullable=False),
        pa.field("symbol", pa.string()),
        pa.field("price", pa.float64()),
        pa.field("size", pa.int64()),
        pa.field("side", pa.string()),
    ]
)
trades_df = picks[["ts", "symbol", "price", "size", "side"]].sort_values(["ts", "symbol"])
trades = pa.Table.from_pandas(trades_df, preserve_index=False).cast(trade_schema)
print(f"{len(trades_df):,} trades printed off {len(q):,} quotes")
trades_df.head()
output
59,090 trades printed off 237,391 quotes
ts symbol price size side
3 2026-06-01 13:30:00.194445+00:00 AAPL 86.29 100 B
7 2026-06-01 13:30:00.617904+00:00 AAPL 86.29 100 S
10 2026-06-01 13:30:00.937433+00:00 AAPL 86.30 100 S
13 2026-06-01 13:30:01.212017+00:00 MSFT 219.75 100 S
16 2026-06-01 13:30:01.513349+00:00 MSFT 219.77 100 B
db.create_table("quotes", quotes.schema, time_column="ts", sort_key=["ts", "symbol"])
db.append("quotes", quotes, note="2-day synthetic NBBO")
db.create_table("trades", trade_schema, time_column="ts", sort_key=["ts", "symbol"])
db.append("trades", trades, note="tape printed off the quote stream")
print({t: len(db.read(t)) for t in db.tables()})
output
{'quotes': 237391, 'trades': 59090}

2. Audit the signing rules on a QA window#

Lee-Ready says: above the prevailing mid is a buy, below is a sell, and at the mid you fall back to the tick test, where an uptick is a buy.

The prevailing-quote lookup is asof_join(..., 'backward', 5_000_000): the latest quote at most 5 seconds old, per symbol, with the tolerance in raw microseconds.

We run the audit on a midday 15-minute window, carved out with time-ranged db.read calls using raw microsecond bounds, pruned at the segment level, and stored as small workbench tables. A windowed audit is fast to iterate on and easy to eyeball.

We assert the join returns exactly one row per trade, and cross-check the attached quotes against pandas.merge_asof.

w0 = int(pd.Timestamp("2026-06-01 16:00", tz="UTC").value // 1000)
w1 = int(pd.Timestamp("2026-06-01 16:15", tz="UTC").value // 1000)

trades_w = db.read("trades", time_start=w0, time_end=w1)
quotes_w = db.read("quotes", time_start=w0 - 60_000_000, time_end=w1)  # 60s run-up
db.create_table("trades_qa", trade_schema, time_column="ts", sort_key=["ts", "symbol"])
db.append("trades_qa", trades_w, note="midday QA window")
db.create_table("quotes_qa", quotes.schema, time_column="ts", sort_key=["ts", "symbol"])
db.append("quotes_qa", quotes_w, note="midday QA window + run-up")
print(f"QA window: {len(trades_w):,} trades, {len(quotes_w):,} quotes")

def sign_of(x, prev):
    """+1 uptick, -1 downtick, 0 unchanged - the tick test."""
    return when(x > prev).then(lit(1)).when(x < prev).then(lit(-1)).otherwise(lit(0))


PREV_PX = sql_expr("lag(price)").over(partition_by="symbol", order_by="ts")
TRUE_SIGN = when(col("side") == "B").then(lit(1)).otherwise(lit(-1))

audit = (
    db.table("trades_qa")
    .join_asof(db.table("quotes_qa"), on="ts", by="symbol", tolerance=5_000_000)
    .select("ts", "symbol", "price", "size", "side",
            mid=(col("bid") + col("ask")) / 2)
    .with_columns(prev_px=PREV_PX)
    .select(
        "ts", "symbol", "mid",
        lr_sign=when(col("price") > col("mid")).then(lit(1))
        .when(col("price") < col("mid")).then(lit(-1))
        .when(col("price") > col("prev_px")).then(lit(1))
        .when(col("price") < col("prev_px")).then(lit(-1))
        .otherwise(lit(0)),
        tick_sign=sign_of(col("price"), col("prev_px")),
        true_sign=TRUE_SIGN,
    )
    .sort(["ts", "symbol"])
    .to_pandas()
)
assert len(audit) == len(trades_w), "asof_join must return one row per trade"

# cross-check the ASOF lookup itself against pandas.merge_asof
ref = pd.merge_asof(
    trades_w.to_pandas().sort_values("ts"),
    quotes_w.to_pandas().sort_values("ts").assign(mid_ref=lambda d: (d["bid"] + d["ask"]) / 2),
    on="ts",
    by="symbol",
    tolerance=pd.Timedelta("5s"),
)
chk = audit.sort_values("ts").reset_index(drop=True)
assert np.allclose(chk["mid"], ref["mid_ref"], equal_nan=True), "asof mismatch vs pandas"
print("asof_join validated against pandas.merge_asof")

for rule in ("lr_sign", "tick_sign"):
    scored = audit[audit[rule] != 0]
    acc = (scored[rule] == scored["true_sign"]).mean()
    print(f"  {rule:9s}: {acc:.1%} accuracy vs true aggressor side ({len(scored):,} classified)")
output
QA window: 438 trades, 1,884 quotes
asof_join validated against pandas.merge_asof
  lr_sign  : 98.1% accuracy vs true aggressor side (432 classified)
  tick_sign: 72.4% accuracy vs true aggressor side (377 classified)

On a price-consistent tape, Lee-Ready earns its reputation. The quote rule is nearly perfect, with errors coming from quotes that updated in the 1 to 50 ms between quote and print, while the pure tick test trails well behind. That is the same ordering seen on real TAQ, where Lee-Ready scores around 85%.

3. Sign the full tape#

The audited quote rule would need the trade-quote ASOF at full scale. For this recipe the tick test is accurate enough to carry the OFI study, and it is a pure lag() window over trades with no alignment needed. We keep the generator's true side as an oracle upper bound.

The signed tape is persisted as a first-class table. Signing is expensive enough that you want it done once, committed and versioned.

signed = (
    db.table("trades")
    .with_columns(prev_px=PREV_PX)
    .select(
        "ts", "symbol", "price", "size",
        tick_sign=sign_of(col("price"), col("prev_px")),
        true_sign=TRUE_SIGN,
    )
    .sort(["ts", "symbol"])
    .to_arrow()
)

full = signed.to_pandas()
scored = full[full["tick_sign"] != 0]
print(f"full tape: {(scored['tick_sign'] == scored['true_sign']).mean():.1%} tick-test accuracy on {len(scored):,} prints")
output
full tape: 76.8% tick-test accuracy on 47,957 prints
signed_schema = pa.schema(
    [
        pa.field("ts", pa.timestamp("us", tz="UTC"), nullable=False),
        pa.field("symbol", pa.string()),
        pa.field("price", pa.float64()),
        pa.field("size", pa.int64()),
        pa.field("tick_sign", pa.int32()),
        pa.field("true_sign", pa.int32()),
    ]
)
db.create_table("signed_trades", signed_schema, time_column="ts", sort_key=["ts", "symbol"])
db.append("signed_trades", signed.cast(signed_schema), note="tick-test + oracle signs")
output
{'table': 'signed_trades',
 'sequence': 1,
 'op': 'append',
 'rows_total': 59090,
 'segments_total': 1,
 'segments_added': 1,
 'segments_deduped': 0,
 'committed_at_ns': 1785195744295665157}

4. Trade OFI per minute#

One time_bucket aggregation gives signed buy and sell volume, the normalized imbalance (buys - sells) / total, and the bucket's closing print via the last_value(... ORDER BY ts) idiom.

BAR = time_bucket("1m", col("ts"))
size_when = lambda flag: when(flag).then(col("size")).otherwise(lit(0)).sum()

trade_ofi = (
    db.table("signed_trades")
    .group_by(BAR.alias("bar"), "symbol")
    .agg(
        buy_vol=size_when(col("tick_sign") > 0),
        sell_vol=size_when(col("tick_sign") < 0),
        oracle_net=when(col("true_sign") > 0).then(col("size")).otherwise(-col("size")).sum(),
        volume=col("size").sum(),
        close=col("price").last("ts"),
    )
    .sort(["bar", "symbol"])
    .to_pandas()
)
trade_ofi["ofi"] = (trade_ofi["buy_vol"] - trade_ofi["sell_vol"]) / trade_ofi["volume"]
trade_ofi["ofi_oracle"] = trade_ofi["oracle_net"] / trade_ofi["volume"]
trade_ofi.head(4)
output
bar symbol buy_vol sell_vol oracle_net volume close ofi ofi_oracle
0 2026-06-01 13:30:00+00:00 AAPL 3200 5100 -1000 11800 86.20 -0.161017 -0.084746
1 2026-06-01 13:30:00+00:00 MSFT 9400 6100 2400 17000 219.75 0.194118 0.141176
2 2026-06-01 13:31:00+00:00 AAPL 5800 4300 -2400 13000 86.17 0.115385 -0.184615
3 2026-06-01 13:31:00+00:00 MSFT 9300 10900 -200 22400 219.77 -0.071429 -0.008929

5. Quote OFI (Cont et al.)#

Book-pressure OFI needs changes at the best. A bid price rising, or size growing at an unchanged bid, adds buy pressure. The mirror on the ask side subtracts.

That is four lag() comparisons per quote, pure SQL window machinery, summed per minute, with the bucket's closing mid for returns.

def prev(name: str):
    return sql_expr(f"lag({name})").over(partition_by="symbol", order_by="ts")


# Cont et al.: size added at the bid counts positive, size pulled counts
# negative, mirrored on the ask. Four lagged comparisons per quote.
keep = lambda flag, x: when(flag).then(x).otherwise(lit(0))

quote_ofi = (
    db.table("quotes")
    .with_columns(pb=prev("bid"), pa=prev("ask"), pbs=prev("bid_size"), pas=prev("ask_size"))
    .filter(col("pb").is_not_null())
    .group_by(BAR.alias("bar"), "symbol")
    .agg(
        qofi=(
            keep(col("bid") >= col("pb"), col("bid_size"))
            - keep(col("bid") <= col("pb"), col("pbs"))
            - keep(col("ask") <= col("pa"), col("ask_size"))
            + keep(col("ask") >= col("pa"), col("pas"))
        ).sum(),
        mid_close=((col("bid") + col("ask")) / 2).last("ts"),
    )
    .sort(["bar", "symbol"])
    .to_pandas()
)
quote_ofi.head(4)
output
bar symbol qofi mid_close
0 2026-06-01 13:30:00+00:00 AAPL -11900 86.205
1 2026-06-01 13:30:00+00:00 MSFT 24600 219.745
2 2026-06-01 13:31:00+00:00 AAPL -1700 86.150
3 2026-06-01 13:31:00+00:00 MSFT 27500 219.775

6. Contemporaneous vs predictive#

Here is the question that matters. Does this minute's OFI tell you about this minute's return, which is mechanical, or the next minute's, which would be alpha?

We compute both correlations per symbol, per estimator. Returns are within-session only, with the shift taken inside each symbol and day, so there are no overnight artifacts.

def bucket_corrs(frame, ofi_col, px_col):
    rows = []
    frame = frame.copy()
    frame["day"] = frame["bar"].dt.date
    for (sym, _), g in frame.groupby(["symbol", "day"]):
        g = g.sort_values("bar")
        frame.loc[g.index, "ret"] = g[px_col].pct_change()
        frame.loc[g.index, "ret_next"] = g[px_col].pct_change().shift(-1)
    for sym, g in frame.groupby("symbol"):
        g = g.dropna(subset=["ret", "ret_next"])
        rows.append(
            {
                "symbol": sym,
                "n_buckets": len(g),
                "corr_same_min": g[ofi_col].corr(g["ret"]),
                "corr_next_min": g[ofi_col].corr(g["ret_next"]),
            }
        )
    return pd.DataFrame(rows)

results = pd.concat(
    [
        bucket_corrs(trade_ofi, "ofi", "close").assign(estimator="trade OFI (tick test)"),
        bucket_corrs(trade_ofi, "ofi_oracle", "close").assign(estimator="trade OFI (oracle)"),
        bucket_corrs(quote_ofi, "qofi", "mid_close").assign(estimator="quote OFI (Cont)"),
    ]
)[["estimator", "symbol", "n_buckets", "corr_same_min", "corr_next_min"]]
results.round(3)
output
estimator symbol n_buckets corr_same_min corr_next_min
0 trade OFI (tick test) AAPL 776 0.503 0.007
1 trade OFI (tick test) MSFT 776 0.453 -0.109
0 trade OFI (oracle) AAPL 776 0.077 -0.055
1 trade OFI (oracle) MSFT 776 0.068 -0.070
0 quote OFI (Cont) AAPL 776 0.638 -0.036
1 quote OFI (Cont) MSFT 776 0.494 -0.007
import matplotlib.pyplot as plt

sym = "AAPL"
g = trade_ofi[trade_ofi["symbol"] == sym].copy()
g["day"] = g["bar"].dt.date
g["ret"] = g.groupby("day")["close"].pct_change()
g = g.dropna(subset=["ret"])

fig, ax = plt.subplots(figsize=(7.5, 5))
ax.scatter(g["ofi"], g["ret"] * 1e4, s=8, alpha=0.35, color="tab:blue")
b, a = np.polyfit(g["ofi"], g["ret"] * 1e4, 1)
xs = np.linspace(-1, 1, 50)
ax.plot(xs, a + b * xs, color="tab:red", lw=1.5, label=f"OLS slope = {b:.1f} bps per unit OFI")
ax.set_title(f"{sym}: 1-minute trade OFI vs same-minute return")
ax.set_xlabel("OFI = (buy vol - sell vol) / total vol")
ax.set_ylabel("1-minute return (bps)")
ax.legend()
fig.tight_layout()
output
output figure

Three honest lessons in one table.

On real tick data quote OFI does carry short-horizon predictive power. The reality check still applies before calling it alpha: the effect lives at seconds-to-minutes horizons in size-constrained depth, and capturing a 1 bp move costs you the spread plus fees. OFI signals are best understood as execution-layer inputs, deciding when to cross and when to post, not as standalone strategies.

Takeaways#

db.close()