From be5e2c4497d4a7ae5ede47d23d7f0d006251d8b9 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sun, 20 Sep 2026 08:13:10 -0500 Subject: [PATCH] Reconcile OpenAPI 3.30.0 spec drift (v15.0.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #515. Closes #517. Vendors OpenAPI 3.29.0 → 3.30.0 plus matching perps/Klear/AsyncAPI updates. Breaking for constructors of Series, GetTargetBalanceAllocationResponse, MarginMarket, and WS quote created/accepted payloads that omit newly required fields. --- CHANGELOG.md | 64 +++ CLAUDE.md | 21 +- README.md | 4 +- ROADMAP.md | 7 + docs/index.md | 6 +- docs/migration.md | 46 ++ docs/perps.md | 8 + docs/resources/communications.md | 1 + docs/resources/fcm.md | 26 + docs/resources/historical.md | 4 +- docs/resources/portfolio.md | 1 + docs/resources/series.md | 2 +- kalshi/__init__.py | 20 +- kalshi/_contract_map.py | 72 +++ kalshi/models/__init__.py | 20 + kalshi/models/communications.py | 8 +- kalshi/models/fcm.py | 95 ++++ kalshi/models/portfolio.py | 19 +- kalshi/models/series.py | 3 + kalshi/perps/klear/models/__init__.py | 18 + kalshi/perps/klear/models/margin.py | 93 ++++ kalshi/perps/klear/resources/margin.py | 111 +++- kalshi/perps/models/markets.py | 3 + kalshi/perps/ws/channels.py | 100 ++-- kalshi/perps/ws/models/control.py | 12 +- kalshi/perps/ws/models/orderbook.py | 2 + kalshi/resources/communications.py | 25 +- kalshi/resources/fcm.py | 355 +++++++++++- kalshi/resources/historical.py | 23 +- kalshi/ws/channels.py | 94 ++-- kalshi/ws/models/communications.py | 9 +- kalshi/ws/models/orderbook_delta.py | 2 + pyproject.toml | 2 +- specs/asyncapi.yaml | 713 +++++++++++++++++-------- specs/openapi.yaml | 559 ++++++++++++++++++- specs/perps_asyncapi.yaml | 58 +- specs/perps_openapi.yaml | 37 +- specs/perps_scm_openapi.yaml | 162 +++++- tests/_contract_support.py | 70 ++- tests/_model_fixtures.py | 4 +- tests/perps/klear/test_margin.py | 167 +++--- tests/perps/test_markets.py | 41 +- tests/perps/ws/test_channels.py | 28 +- tests/test_communications.py | 214 +++++--- tests/test_contracts.py | 31 +- tests/test_fcm.py | 107 ++++ tests/test_historical.py | 26 +- tests/test_portfolio.py | 44 +- tests/test_series.py | 165 +++--- tests/test_series_models.py | 310 ++++++----- tests/ws/test_models.py | 10 +- 51 files changed, 3174 insertions(+), 848 deletions(-) create mode 100644 kalshi/models/fcm.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 89ad8837..9f153101 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,70 @@ All notable changes to kalshi-sdk will be documented in this file. +## 15.0.0 — 2026-09-20 + +Reconciles upstream OpenAPI **3.29.0 → 3.30.0** plus matching perps, Klear, +and AsyncAPI updates after nightly contract failures (Closes #515, +Closes #517). **Breaking** for constructors of `Series`, +`GetTargetBalanceAllocationResponse`, `MarginMarket`, and WS +`QuoteCreatedPayload` / `QuoteAcceptedPayload` that omit newly required +fields. + +### Changed (breaking) + +- **`Series.categories`** (`list[str]`, required) — discovery categories + the series belongs to. The `category` filter on `series.list()` matches + any entry. Live list callers are unaffected; tests/mocks that construct + `Series` must pass `categories`. +- **`GetTargetBalanceAllocationResponse.resting_margin_reservation`** + (`"max"` / `"sum"`, required). The GET now echoes the reservation policy + previously write-only on `set_target_balance_allocation`. +- **Perps** `MarginMarket.underlying_multiplier` (`str`, required) — + underlying units per contract-size unit. +- **WS** `QuoteCreatedPayload.rfq_creator_id` and + `QuoteAcceptedPayload.rfq_creator_id` (`str`, required). Live stream + callers are unaffected; tests/mocks that construct these payloads must + pass the creator id. + +### Added + +- **FCM subtrader admin** on `client.fcm`: + `list_subtraders()` / `create_subtrader(subtrader_suffix=...)`, + `blocked_categories(subtrader_id=)` / + `update_blocked_categories(subtrader_id=, category=, blocked=)`, + `event_contract_daily_cap(subtrader_id=)` / + `update_event_contract_daily_cap(subtrader_id=, limit=)` / + `delete_event_contract_daily_cap(subtrader_id=)`. +- Optional **`CreateRFQRequest.target_cost_excludes_fees`** (and the same + field on `RFQ` / `Quote` responses) — sizes quotes against the target + cost as principal only, with taker fees charged on top. +- Optional **`historical.fills` / `fills_all` / `orders` / `orders_all`** + `min_ts=` query (mirrors `historical.trades`). +- Optional **`MarginMarket.product_metadata`**. +- **Klear** `estimate_maintenance_margin_metadata(asset_class=, date=)`, + `funding_estimate_by_asset_class()`, `funding_schedule(asset_class=)`. +- Optional **`id`** on orderbook snapshot envelopes (core + perps) when + the snapshot is a `get_snapshot` reply. +- Perps WS `UpdateSubscriptionAction.get_snapshot` — request a fresh + orderbook snapshot without changing the subscription. + +### Changed (non-breaking) + +- Perps WS `ListSubscriptionsResponse.id` is optional (spec dropped it + from required). `OkMsg` accepts optional `index_ids` / + `underlying_tickers` on subscribed-indices / subscribed-underlyings + acks. + +### Spec notes + +- Core OpenAPI `info.version` **3.30.0** (paths 99; 116 operations; + 115 mapped). Still unimplemented on the core client: + `POST /portfolio/intra_exchange_instance_transfer`. +- AsyncAPI still 15 channels. 12 typed `subscribe_*` helpers. +- Perps OpenAPI: 48 operations. +- Perps SCM OpenAPI: 24 → 27 operations. Still unimplemented: + `GET /margin/large_trader_positions` (surveillance). + ## 14.0.0 — 2026-09-06 Reconciles upstream OpenAPI **3.29.0** content drift plus matching perps, diff --git a/CLAUDE.md b/CLAUDE.md index 1117d7a9..ee2eae3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,7 @@ tests/ ## API Reference -- 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) +- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.30.0, 116 operations; 115 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 @@ -144,24 +144,25 @@ Reference issues from PRs via `Closes #N` so the issue closes on merge. # GitNexus — Code Intelligence -This project is indexed by GitNexus as **kalshi-python-sdk** (13553 symbols, 29700 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **kalshi-python-sdk** (12128 symbols, 23376 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. +> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939). ## Always Do -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`. - **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. +- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`. +- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`). ## Never Do -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER edit a function, class, or method without first running `impact` on it. - NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. +- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph. +- NEVER commit changes without running `detect_changes()` to check affected scope. ## Resources diff --git a/README.md b/README.md index b12eccc0..d0a270a3 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 (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). +- **Full coverage** of the Kalshi REST API (115 mapped of 116 operations across 19 resources, OpenAPI v3.30.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 (26 of 27 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()`. diff --git a/ROADMAP.md b/ROADMAP.md index f8f72edf..a319cf5e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,13 @@ ## Shipped +- **v15.0.0 (2026-09-20)** — Spec-drift reconcile (#515 / #517). OpenAPI + 3.29.0 → 3.30.0. **Breaking:** `Series.categories`, + `GetTargetBalanceAllocationResponse.resting_margin_reservation`, + `MarginMarket.underlying_multiplier`, WS quote created/accepted + `rfq_creator_id` required. Additive: FCM subtrader admin, RFQ + `target_cost_excludes_fees`, historical fills/orders `min_ts`, Klear + maintenance-margin metadata + funding estimate/schedule. - **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`. diff --git a/docs/index.md b/docs/index.md index dcd8bc19..9b520537 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,8 +3,8 @@ A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) prediction markets API. -- **Full REST coverage** — 108 mapped of 109 operations across 19 resources - (OpenAPI v3.29.0), every kwarg drift-tested against the spec. +- **Full REST coverage** — 115 mapped of 116 operations across 19 resources + (OpenAPI v3.30.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` keeps working; deprecation no earlier than May 6, 2026. @@ -18,7 +18,7 @@ markets API. - **Perps (margin) API** — standalone `PerpsClient` / `AsyncPerpsClient` + `PerpsWebSocket` for the perpetual-futures exchange (48 REST operations, 6 WS channels), and a `KlearClient` for the Self-Clearing-Member settlement API - (23 operations, Bearer token auth). See [Perps](perps.md). + (26 of 27 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 d60202a0..61db7c7d 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,5 +1,51 @@ # Migration +## v14.0 → v15.0.0 + +Reconciles upstream OpenAPI **3.29.0 → 3.30.0** plus matching perps, Klear, +and AsyncAPI updates (Closes #515, Closes #517). **Breaking** only for code +that constructs `Series`, `GetTargetBalanceAllocationResponse`, +`MarginMarket`, or WS quote created/accepted payloads without the new +required fields. + +### Response model field changes + +- **`Series.categories`** — required `list[str]`. +- **`GetTargetBalanceAllocationResponse.resting_margin_reservation`** — + required `"max"` / `"sum"`. +- **Perps** `MarginMarket.underlying_multiplier` — required `str`. +- **WS** `QuoteCreatedPayload.rfq_creator_id` and + `QuoteAcceptedPayload.rfq_creator_id` — required `str`. + +```python +# Before (constructors / test fixtures): +# Series(..., category="Politics") +# GetTargetBalanceAllocationResponse(allocations=[...]) +# MarginMarket(..., contract_size="1.000000") +# QuoteCreatedPayload(..., quote_creator_id="u2") + +# After: +Series(..., category="Politics", categories=["Politics"]) +GetTargetBalanceAllocationResponse(allocations=[...], resting_margin_reservation="sum") +MarginMarket(..., contract_size="1.000000", underlying_multiplier="1") +QuoteCreatedPayload(..., quote_creator_id="u2", rfq_creator_id="u1") +``` + +Live list / stream callers are unaffected. + +### Added (non-breaking) + +- `fcm.list_subtraders()` / `create_subtrader()` / blocked-categories / + event-contract daily cap +- `communications.rfqs.create(..., target_cost_excludes_fees=)` +- `historical.fills(..., min_ts=)` / `orders(..., min_ts=)` +- Klear `estimate_maintenance_margin_metadata` / + `funding_estimate_by_asset_class` / `funding_schedule` +- Perps WS `update_subscription(..., action="get_snapshot")` + +See the [changelog](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/CHANGELOG.md) +for the full list. + ## v13.0 → v14.0.0 Reconciles upstream OpenAPI **3.29.0** content drift plus matching perps, diff --git a/docs/perps.md b/docs/perps.md index 5937b7ef..69fba213 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -244,6 +244,14 @@ returns a ticker → centicents map at a settlement cycle. margins a hypothetical portfolio. Optional `date=` (YYYY-MM-DD) and `clearing_type=` (`"FCM"` / `"SelfClearing"`) select the matrix day and clearing arrangement. +`klear.margin.estimate_maintenance_margin_metadata(asset_class="Crypto", date=...)` +returns the matrices, liquidation configs, and subgroups that feed that +estimate. + +`klear.margin.funding_estimate_by_asset_class()` returns the next-funding +estimate keyed by asset class. +`klear.margin.funding_schedule(asset_class="Crypto")` returns a cron +expression evaluated in US Eastern Time. `klear.margin.member_funding_payments(funding_time=...)` returns the member's funding payments for one funding execution (distinct from the diff --git a/docs/resources/communications.md b/docs/resources/communications.md index 0f57ee65..ff5a69b3 100644 --- a/docs/resources/communications.md +++ b/docs/resources/communications.md @@ -48,6 +48,7 @@ rfq = client.communications.rfqs.create( market_ticker="KXPRES-24-DJT", contracts=500, rest_remainder=True, + target_cost_excludes_fees=True, # optional; principal-only target cost ) print(rfq.rfq.rfq_id) diff --git a/docs/resources/fcm.md b/docs/resources/fcm.md index ddc7e2b4..75ac6714 100644 --- a/docs/resources/fcm.md +++ b/docs/resources/fcm.md @@ -13,6 +13,13 @@ calls come back 401/403. Auth required throughout. | `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_subtraders()` | `GET /fcm/subtraders` | +| `create_subtrader(*, subtrader_suffix)` | `POST /fcm/subtraders` | +| `blocked_categories(*, subtrader_id)` | `GET /fcm/subtraders/blocked_categories` | +| `update_blocked_categories(*, subtrader_id, category, blocked)` | `PUT /fcm/subtraders/blocked_categories` | +| `event_contract_daily_cap(*, subtrader_id=None)` | `GET /fcm/subtraders/event_contract_daily_cap` | +| `update_event_contract_daily_cap(*, subtrader_id, limit)` | `PUT /fcm/subtraders/event_contract_daily_cap` | +| `delete_event_contract_daily_cap(*, subtrader_id)` | `DELETE /fcm/subtraders/event_contract_daily_cap` | ## List orders @@ -66,6 +73,25 @@ for mp in client.fcm.positions_all(subtrader_id="st_alpha", settlement_status="u `settlement_status` is the FCM-specific kwarg that does **not** exist on `portfolio.positions()`. +## Subtrader admin + +List and create subtraders, block event categories, and set a daily +event-contract notional cap. POST/PUT/DELETE are never retried. + +```python +owned = client.fcm.list_subtraders() +created = client.fcm.create_subtrader(subtrader_suffix="desk1") +client.fcm.update_blocked_categories( + subtrader_id=created.subtrader_id, category="Politics", blocked=True +) +client.fcm.update_event_contract_daily_cap( + subtrader_id=created.subtrader_id, limit="10000.00" +) +``` + +`create_subtrader` composes the full id server-side as +`{account_id}_{suffix}` (suffix is 1–16 ASCII alphanumeric characters). + ## Reference ::: kalshi.resources.fcm.FcmResource diff --git a/docs/resources/historical.md b/docs/resources/historical.md index 9226c284..0447b29b 100644 --- a/docs/resources/historical.md +++ b/docs/resources/historical.md @@ -67,10 +67,10 @@ trades = client.historical.trades( Both require auth — these are your own trade history. ```python -for fill in client.historical.fills_all(ticker="KXPRES-24-DJT"): +for fill in client.historical.fills_all(ticker="KXPRES-24-DJT", min_ts=1_600_000_000): print(fill.fill_id, fill.price, fill.count) -for order in client.historical.orders_all(status="executed"): +for order in client.historical.orders_all(ticker="KXPRES-24-DJT", min_ts=1_600_000_000): print(order.order_id, order.client_order_id) ``` diff --git a/docs/resources/portfolio.md b/docs/resources/portfolio.md index f3442220..f7e3a1e5 100644 --- a/docs/resources/portfolio.md +++ b/docs/resources/portfolio.md @@ -230,6 +230,7 @@ Per-shard sweepable-balance targets. POST is never retried. from kalshi import TargetBalanceAllocationInput current = client.portfolio.target_balance_allocation() +print(current.resting_margin_reservation) # "max" or "sum" client.portfolio.set_target_balance_allocation( allocations=[TargetBalanceAllocationInput(exchange_index=0, percent=100)] ) diff --git a/docs/resources/series.md b/docs/resources/series.md index cd809eb6..1208936f 100644 --- a/docs/resources/series.md +++ b/docs/resources/series.md @@ -27,7 +27,7 @@ all_series = client.series.list( include_volume=True, ) for s in all_series: - print(s.series_ticker, s.title) + print(s.ticker, s.title, s.category, s.categories) ``` ## Get one series diff --git a/kalshi/__init__.py b/kalshi/__init__.py index dbe954d3..a55cd2c5 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -55,6 +55,8 @@ Candlestick, CreateApiKeyRequest, CreateApiKeyResponse, + CreateFCMSubtraderRequest, + CreateFCMSubtraderResponse, CreateMarketInMultivariateEventCollectionRequest, CreateMarketResponse, CreateOrderGroupRequest, @@ -82,6 +84,7 @@ ExchangeIndexStatus, ExchangeInstanceLiteral, ExchangeStatus, + FCMSubtrader, Fill, ForecastPercentilesPoint, GenerateApiKeyRequest, @@ -90,6 +93,8 @@ GetBlockTradeProposalsResponse, GetCommunicationsIDResponse, GetEventLiveDataResponse, + GetFCMEventContractDailyCapResponse, + GetFCMSubtraderBlockedCategoriesResponse, GetFiltersBySportsResponse, GetGameStatsResponse, GetIncentiveProgramsResponse, @@ -117,6 +122,7 @@ IndexedBalance, IntraExchangeInstanceTransfer, IntraExchangeInstanceTransferStatusLiteral, + ListFCMSubtradersResponse, LiveData, MaintenanceWindow, Market, @@ -172,6 +178,9 @@ TimeInForceLiteral, TotalRestingOrderValue, Trade, + UpdateFCMEventContractDailyCapRequest, + UpdateFCMSubtraderBlockedCategoriesRequest, + UpdateFCMSubtraderBlockedCategoriesResponse, UpdateOrderGroupLimitRequest, UpdateSubaccountNettingRequest, UserDataTimestamp, @@ -240,6 +249,8 @@ "Candlestick", "CreateApiKeyRequest", "CreateApiKeyResponse", + "CreateFCMSubtraderRequest", + "CreateFCMSubtraderResponse", "CreateMarketInMultivariateEventCollectionRequest", "CreateMarketResponse", "CreateOrderGroupRequest", @@ -267,6 +278,7 @@ "ExchangeIndexStatus", "ExchangeInstanceLiteral", "ExchangeStatus", + "FCMSubtrader", "Fill", "FixClient", "FixConfig", @@ -279,6 +291,8 @@ "GetBlockTradeProposalsResponse", "GetCommunicationsIDResponse", "GetEventLiveDataResponse", + "GetFCMEventContractDailyCapResponse", + "GetFCMSubtraderBlockedCategoriesResponse", "GetFiltersBySportsResponse", "GetGameStatsResponse", "GetIncentiveProgramsResponse", @@ -328,6 +342,7 @@ "KlearAuth", "KlearClient", "KlearConfig", + "ListFCMSubtradersResponse", "LiveData", "MaintenanceWindow", "MarginFixClient", @@ -391,6 +406,9 @@ "TimeInForceLiteral", "TotalRestingOrderValue", "Trade", + "UpdateFCMEventContractDailyCapRequest", + "UpdateFCMSubtraderBlockedCategoriesRequest", + "UpdateFCMSubtraderBlockedCategoriesResponse", "UpdateOrderGroupLimitRequest", "UpdateSubaccountNettingRequest", "UserDataTimestamp", @@ -403,4 +421,4 @@ "Withdrawal", ] -__version__ = "14.0.0" +__version__ = "15.0.0" diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index b290b77c..27c96738 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -126,6 +126,42 @@ class ContractEntry: sdk_model="kalshi.models.series.SeriesFeeChange", spec_schema="SeriesFeeChange", ), + ContractEntry( + sdk_model="kalshi.models.fcm.FCMSubtrader", + spec_schema="FCMSubtrader", + ), + ContractEntry( + sdk_model="kalshi.models.fcm.ListFCMSubtradersResponse", + spec_schema="ListFCMSubtradersResponse", + ), + ContractEntry( + sdk_model="kalshi.models.fcm.CreateFCMSubtraderRequest", + spec_schema="CreateFCMSubtraderRequest", + ), + ContractEntry( + sdk_model="kalshi.models.fcm.CreateFCMSubtraderResponse", + spec_schema="CreateFCMSubtraderResponse", + ), + ContractEntry( + sdk_model="kalshi.models.fcm.GetFCMSubtraderBlockedCategoriesResponse", + spec_schema="GetFCMSubtraderBlockedCategoriesResponse", + ), + ContractEntry( + sdk_model="kalshi.models.fcm.UpdateFCMSubtraderBlockedCategoriesRequest", + spec_schema="UpdateFCMSubtraderBlockedCategoriesRequest", + ), + ContractEntry( + sdk_model="kalshi.models.fcm.UpdateFCMSubtraderBlockedCategoriesResponse", + spec_schema="UpdateFCMSubtraderBlockedCategoriesResponse", + ), + ContractEntry( + sdk_model="kalshi.models.fcm.GetFCMEventContractDailyCapResponse", + spec_schema="GetFCMEventContractDailyCapResponse", + ), + ContractEntry( + sdk_model="kalshi.models.fcm.UpdateFCMEventContractDailyCapRequest", + spec_schema="UpdateFCMEventContractDailyCapRequest", + ), ContractEntry( sdk_model="kalshi.models.multivariate.MultivariateEventCollection", spec_schema="MultivariateEventCollection", @@ -1039,4 +1075,40 @@ class ContractEntry: sdk_model="kalshi.perps.klear.models.margin.GetMemberFundingPaymentsResponse", spec_schema="GetMemberFundingPaymentsResponse", ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.MaintenanceMarginMatrix", + spec_schema="MaintenanceMarginMatrix", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.MaintenanceMarginMatrices", + spec_schema="MaintenanceMarginMatrices", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.MaintenanceMarginLiquidationConfig", + spec_schema="MaintenanceMarginLiquidationConfig", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.GetMaintenanceMarginMetadataResponse", + spec_schema="GetMaintenanceMarginMetadataResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.MarketFundingEstimate", + spec_schema="MarketFundingEstimate", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.FundingEstimate", + spec_schema="FundingEstimate", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.AssetClassFundingEstimate", + spec_schema="AssetClassFundingEstimate", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.GetFundingEstimateByAssetClassResponse", + spec_schema="GetFundingEstimateByAssetClassResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.GetMarginFundingScheduleResponse", + spec_schema="GetMarginFundingScheduleResponse", + ), ] diff --git a/kalshi/models/__init__.py b/kalshi/models/__init__.py index fb2407c3..c0a3d8e2 100644 --- a/kalshi/models/__init__.py +++ b/kalshi/models/__init__.py @@ -60,6 +60,17 @@ UserDataTimestamp, WeeklySchedule, ) +from kalshi.models.fcm import ( + CreateFCMSubtraderRequest, + CreateFCMSubtraderResponse, + FCMSubtrader, + GetFCMEventContractDailyCapResponse, + GetFCMSubtraderBlockedCategoriesResponse, + ListFCMSubtradersResponse, + UpdateFCMEventContractDailyCapRequest, + UpdateFCMSubtraderBlockedCategoriesRequest, + UpdateFCMSubtraderBlockedCategoriesResponse, +) from kalshi.models.historical import HistoricalCutoff, MveHistoricalFilterLiteral, Trade from kalshi.models.incentive_programs import ( GetIncentiveProgramsResponse, @@ -221,6 +232,8 @@ "Candlestick", "CreateApiKeyRequest", "CreateApiKeyResponse", + "CreateFCMSubtraderRequest", + "CreateFCMSubtraderResponse", "CreateMarketInMultivariateEventCollectionRequest", "CreateMarketResponse", "CreateOrderGroupRequest", @@ -248,6 +261,7 @@ "ExchangeIndexStatus", "ExchangeInstanceLiteral", "ExchangeStatus", + "FCMSubtrader", "Fill", "ForecastPercentilesPoint", "GenerateApiKeyRequest", @@ -256,6 +270,8 @@ "GetBlockTradeProposalsResponse", "GetCommunicationsIDResponse", "GetEventLiveDataResponse", + "GetFCMEventContractDailyCapResponse", + "GetFCMSubtraderBlockedCategoriesResponse", "GetFiltersBySportsResponse", "GetGameStatsResponse", "GetIncentiveProgramsResponse", @@ -283,6 +299,7 @@ "IndexedBalance", "IntraExchangeInstanceTransfer", "IntraExchangeInstanceTransferStatusLiteral", + "ListFCMSubtradersResponse", "LiveData", "MaintenanceWindow", "Market", @@ -338,6 +355,9 @@ "TimeInForceLiteral", "TotalRestingOrderValue", "Trade", + "UpdateFCMEventContractDailyCapRequest", + "UpdateFCMSubtraderBlockedCategoriesRequest", + "UpdateFCMSubtraderBlockedCategoriesResponse", "UpdateOrderGroupLimitRequest", "UpdateSubaccountNettingRequest", "UserDataTimestamp", diff --git a/kalshi/models/communications.py b/kalshi/models/communications.py index b714284a..505de327 100644 --- a/kalshi/models/communications.py +++ b/kalshi/models/communications.py @@ -27,7 +27,8 @@ class MveSelectedLeg(BaseModel): yes_settlement_value: DollarDecimal | None = Field( default=None, validation_alias=AliasChoices( - "yes_settlement_value_dollars", "yes_settlement_value", + "yes_settlement_value_dollars", + "yes_settlement_value", ), ) @@ -59,6 +60,8 @@ class RFQ(BaseModel): # v3.18.0 backfill (#161). creator_subaccount: int | None = None + # Spec 3.30.0: True when target_cost is principal-only (taker fees on top). + target_cost_excludes_fees: bool | None = None model_config = {"extra": "allow", "populate_by_name": True} @@ -112,6 +115,8 @@ class Quote(BaseModel): creator_subaccount: int | None = None rfq_creator_subaccount: int | None = None post_only: bool | None = None + # Spec 3.30.0: True when the parent RFQ's target cost is principal-only. + target_cost_excludes_fees: bool | None = None model_config = {"extra": "allow", "populate_by_name": True} @@ -164,6 +169,7 @@ class CreateRFQRequest(BaseModel): replace_existing: bool | None = None subtrader_id: str | None = None subaccount: StrictInt | None = Field(default=None, ge=0) + target_cost_excludes_fees: bool | None = None model_config = {"extra": "forbid"} diff --git a/kalshi/models/fcm.py b/kalshi/models/fcm.py new file mode 100644 index 00000000..51a4d423 --- /dev/null +++ b/kalshi/models/fcm.py @@ -0,0 +1,95 @@ +"""FCM (Futures Commission Merchant) models — subtraders, category blocks, daily caps.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from kalshi.types import DollarDecimal + + +class CreateFCMSubtraderRequest(BaseModel): + """Body for POST /fcm/subtraders. + + ``subtrader_suffix`` is 1-16 case-sensitive ASCII alphanumeric characters. + The full id is composed server-side as ``{account_id}_{suffix}``. + """ + + subtrader_suffix: str = Field(min_length=1, max_length=16, pattern=r"^[A-Za-z0-9]{1,16}$") + + model_config = {"extra": "forbid"} + + +class CreateFCMSubtraderResponse(BaseModel): + """Response from POST /fcm/subtraders.""" + + subtrader_id: str + + model_config = {"extra": "allow"} + + +class FCMSubtrader(BaseModel): + """One FCM-owned subtrader from GET /fcm/subtraders.""" + + subtrader_id: str + exchange_indices: list[int] + trading_blocked: bool + fcm_trading_blocked: bool + propagation_pending: bool + + model_config = {"extra": "allow"} + + +class ListFCMSubtradersResponse(BaseModel): + """Response from GET /fcm/subtraders.""" + + subtraders: list[FCMSubtrader] + + model_config = {"extra": "allow"} + + +class GetFCMSubtraderBlockedCategoriesResponse(BaseModel): + """Response from GET /fcm/subtraders/blocked_categories.""" + + categories: list[str] + + model_config = {"extra": "allow"} + + +class UpdateFCMSubtraderBlockedCategoriesRequest(BaseModel): + """Body for PUT /fcm/subtraders/blocked_categories.""" + + subtrader_id: str + category: str = Field(min_length=1, max_length=100) + blocked: bool + + model_config = {"extra": "forbid"} + + +class UpdateFCMSubtraderBlockedCategoriesResponse(BaseModel): + """Response from PUT /fcm/subtraders/blocked_categories.""" + + categories: list[str] + + model_config = {"extra": "allow"} + + +class GetFCMEventContractDailyCapResponse(BaseModel): + """Response from GET /fcm/subtraders/event_contract_daily_cap.""" + + subtrader_id: str + limit: DollarDecimal + executed_utilization: DollarDecimal + resting_order_utilization: DollarDecimal + pending_order_utilization: DollarDecimal + cap_date: str + + model_config = {"extra": "allow"} + + +class UpdateFCMEventContractDailyCapRequest(BaseModel): + """Body for PUT /fcm/subtraders/event_contract_daily_cap.""" + + subtrader_id: str + limit: DollarDecimal + + model_config = {"extra": "forbid"} diff --git a/kalshi/models/portfolio.py b/kalshi/models/portfolio.py index 1459d4b6..41df4439 100644 --- a/kalshi/models/portfolio.py +++ b/kalshi/models/portfolio.py @@ -258,22 +258,24 @@ class TargetBalanceAllocationInput(BaseModel): model_config = {"extra": "forbid"} +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 +on the write path; the GET response always includes the effective value. +""" + + class GetTargetBalanceAllocationResponse(BaseModel): """Response from GET /portfolio/target_balance_allocation.""" allocations: list[TargetBalanceAllocation] + resting_margin_reservation: RestingMarginReservationLiteral 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.""" @@ -281,4 +283,3 @@ class SetTargetBalanceAllocationRequest(BaseModel): resting_margin_reservation: RestingMarginReservationLiteral | None = None model_config = {"extra": "forbid"} - diff --git a/kalshi/models/series.py b/kalshi/models/series.py index 19a86252..f9d2cb39 100644 --- a/kalshi/models/series.py +++ b/kalshi/models/series.py @@ -22,6 +22,9 @@ class Series(BaseModel): frequency: str title: str category: str + # Spec 3.30.0: discovery categories the series belongs to. The `category` + # filter on GET /series matches any entry in this list. + categories: list[str] tags: NullableList[str] settlement_sources: NullableList[dict[str, Any]] contract_url: str diff --git a/kalshi/perps/klear/models/__init__.py b/kalshi/perps/klear/models/__init__.py index 712ca1a4..73db4c69 100644 --- a/kalshi/perps/klear/models/__init__.py +++ b/kalshi/perps/klear/models/__init__.py @@ -4,6 +4,7 @@ from kalshi.perps.klear.models.common import Error from kalshi.perps.klear.models.margin import ( + AssetClassFundingEstimate, AssetClassLiteral, AssetClassSettlementEstimate, ClearingTypeLiteral, @@ -14,11 +15,15 @@ EstimatePortfolioMaintenanceMarginPosition, EstimatePortfolioMaintenanceMarginRequest, EstimatePortfolioMaintenanceMarginResponse, + FundingEstimate, FundingPaymentDetail, GenerateMarginFcmApiKeyRequest, GenerateMarginFcmApiKeyResponse, GetActiveMarginObligationsResponse, + GetFundingEstimateByAssetClassResponse, GetGuarantyFundBalanceResponse, + GetMaintenanceMarginMetadataResponse, + GetMarginFundingScheduleResponse, GetMarginReportsResponse, GetMarginSubtraderGroupsResponse, GetMemberFundingPaymentsResponse, @@ -33,10 +38,14 @@ GetSettlementPricesResponse, ListMarginFcmApiKeysResponse, MaintenanceMarginDetail, + MaintenanceMarginLiquidationConfig, + MaintenanceMarginMatrices, + MaintenanceMarginMatrix, MarginFcmApiKey, MarginReport, MarginReportTypeLiteral, MarginSubtraderGroup, + MarketFundingEstimate, MarketSettlementEstimate, MemberFundingPayment, ObligationEntry, @@ -51,6 +60,7 @@ ) __all__ = [ + "AssetClassFundingEstimate", "AssetClassLiteral", "AssetClassSettlementEstimate", "ClearingTypeLiteral", @@ -62,11 +72,15 @@ "EstimatePortfolioMaintenanceMarginPosition", "EstimatePortfolioMaintenanceMarginRequest", "EstimatePortfolioMaintenanceMarginResponse", + "FundingEstimate", "FundingPaymentDetail", "GenerateMarginFcmApiKeyRequest", "GenerateMarginFcmApiKeyResponse", "GetActiveMarginObligationsResponse", + "GetFundingEstimateByAssetClassResponse", "GetGuarantyFundBalanceResponse", + "GetMaintenanceMarginMetadataResponse", + "GetMarginFundingScheduleResponse", "GetMarginReportsResponse", "GetMarginSubtraderGroupsResponse", "GetMemberFundingPaymentsResponse", @@ -81,10 +95,14 @@ "GetSettlementPricesResponse", "ListMarginFcmApiKeysResponse", "MaintenanceMarginDetail", + "MaintenanceMarginLiquidationConfig", + "MaintenanceMarginMatrices", + "MaintenanceMarginMatrix", "MarginFcmApiKey", "MarginReport", "MarginReportTypeLiteral", "MarginSubtraderGroup", + "MarketFundingEstimate", "MarketSettlementEstimate", "MemberFundingPayment", "ObligationEntry", diff --git a/kalshi/perps/klear/models/margin.py b/kalshi/perps/klear/models/margin.py index 5d72b6b2..a758c101 100644 --- a/kalshi/perps/klear/models/margin.py +++ b/kalshi/perps/klear/models/margin.py @@ -618,6 +618,99 @@ class ListMarginFcmApiKeysResponse(BaseModel): model_config = {"extra": "allow"} +class MaintenanceMarginMatrix(BaseModel): + """One scenario-return matrix used in maintenance-margin metadata.""" + + market_tickers: list[str] + returns: list[list[float]] + + model_config = {"extra": "allow"} + + +class MaintenanceMarginMatrices(BaseModel): + """HVaR / APC / AUG / funding matrices for an asset class.""" + + hvar: MaintenanceMarginMatrix | None = None + apc: MaintenanceMarginMatrix | None = None + aug: MaintenanceMarginMatrix | None = None + funding: MaintenanceMarginMatrix | None = None + + model_config = {"extra": "allow"} + + +class MaintenanceMarginLiquidationConfig(BaseModel): + """Liquidation-margin parameters for one market.""" + + market_ticker: str + market_impact_volatility: float + market_impact_coefficient: float + market_impact_exponent: float + market_impact_forecasted_volume: float + spread_rate: float + + model_config = {"extra": "allow"} + + +class GetMaintenanceMarginMetadataResponse(BaseModel): + """Response from GET /margin/estimate_maintenance_margin/metadata.""" + + asset_class: AssetClassLiteral + base_tail_percentile: float + funding_tail_percentile: float + matrices: MaintenanceMarginMatrices + liquidation_configs: list[MaintenanceMarginLiquidationConfig] + subgroups: list[list[str]] + + model_config = {"extra": "allow"} + + +class MarketFundingEstimate(BaseModel): + """Per-market funding estimate on a subtrader or group breakdown.""" + + quantity_centicount: int + funding_amount_centicents: int + + model_config = {"extra": "allow"} + + +class FundingEstimate(BaseModel): + """Funding amount plus optional per-market position breakdown.""" + + funding_amount_centicents: int + positions: dict[str, MarketFundingEstimate] | None = None + + model_config = {"extra": "allow"} + + +class AssetClassFundingEstimate(BaseModel): + """Next-funding estimate for one asset class, with optional breakdowns.""" + + user_breakdown: FundingEstimate + omitted_subtrader_count: int + omitted_group_count: int + next_funding_time: AwareDatetime + subtrader_breakdowns: dict[str, FundingEstimate] | None = None + group_breakdowns: dict[str, FundingEstimate] | None = None + + model_config = {"extra": "allow"} + + +class GetFundingEstimateByAssetClassResponse(BaseModel): + """Response from GET /margin/funding_estimate_by_asset_class.""" + + estimates: dict[str, AssetClassFundingEstimate] + + model_config = {"extra": "allow"} + + +class GetMarginFundingScheduleResponse(BaseModel): + """Response from GET /margin/funding_schedule.""" + + schedule: str + + model_config = {"extra": "allow"} + + class MemberFundingPayment(FundingPaymentDetail): """Spec ``MemberFundingPayment`` — obligation funding row plus settlement time.""" diff --git a/kalshi/perps/klear/resources/margin.py b/kalshi/perps/klear/resources/margin.py index 0246eae6..c1d49246 100644 --- a/kalshi/perps/klear/resources/margin.py +++ b/kalshi/perps/klear/resources/margin.py @@ -52,7 +52,10 @@ GenerateMarginFcmApiKeyRequest, GenerateMarginFcmApiKeyResponse, GetActiveMarginObligationsResponse, + GetFundingEstimateByAssetClassResponse, GetGuarantyFundBalanceResponse, + GetMaintenanceMarginMetadataResponse, + GetMarginFundingScheduleResponse, GetMarginReportsResponse, GetMarginSubtraderGroupsResponse, GetSettlementBalanceResponse, @@ -109,9 +112,7 @@ def _validate_date_range(start_date: str, end_date: str) -> None: f"(parsed as {parsed.isoformat()})" ) if end < start: - raise ValueError( - f"end_date ({end_date}) must be on or after start_date ({start_date})" - ) + raise ValueError(f"end_date ({end_date}) must be on or after start_date ({start_date})") class MarginResource(KlearSyncResource): @@ -314,9 +315,7 @@ def settlement_estimate_by_asset_class( Next-settlement estimates keyed by asset class. """ - data = self._get( - "/margin/settlement_estimate_by_asset_class", extra_headers=extra_headers - ) + data = self._get("/margin/settlement_estimate_by_asset_class", extra_headers=extra_headers) return GetSettlementEstimateByAssetClassResponse.model_validate(data) def settlement_balance( @@ -518,9 +517,7 @@ def list_fcm_api_keys( ) -> 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 - ) + data = self._get("/fcm/margin/api_keys", params=params, extra_headers=extra_headers) return ListMarginFcmApiKeysResponse.model_validate(data) def create_fcm_api_key( @@ -568,9 +565,7 @@ def generate_fcm_api_key( "generate_fcm_api_key() requires `name` and `fcm_subtrader_id` " "(or pass `request=...`)" ) - request = GenerateMarginFcmApiKeyRequest( - name=name, fcm_subtrader_id=fcm_subtrader_id - ) + 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"), @@ -606,8 +601,7 @@ def create_subtrader_group( if request is None: if subtrader_ids is None: raise TypeError( - "create_subtrader_group() requires `subtrader_ids` " - "(or pass `request=...`)" + "create_subtrader_group() requires `subtrader_ids` (or pass `request=...`)" ) request = CreateMarginSubtraderGroupRequest(subtrader_ids=subtrader_ids) data = self._post( @@ -630,8 +624,7 @@ def update_subtrader_group( if request is None: if subtrader_ids is None: raise TypeError( - "update_subtrader_group() requires `subtrader_ids` " - "(or pass `request=...`)" + "update_subtrader_group() requires `subtrader_ids` (or pass `request=...`)" ) request = UpdateMarginSubtraderGroupRequest(subtrader_ids=subtrader_ids) self._put( @@ -649,6 +642,40 @@ def delete_subtrader_group( extra_headers=extra_headers, ) + def estimate_maintenance_margin_metadata( + self, + *, + asset_class: AssetClassLiteral, + date: datetime.date, + extra_headers: dict[str, str] | None = None, + ) -> GetMaintenanceMarginMetadataResponse: + """``GET /margin/estimate_maintenance_margin/metadata``.""" + params = _params(asset_class=asset_class, date=date.isoformat()) + data = self._get( + "/margin/estimate_maintenance_margin/metadata", + params=params, + extra_headers=extra_headers, + ) + return GetMaintenanceMarginMetadataResponse.model_validate(data) + + def funding_estimate_by_asset_class( + self, *, extra_headers: dict[str, str] | None = None + ) -> GetFundingEstimateByAssetClassResponse: + """``GET /margin/funding_estimate_by_asset_class``.""" + data = self._get("/margin/funding_estimate_by_asset_class", extra_headers=extra_headers) + return GetFundingEstimateByAssetClassResponse.model_validate(data) + + def funding_schedule( + self, + *, + asset_class: AssetClassLiteral, + extra_headers: dict[str, str] | None = None, + ) -> GetMarginFundingScheduleResponse: + """``GET /margin/funding_schedule`` — cron expression in US Eastern Time.""" + params = _params(asset_class=asset_class) + data = self._get("/margin/funding_schedule", params=params, extra_headers=extra_headers) + return GetMarginFundingScheduleResponse.model_validate(data) + class AsyncMarginResource(KlearAsyncResource): """Async Klear (SCM) margin API — obligations, estimates, balances, groups.""" @@ -1036,9 +1063,7 @@ async def list_fcm_api_keys( ) -> 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 - ) + 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( @@ -1086,9 +1111,7 @@ async def generate_fcm_api_key( "generate_fcm_api_key() requires `name` and `fcm_subtrader_id` " "(or pass `request=...`)" ) - request = GenerateMarginFcmApiKeyRequest( - name=name, fcm_subtrader_id=fcm_subtrader_id - ) + 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"), @@ -1124,8 +1147,7 @@ async def create_subtrader_group( if request is None: if subtrader_ids is None: raise TypeError( - "create_subtrader_group() requires `subtrader_ids` " - "(or pass `request=...`)" + "create_subtrader_group() requires `subtrader_ids` (or pass `request=...`)" ) request = CreateMarginSubtraderGroupRequest(subtrader_ids=subtrader_ids) data = await self._post( @@ -1148,8 +1170,7 @@ async def update_subtrader_group( if request is None: if subtrader_ids is None: raise TypeError( - "update_subtrader_group() requires `subtrader_ids` " - "(or pass `request=...`)" + "update_subtrader_group() requires `subtrader_ids` (or pass `request=...`)" ) request = UpdateMarginSubtraderGroupRequest(subtrader_ids=subtrader_ids) await self._put( @@ -1166,3 +1187,41 @@ async def delete_subtrader_group( f"/fcm/margin/subtrader_groups/{_seg(group_id, name='group_id')}", extra_headers=extra_headers, ) + + async def estimate_maintenance_margin_metadata( + self, + *, + asset_class: AssetClassLiteral, + date: datetime.date, + extra_headers: dict[str, str] | None = None, + ) -> GetMaintenanceMarginMetadataResponse: + """Async :meth:`MarginResource.estimate_maintenance_margin_metadata`.""" + params = _params(asset_class=asset_class, date=date.isoformat()) + data = await self._get( + "/margin/estimate_maintenance_margin/metadata", + params=params, + extra_headers=extra_headers, + ) + return GetMaintenanceMarginMetadataResponse.model_validate(data) + + async def funding_estimate_by_asset_class( + self, *, extra_headers: dict[str, str] | None = None + ) -> GetFundingEstimateByAssetClassResponse: + """Async :meth:`MarginResource.funding_estimate_by_asset_class`.""" + data = await self._get( + "/margin/funding_estimate_by_asset_class", extra_headers=extra_headers + ) + return GetFundingEstimateByAssetClassResponse.model_validate(data) + + async def funding_schedule( + self, + *, + asset_class: AssetClassLiteral, + extra_headers: dict[str, str] | None = None, + ) -> GetMarginFundingScheduleResponse: + """Async :meth:`MarginResource.funding_schedule`.""" + params = _params(asset_class=asset_class) + data = await self._get( + "/margin/funding_schedule", params=params, extra_headers=extra_headers + ) + return GetMarginFundingScheduleResponse.model_validate(data) diff --git a/kalshi/perps/models/markets.py b/kalshi/perps/models/markets.py index d7d15de3..6965a6bd 100644 --- a/kalshi/perps/models/markets.py +++ b/kalshi/perps/models/markets.py @@ -79,6 +79,8 @@ class MarginMarket(BaseModel): title: str status: MarginMarketStatusLiteral contract_size: DollarDecimal + # Spec 3.30.0: underlying units per contract-size unit (required string). + underlying_multiplier: str tick_size: DollarDecimal fractional_trading_enabled: bool # Spec requires the key; value is null for markets that trade 24/7 @@ -134,6 +136,7 @@ class MarginMarket(BaseModel): reference_price: TickerPrice | None = None # Omitted when the market has no assigned class. New classes may appear over time. asset_class: str | None = None + product_metadata: dict[str, object] | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/perps/ws/channels.py b/kalshi/perps/ws/channels.py index 62e792e6..c23b6d4e 100644 --- a/kalshi/perps/ws/channels.py +++ b/kalshi/perps/ws/channels.py @@ -44,6 +44,7 @@ logger = logging.getLogger("kalshi.perps.ws") + class PerpsSubscription: """A single perps channel subscription with durable identity.""" @@ -150,9 +151,7 @@ async def _wait_for_response( op=op, # type: ignore[arg-type] ) try: - raw = await asyncio.wait_for( - self._connection.recv(), timeout=remaining - ) + raw = await asyncio.wait_for(self._connection.recv(), timeout=remaining) except ConnectionClosed as e: raise KalshiConnectionError( f"Connection closed while awaiting response to command {msg_id}" @@ -198,9 +197,9 @@ def _maybe_stash(self, raw: str, data: dict[str, Any]) -> None: sid = data.get("sid") if not isinstance(sid, int): logger.debug( - "Stash mode: dropping non-matching frame with non-int sid: " - "type=%s sid=%r", - data.get("type"), sid, + "Stash mode: dropping non-matching frame with non-int sid: type=%s sid=%r", + data.get("type"), + sid, ) return bucket = self._stash.get(sid) @@ -213,7 +212,8 @@ def _maybe_stash(self, raw: str, data: dict[str, Any]) -> None: "Stash for sid %d is full (%d frames); oldest frame will be " "evicted. Resubscribe may be stalled or the channel is too " "high-volume for the configured stash_maxlen.", - sid, self._stash_maxlen, + sid, + self._stash_maxlen, ) bucket.append(raw) @@ -246,14 +246,15 @@ async def subscribe( id=msg_id, params=SubscribeParams.model_validate(sub.to_subscribe_params()), ) - await self._connection.send( - cmd.model_dump(exclude_none=True, by_alias=True, mode="json") - ) + await self._connection.send(cmd.model_dump(exclude_none=True, by_alias=True, mode="json")) # An error ack is raised inside _wait_for_response (centralized), so a # returned frame here is always a success. data = await self._wait_for_response( - msg_id, channel=channel, client_id=client_id, op="subscribe", + msg_id, + channel=channel, + client_id=client_id, + op="subscribe", ) server_sid = data.get("msg", {}).get("sid") if server_sid is not None: @@ -263,7 +264,9 @@ async def subscribe( self._subscriptions[client_id] = sub logger.debug( "Subscribed to %s: client_id=%d, server_sid=%s", - channel, client_id, server_sid, + channel, + client_id, + server_sid, ) return sub @@ -274,22 +277,19 @@ async def unsubscribe(self, client_id: int) -> None: return msg_id = self._get_msg_id() - cmd = UnsubscribeCommand( - id=msg_id, params=UnsubscribeParams(sids=[sub.server_sid]) - ) - await self._connection.send( - cmd.model_dump(exclude_none=True, by_alias=True, mode="json") - ) + cmd = UnsubscribeCommand(id=msg_id, params=UnsubscribeParams(sids=[sub.server_sid])) + await self._connection.send(cmd.model_dump(exclude_none=True, by_alias=True, mode="json")) await self._wait_for_response( - msg_id, channel=sub.channel, client_id=client_id, op="unsubscribe", + msg_id, + channel=sub.channel, + client_id=client_id, + op="unsubscribe", ) await sub.queue.put_sentinel() self._sid_to_client.pop(sub.server_sid, None) del self._subscriptions[client_id] - logger.debug( - "Unsubscribed client_id=%d (server_sid=%d)", client_id, sub.server_sid - ) + logger.debug("Unsubscribed client_id=%d (server_sid=%d)", client_id, sub.server_sid) @staticmethod def _apply_market_delta( @@ -320,10 +320,11 @@ async def update_subscription( market_tickers: builtins.list[str] | None = None, send_initial_snapshot: bool | None = None, ) -> None: - """Add or remove markets from an existing subscription (array-``sids`` form). + """Add or remove markets, or request a snapshot, on an existing subscription. Builds ``updateSubscriptionCommandPayload`` with ``params.sids`` as a - single-element array (spec ``maxItems: 1``). + single-element array (spec ``maxItems: 1``). ``get_snapshot`` does not + mutate the persisted market set. """ sub = self._subscriptions.get(client_id) if not sub or sub.server_sid is None: @@ -344,17 +345,16 @@ async def update_subscription( send_initial_snapshot=send_initial_snapshot, ), ) - await self._connection.send( - cmd.model_dump(exclude_none=True, by_alias=True, mode="json") - ) + await self._connection.send(cmd.model_dump(exclude_none=True, by_alias=True, mode="json")) await self._wait_for_response( - msg_id, channel=sub.channel, client_id=client_id, + msg_id, + channel=sub.channel, + client_id=client_id, op="update_subscription", ) - self._apply_market_delta(sub, action, market_tickers) - logger.debug( - "Updated subscription (sids) client_id=%d action=%s", client_id, action - ) + if action != UpdateSubscriptionAction.GET_SNAPSHOT: + self._apply_market_delta(sub, action, market_tickers) + logger.debug("Updated subscription (sids) client_id=%d action=%s", client_id, action) async def update_subscription_single_sid( self, @@ -388,17 +388,19 @@ async def update_subscription_single_sid( send_initial_snapshot=send_initial_snapshot, ), ) - await self._connection.send( - cmd.model_dump(exclude_none=True, by_alias=True, mode="json") - ) + await self._connection.send(cmd.model_dump(exclude_none=True, by_alias=True, mode="json")) await self._wait_for_response( - msg_id, channel=sub.channel, client_id=client_id, + msg_id, + channel=sub.channel, + client_id=client_id, op="update_subscription", ) - self._apply_market_delta(sub, action, market_tickers) + if action != UpdateSubscriptionAction.GET_SNAPSHOT: + self._apply_market_delta(sub, action, market_tickers) logger.debug( "Updated subscription (single sid) client_id=%d action=%s", - client_id, action, + client_id, + action, ) async def list_subscriptions(self) -> builtins.list[SubscriptionEntry]: @@ -411,9 +413,7 @@ async def list_subscriptions(self) -> builtins.list[SubscriptionEntry]: """ msg_id = self._get_msg_id() cmd = ListSubscriptionsCommand(id=msg_id) - await self._connection.send( - cmd.model_dump(exclude_none=True, by_alias=True, mode="json") - ) + await self._connection.send(cmd.model_dump(exclude_none=True, by_alias=True, mode="json")) data = await self._wait_for_response(msg_id, op="list_subscriptions") raw_entries = data.get("msg") if not isinstance(raw_entries, list): @@ -448,16 +448,16 @@ async def resubscribe_all(self) -> None: params=SubscribeParams.model_validate(params), ) await self._connection.send( - cmd.model_dump( - exclude_none=True, by_alias=True, mode="json" - ) + cmd.model_dump(exclude_none=True, by_alias=True, mode="json") ) # An error ack raises inside _wait_for_response and is caught # by the per-sub ``except`` below (sentinel + drop). data = await self._wait_for_response( - msg_id, channel=sub.channel, - client_id=client_id, op="subscribe", + msg_id, + channel=sub.channel, + client_id=client_id, + op="subscribe", ) new_sid = data.get("msg", {}).get("sid") if new_sid is not None: @@ -465,12 +465,16 @@ async def resubscribe_all(self) -> None: self._sid_to_client[new_sid] = client_id logger.debug( "Resubscribed %s: client_id=%d, new_sid=%s", - sub.channel, client_id, new_sid, + sub.channel, + client_id, + new_sid, ) except Exception: logger.warning( "Resubscribe failed for client_id=%d channel=%s", - client_id, sub.channel, exc_info=True, + client_id, + sub.channel, + exc_info=True, ) await sub.queue.put_sentinel() self._subscriptions.pop(client_id, None) diff --git a/kalshi/perps/ws/models/control.py b/kalshi/perps/ws/models/control.py index f10c65fd..2cd35ae2 100644 --- a/kalshi/perps/ws/models/control.py +++ b/kalshi/perps/ws/models/control.py @@ -54,10 +54,15 @@ class PerpsChannel(StrEnum): class UpdateSubscriptionAction(StrEnum): - """Spec ``updateSubscriptionCommandPayload.params.action.enum`` — add or remove markets.""" + """Spec ``updateSubscriptionCommandPayload.params.action.enum``. + + ``get_snapshot`` requests a fresh orderbook snapshot without changing + the subscription. + """ ADD_MARKETS = "add_markets" DELETE_MARKETS = "delete_markets" + GET_SNAPSHOT = "get_snapshot" class PerpsBookSide(StrEnum): @@ -214,6 +219,9 @@ class OkMsg(BaseModel): model_config = {"extra": "allow", "populate_by_name": True} market_tickers: builtins.list[str] | None = None + # Subscribed-indices / subscribed-underlyings update acks. + index_ids: builtins.list[str] | None = None + underlying_tickers: builtins.list[str] | None = None class OkResponse(BaseModel): @@ -247,7 +255,7 @@ class ListSubscriptionsResponse(BaseModel): model_config = {"extra": "allow", "populate_by_name": True} - id: int + id: int | None = None type: Literal["ok"] = "ok" msg: builtins.list[SubscriptionEntry] diff --git a/kalshi/perps/ws/models/orderbook.py b/kalshi/perps/ws/models/orderbook.py index 126778b1..4229be5a 100644 --- a/kalshi/perps/ws/models/orderbook.py +++ b/kalshi/perps/ws/models/orderbook.py @@ -117,6 +117,8 @@ class MarginOrderbookSnapshotMessage(BaseModel): type: Literal["orderbook_snapshot"] = "orderbook_snapshot" sid: int seq: int + # Present when the snapshot is a reply to a get_snapshot command with an ID. + id: int | None = None msg: MarginOrderbookSnapshotPayload model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/resources/communications.py b/kalshi/resources/communications.py index e68333d0..9b85f913 100644 --- a/kalshi/resources/communications.py +++ b/kalshi/resources/communications.py @@ -145,6 +145,7 @@ def _build_create_rfq_body( replace_existing: bool | None, subtrader_id: str | None, subaccount: int | None, + target_cost_excludes_fees: bool | None, ) -> dict[str, Any]: _check_request_exclusive( request, @@ -155,6 +156,7 @@ def _build_create_rfq_body( replace_existing=replace_existing, subtrader_id=subtrader_id, subaccount=subaccount, + target_cost_excludes_fees=target_cost_excludes_fees, ) if request is None: if market_ticker is None or rest_remainder is None: @@ -169,6 +171,7 @@ def _build_create_rfq_body( replace_existing=replace_existing, subtrader_id=subtrader_id, subaccount=subaccount, + target_cost_excludes_fees=target_cost_excludes_fees, ) return request.model_dump(exclude_none=True, by_alias=True, mode="json") @@ -400,9 +403,7 @@ def list_all( extra_headers=extra_headers, ) - def get( - self, rfq_id: str, *, extra_headers: dict[str, str] | None = None - ) -> GetRFQResponse: + def get(self, rfq_id: str, *, extra_headers: dict[str, str] | None = None) -> GetRFQResponse: self._require_auth() data = self._get( f"/communications/rfqs/{_seg(rfq_id, name='rfq_id')}", extra_headers=extra_headers @@ -424,6 +425,7 @@ def create( replace_existing: bool | None = ..., subtrader_id: str | None = ..., subaccount: int | None = ..., + target_cost_excludes_fees: bool | None = ..., extra_headers: dict[str, str] | None = None, ) -> CreateRFQResponse: ... def create( @@ -437,6 +439,7 @@ def create( replace_existing: bool | None = None, subtrader_id: str | None = None, subaccount: int | None = None, + target_cost_excludes_fees: bool | None = None, extra_headers: dict[str, str] | None = None, ) -> CreateRFQResponse: self._require_auth() @@ -449,6 +452,7 @@ def create( replace_existing=replace_existing, subtrader_id=subtrader_id, subaccount=subaccount, + target_cost_excludes_fees=target_cost_excludes_fees, ) data = self._post("/communications/rfqs", json=body, extra_headers=extra_headers) return CreateRFQResponse.model_validate(data) @@ -1006,6 +1010,7 @@ def create_rfq( replace_existing: bool | None = None, subtrader_id: str | None = None, subaccount: int | None = None, + target_cost_excludes_fees: bool | None = None, extra_headers: dict[str, str] | None = None, ) -> CreateRFQResponse: """.. deprecated:: 3.0.0 Use :meth:`client.communications.rfqs.create` instead.""" @@ -1018,6 +1023,7 @@ def create_rfq( replace_existing=replace_existing, subtrader_id=subtrader_id, subaccount=subaccount, + target_cost_excludes_fees=target_cost_excludes_fees, extra_headers=extra_headers, ) @@ -1243,6 +1249,7 @@ async def create( replace_existing: bool | None = ..., subtrader_id: str | None = ..., subaccount: int | None = ..., + target_cost_excludes_fees: bool | None = ..., extra_headers: dict[str, str] | None = None, ) -> CreateRFQResponse: ... async def create( @@ -1256,6 +1263,7 @@ async def create( replace_existing: bool | None = None, subtrader_id: str | None = None, subaccount: int | None = None, + target_cost_excludes_fees: bool | None = None, extra_headers: dict[str, str] | None = None, ) -> CreateRFQResponse: self._require_auth() @@ -1268,6 +1276,7 @@ async def create( replace_existing=replace_existing, subtrader_id=subtrader_id, subaccount=subaccount, + target_cost_excludes_fees=target_cost_excludes_fees, ) data = await self._post("/communications/rfqs", json=body, extra_headers=extra_headers) return CreateRFQResponse.model_validate(data) @@ -1419,9 +1428,7 @@ async def create( data = await self._post("/communications/quotes", json=body, extra_headers=extra_headers) return CreateQuoteResponse.model_validate(data) - async def delete( - self, quote_id: str, *, extra_headers: dict[str, str] | None = None - ) -> None: + async def delete(self, quote_id: str, *, extra_headers: dict[str, str] | None = None) -> None: self._require_auth() await self._delete( f"/communications/quotes/{_seg(quote_id, name='quote_id')}", extra_headers=extra_headers @@ -1459,9 +1466,7 @@ async def accept( extra_headers=extra_headers, ) - async def confirm( - self, quote_id: str, *, extra_headers: dict[str, str] | None = None - ) -> None: + async def confirm(self, quote_id: str, *, extra_headers: dict[str, str] | None = None) -> None: self._require_auth() # json={} forces Content-Type: application/json — demo rejects empty PUTs. await self._put( @@ -1821,6 +1826,7 @@ async def create_rfq( replace_existing: bool | None = None, subtrader_id: str | None = None, subaccount: int | None = None, + target_cost_excludes_fees: bool | None = None, extra_headers: dict[str, str] | None = None, ) -> CreateRFQResponse: """.. deprecated:: 3.0.0 Use :meth:`client.communications.rfqs.create` instead.""" @@ -1833,6 +1839,7 @@ async def create_rfq( replace_existing=replace_existing, subtrader_id=subtrader_id, subaccount=subaccount, + target_cost_excludes_fees=target_cost_excludes_fees, extra_headers=extra_headers, ) diff --git a/kalshi/resources/fcm.py b/kalshi/resources/fcm.py index 18fa0c68..9029d7b8 100644 --- a/kalshi/resources/fcm.py +++ b/kalshi/resources/fcm.py @@ -1,9 +1,8 @@ """FCM resource — Futures Commission Merchant endpoints. -These endpoints filter orders/positions by ``subtrader_id`` and are only -usable by FCM-member accounts. They REUSE the existing Order and -PositionsResponse shapes — the endpoints differ only in the subtrader -filter, not in response shape. +Orders/positions filter by ``subtrader_id`` and reuse the existing Order and +PositionsResponse shapes. Subtrader admin routes (list/create, blocked +categories, event-contract daily cap) live on ``/fcm/subtraders*``. Non-FCM accounts receive 401/403 on these routes. Demo does service them (per Path B audit 2026-04-18) but typically returns empty lists for an @@ -14,18 +13,31 @@ import builtins from collections.abc import AsyncIterator, Iterator -from typing import Any +from decimal import Decimal +from typing import Any, overload from kalshi.models.common import Page +from kalshi.models.fcm import ( + CreateFCMSubtraderRequest, + CreateFCMSubtraderResponse, + GetFCMEventContractDailyCapResponse, + GetFCMSubtraderBlockedCategoriesResponse, + ListFCMSubtradersResponse, + UpdateFCMEventContractDailyCapRequest, + UpdateFCMSubtraderBlockedCategoriesRequest, + UpdateFCMSubtraderBlockedCategoriesResponse, +) from kalshi.models.orders import Order, OrderStatusLiteral from kalshi.models.portfolio import MarketPosition, PositionsResponse, SettlementStatusLiteral from kalshi.resources._base import ( AsyncResource, SyncResource, + _check_request_exclusive, _params, _validate_limit, _validate_max_pages, ) +from kalshi.types import DollarDecimal # Shared param builders (issue #46). @@ -95,6 +107,61 @@ def _fcm_positions_params( ) +def _build_create_fcm_subtrader_body( + request: CreateFCMSubtraderRequest | None, + *, + subtrader_suffix: str | None, +) -> dict[str, object]: + _check_request_exclusive(request, subtrader_suffix=subtrader_suffix) + if request is None: + if subtrader_suffix is None: + raise TypeError( + "create_subtrader() requires `subtrader_suffix` (or pass `request=...`)" + ) + request = CreateFCMSubtraderRequest(subtrader_suffix=subtrader_suffix) + return request.model_dump(exclude_none=True, by_alias=True, mode="json") + + +def _build_update_blocked_categories_body( + request: UpdateFCMSubtraderBlockedCategoriesRequest | None, + *, + subtrader_id: str | None, + category: str | None, + blocked: bool | None, +) -> dict[str, object]: + _check_request_exclusive(request, subtrader_id=subtrader_id, category=category, blocked=blocked) + if request is None: + if subtrader_id is None or category is None or blocked is None: + raise TypeError( + "update_blocked_categories() requires `subtrader_id`, `category`, " + "and `blocked` (or pass `request=...`)" + ) + request = UpdateFCMSubtraderBlockedCategoriesRequest( + subtrader_id=subtrader_id, category=category, blocked=blocked + ) + return request.model_dump(exclude_none=True, by_alias=True, mode="json") + + +def _build_update_daily_cap_body( + request: UpdateFCMEventContractDailyCapRequest | None, + *, + subtrader_id: str | None, + limit: DollarDecimal | Decimal | str | float | int | None, +) -> dict[str, object]: + _check_request_exclusive(request, subtrader_id=subtrader_id, limit=limit) + if request is None: + if subtrader_id is None or limit is None: + raise TypeError( + "update_event_contract_daily_cap() requires `subtrader_id` and " + "`limit` (or pass `request=...`)" + ) + request = UpdateFCMEventContractDailyCapRequest( + subtrader_id=subtrader_id, + limit=limit, # type: ignore[arg-type] + ) + return request.model_dump(exclude_none=True, by_alias=True, mode="json") + + class FcmResource(SyncResource): """Sync FCM API — orders and positions filtered by subtrader_id.""" @@ -227,6 +294,145 @@ def positions_all( extra_headers=extra_headers, ) + def list_subtraders( + self, *, extra_headers: dict[str, str] | None = None + ) -> ListFCMSubtradersResponse: + """``GET /fcm/subtraders`` — list subtraders owned by the authenticated FCM.""" + self._require_auth() + data = self._get("/fcm/subtraders", extra_headers=extra_headers) + return ListFCMSubtradersResponse.model_validate(data) + + @overload + def create_subtrader( + self, + *, + request: CreateFCMSubtraderRequest, + extra_headers: dict[str, str] | None = None, + ) -> CreateFCMSubtraderResponse: ... + @overload + def create_subtrader( + self, + *, + subtrader_suffix: str, + extra_headers: dict[str, str] | None = None, + ) -> CreateFCMSubtraderResponse: ... + def create_subtrader( + self, + *, + request: CreateFCMSubtraderRequest | None = None, + subtrader_suffix: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> CreateFCMSubtraderResponse: + """``POST /fcm/subtraders``. Not retried.""" + self._require_auth() + body = _build_create_fcm_subtrader_body(request, subtrader_suffix=subtrader_suffix) + data = self._post("/fcm/subtraders", json=body, extra_headers=extra_headers) + return CreateFCMSubtraderResponse.model_validate(data) + + def blocked_categories( + self, *, subtrader_id: str, extra_headers: dict[str, str] | None = None + ) -> GetFCMSubtraderBlockedCategoriesResponse: + """``GET /fcm/subtraders/blocked_categories``.""" + self._require_auth() + params = _params(subtrader_id=subtrader_id) + data = self._get( + "/fcm/subtraders/blocked_categories", params=params, extra_headers=extra_headers + ) + return GetFCMSubtraderBlockedCategoriesResponse.model_validate(data) + + @overload + def update_blocked_categories( + self, + *, + request: UpdateFCMSubtraderBlockedCategoriesRequest, + extra_headers: dict[str, str] | None = None, + ) -> UpdateFCMSubtraderBlockedCategoriesResponse: ... + @overload + def update_blocked_categories( + self, + *, + subtrader_id: str, + category: str, + blocked: bool, + extra_headers: dict[str, str] | None = None, + ) -> UpdateFCMSubtraderBlockedCategoriesResponse: ... + def update_blocked_categories( + self, + *, + request: UpdateFCMSubtraderBlockedCategoriesRequest | None = None, + subtrader_id: str | None = None, + category: str | None = None, + blocked: bool | None = None, + extra_headers: dict[str, str] | None = None, + ) -> UpdateFCMSubtraderBlockedCategoriesResponse: + """``PUT /fcm/subtraders/blocked_categories``. Not retried.""" + self._require_auth() + body = _build_update_blocked_categories_body( + request, subtrader_id=subtrader_id, category=category, blocked=blocked + ) + data = self._put( + "/fcm/subtraders/blocked_categories", json=body, extra_headers=extra_headers + ) + assert data is not None + return UpdateFCMSubtraderBlockedCategoriesResponse.model_validate(data) + + def event_contract_daily_cap( + self, + *, + subtrader_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetFCMEventContractDailyCapResponse: + """``GET /fcm/subtraders/event_contract_daily_cap``.""" + self._require_auth() + params = _params(subtrader_id=subtrader_id) + data = self._get( + "/fcm/subtraders/event_contract_daily_cap", + params=params, + extra_headers=extra_headers, + ) + return GetFCMEventContractDailyCapResponse.model_validate(data) + + @overload + def update_event_contract_daily_cap( + self, + *, + request: UpdateFCMEventContractDailyCapRequest, + extra_headers: dict[str, str] | None = None, + ) -> None: ... + @overload + def update_event_contract_daily_cap( + self, + *, + subtrader_id: str, + limit: DollarDecimal | Decimal | str | float | int, + extra_headers: dict[str, str] | None = None, + ) -> None: ... + def update_event_contract_daily_cap( + self, + *, + request: UpdateFCMEventContractDailyCapRequest | None = None, + subtrader_id: str | None = None, + limit: DollarDecimal | Decimal | str | float | int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """``PUT /fcm/subtraders/event_contract_daily_cap``. Not retried.""" + self._require_auth() + body = _build_update_daily_cap_body(request, subtrader_id=subtrader_id, limit=limit) + self._put( + "/fcm/subtraders/event_contract_daily_cap", json=body, extra_headers=extra_headers + ) + + def delete_event_contract_daily_cap( + self, *, subtrader_id: str, extra_headers: dict[str, str] | None = None + ) -> None: + """``DELETE /fcm/subtraders/event_contract_daily_cap``. Not retried.""" + self._require_auth() + self._delete( + "/fcm/subtraders/event_contract_daily_cap", + params=_params(subtrader_id=subtrader_id), + extra_headers=extra_headers, + ) + class AsyncFcmResource(AsyncResource): """Async FCM API.""" @@ -355,3 +561,142 @@ def positions_all( max_pages=max_pages, extra_headers=extra_headers, ) + + async def list_subtraders( + self, *, extra_headers: dict[str, str] | None = None + ) -> ListFCMSubtradersResponse: + """Async :meth:`FcmResource.list_subtraders`.""" + self._require_auth() + data = await self._get("/fcm/subtraders", extra_headers=extra_headers) + return ListFCMSubtradersResponse.model_validate(data) + + @overload + async def create_subtrader( + self, + *, + request: CreateFCMSubtraderRequest, + extra_headers: dict[str, str] | None = None, + ) -> CreateFCMSubtraderResponse: ... + @overload + async def create_subtrader( + self, + *, + subtrader_suffix: str, + extra_headers: dict[str, str] | None = None, + ) -> CreateFCMSubtraderResponse: ... + async def create_subtrader( + self, + *, + request: CreateFCMSubtraderRequest | None = None, + subtrader_suffix: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> CreateFCMSubtraderResponse: + """Async :meth:`FcmResource.create_subtrader`.""" + self._require_auth() + body = _build_create_fcm_subtrader_body(request, subtrader_suffix=subtrader_suffix) + data = await self._post("/fcm/subtraders", json=body, extra_headers=extra_headers) + return CreateFCMSubtraderResponse.model_validate(data) + + async def blocked_categories( + self, *, subtrader_id: str, extra_headers: dict[str, str] | None = None + ) -> GetFCMSubtraderBlockedCategoriesResponse: + """Async :meth:`FcmResource.blocked_categories`.""" + self._require_auth() + params = _params(subtrader_id=subtrader_id) + data = await self._get( + "/fcm/subtraders/blocked_categories", params=params, extra_headers=extra_headers + ) + return GetFCMSubtraderBlockedCategoriesResponse.model_validate(data) + + @overload + async def update_blocked_categories( + self, + *, + request: UpdateFCMSubtraderBlockedCategoriesRequest, + extra_headers: dict[str, str] | None = None, + ) -> UpdateFCMSubtraderBlockedCategoriesResponse: ... + @overload + async def update_blocked_categories( + self, + *, + subtrader_id: str, + category: str, + blocked: bool, + extra_headers: dict[str, str] | None = None, + ) -> UpdateFCMSubtraderBlockedCategoriesResponse: ... + async def update_blocked_categories( + self, + *, + request: UpdateFCMSubtraderBlockedCategoriesRequest | None = None, + subtrader_id: str | None = None, + category: str | None = None, + blocked: bool | None = None, + extra_headers: dict[str, str] | None = None, + ) -> UpdateFCMSubtraderBlockedCategoriesResponse: + """Async :meth:`FcmResource.update_blocked_categories`.""" + self._require_auth() + body = _build_update_blocked_categories_body( + request, subtrader_id=subtrader_id, category=category, blocked=blocked + ) + data = await self._put( + "/fcm/subtraders/blocked_categories", json=body, extra_headers=extra_headers + ) + assert data is not None + return UpdateFCMSubtraderBlockedCategoriesResponse.model_validate(data) + + async def event_contract_daily_cap( + self, + *, + subtrader_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetFCMEventContractDailyCapResponse: + """Async :meth:`FcmResource.event_contract_daily_cap`.""" + self._require_auth() + params = _params(subtrader_id=subtrader_id) + data = await self._get( + "/fcm/subtraders/event_contract_daily_cap", + params=params, + extra_headers=extra_headers, + ) + return GetFCMEventContractDailyCapResponse.model_validate(data) + + @overload + async def update_event_contract_daily_cap( + self, + *, + request: UpdateFCMEventContractDailyCapRequest, + extra_headers: dict[str, str] | None = None, + ) -> None: ... + @overload + async def update_event_contract_daily_cap( + self, + *, + subtrader_id: str, + limit: DollarDecimal | Decimal | str | float | int, + extra_headers: dict[str, str] | None = None, + ) -> None: ... + async def update_event_contract_daily_cap( + self, + *, + request: UpdateFCMEventContractDailyCapRequest | None = None, + subtrader_id: str | None = None, + limit: DollarDecimal | Decimal | str | float | int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """Async :meth:`FcmResource.update_event_contract_daily_cap`.""" + self._require_auth() + body = _build_update_daily_cap_body(request, subtrader_id=subtrader_id, limit=limit) + await self._put( + "/fcm/subtraders/event_contract_daily_cap", json=body, extra_headers=extra_headers + ) + + async def delete_event_contract_daily_cap( + self, *, subtrader_id: str, extra_headers: dict[str, str] | None = None + ) -> None: + """Async :meth:`FcmResource.delete_event_contract_daily_cap`.""" + self._require_auth() + await self._delete( + "/fcm/subtraders/event_contract_daily_cap", + params=_params(subtrader_id=subtrader_id), + extra_headers=extra_headers, + ) diff --git a/kalshi/resources/historical.py b/kalshi/resources/historical.py index ed2bdbfd..6f5cd2b5 100644 --- a/kalshi/resources/historical.py +++ b/kalshi/resources/historical.py @@ -67,10 +67,11 @@ def _historical_fills_or_orders_params( limit: int | None, cursor: str | None, ticker: str | None, + min_ts: int | None, max_ts: int | None, ) -> dict[str, Any]: limit = _validate_limit(limit, hi=1000) - return _params(limit=limit, cursor=cursor, ticker=ticker, max_ts=max_ts) + return _params(limit=limit, cursor=cursor, ticker=ticker, min_ts=min_ts, max_ts=max_ts) def _historical_trades_params( @@ -210,6 +211,7 @@ def fills( limit: int | None = None, cursor: str | None = None, ticker: str | None = None, + min_ts: int | None = None, max_ts: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Fill]: @@ -218,6 +220,7 @@ def fills( limit=limit, cursor=cursor, ticker=ticker, + min_ts=min_ts, max_ts=max_ts, ) return self._list( @@ -229,6 +232,7 @@ def fills_all( *, limit: int | None = None, ticker: str | None = None, + min_ts: int | None = None, max_ts: int | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, @@ -239,6 +243,7 @@ def fills_all( limit=limit, cursor=None, ticker=ticker, + min_ts=min_ts, max_ts=max_ts, ) return self._list_all( @@ -256,6 +261,7 @@ def orders( limit: int | None = None, cursor: str | None = None, ticker: str | None = None, + min_ts: int | None = None, max_ts: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Order]: @@ -264,6 +270,7 @@ def orders( limit=limit, cursor=cursor, ticker=ticker, + min_ts=min_ts, max_ts=max_ts, ) return self._list( @@ -275,6 +282,7 @@ def orders_all( *, limit: int | None = None, ticker: str | None = None, + min_ts: int | None = None, max_ts: int | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, @@ -285,6 +293,7 @@ def orders_all( limit=limit, cursor=None, ticker=ticker, + min_ts=min_ts, max_ts=max_ts, ) return self._list_all( @@ -505,6 +514,7 @@ async def fills( limit: int | None = None, cursor: str | None = None, ticker: str | None = None, + min_ts: int | None = None, max_ts: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Fill]: @@ -513,6 +523,7 @@ async def fills( limit=limit, cursor=cursor, ticker=ticker, + min_ts=min_ts, max_ts=max_ts, ) return await self._list( @@ -524,6 +535,7 @@ def fills_all( *, limit: int | None = None, ticker: str | None = None, + min_ts: int | None = None, max_ts: int | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, @@ -534,6 +546,7 @@ def fills_all( limit=limit, cursor=None, ticker=ticker, + min_ts=min_ts, max_ts=max_ts, ) return self._list_all( @@ -551,6 +564,7 @@ async def orders( limit: int | None = None, cursor: str | None = None, ticker: str | None = None, + min_ts: int | None = None, max_ts: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Order]: @@ -559,6 +573,7 @@ async def orders( limit=limit, cursor=cursor, ticker=ticker, + min_ts=min_ts, max_ts=max_ts, ) return await self._list( @@ -570,6 +585,7 @@ def orders_all( *, limit: int | None = None, ticker: str | None = None, + min_ts: int | None = None, max_ts: int | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, @@ -580,6 +596,7 @@ def orders_all( limit=limit, cursor=None, ticker=ticker, + min_ts=min_ts, max_ts=max_ts, ) return self._list_all( @@ -668,9 +685,7 @@ async def positions( event_ticker=event_ticker, subaccount=subaccount, ) - data = await self._get( - "/historical/positions", params=params, extra_headers=extra_headers - ) + data = await self._get("/historical/positions", params=params, extra_headers=extra_headers) return PositionsResponse.model_validate(data) def positions_all( diff --git a/kalshi/ws/channels.py b/kalshi/ws/channels.py index 97d82a02..93555962 100644 --- a/kalshi/ws/channels.py +++ b/kalshi/ws/channels.py @@ -45,17 +45,27 @@ _CHANNEL_PARAMS: dict[str, frozenset[str]] = { "ticker": frozenset({"market_ticker", "market_tickers", "market_id", "market_ids"}), "trade": frozenset({"market_ticker", "market_tickers", "market_id", "market_ids"}), - "orderbook_delta": frozenset({ - "market_ticker", "market_tickers", "market_id", "market_ids", - "send_initial_snapshot", - }), + "orderbook_delta": frozenset( + { + "market_ticker", + "market_tickers", + "market_id", + "market_ids", + "send_initial_snapshot", + } + ), "fill": frozenset(), "market_positions": frozenset(), "user_orders": frozenset(), "order_group_updates": frozenset(), - "market_lifecycle_v2": frozenset({ - "market_ticker", "market_tickers", "market_id", "market_ids", - }), + "market_lifecycle_v2": frozenset( + { + "market_ticker", + "market_tickers", + "market_id", + "market_ids", + } + ), "multivariate_market_lifecycle": frozenset(), "communications": frozenset({"shard_factor", "shard_key"}), # CF Benchmarks index value feed: seeded with index_ids only — market_* @@ -200,9 +210,7 @@ async def _wait_for_response( op=op, # type: ignore[arg-type] ) try: - raw = await asyncio.wait_for( - self._connection.recv(), timeout=remaining - ) + raw = await asyncio.wait_for(self._connection.recv(), timeout=remaining) except ConnectionClosed as e: # F-P-05: surface as KalshiConnectionError instead of raw # websockets exception. The recv loop's reconnect path will @@ -250,9 +258,9 @@ def _maybe_stash(self, raw: str, data: dict[str, Any]) -> None: # protocol bug, not a stash bug. if not isinstance(sid, int): logger.debug( - "Stash mode: dropping non-matching frame with non-int sid: " - "type=%s sid=%r", - data.get("type"), sid, + "Stash mode: dropping non-matching frame with non-int sid: type=%s sid=%r", + data.get("type"), + sid, ) return bucket = self._stash.get(sid) @@ -269,7 +277,8 @@ def _maybe_stash(self, raw: str, data: dict[str, Any]) -> None: "Stash for sid %d is full (%d frames); oldest frame will be " "evicted. Resubscribe may be stalled or the channel is too " "high-volume for the configured stash_maxlen.", - sid, self._stash_maxlen, + sid, + self._stash_maxlen, ) bucket.append(raw) @@ -295,9 +304,7 @@ async def subscribe( ) sub_params = params or {} - sub = Subscription( - client_id=client_id, channel=channel, params=sub_params, queue=queue - ) + sub = Subscription(client_id=client_id, channel=channel, params=sub_params, queue=queue) # Send subscribe command msg_id = self._get_msg_id() @@ -306,7 +313,10 @@ async def subscribe( # Read frames until we get our subscribe ack (by matching id) data = await self._wait_for_response( - msg_id, channel=channel, client_id=client_id, op="subscribe", + msg_id, + channel=channel, + client_id=client_id, + op="subscribe", ) if data.get("type") == "error": error_msg = data.get("msg", {}) @@ -343,7 +353,10 @@ async def unsubscribe(self, client_id: int) -> None: await self._connection.send(cmd) await self._wait_for_response( - msg_id, channel=sub.channel, client_id=client_id, op="unsubscribe", + msg_id, + channel=sub.channel, + client_id=client_id, + op="unsubscribe", ) # F-P-08: push sentinel before deleting so any held iterator exits # cleanly via StopAsyncIteration instead of hanging on queue.get(). @@ -351,9 +364,7 @@ async def unsubscribe(self, client_id: int) -> None: # Clean up mappings self._sid_to_client.pop(sub.server_sid, None) del self._subscriptions[client_id] - logger.debug( - "Unsubscribed client_id=%d (server_sid=%d)", client_id, sub.server_sid - ) + logger.debug("Unsubscribed client_id=%d (server_sid=%d)", client_id, sub.server_sid) async def update_subscription( self, @@ -368,8 +379,9 @@ async def update_subscription( ) -> None: """Mutate an existing subscription. - Markets channels take ``add_markets``/``delete_markets`` with - ``market_tickers``/``market_ids``. The ``cfbenchmarks_value`` channel + Markets channels take ``add_markets``/``delete_markets``/ + ``get_snapshot`` with ``market_tickers``/``market_ids``. The + ``cfbenchmarks_value`` channel takes ``subscribe_indices``/``unsubscribe_indices`` with ``index_ids``, or ``indexlist`` (no ids) to request the available index list. """ @@ -399,7 +411,10 @@ async def update_subscription( cmd = {"id": msg_id, "cmd": "update_subscription", "params": params} await self._connection.send(cmd) await self._wait_for_response( - msg_id, channel=sub.channel, client_id=client_id, op="update_subscription", + msg_id, + channel=sub.channel, + client_id=client_id, + op="update_subscription", ) logger.debug("Updated subscription client_id=%d action=%s", client_id, action) @@ -447,8 +462,10 @@ async def resubscribe_all(self) -> None: await self._connection.send(cmd) data = await self._wait_for_response( - msg_id, channel=sub.channel, - client_id=client_id, op="subscribe", + msg_id, + channel=sub.channel, + client_id=client_id, + op="subscribe", ) if data.get("type") == "error": error_msg = data.get("msg", {}) @@ -527,19 +544,24 @@ async def resubscribe_one(self, client_id: int) -> int | None: try: msg_id = self._get_msg_id() cmd = { - "id": msg_id, "cmd": "unsubscribe", + "id": msg_id, + "cmd": "unsubscribe", "params": {"sids": [old_sid]}, } await self._connection.send(cmd) await self._wait_for_response( - msg_id, channel=sub.channel, - client_id=client_id, op="unsubscribe", + msg_id, + channel=sub.channel, + client_id=client_id, + op="unsubscribe", ) except KalshiSubscriptionError: logger.debug( "Unsubscribe during resubscribe_one failed for " "client_id=%d sid=%d; continuing with fresh subscribe", - client_id, old_sid, exc_info=True, + client_id, + old_sid, + exc_info=True, ) self._sid_to_client.pop(old_sid, None) sub.server_sid = None @@ -551,8 +573,10 @@ async def resubscribe_one(self, client_id: int) -> int | None: cmd = {"id": msg_id, "cmd": "subscribe", "params": params} await self._connection.send(cmd) data = await self._wait_for_response( - msg_id, channel=sub.channel, - client_id=client_id, op="subscribe", + msg_id, + channel=sub.channel, + client_id=client_id, + op="subscribe", ) if data.get("type") == "error": error_msg = data.get("msg", {}) @@ -571,9 +595,7 @@ async def resubscribe_one(self, client_id: int) -> int | None: finally: self._stashing = prev_stashing - async def broadcast_error( - self, client_id: int, exc: BaseException - ) -> None: + async def broadcast_error(self, client_id: int, exc: BaseException) -> None: """Surface ``exc`` to the subscription's iterator (#207, #189). Puts an error sentinel on the queue so any active ``async for`` diff --git a/kalshi/ws/models/communications.py b/kalshi/ws/models/communications.py index 931d6c85..2d367829 100644 --- a/kalshi/ws/models/communications.py +++ b/kalshi/ws/models/communications.py @@ -1,4 +1,5 @@ """Communications channel message models (RFQ and quote notifications).""" + from __future__ import annotations from typing import Literal @@ -93,8 +94,8 @@ class QuoteCreatedPayload(BaseModel): default=None, validation_alias=AliasChoices("rfq_target_cost_dollars", "rfq_target_cost"), ) - # AsyncAPI content (2026-07-27): RFQ creator + own subaccount when applicable. - rfq_creator_id: str | None = None + # AsyncAPI 3.30.0 content: RFQ creator is required on created/accepted. + rfq_creator_id: str subaccount: int | None = None model_config = {"extra": "allow", "populate_by_name": True} @@ -133,8 +134,8 @@ class QuoteAcceptedPayload(BaseModel): default=None, validation_alias=AliasChoices("rfq_target_cost_dollars", "rfq_target_cost"), ) - # AsyncAPI content (2026-07-27): RFQ creator + own subaccount when applicable. - rfq_creator_id: str | None = None + # AsyncAPI 3.30.0 content: RFQ creator is required on created/accepted. + rfq_creator_id: str subaccount: int | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/ws/models/orderbook_delta.py b/kalshi/ws/models/orderbook_delta.py index 1cebdd27..ebb4dfa9 100644 --- a/kalshi/ws/models/orderbook_delta.py +++ b/kalshi/ws/models/orderbook_delta.py @@ -119,6 +119,8 @@ class OrderbookSnapshotMessage(BaseModel): type: Literal["orderbook_snapshot"] = "orderbook_snapshot" sid: int seq: int + # Present when the snapshot is a reply to a get_snapshot command with an ID. + id: int | None = None msg: OrderbookSnapshotPayload model_config = {"extra": "allow", "populate_by_name": True} diff --git a/pyproject.toml b/pyproject.toml index 9434d195..c5931606 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "14.0.0" +version = "15.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 74ff38d6..a8108799 100644 --- a/specs/asyncapi.yaml +++ b/specs/asyncapi.yaml @@ -117,6 +117,10 @@ channels: $ref: '#/components/messages/listSubscriptionsResponse' unsubscribedResponse: $ref: '#/components/messages/unsubscribedResponse' + subscribedIndicesResponse: + $ref: '#/components/messages/subscribedIndicesResponse' + subscribedUnderlyingsResponse: + $ref: '#/components/messages/subscribedUnderlyingsResponse' okResponse: $ref: '#/components/messages/okResponse' errorResponse: @@ -248,6 +252,8 @@ channels: messages: marketLifecycleV2: $ref: '#/components/messages/marketLifecycleV2' + marketMetadataUpdated: + $ref: '#/components/messages/marketMetadataUpdated' eventLifecycle: $ref: '#/components/messages/eventLifecycle' eventFeeUpdate: @@ -338,7 +344,42 @@ channels: description: | Real-time CF Benchmarks index value updates, each carrying the raw upstream frame plus trailing 60-second and quarter-hour final-minute averages. Requires authentication. - **Requirements:** + ## Coins and index IDs + + Use the following CF Benchmarks index IDs to request coin data on this channel or through the [REST passthrough](/cfbenchmarks/rest-passthrough). The BTC and ETH examples on this page are illustrative; they are not the full list of coins. + + | Coin | CF Benchmarks index ID | + |------|------------------------| + | AAVE | `AAVEUSD_RTI` | + | ADA | `ADAUSD_RTI` | + | BCH | `BCHUSD_RTI` | + | BNB | `BNBUSD_RTI` | + | BTC | `BRTI` | + | DOGE | `DOGEUSD_RTI` | + | DOT | `DOTUSD_RTI` | + | ETH | `ETHUSD_RTI` | + | HBAR | `HBARUSD_RTI` | + | HYPE | `HYPEUSD_RTI` | + | LINK | `LINKUSD_RTI` | + | LTC | `LTCUSD_RTI` | + | NEAR | `NEARUSD_RTI` | + | SHIB / kSHIB | `SHIBUSD_RTI` | + | SOL | `SOLUSD_RTI` | + | SUI | `SUIUSD_RTI` | + | VVV | `VVVUSD_RTI` | + | WLD | `WLDUSD_RTI` | + | XLM | `XLMUSD_RTI` | + | XRP | `XRPUSD_RTI` | + | ZEC | `ZECUSD_RTI` | + + Pass the index ID exactly as shown, rather than a coin symbol or Kalshi market ticker. For kSHIB, request `SHIBUSD_RTI`: values are USD per SHIB and are not scaled to kSHIB or a perpetual contract size. + + Use the `indexlist` action below to check the index IDs available on your WebSocket connection. Availability can change; this table is a coin-to-index reference, not a guarantee that every index is streaming in every environment. + + The [5Hz feed](/websockets/cfbenchmarks-value-5hz) has a smaller coin set: BTC, ETH, SOL, XRP, and DOGE. Use `cfbenchmarks_value` for the other coins above. + + ## 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 @@ -350,12 +391,41 @@ channels: **Use case:** Consuming CF Benchmarks reference index values and their short-window averages - **Subscription workflow:** + ## Subscription workflow + 1. Subscribe to `cfbenchmarks_value` (optionally seeding `index_ids`). A successful subscribe returns a `subscribed` response with the assigned `sid`. 2. Discover available index IDs with the `indexlist` action; the server replies with a `cfbenchmarks_value_indexlist` message. 3. Add or remove tracked index IDs with `subscribe_indices` / `unsubscribe_indices`, or use `index_ids: ["all"]` to track everything. - **Averaging semantics:** + For example, subscribe to BNB, HYPE, and SHIB values: + + ```json + { + "id": 1, + "cmd": "subscribe", + "params": { + "channels": ["cfbenchmarks_value"], + "index_ids": ["BNBUSD_RTI", "HYPEUSD_RTI", "SHIBUSD_RTI"] + } + } + ``` + + To discover the available indices, send the following after the `subscribed` response. Replace `sid: 1` with the subscription ID returned by the server: + + ```json + { + "id": 2, + "cmd": "update_subscription", + "params": { + "sid": 1, + "action": "indexlist" + } + } + ``` + + Read the available IDs from `msg.index_ids` in the `cfbenchmarks_value_indexlist` response. This lists the channel's available indices without changing which ones you subscribe to. A successful `subscribe` response alone does not confirm that a requested index is available. + + ## Averaging semantics `avg_60s_data` (always present): - Window is trailing and per tick: `[source_ts_ms - 60000, source_ts_ms)` @@ -368,7 +438,8 @@ channels: - This produces second-indexed counts: `:01 -> 1`, `:14 -> 14`, `:59 -> 59`, close tick (`:00/:15/:30/:45`) -> `60` - The field is omitted outside that final-minute window - **Integration notes:** + ## 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` @@ -385,7 +456,7 @@ channels: 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. + This is the high-frequency sibling of the once-per-second [`cfbenchmarks_value`](/websockets/cfbenchmarks-value) channel. It carries BTC (`BRTI`), ETH (`ETHUSD_RTI`), SOL (`SOLUSD_RTI`), XRP (`XRPUSD_RTI`), and DOGE (`DOGEUSD_RTI`) at up to five updates per second. For BNB, HYPE, NEAR, ZEC, SUI, BCH, LTC, LINK, SHIB/kSHIB, ADA, WLD, AAVE, VVV, and other per-second indices, see the [coin and index ID table](/websockets/cfbenchmarks-value#coins-and-index-ids). Use this channel's `indexlist` action to discover its available IDs. 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 @@ -421,15 +492,141 @@ channels: description: | Real-time Pyth price updates for configured underlying tickers. Requires authentication. - **Requirements:** + ## Access and pricing + + The Pyth data feed costs **$1,000/month**, with the **first seven days free**. [Subscribe to the Pyth data feed](https://buy.stripe.com/eVqaEXfmu9eN2ZB8gr4ZG0a). + + Connect using an authenticated Kalshi WebSocket session. See [Quick Start: WebSockets](/getting_started/quick_start_websockets#authentication) for API key authentication and request signing. + + ## Commodities and underlying tickers + + The reference table below includes spot metals, commodity futures, and indices. The gold and silver examples are only a subset of the feed coverage. + + | Pyth feed ID | Pyth symbol | Description | + |--------------|-------------|-------------| + | 345 | `Metal.XAG/USD` | Silver price in USD | + | 346 | `Metal.XAU/USD` | Gold price in USD | + | 2937 | `Commodities.CCU6/USD` | Cocoa futures (Sept 2026) in USD | + | 3052 | `Commodities.COU6/USD` | Crude Oil futures (Sept 2026) in USD | + | 3053 | `Commodities.COZ6/USD` | Crude Oil futures (Dec 2026) in USD | + | 3080 | `Commodities.WHU6/USD` | Wheat futures (Sept 2026) in USD | + | 3081 | `Commodities.WHZ6/USD` | Wheat futures (Dec 2026) in USD | + | 3085 | `Commodities.SOU6/USD` | Soybeans futures (Sept 2026) in USD | + | 3086 | `Commodities.SOX6/USD` | Soybeans futures (Nov 2026) in USD | + | 3063 | `Commodities.Index.PYTHOIL/USD` | Blended Oil Index in USD | + | 3153 | `Metal.Index.GOLD/USD` | Blended Gold Index in USD | + | 3154 | `Metal.Index.SILVER/USD` | Blended Silver Index in USD | + | 3045 | `Commodities.BRENTX6/USD` | Brent Crude futures (Nov 2026) in USD | + | 3525 | `Commodities.Index.CU/USD` | Copper Index in USD | + | 3265 | `Commodities.Index.NATGAS/USD` | Natural Gas Index in USD | + | 3446 | `Commodities.Index.BRENT/USD` | Blended Brent Crude Index in USD | + | 1781 | `Metal.XPT/USD` | Platinum price in USD | + | 1780 | `Metal.XPD/USD` | Palladium price in USD | + + Pass the exact **Pyth symbol** in `underlying_tickers`. The numeric Pyth feed IDs are reference information and are not subscription parameters for this channel. Kalshi market tickers are not Pyth underlying tickers. + + Use `underlying_list` to discover recently streamed tickers on your connection. Availability can change, including as futures contracts expire; the table does not guarantee that every feed is streaming in every environment. + + ## Requirements + - Authentication required - Seed `underlying_tickers` in the initial subscribe, or add them later - Use `underlying_tickers: ["all"]` to receive every available underlying - Supports `update_subscription` with `subscribe_underlyings`, `unsubscribe_underlyings`, and `underlying_list` actions - Duplicate and out-of-order source timestamps are ignored independently per underlying ticker - Subscribe without `underlying_tickers` to create an empty subscription, then use `underlying_list` - to discover recently streamed underlyings and `subscribe_underlyings` to receive prices. + ## Subscription workflow + + Subscribe to `pyth_value` with the underlying tickers you want to receive. For example, request gold, oil, and natural gas prices: + + ```json + { + "id": 1, + "cmd": "subscribe", + "params": { + "channels": ["pyth_value"], + "underlying_tickers": [ + "Metal.XAU/USD", + "Commodities.Index.PYTHOIL/USD", + "Commodities.Index.NATGAS/USD" + ] + } + } + ``` + + A successful subscribe returns a `subscribed` response with the assigned `sid`. Use that subscription ID in subsequent commands; replace `sid: 1` in the examples below with the returned ID. + + You can also omit `underlying_tickers` to create an empty subscription, discover tickers, and add them later. An empty subscription sends no price updates until you add tickers or switch to `["all"]`. + + ### Discover recently streamed tickers + + ```json + { + "id": 2, + "cmd": "update_subscription", + "params": { + "sid": 1, + "action": "underlying_list" + } + } + ``` + + Read `msg.underlying_tickers` in the `pyth_value_underlying_list` response. This lists tickers observed on the stream within the last two hours without changing your subscription. It is not a complete catalog: inactive feeds can be absent. A successful subscribe response alone does not confirm that a requested ticker is streaming. + + ### Add or remove tickers + + Add platinum to the existing subscription: + + ```json + { + "id": 3, + "cmd": "update_subscription", + "params": { + "sid": 1, + "action": "subscribe_underlyings", + "underlying_tickers": ["Metal.XPT/USD"] + } + } + ``` + + Remove platinum from the subscription: + + ```json + { + "id": 4, + "cmd": "update_subscription", + "params": { + "sid": 1, + "action": "unsubscribe_underlyings", + "underlying_tickers": ["Metal.XPT/USD"] + } + } + ``` + + ### Receive all available underlyings + + Set `underlying_tickers` to `["all"]` in the initial subscribe, or enable it on an existing subscription: + + ```json + { + "id": 5, + "cmd": "update_subscription", + "params": { + "sid": 1, + "action": "subscribe_underlyings", + "underlying_tickers": ["all"] + } + } + ``` + + While all-mode is enabled, removing an individual ticker does not exclude it. Send `unsubscribe_underlyings` with `["all"]` to disable all-mode; any explicitly subscribed tickers remain selected. + + ## Integration notes + + - `sid` identifies the subscription stream; use it for `update_subscription` and `unsubscribe`. + - Missing or empty `underlying_tickers` for `subscribe_underlyings` or `unsubscribe_underlyings` returns an `error` with `code: 28` ("Underlying tickers required"). + - `value_usd` is a USD price string formatted to eight decimal places. + - `source_ts_ms` is the source timestamp in Unix milliseconds; `received_at` is when Kalshi received the update, also in Unix milliseconds. messages: pythValue: $ref: '#/components/messages/pythValue' @@ -600,6 +797,8 @@ operations: $ref: '#/channels/root' messages: - $ref: '#/channels/root/messages/okResponse' + - $ref: '#/channels/root/messages/subscribedUnderlyingsResponse' + - $ref: '#/channels/root/messages/subscribedIndicesResponse' tags: - name: responses @@ -704,6 +903,7 @@ operations: $ref: '#/channels/market_lifecycle_v2' messages: - $ref: '#/channels/market_lifecycle_v2/messages/marketLifecycleV2' + - $ref: '#/channels/market_lifecycle_v2/messages/marketMetadataUpdated' tags: - name: market-data @@ -1018,13 +1218,13 @@ components: channels: ["cfbenchmarks_value_5hz"] index_ids: ["BRTI"] - name: subscribePythValue - summary: Subscribe to pyth_value, seeding underlying tickers + summary: Subscribe to gold, oil, and natural gas prices on pyth_value payload: id: 10 cmd: subscribe params: channels: ["pyth_value"] - underlying_tickers: ["Metal.XAU/USD", "Metal.XAG/USD"] + underlying_tickers: ["Metal.XAU/USD", "Commodities.Index.PYTHOIL/USD", "Commodities.Index.NATGAS/USD"] unsubscribeCommand: name: unsubscribe @@ -1189,23 +1389,32 @@ components: sid: 1 action: underlying_list - name: subscribeUnderlyings - summary: Add underlying tickers to the subscription + summary: Add platinum to the subscription payload: id: 3 cmd: update_subscription params: sid: 1 action: subscribe_underlyings - underlying_tickers: ["Metal.XAU/USD"] + underlying_tickers: ["Metal.XPT/USD"] - name: unsubscribeUnderlyings - summary: Remove underlying tickers from the subscription + summary: Remove platinum from the subscription payload: id: 4 cmd: update_subscription params: sid: 1 action: unsubscribe_underlyings - underlying_tickers: ["Metal.XAU/USD"] + underlying_tickers: ["Metal.XPT/USD"] + - name: subscribeAllUnderlyings + summary: Receive every available underlying on the subscription + payload: + id: 5 + cmd: update_subscription + params: + sid: 1 + action: subscribe_underlyings + underlying_tickers: ["all"] # Response Messages (Server -> Client) subscribedResponse: @@ -1241,6 +1450,22 @@ components: seq: 7 type: unsubscribed + subscribedIndicesResponse: + name: ok + title: Subscribed Indices + summary: Current CF Benchmarks index filter after an update; all-mode is ["all"] + contentType: application/json + payload: + $ref: '#/components/schemas/subscribedIndicesResponsePayload' + + subscribedUnderlyingsResponse: + name: ok + title: Subscribed Underlyings + summary: Current Pyth underlying ticker filter after an update; all-mode is ["all"] + contentType: application/json + payload: + $ref: '#/components/schemas/subscribedUnderlyingsResponsePayload' + okResponse: name: ok title: OK Response @@ -1429,7 +1654,7 @@ components: msg: market_ticker: "FED-23DEC-T3.00" market_id: "9b0f6b43-5b68-4f9f-9f02-9a2d1b8ac1a1" - price_dollars: "0.960" + price_dollars: "0.9600" delta_fp: "-54.00" side: "yes" ts: "2022-11-22T20:44:01Z" @@ -1473,7 +1698,7 @@ components: $ref: '#/components/schemas/cfbenchmarksIndexListPayload' examples: - name: indexListResponse - summary: Available index IDs + summary: Example available index IDs (illustrative subset) payload: type: cfbenchmarks_value_indexlist id: 2 @@ -1535,8 +1760,8 @@ components: sid: 1 seq: 42 msg: - underlying_ticker: "Metal.XAU/USD" - value_usd: "2365.12345000" + underlying_ticker: "Commodities.Index.PYTHOIL/USD" + value_usd: "82.12345000" source_ts_ms: 1710000000100 received_at: 1710000000123 @@ -1555,7 +1780,7 @@ components: sid: 1 seq: 1 msg: - underlying_tickers: ["Metal.XAG/USD", "Metal.XAU/USD"] + underlying_tickers: ["Commodities.Index.NATGAS/USD", "Commodities.Index.PYTHOIL/USD", "Metal.XAG/USD", "Metal.XAU/USD"] ticker: name: ticker @@ -1571,11 +1796,11 @@ components: type: ticker sid: 11 msg: - market_ticker: "FED-23DEC-T3.00" market_id: "9b0f6b43-5b68-4f9f-9f02-9a2d1b8ac1a1" - price_dollars: "0.480" - yes_bid_dollars: "0.450" - yes_ask_dollars: "0.530" + market_ticker: "FED-23DEC-T3.00" + price_dollars: "0.4800" + yes_bid_dollars: "0.4500" + yes_ask_dollars: "0.5300" volume_fp: "33896.00" open_interest_fp: "20422.00" dollar_volume: 16948 @@ -1630,6 +1855,7 @@ components: msg: trade_id: "d91bc706-ee49-470d-82d8-11418bda6fed" order_id: "ee587a1c-8b87-4dcf-b721-9f6f790619fa" + client_order_id: "my-order-1" market_ticker: "HIGHNY-22DEC23-B53.5" exchange_index: 2 is_taker: true @@ -1649,7 +1875,7 @@ components: marketLifecycleV2: name: market_lifecycle_v2 title: Market Lifecycle V2 - summary: Market lifecycle events (created, activated, deactivated, close_date_updated, determined, settled, price_level_structure_updated, metadata_updated) + summary: Market lifecycle events (created, activated, deactivated, close_date_updated, determined, settled, price_level_structure_updated) contentType: application/json payload: $ref: '#/components/schemas/marketLifecycleV2Payload' @@ -1662,11 +1888,9 @@ components: seq: 3 msg: market_ticker: "INXD-23SEP14-B4487" - event_type: "created" exchange_index: 0 open_ts: 1694635200 close_ts: 1694721600 - price_level_structure: "linear_cent" additional_metadata: name: "S&P 500 daily return on Sep 14" title: "S&P 500 closes up by 0.02% or more" @@ -1679,6 +1903,9 @@ components: expected_expiration_ts: 1694721600 strike_type: "greater" floor_strike: 4487 + event_type: "created" + price_level_structure: "linear_cent" + - name: priceLevelStructureUpdated summary: Price level structure updated event payload: @@ -1693,6 +1920,15 @@ components: - start: "0.0000" end: "1.0000" step: "0.0010" + + marketMetadataUpdated: + name: market_lifecycle_v2 + title: Market Metadata Updated + summary: Updated strike information or yes subtitle + contentType: application/json + payload: + $ref: '#/components/schemas/marketMetadataUpdatedPayload' + examples: - name: metadataUpdated summary: Market metadata updated event payload: @@ -1700,8 +1936,8 @@ components: sid: 13 seq: 5 msg: - market_ticker: "KXBTC-25APR30-T0915-B95000" event_type: "metadata_updated" + market_ticker: "KXBTC-25APR30-T0915-B95000" strike_type: "between" floor_strike: 95000 cap_strike: 95250 @@ -1712,8 +1948,8 @@ components: sid: 13 seq: 6 msg: - market_ticker: "KXBTC15M-26APR160100-00" event_type: "metadata_updated" + market_ticker: "KXBTC15M-26APR160100-00" yes_sub_title: "above $95,000" multivariateMarketLifecycle: @@ -1732,7 +1968,6 @@ components: seq: 7 msg: market_ticker: "KXMVE-TEST-EVENT-M1" - event_type: "created" exchange_index: 0 open_ts: 1773936000 close_ts: 1774022400 @@ -1746,6 +1981,7 @@ components: can_close_early: true event_ticker: "KXMVE-TEST-EVENT" expected_expiration_ts: 1774029600 + event_type: "created" eventLifecycle: name: event_lifecycle @@ -1898,7 +2134,7 @@ components: seq: 11 msg: id: "rfq_123" - creator_id: "" + creator_id: "43fc3733603f4830a8c3b2986011a5f78d67b03b473dbfd3a3847abd85b63a51" market_ticker: "FED-23DEC-T3.00" event_ticker: "FED-23DEC" contracts_fp: "100.00" @@ -1911,7 +2147,7 @@ components: seq: 11 msg: id: "rfq_456" - creator_id: "" + creator_id: "43fc3733603f4830a8c3b2986011a5f78d67b03b473dbfd3a3847abd85b63a51" market_ticker: "KXMVE-24DEC-COMBO" event_ticker: "KXMVE-24DEC-EVENT" target_cost_dollars: "100.0000" @@ -1946,7 +2182,7 @@ components: market_ticker: "FED-23DEC-T3.00" event_ticker: "FED-23DEC" contracts_fp: "100.00" - target_cost_dollars: "0.35" + target_cost_dollars: "0.3500" deleted_ts: "2024-12-01T10:05:00Z" quoteCreated: @@ -1970,11 +2206,11 @@ components: rfq_creator_id: "comm_abc123" market_ticker: "FED-23DEC-T3.00" event_ticker: "FED-23DEC" - yes_bid_dollars: "0.35" - no_bid_dollars: "0.65" + yes_bid_dollars: "0.3500" + no_bid_dollars: "0.6500" yes_contracts_offered_fp: "100.00" no_contracts_offered_fp: "200.00" - rfq_target_cost_dollars: "0.35" + rfq_target_cost_dollars: "0.3500" created_ts: "2024-12-01T10:02:00Z" subaccount: 3 @@ -1999,13 +2235,13 @@ components: rfq_creator_id: "comm_abc123" market_ticker: "FED-23DEC-T3.00" event_ticker: "FED-23DEC" - yes_bid_dollars: "0.35" - no_bid_dollars: "0.65" + yes_bid_dollars: "0.3500" + no_bid_dollars: "0.6500" accepted_side: "yes" contracts_accepted_fp: "50.00" yes_contracts_offered_fp: "100.00" no_contracts_offered_fp: "200.00" - rfq_target_cost_dollars: "0.35" + rfq_target_cost_dollars: "0.3500" subaccount: 3 quoteExecuted: @@ -2082,8 +2318,8 @@ components: orderAction: type: string - description: Order action type - enum: ["buy", "sell"] + description: Legacy order action; an unknown action is emitted as an empty string. + enum: ["buy", "sell", "sell_partial", "sell_to_close", ""] # Command payloads subscribeCommandPayload: @@ -2315,11 +2551,11 @@ components: type: object required: ["type", "msg"] properties: - id: - $ref: '#/components/schemas/commandId' type: type: string const: "subscribed" + id: + $ref: '#/components/schemas/commandId' msg: type: object required: ["channel", "sid"] @@ -2333,29 +2569,75 @@ components: type: object required: ["sid", "seq", "type"] properties: + type: + type: string + const: "unsubscribed" id: $ref: '#/components/schemas/commandId' sid: $ref: '#/components/schemas/subscriptionId' seq: $ref: '#/components/schemas/sequenceNumber' + + subscribedIndicesResponsePayload: + type: object + required: ["type", "sid", "seq", "msg"] + properties: type: type: string - const: "unsubscribed" + const: "ok" + 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: Current CF Benchmarks index filter after an update; all-mode is ["all"] + items: + type: string - okResponsePayload: + subscribedUnderlyingsResponsePayload: type: object - required: ["type"] + required: ["type", "sid", "seq", "msg"] properties: + type: + type: string + const: "ok" id: $ref: '#/components/schemas/commandId' sid: $ref: '#/components/schemas/subscriptionId' seq: $ref: '#/components/schemas/sequenceNumber' + msg: + type: object + required: ["underlying_tickers"] + properties: + underlying_tickers: + type: array + description: Current Pyth underlying ticker filter after an update; all-mode is ["all"] + items: + type: string + + okResponsePayload: + type: object + required: ["type"] + properties: type: type: string const: "ok" + id: + $ref: '#/components/schemas/commandId' + sid: + $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' msg: type: object properties: @@ -2374,6 +2656,9 @@ components: type: object required: ["type", "msg"] properties: + type: + type: string + const: "error" id: $ref: '#/components/schemas/commandId' sid: @@ -2382,9 +2667,6 @@ components: seq: $ref: '#/components/schemas/sequenceNumber' description: Present on subscription-scoped errors on sequenced channels. - type: - type: string - const: "error" msg: type: object required: ["code", "msg"] @@ -2425,6 +2707,14 @@ components: msg: type: string description: Human-readable error message + market_ticker: + type: string + description: Optional market ticker associated with the error + market_tickers: + type: array + description: Optional market tickers associated with the error + items: + type: string listSubscriptionsCommandPayload: type: object @@ -2438,13 +2728,13 @@ components: listSubscriptionsResponsePayload: type: object - required: ["id", "type", "msg"] + required: ["type", "msg"] properties: - id: - $ref: '#/components/schemas/commandId' type: type: string const: "ok" + id: + $ref: '#/components/schemas/commandId' msg: type: array description: List of active subscriptions @@ -2651,6 +2941,9 @@ components: type: type: string const: "orderbook_snapshot" + id: + $ref: '#/components/schemas/commandId' + description: Present when replying to a get_snapshot command with an ID sid: $ref: '#/components/schemas/subscriptionId' seq: @@ -2720,11 +3013,6 @@ components: description: | Optional - Present only when you caused this orderbook change. Contains the client_order_id of your order that triggered this delta. - subaccount: - type: integer - description: | - Optional - Present only when you caused this orderbook change and are using subaccounts. - Contains the subaccount number of your order that triggered this delta. ts: type: string deprecated: true @@ -2734,6 +3022,11 @@ components: type: integer description: Optional - Unix timestamp for when the orderbook change was recorded (in milliseconds) format: int64 + subaccount: + type: integer + description: | + Optional - Present only when you caused this orderbook change and are using subaccounts. + Contains the subaccount number of your order that triggered this delta. tickerPayload: type: object @@ -2746,12 +3039,12 @@ components: $ref: '#/components/schemas/subscriptionId' msg: type: object - required: ["market_ticker", "market_id", "price_dollars", "yes_bid_dollars", "yes_ask_dollars", "yes_bid_size_fp", "yes_ask_size_fp", "last_trade_size_fp", "volume_fp", "open_interest_fp", "dollar_volume", "dollar_open_interest", "ts", "ts_ms", "time"] + required: ["market_id", "market_ticker", "price_dollars", "yes_bid_dollars", "yes_ask_dollars", "volume_fp", "open_interest_fp", "dollar_volume", "dollar_open_interest", "yes_bid_size_fp", "yes_ask_size_fp", "last_trade_size_fp", "ts", "ts_ms", "time"] properties: - market_ticker: - $ref: '#/components/schemas/marketTicker' market_id: $ref: '#/components/schemas/marketId' + market_ticker: + $ref: '#/components/schemas/marketTicker' price_dollars: type: string description: Last traded price in dollars @@ -2769,12 +3062,10 @@ components: description: Fixed-point open interest (2 decimals) dollar_volume: type: integer - description: Number of dollars traded in the market so far - minimum: 0 + description: Signed whole-dollar cumulative traded notional dollar_open_interest: type: integer - description: Number of dollars positioned in the market currently - minimum: 0 + description: Signed whole-dollar open-interest notional yes_bid_size_fp: type: string description: Fixed-point contracts at best bid (2 decimals) @@ -2882,6 +3173,9 @@ components: type: string description: Unique identifier for orders. This is what you use to differentiate fills for different orders format: uuid + client_order_id: + type: string + description: Optional client-provided order ID market_ticker: $ref: '#/components/schemas/marketTicker' description: Unique identifier for markets. This is what you use to differentiate fills for different markets @@ -2896,6 +3190,15 @@ components: deprecated: true description: | Deprecated. Use `outcome_side` (or `book_side`) instead. See [Order direction](/getting_started/order_direction). This field will not be removed before May 14, 2026. + ts: + type: integer + deprecated: true + description: Deprecated - Unix timestamp for when the update happened (in seconds). Use ts_ms instead. + format: int64 + ts_ms: + type: integer + description: Unix timestamp for when the update happened (in milliseconds) + format: int64 yes_price_dollars: type: string description: Price for the yes side of the fill in dollars @@ -2910,18 +3213,6 @@ components: deprecated: true description: | Deprecated. Use `outcome_side` (or `book_side`) instead. See [Order direction](/getting_started/order_direction). This field will not be removed before May 14, 2026. - ts: - type: integer - deprecated: true - description: Deprecated - Unix timestamp for when the update happened (in seconds). Use ts_ms instead. - format: int64 - ts_ms: - type: integer - description: Unix timestamp for when the update happened (in milliseconds) - format: int64 - client_order_id: - type: string - description: Optional client-provided order ID post_position_fp: type: string description: Fixed-point position after the fill (2 decimals) @@ -2961,21 +3252,8 @@ components: $ref: '#/components/schemas/sequenceNumber' msg: type: object - required: ["event_type", "market_ticker"] + required: ["market_ticker", "event_type"] properties: - event_type: - type: string - 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 - - `price_level_structure_updated` - Market price level structure changed - - `metadata_updated` - Market metadata updated (e.g. floor strike, yes_sub_title) - enum: ["created", "deactivated", "activated", "close_date_updated", "determined", "settled", "price_level_structure_updated", "metadata_updated"] market_ticker: $ref: '#/components/schemas/marketTicker' description: Unique identifier for markets. This is what you use to differentiate updates for different markets @@ -2990,27 +3268,41 @@ components: 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 + result: + type: string + description: Optional - This key will ONLY exist when the market is determined. Result of the market + 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") 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: + additional_metadata: + $ref: '#/components/schemas/lifecycleAdditionalMetadata' + description: Optional - This key will be emitted when the market is created + event_type: type: string - description: Optional - This key will exist when the market is created or when the price level structure is updated. 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"] + 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 + - `price_level_structure_updated` - Market price level structure changed + enum: ["created", "deactivated", "activated", "close_date_updated", "determined", "settled", "price_level_structure_updated"] + price_level_structure: + $ref: '#/components/schemas/lifecyclePriceLevelStructure' + description: Optional - The market price level structure on creation or price_level_structure_updated events price_ranges: type: array description: Optional - Emitted alongside price_level_structure (on market creation and price_level_structure_updated events). The valid price bands for the market, in fixed-point dollars. Use this to determine valid order prices rather than hardcoding a tick size. @@ -3030,52 +3322,6 @@ components: step: type: string description: Tick size (minimum price increment) within this band, in dollars - strike_type: - type: string - description: Optional - This key will ONLY exist for metadata_updated events. Determines how floor_strike / cap_strike are interpreted (e.g. "between" uses both, "greater" uses floor_strike only, "less" uses cap_strike only) - floor_strike: - type: number - description: Optional - This key will ONLY exist for metadata_updated events. The floor (lower bound) strike value for the market - cap_strike: - type: number - description: Optional - This key will ONLY exist for metadata_updated events. The cap (upper bound) strike value for the market - custom_strike: - type: object - description: Optional - This key will ONLY exist for metadata_updated events with a custom or structured strike type - yes_sub_title: - type: string - description: Optional - This key will ONLY exist for metadata_updated events. The updated yes subtitle for the market - 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 multivariateMarketLifecyclePayload: type: object @@ -3090,19 +3336,8 @@ components: $ref: '#/components/schemas/sequenceNumber' msg: type: object - required: ["event_type", "market_ticker"] + required: ["market_ticker", "event_type"] properties: - event_type: - type: string - 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 @@ -3117,58 +3352,114 @@ components: 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 + result: + type: string + description: Optional - This key will ONLY exist when the market is determined. Result of the market + 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") 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 + additional_metadata: + $ref: '#/components/schemas/lifecycleAdditionalMetadata' + description: Optional - This key will be emitted when the market is created + event_type: + type: string + 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"] price_level_structure: + $ref: '#/components/schemas/lifecyclePriceLevelStructure' + description: Optional - The market price level structure on creation + + lifecycleAdditionalMetadata: + type: object + required: ["name", "title", "yes_sub_title", "no_sub_title", "rules_primary", "rules_secondary", "can_close_early", "event_ticker", "expected_expiration_ts"] + 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 + + lifecyclePriceLevelStructure: + type: string + description: The market price structure. An empty string represents an unknown structure. + enum: ["banded_centi_cent", "", "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"] + + marketMetadataUpdatedPayload: + type: object + 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"] + properties: + event_type: 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: + const: "metadata_updated" + market_ticker: + $ref: '#/components/schemas/marketTicker' + description: Unique identifier for markets. This is what you use to differentiate updates for different markets + strike_type: + type: string + description: Optional - This key will ONLY exist for metadata_updated events. Determines how floor_strike / cap_strike are interpreted (e.g. "between" uses both, "greater" uses floor_strike only, "less" uses cap_strike only) + floor_strike: + type: number + description: Optional - This key will ONLY exist for metadata_updated events. The floor (lower bound) strike value for the market + cap_strike: + type: number + description: Optional - This key will ONLY exist for metadata_updated events. The cap (upper bound) strike value for the market + custom_strike: 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 + description: Optional - This key will ONLY exist for metadata_updated events with a custom or structured strike type + yes_sub_title: + type: string + description: Optional - This key will ONLY exist for metadata_updated events. The updated yes subtitle for the market eventLifecyclePayload: type: object @@ -3231,13 +3522,11 @@ components: type: string description: Unique identifier for the event fee_type_override: - type: string - nullable: true - enum: [quadratic, quadratic_with_maker_fees, quadratic_with_combo_maker_fees, flat, null] + type: [string, "null"] + enum: [quadratic, quadratic_with_maker_fees, quadratic_with_combo_maker_fees, flat, margin_market_maker_program_fees, null] description: Event fee type override. `null` when the override has been cleared. fee_multiplier_override: - type: number - nullable: true + type: [number, "null"] description: Event fee multiplier override. `null` when the override has been cleared. marketPositionPayload: @@ -3341,7 +3630,7 @@ components: status: type: string description: Current order status - enum: ["resting", "canceled", "executed"] + enum: ["resting", "canceled", "executed", "unknown"] side: $ref: '#/components/schemas/marketSide' deprecated: true @@ -3401,23 +3690,15 @@ components: description: Self-trade prevention type enum: ["taker_at_cross", "maker"] created_time: - type: string + type: [string, "null"] deprecated: true description: Deprecated - Order creation time in RFC3339 format. Use created_ts_ms instead. format: date-time - created_ts_ms: - type: integer - description: Order creation time as a Unix timestamp in milliseconds - format: int64 last_update_time: type: string deprecated: true description: Deprecated - Last update time in RFC3339 format. Use last_updated_ts_ms instead. format: date-time - last_updated_ts_ms: - type: integer - description: Last update time as a Unix timestamp in milliseconds - format: int64 expiration_time: type: string deprecated: true @@ -3427,6 +3708,14 @@ components: type: integer description: Order expiration time as a Unix timestamp in milliseconds format: int64 + created_ts_ms: + type: [integer, "null"] + description: Order creation time as a Unix timestamp in milliseconds + format: int64 + last_updated_ts_ms: + type: integer + description: Last update time as a Unix timestamp in milliseconds + format: int64 subaccount_number: type: integer description: Subaccount number (0 for primary, 1-63 for subaccounts) @@ -3451,7 +3740,7 @@ components: description: Unique identifier for the RFQ creator_id: type: string - description: Public communications ID of the RFQ creator (anonymized). Currently empty for rfq_created events. + description: Public communications ID of the RFQ creator (anonymized). market_ticker: type: string description: Market ticker for the RFQ @@ -3544,7 +3833,7 @@ components: $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"] + required: ["quote_id", "rfq_id", "quote_creator_id", "rfq_creator_id", "market_ticker", "yes_bid_dollars", "no_bid_dollars", "created_ts"] properties: quote_id: type: string @@ -3602,7 +3891,7 @@ components: $ref: '#/components/schemas/sequenceNumber' msg: type: object - required: ["quote_id", "rfq_id", "quote_creator_id", "market_ticker", "yes_bid_dollars", "no_bid_dollars"] + required: ["quote_id", "rfq_id", "quote_creator_id", "rfq_creator_id", "market_ticker", "yes_bid_dollars", "no_bid_dollars"] properties: quote_id: type: string diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 8eb66dbe..e70ca36a 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.0 info: title: Kalshi Trade API Manual Endpoints - version: 3.29.0 + version: 3.30.0 description: Manually defined OpenAPI spec for endpoints being migrated to spec-first approach servers: @@ -339,6 +339,8 @@ paths: - name: category in: query required: false + description: >- + Return series whose `categories` list contains this value. A series can have more than one discovery category, so the `category` field of a returned series (its primary category) may differ from the filter value. Matching is exact and case-sensitive. schema: type: string x-go-type-skip-optional-pointer: true @@ -1048,12 +1050,16 @@ paths: $ref: '#/components/responses/BadRequestError' '401': $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' '409': $ref: '#/components/responses/ConflictError' '429': $ref: '#/components/responses/RateLimitError' '500': $ref: '#/components/responses/InternalServerError' + '503': + $ref: '#/components/responses/ServiceUnavailableError' delete: operationId: CancelAllOrders @@ -1245,10 +1251,14 @@ paths: $ref: '#/components/responses/BadRequestError' '401': $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' '404': $ref: '#/components/responses/NotFoundError' '500': $ref: '#/components/responses/InternalServerError' + '503': + $ref: '#/components/responses/ServiceUnavailableError' /portfolio/events/orders/{order_id}/decrease: post: @@ -1798,13 +1808,16 @@ paths: get: operationId: GetPositions summary: Get Positions - description: 'Restricts the positions to those with any of following fields with non-zero values, as a comma separated list. The following values are accepted: position, total_traded' + description: | + Restricts the positions to those with any of following fields with non-zero values, as a comma separated list. The following values are accepted: position, total_traded. + Registered partners may also use a user OAuth access token with the explicitly granted read::compliance_partner scope. tags: - portfolio security: - kalshiAccessKey: [] kalshiAccessSignature: [] kalshiAccessTimestamp: [] + - kalshiOauthAccessToken: [] parameters: - $ref: '#/components/parameters/PositionsCursorQuery' - $ref: '#/components/parameters/PositionsLimitQuery' @@ -1945,6 +1958,7 @@ paths: summary: Get Fills description: | Endpoint for getting all fills for the member. A fill is when a trade you have is matched. + Registered partners may also use a user OAuth access token with the explicitly granted read::compliance_partner scope. Fills that occurred before the historical cutoff are only available via `GET /historical/fills`. See [Historical Data](https://docs.kalshi.com/getting_started/historical_data) for details. tags: - portfolio @@ -1952,6 +1966,7 @@ paths: - kalshiAccessKey: [] kalshiAccessSignature: [] kalshiAccessTimestamp: [] + - kalshiOauthAccessToken: [] parameters: - $ref: '#/components/parameters/TickerQuery' - $ref: '#/components/parameters/OrderIdQuery' @@ -2112,7 +2127,10 @@ paths: get: operationId: GetRFQs summary: Get RFQs - description: ' Endpoint for getting RFQs' + description: >- + List RFQs. Pass pagination cursors back unchanged. A malformed cursor or + invalid creator_user_id UUID returns HTTP 400. Use user_filter=self to + filter by the authenticated user. tags: - communications security: @@ -2140,7 +2158,7 @@ paths: type: string - name: creator_user_id in: query - description: Filter RFQs by creator user ID + description: Filter RFQs by creator user UUID. Use user_filter=self for the authenticated user. deprecated: true schema: type: string @@ -2159,6 +2177,8 @@ paths: application/json: schema: $ref: '#/components/schemas/GetRFQsResponse' + '400': + $ref: '#/components/responses/BadRequestError' '401': $ref: '#/components/responses/UnauthorizedError' '500': @@ -2216,6 +2236,8 @@ paths: application/json: schema: $ref: '#/components/schemas/GetRFQResponse' + '400': + $ref: '#/components/responses/BadRequestError' '401': $ref: '#/components/responses/UnauthorizedError' '404': @@ -2238,6 +2260,8 @@ paths: responses: '204': description: RFQ deleted successfully + '400': + $ref: '#/components/responses/BadRequestError' '401': $ref: '#/components/responses/UnauthorizedError' '404': @@ -2271,6 +2295,8 @@ paths: application/json: schema: $ref: '#/components/schemas/GetQuoteResponse' + '400': + $ref: '#/components/responses/BadRequestError' '401': $ref: '#/components/responses/UnauthorizedError' '404': @@ -2299,6 +2325,8 @@ paths: responses: '204': description: Quote deleted successfully + '400': + $ref: '#/components/responses/BadRequestError' '401': $ref: '#/components/responses/UnauthorizedError' '404': @@ -2343,6 +2371,11 @@ paths: operationId: ConfirmRFQQuote summary: Confirm RFQ Quote description: ' Endpoint for confirming a quote scoped to its RFQ. This will start a timer for order execution.' + x-mint: + content: | + + Rate limits are more favorable when providing the RFQ ID. + tags: - communications security: @@ -2361,6 +2394,8 @@ paths: responses: '204': description: Quote confirmed successfully + '400': + $ref: '#/components/responses/BadRequestError' '401': $ref: '#/components/responses/UnauthorizedError' '404': @@ -2372,7 +2407,9 @@ paths: get: operationId: GetQuotes summary: Get Quotes - description: ' Endpoint for getting quotes' + description: >- + List quotes. The rfq_id filter requires an RFQ UUID. Pass pagination + cursors back unchanged; malformed RFQ IDs or cursors return HTTP 400. tags: - communications security: @@ -2448,7 +2485,7 @@ paths: x-go-type-skip-optional-pointer: true - name: rfq_id in: query - description: Filter quotes by RFQ ID + description: Filter quotes by RFQ UUID. Pass the RFQ ID unchanged; malformed IDs return HTTP 400. schema: type: string x-go-type-skip-optional-pointer: true @@ -2459,6 +2496,8 @@ paths: application/json: schema: $ref: '#/components/schemas/GetQuotesResponse' + '400': + $ref: '#/components/responses/BadRequestError' '401': $ref: '#/components/responses/UnauthorizedError' '500': @@ -2529,6 +2568,8 @@ paths: application/json: schema: $ref: '#/components/schemas/GetQuoteResponse' + '400': + $ref: '#/components/responses/BadRequestError' '401': $ref: '#/components/responses/UnauthorizedError' '404': @@ -2561,6 +2602,8 @@ paths: responses: '204': description: Quote deleted successfully + '400': + $ref: '#/components/responses/BadRequestError' '401': $ref: '#/components/responses/UnauthorizedError' '404': @@ -2616,6 +2659,10 @@ paths: This endpoint is deprecated. Use `PUT /communications/rfqs/{rfq_id}/quotes/{quote_id}/confirm` instead. + + + Rate limits are more favorable when providing the RFQ ID. + tags: - communications security: @@ -2633,6 +2680,8 @@ paths: responses: '204': description: Quote confirmed successfully + '400': + $ref: '#/components/responses/BadRequestError' '401': $ref: '#/components/responses/UnauthorizedError' '404': @@ -3531,10 +3580,10 @@ paths: - name: type in: query required: false - description: 'Type filter. Can be "all", "liquidity", "volume", or "margin_maker_volume". Default is "all".' + description: 'Type filter. Can be "all", "liquidity", "volume", "margin_maker_volume", or "margin_taker_volume". Default is "all".' schema: type: string - enum: [all, liquidity, volume, margin_maker_volume] + enum: [all, liquidity, volume, margin_maker_volume, margin_taker_volume] - name: incentive_description in: query required: false @@ -3583,6 +3632,9 @@ paths: 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. + API keys bound to a single FCM subtrader may also call this endpoint: `subtrader_id` may be + omitted and defaults to the key's bound subtrader, and if supplied it must equal the bound + subtrader or the request is rejected. tags: - fcm security: @@ -3592,7 +3644,7 @@ paths: parameters: - name: subtrader_id in: query - description: Restricts the response to orders for a specific subtrader (FCM members only). Required unless client_order_ids is supplied. + description: Restricts the response to orders for a specific subtrader (FCM members only). Required unless client_order_ids is supplied. For an API key bound to a subtrader, defaults to the bound subtrader when omitted and must equal it when supplied. schema: type: string x-go-type-skip-optional-pointer: true @@ -3641,11 +3693,278 @@ paths: description: Bad request '401': description: Unauthorized + '403': + description: Forbidden - the API key is bound to a different FCM subtrader '404': description: Not found '500': description: Internal server error + /fcm/subtraders: + get: + operationId: ListFCMSubtraders + summary: List FCM Subtraders + description: | + Lists the authenticated FCM's event-contract subtraders, including accounts with + no trades. Exchange metadata is asynchronous, so newly created accounts may not + appear immediately. Trading block status includes firm-wide and Kalshi restrictions; + fcm_trading_blocked identifies the FCM's own per-subtrader restriction. + tags: + - fcm + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + responses: + '200': + description: Event-contract subtraders + content: + application/json: + schema: + $ref: '#/components/schemas/ListFCMSubtradersResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '500': + $ref: '#/components/responses/InternalServerError' + post: + operationId: CreateFCMSubtrader + summary: Create FCM Subtrader + description: | + Endpoint for FCM members to create a subtrader on the event-contract exchange. The + subtrader is registered on every exchange shard that knows the FCM; a shard provisioned + later registers it lazily on the subtrader's first order there. Re-creating an existing + subtrader returns 409. + tags: + - fcm + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateFCMSubtraderRequest' + responses: + '201': + description: Subtrader created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CreateFCMSubtraderResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '409': + $ref: '#/components/responses/ConflictError' + '500': + $ref: '#/components/responses/InternalServerError' + + /fcm/subtraders/event_contract_daily_cap: + get: + operationId: GetFCMEventContractDailyCap + summary: Get FCM Subtrader Event Contract Daily Cap + description: | + Returns the event-contract daily premium cap configured for an FCM member's subtrader, + together with its live utilization. Executed utilization is the net premium deployed by + fills today (sells and settlements credit back); resting and pending utilization reserve + open and in-flight orders at their full worst-case cost plus fees. Executed utilization + resets at midnight New York time on the returned cap date; resting and pending + reservations persist for as long as their orders remain open, including across the reset. + Returns 404 when the subtrader has no cap configured — in that state every order placed + through the subtrader's bound API keys is rejected. + API keys bound to a single FCM subtrader may also call this endpoint: `subtrader_id` may be + omitted and defaults to the key's bound subtrader, and if supplied it must equal the bound + subtrader or the request is rejected. + tags: + - fcm + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - name: subtrader_id + in: query + description: The subtrader whose daily cap should be returned. Must belong to the requesting FCM. Required unless the API key is bound to a subtrader, in which case it defaults to the bound subtrader when omitted and must equal it when supplied. + schema: + type: string + x-go-type-skip-optional-pointer: true + responses: + '200': + description: Daily cap retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetFCMEventContractDailyCapResponse' + '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' + put: + operationId: UpdateFCMEventContractDailyCap + summary: Update FCM Subtrader Event Contract Daily Cap + description: | + Sets the event-contract daily premium cap for an FCM member's subtrader. The cap bounds + the subtrader's net premium at risk per trading day. It is enforced on cap-reservation + sessions — which every subtrader-bound API key uses — and is mandatory there: a subtrader + with no cap cannot trade through its bound keys. Cap changes take effect immediately; + existing resting orders are never cancelled by a cap change. + tags: + - fcm + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateFCMEventContractDailyCapRequest' + responses: + '200': + description: Daily cap updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyResponse' + '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' + delete: + operationId: DeleteFCMEventContractDailyCap + summary: Delete FCM Subtrader Event Contract Daily Cap + description: | + Removes the event-contract daily premium cap for an FCM member's subtrader. Removal + closes the subtrader to new orders on cap-reservation sessions — every subtrader-bound + API key — until a cap is set again. It does not stop orders your own unbound sessions + submit on the subtrader's behalf; to stop the account entirely, block subtrader trading. + tags: + - fcm + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - name: subtrader_id + in: query + required: true + description: The subtrader whose daily cap should be removed. Must belong to the requesting FCM. + schema: + type: string + responses: + '200': + description: Daily cap deleted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyResponse' + '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' + + /fcm/subtraders/blocked_categories: + get: + operationId: GetFCMSubtraderBlockedCategories + summary: Get FCM Subtrader Blocked Categories + description: | + Returns the event categories an FCM member has blocked for one of its + subtraders. The subtrader must belong to the requesting FCM. A subtrader + with no blocks returns an empty list. + tags: + - fcm + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - name: subtrader_id + in: query + required: true + description: The subtrader whose blocked categories should be returned. Must belong to the requesting FCM. + schema: + type: string + responses: + '200': + description: Blocked categories retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetFCMSubtraderBlockedCategoriesResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '500': + $ref: '#/components/responses/InternalServerError' + put: + operationId: UpdateFCMSubtraderBlockedCategories + summary: Update FCM Subtrader Blocked Categories + description: | + Adds one event category to, or removes one from, the set an FCM member + has blocked for one of its subtraders. The subtrader must belong to the + requesting FCM. Blocks apply prospectively: new orders the subtrader + places through its bound API credentials are rejected in markets whose + event belongs to a blocked category, while orders already resting are + not cancelled. Returns the subtrader's full resulting blocked set. + tags: + - fcm + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateFCMSubtraderBlockedCategoriesRequest' + responses: + '200': + description: Blocked categories updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateFCMSubtraderBlockedCategoriesResponse' + '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' + /portfolio/target_balance_allocation: get: operationId: GetTargetBalanceAllocation @@ -3712,6 +4031,9 @@ paths: description: | Endpoint for FCM members to get market positions filtered by subtrader ID. This endpoint requires FCM member access level and allows filtering positions by subtrader ID. + API keys bound to a single FCM subtrader may also call this endpoint: `subtrader_id` may be + omitted and defaults to the key's bound subtrader, and if supplied it must equal the bound + subtrader or the request is rejected. tags: - fcm security: @@ -3721,10 +4043,10 @@ paths: parameters: - name: subtrader_id in: query - required: true - description: Restricts the response to positions for a specific subtrader (FCM members only) + description: Restricts the response to positions for a specific subtrader (FCM members only). Required unless the API key is bound to a subtrader, in which case it defaults to the bound subtrader when omitted and must equal it when supplied. schema: type: string + x-go-type-skip-optional-pointer: true - name: ticker in: query description: Ticker of desired positions @@ -3771,6 +4093,8 @@ paths: description: Bad request '401': description: Unauthorized + '403': + description: Forbidden - the API key is bound to a different FCM subtrader '404': description: Not found '500': @@ -3855,15 +4179,19 @@ paths: get: operationId: GetFillsHistorical summary: Get Historical Fills - description: ' Endpoint for getting all historical fills for the member. A fill is when a trade you have is matched.' + description: | + Endpoint for getting all historical fills for the member. A fill is when a trade you have is matched. + Registered partners may also use a user OAuth access token with the explicitly granted read::compliance_partner scope. tags: - historical security: - kalshiAccessKey: [] kalshiAccessSignature: [] kalshiAccessTimestamp: [] + - kalshiOauthAccessToken: [] parameters: - $ref: '#/components/parameters/TickerQuery' + - $ref: '#/components/parameters/MinTsQuery' - $ref: '#/components/parameters/MaxTsQuery' - $ref: '#/components/parameters/LimitQuery' - $ref: '#/components/parameters/CursorQuery' @@ -3896,6 +4224,7 @@ paths: kalshiAccessTimestamp: [] parameters: - $ref: '#/components/parameters/TickerQuery' + - $ref: '#/components/parameters/MinTsQuery' - $ref: '#/components/parameters/MaxTsQuery' - $ref: '#/components/parameters/LimitQuery' - $ref: '#/components/parameters/CursorQuery' @@ -4022,6 +4351,10 @@ paths: components: securitySchemes: + kalshiOauthAccessToken: + type: http + scheme: bearer + description: User OAuth access token with read::compliance_partner, issued to an explicitly authorized partner. Accepted only on current and historical fills and current portfolio positions endpoints; generic read and partner client-credentials tokens do not grant access. kalshiAccessKey: type: apiKey in: header @@ -4057,6 +4390,12 @@ components: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + ServiceUnavailableError: + description: Service temporarily unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' NotFoundError: description: Resource not found content: @@ -4163,7 +4502,7 @@ components: name: rfq_id in: path required: true - description: RFQ ID + description: RFQ UUID returned when the RFQ was created. Pass it unchanged; malformed IDs return HTTP 400. schema: type: string @@ -4171,7 +4510,7 @@ components: name: quote_id in: path required: true - description: Quote ID + description: Quote UUID. Pass the ID exactly as received when the quote was created; malformed IDs return HTTP 400. schema: type: string @@ -4593,9 +4932,9 @@ components: ApiKeyScope: type: string - enum: ['read', 'write', 'read::block_trade_accept', 'read::portfolio_balance', 'write::trade', 'write::transfer', 'write::block_trade_accept'] - x-enum-varnames: ['ApiKeyScopeRead', 'ApiKeyScopeWrite', 'ApiKeyScopeReadBlockTradeAccept', 'ApiKeyScopeReadPortfolioBalance', 'ApiKeyScopeWriteTrade', 'ApiKeyScopeWriteTransfer', 'ApiKeyScopeWriteBlockTradeAccept'] - description: Scope granted to an API key. Parent scopes grant broad access; for example, `read` grants all read endpoints and `write` grants all write endpoints. Child scopes such as `read::block_trade_accept`, `read::portfolio_balance`, `write::trade`, `write::transfer`, and `write::block_trade_accept` grant only their specific endpoint group and can be granted without the parent scope. + enum: ['read', 'write', 'read::block_trade_accept', 'read::portfolio_balance', 'write::trade', 'write::transfer', 'write::fcm_risk', 'write::block_trade_accept'] + x-enum-varnames: ['ApiKeyScopeRead', 'ApiKeyScopeWrite', 'ApiKeyScopeReadBlockTradeAccept', 'ApiKeyScopeReadPortfolioBalance', 'ApiKeyScopeWriteTrade', 'ApiKeyScopeWriteTransfer', 'ApiKeyScopeWriteFCMRisk', 'ApiKeyScopeWriteBlockTradeAccept'] + description: Scope granted to an API key. Parent scopes grant broad access; for example, `read` grants all read endpoints and `write` grants all write endpoints. Child scopes such as `read::block_trade_accept`, `read::portfolio_balance`, `write::trade`, `write::transfer`, `write::fcm_risk` (FCM subtrader creation, trading blocks, daily premium caps, and margin caps), and `write::block_trade_accept` grant only their specific endpoint group and can be granted without the parent scope. ApiKey: type: object @@ -4666,7 +5005,7 @@ components: 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. + 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 case-sensitive ASCII 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 @@ -4679,7 +5018,7 @@ components: 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. + description: Present only when the minted key is bound to an FCM subtrader that is missing a per-subtrader risk control - the initial-margin cap (margin lane) or the event-contract daily cap. The mint still succeeds; the warning names each missing control - an event-contract subtrader without a daily cap has its event-contract orders rejected until one is set, while a margin subtrader without an initial-margin cap is bounded only by firm-level risk limits. GenerateApiKeyRequest: type: object @@ -4701,7 +5040,7 @@ components: 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. + 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 case-sensitive ASCII 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 @@ -4718,7 +5057,7 @@ components: 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. + description: Present only when the minted key is bound to an FCM subtrader that is missing a per-subtrader risk control - the initial-margin cap (margin lane) or the event-contract daily cap. The mint still succeeds; the warning names each missing control - an event-contract subtrader without a daily cap has its event-contract orders rejected until one is set, while a margin subtrader without an initial-margin cap is bounded only by firm-level risk limits. GetTagsForSeriesCategoriesResponse: type: object @@ -6464,7 +6803,7 @@ components: description: The ticker symbol of the market associated with this incentive program incentive_type: type: string - enum: ['liquidity', 'volume', 'margin_maker_volume'] + enum: ['liquidity', 'volume', 'margin_maker_volume', 'margin_taker_volume'] description: Type of incentive program incentive_description: type: string @@ -6664,6 +7003,137 @@ components: $ref: '#/components/schemas/StructuredTarget' # Order Group schemas + FCMSubtrader: + type: object + required: + - subtrader_id + - exchange_indices + - trading_blocked + - fcm_trading_blocked + - propagation_pending + properties: + subtrader_id: + type: string + description: Full subtrader identifier owned by the authenticated FCM. + exchange_indices: + type: array + description: Exchange indices where this subtrader has been observed. + items: + type: integer + format: int32 + trading_blocked: + type: boolean + description: Effective trading block, including firm-wide and Kalshi restrictions. + fcm_trading_blocked: + type: boolean + description: Whether an FCM-owned per-subtrader trading block is configured. + propagation_pending: + type: boolean + description: Whether a configured per-subtrader block is awaiting engine application. + + ListFCMSubtradersResponse: + type: object + required: + - subtraders + properties: + subtraders: + type: array + items: + $ref: '#/components/schemas/FCMSubtrader' + + CreateFCMSubtraderRequest: + type: object + required: + - subtrader_suffix + properties: + subtrader_suffix: + type: string + description: Suffix for the new subtrader, 1-16 case-sensitive ASCII alphanumeric characters ([A-Za-z0-9]). The full subtrader id becomes {your_account_id}_{suffix}. + + CreateFCMSubtraderResponse: + type: object + required: + - subtrader_id + properties: + subtrader_id: + type: string + description: The full id of the created subtrader. + + GetFCMEventContractDailyCapResponse: + type: object + required: + - subtrader_id + - limit + - executed_utilization + - resting_order_utilization + - pending_order_utilization + - cap_date + properties: + subtrader_id: + type: string + limit: + $ref: '#/components/schemas/FixedPointDollars' + executed_utilization: + $ref: '#/components/schemas/FixedPointDollars' + resting_order_utilization: + $ref: '#/components/schemas/FixedPointDollars' + pending_order_utilization: + $ref: '#/components/schemas/FixedPointDollars' + cap_date: + type: string + description: The New York calendar date the executed utilization applies to. Executed utilization resets at midnight New York time; resting and pending reservations persist while their orders remain open. + + UpdateFCMEventContractDailyCapRequest: + type: object + required: + - subtrader_id + - limit + properties: + subtrader_id: + type: string + description: The subtrader whose daily cap should be set. Must belong to the requesting FCM. + limit: + $ref: '#/components/schemas/FixedPointDollars' + + GetFCMSubtraderBlockedCategoriesResponse: + type: object + required: + - categories + properties: + categories: + type: array + description: The event categories blocked for the subtrader, sorted ascending. Empty when no categories are blocked. + items: + type: string + + UpdateFCMSubtraderBlockedCategoriesRequest: + type: object + required: + - subtrader_id + - category + - blocked + properties: + subtrader_id: + type: string + description: The subtrader whose blocked categories should be updated. Must belong to the requesting FCM. + category: + type: string + description: A single event category to add to or remove from the blocked set, 1-100 characters (e.g. "Politics"). + blocked: + type: boolean + description: True adds the category to the blocked set; false removes it. Removing a category that is not blocked is a no-op. + + UpdateFCMSubtraderBlockedCategoriesResponse: + type: object + required: + - categories + properties: + categories: + type: array + description: The subtrader's full resulting blocked set, sorted ascending. + items: + type: string + EmptyResponse: type: object description: An empty response body @@ -6718,11 +7188,14 @@ components: type: object required: - allocations + - resting_margin_reservation properties: allocations: type: array items: $ref: '#/components/schemas/TargetBalanceAllocation' + resting_margin_reservation: + $ref: '#/components/schemas/RestingMarginReservation' SetTargetBalanceAllocationRequest: type: object @@ -7211,7 +7684,7 @@ components: properties: id: type: string - description: Unique identifier for the RFQ + description: UUID of the RFQ. Preserve the exact returned string. creator_id: type: string description: Public communications ID of the RFQ creator. @@ -7224,6 +7697,12 @@ components: target_cost_dollars: $ref: '#/components/schemas/FixedPointDollars' description: Total value of the RFQ in dollars + target_cost_excludes_fees: + type: boolean + description: >- + True when the target cost is principal-only and quote sizes are + computed without reserving taker fees (fees are charged on top). + x-go-type-skip-optional-pointer: true status: type: string description: Current status of the RFQ (open, closed) @@ -7317,6 +7796,15 @@ components: $ref: '#/components/schemas/FixedPointDollars' description: The target cost for the RFQ in dollars x-go-type-skip-optional-pointer: true + target_cost_excludes_fees: + type: boolean + description: >- + Sizes quotes against the target cost as principal only (contracts = + target cost / price), with your taker fees charged on top of the + target cost. By default (false) the target cost caps principal plus + Kalshi fees, and quote sizes are reduced to make room for the fees. + Only valid together with a target cost. + x-go-type-skip-optional-pointer: true rest_remainder: type: boolean description: Whether to rest the remainder of the RFQ after execution @@ -7340,7 +7828,7 @@ components: properties: id: type: string - description: The ID of the newly created RFQ + description: UUID of the newly created RFQ. Pass it unchanged in subsequent requests. Quote: type: object @@ -7359,10 +7847,10 @@ components: properties: id: type: string - description: Unique identifier for the quote + description: UUID of the quote. Preserve the exact returned string. rfq_id: type: string - description: ID of the RFQ this quote is responding to + description: UUID of the RFQ this quote is responding to. creator_id: type: string description: Public communications ID of the quote creator @@ -7435,6 +7923,13 @@ components: rfq_target_cost_dollars: $ref: '#/components/schemas/FixedPointDollars' description: Total value requested in the RFQ in dollars + target_cost_excludes_fees: + type: boolean + description: >- + True when the RFQ's target cost is principal-only and the + contracts-offered sizes were computed without reserving taker fees + (fees are charged on top of the target cost). + x-go-type-skip-optional-pointer: true rfq_creator_order_id: type: string description: Order ID for the RFQ creator (private field) @@ -7490,7 +7985,7 @@ components: properties: rfq_id: type: string - description: The ID of the RFQ to quote on + description: The UUID of the RFQ to quote on. Pass the RFQ ID unchanged; malformed IDs return HTTP 400. yes_bid: type: string $ref: '#/components/schemas/FixedPointDollars' @@ -7516,7 +8011,7 @@ components: properties: id: type: string - description: The ID of the newly created quote + description: UUID of the newly created quote. Pass it unchanged in subsequent requests. AcceptQuoteRequest: type: object @@ -8829,6 +9324,7 @@ components: - frequency - title - category + - categories - tags - settlement_sources - contract_url @@ -8848,7 +9344,12 @@ components: description: Title describing the series. For full context use you should use this field with the title field of the events belonging to this series. category: type: string - description: Category specifies the category which this series belongs to. + description: Category is the primary category of this series. + categories: + type: array + items: + type: string + description: Categories is the list of discovery categories for this series. The `category` filter on Get Series List matches any entry in this list. May be empty. tags: type: array nullable: true diff --git a/specs/perps_asyncapi.yaml b/specs/perps_asyncapi.yaml index a1150d01..1fe04f1c 100644 --- a/specs/perps_asyncapi.yaml +++ b/specs/perps_asyncapi.yaml @@ -109,6 +109,7 @@ channels: - authenticated connection - market specification required via `market_ticker` or `market_tickers` - sends `orderbook_snapshot` first, then incremental `orderbook_delta` updates + - supports `get_snapshot` on `update_subscription` to request a fresh snapshot without changing the subscription messages: orderbookSnapshot: $ref: '#/components/messages/orderbookSnapshot' @@ -635,8 +636,8 @@ components: lastUpdateReason: type: string - enum: ["", "Decrease", "Amend", "MarginCancel", "SelfTradeCancel", "ExpiryCancel", "Trade", "PostOnlyCrossCancel"] - description: Margin order update reason when the delta corresponds to the authenticated user's order. + enum: ["Decrease", "Amend", "MarginCancel", "SelfTradeCancel", "ExpiryCancel", "CloseCancel", "HaltCancel", "Trade", "PostOnlyCrossCancel"] + description: Margin order update reason when the delta corresponds to the authenticated user's order. CloseCancel and HaltCancel are reserved; the current orderbook stream filters out these operations. The field is omitted when no reason applies. tickerPrice: type: object @@ -762,7 +763,7 @@ components: default: false action: type: string - enum: ["add_markets", "delete_markets"] + enum: ["add_markets", "delete_markets", "get_snapshot"] listSubscriptionsCommandPayload: type: object @@ -778,11 +779,11 @@ components: type: object required: ["type", "msg"] properties: - id: - $ref: '#/components/schemas/commandId' type: type: string const: "subscribed" + id: + $ref: '#/components/schemas/commandId' msg: type: object required: ["channel", "sid"] @@ -796,29 +797,29 @@ components: type: object required: ["sid", "seq", "type"] properties: + type: + type: string + const: "unsubscribed" id: $ref: '#/components/schemas/commandId' sid: $ref: '#/components/schemas/subscriptionId' seq: $ref: '#/components/schemas/sequenceNumber' - type: - type: string - const: "unsubscribed" okResponsePayload: type: object required: ["type"] properties: + type: + type: string + const: "ok" id: $ref: '#/components/schemas/commandId' sid: $ref: '#/components/schemas/subscriptionId' seq: $ref: '#/components/schemas/sequenceNumber' - type: - type: string - const: "ok" msg: type: object properties: @@ -826,16 +827,22 @@ components: type: array items: $ref: '#/components/schemas/marketTicker' + market_ids: + type: array + description: Full list of market IDs after update + items: + type: string + format: uuid listSubscriptionsResponsePayload: type: object - required: ["id", "type", "msg"] + required: ["type", "msg"] properties: - id: - $ref: '#/components/schemas/commandId' type: type: string const: "ok" + id: + $ref: '#/components/schemas/commandId' msg: type: array items: @@ -851,6 +858,9 @@ components: type: object required: ["type", "msg"] properties: + type: + type: string + const: "error" id: $ref: '#/components/schemas/commandId' sid: @@ -859,9 +869,6 @@ components: seq: $ref: '#/components/schemas/sequenceNumber' description: Present on subscription-scoped errors on sequenced channels. - type: - type: string - const: "error" msg: type: object required: ["code", "msg"] @@ -878,6 +885,14 @@ components: 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 + description: Optional market ticker associated with the error + market_tickers: + type: array + description: Optional market tickers associated with the error + items: + type: string marginOrderbookSnapshotPayload: type: object @@ -886,6 +901,9 @@ components: type: type: string const: "orderbook_snapshot" + id: + $ref: '#/components/schemas/commandId' + description: Present when replying to a get_snapshot command with an ID sid: $ref: '#/components/schemas/subscriptionId' seq: @@ -932,12 +950,12 @@ components: $ref: '#/components/schemas/lastUpdateReason' client_order_id: type: string - subaccount: - type: integer ts_ms: type: integer format: int64 description: Unix timestamp in milliseconds. + subaccount: + type: integer marginTickerPayload: type: object @@ -1122,7 +1140,7 @@ components: format: int64 description: Unix timestamp in milliseconds. created_ts_ms: - type: integer + type: [integer, "null"] format: int64 description: Unix timestamp in milliseconds. last_updated_ts_ms: diff --git a/specs/perps_openapi.yaml b/specs/perps_openapi.yaml index d5ee405f..49674ae1 100644 --- a/specs/perps_openapi.yaml +++ b/specs/perps_openapi.yaml @@ -55,6 +55,9 @@ paths: exchange. A cap with neither market_ticker nor asset_class applies across all markets; the remaining caps are scoped to a single market or a single asset class each. Every cap in scope for an order is enforced independently. Markets without a cap are omitted. + API keys bound to a single FCM subtrader may also call this endpoint: `subtrader_id` may be + omitted and defaults to the key's bound subtrader, and if supplied it must equal the bound + subtrader or the request is rejected. tags: - fcm security: @@ -64,10 +67,11 @@ paths: parameters: - name: subtrader_id in: query - required: true - description: The subtrader whose initial margin caps should be returned. Must belong to the requesting FCM. + required: false + description: The subtrader whose initial margin caps should be returned. Must belong to the requesting FCM. Required unless the API key is bound to a subtrader, in which case it defaults to the bound subtrader when omitted and must equal it when supplied. schema: type: string + x-go-type-skip-optional-pointer: true - name: market_ticker in: query required: false @@ -647,13 +651,16 @@ paths: get: operationId: GetMarginFills summary: Get Fills - description: Endpoint for retrieving the authenticated user's margin fills. + description: | + Endpoint for retrieving the authenticated user's margin fills. + Registered partners may also use a user OAuth access token with the explicitly granted read::compliance_partner scope. tags: - portfolio security: - kalshiAccessKey: [] kalshiAccessSignature: [] kalshiAccessTimestamp: [] + - kalshiOauthAccessToken: [] parameters: - name: subaccount in: query @@ -711,13 +718,16 @@ paths: get: operationId: GetMarginPositions summary: Get Positions - description: 'Endpoint for retrieving the authenticated user''s margin positions.' + description: | + Endpoint for retrieving the authenticated user's margin positions. + Registered partners may also use a user OAuth access token with the explicitly granted read::compliance_partner scope. tags: - portfolio security: - kalshiAccessKey: [] kalshiAccessSignature: [] kalshiAccessTimestamp: [] + - kalshiOauthAccessToken: [] parameters: - name: subaccount in: query @@ -1686,6 +1696,10 @@ paths: components: securitySchemes: + kalshiOauthAccessToken: + type: http + scheme: bearer + description: User OAuth access token with read::compliance_partner, issued to an explicitly authorized partner. Accepted only on margin fills and margin positions in this lane; generic read and partner client-credentials tokens do not grant access. kalshiAccessKey: type: apiKey in: header @@ -1752,7 +1766,7 @@ components: properties: subtrader_suffix: type: string - pattern: '^[a-z0-9]{1,16}$' + pattern: '^[A-Za-z0-9]{1,16}$' description: Suffix for the new subtrader. The full subtrader id is composed server-side as {user_id}_{subtrader_suffix}. CreateMarginFCMSubtraderResponse: @@ -2684,6 +2698,7 @@ components: - status - title - contract_size + - underlying_multiplier - tick_size - fractional_trading_enabled - schedule @@ -2699,6 +2714,9 @@ components: contract_size: type: string description: Fixed-point number with 6 decimal places + underlying_multiplier: + type: string + description: Underlying units per contract-size unit. tick_size: $ref: '#/components/schemas/FixedPointDollars' description: Minimum price increment in dollars. @@ -2780,6 +2798,15 @@ components: description: > Asset class grouping for this market. New asset classes may be added over time. Omitted when the market has no assigned class. + product_metadata: + type: object + additionalProperties: true + x-omitempty: true + x-go-type-skip-optional-pointer: true + description: Public metadata for this market. + example: + important_info: + markdown: "**Important information:** Review this market's trading schedule." schedule: $ref: '#/components/schemas/MarginMarketSchedule' diff --git a/specs/perps_scm_openapi.yaml b/specs/perps_scm_openapi.yaml index 5f6d4a4d..983b9582 100644 --- a/specs/perps_scm_openapi.yaml +++ b/specs/perps_scm_openapi.yaml @@ -249,6 +249,27 @@ paths: '404': { $ref: '#/components/responses/NotFoundError' } '500': { $ref: '#/components/responses/InternalServerError' } + /margin/estimate_maintenance_margin/metadata: + get: + operationId: GetMaintenanceMarginMetadata + summary: Get Maintenance Margin Metadata + description: Inputs to the maintenance margin calculation for an asset class. + + parameters: + - { name: asset_class, in: query, required: true, schema: { $ref: '#/components/schemas/AssetClass' } } + - { name: date, in: query, required: true, schema: { type: string, format: date }, description: 'Return the matrices calibrated on this Eastern-time day (YYYY-MM-DD), the liquidation parameters in effect at the end of that day, and the current subgroups of the markets they cover for the caller. Returns 404 if no matrices were calibrated that day.' } + responses: + '200': + description: Successful response + content: + application/json: + schema: { $ref: '#/components/schemas/GetMaintenanceMarginMetadataResponse' } + '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: get: operationId: GetSettlementEstimateByAssetClass @@ -274,6 +295,44 @@ paths: '403': { $ref: '#/components/responses/ForbiddenError' } '500': { $ref: '#/components/responses/InternalServerError' } + /margin/funding_estimate_by_asset_class: + get: + operationId: GetFundingEstimateByAssetClass + summary: Get Funding Estimate By Asset Class + description: >- + Estimated funding at the next scheduled funding time for the + authenticated clearing member, keyed by asset class. + responses: + '200': + description: Successful response + content: + application/json: + schema: { $ref: '#/components/schemas/GetFundingEstimateByAssetClassResponse' } + '401': { $ref: '#/components/responses/UnauthorizedError' } + '403': { $ref: '#/components/responses/ForbiddenError' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /margin/funding_schedule: + get: + operationId: GetMarginFundingSchedule + summary: Get Funding Schedule + description: >- + Returns an asset class's funding schedule as a cron expression + evaluated in US Eastern Time. Funding executes at each scheduled + tick; schedules differ by asset class. + parameters: + - { name: asset_class, in: query, required: true, schema: { $ref: '#/components/schemas/AssetClass' } } + responses: + '200': + description: Successful response + content: + application/json: + schema: { $ref: '#/components/schemas/GetMarginFundingScheduleResponse' } + '400': { $ref: '#/components/responses/BadRequestError' } + '401': { $ref: '#/components/responses/UnauthorizedError' } + '403': { $ref: '#/components/responses/ForbiddenError' } + '500': { $ref: '#/components/responses/InternalServerError' } + /margin/settlement_prices: get: operationId: GetSettlementPrices @@ -650,6 +709,10 @@ components: - settlement_periods - maintenance_margin - maintenance_margin_aggregate + - cash_balance_daily + - cash_activity_daily + - daily_financial_summary + - interest_on_collateral_monthly url: { type: string, description: Presigned download URL (omitted from logs). } date: type: string @@ -872,6 +935,52 @@ components: funding_addon_fp: { type: string, description: 'Funding margin add-on, in USD.' } liquidation_addon_fp: { type: string, description: 'Liquidation margin add-on, in USD.' } + MaintenanceMarginMatrix: + type: object + required: [market_tickers, returns] + properties: + market_tickers: { type: array, items: { type: string }, description: Markets covered by the matrix, in column order. } + returns: + type: array + items: { type: array, items: { type: number, format: double } } + description: One row per scenario; each row holds one return per entry in market_tickers. + + MaintenanceMarginMatrices: + type: object + properties: + hvar: { $ref: '#/components/schemas/MaintenanceMarginMatrix' } + apc: { $ref: '#/components/schemas/MaintenanceMarginMatrix' } + aug: { $ref: '#/components/schemas/MaintenanceMarginMatrix' } + funding: { $ref: '#/components/schemas/MaintenanceMarginMatrix' } + + MaintenanceMarginLiquidationConfig: + type: object + required: [market_ticker, market_impact_volatility, market_impact_coefficient, market_impact_exponent, market_impact_forecasted_volume, spread_rate] + properties: + market_ticker: { type: string } + market_impact_volatility: { type: number, format: double } + market_impact_coefficient: { type: number, format: double } + market_impact_exponent: { type: number, format: double } + market_impact_forecasted_volume: { type: number, format: double } + spread_rate: { type: number, format: double } + + GetMaintenanceMarginMetadataResponse: + type: object + required: [asset_class, base_tail_percentile, funding_tail_percentile, matrices, liquidation_configs, subgroups] + properties: + asset_class: { $ref: '#/components/schemas/AssetClass' } + base_tail_percentile: { type: number, format: double, description: Lower-tail percentile of the scenario P&L distribution used for the base margin. } + funding_tail_percentile: { type: number, format: double, description: Lower-tail percentile of the scenario P&L distribution used for the funding margin. } + matrices: { $ref: '#/components/schemas/MaintenanceMarginMatrices' } + liquidation_configs: + type: array + description: Liquidation margin parameters for each active market with a calibrated margin config. + items: { $ref: '#/components/schemas/MaintenanceMarginLiquidationConfig' } + subgroups: + type: array + description: Partition of the asset class's active markets; positions within a subgroup are margined together. + items: { type: array, items: { type: string } } + GetObligationHistoryResponse: type: object required: [obligations] @@ -999,6 +1108,55 @@ components: additionalProperties: { $ref: '#/components/schemas/AssetClassSettlementEstimate' } settlement_balance_centicents: { type: integer, format: int64, description: Current settlement buffer balance. } + MarketFundingEstimate: + type: object + required: [quantity_centicount, funding_amount_centicents] + properties: + quantity_centicount: { type: integer, format: int64 } + funding_amount_centicents: { type: integer, format: int64 } + + FundingEstimate: + type: object + required: [funding_amount_centicents] + properties: + funding_amount_centicents: { type: integer, format: int64 } + positions: + type: object + description: Keyed by market ticker; only present on subtrader and group breakdowns. + additionalProperties: { $ref: '#/components/schemas/MarketFundingEstimate' } + + AssetClassFundingEstimate: + type: object + required: [user_breakdown, omitted_subtrader_count, omitted_group_count, next_funding_time] + properties: + user_breakdown: { $ref: '#/components/schemas/FundingEstimate' } + subtrader_breakdowns: + type: object + additionalProperties: { $ref: '#/components/schemas/FundingEstimate' } + group_breakdowns: + type: object + additionalProperties: { $ref: '#/components/schemas/FundingEstimate' } + omitted_subtrader_count: { type: integer, format: int64 } + omitted_group_count: { type: integer, format: int64 } + next_funding_time: { type: string, format: date-time, description: Next scheduled funding time for this asset class. } + + GetFundingEstimateByAssetClassResponse: + type: object + required: [estimates] + properties: + estimates: + type: object + description: Keyed by asset class. + additionalProperties: { $ref: '#/components/schemas/AssetClassFundingEstimate' } + + GetMarginFundingScheduleResponse: + type: object + required: [schedule] + properties: + schedule: + type: string + description: Cron expression evaluated in US Eastern Time (e.g. "0 */8 * * *" = midnight, 8AM and 4PM ET). + GetSettlementPricesResponse: type: object required: [settlement_prices] @@ -1135,7 +1293,7 @@ components: 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. + description: Present only when the bound subtrader is missing a per-subtrader risk control - the initial-margin cap (margin lane) or the event-contract daily cap. The mint still succeeds; the warning names each missing control - an event-contract subtrader without a daily cap has its event-contract orders rejected until one is set, while a margin subtrader without an initial-margin cap is bounded only by firm-level risk limits. GenerateMarginFcmApiKeyRequest: type: object @@ -1159,7 +1317,7 @@ components: 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. + description: Present only when the bound subtrader is missing a per-subtrader risk control - the initial-margin cap (margin lane) or the event-contract daily cap. The mint still succeeds; the warning names each missing control - an event-contract subtrader without a daily cap has its event-contract orders rejected until one is set, while a margin subtrader without an initial-margin cap is bounded only by firm-level risk limits. MarginFcmApiKey: type: object diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 96668131..40ab4357 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -780,9 +780,7 @@ class Exclusion: path_template="/portfolio/intra_exchange_instance_transfers", ), MethodEndpointEntry( - sdk_method=( - "kalshi.resources.portfolio.PortfolioResource.intra_exchange_transfers_all" - ), + sdk_method=("kalshi.resources.portfolio.PortfolioResource.intra_exchange_transfers_all"), http_method="GET", path_template="/portfolio/intra_exchange_instance_transfers", ), @@ -849,6 +847,44 @@ class Exclusion: http_method="GET", path_template="/fcm/positions", ), + MethodEndpointEntry( + sdk_method="kalshi.resources.fcm.FcmResource.list_subtraders", + http_method="GET", + path_template="/fcm/subtraders", + ), + MethodEndpointEntry( + sdk_method="kalshi.resources.fcm.FcmResource.create_subtrader", + http_method="POST", + path_template="/fcm/subtraders", + request_body_schema="#/components/schemas/CreateFCMSubtraderRequest", + ), + MethodEndpointEntry( + sdk_method="kalshi.resources.fcm.FcmResource.blocked_categories", + http_method="GET", + path_template="/fcm/subtraders/blocked_categories", + ), + MethodEndpointEntry( + sdk_method="kalshi.resources.fcm.FcmResource.update_blocked_categories", + http_method="PUT", + path_template="/fcm/subtraders/blocked_categories", + request_body_schema="#/components/schemas/UpdateFCMSubtraderBlockedCategoriesRequest", + ), + MethodEndpointEntry( + sdk_method="kalshi.resources.fcm.FcmResource.event_contract_daily_cap", + http_method="GET", + path_template="/fcm/subtraders/event_contract_daily_cap", + ), + MethodEndpointEntry( + sdk_method="kalshi.resources.fcm.FcmResource.update_event_contract_daily_cap", + http_method="PUT", + path_template="/fcm/subtraders/event_contract_daily_cap", + request_body_schema="#/components/schemas/UpdateFCMEventContractDailyCapRequest", + ), + MethodEndpointEntry( + sdk_method="kalshi.resources.fcm.FcmResource.delete_event_contract_daily_cap", + http_method="DELETE", + path_template="/fcm/subtraders/event_contract_daily_cap", + ), # ── incentive programs ────────────────────────────────────────────────── MethodEndpointEntry( sdk_method="kalshi.resources.incentive_programs.IncentiveProgramsResource.list", @@ -1715,8 +1751,7 @@ class Exclusion: ), MethodEndpointEntry( sdk_method=( - "kalshi.perps.klear.resources.margin.MarginResource" - ".maintenance_margin_details_all" + "kalshi.perps.klear.resources.margin.MarginResource.maintenance_margin_details_all" ), http_method="GET", path_template="/margin/obligations/{obligation_id}/maintenance_margin_details", @@ -1733,8 +1768,7 @@ class Exclusion: ), MethodEndpointEntry( sdk_method=( - "kalshi.perps.klear.resources.margin.MarginResource" - ".settlement_estimate_by_asset_class" + "kalshi.perps.klear.resources.margin.MarginResource.settlement_estimate_by_asset_class" ), http_method="GET", path_template="/margin/settlement_estimate_by_asset_class", @@ -1836,6 +1870,26 @@ class Exclusion: http_method="DELETE", path_template="/fcm/margin/api_keys/{api_key_id}", ), + MethodEndpointEntry( + sdk_method=( + "kalshi.perps.klear.resources.margin.MarginResource" + ".estimate_maintenance_margin_metadata" + ), + http_method="GET", + path_template="/margin/estimate_maintenance_margin/metadata", + ), + MethodEndpointEntry( + sdk_method=( + "kalshi.perps.klear.resources.margin.MarginResource.funding_estimate_by_asset_class" + ), + http_method="GET", + path_template="/margin/funding_estimate_by_asset_class", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.funding_schedule", + http_method="GET", + path_template="/margin/funding_schedule", + ), ] # Shared perps exclusion allowlist (same ``(sdk_fqn, field) → Exclusion`` shape @@ -1856,7 +1910,7 @@ class Exclusion: ("kalshi.perps.models.orders.GetMarginOrdersResponse", "cursor"): Exclusion( reason=( "spec marks cursor required, but Kalshi omits the key on the final " - "page (rather than returning \"\") — kept optional so list_all() " + 'page (rather than returning "") — kept optional so list_all() ' "doesn't crash on the last page. Mirrors the GetMarginFillsResponse/" "GetMarginTradesResponse cursor handling." ), diff --git a/tests/_model_fixtures.py b/tests/_model_fixtures.py index 0c0e41f8..6753b6d2 100644 --- a/tests/_model_fixtures.py +++ b/tests/_model_fixtures.py @@ -186,6 +186,7 @@ def series_dict(**overrides: Any) -> dict[str, Any]: "frequency": "weekly", "title": "Series title", "category": "Politics", + "categories": ["Politics"], "tags": [], "contract_url": "", "contract_terms_url": "", @@ -499,6 +500,7 @@ def quote_created_payload_dict(**overrides: Any) -> dict[str, Any]: "quote_id": "q-1", "rfq_id": "rfq-1", "quote_creator_id": "user-1", + "rfq_creator_id": "user-2", "market_ticker": "MKT-A", "yes_bid_dollars": "0.5000", "no_bid_dollars": "0.5000", @@ -514,6 +516,7 @@ def quote_accepted_payload_dict(**overrides: Any) -> dict[str, Any]: "quote_id": "q-1", "rfq_id": "rfq-1", "quote_creator_id": "user-1", + "rfq_creator_id": "user-2", "market_ticker": "MKT-A", "yes_bid_dollars": "0.5000", "no_bid_dollars": "0.5000", @@ -538,7 +541,6 @@ def quote_executed_payload_dict(**overrides: Any) -> dict[str, Any]: return base - def market_lifecycle_payload_dict(**overrides: Any) -> dict[str, Any]: """Spec-shaped MarketLifecyclePayload msg dict. diff --git a/tests/perps/klear/test_margin.py b/tests/perps/klear/test_margin.py index edb46ed3..52834344 100644 --- a/tests/perps/klear/test_margin.py +++ b/tests/perps/klear/test_margin.py @@ -264,9 +264,7 @@ def test_happy_list(self, auth_klear_client: KlearClient) -> None: assert not isinstance(resp.obligations[0].amount_centicents, bool) assert not isinstance(resp.obligations[0].amount_centicents, Decimal) assert resp.obligations[0].asset_class == "Crypto" - assert resp.obligations[0].settlement_details[0].position_quantity_fp == Decimal( - "1.25" - ) + assert resp.obligations[0].settlement_details[0].position_quantity_fp == Decimal("1.25") assert resp.obligations[0].funding_payments[0].funding_amount_centicents == -50 assert resp.obligations[0].execution_time.tzinfo is not None auth_klear_client.close() @@ -444,9 +442,7 @@ def test_cursor_loop_guard(self, auth_klear_client: KlearClient) -> None: auth_klear_client.close() @respx.mock - async def test_async_all_paginates( - self, auth_async_klear_client: AsyncKlearClient - ) -> None: + async def test_async_all_paginates(self, auth_async_klear_client: AsyncKlearClient) -> None: responses = [ httpx.Response(200, json={"obligations": [_obligation()], "cursor": "C"}), httpx.Response(200, json={"obligations": [_obligation()], "cursor": ""}), @@ -540,17 +536,13 @@ def test_funding_payments_page(self, auth_klear_client: KlearClient) -> None: auth_klear_client.close() @respx.mock - def test_detail_limit_over_max_raises_before_http( - self, auth_klear_client: KlearClient - ) -> None: + def test_detail_limit_over_max_raises_before_http(self, auth_klear_client: KlearClient) -> None: with pytest.raises(ValueError): auth_klear_client.margin.settlement_details("ob1", limit=1001) auth_klear_client.close() @respx.mock - async def test_async_funding_payments( - self, auth_async_klear_client: AsyncKlearClient - ) -> None: + async def test_async_funding_payments(self, auth_async_klear_client: AsyncKlearClient) -> None: respx.get(f"{BASE}/margin/obligations/ob1/funding_payments").mock( return_value=httpx.Response( 200, @@ -700,14 +692,10 @@ def test_happy_page(self, auth_klear_client: KlearClient) -> None: @respx.mock def test_all_paginates(self, auth_klear_client: KlearClient) -> None: responses = [ - httpx.Response( - 200, json={"entries": [_balance_history_entry()], "cursor": "NEXT"} - ), + httpx.Response(200, json={"entries": [_balance_history_entry()], "cursor": "NEXT"}), httpx.Response(200, json={"entries": [_balance_history_entry()], "cursor": ""}), ] - route = respx.get(f"{BASE}/margin/settlement_balance_history").mock( - side_effect=responses - ) + route = respx.get(f"{BASE}/margin/settlement_balance_history").mock(side_effect=responses) items = list(auth_klear_client.margin.settlement_balance_history_all()) assert len(items) == 2 assert route.calls[1].request.url.params["cursor"] == "NEXT" @@ -729,17 +717,13 @@ def test_empty_entries(self, auth_klear_client: KlearClient) -> None: auth_klear_client.close() @respx.mock - async def test_async_all_paginates( - self, auth_async_klear_client: AsyncKlearClient - ) -> None: + async def test_async_all_paginates(self, auth_async_klear_client: AsyncKlearClient) -> None: responses = [ httpx.Response(200, json={"entries": [_balance_history_entry()], "cursor": "C"}), httpx.Response(200, json={"entries": [_balance_history_entry()], "cursor": ""}), ] respx.get(f"{BASE}/margin/settlement_balance_history").mock(side_effect=responses) - items = [ - e async for e in auth_async_klear_client.margin.settlement_balance_history_all() - ] + items = [e async for e in auth_async_klear_client.margin.settlement_balance_history_all()] assert len(items) == 2 await auth_async_klear_client.close() @@ -807,9 +791,7 @@ def test_post_not_retried(self) -> None: client.close() @respx.mock - async def test_async_happy_wire_shape( - self, auth_async_klear_client: AsyncKlearClient - ) -> None: + async def test_async_happy_wire_shape(self, auth_async_klear_client: AsyncKlearClient) -> None: route = respx.post(f"{BASE}/margin/withdraw_settlement_balance").mock( return_value=httpx.Response(200, json={"id": "wd-2"}) ) @@ -859,9 +841,7 @@ def test_400_missing_id(self, auth_klear_client: KlearClient) -> None: auth_klear_client.close() @respx.mock - async def test_async_failed_status( - self, auth_async_klear_client: AsyncKlearClient - ) -> None: + async def test_async_failed_status(self, auth_async_klear_client: AsyncKlearClient) -> None: respx.get(f"{BASE}/margin/settlement_balance_withdrawal").mock( return_value=httpx.Response( 200, @@ -912,13 +892,9 @@ def test_create_subtrader_group(self, auth_klear_client: KlearClient) -> None: 200, json={"group_id": "22222222-2222-2222-2222-222222222222"} ) ) - resp = auth_klear_client.margin.create_subtrader_group( - subtrader_ids=["st-a", "st-b"] - ) + resp = auth_klear_client.margin.create_subtrader_group(subtrader_ids=["st-a", "st-b"]) assert resp.group_id == "22222222-2222-2222-2222-222222222222" - assert json.loads(route.calls[0].request.content) == { - "subtrader_ids": ["st-a", "st-b"] - } + assert json.loads(route.calls[0].request.content) == {"subtrader_ids": ["st-a", "st-b"]} # Bearer injected assert "Authorization" in route.calls[0].request.headers auth_klear_client.close() @@ -929,12 +905,8 @@ def test_update_subtrader_group(self, auth_klear_client: KlearClient) -> None: route = respx.put(f"{BASE}/fcm/margin/subtrader_groups/{gid}").mock( return_value=httpx.Response(200, json={}) ) - auth_klear_client.margin.update_subtrader_group( - gid, subtrader_ids=["st-c"] - ) - assert json.loads(route.calls[0].request.content) == { - "subtrader_ids": ["st-c"] - } + auth_klear_client.margin.update_subtrader_group(gid, subtrader_ids=["st-c"]) + assert json.loads(route.calls[0].request.content) == {"subtrader_ids": ["st-c"]} auth_klear_client.close() @respx.mock @@ -963,9 +935,7 @@ class TestSettlementPrices: @respx.mock def test_happy(self, auth_klear_client: KlearClient) -> None: route = respx.get(f"{BASE}/margin/settlement_prices").mock( - return_value=httpx.Response( - 200, json={"settlement_prices": {"BTC-PERP": 650000000}} - ) + return_value=httpx.Response(200, json={"settlement_prices": {"BTC-PERP": 650000000}}) ) resp = auth_klear_client.margin.settlement_prices( asset_class="Crypto", @@ -994,9 +964,7 @@ def test_400_maps(self, auth_klear_client: KlearClient) -> None: return_value=httpx.Response(400, json={"error": {"code": "bad_time"}}) ) with pytest.raises(KalshiValidationError): - auth_klear_client.margin.settlement_prices( - asset_class="Crypto", settlement_time="nope" - ) + auth_klear_client.margin.settlement_prices(asset_class="Crypto", settlement_time="nope") auth_klear_client.close() @@ -1008,9 +976,7 @@ def test_kwargs(self, auth_klear_client: KlearClient) -> None: ) route = respx.post(f"{BASE}/margin/estimate_maintenance_margin").mock( - return_value=httpx.Response( - 200, json={"maintenance_margin_fp": "1234.5600"} - ) + return_value=httpx.Response(200, json={"maintenance_margin_fp": "1234.5600"}) ) pos = EstimatePortfolioMaintenanceMarginPosition( market_ticker="BTC-PERP", @@ -1054,9 +1020,7 @@ def test_requires_args(self, auth_klear_client: KlearClient) -> None: @respx.mock @pytest.mark.asyncio - async def test_async( - self, auth_async_klear_client: AsyncKlearClient - ) -> None: + async def test_async(self, auth_async_klear_client: AsyncKlearClient) -> None: from kalshi.perps.klear.models.margin import ( EstimatePortfolioMaintenanceMarginPosition, ) @@ -1116,15 +1080,11 @@ def test_happy(self, auth_klear_client: KlearClient) -> None: }, ) ) - page = auth_klear_client.margin.member_funding_payments( - funding_time="2026-09-01T16:00: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" - ) + assert dict(route.calls[0].request.url.params)["funding_time"] == ("2026-09-01T16:00:00Z") auth_klear_client.close() @respx.mock @@ -1156,9 +1116,7 @@ def test_list(self, auth_klear_client: KlearClient) -> None: ) 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" - } + assert dict(route.calls[0].request.url.params) == {"fcm_subtrader_id": "user_desk1"} auth_klear_client.close() @respx.mock @@ -1251,3 +1209,86 @@ def test_sends_date_and_clearing_type(self, auth_klear_client: KlearClient) -> N assert body["clearing_type"] == "FCM" assert resp.base_margin_fp == Decimal("8.0000") auth_klear_client.close() + + +class TestMaintenanceMarginMetadata: + @respx.mock + def test_forwards_query_and_parses(self, auth_klear_client: KlearClient) -> None: + import datetime + + route = respx.get(f"{BASE}/margin/estimate_maintenance_margin/metadata").mock( + return_value=httpx.Response( + 200, + json={ + "asset_class": "Crypto", + "base_tail_percentile": 0.01, + "funding_tail_percentile": 0.05, + "matrices": {"hvar": {"market_tickers": ["BTC-PERP"], "returns": [[-0.1]]}}, + "liquidation_configs": [ + { + "market_ticker": "BTC-PERP", + "market_impact_volatility": 0.2, + "market_impact_coefficient": 0.3, + "market_impact_exponent": 0.5, + "market_impact_forecasted_volume": 1.0, + "spread_rate": 0.01, + } + ], + "subgroups": [["BTC-PERP"]], + }, + ) + ) + resp = auth_klear_client.margin.estimate_maintenance_margin_metadata( + asset_class="Crypto", date=datetime.date(2026, 9, 20) + ) + assert dict(route.calls[0].request.url.params) == { + "asset_class": "Crypto", + "date": "2026-09-20", + } + assert resp.asset_class == "Crypto" + assert resp.matrices.hvar is not None + assert resp.matrices.hvar.market_tickers == ["BTC-PERP"] + auth_klear_client.close() + + +class TestFundingEstimateAndSchedule: + @respx.mock + def test_funding_estimate_by_asset_class(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/funding_estimate_by_asset_class").mock( + return_value=httpx.Response( + 200, + json={ + "estimates": { + "Crypto": { + "user_breakdown": {"funding_amount_centicents": 100}, + "omitted_subtrader_count": 0, + "omitted_group_count": 0, + "next_funding_time": "2026-09-20T12:00:00Z", + } + } + }, + ) + ) + resp = auth_klear_client.margin.funding_estimate_by_asset_class() + assert resp.estimates["Crypto"].user_breakdown.funding_amount_centicents == 100 + auth_klear_client.close() + + @respx.mock + def test_funding_schedule(self, auth_klear_client: KlearClient) -> None: + route = respx.get(f"{BASE}/margin/funding_schedule").mock( + return_value=httpx.Response(200, json={"schedule": "0 */8 * * *"}) + ) + resp = auth_klear_client.margin.funding_schedule(asset_class="Crypto") + assert resp.schedule == "0 */8 * * *" + assert dict(route.calls[0].request.url.params)["asset_class"] == "Crypto" + auth_klear_client.close() + + @respx.mock + @pytest.mark.asyncio + async def test_async_funding_schedule(self, auth_async_klear_client: AsyncKlearClient) -> None: + respx.get(f"{BASE}/margin/funding_schedule").mock( + return_value=httpx.Response(200, json={"schedule": "0 */8 * * *"}) + ) + resp = await auth_async_klear_client.margin.funding_schedule(asset_class="Crypto") + assert resp.schedule == "0 */8 * * *" + await auth_async_klear_client.close() diff --git a/tests/perps/test_markets.py b/tests/perps/test_markets.py index aa3a36d5..960ffaa1 100644 --- a/tests/perps/test_markets.py +++ b/tests/perps/test_markets.py @@ -34,6 +34,7 @@ def _market_dict(**overrides: object) -> dict[str, object]: "title": "Bitcoin Perpetual", "status": "active", "contract_size": "1.000000", + "underlying_multiplier": "1", "tick_size": "0.0100", "fractional_trading_enabled": True, # Spec requires schedule; null means 24/7. Populated object used as the @@ -102,6 +103,7 @@ def test_happy(self, perps_client: PerpsClient) -> None: assert m.status == "active" assert m.contract_size == Decimal("1.000000") assert isinstance(m.contract_size, Decimal) + assert m.underlying_multiplier == "1" assert m.tick_size == Decimal("0.0100") assert isinstance(m.tick_size, Decimal) assert m.leverage_estimate == Decimal("2.5") @@ -150,6 +152,7 @@ def test_null_leverage_and_missing_optionals(self, perps_client: PerpsClient) -> "title": "Ether Perpetual", "status": "inactive", "contract_size": "1.000000", + "underlying_multiplier": "1", "tick_size": "0.0100", "fractional_trading_enabled": False, # required key present, null value = 24/7 market @@ -248,9 +251,7 @@ def test_not_found_maps(self, perps_client: PerpsClient) -> None: @respx.mock def test_not_found_single_call(self, perps_client: PerpsClient) -> None: # 404 is non-retryable; GET only retries on 429/502/503/504. - route = respx.get(f"{BASE}/margin/markets/NOPE").mock( - return_value=httpx.Response(404) - ) + route = respx.get(f"{BASE}/margin/markets/NOPE").mock(return_value=httpx.Response(404)) with pytest.raises(KalshiNotFoundError): perps_client.markets.get("NOPE") assert route.call_count == 1 @@ -304,9 +305,7 @@ def test_params(self, perps_client: PerpsClient) -> None: @respx.mock def test_null_bids_asks(self, perps_client: PerpsClient) -> None: respx.get(f"{BASE}/margin/markets/BTC-PERP/orderbook").mock( - return_value=httpx.Response( - 200, json={"orderbook": {"bids": None, "asks": None}} - ) + return_value=httpx.Response(200, json={"orderbook": {"bids": None, "asks": None}}) ) ob = perps_client.markets.orderbook("BTC-PERP") assert ob.bids == [] @@ -323,9 +322,7 @@ def test_missing_bids_asks(self, perps_client: PerpsClient) -> None: @respx.mock def test_not_found_maps(self, perps_client: PerpsClient) -> None: - respx.get(f"{BASE}/margin/markets/NOPE/orderbook").mock( - return_value=httpx.Response(404) - ) + respx.get(f"{BASE}/margin/markets/NOPE/orderbook").mock(return_value=httpx.Response(404)) with pytest.raises(KalshiNotFoundError): perps_client.markets.orderbook("NOPE") @@ -394,9 +391,9 @@ def test_all_null_trade_prices(self, perps_client: PerpsClient) -> None: }, ) ) - c = perps_client.markets.candlesticks( - "BTC-PERP", start_ts=1, end_ts=2, period_interval=60 - )[0] + c = perps_client.markets.candlesticks("BTC-PERP", start_ts=1, end_ts=2, period_interval=60)[ + 0 + ] assert c.price.open is None assert c.price.close is None assert c.price.mean is None @@ -426,14 +423,10 @@ def test_missing_ticker_or_candlesticks_raises(self, perps_client: PerpsClient) perps_client.markets.candlesticks("BTC-PERP", start_ts=1, end_ts=2, period_interval=1) # A null candlesticks array coerces to [] (NullableList). route.mock( - return_value=httpx.Response( - 200, json={"ticker": "BTC-PERP", "candlesticks": None} - ) + return_value=httpx.Response(200, json={"ticker": "BTC-PERP", "candlesticks": None}) ) assert ( - perps_client.markets.candlesticks( - "BTC-PERP", start_ts=1, end_ts=2, period_interval=1 - ) + perps_client.markets.candlesticks("BTC-PERP", start_ts=1, end_ts=2, period_interval=1) == [] ) @@ -460,16 +453,12 @@ def test_include_latest_omitted_by_default(self, perps_client: PerpsClient) -> N route = respx.get(f"{BASE}/margin/markets/BTC-PERP/candlesticks").mock( return_value=httpx.Response(200, json={"ticker": "BTC-PERP", "candlesticks": []}) ) - perps_client.markets.candlesticks( - "BTC-PERP", start_ts=1, end_ts=2, period_interval=1 - ) + perps_client.markets.candlesticks("BTC-PERP", start_ts=1, end_ts=2, period_interval=1) assert "include_latest_before_start" not in dict(route.calls[0].request.url.params) @respx.mock def test_not_found_maps(self, perps_client: PerpsClient) -> None: - respx.get(f"{BASE}/margin/markets/NOPE/candlesticks").mock( - return_value=httpx.Response(404) - ) + respx.get(f"{BASE}/margin/markets/NOPE/candlesticks").mock(return_value=httpx.Response(404)) with pytest.raises(KalshiNotFoundError): perps_client.markets.candlesticks("NOPE", start_ts=1, end_ts=2, period_interval=1) @@ -481,9 +470,7 @@ def test_invalid_period_interval_server_rejected(self, perps_client: PerpsClient return_value=httpx.Response(400, json={"error": {"code": "invalid_parameter"}}) ) with pytest.raises(Exception): # noqa: B017 — mapped SDK validation error - perps_client.markets.candlesticks( - "BTC-PERP", start_ts=1, end_ts=2, period_interval=5 - ) + perps_client.markets.candlesticks("BTC-PERP", start_ts=1, end_ts=2, period_interval=5) @respx.mock async def test_async(self, async_perps_client: AsyncPerpsClient) -> None: diff --git a/tests/perps/ws/test_channels.py b/tests/perps/ws/test_channels.py index ebc174ed..a2f0f1ac 100644 --- a/tests/perps/ws/test_channels.py +++ b/tests/perps/ws/test_channels.py @@ -85,9 +85,7 @@ async def test_update_subscription_array_sids_form( ) -> None: conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) sub = await mgr.subscribe("ticker") - await mgr.update_subscription( - sub.client_id, "add_markets", market_tickers=["ETH-PERP"] - ) + await mgr.update_subscription(sub.client_id, "add_markets", market_tickers=["ETH-PERP"]) cmd = fake_perps_ws.received_commands[-1] assert cmd["cmd"] == "update_subscription" assert cmd["params"]["action"] == "add_markets" @@ -145,9 +143,7 @@ async def test_update_raises_on_error_ack( sub = await mgr.subscribe("ticker") fake_perps_ws.force_error = True with pytest.raises(KalshiSubscriptionError): - await mgr.update_subscription( - sub.client_id, "add_markets", market_tickers=["ETH-PERP"] - ) + await mgr.update_subscription(sub.client_id, "add_markets", market_tickers=["ETH-PERP"]) await conn.close() @@ -166,14 +162,28 @@ async def test_update_add_markets_persists_to_params( await conn.close() +async def test_update_get_snapshot_does_not_persist_markets( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) + sub = await mgr.subscribe("ticker", params={"market_tickers": ["A"]}) + await mgr.update_subscription( + sub.client_id, "get_snapshot", market_tickers=["B"] + ) + persisted = mgr.get_subscription(sub.client_id) + assert persisted is not None + assert persisted.params["market_tickers"] == ["A"] + cmd = fake_perps_ws.received_commands[-1] + assert cmd["params"]["action"] == "get_snapshot" + await conn.close() + + async def test_update_delete_markets_persists_to_params( fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth ) -> None: conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) sub = await mgr.subscribe("ticker", params={"market_tickers": ["A", "B"]}) - await mgr.update_subscription_single_sid( - sub.client_id, "delete_markets", market_tickers=["A"] - ) + await mgr.update_subscription_single_sid(sub.client_id, "delete_markets", market_tickers=["A"]) persisted = mgr.get_subscription(sub.client_id) assert persisted is not None assert persisted.params["market_tickers"] == ["B"] diff --git a/tests/test_communications.py b/tests/test_communications.py index 1bc4d95f..716d3053 100644 --- a/tests/test_communications.py +++ b/tests/test_communications.py @@ -63,7 +63,8 @@ def comms(test_auth: KalshiAuth, config: KalshiConfig) -> CommunicationsResource @pytest.fixture def async_comms( - test_auth: KalshiAuth, config: KalshiConfig, + test_auth: KalshiAuth, + config: KalshiConfig, ) -> AsyncCommunicationsResource: return AsyncCommunicationsResource(AsyncTransport(test_auth, config)) @@ -111,14 +112,18 @@ def unauth_comms(config: KalshiConfig) -> CommunicationsResource: class TestCommunicationsResponseModels: def test_rfq_accepts_fp_and_dollars_aliases(self) -> None: - rfq = RFQ.model_validate( - {**_MINIMAL_RFQ, "target_cost_dollars": "50.0000"} - ) + rfq = RFQ.model_validate({**_MINIMAL_RFQ, "target_cost_dollars": "50.0000"}) assert rfq.id == "rfq-1" assert rfq.contracts == Decimal("100") assert rfq.target_cost == Decimal("50.0000") assert rfq.status == "open" + def test_rfq_and_quote_target_cost_excludes_fees(self) -> None: + rfq = RFQ.model_validate({**_MINIMAL_RFQ, "target_cost_excludes_fees": True}) + quote = Quote.model_validate({**_MINIMAL_QUOTE, "target_cost_excludes_fees": True}) + assert rfq.target_cost_excludes_fees is True + assert quote.target_cost_excludes_fees is True + def test_rfq_accepts_short_name_aliases(self) -> None: rfq = RFQ.model_validate( { @@ -186,10 +191,21 @@ def test_create_rfq_request_omits_optional_fields(self) -> None: body = req.model_dump(exclude_none=True, by_alias=True, mode="json") assert body == {"market_ticker": "MKT-1", "rest_remainder": False} + def test_create_rfq_request_serializes_target_cost_excludes_fees(self) -> None: + req = CreateRFQRequest( + market_ticker="MKT-1", + rest_remainder=True, + target_cost_excludes_fees=True, + ) + body = req.model_dump(exclude_none=True, by_alias=True, mode="json") + assert body["target_cost_excludes_fees"] is True + def test_create_rfq_forbids_extra(self) -> None: with pytest.raises(ValidationError): CreateRFQRequest( # type: ignore[call-arg] - market_ticker="MKT-1", rest_remainder=True, phantom=1, + market_ticker="MKT-1", + rest_remainder=True, + phantom=1, ) def test_create_rfq_rejects_zero_contracts(self) -> None: @@ -261,7 +277,8 @@ def test_returns_paged_rfqs(self, comms: CommunicationsResource) -> None: "https://test.kalshi.com/trade-api/v2/communications/rfqs", ).mock( return_value=httpx.Response( - 200, json={"rfqs": [_MINIMAL_RFQ], "cursor": "next"}, + 200, + json={"rfqs": [_MINIMAL_RFQ], "cursor": "next"}, ), ) page = comms.list_rfqs(limit=10) @@ -290,7 +307,8 @@ def test_passes_filter_params(self, comms: CommunicationsResource) -> None: @respx.mock def test_list_all_rfqs_auto_paginates( - self, comms: CommunicationsResource, + self, + comms: CommunicationsResource, ) -> None: respx.get( "https://test.kalshi.com/trade-api/v2/communications/rfqs", @@ -304,7 +322,8 @@ def test_list_all_rfqs_auto_paginates( }, ), httpx.Response( - 200, json={"rfqs": [{**_MINIMAL_RFQ, "id": "rfq-3"}]}, + 200, + json={"rfqs": [{**_MINIMAL_RFQ, "id": "rfq-3"}]}, ), ], ) @@ -353,6 +372,19 @@ def test_sends_correct_body(self, comms: CommunicationsResource) -> None: "subaccount": 2, } + @respx.mock + def test_sends_target_cost_excludes_fees(self, comms: CommunicationsResource) -> None: + route = respx.post( + "https://test.kalshi.com/trade-api/v2/communications/rfqs", + ).mock(return_value=httpx.Response(201, json={"id": "rfq-new"})) + comms.create_rfq( + market_ticker="MKT-1", + rest_remainder=True, + target_cost_excludes_fees=True, + ) + body = json.loads(route.calls[0].request.content) + assert body["target_cost_excludes_fees"] is True + @respx.mock def test_omits_optional_fields(self, comms: CommunicationsResource) -> None: route = respx.post( @@ -364,7 +396,8 @@ def test_omits_optional_fields(self, comms: CommunicationsResource) -> None: @respx.mock def test_400_maps_to_validation_error( - self, comms: CommunicationsResource, + self, + comms: CommunicationsResource, ) -> None: respx.post( "https://test.kalshi.com/trade-api/v2/communications/rfqs", @@ -407,7 +440,9 @@ def test_passes_filters(self, comms: CommunicationsResource) -> None: "https://test.kalshi.com/trade-api/v2/communications/quotes", ).mock(return_value=httpx.Response(200, json={"quotes": []})) comms.list_quotes( - rfq_id="rfq-1", status="accepted", quote_creator_user_id="u1", + rfq_id="rfq-1", + status="accepted", + quote_creator_user_id="u1", ) params = route.calls[0].request.url.params assert params["rfq_id"] == "rfq-1" @@ -416,7 +451,8 @@ def test_passes_filters(self, comms: CommunicationsResource) -> None: @respx.mock def test_list_all_quotes_auto_paginates( - self, comms: CommunicationsResource, + self, + comms: CommunicationsResource, ) -> None: respx.get( "https://test.kalshi.com/trade-api/v2/communications/quotes", @@ -427,7 +463,8 @@ def test_list_all_quotes_auto_paginates( json={"quotes": [_MINIMAL_QUOTE], "cursor": "page2"}, ), httpx.Response( - 200, json={"quotes": [{**_MINIMAL_QUOTE, "id": "q-2"}]}, + 200, + json={"quotes": [{**_MINIMAL_QUOTE, "id": "q-2"}]}, ), ], ) @@ -435,7 +472,8 @@ def test_list_all_quotes_auto_paginates( assert [q.id for q in items] == ["q-1", "q-2"] def test_raises_without_creator_filter( - self, comms: CommunicationsResource, + self, + comms: CommunicationsResource, ) -> None: """Spec + demo require creator_user_id or rfq_creator_user_id. @@ -448,7 +486,8 @@ def test_raises_without_creator_filter( comms.list_quotes() def test_raises_without_creator_filter_even_with_rfq_id( - self, comms: CommunicationsResource, + self, + comms: CommunicationsResource, ) -> None: """rfq_id alone is not enough — v0.11.0 integration audit confirmed.""" with pytest.raises(ValueError): @@ -456,7 +495,8 @@ def test_raises_without_creator_filter_even_with_rfq_id( @respx.mock def test_rfq_creator_user_id_alone_is_sufficient( - self, comms: CommunicationsResource, + self, + comms: CommunicationsResource, ) -> None: respx.get( "https://test.kalshi.com/trade-api/v2/communications/quotes", @@ -465,7 +505,8 @@ def test_rfq_creator_user_id_alone_is_sufficient( assert page.items == [] def test_list_all_quotes_raises_without_creator_filter( - self, comms: CommunicationsResource, + self, + comms: CommunicationsResource, ) -> None: """Generator-returning variant must raise eagerly, not on first yield.""" with pytest.raises(ValueError): @@ -473,7 +514,8 @@ def test_list_all_quotes_raises_without_creator_filter( @respx.mock def test_user_filter_alone_is_sufficient( - self, comms: CommunicationsResource, + self, + comms: CommunicationsResource, ) -> None: """Spec v3.18.0 added user_filter='self' as a server-side shorthand for the caller's user-id, so it satisfies the filter requirement. @@ -486,7 +528,8 @@ def test_user_filter_alone_is_sufficient( @respx.mock def test_rfq_user_filter_alone_is_sufficient( - self, comms: CommunicationsResource, + self, + comms: CommunicationsResource, ) -> None: """rfq_user_filter='self' (filter to quotes responding to the caller's own RFQs) is also a valid standalone satisfier. @@ -498,7 +541,8 @@ def test_rfq_user_filter_alone_is_sufficient( assert page.items == [] def test_raises_lists_all_four_satisfiers( - self, comms: CommunicationsResource, + self, + comms: CommunicationsResource, ) -> None: """The updated error message must enumerate all four valid filters so callers know about the user_filter / rfq_user_filter shortcuts. @@ -701,7 +745,9 @@ async def test_create_rfq( "https://test.kalshi.com/trade-api/v2/communications/rfqs", ).mock(return_value=httpx.Response(201, json={"id": "rfq-9"})) resp = await async_comms.create_rfq( - market_ticker="MKT-1", rest_remainder=True, contracts=5, + market_ticker="MKT-1", + rest_remainder=True, + contracts=5, ) assert resp.id == "rfq-9" assert route.called @@ -720,7 +766,8 @@ async def test_list_all_rfqs_async( json={"rfqs": [_MINIMAL_RFQ], "cursor": "page2"}, ), httpx.Response( - 200, json={"rfqs": [{**_MINIMAL_RFQ, "id": "rfq-2"}]}, + 200, + json={"rfqs": [{**_MINIMAL_RFQ, "id": "rfq-2"}]}, ), ], ) @@ -836,7 +883,8 @@ async def test_delete_quote( assert route.called async def test_list_quotes_raises_without_creator_filter( - self, async_comms: AsyncCommunicationsResource, + self, + async_comms: AsyncCommunicationsResource, ) -> None: with pytest.raises( ValueError, @@ -845,7 +893,8 @@ async def test_list_quotes_raises_without_creator_filter( await async_comms.list_quotes() async def test_list_all_quotes_raises_without_creator_filter( - self, async_comms: AsyncCommunicationsResource, + self, + async_comms: AsyncCommunicationsResource, ) -> None: """Must raise at call time, not on first iteration.""" with pytest.raises(ValueError): @@ -877,61 +926,71 @@ async def test_list_quotes_rfq_user_filter_alone_is_sufficient( class TestCommunicationsAuthGuard: def test_get_id_requires_auth( - self, unauth_comms: CommunicationsResource, + self, + unauth_comms: CommunicationsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_comms.get_id() def test_list_rfqs_requires_auth( - self, unauth_comms: CommunicationsResource, + self, + unauth_comms: CommunicationsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_comms.list_rfqs() def test_list_all_rfqs_requires_auth( - self, unauth_comms: CommunicationsResource, + self, + unauth_comms: CommunicationsResource, ) -> None: with pytest.raises(AuthRequiredError): list(unauth_comms.list_all_rfqs()) def test_get_rfq_requires_auth( - self, unauth_comms: CommunicationsResource, + self, + unauth_comms: CommunicationsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_comms.get_rfq("rfq-1") def test_create_rfq_requires_auth( - self, unauth_comms: CommunicationsResource, + self, + unauth_comms: CommunicationsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_comms.create_rfq(market_ticker="MKT-1", rest_remainder=True) def test_delete_rfq_requires_auth( - self, unauth_comms: CommunicationsResource, + self, + unauth_comms: CommunicationsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_comms.delete_rfq("rfq-1") def test_list_quotes_requires_auth( - self, unauth_comms: CommunicationsResource, + self, + unauth_comms: CommunicationsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_comms.list_quotes() def test_list_all_quotes_requires_auth( - self, unauth_comms: CommunicationsResource, + self, + unauth_comms: CommunicationsResource, ) -> None: with pytest.raises(AuthRequiredError): list(unauth_comms.list_all_quotes()) def test_get_quote_requires_auth( - self, unauth_comms: CommunicationsResource, + self, + unauth_comms: CommunicationsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_comms.get_quote("q-1") def test_create_quote_requires_auth( - self, unauth_comms: CommunicationsResource, + self, + unauth_comms: CommunicationsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_comms.create_quote( @@ -942,43 +1001,50 @@ def test_create_quote_requires_auth( ) def test_delete_quote_requires_auth( - self, unauth_comms: CommunicationsResource, + self, + unauth_comms: CommunicationsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_comms.delete_quote("q-1") def test_accept_quote_requires_auth( - self, unauth_comms: CommunicationsResource, + self, + unauth_comms: CommunicationsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_comms.accept_quote("q-1", accepted_side="yes") def test_confirm_quote_requires_auth( - self, unauth_comms: CommunicationsResource, + self, + unauth_comms: CommunicationsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_comms.confirm_quote("q-1") def test_get_for_rfq_requires_auth( - self, unauth_comms: CommunicationsResource, + self, + unauth_comms: CommunicationsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_comms.quotes.get_for_rfq("r-1", "q-1") def test_delete_for_rfq_requires_auth( - self, unauth_comms: CommunicationsResource, + self, + unauth_comms: CommunicationsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_comms.quotes.delete_for_rfq("r-1", "q-1") def test_accept_for_rfq_requires_auth( - self, unauth_comms: CommunicationsResource, + self, + unauth_comms: CommunicationsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_comms.quotes.accept_for_rfq("r-1", "q-1", accepted_side="yes") def test_confirm_for_rfq_requires_auth( - self, unauth_comms: CommunicationsResource, + self, + unauth_comms: CommunicationsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_comms.quotes.confirm_for_rfq("r-1", "q-1") @@ -986,15 +1052,18 @@ def test_confirm_for_rfq_requires_auth( class TestClientWiring: def test_sync_client_exposes_communications( - self, client: KalshiClient, + self, + client: KalshiClient, ) -> None: assert isinstance(client.communications, CommunicationsResource) def test_async_client_exposes_communications( - self, async_client: AsyncKalshiClient, + self, + async_client: AsyncKalshiClient, ) -> None: assert isinstance( - async_client.communications, AsyncCommunicationsResource, + async_client.communications, + AsyncCommunicationsResource, ) @@ -1018,7 +1087,11 @@ def test_issue_324_communications_status_literal_narrowing(self) -> None: # status is a non-breaking expansion; removing one is breaking. assert set(get_args(RfqStatusLiteral)) == {"open", "closed"} assert set(get_args(QuoteStatusLiteral)) == { - "open", "accepted", "confirmed", "executed", "cancelled", + "open", + "accepted", + "confirmed", + "executed", + "cancelled", } def test_issue_324_status_literals_reexported_from_models_and_root(self) -> None: @@ -1032,7 +1105,8 @@ def test_issue_324_status_literals_reexported_from_models_and_root(self) -> None @respx.mock def test_issue_324_valid_rfq_status_flows_through_to_query( - self, comms: CommunicationsResource, + self, + comms: CommunicationsResource, ) -> None: route = respx.get( "https://test.kalshi.com/trade-api/v2/communications/rfqs", @@ -1042,7 +1116,8 @@ def test_issue_324_valid_rfq_status_flows_through_to_query( @respx.mock def test_issue_324_valid_quote_status_flows_through_to_query( - self, comms: CommunicationsResource, + self, + comms: CommunicationsResource, ) -> None: route = respx.get( "https://test.kalshi.com/trade-api/v2/communications/quotes", @@ -1089,7 +1164,8 @@ class TestV3DeprecationAliases: @respx.mock def test_issue_348_rfqs_sub_namespace_works( - self, comms: CommunicationsResource, + self, + comms: CommunicationsResource, ) -> None: assert isinstance(comms.rfqs, RFQsResource) respx.get( @@ -1113,7 +1189,8 @@ def test_issue_348_rfqs_sub_namespace_works( @respx.mock def test_issue_348_quotes_sub_namespace_works( - self, comms: CommunicationsResource, + self, + comms: CommunicationsResource, ) -> None: assert isinstance(comms.quotes, QuotesResource) respx.get( @@ -1140,7 +1217,8 @@ def test_issue_348_quotes_sub_namespace_works( comms.quotes.accept("q-1", accepted_side="yes") def test_issue_348_async_rfqs_sub_namespace_class( - self, async_comms: AsyncCommunicationsResource, + self, + async_comms: AsyncCommunicationsResource, ) -> None: # Wiring check — full async I/O is exercised in TestAsyncCommunications # via the deprecated forwarders, which delegate here. @@ -1149,7 +1227,8 @@ def test_issue_348_async_rfqs_sub_namespace_class( @respx.mock def test_issue_348_flat_names_still_work_emit_deprecation_warning( - self, comms: CommunicationsResource, + self, + comms: CommunicationsResource, ) -> None: respx.get( "https://test.kalshi.com/trade-api/v2/communications/rfqs", @@ -1286,7 +1365,8 @@ async def test_issue_348_async_flat_names_emit_deprecation_warning( with pytest.warns(DeprecationWarning, match=r"rfqs\.create"): new_rfq = await async_comms.create_rfq( - market_ticker="MKT-1", rest_remainder=True, + market_ticker="MKT-1", + rest_remainder=True, ) assert new_rfq.id == "rfq-new" @@ -1298,9 +1378,7 @@ async def test_issue_348_async_flat_names_emit_deprecation_warning( assert isinstance(page_quotes.items[0], Quote) with pytest.warns(DeprecationWarning, match=r"quotes\.list_all"): - quotes_all = [ - q async for q in async_comms.list_all_quotes(quote_creator_user_id="u1") - ] + quotes_all = [q async for q in async_comms.list_all_quotes(quote_creator_user_id="u1")] assert isinstance(quotes_all[0], Quote) with pytest.warns(DeprecationWarning, match=r"quotes\.get"): @@ -1549,9 +1627,7 @@ def test_400_maps_to_validation_error(self, comms: CommunicationsResource) -> No class TestAcceptBlockTradeProposal: @respx.mock def test_sends_post_with_empty_body(self, comms: CommunicationsResource) -> None: - route = respx.post(f"{_BTP_URL}/btp-1/accept").mock( - return_value=httpx.Response(204) - ) + route = respx.post(f"{_BTP_URL}/btp-1/accept").mock(return_value=httpx.Response(204)) comms.block_trade_proposals.accept("btp-1") assert route.called # empty AcceptBlockTradeProposalRequest serializes to {} @@ -1559,9 +1635,7 @@ def test_sends_post_with_empty_body(self, comms: CommunicationsResource) -> None @respx.mock def test_sends_post_with_subaccount(self, comms: CommunicationsResource) -> None: - route = respx.post(f"{_BTP_URL}/btp-1/accept").mock( - return_value=httpx.Response(204) - ) + route = respx.post(f"{_BTP_URL}/btp-1/accept").mock(return_value=httpx.Response(204)) comms.block_trade_proposals.accept("btp-1", subaccount=2) body = json.loads(route.calls[0].request.content) assert body == {"subaccount": 2} @@ -1577,7 +1651,9 @@ def test_404(self, comms: CommunicationsResource) -> None: class TestAsyncBlockTradeProposals: async def test_list( - self, async_comms: AsyncCommunicationsResource, respx_mock: respx.MockRouter, + self, + async_comms: AsyncCommunicationsResource, + respx_mock: respx.MockRouter, ) -> None: respx_mock.get(_BTP_URL).mock( return_value=httpx.Response(200, json={"block_trade_proposals": [_MINIMAL_BTP]}) @@ -1587,7 +1663,9 @@ async def test_list( assert isinstance(page.items[0], BlockTradeProposal) async def test_list_all( - self, async_comms: AsyncCommunicationsResource, respx_mock: respx.MockRouter, + self, + async_comms: AsyncCommunicationsResource, + respx_mock: respx.MockRouter, ) -> None: respx_mock.get(_BTP_URL).mock( side_effect=[ @@ -1603,7 +1681,9 @@ async def test_list_all( assert ids == ["btp-1", "btp-2"] async def test_create( - self, async_comms: AsyncCommunicationsResource, respx_mock: respx.MockRouter, + self, + async_comms: AsyncCommunicationsResource, + respx_mock: respx.MockRouter, ) -> None: route = respx_mock.post(_BTP_URL).mock( return_value=httpx.Response(201, json={"block_trade_proposal_id": "btp-9"}) @@ -1621,11 +1701,11 @@ async def test_create( assert route.called async def test_accept( - self, async_comms: AsyncCommunicationsResource, respx_mock: respx.MockRouter, + self, + async_comms: AsyncCommunicationsResource, + respx_mock: respx.MockRouter, ) -> None: - route = respx_mock.post(f"{_BTP_URL}/btp-1/accept").mock( - return_value=httpx.Response(204) - ) + route = respx_mock.post(f"{_BTP_URL}/btp-1/accept").mock(return_value=httpx.Response(204)) await async_comms.block_trade_proposals.accept("btp-1", subtrader_id="st-1") body = json.loads(route.calls[0].request.content) assert body == {"subtrader_id": "st-1"} diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 398b8ffa..7b747769 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -692,9 +692,12 @@ def _sdk_type_kind(ann: Any) -> str: # before any frame parses, so resolve the forward-ref class-name here # so a date-time field is classified ``datetime``, not ``unknown``. if isinstance(base, typing.ForwardRef) and base.__forward_arg__ in ( - "datetime", "datetime | None", - "AwareDatetime", "AwareDatetime | None", - "NaiveDatetime", "NaiveDatetime | None", + "datetime", + "datetime | None", + "AwareDatetime", + "AwareDatetime | None", + "NaiveDatetime", + "NaiveDatetime | None", ): return "datetime" origin = typing.get_origin(base) @@ -766,11 +769,7 @@ def _ws_field_type_violations( # Rule 2: spec string with format=date-time (ISO timestamp) must be str # on the SDK. An int-typed SDK field rejects the wire string # "2026-04-19T18:43:37.662364Z". - if ( - spec_type == "string" - and spec_format == "date-time" - and sdk_kind not in ("str", "datetime") - ): + if spec_type == "string" and spec_format == "date-time" and sdk_kind not in ("str", "datetime"): problems.append( f"{sdk_name!r}: spec '{spec_name}' is string (date-time), " f"SDK typed as {sdk_kind}. Use str or datetime." @@ -1451,6 +1450,15 @@ def _assert_params_match( "#/components/schemas/SetTargetBalanceAllocationRequest": ( "kalshi.models.portfolio.SetTargetBalanceAllocationRequest" ), + "#/components/schemas/CreateFCMSubtraderRequest": ( + "kalshi.models.fcm.CreateFCMSubtraderRequest" + ), + "#/components/schemas/UpdateFCMSubtraderBlockedCategoriesRequest": ( + "kalshi.models.fcm.UpdateFCMSubtraderBlockedCategoriesRequest" + ), + "#/components/schemas/UpdateFCMEventContractDailyCapRequest": ( + "kalshi.models.fcm.UpdateFCMEventContractDailyCapRequest" + ), } @@ -1897,9 +1905,7 @@ def test_exclusion_map_is_current() -> None: # a rename silently masks an upstream param removal/rename — the entry # keeps suppressing drift for a param the spec no longer has. if excl.kind == "kwarg_rename" and name not in sdk_params: - map_entry = next( - (e for e in METHOD_ENDPOINT_MAP if e.sdk_method == fqn), None - ) + map_entry = next((e for e in METHOD_ENDPOINT_MAP if e.sdk_method == fqn), None) if map_entry is None: stale.append( f"EXCLUSIONS[{(fqn, name)}] is a kwarg_rename but {fqn} has " @@ -2118,8 +2124,7 @@ def _assert_perps_response_drift(entry: ContractEntry, spec: dict[str, Any]) -> problems = additive_required + required_issues if problems: pytest.fail( - f"Perps spec drift in {entry.sdk_model}:\n" - + "\n".join(f" - {p}" for p in problems) + f"Perps spec drift in {entry.sdk_model}:\n" + "\n".join(f" - {p}" for p in problems) ) diff --git a/tests/test_fcm.py b/tests/test_fcm.py index a87dd971..88f592c3 100644 --- a/tests/test_fcm.py +++ b/tests/test_fcm.py @@ -2,6 +2,9 @@ from __future__ import annotations +import json +from decimal import Decimal + import httpx import pytest import respx @@ -275,3 +278,107 @@ async def test_positions_all_paginates(self, async_fcm: AsyncFcmResource) -> Non ) tickers = [p.ticker async for p in async_fcm.positions_all(subtrader_id="sub-1")] assert tickers == ["A", "B"] + + +class TestFcmSubtraders: + @respx.mock + def test_list_subtraders(self, fcm: FcmResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/fcm/subtraders").mock( + return_value=httpx.Response( + 200, + json={ + "subtraders": [ + { + "subtrader_id": "acct_desk1", + "exchange_indices": [0], + "trading_blocked": False, + "fcm_trading_blocked": False, + "propagation_pending": False, + } + ] + }, + ) + ) + resp = fcm.list_subtraders() + assert resp.subtraders[0].subtrader_id == "acct_desk1" + + @respx.mock + def test_create_subtrader(self, fcm: FcmResource) -> None: + route = respx.post("https://test.kalshi.com/trade-api/v2/fcm/subtraders").mock( + return_value=httpx.Response(200, json={"subtrader_id": "acct_desk1"}) + ) + resp = fcm.create_subtrader(subtrader_suffix="desk1") + assert resp.subtrader_id == "acct_desk1" + assert json.loads(route.calls[0].request.content) == {"subtrader_suffix": "desk1"} + + def test_create_subtrader_requires_suffix(self, fcm: FcmResource) -> None: + with pytest.raises(TypeError, match="create_subtrader"): + fcm.create_subtrader() # type: ignore[call-overload] + + @respx.mock + def test_blocked_categories_roundtrip(self, fcm: FcmResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/fcm/subtraders/blocked_categories").mock( + return_value=httpx.Response(200, json={"categories": ["Politics"]}) + ) + got = fcm.blocked_categories(subtrader_id="acct_desk1") + assert got.categories == ["Politics"] + route = respx.put( + "https://test.kalshi.com/trade-api/v2/fcm/subtraders/blocked_categories" + ).mock(return_value=httpx.Response(200, json={"categories": ["Politics", "Sports"]})) + updated = fcm.update_blocked_categories( + subtrader_id="acct_desk1", category="Sports", blocked=True + ) + assert updated.categories == ["Politics", "Sports"] + body = json.loads(route.calls[0].request.content) + assert body == { + "subtrader_id": "acct_desk1", + "category": "Sports", + "blocked": True, + } + + @respx.mock + def test_event_contract_daily_cap_roundtrip(self, fcm: FcmResource) -> None: + respx.get( + "https://test.kalshi.com/trade-api/v2/fcm/subtraders/event_contract_daily_cap" + ).mock( + return_value=httpx.Response( + 200, + json={ + "subtrader_id": "acct_desk1", + "limit": "10000.0000", + "executed_utilization": "100.0000", + "resting_order_utilization": "50.0000", + "pending_order_utilization": "25.0000", + "cap_date": "2026-09-20", + }, + ) + ) + got = fcm.event_contract_daily_cap(subtrader_id="acct_desk1") + assert got.limit == Decimal("10000.0000") + route = respx.put( + "https://test.kalshi.com/trade-api/v2/fcm/subtraders/event_contract_daily_cap" + ).mock(return_value=httpx.Response(200, json={})) + fcm.update_event_contract_daily_cap(subtrader_id="acct_desk1", limit="5000.00") + assert json.loads(route.calls[0].request.content) == { + "subtrader_id": "acct_desk1", + "limit": "5000.00", + } + delete = respx.delete( + "https://test.kalshi.com/trade-api/v2/fcm/subtraders/event_contract_daily_cap" + ).mock(return_value=httpx.Response(200, json={})) + fcm.delete_event_contract_daily_cap(subtrader_id="acct_desk1") + assert dict(delete.calls[0].request.url.params)["subtrader_id"] == "acct_desk1" + + @respx.mock + @pytest.mark.asyncio + async def test_async_list_and_create(self, async_fcm: AsyncFcmResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/fcm/subtraders").mock( + return_value=httpx.Response(200, json={"subtraders": []}) + ) + respx.post("https://test.kalshi.com/trade-api/v2/fcm/subtraders").mock( + return_value=httpx.Response(200, json={"subtrader_id": "acct_a"}) + ) + listed = await async_fcm.list_subtraders() + created = await async_fcm.create_subtrader(subtrader_suffix="a") + assert listed.subtraders == [] + assert created.subtrader_id == "acct_a" diff --git a/tests/test_historical.py b/tests/test_historical.py index edcf6166..82691413 100644 --- a/tests/test_historical.py +++ b/tests/test_historical.py @@ -79,9 +79,7 @@ def test_returns_cutoff(self, historical: HistoricalResource) -> None: assert cutoff.market_positions_last_updated_ts is None @respx.mock - def test_returns_cutoff_with_market_positions_ts( - self, historical: HistoricalResource - ) -> None: + def test_returns_cutoff_with_market_positions_ts(self, historical: HistoricalResource) -> None: """Spec v3.26.0: optional market_positions_last_updated_ts archival boundary.""" respx.get(f"{BASE}/historical/cutoff").mock( return_value=httpx.Response( @@ -333,6 +331,15 @@ def test_fills_with_max_ts(self, historical: HistoricalResource) -> None: assert params["ticker"] == "MKT-A" assert params["max_ts"] == "1700099999" + @respx.mock + def test_fills_with_min_ts(self, historical: HistoricalResource) -> None: + route = respx.get(f"{BASE}/historical/fills").mock( + return_value=httpx.Response(200, json={"fills": []}) + ) + historical.fills(ticker="MKT-A", min_ts=1700000000) + params = dict(route.calls[0].request.url.params) + assert params["min_ts"] == "1700000000" + class TestHistoricalOrders: @respx.mock @@ -374,6 +381,15 @@ def test_orders_with_max_ts(self, historical: HistoricalResource) -> None: assert params["ticker"] == "MKT-A" assert params["max_ts"] == "1700099999" + @respx.mock + def test_orders_with_min_ts(self, historical: HistoricalResource) -> None: + route = respx.get(f"{BASE}/historical/orders").mock( + return_value=httpx.Response(200, json={"orders": []}) + ) + historical.orders(ticker="MKT-A", min_ts=1700000000) + params = dict(route.calls[0].request.url.params) + assert params["min_ts"] == "1700000000" + class TestHistoricalTrades: @respx.mock @@ -1000,9 +1016,7 @@ async def test_positions_requires_auth( @respx.mock @pytest.mark.asyncio - async def test_positions_all_paginates( - self, async_historical: AsyncHistoricalResource - ) -> None: + async def test_positions_all_paginates(self, async_historical: AsyncHistoricalResource) -> None: respx.get(f"{BASE}/historical/positions").mock( side_effect=[ httpx.Response( diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index 6b69bbe2..6b5b7156 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -1356,11 +1356,7 @@ class TestPortfolioIntraExchangeTransfers: def test_returns_page(self, portfolio: PortfolioResource) -> None: respx.get( "https://test.kalshi.com/trade-api/v2/portfolio/intra_exchange_instance_transfers" - ).mock( - return_value=httpx.Response( - 200, json={"transfers": [_TRANSFER], "cursor": "next"} - ) - ) + ).mock(return_value=httpx.Response(200, json={"transfers": [_TRANSFER], "cursor": "next"})) page = portfolio.intra_exchange_transfers(limit=10) assert len(page.items) == 1 t = page.items[0] @@ -1411,11 +1407,7 @@ class TestAsyncPortfolioIntraExchangeTransfers: async def test_returns_page(self, async_portfolio: AsyncPortfolioResource) -> None: respx.get( "https://test.kalshi.com/trade-api/v2/portfolio/intra_exchange_instance_transfers" - ).mock( - return_value=httpx.Response( - 200, json={"transfers": [_TRANSFER], "cursor": ""} - ) - ) + ).mock(return_value=httpx.Response(200, json={"transfers": [_TRANSFER], "cursor": ""})) page = await async_portfolio.intra_exchange_transfers() assert len(page.items) == 1 assert page.items[0].transfer_id == "xfer-1" @@ -1431,9 +1423,7 @@ async def test_get_by_id(self, async_portfolio: AsyncPortfolioResource) -> None: assert t.status == "complete" @pytest.mark.asyncio - async def test_requires_auth( - self, unauth_async_portfolio: AsyncPortfolioResource - ) -> None: + async def test_requires_auth(self, unauth_async_portfolio: AsyncPortfolioResource) -> None: with pytest.raises(AuthRequiredError): await unauth_async_portfolio.intra_exchange_transfers() @@ -1441,18 +1431,20 @@ async def test_requires_auth( class TestTargetBalanceAllocation: @respx.mock def test_get(self, portfolio: PortfolioResource) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/target_balance_allocation" - ).mock( + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/target_balance_allocation").mock( return_value=httpx.Response( 200, - json={"allocations": [{"exchange_index": 0, "percent": 100}]}, + json={ + "allocations": [{"exchange_index": 0, "percent": 100}], + "resting_margin_reservation": "sum", + }, ) ) resp = portfolio.target_balance_allocation() assert len(resp.allocations) == 1 assert resp.allocations[0].exchange_index == 0 assert resp.allocations[0].percent == 100 + assert resp.resting_margin_reservation == "sum" @respx.mock def test_set_kwargs(self, portfolio: PortfolioResource) -> None: @@ -1521,17 +1513,17 @@ def test_requires_auth(self, unauth_portfolio: PortfolioResource) -> None: @respx.mock @pytest.mark.asyncio - async def test_async_roundtrip( - self, async_portfolio: AsyncPortfolioResource - ) -> None: + async def test_async_roundtrip(self, async_portfolio: AsyncPortfolioResource) -> None: from kalshi.models.portfolio import TargetBalanceAllocationInput - respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/target_balance_allocation" - ).mock(return_value=httpx.Response(200, json={"allocations": []})) - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/target_balance_allocation" - ).mock(return_value=httpx.Response(200, json={})) + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/target_balance_allocation").mock( + return_value=httpx.Response( + 200, json={"allocations": [], "resting_margin_reservation": "sum"} + ) + ) + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/target_balance_allocation").mock( + return_value=httpx.Response(200, json={}) + ) resp = await async_portfolio.target_balance_allocation() assert resp.allocations == [] await async_portfolio.set_target_balance_allocation( diff --git a/tests/test_series.py b/tests/test_series.py index fadd12c7..f4fedf55 100644 --- a/tests/test_series.py +++ b/tests/test_series.py @@ -40,6 +40,7 @@ def unauth_series(config: KalshiConfig) -> SeriesResource: "frequency": "quarterly", "title": "GDP Report", "category": "Economics", + "categories": ["Economics"], "tags": ["gdp"], "settlement_sources": [], "contract_url": "", @@ -63,9 +64,7 @@ def test_list_returns_series(self, series_resource: SeriesResource) -> None: @respx.mock def test_list_empty(self, series_resource: SeriesResource) -> None: - respx.get(f"{BASE}/series").mock( - return_value=httpx.Response(200, json={"series": []}) - ) + respx.get(f"{BASE}/series").mock(return_value=httpx.Response(200, json={"series": []})) result = series_resource.list() assert result == [] @@ -104,15 +103,20 @@ class TestSeriesFeeChanges: @respx.mock def test_fee_changes(self, series_resource: SeriesResource) -> None: respx.get(f"{BASE}/series/fee_changes").mock( - return_value=httpx.Response(200, json={ - "series_fee_change_arr": [{ - "id": "fc-1", - "series_ticker": "ECON-GDP", - "fee_type": "flat", - "fee_multiplier": 0.5, - "scheduled_ts": "2026-05-01T00:00:00Z", - }] - }) + return_value=httpx.Response( + 200, + json={ + "series_fee_change_arr": [ + { + "id": "fc-1", + "series_ticker": "ECON-GDP", + "fee_type": "flat", + "fee_multiplier": 0.5, + "scheduled_ts": "2026-05-01T00:00:00Z", + } + ] + }, + ) ) result = series_resource.fee_changes() assert len(result) == 1 @@ -133,14 +137,23 @@ class TestSeriesEventCandlesticks: @respx.mock def test_event_candlesticks(self, series_resource: SeriesResource) -> None: respx.get(f"{BASE}/series/SER/events/EVT/candlesticks").mock( - return_value=httpx.Response(200, json={ - "market_tickers": ["MKT-A"], - "market_candlesticks": [[candlestick_dict(end_period_ts=1000, volume_fp="10.00")]], - "adjusted_end_ts": 2000, - }) + return_value=httpx.Response( + 200, + json={ + "market_tickers": ["MKT-A"], + "market_candlesticks": [ + [candlestick_dict(end_period_ts=1000, volume_fp="10.00")] + ], + "adjusted_end_ts": 2000, + }, + ) ) ec = series_resource.event_candlesticks( - "SER", "EVT", start_ts=100, end_ts=200, period_interval=60, + "SER", + "EVT", + start_ts=100, + end_ts=200, + period_interval=60, ) assert ec.market_tickers == ["MKT-A"] assert len(ec.market_candlesticks) == 1 @@ -159,7 +172,11 @@ def test_event_candlesticks_kwarg_uses_ticker_name( ) # New name works: series_resource.event_candlesticks( - "SER", ticker="EVT", start_ts=100, end_ts=200, period_interval=60, + "SER", + ticker="EVT", + start_ts=100, + end_ts=200, + period_interval=60, ) def test_event_candlesticks_event_ticker_kwarg_removed( @@ -184,22 +201,34 @@ class TestSeriesForecastPercentileHistory: @respx.mock def test_happy_path(self, series_resource: SeriesResource) -> None: respx.get(f"{BASE}/series/SER/events/EVT/forecast_percentile_history").mock( - return_value=httpx.Response(200, json={ - "forecast_history": [{ - "event_ticker": "EVT", - "end_period_ts": 12345, - "period_interval": 60, - "percentile_points": [{ - "percentile": 5000, - "raw_numerical_forecast": 3.0, - "numerical_forecast": 3.0, - "formatted_forecast": "3.0%", - }], - }] - }) + return_value=httpx.Response( + 200, + json={ + "forecast_history": [ + { + "event_ticker": "EVT", + "end_period_ts": 12345, + "period_interval": 60, + "percentile_points": [ + { + "percentile": 5000, + "raw_numerical_forecast": 3.0, + "numerical_forecast": 3.0, + "formatted_forecast": "3.0%", + } + ], + } + ] + }, + ) ) result = series_resource.forecast_percentile_history( - "SER", "EVT", percentiles=[5000], start_ts=100, end_ts=200, period_interval=60, + "SER", + "EVT", + percentiles=[5000], + start_ts=100, + end_ts=200, + period_interval=60, ) assert len(result) == 1 assert result[0].percentile_points[0].percentile == 5000 @@ -207,7 +236,12 @@ def test_happy_path(self, series_resource: SeriesResource) -> None: def test_auth_guard(self, unauth_series: SeriesResource) -> None: with pytest.raises(AuthRequiredError): unauth_series.forecast_percentile_history( - "SER", "EVT", percentiles=[5000], start_ts=100, end_ts=200, period_interval=60, + "SER", + "EVT", + percentiles=[5000], + start_ts=100, + end_ts=200, + period_interval=60, ) def test_event_ticker_kwarg_removed(self, series_resource: SeriesResource) -> None: @@ -223,17 +257,13 @@ def test_event_ticker_kwarg_removed(self, series_resource: SeriesResource) -> No ) @respx.mock - def test_percentiles_serialized_as_explode_true( - self, series_resource: SeriesResource - ) -> None: + def test_percentiles_serialized_as_explode_true(self, series_resource: SeriesResource) -> None: """Spec at openapi.yaml:1832 says style:form, explode:true. Wire must be ?percentiles=25&percentiles=50 (NOT comma-joined). Prevents future regression if someone "simplifies" to a comma-join. """ - route = respx.get( - f"{BASE}/series/SER/events/EVT/forecast_percentile_history" - ).mock( + route = respx.get(f"{BASE}/series/SER/events/EVT/forecast_percentile_history").mock( return_value=httpx.Response(200, json={"forecast_history": []}) ) series_resource.forecast_percentile_history( @@ -249,9 +279,7 @@ def test_percentiles_serialized_as_explode_true( assert url.count("percentiles=") == 3 # extract the values values = sorted( - v - for k, v in route.calls[0].request.url.params.multi_items() - if k == "percentiles" + v for k, v in route.calls[0].request.url.params.multi_items() if k == "percentiles" ) assert values == ["25", "50", "75"] @@ -296,14 +324,21 @@ async def test_fee_changes(self, async_series: AsyncSeriesResource) -> None: @pytest.mark.asyncio async def test_event_candlesticks(self, async_series: AsyncSeriesResource) -> None: respx.get(f"{BASE}/series/SER/events/EVT/candlesticks").mock( - return_value=httpx.Response(200, json={ - "market_tickers": [], - "market_candlesticks": [], - "adjusted_end_ts": 0, - }) + return_value=httpx.Response( + 200, + json={ + "market_tickers": [], + "market_candlesticks": [], + "adjusted_end_ts": 0, + }, + ) ) ec = await async_series.event_candlesticks( - "SER", "EVT", start_ts=0, end_ts=1, period_interval=1, + "SER", + "EVT", + start_ts=0, + end_ts=1, + period_interval=1, ) assert ec.market_tickers == [] @@ -314,7 +349,12 @@ async def test_forecast_percentile_history(self, async_series: AsyncSeriesResour return_value=httpx.Response(200, json={"forecast_history": []}) ) result = await async_series.forecast_percentile_history( - "SER", "EVT", percentiles=[5000], start_ts=0, end_ts=1, period_interval=60, + "SER", + "EVT", + percentiles=[5000], + start_ts=0, + end_ts=1, + period_interval=60, ) assert result == [] @@ -353,9 +393,9 @@ async def test_percentiles_serialized_as_explode_true( self, async_series: AsyncSeriesResource ) -> None: """Spec at openapi.yaml:1832 says style:form, explode:true.""" - route = respx.get( - f"{BASE}/series/SER/events/EVT/forecast_percentile_history" - ).mock(return_value=httpx.Response(200, json={"forecast_history": []})) + route = respx.get(f"{BASE}/series/SER/events/EVT/forecast_percentile_history").mock( + return_value=httpx.Response(200, json={"forecast_history": []}) + ) await async_series.forecast_percentile_history( "SER", "EVT", @@ -367,9 +407,7 @@ async def test_percentiles_serialized_as_explode_true( url = str(route.calls[0].request.url) assert url.count("percentiles=") == 3 values = sorted( - v - for k, v in route.calls[0].request.url.params.multi_items() - if k == "percentiles" + v for k, v in route.calls[0].request.url.params.multi_items() if k == "percentiles" ) assert values == ["25", "50", "75"] @@ -377,7 +415,12 @@ async def test_percentiles_serialized_as_explode_true( async def test_forecast_auth_guard(self, unauth_async_series: AsyncSeriesResource) -> None: with pytest.raises(AuthRequiredError): await unauth_async_series.forecast_percentile_history( - "SER", "EVT", percentiles=[5000], start_ts=0, end_ts=1, period_interval=60, + "SER", + "EVT", + percentiles=[5000], + start_ts=0, + end_ts=1, + period_interval=60, ) @@ -386,9 +429,7 @@ class TestSeriesBoolParamSerialization: must serialize to ``"false"`` (was silently dropped by inline ternary).""" @respx.mock - def test_list_include_volume_false_is_sent( - self, series_resource: SeriesResource - ) -> None: + def test_list_include_volume_false_is_sent(self, series_resource: SeriesResource) -> None: route = respx.get(f"{BASE}/series").mock( return_value=httpx.Response(200, json={"series": []}) ) @@ -398,9 +439,7 @@ def test_list_include_volume_false_is_sent( assert params["include_product_metadata"] == "false" @respx.mock - def test_get_include_volume_false_is_sent( - self, series_resource: SeriesResource - ) -> None: + def test_get_include_volume_false_is_sent(self, series_resource: SeriesResource) -> None: route = respx.get(f"{BASE}/series/ECON-GDP").mock( return_value=httpx.Response(200, json={"series": SERIES_PAYLOAD}) ) diff --git a/tests/test_series_models.py b/tests/test_series_models.py index 801a714f..fa72aa78 100644 --- a/tests/test_series_models.py +++ b/tests/test_series_models.py @@ -16,72 +16,85 @@ class TestSeriesModel: def test_parse_with_volume_fp(self) -> None: - s = Series.model_validate({ - "ticker": "ECON-GDP", - "frequency": "quarterly", - "title": "GDP Report", - "category": "Economics", - "tags": ["gdp", "economy"], - "settlement_sources": [{"name": "BEA", "url": "https://bea.gov"}], - "contract_url": "https://kalshi.com/contracts/econ-gdp", - "contract_terms_url": "https://kalshi.com/terms/econ-gdp", - "fee_type": "quadratic", - "fee_multiplier": 1.0, - "additional_prohibitions": [], - "volume_fp": "123456.00", - }) + s = Series.model_validate( + { + "ticker": "ECON-GDP", + "frequency": "quarterly", + "title": "GDP Report", + "category": "Economics", + "categories": ["Economics"], + "tags": ["gdp", "economy"], + "settlement_sources": [{"name": "BEA", "url": "https://bea.gov"}], + "contract_url": "https://kalshi.com/contracts/econ-gdp", + "contract_terms_url": "https://kalshi.com/terms/econ-gdp", + "fee_type": "quadratic", + "fee_multiplier": 1.0, + "additional_prohibitions": [], + "volume_fp": "123456.00", + } + ) assert s.ticker == "ECON-GDP" assert s.volume == Decimal("123456.00") assert s.fee_type == "quadratic" + assert s.categories == ["Economics"] def test_parse_with_volume_alias(self) -> None: - s = Series.model_validate({ - "ticker": "T", - "frequency": "daily", - "title": "T", - "category": "T", - "tags": [], - "settlement_sources": [], - "contract_url": "", - "contract_terms_url": "", - "fee_type": "flat", - "fee_multiplier": 0.5, - "additional_prohibitions": [], - "volume": "99.00", - }) + s = Series.model_validate( + { + "ticker": "T", + "frequency": "daily", + "title": "T", + "category": "T", + "categories": ["T"], + "tags": [], + "settlement_sources": [], + "contract_url": "", + "contract_terms_url": "", + "fee_type": "flat", + "fee_multiplier": 0.5, + "additional_prohibitions": [], + "volume": "99.00", + } + ) assert s.volume == Decimal("99.00") def test_extra_fields_allowed(self) -> None: - s = Series.model_validate({ - "ticker": "T", - "frequency": "daily", - "title": "T", - "category": "T", - "tags": [], - "settlement_sources": [], - "contract_url": "", - "contract_terms_url": "", - "fee_type": "flat", - "fee_multiplier": 0.5, - "additional_prohibitions": [], - "unknown_future_field": "hello", - }) + s = Series.model_validate( + { + "ticker": "T", + "frequency": "daily", + "title": "T", + "category": "T", + "categories": ["T"], + "tags": [], + "settlement_sources": [], + "contract_url": "", + "contract_terms_url": "", + "fee_type": "flat", + "fee_multiplier": 0.5, + "additional_prohibitions": [], + "unknown_future_field": "hello", + } + ) assert s.ticker == "T" def test_volume_none_when_missing(self) -> None: - s = Series.model_validate({ - "ticker": "T", - "frequency": "daily", - "title": "T", - "category": "T", - "tags": [], - "settlement_sources": [], - "contract_url": "", - "contract_terms_url": "", - "fee_type": "flat", - "fee_multiplier": 0.5, - "additional_prohibitions": [], - }) + s = Series.model_validate( + { + "ticker": "T", + "frequency": "daily", + "title": "T", + "category": "T", + "categories": ["T"], + "tags": [], + "settlement_sources": [], + "contract_url": "", + "contract_terms_url": "", + "fee_type": "flat", + "fee_multiplier": 0.5, + "additional_prohibitions": [], + } + ) assert s.volume is None @@ -99,6 +112,7 @@ def _base_payload(self) -> dict[str, Any]: "frequency": "daily", "title": "T", "category": "T", + "categories": ["T"], "contract_url": "", "contract_terms_url": "", "fee_type": "flat", @@ -106,35 +120,54 @@ def _base_payload(self) -> dict[str, Any]: } def test_tags_none_coerced_to_empty_list(self) -> None: - payload = {**self._base_payload(), "tags": None, - "settlement_sources": [], "additional_prohibitions": []} + payload = { + **self._base_payload(), + "tags": None, + "settlement_sources": [], + "additional_prohibitions": [], + } s = Series.model_validate(payload) assert s.tags == [] def test_settlement_sources_none_coerced(self) -> None: - payload = {**self._base_payload(), "tags": [], - "settlement_sources": None, "additional_prohibitions": []} + payload = { + **self._base_payload(), + "tags": [], + "settlement_sources": None, + "additional_prohibitions": [], + } s = Series.model_validate(payload) assert s.settlement_sources == [] def test_additional_prohibitions_none_coerced(self) -> None: - payload = {**self._base_payload(), "tags": [], - "settlement_sources": [], "additional_prohibitions": None} + payload = { + **self._base_payload(), + "tags": [], + "settlement_sources": [], + "additional_prohibitions": None, + } s = Series.model_validate(payload) assert s.additional_prohibitions == [] def test_all_three_none_together(self) -> None: - payload = {**self._base_payload(), "tags": None, - "settlement_sources": None, "additional_prohibitions": None} + payload = { + **self._base_payload(), + "tags": None, + "settlement_sources": None, + "additional_prohibitions": None, + } s = Series.model_validate(payload) assert s.tags == [] assert s.settlement_sources == [] assert s.additional_prohibitions == [] def test_populated_list_passes_through(self) -> None: - payload = {**self._base_payload(), "tags": ["a", "b"], - "settlement_sources": [{"name": "BEA"}], - "additional_prohibitions": ["nope"]} + payload = { + **self._base_payload(), + "tags": ["a", "b"], + "settlement_sources": [{"name": "BEA"}], + "additional_prohibitions": ["nope"], + } s = Series.model_validate(payload) assert s.tags == ["a", "b"] assert len(s.settlement_sources) == 1 @@ -143,42 +176,50 @@ def test_populated_list_passes_through(self) -> None: class TestEventCandlesticksNullableList: def test_null_market_tickers_coerced(self) -> None: - ec = EventCandlesticks.model_validate({ - "market_tickers": None, - "market_candlesticks": [], - "adjusted_end_ts": 0, - }) + ec = EventCandlesticks.model_validate( + { + "market_tickers": None, + "market_candlesticks": [], + "adjusted_end_ts": 0, + } + ) assert ec.market_tickers == [] def test_null_market_candlesticks_coerced(self) -> None: - ec = EventCandlesticks.model_validate({ - "market_tickers": [], - "market_candlesticks": None, - "adjusted_end_ts": 0, - }) + ec = EventCandlesticks.model_validate( + { + "market_tickers": [], + "market_candlesticks": None, + "adjusted_end_ts": 0, + } + ) assert ec.market_candlesticks == [] class TestForecastPercentilesPointNullableList: def test_null_percentile_points_coerced(self) -> None: - fp = ForecastPercentilesPoint.model_validate({ - "event_ticker": "EVT-1", - "end_period_ts": 12345, - "period_interval": 60, - "percentile_points": None, - }) + fp = ForecastPercentilesPoint.model_validate( + { + "event_ticker": "EVT-1", + "end_period_ts": 12345, + "period_interval": 60, + "percentile_points": None, + } + ) assert fp.percentile_points == [] class TestSeriesFeeChangeModel: def test_parse(self) -> None: - fc = SeriesFeeChange.model_validate({ - "id": "fc-1", - "series_ticker": "ECON-GDP", - "fee_type": "quadratic_with_maker_fees", - "fee_multiplier": 1.5, - "scheduled_ts": "2026-05-01T00:00:00Z", - }) + fc = SeriesFeeChange.model_validate( + { + "id": "fc-1", + "series_ticker": "ECON-GDP", + "fee_type": "quadratic_with_maker_fees", + "fee_multiplier": 1.5, + "scheduled_ts": "2026-05-01T00:00:00Z", + } + ) assert fc.id == "fc-1" assert fc.fee_type == "quadratic_with_maker_fees" assert fc.scheduled_ts is not None @@ -186,17 +227,19 @@ def test_parse(self) -> None: class TestEventCandlesticksModel: def test_parse_nested_arrays(self) -> None: - ec = EventCandlesticks.model_validate({ - "market_tickers": ["MKT-A", "MKT-B"], - "market_candlesticks": [ - [candlestick_dict(end_period_ts=1000, volume_fp="50.00")], - [ - candlestick_dict(end_period_ts=1000, volume_fp="30.00"), - candlestick_dict(end_period_ts=2000), + ec = EventCandlesticks.model_validate( + { + "market_tickers": ["MKT-A", "MKT-B"], + "market_candlesticks": [ + [candlestick_dict(end_period_ts=1000, volume_fp="50.00")], + [ + candlestick_dict(end_period_ts=1000, volume_fp="30.00"), + candlestick_dict(end_period_ts=2000), + ], ], - ], - "adjusted_end_ts": 3000, - }) + "adjusted_end_ts": 3000, + } + ) assert ec.market_tickers == ["MKT-A", "MKT-B"] assert len(ec.market_candlesticks) == 2 assert len(ec.market_candlesticks[0]) == 1 @@ -204,36 +247,40 @@ def test_parse_nested_arrays(self) -> None: assert ec.adjusted_end_ts == 3000 def test_empty_candlesticks(self) -> None: - ec = EventCandlesticks.model_validate({ - "market_tickers": [], - "market_candlesticks": [], - "adjusted_end_ts": 0, - }) + ec = EventCandlesticks.model_validate( + { + "market_tickers": [], + "market_candlesticks": [], + "adjusted_end_ts": 0, + } + ) assert ec.market_tickers == [] assert ec.market_candlesticks == [] class TestForecastPercentilesPointModel: def test_parse_with_percentile_points(self) -> None: - fp = ForecastPercentilesPoint.model_validate({ - "event_ticker": "EVT-1", - "end_period_ts": 12345, - "period_interval": 60, - "percentile_points": [ - { - "percentile": 2500, - "raw_numerical_forecast": 3.14, - "numerical_forecast": 3.1, - "formatted_forecast": "3.1%", - }, - { - "percentile": 7500, - "raw_numerical_forecast": 5.5, - "numerical_forecast": 5.5, - "formatted_forecast": "5.5%", - }, - ], - }) + fp = ForecastPercentilesPoint.model_validate( + { + "event_ticker": "EVT-1", + "end_period_ts": 12345, + "period_interval": 60, + "percentile_points": [ + { + "percentile": 2500, + "raw_numerical_forecast": 3.14, + "numerical_forecast": 3.1, + "formatted_forecast": "3.1%", + }, + { + "percentile": 7500, + "raw_numerical_forecast": 5.5, + "numerical_forecast": 5.5, + "formatted_forecast": "5.5%", + }, + ], + } + ) assert fp.event_ticker == "EVT-1" assert len(fp.percentile_points) == 2 assert fp.percentile_points[0].percentile == 2500 @@ -248,6 +295,7 @@ class TestSeriesFeeMultiplierDecimal: "frequency": "daily", "title": "T", "category": "T", + "categories": ["T"], "tags": [], "settlement_sources": [], "contract_url": "", @@ -276,12 +324,14 @@ def test_fee_multiplier_rejects_bool(self) -> None: Series.model_validate({**self._BASE, "fee_multiplier": True}) def test_series_fee_change_multiplier_decimal(self) -> None: - fc = SeriesFeeChange.model_validate({ - "id": "fc-1", - "series_ticker": "T", - "fee_type": "flat", - "fee_multiplier": 1.5, - "scheduled_ts": "2026-01-01T00:00:00Z", - }) + fc = SeriesFeeChange.model_validate( + { + "id": "fc-1", + "series_ticker": "T", + "fee_type": "flat", + "fee_multiplier": 1.5, + "scheduled_ts": "2026-01-01T00:00:00Z", + } + ) assert isinstance(fc.fee_multiplier, Decimal) assert fc.fee_multiplier == Decimal("1.5") diff --git a/tests/ws/test_models.py b/tests/ws/test_models.py index 3cc1f25f..15d2a16f 100644 --- a/tests/ws/test_models.py +++ b/tests/ws/test_models.py @@ -820,6 +820,7 @@ def test_quote_accepted_payload_model(self) -> None: "quote_id": "q-001", "rfq_id": "rfq-001", "quote_creator_id": "user-2", + "rfq_creator_id": "user-1", "market_ticker": "T", "yes_bid_dollars": "0.55", "no_bid_dollars": "0.45", @@ -1053,6 +1054,7 @@ def test_quote_created_rfq_context(self) -> None: { "quote_id": "q-1", "quote_creator_id": "user-2", + "rfq_creator_id": "user-1", "rfq_id": "rfq-001", "created_ts": "2026-01-01T00:00:00Z", "market_ticker": "T", @@ -1074,6 +1076,7 @@ def test_quote_accepted_rfq_context(self) -> None: { "quote_id": "q-1", "quote_creator_id": "user-2", + "rfq_creator_id": "user-1", "rfq_id": "rfq-001", "market_ticker": "T", "yes_bid_dollars": "0.55", @@ -1205,9 +1208,7 @@ def test_orderbook_snapshot_missing_sides_raises(self) -> None: from kalshi.ws.models.orderbook_delta import OrderbookSnapshotPayload with pytest.raises(ValidationError): - OrderbookSnapshotPayload.model_validate( - {"market_ticker": "T", "market_id": "x"} - ) + OrderbookSnapshotPayload.model_validate({"market_ticker": "T", "market_id": "x"}) with pytest.raises(ValidationError): OrderbookSnapshotPayload.model_validate( {"market_ticker": "T", "market_id": "x", "yes": []} @@ -1236,6 +1237,7 @@ def test_quote_accepted_contracts_accepted_parses_as_decimal(self) -> None: "quote_id": "q-1", "rfq_id": "rfq-1", "quote_creator_id": "u2", + "rfq_creator_id": "u1", "market_ticker": "T", "yes_bid_dollars": "0.55", "no_bid_dollars": "0.45", @@ -1282,6 +1284,7 @@ def test_communications_timestamps_parse_as_datetime(self) -> None: "quote_id": "q-1", "rfq_id": "rfq-1", "quote_creator_id": "u2", + "rfq_creator_id": "u1", "market_ticker": "T", "yes_bid_dollars": "0.55", "no_bid_dollars": "0.45", @@ -1521,6 +1524,7 @@ def test_orderbook_delta_payload_side_rejects_trailing_whitespace(self) -> None: "quote_id": "q-1", "rfq_id": "rfq-1", "quote_creator_id": "u2", + "rfq_creator_id": "u1", "market_ticker": "MKT-A", "yes_bid": Decimal("0.50"), "no_bid": Decimal("0.50"),