Strasmore Research

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 โดยไม่แก้อะไรอีก แท่งสองแท่งของแต่ละค่าในพาเนลข้างล่างคือผลของกฎตัวเดียวกันในสองหน้าต่าง

คำสั่งดึงข้อมูลผลของกฎเดียวกันในสองหน้าต่าง: CAGR ต่อปี เรียงตามอันดับในชุดจูน
labelcagr_in_sample_pctcagr_forward_pct
MA 2008.397.87
MA 507.376.23
MA 1507.296.37
MA 1006.244.44
MA 205.864.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 DESC
ลองรันด้วยตัวเอง

CAGR คืออัตราผลตอบแทนทบต้นต่อปี ค่าที่ขึ้นอันดับหนึ่งบนชุดจูนคือ MA 200 ซึ่งทำได้ 8.39% ต่อปีในช่วงปี 2010 ถึง 2019 กฎตัวเดียวกันนั้น ไม่แก้ค่า ไม่แก้ตะกร้า ให้ 7.87% ต่อปีในหน้าต่าง forward ส่วนค่าที่อยู่ท้ายสุดของการจัดอันดับในชุดจูนคือ MA 20 ที่ 5.86% ในชุดจูน และ 4.24% ในหน้าต่าง forward

วิธีอ่านพาเนลนี้ให้ถูกคือดูแท่งเป็นคู่ ไม่ใช่ดูแท่งที่สูงที่สุด ถ้าสองแท่งของค่าเดียวกันใกล้กัน แปลว่ากฎทำสิ่งเดิมในข้อมูลที่มันไม่เคยเห็น ถ้าสองแท่งห่างกันมาก ตัวเลขฝั่งชุดจูนคือราคาที่เราจ่ายให้กับการเลือกค่าหลังจากเห็นคำตอบแล้ว ทั้งหมด 5 ค่าในพาเนลนี้ถูกจัดอันดับด้วยชุดจูนอย่างเดียว ซึ่งเป็นวิธีเดียวที่ทำได้จริงในวันที่คุณยังไม่รู้อนาคต

ความลึกของการขาดทุน และอัตราชนะ ต้องอ่านคู่กับ CAGR เสมอ

ตัวเลขผลตอบแทนเดี่ยวๆ ไม่บอกว่าระหว่างทางมันทรมานแค่ไหน maximum drawdown คือระยะจากยอดสูงสุดของเส้นทุนลงไปถึงก้นที่ลึกที่สุดก่อนทำยอดใหม่ ส่วนอัตราชนะรายวันที่ใช้ในพาเนลนี้คือสัดส่วนของวันที่พอร์ตมีสถานะอยู่อย่างน้อยหนึ่งตัวแล้วปิดเป็นบวก เป็นคำนิยามที่วัดได้ตรงจากแท่งรายวัน และไม่ต้องตีความอะไรเพิ่ม

คำสั่งดึงข้อมูลความลึกของการขาดทุนสูงสุด และอัตราชนะรายวัน สองหน้าต่างเทียบกัน
labelmax_dd_in_sample_pctmax_dd_forward_pcthit_rate_in_sample_pcthit_rate_forward_pct
MA 2014.1919.0653.0353.71
MA 15016.216.9453.553.36
MA 20017.8416.9253.5152.99
MA 10015.4116.6453.8452.62
MA 5012.2711.0553.653.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 สิ่งที่การจูนไปเจอคือเสียงรบกวนเฉพาะของชุดข้อมูลชุดนั้น

คำสั่งดึงข้อมูลขยับพารามิเตอร์หนึ่งขั้น: CAGR ของ MA 180 ถึง MA 220
labelcagr_in_sample_pctcagr_forward_pct
MA 1807.78.42
MA 1907.928.37
MA 2008.397.87
MA 2108.297.78
MA 2208.697.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 ก่อนวันแรกของหน้าต่าง

คำสั่งดึงข้อมูลเส้นทุนรายเดือนในหน้าต่าง forward: SPY ตามกฎ MA 200 เทียบกับถือเฉยๆ
73 rows (showing 20)
monthstrategy_indexbuy_hold_index
2020-0995.995.9
2020-1093.593.5
2020-11103.7103.7
2020-12107107
2021-01105.9105.9
2021-02108.9108.9
2021-03113.5113.5
2021-04119.5119.5
2021-05120.2120.2
2021-06122.5122.5
2021-07125.5125.5
2021-08129.3129.3
2021-09122.9122.9
2021-10131.5131.5
2021-11130.4130.4
2021-12136136
2022-01125.4128.8
2022-02120.3125
2022-03119129.3
2022-04117.4117.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 สำหรับการถือเฉยๆ ตัวเลขทั้งสองนับจากราคาปิดอย่างเดียว ไม่รวมเงินปันผล

สิ่งที่พาเนลนี้สอนไม่ใช่ว่าเส้นไหนสูงกว่า แต่คือรูปร่างของช่วงที่กฎอยู่ในเงินสดตอนตลาดดีดกลับเร็ว กฎที่ตัดขาดทุนด้วยเส้นค่าเฉลี่ยจะพลาดวันดีดแรงเสมอ ตามกลไกของมันเอง และกราฟนี้แสดงช่วงเวลาที่มันเกิดขึ้นจริงให้เห็นเป็นเดือนต่อเดือน

กฎง่ายๆ ที่เอาไปใช้ได้คืนนี้

  1. กันข้อมูลไว้อย่างน้อยหนึ่งในสามของประวัติที่มี และต้องเป็นช่วงท้ายสุดตามเวลา ไม่ใช่สุ่มวันออกมาจากตรงกลาง เพราะตลาดมีช่วงที่พฤติกรรมเปลี่ยนเป็นปีๆ
  2. เขียนค่าพารามิเตอร์ที่ล็อกแล้วลงไฟล์ก่อนเปิดชุดที่กันไว้ แล้วเปิดครั้งเดียว ถ้าผลออกมาไม่สวยแล้วย้อนไปแก้ ให้ถือว่าชุดนั้นหมดสภาพการเป็นชุดทดสอบไปแล้ว
  3. นับจำนวน trade ในชุดที่กันไว้ก่อนจะเชื่อตัวเลขใดๆ ยี่สิบถึงสามสิบ trade บอกอะไรได้น้อยมาก วิธีใส่ช่วงความเชื่อมั่นให้ตัวเลขแทนการเดาอยู่ใน การทำ bootstrap หาช่วงความเชื่อมั่นของ backtest
  4. ทดสอบขยับหนึ่งขั้นทุกครั้งที่จูนเสร็จ ค่าข้างเคียงต้องยังอยู่ในระดับที่ยอมรับได้
  5. ตรวจว่ารายชื่อหุ้นที่เอามาเทสต์ไม่ได้ถูกคัดเพราะมันรอดมาถึงวันนี้ ตะกร้าห้าตัวในหน้านี้ก็มีปัญหานี้อยู่เต็มๆ อ่านต่อที่ 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

#backtest#forward test#walk-forward#overfitting#trading systems