從羅伯.加迪納的選股法出發:在台股尋找小型成長黑馬
前言
在投資市場中,小型成長股因具備高度成長潛力與價格彈性,長期以來吸引著專業投資人與基金經理人的關注。美國知名基金經理人羅伯.加迪納(Robert Gardiner)便是此領域中的代表人物。他以管理瓦薩屈微型股基金(Wasatch Micro Cap Fund)聞名,曾在2000年與2001年美股震盪期間,依然交出超過三成與近五成的年報酬率,表現遠勝大盤,顯示其選股邏輯具備極強的抗震與增長能力。
羅伯.加迪納的選股策略核心在於尋找市值偏小、盈餘成長力強且具備基本面優勢的公司。他透過獨創的 ABGC 架構,結合 GARP(合理成長價格)理念,聚焦於 PEG 值低於 1 的潛力股,建構出一套系統性但具彈性的投資方法。為驗證此策略在台股市場的可行性,本文將依據加迪納的篩選精神,提出可量化的選股準則,並以台灣股市為對象進行回測,進一步探討小型成長股策略在本地市場的實證效果。
選股條件說明
在模仿羅伯.加迪納的選股策略時,我們聚焦在他最核心的投資理念:投資於具備強勁成長潛力的小型企業,並以合理價格買進。雖然他在挑選企業時也會納入主觀判斷(例如經營團隊與競爭優勢),但為了便於量化實作,我們將條件轉化為以下幾個可計算的財務指標:
選股條件
1. 小型市值公司:
選取總市值低於市場平均的 30%。這是為了聚焦在小型與微型成長股,加迪納認為這類公司尚未被市場充分關注,成長空間較大。
2. 預估盈餘成長率大於 15%:
僅保留未來一年預估盈餘成長率超過 15% 的公司。強勁的盈餘成長是股價推升的主要驅動力,也是成長型策略的核心。
3. 近四季毛利率高於產業平均值:
比較公司近四季的毛利率與所屬產業的平均水準。毛利率高代表產品具備競爭力,有較強的議價能力與經營效率,間接反映競爭優勢。
4. 董監事持股比率高於市場平均值:
以公司內部人(董監事)持股比率做為指標。當管理層與股東的利益綁在一起時,能提升公司治理品質,也反映對未來的信心。
5. PEG 小於 1:
使用 PEG(本益比除以預估盈餘成長率)作為評價標準。當 PEG 小於 1,代表以相對合理的價格買到高成長的股票,是 GARP(合理成長價格)投資法則的核心精神。6. 從符合條件的股票中,選出 PEG 值最小的 20%:
在通過上述基本條件的股票中,依 PEG 值由小到大排序,僅挑選最前段的 20%。PEG 越小代表股價相對便宜、成長性又高,有助於提升投資組合的整體報酬潛力,並集中火力在最具吸引力的標的上。
回測整體架構說明
為驗證羅伯.加迪納選股邏輯在台股市場的可行性,本研究以 2015 年 1 月 1 日至 2025 年 5 月 27 日為回測期間,設計一套具體的策略執行架構並進行回測。整體流程如下:
回測期間:2015-01-01 至 2025-05-27。
再平衡頻率:每 20 個交易日(約等同每月調整一次投資組合)。
選股邏輯:依據選股條件篩出符合小型成長股特質之標的,並從中挑選 PEG 值最低的前 20%。
資金配置:等權重分配至每一期選中的股票,假設無槓桿、無融資。
此策略以系統化方式執行加迪納的選股精神,並透過等距頻率再平衡與等權重分配,避免個別權重過度偏重某些標的,同時控制過度交易的風險。
資料撈取與前處理程式碼展示
TEJ 代碼 mktcap per r405 r403 r105 fld005 財務指標 股票市值 本益比 稅後淨利成長率 營業利潤成長率 營業毛利率 董監持股比例 TEJToolAPI 抓取資料時會用到的財務代碼 import pandas as pd import numpy as np import tejapi import os import json import matplotlib.pyplot as plt plt.rcParams['font.family'] = 'Arial' tej_key = 'your key' tejapi.ApiConfig.api_key = tej_key os.environ['TEJAPI_BASE'] = "https://api.tej.com.tw" os.environ['TEJAPI_KEY'] = tej_key from zipline.sources.TEJ_Api_Data import get_universe import TejToolAPI from zipline.data.run_ingest import simple_ingest from zipline.api import set_slippage, set_commission, set_benchmark, symbol, record, order_target_percent from zipline.finance import commission, slippage from zipline import run_algorithm start_date = '2010-01-01'; end_date = '2025-05-27' pool = get_universe(start = start_date, end = end_date, mkt_bd_e = ['TSE', 'OTC'], stktp_e = ['Common Stock-Foreign','Common Stock']) columns = ['coid', 'Industry', 'roi', 'mktcap', 'r405', 'r403', 'per', 'r105', 'fld005'] start_dt = pd.Timestamp(start_date, tz = 'UTC') end_dt = pd.Timestamp(end_date, tz = "UTC") data_use = TejToolAPI.get_history_data(start = start_dt, end = end_dt, ticker = pool + ['IR0001'], fin_type = ['Q', 'TTM'], columns = columns, transfer_to_chinese = False) data_use = data_use.sort_values(['mdate', 'coid']) data_use['avg_mkt'] = data_use.groupby('mdate')['Market_Cap_Dollars'].transform('mean') data_use['avg_ds_ratio'] = data_use.groupby('mdate')['Director_and_Supervisor_Holdings_Percentage'].transform('mean') data_use['ind_gross_margin_mean'] = data_use.groupby(['mdate', 'Industry'])['Gross_Margin_Rate_percent_Q'].transform('mean') data_use['PEG'] = data_use['PER_TWSE'] / data_use['Net_Income_Growth_Rate_TTM'] def compute_stock(date, data): df = data[data['mdate'] == pd.to_datetime(date)].reset_index(drop=True) set_1 = set(df[df['Market_Cap_Dollars'] <= df['avg_mkt'] * 0.3]['coid']) set_2 = set(df[df['Net_Income_Growth_Rate_Q'] >= 15]['coid']) set_3 = set(df[df['Gross_Margin_Rate_percent_TTM'] >= df['ind_gross_margin_mean']]['coid']) set_4 = set(df[df['Director_and_Supervisor_Holdings_Percentage'] > df['avg_ds_ratio']]['coid']) set_5 = set(df[df['PEG'] < 1.0]['coid']) passed = set_1 & set_2 & set_3 & set_4 & set_5 top_n = int(len(passed) * 0.2) # 篩選出通過條件的股票 filtered_df = df[df['coid'].isin(passed)] # 排序並取前 top_n 名(例如 PEG 最小) top_df = filtered_df.sort_values(by='PEG').head(top_n) tickers = list(top_df['coid']) sets = [len(set_1), len(set_2), len(set_3), len(set_4), len(set_5)] return tickers, sets
回測架構程式碼展示
pools = pool + ['IR0001', 'IX0043'] start_ingest = start_date.replace('-', '') end_ingest = end_date.replace('-', '') print(f'開始匯入回測資料') simple_ingest(name = 'tquant' , tickers = pools , start_date = start_ingest , end_date = end_ingest) print(f'結束匯入回測資料') back_start = '2015-01-01' codes = ['IR0001', 'IR0043'] co = ['coid','Industry', 'mkt', 'vol', 'open_d', 'high_d', 'low_d', 'close_d', 'roi', 'shares', 'per', 'pbr_tej','mktcap'] data_index = TejToolAPI.get_history_data(start = start_dt, end = end_dt, ticker = codes, columns = co, transfer_to_chinese = False) # 篩選時間 data_index = data_index[data_index['mdate'] >= back_start] # 分別取出 TSE 與 OTC 並標準化 tse = data_index[data_index['coid'] == 'IR0001'][['mdate', 'Close']].copy() otc = data_index[data_index['coid'] == 'IR0043'][['mdate', 'Close']].copy() tse.rename(columns={'Close': 'TSE_Close'}, inplace=True) otc.rename(columns={'Close': 'OTC_Close'}, inplace=True) # 合併(on mdate) merged = pd.merge(tse, otc, on='mdate', how='inner') # 標準化:以首日為基準 merged['TSE_norm'] = merged['TSE_Close'] / merged['TSE_Close'].iloc[0] * 100 merged['OTC_norm'] = merged['OTC_Close'] / merged['OTC_Close'].iloc[0] * 100 # 計算風險偏好比(OTC / TSE) merged['OTC_TSE_ratio'] = merged['OTC_norm'] / merged['TSE_norm'] # 畫圖 fig, axes = plt.subplots(2, 1, figsize=(12, 8), sharex=True) axes[0].plot(merged['mdate'], merged['TSE_norm'], label='TSE') axes[0].plot(merged['mdate'], merged['OTC_norm'], label='OTC') axes[0].set_title('Normalized Index Performance (Base = 100)') axes[0].legend() axes[0].grid(True) axes[1].plot(merged['mdate'], merged['OTC_TSE_ratio'], label='OTC / TSE') axes[1].set_title('Risk Appetite Ratio (OTC / TSE)') axes[1].axhline(1.0, color='gray', linestyle='--', linewidth=1) axes[1].legend() axes[1].grid(True) plt.tight_layout() plt.show()
此策略主要面向小型成長股,因此另外抓取櫃買指數 IR0043作為後續分析用的資料,此處展示加權股價指數以及櫃買指數的調整後(Base = 100)比較,以及其比值關係的視覺畫圖像。
def initialize(context, re = 20): set_slippage(slippage.VolumeShareSlippage(volume_limit=1, price_impact=0.01)) set_commission(commission.Custom_TW_Commission()) set_benchmark(symbol('IR0001')) context.i = 0 context.state = False context.order_tickers = [] context.last_tickers = [] context.rebalance = re context.set1 = 0 context.set2 = 0 context.set3 = 0 context.set4 = 0 context.set5 = 0 context.set = 0 context.dic = {} def handle_data_1(context, data): # 避免前視偏誤,在篩選股票下一交易日下單 if context.state == True: for i in context.last_tickers: if i not in context.order_tickers: order_target_percent(symbol(i), 0) for i in context.order_tickers: order_target_percent(symbol(i), 1.0 / len(context.order_tickers)) context.dic[i] = data.current(symbol(i), 'price') record(p = context.dic) context.dic = {} print(f"下單日期:{data.current_dt.date()}, 擇股股票數量:{len(context.order_tickers)}, Leverage: {context.account.leverage}") context.last_tickers = context.order_tickers.copy() context.state = False backtest_date = data.current_dt.date() if context.i % context.rebalance == 0: context.state = True context.order_tickers = compute_stock(date = backtest_date, data = data_use)[0] context.set = compute_stock(date = backtest_date, data = data_use)[1] record(tickers = context.order_tickers) record(Leverage = context.account.leverage) if context.account.leverage > 1.2: print(f'{data.current_dt.date()}: Over Leverage, Leverage: {context.account.leverage}') for i in context.order_tickers: order_target_percent(symbol(i), 1 / len(context.order_tickers)) context.i += 1 def analyze(context, perf): plt.style.use('ggplot') # 第一張圖:策略績效與報酬 fig1, axes1 = plt.subplots(nrows=3, ncols=1, figsize=(18, 15), sharex=False) axes1[0].plot(perf.index, perf['algorithm_period_return'], label='Strategy') axes1[0].plot(merged['mdate'], (merged['TSE_norm'] / merged['TSE_norm'].iloc[0])-1, label='Benchmark [TSE]') axes1[0].plot(merged['mdate'], (merged['OTC_norm'] / merged['OTC_norm'].iloc[0])-1, label='Benchmark [OTC]') axes1[0].set_title("Backtest Results") axes1[0].legend() axes1[1].bar(perf.index, perf['algorithm_period_return'] - perf['benchmark_period_return'], label='Excess return', color='#988ED5', alpha = 1.0) axes1[1].set_title('Excess Return with TSE Index') axes1[1].legend() axes1[2].plot(merged['mdate'], merged['OTC_TSE_ratio'], label='OTC / TSE') axes1[2].set_title('Risk Appetite Ratio (OTC / TSE)') axes1[2].axhline(1.0, color='gray', linestyle='--', linewidth=1) axes1[2].legend() axes1[2].grid(True) plt.tight_layout() plt.show() results = run_algorithm( start = pd.Timestamp(back_start, tz = 'utc'), end = pd.Timestamp(end_date, tz = 'utc'), initialize = initialize, handle_data = handle_data_1, analyze = analyze, bundle = 'tquant', capital_base = 1e5)
策略績效分析 & 圖表
在上圖中的紅色區塊,我們可以看到第二張子圖的超額報酬正處於由負轉正並且贏過大盤(加權股價指數),在同時期段應到 Rolling Alpha 的子圖,alpha 數值也是由負翻正並且維持在正數的區間持續大約一年。這段期間正好是熊牛市交錯時間點,市場整處於震盪情勢,而此策略能夠在這種市場情勢中穩定賺取報酬,顯示出策略有良好的選股能力,能夠無視市場的行情投資於有淺力的股票。我們可以在下一張圖去驗證這個猜想。
上圖中顯示出了「策略累積報酬率對應OTC、TSE指數」、「Rolling BETA」、「OTC/TSE數比值---代表市場資金的風險偏好」以及「景氣信號燈分數 — 用以分辨市場處於牛市還是熊市」。在藍色區塊中,我們可以看出整段期間正處于熊市逐漸往牛市的期間,此時大盤也出現明顯的回檔,但是此時策略的報酬率已經與大盤脫鉤不斷上漲。同時期對應第二張子圖的 BETA數值確實不論是對應 OTC 還是 TSE的相關性都在0.5左右震盪,顯示出此策略的報酬率不仰賴市場報酬,而是來自於則股能力的超額報酬(alpha),這加深了之前分析的合理性。
然而在近期2025年之後的情勢,OTC/TSE比值直線往下,顯示出市場資金對於大型公司股票的青睞。另一個觀點可以認為是 2025年的市場不確定性上升造成市場投資人對於風險的偏好趨於保守,造成小型股的表現在短期間難以展現。因此我認為此策略的優勢期間需要等待市場不確定性降低,再來進行相關成長股策略的運行。
從深水圖來看,策略最大回撤約為 -30%,顯示其波動性偏高,尤其在 2025 年初市場資金轉向大型股時,策略表現受壓。由於本策略聚焦小型成長股,在市場風險偏好轉弱時較難維持領先,回撤自然擴大。雖然震盪幅度大,但在風險偏好提升的階段(2022-2023年底),策略具備明顯超額報酬潛力,同時回撤情況很小。建議搭配簡單的市場因子,如 OTC/TSE 比值,作為進出場或降曝險的輔助依據,以提升整體穩定性。
績效表格
回測指標 羅伯加迪納投資法 加權股價指數(大盤) 櫃買指數(OTC) 累積報酬率 249.33% 234.56% 121.744% 年化報酬率 13.257% 12.77% 8.25% 年化波動度 16.61% 16.67% 18.70% 夏普值 0.83 0.81 0.52 卡瑪比率 0.45 0.45 0.25 最大回撤 -29.67% -28.97% -33.137% 註:卡瑪比率計算方式為年化波動度除以期間最大回撤,用以衡量「報酬率對虧損」的比值,概念類似於風暴比,此指標的數值越高越好。
完整程式碼連結
歡迎投資朋友參考,之後也會持續介紹使用 TEJ 資料庫來建構各式指標,並回測指標績效,所以歡迎對各種交易回測有興趣的讀者,選購 TQuant Lab 的相關方案,用高品質的資料庫,建構出適合自己的交易策略。
溫馨提醒,本次分析僅供參考,不代表任何商品或投資上的建議。
【TQuant Lab 回測系統】解決你的量化金融痛點
全方位提供交易回測所需工具
留言 0