finlab.backtest
Backtesting engine module for evaluating strategy performance on historical data.
Use Cases
- Validate strategy logic: Confirm that entry/exit logic works as expected, inspect actual trade records
- Evaluate risk/reward: Calculate annualized return, Sharpe ratio, maximum drawdown, and other key performance metrics
- Optimize strategy parameters: Combine with
finlab.optimizeto test different parameter combinations (rebalancing frequency, stop-loss/take-profit, etc.) - Compare multiple strategies: Compare performance across different strategies to find the best trading approach
Quick Examples
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()
Detailed Guide
See Complete Backtesting Guide for:
- Full backtest parameter documentation (resample, position_limit, stop_loss, etc.)
- Detailed performance metric interpretation (annualized return, Sharpe ratio, win rate, etc.)
- Advanced backtesting techniques (multi-market, custom fees, slippage settings)
- In-depth backtest report analysis methods
API Reference
sim()
sim(
position,
resample=None,
resample_offset=None,
trade_at_price='close',
position_limit=1,
fee_ratio=None,
tax_ratio=None,
name=...,
stop_loss=None,
take_profit=None,
trail_stop=None,
trail_stop_activation=None,
touched_exit=False,
retain_cost_when_rebalance=False,
stop_trading_next_period=True,
live_performance_start=None,
mae_mfe_window=0,
mae_mfe_window_step=1,
market=None,
upload=None,
*,
end_date=None,
metrics_only=False,
notification_enable=False,
line_access_token='',
)
Simulates the equity curve of a position history and returns a backtest Report.
| Parameter | Type | Description |
|---|---|---|
position |
pd.DataFrame, pd.Series or LazyWide |
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 |
Rebalance frequency ('W', 'M', 'Q', ...) or the rebalance dates themselves. FinlabDataFrame period labels are converted to disclosure dates. None (default) 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 |
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 |
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 |
Maximum weight of a single symbol (0-1). |
fee_ratio |
float or None |
Commission ratio per trade. None uses the market default (0 for U.S. stocks and ETFs). |
tax_ratio |
float or None |
Transaction tax ratio. None uses the market default (0 for U.S. stocks and ETFs). |
name |
str |
Strategy name shown in the report and in FinLab Studio. When omitted, a placeholder name is used. |
stop_loss |
float, pd.DataFrame or 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 |
Take-profit threshold, or a DataFrame with the same rules as stop_loss. |
trail_stop |
float or 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 |
Profit the position must reach before the trailing stop activates. |
touched_exit |
bool |
Trigger stop-loss/take-profit when the intraday high or low touches the threshold. |
retain_cost_when_rebalance |
bool |
False (default) 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 |
After a stop-loss/take-profit exit, do not buy the symbol back at the next rebalance. |
live_performance_start |
str or None |
ISO date marking the start of live trading. |
mae_mfe_window |
int |
Window size for the MAE/MFE calculation. |
mae_mfe_window_step |
int |
Step size of the MAE/MFE window. |
market |
Market, str or 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 |
Upload the report to FinLab cloud. None uploads only in cloud-scheduled runs (the FINLAB_FORCED_STRATEGY_NAME environment variable is set). |
end_date |
str, datetime.date, pd.Timestamp or 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 |
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 |
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. |
Returns: a Report, or a Metrics object when metrics_only=True.
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.
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 excludes exited symbols when True, or may buy them back at the new target when False. The exit and purchase each incur the applicable fees and taxes. 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.
upload default (since 2.0.24)
upload defaults to None: the report is uploaded only in cloud-scheduled runs (the FINLAB_FORCED_STRATEGY_NAME environment variable is set). FinLab Studio, local, Jupyter and Colab runs do not upload by default; pass upload=True or call report.upload() after the backtest to upload.
Common Parameter Combinations
Monthly strategy (suitable for 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)
)
Weekly strategy (suitable for 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)
)
Stop-loss/take-profit strategy:
# 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')
Common Errors and Solutions
1. Strategy has no trade records
# Problem: Entry conditions are too strict, position is all False
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()}")
# Solution: Relax conditions or check data range
2. KeyError: Date does not exist
# Problem: Data date ranges don't match
# Solution: Use truncate_start to align start dates
data.truncate_start = '2020-01-01' # Only backtest data after 2020
report = sim(position, resample='M')
3. Forgot to set resample, causing frequent rebalancing
metrics_from_nav()
finlab.backtest.metrics_from_nav
Measure any engine's daily NAV with FinLab's curve-only Metrics.
Supply costs-adjusted NAV neutralized for deposits/withdrawals. Dates must increase with one observation per day; invalid rows are never dropped or sorted. This computes caller-provided performance, not audited returns. Measurement uses local calendar dates without a timezone, so midnight DST transitions do not invalidate otherwise valid daily observations. No report, holdings, upload or orders are inferred from the curve.
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 count
Supply 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-contest
This function does not create/upload a cloud Report or establish contest eligibility.
FAQ
Q: What if backtest results differ from live trading?
Common causes and solutions:
-
Backtest does not account for slippage:
-
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:
sim()signals are always "generated today, executed tomorrow".trade_at_pricedetermines which price is used for execution the next day:# trade_at_price='close' (default) -> next day's close price # Difficult to execute precisely at close price in practice, prone to slippage report = sim( position, trade_at_price='close' ) # Next day close, hard to match in practice # trade_at_price='open' -> next day's open price (recommended) # Closer to real trading: review signals after market close, place orders at next day's open report = sim(position, trade_at_price='open') # Next day open, closer to live trading
Q: 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.
Q: How do I limit the maximum number of holdings?
# Method 1: Use is_largest() to limit stocks in position
position = (close > ma20).is_largest(30) # Hold at most 30 stocks
# Method 2: Limit in sim()
report = sim(
position,
resample='M',
position_limit=0.1, # 10% max per stock
# If position has 50 stocks, effectively holds at most 10 (100% / 10% = 10)
)
Q: Backtest is too slow, what can I do?
# 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 stocks
Resources
- Complete Backtesting Tutorial - In-depth walkthrough of the backtest engine
- Complete Strategy Development Workflow - From research to live trading
- Strategy Optimization Guide - Finding optimal parameters
- Risk Management Guide - Stop-loss/take-profit in practice
- GitHub Source Code
- Example Strategies - Learn from practical examples