From b9c58a376feb12c46ff017cf2788bdaaec17cbd6 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 3 May 2026 20:34:43 +0000 Subject: [PATCH] feat(V2): IBKR write methods + auto-confirm warning flow Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cerbero_mcp/exchanges/ibkr/client.py | 147 +++++++++++++++++++++++ tests/unit/exchanges/ibkr/test_client.py | 53 ++++++++ 2 files changed, 200 insertions(+) diff --git a/src/cerbero_mcp/exchanges/ibkr/client.py b/src/cerbero_mcp/exchanges/ibkr/client.py index e76ff1e..47f81b0 100644 --- a/src/cerbero_mcp/exchanges/ibkr/client.py +++ b/src/cerbero_mcp/exchanges/ibkr/client.py @@ -275,3 +275,150 @@ class IBKRClient: params={"symbol": symbol, "secType": sec_type}, ) return list(data) if isinstance(data, list) else [] + + # ── Order writes ──────────────────────────────────────────── + + _AUTO_CONFIRM_WHITELIST = ( + "outside RTH", "no market data", "you are submitting", "the contract", + ) + _CRITICAL_WARNINGS = ( + "margin", "suitability", "credit", "rejected", "insufficient", + ) + _AUTO_CONFIRM_MAX_CYCLES = 3 + + async def place_order( + self, *, + symbol: str, side: str, qty: float, + order_type: str = "market", + limit_price: float | None = None, + stop_price: float | None = None, + tif: str = "day", + asset_class: str = "stocks", + sec_type: str | None = None, + exchange: str = "SMART", + outside_rth: bool = False, + ) -> dict: + st = sec_type or _SEC_TYPE_MAP.get(asset_class.lower(), "STK") + conid = await self.resolve_conid(symbol, st) + + order: dict[str, Any] = { + "conid": conid, + "secType": f"{conid}:{st}", + "orderType": _ibkr_order_type(order_type), + "side": side.upper(), + "quantity": qty, + "tif": tif.upper(), + "outsideRTH": outside_rth, + "listingExchange": exchange, + } + if limit_price is not None: + order["price"] = limit_price + if stop_price is not None: + order["auxPrice"] = stop_price + + return await self._submit_order_with_confirmation({"orders": [order]}) + + async def _submit_order_with_confirmation( + self, payload: dict, *, cycles: int = 0 + ) -> dict: + path = f"/iserver/account/{self.account_id}/orders" + result = await self._request("POST", path, json_body=payload) + return await self._handle_order_response(result, cycles) + + async def _handle_order_response( + self, result: Any, cycles: int + ) -> dict: + if not isinstance(result, list) or not result: + raise IBKRError(f"IBKR_ORDER_UNEXPECTED_RESPONSE: {result!r}") + first = result[0] + if "id" in first and "message" in first: + messages = first.get("message") or [] + joined = " ".join(messages).lower() + if any(crit in joined for crit in self._CRITICAL_WARNINGS): + raise IBKRError( + f"IBKR_ORDER_REJECTED_WARNING: {messages}" + ) + if cycles >= self._AUTO_CONFIRM_MAX_CYCLES: + raise IBKRError( + f"IBKR_ORDER_TOO_MANY_CONFIRMATIONS: {messages}" + ) + reply = await self._request( + "POST", f"/iserver/reply/{first['id']}", + json_body={"confirmed": True}, + ) + return await self._handle_order_response(reply, cycles + 1) + if "order_id" in first: + return {"order_id": first["order_id"], "status": first.get("order_status")} + raise IBKRError(f"IBKR_ORDER_UNEXPECTED_RESPONSE: {first!r}") + + async def amend_order( + self, order_id: str, *, + qty: float | None = None, + limit_price: float | None = None, + stop_price: float | None = None, + tif: str | None = None, + ) -> dict: + body: dict[str, Any] = {} + if qty is not None: + body["quantity"] = qty + if limit_price is not None: + body["price"] = limit_price + if stop_price is not None: + body["auxPrice"] = stop_price + if tif is not None: + body["tif"] = tif.upper() + path = f"/iserver/account/{self.account_id}/order/{order_id}" + result = await self._request("POST", path, json_body=body) + return await self._handle_order_response(result, cycles=0) + + async def cancel_order(self, order_id: str) -> dict: + path = f"/iserver/account/{self.account_id}/order/{order_id}" + await self._request("DELETE", path) + return {"order_id": order_id, "canceled": True} + + async def cancel_all_orders(self) -> list[dict]: + orders = await self.get_open_orders() + results = [] + for o in orders: + oid = o.get("orderId") or o.get("order_id") + if not oid: + continue + try: + results.append(await self.cancel_order(str(oid))) + except Exception as e: + results.append({"order_id": str(oid), "canceled": False, "error": str(e)}) + return results + + async def close_position( + self, symbol: str, qty: float | None = None + ) -> dict: + positions = await self.get_positions() + target = next((p for p in positions if p.get("contractDesc") == symbol), None) + if not target: + raise IBKRError(f"IBKR_NO_POSITION: {symbol}") + position_qty = float(target.get("position", 0)) + close_qty = abs(qty if qty is not None else position_qty) + side = "SELL" if position_qty > 0 else "BUY" + return await self.place_order( + symbol=symbol, side=side, qty=close_qty, order_type="market", + ) + + async def close_all_positions(self) -> list[dict]: + positions = await self.get_positions() + results = [] + for p in positions: + sym = p.get("contractDesc") + if not sym: + continue + try: + results.append(await self.close_position(sym)) + except Exception as e: + results.append({"symbol": sym, "error": str(e)}) + return results + + +def _ibkr_order_type(t: str) -> str: + m = {"market": "MKT", "limit": "LMT", "stop": "STP", "stop_limit": "STP_LMT"} + if t.lower() not in m: + raise IBKRError(f"unsupported order_type: {t}") + return m[t.lower()] diff --git a/tests/unit/exchanges/ibkr/test_client.py b/tests/unit/exchanges/ibkr/test_client.py index a73aba6..05063aa 100644 --- a/tests/unit/exchanges/ibkr/test_client.py +++ b/tests/unit/exchanges/ibkr/test_client.py @@ -152,3 +152,56 @@ async def test_resolve_conid_malformed_response_raises(httpx_mock: HTTPXMock, cl ) with pytest.raises(IBKRError, match="malformed"): await client.resolve_conid("BAD", "STK") + + +@pytest.mark.asyncio +async def test_place_order_auto_confirms_warning(httpx_mock: HTTPXMock, client): + httpx_mock.add_response(url=re.compile(r".*/tickle"), json={}) + httpx_mock.add_response( + url=re.compile(r".*/trsrv/secdef/search"), + json=[{"conid": 265598, "symbol": "AAPL", "secType": "STK"}], + ) + httpx_mock.add_response( + url=re.compile(r".*/iserver/account/DU1234/orders$"), + method="POST", + json=[{"id": "msgid1", "message": ["outside RTH"]}], + ) + httpx_mock.add_response( + url=re.compile(r".*/iserver/reply/msgid1"), + method="POST", + json=[{"order_id": "OID42", "order_status": "Submitted"}], + ) + res = await client.place_order( + symbol="AAPL", side="buy", qty=1, order_type="market", + ) + assert res["order_id"] == "OID42" + + +@pytest.mark.asyncio +async def test_place_order_rejects_critical_warning(httpx_mock: HTTPXMock, client): + httpx_mock.add_response(url=re.compile(r".*/tickle"), json={}) + httpx_mock.add_response( + url=re.compile(r".*/trsrv/secdef/search"), + json=[{"conid": 265598, "symbol": "AAPL", "secType": "STK"}], + ) + httpx_mock.add_response( + url=re.compile(r".*/iserver/account/DU1234/orders$"), + method="POST", + json=[{"id": "msgid2", "message": ["Margin requirement exceeded"]}], + ) + with pytest.raises(IBKRError, match="IBKR_ORDER_REJECTED_WARNING"): + await client.place_order( + symbol="AAPL", side="buy", qty=1000000, order_type="market", + ) + + +@pytest.mark.asyncio +async def test_cancel_order(httpx_mock: HTTPXMock, client): + httpx_mock.add_response(url=re.compile(r".*/tickle"), json={}) + httpx_mock.add_response( + url=re.compile(r".*/iserver/account/DU1234/order/OID42"), + method="DELETE", + json={"msg": "Request was submitted", "order_id": "OID42"}, + ) + res = await client.cancel_order("OID42") + assert res["canceled"] is True