diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f15310..0f6fe70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,67 @@ All notable changes to kalshi-sdk will be documented in this file. +## 16.0.0 — 2026-09-26 + +Reconciles upstream OpenAPI **3.30.0 → 3.31.0** plus matching perps and +AsyncAPI updates after nightly contract failures (Closes #519). +**Breaking** for constructors of perps `FCMSubtraderRiskControls`, +`GetFCMSubtraderRiskControlsResponse`, and `NotionalRiskLimitResponse` +that omit newly required fields. + +### Changed (breaking) + +- **Perps** `FCMSubtraderRiskControls.current_im` (`Decimal`, required) — + initial margin currently attributable to the cap's scope. +- **Perps** `GetFCMSubtraderRiskControlsResponse.notional_limits` + (`list[FCMSubtraderNotionalRiskLimit]`, required) — the admin-set + notional value risk limits for the same subtrader. +- **Perps** `NotionalRiskLimitResponse.total_current_usage` (`Decimal`) and + `current_usage_by_market_ticker` (`dict[str, Decimal]`), both required. + Live `perps.fcm.risk_controls()` / `perps.margin.notional_risk_limit()` + callers are unaffected; tests/mocks that construct these models must pass + the new fields. + +### Added + +- Optional **`subaccount=`** on `historical.fills` / `fills_all` / + `orders` / `orders_all` (sync + async). Subaccount-restricted keys see + only their own subaccount; a supplied value must match the restriction. +- Optional **`max_updated_ts=`** on `markets.list` / `list_all` + (mirrors `min_updated_ts`). +- Optional **`key_type=`** (`"rsa"` / `"ed25519"`) on `api_keys.generate` + and `GenerateApiKeyRequest`; optional `GenerateApiKeyResponse.key_type`. + The SDK request signer (`KalshiAuth`) remains RSA-PSS only. +- **FCM fills** on `client.fcm`: `fills(min_ts=, max_ts=, cursor=)` → + `GetFcmFillsResponse`, and `fills_all(...)` yielding `FcmFill` + (`GET /fcm/fills`). +- **Perps** `fcm.update_notional_risk_limit(notional_value_risk_limit=)` + (`PUT /margin/fcm/notional_risk_limit`) and + `fcm.delete_notional_risk_limit()` (`DELETE`), with + `UpdateFCMNotionalRiskLimitRequest`. Never retried. +- **Perps** `FCMSubtraderNotionalRiskLimit` model; optional + `NotionalRiskLimitResponse.member_notional_value_risk_limit` and + `effective_account_notional_value_risk_limit`. + +### Changed (non-breaking) + +- `RestingMarginReservationLiteral` accepts `"none"`. +- Perps `LastUpdateReason` / `LastUpdateReasonLiteral` / WS + `PerpsLastUpdateReason` accept `"ReduceOnlyCancel"`. + +### Spec notes + +- Core OpenAPI `info.version` **3.31.0** (117 operations; 116 mapped). + Still unimplemented on the core client: + `POST /portfolio/intra_exchange_instance_transfer`. +- AsyncAPI still 15 channels. 12 typed `subscribe_*` helpers. The new + communications `user_filter` subscribe option and core user-order + `last_update_reason` field are not yet modeled. +- Perps OpenAPI: 48 → 50 operations. +- Upstream dropped the deprecated `Market.liquidity_dollars` and Klear + `prev_settlement_prices`; the SDK keeps its existing optional fields for + now. + ## 15.0.0 — 2026-09-20 Reconciles upstream OpenAPI **3.29.0 → 3.30.0** plus matching perps, Klear, diff --git a/CLAUDE.md b/CLAUDE.md index ee2eae3..b52e4b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,7 @@ tests/ ## API Reference -- 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) +- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.31.0, 117 operations; 116 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 diff --git a/README.md b/README.md index d0a270a..3f45c37 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 (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). +- **Full coverage** of the Kalshi REST API (116 mapped of 117 operations across 19 resources, OpenAPI v3.31.0) and WebSocket API (12 typed `subscribe_*` channels + escape-hatch). +- **Perps (margin) API**: standalone `PerpsClient` / `AsyncPerpsClient` + `PerpsWebSocket` for the perpetual-futures exchange (50 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/docs/index.md b/docs/index.md index 9b52053..89c23cb 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** — 115 mapped of 116 operations across 19 resources - (OpenAPI v3.30.0), every kwarg drift-tested against the spec. +- **Full REST coverage** — 116 mapped of 117 operations across 19 resources + (OpenAPI v3.31.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. @@ -16,7 +16,7 @@ markets API. channels), backpressure strategies, and an in-memory orderbook builder. Async-only — access via `AsyncKalshiClient.ws`. - **Perps (margin) API** — standalone `PerpsClient` / `AsyncPerpsClient` + - `PerpsWebSocket` for the perpetual-futures exchange (48 REST operations, 6 WS + `PerpsWebSocket` for the perpetual-futures exchange (50 REST operations, 6 WS channels), and a `KlearClient` for the Self-Clearing-Member settlement API (26 of 27 operations, Bearer token auth). See [Perps](perps.md). - **FIX protocol** — a hand-rolled, async-first FIX engine (FIXT.1.1 / FIX50SP2) diff --git a/docs/migration.md b/docs/migration.md index 61db7c7..6061907 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,5 +1,53 @@ # Migration +## v15.0 → v16.0.0 + +Reconciles upstream OpenAPI **3.30.0 → 3.31.0** plus matching perps and +AsyncAPI updates (Closes #519). **Breaking** only for code that constructs +perps `FCMSubtraderRiskControls`, `GetFCMSubtraderRiskControlsResponse`, or +`NotionalRiskLimitResponse` without the new required fields. + +### Response model field changes + +- **Perps** `FCMSubtraderRiskControls.current_im` — required `Decimal`. +- **Perps** `GetFCMSubtraderRiskControlsResponse.notional_limits` — + required `list[FCMSubtraderNotionalRiskLimit]`. +- **Perps** `NotionalRiskLimitResponse.total_current_usage` (required + `Decimal`) and `current_usage_by_market_ticker` (required + `dict[str, Decimal]`). + +```python +# Before (constructors / test fixtures): +# FCMSubtraderRiskControls(subtrader_id="u_desk1", im_cap="100.0000") +# GetFCMSubtraderRiskControlsResponse(risk_controls=[...]) +# NotionalRiskLimitResponse(default_notional_value_risk_limit="5000.0000", +# notional_value_risk_limits_by_market_ticker={}) + +# After: +FCMSubtraderRiskControls(subtrader_id="u_desk1", im_cap="100.0000", current_im="42.0000") +GetFCMSubtraderRiskControlsResponse(risk_controls=[...], notional_limits=[]) +NotionalRiskLimitResponse( + default_notional_value_risk_limit="5000.0000", + notional_value_risk_limits_by_market_ticker={}, + total_current_usage="0", + current_usage_by_market_ticker={}, +) +``` + +Live `perps.fcm.risk_controls()` / `perps.margin.notional_risk_limit()` +callers are unaffected. + +### Added (non-breaking) + +- `historical.fills` / `fills_all` / `orders` / `orders_all(..., subaccount=)` +- `markets.list` / `list_all(..., max_updated_ts=)` +- `api_keys.generate(..., key_type=)` (the SDK signer stays RSA-only) +- `fcm.fills()` / `fcm.fills_all()` +- Perps `fcm.update_notional_risk_limit()` / `delete_notional_risk_limit()` + +See the [changelog](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/CHANGELOG.md) +for the full list. + ## v14.0 → v15.0.0 Reconciles upstream OpenAPI **3.29.0 → 3.30.0** plus matching perps, Klear, diff --git a/docs/perps.md b/docs/perps.md index 69fba21..8dd7af2 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -66,7 +66,7 @@ async with AsyncPerpsClient.from_env(demo=True) as perps: | `margin` | `balance()`, `risk()`, `notional_risk_limit()`, `fee_tiers()`, `fee_tier_rates()`, `api_limits()` | | `funding` | `rate_estimate()`, `historical_rates()`, `history()` | | `transfers` | `transfer_instance()`, `create_subaccount()`, `transfer_subaccount()` | -| `fcm` | `create_subtrader(subtrader_suffix=...)`; `risk_controls` / `update_risk_controls` / `delete_risk_controls` | +| `fcm` | `create_subtrader(subtrader_suffix=...)`; `risk_controls` / `update_risk_controls` / `delete_risk_controls`; `update_notional_risk_limit` / `delete_notional_risk_limit` | The margin order side is `bid` / `ask` (not the prediction API's `yes` / `no`). Orders create/cancel/decrease/amend are POSTs/DELETEs and are **never retried**. @@ -94,8 +94,20 @@ perps.fcm.update_risk_controls( perps.fcm.delete_risk_controls(subtrader_id="user_desk1", market_ticker="BTC-PERP") # asset_class is mutually exclusive with market_ticker perps.fcm.risk_controls(subtrader_id="user_desk1", asset_class="Crypto") + +# Member-set account notional limit (PUT/DELETE are never retried). +# The exchange enforces the smaller of this value and the Kalshi-set limit. +perps.fcm.update_notional_risk_limit(notional_value_risk_limit=Decimal("5000.0000")) +perps.fcm.delete_notional_risk_limit() ``` +`risk_controls()` also returns `notional_limits` (admin-set notional caps for +the same subtrader). Each `FCMSubtraderRiskControls` row includes required +`current_im`. `margin.notional_risk_limit()` reports required +`total_current_usage` and `current_usage_by_market_ticker`, plus optional +`member_notional_value_risk_limit` and +`effective_account_notional_value_risk_limit`. + Exit triggers (stop-loss / take-profit / trailing) sit on a position slot: ```python diff --git a/docs/resources/api-keys.md b/docs/resources/api-keys.md index c87abe1..392f6c7 100644 --- a/docs/resources/api-keys.md +++ b/docs/resources/api-keys.md @@ -11,7 +11,7 @@ Auth required throughout (you need an existing key to manage keys). |---|---| | `list(*, fcm_subtrader_id=None)` | `GET /api_keys` | | `create(*, name, public_key, scopes=None, subaccount=None, fcm_subtrader_id=None)` | `POST /api_keys` | -| `generate(*, name, scopes=None, subaccount=None, fcm_subtrader_id=None)` | `POST /api_keys/generate` | +| `generate(*, name, scopes=None, subaccount=None, fcm_subtrader_id=None, key_type=None)` | `POST /api_keys/generate` | | `delete(api_key)` | `DELETE /api_keys/{api_key}` | !!! note "Subaccount-scoped keys (spec v3.23.0)" @@ -44,10 +44,16 @@ The simplest path — Kalshi mints the keypair, you store the private key once: ```python resp = client.api_keys.generate(name="ci-bot-2026", scopes=["read", "write"]) private_pem = resp.private_key.get_secret_value() # SecretStr — see warning -print(resp.api_key.api_key) # the key id +print(resp.api_key_id) # the key id # Persist private_pem somewhere safe; you will not see it again. ``` +`key_type` is `"rsa"` or `"ed25519"`. Omit it and the server mints RSA, which +is what `KalshiAuth` can sign with. An Ed25519 private key (`key_type="ed25519"`, +PKCS#8 PEM) is returned the same way, but this SDK's request signer is +RSA-PSS only — it cannot authenticate calls with that key. `resp.key_type` +echoes the algorithm when the server sends it. + !!! danger "`private_key` is a `SecretStr` — and you only see it once" `resp.private_key` is a `pydantic.SecretStr`. `print(resp.private_key)` will print `**********`, **not** the key. Use `.get_secret_value()` to diff --git a/docs/resources/fcm.md b/docs/resources/fcm.md index 75ac671..c171d15 100644 --- a/docs/resources/fcm.md +++ b/docs/resources/fcm.md @@ -12,6 +12,8 @@ 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` | +| `fills(*, min_ts=None, max_ts=None, cursor=None)` | `GET /fcm/fills` | +| `fills_all(*, min_ts=None, max_ts=None, max_pages=None)` | walks `fills` | | `positions(*, subtrader_id, ...)` | `GET /fcm/positions` | | `list_subtraders()` | `GET /fcm/subtraders` | | `create_subtrader(*, subtrader_suffix)` | `POST /fcm/subtraders` | @@ -43,6 +45,23 @@ for o in client.fcm.orders_all(subtrader_id="st_alpha", status="resting"): Same `Order` model as [Orders](orders.md). Standard `Page[Order]` pagination on `orders()`. +## Fills + +Fills across the member's subtraders. Query params are only `min_ts`, +`max_ts`, and `cursor` — there is no `limit` or `subtrader_id` filter. +`fills()` returns `GetFcmFillsResponse` (`fills`, `cursor`). `fills_all()` +walks that cursor and yields each `FcmFill`. Prices are `Decimal` +(`yes_price` accepts `yes_price_dollars`); `count` accepts `count_fp`. + +```python +resp = client.fcm.fills(min_ts=1_700_000_000, max_ts=1_800_000_000) +for fill in resp.fills: + print(fill.fill_id, fill.ticker, fill.taker_outcome_side, fill.yes_price, fill.count) + +for fill in client.fcm.fills_all(min_ts=1_700_000_000): + print(fill.maker_subtrader_id, fill.taker_subtrader_id, fill.maker_fee_cost) +``` + ## Positions ```python diff --git a/docs/resources/historical.md b/docs/resources/historical.md index 0447b29..c968674 100644 --- a/docs/resources/historical.md +++ b/docs/resources/historical.md @@ -13,8 +13,8 @@ analytics; live trading needs the real-time surfaces. | `market(ticker)` | `GET /historical/markets/{ticker}` | no | | `candlesticks(ticker, *, start_ts, end_ts, period_interval)` | `GET /historical/markets/{ticker}/candlesticks` | no | | `trades(...)` / `trades_all(...)` | `GET /historical/trades` | no | -| `fills(...)` / `fills_all(...)` | `GET /historical/fills` | **yes** | -| `orders(...)` / `orders_all(...)` | `GET /historical/orders` | **yes** | +| `fills(..., subaccount=None)` / `fills_all(..., subaccount=None)` | `GET /historical/fills` | **yes** | +| `orders(..., subaccount=None)` / `orders_all(..., subaccount=None)` | `GET /historical/orders` | **yes** | | `positions(*, subaccount=None, ...)` / `positions_all(*, subaccount=None, ...)` | `GET /historical/positions` | **yes** | ## Cutoff @@ -67,13 +67,21 @@ 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", min_ts=1_600_000_000): - print(fill.fill_id, fill.price, fill.count) +for fill in client.historical.fills_all( + ticker="KXPRES-24-DJT", + min_ts=1_600_000_000, + subaccount=1, # omit to include every subaccount +): + print(fill.fill_id, fill.count) 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) ``` +`subaccount` is optional on both fills and orders (`0` is the primary subaccount). +Omit it to include every subaccount. A key restricted to one subaccount still +sees only that subaccount, and a supplied value must match the restriction. + ## Historical positions Auth required. Settled market positions archived to the historical database diff --git a/docs/resources/markets.md b/docs/resources/markets.md index 7e0e036..a9381df 100644 --- a/docs/resources/markets.md +++ b/docs/resources/markets.md @@ -43,9 +43,11 @@ for market in page: for the wire (this endpoint uses comma-join form, **not** the explode form used by `bulk_orderbooks`). -All seven `*_ts` filters (`min_created_ts`, `max_created_ts`, `min_updated_ts`, -`min_close_ts`, `max_close_ts`, `min_settled_ts`, `max_settled_ts`) are -Unix-second ints. +All eight `*_ts` filters (`min_created_ts`, `max_created_ts`, `min_updated_ts`, +`max_updated_ts`, `min_close_ts`, `max_close_ts`, `min_settled_ts`, +`max_settled_ts`) are Unix-second ints. `max_updated_ts` mirrors +`min_updated_ts`: metadata updated no later than that timestamp. It combines +with `min_updated_ts` and `mve_filter=exclude` the same way. `list_all(...)` walks cursors and returns an iterator — see [Pagination](../pagination.md). diff --git a/kalshi/__init__.py b/kalshi/__init__.py index a55cd2c..3246dee 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -37,6 +37,7 @@ AmendOrderV2Response, Announcement, ApiKey, + ApiKeyTypeLiteral, ApiUsageLevelGrant, ApplySubaccountTransferRequest, AssociatedEvent, @@ -84,6 +85,7 @@ ExchangeIndexStatus, ExchangeInstanceLiteral, ExchangeStatus, + FcmFill, FCMSubtrader, Fill, ForecastPercentilesPoint, @@ -94,6 +96,7 @@ GetCommunicationsIDResponse, GetEventLiveDataResponse, GetFCMEventContractDailyCapResponse, + GetFcmFillsResponse, GetFCMSubtraderBlockedCategoriesResponse, GetFiltersBySportsResponse, GetGameStatsResponse, @@ -223,6 +226,7 @@ "AmendOrderV2Response", "Announcement", "ApiKey", + "ApiKeyTypeLiteral", "ApiUsageLevelGrant", "ApplySubaccountTransferRequest", "AssociatedEvent", @@ -279,6 +283,7 @@ "ExchangeInstanceLiteral", "ExchangeStatus", "FCMSubtrader", + "FcmFill", "Fill", "FixClient", "FixConfig", @@ -293,6 +298,7 @@ "GetEventLiveDataResponse", "GetFCMEventContractDailyCapResponse", "GetFCMSubtraderBlockedCategoriesResponse", + "GetFcmFillsResponse", "GetFiltersBySportsResponse", "GetGameStatsResponse", "GetIncentiveProgramsResponse", @@ -421,4 +427,4 @@ "Withdrawal", ] -__version__ = "15.0.0" +__version__ = "16.0.0" diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index 27c9673..acad4dc 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -162,6 +162,15 @@ class ContractEntry: sdk_model="kalshi.models.fcm.UpdateFCMEventContractDailyCapRequest", spec_schema="UpdateFCMEventContractDailyCapRequest", ), + ContractEntry( + sdk_model="kalshi.models.fcm.FcmFill", + spec_schema="FcmFill", + notes="yes_price/count use short names with _dollars/_fp aliases", + ), + ContractEntry( + sdk_model="kalshi.models.fcm.GetFcmFillsResponse", + spec_schema="GetFcmFillsResponse", + ), ContractEntry( sdk_model="kalshi.models.multivariate.MultivariateEventCollection", spec_schema="MultivariateEventCollection", @@ -901,6 +910,14 @@ class ContractEntry: sdk_model="kalshi.perps.models.fcm.UpdateFCMSubtraderRiskControlsRequest", spec_schema="UpdateFCMSubtraderRiskControlsRequest", ), + ContractEntry( + sdk_model="kalshi.perps.models.fcm.FCMSubtraderNotionalRiskLimit", + spec_schema="FCMSubtraderNotionalRiskLimit", + ), + ContractEntry( + sdk_model="kalshi.perps.models.fcm.UpdateFCMNotionalRiskLimitRequest", + spec_schema="UpdateFCMNotionalRiskLimitRequest", + ), ContractEntry( sdk_model="kalshi.perps.models.portfolio.ExitTrigger", spec_schema="ExitTrigger", diff --git a/kalshi/models/__init__.py b/kalshi/models/__init__.py index c0a3d8e..b145d9b 100644 --- a/kalshi/models/__init__.py +++ b/kalshi/models/__init__.py @@ -12,6 +12,7 @@ ) from kalshi.models.api_keys import ( ApiKey, + ApiKeyTypeLiteral, CreateApiKeyRequest, CreateApiKeyResponse, GenerateApiKeyRequest, @@ -63,8 +64,10 @@ from kalshi.models.fcm import ( CreateFCMSubtraderRequest, CreateFCMSubtraderResponse, + FcmFill, FCMSubtrader, GetFCMEventContractDailyCapResponse, + GetFcmFillsResponse, GetFCMSubtraderBlockedCategoriesResponse, ListFCMSubtradersResponse, UpdateFCMEventContractDailyCapRequest, @@ -214,6 +217,7 @@ "AmendOrderV2Response", "Announcement", "ApiKey", + "ApiKeyTypeLiteral", "ApiUsageLevelGrant", "ApplySubaccountTransferRequest", "AssociatedEvent", @@ -262,6 +266,7 @@ "ExchangeInstanceLiteral", "ExchangeStatus", "FCMSubtrader", + "FcmFill", "Fill", "ForecastPercentilesPoint", "GenerateApiKeyRequest", @@ -272,6 +277,7 @@ "GetEventLiveDataResponse", "GetFCMEventContractDailyCapResponse", "GetFCMSubtraderBlockedCategoriesResponse", + "GetFcmFillsResponse", "GetFiltersBySportsResponse", "GetGameStatsResponse", "GetIncentiveProgramsResponse", diff --git a/kalshi/models/api_keys.py b/kalshi/models/api_keys.py index db6b44c..3131a8d 100644 --- a/kalshi/models/api_keys.py +++ b/kalshi/models/api_keys.py @@ -9,10 +9,20 @@ from __future__ import annotations +from typing import Literal + from pydantic import BaseModel, Field, SecretStr, model_validator from kalshi.types import NullableList +ApiKeyTypeLiteral = Literal["rsa", "ed25519"] +"""Signature algorithm for a generated API key. Spec ``ApiKeyType``. + +``rsa`` is 2048-bit RSA-PSS. ``ed25519`` is RFC 8032. The server defaults +to ``rsa`` when ``key_type`` is omitted. :class:`kalshi.auth.KalshiAuth` +signs with RSA-PSS only — an Ed25519 private key cannot be used with it. +""" + class ApiKey(BaseModel): """An API key registered on the authenticated user's account. @@ -90,9 +100,15 @@ class CreateApiKeyResponse(BaseModel): class GenerateApiKeyRequest(BaseModel): - """Body for POST /api_keys/generate — let Kalshi mint a key pair.""" + """Body for POST /api_keys/generate — let Kalshi mint a key pair. + + ``key_type`` selects the algorithm. Omit it to keep the server default + (``rsa``). The SDK request signer is RSA-only; see + :data:`ApiKeyTypeLiteral`. + """ name: str + key_type: ApiKeyTypeLiteral | None = None scopes: list[str] | None = None # Spec v3.23.0: restrict the key to a single subaccount when set. The spec # declares an explicit minimum/maximum (0-63), so bound it client-side. @@ -117,11 +133,14 @@ class GenerateApiKeyResponse(BaseModel): cannot be retrieved later. The field is typed as :class:`pydantic.SecretStr` so it prints as ``'**********'`` in ``repr()``/logs; call ``response.private_key.get_secret_value()`` to retrieve the PEM string - when you need to persist it. + when you need to persist it. ``key_type`` is ``rsa`` or ``ed25519`` when + the server sends it. :class:`kalshi.auth.KalshiAuth` can sign only with + an RSA private key. """ api_key_id: str private_key: SecretStr + key_type: ApiKeyTypeLiteral | None = None # Present only when a bound FCM subtrader has no initial-margin cap. warning: str | None = None diff --git a/kalshi/models/fcm.py b/kalshi/models/fcm.py index 51a4d42..8408d43 100644 --- a/kalshi/models/fcm.py +++ b/kalshi/models/fcm.py @@ -2,9 +2,10 @@ from __future__ import annotations -from pydantic import BaseModel, Field +from pydantic import AliasChoices, AwareDatetime, BaseModel, Field -from kalshi.types import DollarDecimal +from kalshi.models.orders import SideLiteral +from kalshi.types import DollarDecimal, FixedPointCount class CreateFCMSubtraderRequest(BaseModel): @@ -93,3 +94,41 @@ class UpdateFCMEventContractDailyCapRequest(BaseModel): limit: DollarDecimal model_config = {"extra": "forbid"} + + +class FcmFill(BaseModel): + """One fill from GET /fcm/fills. + + ``yes_price`` accepts the spec ``yes_price_dollars`` wire name and the + short Python name. ``count`` accepts ``count_fp`` and ``count``. + Maker/taker fee fields are fixed-point dollars under their spec names. + """ + + fill_id: str + exchange_index: int + ticker: str + taker_outcome_side: SideLiteral + count: FixedPointCount = Field( + validation_alias=AliasChoices("count_fp", "count"), + ) + yes_price: DollarDecimal = Field( + validation_alias=AliasChoices("yes_price_dollars", "yes_price"), + ) + created_time: AwareDatetime | None = None + maker_order_id: str | None = None + maker_subtrader_id: str | None = None + maker_fee_cost: DollarDecimal | None = None + taker_order_id: str | None = None + taker_subtrader_id: str | None = None + taker_fee_cost: DollarDecimal | None = None + + model_config = {"extra": "allow", "populate_by_name": True} + + +class GetFcmFillsResponse(BaseModel): + """Response from GET /fcm/fills. ``fills`` and ``cursor`` are required.""" + + fills: list[FcmFill] + cursor: str + + model_config = {"extra": "allow"} diff --git a/kalshi/models/portfolio.py b/kalshi/models/portfolio.py index 41df443..5ffc02a 100644 --- a/kalshi/models/portfolio.py +++ b/kalshi/models/portfolio.py @@ -258,12 +258,13 @@ class TargetBalanceAllocationInput(BaseModel): model_config = {"extra": "forbid"} -RestingMarginReservationLiteral = Literal["max", "sum"] +RestingMarginReservationLiteral = Literal["none", "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. +``none`` reserves no collateral 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. """ diff --git a/kalshi/perps/__init__.py b/kalshi/perps/__init__.py index 53b61ad..ced7303 100644 --- a/kalshi/perps/__init__.py +++ b/kalshi/perps/__init__.py @@ -79,8 +79,10 @@ ) from kalshi.perps.models.fcm import ( FCMAssetClassLiteral, + FCMSubtraderNotionalRiskLimit, FCMSubtraderRiskControls, GetFCMSubtraderRiskControlsResponse, + UpdateFCMNotionalRiskLimitRequest, UpdateFCMSubtraderRiskControlsRequest, ) from kalshi.perps.models.funding import ( @@ -265,6 +267,7 @@ "ExitTriggerReasonLiteral", "ExitTriggerStatusLiteral", "FCMAssetClassLiteral", + "FCMSubtraderNotionalRiskLimit", "FCMSubtraderRiskControls", "FeeScheduleLiteral", "FundingPaymentDetail", @@ -374,6 +377,7 @@ "TimeInForceLiteral", "TransfersResource", "UpdateExitTriggerRequest", + "UpdateFCMNotionalRiskLimitRequest", "UpdateFCMSubtraderRiskControlsRequest", "UpdateOrderGroupLimitRequest", "UpdateSubscriptionAction", diff --git a/kalshi/perps/models/__init__.py b/kalshi/perps/models/__init__.py index 594bd63..b3bba23 100644 --- a/kalshi/perps/models/__init__.py +++ b/kalshi/perps/models/__init__.py @@ -27,8 +27,10 @@ ) from kalshi.perps.models.fcm import ( FCMAssetClassLiteral, + FCMSubtraderNotionalRiskLimit, FCMSubtraderRiskControls, GetFCMSubtraderRiskControlsResponse, + UpdateFCMNotionalRiskLimitRequest, UpdateFCMSubtraderRiskControlsRequest, ) from kalshi.perps.models.funding import ( @@ -133,6 +135,7 @@ "ExitTriggerReasonLiteral", "ExitTriggerStatusLiteral", "FCMAssetClassLiteral", + "FCMSubtraderNotionalRiskLimit", "FCMSubtraderRiskControls", "FeeScheduleLiteral", "GetExitTriggersResponse", @@ -182,6 +185,7 @@ "SetIsolatedExitTriggerRequest", "TimeInForceLiteral", "UpdateExitTriggerRequest", + "UpdateFCMNotionalRiskLimitRequest", "UpdateFCMSubtraderRiskControlsRequest", "UpdateOrderGroupLimitRequest", ] diff --git a/kalshi/perps/models/common.py b/kalshi/perps/models/common.py index 3de4c43..808d7e5 100644 --- a/kalshi/perps/models/common.py +++ b/kalshi/perps/models/common.py @@ -77,6 +77,7 @@ class LastUpdateReason(StrEnum): EXPIRY_CANCEL = "ExpiryCancel" TRADE = "Trade" POST_ONLY_CROSS_CANCEL = "PostOnlyCrossCancel" + REDUCE_ONLY_CANCEL = "ReduceOnlyCancel" class OrderSource(StrEnum): diff --git a/kalshi/perps/models/fcm.py b/kalshi/perps/models/fcm.py index 5a8f0f9..fe51635 100644 --- a/kalshi/perps/models/fcm.py +++ b/kalshi/perps/models/fcm.py @@ -47,20 +47,39 @@ class FCMSubtraderRiskControls(BaseModel): """One initial-margin cap for an FCM subtrader. A missing ``market_ticker`` means the cap applies across all markets. + ``current_im`` is the initial margin currently attributable to the cap's + scope (spec-required). """ subtrader_id: str im_cap: DollarDecimal + current_im: DollarDecimal market_ticker: str | None = None asset_class: FCMAssetClassLiteral | None = None model_config = {"extra": "allow"} +class FCMSubtraderNotionalRiskLimit(BaseModel): + """One admin-set notional value risk limit for an FCM subtrader. + + A missing ``market_ticker`` is the whole-subtrader (all-markets) limit. + Dollar fields are fixed-point US dollar strings (``FixedPointDollars``). + """ + + subtrader_id: str + notional_value_risk_limit: DollarDecimal + current_notional: DollarDecimal + market_ticker: str | None = None + + model_config = {"extra": "allow"} + + class GetFCMSubtraderRiskControlsResponse(BaseModel): """Response from GET /margin/fcm/subtraders/risk_controls.""" risk_controls: list[FCMSubtraderRiskControls] + notional_limits: list[FCMSubtraderNotionalRiskLimit] model_config = {"extra": "allow"} @@ -77,3 +96,16 @@ class UpdateFCMSubtraderRiskControlsRequest(BaseModel): asset_class: FCMAssetClassLiteral | None = None model_config = {"extra": "forbid"} + + +class UpdateFCMNotionalRiskLimitRequest(BaseModel): + """Body for PUT /margin/fcm/notional_risk_limit. + + ``notional_value_risk_limit`` is a non-negative fixed-point dollar amount + (max 4 decimals). The exchange enforces the smaller of this member-set + value and the Kalshi-set account limit. + """ + + notional_value_risk_limit: OrderPrice + + model_config = {"extra": "forbid"} diff --git a/kalshi/perps/models/margin_account.py b/kalshi/perps/models/margin_account.py index b2859f9..1da939a 100644 --- a/kalshi/perps/models/margin_account.py +++ b/kalshi/perps/models/margin_account.py @@ -84,12 +84,21 @@ class GetMarginRiskResponse(BaseModel): class NotionalRiskLimitResponse(BaseModel): - """Spec ``NotionalRiskLimitResponse`` — default limit + per-ticker overrides.""" + """Spec ``NotionalRiskLimitResponse`` — default limit, usage, and overrides. + + ``total_current_usage`` and ``current_usage_by_market_ticker`` are + spec-required. ``member_notional_value_risk_limit`` and + ``effective_account_notional_value_risk_limit`` are present only when set. + """ model_config = ConfigDict(extra="allow") default_notional_value_risk_limit: DollarDecimal notional_value_risk_limits_by_market_ticker: dict[str, DollarDecimal] + total_current_usage: DollarDecimal + current_usage_by_market_ticker: dict[str, DollarDecimal] + member_notional_value_risk_limit: DollarDecimal | None = None + effective_account_notional_value_risk_limit: DollarDecimal | None = None class GetMarginFeeTiersResponse(BaseModel): diff --git a/kalshi/perps/models/orders.py b/kalshi/perps/models/orders.py index 33c3688..19a6458 100644 --- a/kalshi/perps/models/orders.py +++ b/kalshi/perps/models/orders.py @@ -54,6 +54,7 @@ "ExpiryCancel", "Trade", "PostOnlyCrossCancel", + "ReduceOnlyCancel", ] """Why an order was last updated. Spec ``LastUpdateReason`` (line 2044). diff --git a/kalshi/perps/resources/fcm.py b/kalshi/perps/resources/fcm.py index 7a21865..caa8295 100644 --- a/kalshi/perps/resources/fcm.py +++ b/kalshi/perps/resources/fcm.py @@ -14,6 +14,7 @@ CreateMarginFCMSubtraderResponse, FCMAssetClassLiteral, GetFCMSubtraderRiskControlsResponse, + UpdateFCMNotionalRiskLimitRequest, UpdateFCMSubtraderRiskControlsRequest, ) from kalshi.resources._base import ( @@ -24,6 +25,7 @@ ) _RISK_CONTROLS_PATH = "/margin/fcm/subtraders/risk_controls" +_NOTIONAL_RISK_LIMIT_PATH = "/margin/fcm/notional_risk_limit" def _build_create_subtrader_body( @@ -71,6 +73,24 @@ def _build_update_risk_controls_body( return request.model_dump(exclude_none=True, by_alias=True, mode="json") +def _build_update_notional_risk_limit_body( + request: UpdateFCMNotionalRiskLimitRequest | None, + *, + notional_value_risk_limit: Decimal | None, +) -> dict[str, object]: + _check_request_exclusive(request, notional_value_risk_limit=notional_value_risk_limit) + if request is None: + if notional_value_risk_limit is None: + raise TypeError( + "update_notional_risk_limit() requires `notional_value_risk_limit` " + "(or pass `request=...`)" + ) + request = UpdateFCMNotionalRiskLimitRequest( + notional_value_risk_limit=notional_value_risk_limit, + ) + return request.model_dump(exclude_none=True, by_alias=True, mode="json") + + class FcmResource(SyncResource): """Sync perps FCM API.""" @@ -178,6 +198,47 @@ def delete_risk_controls( ) self._delete(_RISK_CONTROLS_PATH, params=params, extra_headers=extra_headers) + @overload + def update_notional_risk_limit( + self, + *, + request: UpdateFCMNotionalRiskLimitRequest, + extra_headers: dict[str, str] | None = None, + ) -> None: ... + @overload + def update_notional_risk_limit( + self, + *, + notional_value_risk_limit: Decimal, + extra_headers: dict[str, str] | None = None, + ) -> None: ... + def update_notional_risk_limit( + self, + *, + request: UpdateFCMNotionalRiskLimitRequest | None = None, + notional_value_risk_limit: Decimal | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """``PUT /margin/fcm/notional_risk_limit`` — set the member's account limit. + + Not retried. Returns no body (``EmptyResponse``). + """ + self._require_auth() + body = _build_update_notional_risk_limit_body( + request, notional_value_risk_limit=notional_value_risk_limit + ) + self._put(_NOTIONAL_RISK_LIMIT_PATH, json=body, extra_headers=extra_headers) + + def delete_notional_risk_limit( + self, *, extra_headers: dict[str, str] | None = None + ) -> None: + """``DELETE /margin/fcm/notional_risk_limit`` — clear the member-set limit. + + Not retried. A Kalshi-set limit on the account stays in force. + """ + self._require_auth() + self._delete(_NOTIONAL_RISK_LIMIT_PATH, extra_headers=extra_headers) + class AsyncFcmResource(AsyncResource): """Async perps FCM API.""" @@ -281,3 +342,38 @@ async def delete_risk_controls( asset_class=asset_class, ) await self._delete(_RISK_CONTROLS_PATH, params=params, extra_headers=extra_headers) + + @overload + async def update_notional_risk_limit( + self, + *, + request: UpdateFCMNotionalRiskLimitRequest, + extra_headers: dict[str, str] | None = None, + ) -> None: ... + @overload + async def update_notional_risk_limit( + self, + *, + notional_value_risk_limit: Decimal, + extra_headers: dict[str, str] | None = None, + ) -> None: ... + async def update_notional_risk_limit( + self, + *, + request: UpdateFCMNotionalRiskLimitRequest | None = None, + notional_value_risk_limit: Decimal | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """Async :meth:`FcmResource.update_notional_risk_limit`.""" + self._require_auth() + body = _build_update_notional_risk_limit_body( + request, notional_value_risk_limit=notional_value_risk_limit + ) + await self._put(_NOTIONAL_RISK_LIMIT_PATH, json=body, extra_headers=extra_headers) + + async def delete_notional_risk_limit( + self, *, extra_headers: dict[str, str] | None = None + ) -> None: + """Async :meth:`FcmResource.delete_notional_risk_limit`.""" + self._require_auth() + await self._delete(_NOTIONAL_RISK_LIMIT_PATH, extra_headers=extra_headers) diff --git a/kalshi/perps/ws/models/_common.py b/kalshi/perps/ws/models/_common.py index 0b759c4..3902197 100644 --- a/kalshi/perps/ws/models/_common.py +++ b/kalshi/perps/ws/models/_common.py @@ -37,6 +37,7 @@ "ExpiryCancel", "Trade", "PostOnlyCrossCancel", + "ReduceOnlyCancel", ] # Spec ``orderGroupUpdatesPayload.msg.event_type`` enum. diff --git a/kalshi/resources/api_keys.py b/kalshi/resources/api_keys.py index e592aee..4392fe5 100644 --- a/kalshi/resources/api_keys.py +++ b/kalshi/resources/api_keys.py @@ -6,6 +6,7 @@ from typing import Any, overload from kalshi.models.api_keys import ( + ApiKeyTypeLiteral, CreateApiKeyRequest, CreateApiKeyResponse, GenerateApiKeyRequest, @@ -60,6 +61,7 @@ def _build_generate_api_key_body( scopes: builtins.list[str] | None, subaccount: int | None, fcm_subtrader_id: str | None, + key_type: ApiKeyTypeLiteral | None, ) -> dict[str, Any]: _check_request_exclusive( request, @@ -67,6 +69,7 @@ def _build_generate_api_key_body( scopes=scopes, subaccount=subaccount, fcm_subtrader_id=fcm_subtrader_id, + key_type=key_type, ) if request is None: if name is None: @@ -76,6 +79,7 @@ def _build_generate_api_key_body( scopes=scopes, subaccount=subaccount, fcm_subtrader_id=fcm_subtrader_id, + key_type=key_type, ) return request.model_dump(exclude_none=True, by_alias=True, mode="json") @@ -85,7 +89,9 @@ class ApiKeysResource(SyncResource): All endpoints require authentication. ``create`` takes a caller-minted RSA public key; ``generate`` has Kalshi mint a pair and returns the - private key once (see :class:`GenerateApiKeyResponse`). + private key once (see :class:`GenerateApiKeyResponse`). ``generate`` + may request ``key_type="ed25519"``; :class:`kalshi.auth.KalshiAuth` + signs with RSA-PSS only. """ def list( @@ -152,6 +158,7 @@ def generate( scopes: builtins.list[str] | None = ..., subaccount: int | None = ..., fcm_subtrader_id: str | None = ..., + key_type: ApiKeyTypeLiteral | None = ..., extra_headers: dict[str, str] | None = None, ) -> GenerateApiKeyResponse: ... def generate( @@ -162,8 +169,15 @@ def generate( scopes: builtins.list[str] | None = None, subaccount: int | None = None, fcm_subtrader_id: str | None = None, + key_type: ApiKeyTypeLiteral | None = None, extra_headers: dict[str, str] | None = None, ) -> GenerateApiKeyResponse: + """Mint a key pair. ``key_type`` defaults server-side to ``rsa``. + + The SDK signer (:class:`kalshi.auth.KalshiAuth`) is RSA-PSS only; + an ``ed25519`` private key returned here cannot authenticate REST + calls through this client. + """ self._require_auth() body = _build_generate_api_key_body( request, @@ -171,6 +185,7 @@ def generate( scopes=scopes, subaccount=subaccount, fcm_subtrader_id=fcm_subtrader_id, + key_type=key_type, ) data = self._post("/api_keys/generate", json=body, extra_headers=extra_headers) return GenerateApiKeyResponse.model_validate(data) @@ -247,6 +262,7 @@ async def generate( scopes: builtins.list[str] | None = ..., subaccount: int | None = ..., fcm_subtrader_id: str | None = ..., + key_type: ApiKeyTypeLiteral | None = ..., extra_headers: dict[str, str] | None = None, ) -> GenerateApiKeyResponse: ... async def generate( @@ -257,8 +273,10 @@ async def generate( scopes: builtins.list[str] | None = None, subaccount: int | None = None, fcm_subtrader_id: str | None = None, + key_type: ApiKeyTypeLiteral | None = None, extra_headers: dict[str, str] | None = None, ) -> GenerateApiKeyResponse: + """Async :meth:`ApiKeysResource.generate`. RSA-PSS signer only.""" self._require_auth() body = _build_generate_api_key_body( request, @@ -266,6 +284,7 @@ async def generate( scopes=scopes, subaccount=subaccount, fcm_subtrader_id=fcm_subtrader_id, + key_type=key_type, ) data = await self._post("/api_keys/generate", json=body, extra_headers=extra_headers) return GenerateApiKeyResponse.model_validate(data) diff --git a/kalshi/resources/fcm.py b/kalshi/resources/fcm.py index 9029d7b..340f807 100644 --- a/kalshi/resources/fcm.py +++ b/kalshi/resources/fcm.py @@ -1,8 +1,10 @@ """FCM resource — Futures Commission Merchant endpoints. 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*``. +PositionsResponse shapes. ``fills`` lists fills across the member's +subtraders (``min_ts`` / ``max_ts`` / ``cursor``). 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 @@ -20,7 +22,9 @@ from kalshi.models.fcm import ( CreateFCMSubtraderRequest, CreateFCMSubtraderResponse, + FcmFill, GetFCMEventContractDailyCapResponse, + GetFcmFillsResponse, GetFCMSubtraderBlockedCategoriesResponse, ListFCMSubtradersResponse, UpdateFCMEventContractDailyCapRequest, @@ -85,6 +89,15 @@ def _fcm_orders_params( ) +def _fcm_fills_params( + *, + min_ts: int | None, + max_ts: int | None, + cursor: str | None, +) -> dict[str, Any]: + return _params(min_ts=min_ts, max_ts=max_ts, cursor=cursor) + + def _fcm_positions_params( *, subtrader_id: str, @@ -231,6 +244,41 @@ def orders_all( extra_headers=extra_headers, ) + def fills( + self, + *, + min_ts: int | None = None, + max_ts: int | None = None, + cursor: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetFcmFillsResponse: + """``GET /fcm/fills`` — fills across the authenticated FCM's subtraders.""" + self._require_auth() + params = _fcm_fills_params(min_ts=min_ts, max_ts=max_ts, cursor=cursor) + data = self._get("/fcm/fills", params=params, extra_headers=extra_headers) + return GetFcmFillsResponse.model_validate(data) + + def fills_all( + self, + *, + min_ts: int | None = None, + max_ts: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Iterator[FcmFill]: + """Auto-paginate ``GET /fcm/fills``, yielding each :class:`FcmFill`.""" + self._require_auth() + _validate_max_pages(max_pages) + params = _fcm_fills_params(min_ts=min_ts, max_ts=max_ts, cursor=None) + return self._list_all( + "/fcm/fills", + FcmFill, + "fills", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) + def positions( self, *, @@ -504,6 +552,41 @@ def orders_all( extra_headers=extra_headers, ) + async def fills( + self, + *, + min_ts: int | None = None, + max_ts: int | None = None, + cursor: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetFcmFillsResponse: + """Async :meth:`FcmResource.fills`.""" + self._require_auth() + params = _fcm_fills_params(min_ts=min_ts, max_ts=max_ts, cursor=cursor) + data = await self._get("/fcm/fills", params=params, extra_headers=extra_headers) + return GetFcmFillsResponse.model_validate(data) + + def fills_all( + self, + *, + min_ts: int | None = None, + max_ts: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> AsyncIterator[FcmFill]: + """Async counterpart of :meth:`FcmResource.fills_all`. Use ``async for``.""" + self._require_auth() + _validate_max_pages(max_pages) + params = _fcm_fills_params(min_ts=min_ts, max_ts=max_ts, cursor=None) + return self._list_all( + "/fcm/fills", + FcmFill, + "fills", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) + async def positions( self, *, diff --git a/kalshi/resources/historical.py b/kalshi/resources/historical.py index 6f5cd2b..49bb8f9 100644 --- a/kalshi/resources/historical.py +++ b/kalshi/resources/historical.py @@ -69,9 +69,22 @@ def _historical_fills_or_orders_params( ticker: str | None, min_ts: int | None, max_ts: int | None, + subaccount: int | None, ) -> dict[str, Any]: + """Query params for GET /historical/fills and GET /historical/orders. + + ``subaccount`` is ``SubaccountQuery``: omit it to include every subaccount + (a subaccount-restricted API key still sees only its own). + """ limit = _validate_limit(limit, hi=1000) - return _params(limit=limit, cursor=cursor, ticker=ticker, min_ts=min_ts, max_ts=max_ts) + return _params( + limit=limit, + cursor=cursor, + ticker=ticker, + min_ts=min_ts, + max_ts=max_ts, + subaccount=subaccount, + ) def _historical_trades_params( @@ -213,6 +226,7 @@ def fills( ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, + subaccount: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Fill]: self._require_auth() @@ -222,6 +236,7 @@ def fills( ticker=ticker, min_ts=min_ts, max_ts=max_ts, + subaccount=subaccount, ) return self._list( "/historical/fills", Fill, "fills", params=params, extra_headers=extra_headers @@ -234,6 +249,7 @@ def fills_all( ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, + subaccount: int | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Iterator[Fill]: @@ -245,6 +261,7 @@ def fills_all( ticker=ticker, min_ts=min_ts, max_ts=max_ts, + subaccount=subaccount, ) return self._list_all( "/historical/fills", @@ -263,6 +280,7 @@ def orders( ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, + subaccount: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Order]: self._require_auth() @@ -272,6 +290,7 @@ def orders( ticker=ticker, min_ts=min_ts, max_ts=max_ts, + subaccount=subaccount, ) return self._list( "/historical/orders", Order, "orders", params=params, extra_headers=extra_headers @@ -284,6 +303,7 @@ def orders_all( ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, + subaccount: int | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Iterator[Order]: @@ -295,6 +315,7 @@ def orders_all( ticker=ticker, min_ts=min_ts, max_ts=max_ts, + subaccount=subaccount, ) return self._list_all( "/historical/orders", @@ -516,6 +537,7 @@ async def fills( ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, + subaccount: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Fill]: self._require_auth() @@ -525,6 +547,7 @@ async def fills( ticker=ticker, min_ts=min_ts, max_ts=max_ts, + subaccount=subaccount, ) return await self._list( "/historical/fills", Fill, "fills", params=params, extra_headers=extra_headers @@ -537,6 +560,7 @@ def fills_all( ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, + subaccount: int | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> AsyncIterator[Fill]: @@ -548,6 +572,7 @@ def fills_all( ticker=ticker, min_ts=min_ts, max_ts=max_ts, + subaccount=subaccount, ) return self._list_all( "/historical/fills", @@ -566,6 +591,7 @@ async def orders( ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, + subaccount: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Order]: self._require_auth() @@ -575,6 +601,7 @@ async def orders( ticker=ticker, min_ts=min_ts, max_ts=max_ts, + subaccount=subaccount, ) return await self._list( "/historical/orders", Order, "orders", params=params, extra_headers=extra_headers @@ -587,6 +614,7 @@ def orders_all( ticker: str | None = None, min_ts: int | None = None, max_ts: int | None = None, + subaccount: int | None = None, max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> AsyncIterator[Order]: @@ -598,6 +626,7 @@ def orders_all( ticker=ticker, min_ts=min_ts, max_ts=max_ts, + subaccount=subaccount, ) return self._list_all( "/historical/orders", diff --git a/kalshi/resources/markets.py b/kalshi/resources/markets.py index b6d41d8..3872dc9 100644 --- a/kalshi/resources/markets.py +++ b/kalshi/resources/markets.py @@ -53,6 +53,7 @@ def _list_markets_params( min_created_ts: int | None, max_created_ts: int | None, min_updated_ts: int | None, + max_updated_ts: int | None, min_close_ts: int | None, max_close_ts: int | None, min_settled_ts: int | None, @@ -70,6 +71,7 @@ def _list_markets_params( min_created_ts=min_created_ts, max_created_ts=max_created_ts, min_updated_ts=min_updated_ts, + max_updated_ts=max_updated_ts, min_close_ts=min_close_ts, max_close_ts=max_close_ts, min_settled_ts=min_settled_ts, @@ -230,6 +232,7 @@ def list( min_created_ts: int | None = None, max_created_ts: int | None = None, min_updated_ts: int | None = None, + max_updated_ts: int | None = None, min_close_ts: int | None = None, max_close_ts: int | None = None, min_settled_ts: int | None = None, @@ -247,6 +250,7 @@ def list( min_created_ts=min_created_ts, max_created_ts=max_created_ts, min_updated_ts=min_updated_ts, + max_updated_ts=max_updated_ts, min_close_ts=min_close_ts, max_close_ts=max_close_ts, min_settled_ts=min_settled_ts, @@ -267,6 +271,7 @@ def list_all( min_created_ts: int | None = None, max_created_ts: int | None = None, min_updated_ts: int | None = None, + max_updated_ts: int | None = None, min_close_ts: int | None = None, max_close_ts: int | None = None, min_settled_ts: int | None = None, @@ -285,6 +290,7 @@ def list_all( min_created_ts=min_created_ts, max_created_ts=max_created_ts, min_updated_ts=min_updated_ts, + max_updated_ts=max_updated_ts, min_close_ts=min_close_ts, max_close_ts=max_close_ts, min_settled_ts=min_settled_ts, @@ -476,6 +482,7 @@ async def list( min_created_ts: int | None = None, max_created_ts: int | None = None, min_updated_ts: int | None = None, + max_updated_ts: int | None = None, min_close_ts: int | None = None, max_close_ts: int | None = None, min_settled_ts: int | None = None, @@ -493,6 +500,7 @@ async def list( min_created_ts=min_created_ts, max_created_ts=max_created_ts, min_updated_ts=min_updated_ts, + max_updated_ts=max_updated_ts, min_close_ts=min_close_ts, max_close_ts=max_close_ts, min_settled_ts=min_settled_ts, @@ -515,6 +523,7 @@ def list_all( min_created_ts: int | None = None, max_created_ts: int | None = None, min_updated_ts: int | None = None, + max_updated_ts: int | None = None, min_close_ts: int | None = None, max_close_ts: int | None = None, min_settled_ts: int | None = None, @@ -534,6 +543,7 @@ def list_all( min_created_ts=min_created_ts, max_created_ts=max_created_ts, min_updated_ts=min_updated_ts, + max_updated_ts=max_updated_ts, min_close_ts=min_close_ts, max_close_ts=max_close_ts, min_settled_ts=min_settled_ts, diff --git a/pyproject.toml b/pyproject.toml index 9d00629..5e5d54c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "15.0.0" +version = "16.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 a810879..2d6590f 100644 --- a/specs/asyncapi.yaml +++ b/specs/asyncapi.yaml @@ -289,7 +289,7 @@ channels: - Market specification ignored - Optional sharding for fanout control: - `shard_factor` (1-100) and `shard_key` (`0 <= key < shard_factor`) - - RFQ events (RFQCreated, RFQDeleted) always sent + - RFQ events (RFQCreated, RFQDeleted) are sent for all users by default. Set `user_filter: "self"` to receive only RFQs you created. Omitted or empty `user_filter` keeps the default. - Quote events (QuoteCreated, QuoteAccepted, QuoteExecuted) are only sent if you created the quote OR you created the RFQ **Use case:** Tracking RFQs you create and quotes on your RFQs, or quotes you create on others' RFQs. Use QuoteExecuted to correlate fill messages with quotes via client_order_id. @@ -2316,6 +2316,11 @@ components: description: 'Side of the book for an order or trade. ''bid'' is equivalent to outcome_side ''yes''; ''ask'' is equivalent to outcome_side ''no''.' enum: ["bid", "ask"] + lastUpdateReason: + type: string + description: Order update reason. ReduceOnlyCancel means reduce_only capped the order at placement. + enum: ["Decrease", "Amend", "MarginCancel", "SelfTradeCancel", "ExpiryCancel", "CloseCancel", "HaltCancel", "Trade", "PostOnlyCrossCancel", "ReduceOnlyCancel"] + orderAction: type: string description: Legacy order action; an unknown action is emitted as an empty string. @@ -2383,6 +2388,11 @@ components: the legacy no-leg pricing. Integrations relying on the legacy behavior should migrate before the default flip; concrete dates will be announced before each step. default: false + user_filter: + type: string + enum: ["", "self"] + default: "" + description: 'Communications channel only. Set to "self" to receive RFQ created/deleted events only for RFQs you created. Omitted or empty receives RFQs from all users. Quote notifications are unchanged.' shard_factor: type: integer description: 'Number of shards for communications channel fanout (optional)' @@ -3716,6 +3726,9 @@ components: type: integer description: Last update time as a Unix timestamp in milliseconds format: int64 + last_update_reason: + $ref: '#/components/schemas/lastUpdateReason' + description: Reason for the most recent update, if applicable subaccount_number: type: integer description: Subaccount number (0 for primary, 1-63 for subaccounts) diff --git a/specs/openapi.yaml b/specs/openapi.yaml index e70ca36..979287a 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.30.0 + version: 3.31.0 description: Manually defined OpenAPI spec for endpoints being migrated to spec-first approach servers: @@ -398,7 +398,7 @@ paths: | min_created_ts, max_created_ts | `unopened`, `open`, *empty* | | | min_close_ts, max_close_ts | `closed`, *empty* | | | min_settled_ts, max_settled_ts | `settled`, *empty* | | - | min_updated_ts | *empty* | Incompatible with all filters besides `mve_filter=exclude`. May be combined with `series_ticker`, which requires `mve_filter=exclude` | + | min_updated_ts, max_updated_ts | *empty* | Incompatible with all filters besides `mve_filter=exclude`. May be combined with `series_ticker`, which requires `mve_filter=exclude` | Markets that settled before the historical cutoff are only available via `GET /historical/markets`. See [Historical Data](https://docs.kalshi.com/getting_started/historical_data) for details. @@ -412,6 +412,7 @@ paths: - $ref: '#/components/parameters/MinCreatedTsQuery' - $ref: '#/components/parameters/MaxCreatedTsQuery' - $ref: '#/components/parameters/MinUpdatedTsQuery' + - $ref: '#/components/parameters/MaxUpdatedTsQuery' - $ref: '#/components/parameters/MaxCloseTsQuery' - $ref: '#/components/parameters/MinCloseTsQuery' - $ref: '#/components/parameters/MinSettledTsQuery' @@ -2723,7 +2724,7 @@ paths: post: operationId: CreateApiKey summary: Create API Key - description: ' Endpoint for creating a new API key with a user-provided public key. This endpoint allows users with Premier or Market Maker API usage levels to create API keys by providing their own RSA public key. The platform will use this public key to verify signatures on API requests.' + description: ' Endpoint for creating a new API key with a user-provided public key. This endpoint allows users with Premier or Market Maker API usage levels to create API keys by providing their own RSA or Ed25519 public key in PEM format. The platform will use this public key to verify signatures on API requests: RSA-PSS with SHA-256 for RSA keys, Ed25519 for Ed25519 keys.' tags: - api-keys security: @@ -2758,7 +2759,7 @@ paths: post: operationId: GenerateApiKey summary: Generate API Key - description: ' Endpoint for generating a new API key with an automatically created key pair. This endpoint generates both a public and private RSA key pair. The public key is stored on the platform, while the private key is returned to the user and must be stored securely. The private key cannot be retrieved again.' + description: ' Endpoint for generating a new API key with an automatically created key pair. This endpoint generates a key pair of the requested key_type (RSA when omitted). The public key is stored on the platform, while the private key is returned to the user and must be stored securely. The private key cannot be retrieved again.' tags: - api-keys security: @@ -3624,6 +3625,38 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' + /fcm/fills: + get: + operationId: GetFCMFills + summary: Get FCM Fills + description: | + Returns fills across the authenticated FCM's subtraders. Requires FCM member access. + tags: + - fcm + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/MinTsQuery' + - $ref: '#/components/parameters/MaxTsQuery' + - $ref: '#/components/parameters/CursorQuery' + responses: + '200': + description: Fills retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetFcmFillsResponse' + '400': + description: Bad request + '401': + description: Unauthorized + '403': + description: Forbidden - requires an unrestricted FCM member API key + '500': + description: Internal server error + /fcm/orders: get: operationId: GetFCMOrders @@ -4181,6 +4214,7 @@ paths: summary: Get Historical Fills description: | Endpoint for getting all historical fills for the member. A fill is when a trade you have is matched. + API keys restricted to a subaccount return only that subaccount's fills. If supplied, `subaccount` must match the key's restriction. Registered partners may also use a user OAuth access token with the explicitly granted read::compliance_partner scope. tags: - historical @@ -4195,6 +4229,7 @@ paths: - $ref: '#/components/parameters/MaxTsQuery' - $ref: '#/components/parameters/LimitQuery' - $ref: '#/components/parameters/CursorQuery' + - $ref: '#/components/parameters/SubaccountQuery' responses: '200': description: Fills retrieved successfully @@ -4215,7 +4250,9 @@ paths: get: operationId: GetHistoricalOrders summary: Get Historical Orders - description: ' Endpoint for getting orders that have been archived to the historical database.' + description: | + Endpoint for getting orders that have been archived to the historical database. + API keys restricted to a subaccount return only that subaccount's orders. If supplied, `subaccount` must match the key's restriction. tags: - historical security: @@ -4228,6 +4265,7 @@ paths: - $ref: '#/components/parameters/MaxTsQuery' - $ref: '#/components/parameters/LimitQuery' - $ref: '#/components/parameters/CursorQuery' + - $ref: '#/components/parameters/SubaccountQuery' responses: '200': description: Historical orders retrieved successfully @@ -4364,7 +4402,7 @@ components: type: apiKey in: header name: KALSHI-ACCESS-SIGNATURE - description: RSA-PSS signature of the request + description: Base64 signature of the pre-sign text (timestamp + method + path) made with the API key's algorithm - RSA-PSS with SHA-256 for RSA keys, Ed25519 for Ed25519 keys kalshiAccessTimestamp: type: apiKey in: header @@ -4679,7 +4717,15 @@ components: MinUpdatedTsQuery: name: min_updated_ts in: query - description: Return markets with metadata updated later than this Unix timestamp. Tracks non-trading changes only. Incompatible with any other filters except mve_filter=exclude. May be combined with series_ticker, which requires mve_filter=exclude. + description: Return markets with metadata updated later than this Unix timestamp (in seconds). Tracks non-trading changes only. May be combined with max_updated_ts and mve_filter=exclude. May also be combined with series_ticker, which requires mve_filter=exclude. Incompatible with other filters. + schema: + type: integer + format: int64 + + MaxUpdatedTsQuery: + name: max_updated_ts + in: query + description: Return markets with metadata updated at or before this Unix timestamp (in seconds). Tracks non-trading changes only. May be combined with min_updated_ts and mve_filter=exclude. May also be combined with series_ticker, which requires mve_filter=exclude. Incompatible with other filters. schema: type: integer format: int64 @@ -4936,6 +4982,12 @@ components: 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. + ApiKeyType: + type: string + enum: ['rsa', 'ed25519'] + x-enum-varnames: ['ApiKeyTypeRsa', 'ApiKeyTypeEd25519'] + description: Signature algorithm of an API key pair. `rsa` - 2048-bit RSA; requests are signed with RSA-PSS SHA-256. `ed25519` - Ed25519 (RFC 8032) signatures over the same pre-sign text, with lower client-side signing cost. Defaults to `rsa` when omitted from a generate request, for compatibility with existing clients. + ApiKey: type: object required: @@ -4992,7 +5044,7 @@ components: description: Name for the API key. This helps identify the key's purpose public_key: type: string - description: RSA public key in PEM format. This will be used to verify signatures on API requests + description: RSA or Ed25519 public key in PEM format (`-----BEGIN PUBLIC KEY-----`). This will be used to verify signatures on API requests - RSA-PSS with SHA-256 for RSA keys, Ed25519 for Ed25519 keys scopes: type: array description: List of scopes to grant to the API key. If the broad `write` parent scope is included, `read` must also be included. Child scopes may be granted without the broad parent scope. Defaults to full access (`read`, `write`) if not provided. @@ -5028,6 +5080,8 @@ components: name: type: string description: Name for the API key. This helps identify the key's purpose + key_type: + $ref: '#/components/schemas/ApiKeyType' scopes: type: array description: List of scopes to grant to the API key. If the broad `write` parent scope is included, `read` must also be included. Child scopes may be granted without the broad parent scope. Defaults to full access (`read`, `write`) if not provided. @@ -5051,9 +5105,11 @@ components: api_key_id: type: string description: Unique identifier for the newly generated API key + key_type: + $ref: '#/components/schemas/ApiKeyType' private_key: type: string - description: RSA private key in PEM format. This must be stored securely and cannot be retrieved again after this response + description: Private key in PEM format - PKCS#1 (`-----BEGIN RSA PRIVATE KEY-----`) for `rsa`, PKCS#8 (`-----BEGIN PRIVATE KEY-----`) for `ed25519`. This must be stored securely and cannot be retrieved again after this response warning: type: string nullable: true @@ -6851,6 +6907,12 @@ components: cursor: type: string + OutcomeSide: + type: string + enum: ['yes', 'no'] + x-enum-varnames: ['OutcomeSideYes', 'OutcomeSideNo'] + description: Outcome side. + Fill: type: object required: @@ -6900,8 +6962,7 @@ components: 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. outcome_side: - type: string - enum: ['yes', 'no'] + $ref: '#/components/schemas/OutcomeSide' description: | The outcome side this fill positioned the user for. buy-yes and sell-no produce 'yes'; buy-no and sell-yes produce 'no'. @@ -6943,6 +7004,56 @@ components: format: int64 description: Unix timestamp when this fill was executed (legacy field name) + FcmFill: + type: object + required: + - fill_id + - exchange_index + - ticker + - taker_outcome_side + - count_fp + - yes_price_dollars + properties: + fill_id: + type: string + description: Fill ID. + exchange_index: + $ref: '#/components/schemas/ExchangeIndex' + ticker: + type: string + description: Market ticker. + taker_outcome_side: + $ref: '#/components/schemas/OutcomeSide' + description: Taker outcome side. The maker has the opposite outcome. + count_fp: + $ref: '#/components/schemas/FixedPointCount' + description: Filled contract count. + yes_price_dollars: + $ref: '#/components/schemas/FixedPointDollars' + description: YES price in dollars. + created_time: + type: string + format: date-time + description: Fill execution time. + maker_order_id: + type: string + description: Maker order ID when owned by the FCM. + maker_subtrader_id: + type: string + description: Maker subtrader ID when owned by the FCM. + maker_fee_cost: + $ref: '#/components/schemas/FixedPointDollars' + description: Maker fee in dollars when owned by the FCM. + taker_order_id: + type: string + description: Taker order ID when owned by the FCM. + taker_subtrader_id: + type: string + description: Taker subtrader ID when owned by the FCM. + taker_fee_cost: + $ref: '#/components/schemas/FixedPointDollars' + description: Taker fee in dollars when owned by the FCM. + GetFillsResponse: type: object required: @@ -6956,6 +7067,19 @@ components: cursor: type: string + GetFcmFillsResponse: + type: object + required: + - fills + - cursor + properties: + fills: + type: array + items: + $ref: '#/components/schemas/FcmFill' + cursor: + type: string + # Structured Target schemas StructuredTarget: type: object @@ -7178,10 +7302,11 @@ components: RestingMarginReservation: type: string - enum: [max, sum] - x-enum-varnames: [RestingMarginReservationMax, RestingMarginReservationSum] + enum: [none, max, sum] + x-enum-varnames: [RestingMarginReservationNone, RestingMarginReservationMax, RestingMarginReservationSum] description: | - Collateral an automatic rebalance leaves behind for resting orders. `max` reserves the + Collateral an automatic rebalance leaves behind for resting orders. `none` reserves no + collateral for resting orders. `max` reserves the largest single market-side commitment. `sum` reserves the summed margin of every resting order. GetTargetBalanceAllocationResponse: @@ -7214,7 +7339,7 @@ components: - $ref: '#/components/schemas/RestingMarginReservation' description: Defaults to `sum` when omitted. x-oapi-codegen-extra-tags: - validate: omitempty,oneof=max sum + validate: omitempty,oneof=none max sum x-go-type-skip-optional-pointer: true IntraExchangeInstanceTransferRequest: @@ -8406,7 +8531,7 @@ components: description: If this flag is set to true, the order will be canceled if the order is open and trading on the exchange is paused for any reason. reduce_only: type: boolean - description: Specifies whether the order place count should be capped by the member's current position. + description: Specifies whether the order place count should be capped by the member's current position. Orders with reduce_only set to true will be rejected unless time_in_force is immediate_or_cancel. subaccount: type: integer minimum: 0 @@ -9257,10 +9382,15 @@ components: description: Full title of the event. collateral_return_type: type: string - description: Specifies how collateral is returned when markets settle (e.g., 'binary' for standard yes/no markets). + description: | + Collateral-return netting type for this event: `MECNET` for mutually exclusive markets, `DIRECNET` for directional netting, or an empty string for no collateral-return netting. + Netting applies within this event, not across all events in its series, and also requires netting to be enabled for the trading account or subtrader. + Use `with_nested_markets=true` to retrieve the markets associated with each event. mutually_exclusive: type: boolean - description: If true, only one market in this event can resolve to 'yes'. If false, multiple markets can resolve to 'yes'. + description: | + True when `collateral_return_type` is `MECNET`: at most one market in this event can resolve to 'yes'. + False for both `DIRECNET` and an empty collateral-return type. A false value does not mean collateral-return netting is unavailable. Use `collateral_return_type` to distinguish these cases. category: type: string description: Event category (deprecated, use series-level category instead). @@ -9668,12 +9798,6 @@ components: previous_price_dollars: $ref: '#/components/schemas/FixedPointDollars' description: Price for the last traded YES contract on this market a day ago in dollars - liquidity_dollars: - allOf: - - $ref: '#/components/schemas/FixedPointDollars' - deprecated: true - x-go-type-skip-optional-pointer: true - description: 'DEPRECATED: This field is deprecated and will always return "0.0000".' settlement_value_dollars: $ref: '#/components/schemas/FixedPointDollars' nullable: true diff --git a/specs/perps_asyncapi.yaml b/specs/perps_asyncapi.yaml index 1fe04f1..6f4e318 100644 --- a/specs/perps_asyncapi.yaml +++ b/specs/perps_asyncapi.yaml @@ -636,7 +636,7 @@ components: lastUpdateReason: type: string - enum: ["Decrease", "Amend", "MarginCancel", "SelfTradeCancel", "ExpiryCancel", "CloseCancel", "HaltCancel", "Trade", "PostOnlyCrossCancel"] + enum: ["Decrease", "Amend", "MarginCancel", "SelfTradeCancel", "ExpiryCancel", "CloseCancel", "HaltCancel", "Trade", "PostOnlyCrossCancel", "ReduceOnlyCancel"] 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: @@ -1006,7 +1006,7 @@ components: type: string description: Total notional value of one sided open interest in dollars. reference_price: - description: Reference price of underlying asset, when available. + description: Underlying reference index value scaled to one contract, when available (CF Benchmarks for crypto perps; Pyth for metals, commodities, and other Pyth-indexed perps). ts_ms is the index source timestamp. allOf: - $ref: '#/components/schemas/tickerPrice' settlement_mark_price: @@ -1147,6 +1147,9 @@ components: type: integer format: int64 description: Unix timestamp in milliseconds. + last_update_reason: + $ref: '#/components/schemas/lastUpdateReason' + description: Margin order update reason; ReduceOnlyCancel means reduce_only capped the order at placement. The field is omitted when no reason applies. subaccount_number: type: integer order_source: diff --git a/specs/perps_openapi.yaml b/specs/perps_openapi.yaml index 49674ae..9522c58 100644 --- a/specs/perps_openapi.yaml +++ b/specs/perps_openapi.yaml @@ -51,10 +51,14 @@ paths: operationId: GetFCMSubtraderRiskControls summary: Get FCM Subtrader Risk Controls description: | - Returns the initial margin caps configured for an FCM member's subtrader on the margined - 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. + Returns the risk controls configured for an FCM member's subtrader on the margined + exchange: the FCM-set initial margin caps in `risk_controls`, and the admin-set notional + value risk limits in `notional_limits` — one call returns the subtrader's complete limit + picture. 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. A + notional_limits entry without a market_ticker is the whole-subtrader (all-markets) limit; + the rest are per-market. Every cap or limit 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. @@ -68,7 +72,7 @@ paths: - name: subtrader_id in: query 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. + description: The subtrader whose risk controls and notional value risk limits should be returned. Must belong to the requesting FCM; newly created subtrader IDs take the form {your_account_id}_{suffix}, and any subtrader ID of yours (including legacy UUID-form IDs) is accepted. 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 @@ -86,7 +90,7 @@ paths: schema: type: string x-go-type-skip-optional-pointer: true - enum: [Crypto, Equities, Metals, FX, Energy, Indices, Rates, Compute, GPU] + enum: [Crypto, Equities, Metals] responses: '200': description: Risk controls retrieved successfully @@ -168,7 +172,7 @@ paths: schema: type: string x-go-type-skip-optional-pointer: true - enum: [Crypto, Equities, Metals, FX, Energy, Indices, Rates, Compute, GPU] + enum: [Crypto, Equities, Metals] responses: '200': description: Risk controls deleted successfully @@ -187,6 +191,73 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /margin/fcm/notional_risk_limit: + put: + operationId: UpdateFCMNotionalRiskLimit + summary: Update FCM Notional Risk Limit + description: | + Sets the calling FCM member's own account-level notional value risk limit on the margined + exchange. The limit is stored separately from the limit Kalshi administration sets on the + same account; the exchange accepts any value and always enforces the smaller of the two, + so this value can tighten but never raise the effective limit. As a courtesy, a value + above the current Kalshi-set limit is rejected with a 400 naming that ceiling — a larger + value would never govern. Lowering the limit cancels resting orders until the account is + back within it, exactly like an administrative lowering. + tags: + - fcm + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateFCMNotionalRiskLimitRequest' + responses: + '200': + description: Notional risk limit 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' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: DeleteFCMNotionalRiskLimit + summary: Delete FCM Notional Risk Limit + description: | + Clears the calling FCM member's own account-level notional value risk limit on the + margined exchange. Any limit Kalshi administration has set on the account remains in + force; clearing the member-set value never cancels resting orders, since it can only + raise the effective limit. + tags: + - fcm + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + responses: + '200': + description: Notional risk limit deleted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '500': + $ref: '#/components/responses/InternalServerError' + /account/limits/perps: get: operationId: GetPerpsAccountApiLimits @@ -840,7 +911,11 @@ paths: get: operationId: GetMarginNotionalRiskLimit summary: Get Notional Risk Limit - description: 'Endpoint for retrieving the notional value risk limit for the authenticated margin user.' + description: | + Endpoint for retrieving the notional value risk limits for the authenticated margin user. + Direct members and FCM members may read their own account's limits. For an FCM member the + account-level limit covers the whole omnibus account: exposure across all client accounts + is aggregated against it. tags: - risk security: @@ -856,6 +931,8 @@ paths: $ref: '#/components/schemas/NotionalRiskLimitResponse' '401': $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' '500': $ref: '#/components/responses/InternalServerError' @@ -1709,7 +1786,7 @@ components: type: apiKey in: header name: KALSHI-ACCESS-SIGNATURE - description: RSA-PSS signature of the request + description: Base64 signature of the pre-sign text (timestamp + method + path) made with the API key's algorithm - RSA-PSS with SHA-256 for RSA keys, Ed25519 for Ed25519 keys kalshiAccessTimestamp: type: apiKey in: header @@ -1856,6 +1933,18 @@ components: subaccount_number: type: integer description: The sequential number assigned to this subaccount (1-63). + UpdateFCMNotionalRiskLimitRequest: + type: object + required: + - notional_value_risk_limit + properties: + notional_value_risk_limit: + allOf: + - $ref: '#/components/schemas/FixedPointDollars' + description: A non-negative fixed-point US dollar amount with up to 4 decimal places. A value above the current Kalshi-set limit on the account gets a courtesy 400, since it would never govern. + pattern: '^[0-9]+(\.[0-9]{1,4})?$' + maxLength: 20 + example: "5000.0000" UpdateFCMSubtraderRiskControlsRequest: type: object required: @@ -1873,7 +1962,7 @@ components: type: string description: Scopes the initial margin cap to this asset class when supplied. Mutually exclusive with market_ticker. x-go-type-skip-optional-pointer: true - enum: [Crypto, Equities, Metals, FX, Energy, Indices, Rates, Compute, GPU] + enum: [Crypto, Equities, Metals] im_cap: allOf: - $ref: '#/components/schemas/FixedPointDollars' @@ -1885,17 +1974,24 @@ components: type: object required: - risk_controls + - notional_limits properties: risk_controls: type: array description: One entry per configured initial margin cap. items: $ref: '#/components/schemas/FCMSubtraderRiskControls' + notional_limits: + type: array + description: The admin-set notional value risk limits for the same subtrader as the rest of the response, sorted by market_ticker. An entry without a market_ticker is the whole-subtrader (all-markets) limit and sorts first; the rest are per-market. Set by exchange administration and read-only through this API; markets without a configured limit are omitted, and the market_ticker/asset_class filters apply only to risk_controls. Newly created subtrader IDs take the form {your_account_id}_{suffix}; legacy UUID-form subtraders are included as well. + items: + $ref: '#/components/schemas/FCMSubtraderNotionalRiskLimit' FCMSubtraderRiskControls: type: object required: - subtrader_id - im_cap + - current_im properties: subtrader_id: type: string @@ -1908,12 +2004,53 @@ components: type: string description: Present only on an asset-class-scoped cap. A cap with neither market_ticker nor asset_class applies across all markets. x-go-type-skip-optional-pointer: true - enum: [Crypto, Equities, Metals, FX, Energy, Indices, Rates, Compute, GPU] + enum: [Crypto, Equities, Metals] im_cap: allOf: - $ref: '#/components/schemas/FixedPointDollars' description: A non-negative fixed-point US dollar amount with up to 4 decimal places. example: "100.0000" + current_im: + allOf: + - $ref: '#/components/schemas/FixedPointDollars' + description: >- + The initial margin currently attributable to this cap's scope, in fixed-point US dollars — the value the + exchange compares against im_cap when admitting an order. Computed from the exchange's read model + (positions plus resting orders), so it excludes orders still in flight and may slightly trail the engine. + A market-scoped cap prices that market standalone; an asset-class cap prices the class-filtered portfolio, + so hedged positions within the class margin jointly rather than summing per-market. + example: "42.0000" + FCMSubtraderNotionalRiskLimit: + type: object + required: + - subtrader_id + - notional_value_risk_limit + - current_notional + properties: + subtrader_id: + type: string + description: The subtrader the notional value risk limit applies to. + market_ticker: + type: string + description: The market the notional value risk limit applies to. Absent on the whole-subtrader (all-markets) limit. + x-go-type-skip-optional-pointer: true + notional_value_risk_limit: + allOf: + - $ref: '#/components/schemas/FixedPointDollars' + description: The notional value risk limit as a fixed-point US dollar string with 4 decimal places. + example: "5000.0000" + current_notional: + allOf: + - $ref: '#/components/schemas/FixedPointDollars' + description: >- + The notional currently consumed against this limit, in fixed-point US dollars. Per market it is the larger + of the subtrader's long side (position plus resting bids) and short side (position minus resting asks), + over the signed net position; a whole-subtrader entry carries that value summed across every market the + subtrader touches. Every term is priced through the market's risk-notional model, the same pricing the + exchange's limit check uses: the cached mark for positions and the limit price for resting orders on + mark-model asset classes, quantity times the fixed DV01 base on Rates. Computed from the exchange's read + model, so it excludes orders still in flight and may slightly trail the engine. + example: "1250.0000" # Order Group schemas EmptyResponse: type: object @@ -2864,7 +3001,8 @@ components: LastUpdateReason: type: string - enum: ['', 'Decrease', 'Amend', 'MarginCancel', 'SelfTradeCancel', 'ExpiryCancel', 'Trade', 'PostOnlyCrossCancel'] + enum: ['', 'Decrease', 'Amend', 'MarginCancel', 'SelfTradeCancel', 'ExpiryCancel', 'Trade', 'PostOnlyCrossCancel', 'ReduceOnlyCancel'] + description: ReduceOnlyCancel means reduce_only capped the order at placement. MarginMarketResponse: type: object @@ -3060,6 +3198,8 @@ components: required: - default_notional_value_risk_limit - notional_value_risk_limits_by_market_ticker + - total_current_usage + - current_usage_by_market_ticker properties: default_notional_value_risk_limit: type: string @@ -3072,6 +3212,44 @@ components: description: Map of market_ticker to notional value risk limit as a fixed-point dollar string with 4 decimal places (e.g., "5000.0000"). If present, the market-level risk limit overrides the default notional value risk limit. example: "market-abc-123": "5000.0000" + total_current_usage: + allOf: + - $ref: '#/components/schemas/FixedPointDollars' + description: >- + The account's current notional usage in fixed-point US dollars, aggregated the way the exchange checks the + account-level limit: per market, the larger of the long side (long positions plus resting bids) and the + short side (short positions plus resting asks), summed across every market the account holds positions or + resting orders in — for an FCM, across the whole omnibus account. Every term is priced through the + market's risk-notional model, the same pricing the exchange's limit check uses: the cached mark for + positions and the limit price for resting orders. Computed from the exchange's read model, so it excludes + orders still in flight and may slightly trail the engine. + example: "1250.0000" + current_usage_by_market_ticker: + type: object + additionalProperties: + $ref: '#/components/schemas/FixedPointDollars' + description: >- + Per-market components of total_current_usage, as fixed-point US dollar strings. Entries exist only for + markets where the account holds positions or resting orders; like the total, values come from the + exchange's read model and may slightly trail the engine. + example: + "market-abc-123": "1250.0000" + member_notional_value_risk_limit: + allOf: + - $ref: '#/components/schemas/FixedPointDollars' + description: >- + The account-level notional value risk limit the member set on its own account (FCM members only), as a + fixed-point dollar string. Stored separately from the Kalshi-set limit; the exchange enforces the smaller + of the two. Absent when the member has not set one. + example: "5000.0000" + effective_account_notional_value_risk_limit: + allOf: + - $ref: '#/components/schemas/FixedPointDollars' + description: >- + The account-level notional value risk limit the exchange enforces, as a fixed-point dollar string - the + smaller of the Kalshi-set limit and the member-set limit, or whichever one is set. Absent when neither is + set. + example: "5000.0000" MarginSubaccountBalance: type: object diff --git a/specs/perps_scm_openapi.yaml b/specs/perps_scm_openapi.yaml index 983b958..887a5d0 100644 --- a/specs/perps_scm_openapi.yaml +++ b/specs/perps_scm_openapi.yaml @@ -530,7 +530,7 @@ paths: summary: Create Subtrader-Bound API Key description: >- FCM clearing members only. Mint an API key bound to one of your margin - subtraders using an RSA public key you supply. The binding is set at + subtraders using an RSA or Ed25519 public key you supply. The binding is set at mint and immutable; the resulting key is the institution's trading credential for that subtrader and cannot reach any REST endpoint, including key management. @@ -713,6 +713,12 @@ components: - cash_activity_daily - daily_financial_summary - interest_on_collateral_monthly + - daily_statement + description: >- + Report file type. Every type is a CSV except `daily_statement`, + the end-of-day PDF statement that lays out the same date's + position, trade, settlement, funding, and cash files with an + account summary. url: { type: string, description: Presigned download URL (omitted from logs). } date: type: string @@ -1092,10 +1098,6 @@ components: additionalProperties: { $ref: '#/components/schemas/SettlementEstimate' } omitted_subtrader_count: { type: integer, format: int64, description: Number of subtraders omitted from subtrader_breakdowns. Their amounts are still included in user_breakdown. } omitted_group_count: { type: integer, format: int64, description: Number of groups omitted from group_breakdowns. Their amounts are still included in user_breakdown. } - prev_settlement_prices: - type: object - description: Map of market ticker to that market's most recent settlement (mark) price, in centicents. - additionalProperties: { type: integer, format: int64 } next_runtime: { type: string, format: date-time, description: Next settlement time for this asset class. } GetSettlementEstimateByAssetClassResponse: @@ -1280,7 +1282,7 @@ components: description: Name identifying the key's purpose, e.g. the institution it is issued to. public_key: type: string - description: RSA public key in PEM format. Signatures on the institution's requests verify against it. + description: RSA or Ed25519 public key in PEM format. Signatures on the institution's requests verify against it with the key's algorithm. fcm_subtrader_id: type: string description: The subtrader to bind, spelled {your_account_id}_{suffix} with a suffix of 1-16 characters of [a-z0-9]. Must already exist and belong to you. diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 40ab435..458ee3c 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -827,6 +827,16 @@ class Exclusion: path_template=("/series/{series_ticker}/events/{ticker}/forecast_percentile_history"), ), # ── fcm ───────────────────────────────────────────────────────────────── + MethodEndpointEntry( + sdk_method="kalshi.resources.fcm.FcmResource.fills", + http_method="GET", + path_template="/fcm/fills", + ), + MethodEndpointEntry( + sdk_method="kalshi.resources.fcm.FcmResource.fills_all", + http_method="GET", + path_template="/fcm/fills", + ), MethodEndpointEntry( sdk_method="kalshi.resources.fcm.FcmResource.orders", http_method="GET", @@ -1235,7 +1245,11 @@ class Exclusion: reason="paginator-handled; not a caller-facing kwarg on list_all", kind="paginator_handled", ), - # --- fcm.orders_all: cursor paginator-handled --- + # --- fcm.orders_all / fills_all: cursor paginator-handled --- + ("kalshi.resources.fcm.FcmResource.fills_all", "cursor"): Exclusion( + reason="paginator-handled; not a caller-facing kwarg on fills_all", + kind="paginator_handled", + ), ("kalshi.resources.fcm.FcmResource.orders_all", "cursor"): Exclusion( reason="paginator-handled; not a caller-facing kwarg on list_all", kind="paginator_handled", @@ -1351,6 +1365,7 @@ class Exclusion: "kalshi.resources.portfolio.PortfolioResource.fills_all", "kalshi.resources.portfolio.PortfolioResource.intra_exchange_transfers_all", "kalshi.resources.fcm.FcmResource.orders_all", + "kalshi.resources.fcm.FcmResource.fills_all", "kalshi.resources.fcm.FcmResource.positions_all", "kalshi.resources.incentive_programs.IncentiveProgramsResource.list_all", "kalshi.resources.structured_targets.StructuredTargetsResource.list_all", @@ -1383,6 +1398,7 @@ class Exclusion: "kalshi.resources.portfolio.AsyncPortfolioResource.fills_all", "kalshi.resources.portfolio.AsyncPortfolioResource.intra_exchange_transfers_all", "kalshi.resources.fcm.AsyncFcmResource.orders_all", + "kalshi.resources.fcm.AsyncFcmResource.fills_all", "kalshi.resources.fcm.AsyncFcmResource.positions_all", "kalshi.resources.incentive_programs.AsyncIncentiveProgramsResource.list_all", "kalshi.resources.structured_targets.AsyncStructuredTargetsResource.list_all", @@ -1649,6 +1665,17 @@ class Exclusion: http_method="DELETE", path_template="/margin/fcm/subtraders/risk_controls", ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.fcm.FcmResource.update_notional_risk_limit", + http_method="PUT", + path_template="/margin/fcm/notional_risk_limit", + request_body_schema="#/components/schemas/UpdateFCMNotionalRiskLimitRequest", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.fcm.FcmResource.delete_notional_risk_limit", + http_method="DELETE", + path_template="/margin/fcm/notional_risk_limit", + ), # ── perps exit triggers ── MethodEndpointEntry( sdk_method="kalshi.perps.resources.portfolio.PerpsPortfolioResource.cross_exit_triggers", diff --git a/tests/perps/test_fcm.py b/tests/perps/test_fcm.py index 00d4302..c1944ab 100644 --- a/tests/perps/test_fcm.py +++ b/tests/perps/test_fcm.py @@ -15,6 +15,7 @@ from kalshi.perps.models.fcm import ( CreateMarginFCMSubtraderRequest, CreateMarginFCMSubtraderResponse, + UpdateFCMNotionalRiskLimitRequest, UpdateFCMSubtraderRiskControlsRequest, ) @@ -112,13 +113,22 @@ def test_get_risk_controls(self, perps_client: PerpsClient) -> None: { "subtrader_id": "user_desk1", "im_cap": "100.0000", + "current_im": "40.0000", }, { "subtrader_id": "user_desk1", "market_ticker": "BTC-PERP", "im_cap": "25.5000", + "current_im": "10.0000", }, - ] + ], + "notional_limits": [ + { + "subtrader_id": "user_desk1", + "notional_value_risk_limit": "5000.0000", + "current_notional": "1250.0000", + } + ], }, ) ) @@ -126,13 +136,17 @@ def test_get_risk_controls(self, perps_client: PerpsClient) -> None: assert len(resp.risk_controls) == 2 assert resp.risk_controls[0].market_ticker is None assert resp.risk_controls[0].im_cap == Decimal("100.0000") + assert resp.risk_controls[0].current_im == Decimal("40.0000") assert resp.risk_controls[1].market_ticker == "BTC-PERP" + assert len(resp.notional_limits) == 1 + assert resp.notional_limits[0].market_ticker is None + assert resp.notional_limits[0].current_notional == Decimal("1250.0000") assert dict(route.calls[0].request.url.params) == {"subtrader_id": "user_desk1"} @respx.mock def test_get_risk_controls_filters_market(self, perps_client: PerpsClient) -> None: route = respx.get(f"{BASE}/margin/fcm/subtraders/risk_controls").mock( - return_value=httpx.Response(200, json={"risk_controls": []}) + return_value=httpx.Response(200, json={"risk_controls": [], "notional_limits": []}) ) perps_client.fcm.risk_controls(subtrader_id="user_desk1", market_ticker="ETH-PERP") assert dict(route.calls[0].request.url.params) == { @@ -143,7 +157,7 @@ def test_get_risk_controls_filters_market(self, perps_client: PerpsClient) -> No @respx.mock def test_get_risk_controls_filters_asset_class(self, perps_client: PerpsClient) -> None: route = respx.get(f"{BASE}/margin/fcm/subtraders/risk_controls").mock( - return_value=httpx.Response(200, json={"risk_controls": []}) + return_value=httpx.Response(200, json={"risk_controls": [], "notional_limits": []}) ) perps_client.fcm.risk_controls(subtrader_id="user_desk1", asset_class="Crypto") assert dict(route.calls[0].request.url.params) == { @@ -250,8 +264,13 @@ async def test_async_roundtrip(self, async_perps_client: AsyncPerpsClient) -> No 200, json={ "risk_controls": [ - {"subtrader_id": "user_desk1", "im_cap": "1.0000"}, - ] + { + "subtrader_id": "user_desk1", + "im_cap": "1.0000", + "current_im": "0.2500", + }, + ], + "notional_limits": [], }, ) ) @@ -268,3 +287,105 @@ async def test_async_roundtrip(self, async_perps_client: AsyncPerpsClient) -> No im_cap=Decimal("2.00"), ) await async_perps_client.fcm.delete_risk_controls(subtrader_id="user_desk1") + + +class TestUpdateFCMNotionalRiskLimitRequest: + def test_serializes(self) -> None: + req = UpdateFCMNotionalRiskLimitRequest(notional_value_risk_limit=Decimal("5000.0000")) + assert req.model_dump(exclude_none=True, by_alias=True, mode="json") == { + "notional_value_risk_limit": "5000.0000", + } + + def test_rejects_negative(self) -> None: + with pytest.raises(ValidationError): + UpdateFCMNotionalRiskLimitRequest(notional_value_risk_limit=Decimal("-1.00")) + + def test_forbids_extra(self) -> None: + with pytest.raises(ValidationError): + UpdateFCMNotionalRiskLimitRequest( # type: ignore[call-arg] + notional_value_risk_limit=Decimal("1.00"), + phantom=1, + ) + + +class TestFcmNotionalRiskLimit: + @respx.mock + def test_update_kwargs(self, perps_client: PerpsClient) -> None: + route = respx.put(f"{BASE}/margin/fcm/notional_risk_limit").mock( + return_value=httpx.Response(200, json={}) + ) + perps_client.fcm.update_notional_risk_limit( + notional_value_risk_limit=Decimal("5000.0000") + ) + assert json.loads(route.calls[0].request.content) == { + "notional_value_risk_limit": "5000.0000", + } + + @respx.mock + def test_update_request_model(self, perps_client: PerpsClient) -> None: + route = respx.put(f"{BASE}/margin/fcm/notional_risk_limit").mock( + return_value=httpx.Response(200, json={}) + ) + req = UpdateFCMNotionalRiskLimitRequest(notional_value_risk_limit=Decimal("10.00")) + perps_client.fcm.update_notional_risk_limit(request=req) + assert json.loads(route.calls[0].request.content) == { + "notional_value_risk_limit": "10.00", + } + + def test_update_requires_args(self, perps_client: PerpsClient) -> None: + with pytest.raises(TypeError, match="update_notional_risk_limit"): + perps_client.fcm.update_notional_risk_limit() # type: ignore[call-overload] + + @respx.mock + def test_update_server_400(self, perps_client: PerpsClient) -> None: + respx.put(f"{BASE}/margin/fcm/notional_risk_limit").mock( + return_value=httpx.Response(400, json={"message": "above Kalshi ceiling"}) + ) + with pytest.raises(Exception): # noqa: B017 — mapped validation error + perps_client.fcm.update_notional_risk_limit( + notional_value_risk_limit=Decimal("999999.0000") + ) + + def test_update_unauthenticated(self) -> None: + client = PerpsClient(config=PerpsConfig.demo(max_retries=0)) + with pytest.raises(AuthRequiredError): + client.fcm.update_notional_risk_limit(notional_value_risk_limit=Decimal("1")) + + @respx.mock + def test_delete(self, perps_client: PerpsClient) -> None: + route = respx.delete(f"{BASE}/margin/fcm/notional_risk_limit").mock( + return_value=httpx.Response(200, json={}) + ) + assert perps_client.fcm.delete_notional_risk_limit() is None + assert route.called + assert route.calls[0].request.content == b"" + + @respx.mock + def test_delete_server_401(self, perps_client: PerpsClient) -> None: + respx.delete(f"{BASE}/margin/fcm/notional_risk_limit").mock( + return_value=httpx.Response(401, json={"message": "unauthorized"}) + ) + with pytest.raises(Exception): # noqa: B017 — mapped auth error + perps_client.fcm.delete_notional_risk_limit() + + def test_delete_unauthenticated(self) -> None: + client = PerpsClient(config=PerpsConfig.demo(max_retries=0)) + with pytest.raises(AuthRequiredError): + client.fcm.delete_notional_risk_limit() + + @respx.mock + async def test_async_roundtrip(self, async_perps_client: AsyncPerpsClient) -> None: + put = respx.put(f"{BASE}/margin/fcm/notional_risk_limit").mock( + return_value=httpx.Response(200, json={}) + ) + delete = respx.delete(f"{BASE}/margin/fcm/notional_risk_limit").mock( + return_value=httpx.Response(200, json={}) + ) + await async_perps_client.fcm.update_notional_risk_limit( + notional_value_risk_limit=Decimal("2.00") + ) + await async_perps_client.fcm.delete_notional_risk_limit() + assert json.loads(put.calls[0].request.content) == { + "notional_value_risk_limit": "2.00", + } + assert delete.called diff --git a/tests/perps/test_margin_account.py b/tests/perps/test_margin_account.py index e03589c..7bb7bd0 100644 --- a/tests/perps/test_margin_account.py +++ b/tests/perps/test_margin_account.py @@ -313,6 +313,10 @@ def test_happy(self, perps_client: PerpsClient) -> None: "market-abc-123": "7500.0000", "market-xyz-789": "1000.0000", }, + "total_current_usage": "1250.0000", + "current_usage_by_market_ticker": {"market-abc-123": "1250.0000"}, + "member_notional_value_risk_limit": "4000.0000", + "effective_account_notional_value_risk_limit": "4000.0000", }, ) ) @@ -323,6 +327,10 @@ def test_happy(self, perps_client: PerpsClient) -> None: overrides = resp.notional_value_risk_limits_by_market_ticker assert overrides["market-abc-123"] == Decimal("7500.0000") assert isinstance(overrides["market-xyz-789"], Decimal) + assert resp.total_current_usage == Decimal("1250.0000") + assert resp.current_usage_by_market_ticker["market-abc-123"] == Decimal("1250.0000") + assert resp.member_notional_value_risk_limit == Decimal("4000.0000") + assert resp.effective_account_notional_value_risk_limit == Decimal("4000.0000") @respx.mock def test_edge_empty_override_map(self, perps_client: PerpsClient) -> None: @@ -332,11 +340,16 @@ def test_edge_empty_override_map(self, perps_client: PerpsClient) -> None: json={ "default_notional_value_risk_limit": "5000.0000", "notional_value_risk_limits_by_market_ticker": {}, + "total_current_usage": "0.0000", + "current_usage_by_market_ticker": {}, }, ) ) resp = perps_client.margin.notional_risk_limit() assert resp.notional_value_risk_limits_by_market_ticker == {} + assert resp.current_usage_by_market_ticker == {} + assert resp.member_notional_value_risk_limit is None + assert resp.effective_account_notional_value_risk_limit is None @respx.mock def test_server_401_maps(self, perps_client: PerpsClient) -> None: @@ -354,6 +367,8 @@ def test_unauthenticated_raises_before_http(self) -> None: json={ "default_notional_value_risk_limit": "0.0000", "notional_value_risk_limits_by_market_ticker": {}, + "total_current_usage": "0.0000", + "current_usage_by_market_ticker": {}, }, ) ) @@ -371,6 +386,8 @@ async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: json={ "default_notional_value_risk_limit": "5000.0000", "notional_value_risk_limits_by_market_ticker": {"m-1": "1.0000"}, + "total_current_usage": "1.0000", + "current_usage_by_market_ticker": {"m-1": "1.0000"}, }, ) ) diff --git a/tests/perps/test_models_common.py b/tests/perps/test_models_common.py index fee344c..b3526b8 100644 --- a/tests/perps/test_models_common.py +++ b/tests/perps/test_models_common.py @@ -39,6 +39,7 @@ def test_last_update_reason_includes_empty_string_member(self) -> None: # PascalCase wire members (no snake_case rename). assert LastUpdateReason("MarginCancel") is LastUpdateReason.MARGIN_CANCEL assert LastUpdateReason("PostOnlyCrossCancel") is LastUpdateReason.POST_ONLY_CROSS_CANCEL + assert LastUpdateReason("ReduceOnlyCancel") is LastUpdateReason.REDUCE_ONLY_CANCEL def test_order_source_values(self) -> None: assert OrderSource("user") is OrderSource.USER diff --git a/tests/perps/test_orders.py b/tests/perps/test_orders.py index a029dcd..780c461 100644 --- a/tests/perps/test_orders.py +++ b/tests/perps/test_orders.py @@ -51,6 +51,11 @@ def _order_dict(**overrides: object) -> dict[str, object]: return base +def test_margin_order_accepts_reduce_only_cancel() -> None: + order = MarginOrder.model_validate(_order_dict(last_update_reason="ReduceOnlyCancel")) + assert order.last_update_reason == "ReduceOnlyCancel" + + # ── create ─────────────────────────────────────────────────────────────────── diff --git a/tests/perps/ws/test_perps_ws_models.py b/tests/perps/ws/test_perps_ws_models.py index 1f764b4..7c0df02 100644 --- a/tests/perps/ws/test_perps_ws_models.py +++ b/tests/perps/ws/test_perps_ws_models.py @@ -294,6 +294,22 @@ def test_delta_last_update_reason_empty_string_ok(self) -> None: msg = MarginOrderbookDeltaMessage.model_validate(frame) assert msg.msg.last_update_reason == "" + def test_delta_last_update_reason_reduce_only_cancel(self) -> None: + frame = { + "type": "orderbook_delta", + "sid": 1, + "seq": 2, + "msg": { + "market_ticker": "BTC-PERP", + "price": "100.5000", + "delta": "3.00", + "side": "ask", + "last_update_reason": "ReduceOnlyCancel", + }, + } + msg = MarginOrderbookDeltaMessage.model_validate(frame) + assert msg.msg.last_update_reason == "ReduceOnlyCancel" + class TestTicker: def _full_frame(self) -> dict[str, Any]: diff --git a/tests/test_api_keys.py b/tests/test_api_keys.py index a538638..f8e1b32 100644 --- a/tests/test_api_keys.py +++ b/tests/test_api_keys.py @@ -384,6 +384,59 @@ def test_generate_requires_auth( with pytest.raises(AuthRequiredError): unauth_api_keys.generate(name="bot") + def test_generate_request_serializes_key_type(self) -> None: + req = GenerateApiKeyRequest(name="bot", key_type="ed25519") + body = req.model_dump(exclude_none=True, by_alias=True, mode="json") + assert body["key_type"] == "ed25519" + + def test_generate_request_omits_key_type(self) -> None: + req = GenerateApiKeyRequest(name="bot") + body = req.model_dump(exclude_none=True, by_alias=True, mode="json") + assert "key_type" not in body + + def test_generate_request_rejects_bad_key_type(self) -> None: + with pytest.raises(ValidationError): + GenerateApiKeyRequest(name="bot", key_type="dsa") # type: ignore[arg-type] + + @respx.mock + def test_generate_sends_key_type(self, api_keys: ApiKeysResource) -> None: + route = respx.post( + "https://test.kalshi.com/trade-api/v2/api_keys/generate", + ).mock( + return_value=httpx.Response( + 201, + json={ + "api_key_id": "k-ed", + "private_key": "-----BEGIN PRIVATE KEY-----", + "key_type": "ed25519", + }, + ), + ) + resp = api_keys.generate(name="bot", key_type="ed25519") + assert json.loads(route.calls[0].request.content)["key_type"] == "ed25519" + assert resp.key_type == "ed25519" + assert resp.private_key.get_secret_value().startswith("-----BEGIN") + + @respx.mock + def test_generate_response_key_type_absent(self, api_keys: ApiKeysResource) -> None: + respx.post( + "https://test.kalshi.com/trade-api/v2/api_keys/generate", + ).mock( + return_value=httpx.Response( + 201, json={"api_key_id": "k-auto", "private_key": "-----BEGIN..."} + ), + ) + resp = api_keys.generate(name="bot") + assert resp.key_type is None + + @respx.mock + def test_generate_server_400(self, api_keys: ApiKeysResource) -> None: + respx.post( + "https://test.kalshi.com/trade-api/v2/api_keys/generate", + ).mock(return_value=httpx.Response(400, json={"message": "bad name"})) + with pytest.raises(KalshiValidationError): + api_keys.generate(name="bot", key_type="rsa") + class TestApiKeysDelete: @respx.mock @@ -450,6 +503,28 @@ async def test_generate( ) resp = await async_api_keys.generate(name="bot") assert resp.private_key.get_secret_value().startswith("-----BEGIN") + assert resp.key_type is None + + @respx.mock + @pytest.mark.asyncio + async def test_generate_key_type( + self, async_api_keys: AsyncApiKeysResource, + ) -> None: + route = respx.post( + "https://test.kalshi.com/trade-api/v2/api_keys/generate", + ).mock( + return_value=httpx.Response( + 201, + json={ + "api_key_id": "k-ed", + "private_key": "-----BEGIN PRIVATE KEY-----", + "key_type": "ed25519", + }, + ), + ) + resp = await async_api_keys.generate(name="bot", key_type="rsa") + assert json.loads(route.calls[0].request.content)["key_type"] == "rsa" + assert resp.key_type == "ed25519" @respx.mock @pytest.mark.asyncio diff --git a/tests/test_async_markets.py b/tests/test_async_markets.py index ddedfef..9576688 100644 --- a/tests/test_async_markets.py +++ b/tests/test_async_markets.py @@ -71,7 +71,7 @@ async def test_market_type_kwarg_removed(self, markets: AsyncMarketsResource) -> @respx.mock @pytest.mark.asyncio async def test_list_with_all_new_filters(self, markets: AsyncMarketsResource) -> None: - """v0.7.0 ADDs: tickers, mve_filter, 7 *_ts filters.""" + """v0.7.0 ADDs: tickers, mve_filter, *_ts filters (incl. max_updated_ts).""" route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( return_value=httpx.Response(200, json={"markets": [], "cursor": None}) ) @@ -84,6 +84,7 @@ async def test_list_with_all_new_filters(self, markets: AsyncMarketsResource) -> min_created_ts=1000, max_created_ts=2000, min_updated_ts=1500, + max_updated_ts=1600, min_close_ts=3000, max_close_ts=4000, min_settled_ts=5000, @@ -100,6 +101,7 @@ async def test_list_with_all_new_filters(self, markets: AsyncMarketsResource) -> assert params["min_created_ts"] == "1000" assert params["max_created_ts"] == "2000" assert params["min_updated_ts"] == "1500" + assert params["max_updated_ts"] == "1600" assert params["min_close_ts"] == "3000" assert params["max_close_ts"] == "4000" assert params["min_settled_ts"] == "5000" @@ -485,3 +487,40 @@ async def test_issue_349_list_trades_all_emits_deprecation_warning( iterator = markets.list_trades_all(limit=1) items = [t async for t in iterator] assert [t.trade_id for t in items] == ["t-1"] + + +class TestAsyncMaxUpdatedTs: + @respx.mock + async def test_list_sends_max_updated_ts(self, markets: AsyncMarketsResource) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( + return_value=httpx.Response(200, json={"markets": [], "cursor": None}) + ) + await markets.list(max_updated_ts=1_700_000_000) + assert route.calls[0].request.url.params["max_updated_ts"] == "1700000000" + + @respx.mock + async def test_list_omits_when_unset(self, markets: AsyncMarketsResource) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( + return_value=httpx.Response(200, json={"markets": [], "cursor": None}) + ) + await markets.list() + assert "max_updated_ts" not in route.calls[0].request.url.params + + @respx.mock + async def test_list_server_400(self, markets: AsyncMarketsResource) -> None: + from kalshi.errors import KalshiError + + respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( + return_value=httpx.Response(400, json={"message": "bad window"}) + ) + with pytest.raises(KalshiError): + await markets.list(max_updated_ts=-1) + + @respx.mock + async def test_list_all_forwards_max_updated_ts(self, markets: AsyncMarketsResource) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( + return_value=httpx.Response(200, json={"markets": [], "cursor": ""}) + ) + items = [m async for m in markets.list_all(max_updated_ts=42)] + assert items == [] + assert route.calls[0].request.url.params["max_updated_ts"] == "42" diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 7b74776..1fd8c6e 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -1500,6 +1500,9 @@ def _assert_params_match( "#/components/schemas/UpdateFCMSubtraderRiskControlsRequest": ( "kalshi.perps.models.fcm.UpdateFCMSubtraderRiskControlsRequest" ), + "#/components/schemas/UpdateFCMNotionalRiskLimitRequest": ( + "kalshi.perps.models.fcm.UpdateFCMNotionalRiskLimitRequest" + ), "#/components/schemas/SetCrossExitTriggerRequest": ( "kalshi.perps.models.portfolio.SetCrossExitTriggerRequest" ), diff --git a/tests/test_fcm.py b/tests/test_fcm.py index 88f592c..2fab974 100644 --- a/tests/test_fcm.py +++ b/tests/test_fcm.py @@ -382,3 +382,127 @@ async def test_async_list_and_create(self, async_fcm: AsyncFcmResource) -> None: created = await async_fcm.create_subtrader(subtrader_suffix="a") assert listed.subtraders == [] assert created.subtrader_id == "acct_a" + + +_FILL = { + "fill_id": "fill-1", + "exchange_index": 0, + "ticker": "TEST-MKT", + "taker_outcome_side": "yes", + "count_fp": "2.00", + "yes_price_dollars": "0.5600", + "created_time": "2026-04-12T12:00:00Z", + "maker_order_id": "maker-1", + "maker_subtrader_id": "acct_maker", + "maker_fee_cost": "0.0100", + "taker_order_id": "taker-1", + "taker_subtrader_id": "acct_taker", + "taker_fee_cost": "0.0200", +} + +_FCM = "https://test.kalshi.com/trade-api/v2" + + +class TestFills: + @respx.mock + def test_returns_fills(self, fcm: FcmResource) -> None: + route = respx.get(f"{_FCM}/fcm/fills").mock( + return_value=httpx.Response(200, json={"fills": [_FILL], "cursor": "p2"}) + ) + resp = fcm.fills(min_ts=100, max_ts=200, cursor="p1") + params = dict(route.calls[0].request.url.params) + assert params == {"min_ts": "100", "max_ts": "200", "cursor": "p1"} + assert len(resp.fills) == 1 + fill = resp.fills[0] + assert fill.fill_id == "fill-1" + assert fill.count == Decimal("2.00") + assert fill.yes_price == Decimal("0.5600") + assert fill.taker_outcome_side == "yes" + assert fill.maker_fee_cost == Decimal("0.0100") + assert fill.taker_fee_cost == Decimal("0.0200") + assert resp.cursor == "p2" + + @respx.mock + def test_optional_fields_omitted(self, fcm: FcmResource) -> None: + respx.get(f"{_FCM}/fcm/fills").mock( + return_value=httpx.Response( + 200, + json={ + "fills": [ + { + "fill_id": "fill-2", + "exchange_index": 1, + "ticker": "TEST-MKT", + "taker_outcome_side": "no", + "count_fp": "1.00", + "yes_price_dollars": "0.1000", + } + ], + "cursor": "", + }, + ) + ) + resp = fcm.fills() + fill = resp.fills[0] + assert fill.maker_order_id is None + assert fill.maker_fee_cost is None + assert fill.taker_fee_cost is None + assert fill.created_time is None + assert resp.cursor == "" + assert fill.count == Decimal("1.00") + + def test_requires_auth(self, unauth_fcm: FcmResource) -> None: + with pytest.raises(AuthRequiredError): + unauth_fcm.fills() + + @respx.mock + def test_server_401(self, fcm: FcmResource) -> None: + respx.get(f"{_FCM}/fcm/fills").mock( + return_value=httpx.Response(401, json={"error": "unauthorized"}) + ) + with pytest.raises(KalshiAuthError): + fcm.fills() + + @respx.mock + def test_fills_all_paginates(self, fcm: FcmResource) -> None: + respx.get(f"{_FCM}/fcm/fills").mock( + side_effect=[ + httpx.Response(200, json={"fills": [_FILL], "cursor": "p2"}), + httpx.Response( + 200, + json={ + "fills": [{**_FILL, "fill_id": "fill-2"}], + "cursor": "", + }, + ), + ] + ) + ids = [fill.fill_id for fill in fcm.fills_all(min_ts=5, max_pages=5)] + assert ids == ["fill-1", "fill-2"] + + def test_fills_all_requires_auth(self, unauth_fcm: FcmResource) -> None: + with pytest.raises(AuthRequiredError): + unauth_fcm.fills_all() + + def test_fills_all_rejects_zero_max_pages(self, fcm: FcmResource) -> None: + with pytest.raises(ValueError, match="max_pages"): + fcm.fills_all(max_pages=0) + + @respx.mock + @pytest.mark.asyncio + async def test_async_fills(self, async_fcm: AsyncFcmResource) -> None: + route = respx.get(f"{_FCM}/fcm/fills").mock( + return_value=httpx.Response(200, json={"fills": [_FILL], "cursor": ""}) + ) + resp = await async_fcm.fills(max_ts=9) + assert dict(route.calls[0].request.url.params) == {"max_ts": "9"} + assert resp.fills[0].yes_price == Decimal("0.5600") + + @respx.mock + @pytest.mark.asyncio + async def test_async_fills_all(self, async_fcm: AsyncFcmResource) -> None: + respx.get(f"{_FCM}/fcm/fills").mock( + return_value=httpx.Response(200, json={"fills": [_FILL], "cursor": ""}) + ) + ids = [fill.fill_id async for fill in async_fcm.fills_all()] + assert ids == ["fill-1"] diff --git a/tests/test_historical.py b/tests/test_historical.py index 8269141..aefeac8 100644 --- a/tests/test_historical.py +++ b/tests/test_historical.py @@ -1047,3 +1047,82 @@ async def test_positions_all_requires_auth( with pytest.raises(AuthRequiredError): async for _ in unauth_async_historical.positions_all(): pass + + +class TestHistoricalSubaccount: + @respx.mock + def test_fills_subaccount(self, historical: HistoricalResource) -> None: + route = respx.get(f"{BASE}/historical/fills").mock( + return_value=httpx.Response(200, json={"fills": [], "cursor": ""}) + ) + page = historical.fills(subaccount=3, ticker="MKT-A") + assert page.items == [] + params = dict(route.calls[0].request.url.params) + assert params["subaccount"] == "3" + assert params["ticker"] == "MKT-A" + + @respx.mock + def test_fills_omits_subaccount(self, historical: HistoricalResource) -> None: + route = respx.get(f"{BASE}/historical/fills").mock( + return_value=httpx.Response(200, json={"fills": [], "cursor": ""}) + ) + historical.fills() + assert "subaccount" not in route.calls[0].request.url.params + + def test_fills_requires_auth(self, unauth_historical: HistoricalResource) -> None: + with pytest.raises(AuthRequiredError): + unauth_historical.fills(subaccount=1) + + @respx.mock + def test_orders_subaccount_zero(self, historical: HistoricalResource) -> None: + route = respx.get(f"{BASE}/historical/orders").mock( + return_value=httpx.Response(200, json={"orders": [], "cursor": ""}) + ) + historical.orders(subaccount=0) + assert route.calls[0].request.url.params["subaccount"] == "0" + + def test_orders_requires_auth(self, unauth_historical: HistoricalResource) -> None: + with pytest.raises(AuthRequiredError): + unauth_historical.orders(subaccount=1) + + @respx.mock + def test_fills_all_forwards_subaccount(self, historical: HistoricalResource) -> None: + route = respx.get(f"{BASE}/historical/fills").mock( + return_value=httpx.Response(200, json={"fills": [], "cursor": ""}) + ) + assert list(historical.fills_all(subaccount=2)) == [] + assert route.calls[0].request.url.params["subaccount"] == "2" + assert "cursor" not in route.calls[0].request.url.params + + @respx.mock + def test_orders_all_forwards_subaccount(self, historical: HistoricalResource) -> None: + route = respx.get(f"{BASE}/historical/orders").mock( + return_value=httpx.Response(200, json={"orders": [], "cursor": ""}) + ) + assert list(historical.orders_all(subaccount=4)) == [] + assert route.calls[0].request.url.params["subaccount"] == "4" + + @respx.mock + async def test_async_fills_and_orders(self, async_historical: AsyncHistoricalResource) -> None: + fills = respx.get(f"{BASE}/historical/fills").mock( + return_value=httpx.Response(200, json={"fills": [], "cursor": ""}) + ) + orders = respx.get(f"{BASE}/historical/orders").mock( + return_value=httpx.Response(200, json={"orders": [], "cursor": ""}) + ) + await async_historical.fills(subaccount=1) + await async_historical.orders(subaccount=1) + assert fills.calls[0].request.url.params["subaccount"] == "1" + assert orders.calls[0].request.url.params["subaccount"] == "1" + fill_items = [f async for f in async_historical.fills_all(subaccount=1)] + order_items = [o async for o in async_historical.orders_all(subaccount=1)] + assert fill_items == [] + assert order_items == [] + + async def test_async_requires_auth( + self, unauth_async_historical: AsyncHistoricalResource + ) -> None: + with pytest.raises(AuthRequiredError): + await unauth_async_historical.fills(subaccount=1) + with pytest.raises(AuthRequiredError): + await unauth_async_historical.orders() diff --git a/tests/test_markets.py b/tests/test_markets.py index 6f84e90..003fc9e 100644 --- a/tests/test_markets.py +++ b/tests/test_markets.py @@ -74,7 +74,7 @@ def test_market_type_kwarg_removed(self, markets: MarketsResource) -> None: @respx.mock def test_list_with_all_new_filters(self, markets: MarketsResource) -> None: - """v0.7.0 ADDs: tickers, mve_filter, 7 *_ts filters.""" + """v0.7.0 ADDs: tickers, mve_filter, *_ts filters (incl. max_updated_ts).""" route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( return_value=httpx.Response(200, json={"markets": [], "cursor": None}) ) @@ -87,6 +87,7 @@ def test_list_with_all_new_filters(self, markets: MarketsResource) -> None: min_created_ts=1000, max_created_ts=2000, min_updated_ts=1500, + max_updated_ts=1600, min_close_ts=3000, max_close_ts=4000, min_settled_ts=5000, @@ -103,6 +104,7 @@ def test_list_with_all_new_filters(self, markets: MarketsResource) -> None: assert params["min_created_ts"] == "1000" assert params["max_created_ts"] == "2000" assert params["min_updated_ts"] == "1500" + assert params["max_updated_ts"] == "1600" assert params["min_close_ts"] == "3000" assert params["max_close_ts"] == "4000" assert params["min_settled_ts"] == "5000" @@ -932,3 +934,40 @@ def test_backward_compat_short_names(self) -> None: # Import here to avoid circular issues at module level from kalshi.models.markets import Market # noqa: E402 + + +class TestMaxUpdatedTs: + @respx.mock + def test_list_sends_max_updated_ts(self, markets: MarketsResource) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( + return_value=httpx.Response(200, json={"markets": [], "cursor": None}) + ) + page = markets.list(min_updated_ts=1_600_000_000, max_updated_ts=1_700_000_000) + assert page.items == [] + params = dict(route.calls[0].request.url.params) + assert params["min_updated_ts"] == "1600000000" + assert params["max_updated_ts"] == "1700000000" + + @respx.mock + def test_list_omits_when_unset(self, markets: MarketsResource) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( + return_value=httpx.Response(200, json={"markets": [], "cursor": None}) + ) + markets.list(status="open") + assert "max_updated_ts" not in route.calls[0].request.url.params + + @respx.mock + def test_list_server_400(self, markets: MarketsResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( + return_value=httpx.Response(400, json={"message": "bad window"}) + ) + with pytest.raises(KalshiError): + markets.list(max_updated_ts=-1) + + @respx.mock + def test_list_all_forwards_max_updated_ts(self, markets: MarketsResource) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( + return_value=httpx.Response(200, json={"markets": [], "cursor": ""}) + ) + assert list(markets.list_all(max_updated_ts=42)) == [] + assert route.calls[0].request.url.params["max_updated_ts"] == "42" diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index 6b5b715..04ab23b 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -1477,6 +1477,20 @@ def test_set_resting_margin_reservation(self, portfolio: PortfolioResource) -> N ) assert json.loads(route.calls[0].request.content)["resting_margin_reservation"] == "max" + @respx.mock + def test_resting_margin_reservation_none(self, portfolio: PortfolioResource) -> None: + 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}], + "resting_margin_reservation": "none", + }, + ) + ) + resp = portfolio.target_balance_allocation() + assert resp.resting_margin_reservation == "none" + @respx.mock def test_set_request_model(self, portfolio: PortfolioResource) -> None: import json