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:
@@ -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()]
|
||||
|
||||
Reference in New Issue
Block a user