Skip to content

Commit 2c039a4

Browse files
committed
ENH: Add annualized return/volatility/Sharpe/… stats
1 parent c5cd1b3 commit 2c039a4

File tree

2 files changed

+38
-10
lines changed

2 files changed

+38
-10
lines changed

backtesting/backtesting.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1339,7 +1339,7 @@ def _compute_stats(self, broker: _Broker, strategy: Strategy) -> pd.Series:
13391339

13401340
equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values
13411341
dd = 1 - equity / np.maximum.accumulate(equity)
1342-
dd_dur, dd_peaks = self._compute_drawdown_duration_peaks(pd.Series(dd, index=data.index))
1342+
dd_dur, dd_peaks = self._compute_drawdown_duration_peaks(pd.Series(dd, index=index))
13431343

13441344
equity_df = pd.DataFrame({
13451345
'Equity': equity,
@@ -1386,25 +1386,51 @@ def _round_timedelta(value, _period=_data_period(index)):
13861386
s.loc['Return [%]'] = (equity[-1] - equity[0]) / equity[0] * 100
13871387
c = data.Close.values
13881388
s.loc['Buy & Hold Return [%]'] = (c[-1] - c[0]) / c[0] * 100 # long-only return
1389-
s.loc['Max. Drawdown [%]'] = max_dd = -np.nan_to_num(dd.max()) * 100
1389+
1390+
def geometric_mean(x):
1391+
return np.exp(np.log(1 + x).sum() / (len(x) or np.nan)) - 1
1392+
1393+
day_returns = gmean_day_return = annual_trading_days = np.array(np.nan)
1394+
if index.is_all_dates:
1395+
day_returns = equity_df['Equity'].resample('D').last().dropna().pct_change()
1396+
gmean_day_return = geometric_mean(day_returns)
1397+
annual_trading_days = (
1398+
365 if (index.dayofweek.to_series().between(5, 6)).mean() > 2 / 7 * .6 else
1399+
252)
1400+
1401+
# Annualized return and risk metrics are computed based on the (mostly correct)
1402+
# assumption that the returns are compounded. See: https://dx.doi.org/10.2139/ssrn.3054517
1403+
# Our annualized return matches `empyrical.annual_return(day_returns)` whereas
1404+
# our risk doesn't; they use the simpler approach below.
1405+
annualized_return = ((1 + gmean_day_return)**annual_trading_days - 1)
1406+
s.loc['Return (Ann.) [%]'] = annualized_return * 100
1407+
s.loc['Risk (Ann.) [%]'] = np.sqrt((day_returns.var(ddof=1) + (1 + gmean_day_return)**2)**annual_trading_days - (1 + gmean_day_return)**(2*annual_trading_days)) * 100 # noqa: E501
1408+
# s.loc['Return (Ann.) [%]'] = gmean_day_return * annual_trading_days * 100
1409+
# s.loc['Risk (Ann.) [%]'] = day_returns.std(ddof=1) * np.sqrt(annual_trading_days) * 100
1410+
1411+
# Our Sharpe mismatches `empyrical.sharpe_ratio()` because they use arithmetic mean return
1412+
# and simple standard deviation
1413+
s.loc['Sharpe Ratio (Ann.)'] = s.loc['Return (Ann.) [%]'] / (s.loc['Risk (Ann.) [%]'] or np.nan) # noqa: E501
1414+
# Our Sortino mismatches `empyrical.sortino_ratio()` because they use arithmetic mean return
1415+
s.loc['Sortino Ratio (Ann.)'] = annualized_return / (np.sqrt(np.mean(day_returns.clip(-np.inf, 0)**2)) * np.sqrt(annual_trading_days)) # noqa: E501
1416+
max_dd = -np.nan_to_num(dd.max()) * 100
1417+
s.loc['Calmar Ratio (Ann.)'] = annualized_return / ((-max_dd / 100) or np.nan)
1418+
s.loc['Max. Drawdown [%]'] = max_dd
13901419
s.loc['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100
13911420
s.loc['Max. Drawdown Duration'] = _round_timedelta(dd_dur.max())
13921421
s.loc['Avg. Drawdown Duration'] = _round_timedelta(dd_dur.mean())
13931422
s.loc['# Trades'] = n_trades = len(trades)
13941423
s.loc['Win Rate [%]'] = win_rate = np.nan if not n_trades else (pl > 0).sum() / n_trades * 100 # noqa: E501
13951424
s.loc['Best Trade [%]'] = returns.max() * 100
13961425
s.loc['Worst Trade [%]'] = returns.min() * 100
1397-
mean_return = np.exp(np.log(1 + returns).sum() / (len(returns) or np.nan)) - 1
1426+
mean_return = geometric_mean(returns)
13981427
s.loc['Avg. Trade [%]'] = mean_return * 100
13991428
s.loc['Max. Trade Duration'] = _round_timedelta(durations.max())
14001429
s.loc['Avg. Trade Duration'] = _round_timedelta(durations.mean())
14011430
s.loc['Profit Factor'] = returns[returns > 0].sum() / (abs(returns[returns < 0].sum()) or np.nan) # noqa: E501
14021431
s.loc['Expectancy [%]'] = ((returns[returns > 0].mean() * win_rate -
14031432
returns[returns < 0].mean() * (100 - win_rate)))
14041433
s.loc['SQN'] = np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan)
1405-
s.loc['Sharpe Ratio'] = mean_return / (returns.std() or np.nan)
1406-
s.loc['Sortino Ratio'] = mean_return / (returns[returns < 0].std() or np.nan)
1407-
s.loc['Calmar Ratio'] = mean_return / ((-max_dd / 100) or np.nan)
14081434

14091435
s.loc['_strategy'] = strategy
14101436
s.loc['_equity_curve'] = equity_df

backtesting/test/_test.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import numpy as np
1515
import pandas as pd
16+
from pandas.testing import assert_series_equal
1617

1718
from backtesting import Backtest, Strategy
1819
from backtesting.lib import (
@@ -249,7 +250,6 @@ def test_compute_drawdown(self):
249250
def test_compute_stats(self):
250251
stats = Backtest(GOOG, SmaCross).run()
251252
# Pandas compares in 'almost equal' manner
252-
from pandas.testing import assert_series_equal
253253
assert_series_equal(
254254
stats.filter(regex='^[^_]').sort_index(),
255255
pd.Series({
@@ -261,7 +261,7 @@ def test_compute_stats(self):
261261
'Avg. Trade [%]': 2.3537113951143773,
262262
'Best Trade [%]': 53.59595229490424,
263263
'Buy & Hold Return [%]': 703.4582419772772,
264-
'Calmar Ratio': 0.049055964204885415,
264+
'Calmar Ratio (Ann.)': 0.4445179349739874,
265265
'Duration': pd.Timedelta('3116 days 00:00:00'),
266266
'End': pd.Timestamp('2013-03-01 00:00:00'),
267267
'Equity Final [$]': 51959.94999999997,
@@ -272,10 +272,12 @@ def test_compute_stats(self):
272272
'Max. Drawdown [%]': -47.98012705007589,
273273
'Max. Trade Duration': pd.Timedelta('183 days 00:00:00'),
274274
'Profit Factor': 2.0880175388920286,
275+
'Return (Ann.) [%]': 21.32802699608929,
275276
'Return [%]': 419.59949999999964,
277+
'Risk (Ann.) [%]': 36.53825234483751,
276278
'SQN': 0.916892986080858,
277-
'Sharpe Ratio': 0.17914126763602636,
278-
'Sortino Ratio': 0.5588698138148217,
279+
'Sharpe Ratio (Ann.)': 0.5837177650097084,
280+
'Sortino Ratio (Ann.)': 1.0923863161583591,
279281
'Start': pd.Timestamp('2004-08-19 00:00:00'),
280282
'Win Rate [%]': 46.15384615384615,
281283
'Worst Trade [%]': -18.39887353835481,

0 commit comments

Comments
 (0)