Skip to content

Commit 8fbb902

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 9243738 commit 8fbb902

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

@@ -781,10 +795,10 @@ def last_price(self) -> float:
781795

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

789803
@property
790804
def equity(self) -> float:
@@ -892,15 +906,17 @@ def _process_orders(self):
892906
# Adjust price to include commission (or bid-ask spread).
893907
# In long positions, the adjusted price is a fraction higher, and vice versa.
894908
adjusted_price = self._adjusted_price(order.size, price)
909+
adjusted_price_plus_commission = adjusted_price + self._commission(order.size, price)
895910

896911
# If order size was specified proportionally,
897912
# precompute true size in units, accounting for margin and spread/commissions
898913
size = order.size
899914
if -1 < size < 1:
900915
size = copysign(int((self.margin_available * self._leverage * abs(size))
901-
// adjusted_price), size)
916+
// adjusted_price_plus_commission), size)
902917
# Not enough cash/margin even for a single unit
903918
if not size:
919+
# XXX: The order is canceled by the broker?
904920
self.orders.remove(order)
905921
continue
906922
assert size == round(size)
@@ -929,8 +945,9 @@ def _process_orders(self):
929945
if not need_size:
930946
break
931947

932-
# If we don't have enough liquidity to cover for the order, cancel it
933-
if abs(need_size) * adjusted_price > self.margin_available * self._leverage:
948+
# If we don't have enough liquidity to cover for the order, the broker CANCELS it
949+
if abs(need_size) * adjusted_price_plus_commission > \
950+
self.margin_available * self._leverage:
934951
self.orders.remove(order)
935952
continue
936953

@@ -997,12 +1014,15 @@ def _close_trade(self, trade: Trade, price: float, time_index: int):
9971014
self.orders.remove(trade._tp_order)
9981015

9991016
self.closed_trades.append(trade._replace(exit_price=price, exit_bar=time_index))
1000-
self._cash += trade.pl
1017+
# Apply commission one more time at trade exit
1018+
self._cash += trade.pl - self._commission(trade.size, price)
10011019

10021020
def _open_trade(self, price: float, size: int,
10031021
sl: Optional[float], tp: Optional[float], time_index: int, tag):
10041022
trade = Trade(self, size, price, time_index, tag)
10051023
self.trades.append(trade)
1024+
# Apply broker commission at trade open
1025+
self._cash -= self._commission(size, price)
10061026
# Create SL/TP (bracket) orders.
10071027
# Make sure SL order is created first so it gets adversarially processed before TP order
10081028
# in case of an ambiguous tie (both hit within a single bar).
@@ -1028,7 +1048,8 @@ def __init__(self,
10281048
strategy: Type[Strategy],
10291049
*,
10301050
cash: float = 10_000,
1031-
commission: float = .0,
1051+
spread: float = .0,
1052+
commission: Union[float, Tuple[float, float]] = .0,
10321053
margin: float = 1.,
10331054
trade_on_close=False,
10341055
hedging=False,
@@ -1054,11 +1075,25 @@ def __init__(self,
10541075
10551076
`cash` is the initial cash to start with.
10561077
1057-
`commission` is the commission ratio. E.g. if your broker's commission
1058-
is 1% of trade value, set commission to `0.01`. Note, if you wish to
1059-
account for bid-ask spread, you can approximate doing so by increasing
1060-
the commission, e.g. set it to `0.0002` for commission-less forex
1061-
trading where the average spread is roughly 0.2‰ of asking price.
1078+
`spread` is the the constant bid-ask spread rate (relative to the price).
1079+
E.g. set it to `0.0002` for commission-less forex
1080+
trading where the average spread is roughly 0.2‰ of the asking price.
1081+
1082+
`commission` is the commission rate. E.g. if your broker's commission
1083+
is 1% of order value, set commission to `0.01`.
1084+
The commission is applied twice: at trade entry and at trade exit.
1085+
Besides one single floating value, `commission` can also be a tuple of floating
1086+
values `(fixed, relative)`. E.g. set it to `(100, .01)`
1087+
if your broker charges minimum $100 + 1%.
1088+
Additionally, `commission` can be a callable
1089+
`func(order_size: int, price: float) -> float`
1090+
(note, order size is negative for short orders),
1091+
which can be used to model more complex commission structures.
1092+
Negative commission values are interpreted as market-maker's rebates.
1093+
1094+
.. note::
1095+
Before v0.4.0, the commission was only applied once, like `spread` is now.
1096+
If you want to keep the old behavior, simply set `spread` instead.
10621097
10631098
.. note::
10641099
With nonzero `commission`, long and short orders will be placed
@@ -1092,9 +1127,14 @@ def __init__(self,
10921127
raise TypeError('`strategy` must be a Strategy sub-type')
10931128
if not isinstance(data, pd.DataFrame):
10941129
raise TypeError("`data` must be a pandas.DataFrame with columns")
1095-
if not isinstance(commission, Number):
1096-
raise TypeError('`commission` must be a float value, percent of '
1130+
if not isinstance(spread, Number):
1131+
raise TypeError('`spread` must be a float value, percent of '
10971132
'entry order price')
1133+
if not isinstance(commission, (Number, tuple)) and not callable(commission):
1134+
raise TypeError('`commission` must be a float percent of order value, '
1135+
'a tuple of `(fixed, relative)` commission, '
1136+
'or a function that takes `(order_size, price)`'
1137+
'and returns commission dollar value')
10981138

10991139
data = data.copy(deep=False)
11001140

@@ -1137,7 +1177,7 @@ def __init__(self,
11371177

11381178
self._data: pd.DataFrame = data
11391179
self._broker = partial(
1140-
_Broker, cash=cash, commission=commission, margin=margin,
1180+
_Broker, cash=cash, spread=spread, commission=commission, margin=margin,
11411181
trade_on_close=trade_on_close, hedging=hedging,
11421182
exclusive_orders=exclusive_orders, index=data.index,
11431183
)

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)
@@ -389,7 +420,7 @@ def next(self):
389420
if self.position and crossover(self.sma2, self.sma1):
390421
self.position.close(portion=.5)
391422

392-
bt = Backtest(GOOG, SmaCross, commission=.002)
423+
bt = Backtest(GOOG, SmaCross, spread=.002)
393424
bt.run()
394425

395426
def test_close_orders_from_last_strategy_iteration(self):
@@ -411,7 +442,7 @@ def init(self): pass
411442
def next(self):
412443
self.buy(tp=self.data.Close * 1.01)
413444

414-
self.assertRaises(ValueError, Backtest(SHORT_DATA, S, commission=.02).run)
445+
self.assertRaises(ValueError, Backtest(SHORT_DATA, S, spread=.02).run)
415446

416447

417448
class TestStrategy(TestCase):

0 commit comments

Comments
 (0)