diff --git a/src/cerbero_mcp/exchanges/ibkr/tools.py b/src/cerbero_mcp/exchanges/ibkr/tools.py index 2809337..79914dc 100644 --- a/src/cerbero_mcp/exchanges/ibkr/tools.py +++ b/src/cerbero_mcp/exchanges/ibkr/tools.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import contextlib from typing import Any from fastapi import HTTPException @@ -9,6 +10,13 @@ from pydantic import BaseModel from cerbero_mcp.exchanges.ibkr.client import _SEC_TYPE_MAP, IBKRClient, IBKRError from cerbero_mcp.exchanges.ibkr.leverage_cap import get_max_leverage +from cerbero_mcp.exchanges.ibkr.orders_complex import ( + OrderSpec, + build_bracket_payload, + build_oco_payload, + build_oto_child_payload, + build_oto_first_payload, +) from cerbero_mcp.exchanges.ibkr.ws import IBKRWebSocket # === Schemas: reads === @@ -329,3 +337,111 @@ async def close_all_positions( client: IBKRClient, params: CloseAllPositionsReq ) -> dict: return {"closed": await client.close_all_positions()} + + +# === Write tools: complex orders === + + +def _leg_to_spec(leg: OrderLeg, conid: int) -> OrderSpec: + return OrderSpec( + conid=conid, + sec_type=_sec_type_for(leg.asset_class), + side=leg.side.upper(), # type: ignore[arg-type] + qty=leg.qty, + order_type={ + "market": "MKT", "limit": "LMT", + "stop": "STP", "stop_limit": "STP_LMT", + }[leg.order_type.lower()], # type: ignore[arg-type] + price=leg.limit_price, + aux_price=leg.stop_price, + tif=leg.tif.upper(), + ) + + +async def place_bracket_order( + client: IBKRClient, params: PlaceBracketOrderReq, *, creds: dict, +) -> dict: + sec = _sec_type_for(params.asset_class) + conid = await client.resolve_conid(params.symbol, sec) + cap = get_max_leverage(creds) + notional = params.qty * params.entry_price + try: + account = await client.get_account() + equity = float((account.get("netliquidation") or {}).get("amount") or 0) + except Exception: + equity = 0.0 + if equity > 0 and notional / equity > cap: + raise HTTPException( + status_code=403, + detail={"error": "LEVERAGE_CAP_EXCEEDED", "exchange": "ibkr", + "requested_ratio": notional / equity, "max": cap}, + ) + payload = build_bracket_payload( + conid=conid, sec_type=sec, side=params.side.upper(), qty=params.qty, + entry_price=params.entry_price, stop_loss=params.stop_loss, + take_profit=params.take_profit, tif=params.tif.upper(), + exchange=params.exchange, + ) + return await client._submit_order_with_confirmation(payload) + + +async def place_oco_order( + client: IBKRClient, params: PlaceOcoOrderReq, *, creds: dict, +) -> dict: + if len(params.legs) < 2: + raise HTTPException(400, detail={"error": "OCO requires >=2 legs"}) + cap = get_max_leverage(creds) + leg_notional = max( + l.qty * (l.limit_price or l.stop_price or 0) for l in params.legs + ) + try: + account = await client.get_account() + equity = float((account.get("netliquidation") or {}).get("amount") or 0) + except Exception: + equity = 0.0 + if equity > 0 and leg_notional / equity > cap: + raise HTTPException( + status_code=403, + detail={"error": "LEVERAGE_CAP_EXCEEDED", "exchange": "ibkr", + "requested_ratio": leg_notional / equity, "max": cap}, + ) + + specs = [] + for l in params.legs: + sec = _sec_type_for(l.asset_class) + conid = await client.resolve_conid(l.symbol, sec) + specs.append(_leg_to_spec(l, conid)) + payload = build_oco_payload(specs) + return await client._submit_order_with_confirmation(payload) + + +async def place_oto_order( + client: IBKRClient, params: PlaceOtoOrderReq, *, creds: dict, +) -> dict: + sec_t = _sec_type_for(params.trigger.asset_class) + sec_c = _sec_type_for(params.child.asset_class) + conid_t = await client.resolve_conid(params.trigger.symbol, sec_t) + conid_c = await client.resolve_conid(params.child.symbol, sec_c) + trig_spec = _leg_to_spec(params.trigger, conid_t) + child_spec = _leg_to_spec(params.child, conid_c) + + trig_payload = build_oto_first_payload(trig_spec) + trig_res = await client._submit_order_with_confirmation(trig_payload) + trigger_order_id = trig_res.get("order_id") + if not trigger_order_id: + raise IBKRError(f"IBKR_OTO_TRIGGER_NO_ID: {trig_res!r}") + + try: + child_payload = build_oto_child_payload(child_spec, str(trigger_order_id)) + child_res = await client._submit_order_with_confirmation(child_payload) + except Exception as e: + with contextlib.suppress(Exception): + await client.cancel_order(str(trigger_order_id)) + raise IBKRError( + f"IBKR_OTO_PARTIAL_FAILURE: trigger={trigger_order_id} reason={e}" + ) from e + + return { + "trigger_order_id": trigger_order_id, + "child_order_id": child_res.get("order_id"), + } diff --git a/tests/unit/exchanges/ibkr/test_tools.py b/tests/unit/exchanges/ibkr/test_tools.py index d439f83..8bffe53 100644 --- a/tests/unit/exchanges/ibkr/test_tools.py +++ b/tests/unit/exchanges/ibkr/test_tools.py @@ -86,3 +86,52 @@ async def test_place_order_rejects_excessive_leverage(): ) assert exc_info.value.status_code == 403 assert exc_info.value.detail["error"] == "LEVERAGE_CAP_EXCEEDED" + + +@pytest.mark.asyncio +async def test_place_bracket_order_calls_client_with_three_legs(): + client = MagicMock() + client.resolve_conid = AsyncMock(return_value=42) + client.account_id = "DU1" + client.get_account = AsyncMock(return_value={"netliquidation": {"amount": 100000}}) + client._submit_order_with_confirmation = AsyncMock( + return_value={"order_id": "OID-parent"} + ) + res = await t.place_bracket_order( + client, + t.PlaceBracketOrderReq( + symbol="AAPL", side="buy", qty=1, + entry_price=150, stop_loss=145, take_profit=160, + ), + creds={"max_leverage": 4}, + ) + assert res["order_id"] == "OID-parent" + payload = client._submit_order_with_confirmation.call_args[0][0] + assert len(payload["orders"]) == 3 + + +@pytest.mark.asyncio +async def test_place_oto_partial_failure_cancels_trigger(): + from cerbero_mcp.exchanges.ibkr.client import IBKRError + client = MagicMock() + client.resolve_conid = AsyncMock(return_value=42) + client.account_id = "DU1" + client._submit_order_with_confirmation = AsyncMock( + side_effect=[ + {"order_id": "TRIG1"}, + IBKRError("network"), + ] + ) + client.cancel_order = AsyncMock(return_value={"canceled": True}) + with pytest.raises(IBKRError, match="IBKR_OTO_PARTIAL_FAILURE"): + await t.place_oto_order( + client, + t.PlaceOtoOrderReq( + trigger=t.OrderLeg(symbol="AAPL", side="buy", qty=1, + order_type="limit", limit_price=150), + child=t.OrderLeg(symbol="AAPL", side="sell", qty=1, + order_type="limit", limit_price=160), + ), + creds={"max_leverage": 4}, + ) + client.cancel_order.assert_awaited_once_with("TRIG1")