From 436dfd6f5a00e7cc2a1626a5c09bca6f03715304 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Thu, 30 Apr 2026 20:36:31 +0200 Subject: [PATCH] feat(V2): URL exchange configurabili da .env (DERIBIT_URL_*, BYBIT_URL_*, ecc.) --- src/cerbero_mcp/exchanges/__init__.py | 8 ++ src/cerbero_mcp/exchanges/alpaca/client.py | 16 +++- src/cerbero_mcp/exchanges/bybit/client.py | 19 ++-- src/cerbero_mcp/exchanges/deribit/client.py | 3 + .../exchanges/hyperliquid/client.py | 18 ++-- tests/unit/test_exchanges_builder.py | 90 +++++++++++++++++++ 6 files changed, 141 insertions(+), 13 deletions(-) diff --git a/src/cerbero_mcp/exchanges/__init__.py b/src/cerbero_mcp/exchanges/__init__.py index 0b5c5c6..6391959 100644 --- a/src/cerbero_mcp/exchanges/__init__.py +++ b/src/cerbero_mcp/exchanges/__init__.py @@ -14,35 +14,43 @@ async def build_client( if exchange == "deribit": from cerbero_mcp.exchanges.deribit.client import DeribitClient + url = settings.deribit.url_testnet if env == "testnet" else settings.deribit.url_live return DeribitClient( client_id=settings.deribit.client_id, client_secret=settings.deribit.client_secret.get_secret_value(), testnet=(env == "testnet"), + base_url_override=url, ) if exchange == "bybit": from cerbero_mcp.exchanges.bybit.client import BybitClient + url = settings.bybit.url_testnet if env == "testnet" else settings.bybit.url_live return BybitClient( api_key=settings.bybit.api_key, api_secret=settings.bybit.api_secret.get_secret_value(), testnet=(env == "testnet"), + base_url=url, ) if exchange == "hyperliquid": from cerbero_mcp.exchanges.hyperliquid.client import HyperliquidClient + url = settings.hyperliquid.url_testnet if env == "testnet" else settings.hyperliquid.url_live return HyperliquidClient( wallet_address=settings.hyperliquid.wallet_address, private_key=settings.hyperliquid.private_key.get_secret_value(), testnet=(env == "testnet"), api_wallet_address=settings.hyperliquid.api_wallet_address, + base_url=url, ) if exchange == "alpaca": from cerbero_mcp.exchanges.alpaca.client import AlpacaClient + url = settings.alpaca.url_testnet if env == "testnet" else settings.alpaca.url_live return AlpacaClient( api_key=settings.alpaca.api_key_id, secret_key=settings.alpaca.secret_key.get_secret_value(), paper=(env == "testnet"), + base_url=url, ) if exchange == "macro": # Read-only data provider — env ignored. Il registry diff --git a/src/cerbero_mcp/exchanges/alpaca/client.py b/src/cerbero_mcp/exchanges/alpaca/client.py index 001d5c6..5e3fb83 100644 --- a/src/cerbero_mcp/exchanges/alpaca/client.py +++ b/src/cerbero_mcp/exchanges/alpaca/client.py @@ -92,6 +92,7 @@ class AlpacaClient: api_key: str, secret_key: str, paper: bool = True, + base_url: str | None = None, trading: Any | None = None, stock_data: Any | None = None, crypto_data: Any | None = None, @@ -100,9 +101,18 @@ class AlpacaClient: self.api_key = api_key self.secret_key = secret_key self.paper = paper - self._trading = trading or TradingClient( - api_key=api_key, secret_key=secret_key, paper=paper - ) + self.base_url = base_url + # alpaca-py TradingClient accetta `url_override` per override URL trading. + # Data clients (Stock/Crypto/Option) non supportano url_override sul costruttore; + # usano endpoint dati separati (data.alpaca.markets) — `base_url` è ignorato per essi. + if trading is None: + trading_kwargs: dict[str, Any] = { + "api_key": api_key, "secret_key": secret_key, "paper": paper, + } + if base_url: + trading_kwargs["url_override"] = base_url + trading = TradingClient(**trading_kwargs) + self._trading = trading self._stock = stock_data or StockHistoricalDataClient( api_key=api_key, secret_key=secret_key ) diff --git a/src/cerbero_mcp/exchanges/bybit/client.py b/src/cerbero_mcp/exchanges/bybit/client.py index 5439b46..f6c958d 100644 --- a/src/cerbero_mcp/exchanges/bybit/client.py +++ b/src/cerbero_mcp/exchanges/bybit/client.py @@ -30,15 +30,24 @@ class BybitClient: api_secret: str, testnet: bool = True, http: Any | None = None, + base_url: str | None = None, ) -> None: self.api_key = api_key self.api_secret = api_secret self.testnet = testnet - self._http = http or HTTP( - api_key=api_key, - api_secret=api_secret, - testnet=testnet, - ) + # pybit HTTP non accetta `endpoint` come kwarg (vedi _V5HTTPManager.__init__: + # solo `domain`/`tld`/`testnet`). Override URL applicato post-init + # sovrascrivendo l'attributo `endpoint` dell'istanza HTTP. + self.base_url = base_url + if http is None: + http = HTTP( + api_key=api_key, + api_secret=api_secret, + testnet=testnet, + ) + if base_url: + http.endpoint = base_url + self._http = http async def _run(self, fn, /, **kwargs): return await asyncio.to_thread(fn, **kwargs) diff --git a/src/cerbero_mcp/exchanges/deribit/client.py b/src/cerbero_mcp/exchanges/deribit/client.py index 4dbb6b9..f335d8e 100644 --- a/src/cerbero_mcp/exchanges/deribit/client.py +++ b/src/cerbero_mcp/exchanges/deribit/client.py @@ -28,11 +28,14 @@ class DeribitClient: client_id: str client_secret: str testnet: bool = True + base_url_override: str | None = None _token: str | None = field(default=None, init=False, repr=False) _token_expires_at: float = field(default=0.0, init=False, repr=False) @property def base_url(self) -> str: + if self.base_url_override: + return self.base_url_override return BASE_TESTNET if self.testnet else BASE_LIVE # ── Auth ───────────────────────────────────────────────────── diff --git a/src/cerbero_mcp/exchanges/hyperliquid/client.py b/src/cerbero_mcp/exchanges/hyperliquid/client.py index 03d85bb..72b9df2 100644 --- a/src/cerbero_mcp/exchanges/hyperliquid/client.py +++ b/src/cerbero_mcp/exchanges/hyperliquid/client.py @@ -52,12 +52,17 @@ class HyperliquidClient: private_key: str, testnet: bool = True, api_wallet_address: str | None = None, + base_url: str | None = None, ): self.wallet_address = wallet_address self.private_key = private_key self.testnet = testnet self.api_wallet_address = api_wallet_address or wallet_address - self.base_url = BASE_TESTNET if testnet else BASE_LIVE + self._base_url_override = base_url + if base_url: + self.base_url = base_url + else: + self.base_url = BASE_TESTNET if testnet else BASE_LIVE self._exchange: Any | None = None # ── SDK exchange (lazy) ──────────────────────────────────── @@ -70,13 +75,16 @@ class HyperliquidClient: ) if self._exchange is None: account = Account.from_key(self.private_key) - base_url = ( - hl_constants.TESTNET_API_URL if self.testnet else hl_constants.MAINNET_API_URL - ) + if self._base_url_override: + sdk_base_url = self._base_url_override + else: + sdk_base_url = ( + hl_constants.TESTNET_API_URL if self.testnet else hl_constants.MAINNET_API_URL + ) empty_spot_meta: dict[str, Any] = {"universe": [], "tokens": []} self._exchange = Exchange( account, - base_url, + sdk_base_url, account_address=self.wallet_address, spot_meta=empty_spot_meta, ) diff --git a/tests/unit/test_exchanges_builder.py b/tests/unit/test_exchanges_builder.py index 9a1d19e..c53d95c 100644 --- a/tests/unit/test_exchanges_builder.py +++ b/tests/unit/test_exchanges_builder.py @@ -148,6 +148,96 @@ async def test_build_client_sentiment_no_env_distinction(monkeypatch): assert c_test.lunarcrush_key == c_live.lunarcrush_key +@pytest.mark.asyncio +async def test_deribit_url_from_env_overrides_default(monkeypatch): + """DERIBIT_URL_TESTNET custom nel .env → builder lo passa al client.""" + from tests.unit.test_settings import _minimal_env + + env = _minimal_env(DERIBIT_URL_TESTNET="https://custom-test.example.com/api/v2") + for k, v in env.items(): + monkeypatch.setenv(k, v) + + from cerbero_mcp.exchanges import build_client + from cerbero_mcp.settings import Settings + + s = Settings() + c = await build_client(s, "deribit", "testnet") + assert c.base_url == "https://custom-test.example.com/api/v2" + assert "custom-test.example.com" in c.base_url + + +@pytest.mark.asyncio +async def test_deribit_url_live_from_env_overrides_default(monkeypatch): + from tests.unit.test_settings import _minimal_env + + env = _minimal_env(DERIBIT_URL_LIVE="https://custom-live.example.com/api/v2") + for k, v in env.items(): + monkeypatch.setenv(k, v) + + from cerbero_mcp.exchanges import build_client + from cerbero_mcp.settings import Settings + + s = Settings() + c = await build_client(s, "deribit", "mainnet") + assert c.base_url == "https://custom-live.example.com/api/v2" + + +@pytest.mark.asyncio +async def test_hyperliquid_url_from_env_overrides_default(monkeypatch): + from tests.unit.test_settings import _minimal_env + + env = _minimal_env(HYPERLIQUID_URL_TESTNET="https://hl-custom.example.com") + for k, v in env.items(): + monkeypatch.setenv(k, v) + + from cerbero_mcp.exchanges import build_client + from cerbero_mcp.settings import Settings + + s = Settings() + c = await build_client(s, "hyperliquid", "testnet") + assert c.base_url == "https://hl-custom.example.com" + # SDK override deve essere disponibile per init lazy + assert c._base_url_override == "https://hl-custom.example.com" + + +@pytest.mark.asyncio +async def test_bybit_url_from_env_overrides_default(monkeypatch): + """Bybit: pybit non accetta `endpoint` come kwarg, ma setting di + `_http.endpoint` post-init rispecchia l'override.""" + from tests.unit.test_settings import _minimal_env + + env = _minimal_env(BYBIT_URL_TESTNET="https://bybit-custom.example.com") + for k, v in env.items(): + monkeypatch.setenv(k, v) + + from cerbero_mcp.exchanges import build_client + from cerbero_mcp.settings import Settings + + s = Settings() + c = await build_client(s, "bybit", "testnet") + assert c.base_url == "https://bybit-custom.example.com" + # override applicato all'istanza pybit HTTP via attributo `endpoint` + assert getattr(c._http, "endpoint", None) == "https://bybit-custom.example.com" + + +@pytest.mark.asyncio +async def test_alpaca_url_from_env_overrides_default(monkeypatch): + """Alpaca: TradingClient supporta url_override per trading API. + Data clients (Stock/Crypto/Option) non supportano override sul costruttore.""" + from tests.unit.test_settings import _minimal_env + + env = _minimal_env(ALPACA_URL_TESTNET="https://alpaca-custom.example.com") + for k, v in env.items(): + monkeypatch.setenv(k, v) + + from cerbero_mcp.exchanges import build_client + from cerbero_mcp.settings import Settings + + s = Settings() + c = await build_client(s, "alpaca", "testnet") + assert c.base_url == "https://alpaca-custom.example.com" + + @pytest.mark.asyncio async def test_build_client_unknown_exchange_raises(monkeypatch): from tests.unit.test_settings import _minimal_env