머신러닝을 활용한 매수-매도 스프레드 모델링과 예측
매수-매도 스프레드는 마켓 메이커가 통제하는 가장 중요한 단일 변수다. 너무 넓게 설정하면 주문 흐름을 경쟁자에게 빼앗기고, 너무 좁게 설정하면 역선택이 재고를 산 채로 갉아먹는다. 전통적인 마이크로구조 이론은 스프레드를 그 경제적 구성 요소로 우아하게 분해하는 방법을 제공한다. 머신러닝은 그 구성 요소들이 실시간으로 어떻게 변하는지 예측하는 도구를 제공한다. 이 글은 두 세계를 잇는다. 고전 이론에서 출발해 Roll의 암묵 스프레드 추정량까지 쌓아 올린 다음, 호가창 특성으로부터 스프레드를 예측하는 그래디언트 부스팅과 딥러닝 모델로 넘어간다. 그 과정에서 실전에서 스프레드 모델을 조용히 무효화하는 단위, 누수, 벤치마킹 함정을 짚는다.
마켓 메이커에게 스프레드가 중요한 이유
마켓 메이커는 매수 가격 와 매도 가격 를 지속적으로 호가한다. 호가 스프레드는 다음과 같다.
모든 왕복 거래(메이커의 매수 호가에 매수하고 메이커의 매도 호가에 매도하며, 둘 다 테이커가 체결)는 이론적으로 테이커로부터 메이커에게 최대 를 이전시킨다. 실전에서 메이커는 역선택 때문에 보다 적게 번다. 일부 테이커는 정보를 가지고 있어, 가격이 메이커에게 불리하게 움직이기 직전에 거래한다. 왕복 거래당 실현 이익은 **실현 스프레드(realized spread)**이며, 이는 유효 스프레드에서 가격 충격을 뺀 값과 같다.
세 값 모두 동일한 전체 스프레드(full-spread) 기준(절반이 아님)으로 측정하므로 이 항등식은 차원적으로 일관된다. 단일 거래에 대한 유효 스프레드는 다음과 같다.
여기서 는 거래 방향(테이커 매수 또는 매도), 는 체결 가격, 는 거래 시점의 최우선 매수 호가와 매도 호가의 중간값이다. 가격 충격 항은 거래 이후 시간 지평 에 대해 대칭적으로 정의된다.
여기서 는 거래 후 시간 지평 가 지난 시점의 중간값이다. 시간 지평은 반드시 명시해야 한다. 흔히 쓰이는 값은 주식에서 5분, 가격이 더 빠르게 재가격되는 암호화폐에서 30초다. 유효 스프레드에서 충격을 빼면 실현 스프레드가 남는다. 즉, 시장이 움직인 뒤에 메이커가 챙기는 몫이다.
다음 1초, 5초, 60초의 스프레드와 그 구성 요소를 예측할 수 있는 마켓 메이커는 체결률을 유지하면서 실현 스프레드를 극대화하도록 호가를 동적으로 조정할 수 있다.
스프레드의 세 가지 구성 요소

시장 마이크로구조 문헌(Stoll 1978, Glosten and Milgrom 1985, Huang and Stoll 1997)은 매수-매도 스프레드를 세 가지 경제적 구성 요소로 분해한다.
1. 주문 처리 비용 ()
이는 체결된 한쪽 면당 마켓 메이킹 서비스를 제공하는 비용이다. 메이커가 실제로 지불하는 수수료에 더해 기술 인프라, 규제 준수, 그리고 투입된 자본의 기회비용이 포함된다. Demsetz(1968)와 Tinic(1972)이 이 구성 요소를 처음으로 정식화했다.
핵심 구분은 누가 어떤 수수료를 내는가다. 수동적으로 호가하는 메이커는 자신의 체결에 대해 메이커 수수료 를 낸다. 그리고 많은 거래소에서 은 리베이트, 즉 음수다. 메이커는 그 수동적 체결에 대해 테이커 수수료 를 내지 않는다. 스프레드를 넘는 상대방이 를 낸다. 따라서 메이커의 한쪽 면당 주문 처리 비용은 다음과 같다.
여기서 은 부호가 있고(리베이트는 를 낮추며 음수로 만들 수 있다), 는 연결성, 코로케이션, 연산을 포괄한다. 현대 전자 시장에서 이 구성 요소는 극적으로 줄어들었다. 주식에서는 1센트 미만, 암호화폐에서는 몇 베이시스포인트 또는 순 리베이트 수준이다.
테이커 수수료 는 다른 이유로 중요하다. 스프레드를 넘는 테이커가 스프레드 위에 를 추가로 내기 때문에, 전체 스프레드가 수익성 있게 좁아질 수 있는 하한을 설정한다. 그 테이커 비용 대비 호가를 경제적으로 매력적으로 유지하는 스프레드 하한을 원한다면, 를 메이커 자신의 비용에 접어 넣지 말고 별도로 동기를 부여하라. 둘을 뒤섞으면 단일 절반 스프레드 안에서 왕복 수수료를 이중 계산하게 된다.
2. 재고 보유 비용 ()
마켓 메이커가 방향성 포지션(롱 또는 숏)을 축적하면 가격 위험을 부담한다. 재고 구성 요소는 이 위험을 보상한다. Stoll(1978)과 Amihud and Mendelson(1980)은 이를 변동성과 메이커의 현재 재고의 함수로 모델링했다.
여기서 는 자산의 변동성이고 는 메이커의 현재 재고다. 재고가 늘어나면 메이커는 노출된 쪽의 스프레드를 넓히고 반대쪽을 좁히는데, 이 기법을 **재고 스큐잉(inventory skewing)**이라고 한다.
3. 역선택 비용 ()
이는 가장 위험한 구성 요소다. 정보 거래자, 즉 임박한 가격 변동에 대해 우월한 정보를 가진 자들은 묵은 호가를 체계적으로 골라낸다. 역선택 비용은 정보를 가진 상대방에게 거래당 발생하는 기대 손실과 같다. Copeland and Galai(1983)는 이를 메이커가 정보 거래자에게 공짜로 주는 옵션의 가치로 모델링했다. Glosten and Milgrom(1985)은 이를 거래를 관찰한 후 메이커의 믿음이 베이즈적으로 수정되는 것으로 정식화했다.
여기서 는 진정한 펀더멘털 가치다. 유동성이 높은 시장에서 역선택은 전체 스프레드의 30~60%를 차지할 수 있다.
전체 분해
호가 절반 스프레드는 다음과 같이 쓸 수 있다.
, , 를 모두 한쪽 면당(절반 스프레드) 비용으로 표현한다. 그래야 회계가 일관된다. Huang and Stoll(1997)은 거래 및 호가 데이터로부터 이 구성 요소들을 추정하는 계량경제학적 방법을 제안했다. 핵심 통찰은 다음과 같다. 주문 처리 비용은 고정된 스프레드 하한을 만들고, 재고 비용은 포지션과 변동성에 따라 달라지는 스프레드를 만들며, 역선택 비용은 정보 비대칭에 따라 달라지는 스프레드를 만든다.
Roll의 암묵 스프레드 모델

고빈도 데이터가 널리 보급되기 전, Richard Roll(1984)은 체결 가격만 사용해 유효 스프레드를 추정하는 우아한 방법을 제안했다. 그의 통찰은 이렇다. 효율적 시장에서는 새로운 정보가 없더라도 매수-매도 바운스가 가격 변화에 음의 직렬 공분산을 유발한다.
모델
펀더멘털 가치 가 랜덤워크를 따른다고 가정하자.
관측된 체결 가격은 매수와 매도 사이를 튕긴다.
여기서 이며 확률이 동일하다(즉, 매수와 매도가 똑같이 일어날 가능성이 높다). 가격 변화는 다음과 같다.
1차 자기공분산을 계산하면 다음과 같다.
이 모델은 **가격 단위(price units)**로 유도된다. 는 수익률이 아니라 가격 변화의 자기공분산에서 도출된다. 이 구분이 가장 흔한 구현 오류이며, 아래 코드에서 이를 충실히 지킨다.
Roll 추정량
에 대해 풀면 다음과 같다.
표본 자기공분산이 양수일 때(노이즈나 모멘텀 때문에 실전에서 자주 발생한다) 추정량은 정의되지 않는다. 흔한 해결책은 추정값을 0으로 설정하거나 부호 있는 제곱근을 사용하는 것이다.
여기서 은 표본 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 근처의 펀더멘털 랜덤워크에서 진짜 스프레드 일 때, 가격 변화로부터 를 복원한다. 수익률 기반 변형은 가격 수준만큼 어긋난 을 반환하고, 그것을 다시 중간 가격으로 나누어 bps를 얻으면 오류가 복합된다. 수익률 공간 추정량을 선호한다면, 로그 가격 공간에서 모델을 유도하고 중간 가격으로 두 번째 나누기를 생략하라. 하나의 관례를 택하고 코드가 수식과 일치하도록 하라.
Roll 모델의 한계
Roll의 모델은 (1) 시장 효율성, (2) 정보 비대칭 없음, (3) i.i.d. 거래 방향, (4) 일정한 스프레드를 가정한다. 이 모든 것이 실전에서 위배된다. Harris(1990)는 노이즈가 있는 데이터에 적용할 때 옌센 부등식 때문에 추정량이 심하게 편향됨을 보였다. 이런 한계에도 불구하고 Roll 추정량은 빠른 기준선으로 여전히 유용하며 실증 금융 연구에서 널리 사용된다.
스프레드 예측을 위한 ML 특성

정적 모델을 넘어서려면 스프레드 변동의 동적 원인을 포착하는 특성이 필요하다. 다음은 각 특성이 대리하는 스프레드 구성 요소별로 정리한 특성 분류 체계다.
호가창 특성 (재고 및 역선택)
| 특성 | 공식 | 대리 대상 |
|---|---|---|
| 호가창 불균형 | 방향성 압력 | |
| 가중 중간 가격 | 단기 공정 가치 | |
| 깊이 비율 (레벨 1-5) | 다층 공급/수요 | |
| 호가창 압력 | 거리 가중 압력 | |
| 스프레드 / 틱 비율 | 최소값 대비 좁기 |
여기서 호가창 압력은 중간값까지의 절대 거리 감쇠 를 사용한다. 그래서 터치 근처에 놓인 물량이 깊은 곳의 물량보다 더 큰 비중을 가지며, 양쪽 모두 양의 감소 함수로 가중된다. 이는 부호 있는 거리 (매수 쪽에서는 음수, 매도 쪽에서는 양수이며, 레벨이 중간값에 가까워질수록 발산한다)으로 물량을 나눌 때 생기는 구조적 부호 편향을 피한다. 전형적인 호가창 깊이로부터 를 고르거나, 지수 함수를 거리에 따라 감소하는 임의의 양의 가중치 로 대체하라.
거래 흐름 특성 (역선택)
| 특성 | 공식 | 대리 대상 |
|---|---|---|
| 거래 불균형 | 순 정보 거래 흐름 | |
| VPIN | 거래량 동기화 정보 거래 확률 | 독성 |
| Kyle's lambda | 부호 있는 거래량에 대한 의 회귀 | 단위당 가격 충격 |
| 대형 거래 빈도 | 윈도우 내 인 거래의 수 | 기관 활동 |
변동성 특성 (재고 비용)
| 특성 | 공식 | 대리 대상 |
|---|---|---|
| 실현 변동성 | 단기 위험 | |
| Garman-Klass 변동성 | 범위 기반 변동성 | |
| 변동성의 변동성 | 의 롤링 표준편차 | 국면 불확실성 |
| 수익률 자기상관 | 모멘텀 / 평균 회귀 |
시장 국면 특성
| 특성 | 설명 | 대리 대상 |
|---|---|---|
| 시간대 인코딩 | 일중 계절성 | |
| 마지막 거래 이후 경과 초 | 시간 간격 | 활동 수준 |
| 자산 간 상관관계 | 지수/BTC와의 롤링 상관 | 체계적 위험 |
| 펀딩 비율 (암호화폐) | 무기한 선물 펀딩 비율 | 레버리지 포지셔닝 |
스프레드 예측을 위한 그래디언트 부스팅
그래디언트 부스팅 트리(XGBoost, LightGBM, CatBoost)는 퀀트 금융에서 테이블형 예측의 주력이다. 혼합된 특성 유형을 다루고, 비선형 상호작용을 포착하며, 전처리가 거의 필요 없고, 수백만 행에서 빠르게 학습한다. 단, 특성 구축 자체가 벡터화되어 있어야 한다(아래 자기상관 주석 참고).
문제 정식화
스프레드 예측을 회귀 작업으로 구성한다. 목표값은 다음 초 동안의 시간 가중 평균 호가 스프레드다.
실전에서는 다음 개 스냅샷에 대한 거래량 가중 평균 스프레드로 근사한다.
이 목표값은 길이 (또는 horizon)의 **전방 윈도우(forward window)**이므로, 인접한 행들이 겹치는 미래 윈도우를 공유한다. 그 겹침은 순진한 학습/검증 분할에서 정보를 누수시킨다. 아래 학습 코드에서 이를 명시적으로 처리한다.
전체 파이프라인
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 gap)**이다. 전방 평균 목표값은 인접한 행들이 최대 horizon 스냅샷만큼 겹친다는 뜻이므로, 평범한 TimeSeriesSplit은 검증 행이 학습 행과 미래 윈도우를 공유하게 만들어 정답을 누수시키고 검증 R²를 부풀린다. 각 폴드 경계의 양쪽에서 최소 horizon 행의 간격을 제거하면(Lopez de Prado 방식의 퍼지 k-폴드) 그 편향이 사라진다. 이 누수는 시퀀스 모델에서 더 흔히 논의되지만, 딥러닝뿐 아니라 그래디언트 부스팅 파이프라인에도 똑같이 적용된다.
특성 중요도 분석
트리 기반 모델의 주요 장점 중 하나는 해석 가능성이다. 학습 후, 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자형 일중 패턴(개장/폐장에 넓고, 한낮에 좁다)을 포착한다.
하이퍼파라미터 고려 사항
특히 스프레드 예측의 경우는 다음과 같다.
- MSE보다 MAE 또는 Huber 손실을 사용하라. 스프레드 분포는 오른쪽으로 치우쳐 있으며 가끔(뉴스 이벤트 중) 극단적인 이상치가 나온다. 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: Transformer 인코더
Transformer는 LSTM의 순차적 병목 없이도 호가창 시퀀스의 장거리 의존성을 포착할 수 있다. 스프레드 예측에는 경량 Transformer 인코더가 잘 작동한다.
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)으로 정규화하라. 거래량은 롤링 평균으로 정규화하라. 원시 가격과 거래량은 학습 불안정을 유발한다.
-
손실 함수: 스프레드 급등을 다루기 위해 Huber 손실()을 사용하라.
-
윈도우 샘플링과 누수: 학습에는 겹치지 않는 윈도우를 사용하고, 그래디언트 부스팅 파이프라인에서와 정확히 동일하게 학습과 검증 사이에 최소
horizon스냅샷의 간격을 퍼지/엠바고하라. 전방 평균 목표값과 겹치는 입력 윈도우 둘 다 분할 경계를 가로질러 미래 정보를 누수시키고 겉보기 성능을 부풀린다. -
온라인 적응: 운영 환경에서는 최근 데이터(최근 1~2시간)로 작은 학습률을 사용해 주기적으로 모델을 미세 조정하라. 시장 마이크로구조는 일중에 변하며, 오전 데이터로 학습한 모델은 오후에 성능이 저하될 수 있다.
딥러닝 대 그래디언트 부스팅, 언제 무엇을 쓸까
| 기준 | 그래디언트 부스팅 | 딥러닝 |
|---|---|---|
| 입력 유형 | 테이블형 특성 | 원시 호가창 시퀀스 |
| 학습 데이터 크기 | 10만 행 이상에서 작동 | 100만 행 이상 필요 |
| 특성 공학 | 수동 (높은 노력, 높은 통제력) | 학습됨 (낮은 노력, 낮은 해석 가능성) |
| 추론 지연 시간 | 컴파일된 예측기로 한 자릿수 µs, Python에서는 수십 µs | GPU에서 수백 µs |
| 해석 가능성 | 높음 (SHAP) | 낮음 (어텐션 맵) |
| 국면 적응 | 재학습 / 온라인 업데이트 | 최근 데이터로 미세 조정 |
| 단기 지평 스프레드 능력 | DL과 대체로 비슷 | 더 긴 지평 / 더 큰 데이터에서 우위 확대 |
우리는 의도적으로 특정 R² 수치를 인용하지 않는다. 스프레드 예측 정확도는 지평, 자산, 그리고 점수 중 얼마만큼이 단순히 스프레드 자기상관인지에 크게 의존한다. 어떤 모델은 인상적인 원시 R²를 기록하면서도 한 줄짜리 EWMA에 거의 아무것도 보태지 못할 수 있다. 표제 R²가 아니라, 동일한 데이터에서 지평을 명시한 채 **AR/EWMA 기준선 대비 능력(skill above an AR/EWMA baseline)**을 보고하라. 마찬가지로 지연 시간 수치도 구현 의존적인 것으로 다루어라. 2000라운드, 63리프 LightGBM 모델은 Python에서 단일 행을 수십 마이크로초에 예측하고, 컴파일된 C++ 예측기를 써야만 몇 마이크로초에 도달한다.
실전에서 많은 운영 시스템은 2단계 접근법을 사용한다. 실시간 호가(지연 시간이 중요)를 위한 빠른 그래디언트 부스팅 모델과, 그 부스팅 모델의 파라미터를 조정하거나 보조 신호를 제공하기 위해 비동기로 실행되는 딥러닝 모델이다.
예측에서 호가까지

스프레드 예측은 더 나은 호가로 이어질 때만 가치가 있다. 다음은 예측 스프레드를 사용하는 단순화된 호가 규칙이다.
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, )를 넘어, 마켓 메이킹에 중요한 지표로 스프레드 예측을 평가하라.
- 기준선 대비 능력: 항상 최근 스프레드의 AR(1) 또는 EWMA 예측 대비 MAE/R²를 보고하라. 스프레드는 강하게 지속되므로 절대 점수는 자기상관에 지배된다. 단순 기준선 대비 개선만이 진짜 예측 내용을 반영한다.
- 방향 정확도: 모델이 스프레드가 넓어질지 좁아질지 올바르게 예측하는가? MAE는 평범하지만 방향 정확도가 높은 모델도 여전히 수익성이 있을 수 있다.
- 꼬리 커버리지: 모델이 스프레드 급등을 예측하는가? 스프레드 값 상위 5%에 대해 MAE를 별도로 계산하라. 역선택 손실이 집중되는 곳이 바로 여기다.
- 보정(calibration): 예측 스프레드 분위수 대 실현 스프레드 분위수를 그려라. 잘 보정된 모델의 90번째 백분위 예측은 실현 스프레드의 90번째 백분위와 일치해야 한다.
PnL 기반 평가
궁극적으로 유일하게 중요한 지표는 실현 PnL이다. 전체 루프를 백테스트하라.
- 각 타임스탬프에서 스프레드를 예측한다
- 예측 스프레드 + 재고 스큐를 사용해 호가를 계산한다
- 과거 거래에 대해 체결을 시뮬레이션한다
- 재고, 실현 PnL, 샤프 비율을 추적한다
기준선과 비교하라. (a) 일정한 스프레드(시계열 중앙값), (b) 최근 스프레드의 EWMA, (c) Roll 추정량.
결론
스프레드 모델링은 금융 이론과 응용 ML의 교차점에 있다. 주문 처리, 재고, 역선택 비용으로의 고전적 분해는 스프레드가 왜 변하는지에 대한 경제적 직관을 제공한다. Roll의 모델은 최소한의 데이터로부터 우아한 기준선 추정량을 제공한다. 단, 가격 단위로 계산해야 한다. 그래디언트 부스팅 모델은 마이크로구조 특성을 낮은 지연 시간의 추론으로 정확한 단기 지평 스프레드 예측으로 바꾼다. 딥러닝 아키텍처는 원시 호가창 데이터로부터 직접 학습해 수작업 특성이 놓칠 수 있는 패턴을 포착한다. 단, 아키텍처가 레벨 간 구조를 풀링으로 없애지 않고 보존해야 한다.
운영 마켓 메이킹 시스템에 대한 실용적 권장 사항은 계층적이다.
- Huang-Stoll 분해를 오프라인에서 사용해 스프레드 구성 요소를 이해하고 위험 한도를 보정하라
- Roll 추정량을 정합성 검증으로, 그리고 호가창 데이터가 없는 상품에 대해 사용하라
- 실시간 스프레드 예측을 위해 LightGBM 모델을 배포하라. 빠르고, 해석 가능하며, 강건하다. 퍼지 워크포워드 검증과 AR/EWMA 벤치마크와 함께 쓰라
- 국면 변화를 감지하고 1차 모델을 조정하기 위해 CNN-LSTM 또는 Transformer 모델을 보조 루프에서 실행하라
스프레드는 숫자가 아니다. 신호다. 더 잘 모델링할수록(그리고 그 모델을 더 정직하게 측정할수록), 유동성 공급을 더 정밀하게 가격 책정할 수 있다.
이 글은 알고리즘 마켓 메이킹과 마이크로구조에 관한 marketmaker.cc 시리즈의 일부입니다.
Authors
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.