Skip to content

Commit 592ea41

Browse files
qacollectiveqacollectivekernc
authored
Add Order.tag for tracking orders and trades (#200)
* Add tagging, object typing, fix pep8 * Fix final pep8 issue * Change .tag docstrings * Add Tag column to stats._trades * Add unit test Co-authored-by: qacollective <[email protected]> Co-authored-by: kernc <[email protected]>
1 parent ee324f6 commit 592ea41

File tree

3 files changed

+61
-14
lines changed

3 files changed

+61
-14
lines changed

backtesting/_stats.py

+1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def compute_stats(
6565
'ReturnPct': [t.pl_pct for t in trades],
6666
'EntryTime': [t.entry_time for t in trades],
6767
'ExitTime': [t.exit_time for t in trades],
68+
'Tag': [t.tag for t in trades],
6869
})
6970
trades_df['Duration'] = trades_df['ExitTime'] - trades_df['EntryTime']
7071
del trades

backtesting/backtesting.py

+47-13
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,8 @@ def buy(self, *,
199199
limit: Optional[float] = None,
200200
stop: Optional[float] = None,
201201
sl: Optional[float] = None,
202-
tp: Optional[float] = None):
202+
tp: Optional[float] = None,
203+
tag: object = None):
203204
"""
204205
Place a new long order. For explanation of parameters, see `Order` and its properties.
205206
@@ -209,14 +210,15 @@ def buy(self, *,
209210
"""
210211
assert 0 < size < 1 or round(size) == size, \
211212
"size must be a positive fraction of equity, or a positive whole number of units"
212-
return self._broker.new_order(size, limit, stop, sl, tp)
213+
return self._broker.new_order(size, limit, stop, sl, tp, tag)
213214

214215
def sell(self, *,
215216
size: float = _FULL_EQUITY,
216217
limit: Optional[float] = None,
217218
stop: Optional[float] = None,
218219
sl: Optional[float] = None,
219-
tp: Optional[float] = None):
220+
tp: Optional[float] = None,
221+
tag: object = None):
220222
"""
221223
Place a new short order. For explanation of parameters, see `Order` and its properties.
222224
@@ -228,7 +230,7 @@ def sell(self, *,
228230
"""
229231
assert 0 < size < 1 or round(size) == size, \
230232
"size must be a positive fraction of equity, or a positive whole number of units"
231-
return self._broker.new_order(-size, limit, stop, sl, tp)
233+
return self._broker.new_order(-size, limit, stop, sl, tp, tag)
232234

233235
@property
234236
def equity(self) -> float:
@@ -386,7 +388,8 @@ def __init__(self, broker: '_Broker',
386388
stop_price: Optional[float] = None,
387389
sl_price: Optional[float] = None,
388390
tp_price: Optional[float] = None,
389-
parent_trade: Optional['Trade'] = None):
391+
parent_trade: Optional['Trade'] = None,
392+
tag: object = None):
390393
self.__broker = broker
391394
assert size != 0
392395
self.__size = size
@@ -395,6 +398,7 @@ def __init__(self, broker: '_Broker',
395398
self.__sl_price = sl_price
396399
self.__tp_price = tp_price
397400
self.__parent_trade = parent_trade
401+
self.__tag = tag
398402

399403
def _replace(self, **kwargs):
400404
for k, v in kwargs.items():
@@ -410,6 +414,7 @@ def __repr__(self):
410414
('sl', self.__sl_price),
411415
('tp', self.__tp_price),
412416
('contingent', self.is_contingent),
417+
('tag', self.__tag),
413418
) if value is not None))
414419

415420
def cancel(self):
@@ -481,6 +486,14 @@ def tp(self) -> Optional[float]:
481486
def parent_trade(self):
482487
return self.__parent_trade
483488

489+
@property
490+
def tag(self):
491+
"""
492+
Arbitrary value (such as a string) which, if set, enables tracking
493+
of this order and the associated `Trade` (see `Trade.tag`).
494+
"""
495+
return self.__tag
496+
484497
__pdoc__['Order.parent_trade'] = False
485498

486499
# Extra properties
@@ -515,7 +528,7 @@ class Trade:
515528
When an `Order` is filled, it results in an active `Trade`.
516529
Find active trades in `Strategy.trades` and closed, settled trades in `Strategy.closed_trades`.
517530
"""
518-
def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar):
531+
def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar, tag):
519532
self.__broker = broker
520533
self.__size = size
521534
self.__entry_price = entry_price
@@ -524,10 +537,12 @@ def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar):
524537
self.__exit_bar: Optional[int] = None
525538
self.__sl_order: Optional[Order] = None
526539
self.__tp_order: Optional[Order] = None
540+
self.__tag = tag
527541

528542
def __repr__(self):
529543
return f'<Trade size={self.__size} time={self.__entry_bar}-{self.__exit_bar or ""} ' \
530-
f'price={self.__entry_price}-{self.__exit_price or ""} pl={self.pl:.0f}>'
544+
f'price={self.__entry_price}-{self.__exit_price or ""} pl={self.pl:.0f}' \
545+
f'{" tag="+str(self.__tag) if self.__tag is not None else ""}>'
531546

532547
def _replace(self, **kwargs):
533548
for k, v in kwargs.items():
@@ -541,7 +556,7 @@ def close(self, portion: float = 1.):
541556
"""Place new `Order` to close `portion` of the trade at next market price."""
542557
assert 0 < portion <= 1, "portion must be a fraction between 0 and 1"
543558
size = copysign(max(1, round(abs(self.__size) * portion)), -self.__size)
544-
order = Order(self.__broker, size, parent_trade=self)
559+
order = Order(self.__broker, size, parent_trade=self, tag=self.__tag)
545560
self.__broker.orders.insert(0, order)
546561

547562
# Fields getters
@@ -574,6 +589,19 @@ def exit_bar(self) -> Optional[int]:
574589
"""
575590
return self.__exit_bar
576591

592+
@property
593+
def tag(self):
594+
"""
595+
A tag value inherited from the `Order` that opened
596+
this trade.
597+
598+
This can be used to track trades and apply conditional
599+
logic / subgroup analysis.
600+
601+
See also `Order.tag`.
602+
"""
603+
return self.__tag
604+
577605
@property
578606
def _sl_order(self):
579607
return self.__sl_order
@@ -665,7 +693,7 @@ def __set_contingent(self, type, price):
665693
order.cancel()
666694
if price:
667695
kwargs = {'stop': price} if type == 'sl' else {'limit': price}
668-
order = self.__broker.new_order(-self.size, trade=self, **kwargs)
696+
order = self.__broker.new_order(-self.size, trade=self, tag=self.tag, **kwargs)
669697
setattr(self, attr, order)
670698

671699

@@ -700,6 +728,7 @@ def new_order(self,
700728
stop: Optional[float] = None,
701729
sl: Optional[float] = None,
702730
tp: Optional[float] = None,
731+
tag: object = None,
703732
*,
704733
trade: Optional[Trade] = None):
705734
"""
@@ -725,7 +754,7 @@ def new_order(self,
725754
"Short orders require: "
726755
f"TP ({tp}) < LIMIT ({limit or stop or adjusted_price}) < SL ({sl})")
727756

728-
order = Order(self, size, limit, stop, sl, tp, trade)
757+
order = Order(self, size, limit, stop, sl, tp, trade, tag)
729758
# Put the new order in the order queue,
730759
# inserting SL/TP/trade-closing orders in-front
731760
if trade:
@@ -905,7 +934,12 @@ def _process_orders(self):
905934

906935
# Open a new trade
907936
if need_size:
908-
self._open_trade(adjusted_price, need_size, order.sl, order.tp, time_index)
937+
self._open_trade(adjusted_price,
938+
need_size,
939+
order.sl,
940+
order.tp,
941+
time_index,
942+
order.tag)
909943

910944
# We need to reprocess the SL/TP orders newly added to the queue.
911945
# 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):
964998
self._cash += trade.pl
965999

9661000
def _open_trade(self, price: float, size: int,
967-
sl: Optional[float], tp: Optional[float], time_index: int):
968-
trade = Trade(self, size, price, time_index)
1001+
sl: Optional[float], tp: Optional[float], time_index: int, tag):
1002+
trade = Trade(self, size, price, time_index, tag)
9691003
self.trades.append(trade)
9701004
# Create SL/TP (bracket) orders.
9711005
# Make sure SL order is created first so it gets adversarially processed before TP order

backtesting/test/_test.py

+13-1
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ def almost_equal(a, b):
304304
self.assertSequenceEqual(
305305
sorted(stats['_trades'].columns),
306306
sorted(['Size', 'EntryBar', 'ExitBar', 'EntryPrice', 'ExitPrice',
307-
'PnL', 'ReturnPct', 'EntryTime', 'ExitTime', 'Duration']))
307+
'PnL', 'ReturnPct', 'EntryTime', 'ExitTime', 'Duration', 'Tag']))
308308

309309
def test_compute_stats_bordercase(self):
310310

@@ -506,6 +506,18 @@ def coroutine(self):
506506
stats = self._Backtest(coroutine).run()
507507
self.assertEqual(len(stats._trades), 1)
508508

509+
def test_order_tag(self):
510+
def coroutine(self):
511+
yield self.buy(size=2, tag=1)
512+
yield self.sell(size=1, tag='s')
513+
yield self.sell(size=1)
514+
515+
yield self.buy(tag=2)
516+
yield self.position.close()
517+
518+
stats = self._Backtest(coroutine).run()
519+
self.assertEqual(list(stats._trades.Tag), [1, 1, 2])
520+
509521

510522
class TestOptimize(TestCase):
511523
def test_optimize(self):

0 commit comments

Comments
 (0)