Моделирование и прогнозирование bid-ask спреда с помощью машинного обучения
Bid-ask спред — это самая важная переменная, которой управляет маркет-мейкер. Сделаете его слишком широким — и поток уйдет к конкурентам. Сделаете слишком узким — и неблагоприятный отбор (adverse selection) живьем съест ваш инвентарь. Классическая теория микроструктуры дает нам элегантное разложение спреда на его экономические компоненты. Машинное обучение дает инструменты, чтобы прогнозировать, как эти компоненты меняются в реальном времени. Этот пост соединяет оба мира: мы начнем с классической теории, выстроим неявный оценщик спреда Ролла, а затем перейдем к моделям градиентного бустинга и глубокого обучения, которые прогнозируют спред по признакам стакана. По пути мы отметим ловушки с единицами измерения, утечками данных и бенчмаркингом, которые незаметно обесценивают модели спреда на практике.
Почему спреды важны для маркет-мейкеров
Маркет-мейкер непрерывно котирует цену покупки и цену продажи . Котируемый спред равен:
Каждый полный оборот (купить по биду мейкера, продать по аску мейкера — обе сделки исполнены тейкерами) переносит до от тейкеров к мейкеру — в теории. На практике мейкер зарабатывает меньше из-за неблагоприятного отбора: часть тейкеров информирована и торгует прямо перед тем, как цена двинется против мейкера. Реализованная прибыль за один оборот — это реализованный спред (realized spread), равный эффективному спреду минус влияние на цену:
Все три величины мы измеряем на одной и той же базе полного спреда (а не половины), так что тождество согласовано по размерности. Эффективный спред для отдельной сделки равен:
Здесь — направление сделки (покупка или продажа со стороны тейкера), — цена сделки, а — середина лучшего бида и аска на момент сделки. Член влияния на цену определяется симметрично на горизонте после сделки :
где — мид на горизонте после сделки. Горизонт нужно указывать явно — типичные значения это 5 минут для акций и 30 секунд для крипты, где цены переоцениваются быстрее. Вычитание влияния на цену из эффективного спреда оставляет реализованный спред: то, что остается мейкеру после того, как рынок сдвинулся.
Маркет-мейкер, способный прогнозировать спред — и его компоненты — на следующие 1, 5 или 60 секунд, может динамически подстраивать котировки, максимизируя реализованный спред при сохранении доли исполнения.
Три компонента спреда

Литература по микроструктуре рынка (Stoll 1978, Glosten and Milgrom 1985, Huang and Stoll 1997) раскладывает bid-ask спред на три экономических компонента.
1. Издержки обработки ордеров ()
Это стоимость предоставления маркет-мейкерской услуги на каждую исполненную сторону: комиссия, которую мейкер реально платит, плюс технологическая инфраструктура, регуляторный комплаенс и альтернативная стоимость задействованного капитала. Demsetz (1968) и Tinic (1972) первыми формализовали этот компонент.
Ключевое различие — в том, кто какую комиссию платит. Мейкер, котирующий пассивно, платит комиссию мейкера за свои исполнения — а на многих площадках это ребейт (rebate), то есть отрицательная величина. Он не платит комиссию тейкера за эти пассивные исполнения; ее платит контрагент, который пересекает спред. Таким образом, издержки обработки ордеров мейкера на одну сторону равны:
где знаковая (ребейт снижает и может сделать ее отрицательной), а покрывает связность, колокацию и вычисления. На современных электронных рынках этот компонент резко сжался — доли цента для акций, несколько базисных пунктов или чистый ребейт в крипте.
Комиссия тейкера важна по другой причине: она задает нижнюю границу того, насколько узким может быть прибыльный полный спред, потому что тейкер, пересекающий спред, платит поверх него. Если вам нужен нижний порог спреда, который сохраняет ваши котировки экономически привлекательными относительно этих издержек тейкера, обосновывайте его отдельно, а не вкатывайте в собственные издержки мейкера. Смешивание этих двух величин дважды учитывает комиссию за полный оборот внутри одного полуспреда.
2. Издержки удержания инвентаря ()
Когда маркет-мейкер накапливает направленную позицию (длинную или короткую), он несет ценовой риск. Инвентарный компонент компенсирует этот риск. Stoll (1978), а также Amihud and Mendelson (1980) моделировали его как функцию волатильности и текущего инвентаря мейкера:
где — волатильность актива, а — текущий инвентарь мейкера. По мере роста инвентаря мейкер расширяет спред на той стороне, где он подвержен риску, и сужает на другой — этот прием называется скосом по инвентарю (inventory skewing).
3. Издержки неблагоприятного отбора ()
Это самый опасный компонент. Информированные трейдеры — те, кто обладает превосходной информацией о неизбежных движениях цены — систематически выбивают устаревшие котировки. Издержки неблагоприятного отбора равны ожидаемому убытку на сделку в пользу информированных контрагентов. Copeland and Galai (1983) моделировали это как стоимость бесплатного опциона, который мейкер дает информированным трейдерам. Glosten and Milgrom (1985) формализовали это как байесовский пересмотр убеждений мейкера после наблюдения сделки:
где — истинная фундаментальная стоимость. На ликвидных рынках неблагоприятный отбор может составлять 30-60% всего спреда.
Полное разложение
Котируемый полуспред можно записать как:
где , и выражены как издержки на одну сторону (полуспред) — именно это сохраняет согласованность учета. Huang and Stoll (1997) предложили эконометрический метод оценки этих компонентов из данных о сделках и котировках. Ключевая мысль: издержки обработки ордеров создают фиксированный нижний порог спреда, инвентарные издержки создают спред, меняющийся с позицией и волатильностью, а издержки неблагоприятного отбора создают спред, меняющийся с информационной асимметрией.
Неявная модель спреда Ролла

До того как высокочастотные данные стали широко доступны, Richard Roll (1984) предложил элегантный метод оценки эффективного спреда, используя только цены сделок. Его идея: на эффективном рынке отскок между бидом и аском порождает отрицательную серийную ковариацию в изменениях цен, даже когда новой информации нет.
Модель
Предположим, что фундаментальная стоимость следует случайному блужданию:
Наблюдаемая цена сделки отскакивает между бидом и аском:
где с равной вероятностью (то есть покупки и продажи равновероятны). Изменение цены равно:
Вычисляя автоковариацию первого порядка:
Модель выведена в ценовых единицах: выпадает из автоковариации изменений цены, а не доходностей. Это различие — самая распространенная ошибка реализации, и ниже мы сохраняем код верным ему.
Оценщик Ролла
Решая относительно :
Когда выборочная автоковариация положительна (что на практике случается часто из-за шума или моментума), оценщик не определен. Распространенное решение — приравнять оценку к нулю или использовать знаковый корень:
где — выборочная автоковариация первого порядка.
Реализация на Python
Оценщик возвращает спред в ценовых единицах. Чтобы выразить его в базисных пунктах, мы делим на мидпрайс один раз — потому что, в отличие от оценщика в пространстве доходностей, он еще не был поделен на цену:
import numpy as np
import pandas as pd
def roll_spread(prices: pd.Series, window: int = 200) -> pd.Series:
"""
Rolling Roll (1984) spread estimator, in PRICE units.
The model is P_t = V_t + (S/2) d_t with Cov(ΔP_t, ΔP_{t-1}) = -S^2/4,
so S is recovered from the autocovariance of price CHANGES (diff),
not returns (pct_change). Using returns rescales the estimate by the
price level and is wrong by roughly that factor.
Parameters
----------
prices : pd.Series
Transaction prices.
window : int
Rolling window size (number of price changes).
Returns
-------
pd.Series
Estimated spread per window, in price units.
"""
dprice = prices.diff().dropna()
autocov = dprice.rolling(window).apply(
lambda x: np.cov(x[:-1], x[1:])[0, 1], raw=True
)
return 2.0 * np.sqrt(np.maximum(-autocov, 0.0))
trades = pd.read_parquet("trades.parquet")
trades["roll_spread"] = roll_spread(trades["price"], window=200)
trades["quoted_spread"] = trades["ask"] - trades["bid"]
trades["midprice"] = 0.5 * (trades["ask"] + trades["bid"])
trades["quoted_spread_bps"] = trades["quoted_spread"] / trades["midprice"] * 1e4
trades["roll_spread_bps"] = trades["roll_spread"] / trades["midprice"] * 1e4
Быстрая проверка на симулированном ряду — фундаментальное случайное блуждание около цены 100 с истинным спредом — восстанавливает из изменений цены. Вариант на основе доходностей вернул бы , отличаясь на величину уровня цены, и затем деление этого результата на мидпрайс еще раз для перевода в bps усугубляет ошибку. Если вы предпочитаете оценщик в пространстве доходностей, выведите модель в пространстве лог-цен и уберите второе деление на мидпрайс; выберите одно соглашение и приведите код в соответствие с математикой.
Ограничения модели Ролла
Модель Ролла предполагает: (1) эффективность рынка, (2) отсутствие информационной асимметрии, (3) i.i.d.-направление сделок и (4) постоянный спред. Все эти предпосылки нарушаются на практике. Harris (1990) показал, что оценщик серьезно смещен из-за неравенства Йенсена при применении к зашумленным данным. Несмотря на эти ограничения, оценщик Ролла остается полезным как быстрый базовый ориентир и широко используется в эмпирических финансовых исследованиях.
ML-признаки для прогнозирования спреда

Чтобы выйти за рамки статических моделей, нужны признаки, которые улавливают динамические драйверы вариации спреда. Вот таксономия признаков, организованная по тому компоненту спреда, который они аппроксимируют.
Признаки стакана (инвентарь и неблагоприятный отбор)
| Признак | Формула | Аппроксимирует |
|---|---|---|
| Дисбаланс стакана | Направленное давление | |
| Взвешенный мидпрайс | Краткосрочная справедливая стоимость | |
| Отношение глубины (уровни 1-5) | Многоуровневое предложение/спрос | |
| Давление стакана | Давление, взвешенное по расстоянию | |
| Отношение спред / тик | Узость относительно минимума |
Давление стакана здесь использует затухание по абсолютному расстоянию до мида, , так что объем вблизи лучшей цены весит больше, чем глубокий объем, и обе стороны взвешиваются положительной убывающей функцией. Это позволяет избежать структурного смещения знака, возникающего при делении объема на знаковое расстояние (которое отрицательно на стороне бида, положительно на стороне аска и взрывается, когда уровень приближается к миду). Выберите исходя из типичной глубины стакана или замените экспоненту любым положительным весом , убывающим по расстоянию.
Признаки потока сделок (неблагоприятный отбор)
| Признак | Формула | Аппроксимирует |
|---|---|---|
| Дисбаланс сделок | Чистый информированный поток | |
| VPIN | Объемно-синхронизированная вероятность информированной торговли | Токсичность |
| Лямбда Кайла | Регрессия на знаковый объем | Влияние на цену на единицу |
| Частота крупных сделок | Число сделок в окне | Институциональная активность |
Признаки волатильности (инвентарные издержки)
| Признак | Формула | Аппроксимирует |
|---|---|---|
| Реализованная волатильность | Краткосрочный риск | |
| Волатильность Гармана-Класса | Волатильность по диапазону | |
| Волатильность волатильности | Скользящее std от | Неопределенность режима |
| Автокорреляция доходностей | Моментум / возврат к среднему |
Признаки рыночного режима
| Признак | Описание | Аппроксимирует |
|---|---|---|
| Кодирование времени суток | Внутридневная сезонность | |
| Секунды с последней сделки | Временной разрыв | Уровень активности |
| Кросс-активная корреляция | Скользящая корреляция с индексом/BTC | Систематический риск |
| Ставка финансирования (крипта) | Ставка финансирования перпетуала | Кредитное позиционирование |
Градиентный бустинг для прогнозирования спреда
Деревья градиентного бустинга (XGBoost, LightGBM, CatBoost) — рабочая лошадка табличного прогнозирования в количественных финансах. Они работают со смешанными типами признаков, улавливают нелинейные взаимодействия, требуют минимальной предобработки и быстро обучаются на миллионах строк — при условии, что само построение признаков векторизовано (см. примечание об автокорреляции ниже).
Постановка задачи
Мы формулируем прогнозирование спреда как задачу регрессии. Целевая переменная — это взвешенный по времени средний котируемый спред за следующие секунд:
На практике мы аппроксимируем это взвешенным по объему средним спредом за следующие снимков:
Эта целевая переменная — прямое окно (forward window) длины (или horizon), а это означает, что соседние строки разделяют пересекающиеся будущие окна. Это пересечение утекает информацией через наивное разбиение train/validation — мы явно справляемся с этим в коде обучения ниже.
Полный конвейер
import lightgbm as lgb
import numpy as np
import pandas as pd
from sklearn.metrics import mean_absolute_error, r2_score
def build_features(df: pd.DataFrame) -> pd.DataFrame:
"""Build spread-prediction features from L2 order book snapshots."""
f = pd.DataFrame(index=df.index)
f["imb1"] = (df["bid_vol_1"] - df["ask_vol_1"]) / (
df["bid_vol_1"] + df["ask_vol_1"] + 1e-9
)
bid_depth = df[[f"bid_vol_{i}" for i in range(1, 6)]].sum(axis=1)
ask_depth = df[[f"ask_vol_{i}" for i in range(1, 6)]].sum(axis=1)
f["depth_imb5"] = (bid_depth - ask_depth) / (bid_depth + ask_depth + 1e-9)
mid = 0.5 * (df["ask_1"] + df["bid_1"])
f["spread_bps"] = (df["ask_1"] - df["bid_1"]) / mid * 1e4
f["log_spread"] = np.log1p(df["ask_1"] - df["bid_1"])
log_ret = np.log(mid / mid.shift(1))
f["rvol_50"] = log_ret.rolling(50).std()
f["rvol_200"] = log_ret.rolling(200).std()
if "trade_sign" in df.columns and "trade_vol" in df.columns:
signed_vol = df["trade_sign"] * df["trade_vol"]
total_vol = df["trade_vol"].rolling(50).sum()
f["tfi_50"] = signed_vol.rolling(50).sum() / (total_vol + 1e-9)
lag1 = log_ret.shift(1)
f["ret_autocorr"] = log_ret.rolling(100).corr(lag1)
if isinstance(df.index, pd.DatetimeIndex):
seconds = df.index.hour * 3600 + df.index.minute * 60 + df.index.second
f["tod_sin"] = np.sin(2 * np.pi * seconds / 86400)
f["tod_cos"] = np.cos(2 * np.pi * seconds / 86400)
for lag in [1, 5, 10, 50]:
f[f"spread_lag_{lag}"] = f["spread_bps"].shift(lag)
return f.dropna()
def build_target(df: pd.DataFrame, horizon: int = 10) -> pd.Series:
"""Forward mean spread over the next `horizon` snapshots (in bps).
target[t] = mean(spread_bps[t+1 .. t+horizon]). Note that consecutive
targets share an overlapping forward window of length `horizon`, which
is why the CV below purges a gap of `horizon` rows around each fold.
"""
mid = 0.5 * (df["ask_1"] + df["bid_1"])
spread_bps = (df["ask_1"] - df["bid_1"]) / mid * 1e4
fwd = spread_bps.shift(-1).rolling(horizon).mean().shift(-(horizon - 1))
return fwd
def purged_walk_forward(n: int, n_splits: int, horizon: int):
"""Expanding-window splits with a purge/embargo gap of `horizon` rows.
Because each target spans `horizon` future snapshots, rows straddling a
train/val boundary share overlapping target windows. Dropping a gap of
`horizon` rows between train and validation removes that leakage
(Lopez de Prado-style purging). Without it, validation R²/MAE are
optimistically biased by the target overlap.
"""
fold_size = n // (n_splits + 1)
for k in range(1, n_splits + 1):
train_end = fold_size * k
val_start = train_end + horizon # embargo gap
val_end = val_start + fold_size
if val_end > n:
break
train_idx = np.arange(0, train_end - horizon) # purge gap
val_idx = np.arange(val_start, val_end)
yield train_idx, val_idx
def train_spread_model(features: pd.DataFrame, target: pd.Series, horizon: int = 10):
"""Train LightGBM with purged, embargoed walk-forward validation."""
common = features.index.intersection(target.dropna().index)
X = features.loc[common].reset_index(drop=True)
y = target.loc[common].reset_index(drop=True)
models, scores = [], []
params = {
"objective": "mae",
"learning_rate": 0.05,
"num_leaves": 63,
"min_child_samples": 100,
"subsample": 0.8,
"colsample_bytree": 0.8,
"reg_alpha": 0.1,
"reg_lambda": 1.0,
"verbose": -1,
}
for fold, (train_idx, val_idx) in enumerate(
purged_walk_forward(len(X), n_splits=5, horizon=horizon)
):
X_tr, X_val = X.iloc[train_idx], X.iloc[val_idx]
y_tr, y_val = y.iloc[train_idx], y.iloc[val_idx]
ds_tr = lgb.Dataset(X_tr, y_tr)
ds_val = lgb.Dataset(X_val, y_val, reference=ds_tr)
model = lgb.train(
params,
ds_tr,
num_boost_round=2000,
valid_sets=[ds_val],
callbacks=[lgb.early_stopping(50), lgb.log_evaluation(200)],
)
preds = model.predict(X_val)
mae = mean_absolute_error(y_val, preds)
r2 = r2_score(y_val, preds)
print(f"Fold {fold}: MAE={mae:.4f} bps, R²={r2:.4f}")
models.append(model)
scores.append({"mae": mae, "r2": r2})
return models[-1], scores
Критически важная деталь — разрыв purge/embargo. Целевая переменная как прямое среднее означает, что соседние строки пересекаются на величину до horizon снимков, так что обычный TimeSeriesSplit позволяет валидационным строкам разделять будущие окна со строками обучения — утекая ответ и завышая валидационный R². Удаление разрыва как минимум в horizon строк по обе стороны границы каждого фолда (purged k-fold в стиле Lopez de Prado) убирает это смещение. Это относится к конвейеру градиентного бустинга ровно так же, как и к глубокому обучению, хотя об утечке чаще говорят применительно к моделям последовательностей.
Анализ важности признаков
Одно из ключевых преимуществ моделей на деревьях — интерпретируемость. После обучения изучите значения SHAP, чтобы понять, какие признаки определяют прогнозы спреда:
import shap
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_val)
shap.summary_plot(shap_values, X_val, max_display=15)
Типичные находки в разных классах активов:
- Лагированный спред () почти всегда самый важный признак — спреды сильно автокоррелированы. Именно поэтому заголовочный R² выглядит высоким: значительная часть оценки — это просто персистентность, поэтому всегда сравнивайте с базовой моделью AR/EWMA (об этом подробнее ниже).
- Реализованная волатильность — второй по важности — внутридневная волатильность и спреды сильно положительно коррелируют как одновременно, так и динамически.
- Дисбаланс стакана важнее всего в волатильные периоды — он сигнализирует о неизбежных направленных движениях.
- Дисбаланс потока сделок улавливает краткосрочный неблагоприятный отбор — всплеск одностороннего потока предсказывает расширение спреда.
- Время суток улавливает U-образный внутридневной паттерн (шире на открытии/закрытии, уже в середине дня).
Соображения по гиперпараметрам
Применительно конкретно к прогнозированию спреда:
- Используйте MAE или потерю Хьюбера вместо MSE. Распределения спреда скошены вправо с редкими экстремальными выбросами (во время новостных событий). MAE более устойчива.
- Установите
min_child_samplesвысоким (100+), чтобы модель не подгонялась под микроструктурный шум в отдельных снимках. - Используйте
subsample < 1.0, чтобы декоррелировать деревья и улучшить обобщение по разным режимам волатильности.
Подходы на основе глубокого обучения
Хотя градиентный бустинг превосходен на табличных признаках, глубокое обучение может выучивать представления прямо из сырых данных стакана. Две архитектуры доказали свою эффективность в задачах прогнозирования, связанных со спредом.
Архитектура 1: CNN-LSTM для снимков стакана
Архитектура DeepLOB (Zhang et al. 2019) использует стек сверток с малым ядром — и модуль Inception — для извлечения пространственных паттернов по уровням стакана с сохранением этой пространственной структуры, за которыми следуют слои LSTM для моделирования временных зависимостей. Важное проектное решение — не схлопывать ось уровней глобальным пулингом перед рекуррентным слоем: это уничтожает ровно ту межуровневую структуру, которую свертки и призваны улавливать.
Для прогнозирования спреда вход — это тензор формы :
- = число временных шагов (например, 100 снимков)
- = число ценовых уровней (например, 10 бид + 10 аск = 20)
- = признаки на уровень (цена, объем, число ордеров)
Модель ниже сохраняет сверточную карту признаков по уровням и разворачивает ее во вход LSTM (input_size = 16 * L), а не усредняет измерение уровней в 16 средних по каналам:
import torch
import torch.nn as nn
class SpreadPredictor(nn.Module):
"""
CNN-LSTM model for bid-ask spread prediction from L2 order book.
Input: (batch, seq_len, n_levels, n_features)
Output: (batch, 1) — predicted spread in bps
"""
def __init__(
self,
n_levels: int = 20,
n_features: int = 3,
seq_len: int = 100,
hidden_dim: int = 64,
n_lstm_layers: int = 2,
dropout: float = 0.2,
):
super().__init__()
self.seq_len = seq_len
self.n_levels = n_levels
self.conv = nn.Sequential(
nn.Conv1d(n_features, 32, kernel_size=3, padding=1),
nn.BatchNorm1d(32),
nn.LeakyReLU(0.1),
nn.Conv1d(32, 16, kernel_size=3, padding=1),
nn.BatchNorm1d(16),
nn.LeakyReLU(0.1),
)
conv_out_dim = 16 * n_levels # flattened (channels × levels)
self.lstm = nn.LSTM(
input_size=conv_out_dim,
hidden_size=hidden_dim,
num_layers=n_lstm_layers,
batch_first=True,
dropout=dropout,
)
self.head = nn.Sequential(
nn.Linear(hidden_dim, 32),
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(32, 1),
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Parameters
----------
x : Tensor of shape (batch, seq_len, n_levels, n_features)
Returns
-------
Tensor of shape (batch, 1) — predicted spread
"""
batch, T, L, F = x.shape
x = x.reshape(batch * T, L, F).permute(0, 2, 1)
x = self.conv(x) # (batch * T, 16, L)
x = x.reshape(batch, T, 16 * L) # (batch, T, 16 * L) — keep levels
lstm_out, _ = self.lstm(x) # (batch, T, hidden_dim)
last_hidden = lstm_out[:, -1, :] # (batch, hidden_dim)
return self.head(last_hidden) # (batch, 1)
Если вам все же нужен пулинг по оси уровней, чтобы контролировать число параметров, используйте пулинг с шагом или обучаемый пулинг, сохраняющий больше одной позиции — а не AdaptiveAvgPool1d(1), который усредняет каждый уровень в одно число и выбрасывает пространственный сигнал.
Архитектура 2: Энкодер-трансформер
Трансформеры могут улавливать дальние зависимости в последовательностях стакана без последовательного узкого места LSTM. Для прогнозирования спреда хорошо работает легковесный энкодер-трансформер:
class TransformerSpreadPredictor(nn.Module):
"""Transformer encoder for spread prediction from order book sequences."""
def __init__(
self,
input_dim: int = 40, # 20 levels * 2 features (price_offset, volume)
d_model: int = 64,
nhead: int = 4,
n_layers: int = 3,
seq_len: int = 100,
dropout: float = 0.1,
):
super().__init__()
self.input_proj = nn.Linear(input_dim, d_model)
self.pos_encoding = nn.Parameter(torch.randn(1, seq_len, d_model) * 0.02)
encoder_layer = nn.TransformerEncoderLayer(
d_model=d_model,
nhead=nhead,
dim_feedforward=d_model * 4,
dropout=dropout,
batch_first=True,
activation="gelu",
)
self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=n_layers)
self.head = nn.Sequential(
nn.LayerNorm(d_model),
nn.Linear(d_model, 1),
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
x: (batch, seq_len, input_dim) — flattened order book snapshots
"""
x = self.input_proj(x) + self.pos_encoding[:, : x.size(1), :]
x = self.encoder(x)
return self.head(x[:, -1, :])
Соображения по обучению
-
Нормализация: Нормализуйте цены как смещения от мидпрайса (в тиках или bps). Нормализуйте объемы по их скользящему среднему. Сырые цены и объемы вызывают нестабильность обучения.
-
Функция потерь: Используйте потерю Хьюбера () для обработки всплесков спреда:
-
Выборка окон и утечка данных: Используйте непересекающиеся окна для обучения и — ровно как в конвейере градиентного бустинга — применяйте purge/embargo с разрывом как минимум в
horizonснимков между train и validation. И целевая переменная как прямое среднее, и пересекающиеся входные окна утекают будущую информацию через границы разбиения и завышают кажущуюся производительность. -
Онлайн-адаптация: В продакшене периодически дообучайте модель на свежих данных (последние 1-2 часа) с малым learning rate. Микроструктура рынка меняется в течение дня, и модель, обученная на утренних данных, может работать хуже днем.
Когда использовать глубокое обучение, а когда градиентный бустинг
| Критерий | Градиентный бустинг | Глубокое обучение |
|---|---|---|
| Тип входа | Табличные признаки | Сырые последовательности стакана |
| Размер обучающих данных | Работает на 100K+ строк | Нужно 1M+ строк |
| Конструирование признаков | Ручное (высокие усилия, высокий контроль) | Выучивается (меньше усилий, меньше интерпретируемости) |
| Задержка инференса | Единицы µs с компилированным предиктором; десятки µs из Python | Сотни µs на GPU |
| Интерпретируемость | Высокая (SHAP) | Низкая (карты внимания) |
| Адаптация к режиму | Переобучение / онлайн-обновление | Дообучение на свежих данных |
| Качество на коротком горизонте | В целом сопоставимо с DL | Преимущество растет на длинных горизонтах / больших данных |
Мы сознательно избегаем приводить конкретные цифры R²: точность прогноза спреда сильно зависит от горизонта, актива и от того, какая часть оценки — это просто автокорреляция спреда. Модель может показать впечатляющий сырой R², добавляя при этом почти ничего сверх однострочного EWMA. Сообщайте качество сверх базовой модели AR/EWMA на тех же данных, с указанным горизонтом, а не заголовочный R². Аналогично относитесь к числам задержки как к зависящим от реализации: модель LightGBM с 2000 раундов и 63 листьями прогнозирует одну строку за десятки микросекунд из Python и доходит до нескольких микросекунд только с компилированным/C++ предиктором.
На практике многие продакшен-системы используют двухступенчатый подход: быструю модель градиентного бустинга для котирования в реальном времени (критичную к задержке) и модель глубокого обучения, работающую асинхронно, чтобы подстраивать параметры бустинговой модели или давать вторичный сигнал.
От прогноза к котированию

Прогноз спреда ценен, только если он превращается в более качественные котировки. Вот упрощенное правило котирования, использующее прогнозируемый спред:
def compute_quotes(
mid: float,
predicted_spread_bps: float,
inventory: float,
max_inventory: float,
skew_factor: float = 0.5,
min_spread_bps: float = 1.0,
) -> tuple[float, float]:
"""
Compute bid/ask quotes from predicted spread and inventory.
Parameters
----------
mid : float
Current midprice.
predicted_spread_bps : float
Model-predicted spread in basis points.
inventory : float
Current inventory (positive = long).
max_inventory : float
Maximum allowed inventory.
skew_factor : float
How aggressively to skew quotes toward inventory neutrality.
min_spread_bps : float
Minimum spread floor (covers order processing costs).
Returns
-------
(bid, ask) : tuple[float, float]
"""
spread_bps = max(predicted_spread_bps, min_spread_bps)
half_spread = mid * spread_bps / 2e4
inv_ratio = inventory / max_inventory # in [-1, 1]
skew = skew_factor * inv_ratio * half_spread
bid = mid - half_spread - skew
ask = mid + half_spread - skew
return bid, ask
Когда инвентарь длинный (), скос понижает и бид, и аск. С единой согласованной точки зрения: более низкий аск делает дешевле для тейкеров покупать у нас, что разгружает нашу длинную позицию; более низкий бид делает менее вероятным, что нас вынесут продавцы, замедляя дальнейшее накопление. Прогнозируемый спред управляет общей шириной — расширяя ее, когда модель ожидает волатильности или неблагоприятного отбора, и сужая, когда условия спокойны.
Оценка и бэктестинг

Метрики прогнозирования спреда
Помимо стандартных метрик регрессии (MAE, ), оценивайте прогнозы спреда метриками, которые важны для маркет-мейкинга:
- Качество сверх базовой модели: Всегда сообщайте MAE/R² относительно прогноза AR(1) или EWMA по недавним спредам. Поскольку спреды сильно персистентны, абсолютная оценка определяется автокорреляцией; только улучшение над тривиальной базовой моделью отражает реальное предсказательное содержание.
- Точность направления: Правильно ли модель предсказывает, расширится спред или сузится? Модель с посредственным MAE, но высокой точностью направления, все равно может быть прибыльной.
- Покрытие хвостов: Предсказывает ли модель всплески спреда? Вычислите MAE отдельно для верхних 5% значений спреда — именно там концентрируются убытки от неблагоприятного отбора.
- Калибровка: Постройте график квантилей прогнозируемого против реализованного спреда. Прогноз 90-го перцентиля у хорошо калиброванной модели должен совпадать с 90-м перцентилем реализованных спредов.
Оценка на основе PnL
В конечном счете единственная метрика, которая важна, — это реализованный PnL. Бэктестируйте полный цикл:
- На каждом таймстемпе прогнозируйте спред
- Вычисляйте котировки, используя прогнозируемый спред + скос по инвентарю
- Симулируйте исполнения против исторических сделок
- Отслеживайте инвентарь, реализованный PnL и коэффициент Шарпа
Сравнивайте с базовыми моделями: (a) постоянный спред (медиана временного ряда), (b) EWMA по недавним спредам и (c) оценщик Ролла.
Заключение
Моделирование спреда находится на пересечении финансовой теории и прикладного ML. Классическое разложение на издержки обработки ордеров, инвентаря и неблагоприятного отбора дает экономическую интуицию о том, почему спреды меняются. Модель Ролла дает элегантный базовый оценщик по минимуму данных — если вычислять его в ценовых единицах. Модели градиентного бустинга превращают микроструктурные признаки в точные прогнозы спреда на коротком горизонте с низкой задержкой инференса. Архитектуры глубокого обучения учатся прямо из сырых данных стакана, улавливая паттерны, которые могут упустить рукотворные признаки — при условии, что архитектура сохраняет межуровневую структуру, а не схлопывает ее пулингом.
Для продакшен-системы маркет-мейкинга практическая рекомендация многослойна:
- Используйте разложение Хуанга-Столла офлайн, чтобы понять компоненты вашего спреда и откалибровать лимиты риска
- Используйте оценщик Ролла как проверку на адекватность и для инструментов, где у вас нет данных стакана
- Разверните модель LightGBM для прогнозирования спреда в реальном времени — она быстра, интерпретируема и устойчива — с purged walk-forward валидацией и бенчмарком AR/EWMA
- Запускайте модель CNN-LSTM или Transformer во вторичном цикле, чтобы обнаруживать смены режима и подстраивать основную модель
Спред — это не число, это сигнал. Чем лучше вы его моделируете (и чем честнее измеряете эту модель), тем точнее можете оценивать предоставление ликвидности.
Этот пост — часть серии marketmaker.cc об алгоритмическом маркет-мейкинге и микроструктуре.
Авторы
Инженер торговых систем
Разработка торговых ботов с 2017 года: межбиржевой арбитраж (подключал до 30 бирж), парный арбитраж на коинтеграции между спотом и фьючерсами, скальпинг, фронтраннинг, торговля по новостям, сентиментный анализ, трендовые алгоритмы, а также алгоритмы управления и балансировки портфелей. Делает выставление ордеров до 1 мс, warehouse для big data, бэктестинг-движки, AI-агентов и интерфейсы для ботов (в т.ч. open-source profitmaker.cc). Стек: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, архитектура.