Backtest กับ Forward Test ต่างกันอย่างไร
แยกให้ชัดระหว่างช่วงจูนระบบ (in-sample) ช่วงที่กันไว้ทดสอบ (forward test) และการเทรดจริง พร้อมตัวเลข CAGR ขาดทุนสูงสุด และอัตราชนะของกฎเดียวกันในสองหน้าต่าง
Backtest กับ forward test ต่างกันที่จุดเดียวที่สำคัญ: ข้อมูลช่วงนั้นเคยถูกใช้จูนระบบมาแล้วหรือยัง backtest คือการรันกฎย้อนหลังบนช่วงเวลาที่เราเลื่อนค่าไปมาหลายสิบรอบจนเส้นทุนออกมาสวย ส่วน forward test คือการรันกฎเดิมโดยไม่แก้อะไรอีกเลยบนช่วงที่กันไว้ตั้งแต่ต้นและไม่เคยเปิดดูตอนจูน หลักฐานที่ใช้ตัดสินใจได้จริงมาจากอย่างหลัง ไม่ใช่อย่างแรก ด้านล่างนี้คือการลงมือทำให้เห็นกับตา: กฎง่ายๆ หนึ่งข้อบนหุ้นใหญ่สหรัฐ จูนพารามิเตอร์บนแท่งรายวันปี 2010 ถึง 2019 แล้วรันกฎเดิมแบบไม่แตะอะไรเลยบนช่วงกันยายน 2020 ถึงกันยายน 2026 ช่องว่างระหว่างตัวเลขของสองหน้าต่างคือสิ่งที่ต้องอ่าน
สามหน้าต่างเวลาที่ต้องแยกให้ออกก่อน
คนที่เคยรัน expert advisor บน MT4 หรือ MT5 หรือเคยกด bar replay บน TradingView จนได้เส้นทุนที่พุ่งเป็นเส้นตรง แล้วเปิดพอร์ตจริงกลับไม่เหมือนกันเลย มักขาดการแยกสามอย่างนี้ออกจากกัน
- ช่วงจูน (in-sample): ช่วงที่คุณปรับค่าพารามิเตอร์ไปมาจนผลลัพธ์ถูกใจ ตัวเลขจากช่วงนี้บอกได้อย่างเดียวว่ากฎพอดีกับอดีตชุดนั้นแค่ไหน มันไม่ใช่หลักฐานว่ากฎใช้งานได้
- ช่วงที่กันไว้ (out-of-sample หรือ forward test): ช่วงที่ตัดเก็บไว้ก่อนเริ่มจูน แล้วเปิดใช้ครั้งเดียวตอนท้ายด้วยค่าที่ล็อกไว้แล้ว ยิ่งเปิดดูหลายครั้งแล้วย้อนกลับไปแก้กฎ ช่วงนี้ก็ยิ่งกลายเป็นช่วงจูนไปโดยปริยาย
- การเทรดเดินหน้าไปตามเวลาจริง (live หรือ paper trade): ช่วงที่ราคายังไม่เกิดตอนคุณกดส่งคำสั่ง ที่นี่มีสเปรด ค่าคอมมิชชั่น การวางออเดอร์ไม่ทัน และการที่คนปิดจอหนีตอนพอร์ตติดลบ ซึ่งไม่มีอยู่ในไฟล์ทดสอบใดๆ
สองอย่างหลังเท่านั้นที่นับเป็นหลักฐาน อย่างแรกเป็นแค่กระบวนการผลิตสมมติฐาน ถ้ายังไม่เคยรันเทสต์แรกในชีวิต เริ่มจาก backtesting คืออะไร แล้วค่อยไล่ตาม ขั้นตอนการทำ backtest ทีละขั้น ก่อนกลับมาอ่านตัวเลขข้างล่าง
คำว่า walk forward ที่เห็นบ่อยในฟอรั่มคือการทำเรื่องนี้ซ้ำเป็นช่วงๆ: จูนบนสามปี ทดสอบบนหนึ่งปีถัดไป แล้วเลื่อนหน้าต่างทั้งคู่ไปข้างหน้า วนไปจนหมดประวัติ หลักการเหมือนกันทุกอย่าง ต่างแค่ทำหลายรอบ
กฎที่ใช้ทดสอบ และการจูนมันบนปี 2010 ถึง 2019
กฎถูกทำให้เรียบง่ายที่สุดโดยตั้งใจ ตะกร้ามีหุ้นห้าตัวน้ำหนักเท่ากัน SPY, MSFT, JPM, HD และ PEP เงื่อนไขคือ ถ้าราคาปิดของเมื่อวานอยู่เหนือเส้นค่าเฉลี่ยเคลื่อนที่ N วันของตัวนั้น (moving average คือค่าเฉลี่ยของราคาปิดย้อนหลัง N วัน) ให้ถือหุ้นตัวนั้นในวันรุ่งขึ้น ถ้าอยู่ต่ำกว่า ให้ถือเงินสด สัญญาณถูกเลื่อนออกไปหนึ่งวันเต็มทุกครั้ง เพื่อไม่ให้ระบบมองเห็นราคาที่ยังไม่เกิด ซึ่งเป็นความพลาดที่พบบ่อยที่สุดในไฟล์ทดสอบของมือใหม่ รายละเอียดอยู่ใน look ahead bias ในการทำ backtest
พารามิเตอร์ที่จูนมีตัวเดียวคือ N เราไล่ค่า 20, 50, 100, 150 และ 200 วันบนช่วงปี 2010 ถึง 2019 แล้วให้ค่าที่ชนะบนช่วงนั้นเป็นคำตอบ นี่คือสิ่งที่คนทำกันจริงเวลากดปุ่ม optimize แล้วเลือกแถวบนสุดของตาราง หลังจากนั้นกฎถูกล็อกและเอาไปรันบนช่วงกันยายน 2020 ถึงกันยายน 2026 โดยไม่แก้อะไรอีก แท่งสองแท่งของแต่ละค่าในพาเนลข้างล่างคือผลของกฎตัวเดียวกันในสองหน้าต่าง
| label | cagr_in_sample_pct | cagr_forward_pct |
|---|---|---|
| MA 200 | 8.39 | 7.87 |
| MA 50 | 7.37 | 6.23 |
| MA 150 | 7.29 | 6.37 |
| MA 100 | 6.24 | 4.44 |
| MA 20 | 5.86 | 4.24 |
SQL เบื้องหลังตัวเลขแต่ละตัว
WITH px AS (
SELECT
ticker,
date,
toFloat64(close) AS c
FROM global_markets.stocks_daily_aggs
WHERE ticker IN ('SPY', 'MSFT', 'JPM', 'HD', 'PEP')
AND date >= '2008-12-01'
AND date < '2026-10-01'
),
ma AS (
SELECT
ticker,
date,
c,
avg(c) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS ma_20,
avg(c) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS ma_50,
avg(c) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 99 PRECEDING AND CURRENT ROW) AS ma_100,
avg(c) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 149 PRECEDING AND CURRENT ROW) AS ma_150,
avg(c) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 199 PRECEDING AND CURRENT ROW) AS ma_200
FROM px
),
grid AS (
SELECT
ticker,
date,
c,
g.1 AS label,
g.2 AS ma_value
FROM ma
ARRAY JOIN [('MA 20', ma_20), ('MA 50', ma_50), ('MA 100', ma_100), ('MA 150', ma_150), ('MA 200', ma_200)] AS g
),
sig AS (
SELECT
label,
ticker,
date,
c,
any(c) OVER (PARTITION BY label, ticker ORDER BY date ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS prev_c,
any(if(c > ma_value, 1, 0)) OVER (PARTITION BY label, ticker ORDER BY date ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS held
FROM grid
),
port AS (
SELECT
label,
if(date < '2020-01-01', 'in_sample', 'forward') AS window_name,
date,
avg(held * (c / prev_c - 1)) AS r
FROM sig
WHERE prev_c > 0
AND ((date >= '2010-01-01' AND date < '2020-01-01')
OR (date >= '2020-09-01' AND date < '2026-10-01'))
GROUP BY label, window_name, date
),
curve AS (
SELECT
label,
window_name,
date,
exp(sum(log(1 + r)) OVER (PARTITION BY label, window_name ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)) AS equity
FROM port
),
metrics AS (
SELECT
label,
window_name,
round((pow(argMax(equity, date), 252.0 / count()) - 1) * 100, 2) AS cagr_pct
FROM curve
GROUP BY label, window_name
)
SELECT
label,
anyIf(cagr_pct, window_name = 'in_sample') AS cagr_in_sample_pct,
anyIf(cagr_pct, window_name = 'forward') AS cagr_forward_pct
FROM metrics
GROUP BY label
HAVING countIf(window_name = 'in_sample') > 0
AND countIf(window_name = 'forward') > 0
ORDER BY cagr_in_sample_pct DESCCAGR คืออัตราผลตอบแทนทบต้นต่อปี ค่าที่ขึ้นอันดับหนึ่งบนชุดจูนคือ MA 200 ซึ่งทำได้ 8.39% ต่อปีในช่วงปี 2010 ถึง 2019 กฎตัวเดียวกันนั้น ไม่แก้ค่า ไม่แก้ตะกร้า ให้ 7.87% ต่อปีในหน้าต่าง forward ส่วนค่าที่อยู่ท้ายสุดของการจัดอันดับในชุดจูนคือ MA 20 ที่ 5.86% ในชุดจูน และ 4.24% ในหน้าต่าง forward
วิธีอ่านพาเนลนี้ให้ถูกคือดูแท่งเป็นคู่ ไม่ใช่ดูแท่งที่สูงที่สุด ถ้าสองแท่งของค่าเดียวกันใกล้กัน แปลว่ากฎทำสิ่งเดิมในข้อมูลที่มันไม่เคยเห็น ถ้าสองแท่งห่างกันมาก ตัวเลขฝั่งชุดจูนคือราคาที่เราจ่ายให้กับการเลือกค่าหลังจากเห็นคำตอบแล้ว ทั้งหมด 5 ค่าในพาเนลนี้ถูกจัดอันดับด้วยชุดจูนอย่างเดียว ซึ่งเป็นวิธีเดียวที่ทำได้จริงในวันที่คุณยังไม่รู้อนาคต
ความลึกของการขาดทุน และอัตราชนะ ต้องอ่านคู่กับ CAGR เสมอ
ตัวเลขผลตอบแทนเดี่ยวๆ ไม่บอกว่าระหว่างทางมันทรมานแค่ไหน maximum drawdown คือระยะจากยอดสูงสุดของเส้นทุนลงไปถึงก้นที่ลึกที่สุดก่อนทำยอดใหม่ ส่วนอัตราชนะรายวันที่ใช้ในพาเนลนี้คือสัดส่วนของวันที่พอร์ตมีสถานะอยู่อย่างน้อยหนึ่งตัวแล้วปิดเป็นบวก เป็นคำนิยามที่วัดได้ตรงจากแท่งรายวัน และไม่ต้องตีความอะไรเพิ่ม
| label | max_dd_in_sample_pct | max_dd_forward_pct | hit_rate_in_sample_pct | hit_rate_forward_pct |
|---|---|---|---|---|
| MA 20 | 14.19 | 19.06 | 53.03 | 53.71 |
| MA 150 | 16.2 | 16.94 | 53.5 | 53.36 |
| MA 200 | 17.84 | 16.92 | 53.51 | 52.99 |
| MA 100 | 15.41 | 16.64 | 53.84 | 52.62 |
| MA 50 | 12.27 | 11.05 | 53.6 | 53.16 |
SQL เบื้องหลังตัวเลขแต่ละตัว
WITH px AS (
SELECT
ticker,
date,
toFloat64(close) AS c
FROM global_markets.stocks_daily_aggs
WHERE ticker IN ('SPY', 'MSFT', 'JPM', 'HD', 'PEP')
AND date >= '2008-12-01'
AND date < '2026-10-01'
),
ma AS (
SELECT
ticker,
date,
c,
avg(c) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS ma_20,
avg(c) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS ma_50,
avg(c) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 99 PRECEDING AND CURRENT ROW) AS ma_100,
avg(c) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 149 PRECEDING AND CURRENT ROW) AS ma_150,
avg(c) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 199 PRECEDING AND CURRENT ROW) AS ma_200
FROM px
),
grid AS (
SELECT
ticker,
date,
c,
g.1 AS label,
g.2 AS ma_value
FROM ma
ARRAY JOIN [('MA 20', ma_20), ('MA 50', ma_50), ('MA 100', ma_100), ('MA 150', ma_150), ('MA 200', ma_200)] AS g
),
sig AS (
SELECT
label,
ticker,
date,
c,
any(c) OVER (PARTITION BY label, ticker ORDER BY date ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS prev_c,
any(if(c > ma_value, 1, 0)) OVER (PARTITION BY label, ticker ORDER BY date ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS held
FROM grid
),
port AS (
SELECT
label,
if(date < '2020-01-01', 'in_sample', 'forward') AS window_name,
date,
avg(held * (c / prev_c - 1)) AS r,
avg(held) AS exposure
FROM sig
WHERE prev_c > 0
AND ((date >= '2010-01-01' AND date < '2020-01-01')
OR (date >= '2020-09-01' AND date < '2026-10-01'))
GROUP BY label, window_name, date
),
curve AS (
SELECT
label,
window_name,
date,
r,
exposure,
exp(sum(log(1 + r)) OVER (PARTITION BY label, window_name ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)) AS equity
FROM port
),
dd AS (
SELECT
label,
window_name,
r,
exposure,
equity / greatest(max(equity) OVER (PARTITION BY label, window_name ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW), 1.0) - 1 AS drawdown
FROM curve
),
metrics AS (
SELECT
label,
window_name,
round(abs(min(drawdown)) * 100, 2) AS max_dd_pct,
round(100.0 * countIf(r > 0 AND exposure > 0) / greatest(countIf(exposure > 0), 1), 2) AS hit_rate_pct
FROM dd
GROUP BY label, window_name
)
SELECT
label,
anyIf(max_dd_pct, window_name = 'in_sample') AS max_dd_in_sample_pct,
anyIf(max_dd_pct, window_name = 'forward') AS max_dd_forward_pct,
anyIf(hit_rate_pct, window_name = 'in_sample') AS hit_rate_in_sample_pct,
anyIf(hit_rate_pct, window_name = 'forward') AS hit_rate_forward_pct
FROM metrics
GROUP BY label
HAVING countIf(window_name = 'in_sample') > 0
AND countIf(window_name = 'forward') > 0
ORDER BY max_dd_forward_pct DESCแถวบนสุดคือค่าที่ลงลึกที่สุดในหน้าต่าง forward: MA 20 ลงไป 19.06% จากยอด เทียบกับ 14.19% ในชุดจูน อัตราชนะรายวันของค่าเดียวกันอยู่ที่ 53.03% ในชุดจูน และ 53.71% ในหน้าต่าง forward ค่าที่ลงตื้นที่สุดในหน้าต่าง forward อยู่แถวล่างสุดของพาเนล ที่ 11.05%
สองคอลัมน์อัตราชนะมักขยับน้อยกว่าคอลัมน์ผลตอบแทน นี่คือเรื่องปกติของกฎที่ตามแนวโน้ม จำนวนวันที่ถูกกับผิดใกล้เคียงกันในทุกยุค สิ่งที่เปลี่ยนคือขนาดของวันที่ถูกและวันที่ผิด ถ้าใครเคยเห็นระบบที่โฆษณาอัตราชนะ 90% แล้วพอร์ตพังในเดือนเดียว คอลัมน์คู่นี้คือเหตุผลที่ต้องดูความลึกของการขาดทุนพร้อมกันทุกครั้ง
สัญญาณ curve fitting ที่ชัดที่สุด: ขยับพารามิเตอร์หนึ่งขั้น
เครื่องมือตรวจสอบที่เร็วที่สุดไม่ใช่สถิติชั้นสูง แต่คือการเลื่อนค่าที่จูนได้ไปหนึ่งขั้นแล้วดูว่าตัวเลขขยับแรงแค่ไหน ถ้าพฤติกรรมที่กฎจับได้มีอยู่จริง ค่าที่อยู่ติดกันควรให้ผลใกล้กัน ถ้าตัวเลขกระโดดเวลาเปลี่ยน N จาก 200 เป็น 190 สิ่งที่การจูนไปเจอคือเสียงรบกวนเฉพาะของชุดข้อมูลชุดนั้น
| label | cagr_in_sample_pct | cagr_forward_pct |
|---|---|---|
| MA 180 | 7.7 | 8.42 |
| MA 190 | 7.92 | 8.37 |
| MA 200 | 8.39 | 7.87 |
| MA 210 | 8.29 | 7.78 |
| MA 220 | 8.69 | 7.87 |
SQL เบื้องหลังตัวเลขแต่ละตัว
WITH px AS (
SELECT
ticker,
date,
toFloat64(close) AS c
FROM global_markets.stocks_daily_aggs
WHERE ticker IN ('SPY', 'MSFT', 'JPM', 'HD', 'PEP')
AND date >= '2008-12-01'
AND date < '2026-10-01'
),
ma AS (
SELECT
ticker,
date,
c,
avg(c) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 179 PRECEDING AND CURRENT ROW) AS ma_180,
avg(c) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 189 PRECEDING AND CURRENT ROW) AS ma_190,
avg(c) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 199 PRECEDING AND CURRENT ROW) AS ma_200,
avg(c) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 209 PRECEDING AND CURRENT ROW) AS ma_210,
avg(c) OVER (PARTITION BY ticker ORDER BY date ROWS BETWEEN 219 PRECEDING AND CURRENT ROW) AS ma_220
FROM px
),
grid AS (
SELECT
ticker,
date,
c,
g.1 AS label,
g.2 AS ma_value
FROM ma
ARRAY JOIN [('MA 180', ma_180), ('MA 190', ma_190), ('MA 200', ma_200), ('MA 210', ma_210), ('MA 220', ma_220)] AS g
),
sig AS (
SELECT
label,
ticker,
date,
c,
any(c) OVER (PARTITION BY label, ticker ORDER BY date ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS prev_c,
any(if(c > ma_value, 1, 0)) OVER (PARTITION BY label, ticker ORDER BY date ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS held
FROM grid
),
port AS (
SELECT
label,
if(date < '2020-01-01', 'in_sample', 'forward') AS window_name,
date,
avg(held * (c / prev_c - 1)) AS r
FROM sig
WHERE prev_c > 0
AND ((date >= '2010-01-01' AND date < '2020-01-01')
OR (date >= '2020-09-01' AND date < '2026-10-01'))
GROUP BY label, window_name, date
),
curve AS (
SELECT
label,
window_name,
date,
exp(sum(log(1 + r)) OVER (PARTITION BY label, window_name ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)) AS equity
FROM port
),
metrics AS (
SELECT
label,
window_name,
round((pow(argMax(equity, date), 252.0 / count()) - 1) * 100, 2) AS cagr_pct
FROM curve
GROUP BY label, window_name
)
SELECT
label,
anyIf(cagr_pct, window_name = 'in_sample') AS cagr_in_sample_pct,
anyIf(cagr_pct, window_name = 'forward') AS cagr_forward_pct
FROM metrics
GROUP BY label
HAVING countIf(window_name = 'in_sample') > 0
AND countIf(window_name = 'forward') > 0
ORDER BY labelพาเนลนี้ไล่ค่าจาก MA 180 ถึง MA 220 ห่างกันขั้นละสิบวันทำการ ซึ่งเป็นการขยับที่เล็กมากเมื่อเทียบกับความยาวของเส้นเอง ลองอ่านสองค่าที่อยู่ติดกัน: จาก MA 190 ไป MA 200 CAGR ในชุดจูนเปลี่ยนจาก 7.92% เป็น 8.39% ขณะที่ในหน้าต่าง forward เปลี่ยนจาก 8.37% เป็น 7.87%
เกณฑ์ที่ใช้ได้ทันทีคือ ขยับหนึ่งขั้นแล้วตัวเลขต้องยังอยู่ในระดับที่คุณยอมรับได้ ถ้าระบบของคุณกำไรเฉพาะค่า N เป๊ะๆ ค่าเดียว และพังที่ค่าข้างๆ ค่านั้นไม่ใช่การค้นพบ นั่นคือการเลือกจุดที่โชคดีที่สุดในอดีตชุดหนึ่ง
เส้นทุนในหน้าต่าง forward หน้าตาเป็นอย่างไร
ตัวเลขสรุปซ่อนลำดับเวลาไว้หมด ของจริงที่คนถือพอร์ตต้องทนคือรูปร่างของเส้น พาเนลนี้จึงตรึงตัวอย่างหนึ่งตัวแบบไม่เปลี่ยนไปตามการรันใหม่: SPY ตัวเดียว กฎ MA 200 ตัวเดียว ในหน้าต่าง forward ตั้งแต่กันยายน 2020 เทียบกับการถือเฉยๆ ตลอดช่วง ทั้งสองเส้นตั้งต้นที่ 100 ก่อนวันแรกของหน้าต่าง
| month | strategy_index | buy_hold_index |
|---|---|---|
| 2020-09 | 95.9 | 95.9 |
| 2020-10 | 93.5 | 93.5 |
| 2020-11 | 103.7 | 103.7 |
| 2020-12 | 107 | 107 |
| 2021-01 | 105.9 | 105.9 |
| 2021-02 | 108.9 | 108.9 |
| 2021-03 | 113.5 | 113.5 |
| 2021-04 | 119.5 | 119.5 |
| 2021-05 | 120.2 | 120.2 |
| 2021-06 | 122.5 | 122.5 |
| 2021-07 | 125.5 | 125.5 |
| 2021-08 | 129.3 | 129.3 |
| 2021-09 | 122.9 | 122.9 |
| 2021-10 | 131.5 | 131.5 |
| 2021-11 | 130.4 | 130.4 |
| 2021-12 | 136 | 136 |
| 2022-01 | 125.4 | 128.8 |
| 2022-02 | 120.3 | 125 |
| 2022-03 | 119 | 129.3 |
| 2022-04 | 117.4 | 117.9 |
SQL เบื้องหลังตัวเลขแต่ละตัว
WITH px AS (
SELECT
date,
toFloat64(close) AS c
FROM global_markets.stocks_daily_aggs
WHERE ticker = 'SPY'
AND date >= '2019-10-01'
AND date < '2026-10-01'
),
ma AS (
SELECT
date,
c,
avg(c) OVER (ORDER BY date ROWS BETWEEN 199 PRECEDING AND CURRENT ROW) AS ma_200
FROM px
),
sig AS (
SELECT
date,
c,
any(c) OVER (ORDER BY date ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS prev_c,
any(if(c > ma_200, 1, 0)) OVER (ORDER BY date ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) AS held
FROM ma
),
daily AS (
SELECT
date,
held * (c / prev_c - 1) AS r_rule,
c / prev_c - 1 AS r_hold
FROM sig
WHERE date >= '2020-09-01'
AND prev_c > 0
),
cum AS (
SELECT
date,
exp(sum(log(1 + r_rule)) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)) AS eq_rule,
exp(sum(log(1 + r_hold)) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)) AS eq_hold
FROM daily
)
SELECT
formatDateTime(toStartOfMonth(date), '%Y-%m') AS month,
round(argMax(eq_rule, date) * 100, 1) AS strategy_index,
round(argMax(eq_hold, date) * 100, 1) AS buy_hold_index
FROM cum
GROUP BY month
ORDER BY monthสิ้นเดือนแรกของหน้าต่าง ดัชนีของกฎอยู่ที่ 95.9 และเดินต่อไปอีก 73 เดือนจนถึง 2026-09 ซึ่งอ่านได้ 188.2 สำหรับกฎ และ 218.3 สำหรับการถือเฉยๆ ตัวเลขทั้งสองนับจากราคาปิดอย่างเดียว ไม่รวมเงินปันผล
สิ่งที่พาเนลนี้สอนไม่ใช่ว่าเส้นไหนสูงกว่า แต่คือรูปร่างของช่วงที่กฎอยู่ในเงินสดตอนตลาดดีดกลับเร็ว กฎที่ตัดขาดทุนด้วยเส้นค่าเฉลี่ยจะพลาดวันดีดแรงเสมอ ตามกลไกของมันเอง และกราฟนี้แสดงช่วงเวลาที่มันเกิดขึ้นจริงให้เห็นเป็นเดือนต่อเดือน
กฎง่ายๆ ที่เอาไปใช้ได้คืนนี้
- กันข้อมูลไว้อย่างน้อยหนึ่งในสามของประวัติที่มี และต้องเป็นช่วงท้ายสุดตามเวลา ไม่ใช่สุ่มวันออกมาจากตรงกลาง เพราะตลาดมีช่วงที่พฤติกรรมเปลี่ยนเป็นปีๆ
- เขียนค่าพารามิเตอร์ที่ล็อกแล้วลงไฟล์ก่อนเปิดชุดที่กันไว้ แล้วเปิดครั้งเดียว ถ้าผลออกมาไม่สวยแล้วย้อนไปแก้ ให้ถือว่าชุดนั้นหมดสภาพการเป็นชุดทดสอบไปแล้ว
- นับจำนวน trade ในชุดที่กันไว้ก่อนจะเชื่อตัวเลขใดๆ ยี่สิบถึงสามสิบ trade บอกอะไรได้น้อยมาก วิธีใส่ช่วงความเชื่อมั่นให้ตัวเลขแทนการเดาอยู่ใน การทำ bootstrap หาช่วงความเชื่อมั่นของ backtest
- ทดสอบขยับหนึ่งขั้นทุกครั้งที่จูนเสร็จ ค่าข้างเคียงต้องยังอยู่ในระดับที่ยอมรับได้
- ตรวจว่ารายชื่อหุ้นที่เอามาเทสต์ไม่ได้ถูกคัดเพราะมันรอดมาถึงวันนี้ ตะกร้าห้าตัวในหน้านี้ก็มีปัญหานี้อยู่เต็มๆ อ่านต่อที่ survivorship bias ในข้อมูลหุ้น
ข้อจำกัดของตัวเลขชุดนี้ อ่านก่อนนำไปอ้างอิง
ทุกพาเนลคิดจากราคาปิดรายวันเท่านั้น ไม่หักค่าคอมมิชชั่น สเปรด slippage และภาษี ค่าใช้จ่ายเหล่านี้ทำให้ผลในโลกจริงต่ำกว่าตัวเลขที่เห็น และยิ่งกฎซื้อขายถี่ก็ยิ่งต่ำลงมาก ผลตอบแทนไม่รวมเงินปันผล ทั้งฝั่งกฎและฝั่งถือเฉยๆ จึงถูกตัดส่วนนี้ออกเท่ากัน ตะกร้าใช้น้ำหนักเท่ากันแบบคงที่ ไม่มีการปรับขนาดสถานะตามความผันผวน เส้นค่าเฉลี่ยยาวสุดต้องใช้ข้อมูลก่อนปี 2010 เป็นช่วงอุ่นเครื่อง ข้อมูลส่วนนั้นจึงถูกอ่านเข้ามาแต่ไม่ถูกนับเป็นผลตอบแทน อัตราชนะที่รายงานเป็นระดับวัน ไม่ใช่ระดับ trade ตัวเลขทั้งหมดเป็นสื่อการสอนเรื่องวิธีทดสอบระบบ ไม่ใช่คำแนะนำให้ซื้อหรือขายอะไร
FAQ
backtest กับ forward test ต่างกันอย่างไร
backtest รันกฎบนข้อมูลที่ถูกใช้ปรับค่ากฎมาแล้ว ส่วน forward test รันกฎที่ล็อกค่าแล้วบนข้อมูลที่กันไว้และยังไม่เคยถูกใช้จูน ตัวเลขจาก backtest บอกว่ากฎพอดีกับอดีตแค่ไหน ตัวเลขจาก forward test เป็นตัวเดียวที่เข้าใกล้คำว่าหลักฐาน
ควรกันข้อมูลไว้ทดสอบเท่าไรถึงจะพอ
แนวปฏิบัติที่ใช้กันคือกันช่วงท้ายสุดไว้ประมาณหนึ่งในสามของประวัติ และต้องยาวพอให้ครอบคลุมมากกว่าหนึ่งสภาวะตลาด สำหรับระบบรายวันบนหุ้น ช่วงสามถึงห้าปีคือพื้นฐานที่สมเหตุสมผล สำหรับระบบรายนาที วัดด้วยจำนวน trade แทนจำนวนปี
forward test ต้องมีกี่ trade ถึงจะเชื่อตัวเลขได้
ยิ่งมากยิ่งดี และสิ่งที่ควรดูคือความกว้างของช่วงความเชื่อมั่น ไม่ใช่ค่ากลางค่าเดียว ถ้าช่วงความเชื่อมั่นของผลตอบแทนยังคาบเส้นศูนย์ จำนวน trade ที่มีก็ยังไม่พอจะแยกฝีมือออกจากโชค วิธีคำนวณอยู่ในบทความเรื่อง bootstrap ที่ลิงก์ไว้ด้านบน
รู้ได้อย่างไรว่าระบบถูก curve fit
สัญญาณที่ชัดที่สุดคือผลลัพธ์หายไปเมื่อขยับพารามิเตอร์ตัวเดียวไปหนึ่งขั้น สัญญาณอื่นที่พบบ่อยคือจำนวนเงื่อนไขเยอะกว่าจำนวน trade ที่ชนะ มีตัวกรองพิเศษที่ใส่ไว้เพื่อตัดช่วงขาดทุนช่วงเดียวในอดีต และผลที่ดีขึ้นทุกครั้งที่เพิ่มพารามิเตอร์ใหม่
ทุกพาเนลด้านบนกาง SQL ให้ดูได้ครบ กดเปิดดูได้ว่าแต่ละตัวเลขถูกนับมาอย่างไร ถ้าอยากเปลี่ยนตะกร้าหุ้น เปลี่ยนช่วงที่กันไว้ หรือเปลี่ยนค่า N แล้วดูว่าช่องว่างระหว่างสองหน้าต่างขยับไปทางไหน ถามเป็นภาษาคนได้บน Strasmore terminal