ภาษีของเฟรมเวิร์ก: เมื่อไลบรารี Backtest ของคุณช้ากว่าลูป Pandas แบบง่ายๆ
ส่วนหนึ่งของซีรีส์ "Backtests Without Illusions"
มีข้อสมมติที่สบายใจอยู่ที่รากฐานของโปรเจกต์ algo-trading ส่วนใหญ่ นั่นคือไลบรารี backtesting ที่เติบโตเต็มที่และมีดาวเยอะบน GitHub นั้น เร็ว มันมีการมีส่วนร่วมมาหลายปี มี event loop จริง มีโมเดล broker มีระบบค่าคอมมิชชัน มันย่อมเอาชนะลูป pandas สุกเอาเผากินที่คุณอาจเขียนเองได้แน่นอน คุณจึงหยิบมันมาใช้ ต่อสายกลยุทธ์ของคุณเข้าไป แล้วเริ่ม parameter sweep — คอนฟิกูเรชันสองสามพันแบบ งานที่รันข้ามคืน คุณกลับมาตอนเช้า มันยังรันอยู่
เราทำเบนช์มาร์กเอนจิน backtest แปดตัวบน parameter sweep ชุดเดียวกัน และพบสิ่งที่ควรเปลี่ยนวิธีที่คุณเลือกเครื่องมือสำหรับการค้นหา เฟรมเวิร์ก event-driven โอเพนซอร์สยอดนิยมสองตัว — backtrader และ bt — รัน sweep นี้ ช้ากว่าลูป pandas ธรรมดาที่เราเขียนขึ้นมาเป็น baseline ทิ้งขว้าง ไม่ใช่ช้ากว่านิดหน่อย backtrader ใช้เวลาประมาณ 2.5 เท่าของ baseline pandas ส่วน bt ประมาณ 4.7 เท่า ในขณะเดียวกันเอนจินแบบ vectorized/compiled บนงานเดียวกันนี้ทำงานเร็วกว่า bt ประมาณ 13,000 เท่า
นี่คือภาษีของเฟรมเวิร์ก เครื่องมือ backtest ยอดนิยมถูกสร้างมาสำหรับการรันที่ตรงไปตรงมา ครั้งเดียว — กลยุทธ์เดียว ชุดข้อมูลเดียว การจับคู่คำสั่งซื้อขายที่พิถีพิถัน broker ที่ทำตัวเหมือน broker จริง นั่นคือสิ่งที่คุณต้องการพอดีสำหรับการตรวจสอบขั้นสุดท้ายหรือการเช็ก live-parity มันคือเครื่องมือที่ผิดพอดีสำหรับสิ่งที่งานวิจัย algo ใช้เวลาไปกับมันจริงๆ นั่นคือการรันกลยุทธ์เดียวกันหมื่นครั้งด้วยปุ่มปรับที่ต่างกันเล็กน้อย บทความนี้วัดภาษีนั้น อธิบายกลไก และให้กฎการตัดสินใจว่าเมื่อไหร่ที่เฟรมเวิร์ก backtesting "แบบจริงจัง" คือตัวเลือกที่ผิด
ตัวเลขทุกตัวในบทความนี้มาจากชุดทดสอบที่ทำซ้ำได้ชุดเดียว (benchmarks/bench_oss_engines.py, commit 250dbb5) บนภาระงานที่เหมือนกันทุกประการและล็อก parity ไว้ ในกรณีที่เราไม่ได้รันเอนจินตัวใดเอง เราจะบอกตรงๆ และแยกไว้ในหัวข้อที่ซื่อสัตย์ต่างหาก แทนที่จะกุตัวเลขขึ้นมา
หนึ่งครั้งกับหนึ่งหมื่นครั้ง

ข้อเท็จจริงที่นิยาม parameter search คือ เอนจิน รันเป็นพันครั้ง แต่ การวิเคราะห์ เกิดขึ้นเพียงครั้งเดียว ต้นทุนคงที่ใดๆ ที่คุณต้องจ่ายเพื่อตั้งค่า backtest — สร้าง event loop, สร้างอินสแตนซ์ broker, จัดสรรอ็อบเจกต์ต่อแท่งราคา — คุณต้องจ่ายมันในทุกๆ คอมโบ ต้นทุนที่มองไม่เห็นในการรันครั้งเดียว ("ใครจะสนใจ 6 วินาที?") กลายเป็นบิลทั้งหมดข้าม sweep ("6 วินาที × 10,000 = 16.6 ชั่วโมง")
เอนจิน backtest แบ่งออกเป็นสามพาราไดม์ และพาราไดม์คือชะตากรรมของประสิทธิภาพการ sweep:
- Event-driven — เอนจินเดินไปทีละแท่งราคา ปล่อยเหตุการณ์ เรียก callback
next()/onBars()ของคุณ ส่งคำสั่งซื้อขายผ่านอ็อบเจกต์ broker นี่คือสถาปัตยกรรมของ backtrader, backtesting.py, PyAlgoTrade, zipline และ nautilus_trader มันสะท้อนวิธีที่การเทรดจริงทำงานจริงๆ ซึ่งเป็นเหตุผลว่าทำไมมันจึงได้รับความเชื่อถือด้านความสมจริง — และเป็นเหตุผลเดียวกันว่าทำไมมันช้า: ต้นทุนแฝง Python ต่อแท่งราคาถูกจ่ายไป 150,000 ครั้งต่อคอมโบ - Weights/rebalance —
btอยู่ตรงนี้ คุณป้อนเมทริกซ์น้ำหนักเป้าหมายให้มัน และมัน rebalance ในวันที่คุณกำหนด ไม่มี callback ต่อแท่งราคา แต่ยังมีกราฟอ็อบเจกต์ต่อเหตุการณ์ (ต้นไม้ของ algo, บัญชีธุรกรรม) ที่ประเมินผลใน Python - Vectorized / compiled — กลยุทธ์ทั้งหมดถูกแสดงเป็นการดำเนินการแบบอาร์เรย์ (vectorbt) เคอร์เนลที่คอมไพล์แบบ JIT (numba) หรือโค้ดเนทีฟ (Rust, เคอร์เนล GPU ของ MLX) ไม่มี Python ต่อแท่งราคาเลย ลูป ถ้ามี จะรันด้วยความเร็วระดับเครื่อง
ส่วนที่เหลือของบทความนี้คือผลลัพธ์เชิงประจักษ์ของอนุกรมวิธานนั้น เราสร้างภาระงานหนึ่งชุด ทำให้ทุกเอนจินทำงานเดียวกันได้อย่างพิสูจน์ได้ และจับเวลามัน
ภาระงาน: กลยุทธ์เดียว ปุ่มปรับแปดสิบตัว เหมือนกันสำหรับทุกคน
เบนช์มาร์กจะซื่อสัตย์ก็ต่อเมื่อทุกเอนจินทำงานเดียวกัน ของเราตั้งใจทำให้เรียบง่าย — ภาระงานที่สิ่ง เดียว ที่ต่างกันระหว่างเอนจินคือตัวเอนจินเอง
- ข้อมูล ซีรีส์ราคาปิดแบบ geometric-Brownian-motion สังเคราะห์ชุดเดียว:
150,000แท่งราคา,seed=42, ความผันผวนต่อแท่งsigma=0.0008,x0=30000กำหนดแน่นอน จึงทำให้ใครก็ตามสามารถทำซ้ำได้ตรงบิตต่อบิต เป็น close-only โดยการออกแบบ — OHLC ถูกตั้งให้เท่ากับราคาปิดในทุกช่วง เพราะกลยุทธ์คือการตัดกันของราคาปิด - กลยุทธ์ การตัดกันของ Hull Moving Average: HMA ที่มีความยาว
Lเทียบกับตัวแปร HMA-of-a-third ที่เร็วกว่า (HMAเทียบHMA3) อยู่ในตลาดตลอดเวลา สลับ long/short ทุกครั้งที่เกิดการตัดกัน นี่คืออินดิเคเตอร์จริงที่ไม่ใช่ของเล่น — moving average ถ่วงน้ำหนักที่ซ้อนกันสองชั้นบวกกับตัวปรับให้เรียบแบบ square-root-window — ไม่ใช่ SMA ของเล่น ดังนั้นงานต่อแท่งราคาจึงเป็นตัวแทนที่แท้จริง - Sweep ความยาว HMA จำนวน
80ค่าครอบคลุม6..200นั่นคือ "หมื่นครั้งของการรัน" ที่ถูกย่อให้เล็กพอจะวัดได้โดยตรง: 80 คอมโบที่เป็นอิสระจากกัน แต่ละอันคือ backtest เต็มรูปแบบบน 150k แท่งราคา - ต้นทุน ค่าธรรมเนียมไปกลับ
0.09%แบ่งต่อฝั่งสำหรับเอนจินที่โมเดลค่าคอมมิชชันแยกฝั่ง การจับคู่คำสั่งในแท่งราคาเดียวกันที่close[i]— สัญญาณในแท่งราคาiถูกดำเนินการที่ราคาปิดของแท่งนั้น ซึ่งเป็นข้อตกลงที่เอนจินโปรดักชันของเราใช้
ตัวจับเวลาต่อคอมโบครอบคลุมสองสิ่งพอดี: การคำนวณ HMA ล่วงหน้าด้วย numpy และการรันเอนจิน การตั้งค่าที่เป็นแบบครั้งเดียวจริงๆ (โหลดข้อมูล, สร้างอ็อบเจกต์แท่งราคา) อยู่นอกตัวจับเวลา มีการวอร์มอัพหนึ่งครั้ง จากนั้นทำซ้ำแบบ best-of-N — และเนื่องจากเอนจิน event-driven ช้าพอที่ 80 คอมโบเต็มจะใช้เวลาหลายนาทีไปจนถึงมากกว่าหนึ่งชั่วโมง เราจึงจับเวลาตัวอย่างที่สม่ำเสมอของกริดแล้วประมาณค่าแบบเชิงเส้น เนื่องจากคอมโบเป็นอิสระจากกัน การประมาณค่าเชิงเส้นจึงแม่นยำตามความคาดหวัง และข้อตกลงเดียวกันนี้ถูกใช้กับ baseline pandas ด้วย ดังนั้นจึงไม่มีเอนจินใดได้เปรียบจากมัน
Parity: พิสูจน์ว่าทุกคนทำงานเดียวกัน
นี่คือกับดักที่การเปรียบเทียบเอนจินแบบไม่ระมัดระวังตกลงไป: เอนจินที่ "เร็ว" อาจแค่ทำงาน น้อยกว่า ถ้า backtrader บันทึกเทรด 2,700 รายการ และเอนจิน vectorized ของคุณบันทึกแค่ 40 รายการ เอนจิน vectorized ไม่ได้เร็วกว่า — มันผิด และการเปรียบเทียบก็ไร้ความหมาย
ดังนั้นเราจึงล็อกการเปรียบเทียบด้วย การตรวจสอบ parity ของจำนวนเทรด ที่ L=104 ตัวอ้างอิง numpy ให้เทรดที่ปิดแล้วพอดี 2,707 รายการ ทุกเอนจินต้องผลิตผลลัพธ์นั้นภายในค่าคลาดเคลื่อน ±1 มิฉะนั้นการรันจะยกเลิกด้วย assertion work-parity FAILED ค่าคลาดเคลื่อนนี้มีอยู่ก็เพราะเอนจินต่างๆ ไม่เห็นพ้องกันในเรื่องข้อตกลงการบันทึกบัญชี — ไม่ว่าจะเป็นการบังคับปิดโพซิชันสุดท้ายที่ยังเปิดอยู่และนับมันหรือไม่ หรือการเข้าครั้งแรกนับเป็น "เทรด" หรือไม่ — ไม่ใช่ในตัวเทรดเอง:
| เอนจิน | จำนวนเทรดที่รายงาน @ L=104 | ข้อตกลง |
|---|---|---|
| ตัวอ้างอิง numpy | 2707 | round-trip ที่ปิดแล้ว |
| backtesting.py | 2708 | +1: บังคับปิดโพซิชันสุดท้ายเมื่อจบ |
| backtrader | 2707 | ไม่นับโพซิชันเปิดสุดท้าย |
| bt | 2708 | +1: นับการเข้าครั้งแรกเป็นธุรกรรม |
| PyAlgoTrade | 2708 | +1: นับการเข้าครั้งแรกเป็น fill |
ทุกเอนจินลงเอยที่ 2707 ± 1 ไม่ว่าความแตกต่างของความเร็วจะออกมาเป็นอย่างไร มันไม่ใช่สิ่งประดิษฐ์จากเอนจินตัวใดตัวหนึ่งที่แอบข้ามงานไป นี่คือวินัยที่ทำให้เราวางเฟรมเวิร์ก event-driven กับเคอร์เนล GPU ไว้ในตารางเดียวกันได้อย่างมีความหมาย
ผลลัพธ์
นี่คือตารางทั้งหมด เรียงจากเร็วที่สุดไปช้าที่สุด combos/s คือปริมาณงาน คอลัมน์สุดท้ายคือเวลาที่ sweep 80 คอมโบเต็มใช้ แถว baseline คือ M0 — เอนจิน pandas ธรรมดา ลูป for วนบนแท่งราคาพร้อมการบันทึกบัญชีแบบสเกลาร์ สิ่งที่คุณอาจเขียนในบ่ายวันหนึ่งแล้วทิ้งไป ทุกอย่างที่ ช้ากว่า baseline นั้นตัวหนา
| เอนจิน | combos/s | พาราไดม์ | Sweep 80 คอมโบเต็ม |
|---|---|---|---|
| MLX GPU kernel | 779 | vectorized (Apple GPU) | 0.10 วินาที |
| native Rust | ~350 | compiled | 0.23 วินาที |
| mp + numba | 246 | compiled JIT + multiprocess | 0.33 วินาที |
| vectorbt | 56.9 | vectorized (numpy/numba) | 1.4 วินาที |
| numba (single core) | 39.7 | compiled JIT | 2.0 วินาที |
| backtesting.py | 1.42 | event-driven | 56 วินาที |
| PyAlgoTrade | 0.51 | event-driven | 2.6 นาที |
| M0 — naive pandas + loop | 0.28 | scalar baseline | 4.8 นาที |
| backtrader | 0.11 | event-driven | 12.7 นาที |
| bt | 0.06 | weights / rebalance | 22.5 นาที |
อ่านตารางจากบนลงล่างแล้วพาราไดม์จะจัดเรียงตัวเอง: ห้าอันดับบนล้วนเป็น vectorized หรือ compiled ห้าอันดับล่างล้วนเป็น event-driven หรือ object-graph — โดยลูป pandas ธรรมดาอยู่ เหนือ เฟรมเวิร์กเติบโตเต็มที่ยอดนิยมสองตัว ช่วงจากบนสุดถึงล่างสุดคือสี่อันดับของขนาด บนภาระงาน 2,707 เทรดที่เหมือนกันทุกประการ เคอร์เนล MLX จบ sweep ในหนึ่งในสิบวินาที ส่วน bt ต้องใช้ยี่สิบสองนาทีครึ่ง นั่นคือปัจจัยประมาณ 13,000 เท่า
เรื่องอื้อฉาวตรงกลางตาราง

ตัวเลขที่สะดุดตาอยู่ที่สุดขั้ว แต่ผลลัพธ์ที่ ให้บทเรียน อยู่ตรงกลาง: backtrader (0.11 combos/s) และ bt (0.06 combos/s) ต่างก็ช้ากว่า baseline pandas ธรรมดา (0.28 combos/s)
สิ่งนี้สมควรที่จะซึมซับ M0 ไม่ใช่เอนจินที่ฉลาด มันคือลูป for ของ Python ที่ทำ index เข้าไปใน DataFrame ติดตามโพซิชันและเงินสดด้วยสเกลาร์ธรรมดา และ append เทรดเข้าไปในลิสต์ — "ตัวควบคุม" ที่ไม่ปรับแต่งใดๆ โดยเจตนา ที่เรารวมไว้เพื่อมีบางอย่างที่แย่อย่างชัดเจนให้เอาชนะ การเข้าถึงข้อมูลรายแถวของ pandas นั้นขึ้นชื่อเรื่องความช้า และเราก็อาศัยจุดนั้น กระนั้นไลบรารี backtesting ที่ถูกแนะนำมากที่สุดสองตัวในระบบนิเวศกลับแพ้มัน: backtrader แพ้ 2.5 เท่า bt แพ้ 4.7 เท่า
รายละเอียดที่ทำให้เรื่องนี้ยังคงซื่อสัตย์: ไม่ใช่ทุกเอนจิน event-driven จะช้ากว่า pandas backtesting.py (1.42 combos/s) ชนะ baseline ไป 5 เท่า เพราะมันเป็น event loop ที่คล่องตัวและอาศัย numpy ที่ลดการสร้างอ็อบเจกต์ต่อแท่งราคาให้น้อยที่สุด PyAlgoTrade (0.51) ก็แซง baseline ไปเช่นกัน ดังนั้น "event-driven" จึงไม่ใช่โทษประหารโดยอัตโนมัติ — แต่ยิ่งกลไกต่อแท่งราคาหนักเท่าไหร่ ก็ยิ่งแย่ลงเท่านั้น และ backtrader กับ bt แบกกลไกที่หนักที่สุดตรงนี้ พาราไดม์กำหนดเพดาน การอิมพลีเมนต์ตัดสินว่าคุณจะลงจอดตรงไหนใต้เพดานนั้น
ประเด็นไม่ใช่ว่านี่เป็นไลบรารีที่แย่ โมเดล broker ของ backtrader และการออกแบบต้นไม้ของ algo ของ bt มีอยู่เพื่อซื้อ ความถูกต้องและความสามารถในการแสดงออก ให้คุณ — การจัดการคำสั่งซื้อขายที่สมจริง การ rebalance พอร์ต ตัววิเคราะห์ คุณสมบัติเหล่านั้นมีต้นทุนรันไทม์ และต้นทุนนั้นมองไม่เห็นเมื่อคุณรันครั้งเดียว ข้าม sweep มันคือเรื่องราวทั้งหมด
ทำไมเอนจิน event-driven จึงจ่ายภาษี

กลไกนี้ไม่ใช่เรื่องลึกลับ backtest แบบ event-driven ต่อแท่งราคาหนึ่งแท่งทำสิ่งประมาณนี้:
- เดินนาฬิกาไปข้างหน้า ตัดแท่งราคาถัดไปออกจาก data feed และสร้างมันขึ้นเป็นอ็อบเจกต์ (
Bar,Line, dict) - ยิง callback เข้าไปในโค้ดผู้ใช้ (
next(),onBars()) ซึ่งเป็นการเรียกฟังก์ชัน Python ที่มี frame ของตัวเอง - ภายใน callback สอบถามสถานะ broker/โพซิชัน ผ่านการเรียกเมธอดและการค้นหา attribute อีกครั้ง
- ถ้ามีการสร้างคำสั่งซื้อขาย ให้ส่งมันผ่าน broker: ตรวจสอบความถูกต้อง ตรวจ margin/เงินสด กำหนดตาราง fill เปลี่ยนแปลงอ็อบเจกต์พอร์ต append เข้าไปในบัญชีธุรกรรม
- อัปเดตตัววิเคราะห์ ตัวสังเกตการณ์ และการบันทึกบัญชีใดๆ ที่เฟรมเวิร์กดูแลอยู่
ทีนี้คูณด้วย 150,000 แท่งราคา แล้วคูณด้วย 80 คอมโบ: สิบสองล้านการวนรอบแท่งราคาต่อ sweep แต่ละครั้งคือกำมือของการจัดสรรอ็อบเจกต์ระดับ Python และการ dispatch แบบไดนามิก ต้นทุนแฝงต่อการดำเนินการของ Python — หลักสิบถึงหลักร้อยนาโนวินาทีสำหรับการค้นหา attribute หรือการจัดสรรเล็กๆ — เป็นเรื่องเล็กน้อยเมื่อเกิดครั้งเดียว แต่หายนะเมื่อเกิดสิบสองล้านครั้ง กรณีของ bt เป็นตัวแปรของโรคเดียวกัน แม้ว่ามันจะ rebalance เฉพาะในวันที่มีการเทรดแทนที่จะเป็นทุกแท่งราคา แต่การ rebalance แต่ละครั้งประเมินต้นไม้ของอ็อบเจกต์ algo และแตะบัญชีพอร์ตที่อาศัย pandas และมี 2,707 ครั้งของสิ่งนั้นต่อคอมโบ คูณด้วย 80
ลูป pandas ธรรมดาชนะ backtrader และ bt ด้วยเหตุผลตรงไปตรงมา: มันทำงานน้อยกว่าต่อแท่งราคา มันข้าม broker ข้ามอ็อบเจกต์เหตุการณ์ ข้ามกองตัววิเคราะห์ ข้าม state machine การส่งคำสั่งซื้อขาย มันจ่ายภาษีต่อแถวที่น่าเกลียดของ pandas แต่ภาษีน่าเกลียดตัวเดียวนั้นยังถูกกว่าภาษีที่เป็นระเบียบ ครบครัน และเป็นอ็อบเจกต์ต่อเหตุการณ์ของเฟรมเวิร์ก เมื่อคุณตัด backtest ให้เหลือแค่ "โพซิชัน × ผลตอบแทนถัดไป − ค่าธรรมเนียม" สิ่งที่เฟรมเวิร์กทำต่อแท่งราคาส่วนใหญ่คือต้นทุนแฝงที่คุณไม่ได้ใช้ระหว่างการค้นหา
และนี่คือกับดัก: ต้นทุนแฝงคือ เหตุผลที่คุณเลือกเฟรมเวิร์กนั้น คุณต้องการ broker ที่สมจริง คุณต้องการตัววิเคราะห์ ระหว่างการตรวจสอบขั้นสุดท้าย คุณต้องการมันทั้งหมด ระหว่างการค้นหา 10,000 คอมโบที่คุณอ่านแค่ objective ตัวเดียวออกมาจากปลายอีกด้าน คุณกำลังจ่ายเงินเพื่อรถลีมูซีนวิ่งรอบสนาม
อีกฝั่งของตาราง: vectorized และ compiled
ด้านบนของตารางคือสิ่งที่เกิดขึ้นเมื่อคุณลบ Python ต่อแท่งราคาออกไปทั้งหมด
- vectorbt (56.9 combos/s) แสดงกลยุทธ์ทั้งหมดเป็นการดำเนินการอาร์เรย์ numpy/numba ไม่มีลูปแท่งราคาใน Python เลย — สัญญาณ โพซิชัน PnL ล้วนเป็นระดับอาร์เรย์ มันรัน sweep ใน 1.4 วินาที เทียบกับ 22.5 นาทีของ
bt: เร็วกว่าประมาณ 950 เท่าบนงานเดียวกันทุกประการ (เราครอบคลุมการออกแบบของ vectorbt อย่างลึกซึ้งกว่านี้ใน vectorbt overview และการเปรียบเทียบที่กว้างขึ้นใน pandas-vs-polars) - numba (39.7) คือลูป pandas ที่ไม่เปลี่ยนรูปร่าง แต่ถูกคอมไพล์แบบ JIT เป็นโค้ดเนทีฟ อัลกอริทึมเดียวกับ M0 แต่
@njitเปลี่ยน 0.28 combos/s ให้เป็น 39.7 — เร็วขึ้นประมาณ 140 เท่าจากดีคอเรเตอร์ตัวเดียว เพราะต้นทุนแฝงของ interpreter ที่ครอบงำลูปสเกลาร์เพียงแค่ระเหยหายไป - mp + numba (246) รันเคอร์เนลที่คอมไพล์ข้ามคอร์ CPU คอมโบเป็นแบบขนานอย่างง่ายดาย — แต่ละอันเป็นอิสระจากกัน — ดังนั้น multiprocessing จึงขยายตัวเกือบเป็นเส้นตรงบน JIT
- native Rust (~350) ลบกาว Python ตัวสุดท้ายออก: sweep ทั้งหมดเป็นเนทีฟ
- MLX GPU (779) ทำแผนที่ sweep ไปยังเคอร์เนล GPU ของ Apple-silicon 80 คอมโบกลายเป็น 80 เลนขนานของการคำนวณเลขคณิต sweep จบก่อนที่คุณจะปล่อยปุ่ม enter
มีสองสิ่งที่ควรระบุให้ชัดเจน ประการแรก numba พิสูจน์ว่าพาราไดม์สำคัญกว่าภาษา M0 และ numba รัน อัลกอริทึมเดียวกัน — ความแตกต่างคือหนึ่งเป็น Python แบบ interpreted ต่อแท่งราคา ส่วนอีกอันคอมไพล์แล้ว นั่นคือภาษีของเฟรมเวิร์กทั้งหมดใน A/B ที่ควบคุมได้: ~140 เท่าจากการลบ interpreter ออกจากลูปด้านใน ประการที่สอง การกระโดดจาก numba (39.7) ไปยัง mp+numba (246) ไปยัง MLX (779) ไม่ได้เกี่ยวกับเอนจินอีกต่อไป — มันเกี่ยวกับการจัดการและฮาร์ดแวร์ เมื่อภาษีต่อแท่งราคาหายไป ความเร็วก็กลายเป็นคำถามว่าคุณรันกี่คอมโบแบบขนานและบนซิลิคอนแบบไหน เราเดินผ่านความก้าวหน้าเต็มรูปแบบนั้นใน backtest engine speed ladder และเหตุผลที่ไมล์สุดท้ายถูกครอบงำโดยต้นทุน process/serialization ใน บทความ IPC-tax
สิ่งที่เราไม่ได้วัด (และทำไมเราถึงบอกคุณ)
ความน่าเชื่อถือของเบนช์มาร์กอยู่ที่สิ่งที่มันปฏิเสธที่จะปลอมแปลง เรารันเอนจินแปดตัวแบบ end-to-end ภายใต้ parity เฟรมเวิร์กที่มีชื่อเสียงหลายตัวที่เรา ไม่ได้ ใส่ตัวเลขให้ และเราขอลิสต์มันอย่างซื่อสัตย์แทนที่จะประมาณตัวเลขที่เราไม่ได้วัด:
- zipline / zipline-reloaded — event-driven สายเลือด Quantopian การตั้งค่าหนักมาก (ปฏิทินการเทรดเต็มรูปแบบและ data bundle) ซึ่งทำให้การจับเวลาต่อคอมโบแบบเทียบเท่ากันยุ่งยาก ในเชิงสถาปัตยกรรมมันอยู่ในกลุ่ม event-driven เดียวกับ backtrader เราคาดว่ามันจะอยู่ใกล้ปลายด้านนั้นของตาราง แต่เราไม่ได้พิสูจน์มัน
- nautilus_trader — event-driven ด้วยแกน Rust/Cython ออกแบบมาอย่างชัดเจนสำหรับ live-parity แกนของมันคอมไพล์แล้ว ดังนั้นมันจึงเป็นเอนจิน event-driven ที่มีแนวโน้มมากที่สุดที่จะ ไม่ จ่ายภาษี Python เต็มจำนวน — เป็นการวัดที่น่าสนใจอย่างแท้จริงที่เรายังไม่ได้รัน
- QuantConnect Lean — อิงกับ C# เป็นรันไทม์ที่ต่างออกไปโดยสิ้นเชิง ไม่สามารถเปรียบเทียบโดยตรงในชุดทดสอบ Python ได้
- Jesse — event-driven เน้นคริปโต เราวิจารณ์การออกแบบของมันใน บันทึกแยกต่างหาก แต่ไม่ได้เบนช์มาร์กมันที่นี่
- QSTrader — event-driven เน้นพอร์ตโฟลิโอ ข้อจำกัดพาราไดม์เดียวกัน
- fastquant — เราลองแล้ว แต่การติดตั้ง/API เสียหายในสภาพแวดล้อมของเรา จึงไม่มีตัวเลข เราจะไม่เดา
ข้อควรระวังที่ซื่อสัตย์สองข้อเกี่ยวกับตัวเลขที่เรา รายงาน จริง ตัวเลขของ vectorbt, numba, mp+numba, native-Rust และ MLX มาจาก engine ladder ของเราเองบนภาระงานเดียวกัน ไม่ใช่จากชุดทดสอบ OSS ที่ผลิตสี่แถว event-driven — มันคืองานเดียวกันแต่ใช้อุปกรณ์การวัดที่ต่างกัน และตัวเลข native-Rust เป็นค่าประมาณ ~350 ไม่ใช่การวัดที่แม่นยำ และ combos/s แบบสัมบูรณ์ขึ้นอยู่กับฮาร์ดแวร์เฉพาะ สิ่งที่ยังคงเป็นจริงคือ ลำดับและอัตราส่วน ซึ่งใหญ่พอ (13,000 เท่าจากบนสุดถึงล่างสุด, 2.5–4.7 เท่าสำหรับการกลับด้าน pandas-เทียบกับ-เฟรมเวิร์ก) ที่ไม่มีความแตกต่างของฮาร์ดแวร์ที่สมเหตุสมผลใดจะพลิกมันได้
ในการปกป้องเอนจิน event-driven

การอ่านเรื่องนี้ว่า "เอนจิน event-driven แย่" นั้นง่ายเกินไป นั่นคือบทเรียนที่ผิด และไม่ยุติธรรม
เอนจิน event-driven ถูกสร้างมาสำหรับงานที่ต่างออกไป และมัน เก่งในสิ่งนั้น broker ต่อแท่งราคา วงจรชีวิตของคำสั่งซื้อขาย ตรรกะการ fill ตัววิเคราะห์ — กลไกเหล่านั้นมีอยู่เพื่อทำให้ backtest คล้ายกับการเทรดจริงมากที่สุดเท่าที่จะเป็นไปได้ เมื่อเป้าหมายของคุณคือการซ้อมใหญ่ที่น่าเชื่อถือเพียงครั้งเดียวของกลยุทธ์ที่คุณกำลังจะนำไปใช้จริง คุณ ต้องการ ให้เอนจินเหงื่อออกกับทุก fill จำลองการดำเนินการบางส่วน เคารพ margin และปฏิเสธที่จะให้คุณเทรดที่ราคาที่คุณไม่สามารถได้รับจริงๆ ความสมจริงนั้นคือตัวผลิตภัณฑ์ ต้นทุนรันไทม์ของมันคือราคาของความสมจริง และสำหรับการรันครั้งเดียวราคานั้นแทบไม่มีนัยสำคัญ
ความล้มเหลวไม่ใช่เอนจิน แต่คือการใช้มันในเฟสที่ผิด งานวิจัยมีสองเฟสที่แตกต่างกันโดยมีข้อกำหนดตรงข้ามกัน:
- การค้นหา ต้องการปริมาณงาน คุณกำลังสำรวจภูมิทัศน์ ซึ่งส่วนใหญ่เป็นขยะ และคุณต้องประเมินจุดเป็นพันเพื่อหาไม่กี่จุดที่คุ้มค่าที่จะดูรอบสอง ความสมจริงต่อจุดแทบไม่สำคัญ — คุณกำลังจัดอันดับ ไม่ใช่นำไปใช้จริง ตรงนี้ภาษีของเฟรมเวิร์กเป็นการสูญเปล่าล้วนๆ
- การตรวจสอบ ต้องการความสมจริง คุณมีผู้สมัครไม่กี่คน และคุณต้องรู้ให้แม่นยำที่สุดเท่าที่จะเป็นไปได้ว่าพวกมันรอดจากการดำเนินการที่สมจริง ค่าธรรมเนียม การเลื่อนราคา และ กับดัก look-ahead ที่ทำให้ผลตอบแทนบนกระดาษพองตัว หรือไม่ ตรงนี้เอนจิน event-driven คุ้มค่าต้นทุนของมัน
ความผิดพลาดที่ภาษีของเฟรมเวิร์กลงโทษคือการรัน การค้นหา ของคุณบนเอนจิน การตรวจสอบ ของคุณ — จ่ายในราคารถลีมูซีนเพื่อสำรวจภูมิทัศน์ที่คุณจะทิ้ง 99% ไป
กฎการตัดสินใจ
บทสรุปเชิงปฏิบัติย่อลงเหลือการตัดสินใจเดียว:
ค้นหาบนเอนจินแบบ vectorized/compiled ตรวจสอบผู้รอดชีวิตบนเอนจิน event-driven
โดยเฉพาะเจาะจง:
- สร้างหรือยืมเคอร์เนลที่เร็วสำหรับ sweep vectorbt ถ้าคุณต้องการของสำเร็จรูป ลูปที่คอมไพล์ด้วย numba ถ้ากลยุทธ์ของคุณไม่ vectorize ได้อย่างเรียบร้อย (ดีคอเรเตอร์
@njitเพียงตัวเดียวซื้อความเร็วขึ้นประมาณ 140 เท่าที่นี่) รันพื้นที่พารามิเตอร์ทั้งหมดผ่านมัน - อย่ารัน sweep ขนาดใหญ่บน backtrader,
bt, zipline หรือเฟรมเวิร์ก event-driven หนักตัวใดก็ตาม ถ้า sweep บนหนึ่งในนั้นคือคอขวดของคุณ ทางแก้ไม่ใช่เครื่องที่ใหญ่กว่า — มันคือเอนจินที่ผิด แม้แต่ลูป pandas ธรรมดาก็ยังชนะสองในนั้น - เลื่อนขั้นรายชื่อสั้นๆ ไปยังเอนจิน event-driven สำหรับการตรวจสอบความสมจริง เอาผู้รอดชีวิตไม่กี่คนมารันใหม่บนเอนจินที่สมจริง ที่ซึ่งโมเดล broker และตรรกะ fill สามารถเปิดเผยปัญหาที่เคอร์เนลเร็วนั้นทำให้เป็นนามธรรมไป
- บังคับ parity ระหว่างสองอย่าง เอนจินที่เร็วและเอนจินความสมจริงต้องเห็นพ้องกันในจำนวนเทรดและ PnL บนคอนฟิกูเรชันคงที่ (การตรวจสอบ ±1-เทรดของเราที่ L=104) ไม่เช่นนั้นการค้นหาและการตรวจสอบกำลังวัดกลยุทธ์ที่ต่างกัน และไปป์ไลน์ทั้งหมดก็เป็นเรื่องโกหก
นี่คือสถาปัตยกรรมสองความเร็วเดียวกันกับที่ปรากฏขึ้นเมื่อใดก็ตามที่การค้นหาและการตรวจสอบมีโปรไฟล์ต้นทุนตรงข้ามกัน และเป็นเหตุผลว่าทำไมสแต็กของเราเองจึงเก็บพาธแบบ vectorized/compiled ที่เร็วไว้สำหรับ parameter search และสงวนกลไกหนักไว้สำหรับ การประเมิน objective ขั้นสุดท้ายและ การตรวจสอบ plateau
ประเด็นสำคัญ
- ยอดนิยม ≠ เร็วสำหรับ sweep บนภาระงานที่เหมือนกันทุกประการและล็อก parity ไว้ชุดเดียว (150k แท่งราคา, 80 คอมโบ HMA-cross, 2,707 เทรด) backtrader (0.11 combos/s) และ
bt(0.06) ต่างก็รัน ช้ากว่า ลูป pandas ธรรมดา (0.28) เฟรมเวิร์กที่เติบโตเต็มที่และมีดาวเยอะไม่ใช่ตัวเลือกที่เร็วโดยอัตโนมัติ - ภาษีของเฟรมเวิร์กเป็นแบบต่อแท่งราคา และ sweep ทวีคูณมัน สิบสองล้านการวนรอบแท่งราคาต่อ sweep แต่ละครั้งพกพาอ็อบเจกต์เหตุการณ์ callback และการเดินทางไปกลับของ broker ต้นทุนที่มองไม่เห็นในการรันครั้งเดียวคือบิลทั้งหมดข้ามหมื่นครั้ง
- พาราไดม์กำหนดเพดาน เอนจินแบบ vectorized/compiled (vectorbt 56.9, numba 39.7, mp+numba 246, native Rust ~350, MLX 779) ชนะเอนจิน event-driven ด้วยสองถึงสี่อันดับของขนาด — สูงถึง ~13,000 เท่าจากบนสุดถึงล่างสุด อัลกอริทึมเดียวกัน เพียงแค่คอมไพล์แบบ JIT เร็วขึ้น 140 เท่า
- ไม่ใช่เอนจิน event-driven ทุกตัวจะเท่ากัน backtesting.py (1.42) และ PyAlgoTrade (0.51) ยังคงชนะ baseline ธรรมดา ภาษีขยายตัวตามความหนักของกลไกต่อแท่งราคา การอิมพลีเมนต์ตัดสินว่าคุณจะลงจอดตรงไหนใต้เพดาน
- สองเอนจิน สองเฟส ค้นหาบนเคอร์เนลแบบ vectorized/compiled ตรวจสอบผู้รอดชีวิตบนเอนจิน event-driven ที่สมจริง บังคับ parity ของจำนวนเทรด/PnL ระหว่างกัน เพื่อให้ทั้งสองวัดกลยุทธ์เดียวกัน
- ซื่อสัตย์เกี่ยวกับสิ่งที่คุณวัด เราเบนช์มาร์กเอนจินแปดตัวภายใต้ parity และลิสต์ตัวที่เราไม่ได้ทำ (zipline, nautilus_trader, Lean, Jesse, QSTrader และการติดตั้ง fastquant ที่รันไม่ได้) แทนที่จะกุตัวเลขขึ้นมาสำหรับมัน
บทสรุปที่ไม่สบายใจ: ถ้า parameter sweep คือคอขวดของคุณ ปัญหาน่าจะไม่ใช่เครื่องของคุณและไม่ใช่กลยุทธ์ของคุณ มันคือคุณกำลังรันการค้นหาบนเอนจินที่ถูกสร้างมาสำหรับการรันที่ตรงไปตรงมาครั้งเดียว — และคุณจะเร็วกว่าด้วยลูป pandas ที่คุณอายเกินกว่าจะเก็บไว้
ชุดทดสอบเต็ม, assertion parity และผลลัพธ์ JSON ดิบต่อเอนจินอยู่ใน benchmarks/bench_oss_engines.py และ benchmarks/results_oss/ ที่ commit 250dbb5 สำหรับปลายด้าน compiled/GPU ของ ladder ดู backtest engine speed ladder และ การวิเคราะห์ IPC-tax
ผู้เขียน
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.