本文重點摘要
- 文章難度:★☆☆☆☆
- 簡介董監事持股對股票之影響。
- 透過 TQuant Lab 回測董監事持股策略,觀察此策略績效。
前言
在股市中,董監事的持股比例一直是投資人關注的重要指標。內部人持股的增減,往往透露出他們對公司未來發展的信心,對股價有著潛在的影響。然而,要從眾多數據中找出有意義的持股變化,對於投資者來說並不容易。
為了解決這個問題,我們將運用 TQuant Lab 的功能,來篩選出董監持股比例變動顯著的公司,並進行回測。透過 TQuant Lab,我們可以快速找到管理層動向與股價變動之間的潛在關聯,並檢驗此策略的績效,以協助投資人捕捉到市場中的隱藏機會。
想知道如何利用董監事持股比例來預測股價走勢,並在投資中取得優勢嗎?讓我們一起深入探討吧!
如何簡單選股
交易邏輯
我們將董監持股的變化分為三種策略進行分析,並將回測時間設為 2020 年 1 月 1 日至 2023 年 12 月 31 日。根據《證券交易法》第二十五條第二項規定,董監持股比例須於每月 15 日前公布上個月的數據,因此我們將策略再平衡日設定為每月 16 日,避免前視偏誤。
策略篩選條件
- 持股比例大於 40%:確保董監事持股比例達到一定水準,以篩選出具高持股比例的公司。
- 持股比例前 30 名:利用動能效應選取前 30 名進行投資,確認是否有較高的報酬。
- 持股比例連續 2 個月增加:表示內部人對於未來有較高的期望,因此連月增加持股比率。
以下本文將簡稱為策略 1、2、3。
模擬交易成本
為了提升策略的真實性,我們會在模型中設定滑價、手續費功能,以模擬下單時股價摩擦成本對交易結果的影響。
編輯環境與模組需求
本文使用 Windows 11 作業系統,並以 Visual Studio Code ( Vscode ) 作為主要編輯器進行分析與撰寫,zipline-tej版本別為 2.0.0,請注意本文將挑選較重要的程式碼進行講解,若想了解詳細的程式碼請至 GitHub 參閱!
回測完整過程
載入常用套件
# 載入常用套件 import os import pandas as pd os.environ['TEJAPI_KEY'] = "Yourkey" os.environ['TEJAPI_BASE'] = "https://api.tej.com.tw" from zipline.pipeline import Pipeline from logbook import Logger, StderrHandler, INFO log_handler = StderrHandler(format_string='[{record.time:%Y-%m-%d %H:%M:%S.%f}]: ' + '{record.level_name}: {record.func_name}: {record.message}', level=INFO) log_handler.push_application() log = Logger('Algorithm')
取得股票池
在此步驟中,我們會檢查股票池中是否包含具有不符合策略需求的特性之股票,並根據需求進行適當的調整。本次策略選擇範圍涵蓋普通股和 KY 股,考量到市值較小的公司通常擁有較高的董監事持股比率,因此並未特別選用流動性較高的台灣前 200 大標的股。此設計能更精準地鎖定符合策略條件的中小型股,以提高策略效果。
from zipline.sources.TEJ_Api_Data import get_universe start = '2020-01-01' end = '2023-12-31' pool = get_universe(start, end, mkt = ['TWSE', 'OTC'], stktp_e =['Common Stock-Foreign', 'Common Stock'])
導入股票池、價量資料
資料期間從 2020-01-01 至 2023-12-31,並導入上述股票的價量資料與加權股價報酬指數 ( IR0001 ) 作為績效比較基準。
start_dt = pd.Timestamp(start, tz='utc') end_dt = pd.Timestamp(end, tz='utc') tickers = ' '.join(pool) os.environ['mdate'] = start+' '+end os.environ['ticker'] = tickers+' IR0001' !zipline ingest -b tquant
建立歷史資料
import tejapi # 從 API 取得 TWN/APIBSTN1 董監全體持股狀況 data = tejapi.fastget( 'TWN/APIBSTN1', coid=pool, mdate={'gte':pd.Timestamp(start) - pd.DateOffset(months=3), 'lte':pd.Timestamp(end)}, opts={'columns':['coid','mdate','fld005','fld005l']}, paginate=True ).rename(columns={'fld005': 'Director_and_Supervisor_Holdings_Percentage', 'fld005l': 'Difference_in_Director_and_Supervisor_Holdings_Percentage_from_Previous_Period'}) # 計算 The_Month_Before_Last (前一期的與前期董監持股差異%
fld005l) shift_data = ( data[['coid','mdate','Difference_in_Director_and_Supervisor_Holdings_Percentage_from_Previous_Period']]. rename(columns={'Difference_in_Director_and_Supervisor_Holdings_Percentage_from_Previous_Period':'The_Month_Before_Last'}) ) shift_data['mdate'] = shift_data['mdate'] + pd.DateOffset(months=1) data = data.merge(shift_data,on=['coid','mdate'], how='left') # 董監持股比例須於每月 15 日前公布上個月的數據,因此先將 mdate(年月)先轉成名目每月十五號(均為次月十五號) data['mdate'] = data['mdate'] + pd.DateOffset(months=1) data['mdate'] = data['mdate'].apply(lambda x: x.replace(day=15)) # 將 NA 值替換為 'N/A',避免後續 ffill 時填補到前一期資料 for i in ['Director_and_Supervisor_Holdings_Percentage', 'Difference_in_Director_and_Supervisor_Holdings_Percentage_from_Previous_Period', 'The_Month_Before_Last']: data[i] = np.where(data[i].isnull(), 'N/A', data[i]) # 生成每間公司所有日曆日的 DataFrame days = pd.date_range(pd.Timestamp(start) - pd.DateOffset(months=2), end).tolist() df_tradeday = pd.DataFrame({ 'coid':[tick for tick in pool for day in days], 'mdate':days * len(pool) }) # 將 df_tradeday 合併 API 資料,並 foward ffill,同時把 'N/A' 替換回 np.nan data = ( df_tradeday. merge(data,on=['coid','mdate'], how='left'). set_index(['coid','mdate']). sort_index(). groupby(['coid']). ffill(). reset_index(). replace({'N/A': np.nan}) )
將資料轉置成 Pipline 所需要的形式
from zipline.data import bundles bundle = bundles.load('tquant') sids = bundle.asset_finder.equities_sids assets = bundle.asset_finder.retrieve_all(sids) symbol_mapping_sid = {i.symbol:i.sid for i in assets} # shift(1): 15 號公告的資料 16 號才能用,避免前視偏誤 transform_data = data.set_index(['coid', 'mdate']).unstack('coid').shift(1) transform_data = transform_data.rename(columns = symbol_mapping_sid) transform_data.index = transform_data.index.tz_localize('UTC') transform_data.tail()
建立 CustomDataset 資料庫
取得價量與歷史數據後,我們可以利用 CustomDataset 來輔助構建所需的交易訊號。由於策略 1 和策略 2 所需的數據欄位相同,因此可共用同一個 CustomDataset,提高數據處理的效率並簡化流程。
策略1、2
from zipline.pipeline.data.dataset import Column, DataSet from zipline.pipeline.domain import TW_EQUITIES from zipline.pipeline.loaders.frame import DataFrameLoader class CustomDataset(DataSet): Director_and_Supervisor_Holdings_Percentage = Column(dtype=float) domain = TW_EQUITIES inputs=[CustomDataset.Director_and_Supervisor_Holdings_Percentage] Custom_loader = {i:DataFrameLoader(column=i, baseline=transform_data[i.name]) for i in inputs} Custom_loader
策略3
from zipline.pipeline.loaders.frame import DataFrameLoader from zipline.pipeline.data.dataset import Column, DataSet from zipline.pipeline.domain import TW_EQUITIES class CustomDataset(DataSet): Difference_in_Director_and_Supervisor_Holdings_Percentage_from_Previous_Period = Column(dtype=float) The_Month_Before_Last = Column(dtype=float) domain = TW_EQUITIES inputs=[CustomDataset.Difference_in_Director_and_Supervisor_Holdings_Percentage_from_Previous_Period, CustomDataset.The_Month_Before_Last] Custom_loader = {i:DataFrameLoader(column=i, baseline=transform_data[i.name]) for i in inputs} Custom_loader
建立交易訊號
由於三個策略的交易訊號有所不同,因此需要分別設置交易訊號。然而,這些策略的基本設定是一致的,可以共享相同的基礎架構,以提升設置效率並確保一致性。
策略1
def compute_signals(): # 讓 Pipeline 可以顯示對應日期的資料 Dire = CustomDataset.Director_and_Supervisor_Holdings_Percentage.latest # filter Director_and_Supervisor_Holdings_Percentage_filter = (CustomDataset.Director_and_Supervisor_Holdings_Percentage.latest > 40) # 當董監事持股比率 > 40 時,則表示為 True。 return Pipeline(columns={ 'Dire':Dire, 'longs' : Director_and_Supervisor_Holdings_Percentage_filter }, ) # algo 會買入 "longs" 欄位為 True 的股票並出清為 False 的持股。
策略2
def compute_signals(): Dire = CustomDataset.Director_and_Supervisor_Holdings_Percentage.latest return Pipeline(columns={ 'Dire':Dire, 'longs' : Dire.top(30), }, )
策略3
def compute_signals(): MOM1 = CustomDataset.Difference_in_Director_and_Supervisor_Holdings_Percentage_from_Previous_Period.latest MOM2 = CustomDataset.The_Month_Before_Last.latest Difference_in_Director_and_Supervisor_Holdings_Percentage_from_Previous_Period_filter = \ (CustomDataset.Difference_in_Director_and_Supervisor_Holdings_Percentage_from_Previous_Period.latest > 0) The_Month_Before_Last_filter = (CustomDataset.The_Month_Before_Last.latest > 0) mask = (Difference_in_Director_and_Supervisor_Holdings_Percentage_from_Previous_Period_filter & The_Month_Before_Last_filter) # 兩者都符合 >0 則表示連續兩月月增 return Pipeline(columns={ 'MOM-1':MOM1, 'MOM-1 是否為正':Difference_in_Director_and_Supervisor_Holdings_Percentage_from_Previous_Period_filter, 'MOM-2':MOM2, 'MOM-2 是否為正':The_Month_Before_Last_filter, 'longs' : mask, }, )
確認 Pipline 是否設置正確
手動檢查 Pipeline 的結果,以確認是否符合預期的設定。如有誤差,請返回 compute_signals 函數進行修正。
pipeline_result.query("longs == 1")
定義再平衡日期
根據《證券交易法》第二十五條第二項規定,董監持股比例須於每月 15 日前公布上個月的數據,並且為了避免前視偏誤,我們將策略再平衡日設定為每月 16 日。
# 取出所有交易日 from zipline.utils.calendar_utils import get_calendar cal = get_calendar('TEJ').all_sessions cal = cal[(cal >= '2020-01-01') & (cal <= '2023-12-31')] # 計算每個交易日距離每月16日的距離 cal = pd.DataFrame(cal).rename(columns={0:'date'}) cal['diff'] = cal['date'].transform(lambda x: x - pd.Timestamp(year=x.year, month=x.month, day=16, tz='UTC')) # 篩選出平衡日期(取每月16日(含)後的第一個交易日),並轉為字串 tradeday = cal.groupby([cal['date'].dt.year, cal['date'].dt.month]).apply(lambda x: x[x['diff'].ge(pd.Timedelta(days=0))].head(1)).date.tolist() tradeday = [str(i.date()) for i in tradeday]
利用 Algo 進行簡易的回測
將初始資金設定在 100 萬,並且不使用槓桿。
from zipline.algo.pipeline_algo import * algo = TargetPercentPipeAlgo( start_session=start_dt, end_session=end_dt, capital_base=1e6, tradeday=tradeday, max_leverage=1, slippage_model=slippage.VolumeShareSlippage(volume_limit=0.15, price_impact=0.01), pipeline=compute_signals, custom_loader = Custom_loader ) results = algo.run()
利用 Pyfolio 進行績效評估
from pyfolio.utils import extract_rets_pos_txn_from_zipline import pyfolio as pf # 從 results 資料表中取出 returns, positions & transactions returns, positions, transactions = extract_rets_pos_txn_from_zipline(results) benchmark_rets = results.benchmark_return # 取出 benchmark 的報酬率 # 繪製 Pyfolio 中提供的所有圖表 pf.tears.create_full_tear_sheet(returns=returns, positions=positions, transactions=transactions, benchmark_rets=benchmark_rets )
策略 1
策略 2
策略 3
從上圖數據可以看出,三個策略的年化報酬率(Annual return)分別為 15.145%、17.988% 和 20.04%,均優於大盤(14.931%)。然而,若觀察走勢圖會發現,3個策略在 2022 年之前大部分時間的表現僅持平或甚至落後於大盤,2022年開始才逐漸挽回頹勢。後續可以拉長時間觀察這個投資策略是否在多頭與空頭間的表現存在明顯差異,找到策略在不同市場週期時的優勢和盲點。
除了單純的報酬率以外,在風險調整後收益方面,三個策略的夏普比率(Sharpe ratio)分別為 0.99、1.17 和 1.28,均優於大盤(0.86);而三個策略的 Sortino 比率(Sortino ratio)分別為 1.26、1.56 和 1.68,均優於大盤(1.22),這些數據顯示策略 2 和策略 3 在報酬面與風險調整收益方面表現較佳。
策略 1
策略 2
策略 3
從上圖中可以觀察到,三個策略中,報酬為負的月份數分別為 15、16 和 16 個月,均少於大盤(20個)。在相較於大盤更少的虧損月份下,有助於減少投資者心理壓力。
策略 1
策略 2
策略 3
進一步觀察各策略的持股 TOP 10 曝險(Portfolio allocation over time),發現各策略中單一股票曝險平均大約是在 0.004、0.035、0.0125 ,並不會有持股過度集中的風險。同時,使用者也可以依照自身偏好選擇適當持股檔數進行測試。
策略 1
策略 2
策略 3
最後,在最大回撤(Max drawdown,MDD)方面,各策略MDD分別為 -30.345%、-22.702% 和 -27.511%,僅策略2與策略3優於大盤(-28.553%)。在五大回撤的圖表中可以發現,最大回徹(Net drawdown in %)發生的期間都是在2020年新冠疫情期間。從回徹持續時間(Duration)來看,各策略的最大回徹期間為273、266和160天,均優於大盤(359天)。
結論
從三個策略的結果可以看到,不同策略下的風險、報酬特徵各異,其中策略 2與3在報酬與風險調整後的報酬方面表現最佳,而策略 2 則是擁有最小的最大回檔幅度。整體而言,可以發現我們利用董監事持股比例建構出的策略,報酬率皆優於大盤、夏普值皆接近或大於 1。所以整體而言,董監事持股比例確實展現出一定的預測力。
因此在三個策略中,策略 2 與 3 ,皆是可以參考的策略,而詳細的進出場條件,仍可以依照投資者的喜好,並利用Tquant Lab工具進行延伸,這邊僅是一個簡單的範例。
TQuant Lab 提供了便捷的因子測試環境,使得董監事持股比例策略的篩選、測試和回溯分析更加直觀。若能將此工具應用於實際投資過程,投資者將能更從容地探索董監事持股變動帶來的潛在市場機會,並有助於提高投資決策的科學性。
【TQuant Lab 回測系統】解決你的量化金融痛點
全方位提供交易回測所需工具