彼得‧林區選股哲學實踐:成長與價值兼具的量化策略
前言
彼得‧林區(Peter Lynch)是美國基金界傳奇人物,曾於1977至1990年間擔任富達麥哲倫基金(Fidelity Magellan Fund)經理人,期間資產規模從2,000萬美元成長至140億美元,年化報酬率高達29.2%,堪稱史上最成功的基金經理之一。他不僅以出色的績效聞名,更以深入淺出的投資哲學啟發無數散戶與專業投資人。
林區倡導「投資你所熟悉的企業」(Invest in what you know),認為個人投資者擁有觀察生活的優勢,能在企業基本面反映於股價前率先發現潛力股。他同時強調選股應建立在企業獲利能力、成長性與財務穩健之上,並透過本益比(P/E)、盈餘成長率(EPS Growth)等指標發掘「被低估的成長股」。
投資標的 & 回測期間
本研究以台灣證券交易所與櫃檯買賣中心掛牌之所有上市櫃公司為投資標的,蒐集2017年起的股價、財務報表與董監事持股等基礎資料,並完成資料清洗與整合。由於策略邏輯需引用最近一年之財務資訊,實際回測期間訂為2018年1月1日至2025年4月29日,僅對符合條件之樣本進行歷史績效模擬,以確保數據完整性與策略檢驗的嚴謹性。
策略邏輯
彼得.林區的選股邏輯核心在於尋找成長與價值兼具的企業,而非依賴市場時機或總體經濟預測。其投資哲學強調深入了解公司本身、強調基礎面調查(kick the tire),並透過量化財務指標輔助篩選出具吸引力的投資標的。為將其理念具體化,本策略將其核心財務標準轉化為以下五項量化條件進行選股:
最近一季負債比率 ≤ 25%企業的財務風險往往來自過高的負債比例。彼得.林區強調挑選財務體質良好的公司,以減少在景氣反轉或利率上升時的償債壓力。設定負債比率(總負債 / 總資產)不超過 25%,能有效過濾掉高槓桿經營的企業,確保投資標的具有良好的償債能力與長期生存空間。
每股淨現金 > 產業平均值企業帳上的現金水位反映其短期流動性與彈性。林區偏好手上現金充裕、對長期負債具備反制能力的公司。本策略以「每股淨現金」作為衡量基準,並要求高於同產業平均水平,以突顯其在資本結構與經營靈活度上的相對優勢。
每股自由現金流本益比(P/FCF) < 產業平均值林區關注企業實際產出現金的能力,遠勝於帳面盈餘。本策略以股價與每股自由現金流(營業現金流減資本支出)的比值,作為評估企業估值的依據。若其低於同產業平均,代表該公司在現金創造力良好的前提下,仍被市場低估,屬具備上漲潛力的價值股。
存貨成長率 < 營收成長率企業營運效率亦是林區選股的重要考量。若存貨累積速度高於營收成長,可能顯示產品滯銷或需求減弱。相反地,當營收成長快於存貨,則意味著企業產銷協調良好、存貨管理得當。此一條件有助篩選出營運體質健全、不易出現存貨壓力的公司。
(1年平均盈餘成長率 + 現金股息率) / 本益比 ≧ 2
本策略中以「1年平均稅後淨利成長率 + 現金股利率」除以本益比,要求結果不低於 2。這樣的標準意味著,只有當企業具備高度成長性與穩定配息,且估值合理的情況下,才會被納入投資名單。
實務操作上是通過上述的條件進行股票的篩選,篩選出來的股票以等權重的方式進行買入並且持有至下一次的再平衡日。再平衡天數的設計,此策略設定每30天進行換股。
股票篩選程式碼展示
導入套件
import pandas as pd import numpy as np import tejapi import os import datetime start='2017-01-01' end='2025-04-29' os.environ['TEJAPI_KEY'] = 'Your Key' 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('get_universe')
匯入股票池(所有曾經上下市櫃的普通股)
from zipline.sources.TEJ_Api_Data import get_universe # 由文字型態轉為Timestamp,供回測使用 tz = 'UTC' start_dt, end_dt = pd.Timestamp(start, tz = tz), pd.Timestamp(end, tz = tz) from zipline.sources.TEJ_Api_Data import get_universe pool = get_universe(start = start_dt, end = end_dt, mkt=['TWSE','OTC'], stktp_e=['Common Stock-Foreign', 'Common Stock'])
匯入股票財務資訊
import TejToolAPI columns = ['主產業別_中文', '報酬率', '收盤價', '本益比', '現金及約當現金', ' 非流動負債合計', '負債比率', '營收成長率', '現金股利率', '稅後淨利成長率', '存貨', '營運產生現金流量', '投資產生現金流量', '個股市值_元', '加權平均股數'] data__ = TejToolAPI.get_history_data(start = start_dt, end = end_dt, ticker = pool, fin_type = ['Q'], columns = columns, transfer_to_chinese = True)
處理資料
inv_change_df = data.copy() inv_change_df['存貨變動'] = inv_change_df.groupby('股票代碼')['存貨_Q'].diff().ne(0) inv_change_df = inv_change_df.loc[inv_change_df['存貨變動'], ['股票代碼', '日期', '存貨_Q']] inv_change_df['存貨成長率'] = inv_change_df.groupby('股票代碼')['存貨_Q'].pct_change() inv_change_df.loc[inv_change_df['存貨成長率'] == 0, '存貨成長率'] = np.nan idx = pd.MultiIndex.from_frame(data.loc[:, ['股票代碼', '日期']]) inv_growth_series = ( inv_change_df .set_index(['股票代碼', '日期'])['存貨成長率'] .reindex(idx) # 把變動日對齊到日資料中 .groupby(level=0) .ffill() # 把變動日的值填入之後的日期 ) data['存貨成長率'] = inv_growth_series.values data['個股市值(千)'] = data['個股市值_元'] / 1000 # 將個股市值的單位改為(千) data['每股淨現金'] = (data['現金及約當現金_Q'] - data['非流動負債合計_Q']) / data['加權平均股數_Q'] * 1000 data['每股自由現金流'] = (data['營運產生現金流量_Q'] - data['投資產生現金流量_Q']) / data['加權平均股數_Q'] * 1000 data['1年平均稅後淨利成長率'] = data.groupby('股票代碼')['稅後淨利成長率_Q'].rolling(252).mean().reset_index(level=0, drop=True) data['1年平均現金股利率'] = data.groupby('股票代碼')['現金股利率'].rolling(252).mean().reset_index(level=0, drop=True) data['sharpe_ratio'] = data.groupby('股票代碼')['報酬率'].rolling(252).mean().reset_index(0, drop=True) / data.groupby('股票代碼')['報酬率'].rolling(252).std().reset_index(0, drop=True)
匯入回測資料
from zipline.data.run_ingest import simple_ingest pools = pool + ['IR0001'] start_ingest = start.replace('-', '') end_ingest = end.replace('-', '') print(f'開始匯入回測資料') simple_ingest(name = 'tquant' , tickers = pools , start_date = start_ingest , end_date = end_ingest) print(f'結束匯入回測資料')
回測架構
from zipline.pipeline import Pipeline from zipline.pipeline.factors import Returns from zipline.pipeline.filters import SingleAsset from zipline.api import set_slippage, set_commission, set_benchmark, symbol, record, order_target_percent, pipeline_output, attach_pipeline from zipline.finance import commission, slippage def initialize(context): set_slippage(slippage.VolumeShareSlippage(volume_limit=1, price_impact=0.01))#調整volume_limit to 1 and price impact to 0.01 set_commission(commission.Custom_TW_Commission()) set_benchmark(symbol('IR0001')) # attach_pipeline(make_pipeline(), 'my_pipeline') context.i = 0 context.state = False context.order_tickers = [] context.last_tickers = [] def compute_stock(date, data): """ 根據指定的日期進行選股,篩選出符合條件的股票列表。 Parameters: date (str): 選股的日期,格式為 'YYYY-MM-DD'。 data (DataFrame): 包含股票數據的 DataFrame。 Returns: list: 符合條件的股票代碼列表。 """ # 提取指定日期的股票資訊 df = data[data['日期'] == pd.Timestamp(date)].reset_index(drop=True) # 條件 1:負債比例 < 25% set_1 = set(df[df['負債比率_Q'] <= df.groupby('主產業別_中文')['負債比率_Q'].transform('mean')]['股票代碼']) # 條件 2:每股淨現金 > 產業平均每股淨現金 df['產業平均每股淨現金'] = df.groupby('主產業別_中文')['每股淨現金'].transform('mean') set_2 = set(df[df['每股淨現金'] > df['產業平均每股淨現金']]['股票代碼']) # 條件 3: (股價/ 每股自由現金流量) < 產業平均(股價/ 每股自由現金流量) df['股價/每股自由現金流'] = df['收盤價'] / df['每股自由現金流'] df['產業平均股價/每股自由現金流'] = df.groupby('主產業別_中文')['股價/每股自由現金流'].transform('mean') set_3 = set(df[df['股價/每股自由現金流'] < df['產業平均股價/每股自由現金流']]['股票代碼']) # 條件 4: 存貨成長率 < 營收成長率 set_4 = set(df[df['存貨成長率'] < df['營收成長率_Q']]['股票代碼']) # 條件 5: 1年平均(稅後淨利成長率 + 現金股利率)/ 本益比 >= 2 df['條件五'] = (df['1年平均稅後淨利成長率'] + df['1年平均現金股利率']) / (df['本益比']) set_5 = set(df[ df['條件五'] >= 2]['股票代碼']) tickers = list(set_1 & set_2 & set_3 & set_4 & set_5) # 從 df 中找出符合交集條件的股票,並依本益比排序(由低到高) filtered_df = df[df['股票代碼'].isin(tickers)].copy() filtered_df = filtered_df[filtered_df['sharpe_ratio'].notna()] # 移除本益比為空值的資料 sorted_df = filtered_df.sort_values(by='sharpe_ratio', ascending=False) # 取前十檔股票代碼,若不足十檔則全部輸出 top_20 = sorted_df['股票代碼'].tolist()[:20] return top_20 rebalance_period = 21 # 調倉週期 def handle_data_1(context, data, rebalance = rebalance_period): # 避免前視偏誤,在篩選股票下一交易日下單 if context.state == True: print(f"下單日期:{data.current_dt.date()}, 擇股股票數量:{len(context.order_tickers)}") 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 / len(context.order_tickers)) curr = data.current(symbol(i), 'price') record(price = curr, days = context.i) context.last_tickers = context.order_tickers context.state = False backtest_date = data.current_dt.date() # 若今天是月底交易日,執行轉倉前的擇股邏輯 if context.i % rebalance == 0: context.state = True context.order_tickers = compute_stock(date = backtest_date, data = data_) # e.g., ['2330', '2317', …] print(f"月末擇股名單:{context.order_tickers}") context.i += 1 import matplotlib.pyplot as plt import matplotlib def analyze(context, perf): PASS return from zipline import run_algorithm from zipline.utils.calendar_utils import get_calendar calendar_name = 'TEJ' import logging # 關掉 Zipline 訊息 logging.getLogger('zipline').setLevel(logging.WARNING) logging.getLogger('zipline.finance').setLevel(logging.WARNING) logging.getLogger('exchange_calendars').setLevel(logging.WARNING) start = pd.Timestamp('2018-01-01', tz = 'UTC') end_dt = pd.Timestamp('2025-04-29', tz = "UTC") results = run_algorithm( start = start_, end = end_dt, initialize = initialize, handle_data = handle_data_1, analyze = analyze, bundle = 'tquant', capital_base = 1e6, trading_calendar=get_calendar(calendar_name), data_frequency='daily')
查看績效
from pyfolio.utils import extract_rets_pos_txn_from_zipline returns, positions, transactions = extract_rets_pos_txn_from_zipline(results) benchmark_rets = results.benchmark_return # 時區標準化 returns.index = returns.index.tz_localize(None).tz_localize('UTC') positions.index = positions.index.tz_localize(None).tz_localize('UTC') transactions.index = transactions.index.tz_localize(None).tz_localize('UTC') benchmark_rets.index = benchmark_rets.index.tz_localize(None).tz_localize('UTC') import pyfolio from pyfolio.utils import extract_rets_pos_txn_from_zipline import matplotlib.pyplot as plt import matplotlib matplotlib.rcParams['font.family'] = 'SimHei' matplotlib.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = False returns, positions, transactions = extract_rets_pos_txn_from_zipline(results) benchmark_rets = results.benchmark_return pyfolio.plot_gross_leverage(returns, positions) pyfolio.tears.create_full_tear_sheet(returns=returns, positions=positions, transactions=transactions, benchmark_rets=benchmark_rets )
績效圖表&分析
本策略於七年期間內累積報酬率達 176.71%,年化報酬率為 15.51%,整體表現略優於基準指數。從報酬表現來看,策略存在正向Alpha,但與大盤相關性也偏高。不過,策略的 年化波動率為 21.49%,顯示資產報酬具有一定波動風險,並在投資期間經歷了最大 -28.02% 的回落。
歡迎投資朋友參考,之後也會持續介紹使用 TEJ 資料庫來建構各式指標,並回測指標績效,所以歡迎對各種交易回測有興趣的讀者,選購 TQuant Lab 的相關方案,用高品質的資料庫,建構出適合自己的交易策略。
溫馨提醒,本次分析僅供參考,不代表任何商品或投資上的建議。
【TQuant Lab 回測系統】解決你的量化金融痛點
全方位提供交易回測所需工具
留言 0