diff --git a/backtesting/_stats.py b/backtesting/_stats.py index 086d4e35..e7c40116 100644 --- a/backtesting/_stats.py +++ b/backtesting/_stats.py @@ -65,6 +65,7 @@ def compute_stats( 'ReturnPct': [t.pl_pct for t in trades], 'EntryTime': [t.entry_time for t in trades], 'ExitTime': [t.exit_time for t in trades], + 'Tag': [t.tag for t in trades], }) trades_df['Duration'] = trades_df['ExitTime'] - trades_df['EntryTime'] del trades diff --git a/backtesting/backtesting.py b/backtesting/backtesting.py index 45e0e831..766c3ace 100644 --- a/backtesting/backtesting.py +++ b/backtesting/backtesting.py @@ -199,7 +199,8 @@ def buy(self, *, limit: Optional[float] = None, stop: Optional[float] = None, sl: Optional[float] = None, - tp: Optional[float] = None): + tp: Optional[float] = None, + tag: object = None): """ Place a new long order. For explanation of parameters, see `Order` and its properties. @@ -209,14 +210,15 @@ def buy(self, *, """ assert 0 < size < 1 or round(size) == size, \ "size must be a positive fraction of equity, or a positive whole number of units" - return self._broker.new_order(size, limit, stop, sl, tp) + return self._broker.new_order(size, limit, stop, sl, tp, tag) def sell(self, *, size: float = _FULL_EQUITY, limit: Optional[float] = None, stop: Optional[float] = None, sl: Optional[float] = None, - tp: Optional[float] = None): + tp: Optional[float] = None, + tag: object = None): """ Place a new short order. For explanation of parameters, see `Order` and its properties. @@ -228,7 +230,7 @@ def sell(self, *, """ assert 0 < size < 1 or round(size) == size, \ "size must be a positive fraction of equity, or a positive whole number of units" - return self._broker.new_order(-size, limit, stop, sl, tp) + return self._broker.new_order(-size, limit, stop, sl, tp, tag) @property def equity(self) -> float: @@ -386,7 +388,8 @@ def __init__(self, broker: '_Broker', stop_price: Optional[float] = None, sl_price: Optional[float] = None, tp_price: Optional[float] = None, - parent_trade: Optional['Trade'] = None): + parent_trade: Optional['Trade'] = None, + tag: object = None): self.__broker = broker assert size != 0 self.__size = size @@ -395,6 +398,7 @@ def __init__(self, broker: '_Broker', self.__sl_price = sl_price self.__tp_price = tp_price self.__parent_trade = parent_trade + self.__tag = tag def _replace(self, **kwargs): for k, v in kwargs.items(): @@ -410,6 +414,7 @@ def __repr__(self): ('sl', self.__sl_price), ('tp', self.__tp_price), ('contingent', self.is_contingent), + ('tag', self.__tag), ) if value is not None)) def cancel(self): @@ -481,6 +486,14 @@ def tp(self) -> Optional[float]: def parent_trade(self): return self.__parent_trade + @property + def tag(self): + """ + Arbitrary value (such as a string) which, if set, enables tracking + of this order and the associated `Trade` (see `Trade.tag`). + """ + return self.__tag + __pdoc__['Order.parent_trade'] = False # Extra properties @@ -515,7 +528,7 @@ class Trade: When an `Order` is filled, it results in an active `Trade`. Find active trades in `Strategy.trades` and closed, settled trades in `Strategy.closed_trades`. """ - def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar): + def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar, tag): self.__broker = broker self.__size = size self.__entry_price = entry_price @@ -524,10 +537,12 @@ def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar): self.__exit_bar: Optional[int] = None self.__sl_order: Optional[Order] = None self.__tp_order: Optional[Order] = None + self.__tag = tag def __repr__(self): return f'' + f'price={self.__entry_price}-{self.__exit_price or ""} pl={self.pl:.0f}' \ + f'{" tag="+str(self.__tag) if self.__tag is not None else ""}>' def _replace(self, **kwargs): for k, v in kwargs.items(): @@ -541,7 +556,7 @@ def close(self, portion: float = 1.): """Place new `Order` to close `portion` of the trade at next market price.""" assert 0 < portion <= 1, "portion must be a fraction between 0 and 1" size = copysign(max(1, round(abs(self.__size) * portion)), -self.__size) - order = Order(self.__broker, size, parent_trade=self) + order = Order(self.__broker, size, parent_trade=self, tag=self.__tag) self.__broker.orders.insert(0, order) # Fields getters @@ -574,6 +589,19 @@ def exit_bar(self) -> Optional[int]: """ return self.__exit_bar + @property + def tag(self): + """ + A tag value inherited from the `Order` that opened + this trade. + + This can be used to track trades and apply conditional + logic / subgroup analysis. + + See also `Order.tag`. + """ + return self.__tag + @property def _sl_order(self): return self.__sl_order @@ -665,7 +693,7 @@ def __set_contingent(self, type, price): order.cancel() if price: kwargs = {'stop': price} if type == 'sl' else {'limit': price} - order = self.__broker.new_order(-self.size, trade=self, **kwargs) + order = self.__broker.new_order(-self.size, trade=self, tag=self.tag, **kwargs) setattr(self, attr, order) @@ -700,6 +728,7 @@ def new_order(self, stop: Optional[float] = None, sl: Optional[float] = None, tp: Optional[float] = None, + tag: object = None, *, trade: Optional[Trade] = None): """ @@ -725,7 +754,7 @@ def new_order(self, "Short orders require: " f"TP ({tp}) < LIMIT ({limit or stop or adjusted_price}) < SL ({sl})") - order = Order(self, size, limit, stop, sl, tp, trade) + order = Order(self, size, limit, stop, sl, tp, trade, tag) # Put the new order in the order queue, # inserting SL/TP/trade-closing orders in-front if trade: @@ -905,7 +934,12 @@ def _process_orders(self): # Open a new trade if need_size: - self._open_trade(adjusted_price, need_size, order.sl, order.tp, time_index) + self._open_trade(adjusted_price, + need_size, + order.sl, + order.tp, + time_index, + order.tag) # We need to reprocess the SL/TP orders newly added to the queue. # This allows e.g. SL hitting in the same bar the order was open. @@ -964,8 +998,8 @@ def _close_trade(self, trade: Trade, price: float, time_index: int): self._cash += trade.pl def _open_trade(self, price: float, size: int, - sl: Optional[float], tp: Optional[float], time_index: int): - trade = Trade(self, size, price, time_index) + sl: Optional[float], tp: Optional[float], time_index: int, tag): + trade = Trade(self, size, price, time_index, tag) self.trades.append(trade) # Create SL/TP (bracket) orders. # Make sure SL order is created first so it gets adversarially processed before TP order diff --git a/backtesting/test/_test.py b/backtesting/test/_test.py index 8a267c41..edc8d6c2 100644 --- a/backtesting/test/_test.py +++ b/backtesting/test/_test.py @@ -304,7 +304,7 @@ def almost_equal(a, b): self.assertSequenceEqual( sorted(stats['_trades'].columns), sorted(['Size', 'EntryBar', 'ExitBar', 'EntryPrice', 'ExitPrice', - 'PnL', 'ReturnPct', 'EntryTime', 'ExitTime', 'Duration'])) + 'PnL', 'ReturnPct', 'EntryTime', 'ExitTime', 'Duration', 'Tag'])) def test_compute_stats_bordercase(self): @@ -506,6 +506,18 @@ def coroutine(self): stats = self._Backtest(coroutine).run() self.assertEqual(len(stats._trades), 1) + def test_order_tag(self): + def coroutine(self): + yield self.buy(size=2, tag=1) + yield self.sell(size=1, tag='s') + yield self.sell(size=1) + + yield self.buy(tag=2) + yield self.position.close() + + stats = self._Backtest(coroutine).run() + self.assertEqual(list(stats._trades.Tag), [1, 1, 2]) + class TestOptimize(TestCase): def test_optimize(self):