Skip to content

Commit f88baff

Browse files
committed
Build stats series via a dict (shorter, faster, compatible)
1 parent cf51b35 commit f88baff

1 file changed

Lines changed: 42 additions & 70 deletions

File tree

backtesting/_stats.py

Lines changed: 42 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -97,34 +97,24 @@ 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-
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-
])
100+
s = dict()
101+
s['Start'] = index[0]
102+
s['End'] = index[-1]
103+
s['Duration'] = s['End'] - s['Start']
109104

110105
have_position = np.repeat(0, len(index))
111106
for t in trades_df[['EntryBar', 'ExitBar']].itertuples(index=False):
112107
have_position[t.EntryBar:t.ExitBar + 1] = 1
113108

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))
109+
s['Exposure Time [%]'] = have_position.mean() * 100 # In "n bars" time, not index time
110+
s['Equity Final [$]'] = equity[-1]
111+
s['Equity Peak [$]'] = equity.max()
120112
if commissions:
121-
stat_items.append(('Commissions [$]', commissions))
122-
return_pct = (equity_final - equity[0]) / equity[0] * 100
123-
stat_items.append(('Return [%]', return_pct))
113+
s['Commissions [$]'] = commissions
114+
s['Return [%]'] = (equity[-1] - equity[0]) / equity[0] * 100
124115
first_trading_bar = _indicator_warmup_nbars(strategy_instance)
125116
c = ohlc_data.Close.values
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
117+
s['Buy & Hold Return [%]'] = (c[-1] - c[first_trading_bar]) / c[first_trading_bar] * 100 # long-only return
128118

129119
gmean_day_return: float = 0
130120
day_returns = np.array(np.nan)
@@ -147,29 +137,22 @@ def _round_timedelta(value, _period=_data_period(index)):
147137
# Our annualized return matches `empyrical.annual_return(day_returns)` whereas
148138
# our risk doesn't; they use the simpler approach below.
149139
annualized_return = (1 + gmean_day_return)**annual_trading_days - 1
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))
154-
# s.loc['Return (Ann.) [%]'] = gmean_day_return * annual_trading_days * 100
155-
# s.loc['Risk (Ann.) [%]'] = day_returns.std(ddof=1) * np.sqrt(annual_trading_days) * 100
140+
s['Return (Ann.) [%]'] = annualized_return * 100
141+
s['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
142+
# d['Return (Ann.) [%]'] = gmean_day_return * annual_trading_days * 100
143+
# d['Risk (Ann.) [%]'] = day_returns.std(ddof=1) * np.sqrt(annual_trading_days) * 100
156144
if is_datetime_index:
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))
145+
time_in_years = (s['Duration'].days + s['Duration'].seconds / 86400) / annual_trading_days
146+
s['CAGR [%]'] = ((s['Equity Final [$]'] / equity[0])**(1 / time_in_years) - 1) * 100 if time_in_years else np.nan # noqa: E501
160147

161148
# Our Sharpe mismatches `empyrical.sharpe_ratio()` because they use arithmetic mean return
162149
# and simple standard deviation
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
150+
s['Sharpe Ratio'] = (s['Return (Ann.) [%]'] - risk_free_rate * 100) / (s['Volatility (Ann.) [%]'] or np.nan) # noqa: E501
166151
# Our Sortino mismatches `empyrical.sortino_ratio()` because they use arithmetic mean return
167152
with np.errstate(divide='ignore'):
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))
153+
s['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
170154
max_dd = -np.nan_to_num(dd.max())
171-
calmar_ratio = annualized_return / (-max_dd or np.nan)
172-
stat_items.append(('Calmar Ratio', calmar_ratio))
155+
s['Calmar Ratio'] = annualized_return / (-max_dd or np.nan)
173156
equity_log_returns = np.log(equity[1:] / equity[:-1])
174157
market_log_returns = np.log(c[1:] / c[:-1])
175158
beta = np.nan
@@ -178,42 +161,31 @@ def _round_timedelta(value, _period=_data_period(index)):
178161
cov_matrix = np.cov(equity_log_returns, market_log_returns)
179162
beta = cov_matrix[0, 1] / cov_matrix[1, 1]
180163
# Jensen CAPM Alpha: can be strongly positive when beta is negative and B&H Return is large
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))
164+
s['Alpha [%]'] = s['Return [%]'] - risk_free_rate * 100 - beta * (s['Buy & Hold Return [%]'] - risk_free_rate * 100) # noqa: E501
165+
s['Beta'] = beta
166+
s['Max. Drawdown [%]'] = max_dd * 100
167+
s['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100
168+
s['Max. Drawdown Duration'] = _round_timedelta(dd_dur.max())
169+
s['Avg. Drawdown Duration'] = _round_timedelta(dd_dur.mean())
170+
s['# Trades'] = n_trades = len(trades_df)
190171
win_rate = np.nan if not n_trades else (pl > 0).mean()
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))
172+
s['Win Rate [%]'] = win_rate * 100
173+
s['Best Trade [%]'] = returns.max() * 100
174+
s['Worst Trade [%]'] = returns.min() * 100
194175
mean_return = geometric_mean(returns)
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)
215-
216-
s = _Stats(s)
176+
s['Avg. Trade [%]'] = mean_return * 100
177+
s['Max. Trade Duration'] = _round_timedelta(durations.max())
178+
s['Avg. Trade Duration'] = _round_timedelta(durations.mean())
179+
s['Profit Factor'] = returns[returns > 0].sum() / (abs(returns[returns < 0].sum()) or np.nan) # noqa: E501
180+
s['Expectancy [%]'] = returns.mean() * 100
181+
s['SQN'] = np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan)
182+
s['Kelly Criterion'] = win_rate - (1 - win_rate) / (pl[pl > 0].mean() / -pl[pl < 0].mean())
183+
184+
s['_strategy'] = strategy_instance
185+
s['_equity_curve'] = equity_df
186+
s['_trades'] = trades_df
187+
188+
s = _Stats(s, dtype=object)
217189
return s
218190

219191

0 commit comments

Comments
 (0)