Table of Contents
在投資領域中,「景氣循環」向來是一個重要的參考依據。無論是總體經濟的波動、企業獲利的起伏,還是查爾士‧布蘭帝 是班傑明‧葛拉漢 的得意門生,自1974年創立Brandes Investment Partners 以來,將管理資產從 1.3 億美元成長至逾 750 億美元。旗下 Brandes Global Equity Fund 二十年年化報酬率達 17.91%,顯著領先 MSCI World Index,並獲晨星五顆星與多項國際大獎肯定。其另一代表作 AGF International Value Fund 同樣展現卓越長線績效,布蘭帝本人也曾蟬聯全球頂尖基金經理人之首。
布蘭帝堅守價值投資本質,反對以未來預測資料作為選股依據,強調以「安全邊際」與公司真實價值為核心,並以中長期持有為原則。為忠實還原其投資思想,本研究將其理念量化,採用公司淨值替代傳統折現模型,並搭建回測架構於歷史行情中檢驗策略表現。本篇文章將分別介紹資料處理流程、回測設計與績效分析,期望呈現布蘭帝價值投資在量化框架下的可行性與穩健性。
安全邊際(Margin of Safety):指的是當資產的內在價值高於市場價格時,所存在的保護空間。這個概念強調,即便未來經營或市場環境出現不利變化,因為買入時價格足夠便宜,仍能降低損失風險。
本研究以台灣證券交易所與櫃檯買賣中心掛牌之所有上市櫃公司為投資標的,蒐集自2013年起十年期間的股價、財務報表與董監事持股等基礎資料,並完成資料清洗與整合。由於策略邏輯需引用最近五年之財務資訊,實際回測期間訂為2020年1月1日至 2025年4月21日,僅對符合條件之樣本進行歷史績效模擬,以確保數據完整性與策略檢驗的嚴謹性。
本策略旨在篩選出財務結構穩健、管理階層持股充足,且市場評價偏低、具備安全邊際的股票,具體條件與說明如下:
實務操作上是通過上述的條件進行股票的篩選,篩選出來的股票以等權重的方式進行買入並且持有至下一次的再平衡日。再平衡天數的設計,由於考慮到該策略為價值投資策略,會需要較長的時間來讓股價反應公司的真實價值,因此設定每60天進行換股。
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 ='SZf1BjNEcKQhvQmn96eLrNL60Q2RH1'
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-04-21'
pool = get_universe(start = start_date,
end = end_date,
mkt_bd_e = ['TSE', 'OTC'],
stktp_e = 'Common Stock',
main_ind_e = 'General Industry')
columns = ['coid','bstl', 'bsse', 'fld005', 'close_d', 'per', 'pbr_tej', 'shares', 'cscfo', 'cscfi', 'cscff']
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,
fin_type = 'Q', # 為累計資料
columns = columns,
transfer_to_chinese = False)
# 確保時間格式正確
data_use['mdate'] = pd.to_datetime(data_use['mdate'])
# 計算 Total_cashflow
data_use['Total_cashflow'] = data_use['Cash_Flow_from_Operating_Activities_Q']
# 排序
data_use = data_use.sort_values(['coid', 'mdate'])
# 轉成季資料:每股公司取每季最後一筆
df_q = data_use.set_index('mdate').groupby('coid', group_keys=False).resample('Q').last().reset_index()
# 計算近四季平均本益比
df_q['PER_4Q_avg'] = df_q.groupby('coid')['PER_TWSE'].transform(lambda x: x.rolling(4, min_periods=4).mean())
# 計算近四季總現金流
df_q['Cashflow_4Q_sum'] = df_q.groupby('coid')['Total_cashflow'].transform(lambda x: x.rolling(4, min_periods=4).sum())
# 計算「股價 / 現金流」:若現金流為 0,則設為 NaN 避免除以 0
df_q['Price_to_CF'] = (df_q['Close'] * df_q['Issue_Shares_1000_Shares']) / df_q['Cashflow_4Q_sum'].replace(0, np.nan)
# 市場平均本益比 & 市場平均 Price_to_CF
df_q['Market_PER_avg'] = df_q.groupby('mdate')['PER_TWSE'].transform('mean')
df_q['Market_PCF_avg'] = df_q.groupby('mdate')['Price_to_CF'].transform('mean')
# 判斷是否低於市場平均
df_q['PER_below_market'] = df_q['PER_4Q_avg'] < df_q['Market_PER_avg']
df_q['PCF_below_market'] = df_q['Price_to_CF'] < df_q['Market_PCF_avg']
# 確保排序
data_use = data_use.sort_values(['coid', 'mdate'])
df_q = df_q.sort_values(['coid', 'mdate'])
# 用 merge_asof 把季資料合併回每日
result_list = []
for coid, df_daily_group in data_use.groupby('coid'):
df_q_group = df_q[df_q['coid'] == coid]
merged = pd.merge_asof(
df_daily_group,
df_q_group[['mdate', 'PER_below_market', 'PCF_below_market']],
on='mdate',
direction='backward'
)
result_list.append(merged)
# 合併所有公司
data_final = pd.concat(result_list).sort_values(['coid', 'mdate']).reset_index(drop=True)
def compute_stock(date, data):
df = data[data['mdate'] == pd.to_datetime(date)].reset_index(drop = True)
df['debt_equity_ratio'] = df['Total_Liabilities_Q'] / df['Total_Equity_Q']
set_1 = set(df[df['debt_equity_ratio'] < .4]['coid'])
Director_avg = df['Director_and_Supervisor_Holdings_Percentage'].mean()
set_2 = set(df[df['Director_and_Supervisor_Holdings_Percentage'] > Director_avg]['coid'])
set_3 = set(df[df['PER_below_market'] == True]['coid'])
set_4 = set(df[df['PCF_below_market'] == True]['coid'])
PBR_avg = df['PBR_TEJ'].mean()
set_5 = set(df[df['PBR_TEJ'] < PBR_avg]['coid'])
set_6 = set(df[df['PBR_TEJ'] < 1.0]['coid'])
tickers = list(set_1 & set_2 & set_5 & set_6 & set_3 & set_4)
return tickers
def initialize(context):
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 = []
def handle_data_1(context, data, rebalance = 60):
# 避免前視偏誤,在篩選股票下一交易日下單
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 / len(context.order_tickers))
curr = data.current(symbol(i), 'price')
record(price = curr, days = context.i)
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 % rebalance == 0:
context.state = True
context.order_tickers = compute_stock(date = backtest_date, data = data_final)
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):
fig, axes = plt.subplots(nrows=2, ncols=1, figsize=(18, 10), sharex=False)
plt.style.use('ggplot')
axes[0].plot(perf.index, perf['algorithm_period_return'], label = 'Strategy')
axes[0].plot(perf.index, perf['benchmark_period_return'], label = 'Benchmark')
axes[0].set_title(f"Backtest_Results")
axes[0].legend()
axes[1].plot(perf.index, perf['Leverage'], label = 'Leverage')
axes[1].set_title(f"Leverage")
axes[1].legend
plt.tight_layout()
plt.show()
results = run_algorithm(
start = pd.Timestamp('2020-01-01', tz = 'utc'),
end = pd.Timestamp('2025-04-21', tz = 'utc'),
initialize = initialize,
handle_data = handle_data_1,
analyze = analyze,
bundle = 'tquant',
capital_base = 1e6)
# ==================== 回測輸出結果 =====================
def analyze(context, perf):
fig, axes = plt.subplots(nrows=2, ncols=1, figsize=(18, 10), sharex=False)
plt.style.use('ggplot')
axes[0].plot(perf.index, perf['algorithm_period_return'], label = 'Strategy')
axes[0].plot(perf.index, perf['benchmark_period_return'], label = 'Benchmark')
axes[0].set_title(f"Backtest_Results")
axes[0].legend()
axes[1].plot(perf.index, perf['Leverage'], label = 'Leverage')
axes[1].set_title(f"Leverage")
axes[1].legend
plt.tight_layout()
plt.show()
results = run_algorithm(
start = pd.Timestamp('2020-01-01', tz = 'utc'),
end = pd.Timestamp('2025-04-21', tz = 'utc'),
initialize = initialize,
handle_data = handle_data_1,
analyze = analyze,
bundle = 'tquant',
capital_base = 1e6)
策略年化報酬率達 23.75%,同期大盤年化僅 15%,明顯超越指數。策略夏普比率為 1.15,代表在扣除無風險報酬後,每承擔一單位風險可獲取 1.15 單位超額報酬。CAPM 回歸結果顯示,alpha = 0.15(年化 15%),說明穩定產生超額收益;beta = 0.61,意味著組合對大盤波動的敏感度僅一半左右,具備良好分散效益。
觀察累積報酬曲線,策略在 2020年底至 2021年第一季一度拉開與大盤的差距,主因當時高品質價值股(如航運、傳產)估值修復;而 2023年初至 2024年初整體牛市階段,價值投資標的亦隨大市上行而受益,使策略持續領先指數,進一步驗證在牛市中以安全邊際為核心的選股方法同樣具備顯著優勢。
策略歷經 COVID 初期、高估值修正與升息壓力等多次市場動盪,五大回撤峰值介於約 −10% 至 −30% 之間,多數回撤能在 3 – 6 個月內收斂;但是在 2024 年至2025 年出現一段較大的回撤時段,當時正處於市場牛市的末升段,對於價值投資來說會是機會比較少的時期,同時也是適合出脫部分股票,換手現金或短天期債券的時期。
歡迎投資朋友參考,之後也會持續介紹使用 TEJ 資料庫來建構各式指標,並回測指標績效,所以歡迎對各種交易回測有興趣的讀者,選購 TQuant Lab 的相關方案,用高品質的資料庫,建構出適合自己的交易策略。
溫馨提醒,本次分析僅供參考,不代表任何商品或投資上的建議。
機器學習算法 XGBoost 提升技術指標一目均衡表的投資績效
電子報訂閱