低股價淨值比+F-Score+營收成長
股價淨值比最低的 500 檔裡,挑 F-Score 至少 7 分、最新月營收比去年同月成長的股票,每月換股、等權,平均持有約 49 檔。2014 年至 2026 年台股回測年化 19.7%、日夏普 1.26、最大回撤 -30.8%;同視窗 0050 含息年化 19.8%、日夏普 1.05、最大回撤 -34.0%:報酬和 0050 差不多、波動與回撤較小。2020 年 8 月底以後的後段日夏普 1.33 對 1.30,領先幅度很小。
投資警語:本文僅供教學參考,不構成投資建議。過去績效不代表未來表現,投資有風險。
回測績效
- 年化報酬率
- 19.7%
- 日夏普
- 1.26
- 最大回撤
- -30.8%
- 總報酬率
- 814%
- 平均持股
- 49.2 檔
回測含手續費 0.1425% 與賣出證交稅 0.3%(finlab 套件 sim() 預設成本),日夏普未扣無風險利率。策略淨值自資料可得日(2014-06-30)起算。
淨值曲線(對數座標)
低股價淨值比+F-Score+營收成長0050 買進持有(含息)
與 0050 買進持有(含息)對照
| 對象 | 年化報酬率 | 日夏普 | 最大回撤 | 統計視窗 |
|---|---|---|---|---|
| 低股價淨值比+F-Score+營收成長 | 19.7% | 1.26 | -30.8% | 2014-06-30 〜 2026-10-30 |
| 0050 買進持有(含息) | 19.8% | 1.05 | -34.0% | 2014-06-30 〜 2026-10-30 |
完整策略程式碼
安裝 finlab 套件後即可直接執行重現(finlab.login() 會自動引導登入,不需手動填 token)。想用 AI 輔助安裝與跑第一次回測,可以走安裝教學。
展開程式碼(strategy.py)
"""低股價淨值比+F-Score+營收成長:/strategies/f-score-low-pb-revenue 的數字來源。
/blog/f-score-indicator「進階版:低股價淨值比+F-Score+營收成長」一節的程式:
- 股價淨值比最低的 500 檔(股價淨值比 > 0)
- F-Score 至少 7 分(9 項:ROE > 0、營業現金流 > 0、ROE 比去年同季高、營業現金流 ÷ 總資產 > ROE、
負債比率下降、流動比率上升、普通股股本沒有增加、毛利率上升、總資產週轉上升,都和去年同季比)
- 最新一個月營收比去年同月成長
- 2014-06 起每月底換股、等權,sim() 預設手續費 0.1425% 與證交稅 0.3%
- 財報用各公司實際公布日,月營收用每月 10 日公布期限
統計口徑:日夏普 = 日報酬平均 / 標準差 × √252,不扣無風險利率;
0050 含息 = etl:adj_close 還原價買進持有,與策略同一段期間。
執行後會在目前目錄輸出 data.csv(淨值曲線)與 metrics.json(統計)。
pip install finlab
python strategy.py
finlab 需要資料時會自動引導登入,不需要在程式裡填 token。
"""
import json
import pandas as pd
import finlab
from finlab import data
from finlab.backtest import sim
STRATEGY_NAME = "低股價淨值比+F-Score+營收成長"
START = "2014-06-01"
LOW_PB_N = 500
F_SCORE_MIN = 7
def f_score():
"""9 項財報檢查各得 1 分,每項都和 4 季前(去年同季)比。"""
roe = data.get("fundamental_features:ROE稅後")
ocf = data.get("financial_statement:營業活動之淨現金流入_流出")
assets = data.get("financial_statement:資產總額")
revenue = data.get("financial_statement:營業收入淨額")
debt_ratio = data.get("fundamental_features:負債比率")
current_ratio = data.get("fundamental_features:流動比率")
capital = data.get("financial_statement:普通股股本")
gross_margin = data.get("financial_statement:營業毛利") / revenue
turnover = revenue / assets
checks = [
roe > 0,
ocf > 0,
roe > roe.shift(4),
ocf / assets > roe / 100,
debt_ratio < debt_ratio.shift(4),
current_ratio > current_ratio.shift(4),
capital <= capital.shift(4),
gross_margin > gross_margin.shift(4),
turnover > turnover.shift(4),
]
return sum(c.astype(int) for c in checks)
def build_report():
pb = data.get("price_earning_ratio:股價淨值比")
revenue_yoy = data.get("monthly_revenue:去年同月增減(%)")
score = f_score().index_str_to_date()
position = pb[pb > 0].is_smallest(LOW_PB_N) & (score >= F_SCORE_MIN) & (revenue_yoy > 0)
return sim(position.loc[START:], resample="M", 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()
# 0050 要在策略設定股票池之前取得
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%}")
# 前後兩段:期間對半切,各自和同一段的 0050 含息比日夏普
mid = creturn.index[0] + (creturn.index[-1] - creturn.index[0]) / 2
for label, part in [("前段", slice(None, mid)), ("後段", slice(mid, None))]:
s, b = curve_stats(creturn.loc[part]), curve_stats(bench.loc[part])
print(f"{label}:策略日夏普 {s['daily_sharpe']:.2f},0050 含息 {b['daily_sharpe']:.2f}")
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/f-score-indicator/low-pb-revenue/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()
在 Studio 裡用同一套真實資料調整條件、重跑回測。