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
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand All @@ -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

Expand Down
44 changes: 19 additions & 25 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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)

Expand Down
24 changes: 24 additions & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
25 changes: 25 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 5 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,20 @@
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`
keeps working; deprecation no earlier than May 6, 2026.
- **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.
Expand Down
15 changes: 15 additions & 0 deletions docs/resources/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions docs/resources/multivariate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions docs/websockets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Loading