Features

Performance and risk metrics

Compute returns, Sharpe, Sortino, benchmark, and rolling metrics on any backtest

How good is a backtest result, and compared with what? VBT computes backtest performance metrics such as total return, Sharpe, Sortino, Calmar, drawdowns, and benchmark statistics in Python for one portfolio or for thousands of them at once, and works on any return series, not only on its own backtests.

Get a full report for one backtest
data = vbt.YFData.pull("BTC-USD", start="2020-01-01", end="2025-01-01")
fast = data.close.rolling(20).mean()
slow = data.close.rolling(50).mean()
pf = vbt.PF.from_signals(
    data,
    fast.vbt.crossed_above(slow),
    fast.vbt.crossed_below(slow),
    fees=0.001,
)
pf.stats()
Start Index                   2020-01-01 00:00:00+00:00
End Index                     2024-12-31 00:00:00+00:00
Total Duration                       1827 days 00:00:00
Start Value                                       100.0
Min Value                                     95.605003
Max Value                                    787.725311
End Value                                    693.387328
Total Return [%]                             593.387328
Benchmark Return [%]                        1197.596406
Position Coverage [%]                         53.858785
Max Gross Exposure [%]                            100.0
Max Drawdown [%]                              58.610935
Max Drawdown Duration                1126 days 00:00:00
Total Orders                                         37
Total Fees Paid                               15.776306
Total Trades                                         19
Win Rate [%]                                  33.333333
Best Trade [%]                               381.070734
Worst Trade [%]                              -19.099978
Avg Winning Trade [%]                         83.748564
Avg Losing Trade [%]                          -8.434649
Avg Winning Trade Duration             93 days 08:00:00
Avg Losing Trade Duration              27 days 04:00:00
Profit Factor                                  1.754588
Expectancy                                    20.504842
Sharpe Ratio                                   1.083913
Calmar Ratio                                   0.805908
Omega Ratio                                    1.249536
Sortino Ratio                                  1.700246
dtype: object

The same call works on a whole grid. Here are 100 moving average pairs, one row each:

Compare selected metrics across 100 backtests
fast_w, slow_w = np.meshgrid(np.arange(5, 55, 5), np.arange(60, 260, 20))
fast = vbt.MA.run(data.close, window=fast_w.ravel(), short_name="fast")
slow = vbt.MA.run(data.close, window=slow_w.ravel(), short_name="slow")
grid_pf = vbt.PF.from_signals(
    data,
    fast.ma_crossed_above(slow),
    fast.ma_crossed_below(slow),
    fees=0.001,
)
metrics = ["total_return", "sharpe_ratio", "max_dd", "total_trades"]
grid_stats = grid_pf.stats(metrics)  
grid_stats.sort_values("Sharpe Ratio", ascending=False).head()
                         Total Return [%]  Sharpe Ratio  Max Drawdown [%]  Total Trades
fast_window slow_window
5           120               1744.634182      1.510302         38.569486             9
            100               1577.778405      1.467615         41.260714            12
25          100               1315.741883      1.405711         40.937689             8
15          100               1241.937564      1.372806         38.948698             9
10          100               1244.107822      1.365325         41.252897            10

The table makes it easy to compare return, drawdown, and trade count alongside Sharpe. To take the best rows into further testing, see Robustness and overfitting.

Returns

Every metric starts from a return series. For a portfolio, pf.returns is the bar-by-bar change in value, with deposits and withdrawals taken out so they do not count as performance. pf.asset_returns ignores cash and measures only the invested part. Convert a price or value series with series.vbt.to_returns(), then use the returns series' vbt.returns accessor for cumulative, total, and annualized returns, volatility, and the ratios on this page.

Annualization needs two numbers: how long a bar is and how long a year is. The year defaults to 365 days, which fits markets that trade every day. For stocks that trade about 252 days a year, set year_freq, or let VBT count the bars per year with "auto":

Annualize stock returns with the right year length
spy = vbt.YFData.pull("SPY", start="2020-01-01", end="2025-01-01")
rets = spy.close.vbt.to_returns()

print(round(rets.vbt.returns(freq="1D").sharpe_ratio(), 3))
0.892
print(round(rets.vbt.returns(freq="1D", year_freq="252 days").sharpe_ratio(), 3))
0.741
print(round(rets.vbt.returns(freq="1D", year_freq="auto").sharpe_ratio(), 3))
0.741

Set it once for a session with vbt.settings.returns.year_freq.

Analyze an external equity curve

You can use the same metrics on an equity curve exported from another backtester, or on returns you have already calculated in Pandas. Here is a small account-value series with no deposits or withdrawals:

Measure returns from an equity curve without creating a portfolio
equity = pd.Series([102.0, 101.0, 104.0, 103.0, 106.0], index=pd.date_range("2026-01-05", periods=5))
external_returns = equity.vbt.to_returns(init_value=100)
external_acc = external_returns.vbt.returns(freq="1D", year_freq="252 days")
pd.Series({
    "Total return [%]": external_acc.total() * 100,
    "Max drawdown [%]": external_acc.max_drawdown() * 100,
}).round(2)
Total return [%]    6.00
Max drawdown [%]   -0.98
dtype: float64

The starting value of 100 includes the first day's gain to 102. The final value of 106 gives a 6% total return. The same accessor works on a DataFrame of return series, so results from several strategies can be compared without rebuilding their trades.

Risk-adjusted ratios

The Sharpe, Sortino, Calmar, Omega, and information ratios, value at risk, conditional value at risk, tail ratio, skew, and kurtosis are all available as properties with defaults and as methods that take arguments, such as pf.get_sharpe_ratio(risk_free=...).

QuestionMetrics to inspect
How much return did the strategy earn relative to its variability?Sharpe ratio
How much return did it earn relative to downside variation?Sortino ratio and downside risk
How does annualized return compare with the worst drawdown?Calmar ratio
What do the worst bar returns look like?Value at risk and conditional value at risk

For the depth, duration, and recovery of individual losses, see Drawdown analysis.

The Sharpe ratio is the mean excess bar return divided by its standard deviation, times the square root of the bars per year. It is not the annualized return divided by the annualized volatility, which compounds first and gives a different number.

Set an annual risk-free rate

Pass an annual rate, for example pf.get_sharpe_ratio(risk_free=0.04) for 4%. VBT converts it to a per-bar return using the configured frequency and year length. For a changing rate, subtract an aligned series of per-bar risk-free returns from your strategy returns, then calculate Sharpe with risk_free=0.

Bars without a position have a return of zero, and those zeros stay in the calculation, because waiting in cash is part of the strategy. To measure only the time in the market, set those returns to NaN before computing the ratio. Returns of individual trades cannot replace bar returns in the Sharpe ratio, because trades have different lengths and cannot be annualized.

Benchmark comparison

Every portfolio has a benchmark. By default it is buy-and-hold of the traded assets, and bm_close sets any other price series, as in the Benchmark highlight below. The returns accessor then compares the two:

Compare the crossover strategy with holding Bitcoin
ret_acc = pf.returns_acc
pd.Series({
    "alpha": ret_acc.alpha(),
    "beta": ret_acc.beta(),
    "information ratio": ret_acc.information_ratio(),
    "up capture": ret_acc.up_capture_ratio(),
    "down capture": ret_acc.down_capture_ratio(),
}).round(3)
alpha                0.142
beta                 0.491
information ratio   -0.027
up capture           0.127
down capture         0.883
dtype: float64

The strategy carries about half of Bitcoin's market exposure. It captured little of the upside and most of the downside relative to holding, which is why it returned half as much.

Rolling metrics

A single number hides how performance changes over time. Every ratio has a rolling version over a window of bars:

Plot a rolling one-year Sharpe ratio against the benchmark
rolling = pd.DataFrame({
    "Strategy": pf.returns_acc.rolling_sharpe_ratio(365),
    "Benchmark": pf.bm_returns.vbt.returns(freq="1D").rolling_sharpe_ratio(365),
})
rolling.vbt.plot().show()
Rolling one-year Sharpe ratio of a Bitcoin moving average strategy and of holding Bitcoin from 2020 to 2024 Figure data (JSON)

Attribution and regimes

The return of a group is the return of its combined value, not the sum of its members' returns. To split it by asset, weight each column's return by its share of the group value on the bar before, cash included. To compare market regimes, label each bar with its regime, group the returns by label, and compute the metrics on each group through the returns accessor:

Compare Sharpe ratios in uptrends and downtrends
sma200 = data.close.rolling(200).mean()
regime = pd.Series(
    np.where(data.close > sma200, "uptrend", "downtrend"),
    index=data.index,
    name="regime",
)[sma200.notna()]  

def sharpe(returns):
    return returns.vbt.returns(freq="1D").sharpe_ratio()

pd.DataFrame({
    "bars": regime.value_counts(),
    "strategy": pf.returns.groupby(regime).apply(sharpe),
    "buy and hold": pf.bm_returns.groupby(regime).apply(sharpe),
}).round(2)
           bars  strategy  buy and hold
regime
downtrend   617      -1.3         -1.14
uptrend    1011       2.0          2.67

The strategy earned its returns in uptrends and lost in downtrends, and holding had the better Sharpe ratio in both. A split like this shows whether a rule adds anything beyond the trend of the market itself.

Statistics reports

stats builds the report from a list of metrics. Choose the ones you need, which also makes it faster on large grids, or add your own next to the built-in ones:

Add a custom metric to the report
pf.stats([
    "total_return",
    "sharpe_ratio",
    ("trades_per_year", dict(
        title="Trades per Year",
        calc_func=lambda self: self.trades.count() / (len(self.wrapper.index) / 365),
    )),
])
Total Return [%]    593.387328
Sharpe Ratio          1.083913
Trades per Year        3.79584
dtype: object

Durations in a report count bars times the frequency, not calendar time. Four daily bars spread over nine calendar days report a total duration of 4 days, so weekends and holidays that are not in the data are not counted.

Settings pass through to every metric, for example pf.stats(settings=dict(use_asset_returns=True)) to compute the return metrics on asset returns. The same reports exist for returns (returns_stats), trades, drawdowns, and orders, and stats on a grouped portfolio reports per group. For very large grids, compute stats on chunks of columns in parallel with vbt.chunked.

QuantStats

With QuantStats installed, pf.qs exposes its functions, plots, and reports with the portfolio's returns, benchmark, and frequency already filled in. For example, pf.qs.sharpe() returns the QuantStats version of the ratio, and pf.qs.html_report() builds its full tearsheet. The adapter also translates the risk-free rate and annualization settings for QuantStats.

Benchmark

✅ The benchmark can now be easily set for your entire portfolio.

Compare Microsoft to S&P 500
data = vbt.YFData.pull(["SPY", "MSFT"], start="2010", missing_columns="drop")

pf = vbt.PF.from_holding(
    close=data.data["MSFT"]["Close"],
    bm_close=data.data["SPY"]["Close"]
)
pf.plot_cumulative_returns().show()
Cumulative returns of Microsoft compared with the S&P 500 benchmark Figure data (JSON)

Copyright © 2021–2026 Oleg Polakow. All rights reserved.

Site content and documentation are provided for using and evaluating VectorBT PRO and for educational purposes. Any other use, including building or supporting competing products or services, requires prior written consent.