perps: full Perps (margin) API — REST + WebSocket + SCM/Klear (#387)#403
Conversation
… spec sync & contract-test harness (#388) Dependency root for the Kalshi Perps (margin) API. Adds standalone PerpsClient / AsyncPerpsClient on the separate perps host, a PerpsConfig, the kalshi/perps/ package skeleton with wired resource stubs, the shared common models, the three vendored perps specs, weekly spec-sync coverage, and the parameterized contract-test harness. No business endpoints — this unblocks every per-resource perps issue (#389-401). Key decisions: - PerpsConfig subclasses KalshiConfig (Liskov) so the reused SyncTransport/ AsyncTransport accept it unchanged; __post_init__ validates the perps hosts/path instead of the prediction-API set. - KalshiAuth + transports reused unchanged (sign str(ts_ms)+METHOD+path). - from_env reads a separate KALSHI_PERPS_* credential namespace. - Perps models/resources are namespaced under kalshi.perps (not flooded into kalshi.__all__) because many perps schema names collide with prediction-API models; only PerpsClient/AsyncPerpsClient/PerpsConfig re-export to top level. - Client resource attrs reconciled to the per-resource issues (authoritative via their contract FQNs): exchange, markets, orders, order_groups, portfolio, margin (#394), funding, transfers (#396 folds subaccounts; #391 folds fcm). - Contract harness parameterized per spec: PERPS_/PERPS_SCM_ method+body maps, shared PERPS_EXCLUSIONS, response-side PERPS_CONTRACT_MAP, and TestPerps*Drift classes resolving against specs/perps_openapi.yaml + specs/perps_scm_openapi.yaml (keyed per spec to sidestep cross-spec ref collisions). _classify_drift / _check_model_drift gained a safe exclusions= param. - sync_spec.py + spec-sync.yml fetch/snapshot/diff/checksum all three perps specs and fold their sha256 into the drift fingerprint, preserving the contents:read + issues:write security model. Verified: ruff clean, mypy --strict clean (94 files), hermetic pytest green (3035 passed, 286 skipped). Part of #387. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the three read-only perps exchange health/access endpoints on PerpsClient.exchange / AsyncPerpsClient.exchange: - GET /margin/exchange/status (public) -> ExchangeStatus - GET /margin/enabled (auth-gated; _require_auth before HTTP) -> MarginEnabledResponse - GET /margin/risk_parameters (public) -> GetMarginRiskParametersResponse number/double ratio fields use MultiplierDecimal (no binary-float drift). Registers the 3 GET entries in PERPS_METHOD_ENDPOINT_MAP and the 3 response models in PERPS_CONTRACT_MAP (response-side drift now active against specs/perps_openapi.yaml). Establishes the canonical perps-resource pattern (models + resource + exports + contract maps + respx tests). ruff clean, mypy --strict clean, pytest green. Part of #387. Closes #389. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the perps markets resource (sync + async) on PerpsClient, reusing the shared transport/auth and the DollarDecimal/FixedPointCount/ OrderPrice/StrictInt/NullableList types. respx unit tests cover happy/error/ edge per public method, sync + async. Part of #387. Closes #390. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…orders (#391) Implements the perps orders resource (sync + async) on PerpsClient, reusing the shared transport/auth and the DollarDecimal/FixedPointCount/ OrderPrice/StrictInt/NullableList types. respx unit tests cover happy/error/ edge per public method, sync + async. Part of #387. Closes #391. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pdate-limit (#392) Implements the perps order groups resource (sync + async) on PerpsClient, reusing the shared transport/auth and the DollarDecimal/FixedPointCount/ OrderPrice/StrictInt/NullableList types. respx unit tests cover happy/error/ edge per public method, sync + async. Part of #387. Closes #392. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the perps portfolio resource (sync + async) on PerpsClient, reusing the shared transport/auth and the DollarDecimal/FixedPointCount/ OrderPrice/StrictInt/NullableList types. respx unit tests cover happy/error/ edge per public method, sync + async. Part of #387. Closes #393. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the perps margin account resource (sync + async) on PerpsClient, reusing the shared transport/auth and the DollarDecimal/FixedPointCount/ OrderPrice/StrictInt/NullableList types. respx unit tests cover happy/error/ edge per public method, sync + async. Part of #387. Closes #394. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…story (#395) Implements the perps funding resource (sync + async) on PerpsClient, reusing the shared transport/auth and the DollarDecimal/FixedPointCount/ OrderPrice/StrictInt/NullableList types. respx unit tests cover happy/error/ edge per public method, sync + async. Part of #387. Closes #395. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…account (#396) Implements the perps transfers resource (sync + async) on PerpsClient, reusing the shared transport/auth and the DollarDecimal/FixedPointCount/ OrderPrice/StrictInt/NullableList types. respx unit tests cover happy/error/ edge per public method, sync + async. Part of #387. Closes #396. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cross-cutting integration for the 8 perps REST resources: re-exports every perps model + resource class from kalshi.perps (namespaced, not flooded into kalshi.__all__ — the names twin the prediction-API models), and populates the per-spec PERPS_METHOD_ENDPOINT_MAP / PERPS_BODY_MODEL_MAP / PERPS_EXCLUSIONS / PERPS_CONTRACT_MAP so the TestPerps*Drift suites validate the resources against specs/perps_openapi.yaml. Corrections vs. the per-resource drafts: dropped exchange_index from order_groups.delete (not a spec param); list_all/list_all_fcm now expose limit (spec param); PriceDistributionHistorical fields are required-key/nullable-value (spec required+nullable); GetMarginFills/TradesResponse left out of the response contract map (cursor spec-required but SDK-Optional for terminal-page safety). Part of #387. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the kalshi/perps/ws/ package — the perps (margin) WebSocket stack — mirroring kalshi/ws/ with ZERO edits to the shared prediction-API WS code. #397 (foundation): - PerpsConnectionManager (subclasses the config-driven ConnectionManager; PerpsConfig carries ws_base_url + ws_* knobs), backpressure re-export, PerpsSequenceTracker (subclass overriding should_track with PERPS_SEQUENCED_CHANNELS), a FORKED PerpsOrderbookManager (bid/ask sides, priceLevelDollarsCountFp levels, MarginOrderbook view) — no change to the shared OrderbookManager. - models/control.py: command params/wrappers (extra=forbid) + response/control envelopes; PerpsChannel/UpdateSubscriptionAction enums. - PerpsSubscriptionManager (perps command grammar: subscribe/unsubscribe/ update_subscription array+single-sid/list_subscriptions) and PerpsWebSocket facade. WS command/response parity test vs specs/perps_asyncapi.yaml. - Found+fixed a recv-loop ConcurrencyError in the cooperative pause handshake. #398 (channels): - Six channel payload/envelope modules (orderbook, ticker incl. funding_rate + marks, trade, fill, user_orders, order_group) with bid/ask sides and epoch-ms _ms timestamps; dispatcher MESSAGE_MODELS wired; six typed subscribe_* helpers on PerpsWebSocket; out-of-scope channels asserted absent. Integration fixes during verification: orderbook manager now consumes the concrete Margin*Message types (Protocol seam removed once #398 landed); subscribe() validates the FULL params dict so unknown keys raise; malformed orderbook levels surface as ValidationError; MESSAGE_MODELS test pollution fixed. PerpsWebSocket re-exported from kalshi.perps and kalshi. Verified: ruff clean, mypy --strict clean (119 files), tests/perps/ws + tests/ws green (510), full hermetic suite 3478 passed. Part of #387. Closes #397. Closes #398. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…400) Adds the kalshi/perps/klear/ package — the Kalshi Self-Clearing-Member "Klear" API (klear-api/v1), a THIRD distinct auth surface (cookie-session + MFA, not RSA-PSS). #399 (auth foundation): - KlearConfig (subclasses KalshiConfig for transport reuse; validates the Klear hosts + /klear-api/v1 path, https-only to remote), KlearAuth (session-state holder, not a signer; redacting repr), KlearClient/AsyncKlearClient. - POST /log_in (security: []): LogInRequest (extra=forbid, redacting repr) + LogInResponse (redacts token). The transport is built with auth=None so no RSA headers are signed; the `session` cookie is captured/replayed by the httpx cookie jar. MFA returns to the caller (no auto-loop); POST is never retried. - KlearSyncResource/_require_session() — a cookie-session auth guard (the RSA _require_auth would always fail under auth=None). #400 (margin endpoints): MarginResource/AsyncMarginResource on KlearClient.margin - All 9 SCM endpoints (the issue listed 8; the spec adds GET /margin/guaranty_fund_balance): reports, active/historical obligations, settlement estimate, settlement + guaranty-fund balances, settlement-balance history, withdraw + withdrawal-status. - Money typing: monetary fields are plain int CENTICENTS; only the withdraw/ withdrawal `amount` is a DollarDecimal dollar-string. ObligationEntry flattens the spec allOf. Two cursor paginators reuse _list/_list_all. Security: passed an adversarial review (credential leakage, cookie confinement, no-retry on login/withdraw, _require_session on every endpoint incl. paginators, MFA non-auto-loop, host/path/SSRF — all clean). One finding fixed: WithdrawSettlementBalanceRequest.amount now rejects non-positive values at construction (the single real-money write), mirroring OrderPrice. Klear clients/models re-exported from kalshi.perps and kalshi; SCM contract-drift maps populated (TestPerpsScm*Drift green vs specs/perps_scm_openapi.yaml). Verified: ruff clean, mypy --strict clean (132 files), full hermetic suite 3581 passed. Part of #387. Closes #399. Closes #400. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ts (#401) Release closeout for the perps (margin) milestone (#387). Docs/examples/tests and release plumbing only — no new SDK models or resource methods. - README: "Perps (margin) trading" section (sync + async quickstarts) + a feature bullet; README + docs/index.md banners note the perps surface (34 REST ops, 6 WS channels, 10 SCM ops). - docs/perps.md (new) + mkdocs nav entry; `mkdocs build --strict` passes. - examples/perps_create_order.py / perps_stream_ticker.py (funding) / perps_balance_risk.py — runnable, demo-defaulted, from_env; import-checked by tests/test_examples_importable.py. - CHANGELOG 3.2.0 entry; version bumped 3.1.0 -> 3.2.0 (pyproject + __version__), minor (additive — no breaking changes to KalshiClient). - Demo integration tests under tests/integration/ for every shipped family (markets, orders, balance/risk, funding, WS, Klear), gated @pytest.mark.integration, auto-skipping without KALSHI_PERPS_* creds and hard-failing unless resolved to the demo hosts (external-api.demo.kalshi.co / external-api-margin-ws.demo.kalshi.co). Perps fixtures + a parallel coverage registry in coverage_harness.py; an autouse session sweep cleans up any resting perps orders. SCM/Klear live calls are gated under integration_real_api_only. - The `perps` GitHub label already exists on the repo. Verified: ruff clean, mypy --strict clean (132 files), mkdocs --strict clean, full hermetic suite 3585 passed / 316 skipped (the perps integration tests auto-skip without demo credentials). Closes #401. Completes #387 (perps milestone: #388-401). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review — Perps (margin) API full implementation (#387 epic)This is a large but well-structured PR. The security posture is strong, the conventions are consistently applied, and the test coverage is comprehensive. I have a handful of substantive findings and a few minor ones. OverviewAdds Findings1.
|
Responds to the automated code review on PR #403. Substantive: 1. Move the Klear SCM margin models + resource under `kalshi/perps/klear/` (`klear/models/margin.py`, `klear/resources/margin.py`) so the entire Klear surface is namespaced under `klear/` — they previously lived in the perps REST dirs despite inheriting the Klear `_require_session` base. Updated all imports, exports, and contract-drift map FQNs. 2. Document in `PerpsConfig` / `KlearConfig.__post_init__` exactly which `KalshiConfig.__post_init__` guards are manually mirrored (since `super()` is intentionally skipped), so a future maintainer keeps them in sync. 3. `_apply_delta_inplace` now routes by exact side; an unknown side is dropped with a warning instead of silently corrupting the ask book. 4. WS orderbook now emits bids **descending** / asks ascending (best-first), matching the spec `MarginOrderbookCount` ordering and the REST orderbook so the same `MarginOrderbook` model is consistent across REST and WS. 5. Extract `_clear_session_state()` in `PerpsWebSocket`, shared by `_start`, `_stop`, and the recv-loop fatal-error teardown (the fatal path previously left `_recv_task` dangling). 6. `margin_reports` validates `YYYY-MM-DD` format + `end_date >= start_date` at the SDK boundary. Minor: - `KlearClient`/`AsyncKlearClient.from_env` now accept `transport` (parity with `PerpsClient.from_env`; enables test injection). - `MarginReport` gets a redacting `__repr__` (the presigned `url` is sensitive). - `LogInResponse` aliases `__str__ = __repr__` (parity with `LogInRequest`). Verified: ruff clean, mypy --strict clean (132 files), full hermetic suite 3585 passed / 316 skipped / 0 failed. Part of #387. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…adlock
Addresses multi-LLM review findings on the perps WebSocket layer:
- F8: centralize the `type:"error"` ack check in `_wait_for_response` so
every command (unsubscribe/update/list — not just subscribe) fails loudly
instead of mutating local state on a server-side error. Also corrects the
`list_subscriptions` op label from "subscribe" to "list_subscriptions".
- F7: `update_subscription`/`update_subscription_single_sid` now persist the
add/remove of markets onto `sub.params`, so a reconnect's `resubscribe_all`
replays the UPDATED market set rather than silently reverting the update.
- F1: move each command wrapper's post-`_pause_recv_loop()` precondition
check INSIDE the `try` whose `finally` resumes the loop — an early
raise/return while parked otherwise abandons the recv loop (deadlock).
F2 (stash-during-all-commands) is intentionally NOT changed here: it is a
documented design tradeoff shared with the prediction WS ("default off so
normal subscribe paths don't accumulate stale state") and warrants a
deliberate cross-WS decision rather than a perps-only divergence.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n errors Addresses multi-LLM review findings on the Klear (SCM) real-money surface: - Re-login auth-bleed: `log_in` now resets the `KlearAuth` state AND drops the stale `session` cookie BEFORE the attempt, so a re-login that returns an MFA challenge can never leave the previous account authenticated (a subsequent withdraw can't be replayed against the wrong account). Adds a small additive `clear_cookie(name)` helper to Sync/AsyncTransport for the cookie drop. - `LogInRequest` sets `hide_input_in_errors=True` so a Pydantic ValidationError can't echo a fat-fingered email/password/MFA secret into logs or tracebacks (the `__repr__` redaction does not cover that path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-auth fail-fast Addresses multi-LLM review findings on the perps REST models + clients: - `GetMarginOrdersResponse.cursor` is now `str | None` — Kalshi omits the key on the final page, so a bare `str` crashed `list_all()` on the last page. Matches the sibling fills/trades responses; adds a server_omits_despite_required exclusion so the required-drift harness stays green. - Order sizes get range guards: `count` (create/amend) and `reduce_by` are `Field(gt=0)`; `reduce_to` is `ge=0` (a target of 0 — decrease to nothing — is valid). Stops a zero-count order burning a client_order_id on a POST. - `transfer_instance` shard kwargs join `_check_request_exclusive`, so passing `request=...` together with an explicit shard raises instead of silently dropping the shard (mis-route risk). Shards default to 0 only on the kwargs path. - `PerpsClient`/`AsyncPerpsClient` fail fast on partial credentials (key_id without key material, or vice versa) instead of silently building an unauthenticated client that 401s later. Regression tests in tests/perps/test_review_fixes.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review — PR #403: Perps (margin) API full implementationOverviewThis is a large, well-structured additive PR. It delivers three independent client surfaces — What's done well
IssuesMedium — Fix before merge1. Stale module docstring in The docstring at the top of
But the actual code (lines 55–63) fully populates 2. Raw dict in
cmd = {
"id": msg_id,
"cmd": "unsubscribe",
"params": {"sids": [sid]},
}
await self._sub_mgr._connection.send(cmd)Every other command path (subscribe, unsubscribe, update, list) goes through the cmd = UnsubscribeCommand(
id=msg_id, params=UnsubscribeParams(sids=[sid])
)
await self._sub_mgr._connection.send(
cmd.model_dump(exclude_none=True, by_alias=True, mode="json")
)
3. Unretrieved task exception → asyncio "Task exception was never retrieved" warning In if self._recv_task and not self._recv_task.done():
with contextlib.suppress(asyncio.CancelledError, Exception):
await asyncio.wait_for(self._recv_task, timeout=2.0)
if not self._recv_task.done():
self._recv_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._recv_taskWhen The fix is to retrieve the exception for an already-done task: if self._recv_task and not self._recv_task.done():
with contextlib.suppress(asyncio.CancelledError, Exception):
await asyncio.wait_for(self._recv_task, timeout=2.0)
if not self._recv_task.done():
self._recv_task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._recv_task
elif self._recv_task is not None:
# Task already done; retrieve stored exception to prevent asyncio warning.
with contextlib.suppress(Exception):
self._recv_task.exception()The same pattern applies to the prediction-API Minor — Nice-to-fix4. Two more stale
5.
Questions / spec clarity
VerdictThe PR is close to mergeable. The security model and test depth are production quality. The three issues above are all in the WS layer: (1) stale docstring, (2) raw dict bypassing the Pydantic guard for orphan-unsubscribe, and (3) unretrieved task exception. Items 1 and 2 are one-liners; item 3 requires a small 🤖 Generated with Claude Code |
…e, _stop exception retrieval, stale docs - `_handle_orphan_subscribed` now builds the unsubscribe via the `extra="forbid"` `UnsubscribeCommand`/`UnsubscribeParams` model (same wire shape) instead of a raw dict, so a key typo fails at construction rather than silently on the wire. - `PerpsWebSocket._stop()` retrieves the exception of an already-done recv task (new `elif ... .exception()`), preventing asyncio's "Task exception was never retrieved" warning when the loop exited on a permanent close code. (The prediction WS has the same bug — tracked in #413, kept out of this PR.) - `update_subscription`/`update_subscription_single_sid` use `builtins.list[str]` (the `list` builtin is shadowed by `list_subscriptions` on this class). - Refresh stale `#398` "deferred / empty stub / until those land" comments in dispatch.py + client.py that described work which has since shipped. `last_trade_size` is left required — verified `last_trade_size_fp` is in the AsyncAPI `required` set. Host-allowlist split is tracked in #412. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code Review — PR #403: Perps (margin) API — REST + WebSocket + SCM/KlearThis is a large additive PR (~23k lines across 116 files). I focused on correctness, security, and maintainability. The implementation is overall well-engineered, but there are a few items worth addressing. What's Done WellSecurity posture is strong:
Architecture:
Issues1.
|
| Severity | Finding |
|---|---|
| Medium | __post_init__ guard duplication — future KalshiConfig guard additions won't propagate to PerpsConfig/KlearConfig |
| Low | _LOCAL_HOSTS defined in three files; belongs in _constants.py |
| Low | id parameter name shadows Python builtin in settlement_balance_withdrawal |
| Cosmetic | KlearConfig inherits unused ws_base_url without documentation callout |
The medium-severity item (#1) is the one I'd most want resolved before merge since it's a silent security-regression risk. The rest are polish. Overall the PR is well-thought-out and the security-critical paths (credential handling, real-money writes, session isolation) are correctly implemented.
🤖 Generated with Claude Code
Perps (margin) API — full SDK implementation
Implements the entire Kalshi Perps (margin) milestone (#387): the perpetual-futures REST + WebSocket surfaces and the Self-Clearing-Member "Klear" settlement API, as standalone clients alongside the existing prediction-API surface. Additive — no changes to
KalshiClient/AsyncKalshiClientbehavior.What's included
PerpsClient/AsyncPerpsClient— separate host (external-api.kalshi.com) +KALSHI_PERPS_*credentials, reusing the RSA-PSS signer + HTTP transport unchanged. 8 resource families (exchange,markets,orders,order_groups,portfolio,margin,funding,transfers), 34 REST operations. Margin order side isbid/ask; pricesDollarDecimal, countsFixedPointCount.PerpsWebSocket— 6 typed channels (tickercarriesfunding_rate+next_funding_time_ms,orderbook_delta,trade,fill,user_orders,order_group_updates); epoch-ms*_mstimestamps; zero edits to the shared prediction-API WS (forked orderbook, subclassed sequence tracker/connection).KlearClient/AsyncKlearClient— a third auth surface (cookie-session + MFA login), 9 SCM settlement endpoints (reports, obligations, settlement/guaranty-fund balances, withdrawal). Money fields are integer centicents.TestPerps*Drift/TestPerpsScm*Drift), 3 vendored specs + weekly spec-sync coverage,docs/perps.md+ README section, 3 runnableexamples/, CHANGELOG + version bump 3.1.0 → 3.2.0, and gated demo integration tests.Verification
ruff check .✓ ·mypy kalshi/(strict) ✓ (132 files) ·mkdocs build --strict✓pytest tests/: 3585 passed, 316 skipped, 0 failed (~660 new tests; perps integration tests auto-skip without demo credentials).withdraw_settlement_balanceaccepting negative/zero amounts) was fixed with a positive-amount boundary guard + regression test.Notable
guaranty_fund_balance) the issue text missed; corrected several issue-vs-spec discrepancies (order_groups.deletehas noexchange_index;orders.list_allneededlimit;PriceDistributionHistoricalis required-but-nullable).PerpsConfig/KlearConfigsubclassKalshiConfig(Liskov) so the transport is reused unchanged. Perps models are namespaced underkalshi.perps(not flooded intokalshi.__all__) since many names twin the prediction-API models.Commit map
Per-issue commits where cleanly separable; WS (#397/#398) and SCM (#399/#400) landed one commit each (their code is intertwined in
dispatch.py/client.py).Closes #388
Closes #389
Closes #390
Closes #391
Closes #392
Closes #393
Closes #394
Closes #395
Closes #396
Closes #397
Closes #398
Closes #399
Closes #400
Closes #401
Completes the #387 epic.
🤖 Generated with Claude Code