教學
歷史回測
本頁內容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()from finlab import data
from finlab import backtest
data.set_market('us_fund')
close = data.get('price:adj_close')
position = close[['SPY', 'QQQ']].pct_change(126).is_largest(1)
report = backtest.sim(position, resample='M', name='美股 ETF 動能')
report.display()report.display() 會畫出下面這份報告,可以直接在上面操作:
回測什麼時候買、什麼時候賣
- T 日收盤後 當天資料更新 日期是 T 的那一列,只用到 T 日已公布的資料。
- T 日 產生訊號 position 在 T 日那一列,決定要持有哪些股票。
- 下一個交易日 成交 價格依 trade_at_price,預設收盤價。
- 到下次換股 持有 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 只沿用過去的訊號;最初還沒有大盤訊號的日期視為不持有。
from finlab.backtest import sim
timing = close['0050'] > close['0050'].rolling(20).mean()
position *= timing
report = sim(position, upload=False) # 不設定 resample,讓擇時訊號每天生效position[timing]、position & timing、position *= timing 都會保留每個日期,False 的日期部位歸零。FinlabDataFrame 會自動把 timing 依日期套用到每檔股票,不必手動 reindex 或轉型;兩者日期不同時,沿用前一筆訊號。
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 | 持有期間最大有利幅度 |
bmfe | mae 發生前的最大有利幅度 |
每筆交易最多賺、最多賠多少(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')。
下一步
- sim() 完整參數 停損停利、手續費、成交價與所有選項。
- 策略參數優化 一次比較不同換股頻率與停損停利。
- 風險管理 停損停利與部位控制的實戰做法。
- 每天自動更新 讓策略每天重跑,換股日收到通知。
這一頁有幫到你嗎?