สวัสดีครับ ไม่ได้เขียน blog ซะนาน วันนี้มีโอกาสได้กลับมาอัพเดต blog กันซะหน่อย วันนี้เรามาวอร์มอัพ Python กับการ backtest แบบง่ายๆกันดีกว่าครับ

สมมุติว่าเราต้องการซื้อหุ้นด้วยเงื่อนไขสุดเบสิค คือ ถ้าเราซื้อหุ้นเฉพาะที่ "เป็นหุ้นผู้ชนะ" ในช่วงนี้ผ่านมาแล้วถือไว้ซักระยะหนึ่ง เราจะสามารถทำกำไรได้หรือไม่?
ลองมาขยายความกันหน่อยดีกว่า ว่าเงื่อนไขนี้เป็นอย่างไร
- ทำการเรียงหุ้นใน pool (กลุ่มของหุ้นที่เราเลือกมา) ทั้งหมด ที่มีผลงานดีที่สุดในช่วงเวลาที่ p โดยที่ p อาจจะเป็น 1 สัปดาห์ 1 เดือน 3 เดือน ฯลฯ ผ่านมา
- เลือกหุ้นที่ทำผลงานได้ดีที่สุดมา n ตัว แล้วถือไว้ใน portfolio ของเราเป็นช่วงเวลา อีก q หนึง (หรือจะมากกว่าน้อยกว่าก็แล้วแต่เราจะดีไซน์) คิดผลกำไร / ขาดทุนของช่วงเวลาที่ถือหุ้นเหล่านั้นไว้ใน portfolio (ช่วงเวลา q)
- ให้เราเริ่มกระบวนการเดิมซ้ำคือการไปเรียงลำดับผลงานของหุ้นใน portfolio ของเรา และ ในส่วนของ Pool
- ในส่วนของ portfolio ของเรานั้นให้เราเลือกหุ้นที่ทำผลงานได้แย่ที่สุด d ตัวจากนั้นทำการนำมันออกจาก portfolio ของเราไป
- ในส่วนของ pool ให้เราเราเลือกหุ้นที่ทำผลงานดีที่สุด d ตัว (เพื่อมาทดแทนหุ้น d ตัวที่นำออกไป) และ นำเข้ามาใน portfolio ของเราแทน จากนั้นคิดผลกำไรและวนซ้ำกระบวนการไปเรื่อยๆจนหมดช่วงเวลาทดสอบ
- ผลที่ได้ก็จะเป็นกำไรขาดทุนของการเลือกหุ้นผู้ชนะมาไว้ใน portfolio เพื่อนำมาเปรียบเทียบกับ ตัวชี้วัด(ในที่นี้เราใชเป็น index ของกลุ่มหุ้นนั้นๆ) อีกทีว่าทำผลงานได้ดีกว่า index ไหม ดีกว่า/แย่กว่าก็นำมาพิจรณาว่าจะปรับเพิ่มเติมตรงไหนไหม หรือ จะนำไปใช้อะไรก็ว่ากันไป
- ในการทดลองนี้เราได้สมมุติว่าเราถือหุ้นจนมีมูลค่าใน portfolio เท่ากันทุกตัว(equal weight) และทำการปรับสมดุล(rebalance) ทุกเดือนนะครับ ฉะนั้นนอกจากจะเลือกหุ้น d ตัวเข้ามาใน portfolio แล้ว หุ้นผู้ชนะถ้ามีมูลค่าเพิ่มขึ้นจนมีอัตราส่วนมากกว่าตัวอื่นๆ เราต้องทำการขายออกมาให้มีน้ำหนักเท่าๆกัน

มาเริ่มเขียนโปรแกรมกันเลยดีกว่าครับ เริ่มด้วยการ Import library ที่สำคัญที่เราจะใช้ก่อน
Import Libraries
import pandas as pd
import numpy as np
import yfinance as yf
import matplotlib.pyplot as plt
plt.style.use('ggplot')
%matplotlib inline
Retrieve Data
pool หุ้นที่ผมจะใช้ในการทดสอบคราวนี้จะเป็นหุ้นของ Dow Jones Industrial Average (DJI) จากประกอบไปด้วยหุ้น 30 บริษัทใหญ่ของตลาดสหรัฐอเมริกา ฉะนั้นสิ่งแรกที่ต้องทำคือเอาชื่อหุ้น 30 ตัวนั้นมาก่อน โดยใช้คำสั่งดึงชื่อมาจาก Dow Jones Industrial Average ถ้าเราเข้าไปดูเราจะเห็นตารางนี้

ในตารางจะมีชื่อหุ้นที่เราต้องการอยู่เราสามารถใช้คำสั่ง read_html เพื่อเก็บข้อมูลตารางเหล่านี้ได้
table =pd.read_html('https://en.wikipedia.org/wiki/Dow_Jones_Industrial_Average')
table[1]

ข้อมูลที่ดึงมาจาก wikipedia จะถูกเก็บไว้ใน list โดยใน list จะมีหลายตาราง เราจะไปสนใจตารางด้านบนที่มีชื่อหุ้นอยู่ ซึ่งจะถูกเก็บอยู่ใน list ตำแหน่งที่ 1 จะเห็นว่าชื่อหุ้นอยู่ใน column ชื่อ “Symbol”
symbols = table[1]['Symbol'].values
print(symbols)

เราเก็บชื่อของหุ้นไว้ในตัวแปร symbols จากนั้นเราจะมาดาวน์โหลดข้อมูลด้วย library y-finance หรือ yahoo finace ที่เรา Import เข้ามาในตอนต้นของโปรแกรม ก่อนอื่นเลยนะครับ หุ้น 30 ตัวนี้อาจจะมี survivorship bias ก็ได้ เพราะเราก็ไม่แน่ใจว่าในระยะเวลาที่เราจะทดลอง ที่จะเริ่มจากต้นปี 2016 จนถึง ปลายปี 2020 นั้นได้มีหุ้นตัวไหนออกจากกลุ่มไปหรือไม่
#กำหนดจุดเริ่มต้นและจุดสิ้นสุดของการดึงข้อมูล
start = '2015-12-01'
end = '2020-12-31'
period = '1mo'
pool = {}
pool_return = pd.DataFrame()
for symbol in symbols:
pool[symbol] = yf.download(symbol, start, end, interval=period)
pool[symbol].dropna(inplace=True,how="all")
pool_return[symbol] = pool[symbol]["Adj Close"].pct_change()
index_ret = yf.download("^DJI", start, end, interval=period)
index_ret["ret"] = index_ret["Adj Close"].pct_change()

ในที่นี้ เราตั้งค่าตัวแปร “start” และ “end” เพื่อมากำหนดจุดเริ่มต้น และจุดสิ้นสุดของข้อมูลที่เราจะดึงมา จากนั้นกำหนดตัวแปร period เพื่อกำหนดระยะเวลาข้อมูลที่เราจะดึง จากนั้นใช้คำสั่ง for เพื่อวนลูป อ่านค่าหุ้นที่อยู่ในตัวแปร symbol ตั้งแต่ตัวแรกไปจนถึงตัวสุดท้าย ข้อมูลด้วย yf.download(symbol, start, end, interval=period) จะเห็นว่าผมเริ่มโหลดข้อมูลจากปลายปี 2015-12 กล่าวคือเกินมา 1 เดือน เพื่อมาใช้เพื่อประโยชน์ในการคำนวณ return สำหรับต้นปี 2016-01 โดยเฉพาะ
จากนั้นจึง ทำการหา return ของหุ้นในช่วงเวลานั้นๆ ดัง code ตัวอย่าง ด้านล่าง คือการหาค่า returnของหุ้นทุกเดือน จากนั้นเก็บไว้ใน column ชื่อของหุ้นตัวนั้นๆ ใน dataframe ที่มีชื่อว่า pool_return
โค้ดด้านล่างทำงานเหมือนกันครับ แต่เราทำเฉพาะ index ของ Dow Jones Industrial Average (DJI) ตัวเดียวเพื่อใช้เอามาเปรียบเทียบผลงานภายหลัง
pool_return.head()

ลองดูหน้าตาของ DJI Index ครับ จะเห็นว่ามี Column ret ที่เพิ่มขึ้นมา
pool_return.head()


หน้าตาของตัวแปรที่เราจะนำมาทำงานด้วย
เริ่มเขียน Function เลือกหุ้น
เมื่อได้ค่า return ของหุ้นแต่ละตัว ในช่วงเวลาที่กำหนดแล้ว ก็ถึงขั้นตอนในการเลือกหุ้น ซึ่งสามารถทำได้ ดังนี้
#strategy
def strategy(df, n, d):
portfolio = []
ret = [0]
for i in range(1,len(df)):
##################[2]##################
print('-'*100,'\n')
print('in port:', portfolio)
if i > 1:
ret.append(df[portfolio].iloc[i,:].mean())
losser_stock = df[portfolio].iloc[i,:].sort_values(ascending=True)[:d].index.values.tolist()
portfolio = [t for t in portfolio if t not in losser_stock]
##################[1]##################
print('month:', i)
print('return: ',ret[i-1])
n_select = n - len(portfolio)
new = df[df.columns.difference(portfolio)].iloc[i,:].sort_values(ascending=False)[:n_select].index.values.tolist()
portfolio = portfolio + new
print('new pick:', new)
ret_df = pd.DataFrame(np.array(ret),columns=["ret"])
return ret_df
- ฟังก์ชั่น strategy เป็นฟังก์ชั่นสำหรับเลือก “หุ้นผู้ชนะ” มาใส่ portfolio ของเรา โดยการทำงานหลักๆ คือ
“for i in range(1,len(df))” คือ การวนลูป จาก 1 ไปถึง ความยาวของข้อมูลทั้งหมด เช่น ถ้าข้อมูลมี 60 เดือน ก็จะเริ่มจากเลข 1 ไปจนถึงเลข 59 เพราะ index เริ่มจาก 0 ดังนั้นจึงจบที่ 59 ไม่ใช่ 60 ส่วนสาเหตุที่ต้องเริ่มวนจาก 1 แทนที่จะเริ่มต้น index ที่ 0 นั้นก็เพราะว่า เดือนแรกสุดเราไม่สามารถนำมาหา return ได้ เพราะไม่มีข้อมูลก่อนหน้า ซึ่งจะเห็นได้จาก Row แรกของหุ้นทุกตัวมีค่าเป็น “NaN” เราจึงไปเริ่มทำงานที่ index ที่ 1 หรือเป็น วันที่ 2016-02 ในชุดข้อมูลตัวอย่าง
จากนั้นเรามาคำนวณผลการลงทุนจากหุ้นใน portfolio ของเรา และทำการจัดอันดับว่าหุ้นตัวไหนทำงานดีสุด ตัวไหนแย่สุด ซึ่งจะต้องถูกนำออกจาก portfolio ของเรา - แต่ก่อนจะไปถึงขั้นนั้น ขอข้ามมาดูที่ [1] กันก่อนครับ เนื่องจาก ในรอบแรกของการทำงาน เมื่อเช็คที่บรรทัด if i > 1: จะเป็นเท็จ เนื่องจากเราไม่สามารถมีหุ้นในพอร์ตจริงๆได้ เพราะเราต้องไปเลือกหุ้นจากข้อมูลใน เดือน 2016-02 ก่อนว่าตัวไหนมีผลงานดีสุด n อันดับแรกมาเข้าใส่พอร์ต และไปรับรู้ผลกำไรในเดือนที่ 2016-03 หรือ รอบที่ i = 2 นั่นเอง ฉะนั้นรอบแรกตรงนี้จะยังไม่ทำงาน ทำให้ code การทำงานในส่วนของ [2] ทั้งหมดถูกข้ามไปก่อนในรอบแรก นั่นเองครับ
- เราเลื่อนลงมาดูที่ [1] กันครับ ฟังก์ชั้นตรงนี้คือในส่วนของการกำหนดว่าเราจะเลือกหุ้นใหม่เข้ามาในพอร์ตกี่หุ้น และ เลือกแบบไหน “n_select = n – len(portfolio)” คือการกำหนดว่าเราจะเลือกหุ้นใหม่เป็นจำนวนกี่หุ้น ในรอบแรกของการทำงาน “len(portfolio)” จะเป็น 0 อ้างอิงจากการประกาศตัวแปร portfolio = [] ข้างต้น ส่วน n ซึ่งเป็นตัวแปรที่เรากำหนดว่าจะให้มีหุ้นใน portfolio ทั้งหมดเท่าไหร่ ซึ่งในที่นี้ตัวแปร n จะเป็นเท่าไหร่แล้วแต่เราจะกำหนด ฉะนั้นในรอบแรกสุดเราก็จะเลือกหุ้นมา n ตัวเลย
- เลื่อนลงมาอีกบรรทัด “df[df.columns.difference(portfolio)].iloc[i,:].sort_values(ascending=False)[:n_select].index.values.tolist()” บรรทัดนี้คือเราจะเรียงผลจากหุ้นจากทำผลงานได้ดีสุดไปแย่สุด โดยจะเลือกมาจาก pool โดยไม่ซ้ำกับตัวที่เรามีอยู่ใน portfolio ด้วยคำสั่ง difference แล้วเราก็ทำการเลือก จากตัวที่ทำงานได้ดีสุด มาจนถึงตัวที่ n_select และ เก็บหุ้นเหล่านั้นไว้ในตัวแปรชื่อ new
- จากนั้นก็นำหุ้นที่เลือกมาใหม่ไปรวมกับหุ้นเดิมที่มีอยู่ใน portfolio
- กลับขึ้นไปที่ [2] กันครับ คราวนี้เมื่อถึงรอบที่ i>1 code ในส่วนที่ [2] นี้ก็จะทำงาน โดยหุ้นที่ทำผลงานที่ดีสุดในเดือนที่ผ่านมา ถูกนำมาถือไว้ใน portfolioและ คิดผลกำไรในเดือนนี้ ในส่วนของคำสั่ง “ret.append(df
Your Portfolio Archive currently has no entries. You can start creating them on your dashboard.
.iloc[i,:].mean())” นั้น ที่เราคิดโดยใช้ mean เพราะเราถือว่าเราถือหุ้นทุกตัวแบบมีน้ำหนังเท่ากัน (Equal weight) หรือ ถือหุ้นแต่ละตัวไว้ใน portfolio ไว้เป็นมูลค่าเท่ากันนั่นเอง ถ้าหุ้นตัวไหนชนะตลอด ไม่เคยโดนเอาออกจาก portfolio ของเราเลย เราต้องทำการขายมันออกไปให้จนมีมูลค่าเท่ากับตัวอื่นๆ - นั่นเป็นเหตุผลให้เรากำหนด ret = [0] ในข้างต้น เนื่องจาก เดือนแรกเราจะไม่มีกำไร หรือ ขาดทุนเพราะเราไม่ได้ถือหุ้นในรอบแรกที่ i = 1 นั่นเอง
- “losser_stock = df
Your Portfolio Archive currently has no entries. You can start creating them on your dashboard.
.iloc[i,:].sort_values(ascending=True)[:d].index.values.tolist()” อันนี้ทำงานคล้ายกกับฟังก์ชั่นที่ได้พูดถึงมาแล้วครับ แต่ต่างกันตรงที่เราจะดูแค่ใน portfolio ของเราที่เราถืออยู่ และ เรียงลำดับจากน้อยไปมากแทน จากนั้นเลือกตัวที่แย่ที่สุด d ตัวออกมา และนำหุ้นนั้นๆ ออกจาก portfolio ของเราไป ทำให้จำนวนหุ้นในตัวแปร portfolio ลดลงไป d ตัว เช่น ถ้าเรากำหนดให้ถือหุ้นทั้งหมด 7 ตัว (n) และ คัดตัวที่แย่สุด 2 ตัว (d) ออกไป ในขั้นตอนนี้ portfolioของเราจะเหลือหุ้น 5 ตัว - จากนั้นกระบวนการก็จะไปซ้ำที่ “n_select = n – len(portfolio)” ใหม่ไปเรื่อยๆ แต่ในรอบที่ i>1 เป็นต้นไปเราไม่ได้เลือกหุ้นมาทั้งหมดอีกแล้ว เพราะ len(portfolio) จะมีค่าเท่ากับหุ้นที่เรายังถืออยู่ในพอร์ตเพราะผลงานดีพอ n_select ก็จะเท่ากับ 2 เป็นการบอกว่าคราวนี้เราจะไปดูที่ pool กัน และหาตัวที่ทำผลงานดีสุดมาแค่ 2 ตัวละนำไปใส่ portfolio สำหรับนำไปคิดกำไรสำหรับเดือนถัดไป กระบวนเหล่านี้จะทำซ้ำไปเรื่อยๆจนครบทุกวันครับ

วัดผล
วัดผลเราจะใช้ Compound Annual Growth Rate (CAGR) และ Maximum drawdown ซึงรายละเอียดพวกนี้เราได้พูดถึงในโพสเก่าๆไปแล้วจึงขอไม่อธิบายอะไรซ้ำนะครับ
# performance maxtrix
def CAGR(df):
df["cum_ret"] = (1 + df['ret']).cumprod()
n = len(df)/12
CAGR = (df["cum_ret"].tolist()[-1])**(1/n) - 1
return round(CAGR*100,2)
def max_dd(df):
df["cum_return"] = (1 + df['ret']).cumprod()
df["cum_roll_max"] = df["cum_return"].cummax()
df["dd"] = df["cum_roll_max"] - df["cum_return"]
df["dd_pct"] = df["dd"]/df["cum_roll_max"]
max_dd = df["dd_pct"].max()
return round(max_dd*100,2)

ทดลองใช้งาน
จากนี้เราจะลองทดลองใช้งานกันครับ โดยจะกำหนดให้ถือหุ้น 7 ตัวที่ทำผลงานในเดือนที่แล้วได้ดีที่สุด จากนั้น จะคัดออกตัวที่ทำผลงานได้แย่สุดออกไป ครั้งละ 2 ตัว และนำเข้ามาตัวที่ดีสุดใหม่ครั้งละ 2 ตัวเช่นกัน
n = 7
d = 2
wining_selected = strategy(pool_return, n, d)

จะเห็นว่ามันทำงานอย่างที่อธิบายไปด้านบน ในรอบแรกเราจะเลือกหุ้นมา 7 ตัวแรกสุดเลย โดยที่ไม่มีหุ้นค้างใน portfolio มาก่อน แต่ในรอบต่อๆ ไปเรามีหุ้นค้างใน portfolio แล้ว 5 ตัว เราจึงเลือกใหม่เพียงแค่ครั้งละ 2 ตัว นั่นเอง
สุดท้ายนี้เราก็ควรจะมาวัดผลว่า “กลยุทธ์ของเราทำผลงานได้ดีกว่า index ของตัวมันเองหรือไม่” ถ้าไม่ จาก common sense เราก็ไม่ควรมาใช้กลยุทธนี้ ให้เราไปถือกองทุนที่ลงทุนกับ index ของมันดีกว่า (ฮา) อันนี้พูดเล่นคัรบถ้ามันแย่กว่า เราก็ไปหาทางปรับปรุงให้มันดีขึ้นได้ครับ แต่ถ้าทำยังไงก็ดีขึ้นไม่ได้จริงๆ ก็ไปถือกองทุนที่ลงทุนกับ index น่าจะเป็นทางเลือกที่ดีกว่าครับ
print('-'*50)
print('Startegy Return:')
print('CAGR: ', CAGR(wining_selected),'%')
print('Max Drawdown: ', max_dd(wining_selected),'%')
print('-'*50)
print('Index Return:')
print('CAGR: ', CAGR(index_ret),'%')
print('Max Drawdown: ', max_dd(index_ret),'%')

ผลที่ได้คือ “กลยุทธ์ง่ายๆ ของเรา ชนะ index หรือ ตลาด” อยู่พอสมควร ทั้ง CAGR และ Max drawdown ก็เป็นไปได้ว่า การถือผู้ชนะในเดือนก่อนมาถือไว้ทีละ 1 เดือนแล้วเลือกใหม่ทีละ 2 ตัวนั้นอาจจะดีจริงๆก็ได้ (สมมุติฐานเบื้องต้น) แต่อย่าลืมว่านี่เรายังทำแค่ทดสอบอย่างง่ายเท่านั้น เราไม่ได้ใส่รายละเอียดหลายๆอย่างลงไป เช่น ค่าคอมมิชชั่น ค่าความคลาดเคลื่อน (slippage) หรือ มันมั่นคงจริงหรือไม่ ถ้าเราถือหุ้นมากกว่า 7 ตัวจะเป็นอย่างไร หรือ ถ้าเราคัดหุ้นออกมากกว่า 2 ตัวจะเป็นอย่างไร เป็นต้น
สุดท้ายนี้เรามาลอง plot ผลงานของ index และ กลยุทธ์ของเราเทียบกันดูครับ
fig = plt.gcf()
fig = plt.gcf()
fig.set_size_inches(15, 8)
plt.plot((1+wining_selected['ret']).cumprod())
plt.plot((1+index_ret['ret'].iloc[1:].reset_index(drop=True)).cumprod())
plt.title("Strategy vs Index Return")
plt.ylabel("Return")
plt.xlabel("months")
plt.legend(["Strategy Return","Index Return"]);

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