diff --git a/kalshi/async_client.py b/kalshi/async_client.py index c9a1ddf8..9ac422ec 100644 --- a/kalshi/async_client.py +++ b/kalshi/async_client.py @@ -82,6 +82,20 @@ def __init__( if config is not None: self._config = config else: + # #239: reject demo=True combined with an explicit non-demo + # base_url. The old setdefault logic silently honored base_url + # while still pinning ws_base_url to DEMO_WS_URL, producing a + # split REST/WS environment (real-money REST + demo WS feed). + if demo and base_url is not None and base_url.rstrip("/") != DEMO_BASE_URL: + raise ValueError( + "Conflicting environment: demo=True together with explicit " + f"base_url={base_url!r}. demo=True implies base_url=" + f"{DEMO_BASE_URL!r}; passing a different REST endpoint would " + "leave ws_base_url pinned to the demo WS feed, producing a " + "split REST/WS environment. Drop demo=True and pass both " + "base_url and ws_base_url via a KalshiConfig, or use " + "KalshiConfig.demo() / KalshiConfig.production()." + ) config_kwargs: dict[str, object] = {} if base_url: config_kwargs["base_url"] = base_url diff --git a/kalshi/client.py b/kalshi/client.py index 055a9122..f41076b7 100644 --- a/kalshi/client.py +++ b/kalshi/client.py @@ -84,6 +84,20 @@ def __init__( if config is not None: self._config = config else: + # #239: reject demo=True combined with an explicit non-demo + # base_url. The old setdefault logic silently honored base_url + # while still pinning ws_base_url to DEMO_WS_URL, producing a + # split REST/WS environment (real-money REST + demo WS feed). + if demo and base_url is not None and base_url.rstrip("/") != DEMO_BASE_URL: + raise ValueError( + "Conflicting environment: demo=True together with explicit " + f"base_url={base_url!r}. demo=True implies base_url=" + f"{DEMO_BASE_URL!r}; passing a different REST endpoint would " + "leave ws_base_url pinned to the demo WS feed, producing a " + "split REST/WS environment. Drop demo=True and pass both " + "base_url and ws_base_url via a KalshiConfig, or use " + "KalshiConfig.demo() / KalshiConfig.production()." + ) config_kwargs: dict[str, object] = {} if base_url: config_kwargs["base_url"] = base_url diff --git a/kalshi/config.py b/kalshi/config.py index df2b2829..3f266574 100644 --- a/kalshi/config.py +++ b/kalshi/config.py @@ -88,6 +88,27 @@ def __post_init__(self) -> None: f"KalshiConfig.base_url must include the /trade-api/v2 path " f"component, got base_url={self.base_url!r}" ) + # #239: reject split REST/WS environment. If base_url and ws_base_url + # resolve to *different* known Kalshi environments (prod vs demo), a + # caller who set demo=True alongside an explicit prod base_url would + # otherwise place live orders against signals from a demo book. + # Localhost on either side is fine (mock servers). + base_host = (urlparse(self.base_url).hostname or "").lower() + ws_host = (urlparse(self.ws_base_url).hostname or "").lower() + _prod_host = "api.elections.kalshi.com" + _demo_host = "demo-api.kalshi.co" + if (base_host == _prod_host and ws_host == _demo_host) or ( + base_host == _demo_host and ws_host == _prod_host + ): + raise ValueError( + "KalshiConfig: split REST/WS environment is not allowed. " + f"base_url={self.base_url!r} and ws_base_url={self.ws_base_url!r} " + "resolve to different Kalshi environments (production vs demo). " + "REST and WS must point at the same environment to avoid placing " + "live orders against demo-derived signals (or vice versa). Use " + "KalshiConfig.production() / KalshiConfig.demo(), or pass both " + "base_url and ws_base_url explicitly." + ) if self.http2: import importlib.util diff --git a/tests/test_async_orders.py b/tests/test_async_orders.py index 57dae8a2..e6a6c098 100644 --- a/tests/test_async_orders.py +++ b/tests/test_async_orders.py @@ -56,9 +56,9 @@ def unauth_orders_async(config: KalshiConfig) -> AsyncOrdersResource: @pytest.fixture def client(test_auth: KalshiAuth) -> AsyncKalshiClient: """AsyncKalshiClient wired to the demo base URL (matches wire-shape test mocks).""" - from kalshi.config import DEMO_BASE_URL + from kalshi.config import DEMO_BASE_URL, DEMO_WS_URL - cfg = KalshiConfig(base_url=DEMO_BASE_URL, timeout=5.0, max_retries=0) + cfg = KalshiConfig(base_url=DEMO_BASE_URL, ws_base_url=DEMO_WS_URL, timeout=5.0, max_retries=0) return AsyncKalshiClient(auth=test_auth, config=cfg) diff --git a/tests/test_client.py b/tests/test_client.py index a9704b32..ba6f93b6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -522,11 +522,12 @@ def test_base_url_override(self, test_auth: KalshiAuth) -> None: assert client._config.base_url == custom client.close() - def test_base_url_takes_precedence_over_demo(self, test_auth: KalshiAuth) -> None: + def test_demo_with_non_demo_base_url_rejected(self, test_auth: KalshiAuth) -> None: + # #239: demo=True + an explicit non-demo base_url used to silently produce + # a split REST/WS env (real-money REST + demo WS feed). Now rejected. custom = "https://custom.api.com/trade-api/v2" - client = KalshiClient(auth=test_auth, base_url=custom, demo=True) - assert client._config.base_url == custom - client.close() + with pytest.raises(ValueError, match="demo=True"): + KalshiClient(auth=test_auth, base_url=custom, demo=True) def test_default_production_url(self, test_auth: KalshiAuth) -> None: client = KalshiClient(auth=test_auth) diff --git a/tests/test_communications.py b/tests/test_communications.py index a21ab98e..f272b129 100644 --- a/tests/test_communications.py +++ b/tests/test_communications.py @@ -14,7 +14,7 @@ from kalshi.async_client import AsyncKalshiClient from kalshi.auth import KalshiAuth from kalshi.client import KalshiClient -from kalshi.config import DEMO_BASE_URL, KalshiConfig +from kalshi.config import DEMO_BASE_URL, DEMO_WS_URL, KalshiConfig from kalshi.errors import ( AuthRequiredError, KalshiNotFoundError, @@ -61,13 +61,13 @@ def async_comms( @pytest.fixture def client(test_auth: KalshiAuth) -> KalshiClient: - cfg = KalshiConfig(base_url=DEMO_BASE_URL, timeout=5.0, max_retries=0) + cfg = KalshiConfig(base_url=DEMO_BASE_URL, ws_base_url=DEMO_WS_URL, timeout=5.0, max_retries=0) return KalshiClient(auth=test_auth, config=cfg) @pytest.fixture def async_client(test_auth: KalshiAuth) -> AsyncKalshiClient: - cfg = KalshiConfig(base_url=DEMO_BASE_URL, timeout=5.0, max_retries=0) + cfg = KalshiConfig(base_url=DEMO_BASE_URL, ws_base_url=DEMO_WS_URL, timeout=5.0, max_retries=0) return AsyncKalshiClient(auth=test_auth, config=cfg) diff --git a/tests/test_config.py b/tests/test_config.py index 20fe4abe..34764ca5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -29,11 +29,18 @@ class TestTrailingSlashStripping: """F-Q-05: __post_init__ rstrips trailing slashes on base_url and ws_base_url.""" def test_trailing_slash_stripped_on_base_url(self) -> None: - config = KalshiConfig(base_url="https://demo-api.kalshi.co/trade-api/v2/") + # Pair with matching demo ws_base_url to satisfy #239 split-env check. + config = KalshiConfig( + base_url="https://demo-api.kalshi.co/trade-api/v2/", + ws_base_url=DEMO_WS_URL, + ) assert config.base_url == "https://demo-api.kalshi.co/trade-api/v2" def test_trailing_slash_stripped_on_ws_base_url(self) -> None: - config = KalshiConfig(ws_base_url="wss://demo-api.kalshi.co/trade-api/ws/v2/") + config = KalshiConfig( + base_url=DEMO_BASE_URL, + ws_base_url="wss://demo-api.kalshi.co/trade-api/ws/v2/", + ) assert config.ws_base_url == "wss://demo-api.kalshi.co/trade-api/ws/v2" def test_multiple_trailing_slashes_all_stripped(self) -> None: @@ -63,7 +70,10 @@ def test_demo_classmethod_url_has_no_trailing_slash(self) -> None: def test_trailing_slash_base_url_still_passes_validation(self) -> None: # Regression: stripping happens before _validate_url, so a known-host URL # with a trailing slash must not trip the validator. - config = KalshiConfig(base_url="https://demo-api.kalshi.co/trade-api/v2/") + config = KalshiConfig( + base_url="https://demo-api.kalshi.co/trade-api/v2/", + ws_base_url=DEMO_WS_URL, + ) assert config.base_url == DEMO_BASE_URL @@ -76,6 +86,7 @@ def test_signed_get_with_trailing_slash_base_no_double_slash( ) -> None: config = KalshiConfig( base_url="https://demo-api.kalshi.co/trade-api/v2/", + ws_base_url=DEMO_WS_URL, timeout=5.0, max_retries=0, ) @@ -103,6 +114,7 @@ class TestExtraHeadersForwarding: def test_extra_headers_forwarded_to_request(self, test_auth: KalshiAuth) -> None: config = KalshiConfig( base_url="https://demo-api.kalshi.co/trade-api/v2", + ws_base_url=DEMO_WS_URL, timeout=5.0, max_retries=0, extra_headers={ @@ -136,6 +148,7 @@ def test_extra_headers_persist_across_multiple_requests( # carries them — not just the first one. config = KalshiConfig( base_url="https://demo-api.kalshi.co/trade-api/v2", + ws_base_url=DEMO_WS_URL, timeout=5.0, max_retries=0, extra_headers={"X-Trace-Id": "trace-1"}, @@ -211,12 +224,18 @@ def test_base_url_without_trade_api_v2_raises(self) -> None: KalshiConfig(base_url="https://demo-api.kalshi.co/trade-api") def test_base_url_with_trade_api_v2_accepted(self) -> None: - config = KalshiConfig(base_url="https://demo-api.kalshi.co/trade-api/v2") + config = KalshiConfig( + base_url="https://demo-api.kalshi.co/trade-api/v2", + ws_base_url=DEMO_WS_URL, + ) assert config.base_url == "https://demo-api.kalshi.co/trade-api/v2" def test_base_url_with_trailing_slash_accepted(self) -> None: # Trailing slash is stripped before path validation. - config = KalshiConfig(base_url="https://demo-api.kalshi.co/trade-api/v2/") + config = KalshiConfig( + base_url="https://demo-api.kalshi.co/trade-api/v2/", + ws_base_url=DEMO_WS_URL, + ) assert config.base_url == "https://demo-api.kalshi.co/trade-api/v2" diff --git a/tests/test_order_groups.py b/tests/test_order_groups.py index 1c0b9c5b..73bb76b5 100644 --- a/tests/test_order_groups.py +++ b/tests/test_order_groups.py @@ -14,7 +14,7 @@ from kalshi.async_client import AsyncKalshiClient from kalshi.auth import KalshiAuth from kalshi.client import KalshiClient -from kalshi.config import DEMO_BASE_URL, KalshiConfig +from kalshi.config import DEMO_BASE_URL, DEMO_WS_URL, KalshiConfig from kalshi.errors import ( AuthRequiredError, KalshiNotFoundError, @@ -60,13 +60,13 @@ def async_order_groups( @pytest.fixture def client(test_auth: KalshiAuth) -> KalshiClient: - cfg = KalshiConfig(base_url=DEMO_BASE_URL, timeout=5.0, max_retries=0) + cfg = KalshiConfig(base_url=DEMO_BASE_URL, ws_base_url=DEMO_WS_URL, timeout=5.0, max_retries=0) return KalshiClient(auth=test_auth, config=cfg) @pytest.fixture def async_client(test_auth: KalshiAuth) -> AsyncKalshiClient: - cfg = KalshiConfig(base_url=DEMO_BASE_URL, timeout=5.0, max_retries=0) + cfg = KalshiConfig(base_url=DEMO_BASE_URL, ws_base_url=DEMO_WS_URL, timeout=5.0, max_retries=0) return AsyncKalshiClient(auth=test_auth, config=cfg) diff --git a/tests/test_orders.py b/tests/test_orders.py index 1b92f01f..a970f6c6 100644 --- a/tests/test_orders.py +++ b/tests/test_orders.py @@ -50,9 +50,9 @@ def orders(test_auth: KalshiAuth, config: KalshiConfig) -> OrdersResource: @pytest.fixture def client(test_auth: KalshiAuth) -> KalshiClient: """KalshiClient wired to the demo base URL (matches wire-shape test mocks).""" - from kalshi.config import DEMO_BASE_URL + from kalshi.config import DEMO_BASE_URL, DEMO_WS_URL - cfg = KalshiConfig(base_url=DEMO_BASE_URL, timeout=5.0, max_retries=0) + cfg = KalshiConfig(base_url=DEMO_BASE_URL, ws_base_url=DEMO_WS_URL, timeout=5.0, max_retries=0) return KalshiClient(auth=test_auth, config=cfg) diff --git a/tests/test_split_env.py b/tests/test_split_env.py new file mode 100644 index 00000000..2b972411 --- /dev/null +++ b/tests/test_split_env.py @@ -0,0 +1,125 @@ +"""Regression tests for #239: split REST/WS environment rejection. + +If ``demo=True`` was combined with an explicit production ``base_url`` (or +``KALSHI_DEMO=true`` with ``KALSHI_API_BASE_URL=``), the old +``setdefault`` logic in the client constructors silently honored ``base_url`` +while still pinning ``ws_base_url`` to the demo WS feed — REST hit production +(real money) while a WS-driven strategy made decisions against a demo book. +``KalshiConfig`` itself never cross-checked that REST and WS resolved to the +same Kalshi environment. + +These tests pin down the new contract: + +* ``KalshiConfig.__post_init__`` rejects a base_url/ws_base_url pair whose + hosts resolve to different known Kalshi environments (prod vs demo). +* ``KalshiClient`` / ``AsyncKalshiClient`` constructors reject + ``demo=True`` combined with an explicit non-demo ``base_url`` (and the + ``from_env`` env-var equivalent) before building the config. +* Localhost on either side is fine (mock servers); both-prod and both-demo + remain valid. +""" + +from __future__ import annotations + +import pytest + +from kalshi.async_client import AsyncKalshiClient +from kalshi.client import KalshiClient +from kalshi.config import ( + DEMO_BASE_URL, + DEMO_WS_URL, + PRODUCTION_BASE_URL, + PRODUCTION_WS_URL, + KalshiConfig, +) + + +class TestKalshiConfigSplitEnv: + """KalshiConfig cross-check of base_url vs ws_base_url environment.""" + + def test_prod_base_demo_ws_rejected(self) -> None: + with pytest.raises(ValueError, match="split REST/WS environment"): + KalshiConfig(base_url=PRODUCTION_BASE_URL, ws_base_url=DEMO_WS_URL) + + def test_demo_base_prod_ws_rejected(self) -> None: + with pytest.raises(ValueError, match="split REST/WS environment"): + KalshiConfig(base_url=DEMO_BASE_URL, ws_base_url=PRODUCTION_WS_URL) + + def test_both_production_ok(self) -> None: + config = KalshiConfig( + base_url=PRODUCTION_BASE_URL, ws_base_url=PRODUCTION_WS_URL + ) + assert config.base_url == PRODUCTION_BASE_URL + assert config.ws_base_url == PRODUCTION_WS_URL + + def test_both_demo_ok(self) -> None: + config = KalshiConfig(base_url=DEMO_BASE_URL, ws_base_url=DEMO_WS_URL) + assert config.base_url == DEMO_BASE_URL + assert config.ws_base_url == DEMO_WS_URL + + def test_localhost_base_demo_ws_ok(self) -> None: + # Mock REST server + real demo WS: legitimate dev setup, not a split env. + config = KalshiConfig( + base_url="http://localhost:8080/trade-api/v2", + ws_base_url=DEMO_WS_URL, + ) + assert config.ws_base_url == DEMO_WS_URL + + def test_prod_base_localhost_ws_ok(self) -> None: + # Real REST + mock WS server: also legitimate; not a known-env mismatch. + config = KalshiConfig( + base_url=PRODUCTION_BASE_URL, + ws_base_url="ws://localhost:8080/trade-api/ws/v2", + ) + assert config.base_url == PRODUCTION_BASE_URL + + +class TestKalshiClientSplitEnv: + """Sync client rejects demo=True paired with a non-demo base_url.""" + + def test_demo_true_with_prod_base_url_raises(self) -> None: + with pytest.raises(ValueError, match="demo=True"): + KalshiClient(demo=True, base_url=PRODUCTION_BASE_URL) + + def test_demo_true_with_demo_base_url_ok(self) -> None: + # Redundant but consistent — must not raise. + client = KalshiClient(demo=True, base_url=DEMO_BASE_URL) + assert client._config.base_url == DEMO_BASE_URL + assert client._config.ws_base_url == DEMO_WS_URL + client.close() + + def test_demo_false_with_demo_base_url_raises_via_config(self) -> None: + # demo=False (default) + demo REST URL leaves ws_base_url pointing at + # production. The config-level cross-check catches the resulting split. + with pytest.raises(ValueError, match="split REST/WS environment"): + KalshiClient(base_url=DEMO_BASE_URL) + + def test_from_env_demo_plus_prod_base_url_raises( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("KALSHI_KEY_ID", raising=False) + monkeypatch.delenv("KALSHI_PRIVATE_KEY", raising=False) + monkeypatch.delenv("KALSHI_PRIVATE_KEY_PATH", raising=False) + monkeypatch.setenv("KALSHI_DEMO", "true") + monkeypatch.setenv("KALSHI_API_BASE_URL", PRODUCTION_BASE_URL) + with pytest.raises(ValueError, match="demo=True"): + KalshiClient.from_env() + + +class TestAsyncKalshiClientSplitEnv: + """Async client mirrors the sync rejection.""" + + def test_demo_true_with_prod_base_url_raises(self) -> None: + with pytest.raises(ValueError, match="demo=True"): + AsyncKalshiClient(demo=True, base_url=PRODUCTION_BASE_URL) + + def test_from_env_demo_plus_prod_base_url_raises( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("KALSHI_KEY_ID", raising=False) + monkeypatch.delenv("KALSHI_PRIVATE_KEY", raising=False) + monkeypatch.delenv("KALSHI_PRIVATE_KEY_PATH", raising=False) + monkeypatch.setenv("KALSHI_DEMO", "true") + monkeypatch.setenv("KALSHI_API_BASE_URL", PRODUCTION_BASE_URL) + with pytest.raises(ValueError, match="demo=True"): + AsyncKalshiClient.from_env() diff --git a/tests/test_subaccounts.py b/tests/test_subaccounts.py index 38bacaa6..2e743aba 100644 --- a/tests/test_subaccounts.py +++ b/tests/test_subaccounts.py @@ -14,7 +14,7 @@ from kalshi.async_client import AsyncKalshiClient from kalshi.auth import KalshiAuth from kalshi.client import KalshiClient -from kalshi.config import DEMO_BASE_URL, KalshiConfig +from kalshi.config import DEMO_BASE_URL, DEMO_WS_URL, KalshiConfig from kalshi.errors import ( AuthRequiredError, KalshiServerError, @@ -59,13 +59,13 @@ def async_subaccounts( @pytest.fixture def client(test_auth: KalshiAuth) -> KalshiClient: - cfg = KalshiConfig(base_url=DEMO_BASE_URL, timeout=5.0, max_retries=0) + cfg = KalshiConfig(base_url=DEMO_BASE_URL, ws_base_url=DEMO_WS_URL, timeout=5.0, max_retries=0) return KalshiClient(auth=test_auth, config=cfg) @pytest.fixture def async_client(test_auth: KalshiAuth) -> AsyncKalshiClient: - cfg = KalshiConfig(base_url=DEMO_BASE_URL, timeout=5.0, max_retries=0) + cfg = KalshiConfig(base_url=DEMO_BASE_URL, ws_base_url=DEMO_WS_URL, timeout=5.0, max_retries=0) return AsyncKalshiClient(auth=test_auth, config=cfg)