diff --git a/src/order_matching/matching_engine.py b/src/order_matching/matching_engine.py index 3befa84..32f1d11 100644 --- a/src/order_matching/matching_engine.py +++ b/src/order_matching/matching_engine.py @@ -106,6 +106,11 @@ def _execute_trades_for_one_price(self, incoming_order: Order, price: float) -> if book_order.size == 0: zero_size_orders.append(book_order) opposite_side_orders[price].remove(orders=zero_size_orders) + for book_order in zero_size_orders: + expiration_orders = self.unprocessed_orders.orders_by_expiration[book_order.expiration] + expiration_orders.remove(orders=[book_order]) + if len(expiration_orders) == 0: + self.unprocessed_orders.orders_by_expiration.pop(book_order.expiration) if len(list(filter(lambda order: order.size > 0, opposite_side_orders[price]))) == 0: opposite_side_orders.pop(price) return ExecutedTrades(trades=trades) diff --git a/tests/test_matching_engine.py b/tests/test_matching_engine.py index fa9f669..c410b36 100644 --- a/tests/test_matching_engine.py +++ b/tests/test_matching_engine.py @@ -744,6 +744,57 @@ def test_cancellation_of_expired_orders(self) -> None: assert matching_engine.unprocessed_orders.bids == dict() assert matching_engine.unprocessed_orders.offers == dict() + def test_removal_of_filled_orders(self) -> None: + matching_engine = MatchingEngine(seed=1) + t0 = datetime(2023, 1, 1) + buy = LimitOrder(side=Side.BUY, price=100.0, size=10, timestamp=t0, order_id="A", trader_id="x") + sell = LimitOrder( + side=Side.SELL, price=100.0, size=10, timestamp=t0 + timedelta(seconds=1), order_id="B", trader_id="y" + ) + matching_engine.match(timestamp=t0, orders=Orders([buy])) + matching_engine.match(timestamp=t0 + timedelta(seconds=1), orders=Orders([sell])) # "A" is fully filled + + order_book = matching_engine.unprocessed_orders + + assert order_book.bids == {} + assert order_book.offers == {} + assert order_book.orders_by_expiration == {} + + def test_live_order_eviction(self) -> None: + matching_engine = MatchingEngine(seed=1) + timestamp = datetime(2023, 1, 1) + expiration = timestamp + timedelta(hours=1) + + # 1) rest "X" @100 with a finite expiration, then fully fill it + order = LimitOrder( + side=Side.BUY, price=100.0, size=10, timestamp=timestamp, order_id="X", trader_id="x", expiration=expiration + ) + matching_engine.match(timestamp=timestamp, orders=Orders(orders=[order])) + order = LimitOrder( + side=Side.SELL, + price=100.0, + size=10, + timestamp=timestamp + timedelta(seconds=1), + order_id="B", + trader_id="y", + ) + matching_engine.match(timestamp=timestamp + timedelta(seconds=1), orders=Orders(orders=[order])) + + # 2) a brand-new LIVE order re-uses id "X" at the same price (default far expiration) + order = LimitOrder( + side=Side.BUY, price=100.0, size=5, timestamp=timestamp + timedelta(seconds=2), order_id="X", trader_id="z" + ) + matching_engine.match(timestamp=timestamp + timedelta(seconds=2), orders=Orders(orders=[order])) + + # 3) advance time past expiration with any order -> expiry sweep fires + order = LimitOrder( + side=Side.SELL, price=999.0, size=1, timestamp=timestamp + timedelta(hours=2), order_id="D", trader_id="w" + ) + matching_engine.match(timestamp=timestamp + timedelta(hours=2), orders=Orders(orders=[order])) + + # the live re-used-id "X" was evicted; expected ['X'] + assert [o.order_id for v in matching_engine.unprocessed_orders.bids.values() for o in v] == ["X"] + def test_matching_with_benchmark(self, random_orders: Orders, benchmark: BenchmarkFixture) -> None: order_book = MatchingEngine() benchmark(order_book.match, orders=random_orders, timestamp=random_orders.orders[-1].timestamp)