Backtesting Part1: อย่างง่าย แบบ Non-vectorization ฉบับจับมือทำ [แจกโค้ด]

อย่างที่เรารู้กันมาว่าการเขียนโปรแกรม Python ให้ดีคือการหลีกเลี่ยงการใช้ foor loop ที่อาจจะส่งผลให้โปรแกรมทำงานได้ช้าลง เราจะนำไป Optimization ก็อาจจะทำให้ใช้เวลามากเกินจำเป็น แต่บางครั้งก็มีความจำเป็นที่จะต้องทำแบบ Non-Vectorization บ้างเหมือนกัน บทความนี้ขอชวนทุกท่านมาทดลองทำ Backtesting ด้วยตัวเองแบบง่ายๆ กันครับ โดยบทความชุดนี้จะเป็นบทความชุด ในบทความแรกนี้จะไม่มีรายละเอียดมากนัก แต่จะทำเป็น Building blog ให้เราค่อยๆเพิ่มเติมรายละเอียดให้กับการเขียน Backtest เพิ่มเติมต่อไปครับ

Photo by Quinton Coetzee on Unsplash

ทำเองใช้เอง ไม่ต้องง้อใคร เพื่อทดสอบสมมุติฐานของเราในเบื้องต้น มือใหม่ก็เข้าใจได้ แถมแจกโค้ดไปรันกันเองให้หนำใจไปเลย ใครที่เพิ่งเริ่มต้นศึกษา ยิ่งได้ทดลองทำด้วยตัวเอง ก็จะช่วยให้เข้าใจหลักการของการทำ Backtest มากขึ้นไปอีกครับ

เกริ่นนำกันมาพาพอสมควรแล้ว อย่าเสียเวลาเลยครับ เรามาเริ่มต้นทำกันดีกว่า กับ Backtesting ฉบับจับมือทำ

Step 1: Import Libraries ที่จำเป็น

Photo by Dayne Topkin on Unsplash

ก่อนอื่นเรามาเริ่มต้นด้วยการ Import ไลบรารี่ที่จำเป็นกันก่อน ในที่นี้เราจะ 4 ไลบรารี่ด้วยกัน ดังนี้

import pandas as pd
import numpy as np

import yfinance as yf
import matplotlib.pyplot as plt
%matplotlib inline
plt.style.use('ggplot')

Step 2: ดึงข้อมูลหุ้นจาก Yahoo Finance

Photo by Jaimie Harmsen on Unsplash

ในส่วนของการดึงข้อมูล เราจะใช้ yfinance เป็น api ช่วยในการดึงข้อมูลจาก yahoo finance มาให้เรา โดยผ่านคำสั่ง download โดยในการใช้คำสั่งนี้ เราจะต้องกำหนด symbol หรือ ชื่อของหุ้นที่เราต้องการดึงข้อมูล จุดเริ่มต้น และ สินสุดของข้อมูล สุดท้าย เราก็จะต้องระบุ Interval หรือ ช่วงของข้อมูลด้วย เช่น ข้อมูลรายวัน รายเดือน หรือ รายชั่วโมง เป็นต้น

start = "2018-01-01"
end = "2020-12-31"
period = "1d"
symbol = "GOOG"
stock = yf.download(symbol, start, end, interval=period)

จะเห็นว่า ข้อมูลที่เราจะใช้ในการทดสอบการทำ Backtest นี้เราจะใช้หุ้น Google โดยเลือกเอาข้อมูล ตั้งแต่ ปี 2018 ไปจนถึงปี 2020 โดยที่ Interval ของข้อมูลนี้คือ 1d หรือ ข้อมูลรายวันนั่นเอง

Step 3: Data Preparation

Photo by Maxim Hopman on Unsplash

ส่วนนี้จะเป็นการจัดเตรียมข้อมูล โดยจะเก็บข้อมูลดิบไว้ในตัวแปรที่มีชื่อว่า bnh เพื่อใช้เป็น Performance Index เพื่อใช้ในการวัดประสิทธิภาพของกลยุทธ์ในภายหลัง และ ทำการหาค่า Return ในรูปแบบของ Simple Return ไว้ด้วย จะเห็นว่า ในบทความนี้ เราได้เปลี่ยนมาใช้การหาค่า Return แบบ Simple Return กันบ้าง (ปกติใช้แต่ Log Return)

จากนั้น เราก็จะทำการตัดดาต้าปี 2018 ทิ้งไปจากข้อมูล bnh เนื่องจากข้อมูล bnh จะเก็บเฉพาะส่วนของข้อมูลตั้งแต่ปี 2019 เป็นต้นไป เนื่องจากเป็นชุดข้อมูลที่จะใช้ในการทดสอบกลยุทธ์นั่นเอง

start_test = "2019"
bnh = stock.copy()
bnh= bnh[start_test:]
bnh["rets"] = bnh["Close"].pct_change()

Step 4: Creating SMA function

Photo by Graeme Watkins

ในส่วนนี้ เราจะทำการสร้าง Function คำนวณ Simple Moving Average เส้นยาว และ สั้น เพื่อเตรียมไว้เขียน

Backtest กันครับ เมื่อคำนวณเสร็จเรียบร้อยแล้ว แล้วก็ตัดดาต้าปี 2018 ทิ้งไป เก็บไว้เฉพาะส่วนของข้อมูลตั้งแต่ปี 2019 เป็นต้นไป เพื่อทำการทดสอบประสิทธิภาพของกลยุทธอย่างที่กล่าวไว้ข้างบน นั่นเองครับ

def SMA(df, fast, slow):


    df["sma_fast"] = df["Close"].rolling(fast).mean()
    df["sma_slow"] = df["Close"].rolling(slow).mean()
    return df

fast = 10
slow = 40

stock = SMA(stock, fast, slow)
stock = stock[start_test:]

มาลองพล็อตข้อมูลราคาปิด และ เส้น SMA กันดูซะหน่อย โดยใช้คำสั่ง

stock[["Close", "sma_fast", "sma_slow"]].plot();
หุ้น goog จากปี 2019 – 2020

Step 5: Creating Trading Strategy

Photo by Zoe Holling on Unsplash

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

signal = ""
ret = []

ในการทำงานนี้ เราจะกำหนด ตัวแปร signal ไว้เพื่อนำมาเป็นตัวเก็บสัญญาณซื้อขาย ส่วนตัวแปร ret จะเป็น ลิสต์ที่นำมาเก็บ return จากผลกำไรในแต่ละวัน

for i in range(0,len(stock)):

    if signal == "":
        ret.append(0)
        print(stock.index[i], "No trade return: ", ret[i],"%")     
        
        if i > 0:      
            if (stock["sma_fast"][i] >= stock["sma_slow"][i]) & (stock["sma_fast"][i-1] < stock["sma_slow"][i-1]):
                signal = "Buy"
                print(stock.index[i], "Buy signal created")
            elif (stock["sma_fast"][i]<=stock["sma_slow"][i]) & (stock["sma_fast"][i-1]> stock["sma_slow"][i-1]) :    
                signal  = "Sell" 
                print(stock.index[i], "Sell signal created")          
  • อย่างที่บอกไว้ในตอนต้นว่า ในที่นี้เราจะไม่ใช้วิธี vectorization เราจึงต้องวนลูป ไปดูตั้งแต่วันแรกจนถึงวันสุดท้ายเลยด้วยคำสั่ง for i in range (0 , len(stock) )
  • จากนั้น เราจะไปเช็ค signal ของเรา ถ้า signal ไม่มี ก็แปลว่าเรายังไม่มีสัญญาณซื้อหรือขาย เราจึงเก็บ return ของวันนั้นเป็น 0 ด้วยคำสั่ง ret.append(0) ไปต่อเก็บไว้ในลิสต์
  • if i > 0: ในที่นี้ เราเช็คก่อนว่า วันแรกสุดของดาต้าเฟรม เราจะไม่เทรดบนสมมุติฐานของ SMA Crossover เพราะการ Crossover เราควรเช็ควันก่อนหน้าด้วยว่าถ้าวันนี้ sma_fast > sma_slow จริงมันก็ไม่ได้แปลว่ามันตัดขึ้น ถ้ามันตัดขึ้นจริง เมื่อวาน sma_fast ต้องน้อยกว่า sma_slow ด้วย แต่เมื่อดาต้าเฟรมแรกเข้ามาวันที่ 0 เราไม่สามารถเช็คตรงนี้ได้เราจึงต้องตั้งเงื่อนไขตรงนี้
  • หลังจากนั้นเราก็เช็คการตัดขึ้นตัดลงเป็นธรรมดา โดยทำการเช็คราคาในวันนี้ (stock[“sma_fast”][i] >= stock[“sma_slow”][i]) จากนั้นก็ต้องเช็คค่าราคาของเมื่อวานด้วยคำสั่ง (stock[“sma_fast”][i-1] < stock[“sma_slow”][i-1]) ถ้าเงื่อนไขนี้เป็นจริง ก็แปลว่ามีการ crossover จริง เราจะ กำหนด signal ให้เป็นสัญญาณ “ซื้อ” แต่เราจะไม่ซื้อทันที เพราะดาต้าเหล่านี้เราจะรู้เมื่อหมดวันแล้วเท่านั้น การจะซื้อจึงจะต้องกระทำในวันถัดไปเท่านั้น เราจะทำแบบเดียวกันนี้กับฝั่ง short ด้วยเช่นกัน
  • จากนั้นแสดงผลออกทางจอภาพ ด้วยคำสั่ง print ด้วย ณ วันนั้นๆ หลังจากสัญญาณ ซื้อ หรือ ขายได้เกิดขึ้น
    elif signal == "Buy":   
        if (stock["sma_fast"][i] <= stock["sma_slow"][i]) & (stock["sma_fast"][i-1]>stock["sma_slow"][i-1]):  
            signal  = "Sell"
            print(stock.index[i], "Sell signal create")    
            
        ret.append((stock["Close"][i]/stock["Close"][i-1])-1)               
        print(stock.index[i], "Buy return: ",round(ret[i]*100,2),"%")       
        
    elif signal == "Sell": 
        if (stock["sma_fast"][i]>=stock["sma_slow"][i]) & (stock["sma_fast"][i-1] < stock["sma_slow"][i-1]):
            signal  = "Buy"
            print(stock.index[i], "Buy signal create") 

        ret.append(1-(stock["Close"][i]/stock["Close"][i-1]))    
        print(stock.index[i], "Sell return: ",round(ret[i]*100,4),"%") 
        
stock["strategy"] = np.array(ret)       
  • ในส่วนนี้เราจะเช็ค signal ว่ามีสัญญาณซื้อหรือขายเข้ามาไหมด้วยคำสั่ง elif signal == “Buy”: และ elif signal == “Sell”: ถ้าเงื่อนไขนั้นๆ เป็นจริง ก็เป็นไปได้ 2 case คือ เมื่อวานมีสัญญาณซื้อ long หรือขาย short มา
  • สมมุติว่า signal เป็น Buy เราก็จะเช็คต่อในอีก 1 กรณี ก็คือสัญาณ whipsaw สัญญาณหลอก เมื่อวานมีคำสั่งให้เราซื้อ แต่วันนี้อาจจะตัดลงเป็นผลให้เราต้องชอร์ตหุ้นลงมาก็ได้ เราจึงเช็คการตัดลงอีกครั้งด้วย if (stock[“sma_fast”][i] <= stock[“sma_slow”][i]) & (stock[“sma_fast”][i-1]>stock[“sma_slow”][i-1]) ถ้าเป็นจริง เราก็ไป set สัญญาณ signal ให้เป็น Sell แต่ไม่ว่าจะมีสัญญาณ counter signal ดั้งเดิมของเราหรือไม่ เราก็ต้องคิดกำไรของการ long ในวันนี้เสียก่อนด้วยคำสั่ง ret.append((stock[“Close”][i]/stock[“Close”][i-1])-1) จะเห็นว่า เราเอาวันนี้/เมื่อวาน – 1 ก็เพราะว่าเรา Long เราจะได้กำไรเมื่อหุ้นขึ้น
  • ส่วนของ signal เป็น Sell นั้นทำเหมือน Buy ทุกอย่าง เราต้องเช็คสัญญาณ counter signal ของเราด้วย แต่ไม่ว่าจะมี counter signal หรือไม่ เราก็ต้องรับรู้กำไรของการ short อยู่ดี เราจึง ret.append(1-(stock[“Close”][i]/stock[“Close”][i-1])) จะเห็นว่าเราใช้ 1 – วันนี้/เมื่อวาน เนื่องจาก เราจะได้กำไรก็ต่อเมื่อหุ้นลงนั่นเอง
  • stock[“strategy”] = np.array(ret) เราเก็บผลการเทรดไว้ใน Column strategy

มาทดลองรันโปรแกรมกันดูครับ

ผลกำไรจากการเทรดแต่ละวัน

จะเห็นว่าโปรแกรมก็ทำงานตามที่เราคาดหวังไว้นะครับ สัญญาณซื้อจากการ crossover มาในวันที่ 09 มากราคม เราไปซื้อจริง วันที่ 10 มกราคม แทน แต่แน่นอนครับว่าประเด็นนี้ยังมีช่องโหว่อยู่บ้าง ซึ่งเราจะค่อยๆมาแก้ไขเพิ่มเติมกันต่อไปครับ

Step 6: Performance Evaluation

Photo by Chris Liverani on Unsplash

เมื่อสร้างกลยุทธ์ และ คำสั่ง Backtest เสร็จเรียบร้อยแล้ว เราจะมาดูผลการ Simulation กันครับ ด้วยคำสั่ง ดังนี้

print("pnl: ",((1+stock["strategy"]).cumprod()[-1]-1)*100)
plt.plot((1+stock["strategy"]).cumprod(), label="Strategy", color = 'green')
plt.plot((1+bnh["rets"].fillna(0)).cumprod(), label="BnH", color = 'black')
plt.title(symbol+" strategy return")
plt.legend()
plt.show()
  • จะเห็นว่า ผลที่ได้จากกลยุทธ์ของเราได้กำไรที่ 50.6% ผลอาจจะสู้ Buy and Hold ไม่ได้ แต่มันไม่ใช่ประเด็นครับ เรามาดูรายละเอียดการคำนวณกันหน่อยดีกว่า
  • อย่างที่รู้กันว่าเราไม่ได้ใช้ Log Return แล้ว เราจึงเสียความสามารถในการ Additive ไป เราจึงไม่สามารถคำนวณผลกำไรด้วยการบวกด้วยคำสั่ง .cumsum() (cumulative summation) ได้อีกแล้ว เราจึงต้องใช้ ฟอร์แมตการคูณ (1+simple return วันที่ 1) * (1+simple return วันที่ 2) …. * (1+simple return วันที่ n) ด้วยคำสั่ง .cumprod() (commulative product) แทนครับ

นอกจากนี้ เรายังสามารถดูผลการเทรดอื่นๆ จากฟังก์ชั่นที่เราเขียนเองในคอร์สได้อีก ดังนี้

ผลกำไรและ drawdown
reuturn boxplot ของ return และ value at risk แบบ cornish fisher ของผลการลงทุนเรา

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

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s