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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException
|
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.client import _SEC_TYPE_MAP, IBKRClient, IBKRError
|
||||||
from cerbero_mcp.exchanges.ibkr.leverage_cap import get_max_leverage
|
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
|
from cerbero_mcp.exchanges.ibkr.ws import IBKRWebSocket
|
||||||
|
|
||||||
# === Schemas: reads ===
|
# === Schemas: reads ===
|
||||||
@@ -329,3 +337,111 @@ async def close_all_positions(
|
|||||||
client: IBKRClient, params: CloseAllPositionsReq
|
client: IBKRClient, params: CloseAllPositionsReq
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return {"closed": await client.close_all_positions()}
|
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"),
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,3 +86,52 @@ async def test_place_order_rejects_excessive_leverage():
|
|||||||
)
|
)
|
||||||
assert exc_info.value.status_code == 403
|
assert exc_info.value.status_code == 403
|
||||||
assert exc_info.value.detail["error"] == "LEVERAGE_CAP_EXCEEDED"
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user