Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/tradeforge/domain/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion tests/unit/test_domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
Loading