← К списку статей
June 3, 2026
5 мин. чтения

Моделирование и прогнозирование bid-ask спреда с помощью машинного обучения

Моделирование и прогнозирование bid-ask спреда с помощью машинного обучения
#microstructure
#spread
#market-making
#prediction
#machine-learning
#gradient-boosting
#deep-learning

Bid-ask спред — это самая важная переменная, которой управляет маркет-мейкер. Сделаете его слишком широким — и поток уйдет к конкурентам. Сделаете слишком узким — и неблагоприятный отбор (adverse selection) живьем съест ваш инвентарь. Классическая теория микроструктуры дает нам элегантное разложение спреда на его экономические компоненты. Машинное обучение дает инструменты, чтобы прогнозировать, как эти компоненты меняются в реальном времени. Этот пост соединяет оба мира: мы начнем с классической теории, выстроим неявный оценщик спреда Ролла, а затем перейдем к моделям градиентного бустинга и глубокого обучения, которые прогнозируют спред по признакам стакана. По пути мы отметим ловушки с единицами измерения, утечками данных и бенчмаркингом, которые незаметно обесценивают модели спреда на практике.

Почему спреды важны для маркет-мейкеров

Маркет-мейкер непрерывно котирует цену покупки PbP_b и цену продажи PaP_a. Котируемый спред равен:

S=PaPbS = P_a - P_b

Каждый полный оборот (купить по биду мейкера, продать по аску мейкера — обе сделки исполнены тейкерами) переносит до SS от тейкеров к мейкеру — в теории. На практике мейкер зарабатывает меньше SS из-за неблагоприятного отбора: часть тейкеров информирована и торгует прямо перед тем, как цена двинется против мейкера. Реализованная прибыль за один оборот — это реализованный спред (realized spread), равный эффективному спреду минус влияние на цену:

Srealized=SeffectivePriceImpactS_{\text{realized}} = S_{\text{effective}} - \text{PriceImpact}

Все три величины мы измеряем на одной и той же базе полного спреда (а не половины), так что тождество согласовано по размерности. Эффективный спред для отдельной сделки равен:

Seffective=2dt(PtMt)S_{\text{effective}} = 2 \cdot d_t \cdot (P_t - M_t)

Здесь dt{+1,1}d_t \in \{+1, -1\} — направление сделки (покупка или продажа со стороны тейкера), PtP_t — цена сделки, а MtM_t — середина лучшего бида и аска на момент сделки. Член влияния на цену определяется симметрично на горизонте после сделки τ\tau:

PriceImpact=2dt(Mt+τMt)\text{PriceImpact} = 2 \cdot d_t \cdot (M_{t+\tau} - M_t)

где Mt+τM_{t+\tau} — мид на горизонте τ\tau после сделки. Горизонт нужно указывать явно — типичные значения это 5 минут для акций и 30 секунд для крипты, где цены переоцениваются быстрее. Вычитание влияния на цену из эффективного спреда оставляет реализованный спред: то, что остается мейкеру после того, как рынок сдвинулся.

Маркет-мейкер, способный прогнозировать спред — и его компоненты — на следующие 1, 5 или 60 секунд, может динамически подстраивать котировки, максимизируя реализованный спред при сохранении доли исполнения.

Три компонента спреда

Bid-ask спред, разложенный на слои издержек: обработка ордеров, инвентарь и неблагоприятный отбор

Литература по микроструктуре рынка (Stoll 1978, Glosten and Milgrom 1985, Huang and Stoll 1997) раскладывает bid-ask спред на три экономических компонента.

1. Издержки обработки ордеров (α\alpha)

Это стоимость предоставления маркет-мейкерской услуги на каждую исполненную сторону: комиссия, которую мейкер реально платит, плюс технологическая инфраструктура, регуляторный комплаенс и альтернативная стоимость задействованного капитала. Demsetz (1968) и Tinic (1972) первыми формализовали этот компонент.

Ключевое различие — в том, кто какую комиссию платит. Мейкер, котирующий пассивно, платит комиссию мейкера fmf_m за свои исполнения — а на многих площадках fmf_m это ребейт (rebate), то есть отрицательная величина. Он не платит комиссию тейкера ftf_t за эти пассивные исполнения; ее платит контрагент, который пересекает спред. Таким образом, издержки обработки ордеров мейкера на одну сторону равны:

αfm+cinfra\alpha \approx f_m + c_{\text{infra}}

где fmf_m знаковая (ребейт снижает α\alpha и может сделать ее отрицательной), а cinfrac_{\text{infra}} покрывает связность, колокацию и вычисления. На современных электронных рынках этот компонент резко сжался — доли цента для акций, несколько базисных пунктов или чистый ребейт в крипте.

Комиссия тейкера ftf_t важна по другой причине: она задает нижнюю границу того, насколько узким может быть прибыльный полный спред, потому что тейкер, пересекающий спред, платит ftf_t поверх него. Если вам нужен нижний порог спреда, который сохраняет ваши котировки экономически привлекательными относительно этих издержек тейкера, обосновывайте его отдельно, а не вкатывайте ftf_t в собственные издержки мейкера. Смешивание этих двух величин дважды учитывает комиссию за полный оборот внутри одного полуспреда.

2. Издержки удержания инвентаря (β\beta)

Когда маркет-мейкер накапливает направленную позицию (длинную или короткую), он несет ценовой риск. Инвентарный компонент компенсирует этот риск. Stoll (1978), а также Amihud and Mendelson (1980) моделировали его как функцию волатильности и текущего инвентаря мейкера:

βσQ\beta \propto \sigma \cdot |Q|

где σ\sigma — волатильность актива, а QQ — текущий инвентарь мейкера. По мере роста инвентаря мейкер расширяет спред на той стороне, где он подвержен риску, и сужает на другой — этот прием называется скосом по инвентарю (inventory skewing).

3. Издержки неблагоприятного отбора (γ\gamma)

Это самый опасный компонент. Информированные трейдеры — те, кто обладает превосходной информацией о неизбежных движениях цены — систематически выбивают устаревшие котировки. Издержки неблагоприятного отбора равны ожидаемому убытку на сделку в пользу информированных контрагентов. Copeland and Galai (1983) моделировали это как стоимость бесплатного опциона, который мейкер дает информированным трейдерам. Glosten and Milgrom (1985) формализовали это как байесовский пересмотр убеждений мейкера после наблюдения сделки:

γ=E[Vtrade]Mt\gamma = E[V | \text{trade}] - M_t

где VV — истинная фундаментальная стоимость. На ликвидных рынках неблагоприятный отбор может составлять 30-60% всего спреда.

Полное разложение

Котируемый полуспред можно записать как:

S2=α+β+γ\frac{S}{2} = \alpha + \beta + \gamma

где α\alpha, β\beta и γ\gamma выражены как издержки на одну сторону (полуспред) — именно это сохраняет согласованность учета. Huang and Stoll (1997) предложили эконометрический метод оценки этих компонентов из данных о сделках и котировках. Ключевая мысль: издержки обработки ордеров создают фиксированный нижний порог спреда, инвентарные издержки создают спред, меняющийся с позицией и волатильностью, а издержки неблагоприятного отбора создают спред, меняющийся с информационной асимметрией.

Неявная модель спреда Ролла

Неявная модель спреда Ролла: цены сделок отскакивают между бидом и аском, оставляя след отрицательной серийной ковариации

До того как высокочастотные данные стали широко доступны, Richard Roll (1984) предложил элегантный метод оценки эффективного спреда, используя только цены сделок. Его идея: на эффективном рынке отскок между бидом и аском порождает отрицательную серийную ковариацию в изменениях цен, даже когда новой информации нет.

Модель

Предположим, что фундаментальная стоимость VtV_t следует случайному блужданию:

Vt=Vt1+ut,uti.i.d.(0,σu2)V_t = V_{t-1} + u_t, \quad u_t \sim \text{i.i.d.}(0, \sigma_u^2)

Наблюдаемая цена сделки отскакивает между бидом и аском:

Pt=Vt+S2dtP_t = V_t + \frac{S}{2} \cdot d_t

где dt{1,+1}d_t \in \{-1, +1\} с равной вероятностью (то есть покупки и продажи равновероятны). Изменение цены равно:

ΔPt=ut+S2(dtdt1)\Delta P_t = u_t + \frac{S}{2}(d_t - d_{t-1})

Вычисляя автоковариацию первого порядка:

Cov(ΔPt,ΔPt1)=S24\text{Cov}(\Delta P_t, \Delta P_{t-1}) = -\frac{S^2}{4}

Модель выведена в ценовых единицах: SS выпадает из автоковариации изменений цены, а не доходностей. Это различие — самая распространенная ошибка реализации, и ниже мы сохраняем код верным ему.

Оценщик Ролла

Решая относительно SS:

S^Roll=2Cov(ΔPt,ΔPt1)\hat{S}_{\text{Roll}} = 2\sqrt{-\text{Cov}(\Delta P_t, \Delta P_{t-1})}

Когда выборочная автоковариация положительна (что на практике случается часто из-за шума или моментума), оценщик не определен. Распространенное решение — приравнять оценку к нулю или использовать знаковый корень:

S^Roll=2sign(γ^1)γ^1\hat{S}_{\text{Roll}}^{*} = 2 \cdot \text{sign}(-\hat{\gamma}_1) \cdot \sqrt{|\hat{\gamma}_1|}

где γ^1\hat{\gamma}_1 — выборочная автоковариация первого порядка.

Реализация на 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 с истинным спредом S=0.10S = 0.10 — восстанавливает 0.0999\approx 0.0999 из изменений цены. Вариант на основе доходностей вернул бы 0.001\approx 0.001, отличаясь на величину уровня цены, и затем деление этого результата на мидпрайс еще раз для перевода в bps усугубляет ошибку. Если вы предпочитаете оценщик в пространстве доходностей, выведите модель в пространстве лог-цен и уберите второе деление на мидпрайс; выберите одно соглашение и приведите код в соответствие с математикой.

Ограничения модели Ролла

Модель Ролла предполагает: (1) эффективность рынка, (2) отсутствие информационной асимметрии, (3) i.i.d.-направление сделок и (4) постоянный спред. Все эти предпосылки нарушаются на практике. Harris (1990) показал, что оценщик серьезно смещен из-за неравенства Йенсена при применении к зашумленным данным. Несмотря на эти ограничения, оценщик Ролла остается полезным как быстрый базовый ориентир и широко используется в эмпирических финансовых исследованиях.

ML-признаки для прогнозирования спреда

Сконструированные микроструктурные признаки — волатильность, дисбаланс потока ордеров, глубина, интенсивность сделок — подаются в предиктор спреда

Чтобы выйти за рамки статических моделей, нужны признаки, которые улавливают динамические драйверы вариации спреда. Вот таксономия признаков, организованная по тому компоненту спреда, который они аппроксимируют.

Признаки стакана (инвентарь и неблагоприятный отбор)

Признак Формула Аппроксимирует
Дисбаланс стакана BI=Vb1Va1Vb1+Va1\text{BI} = \frac{V_b^1 - V_a^1}{V_b^1 + V_a^1} Направленное давление
Взвешенный мидпрайс Pw=PaVbVb+Va+PbVaVb+VaP_w = P_a \cdot \frac{V_b}{V_b + V_a} + P_b \cdot \frac{V_a}{V_b + V_a} Краткосрочная справедливая стоимость
Отношение глубины (уровни 1-5) DR5=i=15Vbii=15Vai\text{DR}_5 = \frac{\sum_{i=1}^{5} V_b^i}{\sum_{i=1}^{5} V_a^i} Многоуровневое предложение/спрос
Давление стакана BP=i=15Vbiwibi=15Vaiwia\text{BP} = \sum_{i=1}^{5} V_b^i\, w_i^b - \sum_{i=1}^{5} V_a^i\, w_i^a Давление, взвешенное по расстоянию
Отношение спред / тик S/tickS / \text{tick} Узость относительно минимума

Давление стакана здесь использует затухание по абсолютному расстоянию до мида, wi=eλPiMw_i = e^{-\lambda |P_i - M|}, так что объем вблизи лучшей цены весит больше, чем глубокий объем, и обе стороны взвешиваются положительной убывающей функцией. Это позволяет избежать структурного смещения знака, возникающего при делении объема на знаковое расстояние PiMP_i - M (которое отрицательно на стороне бида, положительно на стороне аска и взрывается, когда уровень приближается к миду). Выберите λ\lambda исходя из типичной глубины стакана или замените экспоненту любым положительным весом w(PiM)w(|P_i - M|), убывающим по расстоянию.

Признаки потока сделок (неблагоприятный отбор)

Признак Формула Аппроксимирует
Дисбаланс сделок TIn=i=1ndivii=1nvi\text{TI}_{n} = \frac{\sum_{i=1}^{n} d_i \cdot v_i}{\sum_{i=1}^{n} v_i} Чистый информированный поток
VPIN Объемно-синхронизированная вероятность информированной торговли Токсичность
Лямбда Кайла Регрессия ΔM\Delta M на знаковый объем Влияние на цену на единицу
Частота крупных сделок Число сделок >kmedian> k \cdot \text{median} в окне Институциональная активность

Признаки волатильности (инвентарные издержки)

Признак Формула Аппроксимирует
Реализованная волатильность σrv=(ΔlogM)2\sigma_{\text{rv}} = \sqrt{\sum (\Delta \log M)^2} Краткосрочный риск
Волатильность Гармана-Класса 12(logH/L)2(2ln21)(logC/O)2\frac{1}{2}(\log H/L)^2 - (2\ln 2 - 1)(\log C/O)^2 Волатильность по диапазону
Волатильность волатильности Скользящее std от σrv\sigma_{\text{rv}} Неопределенность режима
Автокорреляция доходностей ρ1(ΔM)\rho_1(\Delta M) Моментум / возврат к среднему

Признаки рыночного режима

Признак Описание Аппроксимирует
Кодирование времени суток sin(2πt/T),cos(2πt/T)\sin(2\pi t / T), \cos(2\pi t / T) Внутридневная сезонность
Секунды с последней сделки Временной разрыв Уровень активности
Кросс-активная корреляция Скользящая корреляция с индексом/BTC Систематический риск
Ставка финансирования (крипта) Ставка финансирования перпетуала Кредитное позиционирование

Градиентный бустинг для прогнозирования спреда

Деревья градиентного бустинга (XGBoost, LightGBM, CatBoost) — рабочая лошадка табличного прогнозирования в количественных финансах. Они работают со смешанными типами признаков, улавливают нелинейные взаимодействия, требуют минимальной предобработки и быстро обучаются на миллионах строк — при условии, что само построение признаков векторизовано (см. примечание об автокорреляции ниже).

Постановка задачи

Мы формулируем прогнозирование спреда как задачу регрессии. Целевая переменная — это взвешенный по времени средний котируемый спред за следующие τ\tau секунд:

yt=1τtt+τS(u)duy_t = \frac{1}{\tau} \int_{t}^{t+\tau} S(u) \, du

На практике мы аппроксимируем это взвешенным по объему средним спредом за следующие NN снимков:

yt=i=1NSt+iVt+ii=1NVt+iy_t = \frac{\sum_{i=1}^{N} S_{t+i} \cdot V_{t+i}}{\sum_{i=1}^{N} V_{t+i}}

Эта целевая переменная — прямое окно (forward window) длины NN (или 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)

Типичные находки в разных классах активов:

  1. Лагированный спред (spread_lag_1\text{spread\_lag\_1}) почти всегда самый важный признак — спреды сильно автокоррелированы. Именно поэтому заголовочный R² выглядит высоким: значительная часть оценки — это просто персистентность, поэтому всегда сравнивайте с базовой моделью AR/EWMA (об этом подробнее ниже).
  2. Реализованная волатильность — второй по важности — внутридневная волатильность и спреды сильно положительно коррелируют как одновременно, так и динамически.
  3. Дисбаланс стакана важнее всего в волатильные периоды — он сигнализирует о неизбежных направленных движениях.
  4. Дисбаланс потока сделок улавливает краткосрочный неблагоприятный отбор — всплеск одностороннего потока предсказывает расширение спреда.
  5. Время суток улавливает U-образный внутридневной паттерн (шире на открытии/закрытии, уже в середине дня).

Соображения по гиперпараметрам

Применительно конкретно к прогнозированию спреда:

  • Используйте MAE или потерю Хьюбера вместо MSE. Распределения спреда скошены вправо с редкими экстремальными выбросами (во время новостных событий). MAE более устойчива.
  • Установите min_child_samples высоким (100+), чтобы модель не подгонялась под микроструктурный шум в отдельных снимках.
  • Используйте subsample < 1.0, чтобы декоррелировать деревья и улучшить обобщение по разным режимам волатильности.

Подходы на основе глубокого обучения

Хотя градиентный бустинг превосходен на табличных признаках, глубокое обучение может выучивать представления прямо из сырых данных стакана. Две архитектуры доказали свою эффективность в задачах прогнозирования, связанных со спредом.

Архитектура 1: CNN-LSTM для снимков стакана

Архитектура DeepLOB (Zhang et al. 2019) использует стек сверток с малым ядром — и модуль Inception — для извлечения пространственных паттернов по уровням стакана с сохранением этой пространственной структуры, за которыми следуют слои LSTM для моделирования временных зависимостей. Важное проектное решение — не схлопывать ось уровней глобальным пулингом перед рекуррентным слоем: это уничтожает ровно ту межуровневую структуру, которую свертки и призваны улавливать.

Для прогнозирования спреда вход — это тензор формы (T,L,F)(T, L, F):

  • TT = число временных шагов (например, 100 снимков)
  • LL = число ценовых уровней (например, 10 бид + 10 аск = 20)
  • FF = признаки на уровень (цена, объем, число ордеров)

Модель ниже сохраняет сверточную карту признаков по уровням и разворачивает ее во вход 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, :])

Соображения по обучению

  1. Нормализация: Нормализуйте цены как смещения от мидпрайса (в тиках или bps). Нормализуйте объемы по их скользящему среднему. Сырые цены и объемы вызывают нестабильность обучения.

  2. Функция потерь: Используйте потерю Хьюбера (δ=1.0\delta = 1.0) для обработки всплесков спреда:

Lδ(y,y^)={12(yy^)2if yy^δδyy^12δ2otherwiseL_\delta(y, \hat{y}) = \begin{cases} \frac{1}{2}(y - \hat{y})^2 & \text{if } |y - \hat{y}| \le \delta \\ \delta |y - \hat{y}| - \frac{1}{2}\delta^2 & \text{otherwise} \end{cases}

  1. Выборка окон и утечка данных: Используйте непересекающиеся окна для обучения и — ровно как в конвейере градиентного бустинга — применяйте purge/embargo с разрывом как минимум в horizon снимков между train и validation. И целевая переменная как прямое среднее, и пересекающиеся входные окна утекают будущую информацию через границы разбиения и завышают кажущуюся производительность.

  2. Онлайн-адаптация: В продакшене периодически дообучайте модель на свежих данных (последние 1-2 часа) с малым learning rate. Микроструктура рынка меняется в течение дня, и модель, обученная на утренних данных, может работать хуже днем.

Когда использовать глубокое обучение, а когда градиентный бустинг

Критерий Градиентный бустинг Глубокое обучение
Тип входа Табличные признаки Сырые последовательности стакана
Размер обучающих данных Работает на 100K+ строк Нужно 1M+ строк
Конструирование признаков Ручное (высокие усилия, высокий контроль) Выучивается (меньше усилий, меньше интерпретируемости)
Задержка инференса Единицы µs с компилированным предиктором; десятки µs из Python Сотни µs на GPU
Интерпретируемость Высокая (SHAP) Низкая (карты внимания)
Адаптация к режиму Переобучение / онлайн-обновление Дообучение на свежих данных
Качество на коротком горизонте В целом сопоставимо с DL Преимущество растет на длинных горизонтах / больших данных

Мы сознательно избегаем приводить конкретные цифры R²: точность прогноза спреда сильно зависит от горизонта, актива и от того, какая часть оценки — это просто автокорреляция спреда. Модель может показать впечатляющий сырой R², добавляя при этом почти ничего сверх однострочного EWMA. Сообщайте качество сверх базовой модели AR/EWMA на тех же данных, с указанным горизонтом, а не заголовочный R². Аналогично относитесь к числам задержки как к зависящим от реализации: модель LightGBM с 2000 раундов и 63 листьями прогнозирует одну строку за десятки микросекунд из Python и доходит до нескольких микросекунд только с компилированным/C++ предиктором.

На практике многие продакшен-системы используют двухступенчатый подход: быструю модель градиентного бустинга для котирования в реальном времени (критичную к задержке) и модель глубокого обучения, работающую асинхронно, чтобы подстраивать параметры бустинговой модели или давать вторичный сигнал.

От прогноза к котированию

Превращение прогнозируемого спреда в живые bid/ask котировки, скошенные по инвентарю вокруг справедливой средней цены

Прогноз спреда ценен, только если он превращается в более качественные котировки. Вот упрощенное правило котирования, использующее прогнозируемый спред:

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

Когда инвентарь длинный (Q>0Q > 0), скос понижает и бид, и аск. С единой согласованной точки зрения: более низкий аск делает дешевле для тейкеров покупать у нас, что разгружает нашу длинную позицию; более низкий бид делает менее вероятным, что нас вынесут продавцы, замедляя дальнейшее накопление. Прогнозируемый спред управляет общей шириной — расширяя ее, когда модель ожидает волатильности или неблагоприятного отбора, и сужая, когда условия спокойны.

Оценка и бэктестинг

Оценка модели спреда: калибровка прогнозируемого-против-реализованного спреда рядом с измерителем доли исполнения и PnL

Метрики прогнозирования спреда

Помимо стандартных метрик регрессии (MAE, R2R^2), оценивайте прогнозы спреда метриками, которые важны для маркет-мейкинга:

  • Качество сверх базовой модели: Всегда сообщайте MAE/R² относительно прогноза AR(1) или EWMA по недавним спредам. Поскольку спреды сильно персистентны, абсолютная оценка определяется автокорреляцией; только улучшение над тривиальной базовой моделью отражает реальное предсказательное содержание.
  • Точность направления: Правильно ли модель предсказывает, расширится спред или сузится? Модель с посредственным MAE, но высокой точностью направления, все равно может быть прибыльной.
  • Покрытие хвостов: Предсказывает ли модель всплески спреда? Вычислите MAE отдельно для верхних 5% значений спреда — именно там концентрируются убытки от неблагоприятного отбора.
  • Калибровка: Постройте график квантилей прогнозируемого против реализованного спреда. Прогноз 90-го перцентиля у хорошо калиброванной модели должен совпадать с 90-м перцентилем реализованных спредов.

Оценка на основе PnL

В конечном счете единственная метрика, которая важна, — это реализованный PnL. Бэктестируйте полный цикл:

  1. На каждом таймстемпе прогнозируйте спред
  2. Вычисляйте котировки, используя прогнозируемый спред + скос по инвентарю
  3. Симулируйте исполнения против исторических сделок
  4. Отслеживайте инвентарь, реализованный PnL и коэффициент Шарпа

Сравнивайте с базовыми моделями: (a) постоянный спред (медиана временного ряда), (b) EWMA по недавним спредам и (c) оценщик Ролла.

Заключение

Моделирование спреда находится на пересечении финансовой теории и прикладного ML. Классическое разложение на издержки обработки ордеров, инвентаря и неблагоприятного отбора дает экономическую интуицию о том, почему спреды меняются. Модель Ролла дает элегантный базовый оценщик по минимуму данных — если вычислять его в ценовых единицах. Модели градиентного бустинга превращают микроструктурные признаки в точные прогнозы спреда на коротком горизонте с низкой задержкой инференса. Архитектуры глубокого обучения учатся прямо из сырых данных стакана, улавливая паттерны, которые могут упустить рукотворные признаки — при условии, что архитектура сохраняет межуровневую структуру, а не схлопывает ее пулингом.

Для продакшен-системы маркет-мейкинга практическая рекомендация многослойна:

  1. Используйте разложение Хуанга-Столла офлайн, чтобы понять компоненты вашего спреда и откалибровать лимиты риска
  2. Используйте оценщик Ролла как проверку на адекватность и для инструментов, где у вас нет данных стакана
  3. Разверните модель LightGBM для прогнозирования спреда в реальном времени — она быстра, интерпретируема и устойчива — с purged walk-forward валидацией и бенчмарком AR/EWMA
  4. Запускайте модель CNN-LSTM или Transformer во вторичном цикле, чтобы обнаруживать смены режима и подстраивать основную модель

Спред — это не число, это сигнал. Чем лучше вы его моделируете (и чем честнее измеряете эту модель), тем точнее можете оценивать предоставление ликвидности.


Этот пост — часть серии marketmaker.cc об алгоритмическом маркет-мейкинге и микроструктуре.

Дисклеймер: Информация в этой статье предоставлена исключительно в образовательных и ознакомительных целях и не является финансовым, инвестиционным или торговым советом. Торговля криптовалютами сопряжена с высоким риском убытков.

Авторы

Eugen Soloviov
Eugen Soloviov

Инженер торговых систем

Разработка торговых ботов с 2017 года: межбиржевой арбитраж (подключал до 30 бирж), парный арбитраж на коинтеграции между спотом и фьючерсами, скальпинг, фронтраннинг, торговля по новостям, сентиментный анализ, трендовые алгоритмы, а также алгоритмы управления и балансировки портфелей. Делает выставление ордеров до 1 мс, warehouse для big data, бэктестинг-движки, AI-агентов и интерфейсы для ботов (в т.ч. open-source profitmaker.cc). Стек: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, архитектура.

Newsletter

Будьте в курсе событий

Подпишитесь на нашу рассылку, чтобы получать эксклюзивную аналитику по AI-трейдингу и обновления платформы.

Мы уважаем вашу конфиденциальность. Отписаться можно в любой момент.