Skip to content

Commit ce0e21a

Browse files
committed
Build stats series all at once at the end rather than incrementally using s.loc
1 parent 028d1eb commit ce0e21a

File tree

1 file changed

+66
-38
lines changed

1 file changed

+66
-38
lines changed

backtesting/_stats.py

Lines changed: 66 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -97,24 +97,34 @@ def _round_timedelta(value, _period=_data_period(index)):
9797
resolution = getattr(_period, 'resolution_string', None) or _period.resolution
9898
return value.ceil(resolution)
9999

100-
s = pd.Series(dtype=object)
101-
s.loc['Start'] = index[0]
102-
s.loc['End'] = index[-1]
103-
s.loc['Duration'] = s.End - s.Start
100+
stat_items: list[tuple[str, object]] = []
101+
start = index[0]
102+
end = index[-1]
103+
duration = end - start
104+
stat_items.extend([
105+
('Start', start),
106+
('End', end),
107+
('Duration', duration),
108+
])
104109

105110
have_position = np.repeat(0, len(index))
106111
for t in trades_df.itertuples(index=False):
107112
have_position[t.EntryBar:t.ExitBar + 1] = 1
108113

109-
s.loc['Exposure Time [%]'] = have_position.mean() * 100 # In "n bars" time, not index time
110-
s.loc['Equity Final [$]'] = equity[-1]
111-
s.loc['Equity Peak [$]'] = equity.max()
114+
exposure_time_pct = have_position.mean() * 100 # In "n bars" time, not index time
115+
stat_items.append(('Exposure Time [%]', exposure_time_pct))
116+
equity_final = equity[-1]
117+
equity_peak = equity.max()
118+
stat_items.append(('Equity Final [$]', equity_final))
119+
stat_items.append(('Equity Peak [$]', equity_peak))
112120
if commissions:
113-
s.loc['Commissions [$]'] = commissions
114-
s.loc['Return [%]'] = (equity[-1] - equity[0]) / equity[0] * 100
121+
stat_items.append(('Commissions [$]', commissions))
122+
return_pct = (equity_final - equity[0]) / equity[0] * 100
123+
stat_items.append(('Return [%]', return_pct))
115124
first_trading_bar = _indicator_warmup_nbars(strategy_instance)
116125
c = ohlc_data.Close.values
117-
s.loc['Buy & Hold Return [%]'] = (c[-1] - c[first_trading_bar]) / c[first_trading_bar] * 100 # long-only return
126+
buy_hold_return_pct = (c[-1] - c[first_trading_bar]) / c[first_trading_bar] * 100
127+
stat_items.append(('Buy & Hold Return [%]', buy_hold_return_pct)) # long-only return
118128

119129
gmean_day_return: float = 0
120130
day_returns = np.array(np.nan)
@@ -137,22 +147,29 @@ def _round_timedelta(value, _period=_data_period(index)):
137147
# Our annualized return matches `empyrical.annual_return(day_returns)` whereas
138148
# our risk doesn't; they use the simpler approach below.
139149
annualized_return = (1 + gmean_day_return)**annual_trading_days - 1
140-
s.loc['Return (Ann.) [%]'] = annualized_return * 100
141-
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
150+
return_ann_pct = annualized_return * 100
151+
volatility_ann_pct = 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
152+
stat_items.append(('Return (Ann.) [%]', return_ann_pct))
153+
stat_items.append(('Volatility (Ann.) [%]', volatility_ann_pct))
142154
# s.loc['Return (Ann.) [%]'] = gmean_day_return * annual_trading_days * 100
143155
# s.loc['Risk (Ann.) [%]'] = day_returns.std(ddof=1) * np.sqrt(annual_trading_days) * 100
144156
if is_datetime_index:
145-
time_in_years = (s.loc['Duration'].days + s.loc['Duration'].seconds / 86400) / annual_trading_days
146-
s.loc['CAGR [%]'] = ((s.loc['Equity Final [$]'] / equity[0])**(1 / time_in_years) - 1) * 100 if time_in_years else np.nan # noqa: E501
157+
time_in_years = (duration.days + duration.seconds / 86400) / annual_trading_days
158+
cagr_pct = ((equity_final / equity[0])**(1 / time_in_years) - 1) * 100 if time_in_years else np.nan # noqa: E501
159+
stat_items.append(('CAGR [%]', cagr_pct))
147160

148161
# Our Sharpe mismatches `empyrical.sharpe_ratio()` because they use arithmetic mean return
149162
# and simple standard deviation
150-
s.loc['Sharpe Ratio'] = (s.loc['Return (Ann.) [%]'] - risk_free_rate * 100) / (s.loc['Volatility (Ann.) [%]'] or np.nan) # noqa: E501
163+
sharpe_denom = volatility_ann_pct or np.nan
164+
sharpe_ratio = (return_ann_pct - risk_free_rate * 100) / sharpe_denom
165+
stat_items.append(('Sharpe Ratio', sharpe_ratio)) # noqa: E501
151166
# Our Sortino mismatches `empyrical.sortino_ratio()` because they use arithmetic mean return
152167
with np.errstate(divide='ignore'):
153-
s.loc['Sortino Ratio'] = (annualized_return - risk_free_rate) / (np.sqrt(np.mean(day_returns.clip(-np.inf, 0)**2)) * np.sqrt(annual_trading_days)) # noqa: E501
168+
sortino_ratio = (annualized_return - risk_free_rate) / (np.sqrt(np.mean(day_returns.clip(-np.inf, 0)**2)) * np.sqrt(annual_trading_days)) # noqa: E501
169+
stat_items.append(('Sortino Ratio', sortino_ratio))
154170
max_dd = -np.nan_to_num(dd.max())
155-
s.loc['Calmar Ratio'] = annualized_return / (-max_dd or np.nan)
171+
calmar_ratio = annualized_return / (-max_dd or np.nan)
172+
stat_items.append(('Calmar Ratio', calmar_ratio))
156173
equity_log_returns = np.log(equity[1:] / equity[:-1])
157174
market_log_returns = np.log(c[1:] / c[:-1])
158175
beta = np.nan
@@ -161,29 +178,40 @@ def _round_timedelta(value, _period=_data_period(index)):
161178
cov_matrix = np.cov(equity_log_returns, market_log_returns)
162179
beta = cov_matrix[0, 1] / cov_matrix[1, 1]
163180
# Jensen CAPM Alpha: can be strongly positive when beta is negative and B&H Return is large
164-
s.loc['Alpha [%]'] = s.loc['Return [%]'] - risk_free_rate * 100 - beta * (s.loc['Buy & Hold Return [%]'] - risk_free_rate * 100) # noqa: E501
165-
s.loc['Beta'] = beta
166-
s.loc['Max. Drawdown [%]'] = max_dd * 100
167-
s.loc['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100
168-
s.loc['Max. Drawdown Duration'] = _round_timedelta(dd_dur.max())
169-
s.loc['Avg. Drawdown Duration'] = _round_timedelta(dd_dur.mean())
170-
s.loc['# Trades'] = n_trades = len(trades_df)
181+
alpha_pct = return_pct - risk_free_rate * 100 - beta * (buy_hold_return_pct - risk_free_rate * 100) # noqa: E501
182+
stat_items.append(('Alpha [%]', alpha_pct))
183+
stat_items.append(('Beta', beta))
184+
stat_items.append(('Max. Drawdown [%]', max_dd * 100))
185+
stat_items.append(('Avg. Drawdown [%]', -dd_peaks.mean() * 100))
186+
stat_items.append(('Max. Drawdown Duration', _round_timedelta(dd_dur.max())))
187+
stat_items.append(('Avg. Drawdown Duration', _round_timedelta(dd_dur.mean())))
188+
n_trades = len(trades_df)
189+
stat_items.append(('# Trades', n_trades))
171190
win_rate = np.nan if not n_trades else (pl > 0).mean()
172-
s.loc['Win Rate [%]'] = win_rate * 100
173-
s.loc['Best Trade [%]'] = returns.max() * 100
174-
s.loc['Worst Trade [%]'] = returns.min() * 100
191+
stat_items.append(('Win Rate [%]', win_rate * 100))
192+
stat_items.append(('Best Trade [%]', returns.max() * 100))
193+
stat_items.append(('Worst Trade [%]', returns.min() * 100))
175194
mean_return = geometric_mean(returns)
176-
s.loc['Avg. Trade [%]'] = mean_return * 100
177-
s.loc['Max. Trade Duration'] = _round_timedelta(durations.max())
178-
s.loc['Avg. Trade Duration'] = _round_timedelta(durations.mean())
179-
s.loc['Profit Factor'] = returns[returns > 0].sum() / (abs(returns[returns < 0].sum()) or np.nan) # noqa: E501
180-
s.loc['Expectancy [%]'] = returns.mean() * 100
181-
s.loc['SQN'] = np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan)
182-
s.loc['Kelly Criterion'] = win_rate - (1 - win_rate) / (pl[pl > 0].mean() / -pl[pl < 0].mean())
183-
184-
s.loc['_strategy'] = strategy_instance
185-
s.loc['_equity_curve'] = equity_df
186-
s.loc['_trades'] = trades_df
195+
stat_items.append(('Avg. Trade [%]', mean_return * 100))
196+
stat_items.append(('Max. Trade Duration', _round_timedelta(durations.max())))
197+
stat_items.append(('Avg. Trade Duration', _round_timedelta(durations.mean())))
198+
profit_factor = returns[returns > 0].sum() / (abs(returns[returns < 0].sum()) or np.nan) # noqa: E501
199+
stat_items.append(('Profit Factor', profit_factor))
200+
expectancy = returns.mean() * 100
201+
stat_items.append(('Expectancy [%]', expectancy))
202+
sqn = np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan)
203+
stat_items.append(('SQN', sqn))
204+
kelly = win_rate - (1 - win_rate) / (pl[pl > 0].mean() / -pl[pl < 0].mean())
205+
stat_items.append(('Kelly Criterion', kelly))
206+
207+
stat_items.extend([
208+
('_strategy', strategy_instance),
209+
('_equity_curve', equity_df),
210+
('_trades', trades_df),
211+
])
212+
213+
labels, values = zip(*stat_items)
214+
s = pd.Series(values, index=labels, dtype=object)
187215

188216
s = _Stats(s)
189217
return s

0 commit comments

Comments
 (0)