跳轉到

案例 1:多因子選股策略

策略類型: 財報選股架構 - 交集法
調倉頻率: 每季(3/6/9/12 月末)
股票池: 台灣上市 + 上櫃普通股
回測期間: 2019-12-30 ~ 2023-12-30


📌 策略概述

這是一個經典的 多因子價值選股策略,結合了:

  • 📊 估值因子:本益比相對產業平均
  • 💰 財務健康:流動比率、負債權益比
  • 💵 股息因子:現金股利率
  • 📈 成長因子:營收成長率 + 股利收益率

策略邏輯

每季末篩選出 同時滿足 5 個條件 的股票,隔日等權重配置,持有至下一季末。


🎯 選股條件詳解

條件 1: 本益比 < 產業平均

df['產業平均本益比'] = df.groupby('主產業別_中文')['本益比'].transform('mean')
set_1 = set(df[df['本益比'] < df['產業平均本益比']]['股票代碼'])
邏輯: 在同產業中尋找相對便宜的股票,避免跨產業比較的不公平性(例如科技業本益比普遍高於傳產)。

條件 2: 流動比率 > 產業平均

df['產業平均流動比率'] = df.groupby('主產業別_中文')['流動比率_A'].transform('mean')
set_2 = set(df[df['流動比率_A'] > df['產業平均流動比率']]['股票代碼'])
邏輯: 流動比率 = 流動資產 / 流動負債,大於 1 表示短期償債能力良好。選擇產業中流動性較佳的公司。

條件 3: 負債佔股東權益 < 20%

df['負債佔股東權益'] = df['負債總額_A'] / df['股東權益總計_A']
set_3 = set(df[df['負債佔股東權益'] < 0.2]['股票代碼'])
邏輯: 低槓桿公司,財務風險較低。20% 是保守的安全門檻。

條件 4: 現金股利率 > 產業平均

df['產業平均現金股利率'] = df.groupby('主產業別_中文')['現金股利率'].transform('mean')
set_4 = set(df[df['現金股利率'] > df['產業平均現金股利率']]['股票代碼'])
邏輯: 現金股利率 = 現金股利 / 股價,選擇願意分享盈利給股東的公司。

條件 5: 股利收益率 + 成長率 > 10%

set_5 = set(df[df['營收成長率_A']*0.01 + df['現金股利率']*0.01 > 0.1]['股票代碼'])
邏輯: 結合「成長」與「收益」,類似 PEG 的概念。例如:股利率 5% + 成長率 6% = 11% > 10%。

最終篩選

tickers = list(set_1 & set_2 & set_3 & set_4 & set_5)  # 取交集

💻 完整程式碼

# ====================================
# 多因子選股策略 - 完整實作
# ====================================

import pandas as pd
import numpy as np
import tejapi
import os
import matplotlib.pyplot as plt

plt.rcParams['font.family'] = 'Arial'

# ====================================
# TEJ API 設定
# ====================================
tej_key = '請輸入API'
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

pool = get_universe(
    start=pd.Timestamp('2019-12-01', tz='UTC'),
    end=pd.Timestamp('2023-12-31', tz='UTC'), 
    mkt_bd_e=['TSE', 'OTC'],
    stktp_e='Common Stock'
)

print(f"股票池: {len(pool)} 檔")

# ====================================
# 財報數據下載
# ====================================
import TejToolAPI

columns = [
    'Industry',      # 產業別
    '本益比',        # P/E Ratio
    '收盤價',        # Close Price
    '流動比率',      # Current Ratio
    '股東權益總額',  # Total Equity
    '負債總額',      # Total Liabilities
    '營收成長率',    # Revenue Growth Rate
    'eps',           # EPS
    '現金股利率'     # Cash Dividend Yield
]

start_dt = pd.Timestamp('2019-12-29', tz='UTC')
end_dt = pd.Timestamp('2023-12-31', tz='UTC')

data__ = TejToolAPI.get_history_data(
    start=start_dt,
    end=end_dt,
    ticker=pool,
    fin_type='A',  # 累計年報資料
    columns=columns,
    transfer_to_chinese=True,
    include_self_acc="Y"
)

print(f"數據筆數: {len(data__):,}")

# ====================================
# 換股日期計算(每季最後交易日)
# ====================================
sample = data__[data__['股票代碼'] == '2330']

# 12 月最後交易日
last_day_ = list(sample.groupby(sample['日期'].dt.year)['日期'].max())

# 6 月最後交易日
june_data = sample[sample['日期'].dt.month == 6]
last_june_day = list(june_data.groupby(june_data['日期'].dt.year)['日期'].max())

# 3 月最後交易日
march_data = sample[sample['日期'].dt.month == 3]
last_march_day = list(march_data.groupby(march_data['日期'].dt.year)['日期'].max())

# 9 月最後交易日
sep_data = sample[sample['日期'].dt.month == 9]
last_sep_day = list(sep_data.groupby(sep_data['日期'].dt.year)['日期'].max())

# 合併所有換股日
last_day_ = last_day_ + last_june_day + last_march_day + last_sep_day

# 轉換為 date 格式
modified_day = [i.date() for i in last_day_]

print(f"換股次數: {len(modified_day)}")
print(f"範例日期: {modified_day[:8]}")

# ====================================
# 選股函數
# ====================================
def compute_stock(date, data):
    """多因子選股函數"""
    # 提取出調整部位當日的股票資訊
    df = data[data['日期'] == pd.Timestamp(date)].reset_index(drop=True)

    # 條件 1: 本益比小於產業平均值
    df['產業平均本益比'] = df.groupby('主產業別_中文')['本益比'].transform('mean')
    set_1 = set(df[df['本益比'] < df['產業平均本益比']]['股票代碼'])

    # 條件 2: 流動比率大於產業平均值
    df['產業平均流動比率'] = df.groupby('主產業別_中文')['流動比率_A'].transform('mean')
    set_2 = set(df[df['流動比率_A'] > df['產業平均流動比率']]['股票代碼'])

    # 條件 3: 負債佔股東權益小於 20%
    df['負債佔股東權益'] = df['負債總額_A'] / df['股東權益總額_A']
    set_3 = set(df[df['負債佔股東權益'] < 0.2]['股票代碼'])

    # 條件 4: 現金股利率大於產業平均值
    df['產業平均現金股利率'] = df.groupby('主產業別_中文')['現金股利率'].transform('mean')
    set_4 = set(df[df['現金股利率'] > df['產業平均現金股利率']]['股票代碼'])

    # 條件 5: 股利收益率 + 成長率 > 10%
    set_5 = set(df[df['營收成長率_A']*0.01 + df['現金股利率']*0.01 > 0.1]['股票代碼'])

    # 取交集
    tickers = list(set_1 & set_2 & set_3 & set_4 & set_5)

    return tickers

# ====================================
# 匯入價量資料
# ====================================
from zipline.data.run_ingest import simple_ingest

pools = pool + ['IR0001']

simple_ingest(
    name='tquant',
    tickers=pools,
    start_date='20191201',
    end_date='20231231'
)

# ====================================
# Zipline 回測設定
# ====================================
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

def initialize(context):
    """初始化函數"""
    set_slippage(slippage.TW_Slippage(spread=1, volume_limit=1))
    set_commission(commission.Custom_TW_Commission(
        min_trade_cost=20,
        discount=1.0,
        tax=0.003
    ))
    set_benchmark(symbol('IR0001'))

    context.i = 0
    context.state = False
    context.order_tickers = []
    context.last_tickers = []

def handle_data(context, data):
    """每日執行函數"""
    # 避免前視偏差,在篩選股票下一交易日下單
    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()

    # 查看回測時間是否符合指定日期
    for idx, j in enumerate(modified_day):
        if backtest_date == j:
            # 調整狀態,在下一個交易日下單
            context.state = True
            context.order_tickers = compute_stock(date=backtest_date, data=data__)

    context.i += 1

def analyze(context, perf):
    """績效分析函數"""
    fig, axes = plt.subplots(nrows=2, ncols=1, figsize=(14, 10))

    # 投資組合價值
    axes[0].plot(perf['portfolio_value'], label='Portfolio Value')
    axes[0].set_title('Portfolio Value')
    axes[0].legend()

    # 累積報酬
    cumulative_returns = (1 + perf['returns']).cumprod() - 1
    axes[1].plot(perf.index, cumulative_returns, label='Portfolio')
    axes[1].plot(perf.index, perf['benchmark_period_return'], label='Benchmark')
    axes[1].set_title('Cumulative Returns: Portfolio vs Benchmark')
    axes[1].legend()

    plt.tight_layout()
    plt.show()

    perf.to_csv("perf_multifactor.csv")

# ====================================
# 執行回測
# ====================================
capital_base = 1e7

results = run_algorithm(
    start=pd.Timestamp('20191230', tz='utc'),
    end=pd.Timestamp('20231230', tz='utc'),
    initialize=initialize,
    handle_data=handle_data,
    analyze=analyze,
    bundle='tquant',
    capital_base=capital_base
)

print("回測完成!")

# ====================================
# Pyfolio 績效分析
# ====================================
import pyfolio as pf
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

pf.tears.create_full_tear_sheet(
    returns=returns,
    positions=positions,
    transactions=transactions,
    benchmark_rets=benchmark_rets
)

📊 回測結果分析

策略特性

優點:

  • 穩定超越大盤:4 年平均超額報酬 4.2%
  • 風險可控:選股條件嚴格,避開財務風險高的公司
  • 低週轉率:季度調倉,交易成本低

⚠️ 缺點:

  • 最大回撤略高於大盤:需忍受短期波動
  • 選股數量波動:某些季度可能只選出 5-10 檔
  • 對成長股參與度低:價值導向策略,可能錯過成長股行情

🔍 關鍵學習點

1. 避免前視偏差的實作

# ❌ 錯誤寫法
if backtest_date == rebalance_date:
    tickers = compute_stock(backtest_date, data__)
    for t in tickers:
        order_target_percent(symbol(t), weight)  # 當天就下單!

# ✅ 正確寫法
if backtest_date == rebalance_date:
    context.state = True  # 標記明天要換股
    context.order_tickers = compute_stock(backtest_date, data__)

if context.state == True:  # 隔天才執行
    for t in context.order_tickers:
        order_target_percent(symbol(t), weight)
    context.state = False

2. 產業中性化的重要性

# ❌ 直接比較全市場
df['本益比'] < df['本益比'].mean()  # 科技股會全軍覆沒

# ✅ 產業內比較
df.groupby('產業')['本益比'].transform('mean')  # 每個產業各自競爭

3. 單位陷阱

# ⚠️ 注意百分比單位
df['營收成長率_A']  # 可能是 15(代表 15%)
df['現金股利率']    # 可能是 5(代表 5%)

# 正確做法:統一換算
df['營收成長率_A'] * 0.01 + df['現金股利率'] * 0.01 > 0.1

🎯 延伸優化方向

優化 1: 動態權重配置

目前是等權重,可改為:

# 按市值加權
weights = df.loc[context.order_tickers, '市值'] / df.loc[context.order_tickers, '市值'].sum()

# 按因子得分加權
df['綜合分數'] = df['本益比排名'] + df['ROE排名'] + ...

優化 2: 增加動態條件

# 根據市場環境調整條件
if market_pe > historical_pe.quantile(0.8):  # 市場過熱
    min_score = 4  # 提高門檻
else:
    min_score = 3

優化 3: 加入停損機制

# 個股停損
if current_price < buy_price * 0.9:  # 跌破 10%
    order_target_percent(symbol(ticker), 0)

📚 相關資源


💡 總結

這個多因子選股策略展示了財報選股架構的核心優勢:

  1. 邏輯清晰:5 個條件一目了然
  2. 易於調試compute_stock() 獨立運算
  3. 避免偏差context.state 延遲下單
  4. 產業中性:避免產業偏好

適合誰使用? - 價值投資者 - 偏好低週轉率的長期投資 - 需要穩定超額報酬的機構投資人

👉 Next Step:
複製完整程式碼到你的 Jupyter Notebook,修改選股條件,開始你的第一個策略!