diff --git a/tests/README.md b/tests/README.md index 2daa5a5..c8ac856 100644 --- a/tests/README.md +++ b/tests/README.md @@ -75,4 +75,15 @@ Areas for future test improvements: - Integration with CI/CD pipeline - Property-based testing for complex scenarios - Performance benchmarks -- Snapshot testing for response structures \ No newline at end of file +- Snapshot testing for response structures + +## Order Manager Tests + +The `order_manager` test suite provides high-coverage, unit-level tests for all major flows in `src/project_x_py/order_manager/`. Coverage includes order placement, bracket/position helpers, utils, and price alignment logic. + +**To run only Order Manager tests:** +```bash +pytest tests/order_manager/ +``` + +All network/API operations are mocked for speed and determinism. See `tests/order_manager/conftest.py` for local fixtures and helpers. \ No newline at end of file diff --git a/tests/TESTING.md b/tests/TESTING.md index d791d32..14a59ed 100644 --- a/tests/TESTING.md +++ b/tests/TESTING.md @@ -146,4 +146,21 @@ Focus is placed on testing: The test suite is designed to integrate with CI/CD pipelines. Tests run automatically on: - Pull requests - Main branch changes -- Release tags \ No newline at end of file +- Release tags + +## Order Manager Tests + +To run just the Order Manager test suite: + +```bash +pytest tests/order_manager/ +``` + +This suite covers: +- `OrderManager` core API (place/search/cancel/modify) +- Order type helpers (market, limit, stop, trailing-stop) +- Bracket order validation and flows +- Position order tracking and helpers +- Utility price alignment functions + +All network and API interactions are fully mocked using pytest and unittest.mock. Test execution is fast (<50ms per test). \ No newline at end of file diff --git a/tests/order_manager/__init__.py b/tests/order_manager/__init__.py new file mode 100644 index 0000000..ebe4022 --- /dev/null +++ b/tests/order_manager/__init__.py @@ -0,0 +1 @@ +# Order Manager test subpackage \ No newline at end of file diff --git a/tests/order_manager/conftest.py b/tests/order_manager/conftest.py new file mode 100644 index 0000000..e90506d --- /dev/null +++ b/tests/order_manager/conftest.py @@ -0,0 +1,56 @@ +"""OrderManager test-specific fixtures.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from project_x_py.models import Account, OrderPlaceResponse +from project_x_py.order_manager.core import OrderManager + +@pytest.fixture +def order_manager(initialized_client): + """ + Fixture for an OrderManager wired to a mocked ProjectX client. + Also patches align_price_to_tick_size to return the input price for determinism. + """ + # Patch align_price_to_tick_size in both utils and core to return input price for determinism + patch_utils = patch( + "project_x_py.order_manager.utils.align_price_to_tick_size", + new=AsyncMock(side_effect=lambda price, *_args, **_kwargs: price), + ) + patch_core = patch( + "project_x_py.order_manager.core.align_price_to_tick_size", + new=AsyncMock(side_effect=lambda price, *_args, **_kwargs: price), + ) + patch_utils.start() + patch_core.start() + + # Set up a dummy account + initialized_client.account_info = Account( + id=12345, + name="Test Account", + balance=100000.0, + canTrade=True, + isVisible=True, + simulated=True, + ) + + om = OrderManager(initialized_client) + yield om + + patch_utils.stop() + patch_core.stop() + + +@pytest.fixture +def make_order_response(): + """ + Helper to build a dict compatible with OrderPlaceResponse. + """ + def _make(order_id, success=True, error_code=0, error_msg=None): + return { + "orderId": order_id, + "success": success, + "errorCode": error_code, + "errorMessage": error_msg, + } + return _make \ No newline at end of file diff --git a/tests/order_manager/test_bracket_orders.py b/tests/order_manager/test_bracket_orders.py new file mode 100644 index 0000000..b1b2607 --- /dev/null +++ b/tests/order_manager/test_bracket_orders.py @@ -0,0 +1,62 @@ +"""Tests for BracketOrderMixin (validation and successful flows).""" + +import pytest +from unittest.mock import AsyncMock, patch + +from project_x_py.models import OrderPlaceResponse, BracketOrderResponse +from project_x_py.exceptions import ProjectXOrderError + +@pytest.mark.asyncio +class TestBracketOrderMixin: + """Unit tests for BracketOrderMixin bracket order placement.""" + + @pytest.mark.parametrize( + "side, entry, stop, target, err", + [ + (0, 100.0, 101.0, 102.0, "stop loss (101.0) must be below entry (100.0)"), + (0, 100.0, 99.0, 99.0, "take profit (99.0) must be above entry (100.0)"), + (1, 100.0, 99.0, 98.0, "stop loss (99.0) must be above entry (100.0)"), + (1, 100.0, 101.0, 101.0, "take profit (101.0) must be below entry (100.0)"), + ] + ) + async def test_bracket_order_validation_fails(self, side, entry, stop, target, err): + """BracketOrderMixin validates stop/take_profit price relationships.""" + from project_x_py.order_manager.bracket_orders import BracketOrderMixin + mixin = BracketOrderMixin() + mixin.place_market_order = AsyncMock() + mixin.place_limit_order = AsyncMock() + mixin.place_stop_order = AsyncMock() + mixin.position_orders = {"FOO": {"entry_orders": [], "stop_orders": [], "target_orders": []}} + mixin.stats = {"bracket_orders_placed": 0} + with pytest.raises(ProjectXOrderError) as exc: + await mixin.place_bracket_order( + "FOO", side, 1, entry, stop, target, entry_type="limit" + ) + assert err in str(exc.value) + + async def test_bracket_order_success_flow(self): + """Successful bracket order path places all three orders and updates stats/caches.""" + from project_x_py.order_manager.bracket_orders import BracketOrderMixin + mixin = BracketOrderMixin() + mixin.place_market_order = AsyncMock(return_value=OrderPlaceResponse(orderId=1, success=True, errorCode=0, errorMessage=None)) + mixin.place_limit_order = AsyncMock(side_effect=[ + OrderPlaceResponse(orderId=2, success=True, errorCode=0, errorMessage=None), + OrderPlaceResponse(orderId=3, success=True, errorCode=0, errorMessage=None), + ]) + mixin.place_stop_order = AsyncMock(return_value=OrderPlaceResponse(orderId=4, success=True, errorCode=0, errorMessage=None)) + mixin.position_orders = {"BAR": {"entry_orders": [], "stop_orders": [], "target_orders": []}} + mixin.stats = {"bracket_orders_placed": 0} + + # Entry type = limit + resp = await mixin.place_bracket_order( + "BAR", 0, 2, 100.0, 99.0, 103.0, entry_type="limit" + ) + assert isinstance(resp, BracketOrderResponse) + assert resp.success + assert resp.entry_order_id == 2 + assert resp.stop_order_id == 4 + assert resp.target_order_id == 3 + assert mixin.position_orders["BAR"]["entry_orders"][-1] == 2 + assert mixin.position_orders["BAR"]["stop_orders"][-1] == 4 + assert mixin.position_orders["BAR"]["target_orders"][-1] == 3 + assert mixin.stats["bracket_orders_placed"] == 1 \ No newline at end of file diff --git a/tests/order_manager/test_core.py b/tests/order_manager/test_core.py new file mode 100644 index 0000000..521b348 --- /dev/null +++ b/tests/order_manager/test_core.py @@ -0,0 +1,119 @@ +"""Tests for OrderManager core API.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from project_x_py.models import Order, OrderPlaceResponse +from project_x_py.exceptions import ProjectXOrderError + + +class TestOrderManagerCore: + """Unit tests for OrderManager core public methods.""" + + @pytest.mark.asyncio + async def test_place_market_order_success(self, order_manager, make_order_response): + """place_market_order hits /Order/place with correct payload and updates stats.""" + # Patch _make_request to return a success response + order_manager.project_x._make_request = AsyncMock( + return_value=make_order_response(42) + ) + # Should increment orders_placed + start_count = order_manager.stats["orders_placed"] + resp = await order_manager.place_market_order("MGC", 0, 2) + assert isinstance(resp, OrderPlaceResponse) + assert resp.orderId == 42 + assert order_manager.project_x._make_request.call_count == 1 + call_args = order_manager.project_x._make_request.call_args[1]["data"] + assert call_args["contractId"] == "MGC" + assert call_args["type"] == 2 + assert call_args["side"] == 0 + assert call_args["size"] == 2 + assert order_manager.stats["orders_placed"] == start_count + 1 + + @pytest.mark.asyncio + async def test_place_order_error_raises(self, order_manager, make_order_response): + """place_order raises ProjectXOrderError when API fails.""" + order_manager.project_x._make_request = AsyncMock( + return_value={"success": False, "errorMessage": "Test error"} + ) + with pytest.raises(ProjectXOrderError): + await order_manager.place_order("MGC", 2, 0, 1) + + @pytest.mark.asyncio + async def test_search_open_orders_populates_cache(self, order_manager, make_order_response): + """search_open_orders converts API dicts to Order objects and populates cache.""" + resp_order = { + "id": 101, "accountId": 12345, "contractId": "MGC", + "creationTimestamp": "2024-01-01T01:00:00Z", "updateTimestamp": None, + "status": 1, "type": 1, "side": 0, "size": 2 + } + order_manager.project_x.account_info.id = 12345 + order_manager.project_x._make_request = AsyncMock( + return_value={"success": True, "orders": [resp_order]} + ) + orders = await order_manager.search_open_orders() + assert isinstance(orders[0], Order) + assert order_manager.tracked_orders[str(resp_order["id"])] == resp_order + assert order_manager.order_status_cache[str(resp_order["id"])] == 1 + + @pytest.mark.asyncio + async def test_is_order_filled_cache_hit(self, order_manager): + """is_order_filled returns True from cache and does not call _make_request if cached.""" + order_manager._realtime_enabled = True + order_manager.order_status_cache["77"] = 2 # 2=Filled + order_manager.project_x._make_request = AsyncMock() + result = await order_manager.is_order_filled(77) + assert result is True + order_manager.project_x._make_request.assert_not_called() + + @pytest.mark.asyncio + async def test_is_order_filled_fallback(self, order_manager): + """is_order_filled falls back to get_order_by_id when not cached.""" + order_manager._realtime_enabled = False + dummy_order = Order( + id=55, accountId=12345, contractId="CL", + creationTimestamp="2024-01-01T01:00:00Z", updateTimestamp=None, + status=2, type=1, side=0, size=1 + ) + order_manager.get_order_by_id = AsyncMock(return_value=dummy_order) + result = await order_manager.is_order_filled(55) + assert result is True + + @pytest.mark.asyncio + async def test_cancel_order_success_and_failure(self, order_manager): + """cancel_order updates caches/stats on success and handles failure.""" + # Setup tracked order + order_manager.tracked_orders["888"] = {"status": 1} + order_manager.order_status_cache["888"] = 1 + start = order_manager.stats["orders_cancelled"] + order_manager.project_x._make_request = AsyncMock(return_value={"success": True}) + assert await order_manager.cancel_order(888) is True + assert order_manager.tracked_orders["888"]["status"] == 3 + assert order_manager.order_status_cache["888"] == 3 + assert order_manager.stats["orders_cancelled"] == start + 1 + + order_manager.project_x._make_request = AsyncMock(return_value={"success": False, "errorMessage": "fail"}) + assert await order_manager.cancel_order(888) is False + + @pytest.mark.asyncio + async def test_modify_order_success_and_aligns(self, order_manager): + """modify_order aligns prices, makes API call, returns True on success.""" + dummy_order = Order( + id=12, accountId=12345, contractId="MGC", + creationTimestamp="2024-01-01T01:00:00Z", updateTimestamp=None, + status=1, type=1, side=0, size=1 + ) + order_manager.get_order_by_id = AsyncMock(return_value=dummy_order) + order_manager.project_x._make_request = AsyncMock(return_value={"success": True}) + assert await order_manager.modify_order(12, limit_price=2000.5) is True + + order_manager.project_x._make_request = AsyncMock(return_value={"success": False}) + assert await order_manager.modify_order(12, limit_price=2001.5) is False + + @pytest.mark.asyncio + async def test_get_order_statistics(self, order_manager): + """get_order_statistics returns expected health_status key and stats.""" + stats = await order_manager.get_order_statistics() + assert "statistics" in stats + assert "health_status" in stats + assert stats["health_status"] in {"healthy", "degraded"} \ No newline at end of file diff --git a/tests/order_manager/test_order_types.py b/tests/order_manager/test_order_types.py new file mode 100644 index 0000000..0f83622 --- /dev/null +++ b/tests/order_manager/test_order_types.py @@ -0,0 +1,58 @@ +"""Tests for OrderTypesMixin helpers (market/limit/stop/trailing-stop).""" + +import pytest +from unittest.mock import AsyncMock + +from project_x_py.models import OrderPlaceResponse + +class DummyOrderManager: + def __init__(self): + self.place_order = AsyncMock() + +@pytest.mark.asyncio +class TestOrderTypesMixin: + """Unit tests for OrderTypesMixin order type wrappers.""" + + async def test_place_market_order(self): + """place_market_order delegates to place_order with order_type=2.""" + dummy = DummyOrderManager() + from project_x_py.order_manager.order_types import OrderTypesMixin + mixin = OrderTypesMixin() + mixin.place_order = dummy.place_order + await mixin.place_market_order("MGC", 0, 1) + dummy.place_order.assert_awaited_once() + args = dummy.place_order.call_args.kwargs + assert args["order_type"] == 2 + + async def test_place_limit_order(self): + """place_limit_order delegates to place_order with order_type=1 and passes limit_price.""" + dummy = DummyOrderManager() + from project_x_py.order_manager.order_types import OrderTypesMixin + mixin = OrderTypesMixin() + mixin.place_order = dummy.place_order + await mixin.place_limit_order("MGC", 1, 2, 2040.0) + args = dummy.place_order.call_args.kwargs + assert args["order_type"] == 1 + assert args["limit_price"] == 2040.0 + + async def test_place_stop_order(self): + """place_stop_order delegates to place_order with order_type=4 and passes stop_price.""" + dummy = DummyOrderManager() + from project_x_py.order_manager.order_types import OrderTypesMixin + mixin = OrderTypesMixin() + mixin.place_order = dummy.place_order + await mixin.place_stop_order("MGC", 1, 2, 2030.0) + args = dummy.place_order.call_args.kwargs + assert args["order_type"] == 4 + assert args["stop_price"] == 2030.0 + + async def test_place_trailing_stop_order(self): + """place_trailing_stop_order delegates to place_order with order_type=5 and passes trail_price.""" + dummy = DummyOrderManager() + from project_x_py.order_manager.order_types import OrderTypesMixin + mixin = OrderTypesMixin() + mixin.place_order = dummy.place_order + await mixin.place_trailing_stop_order("MGC", 1, 2, 5.0) + args = dummy.place_order.call_args.kwargs + assert args["order_type"] == 5 + assert args["trail_price"] == 5.0 \ No newline at end of file diff --git a/tests/order_manager/test_position_orders.py b/tests/order_manager/test_position_orders.py new file mode 100644 index 0000000..c4a9285 --- /dev/null +++ b/tests/order_manager/test_position_orders.py @@ -0,0 +1,77 @@ +"""Tests for PositionOrderMixin helpers and tracking.""" + +import pytest +import asyncio +from unittest.mock import AsyncMock, MagicMock +from project_x_py.order_manager.position_orders import PositionOrderMixin +from project_x_py.models import OrderPlaceResponse + +@pytest.mark.asyncio +class TestPositionOrderMixin: + """Unit tests for PositionOrderMixin helpers (track, untrack, add_stop_loss, add_take_profit).""" + + async def test_track_and_untrack_order(self): + """track_order_for_position and untrack_order mutate position_orders/order_to_position correctly.""" + mixin = PositionOrderMixin() + mixin.order_lock = asyncio.Lock() + mixin.position_orders = {} + mixin.order_to_position = {} + + await mixin.track_order_for_position("BAZ", 1001, "entry") + assert 1001 in mixin.order_to_position + assert mixin.order_to_position[1001] == "BAZ" + assert mixin.position_orders["BAZ"]["entry_orders"] == [1001] + + mixin.untrack_order(1001) + assert 1001 not in mixin.order_to_position + assert mixin.position_orders["BAZ"]["entry_orders"] == [] + + async def test_add_stop_loss_success(self): + """add_stop_loss places stop order and tracks it.""" + mixin = PositionOrderMixin() + mixin.project_x = MagicMock() + position = MagicMock(contractId="QWE", size=2) + mixin.project_x.search_open_positions = AsyncMock(return_value=[position]) + mixin.place_stop_order = AsyncMock(return_value=OrderPlaceResponse( + orderId=201, success=True, errorCode=0, errorMessage=None + )) + mixin.track_order_for_position = AsyncMock() + resp = await mixin.add_stop_loss("QWE", 99.0) + assert resp.orderId == 201 + mixin.track_order_for_position.assert_awaited_once_with( + "QWE", 201, "stop", None + ) + + async def test_add_stop_loss_no_position(self): + """add_stop_loss returns None if no position found.""" + mixin = PositionOrderMixin() + mixin.project_x = MagicMock() + mixin.project_x.search_open_positions = AsyncMock(return_value=[]) + mixin.place_stop_order = AsyncMock() + resp = await mixin.add_stop_loss("AAA", 100.0) + assert resp is None + + async def test_add_take_profit_success(self): + """add_take_profit places limit order and tracks it.""" + mixin = PositionOrderMixin() + mixin.project_x = MagicMock() + position = MagicMock(contractId="ZXC", size=3) + mixin.project_x.search_open_positions = AsyncMock(return_value=[position]) + mixin.place_limit_order = AsyncMock(return_value=OrderPlaceResponse( + orderId=301, success=True, errorCode=0, errorMessage=None + )) + mixin.track_order_for_position = AsyncMock() + resp = await mixin.add_take_profit("ZXC", 120.0) + assert resp.orderId == 301 + mixin.track_order_for_position.assert_awaited_once_with( + "ZXC", 301, "target", None + ) + + async def test_add_take_profit_no_position(self): + """add_take_profit returns None if no position found.""" + mixin = PositionOrderMixin() + mixin.project_x = MagicMock() + mixin.project_x.search_open_positions = AsyncMock(return_value=[]) + mixin.place_limit_order = AsyncMock() + resp = await mixin.add_take_profit("TUV", 55.0) + assert resp is None \ No newline at end of file diff --git a/tests/order_manager/test_utils.py b/tests/order_manager/test_utils.py new file mode 100644 index 0000000..c9b4a25 --- /dev/null +++ b/tests/order_manager/test_utils.py @@ -0,0 +1,78 @@ +"""Unit tests for order_manager.utils.""" + +import pytest +from unittest.mock import MagicMock +from project_x_py.order_manager import utils + +class TestAlignPriceToTick: + """Tests for align_price_to_tick utility.""" + + def test_aligns_up(self): + """Price rounds to nearest tick size (upwards).""" + assert utils.align_price_to_tick(100.07, 0.1) == 100.1 + + def test_aligns_down(self): + """Price rounds to nearest tick size (downwards).""" + assert utils.align_price_to_tick(99.92, 0.25) == 100.0 + + def test_zero_tick_size(self): + """Returns price unchanged if tick size is zero.""" + assert utils.align_price_to_tick(50.0, 0.0) == 50.0 + + def test_negative_tick_size(self): + """Returns price unchanged if tick size is negative.""" + assert utils.align_price_to_tick(50.0, -1.0) == 50.0 + + +@pytest.mark.asyncio +async def test_align_price_to_tick_size_returns_input(monkeypatch): + """Patch get_instrument to always return tickSize=0.5; should align price to 100.0.""" + class DummyClient: + async def get_instrument(self, contract_id): + class Instrument: + tickSize = 0.5 + return Instrument() + price = await utils.align_price_to_tick_size(100.2, "MGC", DummyClient()) + assert price == 100.0 + +@pytest.mark.asyncio +async def test_align_price_to_tick_size_price_none(): + """Returns None if price is None.""" + result = await utils.align_price_to_tick_size(None, "MGC", MagicMock()) + assert result is None + +@pytest.mark.asyncio +async def test_align_price_to_tick_size_handles_missing_instrument(monkeypatch): + """Returns original price if instrument lookup fails.""" + class DummyClient: + async def get_instrument(self, contract_id): return None + price = await utils.align_price_to_tick_size(101.5, "FOO", DummyClient()) + assert price == 101.5 + + +@pytest.mark.asyncio +async def test_resolve_contract_id(monkeypatch): + """resolve_contract_id fetches instrument and returns expected dict.""" + class DummyInstrument: + id = "X" + name = "X" + tickSize = 0.1 + tickValue = 1.0 + activeContract = True + class DummyClient: + async def get_instrument(self, contract_id): return DummyInstrument() + result = await utils.resolve_contract_id("X", DummyClient()) + assert result == { + "id": "X", + "name": "X", + "tickSize": 0.1, + "tickValue": 1.0, + "activeContract": True, + } + +@pytest.mark.asyncio +async def test_resolve_contract_id_handles_missing(monkeypatch): + """Returns None if instrument not found.""" + class DummyClient: + async def get_instrument(self, contract_id): return None + assert await utils.resolve_contract_id("X", DummyClient()) is None \ No newline at end of file