From bea37fd7343e94e4cd433c773b05e0158ad0cb49 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 3 May 2026 21:35:28 +0000 Subject: [PATCH] feat(V2): IBKR router wiring + build_client + WS singleton DI Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cerbero_mcp/__main__.py | 2 + src/cerbero_mcp/exchanges/__init__.py | 20 +++ src/cerbero_mcp/routers/ibkr.py | 248 ++++++++++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 src/cerbero_mcp/routers/ibkr.py diff --git a/src/cerbero_mcp/__main__.py b/src/cerbero_mcp/__main__.py index 709b081..576dc31 100644 --- a/src/cerbero_mcp/__main__.py +++ b/src/cerbero_mcp/__main__.py @@ -24,6 +24,7 @@ from cerbero_mcp.routers import ( bybit, deribit, hyperliquid, + ibkr, macro, sentiment, ) @@ -61,6 +62,7 @@ def _make_app(settings: Settings) -> FastAPI: app.include_router(bybit.make_router()) app.include_router(hyperliquid.make_router()) app.include_router(alpaca.make_router()) + app.include_router(ibkr.make_router()) app.include_router(macro.make_router()) app.include_router(sentiment.make_router()) app.include_router(admin.make_admin_router()) diff --git a/src/cerbero_mcp/exchanges/__init__.py b/src/cerbero_mcp/exchanges/__init__.py index f1e9472..616a33d 100644 --- a/src/cerbero_mcp/exchanges/__init__.py +++ b/src/cerbero_mcp/exchanges/__init__.py @@ -72,4 +72,24 @@ async def build_client( cryptopanic_key=settings.sentiment.cryptopanic_key.get_secret_value(), lunarcrush_key=settings.sentiment.lunarcrush_key.get_secret_value(), ) + if exchange == "ibkr": + from cerbero_mcp.exchanges.ibkr.client import IBKRClient + from cerbero_mcp.exchanges.ibkr.oauth import OAuth1aSigner + + creds = settings.ibkr.credentials(env) + url = settings.ibkr.url_testnet if env == "testnet" else settings.ibkr.url_live + signer = OAuth1aSigner( + consumer_key=creds["consumer_key"], + access_token=creds["access_token"], + access_token_secret=creds["access_token_secret"], + signature_key_path=creds["signature_key_path"], + encryption_key_path=creds["encryption_key_path"], + dh_prime=creds["dh_prime"], + ) + return IBKRClient( + signer=signer, + account_id=creds["account_id"], + paper=(env == "testnet"), + base_url=url, + ) raise ValueError(f"unsupported exchange: {exchange}") diff --git a/src/cerbero_mcp/routers/ibkr.py b/src/cerbero_mcp/routers/ibkr.py new file mode 100644 index 0000000..d68a6fc --- /dev/null +++ b/src/cerbero_mcp/routers/ibkr.py @@ -0,0 +1,248 @@ +"""Router /mcp-ibkr/* — DI per env, client e (write) creds.""" +from __future__ import annotations + +from typing import Literal, cast + +from fastapi import APIRouter, Depends, Request + +from cerbero_mcp.client_registry import ClientRegistry +from cerbero_mcp.common.audit_helpers import audit_call +from cerbero_mcp.exchanges.ibkr import tools as t +from cerbero_mcp.exchanges.ibkr.client import IBKRClient +from cerbero_mcp.exchanges.ibkr.ws import IBKRWebSocket + +Environment = Literal["testnet", "mainnet"] + + +def get_environment(request: Request) -> Environment: + return cast(Environment, request.state.environment) + + +async def get_ibkr_client( + request: Request, env: Environment = Depends(get_environment), +) -> IBKRClient: + registry: ClientRegistry = request.app.state.registry + return cast(IBKRClient, await registry.get("ibkr", env)) + + +async def get_ibkr_ws( + request: Request, env: Environment = Depends(get_environment), +) -> IBKRWebSocket: + """Lazy-create singleton WS per env on first streaming call.""" + ws_dict = getattr(request.app.state, "ibkr_ws", None) + if ws_dict is None: + ws_dict = {} + request.app.state.ibkr_ws = ws_dict + if env not in ws_dict: + client = await get_ibkr_client(request, env) + settings = request.app.state.settings + ws_url = ( + settings.ibkr.ws_url_testnet if env == "testnet" + else settings.ibkr.ws_url_live + ) + ws = IBKRWebSocket( + signer=client.signer, + ws_url=ws_url, + base_url=client.base_url, + max_subs=settings.ibkr.ws_max_subscriptions, + idle_timeout_s=settings.ibkr.ws_idle_timeout_s, + ) + await ws.start() + ws_dict[env] = ws + return ws_dict[env] + + +def _build_creds(request: Request) -> dict: + settings = request.app.state.settings + return {"max_leverage": settings.ibkr.max_leverage} + + +def make_router() -> APIRouter: + r = APIRouter(prefix="/mcp-ibkr", tags=["ibkr"]) + + # === READ tools === + + @r.post("/tools/environment_info") + async def _ei(request: Request, client: IBKRClient = Depends(get_ibkr_client)): + return await t.environment_info(client, creds=_build_creds(request)) + + @r.post("/tools/get_account") + async def _ga(params: t.GetAccountReq, client: IBKRClient = Depends(get_ibkr_client)): + return await t.get_account(client, params) + + @r.post("/tools/get_positions") + async def _gp(params: t.GetPositionsReq, client: IBKRClient = Depends(get_ibkr_client)): + return await t.get_positions(client, params) + + @r.post("/tools/get_open_orders") + async def _goo(params: t.GetOpenOrdersReq, client: IBKRClient = Depends(get_ibkr_client)): + return await t.get_open_orders(client, params) + + @r.post("/tools/get_activities") + async def _gact(params: t.GetActivitiesReq, client: IBKRClient = Depends(get_ibkr_client)): + return await t.get_activities(client, params) + + @r.post("/tools/get_ticker") + async def _gt(params: t.GetTickerReq, client: IBKRClient = Depends(get_ibkr_client)): + return await t.get_ticker(client, params) + + @r.post("/tools/get_bars") + async def _gb(params: t.GetBarsReq, client: IBKRClient = Depends(get_ibkr_client)): + return await t.get_bars(client, params) + + @r.post("/tools/get_snapshot") + async def _gs(params: t.GetSnapshotReq, client: IBKRClient = Depends(get_ibkr_client)): + return await t.get_snapshot(client, params) + + @r.post("/tools/get_option_chain") + async def _goc(params: t.GetOptionChainReq, client: IBKRClient = Depends(get_ibkr_client)): + return await t.get_option_chain(client, params) + + @r.post("/tools/search_contracts") + async def _sc(params: t.SearchContractsReq, client: IBKRClient = Depends(get_ibkr_client)): + return await t.search_contracts(client, params) + + @r.post("/tools/get_clock") + async def _gc(params: t.GetClockReq, client: IBKRClient = Depends(get_ibkr_client)): + return await t.get_clock(client, params) + + # === STREAMING tools === + + @r.post("/tools/get_tick") + async def _gtk( + params: t.GetTickReq, + client: IBKRClient = Depends(get_ibkr_client), + ws: IBKRWebSocket = Depends(get_ibkr_ws), + ): + return await t.get_tick(client, params, ws=ws) + + @r.post("/tools/get_depth") + async def _gd( + params: t.GetDepthReq, + client: IBKRClient = Depends(get_ibkr_client), + ws: IBKRWebSocket = Depends(get_ibkr_ws), + ): + return await t.get_depth(client, params, ws=ws) + + @r.post("/tools/subscribe_tick") + async def _st( + params: t.SubscribeTickReq, + client: IBKRClient = Depends(get_ibkr_client), + ws: IBKRWebSocket = Depends(get_ibkr_ws), + ): + return await t.subscribe_tick(client, params, ws=ws) + + @r.post("/tools/unsubscribe") + async def _us( + params: t.UnsubscribeReq, + client: IBKRClient = Depends(get_ibkr_client), + ws: IBKRWebSocket = Depends(get_ibkr_ws), + ): + return await t.unsubscribe(client, params, ws=ws) + + # === WRITE simple === + + @r.post("/tools/place_order") + async def _po( + params: t.PlaceOrderReq, request: Request, + client: IBKRClient = Depends(get_ibkr_client), + ): + creds = _build_creds(request) + return await audit_call( + request=request, exchange="ibkr", action="place_order", + target_field="symbol", params=params, + tool_fn=lambda: t.place_order(client, params, creds=creds), + ) + + @r.post("/tools/amend_order") + async def _ao( + params: t.AmendOrderReq, request: Request, + client: IBKRClient = Depends(get_ibkr_client), + ): + return await audit_call( + request=request, exchange="ibkr", action="amend_order", + target_field="order_id", params=params, + tool_fn=lambda: t.amend_order(client, params), + ) + + @r.post("/tools/cancel_order") + async def _co( + params: t.CancelOrderReq, request: Request, + client: IBKRClient = Depends(get_ibkr_client), + ): + return await audit_call( + request=request, exchange="ibkr", action="cancel_order", + target_field="order_id", params=params, + tool_fn=lambda: t.cancel_order(client, params), + ) + + @r.post("/tools/cancel_all_orders") + async def _cao( + params: t.CancelAllOrdersReq, request: Request, + client: IBKRClient = Depends(get_ibkr_client), + ): + return await audit_call( + request=request, exchange="ibkr", action="cancel_all_orders", + params=params, tool_fn=lambda: t.cancel_all_orders(client, params), + ) + + @r.post("/tools/close_position") + async def _cp( + params: t.ClosePositionReq, request: Request, + client: IBKRClient = Depends(get_ibkr_client), + ): + return await audit_call( + request=request, exchange="ibkr", action="close_position", + target_field="symbol", params=params, + tool_fn=lambda: t.close_position(client, params), + ) + + @r.post("/tools/close_all_positions") + async def _cap( + params: t.CloseAllPositionsReq, request: Request, + client: IBKRClient = Depends(get_ibkr_client), + ): + return await audit_call( + request=request, exchange="ibkr", action="close_all_positions", + params=params, tool_fn=lambda: t.close_all_positions(client, params), + ) + + # === WRITE complex === + + @r.post("/tools/place_bracket_order") + async def _pbo( + params: t.PlaceBracketOrderReq, request: Request, + client: IBKRClient = Depends(get_ibkr_client), + ): + creds = _build_creds(request) + return await audit_call( + request=request, exchange="ibkr", action="place_bracket_order", + target_field="symbol", params=params, + tool_fn=lambda: t.place_bracket_order(client, params, creds=creds), + ) + + @r.post("/tools/place_oco_order") + async def _poco( + params: t.PlaceOcoOrderReq, request: Request, + client: IBKRClient = Depends(get_ibkr_client), + ): + creds = _build_creds(request) + return await audit_call( + request=request, exchange="ibkr", action="place_oco_order", + params=params, + tool_fn=lambda: t.place_oco_order(client, params, creds=creds), + ) + + @r.post("/tools/place_oto_order") + async def _poto( + params: t.PlaceOtoOrderReq, request: Request, + client: IBKRClient = Depends(get_ibkr_client), + ): + creds = _build_creds(request) + return await audit_call( + request=request, exchange="ibkr", action="place_oto_order", + params=params, + tool_fn=lambda: t.place_oto_order(client, params, creds=creds), + ) + + return r