📝

Draft article

This draft is visible to admins and superusers only. Sign in with an authorized account.

← Voltar aos artigos
August 17, 2026
5 min read

Order Flow Imbalance: The Cont-Kukanov-Stoikov Event Decomposition

#microstructure
#order-flow
#imbalance
#prediction
#HFT

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 4L4L 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 PtbP^b_t, best ask price PtaP^a_t, and their sizes QtbQ^b_t and QtaQ^a_t. Between consecutive observations at tn1t_{n-1} and tnt_n:

OFIn=enBenSOFI_n = e_n^B - e_n^S

where the buy-side and sell-side event contributions are:

enB=1{Pnb>Pn1b}Qnb1{Pnb<Pn1b}Qn1b+1{Pnb=Pn1b}(QnbQn1b)e_n^B = \mathbb{1}_{\{P^b_n > P^b_{n-1}\}} Q^b_n - \mathbb{1}_{\{P^b_n < P^b_{n-1}\}} Q^b_{n-1} + \mathbb{1}_{\{P^b_n = P^b_{n-1}\}} (Q^b_n - Q^b_{n-1}) enS=1{Pna<Pn1a}Qna1{Pna>Pn1a}Qn1a+1{Pna=Pn1a}(QnaQn1a)e_n^S = \mathbb{1}_{\{P^a_n < P^a_{n-1}\}} Q^a_n - \mathbb{1}_{\{P^a_n > P^a_{n-1}\}} Q^a_{n-1} + \mathbb{1}_{\{P^a_n = P^a_{n-1}\}} (Q^a_n - Q^a_{n-1})

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 [T1,T2][T_1, T_2] containing NN book updates:

OFI[T1,T2]=n=1NOFIn=n=1N(enBenS)OFI_{[T_1,T_2]} = \sum_{n=1}^{N} OFI_n = \sum_{n=1}^{N} (e_n^B - e_n^S)

The Linear Price Impact Model

ΔP[T1,T2]=α+βOFI[T1,T2]+ε\Delta P_{[T_1,T_2]} = \alpha + \beta \cdot OFI_{[T_1,T_2]} + \varepsilon

where ΔP\Delta P is the change in mid-price, β\beta is the price impact coefficient, and ε\varepsilon is residual noise. On US equities over 10-second to 1-minute intervals, CKS report contemporaneous R2R^2 of 50-65% per stock. Again: same-interval, not forward-looking.

Cross-Sectional Scaling

CKS also showed the impact coefficient scales inversely with depth:

β1Dˉ\beta \propto \frac{1}{\bar{D}}

where Dˉ\bar{D} 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 LL levels.

Definition

MLOFIn=(OFIn(1)OFIn(2)OFIn(L))\mathbf{MLOFI}_n = \begin{pmatrix} OFI_n^{(1)} \\ OFI_n^{(2)} \\ \vdots \\ OFI_n^{(L)} \end{pmatrix}

where OFIn()OFI_n^{(\ell)} applies the identical three-branch formula to the \ell-th bid/ask pair.

Multi-Level Price Impact

ΔP=α+βTMLOFI+ε=α+=1LβOFI()+ε\Delta P = \alpha + \boldsymbol{\beta}^T \cdot \mathbf{MLOFI} + \varepsilon = \alpha + \sum_{\ell=1}^{L} \beta_\ell \cdot OFI^{(\ell)} + \varepsilon

The published finding, on Nasdaq equities, is that each additional level adds out-of-sample R2R^2 — roughly 10-15 percentage points going from one level to five, with marginal gains still present at ten. The coefficients decay monotonically, β1>β2>>βL|\beta_1| > |\beta_2| > \cdots > |\beta_L|: 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:

OFIPC1=wTMLOFIOFI^{PC1} = \mathbf{w}^T \cdot \mathbf{MLOFI}

with w\mathbf{w} 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 PtP_t to the prevailing midpoint Mt=Ptask+Ptbid2M_t = \frac{P^{ask}_t + P^{bid}_t}{2}:

side={+1 (buy)if Pt>Mt1 (sell)if Pt<Mt\text{side} = \begin{cases} +1 \text{ (buy)} & \text{if } P_t > M_t \\ -1 \text{ (sell)} & \text{if } P_t < M_t \end{cases}

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:

Vtbuy=VtΦ(ΔPtσΔP)V^{buy}_t = V_t \cdot \Phi\left(\frac{\Delta P_t}{\sigma_{\Delta P}}\right)

with Φ\Phi the standard normal CDF and σΔP\sigma_{\Delta P} 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 Mt+g(I,S)M_t + g(I,S) estimator built precisely because the naive weighted mid is biased.

Python Implementation

A direct implementation of the CKS decomposition, generalized to LL 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 kk's mlofi with window k+1k+1'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: Pfair=Pmid+γflowOFIzγinvqP^{fair} = P^{mid} + \gamma_{flow} \cdot OFI_{z} - \gamma_{inv} \cdot q. 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 OFIz|OFI_z| 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 pp, not a separate urgency controller. The post-versus-cross decision is explicit break-even arithmetic — p=δ/(Π+δ)p^* = \delta/(\Pi + \delta) with Π=s+ftfm\Pi = s + f_t - f_m — 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 β\beta 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

  1. 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.

  2. 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.

  3. Multi-level OFI adds levels and PCA collapses them. The published incremental-R2R^2 and 89%-variance results are from Nasdaq equities. Whether depth still helps on a crypto book is untested here.

  4. OFI subsumes trade imbalance. Entered jointly, TI goes insignificant — the cancellations OFI sees and TI cannot are what make the difference.

  5. The model is blind to intent. Equal weighting by size is exactly what makes it spoofable.

  6. 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.
blog.disclaimer

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

Fique à frente do mercado

Assine nossa newsletter para insights exclusivos sobre trading com IA, análises de mercado e atualizações da plataforma.

Respeitamos sua privacidade. Cancele a inscrição a qualquer momento.