📝

Draft article

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

← 返回文章列表
August 23, 2026
5 分鐘閱讀

Trade Classification When You Have No Side Flag: Tick, Quote, Lee-Ready, BVC

#microstructure
#trade-classification
#Lee-Ready
#order-flow
#deep-learning

Start by dismissing the version of this problem that most microstructure textbooks open with. On the venues this blog actually trades, trade direction is not something you infer — it is a field. Binance aggTrades ships isBuyerMaker; Bybit's trade stream ships side. The bar construction article takes the aggressor side straight off that flag and never runs a classifier:

def on_trade(self, timestamp: int, price: float, qty: float, is_buyer_maker: bool):
    side = -1 if is_buyer_maker else 1

That is ground truth, for free, at nanosecond resolution. If you are building signed order flow from a modern crypto CEX feed, you do not need this article.

You need it in four situations, and they are common enough to matter:

  1. You only have bars. Historical OHLCV is what most vendors sell and most backtests run on; the trade stream was never archived or costs more than the research is worth. That is the BVC problem below.
  2. The venue does not flag the side. Smaller CEXs, equity TAQ-style datasets, and nearly all pre-2017 archives give you price and size only.
  3. DEX and on-chain flow. A swap event says which token moved which way, but attributing aggression across a routed multi-hop trade or an aggregator has no flag to fall back on.
  4. You are replicating published research. Kyle's lambda estimates, VPIN, spread decompositions — almost the entire microstructure literature is built on Lee-Ready-classified data, so comparability means running their classifier rather than reading the true side.

What follows is the rule set, the failure mode that matters most, the bulk-volume shortcut, and a protocol for measuring all of it — because crypto hands you the labels the equities literature never had.

The tick test

Compare the current trade price PtP_t to the previous one:

dttick={+1if Pt>Pt11if Pt<Pt1dt1tickif Pt=Pt1d_t^{\text{tick}} = \begin{cases} +1 & \text{if } P_t > P_{t-1} \\ -1 & \text{if } P_t < P_{t-1} \\ d_{t-1}^{\text{tick}} & \text{if } P_t = P_{t-1} \end{cases}

The intuition: buyer-initiated trades lift the ask and push price up; seller-initiated trades hit the bid and push it down. Zero ticks carry the previous sign forward, which is where most of the error lives — on a liquid instrument with a one-tick spread, the majority of consecutive trades print at the same price.

sign = np.sign(np.diff(prices, prepend=prices[0]))
sign[sign == 0] = np.nan
directions = pd.Series(sign).ffill().fillna(1).astype(int).values

A production implementation with the same carry-forward semantics already ships as _tick_sign() inside the tick- and volume-imbalance bar generators in beyond time bars — reuse that rather than maintaining a second copy.

The quote rule

Instead of comparing consecutive trades, compare the trade price to the prevailing midpoint Mt=(At+Bt)/2M_t = (A_t + B_t)/2:

dtquote={+1if Pt>Mt1if Pt<Mtundefinedif Pt=Mtd_t^{\text{quote}} = \begin{cases} +1 & \text{if } P_t > M_t \\ -1 & \text{if } P_t < M_t \\ \text{undefined} & \text{if } P_t = M_t \end{cases}
def quote_rule(trade_prices: np.ndarray,
               bid_prices: np.ndarray,
               ask_prices: np.ndarray) -> np.ndarray:
    """Classify trades using the quote rule.

    Returns +1 (buy), -1 (sell), or 0 (unclassified) for midpoint trades.
    """
    midpoints = (bid_prices + ask_prices) / 2.0
    directions = np.zeros(len(trade_prices), dtype=int)
    directions[trade_prices > midpoints] = 1
    directions[trade_prices < midpoints] = -1
    return directions

The quote rule uses strictly more information than the tick test, and it fails in a different place: it says nothing about trades that print exactly at the mid. On a narrow-spread, tick-constrained instrument that is not an edge case — it is a large share of the tape. The rule needs a fallback, which is what Lee-Ready is.

Lee-Ready: the hybrid, and its stale-quote assumption

Lee and Ready (1991) combined the two: apply the quote rule, and for trades at the midpoint fall back to the tick test.

dtLR={dtquoteif PtMtdttickif Pt=Mtd_t^{\text{LR}} = \begin{cases} d_t^{\text{quote}} & \text{if } P_t \neq M_t \\ d_t^{\text{tick}} & \text{if } P_t = M_t \end{cases}
def lee_ready(trade_prices: np.ndarray,
              bid_prices: np.ndarray,
              ask_prices: np.ndarray,
              quote_lag: int = 0) -> np.ndarray:
    """Lee-Ready classification.

    quote_lag : number of quote observations to lag (0 = contemporaneous).
                Set > 0 only if your quote feed is known to trail the trade
                feed; see the discussion of the 5-second rule below.

    Depends on tick_test() with carry-forward semantics — the `_tick_sign()`
    implementation from /blog/beyond-time-bars-candle-construction.
    """
    if quote_lag > 0:
        bid = np.concatenate([bid_prices[:quote_lag], bid_prices[:-quote_lag]])
        ask = np.concatenate([ask_prices[:quote_lag], ask_prices[:-quote_lag]])
    else:
        bid, ask = bid_prices, ask_prices

    directions = quote_rule(trade_prices, bid, ask)

    unclassified = directions == 0
    if unclassified.any():
        directions[unclassified] = tick_test(trade_prices)[unclassified]

    return directions

The more interesting half of the paper is the part usually reproduced without its justification. Lee and Ready did not merely propose a hybrid; they proposed comparing each trade against the quote prevailing five seconds earlier. That number is not a modelling choice about markets. It is a correction for a reporting artifact: on the 1980s NYSE consolidated tape, trades and quotes were disseminated through different paths with different delays, and quotes tended to arrive ahead of the trades they belonged to. Comparing a trade to the contemporaneous printed quote therefore compared it to a quote that already reflected it — reversing the sign on exactly the trades that moved the market.

Two things follow for anyone porting this to crypto.

The 5-second rule is obsolete, and applying it is actively harmful. It corrects a dissemination lag that no longer exists. Equity markets closed the gap by the mid-1990s; a crypto venue's trade and book-delta streams share a sequence space and are stamped by the same matching engine at microsecond resolution. Five seconds on BTCUSDT is not a synchronization tolerance, it is hundreds of book updates. Lagging quotes by five seconds does not fix stale quotes, it manufactures them. Use quote_lag=0 unless you have measured a real offset in your own capture.

But the underlying problem survives in a new form. Quote staleness is now your pipeline's fault, not the exchange's. Reconstruct the book from a WebSocket delta stream, join trades by wall-clock timestamp, and you have introduced a lag whose sign and size depend on your buffering, snapshot resync policy, and clock. Measure it before trusting any classifier that reads the book: replay a window, join trades to book state, and check what fraction fall outside the joined [bid, ask]. A trade printing above the joined ask proves the book you matched it against was stale.

The fix is different from Lee and Ready's; the failure mode is identical.

Bulk Volume Classification

Easley, Lopez de Prado and O'Hara (2012) attack a different problem: what if you never see individual trades? BVC classifies aggregate volume within a bar, splitting it with the standard normal CDF of the standardized price change:

Vtbuy=VtΦ(ΔPtσΔP),Vtsell=VtVtbuyV_t^{\text{buy}} = V_t \cdot \Phi\left(\frac{\Delta P_t}{\sigma_{\Delta P}}\right), \qquad V_t^{\text{sell}} = V_t - V_t^{\text{buy}}

where ΔPt=PtclosePtopen\Delta P_t = P_t^{\text{close}} - P_t^{\text{open}} and σΔP\sigma_{\Delta P} is the standard deviation of bar price changes.

from scipy.stats import norm

def bulk_volume_classification(open_prices: np.ndarray,
                               close_prices: np.ndarray,
                               volumes: np.ndarray,
                               sigma: float | None = None) -> tuple:
    """Split bar volume into buy/sell using the BVC rule.

    Returns (buy_volume, sell_volume) as continuous, not integer, splits.
    """
    delta_p = close_prices - open_prices

    if sigma is None:
        sigma = max(float(np.std(delta_p)), 1e-8)

    buy_fraction = norm.cdf(delta_p / sigma)
    return volumes * buy_fraction, volumes * (1.0 - buy_fraction)

BVC is a different tool, not a worse one, and three properties say why. It does not classify trades — it produces a fraction, so a bar closing unchanged gets a 50/50 split rather than a coin flip on every trade in it; if your consumer needs a per-trade sign, BVC cannot supply one. It runs on bars rather than tape, which is the entire point: it is the only method here that works when trade-level data does not exist. And σ\sigma is a free parameter that moves the results — a full-sample estimate is lookahead, while a short rolling one makes Φ\Phi saturate and pushes every bar toward 0/100. That choice deserves a sensitivity sweep, not a default.

The original paper reports BVC tracking trade-level classification closely enough for VPIN estimation. Specific accuracy figures depend heavily on bar size, instrument, and the σ\sigma estimator, and the numbers quoted in secondary sources rarely name all three. Do not carry one over from an equities study — measure it.

The one classification-specific feature

If you train a model to do this rather than running a rule, most of the feature set is already published: the spread, depth imbalance, trade-flow imbalance, and rolling realized volatility are tabulated and implemented in spread modeling with machine learning, and the multi-level order book features in DeepLOB. Use those.

The feature that is specific to this problem, and that does not appear in either, is the trade's position inside the spread:

relative_pricet=PtBtAtBt\text{relative\_price}_t = \frac{P_t - B_t}{A_t - B_t}

A trade at the bid maps to 0, at the ask to 1, at the mid to 0.5. This is the continuous generalization of the quote rule: rather than thresholding at the midpoint, it hands the model the distance, so the model can learn that a trade at 0.95 is near-certainly a buy while one at 0.55 is barely informative. Values outside [0,1][0, 1] are the stale-quote diagnostic from the previous section, and are worth logging rather than clipping.

For a boosted baseline over these features, the pipeline is the one already published in spread modeling with machine learning with the objective swapped to binary — including, importantly, its purged and embargoed walk-forward CV. Do not evaluate a microstructure classifier with a plain TimeSeriesSplit; that article and walk-forward optimization both explain why the resulting number is optimistic. For sequence models over book snapshots, DeepLOB is the reference architecture and the training-considerations section there applies unchanged.

Measuring this properly: crypto gives you the labels

Every accuracy figure in the equities trade-classification literature is estimated against a proxy for truth — TORQ, audit-trail subsamples, a single venue's internal records — because the true aggressor side was never published. Crypto publishes it. isBuyerMaker on Binance aggTrades is the label, for every trade, at no cost. The experiment is therefore available to anyone, and I have not found it published for crypto. The protocol:

Data. One month of BTCUSDT aggTrades plus the book ticker stream, joined so each trade carries the bid and ask prevailing immediately before it. Repeat on a thin alt, since the whole question is what happens when the spread is wide.

Label. direction = -1 if is_buyer_maker else +1. Then discard the flag and run each method on price and quote data alone.

Methods. Tick test, quote rule, Lee-Ready at quote_lag=0, Lee-Ready at the historical 5-second lag (to quantify the damage the obsolete rule does), and BVC at several bar sizes against the true per-bar buy-volume fraction.

Breakdowns. Overall accuracy is the least interesting number. Break out by spread regime (one-tick versus wide — the tick test's carry-forward should collapse where the spread is pinned), by whether the trade printed at the mid (isolating where the quote rule abstains and Lee-Ready's fallback does all the work), by trade size (aggTrades entries are sweeps; large trades drive impact and cost the most when signed wrong), and by volatility regime (BVC's σ\sigma is estimated, so its error is regime-dependent by construction).

Method Overall At mid Tight spread Wide spread Large trades
Tick test
Quote rule
Lee-Ready (lag 0)
Lee-Ready (5s lag)
BVC (per-bar fraction) n/a n/a

Accuracy is not the objective

Borrowing the framing discipline from DeepLOB: a classification accuracy is not a result, it is an input to one. Nobody trades the sign of a trade — they trade something computed from a whole stream of signs, and errors do not propagate neutrally into it. A classifier wrong at random on 10% of trades and one systematically wrong on the 10% that are large and price-moving produce very different signed order flow at identical accuracy. The tick test's carry-forward failure is specifically correlated with flat price — the regime where signed flow is supposed to be informative. That is why the breakdowns above, not the headline number, are the real output.

With ground-truth labels the downstream question is directly measurable: estimate the quantity you care about from true signs, re-estimate from each classifier's signs, report the gap.

  • Price impact. Regress mid-price changes on signed net taker flow over fixed windows — the estimator is already specified in slippage cost models and its working implementation on public Binance data is in Almgren-Chriss optimal execution. Do not redefine it here; run it twice and compare the fitted coefficient.
  • Order flow imbalance as a predictor. Compute the correlation between OFI and forward returns under true signs and under classified signs. The degradation in that correlation is the cost of misclassification, denominated in the only unit that matters.
  • Toxicity metrics. VPIN and the trade-flow imbalance features are consumers of classified volume; both are covered in spread modeling with machine learning. Their sensitivity to classification error is measurable the same way.

Where this leaves you

If your venue ships an aggressor flag, use it. That is most of crypto, most of the time.

If it does not, Lee-Ready with contemporaneous quotes is the right default — not because it is accurate but because it is the standard, and comparability with three decades of published estimates is worth more than a few points of accuracy you cannot verify. The five-second rule is a fossil of 1980s tape latency; reproducing it on modern data injects the very staleness it was built to remove. If you only have bars, BVC is the sole option that works at all, and it answers a different question: a volume split, not a trade sign.

In every case, measure against the labels crypto gives away for free, break the result out by spread regime and trade size, and report what the error costs downstream rather than what percentage was right.

免責宣告:本文提供的資訊僅用於教育和參考目的,不構成財務、投資或交易建議。加密貨幣交易涉及重大損失風險。

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 交易見解、市場分析和平台更新。

我們尊重您的隱私。您可以隨時退訂。