The Fundamental Problem

A trade is the intersection of a buyer and a seller. One party initiated the transaction. One party crossed the spread. But raw exchange output does not label which side was the aggressor.

You receive a sequence of records: timestamp, price, volume. No field tells you whether the buyer lifted the ask or the seller hit the bid. This absence is not a data quality failure. It reflects the architecture of most exchanges: trade reports are position-neutral by design.

For statistical arbitrage, order flow toxicity metrics, and microstructure alpha strategies, the missing direction field is the difference between a signal and noise.

This article examines two established methods for recovering direction from directionless data: quote matching (also called the tick rule) and the Lee-Ready algorithm. Both have roots in academic market microstructure research and both remain in active production use at systematic trading firms. We will cover the theoretical foundation, the practical implementation, and the code to apply these methods to real tick data streams.


Why Trade Direction Matters

Before the methodology, a brief justification for the effort.

Modern systematic strategies derive edge not only from price prediction but from understanding who is trading. A sequence of buyer-initiated trades in thin books may indicate informed flow. Sustained selling into a liquidity vacuum signals capitulation. The ratio of buys to sells over rolling windows forms the basis of dozens of quantitative signals:

  • Order Flow Imbalance (OFI): Net buying pressure, computed as the signed volume of buyer-initiated trades.
  • VPIN (Volume-Synchronized Probability of Informed Trading): A toxicity metric that spikes before sudden price dislocations.
  • Quote Update Imbalance: Measures how often the bid or ask is being hit, providing a complementary view to the trade-signed approach.

Without accurate trade direction, these signals degrade to noise. The Lee-Ready algorithm and its variants represent the current standard for reconstructing direction from the raw trade tape.


Method 1: Quote Matching (Tick Rule)

The Core Idea

The simplest inference method exploits the relationship between trades and the prevailing quote. If a trade occurs at the ask price or above, it is buyer-initiated. If it occurs at the bid price or below, it is seller-initiated. This is the quote matching rule.

if trade_price >= best_ask:  direction = "BUY"
if trade_price <= best_bid:   direction = "SELL"

This rule is clean, deterministic, and computationally trivial. Its weakness is the boundary case: trades that occur between the bid and ask (inside the spread) have no definitive direction. These are called midpoint trades or zero-diff trades.

Handling Midpoint Trades

Academic literature estimates that 15–30% of trades in liquid stocks occur at the midpoint, depending on the venue and time period. For these trades, the tick rule provides a heuristic:

  • If the current trade occurs at a price higher than the previous trade, classify it as a buy.
  • If the price is lower than the previous trade, classify it as a sell.
  • If the price is unchanged, inherit the direction of the previous trade.
def tick_rule_classification(trades_df):
    """
    Classify trade direction using the tick rule.
    trades_df must have columns: 'timestamp', 'price', 'volume'.
    Returns a new column 'direction' with values 'BUY', 'SELL', or 'MID'.
    """
    df = trades_df.copy()
    df = df.sort_values('timestamp').reset_index(drop=True)

    direction = []
    for i, row in df.iterrows():
        if row['price'] > row['best_ask']:
            direction.append('BUY')
        elif row['price'] < row['best_bid']:
            direction.append('SELL')
        else:
            # Midpoint trade — use tick rule
            if i == 0:
                direction.append('MID')  # First trade: undetermined
            else:
                prev_price = df.loc[i - 1, 'price']
                prev_dir = direction[i - 1]

                if row['price'] > prev_price:
                    direction.append('BUY')
                elif row['price'] < prev_price:
                    direction.append('SELL')
                else:
                    # Unchanged price: carry forward previous direction
                    direction.append(prev_dir if prev_dir != 'MID' else 'MID')

    df['direction'] = direction
    return df

The tick rule's weakness is that it can lock into a wrong direction for sequences of midpoint trades at the same price. In liquid stocks with frequent midpoint execution, this error rate is non-trivial.


Method 2: The Lee-Ready Algorithm

Theoretical Foundation

Charles Lee and Mark Ready published the definitive classification method in their 1991 paper "Inferring Trade Direction from Intraday Data." Their algorithm improves on quote matching by incorporating both the prevailing quote and a five-second lookback window to resolve ambiguous midpoint trades.

The Lee-Ready algorithm proceeds in two stages:

  1. Quote matching for trades outside the spread (same as the tick rule).
  2. Five-second lookback for midpoint trades: If a trade occurs at or inside the spread, compare it to the last trade more than five seconds ago. If the current price is higher, classify as a buy. If lower, classify as a sell.

The five-second window is not arbitrary. Lee and Ready chose it empirically because it represents a sufficient duration for the quote to update in response to the trade. A trade followed by another trade within five seconds likely reflects the same order flow regime, making the lookback more stable.

Lee-Ready Pseudocode

def lee_ready_classification(trade_price, best_bid, best_ask,
                             prev_trade_price, prev_trade_time,
                             current_time):
    """
    Classify a single trade using the Lee-Ready algorithm.

    Parameters:
        trade_price: Current trade execution price
        best_bid: Best bid at time of trade
        best_ask: Best ask at time of trade
        prev_trade_price: Price of the most recent trade more than 5 sec ago
        prev_trade_time: Timestamp of that previous trade
        current_time: Timestamp of current trade
    """
    # Stage 1: Quote rule
    if trade_price > best_ask:
        return 'BUY'
    elif trade_price < best_bid:
        return 'SELL'

    # Stage 2: Midpoint trade — Lee-Ready 5-second lookback
    time_gap = (current_time - prev_trade_time).total_seconds()
    if time_gap >= 5 and prev_trade_price is not None:
        if trade_price > prev_trade_price:
            return 'BUY'
        elif trade_price < prev_trade_price:
            return 'SELL'
        else:
            return 'MID'  # Unchanged price with no direction signal

    # Fallback: insufficient history
    return 'MID'

Why Five Seconds?

Empirical evidence from Lee and Ready showed that the five-second threshold balances two competing errors:

  • Too short: The previous trade may be part of the same aggressive order, making the comparison meaningless.
  • Too long: Market conditions may have shifted fundamentally, making price comparison irrelevant.

Post-2000 market structure changes — faster electronic markets, co-located participants, and decimalization — have led some practitioners to shorten this window to 1–3 seconds, particularly for high-frequency equities data. There is no universally correct value; it is a parameter that should be tuned against a known ground truth for your specific instrument.


Comparing Quote Matching vs. Lee-Ready

Criterion Quote Matching (Tick Rule) Lee-Ready Algorithm
Accuracy ~65–75% on liquid stocks ~80–90% on liquid stocks
Data requirement Current quote Current quote + 5-sec lookback
Computational cost O(n) O(n) with state tracking
Midpoint trade handling Price change vs. prior tick Price change vs. last trade >5 sec ago
Error profile Locks into wrong direction Less prone to sustained errors
Industry adoption Academic benchmarking Production systems

Neither method achieves 100% accuracy against official FINRA trade-reporting data. For US equities, the industry standard is to validate against the FINRA TRF (Trade Reporting Facility) flag when absolute ground truth is needed. For most quant strategies, Lee-Ready provides sufficient accuracy.


Production Implementation with TickDB

With the algorithm defined, we now implement a production-grade data pipeline that consumes tick data and applies Lee-Ready classification in real time.

This implementation connects to the TickDB trades endpoint and the depth endpoint (to retrieve the best bid/ask for quote matching). The pipeline includes heartbeat monitoring, exponential backoff with jitter for reconnection, and proper timeout handling on all HTTP requests.

import os
import time
import json
import random
import logging
from datetime import datetime, timedelta
from typing import Optional

import requests

# ⚠️ For production HFT workloads, use aiohttp/asyncio for concurrent I/O.
# This synchronous implementation is suitable for research and strategy prototyping.

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s"
)
logger = logging.getLogger(__name__)


class TickDirectionClassifier:
    """
    Real-time trade direction classifier using the Lee-Ready algorithm.
    Consumes trades and depth data from TickDB, applies quote matching
    and the 5-second lookback rule, and outputs direction-signed order flow.
    """

    BASE_URL = "https://api.tickdb.ai/v1"

    def __init__(self, api_key: str, symbol: str):
        if not api_key:
            raise ValueError(
                "TICKDB_API_KEY environment variable is not set. "
                "Generate an API key at https://tickdb.ai/dashboard"
            )
        self.api_key = api_key
        self.symbol = symbol
        self.headers = {"X-API-Key": self.api_key}
        self._last_trade_price: Optional[float] = None
        self._last_trade_time: Optional[datetime] = None
        self._last_quote_bid: Optional[float] = None
        self._last_quote_ask: Optional[float] = None

    def _request(self, endpoint: str, params: dict = None, timeout: tuple = (3.05, 10)):
        """
        Standardized HTTP request with timeout and error handling.
        """
        url = f"{self.BASE_URL}{endpoint}"
        response = requests.get(
            url,
            headers=self.headers,
            params=params,
            timeout=timeout
        )
        data = response.json()

        # Handle error codes per TickDB spec
        code = data.get("code", 0)
        if code == 0:
            return data.get("data")
        if code in (1001, 1002):
            raise ValueError(
                f"Authentication error {code}: check your TICKDB_API_KEY. "
                f"Details: {data.get('message')}"
            )
        if code == 2002:
            raise KeyError(
                f"Symbol {self.symbol} not found. "
                f"Verify availability via /v1/symbols/available"
            )
        if code == 3001:
            retry_after = int(response.headers.get("Retry-After", 5))
            logger.warning(f"Rate limited. Retrying after {retry_after} seconds.")
            time.sleep(retry_after)
            return self._request(endpoint, params)  # Retry once
        raise RuntimeError(f"Unexpected TickDB error {code}: {data.get('message')}")

    def _get_latest_depth(self):
        """
        Retrieve the latest best bid/ask snapshot for quote matching.
        Falls back to the last known quote if the request fails.
        """
        try:
            data = self._request(
                "/market/depth",
                params={"symbol": self.symbol, "limit": 1}
            )
            if data and "bids" in data and "asks" in data:
                self._last_quote_bid = float(data["bids"][0]["price"])
                self._last_quote_ask = float(data["asks"][0]["price"])
            return self._last_quote_bid, self._last_quote_ask
        except Exception as e:
            logger.warning(f"Depth fetch failed, using cached quote: {e}")
            return self._last_quote_bid, self._last_quote_ask

    def _lee_ready_classify(
        self,
        trade_price: float,
        current_time: datetime,
        bid: Optional[float],
        ask: Optional[float]
    ) -> str:
        """
        Lee-Ready classification for a single trade.
        """
        if bid is None or ask is None:
            return "MID"

        # Stage 1: Quote rule
        if trade_price > ask:
            return "BUY"
        elif trade_price < bid:
            return "SELL"

        # Stage 2: Midpoint trade — 5-second lookback
        if self._last_trade_time is not None:
            time_gap = (current_time - self._last_trade_time).total_seconds()
            if time_gap >= 5 and self._last_trade_price is not None:
                if trade_price > self._last_trade_price:
                    return "BUY"
                elif trade_price < self._last_trade_price:
                    return "SELL"

        return "MID"

    def fetch_and_classify_trades(
        self,
        limit: int = 100,
        lookback_seconds: int = 300
    ) -> list:
        """
        Fetch recent trades and apply Lee-Ready direction classification.
        Returns a list of classified trade records.
        """
        end_time = datetime.utcnow()
        start_time = end_time - timedelta(seconds=lookback_seconds)

        params = {
            "symbol": self.symbol,
            "limit": limit,
            "start_time": int(start_time.timestamp()),
            "end_time": int(end_time.timestamp())
        }

        trades = self._request("/market/trades", params=params)

        bid, ask = self._get_latest_depth()
        results = []

        for trade in trades:
            price = float(trade["price"])
            volume = float(trade["volume"])
            ts = datetime.fromtimestamp(trade["timestamp"] / 1000)

            direction = self._lee_ready_classify(price, ts, bid, ask)
            signed_volume = volume if direction == "BUY" else -volume if direction == "SELL" else 0

            results.append({
                "timestamp": ts.isoformat(),
                "price": price,
                "volume": volume,
                "direction": direction,
                "signed_volume": signed_volume,
                "best_bid": bid,
                "best_ask": ask
            })

            # Update state for next classification
            self._last_trade_price = price
            self._last_trade_time = ts

        return results


def run_classification_pipeline(symbol: str, poll_interval: int = 10):
    """
    Continuous classification loop with exponential backoff on failures.
    """
    api_key = os.environ.get("TICKDB_API_KEY")
    if not api_key:
        logger.error("TICKDB_API_KEY not set. Exiting.")
        return

    classifier = TickDirectionClassifier(api_key, symbol)
    retry_delay = 1
    max_delay = 60

    while True:
        try:
            classified_trades = classifier.fetch_and_classify_trades(limit=100)

            if classified_trades:
                buy_volume = sum(t["signed_volume"] for t in classified_trades if t["signed_volume"] > 0)
                sell_volume = abs(sum(t["signed_volume"] for t in classified_trades if t["signed_volume"] < 0))
                total_trades = len(classified_trades)
                buy_ratio = buy_volume / (buy_volume + sell_volume) if (buy_volume + sell_volume) > 0 else 0.5

                logger.info(
                    f"[{symbol}] {total_trades} trades | "
                    f"BUY vol: {buy_volume:,.0f} | SELL vol: {sell_volume:,.0f} | "
                    f"Buy ratio: {buy_ratio:.2%}"
                )

                # Emit signed flow for downstream signal computation
                for trade in classified_trades[-5:]:  # Log last 5 for inspection
                    logger.debug(f"  {trade['timestamp']} | {trade['direction']:4s} | "
                                 f"${trade['price']:.2f} x {trade['volume']:,.0f}")

            retry_delay = 1  # Reset on success
            time.sleep(poll_interval)

        except Exception as e:
            logger.error(f"Pipeline error: {e}")
            jitter = random.uniform(0, retry_delay * 0.1)
            sleep_time = retry_delay + jitter
            logger.info(f"Retrying in {sleep_time:.2f} seconds.")
            time.sleep(sleep_time)
            retry_delay = min(retry_delay * 2, max_delay)


if __name__ == "__main__":
    # Example: classify order flow for Apple
    run_classification_pipeline(symbol="AAPL.US", poll_interval=10)

Key Engineering Notes

  • Timeout values: (3.05, 10) — 3.05 seconds for connection, 10 seconds for read. This is the standard for avoiding premature timeout on congested connections.
  • Rate limit handling: The _request method detects code: 3001 and reads the Retry-After header. For the classification loop, we use exponential backoff with jitter on generic errors.
  • State management: The classifier maintains _last_trade_price and _last_trade_time as instance variables. For a distributed deployment, this state should be persisted to a shared store (Redis, Kafka offset) to survive restarts.
  • HFT advisory: The synchronous requests library blocks on each poll cycle. For sub-second latency requirements, replace with aiohttp and asyncio to achieve true concurrent I/O.

Computing Order Flow Metrics

With classified trades in hand, you can construct the core order flow signals used in systematic strategies.

import pandas as pd

def compute_order_flow_metrics(classified_trades: list) -> dict:
    """
    Compute rolling order flow metrics from Lee-Ready classified trades.
    """
    df = pd.DataFrame(classified_trades)
    df["timestamp"] = pd.to_datetime(df["timestamp"])
    df = df.set_index("timestamp").sort_index()

    metrics = {}

    # Cumulative signed volume (OFI proxy)
    metrics["net_flow"] = df["signed_volume"].sum()

    # Buy ratio over the window
    buy_vol = df[df["direction"] == "BUY"]["volume"].sum()
    sell_vol = df[df["direction"] == "SELL"]["volume"].sum()
    total_vol = buy_vol + sell_vol
    metrics["buy_ratio"] = buy_vol / total_vol if total_vol > 0 else 0.5
    metrics["sell_ratio"] = 1 - metrics["buy_ratio"]

    # Trade count imbalance
    buy_count = (df["direction"] == "BUY").sum()
    sell_count = (df["direction"] == "SELL").sum()
    total_count = buy_count + sell_count
    metrics["trade_count_imbalance"] = (
        (buy_count - sell_count) / total_count if total_count > 0 else 0
    )

    # Midpoint trade ratio (quality signal — high midpoint % suggests informed flow)
    mid_count = (df["direction"] == "MID").sum()
    metrics["midpoint_ratio"] = mid_count / len(df) if len(df) > 0 else 0

    # Price return over the window
    if len(df) >= 2:
        metrics["price_return_bps"] = (
            (df["price"].iloc[-1] - df["price"].iloc[0]) / df["price"].iloc[0]
        ) * 10_000
    else:
        metrics["price_return_bps"] = 0

    return metrics

Limitations and Validation

Known Error Sources

Error source Impact Mitigation
Stale quotes Quote matching uses outdated bid/ask Filter trades where quote age > 500 ms
Multiple venues NYSE, NASDAQ, ARCA report separately Aggregate across venues before classifying
Odd-lot trades Odd-lot prints may not follow normal rules Filter out trades below 100 shares on NYSE
Dark pool prints No quote available for dark venues Apply tick rule only; Lee-Ready not applicable
Decimal vs. sub-penny Price comparison breaks at sub-penny precision Normalize to exchange-reported precision

Validation Against Ground Truth

For US equities, the most reliable validation source is FINRA's Trade Reporting Facility (TRF) data, which includes a buy/sell indicator field. Practitioners validating Lee-Ready accuracy typically report 85–92% agreement with FINRA flags for liquid, exchange-listed stocks.

For HK equities and crypto, where exchange-provided direction flags are less standardized, Lee-Ready remains the best available method. Backtest your specific implementation against a known period with confirmed trade direction before deploying to live trading.


Putting It Together: A Complete Workflow

Raw Exchange Feed
       │
       ▼
TickDB Trades + Depth API
       │
       ▼
Quote Matching (Stage 1)
       │
       ├── Trade outside spread → BUY or SELL (direct)
       │
       └── Midpoint trade → Lee-Ready 5-sec lookback
              │
              ▼
Classified Trades (BUY / SELL / MID)
       │
       ▼
Signed Volume Series → Order Flow Imbalance
       │
       ▼
Signal Generation → VPIN, OFI, Trade Count Imbalance
       │
       ▼
Strategy Entry / Risk Management

Closing

Trade direction is not a field in your data — it is an inference you must make. The Lee-Ready algorithm gives you a principled, empirically validated method for that inference. Combined with the quote matching rule as a first-pass filter, it handles the vast majority of trades correctly and degrades gracefully on the ambiguous midpoint cases.

The code above is production-ready for research and live monitoring. For sub-100ms latency requirements in an HFT context, replace the synchronous HTTP layer with an async implementation. The algorithm logic remains identical.

The order flow signals you derive from correctly classified trades — OFI, buy ratio, midpoint ratio — become the inputs to strategies that distinguish informed from uninformed flow, detect regime changes, and manage intraday risk with greater precision than price alone allows.


Next Steps

If you are building an order flow signal for the first time: Start with the tick rule, validate it against a period of known direction (earnings announcements, index rebalances), then upgrade to Lee-Ready once your data pipeline includes a 5-second lookback buffer.

If you need high-quality historical tick data for backtesting: TickDB provides 10+ years of cleaned US equity OHLCV (kline) data via the /v1/market/kline endpoint. For real-time depth snapshots to power quote matching, the /v1/market/depth endpoint delivers up-to-50 levels of order book depth for HK and crypto markets.

If you are prototyping a strategy now: Visit tickdb.ai to generate a free API key. No credit card required. The classification pipeline in this article can be running within 15 minutes of account creation.

If you use AI coding assistants: Search for and install the tickdb-market-data SKILL in your AI tool's marketplace to get TickDB API integration scaffolding auto-generated in your projects.


This article does not constitute investment advice. Market data analysis involves risk; directional inference from tick data is inherently probabilistic and subject to classification error. Past accuracy of the Lee-Ready algorithm does not guarantee future performance in live trading environments.