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
95 changes: 95 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,101 @@

All notable changes to kalshi-sdk will be documented in this file.

## 2.6.0 — 2026-05-22

Post-v2.5 independent reviewer audit closure (`#273` follow-on). 7 issues
(`#295`–`#301`) identified by a fresh 7-agent parallel review of v2.5.0 across
security, HTTP transport, WebSocket reliability, models/types, REST resources,
performance, and docs/testing. Executed across 3 sequential waves (W0 docs,
W1 money/correctness, W2 polish) in disjoint git worktrees. 7 PRs merged;
main is mypy --strict clean, ruff clean, 2780+ unit tests passing.

### Breaking changes

Two behavioral fences; both surface bugs that were already wrong.

- **`int` request fields reject `bool`** (`#295`). Per-field `StrictInt`
annotation on every money-routing / counting integer of every Request
model: `subaccount`, `exchange_index`, `expiration_ts`, `reduce_by`,
`reduce_to`, `contracts_limit`, `contracts`, `from_subaccount`,
`to_subaccount`, `amount_cents`, `subaccount_number` across V1 + V2.
`bool` is an `int` subclass, so a caller passing `True`/`False` used to
silently route to subaccount 1 / transfer 1 cent / decrease by 1 contract
with no error. Now raises `ValidationError` at construction. The existing
`buy_max_cost` validator (`#243`) is unchanged. New `kalshi.StrictInt`
alias is exported for downstream models.
- **`KALSHI-ACCESS-*` in `extra_headers` is rejected** (`#298`). Both
`KalshiClient(..., config=KalshiConfig(extra_headers=...))` at
construction time and per-request `extra_headers=` kwargs now raise
`ValueError` if any key (case-insensitive) starts with `kalshi-access-`.
Previously a caller-supplied `'kalshi-access-key'` (lowercase) co-existed
with the SDK-signed `KALSHI-ACCESS-KEY` and httpx shipped both raw header
lines — a forge surface even though the documented contract promises
auth headers are SDK-managed.

### Critical (money-risk fixes)

- **`int` request fields reject `bool`** (`#295`, see Breaking).
- **Auth-header forge surface closed** (`#298`, see Breaking). Companion fix:
`_post(json=...)` / `_put(json=...)` / `_delete_with_body(json=...)` and
their async mirrors now pin `Content-Type: application/json` explicitly,
preventing a caller-supplied `'content-type': 'text/plain'` in
`extra_headers` from causing httpx to ship a JSON body labelled as
plain text.
- **WebSocket session re-entry is rejected** (`#297`). `KalshiWebSocket._start()`
used to silently rebuild every manager on nested or re-used `connect()`,
orphaning the outer session's subscriptions and recv task with no error.
Now raises `RuntimeError` with a clear message. `_stop()` clears the
manager refs after teardown so the same instance can be cleanly reused
for a fresh `connect()` once the prior session exits. A partial connect
failure (auth/network) also resets state cleanly via a `BaseException`
cleanup block, so a failed connect no longer permanently bricks the
instance.

### High-impact correctness

- **`KalshiConfig.extra_headers` validated at construction** (`#298`). Closes
the construction-time bypass that survived the per-request guard.
- **Case-insensitive header merge** (`#298`). New `_ci_merge` ensures a
caller-supplied `'x-foo'` and SDK-set `'X-Foo'` collapse to one wire
entry rather than co-existing.
- **Public `OrderbookManager.apply_snapshot()` keeps no-aliasing contract**
(`#296`). Snapshot/delta input messages remain safe to reuse after a
public `apply_snapshot()` call — the manager defensively copies the
adopted dicts. The recv loop continues to skip the copy via
`_apply_snapshot_inplace` for the hot-path perf win (#263).

### Performance

- **Orderbook snapshot adoption restored to identity** (`#296`). The
`dict(msg.msg.yes)` / `dict(msg.msg.no)` wrappers in
`_apply_snapshot_inplace` were silently nullifying the ~5x speedup
CHANGELOG #263 advertises. For a 200-level book that's 400 needless
re-hashes/reallocs per snapshot on the recv hot path. Wrappers dropped;
identity adoption restored on the bypass path.

### Polish

- **`Sync/AsyncTransport.close()` explicitly idempotent** (`#301`). New
`_closed` flag matches the `KalshiAuth` / `KalshiClient` pattern; second
and subsequent `close()` calls are no-ops. Documents the threading scope
honestly: sync is sequential-safe (worst case: one redundant
`httpx.Client.close()`, itself idempotent), async is fully race-free
under cooperative scheduling.
- **`docs/resources/orders.md`**: removed stale `# ActionLiteral, defaults to
"buy"` inline comment that would have re-introduced the `#242` footgun
for readers (`#299`).
- **`docs/configuration.md`**: reference table now lists `total_timeout`,
`ws_ping_interval`, `ws_close_timeout`, and `allow_unknown_host`; URL
validation prose updated for the v2.5 default-reject behavior (`#300`).

### Additive

- **`kalshi.StrictInt`** — new public type alias for downstream models that
want the same `bool`-rejection guard (`#295`).
- **`kalshi/_constants.py`** (internal) — holds `AUTH_HEADER_PREFIX` to
eliminate drift between `_base_client.py` and `config.py` (`#298`).

## 2.5.0 — 2026-05-21

Post-v2.4 multi-reviewer audit closure (`#273`). 34 issues across security,
Expand Down
14 changes: 14 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@

See `CHANGELOG.md` for full release history.

- **v2.6.0 (2026-05-22)** — post-v2.5 independent reviewer audit closure
(`#273` follow-on, 7 issues `#295`–`#301`). Two breaking changes folded
in: `int` request fields reject `bool` across V1+V2 via new
`kalshi.StrictInt` (`#295`); `KALSHI-ACCESS-*` in `extra_headers` is
rejected at both construction and per-request (`#298`). Critical fixes:
WS session re-entry guard with state reset on partial connect failure
(`#297`), case-insensitive transport header merge + explicit
`Content-Type` pin on JSON body helpers (`#298`). Performance: orderbook
snapshot identity adoption restored on the recv-loop bypass (`#296`),
with public `apply_snapshot` keeping its defensive copy. Polish:
`Sync/AsyncTransport.close()` idempotent (`#301`), docs corrections
(`#299`, `#300`). Executed across 3 sequential waves (W0 docs, W1
money/correctness, W2 polish) — 7 PRs merged.

- **v2.5.0 (2026-05-21)** — post-v2.4 multi-reviewer SDK audit closure
(#273, 34 issues across 7 surfaces). Critical fixes: WS seq watermark
rolls back on validation failure (#241), REST/WS split-environment
Expand Down
99 changes: 99 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,104 @@
# Migration

## v2.5 → v2.6

v2.6 ships two behavioral fences, both surface bugs that were already wrong:
``bool`` no longer slips through on integer request fields (`#295`), and
``extra_headers`` cannot carry ``KALSHI-ACCESS-*`` keys (`#298`). Wire
format is unchanged.

### Breaking: `int` request fields reject `bool`

Per `#295`, every integer field on every Request model that lacks a
dedicated validator now rejects ``bool`` at construction. ``bool`` is an
``int`` subclass, so a caller passing ``True``/``False`` silently became
``1``/``0`` and money-routing fields (``subaccount``, ``from_subaccount``,
``amount_cents``, ``count``, etc.) silently misrouted. The fix mirrors
``CreateOrderRequest.buy_max_cost`` (`#243`) across all request models on
both V1 and V2 surfaces:

```python
# v2.5 (silent True → 1)
ApplySubaccountTransferRequest(
client_transfer_id=uuid4(),
from_subaccount=True, # → 1, transfer from subaccount 1
to_subaccount=True, # → 1, transfer TO subaccount 1
amount_cents=True, # → 1, one cent
)

# v2.6 (raises ValidationError on each bool)
ApplySubaccountTransferRequest(
client_transfer_id=uuid4(),
from_subaccount=True, # ➜ ValidationError: bool is not a valid int here
)
```

A new public alias ``kalshi.StrictInt = Annotated[int, BeforeValidator(...)]``
is exported for downstream models that want the same guard.

### Breaking: `KALSHI-ACCESS-*` rejected in `extra_headers`

Per `#298`, the SDK promised in docs that auth headers could not be forged
via ``extra_headers``. That promise relied on a case-sensitive Python dict
merge: a caller-supplied lowercase ``"kalshi-access-key"`` co-existed with
the SDK-signed uppercase ``"KALSHI-ACCESS-KEY"`` and httpx shipped both raw
header lines on the wire — a forge surface even though the documented
contract said otherwise. Now both the per-request ``extra_headers`` kwarg
and ``KalshiConfig(extra_headers=...)`` reject any key (case-insensitive)
that starts with ``kalshi-access-`` at the construction boundary:

```python
# v2.5 (silently shipped two KALSHI-ACCESS-KEY header lines)
client.markets.list(
extra_headers={"kalshi-access-key": "forged"},
)

# v2.6 (raises ValueError naming the leaked keys)
client.markets.list(
extra_headers={"kalshi-access-key": "forged"},
)
# ValueError: extra_headers must not include KALSHI-ACCESS-* keys ...

# Same rejection now fires at construction:
KalshiConfig(extra_headers={"kalshi-access-key": "x"})
# ValueError
```

Companion behavior: `_post(json=...)` / `_put(json=...)` /
`_delete_with_body(json=...)` (and async mirrors) now pin
``Content-Type: application/json`` explicitly. A caller-supplied
``extra_headers={"content-type": "text/plain"}`` previously suppressed
httpx's implicit inference and shipped a JSON body labelled as plain
text; the explicit pin prevents that. Most callers are unaffected.

### Behavioral note: raw `subscribe_orderbook_delta` consumers

Per `#296`, `OrderbookManager._apply_snapshot_inplace` now adopts
``msg.msg.yes`` / ``.no`` by identity for the recv-loop hot path. Raw
consumers of ``subscribe_orderbook_delta`` who receive an
``OrderbookSnapshotMessage`` and hold a reference to ``msg.msg.yes`` /
``.no`` will observe future delta mutations through that reference:

```python
# v2.6 raw subscriber
async for msg in stream:
if isinstance(msg, OrderbookSnapshotMessage):
held = msg.msg.yes # ← LIVE dict, mutated by next delta
```

The high-level ``client.orderbook(ticker)`` / ``subscribe_book(ticker)``
iterators are unaffected — both materialize fresh ``Orderbook`` instances
that are safe to retain. The public ``OrderbookManager.apply_snapshot()``
is also unaffected: it defensively copies after the inplace adoption so
the caller-supplied ``msg`` keeps its original dicts. If you need an
immutable snapshot view from the raw stream, copy explicitly:

```python
async for msg in stream:
if isinstance(msg, OrderbookSnapshotMessage):
immutable_yes = dict(msg.msg.yes)
```

## v2.4 → v2.5

v2.5 ships two user-visible breaking changes — both surface bugs that
Expand Down
2 changes: 1 addition & 1 deletion kalshi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,4 +337,4 @@
"Withdrawal",
]

__version__ = "2.5.0"
__version__ = "2.6.0"
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "kalshi-sdk"
version = "2.5.0"
version = "2.6.0"
description = "A professional Python SDK for the Kalshi prediction markets API"
readme = "README.md"
license = { text = "MIT" }
Expand Down
Loading