diff --git a/src/project_x_py/order_manager/bracket_orders.py b/src/project_x_py/order_manager/bracket_orders.py index 5126ad4..ca948d8 100644 --- a/src/project_x_py/order_manager/bracket_orders.py +++ b/src/project_x_py/order_manager/bracket_orders.py @@ -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 diff --git a/tests/order_manager/test_bracket_orders.py b/tests/order_manager/test_bracket_orders.py index 298eed2..2a70844 100644 --- a/tests/order_manager/test_bracket_orders.py +++ b/tests/order_manager/test_bracket_orders.py @@ -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."""