diff --git a/src/cerbero_mcp/exchanges/ibkr/orders_complex.py b/src/cerbero_mcp/exchanges/ibkr/orders_complex.py new file mode 100644 index 0000000..3ba9109 --- /dev/null +++ b/src/cerbero_mcp/exchanges/ibkr/orders_complex.py @@ -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)]} diff --git a/tests/unit/exchanges/ibkr/test_orders_complex.py b/tests/unit/exchanges/ibkr/test_orders_complex.py new file mode 100644 index 0000000..56c26f2 --- /dev/null +++ b/tests/unit/exchanges/ibkr/test_orders_complex.py @@ -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