Order Flow Imbalance: The Cont-Kukanov-Stoikov Event Decomposition
このブログで扱うbook由来のsignalの多くはsnapshotです。今この瞬間に各sideでどれだけvolumeが待機しているかを表します。Order flow imbalanceは別の対象です。二つのsnapshotの間にあるupdateのstreamを、一つの符号付き量へ分解します。bidでのcancelをmarket sellと同じ規則で数える量です。
この分解が本稿の主題です。具体的には、定義を与えるCont-Kukanov-Stoikov (2014)のevent indicator、Xu-Gould-Howison (2019)のmulti-level拡張とprincipal-component reduction、そしてfeedにtrade directionのlabelがない場合に必要なLee-ReadyとBulk Volume Classificationのruleを扱います。
最初に重要な読み方を一つ。以下の数値を読む際の前提になります。 CKS論文で有名な50-65%のR-squaredは同時点のregressionです。同じintervalのorder flowから、そのintervalのprice changeを回帰しています。これはprice moveの分解であって予測ではありません。daily equity return modelが説明するvarianceの1-2%と比較するのは、りんごとオレンジを比べるようなものです。このブログはDeepLOBとprediction-vs-profit gapやhonest negative resultで、両者の混同がtutorialを罠に変えると説明してきました。厳密にlaggedなOFI regressionは正当ですが、数値ははるかに小さくなります。本稿ではそれを報告しません。
TradesではなくOrder Flowを使う理由

continuous limit order bookでのprice changeは、正確に四つのmechanismで起きます。market buyが待機中のaskを消費する、market sellが待機中のbidを消費する、bidでのcancelがsupportを取り除いてbidを下げる、askへの新しいlimit orderがresistanceを加える、の四つです。tradeなのは最初の二つだけです。後の二つはvolume-based measure — VWAP、on-balance volume、signed trade flow — には見えません。多くのvenueではorder-to-trade ratioが10:1を超えるため、見えない部分の方が大きいのです。OFIの有用性に関する主張は、四つのmechanismを同じscaleでpriceに反映することです。(bookをdata structureとして扱う方法と標準の snapshot feature vectorはDeepLOBを参照してください。)
Cont-Kukanov-Stoikov OFI model

基礎となるmodelは、Cont、Kukanov、Stoikovによる2014年論文"The Price Impact of Order Book Events"(Journal of Financial Econometrics)に由来します。
Order Flow Imbalanceの定義
最良bid price 、最良ask price 、それぞれのsize とを考えます。との連続するobservationの間では:
buy-sideとsell-sideのevent contributionは次のとおりです:
平易に言えば、最良bid priceが上がれば新しいbuy interestが現れたので、その全sizeを正として数えます。下がればbuy interestが消えたので、古いsizeを引きます。priceが変わらなければsizeの変化だけを数えます。ask sideは鏡像です。
この形の良さは、三つのindicator branchがbook topで起こり得るすべてのtransitionを覆い、各updateを正確に一つの符号付き数へ写像することです。trade classificationもside labelもmessage-level feedも不要で、連続する二つのsnapshotだけで足ります。
Intervalごとのaggregation
intervalに含まれる個のbook updateについて:
Linear price impact model
ここではmid-priceの変化、はprice impact coefficient、はresidual noiseです。US equityで10秒から1分のintervalについて、CKSはstockごとに同時点 50-65%を報告しました。繰り返しますが、同じintervalであり、forward-lookingではありません。
Cross-sectional scaling
CKSはimpact coefficientがdepthに反比例してscaleすることも示しました:
ここでは最良bidとaskで平均したresting volumeです。薄いbookでは同じflowがpriceをより大きく動かします。これはlevelをfitし直さずvenueをまたいで移せるmodelの唯一の部分です。数値ではなくrelationshipを予測するからであり、異なるdepthのcrypto pairで直接testできます。
Multi-Level Order Flow Imbalance (MLOFI)

元のmodelはbook topだけを使います。Xu、Gould、Howison (2019)はこれを levelに拡張しました。
定義
ここでは、番目のbid/ask pairに同じ三分岐formulaを適用したものです。
Multi-level price impact
Nasdaq equityでのpublished findingは、追加levelごとにout-of-sample が増えるというものです。1 levelから5 levelへ増やすとおよそ10-15 percentage point増え、10 levelでもmarginal gainが残ります。coefficientは単調に減衰し、です。book topが支配的ですが、深いlevelにも無視できないincremental signalがあります。
これがcrypto bookでも成立するかは、本稿で最も興味深いopen questionです。Crypto bookはより薄く、depthでのchurnもはるかに大きいため、Nasdaq order bookを離れるとlevel 2-5は何も加えない可能性も十分あります。
Principal component reduction
隣接levelのOFIは強く相関しています(published equity resultでは0.8超)。そのためprincipal component decompositionが自然です。報告されたfirst componentはtotal varianceの89%以上を捉え、一つのaggregate signalになります:
ここではOFI covariance matrixのleading eigenvectorです。実務上の利点はcollinear regressionをconditionの良い一つのscalarへ縮約できることです。手元dataでvariance shareが低くても実施する価値があります。
Tradeを分類する: Buy initiatedかSell initiatedか

OFI自体にはtrade classificationは不要です。しかしtrade-based measureと比較したい場合、またはaggregated barしかない場合はdirectionを推定する必要があります。
Quote rule
trade price を、その時点のmidpoint と比較します:
midpointより上のtradeはofferをliftしたbuyer、下ならbidをhitしたsellerである可能性が高いです。
Lee-Ready algorithm (1991)は、midpointと完全に同じpriceのtradeでquote ruleが決まらない場合にtick-ruleへfallbackするquote ruleです。tick rule — last price changeのsignを使い、zero tickでは前のsideを引き継ぐ規則 — はBeyond time barsですでに導出・実装され、動作する_tick_sign()も提供されています。報告されたLee-Ready classification accuracyはmarketとperiodにより72-85%です。
Bulk volume classification (BVC)
個別tradeにsideを割り当てられない場合(aggregated barや、ほとんどのpublic candle API)、Easley、Lopez de Prado、O’HaraのBulk Volume Classificationは、normalized price changeからbar volumeのbuy fractionを推定します:
はstandard normal CDF、はrecent price changeから推定します。tick-level classificationより精度は低いものの、OHLCVで動作します。
OFIではないもの

OFIと混同される近接した量が三つあります。それぞれはブログの別記事で適切に扱っています。ここで重要なのは違いです。
**Static order book imbalance (OBI)**はsnapshot版です。resting bid volumeとresting ask volumeを比較しますが、event trackingはありません。formulaとmulti-level formはDeepLOBのtraditional LOB featuresを参照してください。OFIの要点はここです。OBIはbookのstateを、OFIはそのstateに至った経路を教えます。
**Trade imbalance (TI)**とvolume-weighted variantはsigned trade flowで、machine learningによるspread modelingでrolling featureとして定義・実装されています。ここで重要なpublished resultが一つあります。mid-price changeのregressorとしてOFIとTIを同時に入れると、TIはstatistically insignificantになります。そのcontentは吸収されます。これはtradeではなくbook eventを追跡するempirical caseです。ほぼtradeになったeventが、実際にtradeになったeventと同じinformationを持つからです。
Volume-weighted midはfair-value estimatorでありimbalance measureではありません。DeepLOBで、Stoikovのmicro-priceではないという命名上の注意も含めて説明されています。Stoikovのmicro-priceはmartingale-adjusted estimatorで、naive weighted midがbiasedだからこそ作られたものです。
Python実装

levelに一般化したCKS decompositionの直接的な実装です。
OFIのコア計算
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
WindowへのAggregation
@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
aggregatorが出力するものに注意してください。mlofiとdelta_midは同じwindowの値です。これは同時点regressionです。predictiveなものを得るには、window のmlofiとwindow のdelta_midを組み合わせ、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
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}
OFIを組み込む場所

Market making. OFIはfair valueのpredictive skew termとして、Avellaneda-Stoikov market makerで導出・実装されたinventory skewの上に入ります: . 二つのtermは異なる問いに答えます。flow termはpriceが向かう方向、inventory termは保有できる量を示します。published articleは後者を、positive inventoryが両quoteを下げる理由も含めて説明しています。大きなは、digital fingerprints of trader identificationとanomaly detectionに記載されたdefensive response(widen、shrink、片側をpull)のadverse-selection triggerとしても妥当です。
Execution. OFIはtactics layerのfill-probability estimate へのinputであり、別のurgency controllerではありません。post-versus-cross decisionは明示的なbreak-even arithmeticです — 、 — child order execution tacticsで扱われます。そこでは、tactics layerがmarketの独自viewからurgencyを再導出してはならないとも説明しています。そうすると意見の異なる二つのcontrollerが生まれるからです。
実務上の考慮事項

Signal half-lifeとrolling recalibration. これはOFI固有の部分です。Liquid instrumentではOFIのpredictive contentはmillisecondsからsecondsのtime scaleで減衰します。つまりaggregation windowはfree parameterではなく、horizonへのbetです。さらにはconstantではありません。intraday、depth、scheduled eventの前後で動くため、production fitではbacktestの値を固定せずrolling windowで再推定します。
その他は別記事で扱っています。Latency budgetとco-location / FPGA / kernel-bypass ladderはDeepLOBのproduction section。U字型のintraday liquidity patternとdrifting normalization statisticはspread modeling。per-venue recalibrationと、Nasdaqでfitしたmodelが手を加えずcrypto pairへ移らない理由は、再びDeepLOBで扱い、fragmentation側はsmart order routingにあります。
Manipulation. このmodelに固有で明言する価値のある帰結があります。CKS decompositionはintentやpersistenceを考慮せず、すべてのeventをsizeだけでweightします。したがってspooferがsizeを置いてcancelすると、構造上full weightでsignalに直接注入されます。Detection heuristic — cancel rate、order lifetime、price接近時のwall behaviour — はqueue position and order book wall analysisに、cross-venueのphantom liquidityはsmart order routingにあります。
要点

-
OFIはsnapshotではなくevent decompositionです。 三分岐indicator formulaは、連続する二つのsnapshotだけから、book topのすべてのtransition — price up、price down、size change — を一つの符号付き数へ写像します。
-
見出しのR-squaredは同時点の値です。 CKSの50-65%は同じintervalのprice moveを分解したものです。forecastではなく、forward-looking return modelと比較すべきではありません。
-
Multi-level OFIはlevelを加え、PCAはそれを縮約します。 published incremental-と89%-varianceのresultはNasdaq equityから来ています。Crypto bookでdepthがなお有効かは、ここでは未検証です。
-
OFIはtrade imbalanceを包含します。 同時にregressorへ入れるとTIはinsignificantになります。OFIが捉え、TIが捉えられないcancelこそが差を生みます。
-
このmodelはintentに盲目です。 sizeによるequal weightingが、まさにspoof可能にする理由です。
-
本稿の数値はどれも測定されていません。 引用したすべての数値はequity literatureからのものです。このsignalをcapitalに接続する前に、自分のbook dataでfitし、lagged specificationを別に報告し、feeとspreadを差し引いてもedgeが残るか確認する必要があります。
さらに読む

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