feat(V2): IBKR write methods + auto-confirm warning flow

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-03 20:34:43 +00:00
parent ded4414b32
commit b9c58a376f
2 changed files with 200 additions and 0 deletions
+147
View File
@@ -275,3 +275,150 @@ class IBKRClient:
params={"symbol": symbol, "secType": sec_type},
)
return list(data) if isinstance(data, list) else []
# ── Order writes ────────────────────────────────────────────
_AUTO_CONFIRM_WHITELIST = (
"outside RTH", "no market data", "you are submitting", "the contract",
)
_CRITICAL_WARNINGS = (
"margin", "suitability", "credit", "rejected", "insufficient",
)
_AUTO_CONFIRM_MAX_CYCLES = 3
async def place_order(
self, *,
symbol: str, side: str, qty: float,
order_type: str = "market",
limit_price: float | None = None,
stop_price: float | None = None,
tif: str = "day",
asset_class: str = "stocks",
sec_type: str | None = None,
exchange: str = "SMART",
outside_rth: bool = False,
) -> dict:
st = sec_type or _SEC_TYPE_MAP.get(asset_class.lower(), "STK")
conid = await self.resolve_conid(symbol, st)
order: dict[str, Any] = {
"conid": conid,
"secType": f"{conid}:{st}",
"orderType": _ibkr_order_type(order_type),
"side": side.upper(),
"quantity": qty,
"tif": tif.upper(),
"outsideRTH": outside_rth,
"listingExchange": exchange,
}
if limit_price is not None:
order["price"] = limit_price
if stop_price is not None:
order["auxPrice"] = stop_price
return await self._submit_order_with_confirmation({"orders": [order]})
async def _submit_order_with_confirmation(
self, payload: dict, *, cycles: int = 0
) -> dict:
path = f"/iserver/account/{self.account_id}/orders"
result = await self._request("POST", path, json_body=payload)
return await self._handle_order_response(result, cycles)
async def _handle_order_response(
self, result: Any, cycles: int
) -> dict:
if not isinstance(result, list) or not result:
raise IBKRError(f"IBKR_ORDER_UNEXPECTED_RESPONSE: {result!r}")
first = result[0]
if "id" in first and "message" in first:
messages = first.get("message") or []
joined = " ".join(messages).lower()
if any(crit in joined for crit in self._CRITICAL_WARNINGS):
raise IBKRError(
f"IBKR_ORDER_REJECTED_WARNING: {messages}"
)
if cycles >= self._AUTO_CONFIRM_MAX_CYCLES:
raise IBKRError(
f"IBKR_ORDER_TOO_MANY_CONFIRMATIONS: {messages}"
)
reply = await self._request(
"POST", f"/iserver/reply/{first['id']}",
json_body={"confirmed": True},
)
return await self._handle_order_response(reply, cycles + 1)
if "order_id" in first:
return {"order_id": first["order_id"], "status": first.get("order_status")}
raise IBKRError(f"IBKR_ORDER_UNEXPECTED_RESPONSE: {first!r}")
async def amend_order(
self, order_id: str, *,
qty: float | None = None,
limit_price: float | None = None,
stop_price: float | None = None,
tif: str | None = None,
) -> dict:
body: dict[str, Any] = {}
if qty is not None:
body["quantity"] = qty
if limit_price is not None:
body["price"] = limit_price
if stop_price is not None:
body["auxPrice"] = stop_price
if tif is not None:
body["tif"] = tif.upper()
path = f"/iserver/account/{self.account_id}/order/{order_id}"
result = await self._request("POST", path, json_body=body)
return await self._handle_order_response(result, cycles=0)
async def cancel_order(self, order_id: str) -> dict:
path = f"/iserver/account/{self.account_id}/order/{order_id}"
await self._request("DELETE", path)
return {"order_id": order_id, "canceled": True}
async def cancel_all_orders(self) -> list[dict]:
orders = await self.get_open_orders()
results = []
for o in orders:
oid = o.get("orderId") or o.get("order_id")
if not oid:
continue
try:
results.append(await self.cancel_order(str(oid)))
except Exception as e:
results.append({"order_id": str(oid), "canceled": False, "error": str(e)})
return results
async def close_position(
self, symbol: str, qty: float | None = None
) -> dict:
positions = await self.get_positions()
target = next((p for p in positions if p.get("contractDesc") == symbol), None)
if not target:
raise IBKRError(f"IBKR_NO_POSITION: {symbol}")
position_qty = float(target.get("position", 0))
close_qty = abs(qty if qty is not None else position_qty)
side = "SELL" if position_qty > 0 else "BUY"
return await self.place_order(
symbol=symbol, side=side, qty=close_qty, order_type="market",
)
async def close_all_positions(self) -> list[dict]:
positions = await self.get_positions()
results = []
for p in positions:
sym = p.get("contractDesc")
if not sym:
continue
try:
results.append(await self.close_position(sym))
except Exception as e:
results.append({"symbol": sym, "error": str(e)})
return results
def _ibkr_order_type(t: str) -> str:
m = {"market": "MKT", "limit": "LMT", "stop": "STP", "stop_limit": "STP_LMT"}
if t.lower() not in m:
raise IBKRError(f"unsupported order_type: {t}")
return m[t.lower()]
+53
View File
@@ -152,3 +152,56 @@ async def test_resolve_conid_malformed_response_raises(httpx_mock: HTTPXMock, cl
)
with pytest.raises(IBKRError, match="malformed"):
await client.resolve_conid("BAD", "STK")
@pytest.mark.asyncio
async def test_place_order_auto_confirms_warning(httpx_mock: HTTPXMock, client):
httpx_mock.add_response(url=re.compile(r".*/tickle"), json={})
httpx_mock.add_response(
url=re.compile(r".*/trsrv/secdef/search"),
json=[{"conid": 265598, "symbol": "AAPL", "secType": "STK"}],
)
httpx_mock.add_response(
url=re.compile(r".*/iserver/account/DU1234/orders$"),
method="POST",
json=[{"id": "msgid1", "message": ["outside RTH"]}],
)
httpx_mock.add_response(
url=re.compile(r".*/iserver/reply/msgid1"),
method="POST",
json=[{"order_id": "OID42", "order_status": "Submitted"}],
)
res = await client.place_order(
symbol="AAPL", side="buy", qty=1, order_type="market",
)
assert res["order_id"] == "OID42"
@pytest.mark.asyncio
async def test_place_order_rejects_critical_warning(httpx_mock: HTTPXMock, client):
httpx_mock.add_response(url=re.compile(r".*/tickle"), json={})
httpx_mock.add_response(
url=re.compile(r".*/trsrv/secdef/search"),
json=[{"conid": 265598, "symbol": "AAPL", "secType": "STK"}],
)
httpx_mock.add_response(
url=re.compile(r".*/iserver/account/DU1234/orders$"),
method="POST",
json=[{"id": "msgid2", "message": ["Margin requirement exceeded"]}],
)
with pytest.raises(IBKRError, match="IBKR_ORDER_REJECTED_WARNING"):
await client.place_order(
symbol="AAPL", side="buy", qty=1000000, order_type="market",
)
@pytest.mark.asyncio
async def test_cancel_order(httpx_mock: HTTPXMock, client):
httpx_mock.add_response(url=re.compile(r".*/tickle"), json={})
httpx_mock.add_response(
url=re.compile(r".*/iserver/account/DU1234/order/OID42"),
method="DELETE",
json={"msg": "Request was submitted", "order_id": "OID42"},
)
res = await client.cancel_order("OID42")
assert res["canceled"] is True