feat(V2): IBKR complex order tools (bracket/OCO/OTO)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-03 21:30:05 +00:00
parent 9bbc8c05f1
commit bdc40929d4
2 changed files with 165 additions and 0 deletions
+116
View File
@@ -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"),
}
+49
View File
@@ -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")