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
84 changes: 42 additions & 42 deletions backtesting/_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,24 +97,24 @@ 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
s = dict()
s['Start'] = index[0]
s['End'] = index[-1]
s['Duration'] = s['End'] - s['Start']

have_position = np.repeat(0, len(index))
for t in trades_df[['EntryBar', 'ExitBar']].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()
s['Exposure Time [%]'] = have_position.mean() * 100 # In "n bars" time, not index time
s['Equity Final [$]'] = equity[-1]
s['Equity Peak [$]'] = equity.max()
if commissions:
s.loc['Commissions [$]'] = commissions
s.loc['Return [%]'] = (equity[-1] - equity[0]) / equity[0] * 100
s['Commissions [$]'] = commissions
s['Return [%]'] = (equity[-1] - equity[0]) / equity[0] * 100
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
s['Buy & Hold Return [%]'] = (c[-1] - c[first_trading_bar]) / c[first_trading_bar] * 100 # long-only return

gmean_day_return: float = 0
day_returns = np.array(np.nan)
Expand All @@ -137,22 +137,22 @@ 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
# 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
s['Return (Ann.) [%]'] = annualized_return * 100
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
# d['Return (Ann.) [%]'] = gmean_day_return * annual_trading_days * 100
# d['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 = (s['Duration'].days + s['Duration'].seconds / 86400) / annual_trading_days
s['CAGR [%]'] = ((s['Equity Final [$]'] / equity[0])**(1 / time_in_years) - 1) * 100 if time_in_years else np.nan # noqa: E501

# 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
s['Sharpe Ratio'] = (s['Return (Ann.) [%]'] - risk_free_rate * 100) / (s['Volatility (Ann.) [%]'] or np.nan) # 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
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
max_dd = -np.nan_to_num(dd.max())
s.loc['Calmar Ratio'] = annualized_return / (-max_dd or np.nan)
s['Calmar Ratio'] = annualized_return / (-max_dd or np.nan)
equity_log_returns = np.log(equity[1:] / equity[:-1])
market_log_returns = np.log(c[1:] / c[:-1])
beta = np.nan
Expand All @@ -161,31 +161,31 @@ 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)
s['Alpha [%]'] = s['Return [%]'] - risk_free_rate * 100 - beta * (s['Buy & Hold Return [%]'] - risk_free_rate * 100) # noqa: E501
s['Beta'] = beta
s['Max. Drawdown [%]'] = max_dd * 100
s['Avg. Drawdown [%]'] = -dd_peaks.mean() * 100
s['Max. Drawdown Duration'] = _round_timedelta(dd_dur.max())
s['Avg. Drawdown Duration'] = _round_timedelta(dd_dur.mean())
s['# Trades'] = n_trades = len(trades_df)
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
s['Win Rate [%]'] = win_rate * 100
s['Best Trade [%]'] = returns.max() * 100
s['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

s = _Stats(s)
s['Avg. Trade [%]'] = mean_return * 100
s['Max. Trade Duration'] = _round_timedelta(durations.max())
s['Avg. Trade Duration'] = _round_timedelta(durations.mean())
s['Profit Factor'] = returns[returns > 0].sum() / (abs(returns[returns < 0].sum()) or np.nan) # noqa: E501
s['Expectancy [%]'] = returns.mean() * 100
s['SQN'] = np.sqrt(n_trades) * pl.mean() / (pl.std() or np.nan)
s['Kelly Criterion'] = win_rate - (1 - win_rate) / (pl[pl > 0].mean() / -pl[pl < 0].mean())

s['_strategy'] = strategy_instance
s['_equity_curve'] = equity_df
s['_trades'] = trades_df

s = _Stats(s, dtype=object)
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):
# Known fast path to avoid needless __get_array reslicing
assert self.__len >= 0, self
return self.__arrays[key][self.__len - 1]

@property
def Open(self) -> _Array:
return self.__get_array('Open')
Expand Down
40 changes: 32 additions & 8 deletions backtesting/backtesting.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from abc import ABCMeta, abstractmethod
from copy import copy
from difflib import get_close_matches
from functools import lru_cache, partial
from functools import cached_property, lru_cache, partial
from itertools import chain, product, repeat
from math import copysign
from numbers import Number
Expand Down Expand Up @@ -344,17 +344,17 @@ 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)
return self.__broker._position_size

@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._position_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)
total_invested = self.__broker._position_initial_value
return (self.pl / total_invested) * 100 if total_invested else 0

@property
Expand Down Expand Up @@ -811,10 +811,28 @@ def new_order(self,

return order

@cached_property
def _position_size(self) -> int:
return sum(int(trade.size) for trade in self.trades)

@cached_property
def _position_initial_value(self) -> float:
return sum(abs(trade.size) * trade.entry_price for trade in self.trades)

@cached_property
def _position_unrealized_pl(self) -> float:
return (self.last_price * self._position_size -
sum(trade.size * trade.entry_price for trade in self.trades))

def _trades_cache_clear(self):
self.__dict__.pop(self.__class__._position_size.func.__name__, None)
self.__dict__.pop(self.__class__._position_initial_value.func.__name__, None)
self.__dict__.pop(self.__class__._position_unrealized_pl.func.__name__, None)

@property
def last_price(self) -> float:
""" Price at the last (current) close. """
return self._data.Close[-1]
return self._data._current_value('Close')

def _adjusted_price(self, size=None, price=None) -> float:
"""
Expand All @@ -825,7 +843,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._position_unrealized_pl

@property
def margin_available(self) -> float:
Expand All @@ -834,6 +852,9 @@ def margin_available(self) -> float:
return max(0, self.equity - margin_used)

def next(self):
# Reset cached value here due to price change on every bar
self.__dict__.pop(self.__class__._position_unrealized_pl.func.__name__, None)

i = self._i = len(self._data) - 1
self._process_orders()

Expand All @@ -845,14 +866,14 @@ def next(self):
if equity <= 0:
assert self.margin_available <= 0
for trade in self.trades:
self._close_trade(trade, self._data.Close[-1], i)
self._close_trade(trade, self.last_price, i)
self._cash = 0
self._equity[i:] = 0
raise _OutOfMoneyError

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

# Process orders
Expand Down Expand Up @@ -1030,6 +1051,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._trades_cache_clear()

size_left = trade.size + size
assert size_left * trade.size >= 0
Expand All @@ -1050,6 +1072,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._trades_cache_clear()
self.trades.remove(trade)
if trade._sl_order:
self.orders.remove(trade._sl_order)
Expand All @@ -1071,6 +1094,7 @@ def _open_trade(self, price: float, size: int,
sl: Optional[float], tp: Optional[float], time_index: int, tag):
trade = Trade(self, size, price, time_index, tag)
self.trades.append(trade)
self._trades_cache_clear()
# Apply broker commission at trade open
self._cash -= self._commission(size, price)
# Create SL/TP (bracket) orders.
Expand Down