Skip to content

Commit 4e22454

Browse files
committed
ENH: Add Backtest(spread=), change Backtest(commission=)
`commission=` is now applied twice as common with brokers. `spread=` takes the role `commission=` had previously.
1 parent 1c12381 commit 4e22454

File tree

2 files changed

+94
-23
lines changed

2 files changed

+94
-23
lines changed

backtesting/backtesting.py

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -698,16 +698,27 @@ def __set_contingent(self, type, price):
698698

699699

700700
class _Broker:
701-
def __init__(self, *, data, cash, commission, margin,
701+
def __init__(self, *, data, cash, spread, commission, margin,
702702
trade_on_close, hedging, exclusive_orders, index):
703703
assert 0 < cash, f"cash should be >0, is {cash}"
704-
assert -.1 <= commission < .1, \
705-
("commission should be between -10% "
706-
f"(e.g. market-maker's rebates) and 10% (fees), is {commission}")
707704
assert 0 < margin <= 1, f"margin should be between 0 and 1, is {margin}"
708705
self._data: _Data = data
709706
self._cash = cash
710-
self._commission = commission
707+
708+
if callable(commission):
709+
self._commission = commission
710+
else:
711+
try:
712+
self._commission_fixed, self._commission_relative = commission
713+
except TypeError:
714+
self._commission_fixed, self._commission_relative = 0, commission
715+
assert self._commission_fixed >= 0, 'Need fixed cash commission in $ >= 0'
716+
assert -.1 <= self._commission_relative < .1, \
717+
("commission should be between -10% "
718+
f"(e.g. market-maker's rebates) and 10% (fees), is {self._commission_relative}")
719+
self._commission = self._commission_func
720+
721+
self._spread = spread
711722
self._leverage = 1 / margin
712723
self._trade_on_close = trade_on_close
713724
self._hedging = hedging
@@ -719,6 +730,9 @@ def __init__(self, *, data, cash, commission, margin,
719730
self.position = Position(self)
720731
self.closed_trades: List[Trade] = []
721732

733+
def _commission_func(self, order_size, price):
734+
return self._commission_fixed + abs(order_size) * price * self._commission_relative
735+
722736
def __repr__(self):
723737
return f'<Broker: {self._cash:.0f}{self.position.pl:+.1f} ({len(self.trades)} trades)>'
724738

@@ -780,10 +794,10 @@ def last_price(self) -> float:
780794

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

788802
@property
789803
def equity(self) -> float:
@@ -890,15 +904,17 @@ def _process_orders(self):
890904
# Adjust price to include commission (or bid-ask spread).
891905
# In long positions, the adjusted price is a fraction higher, and vice versa.
892906
adjusted_price = self._adjusted_price(order.size, price)
907+
adjusted_price_plus_commission = adjusted_price + self._commission(order.size, price)
893908

894909
# If order size was specified proportionally,
895910
# precompute true size in units, accounting for margin and spread/commissions
896911
size = order.size
897912
if -1 < size < 1:
898913
size = copysign(int((self.margin_available * self._leverage * abs(size))
899-
// adjusted_price), size)
914+
// adjusted_price_plus_commission), size)
900915
# Not enough cash/margin even for a single unit
901916
if not size:
917+
# XXX: The order is canceled by the broker?
902918
self.orders.remove(order)
903919
continue
904920
assert size == round(size)
@@ -927,8 +943,9 @@ def _process_orders(self):
927943
if not need_size:
928944
break
929945

930-
# If we don't have enough liquidity to cover for the order, cancel it
931-
if abs(need_size) * adjusted_price > self.margin_available * self._leverage:
946+
# If we don't have enough liquidity to cover for the order, the broker CANCELS it
947+
if abs(need_size) * adjusted_price_plus_commission > \
948+
self.margin_available * self._leverage:
932949
self.orders.remove(order)
933950
continue
934951

@@ -995,12 +1012,15 @@ def _close_trade(self, trade: Trade, price: float, time_index: int):
9951012
self.orders.remove(trade._tp_order)
9961013

9971014
self.closed_trades.append(trade._replace(exit_price=price, exit_bar=time_index))
998-
self._cash += trade.pl
1015+
# Apply commission one more time at trade exit
1016+
self._cash += trade.pl - self._commission(trade.size, price)
9991017

10001018
def _open_trade(self, price: float, size: int,
10011019
sl: Optional[float], tp: Optional[float], time_index: int, tag):
10021020
trade = Trade(self, size, price, time_index, tag)
10031021
self.trades.append(trade)
1022+
# Apply broker commission at trade open
1023+
self._cash -= self._commission(size, price)
10041024
# Create SL/TP (bracket) orders.
10051025
# Make sure SL order is created first so it gets adversarially processed before TP order
10061026
# in case of an ambiguous tie (both hit within a single bar).
@@ -1026,7 +1046,8 @@ def __init__(self,
10261046
strategy: Type[Strategy],
10271047
*,
10281048
cash: float = 10_000,
1029-
commission: float = .0,
1049+
spread: float = .0,
1050+
commission: Union[float, Tuple[float, float]] = .0,
10301051
margin: float = 1.,
10311052
trade_on_close=False,
10321053
hedging=False,
@@ -1052,11 +1073,25 @@ def __init__(self,
10521073
10531074
`cash` is the initial cash to start with.
10541075
1055-
`commission` is the commission ratio. E.g. if your broker's commission
1056-
is 1% of trade value, set commission to `0.01`. Note, if you wish to
1057-
account for bid-ask spread, you can approximate doing so by increasing
1058-
the commission, e.g. set it to `0.0002` for commission-less forex
1059-
trading where the average spread is roughly 0.2‰ of asking price.
1076+
`spread` is the the constant bid-ask spread rate (relative to the price).
1077+
E.g. set it to `0.0002` for commission-less forex
1078+
trading where the average spread is roughly 0.2‰ of the asking price.
1079+
1080+
`commission` is the commission rate. E.g. if your broker's commission
1081+
is 1% of order value, set commission to `0.01`.
1082+
The commission is applied twice: at trade entry and at trade exit.
1083+
Besides one single floating value, `commission` can also be a tuple of floating
1084+
values `(fixed, relative)`. E.g. set it to `(100, .01)`
1085+
if your broker charges minimum $100 + 1%.
1086+
Additionally, `commission` can be a callable
1087+
`func(order_size: int, price: float) -> float`
1088+
(note, order size is negative for short orders),
1089+
which can be used to model more complex commission structures.
1090+
Negative commission values are interpreted as market-maker's rebates.
1091+
1092+
.. note::
1093+
Before v0.4.0, the commission was only applied once, like `spread` is now.
1094+
If you want to keep the old behavior, simply set `spread` instead.
10601095
10611096
`margin` is the required margin (ratio) of a leveraged account.
10621097
No difference is made between initial and maintenance margins.
@@ -1082,9 +1117,14 @@ def __init__(self,
10821117
raise TypeError('`strategy` must be a Strategy sub-type')
10831118
if not isinstance(data, pd.DataFrame):
10841119
raise TypeError("`data` must be a pandas.DataFrame with columns")
1085-
if not isinstance(commission, Number):
1086-
raise TypeError('`commission` must be a float value, percent of '
1120+
if not isinstance(spread, Number):
1121+
raise TypeError('`spread` must be a float value, percent of '
10871122
'entry order price')
1123+
if not isinstance(commission, (Number, tuple)) and not callable(commission):
1124+
raise TypeError('`commission` must be a float percent of order value, '
1125+
'a tuple of `(fixed, relative)` commission, '
1126+
'or a function that takes `(order_size, price)`'
1127+
'and returns commission dollar value')
10881128

10891129
data = data.copy(deep=False)
10901130

@@ -1127,7 +1167,7 @@ def __init__(self,
11271167

11281168
self._data: pd.DataFrame = data
11291169
self._broker = partial(
1130-
_Broker, cash=cash, commission=commission, margin=margin,
1170+
_Broker, cash=cash, spread=spread, commission=commission, margin=margin,
11311171
trade_on_close=trade_on_close, hedging=hedging,
11321172
exclusive_orders=exclusive_orders, index=data.index,
11331173
)

backtesting/test/_test.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,9 +222,40 @@ def next(self, _FEW_DAYS=pd.Timedelta('3 days')): # noqa: N803
222222

223223
def test_broker_params(self):
224224
bt = Backtest(GOOG.iloc[:100], SmaCross,
225-
cash=1000, commission=.01, margin=.1, trade_on_close=True)
225+
cash=1000, spread=.01, margin=.1, trade_on_close=True)
226226
bt.run()
227227

228+
def test_spread_commission(self):
229+
class S(Strategy):
230+
def init(self):
231+
self.done = False
232+
233+
def next(self):
234+
if not self.position:
235+
self.buy()
236+
else:
237+
self.position.close()
238+
self.next = lambda: None # Done
239+
240+
SPREAD = .01
241+
COMMISSION = .01
242+
CASH = 10_000
243+
ORDER_BAR = 2
244+
stats = Backtest(SHORT_DATA, S, cash=CASH, spread=SPREAD, commission=COMMISSION).run()
245+
trade_open_price = SHORT_DATA['Open'].iloc[ORDER_BAR]
246+
self.assertEqual(stats['_trades']['EntryPrice'].iloc[0], trade_open_price * (1 + SPREAD))
247+
self.assertEqual(stats['_equity_curve']['Equity'].iloc[2:4].round(2).tolist(),
248+
[9685.31, 9749.33])
249+
250+
stats = Backtest(SHORT_DATA, S, cash=CASH, commission=(100, COMMISSION)).run()
251+
self.assertEqual(stats['_equity_curve']['Equity'].iloc[2:4].round(2).tolist(),
252+
[9784.50, 9718.69])
253+
254+
commission_func = lambda size, price: size * price * COMMISSION # noqa: E731
255+
stats = Backtest(SHORT_DATA, S, cash=CASH, commission=commission_func).run()
256+
self.assertEqual(stats['_equity_curve']['Equity'].iloc[2:4].round(2).tolist(),
257+
[9781.28, 9846.04])
258+
228259
def test_dont_overwrite_data(self):
229260
df = EURUSD.copy()
230261
bt = Backtest(df, SmaCross)
@@ -388,7 +419,7 @@ def next(self):
388419
if self.position and crossover(self.sma2, self.sma1):
389420
self.position.close(portion=.5)
390421

391-
bt = Backtest(GOOG, SmaCross, commission=.002)
422+
bt = Backtest(GOOG, SmaCross, spread=.002)
392423
bt.run()
393424

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

413-
self.assertRaises(ValueError, Backtest(SHORT_DATA, S, commission=.02).run)
444+
self.assertRaises(ValueError, Backtest(SHORT_DATA, S, spread=.02).run)
414445

415446

416447
class TestStrategy(TestCase):

0 commit comments

Comments
 (0)