Backtesting
Use daily position signals with finlab.backtest.sim to quickly backtest and generate stock selection lists with risk/return analysis.
Quick Start
from finlab import data
from finlab import backtest
data.set_market('us')
with data.us_universe(index='SP500'):
close = data.get('price:adj_close')
# Hold the 20 S&P 500 stocks with the strongest 6-month return
position = (close / close.shift(126)).is_largest(20)
# Backtest, rebalance at month-end (M)
report = backtest.sim(position, resample='M', name='S&P 500 momentum top 20')
data.us_universe(index='SP500') uses today's S&P 500 members for the whole history, so past results are overstated (survivorship bias); see Historical index backtests for a point-in-time version.
Let AI help you write strategies
Want to try more strategies but unsure about the syntax? After installing FinLab Skill, the AI coding assistant can help you write entry/exit conditions, choose rebalancing frequency, and set stop-loss/take-profit.
Apply a Market Timing Filter
Combining stock positions with an index DataFrame such as world_index:adj_close using position & mk produces zero asset columns because the frames have no columns in common. Select a date-indexed Series, here the S&P 500 (^GSPC), and put it on the right of the stock positions to apply the signal to every stock:
index = data.get('world_index:adj_close')
risk_on = (index > index.average(60))['^GSPC']
risk_on = risk_on.reindex(position.index, method='ffill').fillna(False).astype(bool)
filtered = position & risk_on
report = backtest.sim(filtered, resample='W', upload=False)
Forward filling uses only earlier signals; dates before the first signal are treated as not held. sim() requires at least one asset column and one date row. For an all-cash strategy, keep the assets and dates and set positions to False or 0.
Display Backtest Results
Check Next-Period Targets and Rebalance Timing
report.weights contains the current period's target weights. report.next_weights contains the target for the next rebalance boundary, calculated from the data available now. Before that boundary, next_weights and the rebalance enter/exit actions can change each day; they preview the next period and do not mean you should trade today.
For US stocks with resample='W', the default weekly boundary is Sunday. Seeing next_weights.name equal to 2026-09-27 on 2026-09-22 is only a preview; rerun after the complete Friday 09-25 close data arrives to confirm the target. The backtest executes on the next trading day, 09-28, at the price selected by trade_at_price. Holidays can change the last trading day and the execution date. US due checks currently use market close on the rebalance label's date, so is_rebalance_due() returns True at Sunday 16:00 US Eastern time, not at Friday's close.
Check whether rebalancing is due without comparing dates yourself:
print(report.is_rebalance_due()) # True: rebalance is due
print(report.next_weights)
print(report.actions)
False means rebalancing is not due yet. True only means the rebalance time has arrived; it does not guarantee fresh closing data or executed orders, so rerun sim() with current data when due. Check stop-loss and take-profit actions separately with report.is_stop_triggered().
Daily Taiwan strategies use the exchange trading calendar to set the rebalance window when a usable calendar is available. Run live PortfolioSyncManager.update() on every schedule, not only when rebalancing is due, so it can catch up on missed rebalances.
Get Trade Records
Key Trade Record Fields
* exit_sig_date: Date the exit signal was generated. * entry_sig_date: Date the entry signal was generated. * entry_date: Entry date. * exit_date: Exit date. * period: Holding period in days. * position: Position weight. * return: Return rate. * mdd: Maximum drawdown during the holding period. * mae: Maximum adverse excursion during the holding period. * g_mfe: Maximum favorable excursion during the holding period. * b_mfe: Maximum favorable excursion before MAE occurred.Display Volatility Analysis Chart
Strategy Liquidity Risk Assessment (Taiwan Market Only)
LiquidityAnalysis reports the share of trades that met a minimum volume and turnover and how many trades hit Taiwan's alert, disposition and full-delivery stocks. It supports the Taiwan stock market only.
from finlab.analysis.liquidityAnalysis import LiquidityAnalysis
# Percentage of trades with volume > 100,000 shares and turnover > 1,000,000 TWD, to assess position capacity
report.run_analysis(
LiquidityAnalysis(required_volume=100000, required_turnover=1000000)
)
For U.S. strategies, check capacity with dollar volume (price:adj_close x price:volume) or restrict the universe with etl:us_liquid_stock_filter (20-day average dollar volume above $1M).
More Examples
New High Strategy
from finlab import data
from finlab import backtest
data.set_market('us')
with data.us_universe(index='SP500'):
close = data.get('price:adj_close')
# S&P 500 stocks closing at a 250-day high
position = close == close.rolling(250).max()
report = backtest.sim(position, resample='M', name='S&P 500 new highs')
report.display()
High RSI Technical Indicator Strategy
from finlab import data
from finlab.backtest import sim
data.set_market('us')
with data.us_universe(index='SP500'):
rsi = data.indicator('RSI')
# Top 20 RSI stocks, weekly rebalancing
position = rsi.is_largest(20)
report = sim(position, resample='W', name='High RSI strategy')
Price Deviation + Financial Statement Filter
from finlab import data
from finlab.backtest import sim
data.set_market('us')
with data.us_universe(index='SP500'):
close = data.get('price:adj_close')
net_income = data.get('us_income_statement:net_income')
equity = data.get('us_balance_sheet:total_stockholders_equity')
roe = net_income / equity # quarterly ROE, aligned to each SEC filing date
position = (
(close / close.shift(60)).is_largest(30) # Top 30 by 60-day price change
& (roe > 0) # Positive ROE
)
report = sim(position, resample='M', name='Deviation + ROE filter')
Common Errors and Solutions
Market timing: cash on False dates
When timing is a date-indexed boolean Series, position[timing], position & timing, and position *= timing all keep every date and zero the position on False dates, so sim() holds cash on those dates:
This example uses your strategy's existing position (FinlabDataFrame) and times it with the S&P 500 index.
from finlab import data
from finlab.backtest import sim
sp500 = data.get('world_index:adj_close')['^GSPC']
timing = sp500 > sp500.rolling(20).mean()
position *= timing
report = sim(position, upload=False) # no resample, so the timing signal applies daily
FinlabDataFrame applies timing to every stock by date: False sets the position to zero, without manual reindex or type conversion. When dates differ, its existing alignment rules carry the previous signal forward.
Error 1: Strategy Has No Trade Records
Symptom: Backtest completes but there are no trades; get_trades() returns an empty DataFrame
data.set_market('us')
close = data.get('price:adj_close')
position = (close > 100) & (close < 110) # Conditions too strict
report = sim(position, resample='M')
trades = report.get_trades()
print(len(trades)) # 0
Causes:
- Entry conditions are too strict, causing position to be almost all False
- Date range is too short, with insufficient trading days to trigger entry
- Stock pool after filtering is too small or empty
Solution:
from finlab import data
from finlab.backtest import sim
data.set_market('us')
close = data.get('price:adj_close')
position = (close > 100) & (close < 110)
# Check entry signal statistics
entry_stats = position.sum(axis=1) # Number of entry stocks per day
print("=== Entry Signal Statistics ===")
print(f"Average daily entry stocks: {entry_stats.mean():.2f}")
print(f"Maximum entry stocks: {entry_stats.max()}")
print(f"Minimum entry stocks: {entry_stats.min()}")
print(
f"Trading days with entry signals: {(entry_stats > 0).sum()} / {len(entry_stats)}"
)
# If average entries < 1, conditions are too strict
if entry_stats.mean() < 1:
print(
"\nWarning: Entry conditions are too strict, less than 1 stock per day on average"
)
print("Suggestions:")
print("1. Widen the price range (e.g., 80-120)")
print("2. Use .is_largest(N) to ensure a fixed number of stocks")
print("3. Check data completeness (is data.get() working properly?)")
# Fix example: Use is_largest() to ensure a fixed number
ma20 = close.average(20)
# Rank by a score, not a condition: always the 30 strongest relative to their 20-day average
position_fixed = (close / ma20).is_largest(30)
print("\nUsing fixed-count strategy:")
print(f" Average daily entry stocks: {position_fixed.sum(axis=1).mean():.2f}")
# Run backtest and check results
report = sim(position, resample='M', name="Test Strategy")
trades = report.get_trades()
if len(trades) == 0:
print("\nError: Strategy has no trade records")
print(
"Check the entry signal statistics above to verify if conditions are too strict"
)
else:
print(f"\nBacktest successful, {len(trades)} trades")
print(f" Period: {trades['entry_date'].min()} ~ {trades['exit_date'].max()}")
Advanced Diagnostics:
# Check position status at specific time points
import pandas as pd
# Sample 10 trading days
sample_dates = position.index[:: len(position) // 10][:10]
for date in sample_dates:
stocks = position.loc[date][position.loc[date]].index.tolist()
print(f"{date}: {len(stocks)} stocks - {stocks[:5]}...")
# Check if any stocks never entered
never_entered = position.sum(axis=0) == 0
print(f"\nStocks that never entered: {never_entered.sum()} / {len(position.columns)}")
Error 2: KeyError - Date Index Error
Symptom: KeyError thrown during backtesting with a date that does not exist
Causes:
- Date index ranges are inconsistent when using multiple data sources
- position date range exceeds the available data range
- Using shift() or rolling() causes missing data in early periods
Solution:
from finlab import data
from finlab.backtest import sim
# Method 1: Use truncate_start to align the start date
data.truncate_start = '2020-01-01' # Only backtest data after 2020
data.set_market('us')
close = data.get('price:adj_close')
volume = data.get('price:volume')
# Check date ranges
print(f"Closing price date range: {close.index[0]} ~ {close.index[-1]}")
print(f"Volume date range: {volume.index[0]} ~ {volume.index[-1]}")
# Method 2: Manually align date indices
common_dates = close.index.intersection(volume.index)
close_aligned = close.loc[common_dates]
volume_aligned = volume.loc[common_dates]
print(f"Aligned date range: {common_dates[0]} ~ {common_dates[-1]}")
# Method 3: Use try-except to catch errors
try:
position = (close > close.average(20)) & (volume > 1000)
report = sim(position, resample='M')
print("Backtest successful")
except KeyError as e:
print(f"Date index error: {e}")
print("\nPossible causes:")
print("1. Using rolling() or average() causes missing early-period data")
print("2. Date ranges are inconsistent across multiple data sources")
print("\nSolutions:")
print("1. Set data.truncate_start to unify start date")
print("2. Use .dropna() to remove missing values")
# Auto-fix: remove missing values
position_cleaned = position.dropna(how='all', axis=0) # Remove all-NaN rows
position_cleaned = position_cleaned.fillna(False) # Fill NaN with False
print(f"\nMissing values removed, re-running backtest...")
report = sim(position_cleaned, resample='M')
print(
f" Backtest period: {position_cleaned.index[0]} ~ {position_cleaned.index[-1]}"
)
Advanced Diagnostics: Check position completeness
import pandas as pd
# Check missing values in position
missing_info = position.isna()
print("=== Position Missing Value Statistics ===")
print(f"Total missing values: {missing_info.sum().sum()}")
print(f"All-NaN rows (dates): {missing_info.all(axis=1).sum()}")
print(f"All-NaN columns (stocks): {missing_info.all(axis=0).sum()}")
# Show first few dates with missing values
missing_dates = missing_info.any(axis=1)
if missing_dates.sum() > 0:
print(f"\nFirst 5 dates with missing values:")
for date in position.index[missing_dates][:5]:
na_count = position.loc[date].isna().sum()
print(f" {date}: {na_count} stocks missing")
Error 3: Forgetting to Set resample Results in Frequent Rebalancing
Symptom: Backtest returns are very low with an abnormally high number of trades
report = sim(position, upload=False) # Missing resample parameter
trades = report.get_trades()
print(len(trades)) # Thousands of trades
Causes:
- Without resample, the default None rebalances the whole portfolio whenever any symbol's row in position changes, so a strategy whose signals change every day rebalances every day
- Frequent rebalancing multiplies turnover and trading costs (commissions, taxes and slippage where they apply)
- On every rebalance, the default retain_cost_when_rebalance=False also resets the stop-loss/take-profit basis of every holding, including symbols that stay held; see Rebalance timing and stop basis
- Most strategies should not rebalance this often
Solution:
from finlab import data
from finlab.backtest import sim
data.set_market('us')
close = data.get('price:adj_close')
position = close > close.average(20)
# Wrong: no resample set (signals change daily, so it rebalances almost every day)
report_daily = sim(position, name="Rebalance on Every Change")
trades_daily = report_daily.get_trades()
# Correct: set a reasonable rebalancing frequency
report_monthly = sim(position, resample='M', name="Monthly Rebalancing")
trades_monthly = report_monthly.get_trades()
# Compare trade counts and performance
print("=== Rebalancing Frequency Comparison ===")
print(
f"No resample: {len(trades_daily)} trades, annualized return {report_daily.get_stats()['cagr']:.2%}"
)
print(
f"Monthly rebalancing: {len(trades_monthly)} trades, annualized return {report_monthly.get_stats()['cagr']:.2%}"
)
# Monthly rebalancing usually has far lower turnover and trading costs
if report_monthly.get_stats()['cagr'] > report_daily.get_stats()['cagr']:
print(
"\nRecommendation: Use 'Monthly Rebalancing' to significantly reduce trading costs"
)
Common rebalancing frequencies:
# Weekly strategy (suitable for short-term)
report = sim(position, resample='W', name="Weekly Rebalancing")
# Monthly strategy (most common, suitable for part-time traders)
report = sim(position, resample='M', name="Monthly Rebalancing")
# Quarterly strategy (suitable for long-term)
report = sim(position, resample='Q', name="Quarterly Rebalancing")
# Custom rebalancing frequency (e.g., every 20 trading days)
report = sim(position, resample='20D', name="Every 20 Days Rebalancing")
Reference Resources
- API Reference - backtest - Complete sim() parameter documentation
- Strategy Optimization Guide - Find optimal rebalancing frequency and stop-loss/take-profit parameters
- Complete Strategy Development Workflow - From research to live trading
- Risk Management Guide - Stop-loss/take-profit practical techniques
- FAQ - More troubleshooting