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()from finlab import data
from finlab import backtest
data.set_market('us_fund')
close = data.get('price:adj_close')
position = close[['SPY', 'QQQ']].pct_change(126).is_largest(1)
report = backtest.sim(position, resample='M', name='US ETF momentum')
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:
When a backtest buys and sells
- Day T, after the close Data for day T is published A row dated T holds only what was known by then.
- Day T The signal is read position on day T says what to hold.
- Next trading day The trade fills At trade_at_price, the close by default.
- Until the next rebalance Holdings stay resample='M' rebalances after each month's last trading day.
- Signal: a row of
positionuses 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.
resample | When 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.
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 applies dailyposition[timing], position & timing and position *= timing all keep every date and zero the positions on False dates. FinlabDataFrame applies timing to every stock by date, with no manual reindex or type conversion; when dates differ, the previous signal carries forward.
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()| Column | Meaning |
|---|---|
entry_sig_date | Date the entry signal appeared |
entry_date | Entry date |
exit_sig_date | Date the exit signal appeared |
exit_date | Exit date |
period | Days held |
position | Position weight |
return | Return |
mdd | Max drawdown while held |
mae | Max adverse excursion while held |
gmfe | Max favorable excursion while held |
bmfe | Max favorable excursion before the mae |
How far each trade went for and against you (MAE/MFE)
report.display_mae_mfe_analysis()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()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') # weeklyPrice 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')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 stocksA 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 stocksVery 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
- All sim() parameters Stops, fees, fill prices and every option.
- Strategy optimization Compare rebalance frequencies and stops in one go.
- Risk management Stops and position sizing in practice.
- Update strategies daily Rerun the strategy every day and get rebalance alerts.
Was this page helpful?