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"),
}