Docs
Open Studio

Guides

Backtesting

On this page8

backtest.sim(position) turns a table of what to hold each day into a full backtest report: the equity curve, risk figures and every trade. If you haven't written a position yet, start with Stock selection rules.

Run a backtest

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)

# Rebalance at each month end
report = backtest.sim(position, resample='M', name='S&P 500 momentum top 20')
report.display()

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.

report.display() draws this report; you can explore it right here:

Backtest report · interactive Open full size

When a backtest buys and sells

  1. Day T, after the close Data for day T is published A row dated T holds only what was known by then.
  2. Day T The signal is read position on day T says what to hold.
  3. Next trading day The trade fills At trade_at_price, the close by default.
  4. Until the next rebalance Holdings stay resample='M' rebalances after each month's last trading day.
  • Signal: a row of position uses only data published by that day. A financial statement is dated by its filing; volume and turnover after that day's close.
  • Fill: trades fill on the next trading day after the signal, at trade_at_price, the close by default.
  • Rebalancing: set by resample.
resampleWhen it rebalances
Not set (None, the default)Whenever any stock's row in position differs from the previous row, the whole portfolio rebalances
'W'Weekly
'M'At each month end; the most common
'Q'Quarterly
'20D'Every 20 days
from finlab import data
from finlab.backtest import sim

data.set_market('us')
close = data.get('price:adj_close')
position = close > close.average(20)

report_daily = sim(position, name='Rebalance on any change')   # no resample
report_monthly = sim(position, resample='M', name='Monthly')

print(len(report_daily.get_trades()), report_daily.get_stats()['cagr'])
print(len(report_monthly.get_trades()), report_monthly.get_stats()['cagr'])

Hold stocks only when the market is up

To hold only in a bull market, combine the positions with a date-indexed True/False Series: on False dates the positions go to zero and sim() holds cash.

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)

Combining stock positions directly with an index table such as world_index:adj_close (position & mk) gives zero asset columns, because the two share no columns. Select a date-indexed Series, here the S&P 500 (^GSPC), and put it to the right of the stock positions so the signal applies to every stock. Forward filling uses only earlier signals; dates before the first signal count as not held.

sim() needs at least one asset column and one date row. For an all-cash strategy, keep the assets and dates and set the positions to False or 0.

Read the report and every trade

report.get_stats() returns figures such as annual return, max drawdown and Sharpe ratio; report.get_trades() returns every trade:

report.get_trades()
ColumnMeaning
entry_sig_dateDate the entry signal appeared
entry_dateEntry date
exit_sig_dateDate the exit signal appeared
exit_dateExit date
periodDays held
positionPosition weight
returnReturn
mddMax drawdown while held
maeMax adverse excursion while held
gmfeMax favorable excursion while held
bmfeMax favorable excursion before the mae

How far each trade went for and against you (MAE/MFE)

report.display_mae_mfe_analysis()
Backtest report · interactive Open full size

Can it absorb more money (liquidity)

For US strategies, check capacity with dollar volume (price:adj_close × price:volume), or restrict the universe with etl:us_liquid_stock_filter (20-day average dollar volume above $1M). The built-in LiquidityAnalysis module covers the Taiwan market only. See Analysis modules for more.

Next period's holdings and when to rebalance

report.weights holds this period's target weights; report.next_weights the target for the next rebalance boundary, computed from the data available now. Before that boundary, next_weights and the rebalance enter/exit actions can change daily: they preview the next period and don't mean you should trade today.

Ask whether rebalancing is due instead of comparing dates yourself:

print(report.is_rebalance_due())   # True: rebalance is due
print(report.next_weights)
print(report.actions)

False means not yet. True only means the time has come; it doesn't guarantee fresh closing data or filled orders, so rerun sim() with current data when due. Check stop-loss and take-profit actions separately with report.is_stop_triggered().

Example: when a weekly US rebalance happens

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 list. The backtest fills on the next trading day, 09-28, at trade_at_price. Holidays can move the last trading day and the fill date. US due checks use the 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.

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.

More examples

New highs

from finlab import data
from finlab import backtest

data.set_market('us')
with data.us_universe(index='SP500'):
    close = data.get('price:adj_close')

position = close == close.rolling(250).max()   # S&P 500 stocks at a 250-day high
report = backtest.sim(position, resample='M', name='S&P 500 new highs')
report.display()
Backtest report · interactive Open full size

The 20 highest RSI

from finlab import data
from finlab.backtest import sim

data.set_market('us')
with data.us_universe(index='SP500'):
    rsi = data.indicator('RSI')

position = rsi.is_largest(20)
report = sim(position, resample='W', name='High RSI strategy')   # weekly
Backtest report · interactive Open full size

Price momentum with a profitability 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')
Backtest report · interactive Open full size

Common problems

The backtest finished with no trades

get_trades() returns an empty table, usually because the rule is too strict and position is almost all False; the date range may also be too short, or the filtered universe too small or empty. Count how many stocks qualify each day:

from finlab import data

data.set_market('us')
close = data.get('price:adj_close')
position = (close > 100) & (close < 110)

per_day = position.sum(axis=1)   # stocks qualifying each day
print(f'{per_day.mean():.2f} a day on average, at most {per_day.max()}')
print(f'days with a signal: {(per_day > 0).sum()} / {len(per_day)}')
print(f'stocks never held: {(position.sum(axis=0) == 0).sum()} / {len(position.columns)}')

With fewer than one a day on average, loosen the rule (for example a wider price range), or pick a fixed number each day with is_largest(N), and check that data.get() returned complete data:

ma20 = close.average(20)
position_fixed = (close > ma20).is_largest(30)   # always 30 stocks
A KeyError says a date doesn't exist

For example KeyError: Timestamp('2023-05-01 00:00:00'). Usual causes: several datasets cover different date ranges, position reaches past the available data, or shift() and rolling() leave early periods empty. Fixes:

from finlab import data
from finlab.backtest import sim

# 1. Start everything on the same date
data.truncate_start = '2020-01-01'   # backtest from 2020 only

data.set_market('us')
close = data.get('price:adj_close')
volume = data.get('price:volume')
print(close.index[0], close.index[-1], volume.index[0], volume.index[-1])

# 2. Align dates by hand
common_dates = close.index.intersection(volume.index)
close_aligned = close.loc[common_dates]
volume_aligned = volume.loc[common_dates]

# 3. Drop dates that are all NaN and treat the remaining NaN as not held
position = (close > close.average(20)) & (volume > 1000)
position_cleaned = position.dropna(how='all', axis=0).fillna(False)
report = sim(position_cleaned, resample='M')

See how much of position is missing:

missing = position.isna()
print(missing.sum().sum(), missing.all(axis=1).sum(), missing.all(axis=0).sum())   # total, empty dates, empty stocks
Very many trades and low returns

Usually resample isn't set; see When a backtest buys and sells. Common choices: weekly ('W', short term), monthly ('M', the most common), quarterly ('Q', long term) or a number of days ('20D').

Next steps

↑↓ Move Enter Open Esc Close