Mô hình hóa và dự báo spread bid-ask bằng Machine Learning
Spread bid-ask là biến số quan trọng nhất mà một market maker kiểm soát. Đặt quá rộng thì bạn mất dòng lệnh vào tay đối thủ. Đặt quá hẹp thì lựa chọn bất lợi (adverse selection) sẽ ngấu nghiến tồn kho của bạn. Lý thuyết vi cấu trúc thị trường (microstructure) truyền thống cho ta những cách phân rã tinh tế spread thành các thành phần kinh tế của nó. Machine learning cho ta công cụ để dự báo các thành phần đó dịch chuyển ra sao theo thời gian thực. Bài viết này bắc cầu giữa hai thế giới đó: ta bắt đầu với lý thuyết cổ điển, xây dựng đến ước lượng spread ẩn của Roll, rồi chuyển sang các mô hình gradient boosting và deep learning dự báo spread từ các đặc trưng của sổ lệnh (order book). Trên đường đi, ta sẽ chỉ ra những cái bẫy về đơn vị, rò rỉ dữ liệu (leakage) và benchmarking vốn âm thầm làm vô hiệu hóa các mô hình spread trong thực tế.
Vì sao spread quan trọng với market maker
Một market maker liên tục yết giá mua (bid) và giá bán (ask) . Spread được yết là:
Mỗi vòng giao dịch khứ hồi (mua tại bid của maker, bán tại ask của maker, cả hai đều được khớp bởi taker) chuyển tối đa từ các taker sang maker — về lý thuyết. Trên thực tế, maker kiếm được ít hơn vì adverse selection: một số taker có thông tin và giao dịch ngay trước khi giá dịch chuyển bất lợi cho maker. Lợi nhuận thực hiện được trên mỗi vòng khứ hồi là realized spread (spread thực hiện), bằng effective spread trừ đi tác động giá (price impact):
Ta đo cả ba đại lượng trên cùng một cơ sở full-spread (spread đầy đủ, không phải nửa), nên đẳng thức nhất quán về thứ nguyên. Effective spread cho một giao dịch đơn lẻ là:
Ở đây là chiều giao dịch (taker mua hay bán), là giá giao dịch, và là điểm giữa (midpoint) của best bid và best ask tại thời điểm giao dịch. Thành phần price impact được định nghĩa đối xứng trên một khoảng thời gian sau giao dịch :
trong đó là midprice tại khoảng sau giao dịch. Khoảng thời gian này phải được nêu rõ ràng — lựa chọn thường gặp là 5 phút với cổ phiếu và 30 giây với crypto, nơi giá định giá lại nhanh hơn. Trừ price impact khỏi effective spread cho ta realized spread: phần mà maker giữ lại sau khi thị trường đã dịch chuyển.
Một market maker có khả năng dự báo spread — cùng các thành phần của nó — trong 1, 5 hay 60 giây tiếp theo có thể điều chỉnh động các báo giá nhằm tối đa hóa realized spread trong khi vẫn duy trì tỷ lệ khớp lệnh (fill rate).
Ba thành phần của spread

Tài liệu về vi cấu trúc thị trường (Stoll 1978, Glosten và Milgrom 1985, Huang và Stoll 1997) phân rã spread bid-ask thành ba thành phần kinh tế.
1. Chi phí xử lý lệnh ()
Đây là chi phí cung cấp dịch vụ tạo lập thị trường trên mỗi phía được khớp: phí mà maker thực sự trả cộng với hạ tầng công nghệ, tuân thủ quy định, và chi phí cơ hội của vốn được triển khai. Demsetz (1968) và Tinic (1972) là những người đầu tiên hình thức hóa thành phần này.
Điểm phân biệt then chốt là ai trả phí nào. Một maker yết giá thụ động trả phí maker trên các lệnh được khớp của chính mình — và trên nhiều sàn thì là một khoản rebate (hoàn phí), tức là âm. Nó không trả phí taker trên các lệnh thụ động đó; phía đối ứng vượt qua spread mới là người trả . Vậy chi phí xử lý lệnh trên mỗi phía của maker là:
trong đó có dấu (rebate làm giảm và có thể khiến nó âm) và bao gồm kết nối, colocation và tính toán. Trong các thị trường điện tử hiện đại, thành phần này đã thu nhỏ đáng kể — dưới một xu (sub-penny) với cổ phiếu, vài điểm cơ bản (basis point) hoặc một khoản rebate ròng với crypto.
Phí taker quan trọng vì một lý do khác: nó đặt một mức sàn cho việc spread đầy đủ có thể hẹp đến đâu mà vẫn có lãi, bởi một taker vượt qua spread phải trả cộng thêm vào spread. Nếu bạn muốn một mức sàn spread giữ cho báo giá của bạn hấp dẫn về mặt kinh tế so với chi phí taker đó, hãy lý giải nó riêng rẽ thay vì gộp vào chi phí của chính maker. Trộn lẫn hai thứ này sẽ đếm trùng phí của một vòng khứ hồi bên trong một nửa spread duy nhất.
2. Chi phí nắm giữ tồn kho ()
Khi một market maker tích lũy một vị thế có hướng (long hay short), họ gánh rủi ro giá. Thành phần tồn kho bù đắp cho rủi ro này. Stoll (1978) cùng Amihud và Mendelson (1980) mô hình hóa nó như một hàm của độ biến động (volatility) và tồn kho hiện tại của maker:
trong đó là độ biến động của tài sản và là tồn kho hiện tại của maker. Khi tồn kho tăng lên, maker nới rộng spread ở phía mình đang phơi nhiễm và thu hẹp ở phía kia, một kỹ thuật gọi là inventory skewing (lệch báo giá theo tồn kho).
3. Chi phí lựa chọn bất lợi ()
Đây là thành phần nguy hiểm nhất. Các nhà giao dịch có thông tin — những người có thông tin vượt trội về các biến động giá sắp xảy ra — một cách hệ thống "nhặt" các báo giá cũ kỹ (stale quote). Chi phí lựa chọn bất lợi bằng với khoản lỗ kỳ vọng trên mỗi giao dịch với các đối tác có thông tin. Copeland và Galai (1983) mô hình hóa nó như giá trị của một quyền chọn miễn phí mà maker trao cho các nhà giao dịch có thông tin. Glosten và Milgrom (1985) hình thức hóa nó như sự điều chỉnh Bayes trong niềm tin của maker sau khi quan sát một giao dịch:
trong đó là giá trị cơ bản thực. Ở các thị trường có thanh khoản cao, lựa chọn bất lợi có thể chiếm 30-60% tổng spread.
Phân rã đầy đủ
Nửa spread được yết có thể viết là:
với , và đều được biểu diễn dưới dạng chi phí trên mỗi phía (nửa spread) — đó là điều giữ cho phép hạch toán nhất quán. Huang và Stoll (1997) đề xuất một phương pháp kinh tế lượng để ước lượng các thành phần này từ dữ liệu giao dịch và báo giá. Nhận định then chốt: chi phí xử lý lệnh tạo ra một mức sàn spread cố định, chi phí tồn kho tạo ra một spread biến thiên theo vị thế và độ biến động, còn chi phí lựa chọn bất lợi tạo ra một spread biến thiên theo bất cân xứng thông tin.
Mô hình spread ẩn của Roll

Trước khi dữ liệu tần suất cao trở nên phổ biến rộng rãi, Richard Roll (1984) đề xuất một phương pháp tinh tế để ước lượng effective spread chỉ dùng giá giao dịch. Nhận định của ông: trong một thị trường hiệu quả, sự nảy bid-ask (bid-ask bounce) tạo ra hiệp phương sai chuỗi âm trong các thay đổi giá, ngay cả khi không có thông tin mới.
Mô hình
Giả định giá trị cơ bản tuân theo một bước đi ngẫu nhiên (random walk):
Giá giao dịch quan sát được nảy qua lại giữa bid và ask:
trong đó với xác suất bằng nhau (tức là, mua và bán có khả năng như nhau). Thay đổi giá là:
Tính hiệp phương sai bậc một (first-order autocovariance):
Mô hình được suy ra theo đơn vị giá: thoát ra từ hiệp phương sai của các thay đổi giá, chứ không phải lợi suất (return). Sự phân biệt này là lỗi triển khai phổ biến nhất, và ta giữ cho mã nguồn bên dưới trung thành với nó.
Ước lượng Roll
Giải tìm :
Khi hiệp phương sai mẫu dương (điều này xảy ra thường xuyên trong thực tế do nhiễu hay đà tăng/giảm), ước lượng không xác định. Một cách khắc phục phổ biến là đặt ước lượng bằng không hoặc dùng căn có dấu:
trong đó là hiệp phương sai bậc một của mẫu.
Triển khai bằng Python
Ước lượng trả về một spread theo đơn vị giá. Để biểu diễn nó theo điểm cơ bản (basis point), ta chia cho midprice một lần — bởi vì, khác với một ước lượng trong không gian lợi suất, nó chưa được chia cho giá:
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
Một phép kiểm tra nhanh tính hợp lý trên một chuỗi mô phỏng — một bước đi ngẫu nhiên cơ bản quanh mức giá 100 với spread thực — thu hồi được từ các thay đổi giá. Biến thể dựa trên lợi suất sẽ trả về , lệch đi đúng bằng mức giá, và rồi chia con số đó cho midprice một lần nữa để ra bps sẽ làm lỗi nhân lên. Nếu bạn ưa một ước lượng trong không gian lợi suất, hãy suy ra mô hình trong không gian log-giá và bỏ phép chia thứ hai cho midprice; chọn một quy ước và làm cho mã nguồn khớp với công thức toán.
Hạn chế của mô hình Roll
Mô hình Roll giả định: (1) thị trường hiệu quả, (2) không có bất cân xứng thông tin, (3) chiều giao dịch i.i.d., và (4) spread không đổi. Tất cả những điều này đều bị vi phạm trong thực tế. Harris (1990) chỉ ra rằng ước lượng bị thiên lệch nghiêm trọng do bất đẳng thức Jensen khi áp dụng cho dữ liệu nhiễu. Mặc dù có những hạn chế này, ước lượng Roll vẫn hữu ích như một baseline nhanh và được sử dụng rộng rãi trong nghiên cứu tài chính thực nghiệm.
Các đặc trưng ML để dự báo spread

Để vượt ra ngoài các mô hình tĩnh, ta cần những đặc trưng nắm bắt được các động lực biến thiên của spread theo thời gian. Sau đây là một phân loại các đặc trưng được tổ chức theo thành phần spread mà chúng đại diện (proxy).
Đặc trưng sổ lệnh (Tồn kho & Lựa chọn bất lợi)
| Đặc trưng | Công thức | Đại diện cho |
|---|---|---|
| Mất cân bằng sổ lệnh | Áp lực có hướng | |
| Mid-price gia quyền | Giá trị hợp lý ngắn hạn | |
| Tỷ lệ độ sâu (mức 1-5) | Cung/cầu đa mức | |
| Áp lực sổ lệnh | Áp lực gia quyền theo khoảng cách | |
| Tỷ lệ spread / tick | Độ hẹp tương đối so với mức tối thiểu |
Áp lực sổ lệnh ở đây dùng một suy giảm theo khoảng cách tuyệt đối tới mid, , nên khối lượng nằm gần touch (mức giá tốt nhất) được tính nặng hơn khối lượng nằm sâu, và cả hai phía đều được gia quyền bởi một hàm dương, giảm dần. Cách này tránh được thiên lệch dấu mang tính cấu trúc khi chia khối lượng cho khoảng cách có dấu (vốn âm ở phía bid, dương ở phía ask, và bùng nổ khi một mức tiến gần tới mid). Hãy chọn từ độ sâu điển hình của sổ lệnh, hoặc thay hàm mũ bằng bất kỳ trọng số dương nào giảm dần theo khoảng cách.
Đặc trưng dòng giao dịch (Lựa chọn bất lợi)
| Đặc trưng | Công thức | Đại diện cho |
|---|---|---|
| Mất cân bằng giao dịch | Dòng có thông tin ròng | |
| VPIN | Xác suất giao dịch có thông tin đồng bộ theo khối lượng | Độ độc hại (toxicity) |
| Lambda của Kyle | Hồi quy của theo khối lượng có dấu | Tác động giá trên mỗi đơn vị |
| Tần suất giao dịch lớn | Số giao dịch trong cửa sổ | Hoạt động của tổ chức |
Đặc trưng độ biến động (Chi phí tồn kho)
| Đặc trưng | Công thức | Đại diện cho |
|---|---|---|
| Độ biến động thực hiện | Rủi ro ngắn hạn | |
| Vol Garman-Klass | Vol dựa trên biên độ | |
| Vol-of-vol | Độ lệch chuẩn cuộn của | Bất định chế độ (regime) |
| Tự tương quan lợi suất | Đà tăng/giảm (momentum) / hồi quy về trung bình |
Đặc trưng chế độ thị trường
| Đặc trưng | Mô tả | Đại diện cho |
|---|---|---|
| Mã hóa thời điểm trong ngày | Tính mùa vụ nội ngày | |
| Số giây kể từ giao dịch cuối | Khoảng cách thời gian | Mức độ hoạt động |
| Tương quan chéo tài sản | Tương quan cuộn với chỉ số/BTC | Rủi ro hệ thống |
| Funding rate (crypto) | Funding rate của hợp đồng perp | Định vị đòn bẩy |
Gradient Boosting để dự báo spread
Cây gradient boosting (XGBoost, LightGBM, CatBoost) là chủ lực của dự báo dạng bảng (tabular) trong tài chính định lượng. Chúng xử lý được các kiểu đặc trưng hỗn hợp, nắm bắt được các tương tác phi tuyến, đòi hỏi tiền xử lý tối thiểu, và huấn luyện nhanh trên hàng triệu dòng — miễn là bản thân việc dựng đặc trưng được vector hóa (xem ghi chú về tự tương quan bên dưới).
Phát biểu bài toán
Ta đặt dự báo spread như một bài toán hồi quy. Mục tiêu (target) là spread được yết trung bình gia quyền theo thời gian trong giây tiếp theo:
Trong thực tế, ta xấp xỉ điều này bằng spread trung bình gia quyền theo khối lượng trên snapshot tiếp theo:
Mục tiêu này là một cửa sổ tiến (forward window) độ dài (hay horizon), nghĩa là các dòng liền kề chia sẻ những cửa sổ tương lai chồng lấn nhau. Sự chồng lấn đó làm rò rỉ thông tin qua một phân chia train/validation ngây thơ — ta xử lý nó một cách tường minh trong mã huấn luyện bên dưới.
Toàn bộ pipeline
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
Chi tiết then chốt là khoảng purge/embargo. Mục tiêu trung bình tiến (forward-mean) nghĩa là các dòng liền kề chồng lấn tới horizon snapshot, nên một TimeSeriesSplit đơn thuần cho phép các dòng validation chia sẻ các cửa sổ tương lai với các dòng training — làm rò rỉ đáp án và thổi phồng R² của validation. Bỏ đi một khoảng ít nhất horizon dòng ở cả hai phía của mỗi ranh giới fold (purged k-fold kiểu Lopez de Prado) loại bỏ thiên lệch đó. Điều này áp dụng cho pipeline gradient boosting hệt như cho deep learning, dù rò rỉ thường được bàn đến nhiều hơn với các mô hình chuỗi (sequence model).
Phân tích tầm quan trọng của đặc trưng
Một trong những lợi thế chính của các mô hình dựa trên cây là khả năng diễn giải. Sau khi huấn luyện, hãy xem xét các giá trị SHAP để hiểu đặc trưng nào dẫn dắt các dự báo spread:
import shap
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_val)
shap.summary_plot(shap_values, X_val, max_display=15)
Những phát hiện điển hình qua các lớp tài sản:
- Spread trễ () hầu như luôn là đặc trưng quan trọng nhất — spread có tự tương quan cao. Đây cũng là lý do R² tiêu đề trông cao: phần lớn điểm số chỉ là tính dai dẳng (persistence), nên hãy luôn benchmark so với một baseline AR/EWMA (chi tiết hơn ở dưới).
- Độ biến động thực hiện là đặc trưng quan trọng thứ hai — độ biến động nội ngày và spread tương quan dương mạnh mẽ, cả đồng thời lẫn theo thời gian.
- Mất cân bằng sổ lệnh quan trọng nhất trong các giai đoạn biến động — nó báo hiệu các biến động có hướng sắp xảy ra.
- Mất cân bằng dòng giao dịch nắm bắt lựa chọn bất lợi ngắn hạn — một đợt bùng nổ dòng lệnh một chiều dự báo spread nới rộng.
- Thời điểm trong ngày nắm bắt mẫu nội ngày hình chữ U (rộng hơn lúc mở/đóng cửa, hẹp hơn giữa ngày).
Cân nhắc về siêu tham số
Riêng cho dự báo spread:
- Dùng MAE hoặc Huber loss thay vì MSE. Phân phối spread lệch phải với những giá trị ngoại lai cực đoan thỉnh thoảng (trong các sự kiện tin tức). MAE bền vững hơn.
- Đặt
min_child_samplescao (100+) để ngăn mô hình khớp vào nhiễu vi cấu trúc trong các snapshot riêng lẻ. - Dùng
subsample < 1.0để giảm tương quan giữa các cây và cải thiện khả năng tổng quát hóa qua các chế độ biến động khác nhau.
Các phương pháp Deep Learning
Trong khi gradient boosting xuất sắc với các đặc trưng dạng bảng, deep learning có thể học các biểu diễn trực tiếp từ dữ liệu sổ lệnh thô. Hai kiến trúc đã chứng tỏ hiệu quả cho các bài toán dự báo liên quan đến spread.
Kiến trúc 1: CNN-LSTM cho các snapshot sổ lệnh
Kiến trúc DeepLOB (Zhang et al. 2019) dùng các tích chập kernel nhỏ xếp chồng — và một module Inception — để trích xuất các mẫu không gian qua các mức của sổ lệnh trong khi bảo toàn cấu trúc không gian đó, theo sau là các lớp LSTM để mô hình hóa các phụ thuộc theo thời gian. Lựa chọn thiết kế quan trọng là không global-pool trục mức (level axis) đi trước lớp hồi quy: làm vậy sẽ làm sụp đổ đúng cấu trúc liên-mức mà các tích chập định nắm bắt.
Với dự báo spread, đầu vào là một tensor có hình dạng :
- = số bước thời gian (ví dụ, 100 snapshot)
- = số mức giá (ví dụ, 10 bid + 10 ask = 20)
- = đặc trưng trên mỗi mức (giá, khối lượng, số lệnh)
Mô hình bên dưới giữ lại bản đồ đặc trưng tích chập trên các mức và trải phẳng (flatten) nó vào đầu vào LSTM (input_size = 16 * L), thay vì lấy trung bình chiều mức thành 16 giá trị trung bình kênh:
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)
Nếu bạn thực sự muốn pooling trên trục mức để kiểm soát số lượng tham số, hãy dùng pooling có bước nhảy (strided) hoặc pooling học được vốn giữ lại nhiều hơn một vị trí — chứ không phải AdaptiveAvgPool1d(1), vốn lấy trung bình mọi mức thành một con số và vứt bỏ tín hiệu không gian.
Kiến trúc 2: Transformer Encoder
Transformer có thể nắm bắt các phụ thuộc tầm xa trong các chuỗi sổ lệnh mà không gặp nút thắt tuần tự của LSTM. Với dự báo spread, một transformer encoder nhẹ hoạt động tốt:
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, :])
Cân nhắc khi huấn luyện
-
Chuẩn hóa: Chuẩn hóa giá dưới dạng độ lệch so với midprice (theo tick hoặc bps). Chuẩn hóa khối lượng theo trung bình cuộn của chúng. Giá và khối lượng thô gây bất ổn khi huấn luyện.
-
Hàm mất mát: Dùng Huber loss () để xử lý các đột biến spread:
-
Lấy mẫu cửa sổ và rò rỉ: Dùng các cửa sổ không chồng lấn để huấn luyện, và — hệt như trong pipeline gradient boosting — purge/embargo một khoảng ít nhất
horizonsnapshot giữa train và validation. Cả mục tiêu trung bình tiến lẫn các cửa sổ đầu vào chồng lấn đều làm rò rỉ thông tin tương lai qua các ranh giới phân chia và thổi phồng hiệu năng biểu kiến. -
Thích ứng trực tuyến: Trong môi trường thực tế, định kỳ fine-tune mô hình trên dữ liệu gần đây (1-2 giờ cuối) với một learning rate nhỏ. Vi cấu trúc thị trường thay đổi trong ngày, và một mô hình huấn luyện trên dữ liệu buổi sáng có thể hoạt động kém hơn vào buổi chiều.
Khi nào dùng Deep Learning so với Gradient Boosting
| Tiêu chí | Gradient Boosting | Deep Learning |
|---|---|---|
| Kiểu đầu vào | Đặc trưng dạng bảng | Chuỗi sổ lệnh thô |
| Cỡ dữ liệu huấn luyện | Hoạt động với 100K+ dòng | Cần 1M+ dòng |
| Feature engineering | Thủ công (công sức cao, kiểm soát cao) | Học được (công sức thấp hơn, ít diễn giải hơn) |
| Độ trễ suy luận | Vài µs một chữ số với bộ dự báo biên dịch; hàng chục µs từ Python | Hàng trăm µs trên GPU |
| Khả năng diễn giải | Cao (SHAP) | Thấp (attention map) |
| Thích ứng chế độ | Huấn luyện lại / cập nhật trực tuyến | Fine-tune trên dữ liệu gần đây |
| Kỹ năng dự báo spread tầm ngắn | Tương đương rộng rãi với DL | Lợi thế tăng ở các tầm dài hơn / dữ liệu lớn hơn |
Ta cố ý tránh dẫn ra các con số R² cụ thể: độ chính xác dự báo spread phụ thuộc nặng nề vào tầm (horizon), tài sản, và bao nhiêu phần điểm số chỉ đơn thuần là tự tương quan của spread. Một mô hình có thể đăng một R² thô ấn tượng trong khi gần như không bổ sung gì so với một EWMA một dòng. Hãy báo cáo kỹ năng vượt trên một baseline AR/EWMA trên cùng dữ liệu, có nêu rõ tầm, thay vì một R² tiêu đề. Tương tự, hãy coi các con số độ trễ là phụ thuộc vào cách triển khai: một mô hình LightGBM 2000 vòng, 63 lá dự báo một dòng đơn lẻ trong hàng chục micro giây từ Python và chỉ đạt tới vài micro giây với một bộ dự báo được biên dịch/C++.
Trong thực tế, nhiều hệ thống production dùng một cách tiếp cận hai giai đoạn: một mô hình gradient boosting nhanh để báo giá thời gian thực (nhạy với độ trễ), và một mô hình deep learning chạy bất đồng bộ để điều chỉnh các tham số của mô hình boosting hoặc cung cấp một tín hiệu thứ cấp.
Từ dự báo đến báo giá

Một dự báo spread chỉ có giá trị nếu nó chuyển hóa thành báo giá tốt hơn. Sau đây là một quy tắc báo giá đơn giản hóa sử dụng spread được dự báo:
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
Khi tồn kho là long (), độ lệch (skew) hạ thấp cả bid lẫn ask. Từ một góc nhìn duy nhất, nhất quán: một ask thấp hơn khiến taker mua từ ta rẻ hơn, qua đó giải phóng tồn kho long của ta; một bid thấp hơn khiến ta ít có khả năng bị khớp bởi người bán, làm chậm việc tích lũy thêm. Spread được dự báo kiểm soát độ rộng tổng thể — nới rộng khi mô hình dự đoán biến động hoặc lựa chọn bất lợi, thu hẹp khi điều kiện êm ả.
Đánh giá và Backtest

Các thước đo dự báo spread
Ngoài các thước đo hồi quy tiêu chuẩn (MAE, ), hãy đánh giá các dự báo spread bằng những thước đo quan trọng với việc tạo lập thị trường:
- Kỹ năng vượt trên một baseline: Luôn báo cáo MAE/R² so với một dự báo AR(1) hoặc EWMA của các spread gần đây. Bởi vì spread có tính dai dẳng mạnh, điểm số tuyệt đối bị chi phối bởi tự tương quan; chỉ phần cải thiện so với một baseline tầm thường mới phản ánh nội dung dự báo thực sự.
- Độ chính xác về hướng: Mô hình có dự báo đúng việc spread sẽ nới rộng hay thu hẹp không? Một mô hình có MAE tầm thường nhưng độ chính xác về hướng cao vẫn có thể có lãi.
- Bao phủ đuôi (tail coverage): Mô hình có dự báo được các đột biến spread không? Tính MAE riêng cho 5% giá trị spread cao nhất — đây là nơi các khoản lỗ do lựa chọn bất lợi tập trung.
- Hiệu chỉnh (calibration): Vẽ các phân vị spread dự báo so với thực hiện. Dự báo phân vị thứ 90 của một mô hình hiệu chỉnh tốt nên khớp với phân vị thứ 90 của các spread thực hiện.
Đánh giá dựa trên PnL
Cuối cùng, thước đo duy nhất quan trọng là PnL thực hiện. Hãy backtest toàn bộ vòng lặp:
- Tại mỗi mốc thời gian, dự báo spread
- Tính báo giá bằng spread được dự báo + độ lệch theo tồn kho
- Mô phỏng các lệnh khớp so với các giao dịch lịch sử
- Theo dõi tồn kho, PnL thực hiện, và tỷ số Sharpe
So sánh với các baseline: (a) spread cố định (trung vị chuỗi thời gian), (b) EWMA của các spread gần đây, và (c) ước lượng Roll.
Kết luận
Mô hình hóa spread nằm ở giao điểm giữa lý thuyết tài chính và ML ứng dụng. Phép phân rã cổ điển thành chi phí xử lý lệnh, tồn kho và lựa chọn bất lợi cung cấp trực giác kinh tế về vì sao spread biến thiên. Mô hình của Roll cho một ước lượng baseline tinh tế từ dữ liệu tối thiểu — miễn là bạn tính nó theo đơn vị giá. Các mô hình gradient boosting biến các đặc trưng vi cấu trúc thành những dự báo spread tầm ngắn chính xác với suy luận độ trễ thấp. Các kiến trúc deep learning học trực tiếp từ dữ liệu sổ lệnh thô, nắm bắt các mẫu mà các đặc trưng thủ công có thể bỏ sót — miễn là kiến trúc bảo toàn cấu trúc liên-mức thay vì pool nó đi.
Đối với một hệ thống tạo lập thị trường production, khuyến nghị thực hành mang tính phân tầng:
- Dùng phân rã Huang-Stoll ngoại tuyến để hiểu các thành phần spread của bạn và hiệu chỉnh các giới hạn rủi ro
- Dùng ước lượng Roll như một phép kiểm tra tính hợp lý và cho các công cụ mà bạn thiếu dữ liệu sổ lệnh
- Triển khai một mô hình LightGBM để dự báo spread thời gian thực — nó nhanh, dễ diễn giải và bền vững — với purged walk-forward validation và một benchmark AR/EWMA
- Chạy một mô hình CNN-LSTM hoặc Transformer trong một vòng lặp thứ cấp để phát hiện các thay đổi chế độ và điều chỉnh mô hình chính
Spread không phải là một con số — nó là một tín hiệu. Bạn mô hình hóa nó càng tốt (và đo lường mô hình đó càng trung thực), bạn càng định giá việc cung cấp thanh khoản chính xác hơn.
Bài viết này là một phần của loạt bài marketmaker.cc về tạo lập thị trường thuật toán và vi cấu trúc.
Tác Giả
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.