feat(V2): IBKR complex order payload builders (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:27:26 +00:00
parent 3510605fdd
commit 9bbc8c05f1
2 changed files with 145 additions and 0 deletions
@@ -0,0 +1,101 @@
"""Pure-function payload builders for IBKR complex orders (bracket/OCO/OTO).
No HTTP. Tests are deterministic.
"""
from __future__ import annotations
import secrets
from dataclasses import dataclass
from typing import Literal
@dataclass
class OrderSpec:
conid: int
sec_type: str # "STK" | "OPT" | "FUT" | "CASH"
side: Literal["BUY", "SELL"]
qty: float
order_type: Literal["MKT", "LMT", "STP", "STP_LMT"]
price: float | None = None # limit price
aux_price: float | None = None # stop price
tif: str = "GTC"
exchange: str = "SMART"
def _to_order_dict(spec: OrderSpec, *, oca_group: str | None = None,
oca_type: int | None = None,
parent_id: str | None = None) -> dict:
o: dict = {
"conid": spec.conid,
"secType": f"{spec.conid}:{spec.sec_type}",
"orderType": spec.order_type,
"side": spec.side,
"quantity": spec.qty,
"tif": spec.tif,
"listingExchange": spec.exchange,
}
if spec.price is not None:
o["price"] = spec.price
if spec.aux_price is not None:
o["auxPrice"] = spec.aux_price
if oca_group:
o["ocaGroup"] = oca_group
if oca_type is not None:
o["ocaType"] = oca_type
if parent_id:
o["parentId"] = parent_id
return o
def _new_oca_group() -> str:
return f"oca-{secrets.token_hex(4)}"
def build_bracket_payload(
*, conid: int, sec_type: str, side: str, qty: float,
entry_price: float, stop_loss: float, take_profit: float,
tif: str = "GTC", exchange: str = "SMART",
) -> dict:
"""Bracket: parent LMT entry + child STP (loss) + child LMT (profit), OCA-linked."""
side = side.upper()
opposite = "SELL" if side == "BUY" else "BUY"
oca = _new_oca_group()
parent = OrderSpec(conid=conid, sec_type=sec_type, side=side, qty=qty, # type: ignore[arg-type]
order_type="LMT", price=entry_price,
tif=tif, exchange=exchange)
sl = OrderSpec(conid=conid, sec_type=sec_type, side=opposite, qty=qty, # type: ignore[arg-type]
order_type="STP", aux_price=stop_loss,
tif=tif, exchange=exchange)
tp = OrderSpec(conid=conid, sec_type=sec_type, side=opposite, qty=qty, # type: ignore[arg-type]
order_type="LMT", price=take_profit,
tif=tif, exchange=exchange)
return {
"orders": [
_to_order_dict(parent, oca_group=oca, oca_type=2),
_to_order_dict(sl, oca_group=oca, oca_type=2),
_to_order_dict(tp, oca_group=oca, oca_type=2),
]
}
def build_oco_payload(legs: list[OrderSpec]) -> dict:
"""OCO: N legs, all sharing same ocaGroup with ocaType=1 (one-cancels-all)."""
if len(legs) < 2:
raise ValueError("OCO requires at least 2 legs")
oca = _new_oca_group()
return {
"orders": [
_to_order_dict(l, oca_group=oca, oca_type=1) for l in legs
]
}
def build_oto_first_payload(trigger: OrderSpec) -> dict:
"""OTO step 1: place trigger as standalone."""
return {"orders": [_to_order_dict(trigger)]}
def build_oto_child_payload(child: OrderSpec, parent_order_id: str) -> dict:
"""OTO step 2: child references parentId from step-1 order_id."""
return {"orders": [_to_order_dict(child, parent_id=parent_order_id)]}
@@ -0,0 +1,44 @@
from __future__ import annotations
from cerbero_mcp.exchanges.ibkr.orders_complex import (
OrderSpec,
build_bracket_payload,
build_oco_payload,
)
def test_bracket_three_legs_with_oca():
payload = build_bracket_payload(
conid=42, sec_type="STK", side="BUY", qty=10,
entry_price=150.0, stop_loss=145.0, take_profit=160.0,
tif="GTC", exchange="SMART",
)
assert "orders" in payload
legs = payload["orders"]
assert len(legs) == 3
oca = legs[0].get("ocaGroup")
assert oca and all(l.get("ocaGroup") == oca for l in legs[1:])
assert legs[0]["orderType"] == "LMT"
assert legs[0]["price"] == 150.0
assert legs[0]["side"] == "BUY"
assert legs[1]["side"] == "SELL"
assert legs[2]["side"] == "SELL"
assert legs[1]["orderType"] == "STP"
assert legs[1]["auxPrice"] == 145.0
assert legs[2]["orderType"] == "LMT"
assert legs[2]["price"] == 160.0
def test_oco_oca_group_and_type():
legs = [
OrderSpec(conid=1, sec_type="STK", side="BUY", qty=1,
order_type="LMT", price=100),
OrderSpec(conid=1, sec_type="STK", side="BUY", qty=1,
order_type="LMT", price=110),
]
payload = build_oco_payload(legs)
assert len(payload["orders"]) == 2
oca = payload["orders"][0]["ocaGroup"]
for o in payload["orders"]:
assert o["ocaGroup"] == oca
assert o["ocaType"] == 1