Trade Classification When You Have No Side Flag: Tick, Quote, Lee-Ready, BVC
Начнем с того, что отбросим версию этой проблемы, с которой открывается большинство учебников по микроструктуре. На площадках, где этот блог действительно торгует, направление сделки не нужно выводить — это готовое поле. Binance aggTrades передает isBuyerMaker, а поток сделок Bybit — side. Статья о построении баров берет сторону агрессора прямо из этого флага и не запускает классификатор:
def on_trade(self, timestamp: int, price: float, qty: float, is_buyer_maker: bool):
side = -1 if is_buyer_maker else 1
Это бесплатная истина с наносекундным разрешением. Если вы строите подписанный order flow по современному потоку криптовалютной CEX, эта статья вам не нужна.
Это необходимо в четырех ситуациях, и они достаточно распространены, чтобы иметь значение:
- У вас есть только бары. Исторический OHLCV продает большинство поставщиков и использует большинство бэктестов; поток сделок не архивировался или стоит дороже, чем оправдывает исследование. Это описанная ниже проблема BVC.
- Площадка не помечает сторону. Небольшие CEX, наборы данных в стиле equity TAQ и почти все архивы до 2017 года дают только цену и объем.
- DEX и ончейн-поток. Событие swap сообщает, какой токен и куда переместился, но для определения агрессии в маршрутизированной многошаговой сделке или у агрегатора не остается флага, на который можно опереться.
- Вы воспроизводите опубликованное исследование. Оценки лямбды Кайла, VPIN и разложения спреда — почти вся литература по микроструктуре построена на данных, классифицированных Lee-Ready. Для сопоставимости нужно запустить их классификатор, а не прочитать настоящую сторону сделки.
Ниже приведены набор правил, самая важная причина ошибки, упрощенный метод для агрегированного объема и протокол измерения — ведь криптовалюта дает метки, которых никогда не было в литературе по акциям.
Тиковый тест

Сравните текущую цену сделки с предыдущей:
Интуиция проста: сделки, инициированные покупателем, поднимают ask и толкают цену вверх; сделки, инициированные продавцом, бьют в bid и толкают ее вниз. Нулевые тики переносят предыдущий знак, и именно здесь возникает большая часть ошибок: на ликвидном инструменте со спредом в один тик большинство последовательных сделок проходит по одной цене.
sign = np.sign(np.diff(prices, prepend=prices[0]))
sign[sign == 0] = np.nan
directions = pd.Series(sign).ffill().fillna(1).astype(int).values
Рабочая реализация с той же семантикой переноса уже поставляется как _tick_sign() внутри генераторов тиковых и объемных баров дисбаланса в статье За пределами временных баров. Используйте ее вместо поддержки второй копии.
Правило котировки

Вместо сравнения последовательных сделок сравните цену сделки с текущей средней котировкой :
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
Правило котировки использует больше информации, чем тиковый тест, но ошибается в другом месте: оно ничего не говорит о сделках, прошедших точно по mid. На инструменте с узким тик-ограниченным спредом это не крайний случай, а значительная часть ленты. Правилу нужен запасной вариант — им и становится Lee-Ready.
Lee-Ready: гибрид и предположение об устаревших котировках

Lee и Ready (1991) объединили два подхода: применять правило котировки, а для сделок по mid возвращаться к тиковому тесту.
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
Самая интересная часть статьи обычно воспроизводится без объяснения. Lee и Ready не просто предложили гибрид: они предложили сравнивать каждую сделку с котировкой, действовавшей пять секунд назад. Это не модельный выбор о рынках, а поправка на артефакт передачи данных: на консолидированной ленте NYSE 1980-х сделки и котировки распространялись по разным каналам с разными задержками, причем котировки часто приходили раньше относящихся к ним сделок. Поэтому сравнение сделки с напечатанной одновременно котировкой означало сравнение с котировкой, уже отражавшей эту сделку, и переворачивало знак именно для сделок, сдвинувших рынок.
Для любого, кто переносит это на криптовалюту, следуют две вещи.
Правило 5 секунд устарело, и его применение вредно. Оно исправляет задержку передачи, которой больше нет. Рынки акций устранили этот разрыв к середине 1990-х; потоки сделок и дельты стакана криптоплощадки используют общее пространство последовательностей и получают отметки от одного matching engine с микросекундным разрешением. Пять секунд на BTCUSDT — не допуск синхронизации, а сотни обновлений стакана. Задержка котировок на пять секунд не исправляет устаревшие котировки, а создает их. Используйте quote_lag=0, если только не измерили реальное смещение в собственной записи.
Но сама проблема сохранилась в новой форме. Теперь устаревание котировок — ошибка вашего конвейера, а не биржи. Восстановите стакан из WebSocket-потока дельт, соедините сделки по настенному времени — и внесете задержку, знак и величина которой зависят от буферизации, политики повторной синхронизации снимка и часов. Измерьте ее до того, как доверять классификатору, читающему стакан: воспроизведите окно, соедините сделки с состоянием стакана и проверьте, какая доля выходит за пределы объединенного [bid, ask]. Сделка выше объединенного ask доказывает, что сопоставленный с ней стакан устарел.
Исправление отличается от решения Lee и Ready, но режим ошибки тот же.
Классификация совокупного объема

Easley, Lopez de Prado и O'Hara (2012) решают другую проблему: что, если отдельных сделок вы не видите вовсе? BVC классифицирует агрегированный объем внутри бара, разделяя его с помощью стандартной нормальной CDF стандартизированного изменения цены:
где и — стандартное отклонение изменения цены бара.
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 — другой инструмент, а не худший вариант, и это объясняют три его свойства. Он не классифицирует сделки, а выдает долю: бар, закрывшийся без изменения, получает разделение 50/50, а не случайный знак для каждой сделки внутри него. Если потребителю нужен знак каждой сделки, BVC его дать не может. Он работает на барах, а не на ленте, и в этом весь смысл: это единственный метод здесь, работающий без данных уровня отдельных сделок. Наконец, — свободный параметр, меняющий результат: оценка по полной выборке создает заглядывание в будущее, а короткая скользящая оценка заставляет насыщаться и приближает каждый бар к 0/100. Здесь нужна проверка чувствительности, а не значение по умолчанию.
В оригинальной статье сообщается, что BVC достаточно близко отслеживает классификацию на уровне сделок для оценки VPIN. Конкретная accuracy сильно зависит от размера бара, инструмента и оценщика , а вторичные источники редко называют все три. Не переносите результат исследования акций — измерьте его.
Единственный специфичный для классификации признак

Если вместо правила вы обучаете для этого модель, большая часть набора признаков уже опубликована: спред, дисбаланс глубины, дисбаланс потока сделок и скользящая реализованная волатильность сведены и реализованы в статье о моделировании спреда с помощью машинного обучения, а многоуровневые признаки стакана — в DeepLOB. Используйте их.
Специфичный для этой задачи признак, отсутствующий в обеих статьях, — положение сделки внутри спреда:
Сделка по bid отображается в 0, по ask — в 1, по mid — в 0,5. Это непрерывное обобщение правила котировки: вместо порога в mid модель получает расстояние и может выучить, что trade по 0,95 почти наверняка является покупкой, а trade по 0,55 малоинформативен. Значения вне — диагностический признак устаревшей котировки из предыдущего раздела; их стоит логировать, а не обрезать.
Для boosted baseline на этих признаках используйте конвейер из статьи о моделировании спреда с помощью машинного обучения, заменив objective на бинарный и, что особенно важно, сохранив purged и embargoed walk-forward CV. Не оценивайте микроструктурный классификатор обычным TimeSeriesSplit: эта статья и walk-forward optimization объясняют, почему полученное число будет оптимистичным. Для sequence-моделей на снимках стакана эталоном остается архитектура DeepLOB, и раздел об обучении применяется без изменений.
Как измерять правильно: криптовалюта дает метки

Каждая оценка accuracy в литературе о классификации сделок на фондовом рынке сравнивается с прокси истины — TORQ, подвыборками аудиторского журнала или внутренними записями одной площадки, — поскольку настоящая сторона агрессора никогда не публиковалась. Криптовалюта ее публикует. isBuyerMaker в Binance aggTrades — бесплатная метка для каждой сделки. Поэтому этот эксперимент доступен всем, но опубликованного варианта для криптовалюты я не нашел. Протокол:
Данные. Месяц aggTrades для BTCUSDT плюс поток book ticker, объединенные так, чтобы каждая сделка несла bid и ask, действовавшие непосредственно перед ней. Повторите эксперимент на малоликвидном альткоине: вопрос как раз в том, что происходит при широком спреде.
Метка. direction = -1 if is_buyer_maker else +1. Затем отбросьте флаг и запустите каждый метод только на данных о ценах и котировках.
Методы. Тиковый тест, правило котировки, Lee-Ready с quote_lag=0, Lee-Ready с исторической задержкой 5 секунд (чтобы измерить ущерб устаревшего правила) и BVC с несколькими размерами баров в сравнении с настоящей долей объема покупок в баре.
Разрезы. Общая accuracy — наименее интересное число. Разбейте результат по режиму спреда (один тик против широкого: carry-forward тикового теста должен рушиться там, где спред зафиксирован), по тому, прошла ли сделка по mid (это изолирует место, где правило котировки воздерживается, а fallback Lee-Ready выполняет всю работу), по размеру сделки (aggTrades содержат sweep'ы; крупные сделки создают impact и дороже всего обходятся при неверном знаке) и по режиму волатильности (в BVC оценивается , поэтому его ошибка по построению зависит от режима).
| Метод | В целом | По mid | Узкий спред | Широкий спред | Крупные сделки |
|---|---|---|---|---|---|
| Тиковый тест | — | — | — | — | — |
| Правило котировки | — | — | — | — | — |
| Lee-Ready (lag 0) | — | — | — | — | — |
| Lee-Ready (lag 5 с) | — | — | — | — | — |
| BVC (доля бара) | — | н/д | — | — | н/д |
Accuracy не является целью

Следуя дисциплине постановки задачи из DeepLOB, accuracy классификации — не результат, а вход для результата. Никто не торгует знаком отдельной сделки: торгуют величиной, вычисленной по целому потоку знаков, и ошибки не передаются в нее нейтрально. Классификатор, случайно ошибающийся в 10% сделок, и классификатор, систематически ошибающийся в 10% крупных сделок, двигающих цену, создают совершенно разный подписанный order flow при одинаковой accuracy. Ошибка carry-forward тикового теста особенно связана с неизменной ценой — режимом, в котором подписанный поток должен быть информативен. Поэтому настоящий результат дают приведенные выше разрезы, а не заголовочное число.
С настоящими метками следующий вопрос измеряется напрямую: оцените нужную величину по настоящим знакам, переоцените ее по знакам каждого классификатора и сообщите разницу.
- Ценовой impact. Регрессируйте изменения mid-price на подписанный net taker flow по фиксированным окнам. Оценка уже задана в моделях издержек проскальзывания, а рабочая реализация на открытых данных Binance приведена в статье об оптимальном исполнении Almgren–Chriss. Не переопределяйте ее здесь: запустите дважды и сравните оцененный коэффициент.
- Order flow imbalance как предиктор. Посчитайте корреляцию OFI с будущими доходностями для настоящих и классифицированных знаков. Падение этой корреляции и есть стоимость ошибочной классификации, выраженная в единственной важной единице.
- Метрики токсичности. VPIN и признаки дисбаланса потока сделок используют классифицированный объем; оба описаны в статье о моделировании спреда с помощью ML. Их чувствительность к ошибке классификации измеряется тем же способом.
Практический вывод

Если площадка передает флаг агрессора, используйте его. Для криптовалют это верно большую часть времени.
Если нет, используйте Lee-Ready с актуальными котировками. Не потому, что он точен, а потому, что это стандарт, а сопоставимость с опубликованными оценками за три десятилетия ценнее нескольких непроверяемых пунктов accuracy. Правило пяти секунд — ископаемое эпохи задержек ленты 1980-х; воспроизведение его на современных данных добавляет именно ту устарелость, которую оно должно было устранить. Если есть только бары, BVC — единственный вообще работающий вариант, но он отвечает на другой вопрос: разделяет объем, а не выдает знак сделки.
В любом случае сравнивайте методы с бесплатными метками криптовалюты, разбивайте результат по режиму спреда и размеру сделки и сообщайте, во что ошибка обходится downstream-оценкам, а не только какой процент оказался правильным.
Авторы
Инженер торговых систем
Разработка торговых ботов с 2017 года: межбиржевой арбитраж (подключал до 30 бирж), парный арбитраж на коинтеграции между спотом и фьючерсами, скальпинг, фронтраннинг, торговля по новостям, сентиментный анализ, трендовые алгоритмы, а также алгоритмы управления и балансировки портфелей. Делает выставление ордеров до 1 мс, warehouse для big data, бэктестинг-движки, AI-агентов и интерфейсы для ботов (в т.ч. open-source profitmaker.cc). Стек: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, архитектура.