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.
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: objectThe same call works on a whole grid. Here are 100 moving average pairs, one row each:
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 10The 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":
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.892print(round(rets.vbt.returns(freq="1D", year_freq="252 days").sharpe_ratio(), 3))0.741print(round(rets.vbt.returns(freq="1D", year_freq="auto").sharpe_ratio(), 3))0.741Set 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:
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: float64The 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=...).
| Question | Metrics 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:
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: float64The 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:
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()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:
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.67The 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:
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: objectDurations 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.
✅ The benchmark can now be easily set for your entire portfolio.
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()Related pages
- Trade analyticsAnalyze trades and positions with MAE, MFE, edge ratio, profit factor, and plots
- Backtesting › Backtesting engineSimulate orders, signals, and callbacks across many assets and parameters at once
- Optimization and validation › Robustness and overfittingCheck whether a backtest is luck with baselines, permutation tests, and deflated Sharpe
- Research toolkit › Multidimensional researchRun assets, parameters, and strategies as labeled columns, then index and stack them
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.