← Kembali ke artikel
June 3, 2026
5 menit baca

Pemodelan dan Prediksi Bid-Ask Spread dengan Machine Learning

Pemodelan dan Prediksi Bid-Ask Spread dengan Machine Learning
#microstructure
#spread
#market-making
#prediction
#machine-learning
#gradient-boosting
#deep-learning

Bid-ask spread adalah satu-satunya variabel terpenting yang dikendalikan oleh seorang market maker. Atur terlalu lebar dan Anda kehilangan arus order ke kompetitor. Atur terlalu sempit dan adverse selection akan melahap inventory Anda hidup-hidup. Teori microstructure tradisional memberi kita dekomposisi spread yang elegan ke dalam komponen-komponen ekonominya. Machine learning memberi kita alat untuk memprediksi bagaimana komponen-komponen itu bergeser secara real time. Tulisan ini menjembatani kedua dunia tersebut: kita mulai dari teori klasik, membangun hingga estimator spread implisit Roll, lalu beralih ke gradient boosting dan model deep learning yang memprediksi spread dari fitur order book. Sepanjang jalan kita menandai jebakan satuan, kebocoran data (leakage), dan benchmarking yang diam-diam membuat model spread tidak valid dalam praktik.

Mengapa Spread Penting bagi Market Maker

Seorang market maker terus-menerus memberikan kuotasi harga bid PbP_b dan harga ask PaP_a. Quoted spread adalah:

S=PaPbS = P_a - P_b

Setiap round-trip (membeli pada bid si maker, menjual pada ask si maker, keduanya diisi oleh taker) mentransfer hingga SS dari para taker ke si maker — secara teori. Dalam praktik, si maker mendapat kurang dari SS karena adverse selection: sebagian taker memiliki informasi dan melakukan trading tepat sebelum harga bergerak melawan si maker. Profit terealisasi per round-trip adalah realized spread, yang sama dengan effective spread dikurangi price impact:

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

Kita mengukur ketiga besaran tersebut atas dasar full-spread yang sama (bukan setengah), sehingga identitasnya konsisten secara dimensional. Effective spread untuk satu trade tunggal adalah:

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

Di sini dt{+1,1}d_t \in \{+1, -1\} adalah arah trade (taker membeli atau menjual), PtP_t adalah harga transaksi, dan MtM_t adalah titik tengah dari best bid dan ask pada saat trade terjadi. Suku price-impact didefinisikan secara simetris atas horizon pasca-trade τ\tau:

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

di mana Mt+τM_{t+\tau} adalah mid pada horizon τ\tau setelah trade. Horizon harus dinyatakan secara eksplisit — pilihan umum adalah 5 menit pada ekuitas dan 30 detik pada kripto, di mana harga melakukan repricing lebih cepat. Mengurangkan impact dari effective spread menyisakan realized spread: apa yang dipertahankan si maker setelah pasar bergerak.

Seorang market maker yang dapat memprediksi spread — beserta komponen-komponennya — dalam 1, 5, atau 60 detik berikutnya dapat menyesuaikan kuotasi secara dinamis untuk memaksimalkan realized spread sambil mempertahankan fill rate.

Tiga Komponen Spread

Bid-ask spread didekomposisi menjadi lapisan biaya order-processing, inventory, dan adverse-selection

Literatur market microstructure (Stoll 1978, Glosten dan Milgrom 1985, Huang dan Stoll 1997) mendekomposisi bid-ask spread menjadi tiga komponen ekonomi.

1. Order Processing Cost (α\alpha)

Ini adalah biaya menyediakan layanan market-making per sisi yang terisi: fee yang benar-benar dibayar si maker ditambah infrastruktur teknologi, kepatuhan regulasi, dan opportunity cost dari modal yang dikerahkan. Demsetz (1968) dan Tinic (1972) adalah yang pertama memformalkan komponen ini.

Perbedaan kuncinya adalah siapa membayar fee yang mana. Seorang maker yang memberikan kuotasi secara pasif membayar maker fee fmf_m atas fill-nya sendiri — dan di banyak venue fmf_m adalah rebate, yaitu negatif. Ia tidak membayar taker fee ftf_t atas fill pasif tersebut; pihak lawan yang melintasi spread itulah yang membayar ftf_t. Jadi order-processing cost per sisi si maker adalah:

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

di mana fmf_m bertanda (sebuah rebate menurunkan α\alpha dan dapat membuatnya negatif) dan cinfrac_{\text{infra}} mencakup konektivitas, colocation, dan compute. Di pasar elektronik modern komponen ini telah menyusut drastis — sub-penny pada ekuitas, beberapa basis point atau rebate bersih pada kripto.

Taker fee ftf_t penting karena alasan yang berbeda: ia menetapkan batas bawah seberapa sempit spread penuh bisa menguntungkan, karena seorang taker yang melintas membayar ftf_t di atas spread. Jika Anda menginginkan batas bawah spread yang menjaga kuotasi Anda tetap menarik secara ekonomi relatif terhadap biaya taker tersebut, motivasikan secara terpisah alih-alih melipat ftf_t ke dalam biaya si maker sendiri. Mencampuradukkan keduanya menghitung-ganda fee round-trip di dalam satu setengah-spread.

2. Inventory Holding Cost (β\beta)

Ketika seorang market maker mengakumulasi posisi terarah (long atau short), ia menanggung risiko harga. Komponen inventory mengompensasi risiko ini. Stoll (1978) dan Amihud dan Mendelson (1980) memodelkannya sebagai fungsi dari volatilitas dan inventory si maker saat ini:

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

di mana σ\sigma adalah volatilitas aset dan QQ adalah inventory si maker saat ini. Seiring bertumbuhnya inventory, si maker melebarkan spread pada sisi di mana ia terekspos dan menyempitkannya pada sisi lain, sebuah teknik yang disebut inventory skewing.

3. Adverse Selection Cost (γ\gamma)

Ini adalah komponen yang paling berbahaya. Trader berinformasi — mereka yang memiliki informasi superior tentang pergerakan harga yang akan segera terjadi — secara sistematis mengambil keuntungan dari kuotasi usang. Adverse selection cost sama dengan ekspektasi kerugian per trade kepada pihak lawan yang berinformasi. Copeland dan Galai (1983) memodelkannya sebagai nilai opsi gratis yang diberikan si maker kepada trader berinformasi. Glosten dan Milgrom (1985) memformalkannya sebagai revisi Bayesian dalam keyakinan si maker setelah mengamati sebuah trade:

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

di mana VV adalah nilai fundamental sebenarnya. Di pasar likuid, adverse selection dapat menyumbang 30-60% dari total spread.

Dekomposisi Penuh

Quoted half-spread dapat ditulis sebagai:

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

dengan α\alpha, β\beta, dan γ\gamma semuanya dinyatakan sebagai biaya per-sisi (half-spread) — itulah yang menjaga akuntansinya tetap konsisten. Huang dan Stoll (1997) mengusulkan metode ekonometrik untuk mengestimasi komponen-komponen ini dari data trade dan quote. Wawasan kuncinya: order processing cost menciptakan batas bawah spread yang tetap, inventory cost menciptakan spread yang bervariasi terhadap posisi dan volatilitas, dan adverse selection cost menciptakan spread yang bervariasi terhadap asimetri informasi.

Model Spread Implisit Roll

Model spread implisit Roll: harga transaksi memantul antara bid dan ask, meninggalkan tanda serial-covariance negatif

Sebelum data frekuensi tinggi tersedia secara luas, Richard Roll (1984) mengusulkan metode elegan untuk mengestimasi effective spread hanya menggunakan harga transaksi. Wawasannya: di pasar yang efisien, bid-ask bounce menginduksi serial covariance negatif pada perubahan harga, bahkan ketika tidak ada informasi baru.

Model

Asumsikan nilai fundamental VtV_t mengikuti random walk:

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)

Harga transaksi yang teramati memantul antara bid dan ask:

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

di mana dt{1,+1}d_t \in \{-1, +1\} dengan probabilitas sama (yaitu, beli dan jual sama-sama mungkin). Perubahan harga adalah:

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

Menghitung autokovarians orde-pertama:

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

Model ini diturunkan dalam satuan harga: SS keluar dari autokovarians perubahan harga, bukan return. Perbedaan ini adalah kesalahan implementasi yang paling umum, dan kita menjaga kode di bawah agar setia terhadapnya.

Estimator Roll

Menyelesaikan untuk SS:

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

Ketika autokovarians sampel positif (yang sering terjadi dalam praktik karena noise atau momentum), estimator menjadi tak terdefinisi. Perbaikan umum adalah menetapkan estimasi ke nol atau menggunakan akar bertanda:

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

di mana γ^1\hat{\gamma}_1 adalah autokovarians orde-pertama sampel.

Implementasi dalam Python

Estimator mengembalikan spread dalam satuan harga. Untuk menyatakannya dalam basis point kita membaginya dengan midprice satu kali — karena, tidak seperti estimator ruang-return, ia belum dibagi dengan harga:

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

Pemeriksaan kewajaran cepat pada deret simulasi — random walk fundamental di dekat harga 100 dengan spread sebenarnya S=0.10S = 0.10 — memulihkan 0.0999\approx 0.0999 dari perubahan harga. Varian berbasis return akan mengembalikan 0.001\approx 0.001, meleset sebesar price level, dan kemudian membagi itu dengan midprice lagi untuk mendapatkan bps memperparah kesalahan tersebut. Jika Anda lebih menyukai estimator ruang-return, turunkan model dalam ruang log-price dan hilangkan pembagian kedua dengan midprice; pilih satu konvensi dan buat kode cocok dengan matematikanya.

Keterbatasan Model Roll

Model Roll mengasumsikan: (1) efisiensi pasar, (2) tidak ada asimetri informasi, (3) arah trade i.i.d., dan (4) spread konstan. Semua ini dilanggar dalam praktik. Harris (1990) menunjukkan bahwa estimator ini bias berat akibat ketidaksamaan Jensen ketika diterapkan pada data yang berisik (noisy). Terlepas dari keterbatasan ini, estimator Roll tetap berguna sebagai baseline cepat dan banyak digunakan dalam riset empiris keuangan.

Fitur ML untuk Prediksi Spread

Fitur microstructure yang direkayasa — volatilitas, ketidakseimbangan order-flow, kedalaman, intensitas trade — yang menyuapi sebuah prediktor spread

Untuk melangkah melampaui model statis, kita membutuhkan fitur yang menangkap pendorong dinamis dari variasi spread. Berikut adalah taksonomi fitur yang ditata berdasarkan komponen spread yang diwakilinya.

Fitur Order Book (Inventory & Adverse Selection)

Fitur Formula Mewakili
Book imbalance BI=Vb1Va1Vb1+Va1\text{BI} = \frac{V_b^1 - V_a^1}{V_b^1 + V_a^1} Tekanan terarah
Weighted mid-price 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} Nilai wajar jangka pendek
Depth ratio (level 1-5) DR5=i=15Vbii=15Vai\text{DR}_5 = \frac{\sum_{i=1}^{5} V_b^i}{\sum_{i=1}^{5} V_a^i} Pasokan/permintaan multi-level
Book pressure 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 Tekanan terbobot jarak
Spread / tick ratio S/tickS / \text{tick} Kerapatan relatif terhadap minimum

Book pressure di sini menggunakan peluruhan jarak-ke-mid absolut, wi=eλPiMw_i = e^{-\lambda |P_i - M|}, sehingga volume yang berada dekat touch dihitung lebih besar daripada volume dalam, dan kedua sisi dibobot oleh fungsi positif yang menurun. Ini menghindari bias tanda struktural akibat membagi volume dengan jarak bertanda PiMP_i - M (yang negatif pada sisi bid, positif pada sisi ask, dan meledak saat sebuah level mendekati mid). Pilih λ\lambda dari kedalaman book yang tipikal, atau ganti eksponensial dengan bobot positif w(PiM)w(|P_i - M|) apa pun yang menurun terhadap jarak.

Fitur Trade Flow (Adverse Selection)

Fitur Formula Mewakili
Trade imbalance TIn=i=1ndivii=1nvi\text{TI}_{n} = \frac{\sum_{i=1}^{n} d_i \cdot v_i}{\sum_{i=1}^{n} v_i} Arus berinformasi bersih
VPIN Volume-synchronized probability of informed trading Toksisitas
Kyle's lambda Regresi ΔM\Delta M terhadap signed volume Price impact per unit
Frekuensi trade besar Jumlah trade >kmedian> k \cdot \text{median} dalam window Aktivitas institusional

Fitur Volatilitas (Inventory Cost)

Fitur Formula Mewakili
Realized volatility σrv=(ΔlogM)2\sigma_{\text{rv}} = \sqrt{\sum (\Delta \log M)^2} Risiko jangka pendek
Garman-Klass vol 12(logH/L)2(2ln21)(logC/O)2\frac{1}{2}(\log H/L)^2 - (2\ln 2 - 1)(\log C/O)^2 Vol berbasis range
Vol-of-vol Rolling std dari σrv\sigma_{\text{rv}} Ketidakpastian rezim
Autokorelasi return ρ1(ΔM)\rho_1(\Delta M) Momentum / mean-reversion

Fitur Rezim Pasar

Fitur Deskripsi Mewakili
Pengkodean waktu-dalam-hari sin(2πt/T),cos(2πt/T)\sin(2\pi t / T), \cos(2\pi t / T) Musiman intraday
Detik sejak trade terakhir Jeda waktu Tingkat aktivitas
Korelasi lintas-aset Rolling corr dengan indeks/BTC Risiko sistematik
Funding rate (kripto) Funding rate perp Penempatan dengan leverage

Gradient Boosting untuk Prediksi Spread

Gradient boosted tree (XGBoost, LightGBM, CatBoost) adalah tulang punggung prediksi tabular dalam keuangan kuantitatif. Mereka menangani tipe fitur campuran, menangkap interaksi nonlinier, membutuhkan preprocessing minimal, dan berlatih cepat pada jutaan baris — asalkan pembangunan fitur itu sendiri tervektorisasi (lihat catatan autokorelasi di bawah).

Perumusan Masalah

Kita merumuskan prediksi spread sebagai tugas regresi. Targetnya adalah rata-rata quoted spread terbobot-waktu selama τ\tau detik berikutnya:

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

Dalam praktik, kita mendekatinya dengan rata-rata spread terbobot-volume selama NN snapshot berikutnya:

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}}

Target ini adalah forward window sepanjang NN (atau horizon), yang berarti baris-baris yang bersebelahan berbagi window masa depan yang tumpang tindih. Tumpang tindih itu membocorkan informasi melintasi split train/validation yang naif — kita menanganinya secara eksplisit dalam kode pelatihan di bawah.

Pipeline Lengkap

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

Detail yang krusial adalah gap purge/embargo. Target forward-mean berarti baris-baris yang bersebelahan tumpang tindih hingga horizon snapshot, sehingga TimeSeriesSplit polos membiarkan baris validation berbagi window masa depan dengan baris pelatihan — membocorkan jawabannya dan menggelembungkan R² validation. Membuang gap minimal horizon baris di kedua sisi setiap batas fold (purged k-fold gaya Lopez de Prado) menghilangkan bias tersebut. Ini berlaku sama untuk pipeline gradient-boosting maupun deep learning, meskipun kebocoran ini lebih sering dibahas untuk model sekuens.

Analisis Feature Importance

Salah satu keunggulan utama model berbasis tree adalah interpretabilitas. Setelah pelatihan, periksa nilai SHAP untuk memahami fitur mana yang mendorong prediksi spread:

import shap

explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_val)
shap.summary_plot(shap_values, X_val, max_display=15)

Temuan tipikal lintas kelas aset:

  1. Lagged spread (spread_lag_1\text{spread\_lag\_1}) hampir selalu merupakan fitur terpenting — spread sangat berautokorelasi. Inilah juga sebabnya R² utama tampak tinggi: sebagian besar skor hanyalah persistensi, jadi selalu benchmark terhadap baseline AR/EWMA (lebih lanjut di bawah).
  2. Realized volatility adalah yang kedua terpenting — volatilitas intraday dan spread berkorelasi positif kuat, baik secara serentak maupun dinamis.
  3. Book imbalance paling penting selama periode bergejolak — ia memberi sinyal pergerakan terarah yang akan segera terjadi.
  4. Trade flow imbalance menangkap adverse selection jangka pendek — ledakan arus satu sisi memprediksi pelebaran spread.
  5. Waktu-dalam-hari menangkap pola intraday berbentuk U (lebih lebar pada pembukaan/penutupan, lebih sempit di tengah hari).

Pertimbangan Hyperparameter

Khusus untuk prediksi spread:

  • Gunakan MAE atau Huber loss alih-alih MSE. Distribusi spread condong ke kanan dengan outlier ekstrem sesekali (selama peristiwa berita). MAE lebih robust.
  • Atur min_child_samples tinggi (100+) untuk mencegah model menyesuaikan diri dengan noise microstructure pada masing-masing snapshot.
  • Gunakan subsample < 1.0 untuk mendekorelasi tree dan meningkatkan generalisasi lintas rezim volatilitas yang berbeda.

Pendekatan Deep Learning

Sementara gradient boosting unggul pada fitur tabular, deep learning dapat mempelajari representasi langsung dari data order book mentah. Dua arsitektur telah terbukti efektif untuk tugas prediksi terkait spread.

Arsitektur 1: CNN-LSTM untuk Snapshot Order Book

Arsitektur DeepLOB (Zhang dkk. 2019) menggunakan konvolusi kernel-kecil bertumpuk — dan sebuah modul Inception — untuk mengekstrak pola spasial lintas level order book sambil mempertahankan struktur spasial tersebut, diikuti oleh lapisan LSTM untuk memodelkan dependensi temporal. Pilihan desain yang penting adalah tidak melakukan global-pool pada sumbu level sebelum lapisan rekuren: melakukannya meruntuhkan persis struktur lintas-level yang seharusnya ditangkap oleh konvolusi.

Untuk prediksi spread, masukannya adalah tensor berbentuk (T,L,F)(T, L, F):

  • TT = jumlah langkah waktu (mis., 100 snapshot)
  • LL = jumlah level harga (mis., 10 bid + 10 ask = 20)
  • FF = fitur per level (harga, volume, jumlah order)

Model di bawah mempertahankan feature map konvolusional atas level dan meratakannya (flatten) ke dalam masukan LSTM (input_size = 16 * L), alih-alih merata-ratakan dimensi level menjadi 16 channel mean:

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)

Jika Anda memang menginginkan pooling pada sumbu level untuk mengendalikan jumlah parameter, gunakan pooling ber-stride atau pooling yang dipelajari yang mempertahankan lebih dari satu posisi — bukan AdaptiveAvgPool1d(1), yang merata-ratakan setiap level menjadi satu angka dan membuang sinyal spasial.

Arsitektur 2: Transformer Encoder

Transformer dapat menangkap dependensi jarak-jauh dalam sekuens order book tanpa hambatan sekuensial LSTM. Untuk prediksi spread, sebuah transformer encoder ringan bekerja dengan baik:

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, :])

Pertimbangan Pelatihan

  1. Normalisasi: Normalisasikan harga sebagai offset dari midprice (dalam tick atau bps). Normalisasikan volume dengan rolling mean-nya. Harga dan volume mentah menyebabkan ketidakstabilan pelatihan.

  2. Fungsi loss: Gunakan Huber loss (δ=1.0\delta = 1.0) untuk menangani lonjakan spread:

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. Pengambilan sampel window dan kebocoran: Gunakan window yang tidak tumpang tindih untuk pelatihan, dan — persis seperti pada pipeline gradient-boosting — purge/embargo gap minimal horizon snapshot antara train dan validation. Baik target forward-mean maupun window masukan yang tumpang tindih membocorkan informasi masa depan melintasi batas split dan menggelembungkan performa yang tampak.

  2. Adaptasi online: Di produksi, secara berkala fine-tune model pada data terkini (1-2 jam terakhir) dengan learning rate kecil. Market microstructure berubah dalam sehari, dan model yang dilatih pada data pagi mungkin berkinerja buruk pada sore hari.

Kapan Menggunakan Deep Learning vs. Gradient Boosting

Kriteria Gradient Boosting Deep Learning
Tipe masukan Fitur tabular Sekuens order book mentah
Ukuran data pelatihan Bekerja dengan 100K+ baris Butuh 1M+ baris
Feature engineering Manual (upaya tinggi, kendali tinggi) Dipelajari (upaya lebih rendah, kurang dapat diinterpretasi)
Latensi inferensi µs satu digit dengan prediktor terkompilasi; puluhan µs dari Python Ratusan µs pada GPU
Interpretabilitas Tinggi (SHAP) Rendah (attention map)
Adaptasi rezim Latih ulang / pembaruan online Fine-tune pada data terkini
Keterampilan spread horizon-pendek Secara luas setara dengan DL Keunggulan tumbuh pada horizon lebih panjang / data lebih besar

Kita sengaja menghindari mengutip angka R² spesifik: akurasi peramalan spread sangat bergantung pada horizon, aset, dan seberapa banyak skornya semata-mata adalah autokorelasi spread. Sebuah model dapat mencetak R² mentah yang mengesankan sambil menambahkan hampir tidak ada apa pun di atas EWMA satu baris. Laporkan keterampilan di atas baseline AR/EWMA pada data yang sama, dengan horizon yang dinyatakan, alih-alih R² utama. Demikian pula, perlakukan angka latensi sebagai bergantung pada implementasi: model LightGBM 2000 putaran, 63 leaf memprediksi satu baris dalam puluhan mikrodetik dari Python dan hanya mencapai beberapa mikrodetik dengan prediktor terkompilasi/C++.

Dalam praktik, banyak sistem produksi menggunakan pendekatan dua tahap: model gradient boosting cepat untuk kuotasi real-time (kritis-latensi), dan model deep learning yang berjalan secara asinkron untuk menyesuaikan parameter model boosting atau memberikan sinyal sekunder.

Dari Prediksi ke Kuotasi

Mengubah spread yang diprediksi menjadi kuotasi bid/ask langsung, dimiringkan oleh inventory di sekitar mid-price yang wajar

Prediksi spread hanya berharga jika ia diterjemahkan menjadi kuotasi yang lebih baik. Berikut adalah aturan kuotasi yang disederhanakan yang menggunakan spread yang diprediksi:

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

Ketika inventory long (Q>0Q > 0), skew menurunkan baik bid maupun ask. Dari satu perspektif yang konsisten: ask yang lebih rendah membuat para taker lebih murah membeli dari kita, yang melepaskan inventory long kita; bid yang lebih rendah membuat kita lebih kecil kemungkinannya dihantam oleh penjual, memperlambat akumulasi lebih lanjut. Spread yang diprediksi mengendalikan lebar keseluruhan — melebar ketika model mengantisipasi volatilitas atau adverse selection, menyempit ketika kondisi tenang.

Evaluasi dan Backtesting

Mengevaluasi model spread: kalibrasi spread prediksi-vs-realisasi berdampingan dengan ukuran fill-rate dan PnL

Metrik Prediksi Spread

Melampaui metrik regresi standar (MAE, R2R^2), evaluasi prediksi spread dengan metrik yang penting bagi market making:

  • Keterampilan di atas baseline: Selalu laporkan MAE/R² relatif terhadap peramalan AR(1) atau EWMA dari spread terkini. Karena spread sangat persisten, skor absolut didominasi oleh autokorelasi; hanya peningkatan di atas baseline trivial yang mencerminkan kandungan prediktif sebenarnya.
  • Akurasi terarah: Apakah model dengan benar memprediksi apakah spread akan melebar atau menyempit? Sebuah model dengan MAE biasa-biasa saja tetapi akurasi terarah tinggi tetap dapat menguntungkan.
  • Cakupan ekor (tail): Apakah model memprediksi lonjakan spread? Hitung MAE secara terpisah untuk 5% nilai spread teratas — di sinilah kerugian adverse selection terkonsentrasi.
  • Kalibrasi: Plot kuantil spread prediksi vs. realisasi. Prediksi persentil ke-90 dari model yang terkalibrasi baik seharusnya cocok dengan persentil ke-90 dari spread yang terealisasi.

Evaluasi Berbasis PnL

Pada akhirnya, satu-satunya metrik yang penting adalah PnL terealisasi. Backtest seluruh loop:

  1. Pada setiap timestamp, prediksi spread
  2. Hitung kuotasi menggunakan spread yang diprediksi + inventory skew
  3. Simulasikan fill terhadap trade historis
  4. Lacak inventory, PnL terealisasi, dan rasio Sharpe

Bandingkan terhadap baseline: (a) spread konstan (median deret waktu), (b) EWMA dari spread terkini, dan (c) estimator Roll.

Kesimpulan

Pemodelan spread berada di persimpangan teori keuangan dan ML terapan. Dekomposisi klasik ke dalam biaya order processing, inventory, dan adverse selection memberikan intuisi ekonomi tentang mengapa spread bervariasi. Model Roll memberikan estimator baseline yang elegan dari data minimal — selama Anda menghitungnya dalam satuan harga. Model gradient boosting mengubah fitur microstructure menjadi peramalan spread horizon-pendek yang akurat dengan inferensi latensi-rendah. Arsitektur deep learning mempelajari langsung dari data order book mentah, menangkap pola yang mungkin terlewat oleh fitur buatan tangan — asalkan arsitekturnya mempertahankan struktur lintas-level alih-alih menggumpalkannya pergi melalui pooling.

Untuk sistem market-making di produksi, rekomendasi praktis bersifat berlapis:

  1. Gunakan dekomposisi Huang-Stoll secara offline untuk memahami komponen spread Anda dan mengalibrasi limit risiko
  2. Gunakan estimator Roll sebagai pemeriksaan kewajaran dan untuk instrumen di mana Anda tidak memiliki data order book
  3. Terapkan model LightGBM untuk prediksi spread real-time — ia cepat, dapat diinterpretasi, dan robust — dengan validasi purged walk-forward dan benchmark AR/EWMA
  4. Jalankan model CNN-LSTM atau Transformer dalam loop sekunder untuk mendeteksi perubahan rezim dan menyesuaikan model utama

Spread bukanlah sebuah angka — ia adalah sebuah sinyal. Semakin baik Anda memodelkannya (dan semakin jujur Anda mengukur model itu), semakin presisi Anda dapat memberi harga pada penyediaan likuiditas.


Tulisan ini adalah bagian dari seri marketmaker.cc tentang algorithmic market making dan microstructure.

Penafian: Informasi yang disediakan dalam artikel ini hanya untuk tujuan edukasi dan informasi serta tidak merupakan nasihat keuangan, investasi, atau trading. Trading mata uang kripto mengandung risiko kerugian yang signifikan.

Penulis

Eugen Soloviov
Eugen Soloviov

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.

Newsletter

Selangkah Lebih Maju dari Pasar

Berlangganan newsletter kami untuk wawasan AI trading eksklusif, analisis pasar, dan pembaruan platform.

Kami menghormati privasi Anda. Berhenti berlangganan kapan saja.