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:
@@ -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"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user