diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac3460..b1f09c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## TBD + +### Features + +* `backtesting.exchange.OrderInfo` now includes pair and order fills. + ## 1.7.1 ### Bug fixes @@ -47,7 +53,7 @@ ## 1.5.0 -* `basana.backtesting.fees.Percentage` now supports a minimum fee. +* `backtesting.fees.Percentage` now supports a minimum fee. ## 1.4.1 diff --git a/basana/backtesting/exchange.py b/basana/backtesting/exchange.py index a168eec..7613756 100644 --- a/basana/backtesting/exchange.py +++ b/basana/backtesting/exchange.py @@ -31,6 +31,7 @@ BarEventHandler = bar.BarEventHandler Error = errors.Error +Fill = orders.Fill LiquidityStrategyFactory = Callable[[], liquidity.LiquidityStrategy] OrderEvent = order_mgr.OrderEvent OrderEventHandler = order_mgr.OrderEventHandler diff --git a/basana/backtesting/orders.py b/basana/backtesting/orders.py index 33e4f97..8dc2df7 100644 --- a/basana/backtesting/orders.py +++ b/basana/backtesting/orders.py @@ -38,10 +38,22 @@ class OrderState(enum.Enum): CANCELED = 102 +@dataclasses.dataclass +class Fill: + #: The time when the fill took place. + when: datetime.datetime + #: The balance updates. + balance_updates: Dict[str, Decimal] + #: The fees. + fees: Dict[str, Decimal] + + @dataclasses.dataclass class OrderInfo: #: The order id. id: str + #: The pair. + pair: Pair #: True if the order is open, False otherwise. is_open: bool #: The operation. @@ -62,6 +74,8 @@ class OrderInfo: stop_price: Optional[Decimal] = None #: The ids of the associated loans. loan_ids: List[str] = dataclasses.field(default_factory=list) + #: The fills. + fills: List[Fill] = dataclasses.field(default_factory=list) @property def fill_price(self) -> Optional[Decimal]: @@ -72,13 +86,6 @@ def fill_price(self) -> Optional[Decimal]: return fill_price -@dataclasses.dataclass -class Fill: - when: datetime.datetime - balance_updates: Dict[str, Decimal] - fees: Dict[str, Decimal] - - # This is an internal abstraction to be used by the exchange. class Order(metaclass=abc.ABCMeta): def __init__( @@ -171,11 +178,11 @@ def add_loan(self, loan_id: str): def get_order_info(self) -> OrderInfo: return OrderInfo( - id=self.id, is_open=self._state == OrderState.OPEN, operation=self.operation, + id=self.id, pair=self.pair, is_open=self._state == OrderState.OPEN, operation=self.operation, amount=self.amount, amount_filled=self.amount_filled, amount_remaining=self.amount_pending, quote_amount_filled=self.quote_amount_filled, fees={symbol: -amount for symbol, amount in self._fees.items() if amount}, - loan_ids=[loan_id for loan_id in self._loan_ids] + loan_ids=[loan_id for loan_id in self._loan_ids], fills=self.fills ) @abc.abstractmethod diff --git a/docs/backtesting_exchange.rst b/docs/backtesting_exchange.rst index a9ff375..906d736 100644 --- a/docs/backtesting_exchange.rst +++ b/docs/backtesting_exchange.rst @@ -13,6 +13,8 @@ basana.backtesting.exchange :members: .. autoclass:: basana.backtesting.exchange.CanceledOrder :members: +.. autoclass:: basana.backtesting.exchange.Fill + :members: .. autoclass:: basana.backtesting.exchange.OrderInfo :members: .. autoclass:: basana.backtesting.exchange.OpenOrder diff --git a/tests/test_backtesting_exchange.py b/tests/test_backtesting_exchange.py index 60953cd..8c73d16 100644 --- a/tests/test_backtesting_exchange.py +++ b/tests/test_backtesting_exchange.py @@ -14,7 +14,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import dataclasses from decimal import Decimal import asyncio import datetime @@ -265,8 +264,7 @@ async def impl(): # Optional[expected_order_events], # ), # ], - # } - + # }, { datetime.date(2000, 1, 4): [ # Stop order canceled due to insufficient funds. Need to tweak the amount and stop price to get the order @@ -301,15 +299,15 @@ async def impl(): OrderOperation.BUY, Pair("ORCL", "USD"), Decimal("2") ), [ - orders.Fill( + dict( when=datetime.datetime(2000, 1, 5, tzinfo=tz.tzlocal()), balance_updates={"ORCL": Decimal("2"), "USD": Decimal("-231.00")}, fees={"USD": Decimal("-0.58")}, ), ], [ - orders.OrderInfo( - id="any", + dict( + pair=Pair("ORCL", "USD"), is_open=True, operation=OrderOperation.BUY, amount=Decimal("2"), @@ -318,15 +316,14 @@ async def impl(): quote_amount_filled=Decimal("0"), fees={} ), - orders.OrderInfo( - id="any", + dict( is_open=False, operation=OrderOperation.BUY, amount=Decimal("2"), amount_filled=Decimal("2"), amount_remaining=Decimal("0"), quote_amount_filled=Decimal("231.00"), - fees={"USD": Decimal("0.58")} + fees={"USD": Decimal("0.58")}, ), ] ), @@ -336,7 +333,7 @@ async def impl(): OrderOperation.BUY, Pair("ORCL", "USD"), Decimal("4"), Decimal("110.01") ), [ - orders.Fill( + dict( when=datetime.datetime(2000, 1, 5, tzinfo=tz.tzlocal()), balance_updates={"ORCL": Decimal("4"), "USD": Decimal("-440.04")}, fees={"USD": Decimal("-1.11")}, @@ -352,7 +349,7 @@ async def impl(): OrderOperation.SELL, Pair("ORCL", "USD"), Decimal("1") ), [ - orders.Fill( + dict( when=datetime.datetime(2000, 1, 19, tzinfo=tz.tzlocal()), balance_updates={"ORCL": Decimal("-1"), "USD": Decimal("107.87")}, fees={"USD": Decimal("-0.27")}, @@ -366,7 +363,7 @@ async def impl(): OrderOperation.SELL, Pair("ORCL", "USD"), Decimal("1"), Decimal("108") ), [ - orders.Fill( + dict( when=datetime.datetime(2000, 1, 19, tzinfo=tz.tzlocal()), balance_updates={"ORCL": Decimal("-1"), "USD": Decimal("108.00")}, fees={"USD": Decimal("-0.27")}, @@ -380,7 +377,7 @@ async def impl(): OrderOperation.SELL, Pair("ORCL", "USD"), Decimal("1"), Decimal("108") ), [ - orders.Fill( + dict( when=datetime.datetime(2000, 1, 19, tzinfo=tz.tzlocal()), balance_updates={"ORCL": Decimal("-1"), "USD": Decimal("107.87")}, fees={"USD": Decimal("-0.27")}, @@ -397,7 +394,7 @@ async def impl(): Decimal("58.03") ), [ - orders.Fill( + dict( when=datetime.datetime(2000, 1, 25, tzinfo=tz.tzlocal()), balance_updates={"ORCL": Decimal("5"), "USD": Decimal("-290.15")}, fees={"USD": Decimal("-0.73")}, @@ -414,7 +411,7 @@ async def impl(): Decimal("80.24") ), [ - orders.Fill( + dict( when=datetime.datetime(2000, 3, 14, tzinfo=tz.tzlocal()), balance_updates={"ORCL": Decimal("10"), "USD": Decimal("-785.00")}, fees={"USD": Decimal("-1.97")}, @@ -429,7 +426,7 @@ async def impl(): Decimal("81") ), [ - orders.Fill( + dict( when=datetime.datetime(2000, 3, 11, tzinfo=tz.tzlocal()), balance_updates={"ORCL": Decimal("9"), "USD": Decimal("-729.00")}, fees={"USD": Decimal("-1.83")}, @@ -444,7 +441,7 @@ async def impl(): Decimal("78.75") ), [ - orders.Fill( + dict( when=datetime.datetime(2000, 3, 14, tzinfo=tz.tzlocal()), balance_updates={"ORCL": Decimal("-1"), "USD": Decimal("78.75")}, fees={"USD": Decimal("-0.20")}, @@ -459,7 +456,7 @@ async def impl(): Decimal("83.65") ), [ - orders.Fill( + dict( when=datetime.datetime(2000, 3, 15, tzinfo=tz.tzlocal()), balance_updates={"ORCL": Decimal("-1"), "USD": Decimal("83.65")}, fees={"USD": Decimal("-0.21")}, @@ -474,7 +471,7 @@ async def impl(): Decimal("83.80") ), [ - orders.Fill( + dict( when=datetime.datetime(2000, 3, 16, tzinfo=tz.tzlocal()), balance_updates={"ORCL": Decimal("-1"), "USD": Decimal("84.00")}, fees={"USD": Decimal("-0.21")}, @@ -492,20 +489,19 @@ async def impl(): OrderOperation.BUY, Pair("ORCL", "USD"), Decimal("50"), Decimal("10") ), [ - orders.Fill( + dict( when=datetime.datetime(2001, 1, 4, tzinfo=tz.tzlocal()), balance_updates={"ORCL": Decimal("25"), "USD": Decimal("-137.50")}, fees={"USD": Decimal("-0.35")}, ), - orders.Fill( + dict( when=datetime.datetime(2001, 1, 5, tzinfo=tz.tzlocal()), balance_updates={"ORCL": Decimal("25"), "USD": Decimal("-137.50")}, fees={"USD": Decimal("-0.34")}, ), ], [ - orders.OrderInfo( - id="any", + dict( is_open=True, operation=OrderOperation.BUY, amount=Decimal("50"), @@ -515,8 +511,7 @@ async def impl(): fees={}, limit_price=Decimal("10"), ), - orders.OrderInfo( - id="any", + dict( is_open=True, operation=OrderOperation.BUY, amount=Decimal("50"), @@ -526,8 +521,7 @@ async def impl(): fees={"USD": Decimal("0.35")}, limit_price=Decimal("10"), ), - orders.OrderInfo( - id="any", + dict( is_open=False, operation=OrderOperation.BUY, amount=Decimal("50"), @@ -549,7 +543,7 @@ async def impl(): OrderOperation.BUY, Pair("ORCL", "USD"), Decimal("8600"), Decimal("115.50") ), [ - orders.Fill( + dict( when=datetime.datetime(2000, 1, 5, tzinfo=tz.tzlocal()), balance_updates={"ORCL": Decimal("8600"), "USD": Decimal("-993300.00")}, fees={"USD": Decimal("-2483.25")}, @@ -627,23 +621,23 @@ async def impl(): for order_id, expected_attrs in expected.items(): order_info = await e.get_order_info(order_id) - assert order_info is not None assert not order_info.is_open, order_info - exchange_order = e._order_mgr._orders.get(order_id) - assert exchange_order is not None - assert exchange_order.fills == expected_attrs["fills"] + # Check fills. + if expected_attrs["fills"] is not None: + for i, expected_fill in enumerate(expected_attrs["fills"]): + order_fill = order_info.fills[i] + for attr, expected_value in expected_fill.items(): + assert getattr(order_fill, attr) == expected_value + # Check order events if expected_attrs["order_events"] is not None: - def normalize_order_info(order): - ret = dataclasses.asdict(order) - ret.pop("id") - return ret - assert order_id in order_events assert len(order_events[order_id]) == len(expected_attrs["order_events"]) - for lhs, rhs in zip(order_events[order_id], expected_attrs["order_events"]): - assert normalize_order_info(lhs) == normalize_order_info(rhs) + for i, expected_order_event in enumerate(expected_attrs["order_events"]): + order_event = order_events[order_id][i] + for attr, expected_value in expected_order_event.items(): + assert getattr(order_event, attr) == expected_value assert len(expected) == sum([len(orders) for orders in order_plan.values()]) diff --git a/tests/test_samples_backtesting_pos_info.py b/tests/test_samples_backtesting_pos_info.py index ee24b9b..0b4c6d9 100644 --- a/tests/test_samples_backtesting_pos_info.py +++ b/tests/test_samples_backtesting_pos_info.py @@ -25,12 +25,13 @@ def test_long_partially_filled(): target = Decimal(10) + pair = bs.Pair("BTC", "USDT") order = exchange.OrderInfo( - id="1234", is_open=True, operation=bs.OrderOperation.BUY, amount=target, amount_filled=Decimal(0), + id="1234", pair=pair, is_open=True, operation=bs.OrderOperation.BUY, amount=target, amount_filled=Decimal(0), amount_remaining=target, quote_amount_filled=Decimal(0), fees={} ) pos_info = PositionInfo( - pair=bs.Pair("BTC", "USDT"), initial=Decimal(0), initial_avg_price=Decimal(0), target=target, order=order + pair=pair, initial=Decimal(0), initial_avg_price=Decimal(0), target=target, order=order ) assert pos_info.order_open is True @@ -55,12 +56,13 @@ def test_long_partially_filled(): def test_short_completely_filled(): target = Decimal(-10) + pair = bs.Pair("BTC", "USDT") order = exchange.OrderInfo( - id="1234", is_open=True, operation=bs.OrderOperation.SELL, amount=target, amount_filled=Decimal(0), + id="1234", pair=pair, is_open=True, operation=bs.OrderOperation.SELL, amount=target, amount_filled=Decimal(0), amount_remaining=abs(target), quote_amount_filled=Decimal(0), fees={} ) pos_info = PositionInfo( - pair=bs.Pair("BTC", "USDT"), initial=Decimal(0), initial_avg_price=Decimal(0), target=target, order=order + pair=pair, initial=Decimal(0), initial_avg_price=Decimal(0), target=target, order=order ) assert pos_info.order_open is True @@ -96,12 +98,13 @@ def test_long_jump(target_position): }[operation] # First order that jumps from one position to the opposite one. + pair = bs.Pair("BTC", "USDT") order = exchange.OrderInfo( - id="1", is_open=True, operation=operation, amount=abs(target * 2), amount_filled=Decimal(0), + id="1", pair=pair, is_open=True, operation=operation, amount=abs(target * 2), amount_filled=Decimal(0), amount_remaining=abs(target * 2), quote_amount_filled=Decimal(0), fees={} ) pos_info = PositionInfo( - pair=bs.Pair("BTC", "USDT"), initial=-target, initial_avg_price=Decimal(900), target=target, order=order + pair=pair, initial=-target, initial_avg_price=Decimal(900), target=target, order=order ) assert pos_info.order_open is True @@ -120,7 +123,7 @@ def test_long_jump(target_position): # The last order was canceled and a new one will start at 3/-3. pos_info.initial, pos_info.initial_avg_price = pos_info.current, pos_info.avg_price pos_info.order = exchange.OrderInfo( - id="2", is_open=True, operation=operation, amount=Decimal(7), amount_filled=Decimal(0), + id="2", pair=pair, is_open=True, operation=operation, amount=Decimal(7), amount_filled=Decimal(0), amount_remaining=Decimal(7), quote_amount_filled=Decimal(0), fees={} ) @@ -141,7 +144,7 @@ def test_long_jump(target_position): pos_info.initial, pos_info.initial_avg_price = pos_info.current, pos_info.avg_price pos_info.target = Decimal(7) * sign pos_info.order = exchange.OrderInfo( - id="3", is_open=True, operation=reverse_operation, amount=Decimal(1), amount_filled=Decimal(0), + id="3", pair=pair, is_open=True, operation=reverse_operation, amount=Decimal(1), amount_filled=Decimal(0), amount_remaining=Decimal(1), quote_amount_filled=Decimal(0), fees={} ) @@ -186,26 +189,28 @@ def test_avg_price( ] order_amount = abs(target - initial) is_open = order_amount != order_filled_amount + pair = bs.Pair("BTC", "USDT") order = exchange.OrderInfo( - id="1", is_open=is_open, operation=order_operation, amount=order_amount, + id="1", pair=pair, is_open=is_open, operation=order_operation, amount=order_amount, amount_filled=order_filled_amount, amount_remaining=order_amount - order_filled_amount, quote_amount_filled=order_filled_amount * order_filled_price, fees={} ) pos_info = PositionInfo( - pair=bs.Pair("BTC", "USDT"), initial=initial, initial_avg_price=initial_avg_price, target=target, order=order + pair=pair, initial=initial, initial_avg_price=initial_avg_price, target=target, order=order ) assert pos_info.avg_price == expected_avg_price def test_pnl_pct(): + pair = bs.Pair("BTC", "USDT") order = exchange.OrderInfo( - id="1", is_open=False, operation=bs.OrderOperation.BUY, amount=Decimal(1), + id="1", pair=pair, is_open=False, operation=bs.OrderOperation.BUY, amount=Decimal(1), amount_filled=Decimal(1), amount_remaining=Decimal(0), quote_amount_filled=(Decimal(1000)), fees={} ) pos_info = PositionInfo( - pair=bs.Pair("BTC", "USDT"), initial=Decimal(0), initial_avg_price=Decimal(0), target=Decimal(1), order=order + pair=pair, initial=Decimal(0), initial_avg_price=Decimal(0), target=Decimal(1), order=order ) assert pos_info.avg_price == Decimal(1000)