Releases: TexasCoding/kalshi-python-sdk
v4.0.0
Spec-drift reconciliation against the latest upstream OpenAPI (3.20.0) and AsyncAPI
specs (closes #443). Includes one breaking change: the Self-Clearing-Member
"Klear" API migrated from cookie-session login to Bearer-token auth. Everything
else is additive.
Breaking
- Klear (SCM) auth is now a Bearer token. Upstream removed
POST /log_inand
switched the Klear API toAuthorization: Bearer <admin_user_id>:<access_token>.
KlearClient/AsyncKlearClientnow requireadmin_user_idandaccess_token
at construction (or viafrom_env(), which readsKALSHI_KLEAR_ADMIN_USER_ID/
KALSHI_KLEAR_ACCESS_TOKEN). Removed: thelogin()method, the
is_authenticatedproperty, theclient.authresource, and theLogInRequest/
LogInResponsemodels.KlearAuthis now a Bearer-credential holder
(KlearAuth(admin_user_id, access_token)). Generate a token at
https://klearing.kalshi.com (the "Security" page).
Added
cfbenchmarks_valueWebSocket channel — stream CF Benchmarks reference index
values (e.g.BRTI) with trailing 60-second and final-minute quarter-hour
averages viaKalshiWebSocket.subscribe_cfbenchmarks_value(index_ids=[...]). New
modelsCFBenchmarksValueMessage/CFBenchmarksValuePayload/
CFBenchmarksAvgData/CFBenchmarksIndexListMessage/
CFBenchmarksIndexListPayload(exported fromkalshi.ws.models).AccountResource.upgrade()—POST /account/api_usage_level/upgradeto
request a permanent Advanced API usage-level grant.AccountApiLimits.grants— the per-exchange-lane usage-level grant list, plus
a newApiUsageLevelGrantmodel (exchange_instance/level/source/
expires_ts), exported fromkalshi.MarginAccountResource.api_limits()—GET /account/limits/perpsfor the
Perps API tier limits (reusesAccountApiLimits).- Perps market notional/leverage fields —
MarginMarketgains
leverage_estimatesandvolume/volume_24h/open_interestnotional-value
fields;MarginMarketCandlestickand thetickerWS payload gain notional-value
fields, all tracking the spec.
Changed
- Re-vendored
specs/openapi.yaml,specs/asyncapi.yaml,specs/perps_openapi.yaml,
specs/perps_asyncapi.yaml, andspecs/perps_scm_openapi.yaml; the subaccount
range documented in prose is now 1–63 (no validation change).
v3.3.0
Adds a complete FIX protocol subsystem (FIXT.1.1 / FIX50SP2) for both the
prediction and perps products. Additive release: no changes to the existing REST,
WebSocket, or Perps surfaces.
Added
- FIX engine (
kalshi.fix) — a hand-rolled, async-first FIX client for both
products:FixClient(prediction) andMarginFixClient(margin, in
kalshi.perps.fix), re-exported from the top-levelkalshipackage alongside
FixConfig,FixEnvironment, andFixSessionType. Covers five session types —
order entry (NR/RT), drop copy, market data, post-trade settlement (prediction),
and RFQ (prediction), plus order-group management over the order-entry session —
on a session state machine with
RSA-PSS logon (reusing the REST key), heartbeat / test-request liveness,
sequence tracking with gap-fill / resend on the retransmission sessions, and
AWS-style full-jitter reconnect. (Epic #402.) - Typed FIX messages — Pydantic v2 models for every Kalshi FIX message across
order entry, order groups, market data, RFQ/quoting, drop copy, and market
settlement, withDollarDecimal/FixedPointCountmoney types (no float
drift) and a central inbound dispatch viadecode_app_message. FixOrderBook— aggregated order-book reconstruction from market-data
snapshots + incrementals;SettlementReassembler— paginated
settlement-report reassembly.FixSession.on_decode_errorhook +decode_app_message_strict— surface a
registered-but-malformed inbound message instead of silently dropping it (#432).- FIX documentation — new
docs/fix.mdguide, plus FIX coverage in the
README, errors, authentication, configuration, environment-variables, and the
API reference. - FIX dictionary drift test —
specs/kalshi-fix-dictionary.xmlchecked in
with a model↔dictionary drift test, the FIX analogue of the REST contract-drift
suite (#437).
Fixed
- Documentation accuracy pass:
fcm.positions_all()is now documented (the FCM
page previously stated it did not exist); theportfolioreads document their
subaccountparameter; themarkets.is_block_tradeversion note is corrected
to SDK v3.1.0; theOrderPriceandMultiplierDecimaltypes are documented;
and the docs landing page's WebSocket channel count is corrected to 11.
v3.2.0
Adds full SDK support for the Kalshi Perps (margin) API — a separate
perpetual-futures exchange — as standalone clients alongside the existing
prediction-API surface. Additive release: no changes to KalshiClient.
Added
PerpsClient/AsyncPerpsClient— standalone clients for the perps
exchange (external-api.kalshi.com/ demoexternal-api.demo.kalshi.co,
/trade-api/v2), with their ownPerpsConfigand a separateKALSHI_PERPS_*
credential namespace. They reuse the prediction-API RSA-PSS signer and HTTP
transport unchanged. The constructors andfrom_env()also accept
ws_base_url(set the WS endpoint independently from REST) andpassword
(passphrase for an encrypted key), and readKALSHI_PERPS_WS_BASE_URL/
KALSHI_PERPS_PRIVATE_KEY_PASSPHRASEfrom the environment; passingconfig=
together withdemo/base_url/ws_base_urlis 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 /
notional risk limit / fee tiers),funding(rate estimate / historical /
history), andtransfers(intra-exchange-instance + margin subaccounts).
Margin order side isbid/ask; prices areDollarDecimal
(FixedPointDollars), countsFixedPointCount, andnumber/doubleratios
(leverage, funding rate, ROE, fee tiers) areMultiplierDecimal(exact
Decimal, string-serialized) — consistent across the REST, WS, and exchange
surfaces. Margin-account list responses tolerate a server-returnednull.PerpsWebSocket— the perps margin WebSocket
(external-api-margin-ws.kalshi.com,/trade-api/ws/v2/margin) with six typed
channels (subscribe_orderbook_delta,subscribe_ticker— carrying
funding_rate+next_funding_time_ms,subscribe_trade,subscribe_fill,
subscribe_user_orders,subscribe_order_group). Reuses the event-contract
WS connection / sequence-gap / backpressure machinery; perps WS timestamps are
Unix epoch milliseconds (*_msfields).KlearClient/AsyncKlearClient— the Self-Clearing-Member "Klear"
settlement API (api.klear.kalshi.com/ demodemo-api.kalshi.co,
/klear-api/v1) with a third auth model: cookie-session + MFA via
login(email=..., password=..., code=...). Resourcemargincovers reports,
active/historical obligations, settlement estimate, settlement + guaranty-fund
balances, settlement-balance history, and settlement-balance withdrawal.
Klear money fields are integer centicents; the single real-money write
(withdraw_settlement_balance) validates a positive amount at construction.
Credentials and the session cookie are never logged or shown inrepr().docs/perps.md(+ mkdocs nav), README "Perps (margin) trading" section, and
runnableexamples/perps_create_order.py/perps_stream_ticker.py/
perps_balance_risk.py.
Changed
- Prediction-API list endpoints
markets.candlesticks/bulk_candlesticks/
bulk_orderbooksnow validate a typed response envelope: a missing
spec-required array key raisesValidationError(surfacing spec drift instead
of silently returning[]), while a null array coerces to[](Kalshi's
empty-as-null convention — the priordata.get(...)extraction would
TypeErroron a null array). The perpsmarkets.list/markets.candlesticks
/funding.historical_rates/funding.historyresponses use the same
NullableListenvelopes, so null-handling is consistent across both surfaces.
The optionalorder_groups.liststays 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
- Vendored the three perps specs (
specs/perps_openapi.yaml,
perps_asyncapi.yaml,perps_scm_openapi.yaml);scripts/sync_spec.pyand the
weekly spec-sync workflow now fetch/diff/checksum them and fold their sha256
into the drift fingerprint (preserving thecontents: read+issues: write
security model). - Parameterized the contract-drift harness per spec:
TestPerps*Drift/
TestPerpsScm*Driftvalidate the perps REST + SCM surfaces against their own
specs, alongside the existing prediction-API drift suites. - README /
docs/index.mdbanners note the perps surface (34 REST operations,
6 WS channels, 10 SCM operations).
v3.1.0
OpenAPI + AsyncAPI spec sync from v3.19.0 → v3.20.0 (#385). Adds the
new GET /events/fee_changes endpoint plus three smaller additive
changes upstream re-published into 3.20.0 after the drift issue was
filed (the OpenAPI checksum in #385 is therefore stale; the AsyncAPI
checksum still matches).
Added
events.fee_changes()/fee_changes_all()(sync + async) for
GET /events/fee_changes— the new paginated event-level fee-override
feed (event_ticker,limit,cursor). ReturnsPage[EventFeeChange]
(and an auto-paginating iterator).EventFeeChangeexposes
fee_type_override/fee_multiplier_override, both present-but-None
when an override is cleared.is_block_tradequery param onmarkets.list_trades/
list_all_trades(+ deprecatedlist_trades_all) and
historical.trades/trades_all. Omit for all trades,Truefor
only block trades,Falsefor only non-block.Trade.is_block_trade(bool) — new spec-required, non-nullable
response field; a missing key raises so a schema regression surfaces.- WebSocket
event_fee_updatemessage on the existing
market_lifecycle_v2channel (EventFeeUpdateMessage/
EventFeeUpdatePayload).subscribe_market_lifecycle()now yields
MarketLifecycleMessage | EventFeeUpdateMessage. Channel count is
unchanged (still 11) — this is a second message type on an existing
channel. Behavioral note for existing subscribers: discriminate on
.typebefore reading payload fields — anEventFeeUpdatePayloadhas no
market_ticker, so naive access raisesAttributeError. See the
migration callout indocs/websockets.md.
Internal
specs/openapi.yaml(sha256
b72a2aa138695d810f6ca85096bfe19e1b66ba5e9b2ed37753be284b5288d271)
andspecs/asyncapi.yaml(sha256
2f72d0a3fd25fe331210ed300f03ad4c1fedcb561b3ab425046b2dca6f4683ec)
snapshots bumped;kalshi/_generated/models.pyregenerated.- Spec also relaxed
CreateOrderV2Request.client_order_idand
EventData.product_metadatafrom required to optional, and added
ApiKeyScope/FeeTypeenums. No SDK-facade change: the V2 order
keepsclient_order_idrequired by design,Event.product_metadata
already tolerated server omission, and API-keyscopesstaysstr
for forward-compat. - README +
docs/index.mdbanners bumped to "99 operations … OpenAPI
v3.20.0".
v3.0.1
OpenAPI spec sync from v3.18.0 → v3.19.0 (#383). Single additive
request-side constraint on GET /structured_targets: the ids query
param gained maxItems: 2000. Mirrored at the SDK boundary so an
oversize filter fails fast with a clear ValueError instead of paying
a network round trip for a 400. No endpoint, channel, or response-model
changes; regenerated kalshi/_generated/models.py is byte-identical to
the v3.18.0 output.
Changed
StructuredTargetsResource.list/list_alland their async
counterparts now raiseValueError("ids accepts at most 2000 entries per spec ...")whenidsexceeds 2000 entries. The 2000 boundary
is inclusive (matches specmaxItems). Mirrors the existing
live_data.batch(milestone_ids, max 100) and
markets.bulk_*(tickers, max 100) precedent.
Internal
specs/openapi.yamlsnapshot bumped (sha256
5eaeca6bb64b2ff0aa4f63f9e13381da5a8f6d8f9b34328408499a0503a3085d).- README +
docs/index.mdbanners bumped to "OpenAPI v3.19.0".
v3.0.0
Public-API rename release. Three breaking-rename issues (#348, #349, #351)
that were deferred from the v2.7.0 audit closure now land with one-release
deprecation aliases: both old and new spellings work in v3.0.0; old names
emit DeprecationWarning and will be removed in a future release (v3.1+).
This is the first release in the v3 line. The wire protocol is unchanged from
v2.7.0; v3 is purely a public Python API ergonomics break.
Migration
See docs/migrations/v2-to-v3.md for full BEFORE/AFTER snippets and a one-page
search-and-replace cheat sheet.
Breaking changes
All three changes ship with @typing_extensions.deprecated aliases on the old
names — existing v2.x callers continue to work, but get a DeprecationWarning
on every call until they migrate.
-
CommunicationsResourcesub-namespaces (#348). Flat noun-prefixed
methods (list_rfqs,get_rfq,create_rfq,delete_rfq,list_all_rfqs,
list_quotes,get_quote,create_quote,delete_quote,accept_quote,
confirm_quote,list_all_quotes) are split into two sub-resources matching
the OpenAPI v3.18.0 tag structure:# v2.x (deprecated in v3.0.0) client.communications.list_rfqs(...) client.communications.create_rfq(...) client.communications.accept_quote(quote_id, accepted_side="yes") # v3.0.0+ client.communications.rfqs.list(...) client.communications.rfqs.create(...) client.communications.quotes.accept(quote_id, accepted_side="yes")
The misc
client.communications.get_id(...)stays at the top level (no
sub-noun). The new sub-resource classesRFQsResource,QuotesResource,
AsyncRFQsResource,AsyncQuotesResourceare exported fromkalshi/__init__.py
for type annotations. -
MarketsResource.list_trades_all→list_all_trades(#349).
Standardizes onlist_all_<noun>matching the other three resources
(CommunicationsResource.list_all_rfqs,SubaccountsResource.list_all_transfers,
etc.).list_trades_allremains as a deprecated alias.# v2.x (deprecated in v3.0.0) for trade in client.markets.list_trades_all(ticker="..."): ... # v3.0.0+ for trade in client.markets.list_all_trades(ticker="..."): ...
-
OrdersResource.fills/fills_all→PortfolioResource.fills/
fills_all(#351). The endpoint URL is/portfolio/fills; this aligns
the SDK layout with the URL family (portfolio.settlements,
portfolio.deposits,portfolio.withdrawals). The oldOrdersResource.fills
/fills_allremain as deprecated aliases.# v2.x (deprecated in v3.0.0) page = client.orders.fills(ticker="...") # v3.0.0+ page = client.portfolio.fills(ticker="...")
Polish
_fills_paramsdeduped tokalshi/resources/_base.py(was duplicated in
orders.pyandportfolio.pyduring the relocation).
Internal
- 78 new regression tests covering the deprecation-alias delegation and warning
emission across sync + async pairs for every renamed method (12 communications
forwarders × 2, plus markets + fills × 2 each). tests/_contract_support.pyMETHOD_ENDPOINT_MAPregisters both old and new
spellings for the duration of the alias window.
Deprecation removal schedule
The v3.0.0 aliases will be removed no sooner than v3.1.0. Callers should
migrate before then. Each deprecated method has a @typing_extensions.deprecated
decorator that surfaces in type checkers and IDEs per PEP 702.
v2.7.0
Post-v2.6 independent multi-LLM reviewer audit closure. 47 issues filed
(#311–#357) by a 9-reviewer parallel pass combining 7 internal specialist
agents (security/auth, HTTP transport, WebSocket, models/contracts, sync/async
parity, performance, resources/API) with fresh-eyes external LLM reviews via
the Codex CLI (GPT-5) and Gemini CLI. 44 issues closed in this release across
4 sequential waves (W0 docs, W1 HIGH integrity, W2 MEDIUM correctness, W3 LOW
polish/perf/deps); 17 PRs merged. 3 breaking-rename issues (#348, #349,
#351) deferred to v3.0.0 milestone. Main is mypy --strict clean, ruff
clean, 2876 unit tests passing (≈100 new regression tests added).
Breaking changes
Five behavioral fences; all surface bugs that were silently wrong or invariants
the SDK now enforces. Per project policy (established by v2.0–v2.6),
bug-surfacing behavioral fences ship in minor releases; intentional public-API
removals or renames are reserved for major releases — that's why the three
breaking renames (#348, #349, #351) are deferred to v3.0.0.
AmendOrderRequest.side/.actionnarrowed toLiteral(#312).
Mirrors the v2.5#270narrowing onCreateOrderRequest. Previously any
string passed validation and the server rejected with a 400; now invalid
values raisepydantic.ValidationErrorat construction.KalshiConfig.extra_headersimmutable post-construction (#313). The
#298KALSHI-ACCESS-*fence ran once at construction; post-construction
mutation ofextra_headerscould reopen the auth-header forge surface.
extra_headersis now stored asMappingProxyTypeover a defensive copy
—config.extra_headers["k"] = "v"raisesTypeError. Construction-time
fence unchanged.CommunicationsResource.list_rfqs/list_quotesstatuskwarg
narrowed toLiteral(#324). NewRfqStatusLiteral/QuoteStatusLiteral
match the spec's closed set; arbitrarystris rejected by mypy at the
call site. Consistent with the existingOrdersResource.list(status=...)
pattern.to_decimal()rejectsNaN/Infinity(#325). The public helper
promised safety in its docstring but accepted anyDecimal. Now raises
ValueErroron non-finite inputs, matching the_coerce_decimalvalidator.DollarDecimalrequest-side fields reject negative prices and
sub-tick precision (#343).CreateOrderRequest,
AmendOrderRequest,CreateOrderV2Request,AmendOrderV2Requestnow
reject negativeyes_price/no_priceand sub-$0.0001-tick precision
at construction. Response-sideDollarDecimalis unchanged (servers may
emit any value).
Critical (HIGH-severity money/auth/data-integrity fixes)
KalshiClient.from_envpreserves caller ownership ofKalshiAuth
(#311). Thefrom_envclassmethod (sync and async) overwrote_auth_owned
from the input kwarg instead of recomputing from what__init__actually
did. Two real bugs: (a)from_env(key_id=..., private_key=...)with no env
vars set leaked the signThreadPoolExecutorbecause the SDK-built auth
was flagged "not owned"; (b)from_env(auth=my_auth)with env vars set
causedclient.close()to close the caller's still-referenced auth,
raisingRuntimeError("KalshiAuth has been closed")on the next
sign_request. The fix recomputes ownership from__init__'s invariant.- WS
_wait_for_responsewrapsTimeoutErrorasKalshiSubscriptionError
(#314).asyncio.wait_forraises bareTimeoutErroron timeout, which
escapedsubscribe()/unsubscribe()unhandled because only
ConnectionClosedwas caught. Now wrapped withchannel/client_id/
opcontext so consumers branching on SDK exceptions actually see the
expected type. - WS zombie-subscription cleanup on gap-recovery failure (
#315).
broadcast_errornever popped the deadSubscription. A failed
resubscribe_onein_handle_seq_gap(and theKalshiBackpressureError
path) left a zombie sub whose closed queue persisted; the next reconnect's
resubscribe_allresurrected it on the server — silent data loss + a
server-quota leak.broadcast_errornow pops_subscriptionsand
_sid_to_clientso reconnects can't resurrect dead subs.
High-impact correctness
from_envlazytry_from_env()evaluation (#316). The classmethod
eagerly evaluatedKalshiAuth.try_from_env()even when the caller passed
auth=/key_id/private_key/private_key_path. A malformed
KALSHI_PRIVATE_KEYin the process env then raised on every CI worker
that bypassed env. Now gated oncaller_supplied_auth.sign_requeststrips URL fragment (#317).path.split("?")[0]
stripped query strings but not#fragment. httpx drops fragments before
sending, producing a guaranteed signature mismatch (401) on any path
containing#. Now strips both.Retry-Afterhonored across 408 / 429 / 503 / 504 (#322). Previously
only 429 parsedRetry-After. Per RFC 7231 §7.1.3 the header applies to
all four._parse_retry_afterextracted;retry_afterlifted to
KalshiErrorbase class soKalshiServerErrorandKalshiTimeoutError
also surface the hint.Retry-Afterpath applies jitter (#321). Synchronized clients
hitting the same 429/503 window would stampede the rate limit at the
exact server-suggested moment. Full Jitter is now added on top of the
server floor, capped atretry_max_delay. Preserves RFC compliance
(Retry-Afteris a "no sooner than" hint).- Response body size cap on success path (
#323). The#20316 KB
cap applied only to error responses; the success path was unbounded.
New_enforce_response_body_capenforces the same protection on
non-streaming success bodies via a Content-Length pre-check plus a
post-buffer guard. - V1 order request models gain
ge=0parity (#326).subaccount,
exchange_index,subaccount_numberonCreateOrderRequest,
AmendOrderRequest,BatchCancelOrderRequest, and related V1 + group
models now reject negative integers (matching V2 and the#295sweep). Page._columnshandlesNone-first nullable nested columns (#328).
Detection inspected onlycols[0]; aNone-first nullable nested
BaseModelcolumn skipped themodel_dumppass and broke
to_dataframe/to_polarsfor nullable-Struct schemas.- WS
OrderbookDeltaPayload.tstyped asAwareDatetime(#331).
Missed by the v2.5#270WS datetime sweep. Tightening across
orderbook_delta,user_orders, andcommunicationspayloads. - WS backpressure error closes connection cleanly (
#332).
KalshiBackpressureErrorin the recv loop previously broadcast sentinels
and broke, but left the WS open and_running=True. The next
subscribe_*restarted the recv loop on top of orphan server-side subs.
Now closes the connection and clears_running/ manager refs so the
next session starts fresh.
Performance
- Orderbook materialization via
model_construct(#327).
_BookState.to_orderbookpreviously re-validated every price level through
Pydantic on eachapply_delta. Data is SDK-canonical after the snapshot
validation; switched tomodel_constructto skip the redundant pass. - Zero-delta orderbook updates preserve cache (
#347). A no-op delta
(quantity unchanged) used to invalidate the memoizedOrderbookview,
defeating the#244cache. Now skipped when the price-level dict is
unchanged. - Public
apply_snapshotsingle-copy (#344). The public path
allocatedyes/nodicts twice — once via identity in
_apply_snapshot_inplace, then again viadict(msg.msg.yes)to copy
defensively. The bypass path (recv hot loop,#296) keeps identity
adoption; the public path now copies once. - V2 batch endpoints use bytes-fast-path (
#329).batch_create_v2/
batch_cancel_v2paid the dict-walk serializer twice; the v2.4#223
fast-path was never extended. Now serialize viamodel_dump_jsononce
and send the bytes. - RSA-PSS / MGF1 / SHA256 config cached (
#345). Previously
allocated per signature. Hoisted to module-level constants — measurable
on the auth hot path. SequenceTracker.tracksync fast-path (#330). New public
track_synclets the recv hot loop skip the per-frame coroutine-object
allocation; the async wrapper remains for callers that need to await on
the gap path.- WS recv
asyncio.timeoutswap (#356). Replacedasyncio.wait_for
in the recv hot path withasync with asyncio.timeout(...)(Python 3.11+)
— fewer TimerHandle allocations per frame. method.upper()hoisted out of retry loop (#342). Computed once
per request instead of once per retry attempt.
Polish
AsyncTransport.close()cancellation-safe (#333). Sets_closed
afteraclose()returns, not before — cancellation between the two no
longer leaks the httpx pool. Mirrors the v2.6#301sync fix._delete_with_body(params=...)symmetry (#340). The body-only
variant gains theparamskwarg that_delete_with_body_jsonalready
accepted.- Pagination cycle detector catches non-adjacent loops (
#352). The
prior detector only caught adjacent cursor repeats; anA→B→A→Bloop
from a load-balanced pod-state drift now raises before exhausting
max_pages. Memory-bounded at 1024 seen cursors. - WS
_handle_orphan_subscribedcorrelation (#354). Server
unsubscribedacks for sids the client never owned no longer clobber
unrelated state. - WS
ConnectionManager.reconnectexc_info on failure (#355). Per-
attempt failures now log withexc_info=Trueat DEBUG so root cause
(auth / TLS / DNS) survives to max-retry. - WS
_stop()teardown order (#357). Closes the connection FIRST,
then broadcasts sentinels — late in-flight frames no longer land on
closed queues.close()wrapped in `try...
v2.6.0
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.
intrequest fields rejectbool(#295). Per-fieldStrictInt
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_numberacross V1 + V2.
boolis anintsubclass, so a caller passingTrue/Falseused to
silently route to subaccount 1 / transfer 1 cent / decrease by 1 contract
with no error. Now raisesValidationErrorat construction. The existing
buy_max_costvalidator (#243) is unchanged. Newkalshi.StrictInt
alias is exported for downstream models.KALSHI-ACCESS-*inextra_headersis rejected (#298). Both
KalshiClient(..., config=KalshiConfig(extra_headers=...))at
construction time and per-requestextra_headers=kwargs now raise
ValueErrorif any key (case-insensitive) starts withkalshi-access-.
Previously a caller-supplied'kalshi-access-key'(lowercase) co-existed
with the SDK-signedKALSHI-ACCESS-KEYand httpx shipped both raw header
lines — a forge surface even though the documented contract promises
auth headers are SDK-managed.
Critical (money-risk fixes)
intrequest fields rejectbool(#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 pinContent-Type: application/jsonexplicitly,
preventing a caller-supplied'content-type': 'text/plain'in
extra_headersfrom 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-usedconnect(),
orphaning the outer session's subscriptions and recv task with no error.
Now raisesRuntimeErrorwith a clear message._stop()clears the
manager refs after teardown so the same instance can be cleanly reused
for a freshconnect()once the prior session exits. A partial connect
failure (auth/network) also resets state cleanly via aBaseException
cleanup block, so a failed connect no longer permanently bricks the
instance.
High-impact correctness
KalshiConfig.extra_headersvalidated at construction (#298). Closes
the construction-time bypass that survived the per-request guard.- Case-insensitive header merge (
#298). New_ci_mergeensures 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
publicapply_snapshot()call — the manager defensively copies the
adopted dicts. The recv loop continues to skip the copy via
_apply_snapshot_inplacefor 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_inplacewere 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
_closedflag matches theKalshiAuth/KalshiClientpattern; second
and subsequentclose()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#242footgun
for readers (#299).docs/configuration.md: reference table now liststotal_timeout,
ws_ping_interval,ws_close_timeout, andallow_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 samebool-rejection guard (#295).kalshi/_constants.py(internal) — holdsAUTH_HEADER_PREFIXto
eliminate drift between_base_client.pyandconfig.py(#298).
v2.5.0
Post-v2.4 multi-reviewer audit closure (#273). 34 issues across security,
HTTP transport, WebSocket reliability, models/types, REST resources,
performance, and docs/testing — identified by a 7-agent parallel review on
top of the v2.4 sweep and executed across 4 sequential waves (W0 docs, W1
money-risk, W2 medium, W3 polish) in disjoint git worktrees. 20 PRs merged;
main is mypy --strict clean, ruff clean, 2742 unit tests passing.
Breaking changes
Two user-visible breakages, both fence-and-forget. Migration in
docs/migration.md v2.4 → v2.5 section.
orders.create()kwarg path requirescountandactionexplicitly
(#242). Previouslyclient.orders.create(ticker=..., side="yes")placed
a 1-contract live buy becauseactiondefaulted to"buy"andcountto
1. Now raisesTypeErrorbefore any HTTP request. Therequest=...
overload is unaffected;CreateOrderRequest.countno longer has a default.- Six REST + three WS model fields widened from
str/floattoDecimal
(#258,#259). WS:OrderGroupPayload.contracts_limit(str→
FixedPointCount),TickerPayload.dollar_volumeanddollar_open_interest
(str→DollarDecimal). REST:Market.floor_strike,Market.cap_strike,
Event.fee_multiplier_override,MarketLifecyclePayload.floor_strike,
Series.fee_multiplier,SeriesFeeChange.fee_multiplier(bareDecimal
orfloat→DollarDecimal/Decimalvia_coerce_decimal). Wire
format unchanged; consumers must adoptDecimalarithmetic.
Critical (money-risk fixes)
- WS: validation failure on a sequenced frame no longer silently advances
the seq watermark (#241). Before: a malformedorderbook_delta/
order_group_updateframe was logged + skipped but seq tracking had
already advanced, so the next legitimate frame matched expected-seq and
gap detection never fired — local orderbook silently corrupted with no
resync trigger. After: pre-validate + apply + dispatch is wrapped in a
try/except that rolls back the watermark on any exception, so the next
delta triggers a real gap-recovery resubscribe. - REST/WS split-environment combinations rejected at construction
(#239). Before:KalshiClient(demo=True, base_url="https://api.elections.kalshi.com/...")(or the env-var
equivalent) silently produced a config where REST hit production but WS
hit demo — a WS-driven strategy could trade real money against a demo
book. After:KalshiConfig.__post_init__rejects mismatched REST/WS
hosts and the constructors raiseValueErrorwith both inputs named. CreateOrderRequest.buy_max_costvalidator rejectsbool(#243).
Before:buy_max_cost=Trueslipped through as1(1¢ cap) because
boolis anintsubclass. After: explicit rejection, matching the
_coerce_decimalinvariant set by v2.4's#225.orders.create()no longer silently defaults to 1-contract buy
(#242, see Breaking changes).- Transport retries network-level httpx errors on idempotent verbs
(#240).ConnectError/NetworkError/RemoteProtocolError/
ReadError/WriteErrornow participate in the sameRETRYABLE_METHODS- backoff + total-timeout loop the timeout branch already uses for
GET/HEAD/OPTIONS.ConnectErroron POST/DELETE is also safe (request
never reached the wire, mirroring v2.4's#204PoolTimeout carve-out).
Other transport errors on non-idempotent verbs still surface immediately
so the caller can reconcile viaclient_order_id. New
KalshiNetworkErrorraised when retries are exhausted.
- backoff + total-timeout loop the timeout branch already uses for
High-impact correctness
- Errors
408and504route toKalshiTimeoutError(#251). Carries
the "may or may not have committed" semantic from v2.4's#226; callers
can branch onexcept KalshiTimeoutErrorand reconcile. - Suppressed error bodies preserve the typed exception class (
#252).
A 429/401/409/422/504 whose body exceeds the 16KBContent-Lengthcap
still routes to the right subclass instead of degrading to
KalshiError. 429 Retry-After is still populated. - WS: resubscribe-window stash drained on every gap recovery (
#254).
Frames captured between unsubscribe-ack and the new subscribe-ack are
replayed through_process_framefor the new sid (filtered by sid;
others dropped with a debug log). Was previously only drained on full
reconnect, so per-gap stashes accumulated until the next disconnect. - WS: stale orderbook frames no longer mutate the local book (
#255).
Snapshot/delta apply is gated on subscription-existence at the configured
sid; frames arriving after teardown short-circuit before validation,
fixing a race where the high-levelsubscribe_bookiterator could read a
stale book. - WS:
_OrderbookIteratorraisesKalshiOrderbookUnavailableError
instead of yielding an emptyOrderbook(#257). Before: if
mgr.get(ticker)returnedNonemid-resync, the iterator yielded an
empty book — indistinguishable from a real zero-liquidity market. After:
typed error surfaces the race so the caller can reattach to a fresh
iterator after the resync snapshot lands. - WS:
KalshiBackpressureErrorcarries structuredchannel/sid/
client_id/maxsize(#256). Matches the structured-error contract
v2.4's#213established forKalshiSequenceGapError/
KalshiSubscriptionError. - WS: orphan
subscribedacks released (#268). If asubscribetask
is cancelled between send and ack, the server still completes the
subscription; the dispatcher now detects the orphan ack (sid present, no
client_id mapping) and emitsunsubscribeso the sid doesn't leak. - WS: snapshot payload
yes/norequired (#268). A malformed
snapshot now raisesValidationError(which pairs cleanly with#241's
seq rollback) instead of silently materializing an empty book. - Auth: conflicting key inputs rejected explicitly (
#249).
KalshiClient(private_key_path=..., private_key=...)and dual env-var
(KALSHI_PRIVATE_KEY+KALSHI_PRIVATE_KEY_PATH) now raise instead of
silently preferring one source. - Unknown
base_urlhost fails closed by default (#250). New
KalshiConfig.allow_unknown_host=Falserejects hosts outside
{api.elections.kalshi.com, demo-api.kalshi.co, localhost, 127.0.0.1, ::1}so a typo likekalsi.comno longer delivers signed requests to
an attacker. Opt-in via the field orKALSHI_ALLOW_UNKNOWN_HOST=1. Decimal('NaN')/Infinityrejected at the boundary (#270).
_coerce_decimalcallsis_finite()after coercion so a downstream
arithmetic NaN never ships"NaN"to a real-money order endpoint.- Sign-executor close race fixed (
#267)._get_sign_executor
rechecks_closedunder the lock so a racingclose()can't leave a
freshly-instantiatedThreadPoolExecutordangling.
Performance
- WS recv loop drops per-frame
asyncio.Task+asyncio.shield
(#245). Cooperative pause viaEvent+ 50 ms poll instead of
allocating aTask/Future/ contextvars copy per frame. Inline
dispatch on the hot path. subscribe_bookiterator caches the materializedOrderbook
(#244). Memoized on_BookState, invalidated by the in-place apply
helpers; eliminates the O(n log n) sort + 2NOrderbookLevel
validations per delta that the high-level iterator was paying on top of
v2.4's#199in-place fast path.- WS snapshot apply collapses to a single dict walk (
#263).
OrderbookSnapshotPayload.yes/.novalidate directly into
dict[Decimal, Decimal]via aBeforeValidator;_apply_snapshot_inplace
adopts the dict in identity (no rebuild). ~5× faster on a 200-level book. Page.to_dataframe/to_polarsbuilt column-oriented (#264).
Replaces per-rowmodel_dump(mode="python")with a single getattr-driven
column build. Preserves the v2.4 Decimal contract; nested-model cells
still dumped per-column so polars Struct inference works.- REST has a pluggable JSON loader (
#260). New
KalshiConfig.rest_json_loadsmirrors the existingws_json_loads; set
toorjson.loadsfor ~2–3× faster list-endpoint parsing. - Signing path skips regex on the common case (
#261).
_normalize_percent_encodingshort-circuits when no%appears. - Header merge hoisted out of the retry loop (
#262). Only
auth_headerschanges per attempt; the 3-wayconfig_extra + per_call_extra + body_headersmerge is now precomputed once per
request, saving N-1 dict copies across retries. MessageQueue._sizecounter dropped (#271).qsize()derives from
len(self._buffer)adjusted for the sentinel; two ints saved per put/get
on the WS hot path.import asynciohoisted out ofAsyncTransport.request(#271).
Stray per-requestsys.moduleslookup eliminated.
Configuration knobs (additive)
KalshiConfig.rest_json_loads(#260).KalshiConfig.allow_unknown_host+KALSHI_ALLOW_UNKNOWN_HOSTenv var
(#250).extra_headers=plumbed through every public REST resource method —
302 method signatures via codemod (#253).KALSHI-ACCESS-*signing
headers always win, so callers cannot forge them via this surface.
Typed-exception expansion
- New
KalshiNetworkError(#240) — exhausted retries on a network-level
httpx error. - New
KalshiOrderbookUnavailableError(#257) —_OrderbookIterator
race wheremgr.get(ticker)returnedNonemid-resync. KalshiBackpressureErrorgainschannel/sid/client_id/
maxsizekeyword-only fields (#256).
Models, types, request shape
- WS
user_orders+communicationspayloads usepydantic.AwareDatetime
(#270). Closes the gap left by v2.4's#234REST sweep — naive RFC3339
strings now raiseValidationErroron WS too. - V1
CreateOrderRequestenum-style fields narrowed toLiteral[...]
(#270). Closes the V1/V2 strictness gap; ...
v2.4.0 — multi-reviewer SDK audit closure
Comprehensive multi-reviewer audit (#224) — 33 issues across security, HTTP
transport, WebSocket reliability, models/types, resources, performance,
testing, and documentation. Identified by an 8-agent parallel review; executed
across 4 sequential waves of disjoint git worktrees. The most impactful items
by category:
Critical (silent data loss / silent money corruption fixes)
- WS orderbook resync after sequence gap (
#189). Before: a single dropped
frame cleared the local book and never asked the server for a fresh
snapshot — the consumer kept receiving deltas against a permanently-empty
book. After:_handle_seq_gapdrives a real unsubscribe+resubscribe with
per-sid ticker tracking so all-markets subscriptions are also covered. Page.to_dataframe/Page.to_polarsDecimal preservation (#190).
Before:DollarDecimal/FixedPointCountserializers ran for
mode='python'too, so DataFrame columns heldstranddf['price'].sum()
returned concatenated strings instead of a numeric sum. After: serializers
usewhen_used='json'; liveDecimalflows through pandas/polars.
High-impact correctness (HTTP + WS + Decimal + V1 orders)
- DollarDecimal serialization is positional (
#191)._decimal_to_str
usesf'{v:f}'so values likeDecimal('1E+10')never reach the wire as
scientific notation that Kalshi would reject. - Retry policy widened (
#192).RETRYABLE_STATUS_CODESnow includes
408, 425, and the Cloudflare 5xx range (520–524). POST/DELETE still never
retry, preserving idempotency. - Total wall-clock retry budget (
#193). NewKalshiConfig.total_timeout
caps cumulative time spent inside a single request including retries.
None(default) preserves the legacy unbounded behavior. - V1 batch order endpoints surface typed per-leg responses (
#194,
BREAKING).orders.batch_createnow returns
BatchCreateOrdersResponse(waslist[Order]that crashed on any failed
leg).orders.batch_cancelnow returnsBatchCancelOrdersResponse(was
None) exposing per-orderreduced_by_fp. Migration: upgrade reads from
response[i]toresponse.orders[i].order(and check.error). - WS generic
subscribe()rejects unknown param keys (#195). Was
silently dropping typos likeparams={'tickerz': [...]}and subscribing
the consumer to a much broader stream than intended. - WS server-side seq reset detection (
#196).SequenceTracker.track
now distinguishesseq == last(drop) fromseq < last(reset → gap
recovery); was silently dispatching the reset window with no signal. - WS fast-fail on permanent close codes (
#197).ConnectionClosedwith
codes 1002/3/7-10 or 4xxx now raisesKalshiConnectionErrorimmediately
instead of burning the 10-retry budget on doomed reconnect attempts. - WS payload type alignment with REST (
#198).*_fpcount/size/volume
fields on every WS payload model now type asFixedPointCount; RFC3339
timestamps type asdatetime. Eliminates silent str+int TypeErrors when
consumer code mixes REST and WS data. order_group_updatessequence gap recovery (#205). Same resubscribe
helper as orderbook gaps; was missed events with no signal.- WS unsubscribe drops orderbook state (
#206). Long-running
subscribe/unsubscribe cycles no longer leak_BookStateentries. - ERROR backpressure strategy raises through iterator (
#207). Consumer
async fornow raisesKalshiBackpressureErrorinstead of terminating
silently (indistinguishable from a clean close).
Performance
- WS recv loop stops rebuilding+discarding orderbook snapshots (
#199).
New_apply_*_inplacevariants onOrderbookManagerskip the O(n log n)
sort + ~2N OrderbookLevel allocations on the per-frame hot path. - Pluggable JSON loader/dumper (
#209).KalshiConfig.ws_json_loads/
ws_json_dumpsallow opt-in toorjson/ujsonfor high-rate
streaming (default: stdlibjson). - WS reconnect uses AWS Full Jitter (
#221polish). Matches the REST
policy; eliminates the thundering-herd window at the capped-delay end. - Batch order bodies serialized once (
#223polish). Resource layer
routes batch_create/batch_cancel through new_post_json/_delete_with_body_json
bytes helpers that usemodel_dump_json+httpx content=, skipping one
full dict-walk per call. _list_allcursor-loop guard is O(1) (#223polish). Switched from
unboundedset[str]to singlelast_cursor(only catches realistic
server-replay shape).
Security & robustness
- Response-body buffering bounded (
#203)._map_errorcaps via
Content-Length(16KB) and truncates the exception message to 1024
chars. Prevents memory + log-volume blowup on hostile error payloads. base_urlvalidated to include/trade-api/v2(#202). Misconfigs
fail at construction instead of producing silent 401s from a corrupted
signing path.- Passphrase-protected PEMs supported (
#217).KalshiAuth.from_pem/
from_key_path/from_envacceptpassword=(str/bytes/callable);
KALSHI_PRIVATE_KEY_PASSPHRASEenv var. Users no longer need to write
plaintext keys to disk. - URL-encoded path segments (
#211)._seg()helper applied across
every resource — user-supplied IDs with/,?,..etc. are encoded
or rejected at the SDK boundary. - RecordingTransport scrubs response headers (
#220polish).
Set-Cookie, Authorization, andX-Kalshi-*-(id|key|account|user)headers
filtered by default (user-overridable).
Typed-exception expansion
- New
KalshiConflictError(409),KalshiTimeoutError,KalshiPoolExhaustedError
(#201,#204). 422 routes toKalshiValidationError.httpx.PoolTimeout
raisesKalshiPoolExhaustedErrorand IS safe to retry on POST/DELETE
(request never reached the wire) —httpx.TimeoutExceptionraises
KalshiTimeoutErrorand preserves the existing POST/DELETE never-retry
policy (server may have committed). KalshiSequenceGapError+KalshiSubscriptionErrorcarry structured
channel/sid/last_seq/next_seq/opcontext (#213).AuthRequiredErrordefault message mentions both
KALSHI_PRIVATE_KEY_PATHandKALSHI_PRIVATE_KEY(#215).
Configuration knobs (additive, all opt-in)
total_timeout(#193)ws_ping_interval,ws_close_timeout(#208)ws_json_loads,ws_json_dumps(#209)http2install extra (#220polish;pip install kalshi-sdk[http2])- Per-request
extra_headersplumbed through transport (#220polish)
Documentation
docs/migration.mdnow has continuous coverage v1 → v2.3 (was missing
v2.1→v2.2 and v2.2→v2.3 sections;#200) plus a v2.3→v2.4 section
documenting #194's breaking shape and the new typed exceptions.- README +
docs/websockets.mdagree on channel count + use real SDK
method names (#218). - New
docs/websockets.mdPerformance section: queue sizing, overflow
strategy, orjson example, recv-loop threading (#222polish). docs/configuration.md,docs/environment-variables.md, cancel/delete
docstrings, and stale audit/predecessor refs cleaned up (#222polish).pydantic.AwareDatetimeadopted on REST response model datetime fields;
new datetime-semantics note indocs/concepts.md(#221polish).
Testing
- WS hardening: 27+ new tests across orderbook resync, seq reset, close
codes, backpressure signal, unsubscribe cleanup (#231). - Phantom-kwarg behavioral coverage parametrized across all 23 Request
models (#219). - Three new bench harnesses:
scripts/bench_ws_recv.py,
scripts/bench_orderbook_delta.py,scripts/bench_request_hot_path.py
(#223). - Integration
conftest.pyenv-bridging moved from import-time mutation
to a session-scoped fixture for clean test isolation (#223).
Breaking changes summary
Only one user-visible breaking change: orders.batch_create and
orders.batch_cancel return typed response models instead of list[Order]
and None respectively (#194). The V2 family (batch_create_v2 /
batch_cancel_v2) was already shaped this way; the V1 fix brings parity.
Migration in docs/migration.md v2.3→v2.4 section.