From 3f1b1a47a5827b21bd114824ee3c6e4d3be53d4b Mon Sep 17 00:00:00 2001 From: Nazim Date: Sat, 20 Mar 2021 01:04:26 -0400 Subject: [PATCH 01/14] ENH: Extract methods _compute_stats and _compute_drawdown_duration_peaks from Backtest --- backtesting/backtesting.py | 134 ++----------------------------------- 1 file changed, 4 insertions(+), 130 deletions(-) diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index d4535f44..20fbc5b9 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -21,6 +21,8 @@ import numpy as np import pandas as pd +from ._stats import compute_stats + try: from tqdm.auto import tqdm as _tqdm _tqdm = partial(_tqdm, leave=False) @@ -29,7 +31,7 @@ def _tqdm(seq, **_): return seq from ._plotting import plot -from ._util import _as_str, _Indicator, _Data, _data_period, try_ +from ._util import _as_str, _Indicator, _Data, try_ __pdoc__ = { 'Strategy.__init__': False, @@ -1177,7 +1179,7 @@ def run(self, **kwargs) -> pd.Series: # for future `indicator._opts['data'].index` calls to work data._set_length(len(self._data)) - self._results = self._compute_stats(broker, strategy) + self._results = compute_stats(self._data, broker, strategy) return self._results def optimize(self, *, @@ -1485,134 +1487,6 @@ def _mp_task(backtest_uuid, batch_index): _mp_backtests: Dict[float, Tuple['Backtest', List, Callable]] = {} - @staticmethod - def _compute_drawdown_duration_peaks(dd: pd.Series): - iloc = np.unique(np.r_[(dd == 0).values.nonzero()[0], len(dd) - 1]) - iloc = pd.Series(iloc, index=dd.index[iloc]) - df = iloc.to_frame('iloc').assign(prev=iloc.shift()) - df = df[df['iloc'] > df['prev'] + 1].astype(int) - # If no drawdown since no trade, avoid below for pandas sake and return nan series - if not len(df): - return (dd.replace(0, np.nan),) * 2 - df['duration'] = df['iloc'].map(dd.index.__getitem__) - df['prev'].map(dd.index.__getitem__) - df['peak_dd'] = df.apply(lambda row: dd.iloc[row['prev']:row['iloc'] + 1].max(), axis=1) - df = df.reindex(dd.index) - return df['duration'], df['peak_dd'] - - def _compute_stats(self, broker: _Broker, strategy: Strategy) -> pd.Series: - data = self._data - index = data.index - - equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values - dd = 1 - equity / np.maximum.accumulate(equity) - dd_dur, dd_peaks = self._compute_drawdown_duration_peaks(pd.Series(dd, index=index)) - - equity_df = pd.DataFrame({ - 'Equity': equity, - 'DrawdownPct': dd, - 'DrawdownDuration': dd_dur}, - index=index) - - trades = broker.closed_trades - trades_df = pd.DataFrame({ - 'Size': [t.size for t in trades], - 'EntryBar': [t.entry_bar for t in trades], - 'ExitBar': [t.exit_bar for t in trades], - 'EntryPrice': [t.entry_price for t in trades], - 'ExitPrice': [t.exit_price for t in trades], - 'PnL': [t.pl for t in trades], - 'ReturnPct': [t.pl_pct for t in trades], - 'EntryTime': [t.entry_time for t in trades], - 'ExitTime': [t.exit_time for t in trades], - }) - trades_df['Duration'] = trades_df['ExitTime'] - trades_df['EntryTime'] - - pl = trades_df['PnL'] - returns = trades_df['ReturnPct'] - durations = trades_df['Duration'] - - def _round_timedelta(value, _period=_data_period(index)): - if not isinstance(value, pd.Timedelta): - return value - resolution = getattr(_period, 'resolution_string', None) or _period.resolution - return value.ceil(resolution) - - s = pd.Series(dtype=object) - s.loc['Start'] = index[0] - s.loc['End'] = index[-1] - s.loc['Duration'] = s.End - s.Start - - have_position = np.repeat(0, len(index)) - for t in trades: - have_position[t.entry_bar:t.exit_bar + 1] = 1 # type: ignore - - s.loc['Exposure Time [%]'] = have_position.mean() * 100 # In "n bars" time, not index time - s.loc['Equity Final [$]'] = equity[-1] - s.loc['Equity Peak [$]'] = equity.max() - s.loc['Return [%]'] = (equity[-1] - equity[0]) / equity[0] * 100 - c = data.Close.values - s.loc['Buy & Hold Return [%]'] = (c[-1] - c[0]) / c[0] * 100 # long-only return - - def geometric_mean(returns): - returns = returns.fillna(0) + 1 - return (0 if np.any(returns <= 0) else - np.exp(np.log(returns).sum() / (len(returns) or np.nan)) - 1) - - day_returns = gmean_day_return = np.array(np.nan) - annual_trading_days = np.nan - if isinstance(index, pd.DatetimeIndex): - day_returns = equity_df['Equity'].resample('D').last().dropna().pct_change() - gmean_day_return = geometric_mean(day_returns) - annual_trading_days = float( - 365 if index.dayofweek.to_series().between(5, 6).mean() > 2/7 * .6 else - 252) - - # Annualized return and risk metrics are computed based on the (mostly correct) - # assumption that the returns are compounded. See: https://dx.doi.org/10.2139/ssrn.3054517 - # Our annualized return matches `empyrical.annual_return(day_returns)` whereas - # our risk doesn't; they use the simpler approach below. - annualized_return = (1 + gmean_day_return)**annual_trading_days - 1 - s.loc['Return (Ann.) [%]'] = annualized_return * 100 - s.loc['Volatility (Ann.) [%]'] = np.sqrt((day_returns.var(ddof=int(bool(day_returns.shape))) + (1 + gmean_day_return)**2)**annual_trading_days - (1 + gmean_day_return)**(2*annual_trading_days)) * 100 # noqa: E501 - # s.loc['Return (Ann.) [%]'] = gmean_day_return * annual_trading_days * 100 - # s.loc['Risk (Ann.) [%]'] = day_returns.std(ddof=1) * np.sqrt(annual_trading_days) * 100 - - # Our Sharpe mismatches `empyrical.sharpe_ratio()` because they use arithmetic mean return - # and simple standard deviation - s.loc['Sharpe Ratio'] = np.clip(s.loc['Return (Ann.) [%]'] / (s.loc['Volatility (Ann.) [%]'] or np.nan), 0, np.inf) # noqa: E501 - # Our Sortino mismatches `empyrical.sortino_ratio()` because they use arithmetic mean return - 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 - max_dd = -np.nan_to_num(dd.max()) - s.loc['Calmar Ratio'] = np.clip(annualized_return / (-max_dd or np.nan), 0, np.inf) - s.loc['Max. Drawdown [%]'] = max_dd * 100 - s.loc['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100 - s.loc['Max. Drawdown Duration'] = _round_timedelta(dd_dur.max()) - s.loc['Avg. Drawdown Duration'] = _round_timedelta(dd_dur.mean()) - s.loc['# Trades'] = n_trades = len(trades) - s.loc['Win Rate [%]'] = np.nan if not n_trades else (pl > 0).sum() / n_trades * 100 # noqa: E501 - s.loc['Best Trade [%]'] = returns.max() * 100 - s.loc['Worst Trade [%]'] = returns.min() * 100 - mean_return = geometric_mean(returns) - s.loc['Avg. Trade [%]'] = mean_return * 100 - s.loc['Max. Trade Duration'] = _round_timedelta(durations.max()) - s.loc['Avg. Trade Duration'] = _round_timedelta(durations.mean()) - s.loc['Profit Factor'] = returns[returns > 0].sum() / (abs(returns[returns < 0].sum()) or np.nan) # noqa: E501 - s.loc['Expectancy [%]'] = returns.mean() * 100 - s.loc['SQN'] = np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan) - - s.loc['_strategy'] = strategy - s.loc['_equity_curve'] = equity_df - s.loc['_trades'] = trades_df - - s = Backtest._Stats(s) - return s - - class _Stats(pd.Series): - def __repr__(self): - # Prevent expansion due to _equity and _trades dfs - with pd.option_context('max_colwidth', 20): - return super().__repr__() - def plot(self, *, results: pd.Series = None, filename=None, plot_width=None, plot_equity=True, plot_return=False, plot_pl=True, plot_volume=True, plot_drawdown=False, From f99ce5f74d7c0620f3bec1025f629fb35300f918 Mon Sep 17 00:00:00 2001 From: Nazim Date: Sat, 20 Mar 2021 01:04:51 -0400 Subject: [PATCH 02/14] ENH: Move compute stats methods to new file, _stats.py --- backtesting/_stats.py | 133 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 backtesting/_stats.py diff --git a/backtesting/_stats.py b/backtesting/_stats.py new file mode 100644 index 00000000..422fdcda --- /dev/null +++ b/backtesting/_stats.py @@ -0,0 +1,133 @@ +import numpy as np +import pandas as pd + +from ._util import _data_period + + +def compute_drawdown_duration_peaks(dd: pd.Series): + iloc = np.unique(np.r_[(dd == 0).values.nonzero()[0], len(dd) - 1]) + iloc = pd.Series(iloc, index=dd.index[iloc]) + df = iloc.to_frame('iloc').assign(prev=iloc.shift()) + df = df[df['iloc'] > df['prev'] + 1].astype(int) + # If no drawdown since no trade, avoid below for pandas sake and return nan series + if not len(df): + return (dd.replace(0, np.nan),) * 2 + df['duration'] = df['iloc'].map(dd.index.__getitem__) - df['prev'].map(dd.index.__getitem__) + df['peak_dd'] = df.apply(lambda row: dd.iloc[row['prev']:row['iloc'] + 1].max(), axis=1) + df = df.reindex(dd.index) + return df['duration'], df['peak_dd'] + + +def compute_stats(data, broker, strategy) -> pd.Series: + index = data.index + + equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values + dd = 1 - equity / np.maximum.accumulate(equity) + dd_dur, dd_peaks = compute_drawdown_duration_peaks(pd.Series(dd, index=index)) + + equity_df = pd.DataFrame({ + 'Equity': equity, + 'DrawdownPct': dd, + 'DrawdownDuration': dd_dur}, + index=index) + + trades = broker.closed_trades + trades_df = pd.DataFrame({ + 'Size': [t.size for t in trades], + 'EntryBar': [t.entry_bar for t in trades], + 'ExitBar': [t.exit_bar for t in trades], + 'EntryPrice': [t.entry_price for t in trades], + 'ExitPrice': [t.exit_price for t in trades], + 'PnL': [t.pl for t in trades], + 'ReturnPct': [t.pl_pct for t in trades], + 'EntryTime': [t.entry_time for t in trades], + 'ExitTime': [t.exit_time for t in trades], + }) + trades_df['Duration'] = trades_df['ExitTime'] - trades_df['EntryTime'] + + pl = trades_df['PnL'] + returns = trades_df['ReturnPct'] + durations = trades_df['Duration'] + + def _round_timedelta(value, _period=_data_period(index)): + if not isinstance(value, pd.Timedelta): + return value + resolution = getattr(_period, 'resolution_string', None) or _period.resolution + return value.ceil(resolution) + + s = pd.Series(dtype=object) + s.loc['Start'] = index[0] + s.loc['End'] = index[-1] + s.loc['Duration'] = s.End - s.Start + + have_position = np.repeat(0, len(index)) + for t in trades: + have_position[t.entry_bar:t.exit_bar + 1] = 1 # type: ignore + + s.loc['Exposure Time [%]'] = have_position.mean() * 100 # In "n bars" time, not index time + s.loc['Equity Final [$]'] = equity[-1] + s.loc['Equity Peak [$]'] = equity.max() + s.loc['Return [%]'] = (equity[-1] - equity[0]) / equity[0] * 100 + c = data.Close.values + s.loc['Buy & Hold Return [%]'] = (c[-1] - c[0]) / c[0] * 100 # long-only return + + def geometric_mean(returns): + returns = returns.fillna(0) + 1 + return (0 if np.any(returns <= 0) else + np.exp(np.log(returns).sum() / (len(returns) or np.nan)) - 1) + + day_returns = gmean_day_return = np.array(np.nan) + annual_trading_days = np.nan + if isinstance(index, pd.DatetimeIndex): + day_returns = equity_df['Equity'].resample('D').last().dropna().pct_change() + gmean_day_return = geometric_mean(day_returns) + annual_trading_days = float( + 365 if index.dayofweek.to_series().between(5, 6).mean() > 2/7 * .6 else + 252) + + # Annualized return and risk metrics are computed based on the (mostly correct) + # assumption that the returns are compounded. See: https://dx.doi.org/10.2139/ssrn.3054517 + # Our annualized return matches `empyrical.annual_return(day_returns)` whereas + # our risk doesn't; they use the simpler approach below. + annualized_return = (1 + gmean_day_return)**annual_trading_days - 1 + s.loc['Return (Ann.) [%]'] = annualized_return * 100 + s.loc['Volatility (Ann.) [%]'] = np.sqrt((day_returns.var(ddof=int(bool(day_returns.shape))) + (1 + gmean_day_return)**2)**annual_trading_days - (1 + gmean_day_return)**(2*annual_trading_days)) * 100 # noqa: E501 + # s.loc['Return (Ann.) [%]'] = gmean_day_return * annual_trading_days * 100 + # s.loc['Risk (Ann.) [%]'] = day_returns.std(ddof=1) * np.sqrt(annual_trading_days) * 100 + + # Our Sharpe mismatches `empyrical.sharpe_ratio()` because they use arithmetic mean return + # and simple standard deviation + s.loc['Sharpe Ratio'] = np.clip(s.loc['Return (Ann.) [%]'] / (s.loc['Volatility (Ann.) [%]'] or np.nan), 0, np.inf) # noqa: E501 + # Our Sortino mismatches `empyrical.sortino_ratio()` because they use arithmetic mean return + 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 + max_dd = -np.nan_to_num(dd.max()) + s.loc['Calmar Ratio'] = np.clip(annualized_return / (-max_dd or np.nan), 0, np.inf) + s.loc['Max. Drawdown [%]'] = max_dd * 100 + s.loc['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100 + s.loc['Max. Drawdown Duration'] = _round_timedelta(dd_dur.max()) + s.loc['Avg. Drawdown Duration'] = _round_timedelta(dd_dur.mean()) + s.loc['# Trades'] = n_trades = len(trades) + s.loc['Win Rate [%]'] = np.nan if not n_trades else (pl > 0).sum() / n_trades * 100 # noqa: E501 + s.loc['Best Trade [%]'] = returns.max() * 100 + s.loc['Worst Trade [%]'] = returns.min() * 100 + mean_return = geometric_mean(returns) + s.loc['Avg. Trade [%]'] = mean_return * 100 + s.loc['Max. Trade Duration'] = _round_timedelta(durations.max()) + s.loc['Avg. Trade Duration'] = _round_timedelta(durations.mean()) + s.loc['Profit Factor'] = returns[returns > 0].sum() / (abs(returns[returns < 0].sum()) or np.nan) # noqa: E501 + s.loc['Expectancy [%]'] = returns.mean() * 100 + s.loc['SQN'] = np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan) + + s.loc['_strategy'] = strategy + s.loc['_equity_curve'] = equity_df + s.loc['_trades'] = trades_df + + s = _Stats(s) + return s + + +class _Stats(pd.Series): + def __repr__(self): + # Prevent expansion due to _equity and _trades dfs + with pd.option_context('max_colwidth', 20): + return super().__repr__() From d302b13863d126b0eec10571f69ce1c69e9b3816 Mon Sep 17 00:00:00 2001 From: Nazim Date: Sat, 20 Mar 2021 01:05:14 -0400 Subject: [PATCH 03/14] TST: Update unit tests for compute_drawdown_duration_peaks --- backtesting/test/_test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backtesting/test/_test.py b/backtesting/test/_test.py index c2698a93..0481ebde 100644 --- a/backtesting/test/_test.py +++ b/backtesting/test/_test.py @@ -15,6 +15,7 @@ import pandas as pd from backtesting import Backtest, Strategy +from backtesting._stats import compute_drawdown_duration_peaks from backtesting.lib import ( OHLCV_AGG, barssince, @@ -242,7 +243,7 @@ def test_strategy_str(self): def test_compute_drawdown(self): dd = pd.Series([0, 1, 7, 0, 4, 0, 0]) - durations, peaks = Backtest._compute_drawdown_duration_peaks(dd) + durations, peaks = compute_drawdown_duration_peaks(dd) np.testing.assert_array_equal(durations, pd.Series([3, 2], index=[3, 5]).reindex(dd.index)) np.testing.assert_array_equal(peaks, pd.Series([7, 4], index=[3, 5]).reindex(dd.index)) From 691f39f501b4cfcd41e4e173076fbb31c443c16b Mon Sep 17 00:00:00 2001 From: Nazim Date: Sat, 20 Mar 2021 01:34:28 -0400 Subject: [PATCH 04/14] TST: Remove ignore type for CI test failure --- backtesting/_stats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backtesting/_stats.py b/backtesting/_stats.py index 422fdcda..3492691e 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -62,7 +62,7 @@ def _round_timedelta(value, _period=_data_period(index)): have_position = np.repeat(0, len(index)) for t in trades: - have_position[t.entry_bar:t.exit_bar + 1] = 1 # type: ignore + have_position[t.entry_bar:t.exit_bar + 1] = 1 s.loc['Exposure Time [%]'] = have_position.mean() * 100 # In "n bars" time, not index time s.loc['Equity Final [$]'] = equity[-1] From bd1075b74d2802ec145ad40492416766ca2207e5 Mon Sep 17 00:00:00 2001 From: Nazim Date: Tue, 23 Mar 2021 23:08:06 -0400 Subject: [PATCH 05/14] REF: Remove broker dependency from compute_stats, update sharpe ratio to use risk free rate --- backtesting/_stats.py | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/backtesting/_stats.py b/backtesting/_stats.py index 3492691e..ab3559b3 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -1,3 +1,5 @@ +from typing import List + import numpy as np import pandas as pd @@ -9,19 +11,32 @@ def compute_drawdown_duration_peaks(dd: pd.Series): iloc = pd.Series(iloc, index=dd.index[iloc]) df = iloc.to_frame('iloc').assign(prev=iloc.shift()) df = df[df['iloc'] > df['prev'] + 1].astype(int) + # If no drawdown since no trade, avoid below for pandas sake and return nan series if not len(df): return (dd.replace(0, np.nan),) * 2 + df['duration'] = df['iloc'].map(dd.index.__getitem__) - df['prev'].map(dd.index.__getitem__) df['peak_dd'] = df.apply(lambda row: dd.iloc[row['prev']:row['iloc'] + 1].max(), axis=1) df = df.reindex(dd.index) return df['duration'], df['peak_dd'] -def compute_stats(data, broker, strategy) -> pd.Series: - index = data.index +def geometric_mean(returns: pd.Series) -> float: + returns = returns.fillna(0) + 1 + if np.any(returns <= 0): + return 0 + + return np.exp(np.log(returns).sum() / (len(returns) or np.nan)) - 1 - equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values + +def compute_stats( + trades: List[pd.DataFrame], + equity: np.ndarray, + ohlc_data: pd.DataFrame, + risk_free_rate: float = 0) -> pd.Series: + + index = ohlc_data.index dd = 1 - equity / np.maximum.accumulate(equity) dd_dur, dd_peaks = compute_drawdown_duration_peaks(pd.Series(dd, index=index)) @@ -31,7 +46,6 @@ def compute_stats(data, broker, strategy) -> pd.Series: 'DrawdownDuration': dd_dur}, index=index) - trades = broker.closed_trades trades_df = pd.DataFrame({ 'Size': [t.size for t in trades], 'EntryBar': [t.entry_bar for t in trades], @@ -68,15 +82,11 @@ def _round_timedelta(value, _period=_data_period(index)): s.loc['Equity Final [$]'] = equity[-1] s.loc['Equity Peak [$]'] = equity.max() s.loc['Return [%]'] = (equity[-1] - equity[0]) / equity[0] * 100 - c = data.Close.values + c = ohlc_data.Close.values s.loc['Buy & Hold Return [%]'] = (c[-1] - c[0]) / c[0] * 100 # long-only return - def geometric_mean(returns): - returns = returns.fillna(0) + 1 - return (0 if np.any(returns <= 0) else - np.exp(np.log(returns).sum() / (len(returns) or np.nan)) - 1) - - day_returns = gmean_day_return = np.array(np.nan) + gmean_day_return: float = 0 + day_returns = np.array(np.nan) annual_trading_days = np.nan if isinstance(index, pd.DatetimeIndex): day_returns = equity_df['Equity'].resample('D').last().dropna().pct_change() @@ -97,7 +107,7 @@ def geometric_mean(returns): # Our Sharpe mismatches `empyrical.sharpe_ratio()` because they use arithmetic mean return # and simple standard deviation - s.loc['Sharpe Ratio'] = np.clip(s.loc['Return (Ann.) [%]'] / (s.loc['Volatility (Ann.) [%]'] or np.nan), 0, np.inf) # noqa: E501 + s.loc['Sharpe Ratio'] = np.clip((s.loc['Return (Ann.) [%]'] - risk_free_rate) / (s.loc['Volatility (Ann.) [%]'] or np.nan), 0, np.inf) # noqa: E501 # Our Sortino mismatches `empyrical.sortino_ratio()` because they use arithmetic mean return 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 max_dd = -np.nan_to_num(dd.max()) @@ -118,7 +128,6 @@ def geometric_mean(returns): s.loc['Expectancy [%]'] = returns.mean() * 100 s.loc['SQN'] = np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan) - s.loc['_strategy'] = strategy s.loc['_equity_curve'] = equity_df s.loc['_trades'] = trades_df From 3d3164d29bad3a3952ac07a81a3a23fb0f98b170 Mon Sep 17 00:00:00 2001 From: Nazim Date: Tue, 23 Mar 2021 23:10:37 -0400 Subject: [PATCH 06/14] REF: Update self._results to account for compute_stats change, fix typo --- backtesting/backtesting.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index 20fbc5b9..c5131d53 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -21,7 +21,6 @@ import numpy as np import pandas as pd -from ._stats import compute_stats try: from tqdm.auto import tqdm as _tqdm @@ -31,6 +30,7 @@ def _tqdm(seq, **_): return seq from ._plotting import plot +from ._stats import compute_stats from ._util import _as_str, _Indicator, _Data, try_ __pdoc__ = { @@ -1088,7 +1088,7 @@ def __init__(self, exclusive_orders=exclusive_orders, index=data.index, ) self._strategy = strategy - self._results = None + self._results: Union[pd.Series, None] = None def run(self, **kwargs) -> pd.Series: """ @@ -1179,7 +1179,16 @@ def run(self, **kwargs) -> pd.Series: # for future `indicator._opts['data'].index` calls to work data._set_length(len(self._data)) - self._results = compute_stats(self._data, broker, strategy) + equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values + + self._results = compute_stats( + trades=broker.closed_trades, + equity=equity, + ohlc_data=self._data, + risk_free_rate=0.0 + ) + self._results.loc['_strategy'] = strategy + return self._results def optimize(self, *, @@ -1255,7 +1264,7 @@ def optimize(self, *, constraint=lambda p: p.sma1 < p.sma2) .. TODO:: - Improve multiprocessing/parallel execution on Windos with start method 'spawn'. + Improve multiprocessing/parallel execution on Windows with start method 'spawn'. """ if not kwargs: raise ValueError('Need some strategy parameters to optimize') From eb800445ccfa65f8f2481203f6025af65f511a2b Mon Sep 17 00:00:00 2001 From: crazy25000 Date: Wed, 31 Mar 2021 20:22:24 -0400 Subject: [PATCH 07/14] Update backtesting/backtesting.py Co-authored-by: kernc --- backtesting/backtesting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index c5131d53..293b76ad 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -1088,7 +1088,7 @@ def __init__(self, exclusive_orders=exclusive_orders, index=data.index, ) self._strategy = strategy - self._results: Union[pd.Series, None] = None + self._results: Optional[pd.Series] = None def run(self, **kwargs) -> pd.Series: """ From dc0b136a8cda948fe9831057ecb5062fc814bb7f Mon Sep 17 00:00:00 2001 From: crazy25000 Date: Wed, 31 Mar 2021 20:22:59 -0400 Subject: [PATCH 08/14] Update backtesting/backtesting.py Co-authored-by: kernc --- backtesting/backtesting.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index 293b76ad..a613d200 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -1181,13 +1181,14 @@ def run(self, **kwargs) -> pd.Series: equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values - self._results = compute_stats( + stats = compute_stats( trades=broker.closed_trades, equity=equity, ohlc_data=self._data, - risk_free_rate=0.0 + risk_free_rate=0.0, ) - self._results.loc['_strategy'] = strategy + stats.loc['_strategy'] = strategy + self._results = stats return self._results From 5cefccc5786fbb4bcf0ff384e0b53fd254538604 Mon Sep 17 00:00:00 2001 From: Nazim Date: Wed, 31 Mar 2021 20:53:21 -0400 Subject: [PATCH 09/14] REF: Add risk_free_rate to Sortino Ratio --- backtesting/_stats.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backtesting/_stats.py b/backtesting/_stats.py index ab3559b3..26cf9801 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -109,7 +109,7 @@ def _round_timedelta(value, _period=_data_period(index)): # and simple standard deviation s.loc['Sharpe Ratio'] = np.clip((s.loc['Return (Ann.) [%]'] - risk_free_rate) / (s.loc['Volatility (Ann.) [%]'] or np.nan), 0, np.inf) # noqa: E501 # Our Sortino mismatches `empyrical.sortino_ratio()` because they use arithmetic mean return - 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 + s.loc['Sortino Ratio'] = np.clip((annualized_return - risk_free_rate) / (np.sqrt(np.mean(day_returns.clip(-np.inf, 0)**2)) * np.sqrt(annual_trading_days)), 0, np.inf) # noqa: E501 max_dd = -np.nan_to_num(dd.max()) s.loc['Calmar Ratio'] = np.clip(annualized_return / (-max_dd or np.nan), 0, np.inf) s.loc['Max. Drawdown [%]'] = max_dd * 100 From edf812d17e100e83b56e1b854af97bc47fcffc76 Mon Sep 17 00:00:00 2001 From: Nazim Date: Wed, 31 Mar 2021 21:56:38 -0400 Subject: [PATCH 10/14] ENH: Add compute_stats to lib, provide public method --- backtesting/lib.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/backtesting/lib.py b/backtesting/lib.py index fafbae7a..d4aeb8d1 100644 --- a/backtesting/lib.py +++ b/backtesting/lib.py @@ -15,13 +15,14 @@ from itertools import compress from numbers import Number from inspect import currentframe -from typing import Sequence, Optional, Union, Callable +from typing import Sequence, Optional, Union, Callable, List import numpy as np import pandas as pd from .backtesting import Strategy from ._plotting import plot_heatmaps as _plot_heatmaps +from ._stats import compute_stats as _compute_stats from ._util import _Array, _as_str __pdoc__ = {} @@ -77,6 +78,21 @@ def barssince(condition: Sequence[bool], default=np.inf) -> int: return next(compress(range(len(condition)), reversed(condition)), default) +def compute_stats( + trades: List[pd.DataFrame], + equity: np.ndarray, + ohlc_data: pd.DataFrame, + risk_free_rate: float = 0) -> pd.Series: + # TODO: Add details + """ + Computes strategy performance metrics. + + >>> perf = compute_stats(trades=stats._trades, equity=stats._equity_curve['Equity'].to_numpy(), ohlc_data=df) + """ + + return _compute_stats(trades, equity, ohlc_data, risk_free_rate) + + def cross(series1: Sequence, series2: Sequence) -> bool: """ Return `True` if `series1` and `series2` just crossed (either From 68da66e2a1695b9b70a31734e25e83c8ec485821 Mon Sep 17 00:00:00 2001 From: Nazim Date: Wed, 31 Mar 2021 22:01:01 -0400 Subject: [PATCH 11/14] REF: Extract params to reduce line length --- backtesting/lib.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backtesting/lib.py b/backtesting/lib.py index d4aeb8d1..99a22638 100644 --- a/backtesting/lib.py +++ b/backtesting/lib.py @@ -87,7 +87,9 @@ def compute_stats( """ Computes strategy performance metrics. - >>> perf = compute_stats(trades=stats._trades, equity=stats._equity_curve['Equity'].to_numpy(), ohlc_data=df) + >>> trades = stats._trades + >>> equity = stats._equity_curve['Equity'].to_numpy() + >>> compute_stats(trades=trades, equity=equity, ohlc_data=df) """ return _compute_stats(trades, equity, ohlc_data, risk_free_rate) From bc83e31d679ba386101a1599fac5ce6735ffbda8 Mon Sep 17 00:00:00 2001 From: Nazim Date: Wed, 31 Mar 2021 22:09:17 -0400 Subject: [PATCH 12/14] REF: Use strategy broker to calculate equity --- backtesting/lib.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backtesting/lib.py b/backtesting/lib.py index 99a22638..8388a12f 100644 --- a/backtesting/lib.py +++ b/backtesting/lib.py @@ -88,8 +88,9 @@ def compute_stats( Computes strategy performance metrics. >>> trades = stats._trades - >>> equity = stats._equity_curve['Equity'].to_numpy() - >>> compute_stats(trades=trades, equity=equity, ohlc_data=df) + >>> broker_eq = stats._strategy._broker._equity + >>> eq = pd.Series(broker_eq).bfill().fillna(stats._strategy._broker._cash).values + >>> compute_stats(trades=trades, equity=eq, ohlc_data=df) """ return _compute_stats(trades, equity, ohlc_data, risk_free_rate) From 5b47fd51b396c0eb4d57ff7a287de9deb2a66af5 Mon Sep 17 00:00:00 2001 From: Nazim Date: Wed, 31 Mar 2021 22:18:37 -0400 Subject: [PATCH 13/14] REF: Use example from test --- backtesting/lib.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/backtesting/lib.py b/backtesting/lib.py index 8388a12f..9d8f884f 100644 --- a/backtesting/lib.py +++ b/backtesting/lib.py @@ -87,10 +87,9 @@ def compute_stats( """ Computes strategy performance metrics. - >>> trades = stats._trades - >>> broker_eq = stats._strategy._broker._equity - >>> eq = pd.Series(broker_eq).bfill().fillna(stats._strategy._broker._cash).values - >>> compute_stats(trades=trades, equity=eq, ohlc_data=df) + >>> broker = stats._strategy._broker + >>> equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values + >>> perf = compute_stats(trades=broker.closed_trades, equity=equity, ohlc_data=GOOG) """ return _compute_stats(trades, equity, ohlc_data, risk_free_rate) From c3e62ac6cee57aca6a5e23da5ac873308ec7900c Mon Sep 17 00:00:00 2001 From: Kernc Date: Tue, 3 Aug 2021 02:45:14 +0200 Subject: [PATCH 14/14] Update, make more idempotent, add doc, test --- backtesting/_stats.py | 49 ++++++++++++++++++++++-------------- backtesting/backtesting.py | 9 +++---- backtesting/lib.py | 51 ++++++++++++++++++++++++-------------- backtesting/test/_test.py | 14 +++++++++++ 4 files changed, 80 insertions(+), 43 deletions(-) diff --git a/backtesting/_stats.py b/backtesting/_stats.py index 26cf9801..8435605c 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -1,10 +1,13 @@ -from typing import List +from typing import List, TYPE_CHECKING, Union import numpy as np import pandas as pd from ._util import _data_period +if TYPE_CHECKING: + from .backtesting import Strategy, Trade + def compute_drawdown_duration_peaks(dd: pd.Series): iloc = np.unique(np.r_[(dd == 0).values.nonzero()[0], len(dd) - 1]) @@ -26,15 +29,17 @@ def geometric_mean(returns: pd.Series) -> float: returns = returns.fillna(0) + 1 if np.any(returns <= 0): return 0 - return np.exp(np.log(returns).sum() / (len(returns) or np.nan)) - 1 def compute_stats( - trades: List[pd.DataFrame], + trades: Union[List['Trade'], pd.DataFrame], equity: np.ndarray, ohlc_data: pd.DataFrame, - risk_free_rate: float = 0) -> pd.Series: + strategy_instance: 'Strategy', + risk_free_rate: float = 0, +) -> pd.Series: + assert -1 < risk_free_rate < 1 index = ohlc_data.index dd = 1 - equity / np.maximum.accumulate(equity) @@ -46,18 +51,23 @@ def compute_stats( 'DrawdownDuration': dd_dur}, index=index) - trades_df = pd.DataFrame({ - 'Size': [t.size for t in trades], - 'EntryBar': [t.entry_bar for t in trades], - 'ExitBar': [t.exit_bar for t in trades], - 'EntryPrice': [t.entry_price for t in trades], - 'ExitPrice': [t.exit_price for t in trades], - 'PnL': [t.pl for t in trades], - 'ReturnPct': [t.pl_pct for t in trades], - 'EntryTime': [t.entry_time for t in trades], - 'ExitTime': [t.exit_time for t in trades], - }) - trades_df['Duration'] = trades_df['ExitTime'] - trades_df['EntryTime'] + if isinstance(trades, pd.DataFrame): + trades_df = trades + else: + # Came straight from Backtest.run() + trades_df = pd.DataFrame({ + 'Size': [t.size for t in trades], + 'EntryBar': [t.entry_bar for t in trades], + 'ExitBar': [t.exit_bar for t in trades], + 'EntryPrice': [t.entry_price for t in trades], + 'ExitPrice': [t.exit_price for t in trades], + 'PnL': [t.pl for t in trades], + 'ReturnPct': [t.pl_pct for t in trades], + 'EntryTime': [t.entry_time for t in trades], + 'ExitTime': [t.exit_time for t in trades], + }) + trades_df['Duration'] = trades_df['ExitTime'] - trades_df['EntryTime'] + del trades pl = trades_df['PnL'] returns = trades_df['ReturnPct'] @@ -75,8 +85,8 @@ def _round_timedelta(value, _period=_data_period(index)): s.loc['Duration'] = s.End - s.Start have_position = np.repeat(0, len(index)) - for t in trades: - have_position[t.entry_bar:t.exit_bar + 1] = 1 + for t in trades_df.itertuples(index=False): + have_position[t.EntryBar:t.ExitBar + 1] = 1 s.loc['Exposure Time [%]'] = have_position.mean() * 100 # In "n bars" time, not index time s.loc['Equity Final [$]'] = equity[-1] @@ -116,7 +126,7 @@ def _round_timedelta(value, _period=_data_period(index)): s.loc['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100 s.loc['Max. Drawdown Duration'] = _round_timedelta(dd_dur.max()) s.loc['Avg. Drawdown Duration'] = _round_timedelta(dd_dur.mean()) - s.loc['# Trades'] = n_trades = len(trades) + s.loc['# Trades'] = n_trades = len(trades_df) s.loc['Win Rate [%]'] = np.nan if not n_trades else (pl > 0).sum() / n_trades * 100 # noqa: E501 s.loc['Best Trade [%]'] = returns.max() * 100 s.loc['Worst Trade [%]'] = returns.min() * 100 @@ -128,6 +138,7 @@ def _round_timedelta(value, _period=_data_period(index)): s.loc['Expectancy [%]'] = returns.mean() * 100 s.loc['SQN'] = np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan) + s.loc['_strategy'] = strategy_instance s.loc['_equity_curve'] = equity_df s.loc['_trades'] = trades_df diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index a613d200..9c820596 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -21,7 +21,6 @@ import numpy as np import pandas as pd - try: from tqdm.auto import tqdm as _tqdm _tqdm = partial(_tqdm, leave=False) @@ -1180,15 +1179,13 @@ def run(self, **kwargs) -> pd.Series: data._set_length(len(self._data)) equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values - - stats = compute_stats( + self._results = compute_stats( trades=broker.closed_trades, equity=equity, ohlc_data=self._data, risk_free_rate=0.0, + strategy_instance=strategy, ) - stats.loc['_strategy'] = strategy - self._results = stats return self._results @@ -1265,7 +1262,7 @@ def optimize(self, *, constraint=lambda p: p.sma1 < p.sma2) .. TODO:: - Improve multiprocessing/parallel execution on Windows with start method 'spawn'. + Improve multiprocessing/parallel execution on Windos with start method 'spawn'. """ if not kwargs: raise ValueError('Need some strategy parameters to optimize') diff --git a/backtesting/lib.py b/backtesting/lib.py index 9d8f884f..09f5ebcf 100644 --- a/backtesting/lib.py +++ b/backtesting/lib.py @@ -15,7 +15,7 @@ from itertools import compress from numbers import Number from inspect import currentframe -from typing import Sequence, Optional, Union, Callable, List +from typing import Sequence, Optional, Union, Callable import numpy as np import pandas as pd @@ -78,23 +78,6 @@ def barssince(condition: Sequence[bool], default=np.inf) -> int: return next(compress(range(len(condition)), reversed(condition)), default) -def compute_stats( - trades: List[pd.DataFrame], - equity: np.ndarray, - ohlc_data: pd.DataFrame, - risk_free_rate: float = 0) -> pd.Series: - # TODO: Add details - """ - Computes strategy performance metrics. - - >>> broker = stats._strategy._broker - >>> equity = pd.Series(broker._equity).bfill().fillna(broker._cash).values - >>> perf = compute_stats(trades=broker.closed_trades, equity=equity, ohlc_data=GOOG) - """ - - return _compute_stats(trades, equity, ohlc_data, risk_free_rate) - - def cross(series1: Sequence, series2: Sequence) -> bool: """ Return `True` if `series1` and `series2` just crossed (either @@ -182,6 +165,38 @@ def quantile(series: Sequence, quantile: Union[None, float] = None): return np.nanpercentile(series, quantile * 100) +def compute_stats( + *, + stats: pd.Series, + data: pd.DataFrame, + trades: pd.DataFrame = None, + risk_free_rate: float = 0.) -> pd.Series: + """ + (Re-)compute strategy performance metrics. + + `stats` is the statistics series as returned by `Backtest.run()`. + `data` is OHLC data as passed to the `Backtest` the `stats` were obtained in. + `trades` can be a dataframe subset of `stats._trades` (e.g. only long trades). + You can also tune `risk_free_rate`, used in calculation of Sharpe and Sortino ratios. + + >>> stats = Backtest(GOOG, MyStrategy).run() + >>> only_long_trades = stats._trades[stats._trades.Size > 0] + >>> long_stats = compute_stats(stats=stats, trades=only_long_trades, + ... data=GOOG, risk_free_rate=.02) + """ + equity = stats._equity_curve.Equity + if trades is None: + trades = stats._trades + else: + # XXX: Is this buggy? + equity = equity.copy() + equity[:] = stats._equity_curve.Equity.iloc[0] + for t in trades.itertuples(index=False): + equity.iloc[t.EntryBar:] += t.PnL + return _compute_stats(trades=trades, equity=equity, ohlc_data=data, + risk_free_rate=risk_free_rate, strategy_instance=stats._strategy) + + def resample_apply(rule: str, func: Optional[Callable[..., Sequence]], series: Union[pd.Series, pd.DataFrame, _Array], diff --git a/backtesting/test/_test.py b/backtesting/test/_test.py index 0481ebde..a943c986 100644 --- a/backtesting/test/_test.py +++ b/backtesting/test/_test.py @@ -13,12 +13,14 @@ import numpy as np import pandas as pd +from pandas.testing import assert_frame_equal from backtesting import Backtest, Strategy from backtesting._stats import compute_drawdown_duration_peaks from backtesting.lib import ( OHLCV_AGG, barssince, + compute_stats, cross, crossover, quantile, @@ -836,6 +838,18 @@ def test_random_ohlc_data(self): self.assertEqual(new_data.shape, GOOG.shape) self.assertEqual(list(new_data.columns), list(GOOG.columns)) + def test_compute_stats(self): + stats = Backtest(GOOG, SmaCross).run() + only_long_trades = stats._trades[stats._trades.Size > 0] + long_stats = compute_stats(stats=stats, trades=only_long_trades, + data=GOOG, risk_free_rate=.02) + self.assertNotEqual(list(stats._equity_curve.Equity), + list(long_stats._equity_curve.Equity)) + self.assertNotEqual(stats['Sharpe Ratio'], long_stats['Sharpe Ratio']) + self.assertEqual(long_stats['# Trades'], len(only_long_trades)) + self.assertEqual(stats._strategy, long_stats._strategy) + assert_frame_equal(long_stats._trades, only_long_trades) + def test_SignalStrategy(self): class S(SignalStrategy): def init(self):