Skip to content
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
1 change: 1 addition & 0 deletions backtesting/_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def compute_stats(
})
trades_df['Duration'] = trades_df['ExitTime'] - trades_df['EntryTime']
trades_df['Tag'] = [t.tag for t in trades]
trades_df['ExitTag'] = [t.exit_tag for t in trades]

# Add indicator values
if len(trades_df) and strategy_instance:
Expand Down
50 changes: 38 additions & 12 deletions backtesting/backtesting.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,12 +367,15 @@ def is_short(self) -> bool:
"""True if the position is short (position size is negative)."""
return self.size < 0

def close(self, portion: float = 1.):
def close(self, portion: float = 1., *, tag: object = None):
"""
Close portion of position by closing `portion` of each active trade. See `Trade.close`.

An optional `tag` marks the closing orders and is afterwards available
as `Trade.exit_tag`, e.g. to record the reason for the exit.
"""
for trade in self.__broker.trades:
trade.close(portion)
trade.close(portion, tag=tag)

def __repr__(self):
return f'<Position: {self.size} ({len(self.__broker.trades)} trades)>'
Expand Down Expand Up @@ -555,6 +558,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.__exit_tag: object = None
self._commissions = 0

def __repr__(self):
Expand All @@ -570,12 +574,19 @@ def _replace(self, **kwargs):
def _copy(self, **kwargs):
return copy(self)._replace(**kwargs)

def close(self, portion: float = 1.):
"""Place new `Order` to close `portion` of the trade at next market price."""
def close(self, portion: float = 1., *, tag: object = None):
"""
Place new `Order` to close `portion` of the trade at next market price.

An optional `tag` marks the closing order and is afterwards available
as `Trade.exit_tag`, e.g. to record the reason for the exit.
When omitted, the closing order inherits this trade's `Trade.tag`.
"""
assert 0 < portion <= 1, "portion must be a fraction between 0 and 1"
# Ensure size is an int to avoid rounding errors on 32-bit OS
size = copysign(max(1, int(round(abs(self.__size) * portion))), -self.__size)
order = Order(self.__broker, size, parent_trade=self, tag=self.__tag)
order = Order(self.__broker, size, parent_trade=self,
tag=self.__tag if tag is None else tag)
self.__broker.orders.insert(0, order)

# Fields getters
Expand Down Expand Up @@ -621,6 +632,20 @@ def tag(self):
"""
return self.__tag

@property
def exit_tag(self):
"""
A tag value inherited from the `Order` that closed this trade
(or None if the trade is still active).

Pass `tag=` to `Trade.close()` or `Position.close()` to set it,
e.g. to record the reason the trade was exited. Absent that, the
closing order inherits `Trade.tag`.

See also `Trade.tag`.
"""
return self.__exit_tag

@property
def _sl_order(self):
return self.__sl_order
Expand Down Expand Up @@ -936,7 +961,7 @@ def _process_orders(self):
size = copysign(min(abs(_prev_size), abs(order.size)), order.size)
# If this trade isn't already closed (e.g. on multiple `trade.close(.5)` calls)
if trade in self.trades:
self._reduce_trade(trade, price, size, time_index)
self._reduce_trade(trade, price, size, time_index, order.tag)
assert order.size != -_prev_size or trade not in self.trades
if order is trade._sl_order:
# Set SL back on the order for stats._trades["SL"]
Expand Down Expand Up @@ -992,12 +1017,12 @@ def _process_orders(self):
# Order size greater than this opposite-directed existing trade,
# so it will be closed completely
if abs(need_size) >= abs(trade.size):
self._close_trade(trade, price, time_index)
self._close_trade(trade, price, time_index, order.tag)
need_size += trade.size
else:
# The existing trade is larger than the new order,
# so it will only be closed partially
self._reduce_trade(trade, price, need_size, time_index)
self._reduce_trade(trade, price, need_size, time_index, order.tag)
need_size = 0

if not need_size:
Expand Down Expand Up @@ -1052,7 +1077,8 @@ def _process_orders(self):
if reprocess_orders:
self._process_orders()

def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int):
def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int,
tag: object = None):
assert trade.size * size < 0
assert abs(trade.size) >= abs(size)
self._trades_cache_clear()
Expand All @@ -1073,17 +1099,17 @@ def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int
close_trade = trade._copy(size=-size, sl_order=None, tp_order=None)
self.trades.append(close_trade)

self._close_trade(close_trade, price, time_index)
self._close_trade(close_trade, price, time_index, tag)

def _close_trade(self, trade: Trade, price: float, time_index: int):
def _close_trade(self, trade: Trade, price: float, time_index: int, tag: object = None):
self._trades_cache_clear()
self.trades.remove(trade)
if trade._sl_order:
self.orders.remove(trade._sl_order)
if trade._tp_order:
self.orders.remove(trade._tp_order)

closed_trade = trade._replace(exit_price=price, exit_bar=time_index)
closed_trade = trade._replace(exit_price=price, exit_bar=time_index, exit_tag=tag)
self.closed_trades.append(closed_trade)
# Apply commission one more time at trade exit
commission = self._commission(trade.size, price)
Expand Down
33 changes: 32 additions & 1 deletion backtesting/test/_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ def almost_equal(a, b):
sorted(stats['_trades'].columns),
sorted(['Size', 'EntryBar', 'ExitBar', 'EntryPrice', 'ExitPrice',
'SL', 'TP', 'PnL', 'ReturnPct', 'EntryTime', 'ExitTime',
'Duration', 'Tag', 'Commission',
'Duration', 'Tag', 'ExitTag', 'Commission',
*indicator_columns]))

def test_compute_stats_bordercase(self):
Expand Down Expand Up @@ -591,6 +591,37 @@ def coroutine(self):
stats = self._Backtest(coroutine).run()
self.assertEqual(list(stats._trades.Tag), [1, 1, 2])

def test_trade_exit_tag(self):
def coroutine(self):
yield self.buy(size=2, tag='in')
yield self.position.close(tag='out')

yield self.buy(size=1, tag='in2')
yield self.trades[-1].close() # No tag; inherits the trade's own

stats = self._Backtest(coroutine).run()
self.assertEqual(list(stats._trades.Tag), ['in', 'in2'])
self.assertEqual(list(stats._trades.ExitTag), ['out', 'in2'])

def test_trade_exit_tag_partial_close(self):
def coroutine(self):
yield self.buy(size=4, tag='in')
yield self.trades[-1].close(.5, tag='half')
yield self.trades[-1].close(tag='rest')

stats = self._Backtest(coroutine).run()
self.assertEqual(list(stats._trades.Size), [2, 2])
self.assertEqual(list(stats._trades.ExitTag), ['half', 'rest'])

def test_trade_exit_tag_opposite_order(self):
def coroutine(self):
yield self.buy(size=2, tag='long')
yield self.sell(size=2, tag='flip')

stats = self._Backtest(coroutine).run()
self.assertEqual(list(stats._trades.Tag), ['long'])
self.assertEqual(list(stats._trades.ExitTag), ['flip'])


class TestOptimize(TestCase):
def test_optimize(self):
Expand Down