API 參考
finlab.backtest
本頁內容8
finlab.backtest 把你的持股訊號(每天要持有哪些股票的 True/False 表)放進歷史資料模擬交易,算出報酬曲線、績效指標和每一筆交易。用它驗證進出場邏輯、評估年化報酬與最大回撤,也能搭配 finlab.optimize 測試不同參數組合、比較多個策略。
選哪個函式
| 你想做的事 | 用這個 |
|---|---|
| 回測一份持股訊號,拿到完整報告 | sim(position) |
| 只要績效數字,不建立報告 | sim(position, metrics_only=True) |
| 用 FinLab 的口徑量測其他引擎或自訂帳本的淨值 | metrics_from_nav(nav) |
| 一次回測多個條件的所有組合 | sim_conditions() |
回測一份持股訊號
把持股訊號交給 sim(),它回傳一份回測報告(Report),用 report.display() 畫出來:
from finlab import data
from finlab.backtest import sim
# 載入資料
close = data.get('price:收盤價')
# 策略邏輯:價格突破 20 日均線
ma20 = close.average(20)
position = close > ma20
# 回測(每月調倉,單股上限 10%)
report = sim(
position,
resample='M', # 每月調倉
position_limit=0.1, # 單股權重上限 10%
)
# 顯示績效報告
report.display()from finlab import data
from finlab.backtest import sim
# 載入美股還原收盤價
data.set_market('us')
close = data.get('price:adj_close')
# 策略邏輯:價格突破 20 日均線
ma20 = close.average(20)
position = close > ma20
# 回測(每月調倉,單股上限 10%)
report = sim(
position,
resample='M', # 每月調倉
position_limit=0.1, # 單股權重上限 10%
)
# 顯示績效報告
report.display()sim() 的訊號永遠是「今天產生、隔天執行」:資料的日期是你拿得到它的那一天,trade_at_price 決定隔天用哪個價格成交。沒有指定 market 時,使用 data.set_market() 選的市場。報告上的每個方法見 finlab.report。
finlab.backtest.sim
sim(position, resample, resample_offset, trade_at_price, position_limit, fee_ratio, tax_ratio, name, stop_loss, take_profit, trail_stop, trail_stop_activation, touched_exit, retain_cost_when_rebalance, stop_trading_next_period, live_performance_start, mae_mfe_window, mae_mfe_window_step, market, upload, end_date, metrics_only, notification_enable, line_access_token) -> report.Report | MetricsSimulate the equity given the stock position history.
See the module-level docstring and the parameter docs below for full details.
參數
positionpd.DataFrame | pd.SeriesBuy/sell signal history. True = hold, False = flat. Can also be a
LazyWide— it will be auto-collected at the rebalance dates determined by resample.resamplestr | NoneTrading period frequency (e.g. 'W', 'M', 'Q'), or a DataFrame/Series, DatetimeIndex or datetime list supplying rebalance dates. FinlabDataFrame period labels are converted to disclosure dates.
None(default) rebalances the whole portfolio whenever any stock's row differs from the previous row, not on a fixed daily schedule.resample_offsetstr | NonePandas offset string (e.g. '7D') added to each rebalance date, for both frequency strings and supplied dates. Applied after disclosure-date conversion and before filtering the backtest period.
trade_at_pricestr | pd.DataFrame預設'close'Execution price type ('close', 'open', etc.), or a DataFrame of execution prices indexed by date with stock-ID columns. For stocks/ETFs, this changes fills only: daily valuation and close-based stops still use
market.get_price('close', adj=True). Custom prices must use the same adjustment scale as those closes. Taiwan's default adjusted close isetl:adj_close, including ex-dividend adjustments. To change the price system, supply a custommarketwith consistent execution, valuation and stop prices.position_limitfloat預設1Maximum single-stock weight (0-1).
fee_ratiofloat | NoneTrading fee ratio.
tax_ratiofloat | NoneTrading tax ratio.
namestr預設'未命名'Strategy name.
stop_lossfloat | pd.DataFrame | NoneStop-loss threshold, or a DataFrame of per-date, per-stock thresholds indexed by the date each value becomes available. Each bar uses the latest value dated on or before it (strictly before it with touched_exit=True, since intraday touches happen before that day's value exists). NaN, 0 and dates before the first value mean no stop. With a DataFrame,
report.stop_lossis None.take_profitfloat | pd.DataFrame | NoneTake-profit threshold, or a DataFrame with the same rules as
stop_loss.trail_stopfloat | NoneDrop from peak return, measured relative to entry cost. For a long position with close-price execution and touched_exit=False, signal an exit when (peak_price - close) / entry_cost > trail_stop. Entry 100, peak 150 and trail_stop=0.2 give a 130 threshold. The signal is executed on the next bar, not at a guaranteed threshold price. With touched_exit=True the peak only includes trade prices up to the previous bar, plus today's open with trade_at_price='open', since intraday touches happen before that day's close exists.
trail_stop_activationfloat | NoneProfit threshold before trailing stop activates.
touched_exitbool預設FalseUse intraday touched stop-loss/take-profit.
retain_cost_when_rebalancebool預設FalseFalse(default) resets the stop-loss/take-profit cost basis, trailing peak andtrail_stop_activationstate of every holding on each executed rebalance, including stocks that stay held.Truekeeps the original cost basis and peak of stocks held in the same direction; only new or reversed positions reset.stop_trading_next_periodbool預設TrueAfter a stop-loss/take-profit exit, do not buy the stock back at the next rebalance. It does not affect whether stocks that stay held reset their cost basis. When an execution quote is available, pending non-touched stops execute before a same-day rebalance.
Falseallows buying back at that rebalance, with separate exit/entry fees and a fresh cost basis.live_performance_startstr | NoneISO date string marking live trading start.
mae_mfe_windowint預設0Window size for MAE/MFE calculation.
mae_mfe_window_stepint預設1Step size for MAE/MFE window.
marketMarket | NoneMarket instance or name string ('TW_STOCK', 'US_STOCK').
uploadbool | NoneWhether to upload the strategy report to FinLab cloud. Defaults to
None: upload only when theFINLAB_FORCED_STRATEGY_NAMEenvironment variable is set, as in cloud-scheduled runs and notebooks run manually on the FinLab Studio website. Other runs, including Studio AI and the desktop app, needupload=True.end_datestr | datetime.date | pd.Timestamp | NoneLast date of the backtest (inclusive; a bare date covers the whole day). Position, prices and the report (creturn, trades, stats) stop there, as if the backtest ran on that day. Positions still open on
end_datestay open inreport.trades.None(default) keeps the current behavior: the end is inferred from the position's signal spacing.metrics_onlybool預設FalseReturn the same
Metricstype asReport.metricswithout constructing a Report. Return-series metrics such as CAGR, Sharpe ratio and maximum drawdown remain available.notification_enablebool預設FalseDiscontinued: LINE Notify shut down on 2025-03-31. Leave it False.
line_access_tokenstr預設''Discontinued LINE Notify access token, used only with
notification_enable.
回傳
report.Report | MetricsA Report, or a Metrics object when
metrics_only=True.
常用參數組合
以下沿用上面的 position、close、ma20。
每月換股(適合上班族)
report = sim(
position,
resample='M', # 每月調倉
position_limit=0.1, # 單股上限 10%
upload=True, # 上傳至雲端(本機預設不上傳,需明確指定)
)每週換股(適合積極型)
report = sim(
position,
resample='W', # 每週調倉
position_limit=0.05, # 單股上限 5%(分散風險)
trade_at_price='open', # 以開盤價交易(更保守)
)加上停損停利
可以直接在 sim() 設定,或用 hold_until() 寫更有彈性的出場條件:
# 方法 1: 在 sim() 中設定
report = sim(
position,
resample='M',
stop_loss=0.1, # 虧損 10% 停損
take_profit=0.2, # 獲利 20% 停利
)
# 方法 2: 使用 hold_until() 更靈活
exit_condition = close < ma20 # 跌破均線出場
position_with_exit = position.hold_until(
exit=exit_condition,
stop_loss=0.1, # 虧損 10% 停損
take_profit=0.2, # 獲利 20% 停利
)
report = sim(position_with_exit, resample='M')再平衡時機與停損基準
resample=None(預設)不是固定每日調倉:position 任何一檔股票和前一列不同時,整本持股依新的一列重新配置,所以只有 position 每天都有變動時,才會每天調倉。
retain_cost_when_rebalance 決定實際執行再平衡的那一天,繼續持有的股票停損停利怎麼算:
False(預設):所有持股的停損停利基準都重設為當天價格,包括繼續持有的股票;trail_stop的最高價與trail_stop_activation的啟動狀態也重新計算。True:同方向繼續持有的股票保留原進場成本與最高價,只有新進場或反向的股票重設。
所以 resample=None 搭配預設 False 時,別檔股票進出也會重設一直持有的股票的停損線。以 stop_loss=0.15、position 每天都持有 A 為例(收盤價成交):
| 日期 | A 收盤價 | 發生的事 | False(預設) | True |
|---|---|---|---|---|
| 01/03 | 100 | A 進場 | 停損線 85 | 停損線 85 |
| 01/08 | 90 | B 在 01/05 轉為持有,這天整本再平衡 | 停損線重設為 90 × 0.85 = 76.5 | 停損線維持 85 |
| 01/11 | 84 | 未跌破 76.5 | 跌破 85,產生停損訊號 | |
| 01/12 | 81 | 未跌破 76.5,繼續持有 | 訊號日的下一個交易日,以 81 出場 |
兩種設定下 A 在 report.trades 的報酬都是 -19%:重設的是停損基準,不是交易報酬。設定 resample='M' 等週期時,每個再平衡日也照這個規則。
stop_trading_next_period 只決定停損停利出場後,下一次再平衡是否買回該股票,和繼續持有的股票是否重設基準無關。
停損停利和再平衡碰在同一天
touched_exit=False 時,若成交日有成交價,前一交易日已排定的停損停利會先成交,即使當天剛好再平衡或價格已回到門檻內。接著再平衡:stop_trading_next_period=True 排除已退出的股票;False 允許依新目標在同日買回,退出與買回各計一次手續費,退出另計稅。
買回是新部位,成本與停損停利基準重新計算,即使 retain_cost_when_rebalance=True 也一樣。交易紀錄分成兩筆持有期間;成交前的 tp_enter/sl_enter 表示先退出再買回,weights 是目前部位、next_weights 是再平衡目標。
自訂成交價與持倉估值
股票/ETF 回測的 trade_at_price 可傳入價格名稱(例如 'close'、'open'),或以日期為 index、股票代號為 columns 的自訂成交價 DataFrame。它決定交易的成交價格;逐日持倉估值與一般收盤價停損判斷(touched_exit=False)仍使用 market.get_price('close', adj=True)。
估值用的收盤價已包含除權息調整,所以配息 ETF 可直接用預設的 sim(position) 回測,無須另外傳入價格來納入除息調整:
- 台股:
'close'使用etl:adj_close,已包含除息調整,00948B 等配息 ETF 直接回測即可。 - 美股:股票與 ETF 分別用
us_price:adj_close/us_fund_price:adj_close估值,已包含分割與配息調整,SPY 等配息 ETF 直接回測即可。
這是還原價格的報酬口徑,不是逐筆現金股利精確再投入的現金帳本模擬。
自訂成交價須與估值價使用一致的還原尺度。沿用前面的 position,可傳入同一份還原收盤價:
adj_close = data.get('etl:adj_close')
report = sim(position, trade_at_price=adj_close, upload=False)data.set_market('us')
adj_close = data.get('price:adj_close')
report = sim(position, trade_at_price=adj_close, upload=False)若只把成交價改成 adj_close * 2,買入數量會按兩倍價格計算,持倉卻仍按原尺度估值,淨值就會失真。若需採用自訂價格體系,請透過自訂 Market,同步提供一致的成交、估值及停損價格;使用 touched_exit=True 時,開盤、最高、最低價也須維持同一尺度。
量測其他引擎的淨值
metrics_from_nav() 接受任意框架或自訂帳本的日頻淨值,提供與 sim() 相同口徑的淨值績效,供研究/策略管理工具登錄結果,不需要改寫成 FinLab 回測:
import pandas as pd
from finlab.backtest import metrics_from_nav
from finlab.backtest.metrics import metrics_to_dict
nav = pd.Series([100, 102, 101], index=pd.date_range("2026-01-01", periods=3))
metrics = metrics_from_nav(nav)
print(metrics.annual_return(), metrics.sharpe_ratio(), metrics.max_drawdown())
print(metrics_to_dict(metrics)) # 無法計算的非有限指標轉為 None
print(metrics.provenance) # 量測方式、期間與有效觀測筆數輸入為數值 Series 與 DatetimeIndex,至少兩筆、日期遞增且每天最多一筆,值有限且為正。依輸入的當地日曆日期量測,首筆正規化為 1,不排序或刪除錯值。淨值必須已扣成本,並排除入金/出金造成的假報酬;結果不是券商查核收益。回傳獨立 Metrics,僅提供淨值可量測的指標,不產生 Report、交易明細、持倉或委託。
任意框架的程式與必要檔案仍可透過現有 ZIP 部署,第三方相依放在根目錄 requirements.txt;入口程式執行框架後呼叫 metrics_from_nav。雲端的 Python、相依套件與資源限制仍適用:
python -m finlab cloud deploy external_nav --zip strategy.zip --entry-script main.py --time 09:30 --no-contest量測函式不建立或上傳雲端 Report,也不宣告競賽資格。
常見問題
策略沒有任何交易紀錄
通常是進場條件過嚴,position 全為 False。先檢查每天有幾檔股票符合條件,再放寬條件或檢查資料範圍:
report = sim(position, resample='M')
trades = report.get_trades()
if len(trades) == 0:
print("警告:策略無任何交易記錄!")
# 檢查進場次數
print(f"平均每日進場股票數:{position.sum(axis=1).mean():.1f}")
print(f"最大進場股票數:{position.sum(axis=1).max()}")KeyError:日期不存在
資料的日期範圍不一致時,用 truncate_start 對齊起始日期:
from finlab import data
data.truncate_start = '2020-01-01' # 只回測 2020 年後的資料
report = sim(position, resample='M')沒設定 resample,交易成本過高
預設 resample=None 會在任何一檔變動時整本再平衡,換股可能很頻繁。設定合理的調倉頻率:
report = sim(position) # 預設 resample=None:任何一檔變動就整本再平衡
report = sim(position, resample='M') # 每月調倉回測結果與實盤有落差
常見原因與解決方法:
- 回測未考慮滑價:改用開盤價交易,結果比較保守。
report = sim(position, trade_at_price='open', resample='M') - 成交量不足:用
LiquidityAnalysis檢查成交量風險(只支援台股)。report.run_analysis('LiquidityAnalysis') - 成交價與實際執行有價差:
trade_at_price='close'(預設)是隔日收盤價成交,實務上難以精確在收盤價成交,容易產生滑價;trade_at_price='open'是隔日開盤價成交,較貼近「盤後看訊號、隔日開盤掛單」的實際下單情境,推薦使用。report = sim(position, trade_at_price='close') # 隔日收盤價,實務不易精確成交 report = sim(position, trade_at_price='open') # 隔日開盤價,較貼近實盤
如何設定自訂手續費?
fee_ratio 與 tax_ratio 為 None 時使用市場預設值:台股手續費 0.1425%、證交稅 0.3%;美股股票與 ETF 都是 0。要模擬自己的費率就直接傳入:
report = sim(
position,
resample='M',
fee_ratio=0.001425, # 買賣各 0.1425%(券商手續費)
tax_ratio=0.003, # 賣出證交稅 0.3%
)如何限制最大持股數量?
用 is_largest() 限制進場股票數:
position = (close > ma20).is_largest(30) # 最多持有 30 檔position_limit 只限制單一股票的權重上限,不限制檔數:position_limit=0.1 時,50 檔各分到 2%,50 檔都會持有;只有 5 檔時,每檔被限制在 10%,其餘資金不投入。
回測速度太慢怎麼辦?
# 1. 使用更長的調倉週期
report = sim(position, resample='Q') # 季度調倉(比月線快 3 倍)
# 2. 縮短回測期間
from finlab import data
data.truncate_start = '2018-01-01' # 只載入 2018 年以後的資料
# 3. 減少股票數量
position = (close > ma20).is_largest(50) # 只測試 50 檔下一步
這一頁有幫到你嗎?