# Performance and risk metrics (/features/analytics/performance-metrics)

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.

```python title="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:

```python title="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)  # (1)
>>> 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
```

1.  With several columns, `stats` returns one row per column. Pass `agg_func=np.mean` to average each
    metric across columns instead.

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](/features/optimization/robustness-and-overfitting/).

## Returns \[#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"`:

```python title="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 \[#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:

```python title="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 \[#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](/features/analytics/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.

!!! info "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 \[#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](#benchmark) highlight below. The returns accessor
then compares the two:

```python title="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 \[#rolling-metrics]

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

```python title="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)](/assets/figures/features/analytics/performance-metrics-rolling-sharpe.856e51fa45cc.json)

## Attribution and regimes \[#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:

```python title="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()]  # (1)

>>> 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
```

1.  Skip the first 199 bars, where the 200-day average does not exist yet.

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 \[#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:

```python title="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 \[#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 \[#benchmark]

New in 1.0.4

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

```python title="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)](/assets/figures/features/analysis/benchmark.c1d30a1b0d0b.json)


## Related pages

*   [Trade analytics](/features/analytics/trade-analytics/): Analyze trades and positions with MAE, MFE, edge ratio, profit factor, and plots
*   [Backtesting engine](/features/backtesting/backtesting-engine/): Simulate orders, signals, and callbacks across many assets and parameters at once
*   [Robustness and overfitting](/features/optimization/robustness-and-overfitting/): Check whether a backtest is luck with baselines, permutation tests, and deflated Sharpe
*   [Multidimensional research](/features/tooling/multidimensional-research/): Run assets, parameters, and strategies as labeled columns, then index and stack them