From 5a82f77ec06a621e60caa9393c9f572c1d3fc14f Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sun, 6 Sep 2026 08:41:03 -0500 Subject: [PATCH] Reconcile OpenAPI 3.29.0 spec drift (v14.0.0) Closes #510. Closes #511. Adds weather calibrations, FCM-bound API keys, target-balance resting_margin_reservation, FCM client_order_ids, historical subaccount, perps fee_tier_rates, Klear FCM API keys and member funding payments, and WS cfbenchmarks_value_5hz. Breaking: MarginFillPayload and MarginUserOrderPayload require order_source. --- CHANGELOG.md | 63 ++++ CLAUDE.md | 4 +- README.md | 15 +- ROADMAP.md | 7 + docs/index.md | 8 +- docs/migration.md | 38 ++ docs/perps.md | 20 +- docs/resources/api-keys.md | 15 +- docs/resources/fcm.md | 8 +- docs/resources/historical.md | 2 +- docs/resources/live-data.md | 11 + docs/resources/portfolio.md | 7 +- docs/websockets.md | 5 +- kalshi/__init__.py | 10 +- kalshi/_contract_map.py | 67 +++- kalshi/models/__init__.py | 8 + kalshi/models/api_keys.py | 26 +- kalshi/models/events.py | 4 +- kalshi/models/live_data.py | 38 ++ kalshi/models/portfolio.py | 9 + kalshi/perps/__init__.py | 6 + kalshi/perps/klear/models/__init__.py | 18 + kalshi/perps/klear/models/margin.py | 84 ++++- kalshi/perps/klear/resources/margin.py | 298 ++++++++++++++- kalshi/perps/models/__init__.py | 6 + kalshi/perps/models/margin_account.py | 27 +- kalshi/perps/models/markets.py | 2 + kalshi/perps/resources/margin_account.py | 17 + kalshi/perps/ws/models/_common.py | 4 + kalshi/perps/ws/models/control.py | 2 + kalshi/perps/ws/models/fill.py | 9 +- kalshi/perps/ws/models/user_orders.py | 7 +- kalshi/resources/api_keys.py | 68 +++- kalshi/resources/fcm.py | 38 +- kalshi/resources/historical.py | 16 +- kalshi/resources/live_data.py | 31 ++ kalshi/resources/portfolio.py | 28 +- kalshi/ws/channels.py | 1 + kalshi/ws/client.py | 20 + kalshi/ws/dispatch.py | 4 + kalshi/ws/models/__init__.py | 8 + kalshi/ws/models/base.py | 2 + kalshi/ws/models/cfbenchmarks.py | 44 +++ pyproject.toml | 2 +- specs/asyncapi.yaml | 444 +++++++++++++++++++---- specs/openapi.yaml | 245 +++++++++++-- specs/perps_asyncapi.yaml | 39 +- specs/perps_openapi.yaml | 79 +++- specs/perps_scm_openapi.yaml | 240 +++++++++++- tests/_contract_support.py | 56 +++ tests/perps/klear/test_margin.py | 159 ++++++++ tests/perps/test_margin_account.py | 66 ++++ tests/perps/ws/test_perps_ws_channels.py | 3 +- tests/perps/ws/test_perps_ws_models.py | 31 +- tests/test_api_keys.py | 80 ++++ tests/test_contracts.py | 6 + tests/test_fcm.py | 17 + tests/test_historical.py | 5 +- tests/test_live_data.py | 60 +++ tests/test_portfolio.py | 15 + tests/ws/test_client.py | 10 + tests/ws/test_dispatch.py | 2 + tests/ws/test_models.py | 48 +++ 63 files changed, 2521 insertions(+), 191 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bb4e602..89ad8837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,69 @@ All notable changes to kalshi-sdk will be documented in this file. +## 14.0.0 — 2026-09-06 + +Reconciles upstream OpenAPI **3.29.0** content drift plus matching perps, +Klear, and AsyncAPI updates after nightly contract failures (Closes #510, +Closes #511). **Breaking** for constructors of perps WS `MarginFillPayload` +and `MarginUserOrderPayload` that omit the new required `order_source`. + +### Changed (breaking) + +- **Perps WS** `MarginFillPayload.order_source` and + `MarginUserOrderPayload.order_source` (`Literal["user", "system"]`, + required). `system` marks liquidations and margin exit / trailing-stop + triggers; `user` is every other order. Live stream callers are + unaffected; tests/mocks that construct these payloads must pass the + source. + +### Added + +- **`live_data.weather_calibrations(city)`** — + `GET /live_data/weather/{city}/calibrations`. Published weather-index + configuration timeline (launch config + weekly offset calibrations). +- Optional **`WeatherIndexPoint.receipt_basis`** (historical-backfill + points only). +- **`api_keys.list(*, fcm_subtrader_id)`** and optional + **`fcm_subtrader_id`** on `create()` / `generate()` / `ApiKey` + (mutually exclusive with `subaccount`). Optional **`warning`** on + create/generate responses when a bound FCM subtrader has no IM cap. +- **`portfolio.set_target_balance_allocation(..., resting_margin_reservation=)`** + (`max` / `sum`; server defaults to `sum`). +- **`fcm.orders` / `orders_all`**: `subtrader_id` is now optional when + `client_order_ids` is supplied (comma-separated, max 100). +- Optional **`subaccount`** query on `historical.positions()` / + `positions_all()`. +- **Perps** `margin.fee_tier_rates()` — `GET /margin/fee_tier_rates`. +- Optional **`MarginMarket.asset_class`**. +- **Klear** FCM margin API keys: `list_fcm_api_keys` / + `create_fcm_api_key` / `generate_fcm_api_key` / `delete_fcm_api_key`. +- **Klear** `member_funding_payments()` / `member_funding_payments_all()` + — `GET /margin/funding_payments`. +- **Klear** `estimate_maintenance_margin(..., date=, clearing_type=)` + plus optional breakdown fields on the response (`base_margin_fp`, + `hvar_fp`, `apc_fp`, `funding_addon_fp`, `liquidation_addon_fp`). +- **WS** `subscribe_cfbenchmarks_value_5hz()` — high-frequency CF + Benchmarks tick feed (up to 5 Hz). Optional `sid` / `seq` on + `ErrorMessage`. + +### Changed (non-breaking) + +- **`Event.available_on_brokers`** is optional (`bool | None = None`). + The spec dropped the field; existing constructors that pass it still + work. + +### Spec notes + +- Core OpenAPI `info.version` still **3.29.0** (paths 96; 109 operations; + 108 mapped). Still unimplemented on the core client: + `POST /portfolio/intra_exchange_instance_transfer`. +- AsyncAPI 14 → 15 channels (`cfbenchmarks_value_5hz`). 12 typed + `subscribe_*` helpers. +- Perps OpenAPI: 47 → 48 operations. +- Perps SCM OpenAPI: 19 → 24 operations. Still unimplemented: + `GET /margin/large_trader_positions` (surveillance). + ## 13.0.0 — 2026-08-28 Reconciles upstream OpenAPI **3.28.0 → 3.29.0**, plus matching perps, Klear, diff --git a/CLAUDE.md b/CLAUDE.md index f358916a..1117d7a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,8 +122,8 @@ tests/ ## API Reference -- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.29.0, 108 operations; 107 mapped in the core SDK — `POST /portfolio/intra_exchange_instance_transfer` is implemented on `PerpsClient.transfers.transfer_instance` and left unimplemented on the core client) -- AsyncAPI spec: https://docs.kalshi.com/asyncapi.yaml (14 WebSocket channels; 11 typed `subscribe_*` + escape-hatch) +- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.29.0, 109 operations; 108 mapped in the core SDK — `POST /portfolio/intra_exchange_instance_transfer` is implemented on `PerpsClient.transfers.transfer_instance` and left unimplemented on the core client) +- AsyncAPI spec: https://docs.kalshi.com/asyncapi.yaml (15 WebSocket channels; 12 typed `subscribe_*` + escape-hatch) - Base URL: https://api.elections.kalshi.com/trade-api/v2 - Demo URL: https://demo-api.kalshi.co/trade-api/v2 - Auth: RSA-PSS / SHA256 / MGF1(SHA256) / salt_length=DIGEST_LENGTH / base64 diff --git a/README.md b/README.md index e52920b1..b12eccc0 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) predi [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Type checked: mypy strict](https://img.shields.io/badge/mypy-strict-blue.svg)](https://mypy.readthedocs.io/) -- **Full coverage** of the Kalshi REST API (107 mapped of 108 operations across 19 resources, OpenAPI v3.29.0) and WebSocket API (11 typed `subscribe_*` channels + 2 escape-hatch). -- **Perps (margin) API**: standalone `PerpsClient` / `AsyncPerpsClient` + `PerpsWebSocket` for the perpetual-futures exchange (47 REST operations, 6 WS channels), plus a `KlearClient` for the Self-Clearing-Member "Klear" settlement API (18 operations). See [Perps (margin) trading](#perps-margin-trading). +- **Full coverage** of the Kalshi REST API (108 mapped of 109 operations across 19 resources, OpenAPI v3.29.0) and WebSocket API (12 typed `subscribe_*` channels + escape-hatch). +- **Perps (margin) API**: standalone `PerpsClient` / `AsyncPerpsClient` + `PerpsWebSocket` for the perpetual-futures exchange (48 REST operations, 6 WS channels), plus a `KlearClient` for the Self-Clearing-Member "Klear" settlement API (23 operations). See [Perps (margin) trading](#perps-margin-trading). - **FIX protocol**: an async-first FIX engine (FIXT.1.1 / FIX50SP2) for both products — order-entry, drop-copy, market-data, post-trade (prediction), and RFQ (prediction) sessions (plus order-group management over the order-entry session) with typed message models, sequence recovery, and order-book / settlement reassembly. `from kalshi import FixClient` / `MarginFixClient`. See [FIX protocol](#fix-protocol-low-latency-trading). - **V2 event-market orders**: `create_v2` / `amend_v2` / `decrease_v2` / `cancel_v2` / `cancel_all_v2` plus batched variants on `/portfolio/events/orders/*` — the only order-write surface. - **Funding & cost introspection**: `portfolio.deposits()`, `portfolio.withdrawals()`, `account.endpoint_costs()`. @@ -171,15 +171,16 @@ async def main() -> None: asyncio.run(main()) ``` -Available channels (11 typed + 2 escape-hatch). Eleven have dedicated +Available channels (12 typed + escape-hatch). Twelve have dedicated `subscribe_*` methods — `subscribe_ticker`, `subscribe_trade`, `subscribe_orderbook_delta`, `subscribe_fill`, `subscribe_market_positions`, `subscribe_user_orders`, `subscribe_order_group`, `subscribe_market_lifecycle`, `subscribe_multivariate_lifecycle`, -`subscribe_communications`, `subscribe_cfbenchmarks_value`. The -AsyncAPI-declared `control_frames` and `root` channels are reachable -through the generic `subscribe(channel, ...)` escape hatch. See -[docs/websockets.md](docs/websockets.md#the-11-channels) for the full +`subscribe_communications`, `subscribe_cfbenchmarks_value`, +`subscribe_cfbenchmarks_value_5hz`. The AsyncAPI-declared `control_frames`, +`root`, and `pyth_value` channels are reachable through the generic +`subscribe(channel, ...)` escape hatch. See +[docs/websockets.md](docs/websockets.md#the-12-channels) for the full channel table. ## Perps (margin) trading diff --git a/ROADMAP.md b/ROADMAP.md index 7729b6b6..f8f72edf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,13 @@ ## Shipped +- **v14.0.0 (2026-09-06)** — Spec-drift reconcile (#510 / #511). OpenAPI + 3.29.0 content + perps/Klear/AsyncAPI. **Breaking:** perps WS + `MarginFillPayload` / `MarginUserOrderPayload` require `order_source`. + Additive: weather calibrations, FCM-bound API keys, target-balance + `resting_margin_reservation`, FCM `client_order_ids`, historical + `subaccount`, perps `fee_tier_rates`, Klear FCM API keys + member + funding payments, WS `cfbenchmarks_value_5hz`. - **v13.0.0 (2026-08-28)** — Spec-drift reconcile (#507 / #508). OpenAPI 3.28.0 → 3.29.0. **Breaking:** `Fill` / `MarketPosition` / `Settlement` require `exchange_index`. Additive: weather index, target-balance diff --git a/docs/index.md b/docs/index.md index 967adc6d..dcd8bc19 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,7 +3,7 @@ A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) prediction markets API. -- **Full REST coverage** — 107 mapped of 108 operations across 19 resources +- **Full REST coverage** — 108 mapped of 109 operations across 19 resources (OpenAPI v3.29.0), every kwarg drift-tested against the spec. - **V2 event-market orders** — new `create_v2` / `amend_v2` / `decrease_v2` / `cancel_v2` / `cancel_all_v2` family on `/portfolio/events/orders/*`. Legacy `/portfolio/orders` @@ -11,14 +11,14 @@ markets API. - **Funding + cost introspection** — `portfolio.deposits()`, `portfolio.withdrawals()`, `portfolio.intra_exchange_transfers()`, `account.endpoint_costs()`. -- **Full WebSocket coverage** — 11 typed channels with sequence-gap detection, +- **Full WebSocket coverage** — 12 typed channels with sequence-gap detection, automatic reconnection (with resubscribe-window frame stashing for high-volume channels), backpressure strategies, and an in-memory orderbook builder. Async-only — access via `AsyncKalshiClient.ws`. - **Perps (margin) API** — standalone `PerpsClient` / `AsyncPerpsClient` + - `PerpsWebSocket` for the perpetual-futures exchange (47 REST operations, 6 WS + `PerpsWebSocket` for the perpetual-futures exchange (48 REST operations, 6 WS channels), and a `KlearClient` for the Self-Clearing-Member settlement API - (18 operations, Bearer token auth). See [Perps](perps.md). + (23 operations, Bearer token auth). See [Perps](perps.md). - **FIX protocol** — a hand-rolled, async-first FIX engine (FIXT.1.1 / FIX50SP2) for both products: order-entry, drop-copy, market-data, post-trade (prediction), and RFQ (prediction) sessions — plus order-group management over the order-entry diff --git a/docs/migration.md b/docs/migration.md index c92bc9ad..d60202a0 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,5 +1,43 @@ # Migration +## v13.0 → v14.0.0 + +Reconciles upstream OpenAPI **3.29.0** content drift plus matching perps, +Klear, and AsyncAPI updates (Closes #510, Closes #511). **Breaking** only +for code that constructs perps WS fill / user-order payloads without +`order_source`. + +### Response model field changes + +- **Perps WS** `MarginFillPayload.order_source` and + `MarginUserOrderPayload.order_source` — required `Literal["user", "system"]`. + Live `subscribe_fill()` / `subscribe_user_orders()` callers are + unaffected. + +```python +# Before (constructors / test fixtures): +# MarginFillPayload(..., post_position="15.00") + +# After: +MarginFillPayload(..., post_position="15.00", order_source="user") +``` + +### Added (non-breaking) + +- `live_data.weather_calibrations(city)` +- `api_keys.list(*, fcm_subtrader_id)` and `create`/`generate(fcm_subtrader_id=)` +- `portfolio.set_target_balance_allocation(..., resting_margin_reservation=)` +- `fcm.orders(*, client_order_ids=)` (`subtrader_id` now optional if IDs given) +- `historical.positions(*, subaccount=)` +- Perps `margin.fee_tier_rates()` +- Klear FCM API keys + `member_funding_payments` +- WS `subscribe_cfbenchmarks_value_5hz()` + +`Event.available_on_brokers` is now optional (the spec dropped the field). + +See the [changelog](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/CHANGELOG.md) +for the full list. + ## v12.0 → v13.0.0 Reconciles upstream OpenAPI **3.28.0 → 3.29.0**, plus matching perps, Klear, diff --git a/docs/perps.md b/docs/perps.md index eb10e406..5937b7ef 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -63,7 +63,7 @@ async with AsyncPerpsClient.from_env(demo=True) as perps: | `orders` | `create()`, `get()`, `list()` / `list_all()`, `cancel()`, `cancel_all()`, `decrease()`, `amend()` | | `order_groups` | `list()`, `get()`, `create()`, `delete()`, `reset()`, `trigger()`, `update_limit()` | | `portfolio` | `positions()`, `fills()` / `fills_all()`, `trades()` / `trades_all()`, cross/isolated exit triggers | -| `margin` | `balance()`, `risk()`, `notional_risk_limit()`, `fee_tiers()`, `api_limits()` | +| `margin` | `balance()`, `risk()`, `notional_risk_limit()`, `fee_tiers()`, `fee_tier_rates()`, `api_limits()` | | `funding` | `rate_estimate()`, `historical_rates()`, `history()` | | `transfers` | `transfer_instance()`, `create_subaccount()`, `transfer_subaccount()` | | `fcm` | `create_subtrader(subtrader_suffix=...)`; `risk_controls` / `update_risk_controls` / `delete_risk_controls` | @@ -241,7 +241,23 @@ in the v11.0.0 reconcile.) `klear.margin.settlement_prices(asset_class="Crypto", settlement_time=...)` returns a ticker → centicents map at a settlement cycle. `klear.margin.estimate_maintenance_margin(asset_class="Crypto", positions=[...])` -margins a hypothetical portfolio. +margins a hypothetical portfolio. Optional `date=` (YYYY-MM-DD) and +`clearing_type=` (`"FCM"` / `"SelfClearing"`) select the matrix day and +clearing arrangement. + +`klear.margin.member_funding_payments(funding_time=...)` returns the +member's funding payments for one funding execution (distinct from the +obligation-scoped `funding_payments(obligation_id)`). + +FCM-bound margin API keys: + +```python +keys = klear.margin.list_fcm_api_keys() +created = klear.margin.create_fcm_api_key( + name="desk", public_key=pem, fcm_subtrader_id="user_desk1" +) +klear.margin.delete_fcm_api_key(created.api_key_id) +``` When an `ObligationEntry` inline detail array is capped at 1000 rows, the matching `*_truncated` flag is set; page the full set via: diff --git a/docs/resources/api-keys.md b/docs/resources/api-keys.md index 59045844..c87abe19 100644 --- a/docs/resources/api-keys.md +++ b/docs/resources/api-keys.md @@ -9,9 +9,9 @@ Auth required throughout (you need an existing key to manage keys). | Method | Endpoint | |---|---| -| `list()` | `GET /api_keys` | -| `create(*, name, public_key, scopes=None, subaccount=None)` | `POST /api_keys` | -| `generate(*, name, scopes=None, subaccount=None)` | `POST /api_keys/generate` | +| `list(*, fcm_subtrader_id=None)` | `GET /api_keys` | +| `create(*, name, public_key, scopes=None, subaccount=None, fcm_subtrader_id=None)` | `POST /api_keys` | +| `generate(*, name, scopes=None, subaccount=None, fcm_subtrader_id=None)` | `POST /api_keys/generate` | | `delete(api_key)` | `DELETE /api_keys/{api_key}` | !!! note "Subaccount-scoped keys (spec v3.23.0)" @@ -20,6 +20,15 @@ Auth required throughout (you need an existing key to manage keys). bounded to `0-63` client-side; `ApiKey.subaccount` echoes it back on `list()` (`None` for account-wide keys). +!!! note "FCM-bound keys" + FCM members can pass `fcm_subtrader_id="{user_id}_{suffix}"` instead of + `subaccount` to bind a key to a single FCM subtrader. The two are + mutually exclusive. A bound key is the institution's trading credential + for that subtrader (FIX + margin WebSocket) and is denied on every REST + endpoint. `list(fcm_subtrader_id=...)` filters to keys bound to that + subtrader. Create/generate may return `warning` when the subtrader has + no initial-margin cap. + ## List ```python diff --git a/docs/resources/fcm.md b/docs/resources/fcm.md index 6cc31c3a..ddc7e2b4 100644 --- a/docs/resources/fcm.md +++ b/docs/resources/fcm.md @@ -3,15 +3,15 @@ Futures Commission Merchant routes. **FCM-member accounts only** — non-FCM calls come back 401/403. Auth required throughout. -`subtrader_id` is the required discriminator on every call — every FCM -request scopes to one subtrader under your member account. +`subtrader_id` is required on positions. On orders it is required **unless** +`client_order_ids` is supplied (the two filters are alternatives). ## Quick reference | Method | Endpoint | |---|---| -| `orders(*, subtrader_id, ...)` | `GET /fcm/orders` | -| `orders_all(*, subtrader_id, ...)` | walks `orders` | +| `orders(*, subtrader_id=None, client_order_ids=None, ...)` | `GET /fcm/orders` | +| `orders_all(*, subtrader_id=None, client_order_ids=None, ...)` | walks `orders` | | `positions(*, subtrader_id, ...)` | `GET /fcm/positions` | ## List orders diff --git a/docs/resources/historical.md b/docs/resources/historical.md index 05a8f2b7..9226c284 100644 --- a/docs/resources/historical.md +++ b/docs/resources/historical.md @@ -15,7 +15,7 @@ analytics; live trading needs the real-time surfaces. | `trades(...)` / `trades_all(...)` | `GET /historical/trades` | no | | `fills(...)` / `fills_all(...)` | `GET /historical/fills` | **yes** | | `orders(...)` / `orders_all(...)` | `GET /historical/orders` | **yes** | -| `positions(...)` / `positions_all(...)` | `GET /historical/positions` | **yes** | +| `positions(*, subaccount=None, ...)` / `positions_all(*, subaccount=None, ...)` | `GET /historical/positions` | **yes** | ## Cutoff diff --git a/docs/resources/live-data.md b/docs/resources/live-data.md index 371d4e31..4347f80c 100644 --- a/docs/resources/live-data.md +++ b/docs/resources/live-data.md @@ -15,6 +15,7 @@ Public — no auth required. | `batch(milestone_ids, *, include_player_stats=None)` | `GET /live_data/batch` | | `game_stats(milestone_id)` | `GET /live_data/milestone/{milestone_id}/game_stats` | | `weather(city, *, from_ts, to, last_sec, detailed)` | `GET /live_data/weather/{city}` | +| `weather_calibrations(city)` | `GET /live_data/weather/{city}/calibrations` | | `get_typed(milestone_type, milestone_id)` | `GET /live_data/{type}/milestone/{milestone_id}` (legacy) | ## Get one milestone's live data @@ -82,6 +83,16 @@ for point in idx.timeseries: `last_sec` is mutually exclusive with `from_ts`/`to` per spec. `detailed=True` attaches per-station audit readings on every point. +```python +cals = client.live_data.weather_calibrations("miami") +for rec in cals.calibrations: + print(rec.config_version, rec.effective_at_ms, rec.city_reference_c) +``` + +`weather_calibrations` returns the launch configuration plus every weekly +offset calibration, ascending by effective time. Units are always Celsius +on this endpoint (the published index value itself remains Fahrenheit). + ## Legacy `get_typed` ```python diff --git a/docs/resources/portfolio.md b/docs/resources/portfolio.md index 0f1fd2c0..f3442220 100644 --- a/docs/resources/portfolio.md +++ b/docs/resources/portfolio.md @@ -17,7 +17,7 @@ Auth required throughout. | `intra_exchange_transfers(...)` / `intra_exchange_transfers_all(...)` | `GET /portfolio/intra_exchange_instance_transfers` | | `get_intra_exchange_transfer(transfer_id)` | `GET /portfolio/intra_exchange_instance_transfers/{transfer_id}` | | `target_balance_allocation()` | `GET /portfolio/target_balance_allocation` | -| `set_target_balance_allocation(*, allocations)` | `POST /portfolio/target_balance_allocation` | +| `set_target_balance_allocation(*, allocations, resting_margin_reservation=None)` | `POST /portfolio/target_balance_allocation` | `balance()`, `positions()` / `positions_all()`, `settlements()` / `settlements_all()`, and `fills()` / `fills_all()` all take an optional @@ -235,7 +235,10 @@ client.portfolio.set_target_balance_allocation( ) ``` -Percents are integers 0–100. At most 101 allocation rows. +Percents are integers 0–100. At most 101 allocation rows. Optional +`resting_margin_reservation` is `"max"` or `"sum"` (server defaults to +`"sum"`) — collateral an automatic rebalance leaves behind for resting +orders. `Fill`, `MarketPosition`, and `Settlement` each carry a required `exchange_index` as of OpenAPI 3.29.0 (SDK v13.0.0). Live list callers diff --git a/docs/websockets.md b/docs/websockets.md index 78869ba6..4a2ffbda 100644 --- a/docs/websockets.md +++ b/docs/websockets.md @@ -1,7 +1,7 @@ # WebSocket The SDK ships an async-only WebSocket client, `KalshiWebSocket`, that covers -the Kalshi market-data WebSocket surface (11 typed channels plus the generic +the Kalshi market-data WebSocket surface (12 typed channels plus the generic escape hatch). It handles RSA-PSS auth on the upgrade handshake, per-subscription sequence-gap detection, automatic reconnection with re-subscription, and a configurable backpressure strategy on each per-channel @@ -30,7 +30,7 @@ SDK's perspective on it. on every delta. - `on_state_change=` and `on_error=` hooks on the constructor for observability. -## The 11 channels +## The 12 channels | SDK method | Wire channel | Message `type` field | Message class | Auth | |---|---|---|---|---| @@ -45,6 +45,7 @@ SDK's perspective on it. | `subscribe_order_group` | `order_group_updates` | `order_group_updates` | `OrderGroupMessage` | private | | `subscribe_communications` | `communications` | `communications` | `CommunicationsMessage` | private | | `subscribe_cfbenchmarks_value` | `cfbenchmarks_value` | `cfbenchmarks_value` / `cfbenchmarks_value_indexlist` | `CFBenchmarksValueMessage` / `CFBenchmarksIndexListMessage` | private | +| `subscribe_cfbenchmarks_value_5hz` | `cfbenchmarks_value_5hz` | `cfbenchmarks_value_5hz` / `cfbenchmarks_value_5hz_indexlist` | `CFBenchmarksValue5HzMessage` / `CFBenchmarks5HzIndexListMessage` | private | The `type` column matters when filtering raw logs — note the singular forms for `user_order` and `market_position`. The standalone `multivariate` / diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 3a2b9bdd..dbe954d3 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -108,6 +108,7 @@ GetSubaccountNettingResponse, GetTagsForSeriesCategoriesResponse, GetTargetBalanceAllocationResponse, + GetWeatherIndexCalibrationsResponse, GetWeatherIndexResponse, HistoricalCutoff, IncentiveProgram, @@ -148,6 +149,7 @@ Quote, QuoteStatusLiteral, RateLimit, + RestingMarginReservationLiteral, RfqStatusLiteral, Schedule, ScopeList, @@ -174,6 +176,8 @@ UpdateSubaccountNettingRequest, UserDataTimestamp, UserFilterLiteral, + WeatherIndexCalibration, + WeatherIndexCalibrationStation, WeatherIndexPoint, WeatherIndexStationReading, WeeklySchedule, @@ -293,6 +297,7 @@ "GetSubaccountNettingResponse", "GetTagsForSeriesCategoriesResponse", "GetTargetBalanceAllocationResponse", + "GetWeatherIndexCalibrationsResponse", "GetWeatherIndexResponse", "HistoricalCutoff", "IncentiveProgram", @@ -362,6 +367,7 @@ "QuotesResource", "RFQsResource", "RateLimit", + "RestingMarginReservationLiteral", "RfqStatusLiteral", "Schedule", "ScopeList", @@ -389,10 +395,12 @@ "UpdateSubaccountNettingRequest", "UserDataTimestamp", "UserFilterLiteral", + "WeatherIndexCalibration", + "WeatherIndexCalibrationStation", "WeatherIndexPoint", "WeatherIndexStationReading", "WeeklySchedule", "Withdrawal", ] -__version__ = "13.0.0" +__version__ = "14.0.0" diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index 69c93316..b290b77c 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -45,7 +45,11 @@ class ContractEntry: ContractEntry( sdk_model="kalshi.models.events.Event", spec_schema="EventData", - notes="Spec uses 'EventData', not 'Event'", + ignored_fields=frozenset({"available_on_brokers"}), + notes=( + "Spec uses 'EventData', not 'Event'. available_on_brokers was " + "dropped from the spec; kept optional for constructor compat." + ), ), ContractEntry( sdk_model="kalshi.models.events.EventFeeChange", @@ -245,6 +249,18 @@ class ContractEntry: sdk_model="kalshi.models.live_data.GetWeatherIndexResponse", spec_schema="GetWeatherIndexResponse", ), + ContractEntry( + sdk_model="kalshi.models.live_data.WeatherIndexCalibrationStation", + spec_schema="WeatherIndexCalibrationStation", + ), + ContractEntry( + sdk_model="kalshi.models.live_data.WeatherIndexCalibration", + spec_schema="WeatherIndexCalibration", + ), + ContractEntry( + sdk_model="kalshi.models.live_data.GetWeatherIndexCalibrationsResponse", + spec_schema="GetWeatherIndexCalibrationsResponse", + ), ContractEntry( sdk_model="kalshi.models.markets.MarketCandlesticks", spec_schema="MarketCandlesticksResponse", @@ -546,6 +562,15 @@ class ContractEntry: spec_schema="cfbenchmarksIndexListPayload", notes="indexlist response: msg.index_ids only.", ), + ContractEntry( + sdk_model="kalshi.ws.models.cfbenchmarks.CFBenchmarksValue5HzPayload", + spec_schema="cfbenchmarksValue5HzPayload", + notes="5Hz tick feed; lean ticks without trailing averages.", + ), + ContractEntry( + sdk_model="kalshi.ws.models.cfbenchmarks.CFBenchmarks5HzIndexListPayload", + spec_schema="cfbenchmarks5HzIndexListPayload", + ), ContractEntry( sdk_model="kalshi.ws.models.fill.FillPayload", spec_schema="fillPayload", @@ -774,6 +799,14 @@ class ContractEntry: sdk_model="kalshi.perps.models.margin_account.GetMarginFeeTiersResponse", spec_schema="GetMarginFeeTiersResponse", ), + ContractEntry( + sdk_model="kalshi.perps.models.margin_account.MarginFeeTierRate", + spec_schema="MarginFeeTierRate", + ), + ContractEntry( + sdk_model="kalshi.perps.models.margin_account.GetMarginFeeTierRatesResponse", + spec_schema="GetMarginFeeTierRatesResponse", + ), ContractEntry( # /account/limits/perps returns the same shape as the prediction API's # /account/limits, so the perps resource reuses kalshi.models.account.AccountApiLimits. @@ -974,4 +1007,36 @@ class ContractEntry: sdk_model="kalshi.perps.klear.models.margin.EstimatePortfolioMaintenanceMarginResponse", spec_schema="EstimatePortfolioMaintenanceMarginResponse", ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.MarginFcmApiKey", + spec_schema="MarginFcmApiKey", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.ListMarginFcmApiKeysResponse", + spec_schema="ListMarginFcmApiKeysResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.CreateMarginFcmApiKeyRequest", + spec_schema="CreateMarginFcmApiKeyRequest", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.CreateMarginFcmApiKeyResponse", + spec_schema="CreateMarginFcmApiKeyResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.GenerateMarginFcmApiKeyRequest", + spec_schema="GenerateMarginFcmApiKeyRequest", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.GenerateMarginFcmApiKeyResponse", + spec_schema="GenerateMarginFcmApiKeyResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.MemberFundingPayment", + spec_schema="MemberFundingPayment", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.GetMemberFundingPaymentsResponse", + spec_schema="GetMemberFundingPaymentsResponse", + ), ] diff --git a/kalshi/models/__init__.py b/kalshi/models/__init__.py index b0bec77b..fb2407c3 100644 --- a/kalshi/models/__init__.py +++ b/kalshi/models/__init__.py @@ -73,10 +73,13 @@ GetGameStatsResponse, GetLiveDataResponse, GetLiveDatasResponse, + GetWeatherIndexCalibrationsResponse, GetWeatherIndexResponse, LiveData, PlayByPlay, PlayByPlayPeriod, + WeatherIndexCalibration, + WeatherIndexCalibrationStation, WeatherIndexPoint, WeatherIndexStationReading, ) @@ -148,6 +151,7 @@ PaymentStatusLiteral, PaymentTypeLiteral, PositionsResponse, + RestingMarginReservationLiteral, SetTargetBalanceAllocationRequest, Settlement, SettlementStatusLiteral, @@ -270,6 +274,7 @@ "GetSubaccountNettingResponse", "GetTagsForSeriesCategoriesResponse", "GetTargetBalanceAllocationResponse", + "GetWeatherIndexCalibrationsResponse", "GetWeatherIndexResponse", "HistoricalCutoff", "IncentiveProgram", @@ -310,6 +315,7 @@ "Quote", "QuoteStatusLiteral", "RateLimit", + "RestingMarginReservationLiteral", "RfqStatusLiteral", "Schedule", "ScopeList", @@ -336,6 +342,8 @@ "UpdateSubaccountNettingRequest", "UserDataTimestamp", "UserFilterLiteral", + "WeatherIndexCalibration", + "WeatherIndexCalibrationStation", "WeatherIndexPoint", "WeatherIndexStationReading", "WeeklySchedule", diff --git a/kalshi/models/api_keys.py b/kalshi/models/api_keys.py index 6922676b..db6b44c4 100644 --- a/kalshi/models/api_keys.py +++ b/kalshi/models/api_keys.py @@ -9,7 +9,7 @@ from __future__ import annotations -from pydantic import BaseModel, Field, SecretStr +from pydantic import BaseModel, Field, SecretStr, model_validator from kalshi.types import NullableList @@ -29,6 +29,10 @@ class ApiKey(BaseModel): # Spec v3.23.0: if set, the key is restricted to this single subaccount # (0-63). Optional — omitted for account-wide keys and by older servers. subaccount: int | None = None + # FCM members only. Bound keys are the institution's trading credential + # for that subtrader (FIX + margin WS) and are denied on every REST + # endpoint. Mutually exclusive with ``subaccount``. + fcm_subtrader_id: str | None = None model_config = {"extra": "allow"} @@ -63,14 +67,24 @@ class CreateApiKeyRequest(BaseModel): # Spec v3.23.0: restrict the key to a single subaccount when set. The spec # declares an explicit minimum/maximum (0-63), so bound it client-side. subaccount: int | None = Field(default=None, ge=0, le=63) + # FCM members only. Mutually exclusive with ``subaccount``. + fcm_subtrader_id: str | None = None model_config = {"extra": "forbid"} + @model_validator(mode="after") + def _subaccount_xor_fcm(self) -> CreateApiKeyRequest: + if self.subaccount is not None and self.fcm_subtrader_id is not None: + raise ValueError("subaccount and fcm_subtrader_id are mutually exclusive") + return self + class CreateApiKeyResponse(BaseModel): """Response from POST /api_keys — the new key's ID.""" api_key_id: str + # Present only when a bound FCM subtrader has no initial-margin cap. + warning: str | None = None model_config = {"extra": "allow"} @@ -83,9 +97,17 @@ class GenerateApiKeyRequest(BaseModel): # Spec v3.23.0: restrict the key to a single subaccount when set. The spec # declares an explicit minimum/maximum (0-63), so bound it client-side. subaccount: int | None = Field(default=None, ge=0, le=63) + # FCM members only. Mutually exclusive with ``subaccount``. + fcm_subtrader_id: str | None = None model_config = {"extra": "forbid"} + @model_validator(mode="after") + def _subaccount_xor_fcm(self) -> GenerateApiKeyRequest: + if self.subaccount is not None and self.fcm_subtrader_id is not None: + raise ValueError("subaccount and fcm_subtrader_id are mutually exclusive") + return self + class GenerateApiKeyResponse(BaseModel): """Response from POST /api_keys/generate. @@ -100,5 +122,7 @@ class GenerateApiKeyResponse(BaseModel): api_key_id: str private_key: SecretStr + # Present only when a bound FCM subtrader has no initial-margin cap. + warning: str | None = None model_config = {"extra": "allow"} diff --git a/kalshi/models/events.py b/kalshi/models/events.py index bd068458..20302dff 100644 --- a/kalshi/models/events.py +++ b/kalshi/models/events.py @@ -34,7 +34,9 @@ class Event(BaseModel): category: str | None = None strike_date: AwareDatetime | None = None strike_period: str | None = None - available_on_brokers: bool + # Spec dropped this field after OpenAPI 3.29.0 content drift; kept optional + # so existing constructors and older responses still parse. + available_on_brokers: bool | None = None # The live demo server omits `product_metadata` on most events (observed # run #26141405845, 2026-05-20). OpenAPI v3.20.0 relaxed it to optional # too (#385), so this is no longer a spec deviation — kept nullable to diff --git a/kalshi/models/live_data.py b/kalshi/models/live_data.py index 46477d2b..ff92eb09 100644 --- a/kalshi/models/live_data.py +++ b/kalshi/models/live_data.py @@ -112,6 +112,8 @@ class WeatherIndexPoint(BaseModel): v: float | None = None contributors: int | None = None stations: list[WeatherIndexStationReading] | None = None + # Present only on labelled historical-backfill points (not settlement-eligible). + receipt_basis: str | None = None model_config = {"extra": "allow"} @@ -127,6 +129,42 @@ class GetWeatherIndexResponse(BaseModel): model_config = {"extra": "allow"} +class WeatherIndexCalibrationStation(BaseModel): + """One configured member station on a weather-index calibration record.""" + + station_id: str + weight: float + offset_c: float + update_note: str | None = None + + model_config = {"extra": "allow"} + + +class WeatherIndexCalibration(BaseModel): + """One published weather-index configuration, effective from ``effective_at_ms``.""" + + config_version: str + effective_at_ms: int + city_reference_c: float + stations: list[WeatherIndexCalibrationStation] + published_at_ms: int | None = None + change_reason: str | None = None + calibration_window_start_ms: int | None = None + calibration_window_end_ms: int | None = None + + model_config = {"extra": "allow"} + + +class GetWeatherIndexCalibrationsResponse(BaseModel): + """Response from GET /live_data/weather/{city}/calibrations.""" + + city: str + units: str + calibrations: list[WeatherIndexCalibration] + + model_config = {"extra": "allow"} + + class GetGameStatsResponse(BaseModel): """Response from GET /live_data/milestone/{milestone_id}/game_stats. diff --git a/kalshi/models/portfolio.py b/kalshi/models/portfolio.py index 525818ed..1459d4b6 100644 --- a/kalshi/models/portfolio.py +++ b/kalshi/models/portfolio.py @@ -266,10 +266,19 @@ class GetTargetBalanceAllocationResponse(BaseModel): model_config = {"extra": "allow"} +RestingMarginReservationLiteral = Literal["max", "sum"] +"""Collateral an automatic rebalance leaves behind for resting orders. + +``max`` reserves the largest single market-side commitment. ``sum`` reserves +the summed margin of every resting order. Spec defaults to ``sum`` when omitted. +""" + + class SetTargetBalanceAllocationRequest(BaseModel): """Body for POST /portfolio/target_balance_allocation.""" allocations: list[TargetBalanceAllocationInput] = Field(max_length=101) + resting_margin_reservation: RestingMarginReservationLiteral | None = None model_config = {"extra": "forbid"} diff --git a/kalshi/perps/__init__.py b/kalshi/perps/__init__.py index fe5e3eca..53b61ad7 100644 --- a/kalshi/perps/__init__.py +++ b/kalshi/perps/__init__.py @@ -89,9 +89,12 @@ MarginFundingRateEstimate, ) from kalshi.perps.models.margin_account import ( + FeeScheduleLiteral, GetMarginBalanceResponse, + GetMarginFeeTierRatesResponse, GetMarginFeeTiersResponse, GetMarginRiskResponse, + MarginFeeTierRate, MarginRiskPosition, MarginSubaccountBalance, NotionalRiskLimitResponse, @@ -263,6 +266,7 @@ "ExitTriggerStatusLiteral", "FCMAssetClassLiteral", "FCMSubtraderRiskControls", + "FeeScheduleLiteral", "FundingPaymentDetail", "FundingRate", "FundingResource", @@ -271,6 +275,7 @@ "GetFCMSubtraderRiskControlsResponse", "GetGuarantyFundBalanceResponse", "GetMarginBalanceResponse", + "GetMarginFeeTierRatesResponse", "GetMarginFeeTiersResponse", "GetMarginFillsResponse", "GetMarginOrderResponse", @@ -299,6 +304,7 @@ "MaintenanceMarginDetail", "MarginAccountResource", "MarginEnabledResponse", + "MarginFeeTierRate", "MarginFill", "MarginFillMessage", "MarginFillPayload", diff --git a/kalshi/perps/klear/models/__init__.py b/kalshi/perps/klear/models/__init__.py index c2e91b71..712ca1a4 100644 --- a/kalshi/perps/klear/models/__init__.py +++ b/kalshi/perps/klear/models/__init__.py @@ -6,16 +6,22 @@ from kalshi.perps.klear.models.margin import ( AssetClassLiteral, AssetClassSettlementEstimate, + ClearingTypeLiteral, + CreateMarginFcmApiKeyRequest, + CreateMarginFcmApiKeyResponse, CreateMarginSubtraderGroupRequest, CreateMarginSubtraderGroupResponse, EstimatePortfolioMaintenanceMarginPosition, EstimatePortfolioMaintenanceMarginRequest, EstimatePortfolioMaintenanceMarginResponse, FundingPaymentDetail, + GenerateMarginFcmApiKeyRequest, + GenerateMarginFcmApiKeyResponse, GetActiveMarginObligationsResponse, GetGuarantyFundBalanceResponse, GetMarginReportsResponse, GetMarginSubtraderGroupsResponse, + GetMemberFundingPaymentsResponse, GetObligationFundingPaymentsResponse, GetObligationHistoryResponse, GetObligationMaintenanceMarginDetailsResponse, @@ -25,11 +31,14 @@ GetSettlementBalanceWithdrawalResponse, GetSettlementEstimateByAssetClassResponse, GetSettlementPricesResponse, + ListMarginFcmApiKeysResponse, MaintenanceMarginDetail, + MarginFcmApiKey, MarginReport, MarginReportTypeLiteral, MarginSubtraderGroup, MarketSettlementEstimate, + MemberFundingPayment, ObligationEntry, ObligationReceiveInfo, SettlementBalanceHistoryEntry, @@ -44,6 +53,9 @@ __all__ = [ "AssetClassLiteral", "AssetClassSettlementEstimate", + "ClearingTypeLiteral", + "CreateMarginFcmApiKeyRequest", + "CreateMarginFcmApiKeyResponse", "CreateMarginSubtraderGroupRequest", "CreateMarginSubtraderGroupResponse", "Error", @@ -51,10 +63,13 @@ "EstimatePortfolioMaintenanceMarginRequest", "EstimatePortfolioMaintenanceMarginResponse", "FundingPaymentDetail", + "GenerateMarginFcmApiKeyRequest", + "GenerateMarginFcmApiKeyResponse", "GetActiveMarginObligationsResponse", "GetGuarantyFundBalanceResponse", "GetMarginReportsResponse", "GetMarginSubtraderGroupsResponse", + "GetMemberFundingPaymentsResponse", "GetObligationFundingPaymentsResponse", "GetObligationHistoryResponse", "GetObligationMaintenanceMarginDetailsResponse", @@ -64,11 +79,14 @@ "GetSettlementBalanceWithdrawalResponse", "GetSettlementEstimateByAssetClassResponse", "GetSettlementPricesResponse", + "ListMarginFcmApiKeysResponse", "MaintenanceMarginDetail", + "MarginFcmApiKey", "MarginReport", "MarginReportTypeLiteral", "MarginSubtraderGroup", "MarketSettlementEstimate", + "MemberFundingPayment", "ObligationEntry", "ObligationReceiveInfo", "SettlementBalanceHistoryEntry", diff --git a/kalshi/perps/klear/models/margin.py b/kalshi/perps/klear/models/margin.py index 6e03bae4..5d72b6b2 100644 --- a/kalshi/perps/klear/models/margin.py +++ b/kalshi/perps/klear/models/margin.py @@ -37,7 +37,7 @@ from decimal import Decimal from typing import Annotated, Literal -from pydantic import AfterValidator, AwareDatetime, BaseModel, Field +from pydantic import AfterValidator, AwareDatetime, BaseModel, Field, SecretStr from kalshi.types import DollarDecimal, FixedPointCount, NullableList @@ -532,6 +532,10 @@ class EstimatePortfolioMaintenanceMarginPosition(BaseModel): model_config = {"extra": "forbid"} +ClearingTypeLiteral = Literal["FCM", "SelfClearing"] +"""Clearing arrangement whose margin rules to apply on an estimate request.""" + + class EstimatePortfolioMaintenanceMarginRequest(BaseModel): """Body for POST /margin/estimate_maintenance_margin.""" @@ -539,6 +543,8 @@ class EstimatePortfolioMaintenanceMarginRequest(BaseModel): positions: list[EstimatePortfolioMaintenanceMarginPosition] = Field( min_length=1, max_length=500 ) + date: datetime.date | None = None + clearing_type: ClearingTypeLiteral | None = None model_config = {"extra": "forbid"} @@ -547,5 +553,81 @@ class EstimatePortfolioMaintenanceMarginResponse(BaseModel): """Response from POST /margin/estimate_maintenance_margin.""" maintenance_margin_fp: DollarDecimal | None = None + base_margin_fp: DollarDecimal | None = None + hvar_fp: DollarDecimal | None = None + apc_fp: DollarDecimal | None = None + funding_addon_fp: DollarDecimal | None = None + liquidation_addon_fp: DollarDecimal | None = None + + model_config = {"extra": "allow"} + + +class CreateMarginFcmApiKeyRequest(BaseModel): + """Body for POST /fcm/margin/api_keys.""" + + name: str + public_key: str + fcm_subtrader_id: str + + model_config = {"extra": "forbid"} + + +class CreateMarginFcmApiKeyResponse(BaseModel): + """Response from POST /fcm/margin/api_keys.""" + + api_key_id: str + warning: str | None = None + + model_config = {"extra": "allow"} + + +class GenerateMarginFcmApiKeyRequest(BaseModel): + """Body for POST /fcm/margin/api_keys/generate.""" + + name: str + fcm_subtrader_id: str + + model_config = {"extra": "forbid"} + + +class GenerateMarginFcmApiKeyResponse(BaseModel): + """Response from POST /fcm/margin/api_keys/generate.""" + + api_key_id: str + private_key: SecretStr + warning: str | None = None + + model_config = {"extra": "allow"} + + +class MarginFcmApiKey(BaseModel): + """One FCM-bound margin API key.""" + + api_key_id: str + name: str + fcm_subtrader_id: str + + model_config = {"extra": "allow"} + + +class ListMarginFcmApiKeysResponse(BaseModel): + """Response from GET /fcm/margin/api_keys.""" + + api_keys: list[MarginFcmApiKey] + + model_config = {"extra": "allow"} + + +class MemberFundingPayment(FundingPaymentDetail): + """Spec ``MemberFundingPayment`` — obligation funding row plus settlement time.""" + + settlement_execution_time: AwareDatetime + + +class GetMemberFundingPaymentsResponse(BaseModel): + """Response from GET /margin/funding_payments.""" + + payments: list[MemberFundingPayment] + cursor: str | None = None model_config = {"extra": "allow"} diff --git a/kalshi/perps/klear/resources/margin.py b/kalshi/perps/klear/resources/margin.py index b47984e8..0246eae6 100644 --- a/kalshi/perps/klear/resources/margin.py +++ b/kalshi/perps/klear/resources/margin.py @@ -40,12 +40,17 @@ from kalshi.models.common import Page from kalshi.perps.klear.models.margin import ( AssetClassLiteral, + ClearingTypeLiteral, + CreateMarginFcmApiKeyRequest, + CreateMarginFcmApiKeyResponse, CreateMarginSubtraderGroupRequest, CreateMarginSubtraderGroupResponse, EstimatePortfolioMaintenanceMarginPosition, EstimatePortfolioMaintenanceMarginRequest, EstimatePortfolioMaintenanceMarginResponse, FundingPaymentDetail, + GenerateMarginFcmApiKeyRequest, + GenerateMarginFcmApiKeyResponse, GetActiveMarginObligationsResponse, GetGuarantyFundBalanceResponse, GetMarginReportsResponse, @@ -54,7 +59,9 @@ GetSettlementBalanceWithdrawalResponse, GetSettlementEstimateByAssetClassResponse, GetSettlementPricesResponse, + ListMarginFcmApiKeysResponse, MaintenanceMarginDetail, + MemberFundingPayment, ObligationEntry, SettlementBalanceHistoryEntry, SettlementDetail, @@ -417,10 +424,18 @@ def estimate_maintenance_margin( request: EstimatePortfolioMaintenanceMarginRequest | None = None, asset_class: AssetClassLiteral | None = None, positions: list[EstimatePortfolioMaintenanceMarginPosition] | None = None, + date: datetime.date | None = None, + clearing_type: ClearingTypeLiteral | None = None, extra_headers: dict[str, str] | None = None, ) -> EstimatePortfolioMaintenanceMarginResponse: """``POST /margin/estimate_maintenance_margin``. Not retried.""" - _check_request_exclusive(request, asset_class=asset_class, positions=positions) + _check_request_exclusive( + request, + asset_class=asset_class, + positions=positions, + date=date, + clearing_type=clearing_type, + ) if request is None: if asset_class is None or positions is None: raise TypeError( @@ -428,7 +443,10 @@ def estimate_maintenance_margin( "`positions` (or pass `request=...`)" ) request = EstimatePortfolioMaintenanceMarginRequest( - asset_class=asset_class, positions=positions + asset_class=asset_class, + positions=positions, + date=date, + clearing_type=clearing_type, ) data = self._post( "/margin/estimate_maintenance_margin", @@ -437,6 +455,138 @@ def estimate_maintenance_margin( ) return EstimatePortfolioMaintenanceMarginResponse.model_validate(data) + def member_funding_payments( + self, + *, + funding_time: str, + market_ticker: str | None = None, + limit: int | None = None, + cursor: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Page[MemberFundingPayment]: + """``GET /margin/funding_payments`` — member payments for one funding time. + + ``funding_time`` is RFC3339. Limit max 10000. + """ + params = _params( + funding_time=funding_time, + market_ticker=market_ticker, + limit=_validate_limit(limit, hi=10000), + cursor=cursor, + ) + return self._list( + "/margin/funding_payments", + MemberFundingPayment, + "payments", + params=params, + cursor_key="cursor", + extra_headers=extra_headers, + ) + + def member_funding_payments_all( + self, + *, + funding_time: str, + market_ticker: str | None = None, + limit: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Iterator[MemberFundingPayment]: + """Auto-paginate :meth:`member_funding_payments`.""" + _validate_max_pages(max_pages) + params = _params( + funding_time=funding_time, + market_ticker=market_ticker, + limit=_validate_limit(limit, hi=10000), + cursor=None, + ) + return self._list_all( + "/margin/funding_payments", + MemberFundingPayment, + "payments", + params=params, + max_pages=max_pages, + cursor_key="cursor", + extra_headers=extra_headers, + ) + + def list_fcm_api_keys( + self, + *, + fcm_subtrader_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> ListMarginFcmApiKeysResponse: + """``GET /fcm/margin/api_keys`` — FCM-bound margin API keys.""" + params = _params(fcm_subtrader_id=fcm_subtrader_id) + data = self._get( + "/fcm/margin/api_keys", params=params, extra_headers=extra_headers + ) + return ListMarginFcmApiKeysResponse.model_validate(data) + + def create_fcm_api_key( + self, + *, + request: CreateMarginFcmApiKeyRequest | None = None, + name: str | None = None, + public_key: str | None = None, + fcm_subtrader_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> CreateMarginFcmApiKeyResponse: + """``POST /fcm/margin/api_keys`` — register a caller-supplied public key.""" + _check_request_exclusive( + request, name=name, public_key=public_key, fcm_subtrader_id=fcm_subtrader_id + ) + if request is None: + if name is None or public_key is None or fcm_subtrader_id is None: + raise TypeError( + "create_fcm_api_key() requires `name`, `public_key`, and " + "`fcm_subtrader_id` (or pass `request=...`)" + ) + request = CreateMarginFcmApiKeyRequest( + name=name, public_key=public_key, fcm_subtrader_id=fcm_subtrader_id + ) + data = self._post( + "/fcm/margin/api_keys", + json=request.model_dump(exclude_none=True, by_alias=True, mode="json"), + extra_headers=extra_headers, + ) + return CreateMarginFcmApiKeyResponse.model_validate(data) + + def generate_fcm_api_key( + self, + *, + request: GenerateMarginFcmApiKeyRequest | None = None, + name: str | None = None, + fcm_subtrader_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GenerateMarginFcmApiKeyResponse: + """``POST /fcm/margin/api_keys/generate`` — mint a key pair (private key once).""" + _check_request_exclusive(request, name=name, fcm_subtrader_id=fcm_subtrader_id) + if request is None: + if name is None or fcm_subtrader_id is None: + raise TypeError( + "generate_fcm_api_key() requires `name` and `fcm_subtrader_id` " + "(or pass `request=...`)" + ) + request = GenerateMarginFcmApiKeyRequest( + name=name, fcm_subtrader_id=fcm_subtrader_id + ) + data = self._post( + "/fcm/margin/api_keys/generate", + json=request.model_dump(exclude_none=True, by_alias=True, mode="json"), + extra_headers=extra_headers, + ) + return GenerateMarginFcmApiKeyResponse.model_validate(data) + + def delete_fcm_api_key( + self, api_key_id: str, *, extra_headers: dict[str, str] | None = None + ) -> None: + """``DELETE /fcm/margin/api_keys/{api_key_id}``.""" + self._delete( + f"/fcm/margin/api_keys/{_seg(api_key_id, name='api_key_id')}", + extra_headers=extra_headers, + ) + def list_subtrader_groups( self, *, extra_headers: dict[str, str] | None = None ) -> GetMarginSubtraderGroupsResponse: @@ -795,10 +945,18 @@ async def estimate_maintenance_margin( request: EstimatePortfolioMaintenanceMarginRequest | None = None, asset_class: AssetClassLiteral | None = None, positions: list[EstimatePortfolioMaintenanceMarginPosition] | None = None, + date: datetime.date | None = None, + clearing_type: ClearingTypeLiteral | None = None, extra_headers: dict[str, str] | None = None, ) -> EstimatePortfolioMaintenanceMarginResponse: """Async :meth:`MarginResource.estimate_maintenance_margin`.""" - _check_request_exclusive(request, asset_class=asset_class, positions=positions) + _check_request_exclusive( + request, + asset_class=asset_class, + positions=positions, + date=date, + clearing_type=clearing_type, + ) if request is None: if asset_class is None or positions is None: raise TypeError( @@ -806,7 +964,10 @@ async def estimate_maintenance_margin( "`positions` (or pass `request=...`)" ) request = EstimatePortfolioMaintenanceMarginRequest( - asset_class=asset_class, positions=positions + asset_class=asset_class, + positions=positions, + date=date, + clearing_type=clearing_type, ) data = await self._post( "/margin/estimate_maintenance_margin", @@ -815,6 +976,135 @@ async def estimate_maintenance_margin( ) return EstimatePortfolioMaintenanceMarginResponse.model_validate(data) + async def member_funding_payments( + self, + *, + funding_time: str, + market_ticker: str | None = None, + limit: int | None = None, + cursor: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Page[MemberFundingPayment]: + """Async :meth:`MarginResource.member_funding_payments`.""" + params = _params( + funding_time=funding_time, + market_ticker=market_ticker, + limit=_validate_limit(limit, hi=10000), + cursor=cursor, + ) + return await self._list( + "/margin/funding_payments", + MemberFundingPayment, + "payments", + params=params, + cursor_key="cursor", + extra_headers=extra_headers, + ) + + def member_funding_payments_all( + self, + *, + funding_time: str, + market_ticker: str | None = None, + limit: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> AsyncIterator[MemberFundingPayment]: + """Async counterpart of :meth:`MarginResource.member_funding_payments_all`.""" + _validate_max_pages(max_pages) + params = _params( + funding_time=funding_time, + market_ticker=market_ticker, + limit=_validate_limit(limit, hi=10000), + cursor=None, + ) + return self._list_all( + "/margin/funding_payments", + MemberFundingPayment, + "payments", + params=params, + max_pages=max_pages, + cursor_key="cursor", + extra_headers=extra_headers, + ) + + async def list_fcm_api_keys( + self, + *, + fcm_subtrader_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> ListMarginFcmApiKeysResponse: + """Async :meth:`MarginResource.list_fcm_api_keys`.""" + params = _params(fcm_subtrader_id=fcm_subtrader_id) + data = await self._get( + "/fcm/margin/api_keys", params=params, extra_headers=extra_headers + ) + return ListMarginFcmApiKeysResponse.model_validate(data) + + async def create_fcm_api_key( + self, + *, + request: CreateMarginFcmApiKeyRequest | None = None, + name: str | None = None, + public_key: str | None = None, + fcm_subtrader_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> CreateMarginFcmApiKeyResponse: + """Async :meth:`MarginResource.create_fcm_api_key`.""" + _check_request_exclusive( + request, name=name, public_key=public_key, fcm_subtrader_id=fcm_subtrader_id + ) + if request is None: + if name is None or public_key is None or fcm_subtrader_id is None: + raise TypeError( + "create_fcm_api_key() requires `name`, `public_key`, and " + "`fcm_subtrader_id` (or pass `request=...`)" + ) + request = CreateMarginFcmApiKeyRequest( + name=name, public_key=public_key, fcm_subtrader_id=fcm_subtrader_id + ) + data = await self._post( + "/fcm/margin/api_keys", + json=request.model_dump(exclude_none=True, by_alias=True, mode="json"), + extra_headers=extra_headers, + ) + return CreateMarginFcmApiKeyResponse.model_validate(data) + + async def generate_fcm_api_key( + self, + *, + request: GenerateMarginFcmApiKeyRequest | None = None, + name: str | None = None, + fcm_subtrader_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GenerateMarginFcmApiKeyResponse: + """Async :meth:`MarginResource.generate_fcm_api_key`.""" + _check_request_exclusive(request, name=name, fcm_subtrader_id=fcm_subtrader_id) + if request is None: + if name is None or fcm_subtrader_id is None: + raise TypeError( + "generate_fcm_api_key() requires `name` and `fcm_subtrader_id` " + "(or pass `request=...`)" + ) + request = GenerateMarginFcmApiKeyRequest( + name=name, fcm_subtrader_id=fcm_subtrader_id + ) + data = await self._post( + "/fcm/margin/api_keys/generate", + json=request.model_dump(exclude_none=True, by_alias=True, mode="json"), + extra_headers=extra_headers, + ) + return GenerateMarginFcmApiKeyResponse.model_validate(data) + + async def delete_fcm_api_key( + self, api_key_id: str, *, extra_headers: dict[str, str] | None = None + ) -> None: + """Async :meth:`MarginResource.delete_fcm_api_key`.""" + await self._delete( + f"/fcm/margin/api_keys/{_seg(api_key_id, name='api_key_id')}", + extra_headers=extra_headers, + ) + async def list_subtrader_groups( self, *, extra_headers: dict[str, str] | None = None ) -> GetMarginSubtraderGroupsResponse: diff --git a/kalshi/perps/models/__init__.py b/kalshi/perps/models/__init__.py index 110dcbbb..594bd634 100644 --- a/kalshi/perps/models/__init__.py +++ b/kalshi/perps/models/__init__.py @@ -37,9 +37,12 @@ MarginFundingRateEstimate, ) from kalshi.perps.models.margin_account import ( + FeeScheduleLiteral, GetMarginBalanceResponse, + GetMarginFeeTierRatesResponse, GetMarginFeeTiersResponse, GetMarginRiskResponse, + MarginFeeTierRate, MarginRiskPosition, MarginSubaccountBalance, NotionalRiskLimitResponse, @@ -131,9 +134,11 @@ "ExitTriggerStatusLiteral", "FCMAssetClassLiteral", "FCMSubtraderRiskControls", + "FeeScheduleLiteral", "GetExitTriggersResponse", "GetFCMSubtraderRiskControlsResponse", "GetMarginBalanceResponse", + "GetMarginFeeTierRatesResponse", "GetMarginFeeTiersResponse", "GetMarginFillsResponse", "GetMarginOrderResponse", @@ -148,6 +153,7 @@ "LastUpdateReason", "LastUpdateReasonLiteral", "MarginEnabledResponse", + "MarginFeeTierRate", "MarginFill", "MarginFundingHistoryEntry", "MarginFundingRate", diff --git a/kalshi/perps/models/margin_account.py b/kalshi/perps/models/margin_account.py index b55591ad..b2859f9f 100644 --- a/kalshi/perps/models/margin_account.py +++ b/kalshi/perps/models/margin_account.py @@ -19,7 +19,9 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field from kalshi.types import ( DollarDecimal, @@ -102,3 +104,26 @@ class GetMarginFeeTiersResponse(BaseModel): maker_fee_rates: dict[str, MultiplierDecimal] taker_fee_rates: dict[str, MultiplierDecimal] + + +FeeScheduleLiteral = Literal["self_clearing_members", "kalshi_prime", "fcm"] +"""Fee schedule containing a :class:`MarginFeeTierRate`.""" + + +class MarginFeeTierRate(BaseModel): + """One row of the authenticated account's margin fee-tier schedule.""" + + model_config = ConfigDict(extra="allow") + + fee_schedule: FeeScheduleLiteral + tier: int = Field(ge=0) + maker_fee_rate: MultiplierDecimal + taker_fee_rate: MultiplierDecimal + + +class GetMarginFeeTierRatesResponse(BaseModel): + """Response from GET /margin/fee_tier_rates.""" + + model_config = ConfigDict(extra="allow") + + fee_tier_rates: list[MarginFeeTierRate] diff --git a/kalshi/perps/models/markets.py b/kalshi/perps/models/markets.py index b925d1c0..d7d15de3 100644 --- a/kalshi/perps/models/markets.py +++ b/kalshi/perps/models/markets.py @@ -132,6 +132,8 @@ class MarginMarket(BaseModel): settlement_mark_price: TickerPrice | None = None liquidation_mark_price: TickerPrice | None = None reference_price: TickerPrice | None = None + # Omitted when the market has no assigned class. New classes may appear over time. + asset_class: str | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/perps/resources/margin_account.py b/kalshi/perps/resources/margin_account.py index 4d871d06..6e8962f0 100644 --- a/kalshi/perps/resources/margin_account.py +++ b/kalshi/perps/resources/margin_account.py @@ -16,6 +16,7 @@ from kalshi.models.account import AccountApiLimits from kalshi.perps.models.margin_account import ( GetMarginBalanceResponse, + GetMarginFeeTierRatesResponse, GetMarginFeeTiersResponse, GetMarginRiskResponse, NotionalRiskLimitResponse, @@ -56,6 +57,14 @@ def fee_tiers( data = self._get("/margin/fee_tiers", extra_headers=extra_headers) return GetMarginFeeTiersResponse.model_validate(data) + def fee_tier_rates( + self, *, extra_headers: dict[str, str] | None = None + ) -> GetMarginFeeTierRatesResponse: + """``GET /margin/fee_tier_rates`` — maker/taker rates by fee-schedule tier.""" + self._require_auth() + data = self._get("/margin/fee_tier_rates", extra_headers=extra_headers) + return GetMarginFeeTierRatesResponse.model_validate(data) + def api_limits(self, *, extra_headers: dict[str, str] | None = None) -> AccountApiLimits: """Perps (margin) API tier limits for the authenticated user. @@ -100,6 +109,14 @@ async def fee_tiers( data = await self._get("/margin/fee_tiers", extra_headers=extra_headers) return GetMarginFeeTiersResponse.model_validate(data) + async def fee_tier_rates( + self, *, extra_headers: dict[str, str] | None = None + ) -> GetMarginFeeTierRatesResponse: + """Async :meth:`MarginAccountResource.fee_tier_rates`.""" + self._require_auth() + data = await self._get("/margin/fee_tier_rates", extra_headers=extra_headers) + return GetMarginFeeTierRatesResponse.model_validate(data) + async def api_limits( self, *, extra_headers: dict[str, str] | None = None ) -> AccountApiLimits: diff --git a/kalshi/perps/ws/models/_common.py b/kalshi/perps/ws/models/_common.py index 891ce1a1..0b759c41 100644 --- a/kalshi/perps/ws/models/_common.py +++ b/kalshi/perps/ws/models/_common.py @@ -22,6 +22,9 @@ # Spec schema ``selfTradePreventionType``. PerpsSelfTradePreventionType = Literal["taker_at_cross", "maker"] +# Spec schema ``orderSource`` — ``system`` for liquidations / exit triggers. +PerpsOrderSource = Literal["user", "system"] + # Spec schema ``lastUpdateReason`` — margin order update reason on a delta # corresponding to the authenticated user's order. The empty string is a valid # enum member per spec. @@ -49,5 +52,6 @@ "OrderGroupEventType", "PerpsBookSide", "PerpsLastUpdateReason", + "PerpsOrderSource", "PerpsSelfTradePreventionType", ] diff --git a/kalshi/perps/ws/models/control.py b/kalshi/perps/ws/models/control.py index c400e36b..f10c65fd 100644 --- a/kalshi/perps/ws/models/control.py +++ b/kalshi/perps/ws/models/control.py @@ -270,3 +270,5 @@ class PerpsErrorResponse(BaseModel): id: int | None = None type: Literal["error"] = "error" msg: PerpsErrorMsg + sid: int | None = None + seq: int | None = None diff --git a/kalshi/perps/ws/models/fill.py b/kalshi/perps/ws/models/fill.py index 22e6b88c..4808f905 100644 --- a/kalshi/perps/ws/models/fill.py +++ b/kalshi/perps/ws/models/fill.py @@ -10,7 +10,7 @@ from pydantic import BaseModel -from kalshi.perps.ws.models._common import PerpsBookSide +from kalshi.perps.ws.models._common import PerpsBookSide, PerpsOrderSource from kalshi.types import DollarDecimal, FixedPointCount @@ -19,9 +19,9 @@ class MarginFillPayload(BaseModel): Required per spec: ``trade_id``/``order_id`` (UUID strings), ``market_ticker``, ``is_taker``, ``side`` (``bid``/``ask``), ``ts_ms`` (epoch ms), ``price`` - (dollar-decimal), ``count``/``post_position`` (fixed-point counts), and - ``fee_cost`` (dollar-decimal). ``client_order_id`` and ``subaccount`` are - optional. + (dollar-decimal), ``count``/``post_position`` (fixed-point counts), + ``fee_cost`` (dollar-decimal), and ``order_source`` (``user``/``system``). + ``client_order_id`` and ``subaccount`` are optional. """ trade_id: str @@ -34,6 +34,7 @@ class MarginFillPayload(BaseModel): count: FixedPointCount fee_cost: DollarDecimal post_position: FixedPointCount + order_source: PerpsOrderSource client_order_id: str | None = None subaccount: int | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/perps/ws/models/user_orders.py b/kalshi/perps/ws/models/user_orders.py index 66d3ca23..b1dcc7dd 100644 --- a/kalshi/perps/ws/models/user_orders.py +++ b/kalshi/perps/ws/models/user_orders.py @@ -15,6 +15,7 @@ from kalshi.perps.ws.models._common import ( PerpsBookSide, + PerpsOrderSource, PerpsSelfTradePreventionType, ) from kalshi.types import DollarDecimal, FixedPointCount @@ -26,8 +27,9 @@ class MarginUserOrderPayload(BaseModel): Required per spec: ``order_id``/``user_id`` (UUID strings), ``client_order_id``, ``ticker`` (note: ``ticker``, NOT ``market_ticker``), ``side`` (``bid``/ ``ask``), ``price`` (dollar-decimal), ``fill_count``/``remaining_count`` - (fixed-point counts), and ``created_ts_ms`` (epoch ms). The remaining fields - — STP type, order-group id, and the ``*_ts_ms`` timestamps — are optional. + (fixed-point counts), ``created_ts_ms`` (epoch ms), and ``order_source`` + (``user``/``system``). The remaining fields — STP type, order-group id, and + the ``*_ts_ms`` timestamps — are optional. """ order_id: str @@ -39,6 +41,7 @@ class MarginUserOrderPayload(BaseModel): fill_count: FixedPointCount remaining_count: FixedPointCount created_ts_ms: int + order_source: PerpsOrderSource self_trade_prevention_type: PerpsSelfTradePreventionType | None = None order_group_id: str | None = None expiration_ts_ms: int | None = None diff --git a/kalshi/resources/api_keys.py b/kalshi/resources/api_keys.py index 640af827..e592aee9 100644 --- a/kalshi/resources/api_keys.py +++ b/kalshi/resources/api_keys.py @@ -16,6 +16,7 @@ AsyncResource, SyncResource, _check_request_exclusive, + _params, _seg, ) @@ -29,6 +30,7 @@ def _build_create_api_key_body( public_key: str | None, scopes: builtins.list[str] | None, subaccount: int | None, + fcm_subtrader_id: str | None, ) -> dict[str, Any]: _check_request_exclusive( request, @@ -36,6 +38,7 @@ def _build_create_api_key_body( public_key=public_key, scopes=scopes, subaccount=subaccount, + fcm_subtrader_id=fcm_subtrader_id, ) if request is None: if name is None or public_key is None: @@ -45,6 +48,7 @@ def _build_create_api_key_body( public_key=public_key, scopes=scopes, subaccount=subaccount, + fcm_subtrader_id=fcm_subtrader_id, ) return request.model_dump(exclude_none=True, by_alias=True, mode="json") @@ -55,12 +59,24 @@ def _build_generate_api_key_body( name: str | None, scopes: builtins.list[str] | None, subaccount: int | None, + fcm_subtrader_id: str | None, ) -> dict[str, Any]: - _check_request_exclusive(request, name=name, scopes=scopes, subaccount=subaccount) + _check_request_exclusive( + request, + name=name, + scopes=scopes, + subaccount=subaccount, + fcm_subtrader_id=fcm_subtrader_id, + ) if request is None: if name is None: raise TypeError("generate() requires `name` (or pass `request=...`)") - request = GenerateApiKeyRequest(name=name, scopes=scopes, subaccount=subaccount) + request = GenerateApiKeyRequest( + name=name, + scopes=scopes, + subaccount=subaccount, + fcm_subtrader_id=fcm_subtrader_id, + ) return request.model_dump(exclude_none=True, by_alias=True, mode="json") @@ -72,9 +88,18 @@ class ApiKeysResource(SyncResource): private key once (see :class:`GenerateApiKeyResponse`). """ - def list(self, *, extra_headers: dict[str, str] | None = None) -> GetApiKeysResponse: + def list( + self, + *, + fcm_subtrader_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetApiKeysResponse: self._require_auth() - data = self._get("/api_keys", extra_headers=extra_headers) + data = self._get( + "/api_keys", + params=_params(fcm_subtrader_id=fcm_subtrader_id), + extra_headers=extra_headers, + ) return GetApiKeysResponse.model_validate(data) @overload @@ -89,6 +114,7 @@ def create( public_key: str, scopes: builtins.list[str] | None = ..., subaccount: int | None = ..., + fcm_subtrader_id: str | None = ..., extra_headers: dict[str, str] | None = None, ) -> CreateApiKeyResponse: ... def create( @@ -99,6 +125,7 @@ def create( public_key: str | None = None, scopes: builtins.list[str] | None = None, subaccount: int | None = None, + fcm_subtrader_id: str | None = None, extra_headers: dict[str, str] | None = None, ) -> CreateApiKeyResponse: self._require_auth() @@ -108,6 +135,7 @@ def create( public_key=public_key, scopes=scopes, subaccount=subaccount, + fcm_subtrader_id=fcm_subtrader_id, ) data = self._post("/api_keys", json=body, extra_headers=extra_headers) return CreateApiKeyResponse.model_validate(data) @@ -123,6 +151,7 @@ def generate( name: str, scopes: builtins.list[str] | None = ..., subaccount: int | None = ..., + fcm_subtrader_id: str | None = ..., extra_headers: dict[str, str] | None = None, ) -> GenerateApiKeyResponse: ... def generate( @@ -132,11 +161,16 @@ def generate( name: str | None = None, scopes: builtins.list[str] | None = None, subaccount: int | None = None, + fcm_subtrader_id: str | None = None, extra_headers: dict[str, str] | None = None, ) -> GenerateApiKeyResponse: self._require_auth() body = _build_generate_api_key_body( - request, name=name, scopes=scopes, subaccount=subaccount + request, + name=name, + scopes=scopes, + subaccount=subaccount, + fcm_subtrader_id=fcm_subtrader_id, ) data = self._post("/api_keys/generate", json=body, extra_headers=extra_headers) return GenerateApiKeyResponse.model_validate(data) @@ -149,9 +183,18 @@ def delete(self, api_key: str, *, extra_headers: dict[str, str] | None = None) - class AsyncApiKeysResource(AsyncResource): """Async API keys resource.""" - async def list(self, *, extra_headers: dict[str, str] | None = None) -> GetApiKeysResponse: + async def list( + self, + *, + fcm_subtrader_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetApiKeysResponse: self._require_auth() - data = await self._get("/api_keys", extra_headers=extra_headers) + data = await self._get( + "/api_keys", + params=_params(fcm_subtrader_id=fcm_subtrader_id), + extra_headers=extra_headers, + ) return GetApiKeysResponse.model_validate(data) @overload @@ -166,6 +209,7 @@ async def create( public_key: str, scopes: builtins.list[str] | None = ..., subaccount: int | None = ..., + fcm_subtrader_id: str | None = ..., extra_headers: dict[str, str] | None = None, ) -> CreateApiKeyResponse: ... async def create( @@ -176,6 +220,7 @@ async def create( public_key: str | None = None, scopes: builtins.list[str] | None = None, subaccount: int | None = None, + fcm_subtrader_id: str | None = None, extra_headers: dict[str, str] | None = None, ) -> CreateApiKeyResponse: self._require_auth() @@ -185,6 +230,7 @@ async def create( public_key=public_key, scopes=scopes, subaccount=subaccount, + fcm_subtrader_id=fcm_subtrader_id, ) data = await self._post("/api_keys", json=body, extra_headers=extra_headers) return CreateApiKeyResponse.model_validate(data) @@ -200,6 +246,7 @@ async def generate( name: str, scopes: builtins.list[str] | None = ..., subaccount: int | None = ..., + fcm_subtrader_id: str | None = ..., extra_headers: dict[str, str] | None = None, ) -> GenerateApiKeyResponse: ... async def generate( @@ -209,11 +256,16 @@ async def generate( name: str | None = None, scopes: builtins.list[str] | None = None, subaccount: int | None = None, + fcm_subtrader_id: str | None = None, extra_headers: dict[str, str] | None = None, ) -> GenerateApiKeyResponse: self._require_auth() body = _build_generate_api_key_body( - request, name=name, scopes=scopes, subaccount=subaccount + request, + name=name, + scopes=scopes, + subaccount=subaccount, + fcm_subtrader_id=fcm_subtrader_id, ) data = await self._post("/api_keys/generate", json=body, extra_headers=extra_headers) return GenerateApiKeyResponse.model_validate(data) diff --git a/kalshi/resources/fcm.py b/kalshi/resources/fcm.py index cff53f34..18fa0c68 100644 --- a/kalshi/resources/fcm.py +++ b/kalshi/resources/fcm.py @@ -12,6 +12,7 @@ from __future__ import annotations +import builtins from collections.abc import AsyncIterator, Iterator from typing import Any @@ -29,9 +30,24 @@ # Shared param builders (issue #46). +def _join_client_order_ids( + ids: str | builtins.list[str] | None, +) -> str | None: + """Serialize ``client_order_ids`` as a comma-separated string (spec max 100).""" + if ids is None: + return None + parts = [p for p in ids.split(",") if p] if isinstance(ids, str) else list(ids) + if len(parts) > 100: + raise ValueError( + f"client_order_ids accepts at most 100 entries per spec (got {len(parts)})" + ) + return ",".join(parts) if parts else None + + def _fcm_orders_params( *, - subtrader_id: str, + subtrader_id: str | None, + client_order_ids: str | builtins.list[str] | None, ticker: str | None, event_ticker: str | None, status: OrderStatusLiteral | None, @@ -40,9 +56,13 @@ def _fcm_orders_params( limit: int | None, cursor: str | None, ) -> dict[str, Any]: + joined = _join_client_order_ids(client_order_ids) + if not subtrader_id and not joined: + raise ValueError("fcm.orders requires subtrader_id or client_order_ids") limit = _validate_limit(limit, hi=1000) return _params( subtrader_id=subtrader_id, + client_order_ids=joined, ticker=ticker, event_ticker=event_ticker, status=status, @@ -81,7 +101,8 @@ class FcmResource(SyncResource): def orders( self, *, - subtrader_id: str, + subtrader_id: str | None = None, + client_order_ids: str | builtins.list[str] | None = None, ticker: str | None = None, event_ticker: str | None = None, status: OrderStatusLiteral | None = None, @@ -94,6 +115,7 @@ def orders( self._require_auth() params = _fcm_orders_params( subtrader_id=subtrader_id, + client_order_ids=client_order_ids, ticker=ticker, event_ticker=event_ticker, status=status, @@ -109,7 +131,8 @@ def orders( def orders_all( self, *, - subtrader_id: str, + subtrader_id: str | None = None, + client_order_ids: str | builtins.list[str] | None = None, ticker: str | None = None, event_ticker: str | None = None, status: OrderStatusLiteral | None = None, @@ -123,6 +146,7 @@ def orders_all( _validate_max_pages(max_pages) params = _fcm_orders_params( subtrader_id=subtrader_id, + client_order_ids=client_order_ids, ticker=ticker, event_ticker=event_ticker, status=status, @@ -210,7 +234,8 @@ class AsyncFcmResource(AsyncResource): async def orders( self, *, - subtrader_id: str, + subtrader_id: str | None = None, + client_order_ids: str | builtins.list[str] | None = None, ticker: str | None = None, event_ticker: str | None = None, status: OrderStatusLiteral | None = None, @@ -223,6 +248,7 @@ async def orders( self._require_auth() params = _fcm_orders_params( subtrader_id=subtrader_id, + client_order_ids=client_order_ids, ticker=ticker, event_ticker=event_ticker, status=status, @@ -238,7 +264,8 @@ async def orders( def orders_all( self, *, - subtrader_id: str, + subtrader_id: str | None = None, + client_order_ids: str | builtins.list[str] | None = None, ticker: str | None = None, event_ticker: str | None = None, status: OrderStatusLiteral | None = None, @@ -253,6 +280,7 @@ def orders_all( _validate_max_pages(max_pages) params = _fcm_orders_params( subtrader_id=subtrader_id, + client_order_ids=client_order_ids, ticker=ticker, event_ticker=event_ticker, status=status, diff --git a/kalshi/resources/historical.py b/kalshi/resources/historical.py index 7160f0bc..ed2bdbfd 100644 --- a/kalshi/resources/historical.py +++ b/kalshi/resources/historical.py @@ -99,11 +99,12 @@ def _historical_positions_params( cursor: str | None, ticker: str | None, event_ticker: str | None, + subaccount: int | None, ) -> dict[str, Any]: """Query params for GET /historical/positions. - Spec params are a subset of /portfolio/positions (no ``count_filter``, - no ``subaccount``). + Spec params are a subset of /portfolio/positions (no ``count_filter``). + ``subaccount`` defaults to the primary (0) server-side when omitted. """ limit = _validate_limit(limit, hi=1000) return _params( @@ -111,6 +112,7 @@ def _historical_positions_params( cursor=cursor, ticker=ticker, event_ticker=event_ticker, + subaccount=subaccount, ) @@ -353,6 +355,7 @@ def positions( cursor: str | None = None, ticker: str | None = None, event_ticker: str | None = None, + subaccount: int | None = None, extra_headers: dict[str, str] | None = None, ) -> PositionsResponse: """Settled market positions archived to the historical database. @@ -360,6 +363,7 @@ def positions( Positions whose markets were archived before ``market_positions_last_updated_ts`` on :meth:`cutoff` are available here. Unsettled positions remain on ``GET /portfolio/positions``. + ``subaccount`` defaults to the primary (0) server-side when omitted. """ self._require_auth() params = _historical_positions_params( @@ -367,6 +371,7 @@ def positions( cursor=cursor, ticker=ticker, event_ticker=event_ticker, + subaccount=subaccount, ) data = self._get("/historical/positions", params=params, extra_headers=extra_headers) return PositionsResponse.model_validate(data) @@ -377,6 +382,7 @@ def positions_all( limit: int | None = None, ticker: str | None = None, event_ticker: str | None = None, + subaccount: int | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Iterator[MarketPosition]: @@ -393,6 +399,7 @@ def positions_all( cursor=None, ticker=ticker, event_ticker=event_ticker, + subaccount=subaccount, ) return self._list_all( "/historical/positions", @@ -643,6 +650,7 @@ async def positions( cursor: str | None = None, ticker: str | None = None, event_ticker: str | None = None, + subaccount: int | None = None, extra_headers: dict[str, str] | None = None, ) -> PositionsResponse: """Settled market positions archived to the historical database. @@ -650,6 +658,7 @@ async def positions( Positions whose markets were archived before ``market_positions_last_updated_ts`` on :meth:`cutoff` are available here. Unsettled positions remain on ``GET /portfolio/positions``. + ``subaccount`` defaults to the primary (0) server-side when omitted. """ self._require_auth() params = _historical_positions_params( @@ -657,6 +666,7 @@ async def positions( cursor=cursor, ticker=ticker, event_ticker=event_ticker, + subaccount=subaccount, ) data = await self._get( "/historical/positions", params=params, extra_headers=extra_headers @@ -669,6 +679,7 @@ def positions_all( limit: int | None = None, ticker: str | None = None, event_ticker: str | None = None, + subaccount: int | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> AsyncIterator[MarketPosition]: @@ -685,6 +696,7 @@ def positions_all( cursor=None, ticker=ticker, event_ticker=event_ticker, + subaccount=subaccount, ) return self._list_all( "/historical/positions", diff --git a/kalshi/resources/live_data.py b/kalshi/resources/live_data.py index 825b00ac..71c488df 100644 --- a/kalshi/resources/live_data.py +++ b/kalshi/resources/live_data.py @@ -15,6 +15,7 @@ GetGameStatsResponse, GetLiveDataResponse, GetLiveDatasResponse, + GetWeatherIndexCalibrationsResponse, GetWeatherIndexResponse, LiveData, ) @@ -167,6 +168,23 @@ def weather( ) return GetWeatherIndexResponse.model_validate(data) + def weather_calibrations( + self, + city: str, + *, + extra_headers: dict[str, str] | None = None, + ) -> GetWeatherIndexCalibrationsResponse: + """``GET /live_data/weather/{city}/calibrations`` — config timeline. + + Returns the launch configuration plus every weekly offset calibration + and methodology update, ascending by effective time. + """ + data = self._get( + f"/live_data/weather/{_seg(city, name='city')}/calibrations", + extra_headers=extra_headers, + ) + return GetWeatherIndexCalibrationsResponse.model_validate(data) + class AsyncLiveDataResource(AsyncResource): """Async live-data API.""" @@ -304,3 +322,16 @@ async def weather( extra_headers=extra_headers, ) return GetWeatherIndexResponse.model_validate(data) + + async def weather_calibrations( + self, + city: str, + *, + extra_headers: dict[str, str] | None = None, + ) -> GetWeatherIndexCalibrationsResponse: + """Async :meth:`LiveDataResource.weather_calibrations`.""" + data = await self._get( + f"/live_data/weather/{_seg(city, name='city')}/calibrations", + extra_headers=extra_headers, + ) + return GetWeatherIndexCalibrationsResponse.model_validate(data) diff --git a/kalshi/resources/portfolio.py b/kalshi/resources/portfolio.py index 91eb5e21..16953d9b 100644 --- a/kalshi/resources/portfolio.py +++ b/kalshi/resources/portfolio.py @@ -14,6 +14,7 @@ IntraExchangeInstanceTransfer, MarketPosition, PositionsResponse, + RestingMarginReservationLiteral, SetTargetBalanceAllocationRequest, Settlement, TargetBalanceAllocationInput, @@ -452,21 +453,30 @@ def set_target_balance_allocation( *, request: SetTargetBalanceAllocationRequest | None = None, allocations: list[TargetBalanceAllocationInput] | None = None, + resting_margin_reservation: RestingMarginReservationLiteral | None = None, extra_headers: dict[str, str] | None = None, ) -> None: """``POST /portfolio/target_balance_allocation`` — replace sweep targets. - Not retried (POST). + Not retried (POST). ``resting_margin_reservation`` defaults to ``sum`` + server-side when omitted. """ self._require_auth() - _check_request_exclusive(request, allocations=allocations) + _check_request_exclusive( + request, + allocations=allocations, + resting_margin_reservation=resting_margin_reservation, + ) if request is None: if allocations is None: raise TypeError( "set_target_balance_allocation() requires `allocations` " "(or pass `request=...`)" ) - request = SetTargetBalanceAllocationRequest(allocations=allocations) + request = SetTargetBalanceAllocationRequest( + allocations=allocations, + resting_margin_reservation=resting_margin_reservation, + ) self._post_void( "/portfolio/target_balance_allocation", json=request.model_dump(exclude_none=True, by_alias=True, mode="json"), @@ -848,18 +858,26 @@ async def set_target_balance_allocation( *, request: SetTargetBalanceAllocationRequest | None = None, allocations: list[TargetBalanceAllocationInput] | None = None, + resting_margin_reservation: RestingMarginReservationLiteral | None = None, extra_headers: dict[str, str] | None = None, ) -> None: """Async :meth:`PortfolioResource.set_target_balance_allocation`.""" self._require_auth() - _check_request_exclusive(request, allocations=allocations) + _check_request_exclusive( + request, + allocations=allocations, + resting_margin_reservation=resting_margin_reservation, + ) if request is None: if allocations is None: raise TypeError( "set_target_balance_allocation() requires `allocations` " "(or pass `request=...`)" ) - request = SetTargetBalanceAllocationRequest(allocations=allocations) + request = SetTargetBalanceAllocationRequest( + allocations=allocations, + resting_margin_reservation=resting_margin_reservation, + ) await self._post_void( "/portfolio/target_balance_allocation", json=request.model_dump(exclude_none=True, by_alias=True, mode="json"), diff --git a/kalshi/ws/channels.py b/kalshi/ws/channels.py index e950f646..97d82a02 100644 --- a/kalshi/ws/channels.py +++ b/kalshi/ws/channels.py @@ -61,6 +61,7 @@ # CF Benchmarks index value feed: seeded with index_ids only — market_* # params are not supported on this channel. "cfbenchmarks_value": frozenset({"index_ids"}), + "cfbenchmarks_value_5hz": frozenset({"index_ids"}), } diff --git a/kalshi/ws/client.py b/kalshi/ws/client.py index 7bcf59f1..faed3f3d 100644 --- a/kalshi/ws/client.py +++ b/kalshi/ws/client.py @@ -28,7 +28,9 @@ from kalshi.ws.dispatch import MessageDispatcher from kalshi.ws.models.base import ErrorMessage from kalshi.ws.models.cfbenchmarks import ( + CFBenchmarks5HzIndexListMessage, CFBenchmarksIndexListMessage, + CFBenchmarksValue5HzMessage, CFBenchmarksValueMessage, ) from kalshi.ws.models.communications import CommunicationsMessage @@ -900,6 +902,24 @@ async def subscribe_cfbenchmarks_value( overflow=OverflowStrategy.DROP_OLDEST, maxsize=maxsize, ) + async def subscribe_cfbenchmarks_value_5hz( + self, *, index_ids: list[str] | None = None, maxsize: int = 1000, + ) -> AsyncIterator[CFBenchmarksValue5HzMessage | CFBenchmarks5HzIndexListMessage]: + """Subscribe to the auth-required ``cfbenchmarks_value_5hz`` tick feed. + + High-frequency sibling of :meth:`subscribe_cfbenchmarks_value` (up to + 5 ticks/sec, currently BRTI/ETHUSD_RTI/SOLUSD_RTI/XRPUSD_RTI/DOGEUSD_RTI). + Ticks are lean — no 60-second or quarter-hour averages. Seed + ``index_ids`` (or ``["all"]``) to receive values immediately. + """ + params: dict[str, Any] = {} + if index_ids: + params["index_ids"] = index_ids + return await self._do_subscribe( + "cfbenchmarks_value_5hz", params=params, + overflow=OverflowStrategy.DROP_OLDEST, maxsize=maxsize, + ) + # ------------------------------------------------------------------ # Generic subscribe # ------------------------------------------------------------------ diff --git a/kalshi/ws/dispatch.py b/kalshi/ws/dispatch.py index 7b634d5c..a34ee386 100644 --- a/kalshi/ws/dispatch.py +++ b/kalshi/ws/dispatch.py @@ -10,7 +10,9 @@ from kalshi.ws.channels import SubscriptionManager from kalshi.ws.models.base import ErrorMessage, ErrorPayload from kalshi.ws.models.cfbenchmarks import ( + CFBenchmarks5HzIndexListMessage, CFBenchmarksIndexListMessage, + CFBenchmarksValue5HzMessage, CFBenchmarksValueMessage, ) from kalshi.ws.models.communications import CommunicationsMessage @@ -45,6 +47,8 @@ "communications": CommunicationsMessage, "cfbenchmarks_value": CFBenchmarksValueMessage, "cfbenchmarks_value_indexlist": CFBenchmarksIndexListMessage, + "cfbenchmarks_value_5hz": CFBenchmarksValue5HzMessage, + "cfbenchmarks_value_5hz_indexlist": CFBenchmarks5HzIndexListMessage, } # Control message types (not routed to subscription queues) diff --git a/kalshi/ws/models/__init__.py b/kalshi/ws/models/__init__.py index 7c357893..e98423d0 100644 --- a/kalshi/ws/models/__init__.py +++ b/kalshi/ws/models/__init__.py @@ -9,9 +9,13 @@ UnsubscribedMessage, ) from kalshi.ws.models.cfbenchmarks import ( + CFBenchmarks5HzIndexListMessage, + CFBenchmarks5HzIndexListPayload, CFBenchmarksAvgData, CFBenchmarksIndexListMessage, CFBenchmarksIndexListPayload, + CFBenchmarksValue5HzMessage, + CFBenchmarksValue5HzPayload, CFBenchmarksValueMessage, CFBenchmarksValuePayload, ) @@ -57,9 +61,13 @@ # Base envelope "BaseMessage", # CF Benchmarks value feed + "CFBenchmarks5HzIndexListMessage", + "CFBenchmarks5HzIndexListPayload", "CFBenchmarksAvgData", "CFBenchmarksIndexListMessage", "CFBenchmarksIndexListPayload", + "CFBenchmarksValue5HzMessage", + "CFBenchmarksValue5HzPayload", "CFBenchmarksValueMessage", "CFBenchmarksValuePayload", # Communications diff --git a/kalshi/ws/models/base.py b/kalshi/ws/models/base.py index d26b62f0..80fe7430 100644 --- a/kalshi/ws/models/base.py +++ b/kalshi/ws/models/base.py @@ -62,4 +62,6 @@ class ErrorMessage(BaseModel): id: int = 0 type: str = "error" msg: ErrorPayload + sid: int | None = None + seq: int | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/ws/models/cfbenchmarks.py b/kalshi/ws/models/cfbenchmarks.py index a8d7d9ed..f68a616e 100644 --- a/kalshi/ws/models/cfbenchmarks.py +++ b/kalshi/ws/models/cfbenchmarks.py @@ -86,3 +86,47 @@ class CFBenchmarksIndexListMessage(BaseModel): seq: int | None = None msg: CFBenchmarksIndexListPayload model_config = {"extra": "allow", "populate_by_name": True} + + +class CFBenchmarksValue5HzPayload(BaseModel): + """``cfbenchmarks_value_5hz.msg`` — a lean raw tick (no trailing averages). + + High-frequency sibling of :class:`CFBenchmarksValuePayload`. ``value_usd`` + is an exact 8-dp decimal string. ``source_ts_ms`` is the upstream + publication timestamp; ``received_at`` is when Kalshi received the frame. + """ + + index_id: str + value_usd: DollarDecimal + source_ts_ms: int + received_at: int + data: str + model_config = {"extra": "allow", "populate_by_name": True} + + +class CFBenchmarksValue5HzMessage(BaseModel): + """``cfbenchmarks_value_5hz`` data message envelope.""" + + type: Literal["cfbenchmarks_value_5hz"] = "cfbenchmarks_value_5hz" + sid: int + seq: int | None = None + msg: CFBenchmarksValue5HzPayload + model_config = {"extra": "allow", "populate_by_name": True} + + +class CFBenchmarks5HzIndexListPayload(BaseModel): + """``cfbenchmarks_value_5hz_indexlist.msg`` — index IDs on the 5Hz stream.""" + + index_ids: list[str] + model_config = {"extra": "allow", "populate_by_name": True} + + +class CFBenchmarks5HzIndexListMessage(BaseModel): + """``cfbenchmarks_value_5hz_indexlist`` — response to the ``indexlist`` action.""" + + type: Literal["cfbenchmarks_value_5hz_indexlist"] = "cfbenchmarks_value_5hz_indexlist" + id: int | None = None + sid: int + seq: int | None = None + msg: CFBenchmarks5HzIndexListPayload + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/pyproject.toml b/pyproject.toml index fc22d3a0..9434d195 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "13.0.0" +version = "14.0.0" description = "A professional Python SDK for the Kalshi prediction markets and Perps (margin) APIs" readme = "README.md" license = { text = "MIT" } diff --git a/specs/asyncapi.yaml b/specs/asyncapi.yaml index f1b9e9b7..74ff38d6 100644 --- a/specs/asyncapi.yaml +++ b/specs/asyncapi.yaml @@ -21,7 +21,7 @@ info: | 3 | Channels required | Missing channels array in subscribe | | 4 | Subscription IDs required | Missing sids in unsubscribe | | 5 | Unknown command | Invalid command name | - | 6 | Already subscribed | Duplicate subscription attempt | + | 6 | Retired | Not emitted. The number stays reserved. | | 7 | Unknown subscription ID | Subscription ID not found | | 8 | Unknown channel name | Invalid channel in subscribe | | 9 | Authentication required | Channel requires authenticated connection | @@ -31,8 +31,8 @@ info: | 13 | Unsupported action | Invalid action for update_subscription | | 14 | Market Ticker required | Missing market specification (market_ticker or market_id) | | 15 | Action required | Missing action in update_subscription | - | 16 | Market not found | Invalid market_ticker or market_id | - | 17 | Internal error | Server-side processing error | + | 16 | Retired | Not emitted. The number stays reserved. | + | 17 | Retired | Not emitted. The number stays reserved. | | 18 | Command timeout | Server timed out while processing command | | 19 | shard_factor must be > 0 | Invalid shard_factor | | 20 | shard_factor is required when shard_key is set | Missing shard_factor when shard_key is set | @@ -49,7 +49,6 @@ info: | Code | Error | |------|-------| | 10 | Channel error | - | 17 | Internal error | | 25 | Subscription buffer overflow | contact: name: Kalshi Support @@ -347,7 +346,7 @@ channels: - Use `index_ids: ["all"]` to receive every available index - Supports `update_subscription` with `subscribe_indices` / `unsubscribe_indices` / `indexlist` actions - `indexlist` returns the available index IDs (as a `cfbenchmarks_value_indexlist` message) without modifying the subscription - - Ticks are emitted roughly once per second; duplicate or out-of-order upstream source timestamps are ignored + - Ticks are emitted roughly once per second; duplicate or out-of-order upstream source timestamps are ignored. For up to 5 updates per second on supported indices, see the [CF Benchmarks 5Hz Value Feed](/websockets/cfbenchmarks-value-5hz) sibling channel **Use case:** Consuming CF Benchmarks reference index values and their short-window averages @@ -373,13 +372,49 @@ channels: - If you subscribe without any `index_ids`, no value events flow until you add indices or switch to `["all"]` - `sid` identifies the subscription stream; use it for `update_subscription` and `unsubscribe` - Missing `index_ids` for `subscribe_indices`/`unsubscribe_indices` returns an `error` with `code: 24` ("Index IDs required"); unsupported actions return a standard websocket `error` - - This channel is real-time only. Historical index values — including intra-second granularity on some indices — are available over REST via the [CF Benchmarks REST Passthrough](/cfbenchmarks/rest-passthrough) + - This channel is real-time only. Historical index values — including intra-second granularity on some indices — are available over REST via the [CF Benchmarks REST Passthrough](/cfbenchmarks/rest-passthrough); live intra-second updates are available on the [`cfbenchmarks_value_5hz`](/websockets/cfbenchmarks-value-5hz) channel messages: cfbenchmarksValue: $ref: '#/components/messages/cfbenchmarksValue' cfbenchmarksIndexList: $ref: '#/components/messages/cfbenchmarksIndexList' + cfbenchmarks_value_5hz: + address: cfbenchmarks_value_5hz + title: CF Benchmarks 5Hz Value Feed + description: | + Real-time CF Benchmarks index value updates at up to 5 updates per second, each carrying the raw upstream frame plus parsed value fields. Requires authentication. + + This is the high-frequency sibling of the once-per-second [`cfbenchmarks_value`](/websockets/cfbenchmarks-value) channel. It carries the indices CF Benchmarks publishes at 200ms granularity (currently `BRTI`, `ETHUSD_RTI`, `SOLUSD_RTI`, `XRPUSD_RTI`, and `DOGEUSD_RTI`); all other indices remain available on `cfbenchmarks_value` only. Messages are lean raw ticks — they do not include the 60-second or quarter-hour averages, which stay on the per-second channel. + + **Requirements:** + - Authentication required + - Index specification via `index_ids` (array of CF Benchmarks index IDs, for example `["BRTI", "ETHUSD_RTI"]`) + - `market_ticker`/`market_tickers`/`market_id`/`market_ids` are not supported for this channel + - You can seed `index_ids` in the initial subscribe, or subscribe first and add indices later + - Use `index_ids: ["all"]` to receive every index available on this channel + - Supports `update_subscription` with `subscribe_indices` / `unsubscribe_indices` / `indexlist` actions + - `indexlist` returns the index IDs recently observed on this channel's stream (as a `cfbenchmarks_value_5hz_indexlist` message) without modifying the subscription + - Up to 5 ticks per second per index; duplicate or out-of-order upstream source timestamps are ignored + + **Use case:** Consuming CF Benchmarks reference index values at the vendor's full publication rate + + **Subscription workflow:** + 1. Subscribe to `cfbenchmarks_value_5hz` (optionally seeding `index_ids`). A successful subscribe returns a `subscribed` response with the assigned `sid`. + 2. Discover the streaming index IDs with the `indexlist` action; the server replies with a `cfbenchmarks_value_5hz_indexlist` message. + 3. Add or remove tracked index IDs with `subscribe_indices` / `unsubscribe_indices`, or use `index_ids: ["all"]` to track everything on this channel. + + **Integration notes:** + - If you subscribe without any `index_ids`, no value events flow until you add indices or switch to `["all"]` + - `sid` identifies the subscription stream; use it for `update_subscription` and `unsubscribe` + - Missing `index_ids` for `subscribe_indices`/`unsubscribe_indices` returns an `error` with `code: 24` ("Index IDs required"); unsupported actions return a standard websocket `error` + - Historical index values — including intra-second granularity — are available over REST via the [CF Benchmarks REST Passthrough](/cfbenchmarks/rest-passthrough) + messages: + cfbenchmarksValue5Hz: + $ref: '#/components/messages/cfbenchmarksValue5Hz' + cfbenchmarks5HzIndexList: + $ref: '#/components/messages/cfbenchmarks5HzIndexList' + pyth_value: address: pyth_value title: Pyth Value Feed @@ -493,7 +528,7 @@ operations: sendCFBenchmarksUpdateSubscription: action: receive title: Update Subscription - CF Benchmarks Indices - summary: Add or remove tracked index IDs, or list available indices, on a cfbenchmarks_value subscription + summary: Add or remove tracked index IDs, or list available indices, on a cfbenchmarks_value or cfbenchmarks_value_5hz subscription channel: $ref: '#/channels/root' messages: @@ -815,6 +850,28 @@ operations: tags: - name: market-data + receiveCFBenchmarksValue5Hz: + action: send + title: CF Benchmarks 5Hz Value Update + summary: Receive real-time CF Benchmarks index values at up to 5 updates per second + channel: + $ref: '#/channels/cfbenchmarks_value_5hz' + messages: + - $ref: '#/channels/cfbenchmarks_value_5hz/messages/cfbenchmarksValue5Hz' + tags: + - name: market-data + + receiveCFBenchmarks5HzIndexList: + action: send + title: CF Benchmarks 5Hz Index List + summary: Receive the index IDs streaming on the 5Hz channel in response to an indexlist action + channel: + $ref: '#/channels/cfbenchmarks_value_5hz' + messages: + - $ref: '#/channels/cfbenchmarks_value_5hz/messages/cfbenchmarks5HzIndexList' + tags: + - name: market-data + receivePythValue: action: send title: Pyth Value Update @@ -952,6 +1009,14 @@ components: params: channels: ["cfbenchmarks_value"] index_ids: ["BRTI", "ETHUSD_RTI"] + - name: subscribeCFBenchmarksValue5Hz + summary: Subscribe to the 5Hz CF Benchmarks feed, seeding index IDs + payload: + id: 11 + cmd: subscribe + params: + channels: ["cfbenchmarks_value_5hz"] + index_ids: ["BRTI"] - name: subscribePythValue summary: Subscribe to pyth_value, seeding underlying tickers payload: @@ -1066,7 +1131,7 @@ components: cfbenchmarksUpdateSubscriptionCommand: name: cfbenchmarks_update_subscription title: Update Subscription - CF Benchmarks Indices - summary: Add or remove tracked index IDs, or list available indices, on a cfbenchmarks_value subscription + summary: Add or remove tracked index IDs, or list available indices, on a cfbenchmarks_value or cfbenchmarks_value_5hz subscription contentType: application/json payload: $ref: '#/components/schemas/cfbenchmarksUpdateSubscriptionCommandPayload' @@ -1231,7 +1296,7 @@ components: | 3 | Channels required | Missing channels array in subscribe | | 4 | Subscription IDs required | Missing sids in unsubscribe | | 5 | Unknown command | Invalid command name | - | 6 | Already subscribed | Duplicate subscription attempt | + | 6 | Retired | Not emitted. The number stays reserved. | | 7 | Unknown subscription ID | Subscription ID not found | | 8 | Unknown channel name | Invalid channel in subscribe | | 9 | Authentication required | Channel requires authenticated connection | @@ -1241,15 +1306,15 @@ components: | 13 | Unsupported action | Invalid action for update_subscription | | 14 | Market Ticker required | Missing market specification (market_ticker or market_id) | | 15 | Action required | Missing action in update_subscription | - | 16 | Market not found | Invalid market_ticker or market_id | - | 17 | Internal error | Server-side processing error | + | 16 | Retired | Not emitted. The number stays reserved. | + | 17 | Retired | Not emitted. The number stays reserved. | | 18 | Command timeout | Server timed out while processing command | | 19 | shard_factor must be > 0 | Invalid shard_factor | | 20 | shard_factor is required when shard_key is set | Missing shard_factor when shard_key is set | | 21 | shard_key must be >= 0 and < shard_factor | Invalid shard_key | | 22 | shard_factor must be <= 100 | shard_factor too large | | 23 | Match IDs required | Missing match_ids for the channel/action | - | 24 | Index IDs required | Missing index_ids for subscribe_indices/unsubscribe_indices on cfbenchmarks_value | + | 24 | Index IDs required | Missing index_ids for subscribe_indices/unsubscribe_indices on cfbenchmarks_value / cfbenchmarks_value_5hz | | 25 | Subscription buffer overflow | The subscription's outbound buffer was exceeded | | 26 | Subscription market limit exceeded | Adding markets would exceed the per-subscription market limit | | 27 | Too many requests | The subscription exceeded its command rate limit | @@ -1258,14 +1323,14 @@ components: payload: $ref: '#/components/schemas/errorResponsePayload' examples: - - name: alreadySubscribed - summary: Already subscribed error + - name: unknownSubscription + summary: Unknown subscription ID error payload: id: 123 type: error msg: - code: 6 - msg: "Already subscribed" + code: 7 + msg: "Unknown subscription ID" - name: unknownChannel summary: Unknown channel error payload: @@ -1290,15 +1355,14 @@ components: msg: code: 9 msg: "Authentication required" - - name: marketNotFound - summary: Invalid market ticker + - name: marketTickerRequired + summary: Missing market ticker payload: id: 127 type: error msg: - code: 16 - msg: "Market not found" - market_ticker: "INVALID-MARKET" + code: 14 + msg: "Market Ticker required" # Control Frame Messages (Server -> Client) outgoingPing: @@ -1418,6 +1482,45 @@ components: msg: index_ids: ["BRTI", "ETHUSD_RTI"] + cfbenchmarksValue5Hz: + name: cfbenchmarks_value_5hz + title: CF Benchmarks 5Hz Value Update + summary: Real-time CF Benchmarks index value at up to 5 updates per second + contentType: application/json + payload: + $ref: '#/components/schemas/cfbenchmarksValue5HzPayload' + examples: + - name: cfbenchmarksValue5HzUpdate + summary: CF Benchmarks 5Hz value update (raw tick, no averages) + payload: + type: cfbenchmarks_value_5hz + sid: 1 + seq: 42 + msg: + index_id: "BRTI" + value_usd: "68000.12000000" + source_ts_ms: 1710000000323 + received_at: 1710000000341 + data: "{\"type\":\"value\",\"id\":\"BRTI\",\"time\":1710000000323,\"value\":\"68000.12\"}" + + cfbenchmarks5HzIndexList: + name: cfbenchmarks_value_5hz_indexlist + title: CF Benchmarks 5Hz Index List + summary: The index IDs recently observed on the 5Hz stream, sent in response to an indexlist action + contentType: application/json + payload: + $ref: '#/components/schemas/cfbenchmarks5HzIndexListPayload' + examples: + - name: indexListResponse + summary: Index IDs streaming on the 5Hz channel + payload: + type: cfbenchmarks_value_5hz_indexlist + id: 2 + sid: 1 + seq: 1 + msg: + index_ids: ["BRTI", "ETHUSD_RTI"] + pythValue: name: pyth_value title: Pyth Value Update @@ -1497,13 +1600,16 @@ components: payload: type: trade sid: 11 + seq: 2 msg: trade_id: "d91bc706-ee49-470d-82d8-11418bda6fed" market_ticker: "HIGHNY-22DEC23-B53.5" - yes_price_dollars: "0.360" - no_price_dollars: "0.640" + yes_price_dollars: "0.3600" + no_price_dollars: "0.6400" count_fp: "136.00" taker_side: "no" + taker_outcome_side: "no" + taker_book_side: "ask" is_block_trade: false ts: 1669149841 ts_ms: 1669149841000 @@ -1528,13 +1634,16 @@ components: exchange_index: 2 is_taker: true side: "yes" - yes_price_dollars: "0.750" + yes_price_dollars: "0.7500" count_fp: "278.00" + fee_cost: "0.010000" action: "buy" ts: 1671899397 ts_ms: 1671899397000 post_position_fp: "500.00" purchased_side: "yes" + outcome_side: "yes" + book_side: "bid" subaccount: 3 marketLifecycleV2: @@ -1550,6 +1659,7 @@ components: payload: type: market_lifecycle_v2 sid: 13 + seq: 3 msg: market_ticker: "INXD-23SEP14-B4487" event_type: "created" @@ -1574,6 +1684,7 @@ components: payload: type: market_lifecycle_v2 sid: 13 + seq: 4 msg: market_ticker: "INXD-23SEP14-B4487" event_type: "price_level_structure_updated" @@ -1587,6 +1698,7 @@ components: payload: type: market_lifecycle_v2 sid: 13 + seq: 5 msg: market_ticker: "KXBTC-25APR30-T0915-B95000" event_type: "metadata_updated" @@ -1598,6 +1710,7 @@ components: payload: type: market_lifecycle_v2 sid: 13 + seq: 6 msg: market_ticker: "KXBTC15M-26APR160100-00" event_type: "metadata_updated" @@ -1616,6 +1729,7 @@ components: payload: type: multivariate_market_lifecycle sid: 14 + seq: 7 msg: market_ticker: "KXMVE-TEST-EVENT-M1" event_type: "created" @@ -1646,6 +1760,7 @@ components: payload: type: event_lifecycle sid: 5 + seq: 8 msg: event_ticker: "KXQUICKSETTLE-26JAN25H2150" exchange_index: 0 @@ -1668,6 +1783,7 @@ components: payload: type: event_fee_update sid: 5 + seq: 9 msg: event_ticker: "KXBTCD-26MAY2018" fee_type_override: "quadratic" @@ -1677,6 +1793,7 @@ components: payload: type: event_fee_update sid: 5 + seq: 10 msg: event_ticker: "KXBTCD-26MAY2018" fee_type_override: null @@ -1723,6 +1840,7 @@ components: event_type: "limit_updated" order_group_id: "og_123" contracts_limit_fp: "150.00" + ts_ms: 1733047200000 userOrder: name: user_order @@ -1745,12 +1863,16 @@ components: status: "resting" side: "yes" is_yes: true + outcome_side: "yes" + book_side: "bid" yes_price_dollars: "0.3500" fill_count_fp: "0.00" remaining_count_fp: "10.00" initial_count_fp: "10.00" - taker_fill_cost_dollars: "0.0000" - maker_fill_cost_dollars: "0.0000" + taker_fill_cost_dollars: "0.000000" + maker_fill_cost_dollars: "0.000000" + taker_fees_dollars: "0.000000" + maker_fees_dollars: "0.000000" client_order_id: "my-order-1" order_group_id: "og_123" self_trade_prevention_type: "taker_at_cross" @@ -1773,14 +1895,36 @@ components: payload: type: rfq_created sid: 15 + seq: 11 msg: id: "rfq_123" creator_id: "" market_ticker: "FED-23DEC-T3.00" event_ticker: "FED-23DEC" contracts_fp: "100.00" - target_cost_dollars: "0.35" created_ts: "2024-12-01T10:00:00Z" + - name: mveRfqCreatedNotification + summary: MVE RFQ created notification + payload: + type: rfq_created + sid: 15 + seq: 11 + msg: + id: "rfq_456" + creator_id: "" + market_ticker: "KXMVE-24DEC-COMBO" + event_ticker: "KXMVE-24DEC-EVENT" + target_cost_dollars: "100.0000" + created_ts: "2024-12-01T10:00:00Z" + mve_collection_ticker: "KXMVE-24DEC" + mve_selected_legs: + - event_ticker: "KXEVENTA-24DEC" + market_ticker: "KXEVENTA-24DEC-YES" + side: "yes" + yes_settlement_value_dollars: "1.0000" + - event_ticker: "KXEVENTB-24DEC" + market_ticker: "KXEVENTB-24DEC-YES" + side: "no" rfqDeleted: name: rfq_deleted @@ -1795,6 +1939,7 @@ components: payload: type: rfq_deleted sid: 15 + seq: 12 msg: id: "rfq_123" creator_id: "comm_abc123" @@ -1817,6 +1962,7 @@ components: payload: type: quote_created sid: 15 + seq: 13 msg: quote_id: "quote_456" rfq_id: "rfq_123" @@ -1845,6 +1991,7 @@ components: payload: type: quote_accepted sid: 15 + seq: 14 msg: quote_id: "quote_456" rfq_id: "rfq_123" @@ -1878,6 +2025,7 @@ components: payload: type: quote_executed sid: 15 + seq: 15 msg: quote_id: "quote_456" rfq_id: "rfq_123" @@ -1956,7 +2104,7 @@ components: description: List of channels to subscribe to items: type: string - enum: ["orderbook_delta", "ticker", "trade", "fill", "market_positions", "market_lifecycle_v2", "multivariate_market_lifecycle", "communications", "order_group_updates", "user_orders", "cfbenchmarks_value", "pyth_value"] + enum: ["orderbook_delta", "ticker", "trade", "fill", "market_positions", "market_lifecycle_v2", "multivariate_market_lifecycle", "communications", "order_group_updates", "user_orders", "cfbenchmarks_value", "cfbenchmarks_value_5hz", "pyth_value"] minItems: 1 market_ticker: description: 'Subscribe to a single market. Type: string. Example: "KXBTCD-25AUG0517-T114999.99" (mutually exclusive with market_tickers)' @@ -2009,7 +2157,7 @@ components: minimum: 0 index_ids: type: array - description: 'cfbenchmarks_value channel only. CF Benchmarks index IDs to seed on the initial subscribe (omit to subscribe with no indices and add them later via update_subscription; use ["all"] to track every available index).' + description: 'cfbenchmarks_value and cfbenchmarks_value_5hz channels only. CF Benchmarks index IDs to seed on the initial subscribe (omit to subscribe with no indices and add them later via update_subscription; use ["all"] to track every index available on the channel).' items: type: string minItems: 1 @@ -2228,6 +2376,12 @@ components: properties: id: $ref: '#/components/schemas/commandId' + sid: + $ref: '#/components/schemas/subscriptionId' + description: Present when the error is scoped to a subscription. + seq: + $ref: '#/components/schemas/sequenceNumber' + description: Present on subscription-scoped errors on sequenced channels. type: type: string const: "error" @@ -2244,7 +2398,6 @@ components: - 3: Channels required - Missing channels array in subscribe - 4: Subscription IDs required - Missing sids in unsubscribe - 5: Unknown command - Invalid command name - - 6: Already subscribed - Duplicate subscription attempt - 7: Unknown subscription ID - Subscription ID not found - 8: Unknown channel name - Invalid channel in subscribe - 9: Authentication required - Channel requires authenticated connection @@ -2254,30 +2407,24 @@ components: - 13: Unsupported action - Invalid action for update_subscription - 14: Market Ticker required - Missing market specification (market_ticker or market_id) - 15: Action required - Missing action in update_subscription - - 16: Market not found - Invalid market_ticker or market_id - - 17: Internal error - Server-side processing error - 18: Command timeout - Server timed out while processing command - 19: shard_factor must be > 0 - Invalid shard_factor - 20: shard_factor is required when shard_key is set - Missing shard_factor when shard_key is set - 21: shard_key must be >= 0 and < shard_factor - Invalid shard_key - 22: shard_factor must be <= 100 - shard_factor too large - 23: Match IDs required - Missing match_ids for the channel/action - - 24: Index IDs required - Missing index_ids for subscribe_indices/unsubscribe_indices on cfbenchmarks_value + - 24: Index IDs required - Missing index_ids for subscribe_indices/unsubscribe_indices on cfbenchmarks_value / cfbenchmarks_value_5hz - 25: Subscription buffer overflow - The subscription's outbound buffer was exceeded - 26: Subscription market limit exceeded - Adding markets would exceed the per-subscription market limit - 27: Too many requests - The subscription exceeded its command rate limit - 28: Underlying tickers required - Missing underlying_tickers for subscribe_underlyings/unsubscribe_underlyings on pyth_value - minimum: 1 - maximum: 28 + + Codes 6, 16, and 17 are retired. The service no longer emits + them and their numbers stay reserved. + enum: [1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28] msg: type: string description: Human-readable error message - market_id: - type: string - description: Market UUID if error is market-specific (optional) - market_ticker: - type: string - description: Market ticker if error is market-specific (optional) listSubscriptionsCommandPayload: type: object @@ -2392,6 +2539,60 @@ components: items: type: string + cfbenchmarksValue5HzPayload: + type: object + required: ["type", "sid", "seq", "msg"] + properties: + type: + type: string + const: "cfbenchmarks_value_5hz" + sid: + $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' + msg: + type: object + required: ["index_id", "value_usd", "source_ts_ms", "received_at", "data"] + properties: + index_id: + type: string + description: CF Benchmarks index ID (for example "BRTI") + value_usd: + type: string + description: Index value in USD, formatted with exactly 8 decimal places + source_ts_ms: + type: integer + description: Upstream publication timestamp of the tick (unix ms) + received_at: + type: integer + description: When Kalshi received the upstream frame (unix ms) + data: + type: string + description: The raw CF Benchmarks JSON frame, as a string + + cfbenchmarks5HzIndexListPayload: + type: object + required: ["type", "sid", "seq", "msg"] + properties: + type: + type: string + const: "cfbenchmarks_value_5hz_indexlist" + id: + $ref: '#/components/schemas/commandId' + sid: + $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' + msg: + type: object + required: ["index_ids"] + properties: + index_ids: + type: array + description: Index IDs recently observed on the 5Hz stream + items: + type: string + pythValuePayload: type: object required: ["type", "sid", "seq", "msg"] @@ -2600,13 +2801,15 @@ components: tradePayload: type: object - required: ["type", "sid", "msg"] + required: ["type", "sid", "seq", "msg"] properties: type: type: string const: "trade" sid: $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' msg: type: object required: ["trade_id", "market_ticker", "yes_price_dollars", "no_price_dollars", "count_fp", "taker_side", "taker_outcome_side", "taker_book_side", "is_block_trade", "ts", "ts_ms"] @@ -2747,13 +2950,15 @@ components: marketLifecycleV2Payload: type: object - required: ["type", "sid", "msg"] + required: ["type", "sid", "seq", "msg"] properties: type: type: string const: "market_lifecycle_v2" sid: $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' msg: type: object required: ["event_type", "market_ticker"] @@ -2873,23 +3078,109 @@ components: type: object multivariateMarketLifecyclePayload: - allOf: - - $ref: '#/components/schemas/marketLifecycleV2Payload' - - type: object + type: object + required: ["type", "sid", "seq", "msg"] + properties: + type: + type: string + const: "multivariate_market_lifecycle" + sid: + $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' + msg: + type: object + required: ["event_type", "market_ticker"] properties: - type: + event_type: type: string - const: "multivariate_market_lifecycle" + description: | + Field to annotate which of the event type this event is for: + - `created` - Market created + - `activated` - Market activated + - `deactivated` - Market deactivated + - `close_date_updated` - Market close date updated + - `determined` - Market determined + - `settled` - Market settled + enum: ["created", "deactivated", "activated", "close_date_updated", "determined", "settled"] + market_ticker: + $ref: '#/components/schemas/marketTicker' + description: Unique identifier for markets. This is what you use to differentiate updates for different markets + exchange_index: + type: integer + description: Optional - This key will ONLY exist when the market is created. Identifier for the exchange shard the market lives on + open_ts: + type: integer + description: Optional - This key will ONLY exist when the market is created. Unix timestamp for when the market opened (in seconds) + format: int64 + close_ts: + type: integer + description: Optional - This key will ONLY exist when the market is created OR when the close date is updated. Unix timestamp for when the market is scheduled to close (in seconds). Will be updated in case of early determination markets + format: int64 + result: + type: string + description: Optional - This key will ONLY exist when the market is determined. Result of the market + determination_ts: + type: integer + description: Optional - This key will ONLY exist when the market is determined. Unix timestamp for when the market is determined (in seconds) + format: int64 + settlement_value: + type: string + description: Optional - This key will ONLY exist when the market is determined. Settlement value of the market in fixed-point dollars (e.g. "0.5000") + settled_ts: + type: integer + description: Optional - This key will ONLY exist when the market is settled. Unix timestamp for when the market is settled (in seconds) + format: int64 + is_deactivated: + type: boolean + description: Optional - This key will ONLY exist when the market is paused/unpaused. Boolean flag to indicate if trading is paused on an open market. This should only be interpreted for an open market + price_level_structure: + type: string + description: Optional - This key will exist when the market is created. The price level structure of the market + enum: ["linear_cent", "deci_cent", "tapered_deci_cent", "center_whole_edge_half_cent", "center_whole_edge_quint_cent", "center_half_edge_half_cent", "center_half_edge_quint_cent", "center_half_edge_deci_cent", "center_quint_edge_quint_cent", "center_quint_edge_deci_cent", "center_centi_edge_centi_cent", "center_deci_edge_centi_cent"] + additional_metadata: + type: object + description: Optional - This key will be emitted when the market is created + properties: + name: + type: string + title: + type: string + yes_sub_title: + type: string + no_sub_title: + type: string + rules_primary: + type: string + rules_secondary: + type: string + can_close_early: + type: boolean + event_ticker: + type: string + expected_expiration_ts: + type: integer + format: int64 + strike_type: + type: string + floor_strike: + type: number + cap_strike: + type: number + custom_strike: + type: object eventLifecyclePayload: type: object - required: ["type", "sid", "msg"] + required: ["type", "sid", "seq", "msg"] properties: type: type: string const: "event_lifecycle" sid: $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' msg: type: object required: ["event_ticker", "exchange_index", "title", "subtitle", "collateral_return_type", "series_ticker"] @@ -2923,13 +3214,15 @@ components: eventFeeUpdatePayload: type: object - required: ["type", "sid", "msg"] + required: ["type", "sid", "seq", "msg"] properties: type: type: string const: "event_fee_update" sid: $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' msg: type: object required: ["event_ticker", "fee_type_override", "fee_multiplier_override"] @@ -3087,16 +3380,16 @@ components: description: Initial number of contracts in fixed-point (2 decimals) taker_fill_cost_dollars: type: string - description: Taker fill cost in fixed-point dollars (4 decimals) + description: Taker fill cost in fixed-point dollars (6 decimals) maker_fill_cost_dollars: type: string - description: Maker fill cost in fixed-point dollars (4 decimals) + description: Maker fill cost in fixed-point dollars (6 decimals) taker_fees_dollars: type: string - description: Taker fees in fixed-point dollars (4 decimals). + description: Taker fees in fixed-point dollars (6 decimals). maker_fees_dollars: type: string - description: Maker fees in fixed-point dollars (4 decimals). + description: Maker fees in fixed-point dollars (6 decimals). client_order_id: type: string description: Client-provided order identifier @@ -3140,13 +3433,15 @@ components: rfqCreatedPayload: type: object - required: ["type", "sid", "msg"] + required: ["type", "sid", "seq", "msg"] properties: type: type: string const: "rfq_created" sid: $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' msg: type: object required: ["id", "creator_id", "market_ticker", "created_ts"] @@ -3175,32 +3470,40 @@ components: format: date-time mve_collection_ticker: type: string - description: Multivariate event collection ticker (optional) + description: Multivariate event collection ticker. Omitted for non-MVE RFQs. mve_selected_legs: type: array - description: Selected legs for multivariate events (optional) + minItems: 1 + description: Selected legs for an MVE RFQ. Omitted when the array is empty. items: type: object + required: ["event_ticker", "market_ticker", "side"] properties: event_ticker: type: string + description: Event ticker for the selected leg market_ticker: type: string + description: Market ticker for the selected leg side: type: string + enum: ["yes", "no"] + description: Side selected for the leg yes_settlement_value_dollars: type: string - description: Yes settlement value in dollars for the selected leg (optional) + description: Yes settlement value in dollars for the selected leg. Omitted when unavailable. rfqDeletedPayload: type: object - required: ["type", "sid", "msg"] + required: ["type", "sid", "seq", "msg"] properties: type: type: string const: "rfq_deleted" sid: $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' msg: type: object required: ["id", "creator_id", "market_ticker", "deleted_ts"] @@ -3230,13 +3533,15 @@ components: quoteCreatedPayload: type: object - required: ["type", "sid", "msg"] + required: ["type", "sid", "seq", "msg"] properties: type: type: string const: "quote_created" sid: $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' msg: type: object required: ["quote_id", "rfq_id", "quote_creator_id", "market_ticker", "yes_bid_dollars", "no_bid_dollars", "created_ts"] @@ -3286,13 +3591,15 @@ components: quoteAcceptedPayload: type: object - required: ["type", "sid", "msg"] + required: ["type", "sid", "seq", "msg"] properties: type: type: string const: "quote_accepted" sid: $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' msg: type: object required: ["quote_id", "rfq_id", "quote_creator_id", "market_ticker", "yes_bid_dollars", "no_bid_dollars"] @@ -3345,13 +3652,15 @@ components: quoteExecutedPayload: type: object - required: ["type", "sid", "msg"] + required: ["type", "sid", "seq", "msg"] properties: type: type: string const: "quote_executed" sid: $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' msg: type: object required: ["quote_id", "rfq_id", "quote_creator_id", "rfq_creator_id", "order_id", "client_order_id", "market_ticker", "executed_ts"] @@ -3415,9 +3724,6 @@ x-error-codes: - code: 5 name: Unknown command description: Invalid command name - - code: 6 - name: Already subscribed - description: Duplicate subscription attempt - code: 7 name: Unknown subscription ID description: Subscription ID not found @@ -3445,12 +3751,6 @@ x-error-codes: - code: 15 name: Action required description: Missing action in update_subscription - - code: 16 - name: Market not found - description: Invalid market_ticker or market_id - - code: 17 - name: Internal error - description: Server-side processing error - code: 18 name: Command timeout description: Server timed out while processing command @@ -3471,7 +3771,7 @@ x-error-codes: description: Missing match_ids for the channel/action - code: 24 name: Index IDs required - description: Missing index_ids for subscribe_indices/unsubscribe_indices on cfbenchmarks_value + description: Missing index_ids for subscribe_indices/unsubscribe_indices on cfbenchmarks_value / cfbenchmarks_value_5hz - code: 25 name: Subscription buffer overflow description: The subscription's outbound buffer was exceeded diff --git a/specs/openapi.yaml b/specs/openapi.yaml index f28cb282..8eb66dbe 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -1057,12 +1057,12 @@ paths: delete: operationId: CancelAllOrders - summary: Cancel Up to 10,000 Orders - description: Cancels up to 10,000 resting event-market orders for the authenticated Direct member across every exchange shard. If `subaccount` is omitted, matching orders may come from any subaccount. If it is provided, only orders for that subaccount are eligible. When more than 10,000 orders match, the orders selected for cancellation are arbitrary and no ordering guarantees should be relied upon. + summary: Cancel All Orders + description: Cancels all resting event-market orders for the authenticated Direct member across every exchange shard. If `subaccount` is omitted, matching orders may come from any subaccount. If it is provided, only orders for that subaccount are eligible. Newly placed orders may also be cancelled during the minute after the request. x-mint: content: | - **Rate limit:** A request consumes the same number of write tokens as a batch cancel containing the maximum number of orders allowed for the caller's API tier. + **Rate limit:** 2 tokens per request, the same cost as cancelling one event-market order. See `GET /trade-api/v2/account/endpoint_costs` for current non-default endpoint costs. tags: - orders @@ -1074,7 +1074,7 @@ paths: kalshiAccessTimestamp: [] responses: '204': - description: Up to 10,000 matching resting orders were cancelled + description: All matching resting orders were cancelled '401': $ref: '#/components/responses/UnauthorizedError' '429': @@ -1123,9 +1123,13 @@ paths: delete: operationId: BatchCancelOrdersV2 summary: Batch Cancel Orders (V2) - description: 'Endpoint for cancelling a batch of event-market orders using the V2 response shape. The maximum batch size scales with your tier''s write budget — see [Rate Limits and Tiers](/getting_started/rate_limits).' + description: 'Endpoint for cancelling a batch of event-market orders using the V2 response shape. To auto-route a cancellation, provide its `market_ticker` and omit `exchange_index` or set it to `-1`. The maximum batch size scales with your tier''s write budget — see [Rate Limits and Tiers](/getting_started/rate_limits).' x-mint: content: | + + For auto-routing, each order must include `market_ticker`. An `order_id` alone cannot identify the exchange shard. + + **Rate limit:** 2 tokens per order in the batch — billed per item, so total cost for a batch of N cancels is N × 2. See `GET /trade-api/v2/account/endpoint_costs` for current non-default endpoint costs. @@ -1161,9 +1165,13 @@ paths: delete: operationId: CancelOrderV2 summary: Cancel Order (V2) - description: 'Endpoint for cancelling event-market orders using the V2 response shape. Returns `{order_id, client_order_id, reduced_by}` rather than a full order object.' + description: 'Endpoint for cancelling event-market orders using the V2 response shape. To auto-route the cancellation, provide `market_ticker` and omit `exchange_index` or set it to `-1`. Returns `{order_id, client_order_id, reduced_by}` rather than a full order object.' x-mint: content: | + + Auto-routing requires `market_ticker`. An `order_id` alone cannot identify the exchange shard. + + **Rate limit:** 2 tokens per request. See `GET /trade-api/v2/account/endpoint_costs` for current non-default endpoint costs. @@ -1178,12 +1186,12 @@ paths: - $ref: '#/components/parameters/SubaccountQueryDefaultPrimary' - name: exchange_index in: query - description: Exchange shard index. If omitted, auto-routes when market_ticker is provided; otherwise defaults to 0. Use -1 to require auto-routing by market ticker. + description: Exchange shard index. If omitted, auto-routes when market_ticker is provided; otherwise defaults to 0. Use -1 to require auto-routing. The market_ticker parameter is required for auto-routing. schema: $ref: '#/components/schemas/ExchangeIndex' - name: market_ticker in: query - description: Market ticker used for auto-routing when exchange_index is omitted or -1. + description: Market ticker. Required for auto-routing when exchange_index is omitted or -1. schema: type: string x-go-type-skip-optional-pointer: true @@ -2644,6 +2652,13 @@ paths: - kalshiAccessKey: [] kalshiAccessSignature: [] kalshiAccessTimestamp: [] + parameters: + - name: fcm_subtrader_id + in: query + required: false + description: 'Return only API keys bound to this FCM subtrader. Spelled {your_user_id}_{suffix}; only FCM members hold bound keys. Omit to return every key.' + schema: + type: string responses: '200': description: List of API keys retrieved successfully @@ -2684,7 +2699,9 @@ paths: '401': description: Unauthorized '403': - description: Forbidden - insufficient API usage level + description: Forbidden - insufficient API usage level, or fcm_subtrader_id from a non-FCM caller + '409': + description: Conflict - the bound FCM subtrader is not yet visible in the credential registry; retry after the subtrader create propagates '500': description: Internal server error @@ -2716,6 +2733,10 @@ paths: description: Bad request - invalid input '401': description: Unauthorized + '403': + description: Forbidden - fcm_subtrader_id requires an FCM member caller + '409': + description: Conflict - the bound FCM subtrader is not yet visible in the credential registry; retry after the subtrader create propagates '500': description: Internal server error @@ -3137,6 +3158,43 @@ paths: description: Internal server error + /live_data/weather/{city}/calibrations: + get: + operationId: GetWeatherIndexCalibrations + summary: Get Weather Index Calibrations + description: >- + Get a city's published weather-index configuration timeline: the + launch configuration plus every weekly offset calibration and + methodology update, ascending by effective time. Each record carries + the station weights, station offsets (Celsius), and the city + reference used to compute index values from the record's effective + time until the next record — everything needed to reproduce published + index values for minutes computed under that configuration version. + Offsets are re-estimated weekly after each complete UTC week; weights + never change through calibration. The timeline is append-only and + complete: it is never trimmed. + tags: + - live-data + parameters: + - name: city + in: path + required: true + description: Index city ID (e.g. `miami`) + schema: + type: string + responses: + '200': + description: Calibration timeline retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetWeatherIndexCalibrationsResponse' + '400': + description: Unknown city + '500': + description: Internal server error + + /structured_targets: get: operationId: GetStructuredTargets @@ -3522,8 +3580,9 @@ paths: operationId: GetFCMOrders summary: Get FCM Orders description: | - Endpoint for FCM members to get orders filtered by subtrader ID. - This endpoint requires FCM member access level and allows filtering orders by subtrader ID. + Endpoint for FCM members to get orders for their subtraders. + This endpoint requires FCM member access level. At least one of `subtrader_id` or + `client_order_ids` is required; supplying both returns only the orders matching both filters. tags: - fcm security: @@ -3533,10 +3592,16 @@ paths: parameters: - name: subtrader_id in: query - required: true - description: Restricts the response to orders for a specific subtrader (FCM members only) + description: Restricts the response to orders for a specific subtrader (FCM members only). Required unless client_order_ids is supplied. schema: type: string + x-go-type-skip-optional-pointer: true + - name: client_order_ids + in: query + description: Client order IDs to filter by, as a comma-separated list (maximum 100). Only orders created within the last 24 hours are searched, and a min_ts earlier than that is raised to 24 hours ago. Client order IDs are only unique within a subtrader among live and recent orders, so a single ID can match orders across subtraders or across time. Required unless subtrader_id is supplied. + schema: + type: string + x-go-type-skip-optional-pointer: true - $ref: '#/components/parameters/CursorQuery' - $ref: '#/components/parameters/SingleEventTickerQuery' - $ref: '#/components/parameters/TickerQuery' @@ -3862,6 +3927,7 @@ paths: parameters: - $ref: '#/components/parameters/TickerQuery' - $ref: '#/components/parameters/SingleEventTickerQuery' + - $ref: '#/components/parameters/SubaccountQueryDefaultPrimary' - $ref: '#/components/parameters/LimitQuery' - $ref: '#/components/parameters/CursorQuery' responses: @@ -4555,6 +4621,10 @@ components: minimum: 0 maximum: 63 description: If set, the API key is restricted to this single sub-account and may only read and trade on it. Absent/null means the key is unrestricted. + fcm_subtrader_id: + type: string + nullable: true + description: If set, the API key is bound to this single FCM subtrader ({fcm_user_id}_{suffix}) and is usable only as that institution's trading credential - FIX sessions and subtrader-scoped margin WebSocket sessions; every REST endpoint is denied. Absent/null means the key carries no subtrader binding. GetApiKeysResponse: type: object @@ -4593,7 +4663,10 @@ components: type: integer minimum: 0 maximum: 63 - description: If set, restricts the API key to a single sub-account (0-63) that you own. A restricted key may only read and trade on that sub-account; it cannot act on other sub-accounts, transfer funds between sub-accounts, or create sub-accounts. Omit to leave the key unrestricted. + description: If set, restricts the API key to a single sub-account (0-63) that you own. A restricted key may only read and trade on that sub-account; it cannot act on other sub-accounts, transfer funds between sub-accounts, or create sub-accounts. Omit to leave the key unrestricted. Mutually exclusive with fcm_subtrader_id. + fcm_subtrader_id: + type: string + description: FCM members only. If set, binds the API key to a single FCM subtrader that you own, spelled {your_user_id}_{suffix} with a suffix of 1-16 lowercase alphanumeric characters. The subtrader must already exist. A bound key is the institution's trading credential for that subtrader - FIX order-entry and market-data sessions, plus margin WebSocket sessions scoped to the subtrader's own data - and is denied on every REST endpoint, including key management. Mutually exclusive with subaccount. CreateApiKeyResponse: type: object @@ -4603,6 +4676,10 @@ components: api_key_id: type: string description: Unique identifier for the newly created API key + warning: + type: string + nullable: true + description: Present only when the minted key is bound to an FCM subtrader that has no initial-margin cap at any scope. The mint still succeeds; once SMA enforcement is enabled, the subtrader's orders will be rejected until a cap is set. GenerateApiKeyRequest: type: object @@ -4621,7 +4698,10 @@ components: type: integer minimum: 0 maximum: 63 - description: If set, restricts the API key to a single sub-account (0-63) that you own. A restricted key may only read and trade on that sub-account; it cannot act on other sub-accounts, transfer funds between sub-accounts, or create sub-accounts. Omit to leave the key unrestricted. + description: If set, restricts the API key to a single sub-account (0-63) that you own. A restricted key may only read and trade on that sub-account; it cannot act on other sub-accounts, transfer funds between sub-accounts, or create sub-accounts. Omit to leave the key unrestricted. Mutually exclusive with fcm_subtrader_id. + fcm_subtrader_id: + type: string + description: FCM members only. If set, binds the API key to a single FCM subtrader that you own, spelled {your_user_id}_{suffix} with a suffix of 1-16 lowercase alphanumeric characters. The subtrader must already exist. A bound key is the institution's trading credential for that subtrader - FIX order-entry and market-data sessions, plus margin WebSocket sessions scoped to the subtrader's own data - and is denied on every REST endpoint, including key management. Mutually exclusive with subaccount. GenerateApiKeyResponse: type: object @@ -4635,6 +4715,10 @@ components: private_key: type: string description: RSA private key in PEM format. This must be stored securely and cannot be retrieved again after this response + warning: + type: string + nullable: true + description: Present only when the minted key is bound to an FCM subtrader that has no initial-margin cap at any scope. The mint still succeeds; once SMA enforcement is enabled, the subtrader's orders will be rejected until a cap is set. GetTagsForSeriesCategoriesResponse: type: object @@ -5319,6 +5403,15 @@ components: description: >- Number of accepted member stations backing the point. Absent on `incomplete` points. + receipt_basis: + type: string + description: >- + Present only on points produced by the labelled historical + backfill that seeds a city's series for the period before it went + live. `synoptic_latency` means the receipt-deadline test used + `observation_time + Synoptic ingest latency` in place of Kalshi's + local receipt clock. Absent on canonical points, which are the + only settlement-eligible ones. stations: type: array description: >- @@ -5373,6 +5466,104 @@ components: Why the primary observation was passed over when a fallback was selected instead. + GetWeatherIndexCalibrationsResponse: + type: object + required: + - city + - units + - calibrations + properties: + city: + type: string + description: Index city ID. + units: + type: string + description: >- + Always `celsius` — offsets and the city reference are Celsius + quantities from the index methodology (the published index value + itself is Fahrenheit). + calibrations: + type: array + description: Configuration records, ascending by effective time. + items: + $ref: '#/components/schemas/WeatherIndexCalibration' + + WeatherIndexCalibration: + type: object + required: + - config_version + - effective_at_ms + - city_reference_c + - stations + properties: + config_version: + type: string + description: >- + Configuration version (e.g. + `miami-temperature-v1.0-cal-20260831`). Index points report the + version they were computed under in their `config_version` field. + published_at_ms: + type: integer + format: int64 + description: When the record was published, unix milliseconds UTC. + effective_at_ms: + type: integer + format: int64 + description: >- + The record governs event minutes at or after this time (unix + milliseconds UTC), until superseded by the next record. + change_reason: + type: string + description: Why the configuration changed. + calibration_window_start_ms: + type: integer + format: int64 + description: >- + Start of the trailing observation window the offsets were + estimated from. Absent on records not derived from a calibration + window (the launch configuration). + calibration_window_end_ms: + type: integer + format: int64 + description: End of the calibration window (exclusive). + city_reference_c: + type: number + format: double + description: >- + City reference B_c in Celsius: the weight-dot-offset sum over all + configured member stations. + stations: + type: array + description: Configured member stations, in configuration order. + items: + $ref: '#/components/schemas/WeatherIndexCalibrationStation' + + WeatherIndexCalibrationStation: + type: object + required: + - station_id + - weight + - offset_c + properties: + station_id: + type: string + description: Member station ID (e.g. `KMIA1M`). + weight: + type: number + format: double + description: Base weight (weights sum to 1.0 across members). + offset_c: + type: number + format: double + description: Station offset in Celsius (positive = station normally runs warmer than its peers). + update_note: + type: string + description: >- + Weekly-calibration disposition, present only on weekly + calibration records: `updated ...` with the residual count, + target, and applied adjustment, or `insufficient ...` when the + prior offset was retained. + GetGameStatsResponse: type: object properties: @@ -6515,6 +6706,14 @@ components: x-oapi-codegen-extra-tags: validate: required,gte=0,lte=100 + RestingMarginReservation: + type: string + enum: [max, sum] + x-enum-varnames: [RestingMarginReservationMax, RestingMarginReservationSum] + description: | + Collateral an automatic rebalance leaves behind for resting orders. `max` reserves the + largest single market-side commitment. `sum` reserves the summed margin of every resting order. + GetTargetBalanceAllocationResponse: type: object required: @@ -6537,6 +6736,13 @@ components: validate: max=101,dive items: $ref: '#/components/schemas/TargetBalanceAllocationInput' + resting_margin_reservation: + allOf: + - $ref: '#/components/schemas/RestingMarginReservation' + description: Defaults to `sum` when omitted. + x-oapi-codegen-extra-tags: + validate: omitempty,oneof=max sum + x-go-type-skip-optional-pointer: true IntraExchangeInstanceTransferRequest: type: object @@ -8036,10 +8242,10 @@ components: exchange_index: allOf: - $ref: '#/components/schemas/ExchangeIndex' - description: "Exchange shard index. If omitted, auto-routes when market_ticker is provided; otherwise defaults to 0. Use -1 to require auto-routing by market ticker." + description: "Exchange shard index. If omitted, auto-routes when market_ticker is provided; otherwise defaults to 0. Use -1 to require auto-routing. The market_ticker field is required for auto-routing." market_ticker: type: string - description: Market ticker used for auto-routing when exchange_index is omitted or -1. + description: Market ticker. Required for auto-routing when exchange_index is omitted or -1. x-go-type-skip-optional-pointer: true BatchCancelOrdersV2Response: @@ -8540,7 +8746,6 @@ components: - title - collateral_return_type - mutually_exclusive - - available_on_brokers - settlement_sources properties: event_ticker: @@ -8584,10 +8789,6 @@ components: items: $ref: '#/components/schemas/Market' x-go-type-skip-optional-pointer: true - available_on_brokers: - type: boolean - description: Deprecated. No longer populated and always returns false; it will be removed in a future release. - deprecated: true product_metadata: type: object nullable: true diff --git a/specs/perps_asyncapi.yaml b/specs/perps_asyncapi.yaml index 36a85aae..a1150d01 100644 --- a/specs/perps_asyncapi.yaml +++ b/specs/perps_asyncapi.yaml @@ -602,6 +602,7 @@ components: event_type: "limit_updated" order_group_id: "og_123" contracts_limit_fp: "150.00" + ts_ms: 1700000000123 schemas: commandId: @@ -852,6 +853,12 @@ components: properties: id: $ref: '#/components/schemas/commandId' + sid: + $ref: '#/components/schemas/subscriptionId' + description: Present when the error is scoped to a subscription. + seq: + $ref: '#/components/schemas/sequenceNumber' + description: Present on subscription-scoped errors on sequenced channels. type: type: string const: "error" @@ -861,12 +868,16 @@ components: properties: code: type: integer - minimum: 1 - maximum: 18 + description: | + Error codes the Margin WebSocket endpoint can emit. + Numbering is shared with the Predictions WebSocket API, so the list is + not contiguous. Codes 23, 24, and 28 are used only on channels + outside the public Margin surface. Codes 6, 16, and 17 are + retired; the service no longer emits them and their numbers + stay reserved. + enum: [1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 23, 24, 25, 26, 27, 28] msg: type: string - market_ticker: - type: string marginOrderbookSnapshotPayload: type: object @@ -993,13 +1004,15 @@ components: marginTradePayload: type: object - required: ["type", "sid", "msg"] + required: ["type", "sid", "seq", "msg"] properties: type: type: string const: "trade" sid: $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' msg: type: object required: ["trade_id", "market_ticker", "price", "count", "taker_side", "ts_ms"] @@ -1031,7 +1044,7 @@ components: $ref: '#/components/schemas/subscriptionId' msg: type: object - required: ["trade_id", "order_id", "market_ticker", "is_taker", "side", "ts_ms", "price", "count", "fee_cost", "post_position"] + required: ["trade_id", "order_id", "market_ticker", "is_taker", "side", "ts_ms", "price", "count", "fee_cost", "post_position", "order_source"] properties: trade_id: type: string @@ -1061,6 +1074,12 @@ components: type: string subaccount: type: integer + order_source: + type: string + enum: ["user", "system"] + description: | + `system` for liquidations and margin exit or trailing-stop + triggers. `user` for every other order. marginUserOrderPayload: type: object @@ -1073,7 +1092,7 @@ components: $ref: '#/components/schemas/subscriptionId' msg: type: object - required: ["order_id", "user_id", "client_order_id", "ticker", "side", "price", "fill_count", "remaining_count", "created_ts_ms"] + required: ["order_id", "user_id", "client_order_id", "ticker", "side", "price", "fill_count", "remaining_count", "created_ts_ms", "order_source"] properties: order_id: type: string @@ -1112,6 +1131,12 @@ components: description: Unix timestamp in milliseconds. subaccount_number: type: integer + order_source: + type: string + enum: ["user", "system"] + description: | + `system` for liquidations and margin exit or trailing-stop + triggers. `user` for every other order. orderGroupUpdatesPayload: type: object diff --git a/specs/perps_openapi.yaml b/specs/perps_openapi.yaml index 7dda126e..d5ee405f 100644 --- a/specs/perps_openapi.yaml +++ b/specs/perps_openapi.yaml @@ -322,12 +322,12 @@ paths: delete: operationId: CancelAllMarginOrders - summary: Cancel Up to 10,000 Orders - description: Cancels up to 10,000 resting margin orders for the authenticated Direct member. If `subaccount` is omitted, matching orders may come from any subaccount. If it is provided, only orders for that subaccount are eligible. When more than 10,000 orders match, the orders selected for cancellation are arbitrary and no ordering guarantees should be relied upon. + summary: Cancel All Orders + description: Cancels all resting margin orders for the authenticated Direct member. If `subaccount` is omitted, matching orders may come from any subaccount. If it is provided, only orders for that subaccount are eligible. Newly placed orders may also be cancelled during the minute after the request. x-mint: content: | - **Rate limit:** A request consumes the same number of write tokens as a batch cancel containing the maximum number of orders allowed for the caller's margin API tier. + **Rate limit:** 10 tokens per request, the same cost as cancelling one margin order. See `GET /trade-api/v2/account/endpoint_costs` for current non-default endpoint costs. tags: - orders @@ -339,7 +339,7 @@ paths: kalshiAccessTimestamp: [] responses: '204': - description: Up to 10,000 matching resting margin orders were cancelled + description: All matching resting margin orders were cancelled '401': $ref: '#/components/responses/UnauthorizedError' '429': @@ -919,7 +919,7 @@ paths: get: operationId: GetMarginFeeTiers summary: Get Fee Tiers - description: 'Endpoint for retrieving the margin fee tiers for the authenticated direct margin user. Returns a map of margin market tickers to their fee tier strings.' + description: 'Endpoint for retrieving the effective margin fee rates for the authenticated direct margin user. Returns maps of margin market tickers to maker and taker fee rates as decimal fractions; no tier numbers are returned. Use Get Fee Tier Rates for the fee schedule itself.' tags: - fees security: @@ -938,6 +938,31 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /margin/fee_tier_rates: + get: + operationId: GetMarginFeeTierRates + summary: Get Fee Tier Rates + description: 'Returns the fee schedule that applies to the authenticated margin-enabled account, as tier numbers with maker and taker fee rates ordered from the entry tier upward.' + tags: + - fees + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + responses: + '200': + description: Fee tier rates retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginFeeTierRatesResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + description: The account is not margin-enabled or no fee schedule applies to it + '500': + $ref: '#/components/responses/InternalServerError' + /margin/funding_history: get: operationId: GetMarginFundingHistory @@ -2750,6 +2775,11 @@ components: reference_price: $ref: '#/components/schemas/TickerPrice' description: Underlying reference price, scaled per contract. + asset_class: + type: string + description: > + Asset class grouping for this market. New asset classes may be added + over time. Omitted when the market has no assigned class. schedule: $ref: '#/components/schemas/MarginMarketSchedule' @@ -3149,6 +3179,45 @@ components: format: double description: A map of margin market ticker to the taker-side fee rate as a decimal fraction of notional (e.g. 0.0012 = 0.12% = 12 bps). Multiply notional by this value to compute the fee. + GetMarginFeeTierRatesResponse: + type: object + required: + - fee_tier_rates + properties: + fee_tier_rates: + type: array + items: + $ref: '#/components/schemas/MarginFeeTierRate' + + MarginFeeTierRate: + type: object + required: + - fee_schedule + - tier + - maker_fee_rate + - taker_fee_rate + properties: + fee_schedule: + type: string + enum: + - self_clearing_members + - kalshi_prime + - fcm + description: Fee schedule containing this tier. + tier: + type: integer + format: int32 + minimum: 0 + description: Stable fee tier number within the indicated fee schedule. Existing numbers never change when the schedule is reshaped. + maker_fee_rate: + type: number + format: double + description: Maker fee rate as a decimal fraction of notional (e.g. 0.0005 = 0.05% = 5 bps). + taker_fee_rate: + type: number + format: double + description: Taker fee rate as a decimal fraction of notional (e.g. 0.0012 = 0.12% = 12 bps). + MarginFundingHistoryEntry: type: object required: diff --git a/specs/perps_scm_openapi.yaml b/specs/perps_scm_openapi.yaml index 42c597df..5f6d4a4d 100644 --- a/specs/perps_scm_openapi.yaml +++ b/specs/perps_scm_openapi.yaml @@ -166,6 +166,30 @@ paths: '404': { $ref: '#/components/responses/NotFoundError' } '500': { $ref: '#/components/responses/InternalServerError' } + /margin/funding_payments: + get: + operationId: GetMemberFundingPayments + summary: Get Member Funding Payments + description: >- + Returns the member's funding payments for a given funding time, if + available. Payments are written shortly after each funding execution, + before the settlement that carries them. + parameters: + - { name: funding_time, in: query, required: true, schema: { type: string, format: date-time }, description: "The funding execution to read, as an RFC3339 timestamp. Funding is scheduled in ET." } + - { name: market_ticker, in: query, required: false, schema: { type: string }, description: Restrict rows to one market. } + - { name: limit, in: query, required: false, schema: { type: integer, default: 1000, maximum: 10000 }, description: Number of rows per page. } + - { name: cursor, in: query, required: false, schema: { type: string }, description: Opaque cursor from a previous response to fetch the next page. } + responses: + '200': + description: Successful response + content: + application/json: + schema: { $ref: '#/components/schemas/GetMemberFundingPaymentsResponse' } + '400': { $ref: '#/components/responses/BadRequestError' } + '401': { $ref: '#/components/responses/UnauthorizedError' } + '403': { $ref: '#/components/responses/ForbiddenError' } + '500': { $ref: '#/components/responses/InternalServerError' } + /margin/large_trader_positions: get: operationId: GetLargeTraderPositions @@ -205,7 +229,8 @@ paths: margined under the rules that apply to the authenticated clearing member. Positions and prices are supplied by the caller and need not reflect positions held. Every market in `positions` must belong to - `asset_class`. + `asset_class`. The response includes the component breakdown of the + estimate; see the response schema for the semantics of each component. requestBody: required: true @@ -221,6 +246,7 @@ paths: '400': { $ref: '#/components/responses/BadRequestError' } '401': { $ref: '#/components/responses/UnauthorizedError' } '403': { $ref: '#/components/responses/ForbiddenError' } + '404': { $ref: '#/components/responses/NotFoundError' } '500': { $ref: '#/components/responses/InternalServerError' } /margin/settlement_estimate_by_asset_class: @@ -439,6 +465,120 @@ paths: '403': { $ref: '#/components/responses/ForbiddenError' } '500': { $ref: '#/components/responses/InternalServerError' } + /fcm/margin/api_keys: + post: + operationId: CreateMarginFcmApiKey + summary: Create Subtrader-Bound API Key + description: >- + FCM clearing members only. Mint an API key bound to one of your margin + subtraders using an RSA public key you supply. The binding is set at + mint and immutable; the resulting key is the institution's trading + credential for that subtrader and cannot reach any REST endpoint, + including key management. + + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateMarginFcmApiKeyRequest' } + responses: + '201': + description: API key created + content: + application/json: + schema: { $ref: '#/components/schemas/CreateMarginFcmApiKeyResponse' } + '400': { $ref: '#/components/responses/BadRequestError' } + '401': { $ref: '#/components/responses/UnauthorizedError' } + '403': { $ref: '#/components/responses/ForbiddenError' } + '409': + description: >- + The bound subtrader is not yet visible in the credential registry, + which updates asynchronously. Retry shortly after creating the + subtrader. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '500': { $ref: '#/components/responses/InternalServerError' } + get: + operationId: ListMarginFcmApiKeys + summary: List Subtrader-Bound API Keys + description: >- + FCM clearing members only. List your API keys that carry a subtrader + binding, optionally filtered to one subtrader. + + parameters: + - name: fcm_subtrader_id + in: query + required: false + schema: { type: string } + description: Return only keys bound to this subtrader. + responses: + '200': + description: Successful response + content: + application/json: + schema: { $ref: '#/components/schemas/ListMarginFcmApiKeysResponse' } + '400': { $ref: '#/components/responses/BadRequestError' } + '401': { $ref: '#/components/responses/UnauthorizedError' } + '403': { $ref: '#/components/responses/ForbiddenError' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /fcm/margin/api_keys/generate: + post: + operationId: GenerateMarginFcmApiKey + summary: Generate Subtrader-Bound API Key + description: >- + FCM clearing members only. Mint a subtrader-bound API key with a + platform-generated RSA key pair. The private key appears exactly once + in this response and cannot be retrieved again; store it securely + before leaving the page or discarding the response. + + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/GenerateMarginFcmApiKeyRequest' } + responses: + '201': + description: API key generated + content: + application/json: + schema: { $ref: '#/components/schemas/GenerateMarginFcmApiKeyResponse' } + '400': { $ref: '#/components/responses/BadRequestError' } + '401': { $ref: '#/components/responses/UnauthorizedError' } + '403': { $ref: '#/components/responses/ForbiddenError' } + '409': + description: >- + The bound subtrader is not yet visible in the credential registry, + which updates asynchronously. Retry shortly after creating the + subtrader. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /fcm/margin/api_keys/{api_key_id}: + delete: + operationId: DeleteMarginFcmApiKey + summary: Delete Subtrader-Bound API Key + description: >- + FCM clearing members only. Permanently revoke a subtrader-bound API + key. Revocation cannot be undone; the institution's live sessions on + this key are terminated. + + parameters: + - name: api_key_id + in: path + required: true + schema: { type: string, format: uuid } + responses: + '204': { description: API key revoked } + '400': { $ref: '#/components/responses/BadRequestError' } + '401': { $ref: '#/components/responses/UnauthorizedError' } + '403': { $ref: '#/components/responses/ForbiddenError' } + '404': { $ref: '#/components/responses/NotFoundError' } + '500': { $ref: '#/components/responses/InternalServerError' } + components: securitySchemes: kalshiBearer: @@ -707,6 +847,14 @@ components: required: [asset_class, positions] properties: asset_class: { $ref: '#/components/schemas/AssetClass' } + date: + type: string + format: date + description: Margin the portfolio against the matrices calibrated on this Eastern-time day (YYYY-MM-DD). Omit to use the current matrices. Returns 404 if no matrices were calibrated that day, or 400 if a requested market is absent from them. + clearing_type: + type: string + enum: [FCM, SelfClearing] + description: Clearing arrangement whose margin rules to apply. Omit to use the arrangement on record for the authenticated clearing member. positions: type: array minItems: 1 @@ -717,7 +865,12 @@ components: EstimatePortfolioMaintenanceMarginResponse: type: object properties: - maintenance_margin_fp: { type: string, description: 'Maintenance margin requirement for the submitted portfolio, in USD (e.g. "1234.5600").' } + maintenance_margin_fp: { type: string, description: 'Maintenance margin requirement for the submitted portfolio, in USD (e.g. "1234.5600"). Equals base_margin_fp + funding_addon_fp + liquidation_addon_fp, subject to per-market minimums on gross-margined markets and to each component being rounded individually, so the components need not sum exactly to this total.' } + base_margin_fp: { type: string, description: 'Base scenario margin charged, in USD: the larger of the historical-VaR and APC candidates, taken per market for gross-margined markets and across the whole portfolio for portfolio-margined ones, then summed. Equals max(hvar_fp, apc_fp) only when the entire portfolio is portfolio-margined; with gross-margined markets it can exceed both.' } + hvar_fp: { type: string, description: 'Portfolio total of the historical-VaR candidate for the base scenario margin, in USD.' } + apc_fp: { type: string, description: 'Portfolio total of the APC candidate for the base scenario margin, in USD, computed over the pooled APC and augmented stress scenario set.' } + funding_addon_fp: { type: string, description: 'Funding margin add-on, in USD.' } + liquidation_addon_fp: { type: string, description: 'Liquidation margin add-on, in USD.' } GetObligationHistoryResponse: type: object @@ -752,6 +905,25 @@ components: type: string description: Opaque cursor; pass as the `cursor` query param to fetch the next page. Absent when there are no more results. + MemberFundingPayment: + allOf: + - $ref: '#/components/schemas/FundingPaymentDetail' + - type: object + required: [settlement_execution_time] + properties: + settlement_execution_time: { type: string, format: date-time, description: Execution time of the FCM-to-clearinghouse omnibus settlement that will carry (or carried) this payment. } + + GetMemberFundingPaymentsResponse: + type: object + required: [payments] + properties: + payments: + type: array + items: { $ref: '#/components/schemas/MemberFundingPayment' } + cursor: + type: string + description: Opaque cursor; pass as the `cursor` query param to fetch the next page. Absent when there are no more results. + GetObligationMaintenanceMarginDetailsResponse: type: object required: [maintenance_margin_details] @@ -940,3 +1112,67 @@ components: groups: type: array items: { $ref: '#/components/schemas/MarginSubtraderGroup' } + + CreateMarginFcmApiKeyRequest: + type: object + required: [name, public_key, fcm_subtrader_id] + properties: + name: + type: string + description: Name identifying the key's purpose, e.g. the institution it is issued to. + public_key: + type: string + description: RSA public key in PEM format. Signatures on the institution's requests verify against it. + fcm_subtrader_id: + type: string + description: The subtrader to bind, spelled {your_account_id}_{suffix} with a suffix of 1-16 characters of [a-z0-9]. Must already exist and belong to you. + + CreateMarginFcmApiKeyResponse: + type: object + required: [api_key_id] + properties: + api_key_id: { type: string } + warning: + type: string + nullable: true + description: Present only when the bound subtrader has no initial-margin cap at any scope. The mint still succeeds; once SMA enforcement is enabled, the subtrader's orders are rejected until a cap is set. + + GenerateMarginFcmApiKeyRequest: + type: object + required: [name, fcm_subtrader_id] + properties: + name: + type: string + description: Name identifying the key's purpose, e.g. the institution it is issued to. + fcm_subtrader_id: + type: string + description: The subtrader to bind, spelled {your_account_id}_{suffix} with a suffix of 1-16 characters of [a-z0-9]. Must already exist and belong to you. + + GenerateMarginFcmApiKeyResponse: + type: object + required: [api_key_id, private_key] + properties: + api_key_id: { type: string } + private_key: + type: string + description: RSA private key in PEM format. Returned exactly once and never stored; it cannot be retrieved again. + warning: + type: string + nullable: true + description: Present only when the bound subtrader has no initial-margin cap at any scope. The mint still succeeds; once SMA enforcement is enabled, the subtrader's orders are rejected until a cap is set. + + MarginFcmApiKey: + type: object + required: [api_key_id, name, fcm_subtrader_id] + properties: + api_key_id: { type: string } + name: { type: string } + fcm_subtrader_id: { type: string } + + ListMarginFcmApiKeysResponse: + type: object + required: [api_keys] + properties: + api_keys: + type: array + items: { $ref: '#/components/schemas/MarginFcmApiKey' } diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 2cca8a86..96668131 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -244,6 +244,11 @@ class Exclusion: http_method="GET", path_template="/live_data/weather/{city}", ), + MethodEndpointEntry( + sdk_method="kalshi.resources.live_data.LiveDataResource.weather_calibrations", + http_method="GET", + path_template="/live_data/weather/{city}/calibrations", + ), # ── events ────────────────────────────────────────────────────────────── MethodEndpointEntry( sdk_method="kalshi.resources.events.EventsResource.list", @@ -1541,6 +1546,11 @@ class Exclusion: http_method="GET", path_template="/margin/fee_tiers", ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.margin_account.MarginAccountResource.fee_tier_rates", + http_method="GET", + path_template="/margin/fee_tier_rates", + ), MethodEndpointEntry( sdk_method="kalshi.perps.resources.margin_account.MarginAccountResource.api_limits", http_method="GET", @@ -1794,6 +1804,38 @@ class Exclusion: path_template="/margin/estimate_maintenance_margin", request_body_schema="#/components/schemas/EstimatePortfolioMaintenanceMarginRequest", ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.member_funding_payments", + http_method="GET", + path_template="/margin/funding_payments", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.member_funding_payments_all", + http_method="GET", + path_template="/margin/funding_payments", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.list_fcm_api_keys", + http_method="GET", + path_template="/fcm/margin/api_keys", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.create_fcm_api_key", + http_method="POST", + path_template="/fcm/margin/api_keys", + request_body_schema="#/components/schemas/CreateMarginFcmApiKeyRequest", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.generate_fcm_api_key", + http_method="POST", + path_template="/fcm/margin/api_keys/generate", + request_body_schema="#/components/schemas/GenerateMarginFcmApiKeyRequest", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.delete_fcm_api_key", + http_method="DELETE", + path_template="/fcm/margin/api_keys/{api_key_id}", + ), ] # Shared perps exclusion allowlist (same ``(sdk_fqn, field) → Exclusion`` shape @@ -1927,6 +1969,20 @@ class Exclusion: reason="client-side page cap, no wire counterpart", kind="client_only", ), + ( + "kalshi.perps.klear.resources.margin.MarginResource.member_funding_payments_all", + "cursor", + ): Exclusion( + reason="cursor consumed by _list_all paginator, not a caller kwarg", + kind="paginator_handled", + ), + ( + "kalshi.perps.klear.resources.margin.MarginResource.member_funding_payments_all", + "max_pages", + ): Exclusion( + reason="client-side page cap, no wire counterpart", + kind="client_only", + ), } diff --git a/tests/perps/klear/test_margin.py b/tests/perps/klear/test_margin.py index 1f16e41e..edb46ed3 100644 --- a/tests/perps/klear/test_margin.py +++ b/tests/perps/klear/test_margin.py @@ -1092,3 +1092,162 @@ def test_parses_optional_snapshot_ts(self) -> None: def test_omitted_snapshot_ts(self) -> None: report = MarginReport.model_validate(_report()) assert report.snapshot_ts is None + + +class TestMemberFundingPayments: + @respx.mock + def test_happy(self, auth_klear_client: KlearClient) -> None: + route = respx.get(f"{BASE}/margin/funding_payments").mock( + return_value=httpx.Response( + 200, + json={ + "payments": [ + { + "id": "p1", + "market_ticker": "BTC-PERP", + "subtrader_id": "user_desk1", + "funding_time": "2026-09-01T16:00:00Z", + "position_quantity_fp": "1.25", + "notional_value_centicents": 10000, + "funding_amount_centicents": -50, + "settlement_execution_time": "2026-09-01T16:05:00Z", + } + ] + }, + ) + ) + page = auth_klear_client.margin.member_funding_payments( + funding_time="2026-09-01T16:00:00Z" + ) + assert len(page.items) == 1 + assert page.items[0].market_ticker == "BTC-PERP" + assert page.items[0].settlement_execution_time is not None + assert dict(route.calls[0].request.url.params)["funding_time"] == ( + "2026-09-01T16:00:00Z" + ) + auth_klear_client.close() + + @respx.mock + def test_400_maps(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/funding_payments").mock( + return_value=httpx.Response(400, json={"error": {"code": "bad_time"}}) + ) + with pytest.raises(KalshiValidationError): + auth_klear_client.margin.member_funding_payments(funding_time="nope") + auth_klear_client.close() + + +class TestFcmApiKeys: + @respx.mock + def test_list(self, auth_klear_client: KlearClient) -> None: + route = respx.get(f"{BASE}/fcm/margin/api_keys").mock( + return_value=httpx.Response( + 200, + json={ + "api_keys": [ + { + "api_key_id": "k-1", + "name": "desk", + "fcm_subtrader_id": "user_desk1", + } + ] + }, + ) + ) + resp = auth_klear_client.margin.list_fcm_api_keys(fcm_subtrader_id="user_desk1") + assert resp.api_keys[0].api_key_id == "k-1" + assert dict(route.calls[0].request.url.params) == { + "fcm_subtrader_id": "user_desk1" + } + auth_klear_client.close() + + @respx.mock + def test_create(self, auth_klear_client: KlearClient) -> None: + route = respx.post(f"{BASE}/fcm/margin/api_keys").mock( + return_value=httpx.Response(201, json={"api_key_id": "k-new"}) + ) + resp = auth_klear_client.margin.create_fcm_api_key( + name="desk", public_key="-----BEGIN PUBLIC KEY-----", fcm_subtrader_id="user_desk1" + ) + assert resp.api_key_id == "k-new" + body = json.loads(route.calls[0].request.content) + assert body["fcm_subtrader_id"] == "user_desk1" + auth_klear_client.close() + + def test_create_requires_args(self, auth_klear_client: KlearClient) -> None: + with pytest.raises(TypeError, match="create_fcm_api_key"): + auth_klear_client.margin.create_fcm_api_key() + auth_klear_client.close() + + @respx.mock + def test_generate(self, auth_klear_client: KlearClient) -> None: + respx.post(f"{BASE}/fcm/margin/api_keys/generate").mock( + return_value=httpx.Response( + 201, + json={"api_key_id": "k-gen", "private_key": "-----BEGIN PRIVATE KEY-----"}, + ) + ) + resp = auth_klear_client.margin.generate_fcm_api_key( + name="desk", fcm_subtrader_id="user_desk1" + ) + assert resp.api_key_id == "k-gen" + assert resp.private_key.get_secret_value().startswith("-----BEGIN") + auth_klear_client.close() + + @respx.mock + def test_delete(self, auth_klear_client: KlearClient) -> None: + route = respx.delete(f"{BASE}/fcm/margin/api_keys/k-1").mock( + return_value=httpx.Response(204) + ) + auth_klear_client.margin.delete_fcm_api_key("k-1") + assert route.called + auth_klear_client.close() + + @respx.mock + @pytest.mark.asyncio + async def test_async_list(self, auth_async_klear_client: AsyncKlearClient) -> None: + respx.get(f"{BASE}/fcm/margin/api_keys").mock( + return_value=httpx.Response(200, json={"api_keys": []}) + ) + resp = await auth_async_klear_client.margin.list_fcm_api_keys() + assert resp.api_keys == [] + await auth_async_klear_client.close() + + +class TestEstimateOptionalFields: + @respx.mock + def test_sends_date_and_clearing_type(self, auth_klear_client: KlearClient) -> None: + import datetime + + from kalshi.perps.klear.models.margin import ( + EstimatePortfolioMaintenanceMarginPosition, + ) + + route = respx.post(f"{BASE}/margin/estimate_maintenance_margin").mock( + return_value=httpx.Response( + 200, + json={ + "maintenance_margin_fp": "10.0000", + "base_margin_fp": "8.0000", + "hvar_fp": "7.0000", + "apc_fp": "8.0000", + "funding_addon_fp": "1.0000", + "liquidation_addon_fp": "1.0000", + }, + ) + ) + resp = auth_klear_client.margin.estimate_maintenance_margin( + asset_class="Crypto", + positions=[ + EstimatePortfolioMaintenanceMarginPosition( + market_ticker="BTC-PERP", quantity=1, price=Decimal("6.8000") + ) + ], + date=datetime.date(2026, 9, 1), + clearing_type="FCM", + ) + body = json.loads(route.calls[0].request.content) + assert body["date"] == "2026-09-01" + assert body["clearing_type"] == "FCM" + assert resp.base_margin_fp == Decimal("8.0000") + auth_klear_client.close() diff --git a/tests/perps/test_margin_account.py b/tests/perps/test_margin_account.py index 5d2468cf..e03589cc 100644 --- a/tests/perps/test_margin_account.py +++ b/tests/perps/test_margin_account.py @@ -443,6 +443,72 @@ async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: await async_perps_client.close() +class TestFeeTierRates: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/fee_tier_rates").mock( + return_value=httpx.Response( + 200, + json={ + "fee_tier_rates": [ + { + "fee_schedule": "fcm", + "tier": 0, + "maker_fee_rate": 0.0005, + "taker_fee_rate": 0.0012, + } + ] + }, + ) + ) + resp = perps_client.margin.fee_tier_rates() + assert len(resp.fee_tier_rates) == 1 + row = resp.fee_tier_rates[0] + assert row.fee_schedule == "fcm" + assert row.tier == 0 + assert row.maker_fee_rate == Decimal("0.0005") + assert isinstance(row.taker_fee_rate, Decimal) + + @respx.mock + def test_empty(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/fee_tier_rates").mock( + return_value=httpx.Response(200, json={"fee_tier_rates": []}) + ) + assert perps_client.margin.fee_tier_rates().fee_tier_rates == [] + + @respx.mock + def test_unauthenticated_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/fee_tier_rates").mock( + return_value=httpx.Response(200, json={"fee_tier_rates": []}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + client.margin.fee_tier_rates() + assert not route.called + client.close() + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/fee_tier_rates").mock( + return_value=httpx.Response( + 200, + json={ + "fee_tier_rates": [ + { + "fee_schedule": "kalshi_prime", + "tier": 1, + "maker_fee_rate": 0.0001, + "taker_fee_rate": 0.0002, + } + ] + }, + ) + ) + resp = await async_perps_client.margin.fee_tier_rates() + assert resp.fee_tier_rates[0].fee_schedule == "kalshi_prime" + await async_perps_client.close() + + class TestApiLimits: """GET /account/limits/perps — reuses kalshi.models.account.AccountApiLimits.""" diff --git a/tests/perps/ws/test_perps_ws_channels.py b/tests/perps/ws/test_perps_ws_channels.py index 91f6f59a..60472298 100644 --- a/tests/perps/ws/test_perps_ws_channels.py +++ b/tests/perps/ws/test_perps_ws_channels.py @@ -97,6 +97,7 @@ async def test_subscribe_fill_yields_typed_message( "is_taker": True, "side": "bid", "ts_ms": 1700000000000, "price": "100.0000", "count": "5.00", "fee_cost": "0.0500", "post_position": "15.00", + "order_source": "user", }, }) frame = await asyncio.wait_for(stream.__anext__(), timeout=2.0) @@ -118,7 +119,7 @@ async def test_subscribe_user_orders_yields_typed_message( "order_id": "o1", "user_id": "u1", "client_order_id": "c1", "ticker": "BTC-PERP", "side": "ask", "price": "100.0000", "fill_count": "2.00", "remaining_count": "8.00", - "created_ts_ms": 1700000000000, + "created_ts_ms": 1700000000000, "order_source": "user", }, }) frame = await asyncio.wait_for(stream.__anext__(), timeout=2.0) diff --git a/tests/perps/ws/test_perps_ws_models.py b/tests/perps/ws/test_perps_ws_models.py index 803d31a0..1f764b4f 100644 --- a/tests/perps/ws/test_perps_ws_models.py +++ b/tests/perps/ws/test_perps_ws_models.py @@ -133,11 +133,11 @@ def test_sequenced_envelopes_require_seq(self) -> None: "marginOrderbookSnapshotPayload", "marginOrderbookDeltaPayload", "orderGroupUpdatesPayload", + "marginTradePayload", ): assert "seq" in spec["components"]["schemas"][schema_name]["required"] for schema_name in ( "marginTickerPayload", - "marginTradePayload", "marginFillPayload", "marginUserOrderPayload", ): @@ -147,17 +147,9 @@ def test_sequenced_envelopes_require_seq(self) -> None: class TestOrderGroupExampleParity: - """The spec ships an inline orderGroupLimitUpdated example — parse it. + """The spec ships an inline orderGroupLimitUpdated example — parse it.""" - SPEC DISCREPANCY: the inline ``orderGroupLimitUpdated`` example omits - ``ts_ms`` from its ``msg`` even though ``orderGroupUpdatesPayload.msg`` - marks ``ts_ms`` as ``required``. The model (correctly) follows the schema's - required list, so this test back-fills the schema-required ``ts_ms`` the - example left out and asserts that detail explicitly, rather than relaxing - the model to match an incomplete example. - """ - - def test_inline_example_omits_required_ts_ms(self) -> None: + def test_inline_example_includes_required_ts_ms(self) -> None: spec = _load_spec() example = spec["components"]["messages"]["orderGroupUpdates"]["examples"][0][ "payload" @@ -165,17 +157,15 @@ def test_inline_example_omits_required_ts_ms(self) -> None: required = spec["components"]["schemas"]["orderGroupUpdatesPayload"][ "properties" ]["msg"]["required"] - # Documents the discrepancy: schema requires ts_ms, example omits it. assert "ts_ms" in required - assert "ts_ms" not in example["msg"] + assert "ts_ms" in example["msg"] - def test_inline_example_validates_with_ts_ms_backfilled(self) -> None: + def test_inline_example_validates(self) -> None: spec = _load_spec() example = spec["components"]["messages"]["orderGroupUpdates"]["examples"][0][ "payload" ] - frame = {**example, "msg": {**example["msg"], "ts_ms": 1700000000000}} - msg = OrderGroupUpdatesMessage.model_validate(frame) + msg = OrderGroupUpdatesMessage.model_validate(example) assert msg.type == "order_group_updates" assert msg.sid == 21 assert msg.seq == 7 @@ -459,6 +449,7 @@ def _frame(self) -> dict[str, Any]: "count": "5.00", "fee_cost": "0.0500", "post_position": "15.00", + "order_source": "user", }, } @@ -471,6 +462,13 @@ def test_taker_fill_decimals(self) -> None: assert isinstance(msg.msg.fee_cost, Decimal) assert msg.msg.client_order_id is None assert msg.msg.subaccount is None + assert msg.msg.order_source == "user" + + def test_missing_order_source_raises(self) -> None: + frame = self._frame() + frame["msg"].pop("order_source") + with pytest.raises(ValidationError): + MarginFillMessage.model_validate(frame) def test_optional_fields_present(self) -> None: frame = self._frame() @@ -502,6 +500,7 @@ def _frame(self) -> dict[str, Any]: "fill_count": "2.00", "remaining_count": "8.00", "created_ts_ms": 1700000000000, + "order_source": "user", }, } diff --git a/tests/test_api_keys.py b/tests/test_api_keys.py index 8ad3ca7a..a5386380 100644 --- a/tests/test_api_keys.py +++ b/tests/test_api_keys.py @@ -158,6 +158,43 @@ def test_generate_request_rejects_out_of_range_subaccount(self, bad: int) -> Non with pytest.raises(ValidationError): GenerateApiKeyRequest(name="bot", subaccount=bad) + def test_create_request_serializes_fcm_subtrader_id(self) -> None: + req = CreateApiKeyRequest( + name="bot", public_key=_PUBKEY_PEM, fcm_subtrader_id="user_desk1" + ) + body = req.model_dump(exclude_none=True, by_alias=True, mode="json") + assert body["fcm_subtrader_id"] == "user_desk1" + + def test_create_request_rejects_subaccount_and_fcm(self) -> None: + with pytest.raises(ValidationError, match="mutually exclusive"): + CreateApiKeyRequest( + name="bot", + public_key=_PUBKEY_PEM, + subaccount=1, + fcm_subtrader_id="user_desk1", + ) + + def test_generate_request_rejects_subaccount_and_fcm(self) -> None: + with pytest.raises(ValidationError, match="mutually exclusive"): + GenerateApiKeyRequest(name="bot", subaccount=1, fcm_subtrader_id="user_desk1") + + def test_api_key_parses_fcm_subtrader_id(self) -> None: + k = ApiKey.model_validate( + { + "api_key_id": "k-1", + "name": "bot", + "scopes": ["read"], + "fcm_subtrader_id": "user_desk1", + }, + ) + assert k.fcm_subtrader_id == "user_desk1" + + def test_create_response_warning(self) -> None: + resp = CreateApiKeyResponse.model_validate( + {"api_key_id": "k-1", "warning": "no IM cap"} + ) + assert resp.warning == "no IM cap" + class TestApiKeysList: @respx.mock @@ -205,6 +242,16 @@ def test_list_handles_null_scopes(self, api_keys: ApiKeysResource) -> None: resp = api_keys.list() assert resp.api_keys[0].scopes == [] + @respx.mock + def test_list_sends_fcm_subtrader_id(self, api_keys: ApiKeysResource) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/api_keys").mock( + return_value=httpx.Response(200, json={"api_keys": []}), + ) + api_keys.list(fcm_subtrader_id="user_desk1") + assert dict(route.calls[0].request.url.params) == { + "fcm_subtrader_id": "user_desk1" + } + def test_list_requires_auth(self, unauth_api_keys: ApiKeysResource) -> None: with pytest.raises(AuthRequiredError): unauth_api_keys.list() @@ -251,6 +298,20 @@ def test_create_with_subaccount(self, api_keys: ApiKeysResource) -> None: body = json.loads(route.calls[0].request.content) assert body["subaccount"] == 5 + @respx.mock + def test_create_with_fcm_subtrader_id(self, api_keys: ApiKeysResource) -> None: + route = respx.post("https://test.kalshi.com/trade-api/v2/api_keys").mock( + return_value=httpx.Response( + 201, json={"api_key_id": "k-new", "warning": "no IM cap"} + ), + ) + resp = api_keys.create( + name="bot", public_key=_PUBKEY_PEM, fcm_subtrader_id="user_desk1" + ) + assert resp.warning == "no IM cap" + body = json.loads(route.calls[0].request.content) + assert body["fcm_subtrader_id"] == "user_desk1" + @respx.mock def test_create_400_maps(self, api_keys: ApiKeysResource) -> None: respx.post("https://test.kalshi.com/trade-api/v2/api_keys").mock( @@ -298,6 +359,25 @@ def test_generate_with_subaccount(self, api_keys: ApiKeysResource) -> None: body = json.loads(route.calls[0].request.content) assert body["subaccount"] == 0 + @respx.mock + def test_generate_with_fcm_subtrader_id(self, api_keys: ApiKeysResource) -> None: + route = respx.post( + "https://test.kalshi.com/trade-api/v2/api_keys/generate", + ).mock( + return_value=httpx.Response( + 201, + json={ + "api_key_id": "k-auto", + "private_key": "-----BEGIN...", + "warning": "no IM cap", + }, + ), + ) + resp = api_keys.generate(name="bot", fcm_subtrader_id="user_desk1") + assert resp.warning == "no IM cap" + body = json.loads(route.calls[0].request.content) + assert body["fcm_subtrader_id"] == "user_desk1" + def test_generate_requires_auth( self, unauth_api_keys: ApiKeysResource, ) -> None: diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 53d2abf7..398b8ffa 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -1519,6 +1519,12 @@ def _assert_params_match( "#/components/schemas/EstimatePortfolioMaintenanceMarginRequest": ( "kalshi.perps.klear.models.margin.EstimatePortfolioMaintenanceMarginRequest" ), + "#/components/schemas/CreateMarginFcmApiKeyRequest": ( + "kalshi.perps.klear.models.margin.CreateMarginFcmApiKeyRequest" + ), + "#/components/schemas/GenerateMarginFcmApiKeyRequest": ( + "kalshi.perps.klear.models.margin.GenerateMarginFcmApiKeyRequest" + ), } diff --git a/tests/test_fcm.py b/tests/test_fcm.py index 6ce1fd2b..a87dd971 100644 --- a/tests/test_fcm.py +++ b/tests/test_fcm.py @@ -91,6 +91,23 @@ def test_forwards_filters(self, fcm: FcmResource) -> None: assert url.params["status"] == "resting" assert url.params["limit"] == "50" + @respx.mock + def test_client_order_ids_without_subtrader(self, fcm: FcmResource) -> None: + route = respx.get( + "https://test.kalshi.com/trade-api/v2/fcm/orders", + ).mock(return_value=httpx.Response(200, json={"orders": []})) + fcm.orders(client_order_ids=["a", "b"]) + assert route.calls.last.request.url.params["client_order_ids"] == "a,b" + assert "subtrader_id" not in route.calls.last.request.url.params + + def test_requires_subtrader_or_client_order_ids(self, fcm: FcmResource) -> None: + with pytest.raises(ValueError, match="subtrader_id or client_order_ids"): + fcm.orders() + + def test_client_order_ids_cap(self, fcm: FcmResource) -> None: + with pytest.raises(ValueError, match="at most 100"): + fcm.orders(client_order_ids=[f"id-{i}" for i in range(101)]) + def test_requires_auth(self, unauth_fcm: FcmResource) -> None: with pytest.raises(AuthRequiredError): unauth_fcm.orders(subtrader_id="sub-1") diff --git a/tests/test_historical.py b/tests/test_historical.py index dfbc3b96..edcf6166 100644 --- a/tests/test_historical.py +++ b/tests/test_historical.py @@ -877,15 +877,16 @@ def test_positions_with_filters(self, historical: HistoricalResource) -> None: cursor="abc", ticker="MKT-A", event_ticker="EVT-X", + subaccount=3, ) params = dict(route.calls[0].request.url.params) assert params["limit"] == "50" assert params["cursor"] == "abc" assert params["ticker"] == "MKT-A" assert params["event_ticker"] == "EVT-X" - # Historical positions do not accept portfolio-only params. + assert params["subaccount"] == "3" + # Historical positions do not accept portfolio-only count_filter. assert "count_filter" not in params - assert "subaccount" not in params def test_positions_requires_auth(self, unauth_historical: HistoricalResource) -> None: with pytest.raises(AuthRequiredError): diff --git a/tests/test_live_data.py b/tests/test_live_data.py index 4a5363bf..ad6fdf2c 100644 --- a/tests/test_live_data.py +++ b/tests/test_live_data.py @@ -13,6 +13,7 @@ from kalshi.models.live_data import ( EventLiveData, GetGameStatsResponse, + GetWeatherIndexCalibrationsResponse, GetWeatherIndexResponse, LiveData, ) @@ -376,3 +377,62 @@ async def test_async_weather( ).mock(return_value=httpx.Response(200, json=_WEATHER_JSON)) resp = await async_live_data.weather("miami", last_sec=3600) assert resp.city == "miami" + + +_CALIBRATIONS_JSON = { + "city": "miami", + "units": "celsius", + "calibrations": [ + { + "config_version": "miami-temperature-v1.0-cal-20260831", + "effective_at_ms": 1756627200000, + "city_reference_c": 1.25, + "stations": [ + {"station_id": "KMIA1M", "weight": 0.5, "offset_c": 0.8}, + {"station_id": "KMIA2M", "weight": 0.5, "offset_c": 1.7}, + ], + "published_at_ms": 1756620000000, + "change_reason": "weekly calibration", + } + ], +} + + +class TestLiveDataWeatherCalibrations: + @respx.mock + def test_happy(self, live_data: LiveDataResource) -> None: + respx.get( + "https://test.kalshi.com/trade-api/v2/live_data/weather/miami/calibrations", + ).mock(return_value=httpx.Response(200, json=_CALIBRATIONS_JSON)) + resp = live_data.weather_calibrations("miami") + assert isinstance(resp, GetWeatherIndexCalibrationsResponse) + assert resp.city == "miami" + assert resp.units == "celsius" + assert resp.calibrations[0].config_version.endswith("20260831") + assert resp.calibrations[0].stations[0].station_id == "KMIA1M" + assert resp.calibrations[0].stations[0].weight == 0.5 + + @respx.mock + def test_400_maps(self, live_data: LiveDataResource) -> None: + from kalshi.errors import KalshiValidationError + + respx.get( + "https://test.kalshi.com/trade-api/v2/live_data/weather/nope/calibrations", + ).mock(return_value=httpx.Response(400, json={"message": "unknown city"})) + with pytest.raises(KalshiValidationError): + live_data.weather_calibrations("nope") + + def test_rejects_empty_city(self, live_data: LiveDataResource) -> None: + with pytest.raises(ValueError, match="city"): + live_data.weather_calibrations("") + + @respx.mock + @pytest.mark.asyncio + async def test_async( + self, async_live_data: AsyncLiveDataResource, + ) -> None: + respx.get( + "https://test.kalshi.com/trade-api/v2/live_data/weather/miami/calibrations", + ).mock(return_value=httpx.Response(200, json=_CALIBRATIONS_JSON)) + resp = await async_live_data.weather_calibrations("miami") + assert resp.city == "miami" diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index e0fe49c0..6b69bbe2 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -1470,6 +1470,21 @@ def test_set_kwargs(self, portfolio: PortfolioResource) -> None: "allocations": [{"exchange_index": 0, "percent": 100}] } + @respx.mock + def test_set_resting_margin_reservation(self, portfolio: PortfolioResource) -> None: + import json + + from kalshi.models.portfolio import TargetBalanceAllocationInput + + route = respx.post( + "https://test.kalshi.com/trade-api/v2/portfolio/target_balance_allocation" + ).mock(return_value=httpx.Response(200, json={})) + portfolio.set_target_balance_allocation( + allocations=[TargetBalanceAllocationInput(exchange_index=0, percent=100)], + resting_margin_reservation="max", + ) + assert json.loads(route.calls[0].request.content)["resting_margin_reservation"] == "max" + @respx.mock def test_set_request_model(self, portfolio: PortfolioResource) -> None: import json diff --git a/tests/ws/test_client.py b/tests/ws/test_client.py index 173b599a..95bc6f30 100644 --- a/tests/ws/test_client.py +++ b/tests/ws/test_client.py @@ -202,6 +202,16 @@ async def test_subscribe_all_indices_passthrough(self, fake_ws, test_auth) -> No cmd = fake_ws.received_commands[0] assert cmd["params"]["index_ids"] == ["all"] + async def test_subscribe_5hz_seeds_index_ids(self, fake_ws, test_auth) -> None: # type: ignore[no-untyped-def] + config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) + ws = KalshiWebSocket(auth=test_auth, config=config) + async with ws.connect() as session: + await session.subscribe_cfbenchmarks_value_5hz(index_ids=["BRTI"]) + cmd = fake_ws.received_commands[0] + assert "cfbenchmarks_value_5hz" in cmd["params"]["channels"] + assert cmd["params"]["index_ids"] == ["BRTI"] + assert "market_tickers" not in cmd["params"] + async def test_receives_value_message(self, fake_ws, test_auth) -> None: # type: ignore[no-untyped-def] config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) ws = KalshiWebSocket(auth=test_auth, config=config) diff --git a/tests/ws/test_dispatch.py b/tests/ws/test_dispatch.py index ef42794a..7ff2f0f8 100644 --- a/tests/ws/test_dispatch.py +++ b/tests/ws/test_dispatch.py @@ -393,6 +393,8 @@ async def test_all_channel_types_have_models(self) -> None: "communications", "cfbenchmarks_value", "cfbenchmarks_value_indexlist", + "cfbenchmarks_value_5hz", + "cfbenchmarks_value_5hz_indexlist", } assert expected == set(MESSAGE_MODELS.keys()) diff --git a/tests/ws/test_models.py b/tests/ws/test_models.py index 492b0bbb..3cc1f25f 100644 --- a/tests/ws/test_models.py +++ b/tests/ws/test_models.py @@ -17,7 +17,9 @@ UnsubscribedMessage, ) from kalshi.ws.models.cfbenchmarks import ( + CFBenchmarks5HzIndexListMessage, CFBenchmarksIndexListMessage, + CFBenchmarksValue5HzMessage, CFBenchmarksValueMessage, ) from kalshi.ws.models.communications import ( @@ -330,6 +332,52 @@ def test_parse_index_list(self) -> None: assert msg.id == 2 assert msg.msg.index_ids == ["BRTI", "ETHUSD_RTI"] + def test_parse_5hz_value(self) -> None: + raw = { + "type": "cfbenchmarks_value_5hz", + "sid": 1, + "seq": 9, + "msg": { + "index_id": "BRTI", + "value_usd": "65000.12345678", + "source_ts_ms": 1715793600000, + "received_at": 1715793600123, + "data": "{}", + }, + } + msg = CFBenchmarksValue5HzMessage.model_validate(raw) + assert msg.type == "cfbenchmarks_value_5hz" + assert msg.msg.value_usd == Decimal("65000.12345678") + assert isinstance(msg.msg.value_usd, Decimal) + assert msg.msg.source_ts_ms == 1715793600000 + + def test_5hz_missing_value_usd_raises(self) -> None: + raw = { + "type": "cfbenchmarks_value_5hz", + "sid": 1, + "seq": 1, + "msg": { + "index_id": "BRTI", + "source_ts_ms": 1, + "received_at": 1, + "data": "{}", + }, + } + with pytest.raises(ValidationError): + CFBenchmarksValue5HzMessage.model_validate(raw) + + def test_parse_5hz_index_list(self) -> None: + raw = { + "type": "cfbenchmarks_value_5hz_indexlist", + "id": 2, + "sid": 1, + "seq": 1, + "msg": {"index_ids": ["BRTI", "ETHUSD_RTI"]}, + } + msg = CFBenchmarks5HzIndexListMessage.model_validate(raw) + assert msg.type == "cfbenchmarks_value_5hz_indexlist" + assert msg.msg.index_ids == ["BRTI", "ETHUSD_RTI"] + # ---------- Trade ----------