Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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