diff --git a/CHANGELOG.md b/CHANGELOG.md index 2329e1b..851dcc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ All notable changes to kalshi-sdk will be documented in this file. ## Unreleased +### Pre-release docs audit (#179) + +Release-prep sweep across all doc surfaces — README, mkdocs site, ROADMAP, +per-resource pages, and public-API docstrings. Findings compiled from a +six-way parallel audit of disjoint file partitions, then triaged: + +- **`ROADMAP.md`** — "Open trackers" section dropped; `#45`, `#53` are + closed and `#106` was a PR (not an issue) whose remaining sub-items all + shipped in this batch. "Next milestone" carry-overs that landed + (`MessageQueue` `maxlen`, `_coerce_decimal`, WS UX foot-guns, + CONTRACT_MAP completeness via `#181`) removed. Closes `#179`. +- **`docs/resources/multivariate.md`** — fixed wrong endpoint path on + `lookup_history` (was `/lookup_history`, actual is `/lookup` with a + `lookback_seconds` query param) and a broken example that called + `hist.lookups` on a list return. +- **`docs/index.md`** — REST coverage updated from "85 endpoints" to "98 + operations" against current spec; sync/async parity claim explicitly + notes WebSocket is async-only. +- **`docs/websockets.md`** — new "Resubscribe-window frame stashing" + subsection documenting the `#176` mechanism, `stash_maxlen` bound, and + overflow logging. +- **`docs/authentication.md`** — new "Async RSA-PSS sign offload" + subsection documenting `KalshiAuth.sign_request_async()` (`#178`) plus + the dedicated `ThreadPoolExecutor` lifecycle. +- **`docs/configuration.md`** — new "Lifecycle" section documenting + `client.close()` semantics and cross-linking the sign-executor teardown. +- **`docs/resources/events.md`** — note documenting `Event.product_metadata` + and `EventMetadata.market_details` server-omission handling from `#183`. +- **`README.md`** — WS quickstart uses the package-level import + (`from kalshi.ws import KalshiWebSocket`) instead of the deeper + `kalshi.ws.client`; channel list clarifies that 11 of the 13 channels + have dedicated `subscribe_*` methods and the remaining two ride the + generic `subscribe()` escape hatch. + ### WS resubscribe-window frame stashing (#176) Fixes silent message loss during reconnect bursts on high-volume channels. diff --git a/CLAUDE.md b/CLAUDE.md index 2286d35..d337511 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,7 @@ tests/ ## API Reference -- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.18.0, 85 endpoints) +- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.18.0, 98 operations) - AsyncAPI spec: https://docs.kalshi.com/asyncapi.yaml (13 WebSocket channels) - Base URL: https://api.elections.kalshi.com/trade-api/v2 - Demo URL: https://demo-api.kalshi.co/trade-api/v2 diff --git a/README.md b/README.md index 237954b..58f7147 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) predi [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Type checked: mypy strict](https://img.shields.io/badge/mypy-strict-blue.svg)](https://mypy.readthedocs.io/) -- **Full coverage** of the Kalshi REST API (85 endpoints across 19 resources, OpenAPI v3.18.0) and WebSocket API (13 channels). +- **Full coverage** of the Kalshi REST API (98 operations across 19 resources, OpenAPI v3.18.0) and WebSocket API (13 channels). - **V2 event-market orders**: `create_v2` / `amend_v2` / `decrease_v2` / `cancel_v2` plus batched variants on `/portfolio/events/orders/*`. Legacy `/portfolio/orders` keeps working — deprecated no earlier than May 6, 2026. - **Funding & cost introspection**: `portfolio.deposits()`, `portfolio.withdrawals()`, `account.endpoint_costs()`. - **Sync and async** clients sharing one transport — no thread-pool wrapping. @@ -176,7 +176,7 @@ request model. See [V2 orders docs](https://texascoding.github.io/kalshi-python- ```python import asyncio from kalshi import KalshiAuth, KalshiConfig -from kalshi.ws.client import KalshiWebSocket +from kalshi.ws import KalshiWebSocket async def main() -> None: auth = KalshiAuth.from_key_path("your-key-id", "~/.kalshi/private_key.pem") @@ -191,10 +191,12 @@ async def main() -> None: asyncio.run(main()) ``` -Available channels (13): `ticker`, `trade`, `orderbook_delta`, `fill`, -`market_positions`, `user_orders`, `order_group_updates`, -`market_lifecycle_v2`, `multivariate`, `multivariate_market_lifecycle`, -`communications`, `control_frames`, `root`. +Available channels (13): 11 have dedicated `subscribe_*` methods — `ticker`, +`trade`, `orderbook_delta`, `fill`, `market_positions`, `user_orders`, +`order_group_updates`, `market_lifecycle_v2`, `multivariate`, +`multivariate_market_lifecycle`, `communications`. The remaining two +(`control_frames`, `root`) are reachable through the generic +`subscribe(channel, ...)` escape hatch. ## Error handling diff --git a/ROADMAP.md b/ROADMAP.md index 8c99886..74dedf3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,6 +4,17 @@ See `CHANGELOG.md` for full release history. +- **Unreleased (post-v2.2.0)** — WS reliability + auth polish batch: + WS resubscribe-window frame stashing (`#176`), `run_forever(stop_event=...)` + cooperative shutdown (`#177`), `run_forever()` raises on missing subscription + instead of silently returning (`#175`), `MessageQueue` `maxlen` defense-in-depth + (`#173`), `_to_decimal_*` consolidation into `_coerce_decimal` (`#174`), + async RSA-PSS sign offload via dedicated `ThreadPoolExecutor` (`#178`), + first two `server_omits_despite_required` exclusions for + `Event.product_metadata` and `EventMetadata.market_details` (`#183`). All + closed the open items previously tracked under #106's "Wave 5 polish backlog" + umbrella; nothing remains. + - **v2.2.0 (2026-05-19)** — response-side spec drift hardening (`#157`). 65 new optional fields backfilled across 16 REST + WS response models for OpenAPI v3.18.0 / AsyncAPI v0.14 (`Market`, `Order`, `Fill`, @@ -44,38 +55,21 @@ See `CHANGELOG.md` for full release history. ## Open trackers -- [#106](https://github.com/TexasCoding/kalshi-python-sdk/issues/106) — - Wave 5 polish backlog umbrella. 5 items landed via #141; remaining - sub-items (e.g. `MessageQueue.maxlen` defense-in-depth, RSA-sign via - executor, `run_forever` foot-gun) are opportunistic. -- [#45](https://github.com/TexasCoding/kalshi-python-sdk/issues/45) — - verify `json={}` workaround under prod credentials. Blocked on - prod-key access. -- [#53](https://github.com/TexasCoding/kalshi-python-sdk/issues/53) — - resolve nested `$ref` pointers in body-schema drift check. Spec - currently has no nested refs; implement when one lands. +None. ## Next milestone -Not scoped. Carry-overs from v2.1 and v2.0 audit backlog: +Not scoped. Open candidates from the v2.0/v2.1 audit backlog: -- **`MessageQueue._buffer = collections.deque(maxlen=maxsize+1)`** for - defense-in-depth (#106 F-P-15 / F-R-02). -- **`_to_decimal_dollars` / `_to_decimal_fp` consolidation** (#106 - F-N-09 + the follow-up noted in #140). -- **WS UX foot-guns**: auto-start recv loop when no `subscribe_*` was - called, resubscribe-time data-frame stashing (#106 F-P-16 / F-R-13). -- **Map remaining REST sub-models / V2 family / internal containers** - (~42 entries) into `CONTRACT_MAP` and promote - `test_contract_map_completeness` (REST) to hard-fail. WS side already - hard-fails as of v2.2.0; the REST side stays warn-only until the - mapping work lands. - **Required-but-Optional drift policy decision** (~204 entries on `test_required_drift` / `test_ws_required_drift`). Currently warn-only; - promote to fail behind either an allowlist or a tightening pass that - drops `None` defaults on fields the server reliably sends. + promote to fail behind either an allowlist or a tightening pass that drops + `None` defaults on fields the server reliably sends. +- **Continued nightly-integration `server_omits_despite_required` triage.** + `#183` was the first batch; the next nightly run against demo will catch + the next set as they surface. -Pick from `gh issue list` and the deferred items in `#106` opportunistically. +Pick from `gh issue list` opportunistically. ## Execution conventions (carried from v2.0) diff --git a/docs/authentication.md b/docs/authentication.md index 65ba5cb..0ab6625 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -198,4 +198,28 @@ headers = auth.sign_request("GET", "/trade-api/v2/exchange/status") # "KALSHI-ACCESS-TIMESTAMP": ...} ``` +### Async RSA-PSS sign offload + +`KalshiAuth.sign_request_async(method, path, timestamp_ms=None)` is the +coroutine version of `sign_request()`. It offloads the RSA-PSS signing +(typically 1–10 ms on a 2048-bit key) onto a **dedicated** +`ThreadPoolExecutor(max_workers=2)` lazy-initialised on first use, so signs +don't queue behind `loop.getaddrinfo` / file I/O / other `to_thread()` work +on a busy event loop: + +```python +headers = await auth.sign_request_async("GET", "/trade-api/v2/exchange/status") +``` + +The async REST transport (`AsyncTransport.request`) and async WebSocket +connect (`ConnectionManager._build_auth_headers`) both use this path +automatically — relevant during reconnect storms where cold DNS resolution +(5–50 ms) would otherwise dominate the sign cost. The sync `sign_request` +API is unchanged for sync-transport callers. + +`KalshiAuth.close()` shuts the executor down; `KalshiClient.close()` and +`AsyncKalshiClient.close()` chain into it, so you only need to call it +directly if you construct `KalshiAuth` standalone (e.g. for the WebSocket +with no REST client alongside). + See the [API reference](reference.md) for the full surface. diff --git a/docs/configuration.md b/docs/configuration.md index 2d37726..7767ff2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -141,6 +141,31 @@ with KalshiClient(auth=auth) as client: `auth=` and the credential kwargs (`key_id` / `private_key_path` / `private_key`) are mutually exclusive — supply one or the other. +## Lifecycle + +`KalshiClient` (and `AsyncKalshiClient`) is a context manager; the `with` +block tears down the underlying `httpx` client and a small dedicated +`ThreadPoolExecutor` used for offloading RSA-PSS signing on the async path +(see [Authentication](authentication.md#async-rsa-pss-sign-offload)). If +you keep a client as a long-lived attribute, call `client.close()` +explicitly when shutting down to release both pools deterministically. + +```python +client = KalshiClient.from_env() +try: + ... +finally: + client.close() +``` + +`AsyncKalshiClient` follows the same shape — prefer `async with` so the +async `close()` chains the executor teardown on exit: + +```python +async with AsyncKalshiClient.from_env() as client: + ... +``` + ## Reference ::: kalshi.config.KalshiConfig diff --git a/docs/index.md b/docs/index.md index 127bbb6..5873552 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,7 +3,7 @@ A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) prediction markets API. -- **Full REST coverage** — 85 endpoints across 19 resources (OpenAPI v3.18.0), +- **Full REST coverage** — 98 operations across 19 resources (OpenAPI v3.18.0), every kwarg drift-tested against the spec. - **V2 event-market orders** — new `create_v2` / `amend_v2` / `decrease_v2` / `cancel_v2` family on `/portfolio/events/orders/*`. Legacy `/portfolio/orders` @@ -11,10 +11,12 @@ markets API. - **Funding + cost introspection** — `portfolio.deposits()`, `portfolio.withdrawals()`, `account.endpoint_costs()`. - **Full WebSocket coverage** — 13 channels with sequence-gap detection, automatic - reconnection, backpressure strategies, and an in-memory orderbook builder. + reconnection (with resubscribe-window frame stashing for high-volume channels), + backpressure strategies, and an in-memory orderbook builder. Async-only — + access via `AsyncKalshiClient.ws`. - **Sync and async parity** — `KalshiClient` and `AsyncKalshiClient` share one transport implementation; method names, kwargs, return types, and error behavior - are identical. + are identical. The one exception is WebSocket access, which is async-only. - **Typed end-to-end** — Pydantic v2 models, `Literal` types for enums, `mypy --strict` clean. Request bodies use `extra="forbid"` so phantom kwargs fail fast. diff --git a/docs/resources/events.md b/docs/resources/events.md index 812e5ee..9374b4b 100644 --- a/docs/resources/events.md +++ b/docs/resources/events.md @@ -60,6 +60,21 @@ print(md.tags, md.category) `EventMetadata` carries tags, categories, and other non-trading attributes. +!!! note "Server omissions on optional-shaped fields" + Two `EventMetadata`-adjacent fields are typed as nullable to absorb live + server behavior: + + - `Event.product_metadata` is typed `dict[str, Any] | None`. The OpenAPI + spec marks it `required`, but the live demo server omits the key on + most events. Defaults to `None`. + - `EventMetadata.market_details: list[MarketMetadata]` — when the live + server sends JSON `null`, the SDK coerces it to `[]` so callers + always see a list. The spec contract (key present) is still + enforced. + + Both are tracked under `server_omits_despite_required` in the SDK's + EXCLUSIONS map. + ## Reference ::: kalshi.resources.events.EventsResource diff --git a/docs/resources/multivariate.md b/docs/resources/multivariate.md index 1e06d4d..39ae865 100644 --- a/docs/resources/multivariate.md +++ b/docs/resources/multivariate.md @@ -16,7 +16,7 @@ Public listing, auth-required minting. Attribute name on the client: | `get(collection_ticker)` | `GET /multivariate_event_collections/{ticker}` | no | | `create_market(collection_ticker, *, selected_markets, with_market_payload=False)` | `POST /multivariate_event_collections/{ticker}` | yes | | `lookup_tickers(collection_ticker, *, selected_markets)` | `PUT /multivariate_event_collections/{ticker}/lookup` | no | -| `lookup_history(collection_ticker, *, lookback_seconds)` | `GET /multivariate_event_collections/{ticker}/lookup_history` | no | +| `lookup_history(collection_ticker, *, lookback_seconds)` | `GET /multivariate_event_collections/{ticker}/lookup` (with `lookback_seconds` query param) | no | ## List collections @@ -73,12 +73,12 @@ if resp.market is not None: ## Recent lookup history ```python -hist = client.multivariate_collections.lookup_history( +history = client.multivariate_collections.lookup_history( "KXWEATHER-SPORTS-COMBO", lookback_seconds=3600, ) -for entry in hist.lookups: - print(entry.market_ticker, entry.created_ts) +for point in history: + print(point.last_queried_ts, point.market_ticker, point.event_ticker) ``` ## Reference diff --git a/docs/websockets.md b/docs/websockets.md index 35a4b5a..f85dfd7 100644 --- a/docs/websockets.md +++ b/docs/websockets.md @@ -281,6 +281,33 @@ On a successful reconnect: 4. Active iterators keep yielding — they reference the durable client-side ids, not the server `sid`s. +### Resubscribe-window frame stashing + +Between the moment `resubscribe_all` clears the `sid → client_id` map (to +prevent stale-sid mis-routing on per-sub failures) and the moment the new +sids land in the wait-for-subscribe-response handler, the server can already +send data frames on the freshly-assigned sids. Without buffering, those +frames have no destination yet and would be silently dropped. Under burst +reconnects on high-volume channels (`ticker`, `trade`, `fill`), this could +lose tens of messages per reconnect. + +`SubscriptionManager` stashes those frames in a per-sid bounded +`collections.deque(maxlen=stash_maxlen)` for the duration of +`resubscribe_all`. After resubscribe completes, `_handle_reconnect` drains +the stash through the normal dispatch path so the seq tracker advances, +orderbook state applies, and iterator consumers receive them in arrival +order. + +The stash is bounded by an internal `stash_maxlen=1000` per sid — generous +enough for normal market-burst reconnects, low enough to bound memory if +resubscribe stalls (not user-configurable on `KalshiWebSocket`). On +overflow, oldest evicts (deque semantics) and a WARNING fires **once per +sid per resubscribe cycle** so the caller notices congestion without log +spam. Worst-case memory is bounded at +`stash_maxlen × len(active_subs) × avg_frame_size`. Frames whose sid never +gets re-mapped (a per-sub failure during resubscribe) are dropped on drain +with a debug log — there's no consumer to deliver them to. + If `ws_max_retries` is exhausted, the receive loop pushes sentinels to all active queues (so `async for` terminates cleanly) and exits. The connection state ends at `CLOSED`.