พื้นที่พารามิเตอร์สองแกน: ทำไมการสวีปส่วนใหญ่ของคุณจึงควรแทบจะฟรี
ส่วนหนึ่งของซีรีส์ "แบ็กเทสต์ไร้ภาพลวงตา" (Backtests Without Illusions)
คำสาปแห่งมิติ (curse of dimensionality) มักถูกเล่าในฐานะคำเตือน: พารามิเตอร์ทุกตัวที่คุณเพิ่มเข้ามาจะคูณขนาดของพื้นที่ค้นหา ดังนั้นกลยุทธ์ 18 มิติจึงดูสิ้นหวังเกินกว่าจะสวีปได้ กรอบคิดแบบนั้นแอบตั้งสมมติฐานเงียบ ๆ ว่าทุกมิติมีต้นทุนการประเมินเท่ากัน ซึ่งไม่จริง ในเอนจิน dual/triple-timeframe ของเรา พารามิเตอร์สองในสามแทบไม่มีต้นทุนในการสวีป ขณะที่อีกหนึ่งในสามแบกภาระค่าคำนวณเกือบทั้งหมดไว้ เมื่อคุณมองเห็นการแบ่งนี้แล้ว คำบ่นว่า "พื้นที่ค้นหาใหญ่เกินไป" ก็ไม่ใช่คำบ่นที่ถูกต้องอีกต่อไป คำถามที่ถูกต้องคือ: คุณกำลังจ่ายให้กับมิติไหนกันแน่?
มิติที่ไม่จำเป็นต้องคำนวณซ้ำ
เบนช์มาร์กการค้นหาพารามิเตอร์ของเรารันกลยุทธ์โมเมนตัมแบบหลายไทม์เฟรม: เส้นค่าเฉลี่ยเคลื่อนที่แบบ Hull และตัวแปรที่ปรับเรียบสามชั้นของมัน (HMA3) บนสองหรือสามไทม์เฟรม พร้อมเกตวัดระยะแยกเชิงทิศทาง (directional-separation gate) ที่ตัดสินว่าจุดตัด (crossover) หนึ่ง ๆ "สะอาด" พอที่จะลงมือหรือไม่ พื้นที่ค้นหาเต็มรูปแบบของตัวแปร triple-TF มีสิบแปดมิติ — ค่า period และความยาว HMA ต่อไทม์เฟรม บวกกับชุดค่าเกณฑ์ separation สำหรับการเข้าและออกบนแต่ละไทม์เฟรม
วิธีสวีปแบบตรงไปตรงมาที่สุดคือลูปเดียว: เลือกเวกเตอร์พารามิเตอร์หนึ่งชุด สร้างอินดิเคเตอร์ทุกตัวขึ้นใหม่จากศูนย์ รันซิมูเลชัน ให้คะแนน แล้วทำซ้ำ เราก็เริ่มจากตรงนั้น มันช้า และมันช้าด้วยเหตุผลที่พอเราตั้งชื่อให้มันได้ก็ชวนให้อายตัวเอง: สำหรับการทดลองที่อยู่ติดกันจำนวนมหาศาล เรากำลังคำนวณอินดิเคเตอร์ที่ไม่ได้เปลี่ยนแปลงเลยซ้ำใหม่
ลองขยับค่าเกณฑ์ entry-separation จาก 0.03 เป็น 0.035 แล้วรันใหม่ เส้น Hull MA บนแท่ง 1 นาทีตลอดหนึ่งปีจะเหมือนกับการทดลองก่อนหน้าทุกบิต — ค่าเกณฑ์ไม่ปรากฏอยู่ที่ไหนเลยในนิยามของมัน แต่ลูปแบบตรงไปตรงมาก็ยังสร้างมันขึ้นใหม่อยู่ดี บนแท่งกว่าครึ่งล้านแท่ง ลึกสามไทม์เฟรม ทุกครั้งไม่มีข้อยกเว้น เรากำลังใช้งบคำนวณเกือบทั้งหมดไปกับการคำนวณปริมาณที่ไม่แปรผัน (invariant) ต่อพารามิเตอร์ที่เรากำลังปรับจริง ๆ ซ้ำแล้วซ้ำอีก
ข้อสังเกตนั้นคือทั้งหมดของบทความนี้ ทันทีที่คุณสังเกตเห็นว่าพารามิเตอร์บางตัวเปลี่ยนอินดิเคเตอร์ และบางตัวเปลี่ยนเพียงกฎการตัดสินใจที่ใช้กับอินดิเคเตอร์ที่ตรึงไว้ พื้นที่พารามิเตอร์ก็เลิกเป็นกลุ่มเมฆแบนราบของมิติ และกลายเป็นสองชั้นซ้อนกันที่มีป้ายราคาต่างกันลิบลับ
สองแกน ไม่ใช่แกนเดียว

แบ่งพารามิเตอร์ตามสิ่งที่การประเมินมันใหม่บังคับให้คุณต้องคำนวณซ้ำ:
-
แกนราคาแพง — พารามิเตอร์อินดิเคเตอร์ ค่า period ของไทม์เฟรมและความยาว HMA เปลี่ยนตัวใดตัวหนึ่งแล้วคุณต้องสร้างอินดิเคเตอร์ใหม่ตลอดทั้งซีรีส์ราคา: รีแซมเปิลไทม์เฟรมที่สูงกว่า รันการถ่วงน้ำหนักแบบ Hull คำนวณซีรีส์จุดตัดและค่า separation ที่จุดตัดแต่ละจุดใหม่ทั้งหมด นี่คือการกวาด O(n) ต่อแท่งของประวัติราคา และคุณจ่ายเต็มราคาสำหรับทุกชุดค่าผสมที่แตกต่างกัน ในกลยุทธ์ triple-TF แกนนี้มีหกพารามิเตอร์:
(period, hma_length)สำหรับไทม์เฟรมสูง กลาง และต่ำอย่างละชุด -
แกนราคาถูก — ค่าเกณฑ์ตัดสินใจ เกต directional-separation ที่ตัดสิน — จากซีรีส์จุดตัดและ separation ที่คำนวณไว้เรียบร้อยแล้ว — ว่าจะเข้าหรือออก เปลี่ยนค่าเกณฑ์แล้วไม่มีอินดิเคเตอร์ตัวไหนขยับ คุณแค่รันการกวาด O(n) รอบเดียวใหม่บนสัญญาณที่คำนวณล่วงหน้าไว้ ตรวจเกตและบันทึกการฟิล ในกลยุทธ์ triple-TF แกนนี้มีสิบสองพารามิเตอร์ (ค่าเกณฑ์ separation สี่ตัว — entry-buy, entry-sell, exit-buy, exit-sell — บนแต่ละไทม์เฟรมจากทั้งหมดสาม) ส่วนตัวแปร dual-TF มีแปดตัว (ค่าเกณฑ์สี่ตัวบนแต่ละไทม์เฟรมจากทั้งหมดสอง)
ดังนั้นรูปทรงที่แท้จริงของพื้นที่นี้คือ: แพง 6 + ถูก 12 = 18 สำหรับ triple-TF และ แพง 4 + ถูก 8 = 12 สำหรับ dual-TF สองในสามของมิติทั้งหมดอาศัยอยู่บนแกนราคาถูก และสองแกนนี้ไม่ได้ต่างกันแค่จำนวน — ต้นทุนต่อหน่วยของมันต่างกันมากกว่าสามลำดับขนาด (orders of magnitude) ซึ่งเป็นตัวเลขที่ส่วนที่เหลือของบทความนี้จะว่าด้วย
โครงสร้างนี้ซ้อนกันอย่างเคร่งครัด แต่ละจุดบนแกนราคาแพง — (period, hma_length) ที่เป็นรูปธรรมหนึ่งชุดต่อไทม์เฟรม — นิยามชุดอาร์เรย์สัญญาณที่ตรึงไว้หนึ่งชุด และบนรากฐานที่ตรึงไว้นั้นวางอยู่แผ่นทั้งแผ่นของแกนราคาถูก: เวกเตอร์ค่าเกณฑ์นับพัน แต่ละตัวคือการกวาดเร็ว ๆ บนอาร์เรย์ชุดเดิม คุณจ่ายค่ารากฐานครั้งเดียวแล้วเฉลี่ยต้นทุนมันไปทั่วทั้งแผ่น
ทำไมอินดิเคเตอร์จึงไม่แปรผันต่อค่าเกณฑ์
การแคชนี้ใช้ได้ก็เพราะข้อเท็จจริงทางคณิตศาสตร์ ไม่ใช่รายละเอียดการอิมพลีเมนต์ที่บังเอิญโชคดี และคุ้มค่าที่จะระบุให้แม่นยำเพราะมันคือสมมติฐานที่แบกรับน้ำหนักทั้งหมด: อาร์เรย์อินดิเคเตอร์เป็นฟังก์ชันของ (period, hma_length) เท่านั้น ค่าเกณฑ์ separation ไม่ปรากฏที่ใดเลยในนิยามของพวกมัน
พูดอย่างเป็นรูปธรรม สำหรับแต่ละไทม์เฟรม เอนจินจะคำนวณอาร์เรย์ที่จัดเรียงตรงกันสี่ชุดล่วงหน้าบนดัชนีฐาน: เส้น Hull MA hma, ตัวปรับเรียบสามชั้น hma3, ซีรีส์จุดตัด cross (+1 ซื้อ / −1 ขาย / 0) และเปอร์เซ็นต์ separation ที่จุดตัดแต่ละจุด ทุกตัวล้วนอนุพัทธ์มาจากราคาและพารามิเตอร์อินดิเคเตอร์สองตัว ส่วนค่าเกณฑ์ — ตัวเลขที่คุณเอา separation ไปเทียบด้วย — จะถูกใช้ทีหลัง ณ เวลาตัดสินใจ มันไม่เคยแตะอาร์เรย์เลย
นี่ไม่ใช่การจัดเรียงใหม่ที่ปลอดภัยโดยอัตโนมัติ มันปลอดภัยก็เพราะเราระมัดระวังเรื่องเมื่อไรที่ข้อมูลกลายเป็นสิ่งที่รู้ได้ ซึ่งเป็นประเด็นของงานว่าด้วยอคติ look-ahead ในซีรีส์เดียวกันนี้ อินดิเคเตอร์ไทม์เฟรมสูงบนแท่งฐาน i คำนวณจากแท่งเทียนไทม์เฟรมสูงที่ปิดแล้ว บวกกับแท่งที่กำลังก่อตัวซึ่งราคาปิดสะสม (running close) เท่ากับราคาปิดฐานปัจจุบัน close[i] — ค่าที่รู้ได้ ณ แท่ง i ไม่มีอะไรจากอนาคตรั่วไหลเข้ามา ดังนั้นสัญญาณที่คำนวณล่วงหน้า ณ แท่ง i จึงเป็นสิ่งเดียวกันเป๊ะกับที่บอตเทรดสดจะเห็น ณ แท่ง i และมันยังคงถูกต้องไม่ว่าคุณจะทดสอบมันกับค่าเกณฑ์ตัวไหนในภายหลัง การแคชสัญญาณที่รั่วก็แค่แคชการรั่วเอาไว้ด้วย ส่วนการแคชสัญญาณเชิงเหตุปัจจัย (causal) คือการแคชสิ่งที่คุณเทรดได้จริง
ความไม่แปรผันนี้มอบการแยกตัวประกอบ (factorization) ให้เรา เขียนการประเมินเวกเตอร์พารามิเตอร์เต็มหนึ่งชุด θ = (indicator_params, threshold_params) เป็น:
signals = build_indicators(indicator_params) # EXPENSIVE, depends only on indicator_params
score = simulate(signals, threshold_params) # CHEAP, reuses signals across all thresholds
build_indicators ไม่อ่าน threshold_params เลย ข้อเท็จจริงเพียงข้อเดียวนี้คือสิ่งที่อนุญาตให้ใช้แคชได้: ตรึง indicator_params ไว้ ปรับ threshold_params ได้ตามใจ แล้ว signals ก็คือค่าคงที่ที่คุณคำนวณเพียงครั้งเดียว
สถาปัตยกรรม: คำนวณครั้งเดียว สวีปหลายรอบ

เอนจิน (scripts/engine_multitf.py คอมมิต bfc8aaa ในแบ็กเทสเตอร์ของเรา) อิมพลีเมนต์การแยกตัวประกอบนี้ด้วยสองชิ้นส่วนที่ชื่อของมันบอกหน้าที่ตรงตัว
SignalCache — แกนราคาแพงที่ถูกเมมโมไอซ์ (memoized) มันคือดิกชันนารีที่ใช้ (period_bars, hma_length) เป็นคีย์ ขอสัญญาณของไทม์เฟรมหนึ่งจากมัน แล้วมันจะคืน TFSignals ที่แคชไว้หากชุดอินดิเคเตอร์นั้นเคยถูกสร้างมาก่อน มิฉะนั้นก็สร้างครั้งเดียวแล้วเก็บไว้ เพราะคีย์มีเพียงพารามิเตอร์อินดิเคเตอร์ ทุกคอนฟิกค่าเกณฑ์ที่ใช้ชุดอินดิเคเตอร์ร่วมกัน — และในการสวีปค่าเกณฑ์แบบหนาแน่นนั่นหมายถึงหลายพันคอนฟิก — จะชนรายการแคชเดียวกัน ไทม์เฟรมที่สูงกว่าได้ประโยชน์จากเรื่องนี้เป็นพิเศษ: กริดหยาบ ๆ ของ period ตัวเลือกสักสี่ค่า คูณความยาว HMA อีกหยิบมือหนึ่ง คือจำนวนการสร้างราคาแพงที่แตกต่างกันเพียงน้อยนิด และแต่ละครั้งถูกใช้ซ้ำทั่วทั้งแผ่นค่าเกณฑ์ที่ซ้อนอยู่ข้างบนมัน
sweep_separations — แกนราคาถูกที่รันเป็นแบตช์ มันรับอาร์เรย์สัญญาณที่แคชไว้กับเมทริกซ์ของเวกเตอร์ค่าเกณฑ์ (sps รูปร่าง [m, 12]) แล้วรันทั้งหมดผ่านเคอร์เนลคอมไพล์แล้วตัวเดียว แต่ละแถวคือการกวาด O(n) หนึ่งรอบ: เดินไปตามแท่ง ใช้เกต บันทึกการฟิลแบบไร้การรั่วที่ open[i+1] รวมยอด PnL และเวลาที่ถือสถานะ ไม่มีอินดิเคเตอร์ถูกสร้างใหม่ภายในลูปนี้ — มันอ่าน cross และ separation ตรงจากแคช ซิมูเลชันชั้นในถูกคอมไพล์แบบ JIT (Numba) ดังนั้นเมื่ออุ่นเครื่องแล้ว ต้นทุนต่อคอนฟิกจะถูกครอบงำโดยการสแกนเชิงเส้นรอบเดียวบนแท่งราคา ไม่ใช่โอเวอร์เฮดของ Python
สองชิ้นส่วนนี้ประกอบกันเป็นการค้นหาแบบซ้อนตามธรรมชาติ: ลูปนอกเดินไปตามแกนราคาแพง (แต่ละรอบสร้างและแคชชุดอินดิเคเตอร์หนึ่งชุด) ส่วนลูปในกระจายแบตช์กว้าง ๆ ไปทั่วแกนราคาถูกบนอาร์เรย์ที่แคชไว้เหล่านั้น ในโค้ด รูปทรงก็เป็นแบบนั้นเป๊ะ — ลูปราคาแพงเล็ก ๆ ห่อแบตช์ราคาถูกกว้าง ๆ:
cache = SignalCache(base_close, base_ts) # keyed by (period, hma_length)
for htf_p, htf_h, mtf_p, mtf_h, ltf_p, ltf_h in indicator_grid: # EXPENSIVE axis (coarse)
htf = cache.get(htf_p, htf_h) # built once, then a cache hit forever
mtf = cache.get(mtf_p, mtf_h)
ltf = cache.get(ltf_p, ltf_h)
sps = sample_thresholds(m=4000) # CHEAP axis: [m, 12] threshold vectors
pnl, n_trades, bars_in_pos = sweep_separations( # one compiled batch, no indicator work
base_close, base_open, htf, ltf, sps, mtf=mtf)
คุณแตะตัวสร้างราคาแพงให้น้อยครั้งเท่าที่กริดอินดิเคเตอร์อนุญาต แล้วปล่อยให้การสวีปราคาถูกรับปริมาณงานไป — แถว sps นับพันวิ่งบนอาร์เรย์ที่ไม่เคยขยับเลย นั่นคือการออปติไมซ์ทั้งหมด — ไม่มีการประมาณค่า ไม่มีความเที่ยงตรงที่สูญเสีย มีแค่การปฏิเสธที่จะคำนวณสิ่งที่ไม่ได้เปลี่ยนซ้ำอีกรอบ
ต้นทุนที่แท้จริงของ "ฟรี": ตัวเลข

เราวัดสองแกนนี้บนเวิร์กโหลดสาธิต: แท่ง 1 นาทีของ ETHUSDT เต็มหนึ่งปี (~527k แท่ง) แบบ triple-TF โดยอุ่นอินดิเคเตอร์และคอมไพล์ JIT ให้เสร็จนอกช่วงจับเวลา
บนแกนราคาถูก sweep_separations ทำความเร็วต่อเนื่องได้ ~5,600 คอนฟิกค่าเกณฑ์ต่อวินาที นั่นคือซิมูเลชันครบวงจร — เกต การฟิล PnL การเปิดสถานะ — สำหรับแต่ละคอนฟิก บนแท่งกว่าครึ่งล้านแท่ง ที่ราว 180 ไมโครวินาทีต่อคอนฟิก เหตุผลที่มันเร็วขนาดนั้นได้ก็เพราะมันไม่ทำงานอินดิเคเตอร์เลยแม้แต่นิดเดียว: ทุกคอนฟิกอ่านอาร์เรย์ cross และ separation ที่แคชไว้ชุดเดียวกัน
ทีนี้ลองตีราคาทางเลือกอีกทาง การสร้างชุดอินดิเคเตอร์ triple-TF หนึ่งครั้งใช้เวลาราวหลายร้อยมิลลิวินาที (~0.3 s) — รีแซมเปิลสามไทม์เฟรม ถ่วงน้ำหนักแบบ Hull สกัดจุดตัดและ separation ตลอดทั้งปี ถ้าคุณคำนวณอินดิเคเตอร์ใหม่ภายในลูปคอนฟิก — ดีไซน์ลูปเดียวแบบตรงไปตรงมา — คอนฟิกทั้ง 5,600 ต่อวินาทีทุกตัวจะต้องจ่ายค่าสร้างอินดิเคเตอร์เต็มจำนวนแทน ต้นทุนต่อคอนฟิกพองจาก ~180 ไมโครวินาทีเป็น ~0.3 วินาที:
| ต้นทุนต่อคอนฟิก | คอนฟิก/วินาที | |
|---|---|---|
| แกนราคาถูก (สัญญาณจากแคช) | ~180 µs | ~5,600 |
| คำนวณอินดิเคเตอร์ใหม่ต่อคอนฟิก | ~0.3 s | ~3.4 |
อัตราส่วนคือ ~1,600 เท่า การสวีปแกนราคาถูกบนสัญญาณจากแคชถูกกว่าดีไซน์ตรงไปตรงมาที่สร้างอินดิเคเตอร์ใหม่ให้ทุกเวกเตอร์ค่าเกณฑ์ราวสามลำดับขนาด พูดให้เป็นรูปธรรม: แบตช์คอนฟิกค่าเกณฑ์ไม่กี่พันตัวที่จบภายในไม่ถึงวินาทีบนเส้นทางแคช จะกินเวลาเกือบชั่วโมงถ้าแต่ละคอนฟิกต้องสร้างอินดิเคเตอร์ของตัวเองใหม่ ผลลัพธ์เดียวกัน ความเที่ยงตรงเดียวกัน ไม่มีทางลัดในคณิตศาสตร์ — ความต่างเพียงอย่างเดียวคือฝ่ายหนึ่งคำนวณสิ่งที่ไม่แปรผันซ้ำ ส่วนอีกฝ่ายไม่ทำ
นี่ไม่ใช่ไมโครออปติไมเซชันที่คุณโรยเพิ่มตอนท้าย มันเปลี่ยนว่าการค้นหาแบบไหนเป็นไปได้จริง ที่ 5,600 คอนฟิก/วินาที แกนค่าเกณฑ์หนาแน่นพอที่จะสำรวจได้อย่างจริงจัง — คุณมีกำลังจ่ายให้กริดละเอียดหรือการสุ่มแบบ random/QMC ยาว ๆ ต่อชุดอินดิเคเตอร์หนึ่งชุด — ขณะที่แกนราคาแพงยังคงเป็นการสร้างหยาบ ๆ เพียงหยิบมือโดยตั้งใจ งบคำนวณไหลไปยังจุดที่พารามิเตอร์มีต้นทุนจริง ๆ
คำสาปแห่งมิติ ฉบับตีราคาใหม่

ย้อนกลับไปที่กรอบคิดที่บทความนี้เปิดมา คำสาปแห่งมิติบอกว่าพื้นที่ค้นหาโตแบบเอกซ์โพเนนเชียลตามจำนวนพารามิเตอร์ ดังนั้นมิติที่มากขึ้นย่อมแย่ลงโดยสิ้นเชิง นั่นเป็นเรื่องจริงในแง่ขนาดของกริด แต่ชวนให้เข้าใจผิดในแง่ต้นทุนของการครอบคลุมมัน เพราะมันตีราคาทุกมิติเท่ากันหมด
พอคุณแยกแกนออกจากกัน จำนวนมิติก็อ่านได้ต่างออกไป กลยุทธ์ triple-TF มี 18 มิติ แต่มีเพียง 6 มิติเท่านั้นที่แพง อีก 12 มิติเป็นมิติแกนราคาถูกที่ขยายกริดโดยไม่ขยายบิลค่าคำนวณอย่างมีนัยสำคัญ — คุณโยนคอนฟิกเป็นพัน ๆ ใส่พวกมันได้ในราคาเศษสตางค์ กลยุทธ์ dual-TF มี 12 มิติ โดยแพงเพียง 4 และถูก 8 ในทั้งสองกรณี "คำสาป" ส่วนใหญ่กระจุกอยู่บนแกนที่แทบไม่มีต้นทุนในการสวีป
ดังนั้นวิธีคิดเรื่องต้นทุนการค้นหาอย่างซื่อตรงจึงไม่ใช่ "มีพารามิเตอร์กี่ตัว" แต่เป็น "มีพารามิเตอร์ราคาแพงกี่ตัว และกริดของพวกมันหยาบได้แค่ไหน" แกนราคาแพงคือที่ที่เอกซ์โพเนนเชียลทำร้ายคุณจริง ๆ และเป็นที่ที่คุณต้องการกริดเล็ก ๆ ที่เลือกมาอย่างดี — period ตัวเลือกไม่กี่ค่า ช่วงความยาว HMA พอประมาณ — อาจกลั่นแบบหยาบ-สู่-ละเอียด ในจิตวิญญาณเดียวกับการเจาะลึกแบบปรับความละเอียด ที่เราใช้ในที่อื่น ส่วนแกนราคาถูกคือที่ที่คุณฟุ่มเฟือยได้ เพราะคอนฟิกค่าเกณฑ์เพิ่มอีกหนึ่งตัวราคาแค่ 180 ไมโครวินาที
การตีราคาใหม่นี้ใช้ได้ทั่วไปเกินกว่ากลยุทธ์ HMA ของเราไปมาก แพตเทิร์นนี้ — พารามิเตอร์บางตัวเปลี่ยนฟีเจอร์ แต่พารามิเตอร์ส่วนใหญ่เปลี่ยนกฎที่ใช้กับฟีเจอร์ที่ตรึงไว้ — โผล่ซ้ำอยู่ทุกหนแห่งในการเทรดเชิงระบบ ความยาวอินดิเคเตอร์ ความถี่การรีแซมเปิล และหน้าต่างมองย้อนหลัง (lookback) นั้นแพง ส่วนค่าเกณฑ์เข้า/ออก ระยะสต็อป ตัวคูณขนาดสถานะ และเกตยืนยันสัญญาณนั้นถูก เมื่อใดก็ตามที่พารามิเตอร์ตัวหนึ่งเพียงเปลี่ยนรูปขอบเขตการตัดสินใจบนสัญญาณที่คำนวณไว้แล้ว มันเป็นของแกนราคาถูก และควรถูกสวีปที่นั่น ชัยชนะจากการแคชในตระกูลเดียวกันยังปรากฏในการแคช parquet แบบหลายไทม์เฟรม และในภาพที่กว้างกว่าอย่างบันไดความเร็วเอนจิน
ตรงไหนที่มื้อกลางวันฟรีมีบิลตามมา
แกนราคาถูกนั้นถูกในแง่การคำนวณ แต่ไม่ถูกในแง่สถิติ และการเอาสองอย่างนี้มาปนกันคือวิธีเปลี่ยนชัยชนะด้านประสิทธิภาพให้กลายเป็นเครื่องจักรผลิตการโอเวอร์ฟิต
ทุกคอนฟิกค่าเกณฑ์ที่คุณประเมินคือการทดลอง (trial) หนึ่งครั้ง และเมื่อคุณรันการทดลองนับพันบนชุดข้อมูลเดียว ตัวที่ดีที่สุดในนั้นดูดีส่วนหนึ่งก็เพราะโชค การทำให้การทดลองเหล่านั้นถูกลง 1,600 เท่าไม่ได้ทำให้โชคหายไป — มันทำให้โชคสะสมได้ง่ายขึ้น การสวีปค่าเกณฑ์แบบหนาแน่นคือสถานการณ์ที่การพองตัวจากการทดสอบพหุคูณ (multiple testing) กัดแรงที่สุดพอดี: ผู้ท้าชิงจำนวนมาก ประวัติราคาเส้นเดียว และกฎคัดเลือกที่รายงานค่าสูงสุด เอนจินที่เร็วที่สุดในโลกยินดีจะยื่นเวกเตอร์ค่าเกณฑ์ที่ฟิตเข้ากับนอยซ์ของหน้าต่างทดสอบของคุณอย่างงดงาม — แล้วล้มเหลวนอกกลุ่มตัวอย่าง — ให้คุณ
ดังนั้นวินัยต้องขยายตามความเร็ว ทันทีที่ต้นทุนคำนวณของการทดลองหนึ่งครั้งดิ่งเข้าใกล้ศูนย์ การทำบัญชีเชิงสถิติก็กลายเป็นข้อจำกัดที่ผูกมัดแทน และคุณต้องจ่ายมันอย่างชัดแจ้ง:
- ปรับลดตามจำนวนการทดลอง ให้คะแนนผู้ชนะโดยเทียบกับจำนวนคอนฟิกที่คุณลองไป ไม่ใช่เทียบกับศูนย์ อัตราส่วนชาร์ปแบบปรับลด (Deflated Sharpe Ratio) และความน่าจะเป็นของการโอเวอร์ฟิตในแบ็กเทสต์ มีไว้เพื่อสิ่งนี้โดยเฉพาะ — มันแปลง "เราลองเวกเตอร์ค่าเกณฑ์ไป 4,000 ตัว" ให้กลายเป็นส่วนลดบนความได้เปรียบ (edge) ที่รายงาน
- ตรวจสอบนอกกลุ่มตัวอย่าง ทีละโฟลด์ การสวีปราคาถูกก็ยังต้องรันภายใต้การแบ่งแบบวอล์ก-ฟอร์เวิร์ด ที่ซื่อสัตย์ ค่าเกณฑ์ที่ชนะเฉพาะในกลุ่มตัวอย่างนั้นไร้ค่าไม่ว่าคุณจะหามันเจอเร็วแค่ไหน เอนจินของเรารักษาการฟิลให้ไร้การรั่ว (
open[i+1]) ก็เพื่อให้แกนราคาถูกไม่มีทางซื้อผลงานด้วยการแอบดูอนาคตได้ - เลือกที่ราบมากกว่ายอดแหลม เพราะแกนค่าเกณฑ์หนาแน่นและเร็ว คุณจึงทำแผนที่พื้นผิวการตอบสนอง (response surface) ของมันได้ทั้งผืน ไม่ใช่แค่หา argmax ของมัน บริเวณค่าเกณฑ์กว้าง ๆ ที่ใช้ได้ทั้งบริเวณคือความได้เปรียบของจริง ส่วนจุดสูงสุดแหลมโดดเดี่ยวคือสิ่งประดิษฐ์จากการฟิต — การแยกแยะที่ราบ-กับ-ยอดแหลม ซึ่งความเร็วที่เรากำลังบรรยายอยู่นี่เองทำให้จ่ายไหว
จงอ่านโครงสร้างสองแกนให้ถูกทาง: มันไม่ได้ซื้อความมั่นใจที่มากขึ้นให้คุณ มันซื้อจำนวนการทดลองที่มากขึ้น ที่ต้นทุนความมั่นใจต่อการทดลองเท่าเดิม นั่นมีค่าอย่างแท้จริง — การครอบคลุมแกนราคาถูกอย่างหนาแน่นคือสิ่งที่ทำให้คุณหาที่ราบเจอและวาดลักษณะพื้นผิวได้ — แต่ก็ต่อเมื่อคุณรักษาบัญชีสถิติให้ซื่อสัตย์เท่านั้น ความเร็วขจัดข้ออ้างเรื่องกำลังคำนวณสำหรับการไม่ค้นหาให้ทั่วถึง แต่มันไม่ได้ขจัดพันธะที่ต้องปรับลดค่าของสิ่งที่การค้นหาอย่างทั่วถึงไปค้นพบ
จะจัดโครงสร้างการค้นหาของคุณเองอย่างไร
การนำสิ่งนี้ไปใช้กับกลยุทธ์ของคุณเอง งานส่วนใหญ่คือการจัดหมวดหมู่ — แยกพารามิเตอร์ของคุณไปไว้บนแกนที่ถูกต้อง — แล้วตามด้วยลูปซ้อน:
- ติดป้ายพารามิเตอร์ทุกตัวตามสิ่งที่มันบังคับให้คุณคำนวณซ้ำ ถ้าเปลี่ยนมันแล้วอาร์เรย์อินดิเคเตอร์/ฟีเจอร์เปลี่ยนตาม มันแพง ถ้ามันเพียงเปลี่ยนการเปรียบเทียบ ค่าเกณฑ์ หรือกฎกำหนดขนาดสถานะที่ใช้กับอาร์เรย์ที่ตรึงไว้ มันถูก เมื่อสงสัย ให้ถามว่า: พารามิเตอร์ตัวนี้ปรากฏอยู่ที่ไหนสักแห่งในนิยามของอินดิเคเตอร์หรือไม่? ถ้าไม่ มันถูก
- ตั้งคีย์แคชบนแกนราคาแพงเท่านั้น เมมโมไอซ์การสร้างฟีเจอร์ด้วยพารามิเตอร์อินดิเคเตอร์ของมัน (แบบเดียวกับที่
SignalCacheทำด้วย(period, hma_length)) การทดลองที่อยู่ติดกันซึ่งใช้ชุดอินดิเคเตอร์ร่วมกันก็จะได้ใช้อาร์เรย์ชุดเดิมซ้ำแบบฟรี ๆ - รันแกนราคาถูกเป็นแบตช์บนฟีเจอร์ที่แคชไว้ รันคอนฟิกค่าเกณฑ์เป็นลูปคอมไพล์แน่น ๆ บนสัญญาณที่คำนวณล่วงหน้า ไม่ใช่การประเมินใหม่เต็มรูปแบบ นี่คือที่มาของทรูพุตของคุณ — และในกรณีของเราคือ ~5,600 คอนฟิก/วินาที
- ซ้อนลูป: แพงอยู่นอก ถูกอยู่ใน รักษากริดราคาแพงให้เล็กและจงใจ (หยาบ หรือหยาบ-สู่-ละเอียด) ปล่อยให้การสวีปราคาถูกหนาแน่นเต็มที่ ใช้งบตรงจุดที่พารามิเตอร์มีต้นทุนจริง
- จัดงบจำนวนการทดลองโดยเทียบกับการโอเวอร์ฟิต ไม่ใช่เทียบกับนาฬิกา ในเมื่อนาฬิกาไม่ใช่ขีดจำกัดอีกต่อไป ก็ให้ deflated Sharpe / PBO เป็นขีดจำกัดแทน ตัดสินใจว่าคุณมีกำลังจ่ายเชิงสถิติให้การทดลองบนแกนราคาถูกได้กี่ครั้ง แล้วตรวจสอบผู้ชนะนอกกลุ่มตัวอย่าง
ผลตอบแทนเชิงวิศวกรรมและราวกันตกเชิงสถิติคือสองด้านของแนวคิดเดียวกัน: การแยกแกนทำให้คุณค้นหามิติราคาถูกได้อย่างถี่ถ้วน ซึ่งก็คือเหตุผลที่คุณต้องปรับลดค่าตามระดับความถี่ถ้วนที่คุณค้นหานั่นเอง
ข้อสรุปสำคัญ
- มิติแต่ละมิติต้นทุนไม่เท่ากัน พารามิเตอร์ของกลยุทธ์แยกออกเป็นแกนราคาแพง (อินดิเคเตอร์ — คำนวณใหม่ตลอดทั้งซีรีส์) และแกนราคาถูก (ค่าเกณฑ์ — การกวาด O(n) บนสัญญาณที่คำนวณล่วงหน้า) ในเอนจินของเรานั่นคือแพง 6 + ถูก 12 สำหรับ triple-TF และ 4 + 8 สำหรับ dual-TF
- อินดิเคเตอร์ไม่แปรผันต่อค่าเกณฑ์ และความไม่แปรผันนั้นคือการออปติไมซ์ทั้งหมด การสร้างฟีเจอร์ขึ้นกับพารามิเตอร์อินดิเคเตอร์เท่านั้น คุณจึงสร้างครั้งเดียว แคชด้วยคีย์
(period, hma_length)แล้วใช้ซ้ำทั่วทุกคอนฟิกค่าเกณฑ์ที่ใช้ชุดเดียวกัน - แกนราคาถูกวิ่งถูกกว่า ~1,600 เท่า ~5,600 คอนฟิกค่าเกณฑ์/วินาที (~180 µs ต่อตัว) บนสัญญาณจากแคช เทียบกับ ~0.3 s ต่อคอนฟิกถ้าคุณสร้างอินดิเคเตอร์ใหม่ทุกครั้ง ความเที่ยงตรงเท่ากันทุกประการ — ความต่างเดียวคือการปฏิเสธที่จะคำนวณสิ่งที่ไม่แปรผันซ้ำ
- คำสาปแห่งมิติแท้จริงแล้วคือคำสาปของมิติราคาแพง จำนวนพารามิเตอร์ส่วนใหญ่อาศัยอยู่บนแกนที่แทบจะฟรีในการสวีป จงรักษากริดราคาแพงให้หยาบ และฟุ่มเฟือยกับกริดราคาถูก
- ความเร็วย้ายข้อจำกัดที่ผูกมัดจากการคำนวณไปยังสถิติ การสวีปที่หนาแน่นและเร็วคือเครื่องจักรทดสอบพหุคูณ จงปรับลดตามจำนวนการทดลอง ตรวจสอบทีละโฟลด์ และเลือกที่ราบมากกว่ายอดแหลม — มื้อกลางวันฟรีมีจริง แต่บิลค่าสถิติไม่ใช่ของเลือกจ่ายได้
เอนจินฉบับเต็ม — SignalCache, sweep_separations, ซิมูเลชันหลายไทม์เฟรมแบบไร้การรั่ว และเทสต์ความเท่าเทียม (parity test) ที่ตรึงมันไว้กับเซแมนติกส์แท่งกำลังก่อตัวแบบเดียวกับการรันสด — อยู่ใน scripts/engine_multitf.py (คอมมิต bfc8aaa) ในแบ็กเทสเตอร์ของเรา ครั้งหน้าที่ใครสักคนบอกคุณว่ากลยุทธ์ 18 พารามิเตอร์ใหญ่เกินกว่าจะค้นหา ลองถามเขาว่าพารามิเตอร์เหล่านั้นมีกี่ตัวที่ขยับอินดิเคเตอร์จริง ๆ โดยปกติแล้วคือหนึ่งในสาม และที่เหลือแทบจะฟรี
ผู้เขียน
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.