|
2 | 2 |
|
3 | 3 | All notable changes to kalshi-sdk will be documented in this file. |
4 | 4 |
|
| 5 | +## 2.7.0 — 2026-05-22 |
| 6 | + |
| 7 | +Post-v2.6 independent multi-LLM reviewer audit closure. **47 issues filed |
| 8 | +(`#311`–`#357`) by a 9-reviewer parallel pass** combining 7 internal specialist |
| 9 | +agents (security/auth, HTTP transport, WebSocket, models/contracts, sync/async |
| 10 | +parity, performance, resources/API) with **fresh-eyes external LLM reviews via |
| 11 | +the Codex CLI (GPT-5) and Gemini CLI**. 44 issues closed in this release across |
| 12 | +4 sequential waves (W0 docs, W1 HIGH integrity, W2 MEDIUM correctness, W3 LOW |
| 13 | +polish/perf/deps); 17 PRs merged. 3 breaking-rename issues (`#348`, `#349`, |
| 14 | +`#351`) deferred to **v3.0.0** milestone. Main is mypy `--strict` clean, ruff |
| 15 | +clean, **2876 unit tests passing** (≈100 new regression tests added). |
| 16 | + |
| 17 | +### Breaking changes |
| 18 | + |
| 19 | +Five behavioral fences; all surface bugs that were silently wrong or invariants |
| 20 | +the SDK now enforces. Per project policy (established by v2.0–v2.6), |
| 21 | +bug-surfacing behavioral fences ship in minor releases; intentional public-API |
| 22 | +removals or renames are reserved for major releases — that's why the three |
| 23 | +breaking renames (`#348`, `#349`, `#351`) are deferred to v3.0.0. |
| 24 | + |
| 25 | +- **`AmendOrderRequest.side` / `.action` narrowed to `Literal`** (`#312`). |
| 26 | + Mirrors the v2.5 `#270` narrowing on `CreateOrderRequest`. Previously any |
| 27 | + string passed validation and the server rejected with a 400; now invalid |
| 28 | + values raise `pydantic.ValidationError` at construction. |
| 29 | +- **`KalshiConfig.extra_headers` immutable post-construction** (`#313`). The |
| 30 | + `#298` `KALSHI-ACCESS-*` fence ran once at construction; post-construction |
| 31 | + mutation of `extra_headers` could reopen the auth-header forge surface. |
| 32 | + `extra_headers` is now stored as `MappingProxyType` over a defensive copy |
| 33 | + — `config.extra_headers["k"] = "v"` raises `TypeError`. Construction-time |
| 34 | + fence unchanged. |
| 35 | +- **`CommunicationsResource.list_rfqs` / `list_quotes` `status` kwarg |
| 36 | + narrowed to `Literal`** (`#324`). New `RfqStatusLiteral` / `QuoteStatusLiteral` |
| 37 | + match the spec's closed set; arbitrary `str` is rejected by mypy at the |
| 38 | + call site. Consistent with the existing `OrdersResource.list(status=...)` |
| 39 | + pattern. |
| 40 | +- **`to_decimal()` rejects `NaN` / `Infinity`** (`#325`). The public helper |
| 41 | + promised safety in its docstring but accepted any `Decimal`. Now raises |
| 42 | + `ValueError` on non-finite inputs, matching the `_coerce_decimal` validator. |
| 43 | +- **`DollarDecimal` request-side fields reject negative prices and |
| 44 | + sub-tick precision** (`#343`). `CreateOrderRequest`, |
| 45 | + `AmendOrderRequest`, `CreateOrderV2Request`, `AmendOrderV2Request` now |
| 46 | + reject negative `yes_price` / `no_price` and sub-$0.0001-tick precision |
| 47 | + at construction. Response-side `DollarDecimal` is unchanged (servers may |
| 48 | + emit any value). |
| 49 | + |
| 50 | +### Critical (HIGH-severity money/auth/data-integrity fixes) |
| 51 | + |
| 52 | +- **`KalshiClient.from_env` preserves caller ownership of `KalshiAuth`** |
| 53 | + (`#311`). The `from_env` classmethod (sync and async) overwrote `_auth_owned` |
| 54 | + from the input kwarg instead of recomputing from what `__init__` actually |
| 55 | + did. Two real bugs: (a) `from_env(key_id=..., private_key=...)` with no env |
| 56 | + vars set leaked the sign `ThreadPoolExecutor` because the SDK-built auth |
| 57 | + was flagged "not owned"; (b) `from_env(auth=my_auth)` with env vars set |
| 58 | + caused `client.close()` to close the caller's still-referenced auth, |
| 59 | + raising `RuntimeError("KalshiAuth has been closed")` on the next |
| 60 | + `sign_request`. The fix recomputes ownership from `__init__`'s invariant. |
| 61 | +- **WS `_wait_for_response` wraps `TimeoutError` as `KalshiSubscriptionError`** |
| 62 | + (`#314`). `asyncio.wait_for` raises bare `TimeoutError` on timeout, which |
| 63 | + escaped `subscribe()` / `unsubscribe()` unhandled because only |
| 64 | + `ConnectionClosed` was caught. Now wrapped with `channel` / `client_id` / |
| 65 | + `op` context so consumers branching on SDK exceptions actually see the |
| 66 | + expected type. |
| 67 | +- **WS zombie-subscription cleanup on gap-recovery failure** (`#315`). |
| 68 | + `broadcast_error` never popped the dead `Subscription`. A failed |
| 69 | + `resubscribe_one` in `_handle_seq_gap` (and the `KalshiBackpressureError` |
| 70 | + path) left a zombie sub whose closed queue persisted; the next reconnect's |
| 71 | + `resubscribe_all` resurrected it on the server — silent data loss + a |
| 72 | + server-quota leak. `broadcast_error` now pops `_subscriptions` and |
| 73 | + `_sid_to_client` so reconnects can't resurrect dead subs. |
| 74 | + |
| 75 | +### High-impact correctness |
| 76 | + |
| 77 | +- **`from_env` lazy `try_from_env()` evaluation** (`#316`). The classmethod |
| 78 | + eagerly evaluated `KalshiAuth.try_from_env()` even when the caller passed |
| 79 | + `auth=` / `key_id` / `private_key` / `private_key_path`. A malformed |
| 80 | + `KALSHI_PRIVATE_KEY` in the process env then raised on every CI worker |
| 81 | + that bypassed env. Now gated on `caller_supplied_auth`. |
| 82 | +- **`sign_request` strips URL fragment** (`#317`). `path.split("?")[0]` |
| 83 | + stripped query strings but not `#fragment`. httpx drops fragments before |
| 84 | + sending, producing a guaranteed signature mismatch (401) on any path |
| 85 | + containing `#`. Now strips both. |
| 86 | +- **`Retry-After` honored across 408 / 429 / 503 / 504** (`#322`). Previously |
| 87 | + only 429 parsed `Retry-After`. Per RFC 7231 §7.1.3 the header applies to |
| 88 | + all four. `_parse_retry_after` extracted; `retry_after` lifted to |
| 89 | + `KalshiError` base class so `KalshiServerError` and `KalshiTimeoutError` |
| 90 | + also surface the hint. |
| 91 | +- **`Retry-After` path applies jitter** (`#321`). Synchronized clients |
| 92 | + hitting the same 429/503 window would stampede the rate limit at the |
| 93 | + exact server-suggested moment. Full Jitter is now added on top of the |
| 94 | + server floor, capped at `retry_max_delay`. Preserves RFC compliance |
| 95 | + (`Retry-After` is a "no sooner than" hint). |
| 96 | +- **Response body size cap on success path** (`#323`). The `#203` 16 KB |
| 97 | + cap applied only to error responses; the success path was unbounded. |
| 98 | + New `_enforce_response_body_cap` enforces the same protection on |
| 99 | + non-streaming success bodies via a Content-Length pre-check plus a |
| 100 | + post-buffer guard. |
| 101 | +- **V1 order request models gain `ge=0` parity** (`#326`). `subaccount`, |
| 102 | + `exchange_index`, `subaccount_number` on `CreateOrderRequest`, |
| 103 | + `AmendOrderRequest`, `BatchCancelOrderRequest`, and related V1 + group |
| 104 | + models now reject negative integers (matching V2 and the `#295` sweep). |
| 105 | +- **`Page._columns` handles `None`-first nullable nested columns** (`#328`). |
| 106 | + Detection inspected only `cols[0]`; a `None`-first nullable nested |
| 107 | + `BaseModel` column skipped the `model_dump` pass and broke |
| 108 | + `to_dataframe` / `to_polars` for nullable-Struct schemas. |
| 109 | +- **WS `OrderbookDeltaPayload.ts` typed as `AwareDatetime`** (`#331`). |
| 110 | + Missed by the v2.5 `#270` WS datetime sweep. Tightening across |
| 111 | + `orderbook_delta`, `user_orders`, and `communications` payloads. |
| 112 | +- **WS backpressure error closes connection cleanly** (`#332`). |
| 113 | + `KalshiBackpressureError` in the recv loop previously broadcast sentinels |
| 114 | + and broke, but left the WS open and `_running=True`. The next |
| 115 | + `subscribe_*` restarted the recv loop on top of orphan server-side subs. |
| 116 | + Now closes the connection and clears `_running` / manager refs so the |
| 117 | + next session starts fresh. |
| 118 | + |
| 119 | +### Performance |
| 120 | + |
| 121 | +- **Orderbook materialization via `model_construct`** (`#327`). |
| 122 | + `_BookState.to_orderbook` previously re-validated every price level through |
| 123 | + Pydantic on each `apply_delta`. Data is SDK-canonical after the snapshot |
| 124 | + validation; switched to `model_construct` to skip the redundant pass. |
| 125 | +- **Zero-delta orderbook updates preserve cache** (`#347`). A no-op delta |
| 126 | + (quantity unchanged) used to invalidate the memoized `Orderbook` view, |
| 127 | + defeating the `#244` cache. Now skipped when the price-level dict is |
| 128 | + unchanged. |
| 129 | +- **Public `apply_snapshot` single-copy** (`#344`). The public path |
| 130 | + allocated `yes` / `no` dicts twice — once via identity in |
| 131 | + `_apply_snapshot_inplace`, then again via `dict(msg.msg.yes)` to copy |
| 132 | + defensively. The bypass path (recv hot loop, `#296`) keeps identity |
| 133 | + adoption; the public path now copies once. |
| 134 | +- **V2 batch endpoints use bytes-fast-path** (`#329`). `batch_create_v2` / |
| 135 | + `batch_cancel_v2` paid the dict-walk serializer twice; the v2.4 `#223` |
| 136 | + fast-path was never extended. Now serialize via `model_dump_json` once |
| 137 | + and send the bytes. |
| 138 | +- **RSA-PSS / MGF1 / SHA256 config cached** (`#345`). Previously |
| 139 | + allocated per signature. Hoisted to module-level constants — measurable |
| 140 | + on the auth hot path. |
| 141 | +- **`SequenceTracker.track` sync fast-path** (`#330`). New public |
| 142 | + `track_sync` lets the recv hot loop skip the per-frame coroutine-object |
| 143 | + allocation; the async wrapper remains for callers that need to await on |
| 144 | + the gap path. |
| 145 | +- **WS recv `asyncio.timeout` swap** (`#356`). Replaced `asyncio.wait_for` |
| 146 | + in the recv hot path with `async with asyncio.timeout(...)` (Python 3.11+) |
| 147 | + — fewer TimerHandle allocations per frame. |
| 148 | +- **`method.upper()` hoisted out of retry loop** (`#342`). Computed once |
| 149 | + per request instead of once per retry attempt. |
| 150 | + |
| 151 | +### Polish |
| 152 | + |
| 153 | +- **`AsyncTransport.close()` cancellation-safe** (`#333`). Sets `_closed` |
| 154 | + after `aclose()` returns, not before — cancellation between the two no |
| 155 | + longer leaks the httpx pool. Mirrors the v2.6 `#301` sync fix. |
| 156 | +- **`_delete_with_body(params=...)` symmetry** (`#340`). The body-only |
| 157 | + variant gains the `params` kwarg that `_delete_with_body_json` already |
| 158 | + accepted. |
| 159 | +- **Pagination cycle detector catches non-adjacent loops** (`#352`). The |
| 160 | + prior detector only caught adjacent cursor repeats; an `A→B→A→B` loop |
| 161 | + from a load-balanced pod-state drift now raises before exhausting |
| 162 | + `max_pages`. Memory-bounded at 1024 seen cursors. |
| 163 | +- **WS `_handle_orphan_subscribed` correlation** (`#354`). Server |
| 164 | + `unsubscribed` acks for sids the client never owned no longer clobber |
| 165 | + unrelated state. |
| 166 | +- **WS `ConnectionManager.reconnect` exc_info on failure** (`#355`). Per- |
| 167 | + attempt failures now log with `exc_info=True` at DEBUG so root cause |
| 168 | + (auth / TLS / DNS) survives to max-retry. |
| 169 | +- **WS `_stop()` teardown order** (`#357`). Closes the connection FIRST, |
| 170 | + then broadcasts sentinels — late in-flight frames no longer land on |
| 171 | + closed queues. `close()` wrapped in `try/finally` so sentinels always |
| 172 | + fire even if `close()` raises. |
| 173 | +- **`KalshiConfig.extra_headers` doubled-pipeline removed** (`#341`). |
| 174 | + Previously attached to `httpx.Client(headers=...)` defaults AND merged |
| 175 | + per-request via `_ci_merge`. Now `_ci_merge` is the single source of |
| 176 | + truth; the `#298` precedence contract (config defaults < per-call |
| 177 | + extras < signed auth) is structurally enforced. |
| 178 | +- **`orders.create()` overload tightening** (`#350`). The `**kwargs` |
| 179 | + overload statically required `action` and `count` since v2.5 `#242`; |
| 180 | + the type signature is now updated to match runtime so mypy catches |
| 181 | + omissions at the call site. |
| 182 | +- **Documentation drift sweep** (`#318`, `#319`, `#320`, `#334`, `#336`, |
| 183 | + `#337`, `#338`, `#339`). Three live-data endpoint paths and the |
| 184 | + `batch()` return shape corrected; `positions_all()` "does not exist" |
| 185 | + claim dropped (it shipped in v2.5 `#269`); `structured_targets.get()` |
| 186 | + 404 mapping corrected (raises `KalshiNotFoundError`, not `None`); |
| 187 | + sync/async docstring drift normalized across `orders`, `markets`, |
| 188 | + `portfolio`, `communications`; `CreateOrderV2Request` docstring |
| 189 | + corrected (`DollarDecimal`, not the nonexistent `FixedPointDollars`); |
| 190 | + `LiveDataResource.get_typed` clarified as the legacy URL form. |
| 191 | +- **`KalshiAuth.from_pem` OpenSSH-format error** (`#335`). Detecting an |
| 192 | + `-----BEGIN OPENSSH PRIVATE KEY-----` header now raises a targeted |
| 193 | + `KalshiAuthError` with the exact `ssh-keygen -p -m PKCS8 -f <path>` |
| 194 | + conversion command instead of the generic PEM parse failure. |
| 195 | + |
| 196 | +### Additive |
| 197 | + |
| 198 | +- **`kalshi.OrderPrice`** — public type alias for the request-side |
| 199 | + bounded `DollarDecimal` used by order-request models (`#343`). |
| 200 | +- **`kalshi.RfqStatusLiteral`, `kalshi.QuoteStatusLiteral`** — public |
| 201 | + Literal aliases for communications status filtering (`#324`). |
| 202 | +- **`SequenceTracker.track_sync`** — public sync entry point for the WS |
| 203 | + recv hot path (`#330`). |
| 204 | + |
| 205 | +### Dependencies |
| 206 | + |
| 207 | +- **`pydantic>=2.4`** (`#346`). Bumped from `>=2.0` to ensure the |
| 208 | + `StrictInt` / `_coerce_decimal` invariants the SDK relies on get the |
| 209 | + 2.4+ semantics. Pydantic 2.0–2.3 also shipped JSON-parser bugs. |
| 210 | + |
5 | 211 | ## 2.6.0 — 2026-05-22 |
6 | 212 |
|
7 | 213 | Post-v2.5 independent reviewer audit closure (`#273` follow-on). 7 issues |
|
0 commit comments