diff --git a/CHANGELOG.md b/CHANGELOG.md index d7e27ad..27c62e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,12 @@ prediction-API surface. Additive release: no changes to `KalshiClient`. exchange (`external-api.kalshi.com` / demo `external-api.demo.kalshi.co`, `/trade-api/v2`), with their own `PerpsConfig` and a separate `KALSHI_PERPS_*` credential namespace. They reuse the prediction-API RSA-PSS signer and HTTP - transport unchanged. Resource families: `exchange` (status / enabled gate / + transport unchanged. The constructors and `from_env()` also accept + `ws_base_url` (set the WS endpoint independently from REST) and `password` + (passphrase for an encrypted key), and read `KALSHI_PERPS_WS_BASE_URL` / + `KALSHI_PERPS_PRIVATE_KEY_PASSPHRASE` from the environment; passing `config=` + together with `demo`/`base_url`/`ws_base_url` is rejected. Resource families: + `exchange` (status / enabled gate / risk parameters), `markets` (list / get / orderbook / candlesticks), `orders` (create / get / list / cancel / decrease / amend + FCM), `order_groups`, `portfolio` (positions / fills / trades), `margin` (balance / risk / @@ -52,8 +57,16 @@ prediction-API surface. Additive release: no changes to `KalshiClient`. spec-required array key raises `ValidationError` (surfacing spec drift instead of silently returning `[]`), while a **null** array coerces to `[]` (Kalshi's empty-as-null convention — the prior `data.get(...)` extraction would - `TypeError` on a null array). The equivalent perps endpoints were hardened the - same way (markets/funding list responses). + `TypeError` on a null array). The perps `markets.list` / `markets.candlesticks` + / `funding.historical_rates` / `funding.history` responses use the same + `NullableList` envelopes, so null-handling is consistent across both surfaces. + The optional `order_groups.list` stays tolerant of a missing/null array. + +### Fixed + +- `KalshiWebSocket._stop()` now retrieves an already-finished receive-loop's + exception, so a session torn down after a permanent close no longer logs + asyncio's "Task exception was never retrieved" on garbage collection. ### Internal diff --git a/ROADMAP.md b/ROADMAP.md index 7c667ee..ee8b5ff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,17 @@ ## Shipped +- **v3.2.0 (2026-06-05)** — full **Perps (margin) API** support: standalone + `PerpsClient` / `AsyncPerpsClient` (REST), `PerpsWebSocket`, and the + Self-Clearing-Member `KlearClient` (cookie-session + MFA settlement API), + alongside the unchanged prediction-API surface. Also hardened the prediction + and perps list endpoints to validate typed response envelopes (a missing + spec-required array now surfaces as a `ValidationError`). Additive for + existing `KalshiClient` users. +- **v3.1.0 (2026-06-04)** — OpenAPI sync v3.19.0 → v3.20.0: `events.fee_changes`, + `is_block_trade`, and the `event_fee_update` frame on the `market_lifecycle_v2` + WebSocket channel. +- **v3.0.1 (2026-05-26)** — OpenAPI sync v3.18.0 → v3.19.0. - **v3.0.0 (2026-05-22)** — first major release in the v3 line. Three breaking-rename issues (`#348`, `#349`, `#351`) deferred from the v2.7.0 audit closure land with one-release deprecation aliases: diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 2eca33e..7d3409b 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -31,6 +31,8 @@ from this repo can publish without further configuration. ## Cutting a release 1. Bump `version` in `pyproject.toml` and `__version__` in `kalshi/__init__.py`. + The `## ` heading in `CHANGELOG.md` must use the same version (the + workflow extracts the section by that heading for the release body). 2. Add a section to `CHANGELOG.md` for the new version. The release workflow extracts the section between `## ` and the next `## ` heading and uses it as the GitHub Release body, so write it for that audience. diff --git a/docs/authentication.md b/docs/authentication.md index 80b435d..2036eb4 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -226,8 +226,14 @@ with KalshiClient.from_env() as client: ... ``` -An explicit `password=` argument always wins over -`KALSHI_PRIVATE_KEY_PASSPHRASE` when both are supplied. A wrong passphrase +`KalshiClient.from_env()` has no `password=` kwarg — it reads the passphrase +only from `KALSHI_PRIVATE_KEY_PASSPHRASE`. For programmatic passphrase control, +build a `KalshiAuth` (above) and pass it as `auth=`. (The perps +`PerpsClient.from_env()` *does* accept a `password=` kwarg.) + +Wherever a `password=` argument is accepted (the `KalshiAuth.from_*` +constructors and `PerpsClient`), it always wins over an environment passphrase +when both are supplied. A wrong passphrase raises `KalshiAuthError` ("Invalid PEM private key…"); a missing passphrase for an encrypted PEM raises `KalshiAuthError` pointing at the `password=` parameter. diff --git a/docs/dataframes.md b/docs/dataframes.md index a9093cc..aeaf0c8 100644 --- a/docs/dataframes.md +++ b/docs/dataframes.md @@ -25,8 +25,11 @@ print(df[["ticker", "yes_bid", "yes_ask", "volume_24h"]].head()) ## How it works -Both methods walk `page.items` and call `item.model_dump(mode="python")` on -each, then hand the records to `pd.DataFrame(...)` / `pl.DataFrame(...)`. +Both methods build a column-oriented `dict[field, list[value]]` over +`page.items` (`getattr` per field — deliberately avoiding a per-row +`model_dump`) and hand it to `pd.DataFrame(...)` / `pl.DataFrame(...)`. Nested +model cells are dumped to dicts per column (so polars can infer a `Struct` +dtype) while scalar cells pass through unchanged. This means: diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 30c02cc..4e9442d 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -8,7 +8,8 @@ these: | `KALSHI_KEY_ID` | unset | Key ID. If unset, `from_env()` returns an unauthenticated client. | | `KALSHI_PRIVATE_KEY` | unset | PEM string. **Conflicts** with `KALSHI_PRIVATE_KEY_PATH` — set exactly one. | | `KALSHI_PRIVATE_KEY_PATH` | unset | Path to PEM file. `~` is expanded. Conflicts with `KALSHI_PRIVATE_KEY`. | -| `KALSHI_DEMO` | `false` | Truthy values (`true`, `1`, `yes`, `on`, case-insensitive) flip the client to the demo URLs. | +| `KALSHI_PRIVATE_KEY_PASSPHRASE` | unset | Passphrase for an encrypted PEM (read by `try_from_env()`). | +| `KALSHI_DEMO` | `false` | The exact string `true` (case-insensitive) selects the demo URLs. Other values (`1`, `yes`, `on`) are **not** recognized. | | `KALSHI_API_BASE_URL` | unset | Overrides `base_url` entirely. Wins over `KALSHI_DEMO`. | | `KALSHI_ALLOW_UNKNOWN_HOST` | unset | Set to `1` to allow `base_url`/`ws_base_url` hosts outside `{api.elections.kalshi.com, demo-api.kalshi.co, localhost}`. Default-fail prevents typos like `kalsi.com` from silently signing to an attacker (#250). | @@ -52,3 +53,30 @@ config = ( with KalshiClient.from_env(config=config) as client: ... ``` + +## Perps environment variables + +`PerpsClient.from_env()` / `AsyncPerpsClient.from_env()` read a **separate** +`KALSHI_PERPS_*` namespace — perps requires a key issued for the perps exchange: + +| Variable | Default | Effect | +|---|---|---| +| `KALSHI_PERPS_KEY_ID` | unset | Perps key ID. Omit for an unauthenticated client. | +| `KALSHI_PERPS_PRIVATE_KEY` | unset | PEM string. Conflicts with `KALSHI_PERPS_PRIVATE_KEY_PATH`. | +| `KALSHI_PERPS_PRIVATE_KEY_PATH` | unset | Path to a PEM file. | +| `KALSHI_PERPS_PRIVATE_KEY_PASSPHRASE` | unset | Passphrase for an encrypted perps PEM. A `password=` kwarg to the client overrides it. | +| `KALSHI_PERPS_DEMO` | `false` | `true` (case-insensitive) selects the demo REST + WS endpoints. | +| `KALSHI_PERPS_API_BASE_URL` | unset | Overrides the perps REST `base_url`. | +| `KALSHI_PERPS_WS_BASE_URL` | unset | Overrides the perps `ws_base_url`, so a REST override doesn't leave the WS feed on production. | +| `KALSHI_PERPS_ALLOW_UNKNOWN_HOST` | unset | Set to `1` to allow non-Kalshi perps hosts (mock servers / proxies). | + +## Klear (SCM) environment variables + +`KlearClient` / `AsyncKlearClient` use cookie-session auth (no RSA keys), so they +read only environment selectors: + +| Variable | Default | Effect | +|---|---|---| +| `KALSHI_KLEAR_DEMO` | `false` | `true` (case-insensitive) selects the demo Klear endpoint. | +| `KALSHI_KLEAR_API_BASE_URL` | unset | Overrides the Klear `base_url`. | +| `KALSHI_KLEAR_ALLOW_UNKNOWN_HOST` | unset | Set to `1` to allow non-Kalshi Klear hosts. | diff --git a/docs/perps.md b/docs/perps.md index 4350d77..43924a3 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -42,7 +42,9 @@ never collide with the prediction-API `KALSHI_*` vars: |---|---| | `KALSHI_PERPS_KEY_ID` | perps API key id (omit for an unauthenticated client) | | `KALSHI_PERPS_PRIVATE_KEY` / `KALSHI_PERPS_PRIVATE_KEY_PATH` | RSA private key (PEM string or file) | -| `KALSHI_PERPS_API_BASE_URL` | optional base-URL override | +| `KALSHI_PERPS_PRIVATE_KEY_PASSPHRASE` | optional passphrase for an encrypted PEM (a `password=` kwarg overrides it) | +| `KALSHI_PERPS_API_BASE_URL` | optional REST base-URL override | +| `KALSHI_PERPS_WS_BASE_URL` | optional WS base-URL override (keeps the WS feed off production when overriding REST) | | `KALSHI_PERPS_DEMO` | `"true"` routes to the demo exchange | ```python diff --git a/docs/resources/markets.md b/docs/resources/markets.md index 8debcc0..3f22a86 100644 --- a/docs/resources/markets.md +++ b/docs/resources/markets.md @@ -43,10 +43,9 @@ for market in page: for the wire (this endpoint uses comma-join form, **not** the explode form used by `bulk_orderbooks`). -All the `*_ts` filters (`min_close_ts`, `max_close_ts`, `min_open_ts`, -`max_open_ts`, `min_settle_ts`, `max_settle_ts`, `min_volume_24h`, -`min_total_volume`, `min_total_open_interest`) are Unix-second ints. Pair them -with `min_open_ts`/`max_open_ts` etc. as needed. +All seven `*_ts` filters (`min_created_ts`, `max_created_ts`, `min_updated_ts`, +`min_close_ts`, `max_close_ts`, `min_settled_ts`, `max_settled_ts`) are +Unix-second ints. `list_all(...)` walks cursors and returns an iterator — see [Pagination](../pagination.md). @@ -94,7 +93,7 @@ candles = client.markets.candlesticks( period_interval=60, # seconds: 60, 3600, 86400 include_latest_before_start=True, ) -for c in candles.candlesticks: +for c in candles: # list[Candlestick] print(c.end_period_ts, c.yes_bid) ``` @@ -106,7 +105,6 @@ for c in candles.candlesticks: ```python batches = client.markets.bulk_candlesticks( market_tickers=["KXPRES-24-DJT", "KXPRES-24-KH"], # 1–100 tickers - series_ticker="KXPRES", # optional shard hint start_ts=1_700_000_000, end_ts=1_700_100_000, period_interval=3600, diff --git a/docs/resources/multivariate.md b/docs/resources/multivariate.md index 39ae865..8968273 100644 --- a/docs/resources/multivariate.md +++ b/docs/resources/multivariate.md @@ -8,6 +8,13 @@ with a list of leg selections mints a derived YES/NO contract. Public listing, auth-required minting. Attribute name on the client: `multivariate_collections`. +!!! warning "Deprecated methods" + `create_market()`, `lookup_tickers()`, and `lookup_history()` are + deprecated — "This endpoint predates RFQs and should not be used for new + integrations." Calling them emits a `DeprecationWarning`. Use the + [Communications (RFQ/Quote)](communications.md) surface instead. `list()` / + `list_all()` / `get()` remain supported. + ## Quick reference | Method | Endpoint | Auth | @@ -15,7 +22,7 @@ Public listing, auth-required minting. Attribute name on the client: | `list(...)` / `list_all(...)` | `GET /multivariate_event_collections` | no | | `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_tickers(collection_ticker, *, selected_markets)` | `PUT /multivariate_event_collections/{ticker}/lookup` | yes | | `lookup_history(collection_ticker, *, lookback_seconds)` | `GET /multivariate_event_collections/{ticker}/lookup` (with `lookback_seconds` query param) | no | ## List collections diff --git a/docs/resources/series.md b/docs/resources/series.md index 7d879a8..cd809eb 100644 --- a/docs/resources/series.md +++ b/docs/resources/series.md @@ -72,7 +72,7 @@ hist = client.series.forecast_percentile_history( end_ts=1_700_100_000, period_interval=86400, ) -for point in hist.forecast_percentiles: +for point in hist: # list[ForecastPercentilesPoint] print(point.ts, point.percentile, point.value) ``` diff --git a/docs/resources/subaccounts.md b/docs/resources/subaccounts.md index e313ab4..1215aa8 100644 --- a/docs/resources/subaccounts.md +++ b/docs/resources/subaccounts.md @@ -50,12 +50,12 @@ below). ```python resp = client.subaccounts.list_balances() -for bal in resp.balances: - print(bal.subaccount_number, bal.balance, bal.portfolio_value, bal.updated_ts) +for bal in resp.subaccount_balances: + print(bal.subaccount_number, bal.balance, bal.updated_ts) ``` -`bal.balance` and `bal.portfolio_value` are **integer cents**. -`bal.updated_ts` is Unix seconds (not ISO datetime). +`bal.balance` is a `DollarDecimal` (dollars). `bal.updated_ts` is Unix seconds +(not ISO datetime). ## List transfers diff --git a/docs/retries.md b/docs/retries.md index b4e3c42..a4516b2 100644 --- a/docs/retries.md +++ b/docs/retries.md @@ -7,8 +7,8 @@ duplicate orders happen. | Verb | Retries on | |---|---| -| `GET`, `HEAD`, `OPTIONS` | `429`, `500`, `502`, `503`, `504` | -| `POST`, `DELETE`, `PUT`, `PATCH` | **never** | +| `GET`, `HEAD`, `OPTIONS` | `408`, `425`, `429`, `500`, `502`, `503`, `504`, `520`, `521`, `522`, `523`, `524` | +| `POST`, `PUT`, `DELETE` | **never** | POST/DELETE/PUT are write paths. Retrying them risks duplicate orders, duplicate cancels, duplicate transfers — even with `client_order_id`, you diff --git a/docs/websockets.md b/docs/websockets.md index ad807b7..f92b87b 100644 --- a/docs/websockets.md +++ b/docs/websockets.md @@ -50,8 +50,9 @@ for `user_order`, `market_position`, and the `multivariate_lookup` / `multivariate` mismatch. !!! warning "Migration (v3.1.0): `event_fee_update` rides `market_lifecycle_v2`" - Since v3.20.0 the `market_lifecycle_v2` channel also emits - `event_fee_update` frames (event-level fee override set or cleared), so + Since the v3.20.0 spec sync (SDK v3.1.0) the `market_lifecycle_v2` channel + also emits `event_fee_update` frames (event-level fee override set or + cleared), so `subscribe_market_lifecycle()` now yields `MarketLifecycleMessage | EventFeeUpdateMessage`. **Existing consumers must discriminate on `.type` before touching payload fields** — an @@ -425,8 +426,8 @@ silently corrupting book state. | 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. | +| `DROP_OLDEST` | Read-only / coalesced feeds: `ticker`, `trade`, `market_lifecycle`, `multivariate*`, `user_orders` | Newest sample is the one that matters; an evicted old frame is recoverable from the next one. | +| `ERROR` | Stateful, sequenced feeds: `orderbook_delta`, `order_group_updates` | A dropped delta corrupts derived state (the reconstructed book / order-group tracking). Surface the backpressure to the consumer rather than continuing on corrupted state. | `ERROR` is fatal — the recv loop broadcasts sentinels and exits when it fires (see [Backpressure](#backpressure)). Wire `on_error=` / `on_state_change=` to diff --git a/pyproject.toml b/pyproject.toml index 4cfb1f9..ff89fe6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "kalshi-sdk" version = "3.2.0" -description = "A professional Python SDK for the Kalshi prediction markets API" +description = "A professional Python SDK for the Kalshi prediction markets and Perps (margin) APIs" readme = "README.md" license = { text = "MIT" } requires-python = ">=3.12"