Skip to content

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.optimize to 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 data
close = data.get('price:收盤價')

# 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()

finlab.backtest.sim

Backtest simulation orchestrator.

This is the main entry point for running backtests via sim(). Configuration, validation, report building, and notifications are delegated to focused package modules:

  • finlab.backtest.config -- SimConfig, input validation
  • finlab.backtest.report -- trades, MAE/MFE, report assembly
  • finlab.backtest.notify -- Line notification delivery

SimConfig dataclass

SimConfig(
    resample=None,
    data_readiness=None,
    planned_rebalance_date=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,
    end_date=None,
    mae_mfe_window=0,
    mae_mfe_window_step=1,
    market=None,
    upload=None,
    notification_enable=False,
    line_access_token='',
)

Configuration for backtest simulation parameters.

Groups the many sim() parameters into a single config object for cleaner internal passing between phases.

columns_to_numpy

columns_to_numpy(df)

Convert DataFrame columns to numpy array for pandas 3.0+ compatibility.

sim

sim(
    position: DataFrame | Series,
    resample: str | None = None,
    resample_offset: str | None = None,
    trade_at_price: str | DataFrame = 'close',
    position_limit: float = 1,
    fee_ratio: float | None = None,
    tax_ratio: float | None = None,
    name: str = '未命名',
    stop_loss: float | DataFrame | None = None,
    take_profit: float | DataFrame | None = None,
    trail_stop: float | None = None,
    trail_stop_activation: float | None = None,
    touched_exit: bool = False,
    retain_cost_when_rebalance: bool = False,
    stop_trading_next_period: bool = True,
    live_performance_start: str | None = None,
    mae_mfe_window: int = 0,
    mae_mfe_window_step: int = 1,
    market: Market | None = None,
    upload: bool | None = None,
    *,
    end_date: str | date | Timestamp | None = None,
    metrics_only: Literal[True],
    notification_enable: bool = False,
    line_access_token: str = '',
) -> Metrics
sim(
    position: DataFrame | Series,
    resample: str | None = None,
    resample_offset: str | None = None,
    trade_at_price: str | DataFrame = 'close',
    position_limit: float = 1,
    fee_ratio: float | None = None,
    tax_ratio: float | None = None,
    name: str = '未命名',
    stop_loss: float | DataFrame | None = None,
    take_profit: float | DataFrame | None = None,
    trail_stop: float | None = None,
    trail_stop_activation: float | None = None,
    touched_exit: bool = False,
    retain_cost_when_rebalance: bool = False,
    stop_trading_next_period: bool = True,
    live_performance_start: str | None = None,
    mae_mfe_window: int = 0,
    mae_mfe_window_step: int = 1,
    market: Market | None = None,
    upload: bool | None = None,
    *,
    end_date: str | date | Timestamp | None = None,
    metrics_only: Literal[False] = False,
    notification_enable: bool = False,
    line_access_token: str = '',
) -> report.Report
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='',
)

Simulate the equity given the stock position history.

See the module-level docstring and the parameter docs below for full details.

PARAMETER DESCRIPTION
position

Buy/sell signal history. True = hold, False = flat. Can also be a LazyWide — it will be auto-collected at the rebalance dates determined by resample.

TYPE: DataFrame | Series

resample

Trading 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.

TYPE: str | None DEFAULT: None

resample_offset

Pandas 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.

TYPE: str | None DEFAULT: None

trade_at_price

Price type for trade execution ('close', 'open', etc.).

TYPE: str | DataFrame DEFAULT: 'close'

position_limit

Maximum single-stock weight (0-1).

TYPE: float DEFAULT: 1

fee_ratio

Trading fee ratio.

TYPE: float | None DEFAULT: None

tax_ratio

Trading tax ratio.

TYPE: float | None DEFAULT: None

name

Strategy name.

TYPE: str DEFAULT: '未命名'

stop_loss

Stop-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_loss is None.

TYPE: float | DataFrame | None DEFAULT: None

take_profit

Take-profit threshold, or a DataFrame with the same rules as stop_loss.

TYPE: float | DataFrame | None DEFAULT: None

trail_stop

Drop 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.

TYPE: float | None DEFAULT: None

trail_stop_activation

Profit threshold before trailing stop activates.

TYPE: float | None DEFAULT: None

touched_exit

Use intraday touched stop-loss/take-profit.

TYPE: bool DEFAULT: False

retain_cost_when_rebalance

Keep original cost basis on rebalance.

TYPE: bool DEFAULT: False

stop_trading_next_period

Skip next period after stop-loss/take-profit.

TYPE: bool DEFAULT: True

live_performance_start

ISO date string marking live trading start.

TYPE: str | None DEFAULT: None

mae_mfe_window

Window size for MAE/MFE calculation.

TYPE: int DEFAULT: 0

mae_mfe_window_step

Step size for MAE/MFE window.

TYPE: int DEFAULT: 1

market

Market instance or name string ('TW_STOCK', 'US_STOCK').

TYPE: Market | None DEFAULT: None

upload

Whether to upload the strategy report to FinLab cloud. Defaults to None: upload only in cloud-scheduled runs (the FINLAB_FORCED_STRATEGY_NAME environment variable is set). FinLab Studio, local, Jupyter and Colab runs upload only when upload=True is passed.

TYPE: bool | None DEFAULT: None

end_date

Last 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_date stay open in report.trades. None (default) keeps the current behavior: the end is inferred from the position's signal spacing.

TYPE: str | date | Timestamp | None DEFAULT: None

metrics_only

Return the same Metrics type as Report.metrics without constructing a Report. Return-series metrics such as CAGR, Sharpe ratio and maximum drawdown remain available.

TYPE: bool DEFAULT: False

notification_enable

Send Line notifications on completion.

TYPE: bool DEFAULT: False

line_access_token

Line Notify access token.

TYPE: str DEFAULT: ''

RETURNS DESCRIPTION
Report | Metrics

A Report, or a Metrics object when metrics_only=True.

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
import finlab

finlab.truncate_start = '2020-01-01'  # Only backtest data after 2020
report = sim(position, resample='M')

3. Forgot to set resample, causing daily rebalancing

# Wrong: Transaction costs too high
report = sim(position)  # Default is daily rebalancing

# Correct: Set a reasonable rebalancing frequency
report = sim(position, resample='M')  # Monthly rebalancing

metrics_from_nav()

finlab.backtest.metrics_from_nav

metrics_from_nav(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.

line_notify()

finlab.backtest.line_notify

line_notify(
    report=None, line_access_token='', test=False, name=''
)

Send backtest position and recent trade signals to a Line chat room.

PARAMETER DESCRIPTION
report

The backtest result report.

TYPE: Report | None DEFAULT: None

line_access_token

Line Notify access token.

TYPE: str DEFAULT: ''

test

If True, send a test message instead of report data.

TYPE: bool DEFAULT: False

name

Strategy name for the notification header.

TYPE: str DEFAULT: ''

LINE Notification Setup

  1. Go to LINE Notify to get a personal token
  2. Set the environment variable:
    import os
    
    os.environ['LINE_NOTIFY_TOKEN'] = 'YOUR_TOKEN'
    
  3. Send notification:
    from finlab.backtest import line_notify
    
    # Send report after strategy execution
    report = sim(position, resample='M')
    line_notify(f"Backtest complete\nAnnual return: {report.stats['annual_return']:.2%}")
    

FAQ

Q: What if backtest results differ from live trading?

Common causes and solutions:

  1. Backtest does not account for slippage:

    # Use open price for trading (more conservative)
    report = sim(position, trade_at_price='open', resample='M')
    

  2. Insufficient trading volume:

    # Use LiquidityAnalysis to check liquidity
    from finlab.analysis import LiquidityAnalysis
    
    liq = LiquidityAnalysis(report)
    liq.display()  # View volume risk
    

  3. Trade price vs actual execution price gap: sim() signals are always "generated today, executed tomorrow". trade_at_price determines 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?

report = sim(
    position,
    resample='M',
    fee_ratio=0.001425,  # 0.1425% broker commission (buy and sell)
    tax_ratio=0.003,  # 0.3% securities transaction tax (sell only)
)

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
import finlab

finlab.truncate_start = '2018-01-01'  # Only test recent 5 years

# 3. Reduce the number of stocks
position = (close > ma20).is_largest(50)  # Only test 50 stocks

Resources