From 531b7b019c6cb3843c0c8676bf153adc08af2b37 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 3 May 2026 21:21:24 +0000 Subject: [PATCH] feat(V2): IBKR read tool schemas + dispatch functions Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cerbero_mcp/exchanges/ibkr/tools.py | 185 ++++++++++++++++++++++++ tests/unit/exchanges/ibkr/test_tools.py | 28 ++++ 2 files changed, 213 insertions(+) create mode 100644 src/cerbero_mcp/exchanges/ibkr/tools.py create mode 100644 tests/unit/exchanges/ibkr/test_tools.py diff --git a/src/cerbero_mcp/exchanges/ibkr/tools.py b/src/cerbero_mcp/exchanges/ibkr/tools.py new file mode 100644 index 0000000..23da0dd --- /dev/null +++ b/src/cerbero_mcp/exchanges/ibkr/tools.py @@ -0,0 +1,185 @@ +"""IBKR tool functions: Pydantic schemas + async dispatch to client/ws.""" +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel + +from cerbero_mcp.exchanges.ibkr.client import IBKRClient +from cerbero_mcp.exchanges.ibkr.leverage_cap import enforce_leverage, get_max_leverage # noqa: F401 +from cerbero_mcp.exchanges.ibkr.ws import IBKRWebSocket # noqa: F401 + +# === Schemas: reads === + +class GetAccountReq(BaseModel): + pass + +class GetPositionsReq(BaseModel): + pass + +class GetOpenOrdersReq(BaseModel): + pass + +class GetActivitiesReq(BaseModel): + days: int = 7 + +class GetTickerReq(BaseModel): + symbol: str + asset_class: str = "stocks" + +class GetBarsReq(BaseModel): + symbol: str + asset_class: str = "stocks" + period: str = "1d" + bar: str = "5min" + +class GetSnapshotReq(BaseModel): + symbol: str + asset_class: str = "stocks" + +class GetOptionChainReq(BaseModel): + underlying: str + expiry: str | None = None + +class SearchContractsReq(BaseModel): + symbol: str + sec_type: str = "STK" + +class GetClockReq(BaseModel): + pass + +# === Schemas: streaming === + +class GetTickReq(BaseModel): + symbol: str + asset_class: str = "stocks" + +class GetDepthReq(BaseModel): + symbol: str + asset_class: str = "stocks" + rows: int = 5 + exchange: str = "SMART" + +class SubscribeTickReq(BaseModel): + symbol: str + asset_class: str = "stocks" + +class UnsubscribeReq(BaseModel): + symbol: str + asset_class: str = "stocks" + +# === Schemas: writes simple === + +class PlaceOrderReq(BaseModel): + 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 + +class AmendOrderReq(BaseModel): + order_id: str + qty: float | None = None + limit_price: float | None = None + stop_price: float | None = None + tif: str | None = None + +class CancelOrderReq(BaseModel): + order_id: str + +class CancelAllOrdersReq(BaseModel): + pass + +class ClosePositionReq(BaseModel): + symbol: str + qty: float | None = None + +class CloseAllPositionsReq(BaseModel): + pass + +# === Schemas: writes complex === + +class PlaceBracketOrderReq(BaseModel): + symbol: str + side: str + qty: float + entry_price: float + stop_loss: float + take_profit: float + tif: str = "gtc" + asset_class: str = "stocks" + exchange: str = "SMART" + +class OrderLeg(BaseModel): + symbol: str + side: str + qty: float + order_type: str = "limit" + limit_price: float | None = None + stop_price: float | None = None + tif: str = "gtc" + asset_class: str = "stocks" + +class PlaceOcoOrderReq(BaseModel): + legs: list[OrderLeg] + +class PlaceOtoOrderReq(BaseModel): + trigger: OrderLeg + child: OrderLeg + +# === Read tools === + +async def environment_info( + client: IBKRClient, *, creds: dict, env_info: Any | None = None +) -> dict: + return { + "exchange": "ibkr", + "environment": "testnet" if client.paper else "mainnet", + "paper": client.paper, + "base_url": client.base_url, + "max_leverage": get_max_leverage(creds), + } + +async def get_account(client: IBKRClient, params: GetAccountReq) -> dict: + return await client.get_account() + +async def get_positions(client: IBKRClient, params: GetPositionsReq) -> dict: + return {"positions": await client.get_positions()} + +async def get_open_orders(client: IBKRClient, params: GetOpenOrdersReq) -> dict: + return {"orders": await client.get_open_orders()} + +async def get_activities(client: IBKRClient, params: GetActivitiesReq) -> dict: + return {"activities": await client.get_activities(params.days)} + +async def get_ticker(client: IBKRClient, params: GetTickerReq) -> dict: + return await client.get_ticker(params.symbol, params.asset_class) + +async def get_bars(client: IBKRClient, params: GetBarsReq) -> dict: + return await client.get_bars( + params.symbol, params.asset_class, params.period, params.bar, + ) + +async def get_snapshot(client: IBKRClient, params: GetSnapshotReq) -> dict: + return await client.get_ticker(params.symbol, params.asset_class) + +async def get_option_chain(client: IBKRClient, params: GetOptionChainReq) -> dict: + return await client.get_option_chain(params.underlying, params.expiry) + +async def search_contracts(client: IBKRClient, params: SearchContractsReq) -> dict: + return {"contracts": await client.search_contracts(params.symbol, params.sec_type)} + +async def get_clock(client: IBKRClient, params: GetClockReq) -> dict: + import datetime as _dt + now = _dt.datetime.now(_dt.UTC) + return { + "timestamp": now.isoformat(), + "is_open": _dt.time(13, 30) <= now.time() <= _dt.time(20, 0) + and now.weekday() < 5, + } diff --git a/tests/unit/exchanges/ibkr/test_tools.py b/tests/unit/exchanges/ibkr/test_tools.py new file mode 100644 index 0000000..ff42253 --- /dev/null +++ b/tests/unit/exchanges/ibkr/test_tools.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from cerbero_mcp.exchanges.ibkr import tools as t + + +def test_place_order_req_schema(): + req = t.PlaceOrderReq(symbol="AAPL", side="buy", qty=1) + assert req.order_type == "market" + assert req.tif == "day" + assert req.exchange == "SMART" + + +def test_place_order_req_options_validates_occ(): + req = t.PlaceOrderReq( + symbol="AAPL 240119C00190000", side="buy", qty=1, asset_class="options", + ) + assert req.asset_class == "options" + + +@pytest.mark.asyncio +async def test_get_account_tool_calls_client(): + client = MagicMock() + client.get_account = AsyncMock(return_value={"netliquidation": {"amount": 10000}}) + res = await t.get_account(client, t.GetAccountReq()) + assert res["netliquidation"]["amount"] == 10000