commit เดิม ไฟล์ parquet ชุดเดิม เครื่องเดิม แต่รัน 2 ครั้งห่างกัน 1 ชั่วโมง ได้ Sharpe 1.34 และ Sharpe 1.19 ผลตอบแทนรวม 41.2% และ 37.8% จำนวนเทรด 1,418 และ 1,421 และเส้นทุนของทั้ง 2 รันตรงกันถึงทศนิยมทุกหลักที่แสดงเป็นเวลา 11,205 แท่งติดต่อกัน ก่อนจะแยกออกจากกัน
3 ตัวเลขแรกชวนหงุดหงิด ส่วนตัวเลขสุดท้ายน่าสนใจ เพราะมันบอกว่าปัญหาไม่ได้มาจากความคลาดเคลื่อนของเลขทศนิยมที่ค่อย ๆ สะสมไปตลอดทั้งรัน มีเหตุการณ์แบบไม่ต่อเนื่องเกิดขึ้นที่แท่งหนึ่งโดยเฉพาะ แล้วผลที่เหลือก็ทบต่อกัน บทความนี้ว่าด้วยการหาแท่งนั้น และผลกระทบจากค่า 0.15 นี้ที่มีต่อทุกการกวาดพารามิเตอร์ที่คุณเคยทำ
กลยุทธ์: โมเมนตัมภาคตัดขวางบน perpetual futures USDⓈ-M ที่มีวอลุ่มสูงสุด 30 อันดับ ปรับสมดุลทุก 4 ชั่วโมง เปิด long 5 อันดับแรกตามผลตอบแทน 12 ชั่วโมง เปิด short 5 อันดับท้ายสุด ใช้ข้อมูลย้อนหลัง 18 เดือน คิดค่าธรรมเนียมและ funding แยกตามแต่ละขา
หาแท่งที่ทำให้ผลการรันแยกจากกัน
ถ้าบันทึกมูลค่าพอร์ตทุกแท่งด้วยความละเอียดเต็ม การหาจุดนี้ใช้เวลาประมาณ 4 นาที ส่งออกทั้ง 2 รันเป็น CSV ของ (bar_index, equity, open_positions_hash) แล้วโหลดทั้งคู่และหา index แรกที่ค่าไม่ตรงกัน เราเขียนสคริปต์นี้ไว้แล้วตอนแก้บั๊กอีกตัว นั่นเป็นเหตุผลเดียวที่ผมไม่ต้องเสียเวลาทั้งเช้าไปกับเรื่องนี้
แท่งที่ 11,206 เวลา 2025-03-14T08:00 UTC มูลค่าพอร์ตที่แท่งก่อนหน้านั้นเหมือนกันถึง 13 หลักนัยสำคัญ แต่ที่แท่ง 11,206 แฮชสถานะแตกต่างกัน: รัน A ถือ SOL ฝั่ง long ส่วนรัน B ถือ AVAX ฝั่ง long อยู่ฝั่งเดียวกัน มูลค่าเท่ากัน แต่เป็นคนละสัญลักษณ์ ทุกอย่างหลังจากนั้นเป็นผลสืบเนื่องจากจุดนี้
ผมจึงพิมพ์ค่าอินพุตที่ใช้จัดอันดับของแท่งนั้นจากทั้ง 2 รันออกมาดู ปรากฏว่าเหมือนกันทุกค่า เหมือนกันทุกไบต์ มี 30 สัญลักษณ์และ 30 คะแนนเหมือนกันเป๊ะ คะแนน 2 ค่าเป็น 0.0 ศูนย์พอดีทั้งคู่ เพราะทั้ง 2 ชื่อมีแท่งข้อมูล 4 ชั่วโมงที่ไม่มีการซื้อขายในช่วง lookback ทำให้ราคาปิดหารด้วยราคาปิดก่อนหน้าได้หนึ่ง แล้ว log จึงได้ศูนย์ ไม่ใช่ค่าที่ถูกปัดเศษจนเป็นศูนย์ แต่เป็นศูนย์จริง ๆ
สัญลักษณ์ 2 ตัวคะแนนเสมอกันที่อันดับ 5 เราเลือก 5 อันดับแรกว่าจะได้ตัวไหนขึ้นอยู่กับลำดับที่ sort วางไว้ และการ sort ก็ไม่ได้ทำอย่างที่ผมคิด
คะแนนเสมอ การ sort ที่ไม่เสถียร และสิ่งที่ผมมองข้ามมานาน 2 ปี
การจัดอันดับทำผ่าน grouped aggregation โดยเฟรมข้อมูลที่ป้อนเข้าไปมาจาก dict comprehension ที่วนผ่านเซตของสัญลักษณ์ซึ่งสร้างใหม่ทุกแท่งจาก async fetch ลำดับการวนสมาชิกในเซตเปลี่ยนตาม hash seed และ Python จะสุ่ม hash seed ของสตริงใหม่ในแต่ละโปรเซส เว้นแต่จะกำหนด PYTHONHASHSEED ไว้ ดังนั้นลำดับแถวก่อน sort จึงต่างกันระหว่างรัน และเมื่อใช้ sort ที่ไม่เสถียรกับคีย์ที่คะแนนเสมอกัน ผลตัดสินคะแนนเสมอก็เปลี่ยนไปด้วย
คะแนนเสมอแบบนี้ไม่ใช่เหตุบังเอิญที่เกิดได้ยาก แต่เกิดจากโครงสร้างของระบบ จุดไหนก็ตามที่ฟีเจอร์อิ่มตัวหรือถูกจำกัดค่า จะทำให้เกิดค่าที่เท่ากันพอดี: แท่งที่ไม่มีวอลุ่มให้ผลตอบแทนเป็นศูนย์พอดี z-score ที่ถูกจำกัดค่าจะค้างอยู่ที่ ±3.0 การแปลงเป็นอันดับเมื่อมีค่าไม่ซ้ำกันน้อยจะทำให้คะแนนเสมอกันหลายสิบรายการ ส่วนตัวกรองแบบ boolean จะให้คะแนน 1.0 กับทุกอย่างที่ผ่าน ในช่วง 18 เดือนที่ใช้แท่ง 4 ชั่วโมง รันนี้มี 47 แท่งที่คะแนนเสมอกันตรงขอบเขตการคัดเลือก มี 3 แท่งที่ทำให้ตะกร้าสินทรัพย์ที่เลือกเปลี่ยนไป ส่วนที่เหลือเป็นการเสมอกันระหว่าง 2 ชื่อที่ถูกเลือกอยู่แล้วทั้งคู่หรือไม่ได้ถูกเลือกทั้งคู่
อยู่ประมาณ 1 วัน ผมมั่นใจว่าตัวโหลดข้อมูลไม่กำหนดแน่นอน เพราะนั่นฟังดูเป็นคำตอบที่น่าตื่นเต้น แต่ไม่ใช่ มันไม่เคยเป็นแบบนั้นหรอก มีแค่เซต คะแนนเสมอ และสมมติฐานเรื่องความเสถียรของ sort ที่ไม่มีใครจดไว้
ทำไม 3 เทรดถึงทำให้ Sharpe ต่างกัน 0.15
ตรงนี้เป็นจุดที่หลายคนแย้ง และคำตอบคือแบ็กเทสต์ที่กำหนดขนาดสถานะตามสัดส่วนของมูลค่าพอร์ตเป็นระบบที่ผลขึ้นอยู่กับเส้นทาง เมื่อกำหนดขนาดสถานะที่ 8% ของมูลค่าพอร์ตปัจจุบันต่อขา ความแตกต่างของมูลค่าพอร์ตที่แท่ง n จึงทำให้มูลค่าทุกสถานะตั้งแต่แท่ง n เป็นต้นไปแตกต่างกัน
การแยกกันครั้งแรกแทบไม่สร้างผลกระทบด้วยตัวมันเอง ขา AVAX ของรัน B ขาดทุน 2.1% ใน 9 ชั่วโมง ส่วนขา SOL ของรัน A ได้กำไร 0.4% ส่วนต่างของมูลค่าพอร์ตหลังเทรดนั้นอยู่ที่ 0.21% แทบไม่มีนัยสำคัญ แต่จากจุดนั้นทั้ง 2 รันก็ไม่ใช่กลยุทธ์เดียวกันอีกต่อไป ขนาดสถานะที่ถือแตกต่างกันเล็กน้อย จึงได้รับผลจาก funding ที่สะสมต่างกันเล็กน้อย และต่อมาคะแนนเสมอที่ขอบเขตการคัดเลือกอีก 2 จุดก็ตัดสินต่างกัน เพราะคะแนนที่ใช้จัดอันดับมาจากสถานะที่ถืออยู่ซึ่งต่างกันเพียงเล็กน้อย หนึ่งในนั้นเกิดขึ้นวันที่ 2025-03-27 ก่อนเทรนด์ 6 วันที่สร้าง PnL ราว 1 ใน 3 ของทั้งรันอยู่ 1 วัน รัน A ได้อยู่ในตลาดตลอดช่วงการเคลื่อนไหว ส่วนรัน B เข้าช้ากว่าไป 1 รอบปรับสมดุล
ส่วนต่างผลตอบแทน: 3.4 จุด ส่วนต่าง Sharpe มากกว่าที่ส่วนต่างผลตอบแทนบอกไว้ เพราะเทรดที่สลับลำดับของรัน B ทับซ้อนกับช่วงที่ผันผวนกว่า ทำให้ตัวหารสูงขึ้นขณะที่ตัวเศษลดลง สาเหตุเล็กน้อย แต่มีตัวขยายผล 2 อย่าง
ถ้าคุณใช้ขนาดสถานะแบบมูลค่าคงที่ และการเข้าเทรดไม่ได้ขึ้นกับสถานะปัจจุบัน คุณก็ได้รับผลกระทบน้อยกว่ามาก แต่กลยุทธ์ที่น่าสนใจส่วนใหญ่ไม่ได้เป็นแบบนั้น
5 จุดที่ปัญหานี้เกิดขึ้นจริง
| ต้นเหตุ | อาการ | วิธีแก้ |
|---|---|---|
ไม่กำหนด PYTHONHASHSEED ทั้งที่ลำดับการวนสมาชิกใน set/dict ป้อนเข้า sort | ผลตัดสินคะแนนเสมอเปลี่ยนระหว่างรัน; จุดที่ผลเริ่มต่างกันอยู่ที่แท่งใดแท่งหนึ่งโดยเฉพาะ | กำหนด seed; sort ด้วยคีย์ลำดับรองที่ระบุชัดเจน (สัญลักษณ์) เพื่อให้คะแนนเสมอตัดสินได้แน่นอน |
ใช้ sort ที่ไม่เสถียรกับคีย์ที่คะแนนเสมอ (quicksort ค่าเริ่มต้นใน NumPy/pandas) | อาการเหมือนข้อข้างต้น และยังเกิดได้แม้กำหนด seed แล้ว | kind="stable" หรือทำให้คีย์มีลำดับตัดสินที่ไม่ซ้ำกัน |
| ไม่ได้กำหนด seed ให้ RNG ใน bootstrap การสลับข้อมูล train/test หรือการสุ่มความผันผวนของ fill จำลอง | ผลทั้งรันคลาดเคลื่อน ไม่มีจุดเริ่มต่างกันที่ชัดเจน | กำหนด seed ชัดเจนแยกตามแต่ละองค์ประกอบ และบันทึกไว้ใน manifest ของรัน |
| การลดค่าทศนิยมแบบขนาน (ลำดับการบวกขึ้นอยู่กับจำนวนเธรด) | ค่าต่างกันในบิตท้าย ๆ ซึ่งโดยมากไม่มีผล เว้นแต่จะข้ามเกณฑ์ในการเปรียบเทียบ | กำหนดจำนวนเธรดสำหรับรันงานวิจัย; อย่าเปรียบเทียบค่า float ด้วย == ตรงจุดตัดสินใจ |
| ไม่ได้กำหนดเวอร์ชันไลบรารี | ทำซ้ำผลได้วันนี้ แต่ทำไม่ได้ในเดือนพฤศจิกายน | บันทึก hash ของ lockfile ใน manifest ควบคู่กับ hash ของ data snapshot |
แถวที่ 4 เป็นปัญหาน้อยกว่าที่หลายคนกลัว ส่วนแถวแรกทำให้เกิดปัญหาอยู่ตลอด
การทำซ้ำผลได้ถึงระดับบิตเป็นเครื่องมือ ไม่ใช่คุณงามความดี
เราต้องการความกำหนดแน่นอน เพื่อให้เมื่อเปลี่ยนโค้ด 1 บรรทัดแล้ว ความเปลี่ยนแปลงของเส้นทุนบอกได้ว่าเกิดจากบรรทัดนั้น นั่นคือเหตุผลทั้งหมด ตอนนี้ agent ทุกตัวที่รันบน Stratmill จะเขียน manifest ซึ่งมี hash ของ data snapshot, hash ของ lockfile และ seed ทุกตัว และถ้ารันซ้ำแล้วทำเส้นทุนเดิมให้ตรงกันทุกบิตไม่ได้ ก็ถือว่า build ล้มเหลว ไม่ใช่เรื่องชวนสงสัย
แต่พอทำซ้ำผลได้แล้ว จงตั้งใจทำให้ผลเปลี่ยน รัน 64 ครั้งด้วย seed 64 ค่า แล้วดูการกระจายของผลลัพธ์:
ช่วงความผันผวนของผลลัพธ์ กลยุทธ์เดิม ข้อมูลเดิม แต่สุ่มลำดับตัดสินคะแนนเสมอและลำดับ fill ที่กำหนด seed ไว้ 64 แบบ ค่า Sharpe p5 เท่ากับ 1.12, มัธยฐาน 1.27, p95 1.41 ช่วงกว้าง 0.29
กลับไปดูการกวาดพารามิเตอร์อีกครั้ง คอนฟิกที่ดีที่สุดได้คะแนน 1.46 ส่วนคอนฟิกอันดับ 40 จาก 96 ได้ 1.31 ส่วนต่าง 0.15 หรือครึ่งหนึ่งของช่วงความผันผวน การกวาดครั้งนั้นไม่ได้จัดอันดับคอนฟิกทั้ง 2 แต่สุ่มผลลัพธ์จากการกระจายของแต่ละคอนฟิกมาอย่างละ 1 ค่า แล้วนำค่านั้นมาเรียงลำดับ
การมองแบบนี้เปลี่ยนวิธีที่เราเลือก ผลการกวาดพารามิเตอร์จะจัดอันดับได้ก็ต่อเมื่อส่วนต่างระหว่างคอนฟิกมากกว่าความผันผวนของคอนฟิกเดียว และสำหรับกลยุทธ์ที่ขึ้นกับเส้นทางและมี 1,400 เทรด ความผันผวนก็มักมากพอที่จะทำให้คอนฟิก 1 ใน 3 อันดับแรกกลายเป็นคะแนนเสมอกัน เมื่อเป็นเช่นนั้น ให้เลือกจากสิ่งที่ช่วงความผันผวนกลบไม่มิด: turnover ต่ำกว่า พารามิเตอร์น้อยกว่า สมมติฐานด้านต้นทุนที่คุณอธิบายให้คนช่างสงสัยเชื่อได้ หรือพฤติกรรมที่ดีกว่าในส่วน walk-forward ที่คุณชอบน้อยที่สุด สิ่งเหล่านี้เป็นเกณฑ์ตัดสินที่มีความหมาย ส่วนได้เปรียบด้าน Sharpe 0.15 ไม่ใช่
ยังมีอีกเรื่องที่ควรทำก่อนจะเชื่อผลเหล่านี้ รันแบ็กเทสต์ของคุณซ้ำ 2 ครั้งตอนนี้ เปรียบเทียบมูลค่าพอร์ตทุกแท่ง และดูว่าคุณอยู่กลุ่มที่ผลเหมือนกันทุกบิตหรือกลุ่มที่ต่างกัน 0.15 การทดลองนี้ใช้เวลา 15 นาที และจะบอกว่าประวัติการวิจัยของคุณวัดตัวกลยุทธ์ หรือวัด hash seed กันแน่
← บทความทั้งหมด


