Skip to content

Commit 6411c7d

Browse files
committed
Add comment to orders (and trades, supporting opening and closing)
Correct type annotation for Order.Tag Add support for stop and limit on close orders Fix typos and docstrings
1 parent a7da71c commit 6411c7d

File tree

2 files changed

+78
-27
lines changed

2 files changed

+78
-27
lines changed

backtesting/_stats.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def compute_stats(
6666
'EntryTime': [t.entry_time for t in trades],
6767
'ExitTime': [t.exit_time for t in trades],
6868
'Tag': [t.tag for t in trades],
69+
'Comment': [t.comment for t in trades],
6970
})
7071
trades_df['Duration'] = trades_df['ExitTime'] - trades_df['EntryTime']
7172
del trades

backtesting/backtesting.py

Lines changed: 77 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from itertools import chain, compress, product, repeat
1717
from math import copysign
1818
from numbers import Number
19-
from typing import Callable, Dict, List, Optional, Sequence, Tuple, Type, Union
19+
from typing import Callable, Dict, List, Optional, Sequence, Tuple, Type, Union, Any, SupportsRound
2020

2121
import numpy as np
2222
import pandas as pd
@@ -200,7 +200,8 @@ def buy(self, *,
200200
stop: Optional[float] = None,
201201
sl: Optional[float] = None,
202202
tp: Optional[float] = None,
203-
tag: object = None):
203+
tag: SupportsRound = None,
204+
comment: str = None):
204205
"""
205206
Place a new long order. For explanation of parameters, see `Order` and its properties.
206207
@@ -210,15 +211,16 @@ def buy(self, *,
210211
"""
211212
assert 0 < size < 1 or round(size) == size, \
212213
"size must be a positive fraction of equity, or a positive whole number of units"
213-
return self._broker.new_order(size, limit, stop, sl, tp, tag)
214+
return self._broker.new_order(size, limit, stop, sl, tp, tag, comment)
214215

215216
def sell(self, *,
216217
size: float = _FULL_EQUITY,
217218
limit: Optional[float] = None,
218219
stop: Optional[float] = None,
219220
sl: Optional[float] = None,
220221
tp: Optional[float] = None,
221-
tag: object = None):
222+
tag: SupportsRound = None,
223+
comment: str = None):
222224
"""
223225
Place a new short order. For explanation of parameters, see `Order` and its properties.
224226
@@ -230,7 +232,7 @@ def sell(self, *,
230232
"""
231233
assert 0 < size < 1 or round(size) == size, \
232234
"size must be a positive fraction of equity, or a positive whole number of units"
233-
return self._broker.new_order(-size, limit, stop, sl, tp, tag)
235+
return self._broker.new_order(-size, limit, stop, sl, tp, tag, comment)
234236

235237
@property
236238
def equity(self) -> float:
@@ -352,12 +354,12 @@ def is_short(self) -> bool:
352354
"""True if the position is short (position size is negative)."""
353355
return self.size < 0
354356

355-
def close(self, portion: float = 1.):
357+
def close(self, portion: float = 1., stop: float = None, limit: float = None, comment: str = None):
356358
"""
357359
Close portion of position by closing `portion` of each active trade. See `Trade.close`.
358360
"""
359361
for trade in self.__broker.trades:
360-
trade.close(portion)
362+
trade.close(portion, stop, limit, comment)
361363

362364
def __repr__(self):
363365
return f'<Position: {self.size} ({len(self.__broker.trades)} trades)>'
@@ -389,7 +391,8 @@ def __init__(self, broker: '_Broker',
389391
sl_price: Optional[float] = None,
390392
tp_price: Optional[float] = None,
391393
parent_trade: Optional['Trade'] = None,
392-
tag: object = None):
394+
tag: SupportsRound = None,
395+
comment: str = None):
393396
self.__broker = broker
394397
assert size != 0
395398
self.__size = size
@@ -399,6 +402,7 @@ def __init__(self, broker: '_Broker',
399402
self.__tp_price = tp_price
400403
self.__parent_trade = parent_trade
401404
self.__tag = tag
405+
self.__comment = comment
402406

403407
def _replace(self, **kwargs):
404408
for k, v in kwargs.items():
@@ -415,7 +419,7 @@ def __repr__(self):
415419
('tp', self.__tp_price),
416420
('contingent', self.is_contingent),
417421
('tag', self.__tag),
418-
) if value is not None))
422+
) if value is not None) + f', comment={self.__comment}')
419423

420424
def cancel(self):
421425
"""Cancel the order."""
@@ -489,7 +493,7 @@ def parent_trade(self):
489493
@property
490494
def tag(self):
491495
"""
492-
Arbitrary value (such as a string) which, if set, enables tracking
496+
A number (int or float or anything that supports rounding) which, if set, enables tracking
493497
of this order and the associated `Trade` (see `Trade.tag`).
494498
"""
495499
return self.__tag
@@ -522,13 +526,24 @@ def is_contingent(self):
522526
"""
523527
return bool(self.__parent_trade)
524528

529+
@property
530+
def comment(self):
531+
"""
532+
A string comment associated with this order. This is different from tag as it can only carry a string while tag can only carry numbers (something that can be rounded).
533+
"""
534+
return self.__comment
535+
536+
@comment.setter
537+
def comment(self, value):
538+
self.__comment = value
539+
525540

526541
class Trade:
527542
"""
528543
When an `Order` is filled, it results in an active `Trade`.
529544
Find active trades in `Strategy.trades` and closed, settled trades in `Strategy.closed_trades`.
530545
"""
531-
def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar, tag):
546+
def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar, tag: SupportsRound, comment: str):
532547
self.__broker = broker
533548
self.__size = size
534549
self.__entry_price = entry_price
@@ -537,7 +552,10 @@ def __init__(self, broker: '_Broker', size: int, entry_price: float, entry_bar,
537552
self.__exit_bar: Optional[int] = None
538553
self.__sl_order: Optional[Order] = None
539554
self.__tp_order: Optional[Order] = None
555+
self.__tp_comment: Optional[str] = None
556+
self.__sl_comment: Optional[str] = None
540557
self.__tag = tag
558+
self.__comment = comment
541559

542560
def __repr__(self):
543561
return f'<Trade size={self.__size} time={self.__entry_bar}-{self.__exit_bar or ""} ' \
@@ -552,12 +570,13 @@ def _replace(self, **kwargs):
552570
def _copy(self, **kwargs):
553571
return copy(self)._replace(**kwargs)
554572

555-
def close(self, portion: float = 1.):
573+
def close(self, portion: float = 1., stop: float = None, limit: float = None, comment: str = None) -> Order:
556574
"""Place new `Order` to close `portion` of the trade at next market price."""
557575
assert 0 < portion <= 1, "portion must be a fraction between 0 and 1"
558576
size = copysign(max(1, round(abs(self.__size) * portion)), -self.__size)
559-
order = Order(self.__broker, size, parent_trade=self, tag=self.__tag)
577+
order = Order(self.__broker, size, stop_price=stop, limit_price=limit, parent_trade=self, tag=self.__tag, comment=comment)
560578
self.__broker.orders.insert(0, order)
579+
return order
561580

562581
# Fields getters
563582

@@ -602,6 +621,13 @@ def tag(self):
602621
"""
603622
return self.__tag
604623

624+
@property
625+
def comment(self):
626+
"""
627+
A string comment associated with this trade.
628+
"""
629+
return self.__comment
630+
605631
@property
606632
def _sl_order(self):
607633
return self.__sl_order
@@ -654,6 +680,17 @@ def value(self):
654680

655681
# SL/TP management API
656682

683+
@property
684+
def sl_comment(self):
685+
"""Comment associated with the SL order."""
686+
return self.__sl_comment
687+
688+
@sl_comment.setter
689+
def sl_comment(self, comment: str):
690+
self.__sl_comment = comment
691+
if self.__sl_order:
692+
self.__sl_order.comment = comment
693+
657694
@property
658695
def sl(self):
659696
"""
@@ -669,6 +706,17 @@ def sl(self):
669706
def sl(self, price: float):
670707
self.__set_contingent('sl', price)
671708

709+
@property
710+
def tp_comment(self):
711+
"""Comment associated with the TP order."""
712+
return self.__tp_comment
713+
714+
@tp_comment.setter
715+
def tp_comment(self, comment: str):
716+
self.__tp_comment = comment
717+
if self.__tp_order:
718+
self.__tp_order.comment = comment
719+
672720
@property
673721
def tp(self):
674722
"""
@@ -692,7 +740,7 @@ def __set_contingent(self, type, price):
692740
if order:
693741
order.cancel()
694742
if price:
695-
kwargs = {'stop': price} if type == 'sl' else {'limit': price}
743+
kwargs = {'stop': price, 'comment': self.sl_comment} if type == 'sl' else {'limit': price, 'comment': self.tp_comment}
696744
order = self.__broker.new_order(-self.size, trade=self, tag=self.tag, **kwargs)
697745
setattr(self, attr, order)
698746

@@ -728,7 +776,8 @@ def new_order(self,
728776
stop: Optional[float] = None,
729777
sl: Optional[float] = None,
730778
tp: Optional[float] = None,
731-
tag: object = None,
779+
tag: SupportsRound = None,
780+
comment: str = None,
732781
*,
733782
trade: Optional[Trade] = None):
734783
"""
@@ -754,7 +803,7 @@ def new_order(self,
754803
"Short orders require: "
755804
f"TP ({tp}) < LIMIT ({limit or stop or adjusted_price}) < SL ({sl})")
756805

757-
order = Order(self, size, limit, stop, sl, tp, trade, tag)
806+
order = Order(self, size, limit, stop, sl, tp, trade, tag, comment)
758807
# Put the new order in the order queue,
759808
# inserting SL/TP/trade-closing orders in-front
760809
if trade:
@@ -780,7 +829,7 @@ def last_price(self) -> float:
780829

781830
def _adjusted_price(self, size=None, price=None) -> float:
782831
"""
783-
Long/short `price`, adjusted for commisions.
832+
Long/short `price`, adjusted for commissions.
784833
In long positions, the adjusted price is a fraction higher, and vice versa.
785834
"""
786835
return (price or self.last_price) * (1 + copysign(self._commission, size))
@@ -873,7 +922,7 @@ def _process_orders(self):
873922
size = copysign(min(abs(_prev_size), abs(order.size)), order.size)
874923
# If this trade isn't already closed (e.g. on multiple `trade.close(.5)` calls)
875924
if trade in self.trades:
876-
self._reduce_trade(trade, price, size, time_index)
925+
self._reduce_trade(trade, price, size, time_index, order.comment)
877926
assert order.size != -_prev_size or trade not in self.trades
878927
if order in (trade._sl_order,
879928
trade._tp_order):
@@ -921,7 +970,7 @@ def _process_orders(self):
921970
else:
922971
# The existing trade is larger than the new order,
923972
# so it will only be closed partially
924-
self._reduce_trade(trade, price, need_size, time_index)
973+
self._reduce_trade(trade, price, need_size, time_index, order.comment)
925974
need_size = 0
926975

927976
if not need_size:
@@ -939,7 +988,8 @@ def _process_orders(self):
939988
order.sl,
940989
order.tp,
941990
time_index,
942-
order.tag)
991+
order.tag,
992+
order.comment)
943993

944994
# We need to reprocess the SL/TP orders newly added to the queue.
945995
# This allows e.g. SL hitting in the same bar the order was open.
@@ -965,7 +1015,7 @@ def _process_orders(self):
9651015
if reprocess_orders:
9661016
self._process_orders()
9671017

968-
def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int):
1018+
def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int, comment: str = None):
9691019
assert trade.size * size < 0
9701020
assert abs(trade.size) >= abs(size)
9711021

@@ -985,21 +1035,21 @@ def _reduce_trade(self, trade: Trade, price: float, size: float, time_index: int
9851035
close_trade = trade._copy(size=-size, sl_order=None, tp_order=None)
9861036
self.trades.append(close_trade)
9871037

988-
self._close_trade(close_trade, price, time_index)
1038+
self._close_trade(close_trade, price, time_index, comment)
9891039

990-
def _close_trade(self, trade: Trade, price: float, time_index: int):
1040+
def _close_trade(self, trade: Trade, price: float, time_index: int, comment: str = None):
9911041
self.trades.remove(trade)
9921042
if trade._sl_order:
9931043
self.orders.remove(trade._sl_order)
9941044
if trade._tp_order:
9951045
self.orders.remove(trade._tp_order)
9961046

997-
self.closed_trades.append(trade._replace(exit_price=price, exit_bar=time_index))
1047+
self.closed_trades.append(trade._replace(exit_price=price, exit_bar=time_index, comment=f'{trade.comment}/{comment}'))
9981048
self._cash += trade.pl
9991049

10001050
def _open_trade(self, price: float, size: int,
1001-
sl: Optional[float], tp: Optional[float], time_index: int, tag):
1002-
trade = Trade(self, size, price, time_index, tag)
1051+
sl: Optional[float], tp: Optional[float], time_index: int, tag: SupportsRound, comment: str):
1052+
trade = Trade(self, size, price, time_index, tag, comment)
10031053
self.trades.append(trade)
10041054
# Create SL/TP (bracket) orders.
10051055
# Make sure SL order is created first so it gets adversarially processed before TP order
@@ -1411,7 +1461,7 @@ def _batch(seq):
14111461
try:
14121462
# If multiprocessing start method is 'fork' (i.e. on POSIX), use
14131463
# a pool of processes to compute results in parallel.
1414-
# Otherwise (i.e. on Windos), sequential computation will be "faster".
1464+
# Otherwise (i.e. on Windows), sequential computation will be "faster".
14151465
if mp.get_start_method(allow_none=False) == 'fork':
14161466
with ProcessPoolExecutor() as executor:
14171467
futures = [executor.submit(Backtest._mp_task, backtest_uuid, i)

0 commit comments

Comments
 (0)