Skip to content

Commit 7d40d8d

Browse files
committed
ENH: Add exit_tag support for trades and positions to indicate closure reason
1 parent ca2e261 commit 7d40d8d

4 files changed

Lines changed: 132 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ These were the major changes contributing to each release:
55

66
### 0.x.x
77

8+
* Enhancement: `Position.close()`/`Trade.close()` accept a `tag=` kwarg; SL/TP-triggered
9+
closes are auto-tagged `"sl"`/`"tp"`; new `Trade.exit_tag` property and `stats._trades`
10+
`'ExitTag'` column expose why a trade was closed (#1352)
11+
812
### 0.6.6
913
(2026-07-22)
1014

backtesting/_stats.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def compute_stats(
7474
})
7575
trades_df['Duration'] = trades_df['ExitTime'] - trades_df['EntryTime']
7676
trades_df['Tag'] = [t.tag for t in trades]
77+
trades_df['ExitTag'] = [t.exit_tag for t in trades]
7778

7879
# Add indicator values
7980
if len(trades_df) and strategy_instance:

backtesting/backtesting.py

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -367,12 +367,15 @@ def is_short(self) -> bool:
367367
"""True if the position is short (position size is negative)."""
368368
return self.size < 0
369369

370-
def close(self, portion: float = 1.):
370+
def close(self, portion: float = 1., *, tag=None):
371371
"""
372372
Close portion of position by closing `portion` of each active trade. See `Trade.close`.
373+
374+
If `tag` is given, it is passed through to each `Trade.close()` call,
375+
ending up as `Trade.exit_tag` of every closed trade.
373376
"""
374377
for trade in self.__broker.trades:
375-
trade.close(portion)
378+
trade.close(portion, tag=tag)
376379

377380
def __repr__(self):
378381
return f'<Position: {self.size} ({len(self.__broker.trades)} trades)>'
@@ -555,6 +558,7 @@ def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar,
555558
self.__sl_order: Optional[Order] = None
556559
self.__tp_order: Optional[Order] = None
557560
self.__tag = tag
561+
self.__exit_tag = None
558562
self._commissions = 0
559563

560564
def __repr__(self):
@@ -570,12 +574,20 @@ def _replace(self, **kwargs):
570574
def _copy(self, **kwargs):
571575
return copy(self)._replace(**kwargs)
572576

573-
def close(self, portion: float = 1.):
574-
"""Place new `Order` to close `portion` of the trade at next market price."""
577+
def close(self, portion: float = 1., *, tag=None):
578+
"""
579+
Place new `Order` to close `portion` of the trade at next market price.
580+
581+
If `tag` is given, it is used as the tag of the closing `Order` and ends up as
582+
`Trade.exit_tag` of this trade once closed, so it can be used to record *why*
583+
the trade was closed. If not given, the closing order (and hence `Trade.exit_tag`)
584+
falls back to this trade's opening `Trade.tag`, preserving prior behavior.
585+
"""
575586
assert 0 < portion <= 1, "portion must be a fraction between 0 and 1"
576587
# Ensure size is an int to avoid rounding errors on 32-bit OS
577588
size = copysign(max(1, int(round(abs(self.__size) * portion))), -self.__size)
578-
order = Order(self.__broker, size, parent_trade=self, tag=self.__tag)
589+
order = Order(self.__broker, size, parent_trade=self,
590+
tag=(self.__tag if tag is None else tag))
579591
self.__broker.orders.insert(0, order)
580592

581593
# Fields getters
@@ -621,6 +633,21 @@ def tag(self):
621633
"""
622634
return self.__tag
623635

636+
@property
637+
def exit_tag(self):
638+
"""
639+
A tag value indicating why/how the trade was closed, or `None` while the
640+
trade is still active.
641+
642+
Unlike `Trade.tag` (the *opening* tag, fixed for the life of the trade),
643+
`exit_tag` reflects the *closing* order: `"sl"` or `"tp"` when the trade
644+
was closed automatically by its stop-loss or take-profit order, the
645+
`tag=` passed to `Trade.close()` / `Position.close()` when closed
646+
explicitly, or `None` for a plain close with no tag given (including
647+
trades closed as a side effect of an opposing order filling).
648+
"""
649+
return self.__exit_tag
650+
624651
@property
625652
def _sl_order(self):
626653
return self.__sl_order
@@ -934,9 +961,19 @@ def _process_orders(self):
934961
# If order.size is "greater" than trade.size, this order is a trade.close()
935962
# order and part of the trade was already closed beforehand
936963
size = copysign(min(abs(_prev_size), abs(order.size)), order.size)
964+
# Determine why the trade is being closed, for `Trade.exit_tag`.
965+
# SL/TP orders are auto-tagged "sl"/"tp" unless their tag was
966+
# explicitly set to something other than the trade's opening tag.
967+
if order is trade._sl_order:
968+
exit_tag = 'sl' if order.tag == trade.tag else order.tag
969+
elif order is trade._tp_order:
970+
exit_tag = 'tp' if order.tag == trade.tag else order.tag
971+
else:
972+
# It's a trade.close()/position.close() order
973+
exit_tag = order.tag
937974
# If this trade isn't already closed (e.g. on multiple `trade.close(.5)` calls)
938975
if trade in self.trades:
939-
self._reduce_trade(trade, price, size, time_index)
976+
self._reduce_trade(trade, price, size, time_index, exit_tag)
940977
assert order.size != -_prev_size or trade not in self.trades
941978
if order is trade._sl_order:
942979
# Set SL back on the order for stats._trades["SL"]
@@ -1052,7 +1089,8 @@ def _process_orders(self):
10521089
if reprocess_orders:
10531090
self._process_orders()
10541091

1055-
def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int):
1092+
def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int,
1093+
exit_tag=None):
10561094
assert trade.size * size < 0
10571095
assert abs(trade.size) >= abs(size)
10581096
self._trades_cache_clear()
@@ -1073,17 +1111,17 @@ def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int
10731111
close_trade = trade._copy(size=-size, sl_order=None, tp_order=None)
10741112
self.trades.append(close_trade)
10751113

1076-
self._close_trade(close_trade, price, time_index)
1114+
self._close_trade(close_trade, price, time_index, exit_tag)
10771115

1078-
def _close_trade(self, trade: Trade, price: float, time_index: int):
1116+
def _close_trade(self, trade: Trade, price: float, time_index: int, exit_tag=None):
10791117
self._trades_cache_clear()
10801118
self.trades.remove(trade)
10811119
if trade._sl_order:
10821120
self.orders.remove(trade._sl_order)
10831121
if trade._tp_order:
10841122
self.orders.remove(trade._tp_order)
10851123

1086-
closed_trade = trade._replace(exit_price=price, exit_bar=time_index)
1124+
closed_trade = trade._replace(exit_price=price, exit_bar=time_index, exit_tag=exit_tag)
10871125
self.closed_trades.append(closed_trade)
10881126
# Apply commission one more time at trade exit
10891127
commission = self._commission(trade.size, price)

backtesting/test/_test.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,7 @@ def almost_equal(a, b):
387387
sorted(stats['_trades'].columns),
388388
sorted(['Size', 'EntryBar', 'ExitBar', 'EntryPrice', 'ExitPrice',
389389
'SL', 'TP', 'PnL', 'ReturnPct', 'EntryTime', 'ExitTime',
390-
'Duration', 'Tag', 'Commission',
390+
'Duration', 'Tag', 'ExitTag', 'Commission',
391391
*indicator_columns]))
392392

393393
def test_compute_stats_bordercase(self):
@@ -441,6 +441,18 @@ def next(self):
441441
with self.assertWarns(UserWarning):
442442
self.assertEqual(Backtest(GOOG, S).run()._trades.iloc[0].ExitPrice, 705.58)
443443

444+
def test_trade_sl_hit_sets_exit_tag(self):
445+
the_day = pd.Timestamp("2012-10-17 00:00:00")
446+
447+
class S(_S):
448+
def next(self):
449+
if self.data.index[-1] == the_day:
450+
self.buy(sl=720)
451+
452+
trades = Backtest(GOOG, S).run()._trades
453+
self.assertEqual(trades.iloc[0].ExitPrice, 720)
454+
self.assertEqual(trades.iloc[0].ExitTag, 'sl')
455+
444456
def test_stop_price_between_sl_tp(self):
445457
class S(_S):
446458
def next(self):
@@ -591,6 +603,55 @@ def coroutine(self):
591603
stats = self._Backtest(coroutine).run()
592604
self.assertEqual(list(stats._trades.Tag), [1, 1, 2])
593605

606+
def test_trade_close_exit_tag(self):
607+
def coroutine(self):
608+
yield self.buy(size=1)
609+
self.trades[-1].close(tag='my reason')
610+
yield
611+
612+
stats = self._Backtest(coroutine).run()
613+
self.assertEqual(list(stats._trades.ExitTag), ['my reason'])
614+
615+
def test_position_close_exit_tag(self):
616+
def coroutine(self):
617+
yield self.buy(size=1)
618+
self.position.close(tag='my reason')
619+
yield
620+
621+
stats = self._Backtest(coroutine).run()
622+
self.assertEqual(list(stats._trades.ExitTag), ['my reason'])
623+
624+
def test_close_without_tag_falls_back_to_opening_tag(self):
625+
# Backward compatibility: a close with no tag= given reuses the trade's
626+
# opening tag for the closing order (and hence for `Trade.exit_tag`), same
627+
# as before `tag=` existed.
628+
def coroutine(self):
629+
yield self.buy(size=1, tag='open-reason')
630+
self.position.close()
631+
yield
632+
633+
stats = self._Backtest(coroutine).run()
634+
self.assertEqual(list(stats._trades.Tag), ['open-reason'])
635+
self.assertEqual(list(stats._trades.ExitTag), ['open-reason'])
636+
637+
def test_position_close_partial_exit_tag(self):
638+
def coroutine(self):
639+
yield self.buy(size=10)
640+
assert self.trades
641+
self.position.close(portion=.5, tag='half')
642+
yield
643+
assert len(self.trades) == 1
644+
assert self.trades[0].size == 5
645+
assert self.trades[0].exit_tag is None
646+
yield
647+
648+
with self.assertWarnsRegex(UserWarning, 'finalize_trades'):
649+
stats = self._Backtest(coroutine, finalize_trades=False).run()
650+
trades = stats._trades
651+
self.assertEqual(len(trades), 1)
652+
self.assertEqual(trades.iloc[0].Size, 5)
653+
self.assertEqual(trades.iloc[0].ExitTag, 'half')
654+
594655

595656
class TestOptimize(TestCase):
596657
def test_optimize(self):
@@ -1266,6 +1327,23 @@ def next(self):
12661327
trades = Backtest(SHORT_DATA, S).run()._trades
12671328
self.assertEqual(trades['ExitBar'].iloc[0], 3)
12681329
self.assertEqual(trades['ExitPrice'].iloc[0], 105)
1330+
self.assertEqual(trades['ExitTag'].iloc[0], 'tp')
1331+
1332+
def test_exit_tag_on_opposing_order_close(self):
1333+
# A trade closed as a side effect of an opposing order filling (FIFO close),
1334+
# with no explicit `trade.close()`/`position.close()` call anywhere, gets no
1335+
# exit tag (`None`) by default.
1336+
class S(_S):
1337+
def next(self):
1338+
i = len(self.data.index)
1339+
if i == 3:
1340+
self.buy(size=1)
1341+
elif i == 4:
1342+
self.sell(size=1)
1343+
1344+
trades = Backtest(SHORT_DATA, S).run()._trades
1345+
self.assertEqual(len(trades), 1)
1346+
self.assertIsNone(trades['ExitTag'].iloc[0])
12691347

12701348
def test_optimize_datetime_index_with_timezone(self):
12711349
data: pd.DataFrame = GOOG.iloc[:100]

0 commit comments

Comments
 (0)