diff --git a/src/tradeforge/domain/orders.py b/src/tradeforge/domain/orders.py index 6b820e1..ec2901c 100644 --- a/src/tradeforge/domain/orders.py +++ b/src/tradeforge/domain/orders.py @@ -108,11 +108,14 @@ def apply_fill(self, quantity_base: int, *, at_ns: int) -> None: f"fill {quantity_base} exceeds remaining {self.remaining_base} " f"for order {self.client_order_id}" ) - self.filled_base += quantity_base - if self.filled_base == self.quantity_base: + filled_after = self.filled_base + quantity_base + # Validate the state transition before committing any fill quantity. + # A rejected fill on a new or terminal order must leave it unchanged. + if filled_after == self.quantity_base: self.transition_to(OrderStatus.FILLED, at_ns=at_ns) else: self.transition_to(OrderStatus.PARTIALLY_FILLED, at_ns=at_ns) + self.filled_base = filled_after @dataclass(slots=True) diff --git a/tests/unit/test_domain.py b/tests/unit/test_domain.py index 50236c5..41204ea 100644 --- a/tests/unit/test_domain.py +++ b/tests/unit/test_domain.py @@ -154,7 +154,7 @@ def test_fill_notional_is_callable(self): class TestOrderStateMachine: - def _order(self) -> ChildOrder: + def _order(self, status=OrderStatus.NEW) -> ChildOrder: return ChildOrder( order_id=1, client_order_id="c1", @@ -164,6 +164,7 @@ def _order(self) -> ChildOrder: quantity_base=100, time_in_force=TimeInForce.DAY, created_ns=0, + status=status, ) def test_new_can_be_accepted_or_rejected(self): @@ -200,6 +201,18 @@ def test_overfill_is_rejected(self): with pytest.raises(OrderStateError): order.apply_fill(101, at_ns=2) + @pytest.mark.parametrize( + "status", + [OrderStatus.NEW, OrderStatus.CANCELLED, OrderStatus.REJECTED, OrderStatus.EXPIRED], + ) + @pytest.mark.parametrize("quantity", [40, 100]) + def test_rejected_fill_does_not_mutate_order(self, status, quantity): + order = self._order(status) + before = (order.filled_base, order.status, order.working_ns, order.terminal_ns) + with pytest.raises(OrderStateError, match="illegal order transition"): + order.apply_fill(quantity, at_ns=2) + assert (order.filled_base, order.status, order.working_ns, order.terminal_ns) == before + def test_non_positive_fill_is_rejected(self): order = self._order() order.transition_to(OrderStatus.ACCEPTED, at_ns=1)