diff --git a/src/cerbero_mcp/exchanges/ibkr/client.py b/src/cerbero_mcp/exchanges/ibkr/client.py new file mode 100644 index 0000000..27e0949 --- /dev/null +++ b/src/cerbero_mcp/exchanges/ibkr/client.py @@ -0,0 +1,112 @@ +"""IBKR Client Portal Web API client (REST httpx + OAuth1a).""" +from __future__ import annotations + +import time +from collections import OrderedDict +from dataclasses import dataclass, field +from typing import Any + +from cerbero_mcp.common.http import async_client +from cerbero_mcp.exchanges.ibkr.oauth import ( + IBKRAuthError, + OAuth1aSigner, + _percent_encode, +) + + +class IBKRError(Exception): + """Generic IBKR API error (non-auth).""" + + +_TICKLE_INTERVAL_S = 240.0 # tickle if last call > 4min ago + + +@dataclass +class IBKRClient: + signer: OAuth1aSigner + account_id: str + paper: bool = True + base_url: str = "https://api.ibkr.com/v1/api" + + _conid_cache: OrderedDict[str, int] = field( + default_factory=OrderedDict, init=False, repr=False + ) + _last_request_at: float = field(default=0.0, init=False, repr=False) + _http: Any = field(default=None, init=False, repr=False) + + _CONID_CACHE_MAX = 1024 + + def __post_init__(self) -> None: + self._http = async_client(timeout=30.0) + + async def aclose(self) -> None: + if self._http and not self._http.is_closed: + await self._http.aclose() + + async def health(self) -> dict[str, Any]: + return {"status": "ok", "paper": self.paper} + + def is_testnet(self) -> dict[str, Any]: + return {"testnet": self.paper, "base_url": self.base_url} + + async def _build_auth_header(self, method: str, url: str) -> str: + await self.signer.get_live_session_token(base_url=self.base_url) + params = self.signer.make_oauth_params() + params["oauth_signature_method"] = "HMAC-SHA256" + sig = self.signer.sign_with_lst(method, url, params) + params["oauth_signature"] = sig + return "OAuth realm=\"limited_poa\", " + ", ".join( + f'{k}="{_percent_encode(v)}"' for k, v in sorted(params.items()) + ) + + async def _maybe_tickle(self) -> None: + if time.monotonic() - self._last_request_at < _TICKLE_INTERVAL_S: + return + try: + url = f"{self.base_url}/tickle" + auth = await self._build_auth_header("POST", url) + await self._http.post(url, headers={"Authorization": auth}) + except Exception: + # Tickle is best-effort; failure shouldn't block real request + pass + + async def _request( + self, + method: str, + path: str, + *, + params: dict[str, Any] | None = None, + json_body: dict[str, Any] | None = None, + skip_tickle: bool = False, + ) -> Any: + if not skip_tickle: + await self._maybe_tickle() + url = f"{self.base_url}{path}" + auth = await self._build_auth_header(method, url) + clean_params = ( + {k: v for k, v in params.items() if v is not None} + if params else None + ) + resp = await self._http.request( + method, url, + params=clean_params or None, + json=json_body, + headers={"Authorization": auth, "User-Agent": "cerbero-mcp/2.0"}, + ) + self._last_request_at = time.monotonic() + if resp.status_code == 401: + raise IBKRAuthError(f"401 on {method} {path}: {resp.text[:200]}") + if resp.status_code == 429: + raise IBKRError(f"IBKR_RATE_LIMITED: {resp.text[:200]}") + if resp.status_code >= 500: + raise IBKRError(f"IBKR_SERVER_ERROR status={resp.status_code}") + if resp.status_code >= 400: + raise IBKRError( + f"IBKR_HTTP_{resp.status_code}: {resp.text[:300]}" + ) + if not resp.content: + return {} + return resp.json() + + async def get_account(self) -> dict: + return await self._request("GET", f"/portfolio/{self.account_id}/summary") diff --git a/src/cerbero_mcp/exchanges/ibkr/leverage_cap.py b/src/cerbero_mcp/exchanges/ibkr/leverage_cap.py new file mode 100644 index 0000000..55d0a97 --- /dev/null +++ b/src/cerbero_mcp/exchanges/ibkr/leverage_cap.py @@ -0,0 +1,57 @@ +"""Leverage cap server-side per place_order (IBKR Reg-T context). + +Cap letto dal secret JSON via campo `max_leverage`. Default 1 (cash) se assente. +IBKR margin accounts default a 4x intraday / 2x overnight (Reg-T). +""" +from __future__ import annotations + +from fastapi import HTTPException + + +def get_max_leverage(creds: dict) -> int: + """Legge max_leverage dal secret. Default 1 se mancante.""" + raw = creds.get("max_leverage", 1) + try: + value = int(raw) + except (TypeError, ValueError): + value = 1 + return max(1, value) + + +def enforce_leverage( + requested: int | float | None, + *, + creds: dict, + exchange: str, +) -> int: + """Verifica e applica leverage cap. Ritorna leverage applicabile. + + Solleva HTTPException(403, LEVERAGE_CAP_EXCEEDED) se requested > cap. + Se requested is None, applica il cap come default. + """ + cap = get_max_leverage(creds) + if requested is None: + return cap + lev = int(requested) + if lev < 1: + raise HTTPException( + status_code=403, + detail={ + "error": "LEVERAGE_CAP_EXCEEDED", + "exchange": exchange, + "requested": lev, + "max": cap, + "reason": "leverage must be >= 1", + }, + ) + if lev > cap: + raise HTTPException( + status_code=403, + detail={ + "error": "LEVERAGE_CAP_EXCEEDED", + "exchange": exchange, + "requested": lev, + "max": cap, + }, + ) + return lev diff --git a/tests/unit/exchanges/ibkr/test_client.py b/tests/unit/exchanges/ibkr/test_client.py new file mode 100644 index 0000000..f5565cb --- /dev/null +++ b/tests/unit/exchanges/ibkr/test_client.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import re +from unittest.mock import AsyncMock, MagicMock + +import pytest +from cerbero_mcp.exchanges.ibkr.client import IBKRClient +from pytest_httpx import HTTPXMock + + +@pytest.fixture +def fake_signer(): + s = MagicMock() + s.consumer_key = "CK" + s.access_token = "AT" + s.get_live_session_token = AsyncMock(return_value="LSTBASE64==") + s.sign_with_lst = MagicMock(return_value="SIG==") + s.make_oauth_params = MagicMock(return_value={ + "oauth_consumer_key": "CK", "oauth_token": "AT", + "oauth_nonce": "n", "oauth_timestamp": "1", + "oauth_signature_method": "HMAC-SHA256", "oauth_version": "1.0", + }) + return s + + +@pytest.fixture +def client(fake_signer): + return IBKRClient( + signer=fake_signer, + account_id="DU1234", + paper=True, + base_url="https://api.ibkr.com/v1/api", + ) + + +@pytest.mark.asyncio +async def test_health_no_network(client): + info = await client.health() + assert info["status"] == "ok" + assert info["paper"] is True + + +@pytest.mark.asyncio +async def test_get_account_summary(httpx_mock: HTTPXMock, client): + httpx_mock.add_response( + url=re.compile(r".*/portfolio/DU1234/summary"), + json={"netliquidation": {"amount": 10000, "currency": "USD"}, "totalcashvalue": {"amount": 8000}}, + ) + httpx_mock.add_response( + url=re.compile(r".*/tickle"), + json={"session": "abc"}, + ) + data = await client.get_account() + assert "netliquidation" in data