Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions kalshi/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions kalshi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions kalshi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions tests/test_async_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
9 changes: 5 additions & 4 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions tests/test_communications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)


Expand Down
29 changes: 24 additions & 5 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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


Expand All @@ -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,
)
Expand Down Expand Up @@ -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={
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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"


Expand Down
6 changes: 3 additions & 3 deletions tests/test_order_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)


Expand Down
4 changes: 2 additions & 2 deletions tests/test_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
125 changes: 125 additions & 0 deletions tests/test_split_env.py
Original file line number Diff line number Diff line change
@@ -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=<prod>``), 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()
6 changes: 3 additions & 3 deletions tests/test_subaccounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)


Expand Down
Loading