Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 66 additions & 38 deletions backtesting/_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,24 +97,34 @@ def _round_timedelta(value, _period=_data_period(index)):
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
stat_items: list[tuple[str, object]] = []
start = index[0]
end = index[-1]
duration = end - start
stat_items.extend([
('Start', start),
('End', end),
('Duration', duration),
])

have_position = np.repeat(0, len(index))
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]
s.loc['Equity Peak [$]'] = equity.max()
exposure_time_pct = have_position.mean() * 100 # In "n bars" time, not index time
stat_items.append(('Exposure Time [%]', exposure_time_pct))
equity_final = equity[-1]
equity_peak = equity.max()
stat_items.append(('Equity Final [$]', equity_final))
stat_items.append(('Equity Peak [$]', equity_peak))
if commissions:
s.loc['Commissions [$]'] = commissions
s.loc['Return [%]'] = (equity[-1] - equity[0]) / equity[0] * 100
stat_items.append(('Commissions [$]', commissions))
return_pct = (equity_final - equity[0]) / equity[0] * 100
stat_items.append(('Return [%]', return_pct))
first_trading_bar = _indicator_warmup_nbars(strategy_instance)
c = ohlc_data.Close.values
s.loc['Buy & Hold Return [%]'] = (c[-1] - c[first_trading_bar]) / c[first_trading_bar] * 100 # long-only return
buy_hold_return_pct = (c[-1] - c[first_trading_bar]) / c[first_trading_bar] * 100
stat_items.append(('Buy & Hold Return [%]', buy_hold_return_pct)) # long-only return

gmean_day_return: float = 0
day_returns = np.array(np.nan)
Expand All @@ -137,22 +147,29 @@ def _round_timedelta(value, _period=_data_period(index)):
# 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
return_ann_pct = annualized_return * 100
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
stat_items.append(('Return (Ann.) [%]', return_ann_pct))
stat_items.append(('Volatility (Ann.) [%]', volatility_ann_pct))
# 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
if is_datetime_index:
time_in_years = (s.loc['Duration'].days + s.loc['Duration'].seconds / 86400) / annual_trading_days
s.loc['CAGR [%]'] = ((s.loc['Equity Final [$]'] / equity[0])**(1 / time_in_years) - 1) * 100 if time_in_years else np.nan # noqa: E501
time_in_years = (duration.days + duration.seconds / 86400) / annual_trading_days
cagr_pct = ((equity_final / equity[0])**(1 / time_in_years) - 1) * 100 if time_in_years else np.nan # noqa: E501
stat_items.append(('CAGR [%]', cagr_pct))

# Our Sharpe mismatches `empyrical.sharpe_ratio()` because they use arithmetic mean return
# and simple standard deviation
s.loc['Sharpe Ratio'] = (s.loc['Return (Ann.) [%]'] - risk_free_rate * 100) / (s.loc['Volatility (Ann.) [%]'] or np.nan) # noqa: E501
sharpe_denom = volatility_ann_pct or np.nan
sharpe_ratio = (return_ann_pct - risk_free_rate * 100) / sharpe_denom
stat_items.append(('Sharpe Ratio', sharpe_ratio)) # noqa: E501
# Our Sortino mismatches `empyrical.sortino_ratio()` because they use arithmetic mean return
with np.errstate(divide='ignore'):
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
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
stat_items.append(('Sortino Ratio', sortino_ratio))
max_dd = -np.nan_to_num(dd.max())
s.loc['Calmar Ratio'] = annualized_return / (-max_dd or np.nan)
calmar_ratio = annualized_return / (-max_dd or np.nan)
stat_items.append(('Calmar Ratio', calmar_ratio))
equity_log_returns = np.log(equity[1:] / equity[:-1])
market_log_returns = np.log(c[1:] / c[:-1])
beta = np.nan
Expand All @@ -161,29 +178,40 @@ def _round_timedelta(value, _period=_data_period(index)):
cov_matrix = np.cov(equity_log_returns, market_log_returns)
beta = cov_matrix[0, 1] / cov_matrix[1, 1]
# Jensen CAPM Alpha: can be strongly positive when beta is negative and B&H Return is large
s.loc['Alpha [%]'] = s.loc['Return [%]'] - risk_free_rate * 100 - beta * (s.loc['Buy & Hold Return [%]'] - risk_free_rate * 100) # noqa: E501
s.loc['Beta'] = beta
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_df)
alpha_pct = return_pct - risk_free_rate * 100 - beta * (buy_hold_return_pct - risk_free_rate * 100) # noqa: E501
stat_items.append(('Alpha [%]', alpha_pct))
stat_items.append(('Beta', beta))
stat_items.append(('Max. Drawdown [%]', max_dd * 100))
stat_items.append(('Avg. Drawdown [%]', -dd_peaks.mean() * 100))
stat_items.append(('Max. Drawdown Duration', _round_timedelta(dd_dur.max())))
stat_items.append(('Avg. Drawdown Duration', _round_timedelta(dd_dur.mean())))
n_trades = len(trades_df)
stat_items.append(('# Trades', n_trades))
win_rate = np.nan if not n_trades else (pl > 0).mean()
s.loc['Win Rate [%]'] = win_rate * 100
s.loc['Best Trade [%]'] = returns.max() * 100
s.loc['Worst Trade [%]'] = returns.min() * 100
stat_items.append(('Win Rate [%]', win_rate * 100))
stat_items.append(('Best Trade [%]', returns.max() * 100))
stat_items.append(('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['Kelly Criterion'] = win_rate - (1 - win_rate) / (pl[pl > 0].mean() / -pl[pl < 0].mean())

s.loc['_strategy'] = strategy_instance
s.loc['_equity_curve'] = equity_df
s.loc['_trades'] = trades_df
stat_items.append(('Avg. Trade [%]', mean_return * 100))
stat_items.append(('Max. Trade Duration', _round_timedelta(durations.max())))
stat_items.append(('Avg. Trade Duration', _round_timedelta(durations.mean())))
profit_factor = returns[returns > 0].sum() / (abs(returns[returns < 0].sum()) or np.nan) # noqa: E501
stat_items.append(('Profit Factor', profit_factor))
expectancy = returns.mean() * 100
stat_items.append(('Expectancy [%]', expectancy))
sqn = np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan)
stat_items.append(('SQN', sqn))
kelly = win_rate - (1 - win_rate) / (pl[pl > 0].mean() / -pl[pl < 0].mean())
stat_items.append(('Kelly Criterion', kelly))

stat_items.extend([
('_strategy', strategy_instance),
('_equity_curve', equity_df),
('_trades', trades_df),
])

labels, values = zip(*stat_items)
s = pd.Series(values, index=labels, dtype=object)

s = _Stats(s)
return s
Expand Down
5 changes: 5 additions & 0 deletions backtesting/_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,11 @@ def __get_array(self, key) -> _Array:
arr = self.__cache[key] = cast(_Array, self.__arrays[key][:self.__len])
return arr

def current_value(self, key: str):
if self.__len <= 0:
raise IndexError("No data available")
return self.__arrays[key][self.__len - 1]

@property
def Open(self) -> _Array:
return self.__get_array('Open')
Expand Down
95 changes: 78 additions & 17 deletions backtesting/backtesting.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,17 +362,21 @@ def __bool__(self):
@property
def size(self) -> float:
"""Position size in units of asset. Negative if position is short."""
return sum(trade.size for trade in self.__broker.trades)
if self.__broker._trade_sums_dirty:
self.__broker._recalculate_trade_sums()
return self.__broker._open_trade_size_sum

@property
def pl(self) -> float:
"""Profit (positive) or loss (negative) of the current position in cash units."""
return sum(trade.pl for trade in self.__broker.trades)
return self.__broker.unrealized_pl

@property
def pl_pct(self) -> float:
"""Profit (positive) or loss (negative) of the current position in percent."""
total_invested = sum(trade.entry_price * abs(trade.size) for trade in self.__broker.trades)
if self.__broker._trade_sums_dirty:
self.__broker._recalculate_trade_sums()
total_invested = self.__broker._open_trade_entry_abs_value_sum
return (self.pl / total_invested) * 100 if total_invested else 0

@property
Expand Down Expand Up @@ -681,7 +685,7 @@ def pl(self):

@property
def pl_pct(self):
"""Trade profit (positive) or loss (negative) in percent."""
"""Trade profit (positive) or loss (negative) in percent relative to trade entry price."""
price = self.__exit_price or self.__broker.last_price
gross_pl_pct = copysign(1, self.__size) * (price / self.__entry_price - 1)

Expand Down Expand Up @@ -772,6 +776,10 @@ def __init__(self, *, data, cash, spread, commission, margin,
self.trades: List[Trade] = []
self.position = Position(self)
self.closed_trades: List[Trade] = []
self._trade_sums_dirty = True
self._open_trade_size_sum = 0
self._open_trade_entry_value_sum = 0.0
self._open_trade_entry_abs_value_sum = 0.0

def _commission_func(self, order_size, price):
return self._commission_fixed + abs(order_size) * price * self._commission_relative
Expand Down Expand Up @@ -829,6 +837,28 @@ def new_order(self,

return order

def _mark_trade_sums_dirty(self) -> None:
self._trade_sums_dirty = True

def _recalculate_trade_sums(self) -> None:
self._open_trade_size_sum = sum(int(trade.size) for trade in self.trades)
self._open_trade_entry_value_sum = sum(
trade.size * trade.entry_price for trade in self.trades
)
self._open_trade_entry_abs_value_sum = sum(
abs(trade.size) * trade.entry_price for trade in self.trades
)
self._trade_sums_dirty = False

@property
def unrealized_pl(self) -> float:
if self._trade_sums_dirty:
self._recalculate_trade_sums()
if not self.trades:
return 0.0
current_price = float(self._data.current_value("Close"))
return current_price * self._open_trade_size_sum - self._open_trade_entry_value_sum

@property
def last_price(self) -> float:
""" Price at the last (current) close. """
Expand All @@ -843,7 +873,7 @@ def _adjusted_price(self, size=None, price=None) -> float:

@property
def equity(self) -> float:
return self._cash + sum(trade.pl for trade in self.trades)
return self._cash + self.unrealized_pl

@property
def margin_available(self) -> float:
Expand All @@ -870,7 +900,9 @@ def next(self):

def _process_orders(self):
data = self._data
open, high, low = data.Open[-1], data.High[-1], data.Low[-1]
open_price = data.current_value("Open")
high_price = data.current_value("High")
low_price = data.current_value("Low")
reprocess_orders = False

# Process orders
Expand All @@ -883,7 +915,9 @@ def _process_orders(self):
# Check if stop condition was hit
stop_price = order.stop
if stop_price:
is_stop_hit = ((high >= stop_price) if order.is_long else (low <= stop_price))
is_stop_hit = (
high_price >= stop_price if order.is_long else low_price <= stop_price
)
if not is_stop_hit:
continue

Expand All @@ -894,7 +928,9 @@ def _process_orders(self):
# Determine purchase price.
# Check if limit order can be filled.
if order.limit:
is_limit_hit = low <= order.limit if order.is_long else high >= order.limit
is_limit_hit = (
low_price <= order.limit if order.is_long else high_price >= order.limit
)
# When stop and limit are hit within the same bar, we pessimistically
# assume limit was hit before the stop (i.e. "before it counts")
is_limit_hit_before_stop = (is_limit_hit and
Expand All @@ -905,14 +941,20 @@ def _process_orders(self):
continue

# stop_price, if set, was hit within this bar
price = (min(stop_price or open, order.limit)
if order.is_long else
max(stop_price or open, order.limit))
price = (
min(stop_price or open_price, order.limit)
if order.is_long
else max(stop_price or open_price, order.limit)
)
else:
# Market-if-touched / market order
# Contingent orders always on next open
prev_close = data.Close[-2]
price = prev_close if self._trade_on_close and not order.is_contingent else open
price = (
prev_close
if self._trade_on_close and not order.is_contingent
else open_price
)
if stop_price:
price = max(price, stop_price) if order.is_long else min(price, stop_price)

Expand Down Expand Up @@ -1018,12 +1060,28 @@ def _process_orders(self):
reprocess_orders = True
# Order.stop and TP hit within the same bar, but SL wasn't. This case
# is not ambiguous, because stop and TP go in the same price direction.
elif stop_price and not order.limit and order.tp and (
(order.is_long and order.tp <= high and (order.sl or -np.inf) < low) or
(order.is_short and order.tp >= low and (order.sl or np.inf) > high)):
elif (
stop_price
and not order.limit
and order.tp
and (
(
order.is_long
and order.tp <= high_price
and (order.sl or -np.inf) < low_price
)
or (
order.is_short
and order.tp >= low_price
and (order.sl or np.inf) > high_price
)
)
):
reprocess_orders = True
elif (low <= (order.sl or -np.inf) <= high or
low <= (order.tp or -np.inf) <= high):
elif (
low_price <= (order.sl or -np.inf) <= high_price
or low_price <= (order.tp or -np.inf) <= high_price
):
warnings.warn(
f"({data.index[-1]}) A contingent SL/TP order would execute in the "
"same bar its parent stop/limit order was turned into a trade. "
Expand All @@ -1043,6 +1101,7 @@ def _process_orders(self):
def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int):
assert trade.size * size < 0
assert abs(trade.size) >= abs(size)
self._mark_trade_sums_dirty()

size_left = trade.size + size
assert size_left * trade.size >= 0
Expand All @@ -1063,6 +1122,7 @@ def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int
self._close_trade(close_trade, price, time_index)

def _close_trade(self, trade: Trade, price: float, time_index: int):
self._mark_trade_sums_dirty()
self.trades.remove(trade)
if trade._sl_order:
self.orders.remove(trade._sl_order)
Expand All @@ -1086,6 +1146,7 @@ def _open_trade(self, price: float, size: int,
self.trades.append(trade)
# Apply broker commission at trade open
self._cash -= self._commission(size, price)
self._mark_trade_sums_dirty()
# Create SL/TP (bracket) orders.
if tp:
trade.tp = tp
Expand Down
4 changes: 2 additions & 2 deletions doc/alternatives.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,6 @@ kindly have a look at some similar alternative Python backtesting frameworks:
A unique new method for designing trading and investing systems_.
- [QTPyLib](https://github.com/ranaroussi/qtpylib) -
a versatile, event-driven algorithmic trading library.
- [Gemini](https://github.com/anfederico/Gemini) -
a backtester namely focusing on cryptocurrency markets.
- [Quantdom](https://github.com/constverum/Quantdom) -
a Qt-based framework that lets you focus on modeling financial strategies,
portfolio management, and analyzing backtests.
Expand Down Expand Up @@ -103,3 +101,5 @@ abandoned, and here for posterity reference only:
a library to construct, backtest, analyze, and evaluate portfolios
and their benchmarks, with comprehensive documentation illustrating
all underlying methodologies and statistics.
- [Gemini](https://github.com/anfederico/Gemini) -
a backtester namely focusing on cryptocurrency markets.