API reference
finlab.backtest
On this page8
finlab.backtest runs your position signal (a True/False table of which stocks to hold each day) through historical data and returns the equity curve, performance metrics and every trade. Use it to validate entry and exit logic and to evaluate annual return and drawdown; combine it with finlab.optimize to test parameter combinations or compare strategies.
Which function to use
| You want to | Use |
|---|---|
| Backtest a position signal and get the full report | sim(position) |
| Get the metrics only, without building a report | sim(position, metrics_only=True) |
| Measure another engine's or a custom ledger's NAV with FinLab's definitions | metrics_from_nav(nav) |
| Backtest every combination of several conditions | sim_conditions() |
| Screen many positions at once | sim_batch() |
Backtest a position signal
sim()
Simulates the equity curve of a position history and returns a backtest Report; report.display() draws it.
from finlab import data
from finlab.backtest import sim
# Load U.S. adjusted closing prices
data.set_market('us')
close = data.get('price:adj_close')
# Strategy logic: price breaks above 20-day moving average
ma20 = close.average(20)
position = close > ma20
# Backtest (monthly rebalancing, 10% max per stock)
report = sim(
position,
resample='M', # Monthly rebalancing
position_limit=0.1, # 10% max weight per stock
)
# Display performance report
report.display()sim() signals are always "generated today, executed tomorrow": each row's date is the day that data was available to you, and trade_at_price sets which price fills the next day. Every method on the report is in finlab.report.
| Parameter | Type | Default | Description |
|---|---|---|---|
position | pd.DataFrame, pd.Series or LazyWide | required | Holdings history indexed by date with one column per symbol: True = hold, False = flat, or numeric weights. A LazyWide is collected automatically at the rebalance dates set by resample. |
resample | str, DataFrame/Series, DatetimeIndex, list of dates or None | None | Rebalance frequency ('W', 'M', 'Q', ...) or the rebalance dates themselves. FinlabDataFrame period labels are converted to disclosure dates. None rebalances the whole portfolio whenever any symbol's row differs from the previous row, not on a fixed daily schedule; see Rebalance timing and stop basis. |
resample_offset | str or None | None | Pandas offset (for example '7D') added to every rebalance date, applied after disclosure-date conversion and before the backtest period is filtered. |
trade_at_price | str or pd.DataFrame | 'close' | Execution price: 'close', 'open', etc., or a DataFrame of execution prices (dates x symbols) on the same adjustment scale as market.get_price('close', adj=True). It changes fills only; valuation and close-based stops keep using adjusted closes. Signals generated on one day execute on the next trading day at this price. |
position_limit | float | 1 | Maximum weight of a single symbol (0-1). |
fee_ratio | float or None | None | Commission ratio per trade. None uses the market default (0 for U.S. stocks and ETFs). |
tax_ratio | float or None | None | Transaction tax ratio. None uses the market default (0 for U.S. stocks and ETFs). |
name | str | placeholder | Strategy name shown in the report and in FinLab Studio. When omitted, a placeholder name is used. |
stop_loss | float, pd.DataFrame or None | None | Stop-loss threshold, or a DataFrame of per-date, per-symbol 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). NaN, 0 and dates before the first value mean no stop. With a DataFrame, report.stop_loss is None. |
take_profit | float, pd.DataFrame or None | None | Take-profit threshold, or a DataFrame with the same rules as stop_loss. |
trail_stop | float or None | None | Drop from the peak, measured relative to entry cost. For a long position with close-price execution and touched_exit=False, an exit is signaled when (peak_price - close) / entry_cost > trail_stop: entry 100, peak 150 and trail_stop=0.2 give a 130 threshold. The exit executes on the next bar, not at a guaranteed threshold price. |
trail_stop_activation | float or None | None | Profit the position must reach before the trailing stop activates. |
touched_exit | bool | False | Trigger stop-loss/take-profit when the intraday high or low touches the threshold. |
retain_cost_when_rebalance | bool | False | False resets every holding's stop-loss/take-profit basis on each executed rebalance, including symbols that stay held; True keeps it for symbols held in the same direction. See Rebalance timing and stop basis. |
stop_trading_next_period | bool | True | After a stop-loss/take-profit exit, do not buy the symbol back at the next rebalance. |
live_performance_start | str or None | None | ISO date marking the start of live trading. |
mae_mfe_window | int | 0 | Window size for the MAE/MFE calculation. |
mae_mfe_window_step | int | 1 | Step size of the MAE/MFE window. |
market | Market, str or None | None | Market instance or name ('US_STOCK', 'us', 'us_fund', 'TW_STOCK', ...). None uses the market selected with data.set_market(). |
upload | bool or None | None | Upload the report to FinLab cloud. None uploads only when the FINLAB_FORCED_STRATEGY_NAME environment variable is set. |
end_date | str, datetime.date, pd.Timestamp or None | None | Last date of the backtest, inclusive (a bare date covers the whole day). Positions, prices and the report stop there as if the backtest ran on that day; positions still open stay open in report.trades. None infers the end from the position's signal spacing. |
metrics_only | bool | False | Return only the Metrics object (the same type as Report.metrics) without building a Report. CAGR, Sharpe ratio, maximum drawdown and other return-series metrics remain available. |
notification_enable | bool | False | Discontinued: sent a LINE Notify message, but LINE Notify shut down on 2025-03-31. Leave it False. |
line_access_token | str | '' | Discontinued LINE Notify access token, used only with notification_enable. |
end_date, metrics_only, notification_enable and line_access_token are keyword-only. Returns a Report, or a Metrics object when metrics_only=True.
Common parameter combinations
These continue from position, close and ma20 above.
Rebalance monthly (suits passive investors)
report = sim(
position,
resample='M', # Monthly rebalancing
position_limit=0.1, # 10% max per stock
upload=True, # Upload to cloud (local runs do not upload by default)
)Rebalance weekly (suits active investors)
report = sim(
position,
resample='W', # Weekly rebalancing
position_limit=0.05, # 5% max per stock (diversified)
trade_at_price='open', # Trade at open price (more conservative)
)Add a stop-loss and take-profit
Set them in sim() directly, or use hold_until() for more flexible exit rules:
# Method 1: Set in sim()
report = sim(
position,
resample='M',
stop_loss=0.1, # 10% stop-loss
take_profit=0.2, # 20% take-profit
)
# Method 2: Use hold_until() for more flexibility
exit_condition = close < ma20 # Exit when price drops below MA
position_with_exit = position.hold_until(
exit=exit_condition,
stop_loss=0.1, # 10% stop-loss
take_profit=0.2, # 20% take-profit
)
report = sim(position_with_exit, resample='M')Rebalance timing and stop basis
resample=None (the default) is not a fixed daily rebalance: whenever any symbol's row in position differs from the previous row, the whole portfolio is reallocated to the new row, so it rebalances every day only if position changes every day.
retain_cost_when_rebalance decides how stop-loss/take-profit is measured for symbols that stay held on the day a rebalance is executed:
False(default): every holding's stop-loss/take-profit basis resets to that day's price, including symbols that stay held; thetrail_stoppeak and thetrail_stop_activationstate are recalculated too.True: symbols held in the same direction keep their original entry cost and peak; only new or reversed positions reset.
So with resample=None and the default False, another symbol entering or leaving also resets the stop line of a symbol you hold throughout. With stop_loss=0.15, position holding A every day, and close-price execution:
| Date | A close | Event | False (default) | True |
|---|---|---|---|---|
| 01/03 | 100 | A enters | Stop line 85 | Stop line 85 |
| 01/08 | 90 | B turned on 01/05; the whole portfolio rebalances today | Stop line resets to 90 × 0.85 = 76.5 | Stop line stays 85 |
| 01/11 | 84 | Above 76.5 | Below 85, stop signal | |
| 01/12 | 81 | Above 76.5, still held | Exits at 81 on the trading day after the signal |
Under both settings A's return in report.trades is -19%: the reset changes the stop basis, not the trade return. With periodic settings such as resample='M', the same rule applies on every rebalance date.
stop_trading_next_period only decides whether a symbol exited by stop-loss/take-profit is bought back at the next rebalance; it does not affect whether symbols that stay held reset their basis.
A stop and a rebalance on the same day
With touched_exit=False, when an execution quote is available, a stop scheduled on the previous trading day executes before a same-day rebalance, even if the price has moved back inside the threshold. The rebalance then excludes exited symbols when stop_trading_next_period=True, or may buy them back at the new target when False. The exit and purchase each incur the applicable fees, and the exit also pays tax.
A repurchase is a new holding with a fresh cost and stop basis, even with retain_cost_when_rebalance=True, and trades show two holding periods. Before execution, tp_enter / sl_enter means exit then re-enter; weights describes current holdings and next_weights the rebalance target.
Custom execution prices and portfolio valuation
For stock/ETF backtests, trade_at_price accepts a price name (such as 'close' or 'open') or a DataFrame of execution prices with dates as its index and stock IDs as columns. It determines trade fills; daily portfolio valuation and ordinary close-based stop checks (touched_exit=False) still use market.get_price('close', adj=True).
U.S. stocks and ETFs value holdings with us_price:adj_close / us_fund_price:adj_close, which include split and dividend adjustments, so dividend-paying ETFs such as SPY can use the default sim(position) directly; supplying custom prices is unnecessary to include those adjustments. Taiwan uses etl:adj_close the same way. This is an adjusted-price return convention, not a cash ledger simulating exact reinvestment of each cash dividend.
Custom execution prices must share the valuation prices' adjustment scale, as in this example using the U.S. position above:
data.set_market('us')
adj_close = data.get('price:adj_close')
report = sim(position, trade_at_price=adj_close, upload=False)If only execution prices are doubled to adj_close * 2, the quantity bought is calculated at twice the price while holdings are valued on the original scale, distorting NAV. To use a custom price system, supply a custom Market with consistent execution, valuation and stop prices. With touched_exit=True, open, high and low prices must also share that scale.
Measure another engine's NAV
metrics_from_nav() accepts daily NAV from any engine or custom ledger and measures curve performance using the same definitions as sim(). Research/strategy management tools can register these results without rewriting the backtest:
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)) # Non-finite measurements become None
print(metrics.provenance) # Backend, period and observation countSupply a numeric Series with a DatetimeIndex, at least two increasing dates, at most one observation per day, and finite positive values. Measurement uses the input's local calendar dates. The first value is normalized to 1; invalid rows are never sorted or dropped. NAV must include costs and neutralize deposits/withdrawals. Performance is not independently audited brokerage performance. The return value is standalone Metrics with curve-derived measurements; no Report, trade records, holdings or orders are created.
Deploy any engine's program and required files with the existing ZIP deployment API. Put third-party dependencies in requirements.txt at the ZIP root and call metrics_from_nav after running the engine. Cloud Python, dependency and resource limits still apply:
python -m finlab cloud deploy external_nav --zip strategy.zip --entry-script main.py --time 09:30 --no-contestThis function does not create/upload a cloud Report or establish contest eligibility.
Common questions
The strategy has no trades
Usually the entry conditions are too strict and position is all False. Check how many stocks qualify each day, then relax the conditions or check the data range:
report = sim(position, resample='M')
trades = report.get_trades()
if len(trades) == 0:
print("Warning: Strategy has no trade records!")
# Check entry frequency
print(f"Average daily stocks in position: {position.sum(axis=1).mean():.1f}")
print(f"Maximum stocks in position: {position.sum(axis=1).max()}")KeyError: date does not exist
When data date ranges don't match, use truncate_start to align start dates:
data.truncate_start = '2020-01-01' # Only backtest data after 2020
report = sim(position, resample='M')No resample, so transaction costs are too high
The default resample=None rebalances the whole portfolio whenever any symbol changes, which can mean frequent trading. Set a reasonable rebalancing frequency:
report = sim(position) # resample=None rebalances on any position change
report = sim(position, resample='M') # Monthly rebalancingBacktest results differ from live trading
Common causes and solutions:
- The backtest does not account for slippage: trade at the open for a more conservative result.
report = sim(position, trade_at_price='open', resample='M') - Insufficient trading volume: keep only symbols whose recent dollar volume can absorb your orders.For Taiwan stocks,
volume = data.get('price:volume') dollar_volume = (close * volume).average(20) position = position & (dollar_volume > 5_000_000) # At least $5M traded per dayLiquidityAnalysisreports how many trades hit low liquidity, alert, disposition or full-delivery stocks (Taiwan market only). - Trade price vs actual execution price gap:
trade_at_price='close'(the default) fills at the next day's close, which is difficult to hit precisely in practice and prone to slippage.trade_at_price='open'fills at the next day's open, closer to real trading: review signals after the close, place orders at the next open. It is the recommended setting.report = sim(position, trade_at_price='close') # Next day close, hard to match in practice report = sim(position, trade_at_price='open') # Next day open, closer to live trading
How do I set custom transaction fees?
U.S. stocks and ETFs default to fee_ratio=0 and tax_ratio=0 (commission-free brokers, no transaction tax). Pass your own values to model commissions:
report = sim(
position,
resample='M',
fee_ratio=0.0005, # 0.05% commission per trade
tax_ratio=0, # No transaction tax on U.S. stocks
)Taiwan stocks default to a 0.1425% commission and a 0.3% transaction tax.
How do I limit the number of holdings?
Use is_largest() to limit how many stocks enter:
position = (close > ma20).is_largest(30) # Hold at most 30 stocksposition_limit caps each stock's weight, not the number of stocks: with position_limit=0.1, 50 stocks get 2% each and all 50 are held; with only 5 stocks, each is capped at 10% and the rest of the capital stays uninvested.
The backtest is too slow
# 1. Use a longer rebalancing period
report = sim(position, resample='Q') # Quarterly rebalancing (3x faster than monthly)
# 2. Shorten backtest period
data.truncate_start = '2018-01-01' # Only load data from 2018 onward
# 3. Reduce the number of stocks
position = (close > ma20).is_largest(50) # Only test 50 stocksNext steps
- Backtesting Every parameter, how to read the metrics, and advanced settings such as multiple markets, custom fees and slippage.
- Strategy parameter optimization Find the best conditions and parameters.
- Risk management Stop-loss and take-profit in practice.
- Complete strategy workflow From research to live trading.
Source code is on GitHub; for worked examples, see the tests folder.
Was this page helpful?