楊育晟(Peter Yang)

嗨, 我叫育晟, 部落格文章主題包含了程式設計、財務金融及投資...等等,內容多是記錄一些學習的過程和心得,任何想法都歡迎留言一起討論。



Email: ycy.tai@gmail.com
LinkedIn: Peter Yang
Github: ycytai

ETF定期定額績效回測(Backtesting for dollar-averaging strategy)

什麼是定期定額?

定期定額就是在固定時間,投入固定金額的投資方法,舉例而言,在每個月5號,投入5000元購買台積電股票,即為定期定額。

近年來投資ETF的風氣逐漸提升,除了概念簡單明瞭、容易被投資人接受之外,市場的不易預測也連帶影響到部份投資人在主動選股上的意願。

定期定額的好處在於規避了一次性大量投入的風險,而改用逐步投入資金的方式平滑持有成本,固定金額而非固定購買數量的投資方式,也讓資產價格在相對高點時購入較少的部位,相對低點時購入較多的部位

今天來實作回測定期定額這樣的投資策略,觀察績效如何,以元大臺灣50(0050) 作為回測標的,主要原因在於兩者成立時間長,有足夠的資料期間,期間內也包含了多空的市場型態,另外,這兩個標的的流動性無虞,相對來說會是理想的投資標的。

資料蒐集

要進行資料回測,首先需要蒐集資料,需要的有

  • 0050 歷年股利和發放日期
  • 0050 每日收盤價

取得股利資料

從證交所抓 etf 股利資料,爬蟲程式碼如下

import requests
import pandas as pd
from bs4 import BeautifulSoup

def get_etf_dividend(symbol:str, start_year:int, end_year:int):
        
    # get source response
    url = 'https://www.twse.com.tw/zh/ETF/etfDiv'
    payload = {
        'stkNo': symbol,
        'startYear': start_year,
        'endYear': end_year,
    }
    res = requests.post(url, data=payload)
        
    # turn html to dataframe
    soup = BeautifulSoup(res.text, 'html.parser')
    table = soup.find_all('table')[0]
    df = pd.read_html(str(table))[0]
    df['證券代號'] = symbol

    return df

先來抓 0050 從 2004 年度到 2022 年度配發股利資料

df = get_etf_dividend('0050', 2004, 2022)
df.head()

得到的資料中,日期的年份用民國顯示,要再做轉換成西元方便計算,然後留下需要的欄位,在回測中只需要證券代號、發放日、除息交易日、收益分配金額 共四個欄位

民國轉西元,取出所需欄位並且改名,程式碼如下

from datetime import datetime

dividend_df = df[[
        '證券代號', '收益分配金額 (每1受益權益單位)', 
        '除息交易日', '收益分配發放日'
    ]].copy()

dividend_df['收益分配發放日'] = dividend_df['收益分配發放日'].apply(
    lambda x:datetime.strptime(
        f'{int(x[0:3])+1911}-{x[4:6]}-{x[7:9]}', 
        '%Y-%m-%d',
    )
)

dividend_df['除息交易日'] = dividend_df['除息交易日'].apply(
    lambda x:datetime.strptime(
        f'{int(x[0:3])+1911}-{x[4:6]}-{x[7:9]}', 
        '%Y-%m-%d',
    )
)

dividend_df.rename(
    columns={
        '證券代號':'symbol',
        '收益分配金額 (每1受益權益單位)':'dividend',
        '除息交易日':'dividend_date',
        '收益分配發放日':'payment_date',
    },
    inplace=True
)

此時股利的表格就整理完了,會像下方的圖片這樣

取得價格資料

股價有幾種取得方式

  1. 買資料庫
  2. 用爬蟲蒐集
  3. 載現成的

這邊選擇第3種,用yahoo finance提供的資料,點進下方連結

Yuanta/P-shares Taiwan Top 50 ETF (0050.TW) Stock Historical Prices & Data - Yahoo Finance

在Time Period中選擇Max → 然後Apply → Download

接著用python來讀取資料,並選取所需要的收盤價

df = pd.read_csv('0050.TW.csv', parse_dates=['Date'])
price_df = df[['Date', 'Close']].dropna().copy()
price_df.head()

當股利和收盤價都有了,接著就來回測看看,定期定額的績效如何

定期定額績效回測

回測條件假設

  • 以當天的收盤價計算買入成本,定期定額能夠完全足額買滿。
  • 如果定期定額交易日遇到休假日則往後順延。
  • 若不斷順延至月底都無開盤,則該月定期定額暫停。

首先,取出價格資料的年份和月份

year_month = pd.to_datetime(df['Date']).dt.strftime('%Y-%m')
year_month = list(year_month.drop_duplicates())

下方為定期定額實作的過程,在一開始先定義幾個變數,TARGET_DATE 為每個月的幾號定期定額,示範中設定每個月5號

接著宣告trading_list,等等會用來紀錄每次定期定額的交易日

迴圈運作邏輯如下

  1. 依序取出逐月的價格資料。
  2. 將資料切為target_date之後(含)的價格資料。
  3. target_date 紀錄起來
TARGET_DATE = 5

trade_list = []
for i in range(len(year_month)):
 
    # filter each month price data
    yr, mn = year_month[i].split('-')
    month_df = price_df[
        (price_df['Date'].dt.year == int(yr)) & \
        (price_df['Date'].dt.month == int(mn))
    ]
 
    # move forward if market closed at trade date.
    month_df = month_df[month_df['Date'].dt.day >= TARGET_DATE]
 
    if len(month_df) > 0:
        
        # select the first trade date
        trade_date = month_df.iloc[0, :].Date
        trade_list.append(trade_date)

而 INVEST_AMOUNT 則為每次投入的金額,示範中設定每個月投入10,000元。

接著將每次交易發生的日期用buy 標記起來,並計算購買的股數和成本,也計算總股數和總成本。

import numpy as np

INVEST_AMOUNT = 10000

output_df = price_df.copy()

output_df['buy'] = np.where(output_df.Date.isin(trade_list), 1, 0)
output_df['share'] = np.where(output_df['buy'], INVEST_AMOUNT / output_df['Close'], 0)
output_df['cost'] = np.where(output_df['buy'], INVEST_AMOUNT, 0)

output_df['total_shares'] = output_df['share'].cumsum()
output_df['total_cost'] = output_df['cost'].cumsum()

output_df['stock_value'] = output_df['total_shares']*price_df['Close']

產出股利的現金流量表

dividend_cashflow = {}
for i in range(len(dividend_df)):
 
    # filter each dividend record
    dividend_rec = dividend_df.iloc[i, :]
    d_date = dividend_rec['dividend_date']
    p_date = dividend_rec['payment_date']
    dividend = dividend_rec['dividend']

    position_rec = output_df[output_df.Date < d_date]
    if len(position_rec) > 0:
    
        # get the latest position before dividend date
        position = output_df[output_df.Date < d_date].iloc[-1, :]
        shares = position['total_shares']
        receive_dividend = shares*dividend
    
        # record the cash flow at payment date
        dividend_cashflow[p_date] = {'dividend':receive_dividend}

dividend_cashflow = pd.DataFrame(dividend_cashflow).T
dividend_cashflow.tail()

接著將收到股利的日期標記起來,也將領到股利的數額併入 output_df

output_df['dividend_date'] = np.where(
    output_df.Date.isin(dividend_cashflow.index), 1, 0
)
output_df = output_df.merge(
    dividend_cashflow, 
    left_on='Date', 
    right_index=True, 
    how='left'
)
output_df['dividend'] = output_df['dividend'].fillna(0)

最後計算損益,總資產會等於當天的股票價值加上所累積領到的股利(此例中就當作現今持有,不再投入)

而損益即為目前的總資產價值減掉投入的成本

output_df['total_asset'] = output_df['stock_value'] + output_df['total_dividend']
output_df['profit_n_loss'] = output_df['total_asset'] - output_df['total_cost']

結論:

從2008年1月開始每月5號投入10,000元新台幣,到2022年12月,總投入為180萬,期末股票加股利合計約362萬,獲利約182萬元。

result = output_df.tail(1)

current_asset = result['total_asset'].values[0]
total_cost = result['total_cost'].values[0]
pnl = result['profit_n_loss'].values[0]

print(
    f'total asset: {current_asset:,.0f} \n'
    f'total cost: {total_cost:,.0f} \n'
    f'total profit: {pnl:,.0f} \n'
)

輸出如下

total asset: 3,626,253 
total cost: 1,800,000 
total profit: 1,826,253 

如果和大盤比較呢?

相對於績效本身的獲利,常見的績效衡量還有相對報酬的方式,也就是和大盤比較,漲的時候漲幅高過大盤,跌的時候跌幅小於大盤,則相對報酬為正。

接著也透過Yahoo Finance來抓取大盤的收盤數據。

TSEC weighted index (^TWII) Charts, Data & News - Yahoo Finance

讀取資料並取的所需欄位,特別的地方在於使用的為Adj Close而非Close,兩者的差別在於Adj不受除權息影響,簡單來說他是包含權息在裡頭的指數。

因為上方0050的績效包含了股息在裡頭(暫不考慮再投入),所以用Adj Close來做比較。

df = pd.read_csv('^TWII.csv', parse_dates=['Date'])
twii_df = df[['Date', 'Adj Close']].dropna().copy()

接著引用一下stackoverflow上找到的XIRR公式,XIRR是一個在Excel中常見的函數,用於計算不定期的現金流入流出所產隱含的報酬率。

financial python library that has xirr and xnpv function?

import scipy.optimize

def xnpv(rate, values, dates):

    '''Equivalent of Excel's XNPV function.
    >>> from datetime import date
    >>> dates = [date(2010, 12, 29), date(2012, 1, 25), date(2012, 3, 8)]
    >>> values = [-10000, 20, 10100]
    >>> xnpv(0.1, values, dates)
    -966.4345...
    '''

    if rate <= -1.0:
        return float('inf')
    d0 = dates[0]    # or min(dates)
    return sum([ vi / (1.0 + rate)**((di - d0).days / 365.0) for vi, di in zip(values, dates)])

def xirr(values, dates):
    return scipy.optimize.anderson(lambda r: xnpv(r, values, dates), 0)

比較的作法為,逐年比較報酬率

大盤報酬率為當年度最後一個交易日除第一個交易日價格減1,即為年報酬率。

$$ \frac{P_{n, year}}{P_{1, year}} -1 $$

策略報酬率則為當年度的現金流量所隱含的內部報酬率,期初投入的投資金額即為前一年末的股票部位以年初第一天的收盤價計算得到的股票價值,期末現金流入為依最後一個交易日的股票部位價值。

計算過程

先建立一個year 變數取得資料年份

接著逐年計算,先算大盤報酬率,再從output_df中取出要的欄位作為cf_table 計算當年的現金流量,然後套入xirr() 得到報酬率並記錄起來。

years = sorted(set(output_df.Date.dt.year))
result = {}
for i in range(len(years)):

    year = years[i]

    # calculate twii return
    twii_close = list(twii_df[twii_df.Date.dt.year == year]['Adj Close'])
    twii_ret = twii_close[-1] / twii_close[0] - 1

    # build cashflow table
    cf_table = output_df[
        output_df.Date.dt.year == year
        ][[
            'Date', 'cost', 
            'dividend', 'stock_value'
        ]].copy()

    start_value = list(cf_table['stock_value'])[0]
    end_value = list(cf_table['stock_value'])[-1]

    # use year start values as beginning investing amount of current year
    cf_table['cashflow'] = cf_table['cost']*-1 + cf_table['dividend']
    cf_table.cashflow.iloc[0] -= start_value
    cf_table.cashflow.iloc[-1] += end_value
    cf_table = cf_table[cf_table.cashflow != 0]

    # use xirr for portfolio return
    dates = list(cf_table['Date'])
    cashflow = list(cf_table['cashflow'])
    portfolio_ret = xirr(cashflow, dates).item()

    result[year] = {
        'twii':twii_ret, 
        'portfolio':portfolio_ret
    }

最後把結果輸出成dataframe

ret_df = pd.DataFrame(result).T

用Excel將資料做成圖,可以觀察到在2008, 2009, 2022等大行情發生時,大盤的表現會略勝於定期定額,而一般期間大多是定期定額策略有更好的表現。

如果把資料去除的大行情(金融風暴, 通膨升溫)產生的年份來看看,定期定額的表現其實滿不賴的,在趨勢上升時,有更好的報酬表現,下降時有較少的跌幅,而且幾乎貼近大盤走勢,整體而言是個很穩定的投資策略。

Tags:
# finance
# investing
# python
# etf