Skip to content

Commit 028f02d

Browse files
authored
ENH: Add *annualized* return/volatility/Sharpe/Sortino stats (#156)
* ENH: Add annualized return/volatility/Sharpe/… stats * Remove "(Ann)" from Sharpe/Sortino/Calmar ratios Annualization is assumed and keeps labels backcompat. * Rename "Risk" to "Volatility" * Clip ratios to [0, inf)
1 parent dfadfd7 commit 028f02d

File tree

2 files changed

+37
-9
lines changed

2 files changed

+37
-9
lines changed

backtesting/backtesting.py

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

13451345
equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values
13461346
dd = 1 - equity / np.maximum.accumulate(equity)
1347-
dd_dur, dd_peaks = self._compute_drawdown_duration_peaks(pd.Series(dd, index=data.index))
1347+
dd_dur, dd_peaks = self._compute_drawdown_duration_peaks(pd.Series(dd, index=index))
13481348

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

14141440
s.loc['_strategy'] = strategy
14151441
s.loc['_equity_curve'] = equity_df

backtesting/test/_test.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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': 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+
'Volatility (Ann.) [%]': 36.53825234483751,
276278
'SQN': 0.916892986080858,
277-
'Sharpe Ratio': 0.17914126763602636,
278-
'Sortino Ratio': 0.5588698138148217,
279+
'Sharpe Ratio': 0.5837177650097084,
280+
'Sortino Ratio': 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)