← 記事一覧に戻る
August 17, 2026
読了時間: 5分

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

Order Flow Imbalance: The Cont-Kukanov-Stoikov Event Decomposition
#microstructure
#order-flow
#imbalance
#prediction
#HFT

このブログで扱う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 gaphonest negative resultで、両者の混同がtutorialを罠に変えると説明してきました。厳密にlaggedなOFI regressionは正当ですが、数値ははるかに小さくなります。本稿ではそれを報告しません。

TradesではなくOrder Flowを使う理由

四つのorder-book mechanismが一つの符号付きflow signalに集約される

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として扱う方法と標準の4L4L snapshot feature vectorはDeepLOBを参照してください。)

Cont-Kukanov-Stoikov OFI model

CKS event decompositionにおけるbidとaskの遷移

基礎となるmodelは、Cont、Kukanov、Stoikovによる2014年論文"The Price Impact of Order Book Events"(Journal of Financial Econometrics)に由来します。

Order Flow Imbalanceの定義

最良bid price PtbP^b_t、最良ask price PtaP^a_t、それぞれのsize QtbQ^b_tQtaQ^a_tを考えます。tn1t_{n-1}tnt_nの連続するobservationの間では:

OFIn=enBenSOFI_n = e_n^B - e_n^S

buy-sideとsell-sideのevent contributionは次のとおりです:

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})

平易に言えば、最良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

[T1,T2][T_1, T_2] intervalに含まれるNN個のbook updateについて:

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)

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

ここでΔP\Delta Pはmid-priceの変化、β\betaはprice impact coefficient、ε\varepsilonはresidual noiseです。US equityで10秒から1分のintervalについて、CKSはstockごとに同時点R2R^2 50-65%を報告しました。繰り返しますが、同じintervalであり、forward-lookingではありません。

Cross-sectional scaling

CKSはimpact coefficientがdepthに反比例してscaleすることも示しました:

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

ここでDˉ\bar{D}は最良bidとaskで平均したresting volumeです。薄いbookでは同じflowがpriceをより大きく動かします。これはlevelをfitし直さずvenueをまたいで移せるmodelの唯一の部分です。数値ではなくrelationshipを予測するからであり、異なるdepthのcrypto pairで直接testできます。

Multi-Level Order Flow Imbalance (MLOFI)

multi-level order-book depthがprincipal signalに圧縮される

元のmodelはbook topだけを使います。Xu、Gould、Howison (2019)はこれをLL levelに拡張しました。

定義

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

ここでOFIn()OFI_n^{(\ell)}は、\ell番目のbid/ask pairに同じ三分岐formulaを適用したものです。

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

Nasdaq equityでのpublished findingは、追加levelごとにout-of-sample R2R^2が増えるというものです。1 levelから5 levelへ増やすとおよそ10-15 percentage point増え、10 levelでもmarginal gainが残ります。coefficientは単調に減衰し、β1>β2>>βL|\beta_1| > |\beta_2| > \cdots > |\beta_L|です。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になります:

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

ここでw\mathbf{w}はOFI covariance matrixのleading eigenvectorです。実務上の利点はcollinear regressionをconditionの良い一つのscalarへ縮約できることです。手元dataでvariance shareが低くても実施する価値があります。

Tradeを分類する: Buy initiatedかSell initiatedか

trade eventがbuy initiationとsell initiationのstreamに分離される

OFI自体にはtrade classificationは不要です。しかしtrade-based measureと比較したい場合、またはaggregated barしかない場合はdirectionを推定する必要があります。

Quote rule

trade price PtP_tを、その時点の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}

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を推定します:

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

Φ\Phiはstandard normal CDF、σΔP\sigma_{\Delta P}は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 Mt+g(I,S)M_t + g(I,S) estimatorで、naive weighted midがbiasedだからこそ作られたものです。

Python実装

order-book event streamがflow calculation engineを通過する

LL 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が出力するものに注意してください。mlofidelta_mid同じwindowの値です。これは同時点regressionです。predictiveなものを得るには、window kkmlofiとwindow k+1k+1delta_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を組み込む場所

order-flow signalがmodular trading pipelineに接続する

Market making. OFIはfair valueのpredictive skew termとして、Avellaneda-Stoikov market makerで導出・実装されたinventory skewの上に入ります: Pfair=Pmid+γflowOFIzγinvqP^{fair} = P^{mid} + \gamma_{flow} \cdot OFI_{z} - \gamma_{inv} \cdot q. 二つのtermは異なる問いに答えます。flow termはpriceが向かう方向、inventory termは保有できる量を示します。published articleは後者を、positive inventoryが両quoteを下げる理由も含めて説明しています。大きなOFIz|OFI_z|は、digital fingerprints of trader identificationanomaly detectionに記載されたdefensive response(widen、shrink、片側をpull)のadverse-selection triggerとしても妥当です。

Execution. OFIはtactics layerのfill-probability estimate ppへのinputであり、別のurgency controllerではありません。post-versus-cross decisionは明示的なbreak-even arithmeticです — p=δ/(Π+δ)p^* = \delta/(\Pi + \delta)Π=s+ftfm\Pi = s + f_t - f_mchild order execution tacticsで扱われます。そこでは、tactics layerがmarketの独自viewからurgencyを再導出してはならないとも説明しています。そうすると意見の異なる二つのcontrollerが生まれるからです。

実務上の考慮事項

noisy microstructure terrainを進むresilient market signal

Signal half-lifeとrolling recalibration. これはOFI固有の部分です。Liquid instrumentではOFIのpredictive contentはmillisecondsからsecondsのtime scaleで減衰します。つまりaggregation windowはfree parameterではなく、horizonへのbetです。さらにβ\betaは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にあります。

要点

主要なorder-flow insightが一つの明確なsignalに集約される

  1. OFIはsnapshotではなくevent decompositionです。 三分岐indicator formulaは、連続する二つのsnapshotだけから、book topのすべてのtransition — price up、price down、size change — を一つの符号付き数へ写像します。

  2. 見出しのR-squaredは同時点の値です。 CKSの50-65%は同じintervalのprice moveを分解したものです。forecastではなく、forward-looking return modelと比較すべきではありません。

  3. Multi-level OFIはlevelを加え、PCAはそれを縮約します。 published incremental-R2R^2と89%-varianceのresultはNasdaq equityから来ています。Crypto bookでdepthがなお有効かは、ここでは未検証です。

  4. OFIはtrade imbalanceを包含します。 同時にregressorへ入れるとTIはinsignificantになります。OFIが捉え、TIが捉えられないcancelこそが差を生みます。

  5. このmodelはintentに盲目です。 sizeによるequal weightingが、まさにspoof可能にする理由です。

  6. 本稿の数値はどれも測定されていません。 引用したすべての数値はequity literatureからのものです。このsignalをcapitalに接続する前に、自分のbook dataでfitし、lagged specificationを別に報告し、feeとspreadを差し引いてもedgeが残るか確認する必要があります。

さらに読む

OFI conceptを囲むmicrostructure research pathの接続

  • 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

市場の先を行く

ニュースレターを購読して、独占的なAI取引の洞察、市場分析、プラットフォームの更新情報を受け取りましょう。

プライバシーを尊重します。いつでも配信停止可能です。