กลยุทธ์ง่ายๆ อย่างการเลือกหุ้นผู้ชนะ ทำกำไรได้จริงหรือ [แจก Code Portfolio Selection with Python]

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

Photo by Museums Victoria on Unsplash
สมมุติว่าเราต้องการซื้อหุ้นด้วยเงื่อนไขสุดเบสิค คือ ถ้าเราซื้อหุ้นเฉพาะที่ "เป็นหุ้นผู้ชนะ" ในช่วงนี้ผ่านมาแล้วถือไว้ซักระยะหนึ่ง เราจะสามารถทำกำไรได้หรือไม่?

ลองมาขยายความกันหน่อยดีกว่า ว่าเงื่อนไขนี้เป็นอย่างไร

  • ทำการเรียงหุ้นใน 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 แล้ว หุ้นผู้ชนะถ้ามีมูลค่าเพิ่มขึ้นจนมีอัตราส่วนมากกว่าตัวอื่นๆ เราต้องทำการขายออกมาให้มีน้ำหนักเท่าๆกัน
Photo by Joshua Golde on Unsplash

มาเริ่มเขียนโปรแกรมกันเลยดีกว่าครับ เริ่มด้วยการ 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()
Photo by Glenn Carstens-Peters on Unsplash

หน้าตาของตัวแปรที่เราจะนำมาทำงานด้วย

เริ่มเขียน 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 สำหรับนำไปคิดกำไรสำหรับเดือนถัดไป กระบวนเหล่านี้จะทำซ้ำไปเรื่อยๆจนครบทุกวันครับ
Photo by Chris Liverani on Unsplash

วัดผล

วัดผลเราจะใช้ 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)
Photo by Austin Distel on Unsplash

ทดลองใช้งาน

จากนี้เราจะลองทดลองใช้งานกันครับ โดยจะกำหนดให้ถือหุ้น 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 การเลือกหุ้นนะครับ ซึ่งเราอาจจะพัฒนาไปใช้เงื่อนไขอื่นในการหาหุ้นผู้ชนะที่ฉลาดกว่าแค่ดูผลงานจากเดือนที่ผ่านก็ได้ครับ

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

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