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, 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, 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, fast_mode=False, 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, 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, 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, fast_mode=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').

TYPE: str | None DEFAULT: None

resample_offset

Time offset for the resample 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.

TYPE: float | None DEFAULT: None

take_profit

Take-profit threshold.

TYPE: float | None DEFAULT: None

trail_stop

Trailing stop threshold.

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: None | Market DEFAULT: None

upload

Whether to upload the strategy report. When omitted, FINLAB_STRATEGY_NAME/FINLAB_FORCED_STRATEGY_NAME enables upload.

TYPE: bool | None DEFAULT: None

fast_mode

Use fast simulation mode (no stop-loss/take-profit).

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

Report object with backtest results and analytics.

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
)

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.hold_until(stop_loss=0.1, take_profit=0.2),
    resample='M'
)

# 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

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