Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: Add Backtest(spread=), change Backtest(commission=) #839

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
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
4 changes: 4 additions & 0 deletions backtesting/_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def compute_stats(

if isinstance(trades, pd.DataFrame):
trades_df: pd.DataFrame = trades
commissions = None # Not shown
else:
# Came straight from Backtest.run()
trades_df = pd.DataFrame({
Expand All @@ -68,6 +69,7 @@ def compute_stats(
'Tag': [t.tag for t in trades],
})
trades_df['Duration'] = trades_df['ExitTime'] - trades_df['EntryTime']
commissions = sum(t._commissions for t in trades)
del trades

pl = trades_df['PnL']
Expand All @@ -92,6 +94,8 @@ def _round_timedelta(value, _period=_data_period(index)):
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()
if commissions:
s.loc['Commissions [$]'] = commissions
s.loc['Return [%]'] = (equity[-1] - equity[0]) / equity[0] * 100
c = ohlc_data.Close.values
s.loc['Buy & Hold Return [%]'] = (c[-1] - c[0]) / c[0] * 100 # long-only return
Expand Down
90 changes: 69 additions & 21 deletions backtesting/backtesting.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@ def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar,
self.__sl_order: Optional[Order] = None
self.__tp_order: Optional[Order] = None
self.__tag = tag
self._commissions = 0

def __repr__(self):
return f'<Trade size={self.__size} time={self.__entry_bar}-{self.__exit_bar or ""} ' \
Expand Down Expand Up @@ -698,16 +699,27 @@ def __set_contingent(self, type, price):


class _Broker:
def __init__(self, *, data, cash, commission, margin,
def __init__(self, *, data, cash, spread, commission, margin,
trade_on_close, hedging, exclusive_orders, index):
assert 0 < cash, f"cash should be >0, is {cash}"
assert -.1 <= commission < .1, \
("commission should be between -10% "
f"(e.g. market-maker's rebates) and 10% (fees), is {commission}")
assert 0 < margin <= 1, f"margin should be between 0 and 1, is {margin}"
self._data: _Data = data
self._cash = cash
self._commission = commission

if callable(commission):
self._commission = commission
else:
try:
self._commission_fixed, self._commission_relative = commission
except TypeError:
self._commission_fixed, self._commission_relative = 0, commission
assert self._commission_fixed >= 0, 'Need fixed cash commission in $ >= 0'
assert -.1 <= self._commission_relative < .1, \
("commission should be between -10% "
f"(e.g. market-maker's rebates) and 10% (fees), is {self._commission_relative}")
self._commission = self._commission_func

self._spread = spread
self._leverage = 1 / margin
self._trade_on_close = trade_on_close
self._hedging = hedging
Expand All @@ -719,6 +731,9 @@ def __init__(self, *, data, cash, commission, margin,
self.position = Position(self)
self.closed_trades: List[Trade] = []

def _commission_func(self, order_size, price):
return self._commission_fixed + abs(order_size) * price * self._commission_relative
Comment on lines +734 to +735
Copy link
Owner Author

@kernc kernc Feb 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this cover all sorts of commission cases?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The base case is solved I would say.

Nice to have:
In cryptocurrency exchanges, your commissions tend to be reduced over a rolling 30-day period if you create enough volume.

It would be handy if you could create a scaling commission feature based on the transaction volume. It would also be handy if it can be split into maker/taker.

image


def __repr__(self):
return f'<Broker: {self._cash:.0f}{self.position.pl:+.1f} ({len(self.trades)} trades)>'

Expand Down Expand Up @@ -780,10 +795,10 @@ def last_price(self) -> float:

def _adjusted_price(self, size=None, price=None) -> float:
"""
Long/short `price`, adjusted for commisions.
Long/short `price`, adjusted for spread.
In long positions, the adjusted price is a fraction higher, and vice versa.
"""
return (price or self.last_price) * (1 + copysign(self._commission, size))
return (price or self.last_price) * (1 + copysign(self._spread, size))

@property
def equity(self) -> float:
Expand Down Expand Up @@ -890,15 +905,17 @@ def _process_orders(self):
# Adjust price to include commission (or bid-ask spread).
# In long positions, the adjusted price is a fraction higher, and vice versa.
adjusted_price = self._adjusted_price(order.size, price)
adjusted_price_plus_commission = adjusted_price + self._commission(order.size, price)

# If order size was specified proportionally,
# precompute true size in units, accounting for margin and spread/commissions
size = order.size
if -1 < size < 1:
size = copysign(int((self.margin_available * self._leverage * abs(size))
// adjusted_price), size)
// adjusted_price_plus_commission), size)
# Not enough cash/margin even for a single unit
if not size:
# XXX: The order is canceled by the broker?
self.orders.remove(order)
continue
assert size == round(size)
Expand Down Expand Up @@ -927,8 +944,9 @@ def _process_orders(self):
if not need_size:
break

# If we don't have enough liquidity to cover for the order, cancel it
if abs(need_size) * adjusted_price > self.margin_available * self._leverage:
# If we don't have enough liquidity to cover for the order, the broker CANCELS it
if abs(need_size) * adjusted_price_plus_commission > \
self.margin_available * self._leverage:
self.orders.remove(order)
continue

Expand Down Expand Up @@ -994,13 +1012,23 @@ def _close_trade(self, trade: Trade, price: float, time_index: int):
if trade._tp_order:
self.orders.remove(trade._tp_order)

self.closed_trades.append(trade._replace(exit_price=price, exit_bar=time_index))
self._cash += trade.pl
closed_trade = trade._replace(exit_price=price, exit_bar=time_index)
self.closed_trades.append(closed_trade)
# Apply commission one more time at trade exit
commission = self._commission(trade.size, price)
self._cash += trade.pl - commission
# Save commissions on Trade instance for stats
trade_open_commission = self._commission(closed_trade.size, closed_trade.entry_price)
# applied here instead of on Trade open because size could have changed
# by way of _reduce_trade()
closed_trade._commissions = commission + trade_open_commission

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)
# Apply broker commission at trade open
self._cash -= self._commission(size, price)
# Create SL/TP (bracket) orders.
# Make sure SL order is created first so it gets adversarially processed before TP order
# in case of an ambiguous tie (both hit within a single bar).
Expand All @@ -1026,7 +1054,8 @@ def __init__(self,
strategy: Type[Strategy],
*,
cash: float = 10_000,
commission: float = .0,
spread: float = .0,
commission: Union[float, Tuple[float, float]] = .0,
margin: float = 1.,
trade_on_close=False,
hedging=False,
Expand All @@ -1052,11 +1081,25 @@ def __init__(self,

`cash` is the initial cash to start with.

`commission` is the commission ratio. E.g. if your broker's commission
is 1% of trade value, set commission to `0.01`. Note, if you wish to
account for bid-ask spread, you can approximate doing so by increasing
the commission, e.g. set it to `0.0002` for commission-less forex
trading where the average spread is roughly 0.2‰ of asking price.
`spread` is the the constant bid-ask spread rate (relative to the price).
E.g. set it to `0.0002` for commission-less forex
trading where the average spread is roughly 0.2‰ of the asking price.

`commission` is the commission rate. E.g. if your broker's commission
is 1% of order value, set commission to `0.01`.
The commission is applied twice: at trade entry and at trade exit.
Besides one single floating value, `commission` can also be a tuple of floating
values `(fixed, relative)`. E.g. set it to `(100, .01)`
if your broker charges minimum $100 + 1%.
Additionally, `commission` can be a callable
`func(order_size: int, price: float) -> float`
(note, order size is negative for short orders),
which can be used to model more complex commission structures.
Negative commission values are interpreted as market-maker's rebates.

.. note::
Before v0.4.0, the commission was only applied once, like `spread` is now.
If you want to keep the old behavior, simply set `spread` instead.

`margin` is the required margin (ratio) of a leveraged account.
No difference is made between initial and maintenance margins.
Expand All @@ -1082,9 +1125,14 @@ def __init__(self,
raise TypeError('`strategy` must be a Strategy sub-type')
if not isinstance(data, pd.DataFrame):
raise TypeError("`data` must be a pandas.DataFrame with columns")
if not isinstance(commission, Number):
raise TypeError('`commission` must be a float value, percent of '
if not isinstance(spread, Number):
raise TypeError('`spread` must be a float value, percent of '
'entry order price')
if not isinstance(commission, (Number, tuple)) and not callable(commission):
raise TypeError('`commission` must be a float percent of order value, '
'a tuple of `(fixed, relative)` commission, '
'or a function that takes `(order_size, price)`'
'and returns commission dollar value')

data = data.copy(deep=False)

Expand Down Expand Up @@ -1127,7 +1175,7 @@ def __init__(self,

self._data: pd.DataFrame = data
self._broker = partial(
_Broker, cash=cash, commission=commission, margin=margin,
_Broker, cash=cash, spread=spread, commission=commission, margin=margin,
trade_on_close=trade_on_close, hedging=hedging,
exclusive_orders=exclusive_orders, index=data.index,
)
Expand Down
37 changes: 34 additions & 3 deletions backtesting/test/_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,9 +222,40 @@ def next(self, _FEW_DAYS=pd.Timedelta('3 days')): # noqa: N803

def test_broker_params(self):
bt = Backtest(GOOG.iloc[:100], SmaCross,
cash=1000, commission=.01, margin=.1, trade_on_close=True)
cash=1000, spread=.01, margin=.1, trade_on_close=True)
bt.run()

def test_spread_commission(self):
class S(Strategy):
def init(self):
self.done = False

def next(self):
if not self.position:
self.buy()
else:
self.position.close()
self.next = lambda: None # Done

SPREAD = .01
COMMISSION = .01
CASH = 10_000
ORDER_BAR = 2
stats = Backtest(SHORT_DATA, S, cash=CASH, spread=SPREAD, commission=COMMISSION).run()
trade_open_price = SHORT_DATA['Open'].iloc[ORDER_BAR]
self.assertEqual(stats['_trades']['EntryPrice'].iloc[0], trade_open_price * (1 + SPREAD))
self.assertEqual(stats['_equity_curve']['Equity'].iloc[2:4].round(2).tolist(),
[9685.31, 9749.33])

stats = Backtest(SHORT_DATA, S, cash=CASH, commission=(100, COMMISSION)).run()
self.assertEqual(stats['_equity_curve']['Equity'].iloc[2:4].round(2).tolist(),
[9784.50, 9718.69])

commission_func = lambda size, price: size * price * COMMISSION # noqa: E731
stats = Backtest(SHORT_DATA, S, cash=CASH, commission=commission_func).run()
self.assertEqual(stats['_equity_curve']['Equity'].iloc[2:4].round(2).tolist(),
[9781.28, 9846.04])

def test_dont_overwrite_data(self):
df = EURUSD.copy()
bt = Backtest(df, SmaCross)
Expand Down Expand Up @@ -388,7 +419,7 @@ def next(self):
if self.position and crossover(self.sma2, self.sma1):
self.position.close(portion=.5)

bt = Backtest(GOOG, SmaCross, commission=.002)
bt = Backtest(GOOG, SmaCross, spread=.002)
bt.run()

def test_close_orders_from_last_strategy_iteration(self):
Expand All @@ -410,7 +441,7 @@ def init(self): pass
def next(self):
self.buy(tp=self.data.Close * 1.01)

self.assertRaises(ValueError, Backtest(SHORT_DATA, S, commission=.02).run)
self.assertRaises(ValueError, Backtest(SHORT_DATA, S, spread=.02).run)


class TestStrategy(TestCase):
Expand Down