Order Flow Imbalance: The Cont-Kukanov-Stoikov Event Decomposition
Most book-derived signals on this blog are snapshots: how much volume is resting on each side right now. Order flow imbalance is a different object. It is a decomposition of the stream of updates between two snapshots into a single signed quantity — one that counts a cancellation on the bid the same way it counts a market sell.
That decomposition is the subject of this article. Specifically: the Cont-Kukanov-Stoikov (2014) event indicators that define it, the Xu-Gould-Howison (2019) multi-level extension and its principal-component reduction, and the Lee-Ready and Bulk Volume Classification rules you need when your feed does not label trade direction.
One framing note up front, because it governs how you should read every number below. The famous 50-65% R-squared from the CKS paper is a contemporaneous regression: price change over an interval regressed on order flow over the same interval. It is a decomposition of price moves, not a forecast of them. Comparing it to the 1-2% of variance a daily equity return model explains is apples to oranges, and this blog has argued at length — in DeepLOB and the prediction-vs-profit gap and in the honest negative result — that conflating the two is how tutorials become traps. A strictly lagged OFI regression is a legitimate and much smaller number. This article does not report one.
Why Order Flow, Not Trades
A price change in a continuous limit order book happens through exactly four mechanisms: a market buy consumes resting asks; a market sell consumes resting bids; a cancellation on the bid removes support and lets the bid drop; a new limit order on the ask adds resistance. Only the first two are trades. The last two are invisible to any volume-based measure — VWAP, on-balance volume, signed trade flow — and on most venues the order-to-trade ratio exceeds 10:1, so the invisible part is the large part. OFI's entire claim to usefulness is that it prices all four mechanisms on the same scale. (For the book as a data structure and the standard snapshot feature vector, see DeepLOB.)
The Cont-Kukanov-Stoikov OFI Model
The foundational model comes from Cont, Kukanov, and Stoikov's 2014 paper "The Price Impact of Order Book Events" (Journal of Financial Econometrics).
Defining Order Flow Imbalance
Consider the best bid price , best ask price , and their sizes and . Between consecutive observations at and :
where the buy-side and sell-side event contributions are:
In plain language: if the best bid price rises, new buy interest appeared — count its full size as positive. If it drops, buy interest disappeared — subtract the old size. If the price is unchanged, count only the size change. The ask side is the mirror image.
The elegance is that these three indicator branches cover every possible transition of the top of book, so every update maps to exactly one signed number. No trade classification, no side labels, no message-level feed required — two consecutive snapshots suffice.
Aggregation Over Intervals
For an interval containing book updates:
The Linear Price Impact Model
where is the change in mid-price, is the price impact coefficient, and is residual noise. On US equities over 10-second to 1-minute intervals, CKS report contemporaneous of 50-65% per stock. Again: same-interval, not forward-looking.
Cross-Sectional Scaling
CKS also showed the impact coefficient scales inversely with depth:
where is average resting volume at the best bid and ask. The same flow pushes price further in a thin book. This is the one part of the model that transfers across venues without refitting the level, because it predicts a relationship rather than a number — and it is directly testable on crypto pairs of differing depth.
Multi-Level Order Flow Imbalance (MLOFI)
The original model uses only the top of book. Xu, Gould, and Howison (2019) extended it to levels.
Definition
where applies the identical three-branch formula to the -th bid/ask pair.
Multi-Level Price Impact
The published finding, on Nasdaq equities, is that each additional level adds out-of-sample — roughly 10-15 percentage points going from one level to five, with marginal gains still present at ten. The coefficients decay monotonically, : the top of book dominates, but deeper levels carry non-trivial incremental signal.
Whether this survives on a crypto book is the single most interesting open question in this article. Crypto books are thinner and far more churned at depth, and it is entirely plausible that levels 2-5 add nothing once you leave a Nasdaq order book.
Principal Component Reduction
Adjacent-level OFIs are highly correlated (above 0.8 in the published equity results), so a principal component decomposition is natural. The reported first component captures over 89% of total variance and serves as a single aggregate signal:
with the leading eigenvector of the OFI covariance matrix. The practical appeal is that it collapses a collinear regression into one well-conditioned scalar — worth doing even if the variance share on your data comes out lower.
Classifying Trades: Buy vs Sell Initiated
OFI itself needs no trade classification. But if you want to compare it against trade-based measures, or you only have aggregated bars, you need to infer direction.
Quote Rule
Compare the trade price to the prevailing midpoint :
A trade above the midpoint was likely a buyer lifting the offer; below it, a seller hitting the bid.
The Lee-Ready algorithm (1991) is the quote rule with a tick-rule fallback for trades exactly at the midpoint, where the quote rule is indeterminate. The tick rule — sign of the last price change, carrying the previous side forward on a zero tick — is already derived and implemented in Beyond time bars, which ships a working _tick_sign(). Reported Lee-Ready classification accuracy is 72-85% depending on market and period.
Bulk Volume Classification (BVC)
When individual trades cannot be assigned — aggregated bars, most public candle APIs — Easley, Lopez de Prado, and O'Hara's Bulk Volume Classification estimates the buy fraction of a bar's volume from its normalized price change:
with the standard normal CDF and estimated from recent price changes. Less accurate than tick-level classification, but it works on OHLCV.
What OFI Is Not
Three neighbouring quantities get confused with OFI. Each is covered properly elsewhere on this blog; the distinctions are what matter here.
Static order book imbalance (OBI) is the snapshot version — resting bid volume against resting ask volume, no event tracking. See DeepLOB's traditional LOB features for the formula and its multi-level form. The contrast is the whole point of OFI: OBI tells you the state of the book, OFI tells you how it got there.
Trade imbalance (TI) and its volume-weighted variants are signed trade flow, defined and implemented as rolling features in spread modeling with machine learning. One published result about the relationship is load-bearing here: when OFI and TI are entered together as regressors on mid-price changes, TI becomes statistically insignificant. Its content is subsumed. That is the empirical case for tracking book events instead of trades — the events that almost became trades carry the same information as the ones that did.
The volume-weighted mid is a fair-value estimator, not an imbalance measure, and it is covered in DeepLOB — including the naming caveat that it is not Stoikov's micro-price, which is a martingale-adjusted estimator built precisely because the naive weighted mid is biased.
Python Implementation
A direct implementation of the CKS decomposition, generalized to levels.
Core OFI Calculation
import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class OrderBookSnapshot:
timestamp: float
bid_prices: np.ndarray # best bid at index 0, descending
ask_prices: np.ndarray # best ask at index 0, ascending
bid_sizes: np.ndarray
ask_sizes: np.ndarray
@dataclass
class OFICalculator:
"""
Computes Order Flow Imbalance from consecutive order book snapshots.
Supports multi-level OFI (MLOFI) up to `n_levels` deep.
"""
n_levels: int = 5
prev_snapshot: Optional[OrderBookSnapshot] = field(default=None, init=False)
def compute_level_ofi(
self,
prev_price: float, curr_price: float,
prev_size: float, curr_size: float,
side: str
) -> float:
"""Compute single-level OFI contribution for bid or ask side."""
if side == "bid":
if curr_price > prev_price:
return curr_size # new level appeared above
elif curr_price < prev_price:
return -prev_size # old level disappeared
else:
return curr_size - prev_size # same level, size changed
else: # ask side
if curr_price < prev_price:
return curr_size # new level appeared below
elif curr_price > prev_price:
return -prev_size # old level disappeared
else:
return curr_size - prev_size # same level, size changed
def update(self, snapshot: OrderBookSnapshot) -> Optional[np.ndarray]:
"""
Process new snapshot, return MLOFI vector of shape (n_levels,).
Returns None on first call (no previous snapshot to compare).
"""
if self.prev_snapshot is None:
self.prev_snapshot = snapshot
return None
prev = self.prev_snapshot
n = min(self.n_levels, len(snapshot.bid_prices), len(prev.bid_prices))
ofi = np.zeros(n)
for level in range(n):
e_buy = self.compute_level_ofi(
prev.bid_prices[level], snapshot.bid_prices[level],
prev.bid_sizes[level], snapshot.bid_sizes[level],
side="bid"
)
e_sell = self.compute_level_ofi(
prev.ask_prices[level], snapshot.ask_prices[level],
prev.ask_sizes[level], snapshot.ask_sizes[level],
side="ask"
)
ofi[level] = e_buy - e_sell
self.prev_snapshot = snapshot
return ofi
Aggregation Into Windows
@dataclass
class OFIAggregator:
"""
Aggregates raw OFI updates into fixed time windows.
Emits the regression inputs (aggregated MLOFI) and target (delta mid).
"""
window_seconds: float = 10.0
n_levels: int = 5
calculator: OFICalculator = field(init=False)
buffer: list = field(default_factory=list, init=False)
window_start: float = 0.0
def __post_init__(self):
self.calculator = OFICalculator(n_levels=self.n_levels)
def on_snapshot(self, snapshot: OrderBookSnapshot) -> Optional[dict]:
"""
Feed a new order book snapshot.
Returns aggregated window dict when a window completes, else None.
"""
ofi_vec = self.calculator.update(snapshot)
if ofi_vec is None:
self.window_start = snapshot.timestamp
return None
if not self.buffer:
self.window_start = snapshot.timestamp
self.buffer.append({
"timestamp": snapshot.timestamp,
"ofi": ofi_vec.copy(),
"mid": (snapshot.bid_prices[0] + snapshot.ask_prices[0]) / 2,
})
elapsed = snapshot.timestamp - self.window_start
if elapsed >= self.window_seconds:
return self._flush()
return None
def _flush(self) -> dict:
"""Aggregate buffered OFI updates into a single window record."""
ofi_matrix = np.array([b["ofi"] for b in self.buffer])
agg_ofi = ofi_matrix.sum(axis=0) # shape: (n_levels,)
result = {
"window_start": self.window_start,
"window_end": self.buffer[-1]["timestamp"],
"n_updates": len(self.buffer),
"mid_open": self.buffer[0]["mid"],
"mid_close": self.buffer[-1]["mid"],
"delta_mid": self.buffer[-1]["mid"] - self.buffer[0]["mid"],
"ofi_level1": agg_ofi[0],
"ofi_total": agg_ofi.sum(),
"mlofi": agg_ofi,
}
self.buffer.clear()
return result
Note what the aggregator emits: mlofi and delta_mid over the same window. That is the contemporaneous regression. To get a predictive one, pair window 's mlofi with window 's delta_mid and expect a substantially worse fit.
Trade Classification (Lee-Ready)
def classify_trades_lee_ready(
trades: pd.DataFrame,
quotes: pd.DataFrame
) -> pd.DataFrame:
"""
Classify trades as buy (+1) or sell (-1) using Lee-Ready:
quote rule first, tick rule as fallback at the midpoint.
Parameters
----------
trades : DataFrame with columns ['timestamp', 'price', 'size']
quotes : DataFrame with columns ['timestamp', 'bid', 'ask']
Returns
-------
trades with added 'side' column
"""
trades = trades.sort_values("timestamp").copy()
quotes = quotes.sort_values("timestamp")
trades = pd.merge_asof(
trades, quotes,
on="timestamp",
direction="backward"
)
trades["mid"] = (trades["bid"] + trades["ask"]) / 2
trades["side"] = np.where(
trades["price"] > trades["mid"], 1,
np.where(trades["price"] < trades["mid"], -1, 0)
)
trades["price_diff"] = trades["price"].diff()
tick_sign = np.sign(trades["price_diff"])
tick_sign = tick_sign.replace(0, np.nan).ffill().fillna(1)
midpoint_mask = trades["side"] == 0
trades.loc[midpoint_mask, "side"] = tick_sign[midpoint_mask].astype(int)
return trades
Normalizing the Signal
class OFISignal:
"""
Rolling z-score normalization of aggregated OFI.
Deliberately does NOT convert OFI into a predicted return: that
requires a fitted beta, and beta is venue-, pair- and regime-specific.
Fit it on your own data before wiring this into anything.
"""
def __init__(self, window_seconds: float = 10.0, n_levels: int = 5,
lookback: int = 100):
self.aggregator = OFIAggregator(
window_seconds=window_seconds, n_levels=n_levels,
)
self.lookback = lookback
self.ofi_history: list[float] = []
def process(self, snapshot: OrderBookSnapshot) -> Optional[dict]:
agg = self.aggregator.on_snapshot(snapshot)
if agg is None:
return None
ofi = agg["ofi_level1"]
self.ofi_history.append(ofi)
if len(self.ofi_history) > self.lookback:
self.ofi_history.pop(0)
if len(self.ofi_history) >= 20:
arr = np.array(self.ofi_history)
mu, sigma = arr.mean(), arr.std()
zscore = (ofi - mu) / max(sigma, 1e-10)
else:
zscore = 0.0
return {**agg, "ofi_zscore": zscore}
Where OFI Plugs In
Market making. OFI enters as a predictive skew term on the fair value, on top of the inventory skew already derived and coded in the Avellaneda-Stoikov market maker: . The two terms answer different questions — the flow term says where price is going, the inventory term says what you can afford to hold — and the published article covers the second one, including why positive inventory pushes both quotes down. A large is also a reasonable adverse-selection trigger for the defensive responses (widen, shrink, pull one side) catalogued in digital fingerprints of trader identification and anomaly detection.
Execution. OFI is an input to the tactics layer's fill-probability estimate , not a separate urgency controller. The post-versus-cross decision is explicit break-even arithmetic — with — and lives in child order execution tactics, which also argues that the tactics layer should never re-derive urgency from its own view of the market, because that creates two controllers that disagree.
Practical Considerations
Signal half-life and rolling recalibration. This is the OFI-specific part. The predictive content of OFI decays on a timescale of milliseconds to seconds in liquid instruments, which means the aggregation window is not a free parameter — it is a bet on the horizon. And is not a constant: it moves intraday, with depth, and around scheduled events, so any production fit re-estimates it on rolling windows rather than pinning a value from a backtest.
Everything else here is covered elsewhere. Latency budgets and the co-location / FPGA / kernel-bypass ladder: DeepLOB's production section. The U-shaped intraday liquidity pattern and drifting normalization statistics: spread modeling. Per-venue recalibration and why a model fitted on Nasdaq will not transfer to a crypto pair untouched: DeepLOB again, with the fragmentation side in smart order routing.
Manipulation. One consequence is specific to this model and worth stating plainly: the CKS decomposition weights every event by size alone, with no notion of intent or persistence. A spoofer placing and cancelling size therefore injects directly into the signal, at full weight, by construction. Detection heuristics — cancel rates, order lifetimes, wall behaviour as price approaches — are in queue position and order book wall analysis; cross-venue phantom liquidity is in smart order routing.
Key Takeaways
-
OFI is an event decomposition, not a snapshot. The three-branch indicator formula maps every top-of-book transition — price up, price down, size change — to one signed number, from two consecutive snapshots and nothing else.
-
The headline R-squared is contemporaneous. The CKS 50-65% figure decomposes same-interval price moves; it is not a forecast, and it should not be compared against forward-looking return models.
-
Multi-level OFI adds levels and PCA collapses them. The published incremental- and 89%-variance results are from Nasdaq equities. Whether depth still helps on a crypto book is untested here.
-
OFI subsumes trade imbalance. Entered jointly, TI goes insignificant — the cancellations OFI sees and TI cannot are what make the difference.
-
The model is blind to intent. Equal weighting by size is exactly what makes it spoofable.
-
Nothing in this article is measured. Every number quoted is from the equities literature. Before this signal touches capital it needs a fit on your own book data, a lagged specification reported separately, and a check of whether the edge survives fees and the spread.
Further Reading
- Cont, R., Kukanov, A., & Stoikov, S. (2014). "The Price Impact of Order Book Events." Journal of Financial Econometrics, 12(1), 47-88.
- Xu, K., Gould, M., & Howison, S. (2019). "Multi-Level Order-Flow Imbalance in a Limit Order Book." arXiv:1907.06230.
- Kolm, P., Turiel, J., & Westray, N. (2023). "Deep Order Flow Imbalance: Extracting Alpha at Multiple Horizons from the Limit Order Book." Mathematical Finance, 33(4).
- Lee, C., & Ready, M. (1991). "Inferring Trade Direction from Intraday Data." Journal of Finance, 46(2), 733-746.
- Easley, D., Lopez de Prado, M., & O'Hara, M. (2012). "Flow Toxicity and Liquidity in a High-Frequency World." Review of Financial Studies, 25(5), 1457-1493.
Авторы
Инженер торговых систем
Разработка торговых ботов с 2017 года: межбиржевой арбитраж (подключал до 30 бирж), парный арбитраж на коинтеграции между спотом и фьючерсами, скальпинг, фронтраннинг, торговля по новостям, сентиментный анализ, трендовые алгоритмы, а также алгоритмы управления и балансировки портфелей. Делает выставление ордеров до 1 мс, warehouse для big data, бэктестинг-движки, AI-агентов и интерфейсы для ботов (в т.ч. open-source profitmaker.cc). Стек: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, архитектура.