跳至主要內容

回測視窗 2014-05-30 〜 2026-10-29 · 資料截至 2026-10-29

0056 進階版(高息+營收成長+均線,前 10 檔)

近四季 EPS 為正、近三年都有配息、營收成長、股價站上 120 與 240 日均線、波動偏低、殖利率在全市場前 40% 的股票,依近兩年平均殖利率、最近一次殖利率與營收年增排名取前 10 檔,每年 5 月 31 日與 12 月 27 日換股。2014 年至 2026 年台股回測年化報酬 25.9%、日夏普 1.80、最大回撤 -23.1%;同視窗 0050 含息年化 20.3%、日夏普 1.08、最大回撤 -34.0%。

投資警語:本文僅供教學參考,不構成投資建議。過去績效不代表未來表現,投資有風險。

回測績效

年化報酬率
25.9%
日夏普
1.80
最大回撤
-23.1%
總報酬率
1647%
平均持股
10.0 檔

回測含手續費 0.1425% 與賣出證交稅 0.3%(finlab 套件 sim() 預設成本),日夏普未扣無風險利率。策略淨值自資料可得日(2014-05-30)起算。

淨值曲線(對數座標)

0056 進階版(高息+營收成長+均線,前 10 檔) vs 0050 買進持有(含息)
1x2x3x5x8x12x2014-05-302016-06-212018-07-112020-08-052022-08-262024-09-262026-10-29
0056 進階版(高息+營收成長+均線,前 10 檔)0050 買進持有(含息)

與 0050 買進持有(含息)對照

對象年化報酬率日夏普最大回撤統計視窗
0056 進階版(高息+營收成長+均線,前 10 檔)25.9%1.80-23.1%2014-05-30 〜 2026-10-29
0050 買進持有(含息)20.3%1.08-34.0%2014-05-30 〜 2026-10-29

完整策略程式碼

安裝 finlab 套件後即可直接執行重現(finlab.login() 會自動引導登入,不需手動填 token)。想用 AI 輔助安裝與跑第一次回測,可以走安裝教學。

展開程式碼(strategy.py)
"""0056 進階版(高息+營收成長+均線,前 10 檔):跑到最新資料的版本(/strategies/etf-0056-replication 的數字來源)。

/blog/etf-0056-replication 第六章「終極版」的程式(原文 Colab 範例檔的最後一版,拿掉市值前 150、流動性與毛利成長三個條件):
- 條件:近四季 EPS 合計為正、近三年都有發放現金股利、近 3 個月營收平均高於近 12 個月、
  股價在 120 日與 240 日均線之上、60 日波動度排名在全市場後 50%、殖利率在全市場前 40%
- 排序:近兩年平均現金殖利率、最近一次現金殖利率、月營收年增率三項排名相加,取前 10 檔等權
- 每年 5/31 與 12/27 換股,sim() 預設手續費 0.1425% 與證交稅 0.3%

統計口徑:日夏普 = 日報酬平均 / 標準差 × √252,不扣無風險利率;
0050 含息 = etl:adj_close 還原價買進持有,與策略同一段期間。
執行後會在目前目錄輸出 data.csv(淨值曲線)與 metrics.json(統計)。

    pip install finlab
    python strategy.py

finlab 需要資料時會自動引導登入,不需要在程式裡填 token。
"""

import json

import numpy as np
import pandas as pd

import finlab
from finlab import data
from finlab.backtest import sim
from finlab.dataframe import FinlabDataFrame

STRATEGY_NAME = "0056 進階版(高息+營收成長+均線,前 10 檔)"
YIELD_QUANTILE = 0.6  # 殖利率至少在全市場 60 分位數(前 40%)
TOP_N = 10


def board_cash_dividend() -> FinlabDataFrame:
    """每股現金股利,日期取股東會日期(沒有就用董事會決議日)。"""
    df = data.get("board_dividend_announcement")
    dates = df["股東會日期"].fillna(df["董事會決議(擬議)股利分派日"])
    out = (df.drop_duplicates(["stock_id", "股東配發之現金(股利)總金額(元)"], keep="first")
             .groupby([df.stock_id, dates]).first()["盈餘分配之現金股利(元/股)"]
             .dropna().reset_index()
             .pivot(index="股東會日期", columns="stock_id", values="盈餘分配之現金股利(元/股)"))
    return FinlabDataFrame(out.rename_axis("date").rename_axis("symbol", axis=1))


def fill_missing_years(series: pd.Series) -> pd.Series:
    """沒有配息紀錄的年份補一筆 0,讓「連續三年」算得出中斷。"""
    if len(series) == 0:
        return series
    years = set(range(series.index[0].year, series.index[-1].year + 1))
    for year in years - set(series.index.year):
        series.loc[pd.Timestamp(f"{year}-01-01")] = 0
    return series.sort_index()


def paid_three_years() -> FinlabDataFrame:
    df = data.get("board_dividend_announcement").copy()
    df["股利年份"] = pd.to_datetime(df["股東會日期"]).fillna(pd.to_datetime(df["董事會決議(擬議)股利分派日"]))
    cash = (df.drop_duplicates(["股利年份", "stock_id"])
              .pivot(index="股利年份", columns="stock_id", values="股東配發之現金(股利)總金額(元)")
              .fillna(0).iloc[1:])
    paid = cash.apply(lambda s: (fill_missing_years(s[s != 0]) != 0).rolling(3).sum() == 3)
    return FinlabDataFrame(paid.ffill().fillna(False).astype(bool))


def build_report():
    close = data.get("price:收盤價")
    cash_dividend = board_cash_dividend().replace(0, np.nan).ffill().fillna(0)
    div_yield = cash_dividend / close
    div_annual = div_yield.rolling(520, min_periods=1).sum()

    eps_positive = data.get("financial_statement:每股盈餘").rolling(4).sum() > 0
    rev = data.get("monthly_revenue:當月營收")
    rev_yoy = data.get("monthly_revenue:去年同月增減(%)")
    yield_ratio = data.get("price_earning_ratio:殖利率(%)")

    rev_growth = rev.average(3) > rev.average(12)
    above_ma = (close > close.average(120)) & (close > close.average(240))
    # pandas 2 的 pct_change() 會先 ffill,這樣寫 pandas 2、3 結果一樣
    calm = close.ffill().pct_change(fill_method=None).rolling(60).std().rank(axis=1, pct=True) < 0.5
    high_yield = yield_ratio.ge(yield_ratio.quantile(YIELD_QUANTILE, axis=1), axis=0)

    conds = eps_positive & paid_three_years() & rev_growth & above_ma & calm & high_yield
    score = (div_annual.rank(axis=1, pct=True) + div_yield.rank(axis=1, pct=True)
             + rev_yoy.rank(axis=1, pct=True))
    position = score[conds].is_largest(TOP_N)

    years = range(position.index[0].year, position.index[-1].year + 1)
    dates = pd.to_datetime([f"{y}-{md}" for y in years for md in ("05-31", "12-27")])
    return sim(position.reindex(dates, method="ffill"), resample=dates, upload=False, name=STRATEGY_NAME)


def curve_stats(curve: pd.Series) -> dict:
    curve = curve / curve.iloc[0]
    daily = curve.pct_change().dropna()
    years = (curve.index[-1] - curve.index[0]).days / 365.25
    return {
        "cagr": curve.iloc[-1] ** (1 / years) - 1,
        "daily_sharpe": daily.mean() / daily.std() * 252 ** 0.5,
        "max_drawdown": (curve / curve.cummax() - 1).min(),
        "total_return": curve.iloc[-1] - 1,
    }


def main() -> None:
    finlab.login()
    bench_all = data.get("etl:adj_close")["0050"].dropna()
    report = build_report()

    # 從第一次有部位的那天起算,0050 含息用同一段期間
    creturn = report.creturn.dropna()
    first_move = (creturn != creturn.iloc[0]).argmax()
    creturn = creturn.iloc[max(first_move - 1, 0):]
    bench = bench_all.loc[creturn.index[0]:creturn.index[-1]]

    strategy_stats = curve_stats(creturn)
    bench_stats = curve_stats(bench)
    holdings = (report.position.fillna(0) != 0).sum(axis=1)
    strategy_stats["avg_holdings"] = holdings[holdings > 0].mean()

    start, end = creturn.index[0].date(), creturn.index[-1].date()
    print(f"期間 {start} 至 {end}")
    for label, s in [("策略", strategy_stats), ("0050 含息", bench_stats)]:
        print(f"{label}:年化 {s['cagr']:.2%},日夏普 {s['daily_sharpe']:.2f},最大回撤 {s['max_drawdown']:.2%}")

    curves = pd.DataFrame({STRATEGY_NAME: creturn / creturn.iloc[0], "0050含息": bench / bench.iloc[0]})
    curves.index.name = "date"
    curves.round(6).to_csv("data.csv")
    metrics = {
        "window": f"{start}/{end}",
        "star": STRATEGY_NAME,
        "variants": {STRATEGY_NAME: {k: round(float(v), 6) for k, v in strategy_stats.items()}},
        "benchmark_0050": {k: round(float(v), 6) for k, v in bench_stats.items()},
        "run_date": str(pd.Timestamp.today().date()),
        "finlab_version": finlab.__version__,
        "generated_by": "static/blog/etf-0056-replication/validation/strategy.py",
    }
    with open("metrics.json", "w", encoding="utf-8") as f:
        json.dump(metrics, f, ensure_ascii=False, indent=2)


if __name__ == "__main__":
    main()

下載 strategy.py 下載 data.csv(淨值曲線)

到 FinLab Studio 重現這支策略

在 Studio 裡用同一套真實資料調整條件、重跑回測。

本頁策略與回測結果僅供教學與研究用途,詳見服務條款。