การสร้างแบบจำลองและการพยากรณ์สเปรด Bid-Ask ด้วย Machine Learning
สเปรด bid-ask คือตัวแปรเดียวที่สำคัญที่สุดที่ market maker เป็นผู้ควบคุม ตั้งกว้างเกินไปคุณก็เสีย flow ให้คู่แข่ง ตั้งแคบเกินไป adverse selection ก็จะกัดกินสินค้าคงคลังของคุณจนหมด ทฤษฎี microstructure ดั้งเดิมให้การแยกองค์ประกอบของสเปรดออกเป็นองค์ประกอบทางเศรษฐกิจอย่างสวยงาม Machine learning ให้เครื่องมือแก่เราในการพยากรณ์ว่าองค์ประกอบเหล่านั้นจะเปลี่ยนแปลงอย่างไรแบบเรียลไทม์ บทความนี้เชื่อมโยงทั้งสองโลกเข้าด้วยกัน เราเริ่มจากทฤษฎีคลาสสิก สร้างต่อยอดไปยังตัวประมาณค่าสเปรดโดยปริยายของ Roll จากนั้นก้าวเข้าสู่ gradient boosting และโมเดล deep learning ที่พยากรณ์สเปรดจากฟีเจอร์ของ order book ระหว่างทาง เราจะชี้ให้เห็นกับดักเรื่องหน่วย การรั่วไหลของข้อมูล (leakage) และการตั้งเกณฑ์เปรียบเทียบ (benchmarking) ที่ค่อย ๆ ทำให้แบบจำลองสเปรดใช้ไม่ได้ในทางปฏิบัติ
ทำไมสเปรดจึงสำคัญสำหรับ Market Maker
market maker เสนอราคา bid และราคา ask อย่างต่อเนื่อง สเปรดที่เสนอ (quoted spread) คือ:
ทุกรอบการซื้อขายไป-กลับ (ซื้อที่ราคา bid ของ maker และขายที่ราคา ask ของ maker โดยทั้งสองฝั่งถูกเติมเต็มโดย taker) จะโอนมูลค่าสูงสุด จาก taker ไปยัง maker — ในทางทฤษฎี ในทางปฏิบัติ maker ได้รับน้อยกว่า เพราะ adverse selection: taker บางรายมีข้อมูลและทำการซื้อขายก่อนที่ราคาจะเคลื่อนไปในทางที่เสียเปรียบ maker กำไรที่เกิดขึ้นจริงต่อรอบการซื้อขายคือ realized spread ซึ่งเท่ากับ effective spread ลบด้วยผลกระทบต่อราคา (price impact):
เราวัดปริมาณทั้งสามค่าบนพื้นฐานเดียวกันคือ full-spread (ไม่ใช่ครึ่งสเปรด) เพื่อให้เอกลักษณ์นี้สอดคล้องกันในเชิงมิติ effective spread สำหรับการซื้อขายครั้งเดียวคือ:
ในที่นี้ คือทิศทางของการซื้อขาย (taker ซื้อหรือขาย), คือราคาธุรกรรม และ คือจุดกึ่งกลางของ best bid และ ask ณ เวลาที่ทำการซื้อขาย เทอม price-impact ถูกนิยามอย่างสมมาตรบนกรอบเวลาหลังการซื้อขาย :
โดยที่ คือ mid ในกรอบเวลา หลังการซื้อขาย ต้องระบุกรอบเวลาอย่างชัดเจน — ตัวเลือกที่พบบ่อยคือ 5 นาทีในตลาดหุ้น และ 30 วินาทีในคริปโต ซึ่งราคาตั้งใหม่เร็วกว่า การหักผลกระทบออกจาก effective spread เหลือไว้คือ realized spread: สิ่งที่ maker เก็บไว้ได้หลังจากตลาดเคลื่อนไปแล้ว
market maker ที่สามารถ พยากรณ์ สเปรด — และองค์ประกอบของมัน — ใน 1, 5 หรือ 60 วินาทีถัดไป สามารถปรับราคาเสนอแบบไดนามิกเพื่อเพิ่ม realized spread ให้สูงสุดในขณะที่ยังคงรักษาอัตราการเติมเต็ม (fill rate) เอาไว้
สามองค์ประกอบของสเปรด

วรรณกรรม market microstructure (Stoll 1978, Glosten and Milgrom 1985, Huang and Stoll 1997) แยกสเปรด bid-ask ออกเป็นสามองค์ประกอบทางเศรษฐกิจ
1. ต้นทุนการประมวลผลคำสั่ง ()
นี่คือต้นทุนของการให้บริการ market-making ต่อฝั่งที่ถูกเติมเต็ม: ค่าธรรมเนียมที่ maker จ่ายจริง บวกกับโครงสร้างพื้นฐานทางเทคโนโลยี การปฏิบัติตามกฎระเบียบ และต้นทุนค่าเสียโอกาสของเงินทุนที่ใช้ไป Demsetz (1968) และ Tinic (1972) เป็นกลุ่มแรกที่ทำให้องค์ประกอบนี้เป็นรูปธรรม
ข้อแตกต่างสำคัญคือ ใครจ่ายค่าธรรมเนียมแบบใด maker ที่เสนอราคาแบบ passive จะจ่าย maker fee บนการเติมเต็มของตัวเอง — และในหลายตลาด เป็น rebate กล่าวคือเป็นค่าลบ maker ไม่ จ่าย taker fee บนการเติมเต็มแบบ passive เหล่านั้น คู่สัญญาที่ข้ามสเปรดเป็นผู้จ่าย ดังนั้นต้นทุนการประมวลผลคำสั่งต่อฝั่งของ maker คือ:
โดยที่ มีเครื่องหมาย (rebate ทำให้ ลดลงและอาจติดลบได้) และ ครอบคลุมการเชื่อมต่อ การ colocation และการคำนวณ ในตลาดอิเล็กทรอนิกส์สมัยใหม่ องค์ประกอบนี้หดตัวลงอย่างมาก — ต่ำกว่าหนึ่งเซ็นต์ในตลาดหุ้น และไม่กี่ basis point หรือเป็น net rebate ในคริปโต
taker fee สำคัญด้วยเหตุผลอื่น: มันกำหนดเพดานล่างว่าสเปรด เต็ม จะแคบลงได้แค่ไหนอย่างมีกำไร เพราะ taker ที่ข้ามสเปรดต้องจ่าย เพิ่มเติมจากสเปรด หากคุณต้องการเพดานล่างของสเปรดที่ทำให้ราคาเสนอของคุณน่าสนใจในเชิงเศรษฐกิจเมื่อเทียบกับต้นทุน taker นั้น จงให้เหตุผลแยกต่างหากแทนที่จะพับ เข้าไปในต้นทุนของ maker เอง การรวมสองสิ่งนี้เข้าด้วยกันเท่ากับนับค่าธรรมเนียมรอบการซื้อขายซ้ำซ้อนภายในครึ่งสเปรดเดียว
2. ต้นทุนการถือครองสินค้าคงคลัง ()
เมื่อ market maker สะสมสถานะที่มีทิศทาง (long หรือ short) พวกเขาต้องแบกรับความเสี่ยงด้านราคา องค์ประกอบ inventory ชดเชยความเสี่ยงนี้ Stoll (1978) และ Amihud and Mendelson (1980) สร้างแบบจำลองนี้เป็นฟังก์ชันของความผันผวนและสินค้าคงคลังปัจจุบันของ maker:
โดยที่ คือความผันผวนของสินทรัพย์ และ คือสินค้าคงคลังปัจจุบันของ maker เมื่อสินค้าคงคลังเพิ่มขึ้น maker จะขยายสเปรดบนฝั่งที่ตนมีความเสี่ยงและทำให้แคบลงในอีกฝั่ง เทคนิคนี้เรียกว่า inventory skewing
3. ต้นทุน Adverse Selection ()
นี่คือองค์ประกอบที่อันตรายที่สุด เทรดเดอร์ที่มีข้อมูล — ผู้ที่มีข้อมูลเหนือกว่าเกี่ยวกับการเคลื่อนไหวของราคาที่กำลังจะเกิดขึ้น — เลือกหยิบราคาเสนอที่ล้าสมัยอย่างเป็นระบบ ต้นทุน adverse selection เท่ากับผลขาดทุนที่คาดหวังต่อการซื้อขายให้แก่คู่สัญญาที่มีข้อมูล Copeland and Galai (1983) สร้างแบบจำลองนี้เป็นมูลค่าของออปชันฟรีที่ maker มอบให้แก่เทรดเดอร์ที่มีข้อมูล Glosten and Milgrom (1985) ทำให้เป็นรูปธรรมในรูปของการปรับปรุงความเชื่อแบบเบย์ของ maker หลังจากสังเกตการซื้อขาย:
โดยที่ คือมูลค่าพื้นฐานที่แท้จริง ในตลาดที่มีสภาพคล่อง adverse selection สามารถคิดเป็น 30-60% ของสเปรดทั้งหมด
การแยกองค์ประกอบทั้งหมด
ครึ่งสเปรดที่เสนอสามารถเขียนได้เป็น:
โดยที่ , และ ทั้งหมดแสดงเป็นต้นทุนต่อฝั่ง (ครึ่งสเปรด) — นั่นคือสิ่งที่ทำให้การบัญชีสอดคล้องกัน Huang and Stoll (1997) เสนอวิธีทางเศรษฐมิติเพื่อประมาณองค์ประกอบเหล่านี้จากข้อมูลการซื้อขายและราคาเสนอ ข้อค้นพบสำคัญ: ต้นทุนการประมวลผลคำสั่งสร้างเพดานล่างของสเปรดที่คงที่ ต้นทุน inventory สร้างสเปรดที่แปรผันตามสถานะและความผันผวน และต้นทุน adverse selection สร้างสเปรดที่แปรผันตามความไม่สมมาตรของข้อมูล
แบบจำลองสเปรดโดยปริยายของ Roll

ก่อนที่ข้อมูลความถี่สูงจะหาได้อย่างแพร่หลาย Richard Roll (1984) เสนอวิธีที่สวยงามในการประมาณ effective spread โดยใช้เพียงราคาธุรกรรม ข้อค้นพบของเขา: ในตลาดที่มีประสิทธิภาพ การเด้งของ bid-ask ก่อให้เกิด serial covariance ที่เป็นลบในการเปลี่ยนแปลงของราคา แม้ในขณะที่ไม่มีข้อมูลใหม่
แบบจำลอง
สมมติว่ามูลค่าพื้นฐาน เคลื่อนตาม random walk:
ราคาธุรกรรมที่สังเกตได้เด้งระหว่าง bid และ ask:
โดยที่ ด้วยความน่าจะเป็นเท่ากัน (กล่าวคือ การซื้อและการขายมีโอกาสเกิดขึ้นเท่ากัน) การเปลี่ยนแปลงของราคาคือ:
เมื่อคำนวณ autocovariance อันดับหนึ่ง:
แบบจำลองนี้ถูกอนุมานใน หน่วยของราคา: ออกมาจาก autocovariance ของ การเปลี่ยนแปลง ของราคา ไม่ใช่ผลตอบแทน (returns) ข้อแตกต่างนี้คือข้อผิดพลาดในการนำไปใช้ที่พบบ่อยที่สุด และเรารักษาโค้ดให้ยึดถือตามนั้นด้านล่าง
ตัวประมาณค่า Roll
แก้สมการหา :
เมื่อ autocovariance ของตัวอย่างเป็นบวก (ซึ่งเกิดขึ้นบ่อยในทางปฏิบัติเนื่องจาก noise หรือ momentum) ตัวประมาณค่าจะไม่ถูกนิยาม วิธีแก้ที่พบบ่อยคือตั้งค่าประมาณเป็นศูนย์หรือใช้รากที่มีเครื่องหมาย:
โดยที่ คือ autocovariance อันดับหนึ่งของตัวอย่าง
การนำไปใช้ใน Python
ตัวประมาณค่าคืนค่าสเปรดใน หน่วยของราคา หากต้องการแสดงเป็น basis point เราหารด้วย midprice หนึ่งครั้ง — เพราะต่างจากตัวประมาณค่าใน return-space ตรงที่มันยังไม่ได้ถูกหารด้วยราคามาก่อน:
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
การตรวจสอบความสมเหตุสมผลอย่างรวดเร็วบนอนุกรมจำลอง — random walk พื้นฐานใกล้ราคา 100 ด้วยสเปรดจริง — กู้คืนค่า จากการเปลี่ยนแปลงของราคา ตัวแปรที่อิงผลตอบแทนจะคืนค่า ซึ่งคลาดเคลื่อนไปตามระดับราคา และการนำ ค่านั้น ไปหารด้วย midprice อีกครั้งเพื่อให้ได้ bps ก็ยิ่งทบความผิดพลาดเข้าไปอีก หากคุณชอบตัวประมาณค่าใน return-space จงอนุมานแบบจำลองในพื้นที่ log-price และตัดการหารด้วย midprice ครั้งที่สองออก เลือกระบบหนึ่งและทำให้โค้ดสอดคล้องกับคณิตศาสตร์
ข้อจำกัดของแบบจำลอง Roll
แบบจำลองของ Roll สมมติว่า: (1) ตลาดมีประสิทธิภาพ, (2) ไม่มีความไม่สมมาตรของข้อมูล, (3) ทิศทางการซื้อขายเป็น i.i.d. และ (4) สเปรดคงที่ ทั้งหมดนี้ถูกละเมิดในทางปฏิบัติ Harris (1990) แสดงให้เห็นว่าตัวประมาณค่ามีอคติอย่างรุนแรงเนื่องจากอสมการของ Jensen เมื่อนำไปใช้กับข้อมูลที่มี noise แม้จะมีข้อจำกัดเหล่านี้ ตัวประมาณค่า Roll ยังคงมีประโยชน์ในฐานะ baseline ที่รวดเร็วและถูกใช้อย่างแพร่หลายในงานวิจัยทางการเงินเชิงประจักษ์
ฟีเจอร์ ML สำหรับการพยากรณ์สเปรด

เพื่อก้าวข้ามแบบจำลองสถิต เราต้องการฟีเจอร์ที่จับปัจจัยขับเคลื่อนแบบไดนามิกของความแปรผันของสเปรด ต่อไปนี้คือการจัดหมวดหมู่ของฟีเจอร์ที่จัดเรียงตามองค์ประกอบของสเปรดที่ฟีเจอร์นั้นเป็นตัวแทน
ฟีเจอร์ Order Book (Inventory และ Adverse Selection)
| ฟีเจอร์ | สูตร | เป็นตัวแทนของ |
|---|---|---|
| Book imbalance | แรงกดดันเชิงทิศทาง | |
| Weighted mid-price | มูลค่ายุติธรรมระยะสั้น | |
| Depth ratio (ระดับ 1-5) | อุปทาน/อุปสงค์หลายระดับ | |
| Book pressure | แรงกดดันถ่วงน้ำหนักตามระยะทาง | |
| อัตราส่วน Spread / tick | ความแคบเทียบกับค่าต่ำสุด |
Book pressure ในที่นี้ใช้ การลดทอนตามระยะทางสัมบูรณ์ถึง mid คือ ดังนั้นปริมาณที่อยู่ใกล้ touch จึงมีน้ำหนักมากกว่าปริมาณที่อยู่ลึก และทั้งสองฝั่งถูกถ่วงน้ำหนักด้วยฟังก์ชันบวกที่ลดลง วิธีนี้หลีกเลี่ยงอคติเชิงเครื่องหมายเชิงโครงสร้างของการหารปริมาณด้วยระยะทาง ที่มีเครื่องหมาย (ซึ่งติดลบในฝั่ง bid เป็นบวกในฝั่ง ask และพุ่งสูงขึ้นเมื่อระดับเข้าใกล้ mid) เลือก จากความลึกของ book ทั่วไป หรือแทนที่ฟังก์ชันเอกซ์โพเนนเชียลด้วยน้ำหนักบวก ใด ๆ ที่ลดลงตามระยะทาง
ฟีเจอร์ Trade Flow (Adverse Selection)
| ฟีเจอร์ | สูตร | เป็นตัวแทนของ |
|---|---|---|
| Trade imbalance | flow ที่มีข้อมูลสุทธิ | |
| VPIN | ความน่าจะเป็นของการซื้อขายที่มีข้อมูลแบบ volume-synchronized | ความเป็นพิษ (toxicity) |
| Kyle's lambda | การถดถอยของ บนปริมาณที่มีเครื่องหมาย | ผลกระทบต่อราคาต่อหน่วย |
| ความถี่การซื้อขายขนาดใหญ่ | จำนวนการซื้อขาย ในหน้าต่าง | กิจกรรมของสถาบัน |
ฟีเจอร์ความผันผวน (ต้นทุน Inventory)
| ฟีเจอร์ | สูตร | เป็นตัวแทนของ |
|---|---|---|
| Realized volatility | ความเสี่ยงระยะสั้น | |
| Garman-Klass vol | vol ที่อิงช่วง (range-based) | |
| Vol-of-vol | rolling std ของ | ความไม่แน่นอนของ regime |
| Return autocorrelation | momentum / mean-reversion |
ฟีเจอร์ Market Regime
| ฟีเจอร์ | คำอธิบาย | เป็นตัวแทนของ |
|---|---|---|
| การเข้ารหัสเวลาของวัน | ฤดูกาลภายในวัน | |
| วินาทีนับจากการซื้อขายล่าสุด | ช่องว่างเวลา | ระดับกิจกรรม |
| สหสัมพันธ์ข้ามสินทรัพย์ | rolling corr กับดัชนี/BTC | ความเสี่ยงเชิงระบบ |
| Funding rate (คริปโต) | funding rate ของ perp | การวางสถานะแบบใช้ leverage |
Gradient Boosting สำหรับการพยากรณ์สเปรด
Gradient boosted trees (XGBoost, LightGBM, CatBoost) คือม้างานของการพยากรณ์ข้อมูลแบบตารางในการเงินเชิงปริมาณ มันจัดการกับฟีเจอร์หลายชนิดได้ จับ interaction ที่ไม่เป็นเชิงเส้น ต้องการการประมวลผลล่วงหน้าน้อยที่สุด และฝึกได้รวดเร็วบนข้อมูลนับล้านแถว — โดยมีเงื่อนไขว่าการสร้างฟีเจอร์เองต้องเป็นแบบ vectorized (ดูหมายเหตุเรื่อง autocorrelation ด้านล่าง)
การตั้งโจทย์ปัญหา
เราตั้งกรอบการพยากรณ์สเปรดเป็นงาน regression เป้าหมายคือค่าเฉลี่ยถ่วงน้ำหนักด้วยเวลาของสเปรดที่เสนอในช่วง วินาทีถัดไป:
ในทางปฏิบัติ เราประมาณค่านี้ด้วยสเปรดเฉลี่ยถ่วงน้ำหนักด้วยปริมาณในช่วง snapshot ถัดไป:
เป้าหมายนี้คือ หน้าต่างไปข้างหน้า (forward window) ที่มีความยาว (หรือ horizon) ซึ่งหมายความว่าแถวที่อยู่ติดกันใช้หน้าต่างอนาคตที่ซ้อนทับกัน การซ้อนทับนั้นทำให้ข้อมูลรั่วไหลข้ามการแบ่ง train/validation แบบไร้เดียงสา — เราจัดการเรื่องนี้อย่างชัดเจนในโค้ดการฝึกด้านล่าง
ไปป์ไลน์เต็มรูปแบบ
import lightgbm as lgb
import numpy as np
import pandas as pd
from sklearn.metrics import mean_absolute_error, r2_score
def build_features(df: pd.DataFrame) -> pd.DataFrame:
"""Build spread-prediction features from L2 order book snapshots."""
f = pd.DataFrame(index=df.index)
f["imb1"] = (df["bid_vol_1"] - df["ask_vol_1"]) / (
df["bid_vol_1"] + df["ask_vol_1"] + 1e-9
)
bid_depth = df[[f"bid_vol_{i}" for i in range(1, 6)]].sum(axis=1)
ask_depth = df[[f"ask_vol_{i}" for i in range(1, 6)]].sum(axis=1)
f["depth_imb5"] = (bid_depth - ask_depth) / (bid_depth + ask_depth + 1e-9)
mid = 0.5 * (df["ask_1"] + df["bid_1"])
f["spread_bps"] = (df["ask_1"] - df["bid_1"]) / mid * 1e4
f["log_spread"] = np.log1p(df["ask_1"] - df["bid_1"])
log_ret = np.log(mid / mid.shift(1))
f["rvol_50"] = log_ret.rolling(50).std()
f["rvol_200"] = log_ret.rolling(200).std()
if "trade_sign" in df.columns and "trade_vol" in df.columns:
signed_vol = df["trade_sign"] * df["trade_vol"]
total_vol = df["trade_vol"].rolling(50).sum()
f["tfi_50"] = signed_vol.rolling(50).sum() / (total_vol + 1e-9)
lag1 = log_ret.shift(1)
f["ret_autocorr"] = log_ret.rolling(100).corr(lag1)
if isinstance(df.index, pd.DatetimeIndex):
seconds = df.index.hour * 3600 + df.index.minute * 60 + df.index.second
f["tod_sin"] = np.sin(2 * np.pi * seconds / 86400)
f["tod_cos"] = np.cos(2 * np.pi * seconds / 86400)
for lag in [1, 5, 10, 50]:
f[f"spread_lag_{lag}"] = f["spread_bps"].shift(lag)
return f.dropna()
def build_target(df: pd.DataFrame, horizon: int = 10) -> pd.Series:
"""Forward mean spread over the next `horizon` snapshots (in bps).
target[t] = mean(spread_bps[t+1 .. t+horizon]). Note that consecutive
targets share an overlapping forward window of length `horizon`, which
is why the CV below purges a gap of `horizon` rows around each fold.
"""
mid = 0.5 * (df["ask_1"] + df["bid_1"])
spread_bps = (df["ask_1"] - df["bid_1"]) / mid * 1e4
fwd = spread_bps.shift(-1).rolling(horizon).mean().shift(-(horizon - 1))
return fwd
def purged_walk_forward(n: int, n_splits: int, horizon: int):
"""Expanding-window splits with a purge/embargo gap of `horizon` rows.
Because each target spans `horizon` future snapshots, rows straddling a
train/val boundary share overlapping target windows. Dropping a gap of
`horizon` rows between train and validation removes that leakage
(Lopez de Prado-style purging). Without it, validation R²/MAE are
optimistically biased by the target overlap.
"""
fold_size = n // (n_splits + 1)
for k in range(1, n_splits + 1):
train_end = fold_size * k
val_start = train_end + horizon # embargo gap
val_end = val_start + fold_size
if val_end > n:
break
train_idx = np.arange(0, train_end - horizon) # purge gap
val_idx = np.arange(val_start, val_end)
yield train_idx, val_idx
def train_spread_model(features: pd.DataFrame, target: pd.Series, horizon: int = 10):
"""Train LightGBM with purged, embargoed walk-forward validation."""
common = features.index.intersection(target.dropna().index)
X = features.loc[common].reset_index(drop=True)
y = target.loc[common].reset_index(drop=True)
models, scores = [], []
params = {
"objective": "mae",
"learning_rate": 0.05,
"num_leaves": 63,
"min_child_samples": 100,
"subsample": 0.8,
"colsample_bytree": 0.8,
"reg_alpha": 0.1,
"reg_lambda": 1.0,
"verbose": -1,
}
for fold, (train_idx, val_idx) in enumerate(
purged_walk_forward(len(X), n_splits=5, horizon=horizon)
):
X_tr, X_val = X.iloc[train_idx], X.iloc[val_idx]
y_tr, y_val = y.iloc[train_idx], y.iloc[val_idx]
ds_tr = lgb.Dataset(X_tr, y_tr)
ds_val = lgb.Dataset(X_val, y_val, reference=ds_tr)
model = lgb.train(
params,
ds_tr,
num_boost_round=2000,
valid_sets=[ds_val],
callbacks=[lgb.early_stopping(50), lgb.log_evaluation(200)],
)
preds = model.predict(X_val)
mae = mean_absolute_error(y_val, preds)
r2 = r2_score(y_val, preds)
print(f"Fold {fold}: MAE={mae:.4f} bps, R²={r2:.4f}")
models.append(model)
scores.append({"mae": mae, "r2": r2})
return models[-1], scores
รายละเอียดสำคัญคือ ช่องว่าง purge/embargo เป้าหมายแบบ forward-mean หมายความว่าแถวที่ติดกันซ้อนทับกันได้ถึง horizon snapshot ดังนั้น TimeSeriesSplit แบบธรรมดาจึงปล่อยให้แถว validation ใช้หน้าต่างอนาคตร่วมกับแถว training — รั่วคำตอบและทำให้ R² ของ validation พองโต การตัดช่องว่างอย่างน้อย horizon แถวบนทั้งสองด้านของขอบเขตแต่ละ fold (purged k-fold แบบ Lopez de Prado) จะกำจัดอคตินั้น ข้อนี้ใช้ได้กับไปป์ไลน์ gradient-boosting มากพอ ๆ กับ deep learning แม้ว่าการรั่วไหลจะถูกพูดถึงบ่อยกว่าในโมเดลแบบ sequence
การวิเคราะห์ความสำคัญของฟีเจอร์
ข้อได้เปรียบสำคัญอย่างหนึ่งของโมเดลที่อิงต้นไม้คือความสามารถในการตีความ หลังการฝึก ให้ตรวจสอบค่า SHAP เพื่อทำความเข้าใจว่าฟีเจอร์ใดเป็นตัวขับเคลื่อนการพยากรณ์สเปรด:
import shap
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_val)
shap.summary_plot(shap_values, X_val, max_display=15)
ข้อค้นพบทั่วไปข้ามกลุ่มสินทรัพย์:
- Lagged spread () เกือบจะเป็นฟีเจอร์ที่สำคัญที่สุดเสมอ — สเปรดมี autocorrelation สูงมาก นี่ก็เป็นเหตุผลว่าทำไม R² พาดหัวจึงดูสูง: คะแนนส่วนใหญ่เป็นเพียงความคงอยู่ (persistence) ดังนั้นจงเปรียบเทียบกับ baseline AR/EWMA เสมอ (รายละเอียดเพิ่มเติมด้านล่าง)
- Realized volatility สำคัญเป็นอันดับสอง — ความผันผวนภายในวันและสเปรดมีสหสัมพันธ์เชิงบวกอย่างแข็งแกร่ง ทั้งในเวลาเดียวกันและเชิงไดนามิก
- Book imbalance สำคัญที่สุดในช่วงที่มีความผันผวน — มันส่งสัญญาณการเคลื่อนไหวเชิงทิศทางที่กำลังจะเกิดขึ้น
- Trade flow imbalance จับ adverse selection ระยะสั้น — การพุ่งของ flow ฝั่งเดียวพยากรณ์การขยายตัวของสเปรด
- Time-of-day จับรูปแบบ U-shaped ภายในวัน (กว้างกว่าตอนเปิด/ปิด แคบกว่าตอนกลางวัน)
ข้อพิจารณาเรื่อง Hyperparameter
สำหรับการพยากรณ์สเปรดโดยเฉพาะ:
- ใช้ MAE หรือ Huber loss แทน MSE การกระจายตัวของสเปรดเบ้ขวาและมี outlier สุดขั้วเป็นครั้งคราว (ในช่วงเหตุการณ์ข่าว) MAE ทนทานกว่า
- ตั้ง
min_child_samplesสูง (100+) เพื่อป้องกันไม่ให้โมเดล fit กับ microstructure noise ใน snapshot รายตัว - ใช้
subsample < 1.0เพื่อลดสหสัมพันธ์ของต้นไม้และปรับปรุงการ generalize ข้าม regime ความผันผวนที่ต่างกัน
แนวทาง Deep Learning
ในขณะที่ gradient boosting โดดเด่นบนฟีเจอร์แบบตาราง deep learning สามารถเรียนรู้การแทนค่า (representation) ได้โดยตรงจากข้อมูล order book ดิบ มีสองสถาปัตยกรรมที่พิสูจน์แล้วว่ามีประสิทธิภาพสำหรับงานพยากรณ์ที่เกี่ยวกับสเปรด
สถาปัตยกรรมที่ 1: CNN-LSTM สำหรับ Order Book Snapshot
สถาปัตยกรรม DeepLOB (Zhang et al. 2019) ใช้การ convolution แบบ kernel เล็กที่ซ้อนกัน — และโมดูล Inception — เพื่อสกัดรูปแบบเชิงพื้นที่ข้ามระดับ order book ในขณะที่ รักษา โครงสร้างเชิงพื้นที่นั้นไว้ ตามด้วยชั้น LSTM เพื่อสร้างแบบจำลองความสัมพันธ์เชิงเวลา การเลือกออกแบบที่สำคัญคือ อย่า global-pool แกนระดับทิ้งไปก่อนชั้น recurrent การทำเช่นนั้นจะยุบโครงสร้างข้ามระดับที่ convolution ตั้งใจจะจับเอาไว้พอดี
สำหรับการพยากรณ์สเปรด input คือ tensor รูปทรง :
- = จำนวน time step (เช่น 100 snapshot)
- = จำนวนระดับราคา (เช่น 10 bid + 10 ask = 20)
- = ฟีเจอร์ต่อระดับ (ราคา, ปริมาณ, จำนวนคำสั่ง)
โมเดลด้านล่างคงไว้ซึ่ง convolutional feature map บนระดับต่าง ๆ และ flatten มันเข้าสู่ input ของ LSTM (input_size = 16 * L) แทนที่จะเฉลี่ยมิติระดับลงเป็นค่าเฉลี่ย 16 channel:
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)
หากคุณต้องการ pooling บนแกนระดับเพื่อควบคุมจำนวนพารามิเตอร์ จงใช้ pooling แบบ strided หรือแบบเรียนรู้ได้ที่คงไว้มากกว่าตำแหน่งเดียว — ไม่ใช่ AdaptiveAvgPool1d(1) ซึ่งเฉลี่ยทุกระดับลงเป็นตัวเลขเดียวและทิ้งสัญญาณเชิงพื้นที่ไป
สถาปัตยกรรมที่ 2: Transformer Encoder
Transformer สามารถจับความสัมพันธ์ระยะไกลในลำดับ order book ได้โดยไม่มีคอขวดเชิงลำดับของ LSTM สำหรับการพยากรณ์สเปรด transformer encoder แบบเบาทำงานได้ดี:
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, :])
ข้อพิจารณาในการฝึก
-
การ Normalize: Normalize ราคาเป็น offset จาก midprice (ในหน่วย tick หรือ bps) Normalize ปริมาณด้วยค่าเฉลี่ย rolling ของมัน ราคาและปริมาณดิบทำให้การฝึกไม่เสถียร
-
ฟังก์ชัน Loss: ใช้ Huber loss () เพื่อจัดการกับการพุ่งของสเปรด:
-
การสุ่มหน้าต่างและการรั่วไหล: ใช้หน้าต่างที่ไม่ซ้อนทับกันสำหรับการฝึก และ — เช่นเดียวกับในไปป์ไลน์ gradient-boosting พอดี — purge/embargo ช่องว่างอย่างน้อย
horizonsnapshot ระหว่าง train และ validation ทั้งเป้าหมายแบบ forward-mean และหน้าต่าง input ที่ซ้อนทับกันต่างก็รั่วข้อมูลอนาคตข้ามขอบเขตการแบ่งและทำให้ประสิทธิภาพที่เห็นพองโต -
การปรับตัวแบบ Online: ใน production ให้ fine-tune โมเดลบนข้อมูลล่าสุดเป็นระยะ (1-2 ชั่วโมงที่ผ่านมา) ด้วย learning rate ขนาดเล็ก market microstructure เปลี่ยนแปลงภายในวัน และโมเดลที่ฝึกบนข้อมูลตอนเช้าอาจทำงานด้อยลงในตอนบ่าย
เมื่อใดควรใช้ Deep Learning เทียบกับ Gradient Boosting
| เกณฑ์ | Gradient Boosting | Deep Learning |
|---|---|---|
| ชนิดของ input | ฟีเจอร์แบบตาราง | ลำดับ order book ดิบ |
| ขนาดข้อมูลฝึก | ใช้งานได้กับ 100K+ แถว | ต้องการ 1M+ แถว |
| Feature engineering | ทำเอง (ใช้แรงมาก ควบคุมได้สูง) | เรียนรู้เอง (ใช้แรงน้อย ตีความได้น้อยกว่า) |
| Latency ของ inference | หลักไมโครวินาทีหลักเดียวด้วย predictor ที่ compile แล้ว หลายสิบไมโครวินาทีจาก Python | หลายร้อยไมโครวินาทีบน GPU |
| ความสามารถในการตีความ | สูง (SHAP) | ต่ำ (attention map) |
| การปรับตัวต่อ regime | ฝึกใหม่ / อัปเดตแบบ online | fine-tune บนข้อมูลล่าสุด |
| ทักษะสเปรดระยะสั้น | เทียบเคียงได้กว้าง ๆ กับ DL | ความได้เปรียบเพิ่มขึ้นในกรอบเวลาที่ยาวขึ้น / ข้อมูลที่ใหญ่ขึ้น |
เราจงใจหลีกเลี่ยงการอ้างตัวเลข R² ที่เฉพาะเจาะจง: ความแม่นยำของการพยากรณ์สเปรดขึ้นอยู่อย่างมากกับกรอบเวลา สินทรัพย์ และว่าคะแนนเป็นเพียง autocorrelation ของสเปรดมากแค่ไหน โมเดลสามารถลงคะแนน R² ดิบที่น่าประทับใจในขณะที่แทบไม่เพิ่มอะไรเหนือ EWMA บรรทัดเดียว จงรายงาน ทักษะที่เหนือกว่า baseline AR/EWMA บนข้อมูลเดียวกัน พร้อมระบุกรอบเวลา แทนที่จะเป็น R² พาดหัว เช่นเดียวกัน ให้ถือว่าตัวเลข latency ขึ้นอยู่กับการนำไปใช้: โมเดล LightGBM แบบ 2000 รอบ 63 ใบ พยากรณ์แถวเดียวในหลักสิบไมโครวินาทีจาก Python และเข้าถึงเพียงไม่กี่ไมโครวินาทีด้วย predictor แบบ compiled/C++ เท่านั้น
ในทางปฏิบัติ ระบบ production จำนวนมากใช้ แนวทางสองขั้น: โมเดล gradient boosting ที่รวดเร็วสำหรับการเสนอราคาแบบเรียลไทม์ (สำคัญต่อ latency) และโมเดล deep learning ที่ทำงานแบบ asynchronous เพื่อปรับพารามิเตอร์ของโมเดล boosting หรือให้สัญญาณเสริม
จากการพยากรณ์ไปสู่การเสนอราคา

การพยากรณ์สเปรดจะมีค่าก็ต่อเมื่อมันแปลงไปเป็นราคาเสนอที่ดีขึ้น ต่อไปนี้คือกฎการเสนอราคาแบบเรียบง่ายที่ใช้สเปรดที่พยากรณ์ไว้:
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
เมื่อสินค้าคงคลังเป็น long () การ skew จะลดทั้ง bid และ ask จากมุมมองเดียวที่สอดคล้องกัน: ask ที่ต่ำลงทำให้ taker ซื้อจากเราถูกลง ซึ่งช่วยระบายสินค้าคงคลัง long ของเราออก; bid ที่ต่ำลงทำให้เรามีโอกาสน้อยลงที่จะถูกผู้ขายเข้าจับคู่ จึงชะลอการสะสมเพิ่มเติม สเปรดที่พยากรณ์ควบคุมความกว้างโดยรวม — ขยายเมื่อโมเดลคาดว่าจะมีความผันผวนหรือ adverse selection และทำให้แคบลงเมื่อสภาวะสงบ
การประเมินผลและการ Backtest

เมตริกการพยากรณ์สเปรด
นอกเหนือจากเมตริก regression มาตรฐาน (MAE, ) จงประเมินการพยากรณ์สเปรดด้วยเมตริกที่สำคัญต่อ market making:
- ทักษะเหนือ baseline: รายงาน MAE/R² เทียบกับ การพยากรณ์ AR(1) หรือ EWMA ของสเปรดล่าสุดเสมอ เพราะสเปรดมีความคงอยู่อย่างแข็งแกร่ง คะแนนสัมบูรณ์จึงถูกครอบงำด้วย autocorrelation เฉพาะการปรับปรุงเหนือ baseline พื้นฐานเท่านั้นที่สะท้อนเนื้อหาเชิงพยากรณ์ที่แท้จริง
- ความแม่นยำเชิงทิศทาง: โมเดลพยากรณ์ถูกต้องหรือไม่ว่าสเปรดจะกว้างขึ้นหรือแคบลง? โมเดลที่มี MAE ปานกลางแต่มีความแม่นยำเชิงทิศทางสูงยังคงทำกำไรได้
- การครอบคลุมส่วนหาง (tail coverage): โมเดลพยากรณ์การพุ่งของสเปรดได้หรือไม่? คำนวณ MAE แยกสำหรับสเปรด 5% บนสุด — นี่คือจุดที่ผลขาดทุนจาก adverse selection กระจุกตัว
- การ Calibrate: พล็อต quantile ของสเปรดที่พยากรณ์เทียบกับที่เกิดจริง การพยากรณ์ percentile ที่ 90 ของโมเดลที่ calibrate ดีควรตรงกับ percentile ที่ 90 ของสเปรดที่เกิดจริง
การประเมินผลที่อิง PnL
ในที่สุด เมตริกเดียวที่สำคัญคือ PnL ที่เกิดจริง จง backtest ลูปเต็มรูปแบบ:
- ที่แต่ละ timestamp พยากรณ์สเปรด
- คำนวณราคาเสนอโดยใช้สเปรดที่พยากรณ์ + inventory skew
- จำลองการเติมเต็มเทียบกับการซื้อขายในอดีต
- ติดตามสินค้าคงคลัง, PnL ที่เกิดจริง และ Sharpe ratio
เปรียบเทียบกับ baseline: (a) สเปรดคงที่ (ค่ามัธยฐานของอนุกรมเวลา), (b) EWMA ของสเปรดล่าสุด และ (c) ตัวประมาณค่า Roll
บทสรุป
การสร้างแบบจำลองสเปรดตั้งอยู่ที่จุดตัดระหว่างทฤษฎีทางการเงินและ ML ประยุกต์ การแยกองค์ประกอบแบบคลาสสิกออกเป็นต้นทุนการประมวลผลคำสั่ง, inventory และ adverse selection ให้สัญชาตญาณเชิงเศรษฐกิจว่า ทำไม สเปรดจึงแปรผัน แบบจำลองของ Roll ให้ตัวประมาณค่า baseline ที่สวยงามจากข้อมูลขั้นต่ำ — ตราบใดที่คุณคำนวณมันในหน่วยของราคา โมเดล gradient boosting แปลงฟีเจอร์ microstructure เป็นการพยากรณ์สเปรดระยะสั้นที่แม่นยำด้วย inference latency ต่ำ สถาปัตยกรรม deep learning เรียนรู้โดยตรงจากข้อมูล order book ดิบ จับรูปแบบที่ฟีเจอร์ที่สร้างด้วยมืออาจพลาดไป — โดยมีเงื่อนไขว่าสถาปัตยกรรมรักษาโครงสร้างข้ามระดับไว้แทนที่จะ pool มันทิ้งไป
สำหรับระบบ market-making ใน production ข้อแนะนำเชิงปฏิบัติเป็นแบบหลายชั้น:
- ใช้ การแยกองค์ประกอบแบบ Huang-Stoll แบบ offline เพื่อทำความเข้าใจองค์ประกอบสเปรดของคุณและ calibrate ขีดจำกัดความเสี่ยง
- ใช้ ตัวประมาณค่า Roll เป็นการตรวจสอบความสมเหตุสมผลและสำหรับตราสารที่คุณไม่มีข้อมูล order book
- นำ โมเดล LightGBM ไปใช้สำหรับการพยากรณ์สเปรดแบบเรียลไทม์ — มันเร็ว ตีความได้ และทนทาน — ด้วยการ validation แบบ purged walk-forward และการตั้งเกณฑ์เปรียบเทียบ AR/EWMA
- รัน โมเดล CNN-LSTM หรือ Transformer ในลูปรองเพื่อตรวจจับการเปลี่ยน regime และปรับโมเดลหลัก
สเปรดไม่ใช่ตัวเลข — มันคือสัญญาณ ยิ่งคุณสร้างแบบจำลองมันได้ดีขึ้น (และยิ่งคุณวัดแบบจำลองนั้นอย่างซื่อตรงมากขึ้น) คุณก็ยิ่งสามารถตั้งราคาการให้สภาพคล่องได้แม่นยำขึ้น
บทความนี้เป็นส่วนหนึ่งของซีรีส์ marketmaker.cc ว่าด้วย algorithmic market making และ microstructure
ผู้เขียน
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.