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
35 changes: 23 additions & 12 deletions src/project_x_py/order_manager/bracket_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -648,30 +648,41 @@ async def place_bracket_order(
target_ref.size = size

elif not is_fully_filled and filled_size == 0:
# Undefined state - recheck once
# Undefined state: order status can lag a real fill by seconds. One
# 1s recheck raised here and left the filled position with no
# stop/target, so poll a bounded window before cancelling.
recheck_attempts = 6
recheck_interval = 1.0
logger.warning(
f"Entry order {entry_order_id} in undefined state. Rechecking..."
f"Entry order {entry_order_id} in undefined state. Polling fill "
f"status up to {recheck_attempts}x{recheck_interval:g}s before abandoning..."
)
await asyncio.sleep(1)

(
is_fully_filled,
filled_size,
remaining_size,
) = await self._check_order_fill_status(entry_order_id)
for _ in range(recheck_attempts):
await asyncio.sleep(recheck_interval)
(
is_fully_filled,
filled_size,
remaining_size,
) = await self._check_order_fill_status(entry_order_id)
if filled_size > 0:
break

if filled_size == 0:
# Still unfilled, cancel and abort
# Still unfilled after the full poll window — genuinely no
# fill; cancel and abort (the safe original behavior).
try:
await self.cancel_order(entry_order_id, account_id)
except Exception as cancel_error:
logger.error(f"Failed to cancel order: {cancel_error}")

raise ProjectXOrderError(
f"Entry order {entry_order_id} failed to fill after recheck."
f"Entry order {entry_order_id} failed to fill after "
f"{recheck_attempts} rechecks."
)
else:
# Actually partially filled
# Filled (fully or partially) during the poll — size the
# protective orders to the actual filled quantity so the
# bracket attaches to what really filled.
size = filled_size
if stop_ref:
stop_ref.size = size
Expand Down
75 changes: 75 additions & 0 deletions tests/order_manager/test_bracket_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,81 @@ async def test_bracket_order_success_flow(self, mock_order_manager):
assert result.stop_order_id == 2
assert result.target_order_id == 3

@pytest.mark.asyncio
async def test_bracket_undefined_state_lagging_fill_attaches_bracket(
self, mock_order_manager
):
"""The fill event fires but the status endpoint lags at 0 filled ('undefined
state'). A fill that lands on a later recheck must still get its bracket."""
mixin = mock_order_manager
mixin.place_order.side_effect = [
OrderPlaceResponse(
orderId=1, success=True, errorCode=0, errorMessage=None
), # entry
OrderPlaceResponse(
orderId=2, success=True, errorCode=0, errorMessage=None
), # stop
OrderPlaceResponse(
orderId=3, success=True, errorCode=0, errorMessage=None
), # target
]
mixin._wait_for_order_fill.return_value = True # event fired
# initial check + poll attempts: 0, 0, then the lagging fill lands on the 2nd poll
mixin._check_order_fill_status.side_effect = [
(False, 0, 1), # initial -> undefined state
(False, 0, 1), # poll 1 -> still lagging
(True, 1, 0), # poll 2 -> fill confirmed
]
with patch(
"project_x_py.order_manager.bracket_orders.asyncio.sleep", new=AsyncMock()
):
result = await mixin.place_bracket_order(
contract_id="MNQ",
side=0,
size=1,
entry_type="limit",
entry_price=100.0,
stop_loss_price=95.0,
take_profit_price=105.0,
)
# Bracket attached — did NOT raise, protective legs placed.
assert result.success is True
assert result.entry_order_id == 1
assert result.stop_order_id == 2
assert result.target_order_id == 3
mixin.cancel_order.assert_not_called() # a real fill is never cancelled

@pytest.mark.asyncio
async def test_bracket_undefined_state_genuinely_unfilled_still_raises(
self, mock_order_manager
):
"""The other half: an order that stays 0-filled across the WHOLE poll window is
genuinely unfilled — it must still be cancelled and raise (the safe behavior)."""
mixin = mock_order_manager
mixin.place_order.side_effect = [
OrderPlaceResponse(orderId=1, success=True, errorCode=0, errorMessage=None),
OrderPlaceResponse(orderId=2, success=True, errorCode=0, errorMessage=None),
OrderPlaceResponse(orderId=3, success=True, errorCode=0, errorMessage=None),
]
mixin._wait_for_order_fill.return_value = True
mixin._check_order_fill_status.return_value = (False, 0, 1) # never fills
with patch(
"project_x_py.order_manager.bracket_orders.asyncio.sleep", new=AsyncMock()
):
with pytest.raises(
ProjectXOrderError, match=r"failed to fill after \d+ rechecks"
):
await mixin.place_bracket_order(
contract_id="MNQ",
side=0,
size=1,
entry_type="limit",
entry_price=100.0,
stop_loss_price=95.0,
take_profit_price=105.0,
)
mixin.cancel_order.assert_called() # unfilled order IS cancelled

@pytest.mark.asyncio
async def test_bracket_order_market_entry(self, mock_order_manager):
"""Test bracket order with market entry."""
Expand Down
Loading