From 4555610403ec390e45a7e5ec21b54657b6a7b4f7 Mon Sep 17 00:00:00 2001 From: audit-w2c Date: Wed, 20 May 2026 22:29:03 -0500 Subject: [PATCH] docs+polish: env-vars NameError, stale AUDIT/predecessor refs, limits default, WS perf section, cancel idempotency, events default; populate_by_name on WS models; AwareDatetime on REST - docs/environment-variables.md: switch retry example to KalshiConfig.demo(timeout=...) so the snippet runs without an extra import. - Remove stale docs/AUDIT-resource-params.md (v0.7.0-era) and drop from mkdocs not_in_nav. - docs/migration.md: drop incorrect 'predecessor referenced in project README' clause. - docs/configuration.md: limits default = None; add Performance cross-link to docs/websockets.md. - docs/websockets.md: new Performance section covering msg rates, queue sizing, overflow strategy, orderbook_delta CPU cost, orjson loader (matches w2-a ws_json_loads/ws_json_dumps), single-threaded recv impl. - docs/resources/{orders,communications,api-keys,order-groups}.md cancel/delete methods: document 404 idempotency contract with KalshiNotFoundError example. - docs/concepts.md: new 'Datetime semantics' section explaining AwareDatetime on REST and bare datetime on WS. - events.get (sync + async): default with_nested_markets=None (was False) so default calls no longer emit ?with_nested_markets=false. - All kalshi/ws/models/* (except orderbook_delta.py, W2-A's scope): add populate_by_name=True to model_config so SDK-name kwargs work. - kalshi/models/* (communications, events, exchange, historical, incentive_programs, markets, milestones, multivariate, orders, portfolio, series, structured_targets): datetime fields -> pydantic.AwareDatetime; rejects naive datetimes at construction. Closes #222 Refs #221 (P2 items 3/4; W2-A finishes items 1/2/5) Refs #224 --- docs/AUDIT-resource-params.md | 293 --------------------------- docs/concepts.md | 23 +++ docs/configuration.md | 2 +- docs/environment-variables.md | 5 +- docs/migration.md | 4 +- docs/resources/api-keys.md | 14 ++ docs/resources/communications.md | 15 ++ docs/resources/order-groups.md | 14 ++ docs/resources/orders.md | 16 ++ docs/websockets.md | 100 +++++++++ kalshi/models/communications.py | 21 +- kalshi/models/events.py | 7 +- kalshi/models/exchange.py | 18 +- kalshi/models/historical.py | 11 +- kalshi/models/incentive_programs.py | 7 +- kalshi/models/markets.py | 25 ++- kalshi/models/milestones.py | 9 +- kalshi/models/multivariate.py | 9 +- kalshi/models/orders.py | 11 +- kalshi/models/portfolio.py | 7 +- kalshi/models/series.py | 7 +- kalshi/models/structured_targets.py | 5 +- kalshi/resources/events.py | 4 +- kalshi/ws/models/base.py | 14 +- kalshi/ws/models/communications.py | 12 +- kalshi/ws/models/fill.py | 4 +- kalshi/ws/models/market_lifecycle.py | 4 +- kalshi/ws/models/market_positions.py | 4 +- kalshi/ws/models/multivariate.py | 8 +- kalshi/ws/models/order_group.py | 4 +- kalshi/ws/models/orderbook_delta.py | 8 +- kalshi/ws/models/ticker.py | 4 +- kalshi/ws/models/trade.py | 4 +- kalshi/ws/models/user_orders.py | 4 +- mkdocs.yml | 1 - tests/test_events.py | 26 +++ tests/test_models.py | 49 ++++- tests/ws/test_models.py | 149 +++++++++++++- 38 files changed, 505 insertions(+), 417 deletions(-) delete mode 100644 docs/AUDIT-resource-params.md diff --git a/docs/AUDIT-resource-params.md b/docs/AUDIT-resource-params.md deleted file mode 100644 index 6e79c21c..00000000 --- a/docs/AUDIT-resource-params.md +++ /dev/null @@ -1,293 +0,0 @@ -# Resource Method / Spec Parameter Audit (v0.7.0) - -Generated: 2026-04-16 during Session 1a (feat/resource-spec-alignment). - -This is the working audit doc for the v0.7.0 resource/spec alignment work. Each -row below represents a single decision about one parameter on one method. Session -1b applies the dispositions in code. - -## Dispositions - -- **ADD** — spec has this param, SDK doesn't. Add the kwarg. -- **REMOVE** — SDK exposes a param that isn't in the spec. Remove the kwarg. **BREAKING.** -- **RENAME** — SDK name ≠ spec name for the same logical param. Rename to spec. **BREAKING.** -- **RESERIALIZE** — wire format differs (e.g., comma-join vs explode:true). Fix serialization. May or may not be breaking depending on server tolerance. -- **EXCLUDE** — spec has it but intentionally not exposed. Reasons limited to `deprecated: true`, operator-only, paginator-handled. -- **TRANSLATED** — SDK uses a different name on purpose (bespoke translation preserved, documented). -- **OK** — spec and SDK agree. No action; row kept for completeness. - -## Methodology - -Rows produced by cross-referencing `METHOD_ENDPOINT_MAP` against `specs/openapi.yaml` -via `tests/_contract_support.py::_resolve_path_params`. Dispositions assigned per -the rules in the design doc. Inline body dicts (`amend`, `decrease`, `create`, -`batch_create`, `batch_cancel`, `create_market`, `lookup_tickers`) are **out of -scope** for this audit; covered by the separate inline-body-dict TODO. - -## Summary - -- **37 actionable rows** across 6 resources (events, exchange, multivariate have no findings). -- **2 REMOVE** (breaking): phantom kwargs `market_type` on markets.list, `settlement_status` on portfolio.positions. -- **2 RENAME** (breaking): path arg `event_ticker` → `ticker` on series.event_candlesticks and forecast_percentile_history. -- **1 QUERY RENAME** (breaking): historical.markets `ticker` → `tickers`. -- **32 ADD** (non-breaking). -- **0 RESERIALIZE** — `percentiles` explode:true already handled correctly by httpx (documented below for clarity). -- Under the 40-row split threshold → ships as a single v0.7.0. - ---- - -## markets.py - -### `MarketsResource.list` + `list_all` — `GET /markets` - -| SDK Param | Spec Param | Spec Type | Style | Disposition | Notes | -|-----------|------------|-----------|-------|-------------|-------| -| `market_type` | — | — | — | **REMOVE** | Phantom. Not in spec. Tested at `test_markets.py:63` — test also gets updated to drop this. | -| — | `min_created_ts` | int | query | **ADD** | | -| — | `max_created_ts` | int | query | **ADD** | | -| — | `min_updated_ts` | int | query | **ADD** | | -| — | `max_close_ts` | int | query | **ADD** | | -| — | `min_close_ts` | int | query | **ADD** | | -| — | `min_settled_ts` | int | query | **ADD** | | -| — | `max_settled_ts` | int | query | **ADD** | | -| — | `tickers` | string | query | **ADD** | Comma-separated list per spec description. Kwarg type `list[str] \| str \| None`, comma-join at method body (same pattern as `orders.queue_positions`). | -| — | `mve_filter` | string | query | **ADD** | Multi-value enum filter. Kwarg type `str \| None`. | -| `status` | `status` | string | query | OK | | -| `series_ticker` | `series_ticker` | string | query | OK | | -| `event_ticker` | `event_ticker` | string | query | OK | | -| `limit` | `limit` | int | query | OK | | -| `cursor` | `cursor` | string | query | OK | `list_all` omits `cursor` (paginator-handled). | - -### `MarketsResource.get` — `GET /markets/{ticker}` -All OK. Path arg `ticker` matches spec. - -### `MarketsResource.orderbook` — `GET /markets/{ticker}/orderbook` - -| SDK Param | Spec Param | Spec Type | Style | Disposition | Notes | -|-----------|------------|-----------|-------|-------------|-------| -| — | `depth` | int | query | **ADD** | Kwarg type `int \| None`. | -| `ticker` | `ticker` | string | path | OK | | - -### `MarketsResource.candlesticks` — `GET /series/{series_ticker}/markets/{ticker}/candlesticks` - -| SDK Param | Spec Param | Spec Type | Style | Disposition | Notes | -|-----------|------------|-----------|-------|-------------|-------| -| — | `include_latest_before_start` | bool | query | **ADD** | Kwarg type `bool \| None`, "true or omit" rule. | -| `series_ticker` | `series_ticker` | string | path | OK | | -| `ticker` | `ticker` | string | path | OK | | -| `start_ts` | `start_ts` | int | query | OK | | -| `end_ts` | `end_ts` | int | query | OK | | -| `period_interval` | `period_interval` | int | query | OK | | - ---- - -## events.py - -All 6 methods match spec. v0.6.0 audit already covered `EventsResource.list()` drift. - -- `list` / `list_all` — OK -- `list_multivariate` / `list_all_multivariate` — OK -- `get` — OK -- `metadata` — OK - ---- - -## exchange.py - -No parameters on any endpoint. All 3 methods OK. - ---- - -## historical.py - -### `HistoricalResource.markets` + `markets_all` — `GET /historical/markets` - -| SDK Param | Spec Param | Spec Type | Style | Disposition | Notes | -|-----------|------------|-----------|-------|-------------|-------| -| `ticker` | `tickers` | string | query | **RENAME** | **BREAKING.** Spec uses plural; comma-separated list. Kwarg becomes `tickers: list[str] \| str \| None`. Migration: `historical.markets(ticker="X")` → `historical.markets(tickers="X")` or `tickers=["X", "Y"]`. | -| — | `mve_filter` | string | query | **ADD** | | -| `limit` | `limit` | int | query | OK | | -| `cursor` | `cursor` | string | query | OK | `markets_all` omits `cursor`. | -| `event_ticker` | `event_ticker` | string | query | OK | | -| `series_ticker` | `series_ticker` | string | query | OK | | - -### `HistoricalResource.cutoff` — `GET /historical/cutoff` -No params. OK. - -### `HistoricalResource.market` — `GET /historical/markets/{ticker}` -All OK. - -### `HistoricalResource.candlesticks` — `GET /historical/markets/{ticker}/candlesticks` -All OK. - -### `HistoricalResource.fills` + `fills_all` — `GET /historical/fills` - -| SDK Param | Spec Param | Spec Type | Style | Disposition | Notes | -|-----------|------------|-----------|-------|-------------|-------| -| — | `max_ts` | int | query | **ADD** | | -| `ticker` | `ticker` | string | query | OK | | -| `limit` | `limit` | int | query | OK | | -| `cursor` | `cursor` | string | query | OK | | - -### `HistoricalResource.orders` + `orders_all` — `GET /historical/orders` - -| SDK Param | Spec Param | Spec Type | Style | Disposition | Notes | -|-----------|------------|-----------|-------|-------------|-------| -| — | `max_ts` | int | query | **ADD** | | -| all others | | | | OK | same pattern as fills | - -### `HistoricalResource.trades` + `trades_all` — `GET /historical/trades` - -| SDK Param | Spec Param | Spec Type | Style | Disposition | Notes | -|-----------|------------|-----------|-------|-------------|-------| -| — | `min_ts` | int | query | **ADD** | | -| — | `max_ts` | int | query | **ADD** | | -| all others | | | | OK | | - ---- - -## orders.py - -### `OrdersResource.cancel` — `DELETE /portfolio/orders/{order_id}` - -| SDK Param | Spec Param | Spec Type | Style | Disposition | Notes | -|-----------|------------|-----------|-------|-------------|-------| -| — | `subaccount` | int | query | **ADD** | | -| `order_id` | `order_id` | string | path | OK | | - -### `OrdersResource.list` + `list_all` — `GET /portfolio/orders` - -| SDK Param | Spec Param | Spec Type | Style | Disposition | Notes | -|-----------|------------|-----------|-------|-------------|-------| -| — | `event_ticker` | string | query | **ADD** | | -| — | `min_ts` | int | query | **ADD** | | -| — | `max_ts` | int | query | **ADD** | | -| — | `subaccount` | int | query | **ADD** | | -| `ticker` | `ticker` | string | query | OK | Manual `if ticker:` truthiness pattern currently (orders.py:80-81). `_params()` standardization refactor applies here — see design doc. | -| `status` | `status` | string | query | OK | Same truthiness issue; standardize via `_params()`. | -| `limit` | `limit` | int | query | OK | | -| `cursor` | `cursor` | string | query | OK | `list_all` omits. | - -### `OrdersResource.fills` + `fills_all` — `GET /portfolio/fills` - -| SDK Param | Spec Param | Spec Type | Style | Disposition | Notes | -|-----------|------------|-----------|-------|-------------|-------| -| — | `min_ts` | int | query | **ADD** | | -| — | `max_ts` | int | query | **ADD** | | -| — | `subaccount` | int | query | **ADD** | | -| all others | | | | OK | | - -### Other orders methods -- `create` — body audit out of scope (inline body dict TODO) -- `get` — OK -- `batch_create` / `batch_cancel` — body audit out of scope -- `amend` / `decrease` — body audit out of scope; path args OK -- `queue_positions` — OK -- `queue_position` — OK - ---- - -## portfolio.py - -### `PortfolioResource.balance` — `GET /portfolio/balance` - -| SDK Param | Spec Param | Spec Type | Style | Disposition | Notes | -|-----------|------------|-----------|-------|-------------|-------| -| — | `subaccount` | int | query | **ADD** | | - -### `PortfolioResource.positions` — `GET /portfolio/positions` - -| SDK Param | Spec Param | Spec Type | Style | Disposition | Notes | -|-----------|------------|-----------|-------|-------------|-------| -| `settlement_status` | — | — | — | **REMOVE** | Not a valid query param on `/portfolio/positions` per spec lines 1055-1090. **BREAKING.** Migration note: NO direct replacement. The spec param `count_filter` is unrelated (filters by which numeric fields are non-zero, not by settlement state — verified spec lines 2206-2221). To filter by settlement state, fetch all positions and filter client-side, OR use `/fcm/positions` if you are an FCM member. | -| — | `count_filter` | string | query | **ADD** | New filter: restricts to positions with non-zero values in the specified fields (`position`, `total_traded`), comma-separated. NOT a `settlement_status` replacement — different semantic. | -| — | `ticker` | string | query | **ADD** | | -| — | `subaccount` | int | query | **ADD** | | -| `limit` | `limit` | int | query | OK | | -| `cursor` | `cursor` | string | query | OK | | -| `event_ticker` | `event_ticker` | string | query | OK | | - -### `PortfolioResource.settlements` + `settlements_all` — `GET /portfolio/settlements` - -| SDK Param | Spec Param | Spec Type | Style | Disposition | Notes | -|-----------|------------|-----------|-------|-------------|-------| -| — | `event_ticker` | string | query | **ADD** | | -| — | `min_ts` | int | query | **ADD** | | -| — | `max_ts` | int | query | **ADD** | | -| — | `subaccount` | int | query | **ADD** | | -| all others | | | | OK | | - ---- - -## series.py - -### `SeriesResource.list` — `GET /series` -All OK. - -### `SeriesResource.get` — `GET /series/{series_ticker}` -All OK. - -### `SeriesResource.fee_changes` — `GET /series/fee_changes` -All OK. - -### `SeriesResource.event_candlesticks` — spec path `GET /series/{series_ticker}/events/{ticker}/candlesticks` - -| SDK Param | Spec Param | Spec Type | Style | Disposition | Notes | -|-----------|------------|-----------|-------|-------------|-------| -| `event_ticker` (arg name) | `ticker` | string | path | **RENAME** | **BREAKING.** Spec path template uses `{ticker}`; SDK arg is named `event_ticker`. Rename positional arg: `event_candlesticks(series_ticker, event_ticker, *, ...)` → `event_candlesticks(series_ticker, ticker, *, ...)`. URL f-string template updates too. Migration: `series.event_candlesticks("X", "Y", start_ts=...)` unchanged (positional); named `event_ticker=` callers must switch to `ticker=`. | -| `series_ticker` | `series_ticker` | string | path | OK | | -| `start_ts` / `end_ts` / `period_interval` | same | int | query | OK | | - -### `SeriesResource.forecast_percentile_history` — spec path `GET /series/{series_ticker}/events/{ticker}/forecast_percentile_history` - -| SDK Param | Spec Param | Spec Type | Style | Disposition | Notes | -|-----------|------------|-----------|-------|-------------|-------| -| `event_ticker` (arg name) | `ticker` | string | path | **RENAME** | **BREAKING.** Same rename as above. | -| `percentiles` | `percentiles` | array of int | `style: form, explode: true` | OK (documented) | Current SDK passes the list directly to `_params()`; httpx serializes as `?percentiles=25&percentiles=50` which matches `explode: true`. **Do not change to comma-join in Session 1b.** This row documents the correct serialization style per spec `openapi.yaml:1832`. | -| `series_ticker` | `series_ticker` | string | path | OK | | -| `start_ts` / `end_ts` / `period_interval` | same | int | query | OK | | - ---- - -## multivariate.py - -All 6 methods OK. `create_market` and `lookup_tickers` have body schemas which are -out of scope for this audit (inline body dict TODO). - ---- - -## Session 1b Checklist (derived from dispositions above) - -**Breaking changes (must be in CHANGELOG with migration):** -- `markets.list` / `list_all` — drop `market_type` kwarg (phantom; not in spec) -- `portfolio.positions` — drop `settlement_status` kwarg (NOT a valid `/portfolio/positions` param; no direct replacement — `count_filter` is unrelated semantics; filter client-side or use `/fcm/positions`) -- `historical.markets` / `markets_all` — rename `ticker` kwarg to `tickers` -- `series.event_candlesticks` — rename `event_ticker` positional arg to `ticker` -- `series.forecast_percentile_history` — rename `event_ticker` positional arg to `ticker` - -**Non-breaking additions:** -- `markets.list` / `list_all`: +9 kwargs (min_created_ts, max_created_ts, min_updated_ts, max_close_ts, min_close_ts, min_settled_ts, max_settled_ts, tickers, mve_filter) -- `markets.orderbook`: +`depth` -- `markets.candlesticks`: +`include_latest_before_start` -- `historical.markets` / `markets_all`: +`mve_filter` (in addition to the rename) -- `historical.fills` / `fills_all`: +`max_ts` -- `historical.orders` / `orders_all`: +`max_ts` -- `historical.trades` / `trades_all`: +`min_ts`, +`max_ts` -- `orders.cancel`: +`subaccount` -- `orders.list` / `list_all`: +`event_ticker`, +`min_ts`, +`max_ts`, +`subaccount` -- `orders.fills` / `fills_all`: +`min_ts`, +`max_ts`, +`subaccount` -- `portfolio.balance`: +`subaccount` -- `portfolio.positions`: +`count_filter`, +`ticker`, +`subaccount` (alongside settlement_status REMOVE) -- `portfolio.settlements` / `settlements_all`: +`event_ticker`, +`min_ts`, +`max_ts`, +`subaccount` - -**Refactor while touching (per design doc):** -- `orders.list` — standardize from manual dict + truthiness (`if ticker:`, `if status:`) to `_params()` helper. Fixes empty-string-drop behavior for BOTH `ticker` AND `status`. Add regression tests `test_empty_string_ticker_passes_through` AND `test_empty_string_status_passes_through`. -- `orders.list_all` — same standardization (sync + async). -- ~~`orders.create`~~ — OUT of scope. Body dicts are covered by the separate inline-body-dict TODO; `_params()` does not apply to JSON bodies. (Removed per /plan-eng-review round 2.) - -**Tests (one per method, sync + async):** -- Consolidated test per method that calls with ALL new/renamed kwargs and asserts they reach the wire (per design doc test density decision). -- Dedicated regression tests for each REMOVE and RENAME (documents the breaking change). -- Dedicated test for `percentiles` explode:true serialization (prevents accidental future regression). -- Dedicated test for `tickers` comma-join serialization on `markets.list` AND `historical.markets` (sync + async = 4 tests). Asserts wire is `?tickers=A,B` for both list and string inputs (NOT `?tickers=A&tickers=B`). Mirror to the `percentiles` explode:true test. (Added per /plan-eng-review round 2.) -- Dedicated test for `markets.candlesticks(include_latest_before_start=True)` "true or omit" bool serialization (sync + async). diff --git a/docs/concepts.md b/docs/concepts.md index 0c1c19ed..e3b95b9d 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -46,6 +46,29 @@ The Kalshi API returns prices as JSON strings with a `_dollars` suffix (`yes_bid_dollars: "0.5600"`). The SDK maps these to short field names (`yes_bid: Decimal("0.5600")`). Both directions round-trip. +## Datetime semantics + +REST response models type every server-emitted timestamp as +`pydantic.AwareDatetime` — Pydantic v2's tz-aware-only variant of +`datetime`. Kalshi's wire format is always RFC3339 with `Z` (UTC), so +live parsing is unaffected. The win is at construction time: tests and +fixtures that build a model directly **must** pass a tz-aware value, or +the model raises `ValidationError`. This catches naive `datetime.now()` +slipping into trading code, where tz arithmetic bugs would otherwise +corrupt expiration / settlement calculations. + +```python +from datetime import datetime, UTC + +Order(... created_time=datetime.now(UTC)) # OK +Order(... created_time=datetime(2026, 1, 1, tzinfo=UTC)) # OK +Order(... created_time=datetime.now()) # ValidationError — naive +``` + +WebSocket payload timestamps (`kalshi.ws.models.*`) currently keep the +bare `datetime` annotation; the AwareDatetime upgrade for WS models is a +separate, future change. + ## Cents vs dollars A few fields are **integer cents**, not dollars: diff --git a/docs/configuration.md b/docs/configuration.md index 7767ff28..be37697e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -40,7 +40,7 @@ with KalshiClient(key_id="...", private_key_path="...", config=config) as client | `ws_base_url` | `wss://api.elections.kalshi.com/trade-api/ws/v2` | WebSocket base URL. | | `ws_max_retries` | `10` | Maximum reconnect attempts before the WS gives up. | | `http2` | `False` | Enable HTTP/2 (requires `httpx[http2]` install extra). | -| `limits` | `httpx.Limits()` defaults | Connection-pool limits passed straight to `httpx`. | +| `limits` | `None` | Connection-pool limits passed to `httpx`. When `None`, the underlying `httpx.Client` uses its own pool defaults. Pass an `httpx.Limits(...)` to tune. See [Performance](websockets.md#performance) for WS sizing guidance. | See [Retries & idempotency](retries.md) for what `max_retries`, `retry_base_delay`, `retry_max_delay` do at runtime. diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 91c09349..444d390d 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -43,10 +43,9 @@ import os from kalshi import KalshiClient, KalshiConfig config = ( - KalshiConfig.demo() if os.getenv("KALSHI_DEMO", "").lower() == "true" - else KalshiConfig.production() + KalshiConfig.demo(timeout=10.0, max_retries=5) if os.getenv("KALSHI_DEMO", "").lower() == "true" + else KalshiConfig.production(timeout=10.0, max_retries=5) ) -config = dataclasses.replace(config, timeout=10.0, max_retries=5) with KalshiClient.from_env(config=config) as client: ... ``` diff --git a/docs/migration.md b/docs/migration.md index 94a4ddfe..0aa3bcce 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -329,8 +329,8 @@ hardening. ## From `kalshi_python_async` -If you're coming from `kalshi_python_async` (the predecessor community -client referenced in the project README), this page summarizes the +If you're coming from the predecessor community client `kalshi_python_async`, +this page summarizes the v1 SDK differences you'll hit. **If you don't recognize that name, you can skip this page** — there's nothing here that applies to a greenfield project on `kalshi-sdk`. diff --git a/docs/resources/api-keys.md b/docs/resources/api-keys.md index 81e9cfa4..cd378bfa 100644 --- a/docs/resources/api-keys.md +++ b/docs/resources/api-keys.md @@ -82,6 +82,20 @@ new_pem = new.private_key.get_secret_value() client.api_keys.delete("old-key-id") ``` +!!! info "Delete is not server-idempotent" + `delete(api_key)` propagates a 404 as `KalshiNotFoundError` when the + key has already been revoked or never existed. The SDK does **not** + swallow it — the caller owns safe-retry idempotency: + + ```python + from kalshi.errors import KalshiNotFoundError + + try: + client.api_keys.delete(key_id) + except KalshiNotFoundError: + pass # already revoked — idempotent + ``` + ## Reference ::: kalshi.resources.api_keys.ApiKeysResource diff --git a/docs/resources/communications.md b/docs/resources/communications.md index 791b8d83..80823e84 100644 --- a/docs/resources/communications.md +++ b/docs/resources/communications.md @@ -117,6 +117,21 @@ client.communications.create_quote( ) ``` +!!! info "Delete is not server-idempotent" + `delete_rfq(rfq_id)` and `delete_quote(quote_id)` propagate a 404 as + `KalshiNotFoundError` when the RFQ or quote is already canceled, + expired, or never existed. The SDK does **not** swallow it — the caller + owns safe-retry idempotency: + + ```python + from kalshi.errors import KalshiNotFoundError + + try: + client.communications.delete_rfq(rfq_id) + except KalshiNotFoundError: + pass # already canceled — idempotent + ``` + ## RFQ statuses `open`, `accepted`, `confirmed`, `canceled`. Status filtering accepts these diff --git a/docs/resources/order-groups.md b/docs/resources/order-groups.md index 6a4c1b32..ec7e1777 100644 --- a/docs/resources/order-groups.md +++ b/docs/resources/order-groups.md @@ -80,6 +80,20 @@ client.order_groups.create(contracts_limit=100, exchange_index=0) client.order_groups.delete("og_abc", exchange_index=0) ``` +!!! info "Delete is not server-idempotent" + `delete(order_group_id)` propagates a 404 as `KalshiNotFoundError` + when the order group is already torn down or never existed. The SDK + does **not** swallow it — the caller owns safe-retry idempotency: + + ```python + from kalshi.errors import KalshiNotFoundError + + try: + client.order_groups.delete(group_id) + except KalshiNotFoundError: + pass # already deleted — idempotent + ``` + ## Reference ::: kalshi.resources.order_groups.OrderGroupsResource diff --git a/docs/resources/orders.md b/docs/resources/orders.md index 87456c6f..03f047b1 100644 --- a/docs/resources/orders.md +++ b/docs/resources/orders.md @@ -142,6 +142,22 @@ client.orders.batch_cancel([ field is no longer emitted. If you talk to the API directly elsewhere, keep that in mind. +!!! info "Cancel is not server-idempotent" + `cancel(order_id)` (and `cancel_v2(...)`, `batch_cancel(...)`) propagate + a 404 as `KalshiNotFoundError` when the order is already canceled, fully + filled, or never existed. The SDK does **not** automatically swallow + that — the caller owns safe-retry idempotency. Treat 404 as "already + canceled" only if you can rule out a typo'd `order_id`: + + ```python + from kalshi.errors import KalshiNotFoundError + + try: + client.orders.cancel(order_id) + except KalshiNotFoundError: + pass # already canceled — idempotent + ``` + ## Amend ```python diff --git a/docs/websockets.md b/docs/websockets.md index f85dfd71..0fdc1d4a 100644 --- a/docs/websockets.md +++ b/docs/websockets.md @@ -359,6 +359,106 @@ Public channels (ticker, trade, orderbook_delta, market_lifecycle, multivariate, multivariate_lifecycle) work without auth — pass `auth=None` if you don't need private channels. +## Performance + +The WebSocket client is built for sustained automated-trading workloads, but +throughput is bounded by a few knobs you control. This section is the practical +tuning guide. + +### Typical message rates per channel + +Observed at-the-money on active election / sports markets. Treat as orders of +magnitude, not SLAs — Kalshi's wire rates vary with volatility, time-of-day, +and how busy the underlying market is. + +| Channel | Typical msg/s (active market) | Burst peak | +|---|---|---| +| `ticker` | 1–10 | 100s during pricing events | +| `trade` | 0.1–5 | 100s on bulk fills | +| `orderbook_delta` | 10–200 | low thousands during market open / news | +| `fill` / `user_orders` | 0.1–10 (your own activity) | bounded by your order rate | +| `market_lifecycle` / `multivariate_lifecycle` | < 1 | bursts on bulk settlement | +| `market_positions` | < 1 (one update per position change) | bounded by your activity | +| `order_group_updates` | < 1 per group | bounded by group size | +| `communications` | < 1 (RFQ/Quote lifecycle) | bursts during quote storms | + +### Queue sizing rule of thumb + +Each per-channel iterator is fed by a bounded `MessageQueue` of size `maxsize`. +The safe default is: + +``` +maxsize >= 2 * peak_burst_per_second +``` + +That gives a 500ms cushion before backpressure triggers — long enough to absorb +typical GIL stalls / GC pauses, short enough that a slow consumer surfaces +quickly. For `orderbook_delta` on hot markets, prefer `maxsize=10_000` and an +ERROR overflow strategy so a stuck consumer halts the loop rather than +silently corrupting book state. + +### Overflow strategy choice + +| Strategy | Use for | Why | +|---|---|---| +| `DROP_OLDEST` | Read-only / latest-wins feeds: `ticker`, `trade`, `market_lifecycle`, `multivariate*` | Newest snapshot is always correct; an evicted old tick is recoverable from the next one. | +| `ERROR` | Stateful, sequenced feeds: `orderbook_delta`, `order_group_updates`, `user_orders` | A dropped delta corrupts derived state. Surface the backpressure to the consumer rather than continuing on a corrupted book. | + +`ERROR` is fatal — the recv loop broadcasts sentinels and exits when it fires +(see [Backpressure](#backpressure)). Wire `on_error=` / `on_state_change=` to +observe. + +### `orderbook_delta` cost vs orderbook depth + +`orderbook_delta` carries the highest CPU cost of any channel: every frame +triggers Pydantic validation, dispatch routing, sequence-tracker update, and +(if you use the `orderbook()` helper) a fresh `Orderbook` snapshot allocation. +If you only need top-of-book, use `ticker` instead — it's an order of magnitude +cheaper per message. + +If you do need the full book, hold the `Orderbook` references your consumer +received rather than re-fetching depth on demand; the SDK already maintains the +book incrementally per-ticker. + +### Pluggable JSON loader + +The default loader is `json.loads`. For high-volume channels (`ticker`, +`orderbook_delta`), swap in [`orjson`](https://github.com/ijl/orjson) — typically +2–3x faster on the parse path: + +```python +import orjson +from kalshi import KalshiConfig + +config = KalshiConfig( + ws_json_loads=orjson.loads, + ws_json_dumps=orjson.dumps, +) +``` + +`orjson.dumps` returns `bytes`; the SDK passes that straight to the underlying +`websockets` client, which accepts both `bytes` and `str` payloads. Set either +loader independently; `None` (the default) falls back to the stdlib `json` +module. + +### Single-threaded recv loop + +Dispatch runs on a single asyncio task. Any blocking work in `on_error=`, +`on_state_change=`, or `@ws.on(channel)` callbacks blocks every other channel +for the duration. The rules: + +- Keep callbacks `async`-only and non-blocking. Push work to a background task + / queue if you need to do anything that takes more than ~1ms. +- Don't do synchronous I/O (network, disk, `print()` to a slow tty) in a + callback. +- Use the iterator API for heavy consumers — each iterator runs on its own + task, so a slow consumer only stalls its own queue (until `maxsize` fills, + which then triggers your overflow strategy). + +A slow `on_error` handler is the most common foot-gun: it runs inline on the +recv loop, so a 500ms blocking log call multiplies the effective error +recovery time. + ## Reference ::: kalshi.ws.client.KalshiWebSocket diff --git a/kalshi/models/communications.py b/kalshi/models/communications.py index 6db862cd..bff1cb7b 100644 --- a/kalshi/models/communications.py +++ b/kalshi/models/communications.py @@ -2,10 +2,9 @@ from __future__ import annotations -from datetime import datetime from typing import Literal -from pydantic import AliasChoices, BaseModel, Field +from pydantic import AliasChoices, AwareDatetime, BaseModel, Field from kalshi.types import DollarDecimal, FixedPointCount @@ -39,7 +38,7 @@ class RFQ(BaseModel): validation_alias=AliasChoices("contracts_fp", "contracts"), ) status: str - created_ts: datetime + created_ts: AwareDatetime target_cost: DollarDecimal | None = Field( default=None, validation_alias=AliasChoices("target_cost_dollars", "target_cost"), @@ -49,8 +48,8 @@ class RFQ(BaseModel): rest_remainder: bool | None = None cancellation_reason: str | None = None creator_user_id: str | None = None - cancelled_ts: datetime | None = None - updated_ts: datetime | None = None + cancelled_ts: AwareDatetime | None = None + updated_ts: AwareDatetime | None = None # v3.18.0 backfill (#161). creator_subaccount: int | None = None @@ -75,14 +74,14 @@ class Quote(BaseModel): validation_alias=AliasChoices("no_bid_dollars", "no_bid"), ) rfq_creator_id: str - created_ts: datetime - updated_ts: datetime + created_ts: AwareDatetime + updated_ts: AwareDatetime status: str accepted_side: Literal["yes", "no"] | None = None - accepted_ts: datetime | None = None - confirmed_ts: datetime | None = None - executed_ts: datetime | None = None - cancelled_ts: datetime | None = None + accepted_ts: AwareDatetime | None = None + confirmed_ts: AwareDatetime | None = None + executed_ts: AwareDatetime | None = None + cancelled_ts: AwareDatetime | None = None rest_remainder: bool | None = None cancellation_reason: str | None = None creator_user_id: str | None = None diff --git a/kalshi/models/events.py b/kalshi/models/events.py index 738cddc6..a353414d 100644 --- a/kalshi/models/events.py +++ b/kalshi/models/events.py @@ -2,11 +2,10 @@ from __future__ import annotations -from datetime import datetime from decimal import Decimal from typing import Any, Literal -from pydantic import BaseModel +from pydantic import AwareDatetime, BaseModel from kalshi.models.markets import Market from kalshi.types import NullableList @@ -25,14 +24,14 @@ class Event(BaseModel): collateral_return_type: str mutually_exclusive: bool category: str | None = None - strike_date: datetime | None = None + strike_date: AwareDatetime | None = None strike_period: str | None = None available_on_brokers: bool # Spec marks `product_metadata` required, but the live demo server omits # it on most events (observed run #26141405845, 2026-05-20). Recorded in # tests/_contract_support.py EXCLUSIONS as `server_omits_despite_required`. product_metadata: dict[str, Any] | None = None - last_updated_ts: datetime | None = None + last_updated_ts: AwareDatetime | None = None markets: NullableList[Market] = [] # v3.18.0 backfill (#160). fee_multiplier_override is `type: number, diff --git a/kalshi/models/exchange.py b/kalshi/models/exchange.py index aa6f41ab..5e73853c 100644 --- a/kalshi/models/exchange.py +++ b/kalshi/models/exchange.py @@ -2,9 +2,7 @@ from __future__ import annotations -from datetime import datetime - -from pydantic import BaseModel +from pydantic import AwareDatetime, BaseModel from kalshi.types import NullableList @@ -14,7 +12,7 @@ class ExchangeStatus(BaseModel): exchange_active: bool trading_active: bool - exchange_estimated_resume_time: datetime | None = None + exchange_estimated_resume_time: AwareDatetime | None = None model_config = {"extra": "allow"} @@ -31,8 +29,8 @@ class DailySchedule(BaseModel): class WeeklySchedule(BaseModel): """Weekly trading hours with per-day sessions.""" - start_time: datetime - end_time: datetime + start_time: AwareDatetime + end_time: AwareDatetime monday: NullableList[DailySchedule] tuesday: NullableList[DailySchedule] wednesday: NullableList[DailySchedule] @@ -47,8 +45,8 @@ class WeeklySchedule(BaseModel): class MaintenanceWindow(BaseModel): """A scheduled maintenance window.""" - start_datetime: datetime - end_datetime: datetime + start_datetime: AwareDatetime + end_datetime: AwareDatetime model_config = {"extra": "allow"} @@ -67,7 +65,7 @@ class Announcement(BaseModel): type: str # "info" | "warning" | "error" message: str - delivery_time: datetime + delivery_time: AwareDatetime status: str # "active" | "inactive" model_config = {"extra": "allow"} @@ -81,6 +79,6 @@ class UserDataTimestamp(BaseModel): a short delay; ``as_of_time`` is the upper bound on that lag. """ - as_of_time: datetime + as_of_time: AwareDatetime model_config = {"extra": "allow"} diff --git a/kalshi/models/historical.py b/kalshi/models/historical.py index b134e8e4..9097419c 100644 --- a/kalshi/models/historical.py +++ b/kalshi/models/historical.py @@ -2,10 +2,9 @@ from __future__ import annotations -from datetime import datetime from typing import Literal -from pydantic import AliasChoices, BaseModel, Field +from pydantic import AliasChoices, AwareDatetime, BaseModel, Field from kalshi.models.orders import BookSideLiteral, SideLiteral from kalshi.types import DollarDecimal, FixedPointCount @@ -19,9 +18,9 @@ class HistoricalCutoff(BaseModel): """Timestamps defining the boundary between live and historical data.""" - market_settled_ts: datetime - trades_created_ts: datetime - orders_updated_ts: datetime + market_settled_ts: AwareDatetime + trades_created_ts: AwareDatetime + orders_updated_ts: AwareDatetime model_config = {"extra": "allow"} @@ -41,7 +40,7 @@ class Trade(BaseModel): validation_alias=AliasChoices("no_price_dollars", "no_price"), ) taker_side: str - created_time: datetime + created_time: AwareDatetime # v3.18.0 backfill (#160). Mirrors Order.outcome_side / book_side from #159 # — canonical direction encoding superseding the deprecated `taker_side`. diff --git a/kalshi/models/incentive_programs.py b/kalshi/models/incentive_programs.py index 20edd97b..9336ecb1 100644 --- a/kalshi/models/incentive_programs.py +++ b/kalshi/models/incentive_programs.py @@ -8,10 +8,9 @@ from __future__ import annotations -from datetime import datetime from typing import Literal -from pydantic import BaseModel +from pydantic import AwareDatetime, BaseModel from kalshi.types import FixedPointCount, NullableList @@ -29,8 +28,8 @@ class IncentiveProgram(BaseModel): market_id: str market_ticker: str incentive_type: str # "liquidity" | "volume" - start_date: datetime - end_date: datetime + start_date: AwareDatetime + end_date: AwareDatetime # Spec: integer (int64), centi-cents. Caller divides by 10000 for dollars. period_reward: int paid_out: bool diff --git a/kalshi/models/markets.py b/kalshi/models/markets.py index 672d827e..8a36cb30 100644 --- a/kalshi/models/markets.py +++ b/kalshi/models/markets.py @@ -2,11 +2,10 @@ from __future__ import annotations -from datetime import datetime from decimal import Decimal from typing import Any, Literal -from pydantic import AliasChoices, BaseModel, Field +from pydantic import AliasChoices, AwareDatetime, BaseModel, Field from kalshi.types import DollarDecimal, FixedPointCount, NullableList @@ -96,15 +95,15 @@ class Market(BaseModel): ) # Timestamps - created_time: datetime - updated_time: datetime - open_time: datetime - close_time: datetime - latest_expiration_time: datetime - expected_expiration_time: datetime | None = None - expiration_time: datetime | None = None - settlement_ts: datetime | None = None - occurrence_datetime: datetime | None = None + created_time: AwareDatetime + updated_time: AwareDatetime + open_time: AwareDatetime + close_time: AwareDatetime + latest_expiration_time: AwareDatetime + expected_expiration_time: AwareDatetime | None = None + expiration_time: AwareDatetime | None = None + settlement_ts: AwareDatetime | None = None + occurrence_datetime: AwareDatetime | None = None # Metadata settlement_timer_seconds: int @@ -126,7 +125,7 @@ class Market(BaseModel): custom_strike: dict[str, Any] | None = None early_close_condition: str | None = None exchange_index: int | None = None - fee_waiver_expiration_time: datetime | None = None + fee_waiver_expiration_time: AwareDatetime | None = None functional_strike: str | None = None is_provisional: bool | None = None mve_collection_ticker: str | None = None @@ -244,7 +243,7 @@ class Candlestick(BaseModel): # Legacy flat fields for backward compat with older API responses ticker: str | None = None - period_start: datetime | None = None + period_start: AwareDatetime | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/models/milestones.py b/kalshi/models/milestones.py index e209db90..9e2a00db 100644 --- a/kalshi/models/milestones.py +++ b/kalshi/models/milestones.py @@ -9,10 +9,9 @@ from __future__ import annotations -from datetime import datetime from typing import Any -from pydantic import BaseModel +from pydantic import AwareDatetime, BaseModel from kalshi.types import NullableList @@ -23,8 +22,8 @@ class Milestone(BaseModel): id: str category: str type: str - start_date: datetime - end_date: datetime | None = None + start_date: AwareDatetime + end_date: AwareDatetime | None = None # Spec marks these required but Kalshi has historically returned JSON null # for required list fields (see v0.9.0 fix for Series.tags). NullableList # coerces None -> [] so demo/prod inconsistencies don't break parsing. @@ -35,7 +34,7 @@ class Milestone(BaseModel): source_ids: dict[str, str] | None = None details: dict[str, Any] primary_event_tickers: NullableList[str] - last_updated_ts: datetime + last_updated_ts: AwareDatetime model_config = {"extra": "allow"} diff --git a/kalshi/models/multivariate.py b/kalshi/models/multivariate.py index 9723791a..62bedc57 100644 --- a/kalshi/models/multivariate.py +++ b/kalshi/models/multivariate.py @@ -2,10 +2,9 @@ from __future__ import annotations -from datetime import datetime from typing import Literal -from pydantic import BaseModel +from pydantic import AwareDatetime, BaseModel from kalshi.models.markets import Market from kalshi.types import NullableList @@ -40,8 +39,8 @@ class MultivariateEventCollection(BaseModel): series_ticker: str title: str description: str - open_date: datetime - close_date: datetime + open_date: AwareDatetime + close_date: AwareDatetime associated_events: NullableList[AssociatedEvent] # Deprecated fields — still returned by API associated_event_tickers: NullableList[str] @@ -143,6 +142,6 @@ class LookupPoint(BaseModel): event_ticker: str market_ticker: str selected_markets: NullableList[TickerPair] - last_queried_ts: datetime + last_queried_ts: AwareDatetime model_config = {"extra": "allow"} diff --git a/kalshi/models/orders.py b/kalshi/models/orders.py index 4106d482..3f621b94 100644 --- a/kalshi/models/orders.py +++ b/kalshi/models/orders.py @@ -2,11 +2,10 @@ from __future__ import annotations -from datetime import datetime from decimal import Decimal from typing import Literal -from pydantic import AliasChoices, BaseModel, Field, field_validator, model_validator +from pydantic import AliasChoices, AwareDatetime, BaseModel, Field, field_validator, model_validator from kalshi.types import DollarDecimal, FixedPointCount @@ -85,8 +84,8 @@ class Order(BaseModel): maker_fees: DollarDecimal = Field( validation_alias=AliasChoices("maker_fees_dollars", "maker_fees"), ) - created_time: datetime | None = None - expiration_time: datetime | None = None + created_time: AwareDatetime | None = None + expiration_time: AwareDatetime | None = None client_order_id: str subaccount: int | None = None @@ -95,7 +94,7 @@ class Order(BaseModel): # for back-compat. subaccount_number is distinct from subaccount. outcome_side: SideLiteral book_side: BookSideLiteral - last_update_time: datetime | None = None + last_update_time: AwareDatetime | None = None self_trade_prevention_type: SelfTradePreventionTypeLiteral | None = None order_group_id: str | None = None cancel_order_on_pause: bool | None = None @@ -132,7 +131,7 @@ class Fill(BaseModel): fee_cost: DollarDecimal = Field( validation_alias=AliasChoices("fee_cost_dollars", "fee_cost"), ) - created_time: datetime | None = None + created_time: AwareDatetime | None = None # v3.18.0 backfill (#159). ts is Unix-ms int per spec — distinct from # the typed created_time: datetime; do NOT coerce. diff --git a/kalshi/models/portfolio.py b/kalshi/models/portfolio.py index 36b4c327..62b312cd 100644 --- a/kalshi/models/portfolio.py +++ b/kalshi/models/portfolio.py @@ -2,10 +2,9 @@ from __future__ import annotations -from datetime import datetime from typing import Literal -from pydantic import AliasChoices, BaseModel, Field +from pydantic import AliasChoices, AwareDatetime, BaseModel, Field from kalshi.types import DollarDecimal, FixedPointCount, NullableList @@ -83,7 +82,7 @@ class MarketPosition(BaseModel): fees_paid: DollarDecimal | None = Field( validation_alias=AliasChoices("fees_paid_dollars", "fees_paid"), ) - last_updated_ts: datetime + last_updated_ts: AwareDatetime model_config = {"extra": "allow", "populate_by_name": True} @@ -182,7 +181,7 @@ class Settlement(BaseModel): validation_alias=AliasChoices("no_total_cost_dollars", "no_total_cost"), ) revenue: int - settled_time: datetime + settled_time: AwareDatetime fee_cost: DollarDecimal | None = Field( validation_alias=AliasChoices("fee_cost_dollars", "fee_cost"), ) diff --git a/kalshi/models/series.py b/kalshi/models/series.py index e1a03836..90531b4b 100644 --- a/kalshi/models/series.py +++ b/kalshi/models/series.py @@ -2,10 +2,9 @@ from __future__ import annotations -from datetime import datetime from typing import Any -from pydantic import AliasChoices, BaseModel, Field +from pydantic import AliasChoices, AwareDatetime, BaseModel, Field from kalshi.models.markets import Candlestick from kalshi.types import FixedPointCount, NullableList @@ -35,7 +34,7 @@ class Series(BaseModel): default=None, validation_alias=AliasChoices("volume_fp", "volume"), ) - last_updated_ts: datetime | None = None + last_updated_ts: AwareDatetime | None = None model_config = {"extra": "allow", "populate_by_name": True} @@ -47,7 +46,7 @@ class SeriesFeeChange(BaseModel): series_ticker: str fee_type: str fee_multiplier: float - scheduled_ts: datetime + scheduled_ts: AwareDatetime model_config = {"extra": "allow"} diff --git a/kalshi/models/structured_targets.py b/kalshi/models/structured_targets.py index da1e2b71..b6333b5f 100644 --- a/kalshi/models/structured_targets.py +++ b/kalshi/models/structured_targets.py @@ -11,10 +11,9 @@ from __future__ import annotations -from datetime import datetime from typing import Any -from pydantic import AliasChoices, BaseModel, Field +from pydantic import AliasChoices, AwareDatetime, BaseModel, Field from kalshi.types import NullableList @@ -37,7 +36,7 @@ class StructuredTarget(BaseModel): details: dict[str, Any] = {} source_id: str | None = None source_ids: dict[str, str] | None = None - last_updated_ts: datetime | None = None + last_updated_ts: AwareDatetime | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/resources/events.py b/kalshi/resources/events.py index 0d231e63..5d014b81 100644 --- a/kalshi/resources/events.py +++ b/kalshi/resources/events.py @@ -152,7 +152,7 @@ def get( self, event_ticker: str, *, - with_nested_markets: bool = False, + with_nested_markets: bool | None = None, ) -> Event: params = _params( with_nested_markets=_bool_param(with_nested_markets), @@ -255,7 +255,7 @@ async def get( self, event_ticker: str, *, - with_nested_markets: bool = False, + with_nested_markets: bool | None = None, ) -> Event: params = _params( with_nested_markets=_bool_param(with_nested_markets), diff --git a/kalshi/ws/models/base.py b/kalshi/ws/models/base.py index 616c2317..45344a6f 100644 --- a/kalshi/ws/models/base.py +++ b/kalshi/ws/models/base.py @@ -8,7 +8,7 @@ class SubscriptionInfo(BaseModel): """Subscription confirmation payload.""" channel: str sid: int - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class ErrorPayload(BaseModel): @@ -17,7 +17,7 @@ class ErrorPayload(BaseModel): msg: str market_ticker: str | None = None market_id: str | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class BaseMessage(BaseModel): @@ -26,7 +26,7 @@ class BaseMessage(BaseModel): type: str sid: int | None = None seq: int | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class SubscribedMessage(BaseModel): @@ -34,7 +34,7 @@ class SubscribedMessage(BaseModel): id: int = 0 type: str = "subscribed" msg: SubscriptionInfo - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class UnsubscribedMessage(BaseModel): @@ -43,7 +43,7 @@ class UnsubscribedMessage(BaseModel): sid: int seq: int type: str = "unsubscribed" - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class OkMessage(BaseModel): @@ -53,7 +53,7 @@ class OkMessage(BaseModel): seq: int | None = None type: str = "ok" msg: dict[str, object] | list[object] | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class ErrorMessage(BaseModel): @@ -61,4 +61,4 @@ class ErrorMessage(BaseModel): id: int = 0 type: str = "error" msg: ErrorPayload - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/ws/models/communications.py b/kalshi/ws/models/communications.py index 7fd495ae..dea71786 100644 --- a/kalshi/ws/models/communications.py +++ b/kalshi/ws/models/communications.py @@ -35,7 +35,7 @@ class RfqCreatedPayload(BaseModel): # event_ticker/market_ticker/side/yes_settlement_value_dollars. mve_collection_ticker: str | None = None mve_selected_legs: list[dict[str, object]] | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class RfqDeletedPayload(BaseModel): @@ -60,7 +60,7 @@ class RfqDeletedPayload(BaseModel): default=None, validation_alias=AliasChoices("target_cost_dollars", "target_cost"), ) - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class QuoteCreatedPayload(BaseModel): @@ -93,7 +93,7 @@ class QuoteCreatedPayload(BaseModel): default=None, validation_alias=AliasChoices("rfq_target_cost_dollars", "rfq_target_cost"), ) - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class QuoteAcceptedPayload(BaseModel): @@ -130,7 +130,7 @@ class QuoteAcceptedPayload(BaseModel): default=None, validation_alias=AliasChoices("rfq_target_cost_dollars", "rfq_target_cost"), ) - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class QuoteExecutedPayload(BaseModel): @@ -144,7 +144,7 @@ class QuoteExecutedPayload(BaseModel): client_order_id: str market_ticker: str executed_ts: datetime - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class CommunicationsMessage(BaseModel): @@ -158,4 +158,4 @@ class CommunicationsMessage(BaseModel): sid: int seq: int | None = None msg: dict[str, object] - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/ws/models/fill.py b/kalshi/ws/models/fill.py index 6a3f4f33..be932e84 100644 --- a/kalshi/ws/models/fill.py +++ b/kalshi/ws/models/fill.py @@ -41,7 +41,7 @@ class FillPayload(BaseModel): outcome_side: SideLiteral book_side: BookSideLiteral ts_ms: int - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class FillMessage(BaseModel): @@ -51,4 +51,4 @@ class FillMessage(BaseModel): sid: int seq: int | None = None msg: FillPayload - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/ws/models/market_lifecycle.py b/kalshi/ws/models/market_lifecycle.py index c3f8e3d7..e9e6af0d 100644 --- a/kalshi/ws/models/market_lifecycle.py +++ b/kalshi/ws/models/market_lifecycle.py @@ -43,7 +43,7 @@ class MarketLifecyclePayload(BaseModel): floor_strike: Decimal | None = None price_level_structure: str | None = None yes_sub_title: str | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class MarketLifecycleMessage(BaseModel): @@ -53,4 +53,4 @@ class MarketLifecycleMessage(BaseModel): sid: int seq: int | None = None msg: MarketLifecyclePayload - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/ws/models/market_positions.py b/kalshi/ws/models/market_positions.py index 7a39e69b..eb0268e0 100644 --- a/kalshi/ws/models/market_positions.py +++ b/kalshi/ws/models/market_positions.py @@ -34,7 +34,7 @@ class MarketPositionsPayload(BaseModel): validation_alias=AliasChoices("volume_fp", "volume"), ) subaccount: int | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class MarketPositionsMessage(BaseModel): @@ -44,4 +44,4 @@ class MarketPositionsMessage(BaseModel): sid: int seq: int | None = None msg: MarketPositionsPayload - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/ws/models/multivariate.py b/kalshi/ws/models/multivariate.py index 6dccec27..da69ec6d 100644 --- a/kalshi/ws/models/multivariate.py +++ b/kalshi/ws/models/multivariate.py @@ -13,7 +13,7 @@ class SelectedMarket(BaseModel): event_ticker: str | None = None market_ticker: str | None = None side: str | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class MultivariatePayload(BaseModel): @@ -23,7 +23,7 @@ class MultivariatePayload(BaseModel): event_ticker: str market_ticker: str selected_markets: NullableList[SelectedMarket] - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class MultivariateMessage(BaseModel): @@ -33,7 +33,7 @@ class MultivariateMessage(BaseModel): sid: int seq: int | None = None msg: MultivariatePayload - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class MultivariateLifecycleMessage(BaseModel): @@ -43,4 +43,4 @@ class MultivariateLifecycleMessage(BaseModel): sid: int seq: int | None = None msg: MarketLifecyclePayload - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/ws/models/order_group.py b/kalshi/ws/models/order_group.py index 23d56be4..957fb161 100644 --- a/kalshi/ws/models/order_group.py +++ b/kalshi/ws/models/order_group.py @@ -15,7 +15,7 @@ class OrderGroupPayload(BaseModel): ) # _fp format # v0.14+ backfill (#162). Matching engine timestamp in Unix ms. ts_ms: int - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class OrderGroupMessage(BaseModel): @@ -25,4 +25,4 @@ class OrderGroupMessage(BaseModel): sid: int seq: int # Required — one of few channels with required seq msg: OrderGroupPayload - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/ws/models/orderbook_delta.py b/kalshi/ws/models/orderbook_delta.py index 2eb1217b..720a9033 100644 --- a/kalshi/ws/models/orderbook_delta.py +++ b/kalshi/ws/models/orderbook_delta.py @@ -27,7 +27,7 @@ class OrderbookSnapshotPayload(BaseModel): default=[], validation_alias=AliasChoices("no_dollars_fp", "no"), ) - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class OrderbookDeltaPayload(BaseModel): @@ -53,7 +53,7 @@ class OrderbookDeltaPayload(BaseModel): ts: str | None = None # v0.14+ backfill (#162). ts_ms (Unix ms) supersedes ts (RFC3339). ts_ms: int | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class OrderbookSnapshotMessage(BaseModel): @@ -62,7 +62,7 @@ class OrderbookSnapshotMessage(BaseModel): sid: int seq: int msg: OrderbookSnapshotPayload - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class OrderbookDeltaMessage(BaseModel): @@ -71,4 +71,4 @@ class OrderbookDeltaMessage(BaseModel): sid: int seq: int msg: OrderbookDeltaPayload - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/ws/models/ticker.py b/kalshi/ws/models/ticker.py index f1b83c11..3b349215 100644 --- a/kalshi/ws/models/ticker.py +++ b/kalshi/ws/models/ticker.py @@ -54,7 +54,7 @@ class TickerPayload(BaseModel): validation_alias=AliasChoices("price_dollars", "price"), ) ts_ms: int - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class TickerMessage(BaseModel): @@ -64,4 +64,4 @@ class TickerMessage(BaseModel): sid: int seq: int | None = None msg: TickerPayload - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/ws/models/trade.py b/kalshi/ws/models/trade.py index 11706d2f..97cae331 100644 --- a/kalshi/ws/models/trade.py +++ b/kalshi/ws/models/trade.py @@ -33,7 +33,7 @@ class TradePayload(BaseModel): taker_outcome_side: SideLiteral taker_book_side: BookSideLiteral ts_ms: int - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class TradeMessage(BaseModel): @@ -43,4 +43,4 @@ class TradeMessage(BaseModel): sid: int seq: int | None = None msg: TradePayload - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/ws/models/user_orders.py b/kalshi/ws/models/user_orders.py index 8a2478cb..3b2f52b5 100644 --- a/kalshi/ws/models/user_orders.py +++ b/kalshi/ws/models/user_orders.py @@ -69,7 +69,7 @@ class UserOrdersPayload(BaseModel): created_ts_ms: int last_updated_ts_ms: int | None = None expiration_ts_ms: int | None = None - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} class UserOrdersMessage(BaseModel): @@ -79,4 +79,4 @@ class UserOrdersMessage(BaseModel): sid: int seq: int | None = None msg: UserOrdersPayload - model_config = {"extra": "allow"} + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/mkdocs.yml b/mkdocs.yml index 61e0ce86..a8d1d418 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,7 +6,6 @@ repo_name: TexasCoding/kalshi-python-sdk edit_uri: edit/main/docs/ not_in_nav: | - /AUDIT-resource-params.md /RELEASING.md theme: diff --git a/tests/test_events.py b/tests/test_events.py index d1b44109..8cf8ae14 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -151,6 +151,32 @@ def test_with_nested_markets(self, events: EventsResource) -> None: assert event.markets[0].ticker == "MKT-1" assert event.markets[0].yes_bid == Decimal("0.45") + @respx.mock + def test_get_with_default_does_not_send_with_nested_markets_param( + self, events: EventsResource + ) -> None: + """Default `with_nested_markets=None` MUST NOT emit the query param (#222).""" + route = respx.get("https://test.kalshi.com/trade-api/v2/events/EVT-1").mock( + return_value=httpx.Response( + 200, + json={"event": event_dict(event_ticker="EVT-1", title="T", series_ticker="S")}, + ) + ) + events.get("EVT-1") + assert "with_nested_markets" not in route.calls[0].request.url.params + + @respx.mock + def test_get_with_explicit_false_still_serializes(self, events: EventsResource) -> None: + """Explicit `with_nested_markets=False` MUST still emit `?with_nested_markets=false`.""" + route = respx.get("https://test.kalshi.com/trade-api/v2/events/EVT-1").mock( + return_value=httpx.Response( + 200, + json={"event": event_dict(event_ticker="EVT-1", title="T", series_ticker="S")}, + ) + ) + events.get("EVT-1", with_nested_markets=False) + assert route.calls[0].request.url.params["with_nested_markets"] == "false" + @respx.mock def test_not_found(self, events: EventsResource) -> None: respx.get("https://test.kalshi.com/trade-api/v2/events/FAKE").mock( diff --git a/tests/test_models.py b/tests/test_models.py index b9e805bd..17f18a38 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime from decimal import Decimal from typing import ClassVar @@ -1299,3 +1299,50 @@ def test_forbid_extra(self) -> None: order_id="x", bogus=5, # type: ignore[call-arg] ) + + +# ---------- P2#4: AwareDatetime on REST response models ---------- + +class TestAwareDatetimeRejectsNaive: + """REST models reject naive datetimes at construction (#221 P2.4).""" + + @pytest.mark.parametrize( + ("model_cls", "ts_field", "fixture_builder"), + [ + (Order, "created_time", order_dict), + (Market, "created_time", market_dict), + (Settlement, "settled_time", settlement_dict), + ], + ids=["Order", "Market", "Settlement"], + ) + def test_aware_datetime_rejects_naive_datetime( + self, model_cls: type, ts_field: str, fixture_builder + ) -> None: + from pydantic import ValidationError + + # Override the timestamp field with a naive datetime. + naive = datetime(2026, 1, 1, 12, 0, 0) # intentional naive for the negative test + wire = fixture_builder(**{ts_field: naive}) + with pytest.raises(ValidationError): + model_cls.model_validate(wire) + + @pytest.mark.parametrize( + ("model_cls", "ts_field", "fixture_builder"), + [ + (Order, "created_time", order_dict), + (Market, "created_time", market_dict), + (Settlement, "settled_time", settlement_dict), + ], + ids=["Order", "Market", "Settlement"], + ) + def test_aware_datetime_accepts_rfc3339_with_z( + self, model_cls: type, ts_field: str, fixture_builder + ) -> None: + """Wire format (RFC3339 with Z suffix) still parses cleanly.""" + wire = fixture_builder(**{ts_field: "2026-01-01T12:00:00Z"}) + obj = model_cls.model_validate(wire) + ts = getattr(obj, ts_field) + assert ts is not None + assert ts.tzinfo is not None + # UTC conversion round-trips cleanly. + assert ts.astimezone(UTC).isoformat().startswith("2026-01-01T12:00:00") diff --git a/tests/ws/test_models.py b/tests/ws/test_models.py index 6e564b3f..77f6b4e8 100644 --- a/tests/ws/test_models.py +++ b/tests/ws/test_models.py @@ -25,18 +25,18 @@ ) from kalshi.ws.models.fill import FillMessage, FillPayload from kalshi.ws.models.market_lifecycle import MarketLifecycleMessage -from kalshi.ws.models.market_positions import MarketPositionsMessage +from kalshi.ws.models.market_positions import MarketPositionsMessage, MarketPositionsPayload from kalshi.ws.models.multivariate import ( MultivariateLifecycleMessage, MultivariateMessage, ) -from kalshi.ws.models.order_group import OrderGroupMessage +from kalshi.ws.models.order_group import OrderGroupMessage, OrderGroupPayload from kalshi.ws.models.orderbook_delta import ( OrderbookDeltaMessage, OrderbookSnapshotMessage, ) -from kalshi.ws.models.ticker import TickerMessage -from kalshi.ws.models.trade import TradeMessage +from kalshi.ws.models.ticker import TickerMessage, TickerPayload +from kalshi.ws.models.trade import TradeMessage, TradePayload from kalshi.ws.models.user_orders import UserOrdersMessage, UserOrdersPayload from tests._model_fixtures import ( fill_payload_dict, @@ -1189,3 +1189,144 @@ def test_orderbook_delta_payload_side_rejects_trailing_whitespace(self) -> None: "side": "yes ", } ) +# ---------- P2#3: populate_by_name across every WS payload model ---------- + +_SDK_NAME_PAYLOADS: list[tuple[type, dict[str, object]]] = [ + ( + TickerPayload, + { + "market_ticker": "MKT-A", + "market_id": "mid-1", + "yes_bid": Decimal("0.50"), + "yes_ask": Decimal("0.51"), + "volume": Decimal("100.00"), + "open_interest": Decimal("200.00"), + "dollar_volume": "50.00", + "dollar_open_interest": "100.00", + "yes_bid_size": Decimal("10.00"), + "yes_ask_size": Decimal("10.00"), + "last_trade_size": Decimal("1.00"), + "ts": 1_700_000_000, + "price": Decimal("0.5050"), + "ts_ms": 1_700_000_000_000, + }, + ), + ( + FillPayload, + { + "trade_id": "t1", + "order_id": "o1", + "market_ticker": "MKT-A", + "is_taker": True, + "side": "yes", + "yes_price": Decimal("0.50"), + "count": Decimal("1.00"), + "fee_cost": Decimal("0.01"), + "action": "buy", + "ts": 1_700_000_000, + "post_position": Decimal("1.00"), + "purchased_side": "yes", + "outcome_side": "yes", + "book_side": "bid", + "ts_ms": 1_700_000_000_000, + }, + ), + ( + TradePayload, + { + "trade_id": "t1", + "market_ticker": "MKT-A", + "yes_price": Decimal("0.50"), + "no_price": Decimal("0.50"), + "count": Decimal("1.00"), + "taker_side": "yes", + "ts": 1_700_000_000, + "taker_outcome_side": "yes", + "taker_book_side": "bid", + "ts_ms": 1_700_000_000_000, + }, + ), + ( + UserOrdersPayload, + { + "order_id": "o1", + "user_id": "u1", + "ticker": "MKT-A", + "status": "resting", + "side": "yes", + "is_yes": True, + "yes_price": Decimal("0.50"), + "fill_count": Decimal("0.00"), + "remaining_count": Decimal("10.00"), + "initial_count": Decimal("10.00"), + "taker_fill_cost": Decimal("0.00"), + "maker_fill_cost": Decimal("0.00"), + "taker_fees": Decimal("0.00"), + "maker_fees": Decimal("0.00"), + "client_order_id": "cid-1", + "created_time": datetime(2026, 1, 1, tzinfo=UTC), + "outcome_side": "yes", + "book_side": "bid", + "created_ts_ms": 1_700_000_000_000, + }, + ), + ( + MarketPositionsPayload, + { + "user_id": "u1", + "market_ticker": "MKT-A", + "position": Decimal("1.00"), + "position_cost": Decimal("0.50"), + "realized_pnl": Decimal("0.00"), + "fees_paid": Decimal("0.00"), + "position_fee_cost": Decimal("0.00"), + "volume": Decimal("1.00"), + }, + ), + ( + OrderGroupPayload, + { + "event_type": "created", + "order_group_id": "og-1", + "contracts_limit": "10.00", + "ts_ms": 1_700_000_000_000, + }, + ), + ( + RfqCreatedPayload, + { + "id": "rfq-1", + "creator_id": "u1", + "market_ticker": "MKT-A", + "created_ts": datetime(2026, 1, 1, tzinfo=UTC), + "contracts": Decimal("100.00"), + "target_cost": Decimal("50.00"), + }, + ), + ( + QuoteCreatedPayload, + { + "quote_id": "q-1", + "rfq_id": "rfq-1", + "quote_creator_id": "u2", + "market_ticker": "MKT-A", + "yes_bid": Decimal("0.50"), + "no_bid": Decimal("0.50"), + "created_ts": datetime(2026, 1, 1, tzinfo=UTC), + }, + ), +] + + +@pytest.mark.parametrize( + ("payload_cls", "kwargs"), + _SDK_NAME_PAYLOADS, + ids=lambda v: v.__name__ if isinstance(v, type) else "kwargs", +) +def test_ws_payload_constructable_with_sdk_field_names( + payload_cls: type, kwargs: dict[str, object] +) -> None: + """Every WS payload model accepts SDK (short) field names — populate_by_name=True.""" + payload = payload_cls(**kwargs) + for key, value in kwargs.items(): + assert getattr(payload, key) == value, key