Implementation Shortfall และ DIY TCA: วัดต้นทุนที่แท้จริงของการเทรด
โต๊ะเทรดสถาบันทุกแห่งมี pipeline สำหรับวิเคราะห์ต้นทุนการทำธุรกรรม (transaction cost analysis) แต่แทบไม่มีใครที่รันบอทคริปโตทำแบบนั้น รูปแบบทั่วไปคือบันทึก fill รวมค่าธรรมเนียม แล้วเรียกส่วนต่างระหว่าง PnL จาก backtest กับ PnL จริงว่า "slippage" — ตัวเลขคงเหลือตัวเดียวที่อธิบายไม่ได้ ซึ่งดูดซับทั้งความหน่วง (latency), สเปรด, impact, adverse selection และคำสั่งที่ไม่ถูก fill ทุกตัวที่ราคาวิ่งหนีไป คุณไม่สามารถแก้ต้นทุนที่คุณวัดออกมาเป็นตัวเลขเดียวได้ กลไกสำหรับวัดสิ่งนี้อย่างถูกต้องมีมาตั้งแต่ปี 1988 แล้ว มันไม่ใช่องค์ความรู้ปิด และเมื่อทำเหนือ log การ fill ของคุณเอง มันใช้โค้ด Python เพียงประมาณ 200 บรรทัด บทความนี้จะสร้างมันขึ้นมา
ผลตอบแทนที่ได้ไม่ใช่แดชบอร์ดที่สวยขึ้น แต่ backtest ของคุณมีโมเดลต้นทุนอยู่ — ค่าคงที่ slippage, ความน่าจะเป็นในการ fill, สัมประสิทธิ์ impact — และทุกพารามิเตอร์ในนั้นตอนนี้เป็นเพียงการเดา TCA เหนือ fill จริงเป็นความจริงภาคพื้นเดียวที่ใช้ปรับเทียบพารามิเตอร์เหล่านั้นได้ เราสร้างฝั่งจำลอง (simulation) ของลูปนี้ไว้แล้วใน Fill simulation: the ladder from close-price fantasy to queue-aware reality บทความนี้จะสร้างฝั่งการวัด
กระดาษกับความเป็นจริง: สิ่งที่ Perold วัดจริง ๆ
เทคนิคพื้นฐานนี้มาจาก André Perold (1988, "The Implementation Shortfall: Paper Versus Reality," Journal of Portfolio Management 14(3), 4–9) ให้รันพอร์ตสองพอร์ตขนานกัน พอร์ตกระดาษ (paper portfolio) ดำเนินการทุกการตัดสินใจในทันที ด้วยขนาดไม่จำกัด ต้นทุนเป็นศูนย์ ที่ราคาซึ่งมีผลอยู่ ณ ขณะตัดสินใจ พอร์ตจริง (real portfolio) คือสิ่งที่บอทของคุณทำจริง ๆ ได้แก่ fill บางส่วน การไล่ตามราคาเสนอ ส่วนที่เหลือถูกยกเลิก ค่าธรรมเนียม implementation shortfall คือส่วนต่างของผลตอบแทนทั้งสองพอร์ต
นิยามนี้สำคัญเพราะสิ่งที่มันปฏิเสธไม่ให้ซ่อนอยู่ ใบแจ้งค่าธรรมเนียมแสดงคอมมิชชัน แต่รายงานเทียบราคา fill กับราคา limit จะไม่แสดงอะไรเลย (คุณไม่มีทาง fill แย่กว่า limit ของคุณ — โดยธรรมชาติของมัน) พอร์ตกระดาษเรียกเก็บทุกอย่างจากคุณ ได้แก่ การเคลื่อนไหวของราคาระหว่างช่วงตัดสินใจกับช่วง arrival, สเปรดที่คุณข้าม, impact ที่คุณก่อ และที่สำคัญคือคำสั่งที่ไม่เคยถูก fill ในขณะที่ราคาวิ่งหนีไป Wagner และ Edwards (1993, "Best Execution," Financial Analysts Journal 49(1), 65–71) เรียกค่าธรรมเนียมที่มองเห็นได้ว่าเป็นเพียงยอดภูเขาน้ำแข็ง สำหรับสินทรัพย์ที่มีการหมุนเวียนสูง ส่วนที่จมอยู่ใต้น้ำนั้นครอบงำทั้งหมด
กำหนดสัญกรณ์ คำสั่งแม่ (parent order): ฝั่ง (ซื้อ/ขาย), ขนาด ราคาตัดสินใจ (decision price) คือราคากลาง (mid) ที่กลยุทธ์ของคุณเห็นเมื่อสัญญาณเกิดขึ้น การ fill มาถึงในรูปแบบ โดยปริมาณรวมที่ถูก fill คือ ที่ขอบฟ้าเวลา (คำสั่งแม่เสร็จสมบูรณ์ ถูกยกเลิก หรือหมดเวลา) ราคากลางคือ ค่าธรรมเนียมชัดแจ้งคือ implementation shortfall ในหน่วยเงินคือ
ทำให้เป็นหน่วยมาตรฐาน (normalized) เป็น basis point โดยหารด้วยมูลค่าตามกระดาษ (paper notional) ค่าบวกหมายความว่าคุณจ่าย เทอมแรกคือต้นทุนของ fill ที่คุณจ่ายเทียบกับพอร์ตกระดาษ เทอมที่สองคือ opportunity cost ของ Perold — ส่วนที่ยังไม่ถูก fill ซึ่งตีราคา ณ ราคาที่มันวิ่งหนีไป เทอมที่สามคือส่วนเดียวที่ใบแจ้งหนี้จากตลาดแลกเปลี่ยนของคุณยอมรับ
ข้อสังเกตด้านธรรมเนียมปฏิบัติหนึ่งข้อ วงการนี้พูดถึง "arrival price" และใน TCA หุ้นส่วนใหญ่ arrival หมายถึงราคากลางเมื่อคำสั่ง มาถึงตลาด สำหรับบอท เวลาตัดสินใจกับเวลา arrival ต่างกันด้วยความหน่วงภายในของคุณเองบวกกับการรอคิว rate-limit — ต้นทุนจริงที่วัดได้ ดังนั้นเราจึงเก็บทั้งสอง timestamp และทั้งสองราคาไว้ และให้การแยกส่วนแยกทั้งสองออกจากกัน
การแยกส่วน: delay, impact, timing, opportunity, fees
ตัวเลข IS เพียงตัวเดียวบอกคุณว่าการเทรดมีต้นทุนสูง แต่มันไม่บอกว่า ทำไม และวิธีแก้สำหรับแต่ละองค์ประกอบนั้นแตกต่างกันโดยสิ้นเชิง คุณไม่สามารถแก้ delay cost และ impact cost ด้วยการเปลี่ยนแปลงเดียวกันได้ Expanded implementation shortfall ของ Robert Kissell (Kissell, 2006, "The Expanded Implementation Shortfall: Understanding Transaction Cost Components," Journal of Trading 1(3), 6–16; พัฒนาอย่างละเอียดในหนังสือ The Science of Algorithmic Trading and Portfolio Management, Academic Press, 2013) แยกยอดรวมออกเป็นองค์ประกอบต่าง ๆ แต่ละส่วนสามารถระบุที่มาไปยังขั้นตอนที่แตกต่างกันของวงจรชีวิตคำสั่งได้ เวอร์ชันสำหรับผู้ปฏิบัติงาน โดยใช้ราคากลาง arrival (ราคากลางเมื่อได้รับการยืนยันครั้งแรกจากตลาดแลกเปลี่ยน):
เอกลักษณ์นี้ยุบตัวกลับไปเป็นนิยามของ Perold ได้พอดี — กระจายเทอมออกมาแล้ว จะหักล้างกัน แต่ละส่วนมีเจ้าของที่ชัดเจน
Delay cost : การเคลื่อนไหวของราคาระหว่างสัญญาณที่เกิดขึ้นกับคำสั่งลูกตัวแรกของคุณที่ขึ้น live บนตลาดแลกเปลี่ยน นี่คือโครงสร้างพื้นฐานของคุณ ได้แก่ การ serialize, เครือข่าย, คิว rate-limit, การตรวจสอบความเสี่ยง ถ้าสัญญาณของคุณมี alpha ระยะสั้นจริง ๆ delay cost คือจุดที่มันรั่วไหลก่อนใคร ค่าเฉลี่ยที่เป็นบวกอย่างสม่ำเสมอบอกว่าตลาดขยับไปในทางที่เป็นประโยชน์กับคุณก่อนที่คุณจะมาถึง — momentum alpha ที่กำลังเสื่อมสภาพ หรือมีใครที่เร็วกว่าเทรดสัญญาณเดียวกัน
Trading cost : สิ่งที่ fill ของคุณจ่ายเทียบกับ arrival ได้แก่ สเปรดที่ข้าม บวก market impact บวกการเคลื่อนไหวของราคาระหว่าง schedule นี่คือใบรายงานผลของอัลกอริทึม execution และเป็นปริมาณที่งานวิจัยด้าน execution จำลองไว้จริง ๆ Almgren, Thum, Hauptmann และ Li (2005, "Direct Estimation of Equity Market Impact," Risk 18(7), 58–62) วัดสิ่งนี้เหนือคำสั่งหุ้นสหรัฐฯ ราว 700,000 คำสั่งจากโต๊ะเทรดของ Citigroup (ธันวาคม 2001–มิถุนายน 2003) และพบว่า trading cost แปรผันตามความผันผวนรายวันและอัตราการเข้าร่วมตลาด (participation rate) — impact ชั่วคราว (temporary impact) เป็นไปตาม power law ในอัตราการเทรดด้วยเลขชี้กำลังใกล้เคียง 3/5 ส่วน impact ถาวร (permanent impact) ใกล้เคียงเชิงเส้นตามขนาด เราจะนำรูปแบบฟังก์ชันนี้กลับมาใช้เมื่อทำการปรับเทียบ
Timing risk: ไม่ใช่เทอมในการแยกค่าเฉลี่ย แต่เป็นความแปรปรวนรอบ ๆ มัน การกระจายคำสั่งแม่ออกไปตามเวลาช่วยลด impact ที่คาดหวังได้ แต่ทำให้คุณเผชิญกับความผันผวน สำหรับ schedule ที่มีตำแหน่งคงเหลือ ส่วนเบี่ยงเบนมาตรฐานของต้นทุนแปรผันตาม นี่คือการแลกเปลี่ยน (trade-off) ที่ กรอบ Almgren–Chriss ปรับให้เหมาะสมพอดี ในรายงาน TCA ของคุณ มันจะปรากฏเป็น การกระจายตัว ของ IS ทั่วทุกคำสั่งแม่ — รายงานส่วนเบี่ยงเบนมาตรฐานควบคู่กับทุกค่าเฉลี่ย ไม่เช่นนั้นค่าเฉลี่ยจะได้รับความสนใจทั้งหมด และหางการกระจาย (tails) จะกินเงินของคุณทั้งหมด
Opportunity cost : ปริมาณที่ยังไม่ถูก fill ซึ่งตีราคา ณ ราคาสุดท้าย สำหรับกลยุทธ์ passive นี่มักเป็นองค์ประกอบที่ใหญ่ที่สุดและถูกตรวจสอบน้อยที่สุด และเป็นเทอมที่ทำให้ทั้งกรอบการวัดมีความซื่อสัตย์ — จะพูดถึงเพิ่มเติมในส่วนกับดัก เพราะการละเลยเทอมนี้เป็นวิธีที่คนหลอกตัวเองด้วย TCA บ่อยที่สุด
Fees : ส่วนที่ชัดแจ้ง ในคริปโต ให้บันทึกค่าธรรมเนียม หลังหักส่วนลด (ระดับ VIP, การคืนเงินเป็นโทเคนที่แปลงเป็นราคา ณ เวลา fill) และเก็บ maker rebate ให้มีเครื่องหมาย — ค่าธรรมเนียมติดลบคือข้อมูล ไม่ใช่สัญญาณรบกวน
ตัวอย่างการคำนวณ
ซื้อ BTC สัญญาณเกิดขึ้นที่ มูลค่าตามกระดาษ 600,000 ดอลลาร์ คำสั่งลูกตัวแรกได้รับการยืนยันที่ราคากลาง ในสองนาทีถัดมา 8 BTC ถูก fill ที่ VWAP 60,072 ราคากำลังเคลื่อนที่ออกไป อัลกอริทึมเคารพ limit ของมัน และ BTC ที่เหลือ 2 หน่วยถูกยกเลิกด้วยราคากลางที่ ค่าธรรมเนียมเฉลี่ย 2.5 bps ของมูลค่าที่ fill
| องค์ประกอบ | สูตร | USD | bps ของมูลค่าตามกระดาษ |
|---|---|---|---|
| Delay | 120 | 2.0 | |
| Trading cost | 480 | 8.0 | |
| Opportunity | 456 | 7.6 | |
| Fees | 120 | 2.0 | |
| IS รวม | 1,176 | 19.6 |
ใบแจ้งหนี้จากตลาดแลกเปลี่ยนแสดง 120 ดอลลาร์ แต่ต้นทุนที่แท้จริงในการดำเนินการตามการตัดสินใจนี้คือ 1,176 ดอลลาร์ — ต่างกันสิบเท่า โดยสององค์ประกอบที่ใหญ่ที่สุดมองไม่เห็นเลยในบัญชีที่อิงตามค่าธรรมเนียม ราว 40% ของมันมาจากปริมาณที่ไม่เคยถูกเทรดเลย รายงาน TCA ที่วิเคราะห์เฉพาะ fill จะให้คะแนนคำสั่งแม่นี้ที่ 8 bps และบอกว่าทุกอย่างเรียบร้อยดี

Markouts: การตีราคา adverse selection
Implementation shortfall ให้คะแนนคำสั่งแม่ แต่มันไม่บอกอะไรเกี่ยวกับ คุณภาพของแต่ละ fill โดยเฉพาะว่าคุณเทรดกับคู่สัญญาที่รู้อะไรบางอย่างที่คุณไม่รู้อย่างเป็นระบบหรือไม่ สิ่งนั้นวัดได้ด้วย markouts: การตีราคาแต่ละ fill เทียบกับราคากลาง ณ ขอบฟ้าเวลาคงที่หลังจากที่มันเกิดขึ้น
โดยที่ คือราคา fill ณ เวลา และ คือราคากลาง ณ เวลา นี่คือ PnL แบบ mark-to-market ต่อหน่วยของ fill ที่ขอบฟ้าเวลา และมันมีโครงสร้างที่ชัดเจนที่ : fill แบบ maker เริ่มต้นที่ ครึ่งหนึ่งของสเปรด (คุณซื้อที่ราคา bid ราคากลางอยู่เหนือคุณ) fill แบบ taker เริ่มต้นที่ ครึ่งหนึ่งของสเปรด สิ่งที่เกิดขึ้นเมื่อ เพิ่มขึ้นคือเนื้อหาข้อมูลของการเทรดนั้น
- วินาที แยกแยะ sniping และการถูกเก็บเกี่ยว (pickoff) จากราคาเสนอที่ล้าสมัย ถ้า fill แบบ maker ของคุณขาดทุนอยู่แล้วหลังจาก fill ไปหนึ่งวินาที นั่นหมายความว่าผู้เล่นที่เร็วกว่ากำลังชน quote ของคุณในขณะที่มันตั้งราคาผิด — ลูปการอัปเดต quote ของคุณช้ากว่าลูปทริกเกอร์ของพวกเขา markout นี้เป็นการวินิจฉัยด้าน latency ไม่ใช่การวินิจฉัยกลยุทธ์
- วินาที วัด adverse selection แบบคลาสสิก: fill ที่ตามมาด้วยการเคลื่อนไหวต่อเนื่องผ่านราคาของคุณ สำหรับ market maker นี่คือต้นทุนที่การจับสเปรด (spread capture) ต้องเอาชนะให้ได้ โครงสร้างจุลภาคของหุ้นเรียกปริมาณที่เกี่ยวข้องนี้ว่า realized spread ซึ่งบรรจุอยู่ในรายงาน SEC Rule 605 ที่ขอบฟ้าเวลา 5 นาที คริปโตเคลื่อนไหวเร็วกว่า ช่วง 10–60 วินาทีคือแถบที่เทียบเท่ากัน
- วินาที บอกคุณว่า fill พา momentum ที่ขัดแย้งกับคุณเกินกว่าขอบฟ้าโครงสร้างจุลภาคหรือไม่ — สำหรับกลยุทธ์ taker คือว่า alpha ของสัญญาณคุณที่ 60 วินาทีเกินกว่าสเปรดบวก impact ที่คุณจ่ายเพื่อเข้าเทรดหรือไม่ markout curve แบบ taker ที่เริ่มต้นที่ bps และไม่เคยข้ามศูนย์เลย คือกลยุทธ์ที่จ่ายค่าเข้าเทรดที่ alpha ของมันไม่สามารถชดใช้ได้
ความไม่สมมาตรระหว่าง maker/taker คือเศรษฐศาสตร์ทั้งหมดของการเทรดแบบ passive ในสองกราฟ กราฟ maker ที่เริ่มต้นที่ bps (ครึ่งสเปรด) แล้วสลายตัวลงไปที่ bps เมื่อถึง 60 วินาที บอกว่า คุณจับสเปรดได้แต่คืนกลับไปมากกว่า และไม่มี rebate tier ใดแก้ไขสิ่งนั้นได้ กราฟเดียวกันที่ตกตะกอนที่ bps บอกว่าเอนจิ้น quoting ทำงานคุ้มค่า นี่ยังเป็นสิ่งที่ backtest แบบ touch-fill ที่ไร้เดียงสาถือว่าไม่มีอยู่ — ตัวจำลอง (simulator) ที่ fill คุณทุกครั้งที่ราคาแตะ limit ของคุณ ละเลยว่า การถูก fill มีความสัมพันธ์กับการที่คุณผิด ซึ่งเป็นเหตุผลว่าทำไมขั้น queue-aware ของ บันไดจำลอง fill จึงต้องการ markout ที่วัดได้จริงเป็นอินพุต แทนที่จะเป็นข้อสมมติฐาน

การคำนวณคือ merge_asof เหนือ stream ราคากลางของคุณเอง:
import pandas as pd
def markouts(fills: pd.DataFrame, mids: pd.DataFrame,
horizons=("1s", "10s", "60s")) -> pd.DataFrame:
"""fills: [ts, price, qty, side, liquidity]; mids: [ts, mid].
Both UTC-indexed and sorted. Mid stream must be from YOUR captured
feed, not candles reconstructed later."""
fills = fills.sort_values("ts").reset_index(drop=True)
mids = mids.sort_values("ts")
out = fills.copy()
for h in horizons:
probe = fills[["ts"]].copy()
probe["ts"] = probe["ts"] + pd.Timedelta(h)
m = pd.merge_asof(probe, mids, on="ts", direction="backward")
out[f"mo_{h}"] = (fills["side"] * (m["mid"].values - fills["price"])
/ fills["price"] * 1e4)
return out
def markout_report(mo: pd.DataFrame) -> pd.DataFrame:
"""Qty-weighted markouts by liquidity flag. Weighting matters:
a 0.001 BTC fill and a 2 BTC fill are not equal evidence."""
cols = [c for c in mo.columns if c.startswith("mo_")]
def agg(g):
w = g["qty"] / g["qty"].sum()
return pd.Series({c: (g[c] * w).sum() for c in cols}
| {"n": len(g), "qty": g["qty"].sum()})
return mo.groupby("liquidity").apply(agg)
แบ่งรายงานให้ละเอียดขึ้นตามสัญลักษณ์ ชั่วโมงของวัน และระยะห่างของ quote จากราคากลาง มุมมองที่นำไปปฏิบัติได้มากที่สุดสำหรับกลยุทธ์ maker คือ markout ตาม queue-position ณ ขณะ fill — fill ที่อยู่หน้าคิวใหม่ (front-of-fresh-queue) มีราคาที่แตกต่างมากจาก fill ที่ระดับราคาถูกกวาดผ่านคุณไป
Pipeline: สิ่งที่ต้องบันทึก
TCA ตายที่ชั้นการบันทึก (logging layer) ไม่ใช่ชั้นคณิตศาสตร์ คณิตศาสตร์ข้างต้นต้องการตัวเลขที่บอทส่วนใหญ่ทิ้งไป และไม่มีตัวใดเลยที่สร้างขึ้นใหม่ภายหลังจากประวัติของตลาดแลกเปลี่ยนได้ สิ่งที่ต่อรองไม่ได้:
- ราคากลางตัดสินใจ (decision mid) จาก feed ของคุณเอง ณ เวลาที่มีสัญญาณ ไม่ใช่ราคาปิดแท่งเทียนจากตลาดแลกเปลี่ยน ไม่ใช่การสร้างใหม่ในภายหลัง benchmark คือ "ราคาที่กลยุทธ์ของฉันเชื่อ ณ ตอนที่ตัดสินใจ" — มีแค่กระบวนการของคุณ ณ ขณะนั้นเท่านั้นที่รู้
- timestamp ทั้งสอง: เวลาตัดสินใจและเวลาที่ได้รับการยืนยันครั้งแรก มิฉะนั้น delay cost จะวัดไม่ได้และจะรวมเข้ากับ trading cost อย่างเงียบ ๆ
- ทุก fill พร้อม timestamp ของตลาดแลกเปลี่ยน ค่าธรรมเนียม และแฟล็ก maker/taker — เวลาที่ได้รับในเครื่องของคุณเองปนเปื้อนด้วยความหน่วงขาเข้าของคุณเอง
- คำสั่งแม่ที่ถูกยกเลิกและหมดอายุ ถูกบันทึกเหมือนกับทุกอย่างอื่น คำสั่งแม่ที่มี fill เป็นศูนย์คือแถวที่มีต้นทุนแพงที่สุดในตาราง
- stream ราคากลางแบบต่อเนื่อง (หรือ stream L1) ที่ความละเอียด 100–250 มิลลิวินาที เก็บไว้นานพอที่จะคำนวณ markout ได้ ถ้าคุณบันทึก orderbook สำหรับ fill simulator อยู่แล้ว นี่ได้มาฟรี
ตารางแบบ flat สองตารางก็เพียงพอ:
PARENTS = {
"parent_id": "str",
"strategy": "str",
"symbol": "str",
"venue": "str",
"algo": "str", # twap | pov | sniper | quote | ...
"side": "int8", # +1 buy, -1 sell
"qty": "float64", # parent size, base units
"limit_px": "float64", # NaN for unconstrained
"decision_ts": "datetime64[ns, UTC]",
"decision_mid": "float64", # mid your feed showed at decision_ts
"arrival_ts": "datetime64[ns, UTC]", # first exchange ack
"arrival_mid": "float64",
"end_ts": "datetime64[ns, UTC]", # filled / cancelled / expired
"terminal_mid": "float64",
"mkt_volume": "float64", # market volume over [arrival_ts, end_ts]
"sigma_bps": "float64", # realized vol estimate at decision time
}
FILLS = {
"parent_id": "str",
"ts": "datetime64[ns, UTC]", # exchange timestamp
"price": "float64",
"qty": "float64",
"fee": "float64", # quote ccy, post-discount, signed
"liquidity": "str", # maker | taker
"venue": "str",
}
ฟังก์ชันการระบุที่มา (attribution) เป็นการแปลตรงจากการแยกส่วน:
import numpy as np
def is_decomposition(p: pd.Series, fills: pd.DataFrame) -> dict:
"""Expanded implementation shortfall for one parent, bps of paper
notional. Sign convention: positive = cost."""
s, X = p["side"], p["qty"]
paper = X * p["decision_mid"]
x = fills["qty"].sum()
delay = s * X * (p["arrival_mid"] - p["decision_mid"])
trade = s * ((fills["price"] - p["arrival_mid"]) * fills["qty"]).sum()
oppty = s * (X - x) * (p["terminal_mid"] - p["arrival_mid"])
fees = fills["fee"].sum()
bps = lambda v: 1e4 * v / paper
return {
"parent_id": p["parent_id"],
"delay_bps": bps(delay), "trade_bps": bps(trade),
"oppty_bps": bps(oppty), "fees_bps": bps(fees),
"is_bps": bps(delay + trade + oppty + fees),
"fill_ratio": x / X,
"participation": x / max(p["mkt_volume"], x),
}
def tca_table(parents: pd.DataFrame, fills: pd.DataFrame) -> pd.DataFrame:
fg = dict(tuple(fills.groupby("parent_id")))
empty = fills.iloc[0:0]
rows = [is_decomposition(p, fg.get(p["parent_id"], empty))
for _, p in parents.iterrows()]
return parents.merge(pd.DataFrame(rows), on="parent_id")
และ query สำหรับการระบุที่มา ซึ่งเป็นจุดที่ TCA เลิกเป็นแค่บัญชีและเริ่มเป็นงานวิจัย:
tca = tca_table(parents, fills)
def wavg(g: pd.DataFrame, col: str) -> float:
w = g["qty"] * g["decision_mid"] # notional weights
return (g[col] * w).sum() / w.sum()
comp = ["delay_bps", "trade_bps", "oppty_bps", "fees_bps", "is_bps"]
by_strat = tca.groupby("strategy").apply(
lambda g: pd.Series({c: wavg(g, c) for c in comp}
| {"is_std": g["is_bps"].std(), "n": len(g)}))
done = tca[tca["fill_ratio"] > 0.99]
done["pov_bin"] = pd.qcut(done["participation"], 6)
impact_curve = done.groupby("pov_bin").apply(lambda g: wavg(g, "trade_bps"))
tca["hour"] = tca["arrival_ts"].dt.hour
by_hour = tca.groupby("hour").apply(lambda g: wavg(g, "is_bps"))
by_venue = tca.groupby("venue").apply(
lambda g: pd.Series({"is_bps": wavg(g, "is_bps"),
"is_std": g["is_bps"].std(),
"fill_ratio": g["fill_ratio"].mean(), "n": len(g)}))
Query ที่ 4 คือตารางอินพุตที่ smart order router ปรับให้เหมาะสม — การจัดเส้นทาง (routing) โดยไม่มี TCA ต่อ venue ก็คือการจัดเส้นทางตามตารางค่าธรรมเนียม กล่าวคือตามองค์ประกอบต้นทุนที่เล็กที่สุด (Smart order routing in crypto ใช้ตารางนี้เป็นจุดเริ่มต้น) เมื่อรวมกับโมดูล markout นี่คือประมาณ 200 บรรทัดตามที่สัญญาไว้
ปิดลูป: การปรับเทียบโมเดลต้นทุนของ backtest
นี่คือจุดที่ pipeline คุ้มค่ากับตัวมันเอง backtest ของคุณยืนยันตัวเลขต่าง ๆ: slippage_bps = 5, กราฟความน่าจะเป็นในการ fill, สัมประสิทธิ์ impact ทุกตัวเลขเหล่านี้เป็นการอ้างสิทธิ์เกี่ยวกับการเทรดจริง และตาราง TCA ก็คือการเทรดจริง ลูปคือ วัดด้วย TCA ปรับโมเดลต้นทุนให้พอดี รัน backtest ด้วยโมเดลที่ปรับแล้ว เทรด แล้ววัดใหม่

สำหรับ taker cost ให้ยืมรูปแบบฟังก์ชันจาก Almgren และคณะ (2005) แทนที่จะคิดขึ้นมาเอง ผลลัพธ์ของพวกเขา — ต้นทุนแปรผันตรงกับความผันผวนรายวันคูณด้วยเลขยกกำลังของอัตราการเข้าร่วมตลาด — ให้โมเดลสองพารามิเตอร์:
โดยมีค่าประมาณของหุ้น เป็นค่าไพรเออร์ (prior) ปรับให้พอดีบนค่าเฉลี่ยที่จัดกลุ่มแล้ว ไม่ใช่บนคำสั่งแม่ดิบ — ต้นทุนของคำสั่งแม่แต่ละตัวถูกครอบงำด้วยสัญญาณรบกวน (นั่นคือเทอม timing-risk) และการถดถอยแบบ log-log บนข้อมูลดิบจะปรับให้พอดีกับสัญญาณรบกวนอย่างเต็มใจ:
done = tca[(tca["fill_ratio"] > 0.99) & (tca["participation"] > 0)]
done["norm_cost"] = done["trade_bps"] / done["sigma_bps"]
bins = done.groupby(pd.qcut(done["participation"], 8)).agg(
pov=("participation", "mean"), cost=("norm_cost", "mean"))
bins = bins[bins["cost"] > 0] # can't log a negative bin
b, log_a = np.polyfit(np.log(bins["pov"]), np.log(bins["cost"]), 1)
def taker_cost_bps(participation, sigma_bps, a=np.exp(log_a), b=b):
return a * sigma_bps * participation ** b
จากนั้นต้องซื่อสัตย์เกี่ยวกับช่วงความคลาดเคลื่อน ทีมของ Almgren มีคำสั่ง 700,000 คำสั่งและยังคงรายงานเลขชี้กำลังของ temporary impact เป็น บอทที่มีคำสั่งแม่เพียง 2,000 คำสั่งไม่มีสิทธิ์ประมาณ ได้อย่างอิสระต่อสัญลักษณ์ วิธีปฏิบัติจริงคือ รวมข้อมูลข้ามสัญลักษณ์หลังจาก normalize ด้วย บีบ ให้เข้าใกล้ 0.6 อย่างมาก (หรือแค่ตรึงมันไว้แล้วปรับเฉพาะ ) และปรับใหม่ทุกเดือน ถ้า ที่ปรับแล้วของคุณลอยตัวสูงขึ้น แสดงว่ารอยเท้า (footprint) ของคุณโตขึ้น หรือตลาดบางลง ไม่ว่าทางไหน backtest ก็จำเป็นต้องรู้
สำหรับกลยุทธ์ maker เป้าหมายการปรับเทียบแตกต่างออกไปและมาจากอีกสองโมดูล:
- ความน่าจะเป็นในการ fill: โมเดลคิวของ fill simulator ทำนาย ต่อเซลล์ (spread-distance, queue-position) log คำสั่งแม่ของคุณให้อัตราการ fill จริงต่อเซลล์ ความไม่สอดคล้องกันคือบั๊กใน simulator ที่มีการอ้างอิงกริดติดมาด้วย
- Adverse selection: แทนที่ข้อสมมติฐานโดยนัยของ simulator ที่ว่า "fill สามารถแลกเปลี่ยนกันได้" ด้วยตาราง markout ที่วัดได้จริง — fill แบบ maker ที่จำลองขึ้นที่ระยะ ในชั่วโมง จะพก ที่วัดได้เป็นส่วนลด mark-to-market ทันที การเปลี่ยนแปลงเดียวนี้คือความแตกต่างระหว่าง backtest แบบ maker ที่หลอนกับที่ติดตามความจริง มันคืออินพุตการปรับเทียบที่หายไปซึ่งถูกระบุไว้ใน บทความ fill-simulation
- การกระจายตัวของต้นทุน: ป้อน การกระจาย ของ IS ไม่ใช่ค่าเฉลี่ยของมัน เข้าไปใน backtest โมเดลต้นทุนที่เลื่อนเฉพาะค่าเฉลี่ยไม่สามารถจำลอง drawdown ที่ timing risk สร้างขึ้นได้ แม้แต่การปรับ lognormal ให้พอดีกับ IS ต่อคำสั่งแม่ก็ยังดีกว่าค่าคงที่
การจัดการ slippage curve อย่างครบถ้วน — รูปแบบฟังก์ชัน การปรับตามระบอบตลาด เมื่อไหร่ที่ power law พัง — เป็นบทความของตัวเอง: Slippage curves and cost models for backtests ประเด็นที่นี่คือเรื่องสถาปัตยกรรม โมเดลในบทความนั้นปรับให้พอดีไม่ได้เลยหากไม่มีตารางของบทความนี้
กับดัก
วรรณกรรมด้าน TCA มีอายุหลายทศวรรษแล้ว และวิธีการเล่นเกมกับมันก็เก่าแก่พอ ๆ กัน สามรูปแบบความล้มเหลวคิดเป็นส่วนใหญ่ของการหลอกตัวเอง
การเล่นเกมกับ benchmark benchmark ใด ๆ ที่ไม่ใช่ arrival price สามารถถูก "กอด" (hugged) ได้ ตัวอย่างคลาสสิกคือ VWAP: อัลกอริทึมที่ให้คะแนนเทียบกับ interval VWAP สามารถติดตามมันได้ในระยะหนึ่ง basis point ในขณะที่ตำแหน่งขาดทุนไปยี่สิบ bps เทียบกับ arrival เพราะ benchmark เคลื่อนไหวไปพร้อมกับราคาที่คุณกำลังผลัก — และที่อัตราการเข้าร่วมตลาดที่มีนัยสำคัญ การพิมพ์ (prints) ของคุณเอง คือ VWAP ดังนั้นการติดตามมันจึงเป็นการให้คะแนนการบ้านตัวเอง เราได้ผ่าการเมืองของ benchmark ไว้ใน TWAP vs VWAP vs POV กฎฝั่ง TCA นั้นง่ายกว่า: benchmark ถูกเลือกก่อนการเทรด และ IS เทียบกับ arrival จะถูกคำนวณเสมอแม้ว่า scheduler จะถูกให้คะแนนเทียบกับ scheduling benchmark ของมันเอง ตัวแปรที่ซับซ้อนกว่าคือการเล่นเกมกับ arrival: ถ้าคอมโพเนนต์ที่กำหนด decision_ts สามารถมองเห็น momentum ระยะสั้นได้ มันสามารถจับเวลา "การตัดสินใจ" เพื่อทำให้เทอม delay ดูดีขึ้นได้ decision timestamp เป็นของชั้นสัญญาณ ถูกบันทึกก่อนที่ตรรกะการเทรดใด ๆ จะรัน
Survivorship bias ในการวิเคราะห์เฉพาะ fill ที่สำเร็จ การกำหนดเงื่อนไขการวิเคราะห์ต้นทุนของคุณด้วย fill เท่านั้น ทำให้การเทรดแบบ passive ดูฟรี ยกตัวอย่างเป็นรูปธรรม: คำสั่งแม่แบบซื้อ passive 100 คำสั่ง ต่ำกว่าราคากลางหนึ่งติ๊ก หกสิบคำสั่งถูก fill และ — เพราะเป็น passive — fill ที่ราคาเฉลี่ยดีกว่า arrival 3 bps: ต้นทุนที่วัดได้ bps รายงานที่น่าภูมิใจ สี่สิบคำสั่งที่ไม่เคยถูก fill เลยคือคำสั่งที่ราคาลอยหนีไปพอดี ถ้าตีราคาว่าเสียเปรียบ 25 bps ณ เวลายกเลิก ตัวเลขที่ซื่อสัตย์คือ bps รายงานเฉพาะ fill ที่สำเร็จกับรายงานที่แท้จริงต่างกัน 11 bps และต่างเครื่องหมาย นี่ไม่ใช่กรณีขอบ — มันคือกลไกของการเทรดแบบ passive: การถูก fill มีความสัมพันธ์กับราคาที่วิ่งผ่านคุณ ซึ่งเป็นการกำหนดเงื่อนไขแบบเดียวกับที่ทำให้ backtest แบบ touch-fill เป็นเรื่องเพ้อฝัน opportunity cost ไม่ใช่การปรับปรุงเสริมของ IS มันคือเทอมที่ปกป้องการวัดทั้งหมดจากการเลือกกลุ่มตัวอย่าง (selection) อคติเดียวกันนี้มีตัวแปรฝั่ง taker ด้วย: คำสั่ง IOC ที่พลาด คำสั่งที่ถูกปฏิเสธโดย rate limit หรือการตรวจสอบความเสี่ยง — ถ้าการพลาดไม่ถูกบันทึก ต้นทุนของการพลาดก็วัดไม่ได้ และมันใหญ่ที่สุดพอดีในตลาดที่เคลื่อนไหวเร็วซึ่งกลยุทธ์ของคุณต้องการเทรดมากที่สุด
กับดักเล็ก ๆ น้อย ๆ สั้น ๆ ได้แก่: ราคากลาง arrival ที่สร้างใหม่จากแท่งเทียนของตลาดแลกเปลี่ยน (feed ของคุณกับแท่งเทียนไม่ตรงกันพอดีในตอนที่มันสำคัญ) การหาค่าเฉลี่ย bps ต่อคำสั่งแม่โดยไม่ถ่วงน้ำหนักด้วยมูลค่า (fill ฝุ่นละอองพันตัวโหวตชนะคำสั่งจริงหนึ่งตัว) ค่าธรรมเนียมที่บันทึกก่อนหักส่วนลด ราคากลาง markout ที่นำมาจาก venue ที่ต่างจาก fill (basis ข้าม venue ปลอมตัวเป็น adverse selection) และในสัญญา perpetual การปล่อยให้การสะสม funding รั่วไหลเข้าไปในหน้าต่างการเทรด — funding เป็นต้นทุน แต่ไม่ใช่ต้นทุน การเทรด และการผสมทั้งสองเข้าด้วยกันทำให้การวิเคราะห์ทั้งสองเสียหาย
สิ่งที่ควรทำในสัปดาห์นี้
เพิ่มตาราง log ทั้งสองตารางเข้าไปในบอทของคุณ — schema ข้างต้นเป็นรายการคอลัมน์ ไม่ใช่โปรเจกต์ อย่าย้อนกลับไปเติมข้อมูลย้อนหลัง (backfill) เลย สอง log ที่ซื่อสัตย์เป็นเวลาสองสัปดาห์ดีกว่าการสร้างใหม่หนึ่งปี รันการแยกส่วนและขอบฟ้าเวลา markout ทั้งสาม คุณจะได้เรียนรู้ว่าองค์ประกอบไหนครอบงำ (แทบไม่เคยเป็นค่าธรรมเนียม) fill แบบ passive ของคุณถูกเลือกอย่างเสียเปรียบ (adversely selected) เกินกว่าการจับสเปรดของมันหรือไม่ และค่าคงที่ต้นทุนใน backtest ของคุณห่างจากกราฟที่วัดได้จริงแค่ไหน จากนั้นเชื่อมกราฟที่ปรับแล้วกลับเข้าไปใน simulator และรัน backtest ใหม่อีกครั้งที่บอกให้คุณรันกลยุทธ์นี้ตั้งแต่แรก พอร์ตกระดาษของ Perold แม้ผ่านมาสามสิบแปดปีแล้ว ก็ยังคงเป็นคู่ต่อสู้ที่ซื่อสัตย์เพียงหนึ่งเดียวที่พอร์ตจริงของคุณมี
ผู้เขียน
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.