文件
開啟 Studio

教學

歷史回測

本頁內容8

backtest.sim(position) 把每天要持有哪些股票的表,變成一份完整的回測報告:報酬曲線、風險指標、每一筆交易。還不會寫 position 的話,先看寫選股條件。

跑一個回測

from finlab import data
from finlab import backtest

close = data.get('price:收盤價')
position = close < 6   # 只買股價低於 6 元的股票

# 每月底換股
report = backtest.sim(position, resample='M', name='價格小於6的股票')
report.display()

report.display() 會畫出下面這份報告,可以直接在上面操作:

回測報告・可互動 開新視窗

回測什麼時候買、什麼時候賣

  1. T 日收盤後 當天資料更新 日期是 T 的那一列,只用到 T 日已公布的資料。
  2. T 日 產生訊號 position 在 T 日那一列,決定要持有哪些股票。
  3. 下一個交易日 成交 價格依 trade_at_price,預設收盤價。
  4. 到下次換股 持有 resample='M' 在每月最後一個交易日後換股。
  • 訊號:position 在某一天的那一列,只用到那一天已經公布的資料。財報的日期是公告日,法人、融資、成交金額是當天盤後。
  • 成交:訊號出現後的下一個交易日成交,價格由 trade_at_price 決定,預設是收盤價。
  • 換股頻率:由 resample 決定。
resample什麼時候換股
不設定(None,預設)position 只要有任何一檔股票和前一列不同,就整本持股重新配置
'W'每週
'M'每月底,最常用
'Q'每季
'20D'每 20 天
from finlab import data
from finlab.backtest import sim

close = data.get('price:收盤價')
position = close > close.average(20)

report_daily = sim(position, name='訊號變動就調倉')          # 未設定 resample
report_monthly = sim(position, resample='M', name='每月調倉')

print(len(report_daily.get_trades()), report_daily.get_stats()['cagr'])
print(len(report_monthly.get_trades()), report_monthly.get_stats()['cagr'])

只在大盤好的時候持股

想讓整個策略只在大盤多頭時持股,把日期索引的 True/False Series 和部位相乘或取交集:False 的日期部位歸零,sim() 在那些日期空手。

tx = data.get('taiex_total_index:收盤指數')
risk_on = (tx > tx.average(60))['TAIEX']
risk_on = risk_on.reindex(position.index, method='ffill').fillna(False).astype(bool)

filtered = position & risk_on
report = backtest.sim(filtered, resample='W', upload=False)

股票部位直接和只有 TAIEX 一欄的大盤表做 position & mk,會因為沒有共同的股票欄位而得到零欄位的部位。所以要先取出以日期為索引的 Series,放在股票部位的右邊,訊號才會套用到每一檔股票。ffill 只沿用過去的訊號;最初還沒有大盤訊號的日期視為不持有。

sim() 需要至少一個股票欄位和一列日期。全部空手的策略,請保留股票和日期,把部位設成 False 或 0。

看懂報告與每一筆交易

report.get_stats() 回傳年化報酬、最大回撤、夏普率等數字;report.get_trades() 回傳每一筆交易:

report.get_trades()
欄位意思
entry_sig_date進場訊號產生日
entry_date進場日
exit_sig_date出場訊號產生日
exit_date出場日
period持有天數
position持有占比
return報酬率
mdd持有期間最大回撤
mae持有期間最大不利幅度
gmfe持有期間最大有利幅度
bmfemae 發生前的最大有利幅度

每筆交易最多賺、最多賠多少(MAE/MFE)

report.display_mae_mfe_analysis()
回測報告・可互動 開新視窗

資金放大後買得到嗎(流動性)

LiquidityAnalysis 計算進出場時成交量、成交金額達到門檻的交易比例,用來估計策略能容納多少資金:

from finlab.analysis.liquidityAnalysis import LiquidityAnalysis

# 成交股數大於 100,000 股、成交金額大於 1,000,000 元的交易比例
report.run_analysis(
    LiquidityAnalysis(required_volume=100000, required_turnover=1000000)
)
回測報告・可互動 開新視窗

更多分析模組請看策略分析模組。

下一期要持有什麼、什麼時候換股

report.weights 是這一期的目標部位;report.next_weights 是用目前的資料算出的下一個換股邊界的目標部位。換股邊界之前,next_weights 與換股的 enter/exit 動作每天都可能變,它們只是下一期的預覽,不代表今天就要下單。

不用自己比較日期,直接問換股是否到期:

print(report.is_rebalance_due())   # True:換股已到期
print(report.next_weights)
print(report.actions)

False 表示還沒到期。True 只表示換股時間到了,不保證報告已含最新收盤資料,也不代表委託已成交,到期時仍要用最新資料重跑 sim()。停損停利的動作另外用 report.is_stop_triggered() 判斷。

例子:美股每週換股的時間點

以美股 resample='W' 為例,週換股邊界預設是週日。2026-09-22 看到 next_weights.name 是 2026-09-27 只是預覽;最後的名單要等 09-25(週五)收盤資料齊全後重跑確認,回測在下一個交易日 09-28 成交,價格依 trade_at_price 決定。假日會改變最後一個交易日與成交日。美股目前依換股標籤當日的市場收盤時間判斷到期,所以 is_rebalance_due() 要到週日 16:00(美東時間)才回傳 True,不是週五收盤。

台股日頻在有可用的交易日曆時,依交易日曆的換股期間判斷到期;同一頁也說明為什麼實單的 PortfolioSyncManager.update() 要每次排程都執行,而不是只在到期時呼叫。

更多範例

創新高

from finlab import data
from finlab.backtest import sim

close = data.get('price:收盤價')
position = close == close.rolling(250).max()   # 創 250 天新高

sim(position, resample='M', name='創年新高策略')   # 每月換股
回測報告・可互動 開新視窗

RSI 最高的 20 檔

from finlab import data
from finlab.backtest import sim

rsi = data.indicator('RSI')
position = rsi.is_largest(20)

report = sim(position, resample='W', name='高RSI策略')   # 每週換股
回測報告・可互動 開新視窗

乖離率加財報濾網

from finlab import data
from finlab.backtest import sim

roe = data.get('fundamental_features:ROE稅後')
close = data.get('price:收盤價')

position = (
    (close / close.shift(60)).is_largest(30)   # 60 日漲幅前 30 名
    & (roe > 0)                                 # ROE 大於 0
)

report = sim(position, resample='M', name='乖離率和ROE濾網策略')
回測報告・可互動 開新視窗

常見問題

回測完成,但沒有任何交易

get_trades() 回傳空的表,通常是條件太嚴,position 幾乎都是 False;也可能是資料日期範圍太短,或篩選後的股票池太小、不存在。先看每天有幾檔股票符合條件:

from finlab import data

close = data.get('price:收盤價')
position = (close > 100) & (close < 110)

per_day = position.sum(axis=1)   # 每天符合條件的股票數
print(f'平均每天 {per_day.mean():.2f} 檔,最多 {per_day.max()} 檔')
print(f'有訊號的交易日:{(per_day > 0).sum()} / {len(per_day)}')
print(f'從未進場的股票:{(position.sum(axis=0) == 0).sum()} / {len(position.columns)}')

平均每天不到 1 檔時,放寬條件(例如價格區間改成 80~120 元),或改用 is_largest(N) 固定每天選 N 檔,並確認 data.get() 拿到的資料是完整的:

ma20 = close.average(20)
position_fixed = (close > ma20).is_largest(30)   # 每天固定選 30 檔
出現 KeyError,說某個日期不存在

例如 KeyError: Timestamp('2023-05-01 00:00:00')。常見原因:多個資料的日期範圍不一致、position 的日期超出資料範圍,或 shift()、rolling() 讓前期沒有資料。解法:

from finlab import data
from finlab.backtest import sim

# 方法 1:統一起始日期
data.truncate_start = '2020-01-01'   # 只回測 2020 年之後

close = data.get('price:收盤價')
volume = data.get('price:成交股數')
print(close.index[0], close.index[-1], volume.index[0], volume.index[-1])

# 方法 2:手動對齊日期
common_dates = close.index.intersection(volume.index)
close_aligned = close.loc[common_dates]
volume_aligned = volume.loc[common_dates]

# 方法 3:移除全是 NaN 的日期,其餘 NaN 視為不持有
position = (close > close.average(20)) & (volume > 1000)
position_cleaned = position.dropna(how='all', axis=0).fillna(False)
report = sim(position_cleaned, resample='M')

檢查 position 有多少缺值:

missing = position.isna()
print(missing.sum().sum(), missing.all(axis=1).sum(), missing.all(axis=0).sum())   # 總數、全空的日期、全空的股票
交易次數異常多、報酬很低

多半是沒有設定 resample,見上方回測什麼時候買、什麼時候賣。常用的頻率:週('W',短線)、月('M',最常用)、季('Q',長線),或自訂天數('20D')。

下一步

↑↓ 選擇 Enter 開啟 Esc 關閉