← กลับไปยังบทความ
June 3, 2026
อ่าน 5 นาที

การสร้างแบบจำลองและการพยากรณ์สเปรด Bid-Ask ด้วย Machine Learning

การสร้างแบบจำลองและการพยากรณ์สเปรด Bid-Ask ด้วย Machine Learning
#microstructure
#spread
#market-making
#prediction
#machine-learning
#gradient-boosting
#deep-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 PbP_b และราคา ask PaP_a อย่างต่อเนื่อง สเปรดที่เสนอ (quoted spread) คือ:

S=PaPbS = P_a - P_b

ทุกรอบการซื้อขายไป-กลับ (ซื้อที่ราคา bid ของ maker และขายที่ราคา ask ของ maker โดยทั้งสองฝั่งถูกเติมเต็มโดย taker) จะโอนมูลค่าสูงสุด SS จาก taker ไปยัง maker — ในทางทฤษฎี ในทางปฏิบัติ maker ได้รับน้อยกว่า SS เพราะ adverse selection: taker บางรายมีข้อมูลและทำการซื้อขายก่อนที่ราคาจะเคลื่อนไปในทางที่เสียเปรียบ maker กำไรที่เกิดขึ้นจริงต่อรอบการซื้อขายคือ realized spread ซึ่งเท่ากับ effective spread ลบด้วยผลกระทบต่อราคา (price impact):

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

เราวัดปริมาณทั้งสามค่าบนพื้นฐานเดียวกันคือ full-spread (ไม่ใช่ครึ่งสเปรด) เพื่อให้เอกลักษณ์นี้สอดคล้องกันในเชิงมิติ effective spread สำหรับการซื้อขายครั้งเดียวคือ:

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

ในที่นี้ dt{+1,1}d_t \in \{+1, -1\} คือทิศทางของการซื้อขาย (taker ซื้อหรือขาย), PtP_t คือราคาธุรกรรม และ MtM_t คือจุดกึ่งกลางของ best bid และ ask ณ เวลาที่ทำการซื้อขาย เทอม price-impact ถูกนิยามอย่างสมมาตรบนกรอบเวลาหลังการซื้อขาย τ\tau:

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

โดยที่ Mt+τM_{t+\tau} คือ mid ในกรอบเวลา τ\tau หลังการซื้อขาย ต้องระบุกรอบเวลาอย่างชัดเจน — ตัวเลือกที่พบบ่อยคือ 5 นาทีในตลาดหุ้น และ 30 วินาทีในคริปโต ซึ่งราคาตั้งใหม่เร็วกว่า การหักผลกระทบออกจาก effective spread เหลือไว้คือ realized spread: สิ่งที่ maker เก็บไว้ได้หลังจากตลาดเคลื่อนไปแล้ว

market maker ที่สามารถ พยากรณ์ สเปรด — และองค์ประกอบของมัน — ใน 1, 5 หรือ 60 วินาทีถัดไป สามารถปรับราคาเสนอแบบไดนามิกเพื่อเพิ่ม realized spread ให้สูงสุดในขณะที่ยังคงรักษาอัตราการเติมเต็ม (fill rate) เอาไว้

สามองค์ประกอบของสเปรด

สเปรด bid-ask ที่แยกออกเป็นชั้นต้นทุน order-processing, inventory และ adverse-selection

วรรณกรรม market microstructure (Stoll 1978, Glosten and Milgrom 1985, Huang and Stoll 1997) แยกสเปรด bid-ask ออกเป็นสามองค์ประกอบทางเศรษฐกิจ

1. ต้นทุนการประมวลผลคำสั่ง (α\alpha)

นี่คือต้นทุนของการให้บริการ market-making ต่อฝั่งที่ถูกเติมเต็ม: ค่าธรรมเนียมที่ maker จ่ายจริง บวกกับโครงสร้างพื้นฐานทางเทคโนโลยี การปฏิบัติตามกฎระเบียบ และต้นทุนค่าเสียโอกาสของเงินทุนที่ใช้ไป Demsetz (1968) และ Tinic (1972) เป็นกลุ่มแรกที่ทำให้องค์ประกอบนี้เป็นรูปธรรม

ข้อแตกต่างสำคัญคือ ใครจ่ายค่าธรรมเนียมแบบใด maker ที่เสนอราคาแบบ passive จะจ่าย maker fee fmf_m บนการเติมเต็มของตัวเอง — และในหลายตลาด fmf_m เป็น rebate กล่าวคือเป็นค่าลบ maker ไม่ จ่าย taker fee ftf_t บนการเติมเต็มแบบ passive เหล่านั้น คู่สัญญาที่ข้ามสเปรดเป็นผู้จ่าย ftf_t ดังนั้นต้นทุนการประมวลผลคำสั่งต่อฝั่งของ maker คือ:

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

โดยที่ fmf_m มีเครื่องหมาย (rebate ทำให้ α\alpha ลดลงและอาจติดลบได้) และ cinfrac_{\text{infra}} ครอบคลุมการเชื่อมต่อ การ colocation และการคำนวณ ในตลาดอิเล็กทรอนิกส์สมัยใหม่ องค์ประกอบนี้หดตัวลงอย่างมาก — ต่ำกว่าหนึ่งเซ็นต์ในตลาดหุ้น และไม่กี่ basis point หรือเป็น net rebate ในคริปโต

taker fee ftf_t สำคัญด้วยเหตุผลอื่น: มันกำหนดเพดานล่างว่าสเปรด เต็ม จะแคบลงได้แค่ไหนอย่างมีกำไร เพราะ taker ที่ข้ามสเปรดต้องจ่าย ftf_t เพิ่มเติมจากสเปรด หากคุณต้องการเพดานล่างของสเปรดที่ทำให้ราคาเสนอของคุณน่าสนใจในเชิงเศรษฐกิจเมื่อเทียบกับต้นทุน taker นั้น จงให้เหตุผลแยกต่างหากแทนที่จะพับ ftf_t เข้าไปในต้นทุนของ maker เอง การรวมสองสิ่งนี้เข้าด้วยกันเท่ากับนับค่าธรรมเนียมรอบการซื้อขายซ้ำซ้อนภายในครึ่งสเปรดเดียว

2. ต้นทุนการถือครองสินค้าคงคลัง (β\beta)

เมื่อ market maker สะสมสถานะที่มีทิศทาง (long หรือ short) พวกเขาต้องแบกรับความเสี่ยงด้านราคา องค์ประกอบ inventory ชดเชยความเสี่ยงนี้ Stoll (1978) และ Amihud and Mendelson (1980) สร้างแบบจำลองนี้เป็นฟังก์ชันของความผันผวนและสินค้าคงคลังปัจจุบันของ maker:

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

โดยที่ σ\sigma คือความผันผวนของสินทรัพย์ และ QQ คือสินค้าคงคลังปัจจุบันของ maker เมื่อสินค้าคงคลังเพิ่มขึ้น maker จะขยายสเปรดบนฝั่งที่ตนมีความเสี่ยงและทำให้แคบลงในอีกฝั่ง เทคนิคนี้เรียกว่า inventory skewing

3. ต้นทุน Adverse Selection (γ\gamma)

นี่คือองค์ประกอบที่อันตรายที่สุด เทรดเดอร์ที่มีข้อมูล — ผู้ที่มีข้อมูลเหนือกว่าเกี่ยวกับการเคลื่อนไหวของราคาที่กำลังจะเกิดขึ้น — เลือกหยิบราคาเสนอที่ล้าสมัยอย่างเป็นระบบ ต้นทุน adverse selection เท่ากับผลขาดทุนที่คาดหวังต่อการซื้อขายให้แก่คู่สัญญาที่มีข้อมูล Copeland and Galai (1983) สร้างแบบจำลองนี้เป็นมูลค่าของออปชันฟรีที่ maker มอบให้แก่เทรดเดอร์ที่มีข้อมูล Glosten and Milgrom (1985) ทำให้เป็นรูปธรรมในรูปของการปรับปรุงความเชื่อแบบเบย์ของ maker หลังจากสังเกตการซื้อขาย:

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

โดยที่ VV คือมูลค่าพื้นฐานที่แท้จริง ในตลาดที่มีสภาพคล่อง adverse selection สามารถคิดเป็น 30-60% ของสเปรดทั้งหมด

การแยกองค์ประกอบทั้งหมด

ครึ่งสเปรดที่เสนอสามารถเขียนได้เป็น:

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

โดยที่ α\alpha, β\beta และ γ\gamma ทั้งหมดแสดงเป็นต้นทุนต่อฝั่ง (ครึ่งสเปรด) — นั่นคือสิ่งที่ทำให้การบัญชีสอดคล้องกัน Huang and Stoll (1997) เสนอวิธีทางเศรษฐมิติเพื่อประมาณองค์ประกอบเหล่านี้จากข้อมูลการซื้อขายและราคาเสนอ ข้อค้นพบสำคัญ: ต้นทุนการประมวลผลคำสั่งสร้างเพดานล่างของสเปรดที่คงที่ ต้นทุน inventory สร้างสเปรดที่แปรผันตามสถานะและความผันผวน และต้นทุน adverse selection สร้างสเปรดที่แปรผันตามความไม่สมมาตรของข้อมูล

แบบจำลองสเปรดโดยปริยายของ Roll

แบบจำลองสเปรดโดยปริยายของ Roll: ราคาธุรกรรมที่เด้งระหว่าง bid และ ask ทิ้งร่องรอย serial-covariance ที่เป็นลบ

ก่อนที่ข้อมูลความถี่สูงจะหาได้อย่างแพร่หลาย Richard Roll (1984) เสนอวิธีที่สวยงามในการประมาณ effective spread โดยใช้เพียงราคาธุรกรรม ข้อค้นพบของเขา: ในตลาดที่มีประสิทธิภาพ การเด้งของ bid-ask ก่อให้เกิด serial covariance ที่เป็นลบในการเปลี่ยนแปลงของราคา แม้ในขณะที่ไม่มีข้อมูลใหม่

แบบจำลอง

สมมติว่ามูลค่าพื้นฐาน VtV_t เคลื่อนตาม 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)

ราคาธุรกรรมที่สังเกตได้เด้งระหว่าง bid และ ask:

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

โดยที่ dt{1,+1}d_t \in \{-1, +1\} ด้วยความน่าจะเป็นเท่ากัน (กล่าวคือ การซื้อและการขายมีโอกาสเกิดขึ้นเท่ากัน) การเปลี่ยนแปลงของราคาคือ:

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

เมื่อคำนวณ autocovariance อันดับหนึ่ง:

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

แบบจำลองนี้ถูกอนุมานใน หน่วยของราคา: SS ออกมาจาก autocovariance ของ การเปลี่ยนแปลง ของราคา ไม่ใช่ผลตอบแทน (returns) ข้อแตกต่างนี้คือข้อผิดพลาดในการนำไปใช้ที่พบบ่อยที่สุด และเรารักษาโค้ดให้ยึดถือตามนั้นด้านล่าง

ตัวประมาณค่า Roll

แก้สมการหา SS:

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

เมื่อ autocovariance ของตัวอย่างเป็นบวก (ซึ่งเกิดขึ้นบ่อยในทางปฏิบัติเนื่องจาก noise หรือ momentum) ตัวประมาณค่าจะไม่ถูกนิยาม วิธีแก้ที่พบบ่อยคือตั้งค่าประมาณเป็นศูนย์หรือใช้รากที่มีเครื่องหมาย:

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

โดยที่ γ^1\hat{\gamma}_1 คือ 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 ด้วยสเปรดจริง S=0.10S = 0.10 — กู้คืนค่า 0.0999\approx 0.0999 จากการเปลี่ยนแปลงของราคา ตัวแปรที่อิงผลตอบแทนจะคืนค่า 0.001\approx 0.001 ซึ่งคลาดเคลื่อนไปตามระดับราคา และการนำ ค่านั้น ไปหารด้วย midprice อีกครั้งเพื่อให้ได้ bps ก็ยิ่งทบความผิดพลาดเข้าไปอีก หากคุณชอบตัวประมาณค่าใน return-space จงอนุมานแบบจำลองในพื้นที่ log-price และตัดการหารด้วย midprice ครั้งที่สองออก เลือกระบบหนึ่งและทำให้โค้ดสอดคล้องกับคณิตศาสตร์

ข้อจำกัดของแบบจำลอง Roll

แบบจำลองของ Roll สมมติว่า: (1) ตลาดมีประสิทธิภาพ, (2) ไม่มีความไม่สมมาตรของข้อมูล, (3) ทิศทางการซื้อขายเป็น i.i.d. และ (4) สเปรดคงที่ ทั้งหมดนี้ถูกละเมิดในทางปฏิบัติ Harris (1990) แสดงให้เห็นว่าตัวประมาณค่ามีอคติอย่างรุนแรงเนื่องจากอสมการของ Jensen เมื่อนำไปใช้กับข้อมูลที่มี noise แม้จะมีข้อจำกัดเหล่านี้ ตัวประมาณค่า Roll ยังคงมีประโยชน์ในฐานะ baseline ที่รวดเร็วและถูกใช้อย่างแพร่หลายในงานวิจัยทางการเงินเชิงประจักษ์

ฟีเจอร์ ML สำหรับการพยากรณ์สเปรด

ฟีเจอร์ microstructure ที่ออกแบบไว้ — ความผันผวน, order-flow imbalance, depth, ความถี่การซื้อขาย — ป้อนเข้าตัวพยากรณ์สเปรด

เพื่อก้าวข้ามแบบจำลองสถิต เราต้องการฟีเจอร์ที่จับปัจจัยขับเคลื่อนแบบไดนามิกของความแปรผันของสเปรด ต่อไปนี้คือการจัดหมวดหมู่ของฟีเจอร์ที่จัดเรียงตามองค์ประกอบของสเปรดที่ฟีเจอร์นั้นเป็นตัวแทน

ฟีเจอร์ Order Book (Inventory และ Adverse Selection)

ฟีเจอร์ สูตร เป็นตัวแทนของ
Book imbalance BI=Vb1Va1Vb1+Va1\text{BI} = \frac{V_b^1 - V_a^1}{V_b^1 + V_a^1} แรงกดดันเชิงทิศทาง
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} มูลค่ายุติธรรมระยะสั้น
Depth ratio (ระดับ 1-5) DR5=i=15Vbii=15Vai\text{DR}_5 = \frac{\sum_{i=1}^{5} V_b^i}{\sum_{i=1}^{5} V_a^i} อุปทาน/อุปสงค์หลายระดับ
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 แรงกดดันถ่วงน้ำหนักตามระยะทาง
อัตราส่วน Spread / tick S/tickS / \text{tick} ความแคบเทียบกับค่าต่ำสุด

Book pressure ในที่นี้ใช้ การลดทอนตามระยะทางสัมบูรณ์ถึง mid คือ wi=eλPiMw_i = e^{-\lambda |P_i - M|} ดังนั้นปริมาณที่อยู่ใกล้ touch จึงมีน้ำหนักมากกว่าปริมาณที่อยู่ลึก และทั้งสองฝั่งถูกถ่วงน้ำหนักด้วยฟังก์ชันบวกที่ลดลง วิธีนี้หลีกเลี่ยงอคติเชิงเครื่องหมายเชิงโครงสร้างของการหารปริมาณด้วยระยะทาง ที่มีเครื่องหมาย PiMP_i - M (ซึ่งติดลบในฝั่ง bid เป็นบวกในฝั่ง ask และพุ่งสูงขึ้นเมื่อระดับเข้าใกล้ mid) เลือก λ\lambda จากความลึกของ book ทั่วไป หรือแทนที่ฟังก์ชันเอกซ์โพเนนเชียลด้วยน้ำหนักบวก w(PiM)w(|P_i - M|) ใด ๆ ที่ลดลงตามระยะทาง

ฟีเจอร์ Trade Flow (Adverse Selection)

ฟีเจอร์ สูตร เป็นตัวแทนของ
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} flow ที่มีข้อมูลสุทธิ
VPIN ความน่าจะเป็นของการซื้อขายที่มีข้อมูลแบบ volume-synchronized ความเป็นพิษ (toxicity)
Kyle's lambda การถดถอยของ ΔM\Delta M บนปริมาณที่มีเครื่องหมาย ผลกระทบต่อราคาต่อหน่วย
ความถี่การซื้อขายขนาดใหญ่ จำนวนการซื้อขาย >kmedian> k \cdot \text{median} ในหน้าต่าง กิจกรรมของสถาบัน

ฟีเจอร์ความผันผวน (ต้นทุน Inventory)

ฟีเจอร์ สูตร เป็นตัวแทนของ
Realized volatility σrv=(ΔlogM)2\sigma_{\text{rv}} = \sqrt{\sum (\Delta \log M)^2} ความเสี่ยงระยะสั้น
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 ที่อิงช่วง (range-based)
Vol-of-vol rolling std ของ σrv\sigma_{\text{rv}} ความไม่แน่นอนของ regime
Return autocorrelation ρ1(ΔM)\rho_1(\Delta M) momentum / mean-reversion

ฟีเจอร์ Market Regime

ฟีเจอร์ คำอธิบาย เป็นตัวแทนของ
การเข้ารหัสเวลาของวัน sin(2πt/T),cos(2πt/T)\sin(2\pi t / T), \cos(2\pi t / T) ฤดูกาลภายในวัน
วินาทีนับจากการซื้อขายล่าสุด ช่องว่างเวลา ระดับกิจกรรม
สหสัมพันธ์ข้ามสินทรัพย์ rolling corr กับดัชนี/BTC ความเสี่ยงเชิงระบบ
Funding rate (คริปโต) funding rate ของ perp การวางสถานะแบบใช้ leverage

Gradient Boosting สำหรับการพยากรณ์สเปรด

Gradient boosted trees (XGBoost, LightGBM, CatBoost) คือม้างานของการพยากรณ์ข้อมูลแบบตารางในการเงินเชิงปริมาณ มันจัดการกับฟีเจอร์หลายชนิดได้ จับ interaction ที่ไม่เป็นเชิงเส้น ต้องการการประมวลผลล่วงหน้าน้อยที่สุด และฝึกได้รวดเร็วบนข้อมูลนับล้านแถว — โดยมีเงื่อนไขว่าการสร้างฟีเจอร์เองต้องเป็นแบบ vectorized (ดูหมายเหตุเรื่อง autocorrelation ด้านล่าง)

การตั้งโจทย์ปัญหา

เราตั้งกรอบการพยากรณ์สเปรดเป็นงาน regression เป้าหมายคือค่าเฉลี่ยถ่วงน้ำหนักด้วยเวลาของสเปรดที่เสนอในช่วง τ\tau วินาทีถัดไป:

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

ในทางปฏิบัติ เราประมาณค่านี้ด้วยสเปรดเฉลี่ยถ่วงน้ำหนักด้วยปริมาณในช่วง NN snapshot ถัดไป:

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

เป้าหมายนี้คือ หน้าต่างไปข้างหน้า (forward window) ที่มีความยาว NN (หรือ horizon) ซึ่งหมายความว่าแถวที่อยู่ติดกันใช้หน้าต่างอนาคตที่ซ้อนทับกัน การซ้อนทับนั้นทำให้ข้อมูลรั่วไหลข้ามการแบ่ง train/validation แบบไร้เดียงสา — เราจัดการเรื่องนี้อย่างชัดเจนในโค้ดการฝึกด้านล่าง

ไปป์ไลน์เต็มรูปแบบ

import lightgbm as lgb
import numpy as np
import pandas as pd
from sklearn.metrics import mean_absolute_error, r2_score

def build_features(df: pd.DataFrame) -> pd.DataFrame:
    """Build spread-prediction features from L2 order book snapshots."""
    f = pd.DataFrame(index=df.index)

    f["imb1"] = (df["bid_vol_1"] - df["ask_vol_1"]) / (
        df["bid_vol_1"] + df["ask_vol_1"] + 1e-9
    )

    bid_depth = df[[f"bid_vol_{i}" for i in range(1, 6)]].sum(axis=1)
    ask_depth = df[[f"ask_vol_{i}" for i in range(1, 6)]].sum(axis=1)
    f["depth_imb5"] = (bid_depth - ask_depth) / (bid_depth + ask_depth + 1e-9)

    mid = 0.5 * (df["ask_1"] + df["bid_1"])
    f["spread_bps"] = (df["ask_1"] - df["bid_1"]) / mid * 1e4
    f["log_spread"] = np.log1p(df["ask_1"] - df["bid_1"])

    log_ret = np.log(mid / mid.shift(1))
    f["rvol_50"] = log_ret.rolling(50).std()
    f["rvol_200"] = log_ret.rolling(200).std()

    if "trade_sign" in df.columns and "trade_vol" in df.columns:
        signed_vol = df["trade_sign"] * df["trade_vol"]
        total_vol = df["trade_vol"].rolling(50).sum()
        f["tfi_50"] = signed_vol.rolling(50).sum() / (total_vol + 1e-9)

    lag1 = log_ret.shift(1)
    f["ret_autocorr"] = log_ret.rolling(100).corr(lag1)

    if isinstance(df.index, pd.DatetimeIndex):
        seconds = df.index.hour * 3600 + df.index.minute * 60 + df.index.second
        f["tod_sin"] = np.sin(2 * np.pi * seconds / 86400)
        f["tod_cos"] = np.cos(2 * np.pi * seconds / 86400)

    for lag in [1, 5, 10, 50]:
        f[f"spread_lag_{lag}"] = f["spread_bps"].shift(lag)

    return f.dropna()


def build_target(df: pd.DataFrame, horizon: int = 10) -> pd.Series:
    """Forward mean spread over the next `horizon` snapshots (in bps).

    target[t] = mean(spread_bps[t+1 .. t+horizon]). Note that consecutive
    targets share an overlapping forward window of length `horizon`, which
    is why the CV below purges a gap of `horizon` rows around each fold.
    """
    mid = 0.5 * (df["ask_1"] + df["bid_1"])
    spread_bps = (df["ask_1"] - df["bid_1"]) / mid * 1e4
    fwd = spread_bps.shift(-1).rolling(horizon).mean().shift(-(horizon - 1))
    return fwd


def purged_walk_forward(n: int, n_splits: int, horizon: int):
    """Expanding-window splits with a purge/embargo gap of `horizon` rows.

    Because each target spans `horizon` future snapshots, rows straddling a
    train/val boundary share overlapping target windows. Dropping a gap of
    `horizon` rows between train and validation removes that leakage
    (Lopez de Prado-style purging). Without it, validation R²/MAE are
    optimistically biased by the target overlap.
    """
    fold_size = n // (n_splits + 1)
    for k in range(1, n_splits + 1):
        train_end = fold_size * k
        val_start = train_end + horizon       # embargo gap
        val_end = val_start + fold_size
        if val_end > n:
            break
        train_idx = np.arange(0, train_end - horizon)   # purge gap
        val_idx = np.arange(val_start, val_end)
        yield train_idx, val_idx


def train_spread_model(features: pd.DataFrame, target: pd.Series, horizon: int = 10):
    """Train LightGBM with purged, embargoed walk-forward validation."""
    common = features.index.intersection(target.dropna().index)
    X = features.loc[common].reset_index(drop=True)
    y = target.loc[common].reset_index(drop=True)

    models, scores = [], []
    params = {
        "objective": "mae",
        "learning_rate": 0.05,
        "num_leaves": 63,
        "min_child_samples": 100,
        "subsample": 0.8,
        "colsample_bytree": 0.8,
        "reg_alpha": 0.1,
        "reg_lambda": 1.0,
        "verbose": -1,
    }

    for fold, (train_idx, val_idx) in enumerate(
        purged_walk_forward(len(X), n_splits=5, horizon=horizon)
    ):
        X_tr, X_val = X.iloc[train_idx], X.iloc[val_idx]
        y_tr, y_val = y.iloc[train_idx], y.iloc[val_idx]

        ds_tr = lgb.Dataset(X_tr, y_tr)
        ds_val = lgb.Dataset(X_val, y_val, reference=ds_tr)

        model = lgb.train(
            params,
            ds_tr,
            num_boost_round=2000,
            valid_sets=[ds_val],
            callbacks=[lgb.early_stopping(50), lgb.log_evaluation(200)],
        )
        preds = model.predict(X_val)
        mae = mean_absolute_error(y_val, preds)
        r2 = r2_score(y_val, preds)
        print(f"Fold {fold}: MAE={mae:.4f} bps, R²={r2:.4f}")
        models.append(model)
        scores.append({"mae": mae, "r2": r2})

    return models[-1], scores

รายละเอียดสำคัญคือ ช่องว่าง purge/embargo เป้าหมายแบบ 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)

ข้อค้นพบทั่วไปข้ามกลุ่มสินทรัพย์:

  1. Lagged spread (spread_lag_1\text{spread\_lag\_1}) เกือบจะเป็นฟีเจอร์ที่สำคัญที่สุดเสมอ — สเปรดมี autocorrelation สูงมาก นี่ก็เป็นเหตุผลว่าทำไม R² พาดหัวจึงดูสูง: คะแนนส่วนใหญ่เป็นเพียงความคงอยู่ (persistence) ดังนั้นจงเปรียบเทียบกับ baseline AR/EWMA เสมอ (รายละเอียดเพิ่มเติมด้านล่าง)
  2. Realized volatility สำคัญเป็นอันดับสอง — ความผันผวนภายในวันและสเปรดมีสหสัมพันธ์เชิงบวกอย่างแข็งแกร่ง ทั้งในเวลาเดียวกันและเชิงไดนามิก
  3. Book imbalance สำคัญที่สุดในช่วงที่มีความผันผวน — มันส่งสัญญาณการเคลื่อนไหวเชิงทิศทางที่กำลังจะเกิดขึ้น
  4. Trade flow imbalance จับ adverse selection ระยะสั้น — การพุ่งของ flow ฝั่งเดียวพยากรณ์การขยายตัวของสเปรด
  5. 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 รูปทรง (T,L,F)(T, L, F):

  • TT = จำนวน time step (เช่น 100 snapshot)
  • LL = จำนวนระดับราคา (เช่น 10 bid + 10 ask = 20)
  • FF = ฟีเจอร์ต่อระดับ (ราคา, ปริมาณ, จำนวนคำสั่ง)

โมเดลด้านล่างคงไว้ซึ่ง 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, :])

ข้อพิจารณาในการฝึก

  1. การ Normalize: Normalize ราคาเป็น offset จาก midprice (ในหน่วย tick หรือ bps) Normalize ปริมาณด้วยค่าเฉลี่ย rolling ของมัน ราคาและปริมาณดิบทำให้การฝึกไม่เสถียร

  2. ฟังก์ชัน Loss: ใช้ Huber loss (δ=1.0\delta = 1.0) เพื่อจัดการกับการพุ่งของสเปรด:

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

  1. การสุ่มหน้าต่างและการรั่วไหล: ใช้หน้าต่างที่ไม่ซ้อนทับกันสำหรับการฝึก และ — เช่นเดียวกับในไปป์ไลน์ gradient-boosting พอดี — purge/embargo ช่องว่างอย่างน้อย horizon snapshot ระหว่าง train และ validation ทั้งเป้าหมายแบบ forward-mean และหน้าต่าง input ที่ซ้อนทับกันต่างก็รั่วข้อมูลอนาคตข้ามขอบเขตการแบ่งและทำให้ประสิทธิภาพที่เห็นพองโต

  2. การปรับตัวแบบ 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 หรือให้สัญญาณเสริม

จากการพยากรณ์ไปสู่การเสนอราคา

การแปลงสเปรดที่พยากรณ์เป็นราคา bid/ask สด ที่เบ้ตามสินค้าคงคลังรอบราคา mid ที่ยุติธรรม

การพยากรณ์สเปรดจะมีค่าก็ต่อเมื่อมันแปลงไปเป็นราคาเสนอที่ดีขึ้น ต่อไปนี้คือกฎการเสนอราคาแบบเรียบง่ายที่ใช้สเปรดที่พยากรณ์ไว้:

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

การประเมินผลและการ Backtest

การประเมินแบบจำลองสเปรด: การ calibrate สเปรดที่พยากรณ์เทียบกับที่เกิดจริง ควบคู่กับมาตรวัดอัตราการเติมเต็มและ PnL

เมตริกการพยากรณ์สเปรด

นอกเหนือจากเมตริก regression มาตรฐาน (MAE, R2R^2) จงประเมินการพยากรณ์สเปรดด้วยเมตริกที่สำคัญต่อ 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 ลูปเต็มรูปแบบ:

  1. ที่แต่ละ timestamp พยากรณ์สเปรด
  2. คำนวณราคาเสนอโดยใช้สเปรดที่พยากรณ์ + inventory skew
  3. จำลองการเติมเต็มเทียบกับการซื้อขายในอดีต
  4. ติดตามสินค้าคงคลัง, 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 ข้อแนะนำเชิงปฏิบัติเป็นแบบหลายชั้น:

  1. ใช้ การแยกองค์ประกอบแบบ Huang-Stoll แบบ offline เพื่อทำความเข้าใจองค์ประกอบสเปรดของคุณและ calibrate ขีดจำกัดความเสี่ยง
  2. ใช้ ตัวประมาณค่า Roll เป็นการตรวจสอบความสมเหตุสมผลและสำหรับตราสารที่คุณไม่มีข้อมูล order book
  3. นำ โมเดล LightGBM ไปใช้สำหรับการพยากรณ์สเปรดแบบเรียลไทม์ — มันเร็ว ตีความได้ และทนทาน — ด้วยการ validation แบบ purged walk-forward และการตั้งเกณฑ์เปรียบเทียบ AR/EWMA
  4. รัน โมเดล CNN-LSTM หรือ Transformer ในลูปรองเพื่อตรวจจับการเปลี่ยน regime และปรับโมเดลหลัก

สเปรดไม่ใช่ตัวเลข — มันคือสัญญาณ ยิ่งคุณสร้างแบบจำลองมันได้ดีขึ้น (และยิ่งคุณวัดแบบจำลองนั้นอย่างซื่อตรงมากขึ้น) คุณก็ยิ่งสามารถตั้งราคาการให้สภาพคล่องได้แม่นยำขึ้น


บทความนี้เป็นส่วนหนึ่งของซีรีส์ marketmaker.cc ว่าด้วย algorithmic market making และ microstructure

ข้อจำกัดความรับผิดชอบ: ข้อมูลที่ให้ไว้ในบทความนี้มีไว้เพื่อการศึกษาและให้ข้อมูลเท่านั้น และไม่ถือเป็นคำแนะนำทางการเงิน การลงทุน หรือการเทรด การเทรดสกุลเงินดิจิทัลมีความเสี่ยงสูงที่จะขาดทุน

ผู้เขียน

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

ก้าวนำหน้าตลาด

สมัครรับจดหมายข่าวของเราเพื่อรับข้อมูลเชิงลึกการเทรดด้วย AI เฉพาะ การวิเคราะห์ตลาด และการอัปเดตแพลตฟอร์ม

เราเคารพความเป็นส่วนตัวของคุณ ยกเลิกการสมัครได้ทุกเมื่อ