Backtesting Part2: Adding Stoploss

หลังจากเราทำ backtest แบบง่ายๆไปกันแล้ว เรามาลองเพิ่มรายละเอียดให้กับมันโดยใช้การหยุดการขาดทุน หรือ Stoploss กันดีกว่าครับ เราจะใช้ Technical Analysis indicator ซักตัวหนึงมาใช้เพื่อรักษาระดับกำไรของเราไว้ ในบทความนี้ก็ยังคงพื้นๆอยู่ครับ

แต่หลังจากโพสนี้ ยังมีรายละเอียดเกี่ยวการ backtest อีกหลายอย่าง เช่น ความสมจริงของราคาซื้อ-ขาย การเก็บ log วันที่ซื้อ-ขาย หรือปัญหาทาง assumption ทางคณิตศาสตร์ของการ backtest (รวม vectorize ด้วย) ที่เราจะมาพูดคุยและค่อยๆประกอบมันกันครับ

เราจะใช้อินดิเคเตอร์ชื่อดังอย่าง Average True Range (ATR) มาช่วยในการรักษาระดับกำไรของเรา อินดิเคเตอร์ตัวนี้ถูกคิดค้นโดยคุณ J. Welles Wilder Jr. ที่เปิดตัวในหนังสือในตำนานทางเทคนิคคอลชื่อ New Concepts in Technical Trading Systems คุณคนนี้เค้ายังคิดค้นเทคนิคอลอินดิเคเตอร์ที่เรารู้จักกันดี และยังใช้กันอยู่ในทุกวันนี้อีกหลายตัวด้วยกัน เช่น Relative strength index(RSI), Average directional movement index(ADX), Parabolic SAR อีกด้วย

Average True Range (ATR)

มันก็ตรงตัวนะครับ อินดิเคเตอร์ตัวนี้ ก็เป็นการหาค่าเฉลี่ยของระยะห่างที่แท้จริงของหลักทรัพย์มาเฉลี่ยกันเท่านั้นเอง โดยจะหา ATR ได้เราต้องหา True Range (TR) ก่อนด้วยสมการนี้

True Range

มันก็ไม่ได้เป็นอะไรมากไปหาค่าที่มากที่สุดทั้งสามอย่างข้างต้น เราจะใช้โค้ดหาดังนี้

df['High-Low'] = abs(df['High'] - df['Low'])
df['High-PrevClose'] = abs(df['High'] - df['Close'].shift(1))
df['Low-PrevClose'] = abs(df['Low'] - df['Close'].shift(1))
df['TR'] = df[['High-Low', 'High-PrevClose', 'Low-PrevClose']].max(axis = 1, skipna = False)

จากนั้นเรามาหาค่าเฉลี่ยนของมันด้วยสมการและโค้ด

ATR
df['ATR'] = df['TR'].rolling(n).mean()

เราลองมาดูหน้าตา ATR กันหน่อยครับ


def ATR(df, n):
    df = df.copy()
    df['High-Low'] = abs(df['High'] - df['Low'])
    df['High-PrevClose'] = abs(df['High'] - df['Close'].shift(1))
    df['Low-PrevClose'] = abs(df['Low'] - df['Close'].shift(1))
    df['TR'] = df[['High-Low', 'High-PrevClose', 'Low-PrevClose']].max(axis = 1, skipna = False)
    df['ATR'] = df['TR'].rolling(n).mean()
    #df['ATR'] = df['TR'].ewm(span=n,adjust=False,min_periods=n).mean()
    df = df.drop(['High-Low', 'High-PrevClose', 'Low-PrevClose'], axis = 1)
    return df['ATR']

n = 40
stock = SMA(stock, fast, slow)
stock['ATR'] = ATR(stock,n)
stock.dropna(inplace=True)

เรานำโค้ดด้านบนมาทำเป็น function โดยส่งแค่ค่า ATR มาอย่างเดียวนอกนั้นตัดทิ้งจากนั้นทดลองใส่ค่า ATR ของ 2 เดือน(ประมาณ 40 วัน) ลงไปแล้วดูหน้าตามัน

stock["ATR"].plot();
ATR ของ google จาก 2019 ถึง 2020

เราจะเห็นว่าเราจะ range ของความผันผวนของหุ้น Google เหวียงประมาณ 10 – 60 เหรียญ ถ้าเราจะใช้งานมันให้เป็นประโยชน์ เราอาจจะตั้งกลยุทธ์การเข้าเทรดผ่านเรตของความผันผวนก็ได้ แต่อย่างไรก็ตามเราจะใช้มันเป็น stoploss ฉะนั้น เราจะสร้าง chanel รักษากำไรของเราด้วยการนำ ATR มา + – กับราคาปิดเพื่อสร้าง chanel ไว้ด้วย โดยเราจะลองใช้ Close – ATR *2 เพื่อสร้างชาแนลที่กว้างขึ้นมาซักหน่อย (โดยปรกติอาจจะใช้ 1.5-2) โดยเราจะนำราคาปิดวันนี้ไปเทียบกับค่า Close – ATR*2 ของเมื่อวาน

sl = stock["Close"] - stock["ATR"]*2
ss = stock["Close"] + stock["ATR"]*2

stop_buy_points = stock["Close"] < stock["Close"].shift(1) - stock["ATR"].shift(1) * 2
stop_buy_prices = stock.loc[stop_buy_points, 'Close']

sdate = "2019-01"
edate = "2019-05"
stock[["Close", "sma_fast", "sma_slow"]][sdate:edate].plot();
sl.shift(1)[sdate:edate].plot(label="Close + ATR*2", ls='--')
ss.shift(1)[sdate:edate].plot(label="Close - ATR*2", ls='--')
plt.scatter(stop_buy_prices[sdate:edate].index, stop_buy_prices[sdate:edate], label = 'Stop loss', color = 'green', s = 80, marker = 'o')
plt.legend()
plt.show()

ในรูปนี้เราจะแสดงแต่การ stop ทางด้าน Long หรือ Buy อย่างเดียว จะเห็นว่าเราจะสามารถออกปิดสัญญาซื้อได้ก่อน เส้น sma_fast จะตัด sma_slow ลงซะอีก ก็ถือเป็นการปกป้องกำไร(ตัดขาดทุน) ได้ระดับหนึง จากนั้นเราไปทำต่อใน main loop ของเรา เราจะยกโค้ดมาแค่บางส่วนจากโพสแรก เพื่อที่จะไม่รกตาก็แล้วกันครับ

signal = ""
ret = []

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")
                
                
    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")   
        elif  stock["Close"][i] < stock["Close"][i-1] - stock["ATR"][i-1]*2: 
            signal = "Stop_Buy"
            print(stock.index[i], "Buy stop loss trigger")            
            
        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") 
            
        elif stock["Close"][i] > stock["Close"][i-1] + stock["ATR"][i-1]*2:
            signal = "Stop_Sell"
            print(stock.index[i], "Sell stop loss trigger")         
            
        ret.append(1-(stock["Close"][i]/stock["Close"][i-1]))    
        print(stock.index[i], "Sell return: ",round(ret[i]*100,4),"%")  
        print(stock.index[i], "Sell return: ",round(ret[i]*100,4),"%")  
  • โค้ดที่เพิ่มขึ้นมา เพียงแค่เติม elif ลงไปใน ช่วงเวลาที่เรามีสัญญาณซื้อ และขาย เพื่อเช็คหาวันที่มันไปชน stoploss ของเรา
  • ในกรณีที่มีสัญญญาณ ซื้อ(ฺBuy) อยู่ เราไปเช็คว่าราคาปิดวันนี้น้อยกว่าราคาปิดเมื่อวาน – ATRเมื่อวาน*2 หรือไม่ ในขั้นนี้เราต้องตัดสินใจเองว่า ถ้าราคาปิดมันไม่น้อยกว่าราคาปิดเมื่อวาน – ATRเมื่อวาน*2 แต่มัน = พอดีเป๊ะเราจะขายออกมาหรือไม่ (แต่กรณีแบบนี้เกิดน้อยมาก) ด้วย stock[“Close”][i] < stock[“Close”][i-1] – stock[“ATR”][i-1]*2: ถ้าเป็นจริงเราก็เซ็ตsignal เป็น Stop_buy เพื่อที่วันต่อมาเราจะได้ขาย position นี้ออกไป
  • ในกรณีที่เป็นสัญญาณขายเราก็ทำเหมือนกัน เพียงแต่เราไปเช็คชาแนลบน ราคาปิดวันนี้มากกว่า ราคาปิดเมื่อวาน + ATRเมื่อวาน*2 ถ้าเป็๋นจริงเราก็เซ็ตเป็น Stop_sell

เมื่อได้สัญญาณ stoploss ทั้งสองด้านแล้วเราก็จะเขียนต่อด้วยว่า ถ้า stop แล้วจะให้โค้ดของเราทำอะไรด้วยคำสั่ง

    elif signal == "Stop_Buy":  
        signal = ""
        ret.append((stock["Close"][i]/stock["Close"][i-1])-1)
        print(stock.index[i],"Stop loss exit return: ",round(ret[i],4))
        
    elif signal == "Stop_Sell":
        signal = ""
        ret.append(1-(stock["Close"][i]/stock["Close"][i-1]))   
        print(stock.index[i],"Stop loss exit return: ",round(ret[i],4))
  • ง่ายมากครับ ถ้าเจอ ไม่ว่าจะ Stop_Buy หรือ Stop_Sell เราก็ทำเหมือนกันคือเคลียร์สัญญาณ Buy หรือ Sell ออกไปด้วยการเซ็ต signal == “”
  • หลังจากนั้นเรกาก็ต้องไปรับรู้กำไรของวันที่เราขาย position ออกไปด้วยเพราะ assumption ของเราจะขายเมื่อหมดวันเท่านั้น ถ้าเป็น Stop_Buy เราต้องรับรู้กำไรของการ Long วันนั้นด้วย ถ้าเป็น Stop_Sell เราต้องรับรู้กำไรของ การ S้ort ณ วันนั้นด้วย

ลองดูผลงานครับ

จะเห็นว่าเราจะสามารถหยุดขาดทุนได้ก่อนหน้าที่สัญญาณจะเป็น counter signal ได้ 5 วัน สัญญาณ Stop_Buy ของเรามาในวันที่ 2020-04-30 เราก็ไปขายจริงในวันที่ 2020-05-01 แต่เราขายเมื่อหมดวันเราจึงต้องรับรู้กำไรของวันที่ 05-01 ด้วย จะเห็นว่าก็ขาดทุนเพราะหุ้นกำลังอยู่ในช่วงลดลง

จะเห็นได้เลยว่ากำไรเราเพิ่มจาก ~50 เป็น ~65% นับว่าเรารักษาระดับกำไรไว้ได้พอสมควร

ทำมันเป็น Function

โค้ดที่เพิ่มขึ้นมาก็มีเท่านั้นเองครับ จากโค้ดพวกนี้เราจะเห็นแล้วว่าการทำงานมันเริ่มซ้ำซ้อน หลายอย่างเช่นการ check สัญญาณซื้อ-ขาย และการคิดกำไร เราจะเอาการคิดกำไรและการหาสัญญาณซื้อ/ขาย + stoploss มาทำเป็น function ดังนี้

def signal_creator(params):
    
    df = params["df"].copy()
    i = params["i"]   
    signal = params["signal"]
    
    if signal != "Buy":
        if (df["sma_fast"][i]>=df["sma_slow"][i]) & (df["sma_fast"][i-1] < df["sma_slow"][i-1]) :
            signal = "Buy" 
            print(df.index[i], signal, "signal created!!!")
            
    if signal != "Sell":    
        if (df["sma_fast"][i]<=df["sma_slow"][i]) & (df["sma_fast"][i-1]>df["sma_slow"][i-1]):
            signal  = "Sell"
            print(df.index[i], signal, "signal created!!!")
            
    if signal == "Buy":        
        if df["Close"][i] < df["Close"][i-1] - df["ATR"][i-1]*2:
            signal = "Stop_Buy"     
            print(df.index[i], signal, "signal created!!!")
            
    if signal == "Sell":        
        if df["Close"][i] > df["Close"][i-1] + stock["ATR"][i-1]*2:
            signal = "Stop_Sell"    
            print(df.index[i], signal, "signal created!!!")
            
    return signal        

เราเอาโค้ดหาสัญญาณใน main loop แยกออกมาเป็น function โดยที่เราจะใช้ชื่อว่า signal_creator()ใน signal_creator เราจะรับค่า params มาจาก main loop เราจะกำหนดตัวแปรมารับค่าจาก params

ทางด้าน สัญญาณ ซื้อ – ขาย

  • เราจะเช็คว่ามีสัญญาณ ซื้อ ไหมทุกวัน ยกเว้นกรณีเดียวเท่านั้น คือกรณีที่เรามีสัญญาณซื้ออยู่แล้ว if signal != “Buy”: เท่ากับเราไม่มีสัญญาณซื้อ เราจึงต้องมาหาว่าจะซื้อหรือไม่ ถ้ามีเราก็ทำเหมือนเดิมคือ เซ็ต signal = “Buy”
  • เราจะเช็คว่ามีสัญญาณ ขาย ไหมทุกวัน ยกเว้นกรณีเดียวเท่านั้น คือกรณีที่เรามีสัญญาณขายอยู่แล้ว if signal != “Sell”: เท่ากับเราไม่มีสัญญาณขาย เราจึงต้องมาหาว่าจะขายหรือไม่ ถ้ามีเราก็ทำเหมือนเดิมคือ เซ็ต signal = “Sell”

ทางด้าน stoploss

  • เราจะเช็คว่ามี Stop_buy ไหมกรณีเดียวเท่านั้นคือ เรามีสัญญาณซื้ออยู่ if signal == “Buy” ถ้าเราก้ไปเช็คว่าถึงจุด stoploss ของเราไหม ถ้าถึงก็เซ็ต signal = “Stop_Buy”
  • เราจะเช็คว่ามี Stop_buy ไหมกรณีเดียวเท่านั้นคือ เรามีสัญญาณซื้ออยู่ if signal == “Sell” ถ้าเราก้ไปเช็คว่าถึงจุด stoploss ของเราไหม ถ้าถึงก็เซ็ต signal = “Stop_Sell”

จากนั้นส่ง signal ออกมาจาก fucntion

def cal_ret(params, opt):
    df = params["df"]
    i = params["i"]
    
    if opt == "No_Trade":
        ret = 0    
        
    elif opt == "Buy":    
        ret = (df["Close"][i]/df["Close"][i-1])-1     
    elif opt == "Sell":  
        ret = 1-(df["Close"][i]/df["Close"][i-1])
    elif opt == "Stop_Buy":     
        ret = (df["Close"][i]/df["Close"][i-1])-1
    elif opt == "Stop_Sell":     
        ret = 1-(df["Close"][i]/df["Close"][i-1]) 
        
    print(df.index[i], opt,"return: ",round(ret*100,2),"%")         
    return ret    

ฟังกชั่น cal_ret ใช้เพื่อคิดกำไร เราก็เช็คเพียงแค่ opt หรือ option ของการคิดกำไรว่าเราจะคิดแบบไหนเท่านั้น (จริงๆมันก็คือ signal นั่นเอง)

  • ถ้าเรียก Buy และ Stop_Buy เราจะคิดกำไรวันนั้นเป็น Long
  • ถ้าเรียก Sell และ Stop_sell เราจะคิดกำไรเป็น Short

เราจะยุบ main loop ได้ดังนี้

signal = ""
ret = []
df = stock.copy()

for i in range(0,len(stock)):
    params = {"df":df, "i":i, "signal": signal}
    if signal == "":
        
        ret.append(cal_ret(params, opt="No_Trade")) 
        if i > 0:
            signal = signal_creator(params)
                
                
    elif signal == "Buy":                   
        ret.append(cal_ret(params, opt="Buy"))                 
        signal = signal_creator(params)      
                
    elif signal == "Sell":           
        ret.append(cal_ret(params,opt="Sell"))
        signal = signal_creator(params)
        
    elif signal == "Stop_Buy":  
        signal = ""
        ret.append(cal_ret(params, opt="Stop_Buy"))
        
    elif signal == "Stop_Sell":
        signal = ""
        ret.append(cal_ret(params, opt="Stop_Sell")) 
        
stock["strategy"] = np.array(ret)               
  • เราก็แค่ แทน การหาสัญญาณด้วยการเรียก signal_creator(params)
  • และแทนการคิดกำไรด้วยการเรียก cal_ret(params, opt=”สัญญาณในวันนั้น”)

ดูผลงานหลังจากเรา add stoploss ซักหน่อย

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()
ผลงานหลังจากทดลองใส่ stoploss

จะเห็นว่าระดับกำไรของเราเพิ่มขึ้นมาเป็น 65% โดยประมาณ ขึ้นมาจนใกล้เคียงกับ Buy and Hold แต่เรายังสรุปไม่ได้ว่าแบบไหนดีกว่า เราต้องดู metric อื่นๆมาวัดผลด้วยก่อนอื่นดูของเราก่อน

ผลงานของ smacrossover+stoploss

จากนั้นดู Buy and Hold

ผลงานของ Buy and Hold

เมื่อดูเทียบกันแล้วจะเห็นว่า Stoploss ง่ายๆพื้นๆ สามารถเพิ่มประสิทธิ์ภาพลดการ drawdown ได้เยอะพอสมควร และเมื่อ drawdown น้อย Volatility ก็น้อยไปด้วยในตัว

แค่นี่เราก็ทำมันเป็นสอง function หลักได้แล้ว ในโพสต่อไปเราจะเพิ่มความสมจริงให้กับการ backtest นี้ด้วยการ adjusted ราคาบางอย่างเพื่อให้ backtest สมจริงยิ่งขึ้นครับ จริงๆแล้วตอนนี้มีอะไรที่ผิดแบบผิดทางคณิตศาสตร์อยู่ด้วย แม้กระทั้งการทำ Vectorization มันก็มีประเด็นทางด้าน assumption อยู่ซึ่งเราไม่ค่อยนำมาพูดกัน ซึ่งเราจะพูดถึงกันต่อๆไปนะครับ

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