Skip to content

Commit fc94f71

Browse files
nyxst4ckruvnet
andcommitted
fix(broker): apply spread on both transactions
Rework the proposed behavior to charge the configured spread at trade entry and exit while retaining per-transaction commission calculation. Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent f8b99ec commit fc94f71

2 files changed

Lines changed: 40 additions & 127 deletions

File tree

backtesting/backtesting.py

Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ def size(self) -> float:
349349
@property
350350
def pl(self) -> float:
351351
"""Profit (positive) or loss (negative) of the current position in cash units."""
352-
return sum(trade.pl for trade in self.__broker.trades)
352+
return self.__broker._position_unrealized_pl
353353

354354
@property
355355
def pl_pct(self) -> float:
@@ -656,7 +656,7 @@ def is_short(self):
656656
def pl(self):
657657
"""
658658
Trade profit (positive) or loss (negative) in cash units.
659-
Commissions already incurred are reflected.
659+
Commissions are reflected only after the Trade is closed.
660660
"""
661661
price = self.__exit_price or self.__broker.last_price
662662
return (self.__size * (price - self.__entry_price)) - self._commissions
@@ -821,8 +821,6 @@ def _position_initial_value(self) -> float:
821821

822822
@cached_property
823823
def _position_unrealized_pl(self) -> float:
824-
# Entry commissions are already reflected in cash. Keep this value gross
825-
# so equity does not count those commissions twice.
826824
return (self.last_price * self._position_size -
827825
sum(trade.size * trade.entry_price for trade in self.trades))
828826

@@ -838,10 +836,10 @@ def last_price(self) -> float:
838836

839837
def _adjusted_price(self, size=None, price=None) -> float:
840838
"""
841-
Long/short `price`, adjusted for half the bid-ask spread.
839+
Long/short `price`, adjusted for spread.
842840
In long positions, the adjusted price is a fraction higher, and vice versa.
843841
"""
844-
return (price or self.last_price) * (1 + copysign(self._spread / 2, size))
842+
return (price or self.last_price) * (1 + copysign(self._spread, size))
845843

846844
@property
847845
def equity(self) -> float:
@@ -957,7 +955,8 @@ def _process_orders(self):
957955

958956
# Else this is a stand-alone trade
959957

960-
# Adjust the entry price for half the bid-ask spread.
958+
# Adjust price to include commission (or bid-ask spread).
959+
# In long positions, the adjusted price is a fraction higher, and vice versa.
961960
adjusted_price = self._adjusted_price(order.size, price)
962961
adjusted_price_plus_commission = \
963962
adjusted_price + self._commission(order.size, price) / abs(order.size)
@@ -1057,26 +1056,20 @@ def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int
10571056
assert abs(trade.size) >= abs(size)
10581057
self._trades_cache_clear()
10591058

1060-
original_size = trade.size
10611059
size_left = trade.size + size
10621060
assert size_left * trade.size >= 0
10631061
if not size_left:
10641062
close_trade = trade
10651063
else:
1066-
# Allocate already-paid entry commission proportionally before
1067-
# reducing the active trade. This is especially important for
1068-
# fixed commissions, which must not be recreated per fragment.
1069-
close_trade = trade._copy(size=-size, sl_order=None, tp_order=None)
1070-
close_trade._commissions = \
1071-
trade._commissions * abs(size) / abs(original_size)
1072-
trade._commissions -= close_trade._commissions
1073-
10741064
# Reduce existing trade ...
10751065
trade._replace(size=size_left)
10761066
if trade._sl_order:
10771067
trade._sl_order._replace(size=-trade.size)
10781068
if trade._tp_order:
10791069
trade._tp_order._replace(size=-trade.size)
1070+
1071+
# ... by closing a reduced copy of it
1072+
close_trade = trade._copy(size=-size, sl_order=None, tp_order=None)
10801073
self.trades.append(close_trade)
10811074

10821075
self._close_trade(close_trade, price, time_index)
@@ -1089,25 +1082,26 @@ def _close_trade(self, trade: Trade, price: float, time_index: int):
10891082
if trade._tp_order:
10901083
self.orders.remove(trade._tp_order)
10911084

1092-
# Apply the remaining half of the bid-ask spread at exit.
1085+
# Crossing the spread affects both transactions: entry and exit.
10931086
price = self._adjusted_price(-trade.size, price)
10941087
closed_trade = trade._replace(exit_price=price, exit_bar=time_index)
10951088
self.closed_trades.append(closed_trade)
1096-
# Entry commission was already debited and stored when the trade opened.
1097-
# Realize gross P/L here so it is not subtracted from cash twice.
1089+
# Apply commission one more time at trade exit
10981090
commission = self._commission(trade.size, price)
1099-
self._cash += trade.size * (price - trade.entry_price) - commission
1100-
closed_trade._commissions += commission
1091+
self._cash += trade.pl - commission
1092+
# Save commissions on Trade instance for stats
1093+
trade_open_commission = self._commission(closed_trade.size, closed_trade.entry_price)
1094+
# applied here instead of on Trade open because size could have changed
1095+
# by way of _reduce_trade()
1096+
closed_trade._commissions = commission + trade_open_commission
11011097

11021098
def _open_trade(self, price: float, size: int,
11031099
sl: Optional[float], tp: Optional[float], time_index: int, tag):
11041100
trade = Trade(self, size, price, time_index, tag)
11051101
self.trades.append(trade)
11061102
self._trades_cache_clear()
11071103
# Apply broker commission at trade open
1108-
commission = self._commission(size, price)
1109-
self._cash -= commission
1110-
trade._commissions = commission
1104+
self._cash -= self._commission(size, price)
11111105
# Create SL/TP (bracket) orders.
11121106
if tp:
11131107
trade.tp = tp
@@ -1144,7 +1138,7 @@ class Backtest:
11441138
`cash` is the initial cash to start with.
11451139
11461140
`spread` is the constant bid-ask spread rate (relative to the price).
1147-
Half the spread is applied at trade entry and the other half at trade exit.
1141+
The spread is applied when entering and exiting a trade.
11481142
E.g. set it to `0.0002` for commission-less forex
11491143
trading where the average spread is roughly 0.2‰ of the asking price.
11501144
@@ -1161,8 +1155,15 @@ class Backtest:
11611155
Negative commission values are interpreted as market-maker's rebates.
11621156
11631157
.. note::
1164-
Before v0.4.0, the commission was only applied once, like `spread` is now.
1165-
If you want to keep the old behavior, simply set `spread` instead.
1158+
Before v0.4.0, the commission was only applied once per trade.
1159+
1160+
.. note::
1161+
With nonzero `commission`, long and short orders will be placed
1162+
at an adjusted price that is slightly higher or lower (respectively)
1163+
than the current price. See e.g.
1164+
[#153](https://github.com/kernc/backtesting.py/issues/153),
1165+
[#538](https://github.com/kernc/backtesting.py/issues/538),
1166+
[#633](https://github.com/kernc/backtesting.py/issues/633).
11661167
11671168
`margin` is the required margin (ratio) of a leveraged account.
11681169
No difference is made between initial and maintenance margins.

backtesting/test/_test.py

Lines changed: 12 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -249,9 +249,9 @@ def next(self):
249249
ORDER_BAR = 2
250250
stats = Backtest(SHORT_DATA, S, cash=CASH, spread=SPREAD, commission=COMMISSION).run()
251251
trade_open_price = SHORT_DATA['Open'].iloc[ORDER_BAR]
252-
self.assertEqual(stats['_trades']['EntryPrice'].iloc[0], trade_open_price * (1 + SPREAD / 2))
252+
self.assertEqual(stats['_trades']['EntryPrice'].iloc[0], trade_open_price * (1 + SPREAD))
253253
self.assertEqual(stats['_equity_curve']['Equity'].iloc[2:4].round(2).tolist(),
254-
[9734.52, 9750.10])
254+
[9685.31, 9652.42])
255255

256256
stats = Backtest(SHORT_DATA, S, cash=CASH, commission=(100, COMMISSION)).run()
257257
self.assertEqual(stats['_equity_curve']['Equity'].iloc[2:4].round(2).tolist(),
@@ -262,7 +262,7 @@ def next(self):
262262
self.assertEqual(stats['_equity_curve']['Equity'].iloc[2:4].round(2).tolist(),
263263
[9781.28, 9846.04])
264264

265-
def test_spread_is_split_between_entry_and_exit(self):
265+
def test_spread_is_applied_at_entry_and_exit(self):
266266
class Long(Strategy):
267267
def init(self):
268268
pass
@@ -287,99 +287,11 @@ def next(self):
287287
long_trade = Backtest(data, Long, spread=.02).run()._trades.iloc[0]
288288
short_trade = Backtest(data, Short, spread=.02).run()._trades.iloc[0]
289289

290-
self.assertEqual((long_trade.EntryPrice, long_trade.ExitPrice), (101., 99.))
291-
self.assertEqual((short_trade.EntryPrice, short_trade.ExitPrice), (99., 101.))
292-
self.assertEqual((long_trade.PnL, short_trade.PnL), (-20., -20.))
290+
self.assertEqual((long_trade.EntryPrice, long_trade.ExitPrice), (102., 98.))
291+
self.assertEqual((short_trade.EntryPrice, short_trade.ExitPrice), (98., 102.))
292+
self.assertEqual((long_trade.PnL, short_trade.PnL), (-40., -40.))
293293

294-
def test_open_trade_pl_includes_entry_commission(self):
295-
class S(Strategy):
296-
def init(self):
297-
self.open_pl = self.open_pl_pct = None
298-
self.position_pl = self.position_pl_pct = None
299-
300-
def next(self):
301-
if len(self.data) == 2:
302-
self.buy(size=10)
303-
elif self.position:
304-
trade = self.trades[0]
305-
self.open_pl = trade.pl
306-
self.open_pl_pct = trade.pl_pct
307-
self.position_pl = self.position.pl
308-
self.position_pl_pct = self.position.pl_pct
309-
self.position.close()
310-
311-
index = pd.date_range('2020', periods=5)
312-
data = pd.DataFrame({column: 100. for column in ('Open', 'High', 'Low', 'Close')},
313-
index=index)
314-
stats = Backtest(data, S, cash=10_000, commission=(5, .01)).run()
315-
316-
self.assertEqual(stats._strategy.open_pl, -15.)
317-
self.assertEqual(stats._strategy.open_pl_pct, -.015)
318-
self.assertEqual(stats._strategy.position_pl, -15.)
319-
self.assertEqual(stats._strategy.position_pl_pct, -1.5)
320-
self.assertEqual(stats._trades.Commission.iloc[0], 30.)
321-
self.assertEqual(stats._trades.PnL.iloc[0], -30.)
322-
323-
def test_partial_close_allocates_entry_commission(self):
324-
class S(Strategy):
325-
def init(self):
326-
self.remaining_pl = None
327-
self.accounting_delta = None
328-
329-
def next(self):
330-
if len(self.data) == 2:
331-
self.buy(size=10)
332-
elif len(self.data) == 3:
333-
self.position.close(.4)
334-
elif self.position:
335-
self.remaining_pl = self.trades[0].pl
336-
self.accounting_delta = (
337-
self.closed_trades[0].pl + self.trades[0].pl,
338-
self.equity - 10_000,
339-
)
340-
self.position.close()
341-
342-
index = pd.date_range('2020', periods=6)
343-
data = pd.DataFrame({column: 100. for column in ('Open', 'High', 'Low', 'Close')},
344-
index=index)
345-
stats = Backtest(data, S, cash=10_000, commission=(5, .01)).run()
346-
347-
self.assertEqual(stats._strategy.remaining_pl, -9.)
348-
self.assertEqual(stats._strategy.accounting_delta, (-24., -24.))
349-
self.assertEqual(stats['Commissions [$]'], 35.)
350-
self.assertEqual(stats._trades.Commission.tolist(), [15., 20.])
351-
self.assertEqual(stats._trades.PnL.tolist(), [-15., -20.])
352-
353-
def test_callable_entry_commission_is_not_recomputed_at_close(self):
354-
class Commission:
355-
def __init__(self):
356-
self.calls = []
357-
358-
def __call__(self, size, price):
359-
self.calls.append((size, price))
360-
return len(self.calls)
361-
362-
class S(Strategy):
363-
def init(self):
364-
pass
365-
366-
def next(self):
367-
if len(self.data) == 2:
368-
self.buy(size=10)
369-
elif self.position:
370-
self.position.close()
371-
372-
commission = Commission()
373-
index = pd.date_range('2020', periods=5)
374-
data = pd.DataFrame({column: 100. for column in ('Open', 'High', 'Low', 'Close')},
375-
index=index)
376-
stats = Backtest(data, S, cash=10_000, commission=commission).run()
377-
378-
self.assertEqual(len(commission.calls), 3)
379-
self.assertEqual(stats._trades.Commission.iloc[0], 5.)
380-
self.assertEqual(stats._trades.PnL.iloc[0], -5.)
381-
382-
def test_reversal_applies_half_spread_once_per_fill(self):
294+
def test_reversal_applies_spread_at_each_transaction(self):
383295
class S(Strategy):
384296
def init(self):
385297
pass
@@ -398,10 +310,10 @@ def next(self):
398310
trades = Backtest(data, S, spread=.02).run()._trades
399311

400312
self.assertEqual(trades[['Size', 'EntryPrice', 'ExitPrice']].values.tolist(),
401-
[[10., 101., 99.], [-5., 99., 101.]])
402-
self.assertEqual(trades.PnL.tolist(), [-20., -10.])
313+
[[10., 102., 98.], [-5., 98., 102.]])
314+
self.assertEqual(trades.PnL.tolist(), [-40., -20.])
403315

404-
def test_stop_exit_pays_half_spread(self):
316+
def test_stop_exit_pays_spread(self):
405317
class S(_S):
406318
def next(self):
407319
if len(self.data) == 2:
@@ -415,9 +327,9 @@ def next(self):
415327
}, index=pd.date_range('2020', periods=5))
416328
trade = Backtest(data, S, spread=.02).run()._trades.iloc[0]
417329

418-
self.assertEqual(trade.EntryPrice, 101.)
330+
self.assertEqual(trade.EntryPrice, 102.)
419331
self.assertEqual(trade.SL, 95.)
420-
self.assertEqual(trade.ExitPrice, 94.05)
332+
self.assertEqual(trade.ExitPrice, 93.1)
421333

422334
def test_commissions(self):
423335
class S(_S):

0 commit comments

Comments
 (0)