การจำลอง Fill: บันไดจากภาพลวงตาราคาปิดสู่ความจริงที่รับรู้คิว
Backtest ของคุณประกอบด้วยสองโมเดล คือโมเดลของ alpha และโมเดลของ fill คนส่วนใหญ่ทุ่มความพยายาม 95% ให้กับอย่างแรก แล้วรับอย่างที่สองมาจากเฟรมเวิร์กที่บังเอิญใช้อยู่โดยไม่ตั้งคำถาม นี่คือการทำสิ่งต่างๆ กลับหัวกลับหาง สัญญาณธรรมดาๆ ที่มาพร้อมโมเดล fill ที่ซื่อสัตย์ จะให้ค่าประมาณ PnL ที่ธรรมดาแต่ เป็นจริง สัญญาณที่ยอดเยี่ยมแต่ใช้ fill_price = candle.close จะให้ตัวเลขที่ไม่ใช่ค่าประมาณของอะไรเลย มันคือผลลัพธ์ของสมมติฐานที่คุณไม่เคยตรวจสอบ
สำหรับกลยุทธ์ taker บนสินทรัพย์ที่มีสภาพคล่องสูง โมเดล fill เป็นแค่พจน์ปรับแก้ (correction term) แต่สำหรับสิ่งใดก็ตามที่เกี่ยวข้องกับออเดอร์ limit ที่วางรอ (resting) ไม่ว่าจะเป็น market making, การเข้าแบบ passive, หรือการเก็บ rebate จาก post-only โมเดล fill คือ ตัวกลยุทธ์เอง คุณจะได้ fill หรือไม่ เมื่อไหร่ ในปริมาณเท่าไหร่ และขึ้นอยู่กับการเคลื่อนไหวของราคาหลังจากนั้นอย่างไร สิ่งเหล่านี้กำหนดเครื่องหมาย (sign) ของ PnL ไม่ใช่แค่ขนาดของมัน
ใน Backtest-live parity เราได้แจกแจงอนุกรมวิธานทั้งหมดของความคลาดเคลื่อนระหว่าง backtest กับการเทรดจริง และให้คะแนนความคลาดเคลื่อนด้าน execution ที่ระดับความรุนแรง 5/5 ซึ่งเป็นระดับที่แย่ที่สุด บทความนั้นให้ระดับความแม่นยำของการจำลอง fill แบบหยาบๆ สามระดับแล้วก็ผ่านไป บทความนี้คือภาคลึกที่ตามมา บันไดฉบับเต็ม ทีละขั้น พร้อมวงจรชีวิตของออเดอร์ในรูปแบบ state machine ขอบเขตความน่าจะเป็นของการ fill ที่คุณคำนวณได้จริง และการทดลองที่วัดผลจริงซึ่งแสดงให้เห็นอย่างชัดเจนว่ากลยุทธ์ maker ที่ "ทำกำไรได้" ตายลงที่ขั้นไหนของบันได
บันได

แต่ละขั้นต้องการข้อมูลและโค้ดมากขึ้น และแต่ละขั้นก็กำจัดอคติเชิงระบบ (systematic bias) เฉพาะอย่างออกไป บันไดถูกเรียงลำดับตามสิ่งที่มันทำผิด ไม่ใช่แค่ตามต้นทุน
ขั้นที่ 0: fill ที่ราคาปิด
fill_price = bar.close
ออเดอร์ fill ทันที เต็มจำนวน ที่ราคาปิดของแท่งที่สร้างสัญญาณนั้นขึ้นมา สัญญาณถูกคำนวณจากราคาปิดเดียวกันนั้นเอง ดังนั้นคุณกำลังเทรดบนข้อมูลที่ยังไม่มีอยู่จริงตอนที่ราคาถูกพิมพ์ออกมา นี่คือ look-ahead bias ที่สวมหน้ากาก execution กลยุทธ์ที่มี turnover สูงจะดูดีเสมอในขั้นนี้
ขั้นที่ 1: เปิดของแท่งถัดไป
fill_price = next_bar.open
โมเดลที่ซื่อสัตย์ขั้นต่ำสุดสำหรับตรรกะ taker บนแท่งราคา สัญญาณถูกคำนวณที่แท่ง การ fill เกิดขึ้นที่ราคาแรกที่สังเกตได้ของแท่ง วิธีนี้กำจัด look-ahead ออกไป แต่ยังคงสมมติว่า spread เป็นศูนย์ impact เป็นศูนย์ สภาพคล่องไม่จำกัดที่ราคาเปิด และความแน่นอนของการ fill อยู่ที่ 100% สำหรับออเดอร์ limit มันจะเสื่อมลงเป็นตรรกะ touch-fill (จะอธิบายว่าทำไมสิ่งนี้เป็นพิษในภายหลัง)
ขั้นที่ 2: spread + slippage คงที่
half_spread = mid * spread_bps / 2e4
slip = mid * slippage_bps / 1e4
fill_price = mid + side * (half_spread + slip) # side: +1 buy, -1 sell
ตอนนี้ทุกการเทรดแบบ taker ต้องจ่ายครึ่งหนึ่งของ spread บวกกับค่าคงที่ที่คาลิเบรตไว้ นี่คือขั้นแรกที่กลยุทธ์ turnover สูงอาจ ตาย ใน backtest ซึ่งนั่นคือประเด็นสำคัญ ความคลาดเคลื่อนที่เหลืออยู่คือ slippage ไม่ใช่ค่าคงที่ มันแปรผันตามขนาดออเดอร์เทียบกับความลึก (depth) ที่แสดง และมันจะพุ่งสูงขึ้นพอดีตอนที่กลยุทธ์ของคุณอยากเทรดมากที่สุด ค่าคงที่ 5 bps เป็นเพียงค่าเฉลี่ยข้ามหลาย regime กลยุทธ์ของคุณไม่ได้เทรดใน regime ค่าเฉลี่ยนั้น
ขั้นที่ 3: L2 depth-walk สำหรับ market order
เมื่อมี order book snapshot คุณจะเลิกเดา slippage แล้วคำนวณมันแทน คำสั่ง market buy ขนาด จะไล่เดินไปตามฝั่ง ask ทีละระดับ ราคา fill คือค่าเฉลี่ยถ่วงน้ำหนักด้วยปริมาณ (volume-weighted average) ข้ามระดับที่ถูกกินไป:
def depth_walk(levels: list[tuple[float, float]], qty: float) -> tuple[float, float]:
"""levels: [(price, size), ...] sorted best-first. Returns (vwap, filled_qty)."""
remaining, cost = qty, 0.0
for price, size in levels:
take = min(size, remaining)
cost += take * price
remaining -= take
if remaining <= 0:
break
filled = qty - remaining
return (cost / filled if filled > 0 else float("nan"), filled)
การปรับแก้สองอย่างทำให้สิ่งนี้ซื่อสัตย์ขึ้นอย่างมีนัยสำคัญ อย่างแรกคือ latency: ไล่เดินไปตาม book ตามที่มันเป็นอยู่ หลังจาก timestamp ที่คุณตัดสินใจ โดย คือ latency ที่วัดได้จริงจากสัญญาณไปถึง exchange — book ที่คุณเห็นไม่ใช่ book ที่คุณโดนตี อย่างที่สองคือ partial marketable fill: ถ้าความลึกที่แสดงภายในราคา limit-through ของคุณน้อยกว่า โมเดลต้องคืนค่าเป็น partial fill และเหลือส่วนที่ยังรอ (resting remainder) ซึ่งจะส่งต่อปัญหาไปยัง state machine ด้านล่าง
ความคลาดเคลื่อนที่เหลืออยู่ในขั้นที่ 3 คือ impact และการเติมใหม่ (refill) คุณกินเข้าไปในตัว book ราวกับมันเป็นวัตถุนิ่ง แต่ book จริงจะเติมกลับบางส่วน (และคู่ค้าจริงก็มีปฏิกิริยาตอบสนอง) สำหรับขนาด clip ที่ต่ำกว่าไม่กี่เปอร์เซ็นต์ของความลึกที่ top-of-book ความคลาดเคลื่อนนี้มีขนาดเล็ก สำหรับ clip ที่ใหญ่กว่านั้นคุณต้องมีโมเดล impact (Almgren-Chriss 2001) ซ้อนทับเข้าไปด้วย
ขั้นที่ 4: การ fill ออเดอร์ limit แบบความน่าจะเป็นตามตำแหน่งในคิว
ขั้นที่ 0-3 ตอบคำถามว่า "ออเดอร์ที่ aggressive ของฉันจะ fill ที่ราคาเท่าไหร่" ขั้นที่ 4 ตอบคำถามที่ยากกว่า: ออเดอร์ passive ของฉันจะ fill หรือไม่เลย — และนี่เป็นขั้นเดียวที่สามารถประเมินราคากลยุทธ์ maker ได้จริง ออเดอร์ limit ที่วางรออยู่ที่ราคา จะ fill เมื่อปริมาณการเทรดสะสมที่ราคา เกินกว่าปริมาณในคิวที่อยู่ข้างหน้ามัน ซึ่งต้องอาศัยการติดตามตำแหน่งของคุณในคิวแบบ FIFO ที่คุณสังเกตโดยตรงไม่ได้
กลไกการประมาณตำแหน่งในคิว — ตำแหน่งเริ่มต้น กฎการอัปเดตเมื่อมีเทรดกับการยกเลิก ตระกูลฟังก์ชันความน่าจะเป็น สำหรับการจัดสรรการยกเลิกที่สังเกตไม่ได้ — คือหน่วยพื้นฐานที่เราสร้างไว้ใน Queue inside the wall ผมจะไม่อนุพันธ์ซ้ำที่นี่ ตัวจำลองใช้มันเป็นข้อมูลนำเข้า สิ่งที่ขั้นที่ 4 เพิ่มเข้ามาคือ กฎการตัดสินใจ fill ที่วางซ้อนบนค่าประมาณนั้น ซึ่งครอบคลุมในหัวข้อความน่าจะเป็นของการ fill ด้านล่าง
เหตุผลว่าทำไมขั้นนี้ถึงสำคัญถูกวัดผลไว้ในวรรณกรรมวิชาการ: Moallemi and Yuan (2016), "A Model for Queue Position Valuation in a Limit Order Book," แสดงให้เห็นว่าสำหรับสินทรัพย์ที่มี tick ใหญ่ มูลค่าทางเศรษฐกิจของตำแหน่งหน้าคิวเทียบกับท้ายคิวนั้นเทียบเท่ากับครึ่งหนึ่งของ spread — ขนาดเดียวกันกับ edge ทางทฤษฎีทั้งหมดของกลยุทธ์ market making เลยทีเดียว โมเดล fill ที่ละเลยตำแหน่งในคิวไม่ได้แค่ประเมิน PnL ของ maker ผิดพลาด มันกำลังประเมิน PnL ของกลยุทธ์คนละแบบไปเลย
ยังมีขั้นที่ 5 อยู่ด้วย นั่นคือการจำลองแบบ agent-based เต็มรูปแบบ ที่ตลาด ตอบสนอง ต่อออเดอร์ของคุณ (queue-reactive model ในความหมายของ Huang, Lehalle and Rosenbaum 2015; เฟรมเวิร์กแบบ multi-agent เช่น ABIDES, Byrd et al. 2020) การ replay ข้อมูลย้อนหลัง แม้จะรับรู้คิวแล้วก็ตาม ยังคงสมมติว่าออเดอร์ของคุณไม่เปลี่ยนแปลงพฤติกรรมของคนอื่นเลย สมมติฐานนั้นใช้ได้ดีในขนาด retail แต่จะยิ่งผิดมากขึ้นเรื่อยๆ เมื่อ quote ของคุณกลายเป็นสัดส่วนที่มองเห็นได้ของระดับราคานั้น ขั้นที่ 5 อยู่นอกขอบเขตของบทความนี้ แต่ควรรู้ไว้ว่าบันไดไม่ได้จบแค่ขั้นที่ 4
Partial fill ในรูปแบบ state machine
ขั้นที่ 0-2 สามารถแสร้งทำเป็นว่าออเดอร์คือการเรียกฟังก์ชัน: ส่ง รับราคา จบ ตั้งแต่ขั้นที่ 3 ขึ้นไป ออเดอร์คือ กระบวนการ ที่มีวงจรชีวิต และตัวจำลองต้องสร้างแบบจำลองมันในรูปแบบ state machine ไม่เช่นนั้นมันจะจัดการกรณีที่สำคัญที่สุดผิดพลาดไปโดยไม่รู้ตัว

enum OrderState {
PendingNew, // sent, not yet acked (latency window)
Resting { remaining: f64, q_ahead: f64 }, // in book, queue position estimated
PartialFill { remaining: f64, q_ahead: f64 }, // some qty done, rest still queued
PendingAmend, // amend in flight
PendingCancel, // cancel in flight
Filled,
Canceled { filled_qty: f64 }, // may be partially filled at cancel time
Rejected,
}
การเปลี่ยนสถานะ (transition) เหล่านี้แบกรับตรรกะทางเศรษฐศาสตร์ไว้:
PendingNew→Resting: ออเดอร์เข้าคิว ต่อท้าย ทุกอย่างที่มีอยู่แล้วในเวลาที่ได้รับ ack ไม่ใช่ในเวลาที่ตัดสินใจ ตำแหน่งในคิวของคุณถูกกำหนดเริ่มต้นด้วยปริมาณของระดับราคา ณ เวลา ตัวจำลองที่กำหนดตำแหน่งเริ่มต้น ณ เวลาตัดสินใจจะประเมินความสำคัญของตำแหน่งในคิว (queue priority) สูงเกินไปอย่างเป็นระบบ — สูงเกินไปพอดีเท่ากับปริมาณที่เข้ามาในช่วง latency window ของคุณ ซึ่งเป็นปริมาณส่วนใหญ่ในช่วงที่ตลาดคึกคัก (burst)Resting→PartialFill: เทรดที่ระดับราคาของคุณซึ่งใหญ่กว่าคิวที่อยู่ข้างหน้าจะทำให้คุณได้ fill บางส่วน ส่วนที่เหลือจะคงตำแหน่งของมันไว้ (ตอนนี้อยู่หน้าคิวแล้ว) Partial fill ไม่ใช่สัญญาณรบกวน — มันคือ ข้อมูล การได้ fill 0.3 จาก 1.0 แล้วเห็นราคาสะท้อนหนีไป เป็นเหตุการณ์ PnL ที่แตกต่างจากการ fill เต็มจำนวน และกระบวนการ inventory ของ maker ถูกสร้างขึ้นจากเศษเสี้ยวเหล่านี้Resting→PendingAmend→Resting: กับดัก บนแทบทุกแพลตฟอร์มคริปโต การ amend คือ cancel/replace —cancelReplaceของ Binance เป็น atomic เพื่อป้องกันการ execute ซ้ำ แต่มันคืน order ID ใหม่ที่ท้ายคิว แม้แต่แพลตฟอร์มที่มี native modify semantics (CME Globex) ก็รักษา time priority ไว้เฉพาะสำหรับ การลดปริมาณ เท่านั้น การเปลี่ยนราคาหรือเพิ่มปริมาณจะเสียสิทธิ์นั้นไป ดังนั้นในตัวจำลอง: การ amend ราคาใดๆ ต้อง resetq_aheadให้เป็นปริมาณเต็มของระดับราคาปัจจุบัน เอนจิ้น quoting ที่ re-peg ทุก 500ms ไม่ได้กำลัง "รักษา quote" อยู่ มันกำลังเข้าคิวใหม่ที่ท้ายคิวอยู่ตลอดเวลา และโปรไฟล์การ fill ที่สมจริงของมันเกือบจะเป็น adverse selection ล้วนๆ เมื่อรวมกับผลลัพธ์ของ Moallemi-Yuan: การ re-quote มีราคาของมัน และราคานั้นคือตำแหน่งในคิวของคุณPendingCancel→PartialFill→Canceled: การยกเลิกก็มี latency เช่นกัน ในช่วงเวลาระหว่างการตัดสินใจถอน quote จนถึงการที่คำสั่งยกเลิกไปถึง matching engine คุณยังคงได้ fill ได้ — และการ fill เหล่านั้นคือการ fill ที่ แย่ที่สุด ที่คุณจะได้รับ เพราะเหตุผลที่คุณกำลังถอน quote ก็คือตลาดกำลังจะวิ่งทับคุณ ตัวจำลองที่ไม่มี cancel latency จะลบการ fill ที่เป็นพิษที่สุดออกจากประวัติของคุณไปพอดี
State machine ยังเป็นตัวที่ทำให้การบัญชีของตัวจำลองซื่อสัตย์: ค่าธรรมเนียมเกิดขึ้นต่อเหตุการณ์ fill inventory อัปเดตต่อเหตุการณ์ fill และสถิติเวลาในแต่ละสถานะ (ออเดอร์รอนานแค่ไหนก่อนจะ fill เทียบกับถูกยกเลิก) กลายเป็นสิ่งที่เปรียบเทียบได้โดยตรงกับ log จริง ซึ่งเป็นสิ่งที่ลูปการคาลิเบรตในตอนท้ายใช้เป็นอาหาร
ความน่าจะเป็นของการ fill ออเดอร์ limit: สามโมเดลและกรอบขอบเขต
เมื่อมีออเดอร์วางรออยู่ที่ราคา (สมมติว่าเป็น bid) ตัวจำลองจะประกาศว่า fill เมื่อไหร่ กฎการตัดสินใจสามแบบ เรียงตามความซื่อสัตย์ที่เพิ่มขึ้น:
1. Touch-fill (แบบไร้เดียงสา) Fill เมื่อราคาแตะระดับของคุณ: นี่คือกฎแบบ first-passage-time และความล้มเหลวของมันถูกวัดไว้ตั้งแต่หนึ่งในสี่ศตวรรษที่แล้ว: Lo, MacKinlay and Zhang (2002), "Econometric Models of Limit-Order Executions" (Journal of Financial Economics 65) ใช้โมเดล survival กับข้อมูลออเดอร์ limit จริง และสรุปว่าการ execution ตามสมมติฐานที่สร้างจาก first-passage time นั้น "เป็นตัวแทนที่แย่มากสำหรับการ execution ของออเดอร์ limit จริง" รูปแบบความล้มเหลวนี้เป็นเชิงโครงสร้าง: เมื่อราคาแตะระดับของคุณแล้วสะท้อนกลับ การแตะนั้นกินคิวที่ อยู่หน้า — เทรดเดอร์ที่ตั้ง quote ไว้ก่อนคุณ Touch-fill มอบ fill ของพวกเขาให้คุณ ที่แย่ไปกว่านั้นคือมันมอบพอดีการ fill ที่ ดี (touch-and-bounce คือสถานการณ์ที่ทำกำไรให้ maker) ในขณะที่ชุดการ fill จริงของคุณจะเอียงไปทาง touch-and-run-through — พวกที่โดน adverse selection
2. Trade-through (ขอบเขตแบบระมัดระวัง) Fill เฉพาะเมื่อราคาเทรดผ่านระดับของคุณอย่างชัดเจน: หรือในข้อมูลระดับ tick ปริมาณเทรดสะสมที่ เกินกว่าปริมาณของ ทั้งระดับ ถ้าราคาเทรดผ่าน คิวทั้งหมดที่ ก็ถูกกินหมด ดังนั้นคุณได้ fill แน่นอนไม่ว่าตำแหน่งของคุณจะอยู่ตรงไหน วิธีนี้ไม่มีทางมอบ fill ที่คุณไม่ควรจะได้รับ อคติของมันเป็นภาพสะท้อนตรงข้ามกับ touch-fill: มันปฏิเสธทุก fill ที่คิวหมดลงจนถึงตำแหน่งของคุณโดยไม่มีการเจาะทะลุเต็มที่ และ fill ที่มันให้มาก็เอียงไปทางพวก run-through (โดน adverse selection) อย่างไม่สมส่วน Backtest ของ maker ภายใต้ trade-through คือการทดสอบความเครียด (stress test) ไม่ใช่ค่าประมาณ
3. ค่าประมาณการหมดคิว (queue-depletion) ติดตามปริมาณเทรดสะสม ที่ราคาของคุณจาก trade tape และปริมาณที่ยกเลิก ที่อนุมานได้จาก L2 delta คิวโดยประมาณที่อยู่ข้างหน้าคุณ:
โดยที่ คือปริมาณของระดับราคา ณ เวลาที่ออเดอร์เข้า (ack-time) และ คือสัดส่วนของการยกเลิกที่สมมติว่ามาจากด้านหน้าของคุณ — ปุ่มปรับที่รูปแบบหลักการของมัน (ตระกูล ) ถูกอนุพันธ์ไว้ในบทความเรื่องตำแหน่งในคิว การ fill เริ่มต้นเมื่อ ปริมาณที่คุณได้ fill คือปริมาณเทรดที่เกินกว่านั้น ซึ่งจะให้ partial fill ออกมาตามธรรมชาติ:
การตั้ง (การยกเลิกทั้งหมดอยู่หน้าคุณ) ให้ขอบที่เป็นมองโลกในแง่ดีของโมเดลนี้ ส่วน ให้ขอบที่มองโลกในแง่ร้าย เมื่อคุณมีเพียง L2 snapshot แบบเบาบางและไม่มี trade tape ที่ระดับราคานั้น — ซึ่งพบได้บ่อยกับฟีดคริปโตที่ถูก throttle ที่ 100ms — คุณสามารถพึ่งพา prior ที่อิงโมเดลได้: Cont, Stoikov and Talreja (2010), "A Stochastic Model for Order Book Dynamics" (Operations Research 58) สร้างแบบจำลองแต่ละระดับราคาเป็นคิวแบบ birth-death และคำนวณ ผ่าน Laplace transform ความน่าจะเป็นที่ออเดอร์ที่ bid จะ execute ก่อนที่ mid จะเคลื่อนที่ โดยมีเงื่อนไขจากขนาดคิวปัจจุบัน มันคือ oracle ความน่าจะเป็นของการ fill เชิงวิเคราะห์: หยาบเมื่อเทียบกับการ replay tape แต่ดีกว่า touch-fill มาก และคำนวณได้ถูกพอที่จะประเมินภายใน backtest loop ที่ทำงานหนัก
วินัยของกรอบขอบเขต (bracket)
กฎทั้งสามนี้ไม่ใช่คู่แข่งกัน — พวกมันคือลำดับชั้น:
ซึ่งก่อให้เกิดกรอบขอบเขต PnL รัน backtest ของ maker ทุกตัว สามครั้ง และรายงานเป็นช่วง:
(อสมการด้านบนเป็นค่าประมาณ — touch-fill สามารถจัดอันดับกลยุทธ์ผิดได้ ไม่ใช่แค่ทำให้สูงเกินจริง เพราะมันมอบ fill ที่ดีแบบสมมติให้คุณ) กฎการตัดสินใจที่ตามมาคือ: กลยุทธ์ maker จะนำไปใช้งานได้ก็ต่อเมื่อมันรอดที่ขอบระมัดระวังของกรอบขอบเขตนี้เท่านั้น และกรอบขอบเขตนั้นต้องแคบพอที่ค่าประมาณจุดจะมีความหมาย กลยุทธ์ที่แสดง +3k แบบ trade-through มีกรอบขอบเขตกว้างถึง 2.1k / +$0.4k กำลังบอกอะไรที่เป็นจริงกับคุณ
การทดลอง: เดินกลยุทธ์ maker หนึ่งตัวลงบันได

ลองใช้ maker แบบเรียบง่ายจงใจหนึ่งตัว — quote แบบสมมาตรที่ best bid/ask, clip ขนาดคงที่ 0.05 BTC, inventory จำกัดที่ ±0.5 BTC พร้อม taker flattening เมื่อถึงขีดจำกัด ใช้ข้อมูล BTCUSDT perpetual หนึ่งเดือน: แท่ง 1 นาทีสำหรับขั้นต่ำๆ, L2 diff 100ms บวก trade tape สำหรับขั้นสูงๆ ค่าธรรมเนียม maker 1.0 bps, taker 4.0 bps ใช้โค้ดสัญญาณเดียวกันในทุกขั้น (shared core ดังนั้นตัวแปรเดียวคือโมเดล fill) ตัวเลขด้านล่างนี้คือการรันหนึ่งครั้งที่เป็นตัวแทนของเรา ขนาดของคุณจะแตกต่างไปตามแพลตฟอร์ม เดือน และขนาด แต่รูปทรงจะไม่แตกต่าง:
| ขั้น | โมเดล fill | อัตรา fill ของ quote | จำนวน fill | PnL รายเดือน | ข้อสรุป |
|---|---|---|---|---|---|
| 0 | close-fill | 98% | 41,200 | +$14,800 | ภาพลวงตา |
| 1 | next-bar-open / touch บนแท่ง 1 นาที | 89% | 37,400 | +$9,600 | ภาพลวงตาที่มี latency |
| 2 | touch + spread & slippage คงที่บน taker flatten | 89% | 37,400 | +$7,100 | สร้างโมเดลต้นทุนแล้ว แต่ fill ยังคงเป็นเรื่องสมมติ |
| 3 | + L2 depth-walk บน taker flatten | 89% | 37,400 | +$6,400 | การออกซื่อสัตย์ การเข้ายังคงเป็นเรื่องสมมติ |
| 4a | trade-through (ระมัดระวัง) | 21% | 8,900 | -$3,900 | ขอบเขต stress |
| 4b | queue-depletion, คาลิเบรต แล้ว | 37% | 15,600 | -$700 | ค่าประมาณที่ดีที่สุด |
| 4c | queue-depletion, (มองโลกในแง่ดี) | 44% | 18,700 | +$1,900 | ขอบบน |
| — | รัน shadow จริง เดือนเดียวกัน | 35% | 14,100 | -$1,150 | ความเป็นจริง |
อ่านตารางจากบนลงล่างแล้วดูว่ากลยุทธ์ตายลงที่ไหน มัน ไม่ใช่ ขั้นที่ 2 — ค่าธรรมเนียมและ slippage เฉือนออกไป 26% แต่กลยุทธ์ก็ยังดูมีกำไรอย่างมั่นคง มันตายลงระหว่างขั้นที่ 3 กับขั้นที่ 4 และมันตายด้วยเหตุผลที่โมเดลต้นทุนใดๆ จับไม่ได้: การเลือก fill (fill selection) Touch-fill มอบ fill 37,400 ครั้ง ซึ่งส่วนใหญ่เป็น touch-and-bounce — การเก็บ spread ล้วนๆ โมเดลที่รับรู้คิวลบ fill เหล่านั้นออกไป 58% และ fill ที่มันลบออกไปนั้น เป็นพวกที่ทำกำไรอย่างไม่สมส่วน เมื่อระดับราคาถูกแตะเบาๆ คิวที่อยู่หน้า maker ที่ re-quote ด้วย latency แบบ retail จะดูดซับทุกอย่างไปหมด fill ที่รอดมาถึงขั้นที่ 4 เอียงไปทางการกวาดที่เคลียร์ทั้งระดับ (level-clearing sweep) — fill ที่ราคากำลังเคลื่อนผ่านคุณไปแล้ว อัตรา fill ลดลง 2.4 เท่า PnL เปลี่ยนเครื่องหมาย ความไม่สมมาตรนั้น — สูญเสีย fill ที่ดี เก็บ fill ที่แย่ไว้ — คือ adverse selection ที่ถูกทำให้ชัดเจนเชิงกลไก และมันมองไม่เห็นเลยในทุกขั้นที่ต่ำกว่า 4
สังเกตด้วยว่ากรอบขอบเขตทำอะไร: [-1,900] ครอบคลุมศูนย์ไว้ โดยค่าประมาณที่คาลิเบรตแล้วอยู่ที่ -1,150 ตัวจำลองไม่ได้ทำนาย PnL จริงได้แม่นเป๊ะถึงดอลลาร์เดียว — แต่มันได้ เครื่องหมาย ได้ ขนาด และอัตรา fill ที่คลาดเคลื่อนเพียง 2 จุด นั่นคือสิ่งที่โมเดล fill มีไว้ทำ Backtest ขั้นที่ 1 พลาดจาก PnL จริงไป $10,750 บนกลยุทธ์ที่ edge รวมทั้งเดือนมีเพียงไม่กี่พันดอลลาร์: ความคลาดเคลื่อนของโมเดล fill มีขนาดใหญ่ราว 3 เท่าของตัว alpha เอง นี่คือที่มาของวิทยานิพนธ์หลัก: โมเดล fill ของคุณเป็นสมมติฐานที่ใหญ่กว่า alpha ของคุณเสียอีก
ข้อควรระวังหนึ่งอย่างสำหรับขั้นต่ำๆ: ถ้าคุณต้องอยู่กับข้อมูลแท่งราคา (ขั้นที่ 0-2) อย่างน้อยที่สุดให้แก้ไขความกำกวมภายในแท่ง (intrabar) ด้วย adaptive drill-down — เจาะลึกจาก 1 นาทีลงไปที่ 1 วินาที/100ms/เทรด เมื่อระดับ SL, TP หรือ quote ตกอยู่ภายในช่วงของแท่ง Drill-down แก้ไขข้อผิดพลาดด้าน ลำดับเหตุการณ์ (ระดับไหนถูกแตะก่อน) แต่แก้ไขข้อผิดพลาดด้าน คิว ไม่ได้ มันเป็นภาคเสริมด้านความละเอียดของข้อมูลสำหรับบทความนี้ ไม่ใช่ตัวแทนของขั้นที่ 4
ลูปการคาลิเบรต: ปิดช่องว่างกับ fill จริง
ตัวจำลองขั้นที่ 4 มีพารามิเตอร์อิสระ — , latency , cancel-latency, สมมติฐานการเติมระดับราคาใหม่ ถ้าไม่คาลิเบรต มันก็เป็นแค่การเดาที่มีรูปทรงต่างออกไป ลูปที่เปลี่ยนมันให้เป็นเครื่องมือ:
1. บันทึกทุกอย่างจากการเทรดจริง ทุกเหตุการณ์ของออเดอร์พร้อม timestamp ของ exchange: submit, ack, แต่ละ partial fill, amend ack, cancel ack บวกกับสถานะ L2 ณ เวลา submit นี่คือวินัยการบันทึก log แบบเดียวกับที่ backtest-live parity เรียกร้องสำหรับ DivergenceMonitor ของมัน — การคาลิเบรตโมเดล fill คือชั้นที่ลึกที่สุดของ monitor นั้น
2. Replay ออเดอร์เดียวกันผ่านตัวจำลอง ป้อนข้อมูลตลาดที่บันทึกไว้และ คำสั่ง ออเดอร์ที่บันทึกไว้ (ไม่ใช่ fill) เข้าไปในตัวจำลอง ตอนนี้คุณมีผลลัพธ์เป็นคู่: สำหรับแต่ละออเดอร์จริง มีชะตากรรมจำลองหนึ่งอัน
3. เปรียบเทียบการกระจายตัว ไม่ใช่ค่าเฉลี่ย แบบแยกกลุ่ม (bucket) อัตรา fill รวมทั้งหมดตัวเดียวสามารถซ่อนข้อผิดพลาดที่ชดเชยกันได้ (มองโลกในแง่ดีเกินไปใน regime เงียบสงบ มองโลกในแง่ร้ายเกินไปในช่วง burst — หักลบกันจนดูเหมือน "คาลิเบรตแล้ว") แยกกลุ่มตามปัจจัยขับเคลื่อน:
import numpy as np
from scipy.stats import ks_2samp
def calibration_report(pairs, bucket_key):
"""pairs: [{'bucket':…, 'live_filled':bool, 'sim_filled':bool,
'live_ttf':float|None, 'sim_ttf':float|None}, …]"""
out = {}
for b in sorted({p['bucket'] for p in pairs}):
grp = [p for p in pairs if p['bucket'] == b]
live_fr = np.mean([p['live_filled'] for p in grp])
sim_fr = np.mean([p['sim_filled'] for p in grp])
live_ttf = [p['live_ttf'] for p in grp if p['live_ttf'] is not None]
sim_ttf = [p['sim_ttf'] for p in grp if p['sim_ttf'] is not None]
ks = ks_2samp(live_ttf, sim_ttf) if len(live_ttf) > 20 and len(sim_ttf) > 20 else None
out[b] = {
'n': len(grp),
'fill_rate_live': live_fr,
'fill_rate_sim': sim_fr,
'fill_rate_gap': sim_fr - live_fr, # signed: + means sim optimistic
'ttf_ks_pvalue': ks.pvalue if ks else None,
}
return out
สถิติสองตัวต่อแต่ละ bucket: ช่องว่างอัตรา fill ที่มีเครื่องหมาย (simulator ลบ live) และการทดสอบ KS บนการกระจายตัวของ เวลาจนถึง fill ในบรรดาออเดอร์ที่ได้ fill การเปรียบเทียบเวลาจนถึง fill คือตัวที่เฉียบคมที่สุด — ตัวจำลองสามารถจับคู่ อัตรา fill ได้ในขณะที่ fill ผิด เวลา อย่างเป็นระบบ ซึ่งจะทำให้สถิติ inventory และ adverse-selection ที่ตามมาผิดเพี้ยนไปหมด นี่คือบทเรียนของกรอบแนวคิด survival-analysis ของ Lo-MacKinlay-Zhang โดยตรง: execution คือปัญหาแบบ time-to-event ดังนั้นจึงต้องตรวจสอบมันในฐานะปัญหาแบบนั้น
4. ปรับปุ่มควบคุม ตามลำดับความสามารถในการระบุค่า (identifiability) Latency ก่อนเป็นอันดับแรก (วัดโดยตรงจาก ack timestamp — ไม่ใช่การ fit) แล้วจึงปรับ โดยการลดช่องว่างอัตรา fill ให้น้อยที่สุดข้าม bucket ความลึกของคิว จากนั้นตรวจสอบ bucket ของ regime ความผันผวน: ช่องว่างที่มองโลกในแง่ดีอย่างต่อเนื่องซึ่งกระจุกตัวอยู่ใน bucket ของช่วง burst มักหมายความว่าตัวจำลองของคุณสร้างแบบจำลอง fill ที่เป็นพิษจาก cancel-latency หรือการเติมระดับราคาใหม่ต่ำเกินไป ไม่ใช่ปัญหาที่
5. รันกรอบขอบเขตอีกครั้ง หลังจากคาลิเบรตแล้ว ค่าประมาณ queue-depletion ควรอยู่ภายในกรอบขอบเขตใกล้กับค่าจริง และ — การทดสอบการยอมรับที่แท้จริง — การจัดอันดับ ของกลยุทธ์รุ่นต่างๆ ภายใต้ตัวจำลองควรตรงกับการจัดอันดับของพวกมันในโหมด shadow จากนั้นตรึงพารามิเตอร์ไว้และคาลิเบรตใหม่ตามตารางเวลา พลวัตของการ fill เปลี่ยนแปลงไปตามระดับค่าธรรมเนียมของแพลตฟอร์ม การเปลี่ยนแปลง tick-size และประชากร HFT และ ที่คาลิเบรตในเดือนมีนาคมก็เป็นเพียงสมมติฐานภายในเดือนกรกฎาคม
ความคาดหวังในการลู่เข้า (convergence) จากการรันของเรา: ตัวจำลองขั้นที่ 4 ที่ยังไม่ได้คาลิเบรตมักจะอยู่ในช่วง ±10-15 จุดจากอัตรา fill จริง หลังจากคาลิเบรตหนึ่งรอบ เหลือ ±3-5 จุด โดยค่า p-value ของ KS สำหรับเวลาจนถึง fill ไม่ปฏิเสธสมมติฐานอย่างสม่ำเสมออีกต่อไป คุณจะไม่สามารถทำได้ดีกว่านั้นด้วยการ replay ข้อมูลย้อนหลัง — ส่วนที่เหลือคือปฏิกิริยาของตลาดต่อตัวคุณเอง ซึ่งเป็นปัญหาของขั้นที่ 5
สิ่งที่ควรจดจำ
- ระบุขั้นของคุณ Backtest ทุกตัวอยู่บนบันไดนี้ ไม่ว่าคุณจะเลือกขั้นเองหรือเฟรมเวิร์กของคุณเลือกให้ ถ้าคุณระบุขั้นของโมเดล fill และอคติที่รู้จักของมันไม่ได้ ตัวเลข PnL ของคุณก็มี error bar ที่คุณยังไม่เคยเห็น
- Taker หยุดที่ขั้นที่ 3 ได้ Depth-walk บวกกับ latency ที่วัดได้ ให้ราคาการ execute แบบ aggressive ที่ซื่อสัตย์ในขนาด retail ใช้ความพยายามที่ประหยัดได้ไปกับคุณภาพข้อมูลแทน
- Maker เริ่มที่ขั้นที่ 4 ต่ำกว่านั้น ตรรกะการ fill ออเดอร์ limit ไม่ใช่แค่ประมาณคร่าวๆ — มันเลือกชุด fill แบบสมมติที่มี skew ของ adverse selection ตรงกันข้าม Backtest แบบ touch-fill ของกลยุทธ์ maker คือตัวสร้างกลยุทธ์ที่ตายทันทีที่สัมผัสกับ production ที่น่าเชื่อถือที่สุด
- สร้างแบบจำลองวงจรชีวิต ไม่ใช่แค่การ fill Partial fill, amend-reset-queue, cancel latency — state machine คือที่ที่ fill ที่เป็นพิษอาศัยอยู่ และ fill ที่เป็นพิษคือที่ที่ PnL ของ maker ไปตาย
- รายงานกรอบขอบเขต ขอบเขตแบบระมัดระวังและมองโลกในแง่ดีมีต้นทุนแค่การรัน backtest เพิ่มอีกสองครั้ง และเปลี่ยน "backtest ของฉันบอกว่า +3.9k, +$1.9k]" — ซึ่งเป็นการตัดสินใจที่แตกต่างและดีกว่า
- คาลิเบรตกับ fill จริงแบบแยกกลุ่ม โมเดล fill ที่ตรวจสอบเฉพาะในภาพรวมคือโมเดล fill ที่มีข้อผิดพลาดชดเชยกันซ่อนอยู่ ช่องว่างอัตรา fill แบบแยกกลุ่มบวกกับการทดสอบ KS ของเวลาจนถึง fill คาลิเบรตใหม่ทุกไตรมาส
ขั้นบันไดเหล่านี้ไม่ใช่การแบ่งระดับเชิงวิชาการ — แต่ละขั้นคือคำโกหกเฉพาะอย่างที่ backtest ของคุณเลิกบอกคุณ ไต่ขึ้นไปจนกว่าคำโกหกจะเล็กกว่า edge ของคุณ
ลิงก์ที่เป็นประโยชน์
- Moallemi, C., Yuan, K. — A Model for Queue Position Valuation in a Limit Order Book (2016)
- Cont, R., Stoikov, S., Talreja, R. — A Stochastic Model for Order Book Dynamics, Operations Research 58(3), 549-563 (2010)
- Lo, A., MacKinlay, C., Zhang, J. — Econometric Models of Limit-Order Executions, Journal of Financial Economics 65(1), 31-71 (2002)
- Huang, W., Lehalle, C.-A., Rosenbaum, M. — Simulating and Analyzing Order Book Data: The Queue-Reactive Model, JASA 110(509), 107-122 (2015)
- Almgren, R., Chriss, N. — Optimal Execution of Portfolio Transactions (2001)
- Byrd, D., Hybinette, M., Balch, T. — ABIDES: Towards High-Fidelity Multi-Agent Market Simulation (2020)
- Binance API — Cancel-Replace order semantics
- CME Globex — Order modification and time priority rules
Citation
@article{soloviov2026fillsimulation,
author = {Soloviov, Eugen},
title = {Fill simulation: the ladder from close-price fantasy to queue-aware reality},
year = {2026},
url = {https://marketmaker.cc/blog/fill-simulation-partial-fills-backtest},
description = {Five rungs of fill simulation fidelity — from close-price fills to probabilistic queue-position models. Partial fills as a state machine, limit-fill probability bounds as a PnL bracket, and a calibration loop against live fills.}
}
ผู้เขียน
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.