From 6f9504248098d7926ab481c922078f4c61afc8fa Mon Sep 17 00:00:00 2001 From: azerv1 Date: Wed, 5 Aug 2026 19:03:41 +0300 Subject: [PATCH] ENH: Add exit tags via Trade.close(tag=) / Position.close(tag=) Trades could already be tagged on entry (Order.tag, inherited as Trade.tag), but there was no way to record *why* a trade was closed: the order placed by Trade.close() unconditionally inherited the entry tag, so post-run analysis could not tell a session-end exit from a signal flip from a discretionary close. Add an optional `tag` keyword to Trade.close() and Position.close() which marks the closing order, and surface it afterwards as Trade.exit_tag and as the ExitTag column of stats._trades. This enables subgroup analysis of exit reasons. The tag recorded is that of whichever order actually closed the trade, so a trade closed FIFO by an opposite order picks up that order's tag. When no tag is passed the closing order keeps inheriting Trade.tag exactly as before, so existing behaviour is unchanged. Contingent SL/TP orders are likewise left alone. Closes #1303 Co-Authored-By: Claude Opus 5 --- backtesting/_stats.py | 1 + backtesting/backtesting.py | 50 +++++++++++++++++++++++++++++--------- backtesting/test/_test.py | 33 ++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/backtesting/_stats.py b/backtesting/_stats.py index 3888192b..de67e818 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -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: diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index d356b211..724ac7fb 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -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'' @@ -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): @@ -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 @@ -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 @@ -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"] @@ -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: @@ -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() @@ -1073,9 +1099,9 @@ 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: @@ -1083,7 +1109,7 @@ def _close_trade(self, trade: Trade, price: float, time_index: int): 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) diff --git a/backtesting/test/_test.py b/backtesting/test/_test.py index d74fde9f..242cdb52 100644 --- a/backtesting/test/_test.py +++ b/backtesting/test/_test.py @@ -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): @@ -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):