From aabc78ce13e943b9fe8f5b57f069aae68c901172 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sat, 3 Oct 2026 09:30:11 -0500 Subject: [PATCH] Reconcile perps market_version drift (v18.0.0) Closes #527. Adds required MarginMarket.market_version and optional CreateMarginOrderRequest.market_version / orders.create kwarg after nightly strict contract failures. Re-vendors OpenAPI/AsyncAPI/perps specs (core info.version remains 3.32.0). --- CHANGELOG.md | 31 +++++++++++++++++++++++++++++ docs/migration.md | 34 ++++++++++++++++++++++++++++++++ docs/perps.md | 4 ++++ kalshi/__init__.py | 2 +- kalshi/perps/models/markets.py | 3 +++ kalshi/perps/models/orders.py | 4 ++++ kalshi/perps/resources/orders.py | 14 ++++++++++++- kalshi/resources/orders.py | 20 +++++++++++++++---- kalshi/resources/portfolio.py | 11 ++++++++++- pyproject.toml | 2 +- specs/asyncapi.yaml | 10 +++++----- specs/openapi.yaml | 13 ++++++++---- specs/perps_openapi.yaml | 11 +++++++++++ tests/perps/test_markets.py | 3 +++ tests/perps/test_orders.py | 23 +++++++++++++++++++++ 15 files changed, 168 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8080254..2fdcca5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to kalshi-sdk will be documented in this file. +## 18.0.0 — 2026-10-03 + +Reconciles upstream OpenAPI **3.32.0** content drift plus perps/AsyncAPI +updates after nightly contract failures (Closes #527). **Breaking** for +constructors of perps `MarginMarket` that omit the new required +`market_version`. Re-vendored `specs/openapi.yaml` (hash +`fc70d406efd7a27dfff117ae1e509d44c2d01db57c1c9613a3fbb1a69caf2c88`), +`specs/asyncapi.yaml` (hash +`304956d30c986b02e8a375b004f30f6bca07e484a0a6cf3c45eb32bf01c4ba19`), and +`specs/perps_openapi.yaml` (hash +`d13cb9c5c18cbb9ab2fe60d173c74511dea627a89321d17f7b0505a88f82aeb0`). + +### Changed (breaking) + +- Perps **`MarginMarket.market_version`** (required `int`) — market version + counter (starts at 1; increases on corporate actions). Live list/get callers + are unaffected; tests/mocks that construct `MarginMarket` must pass it. + +### Added + +- Optional **`market_version`** on perps `CreateMarginOrderRequest` / `orders.create` + (sync + async). If set and the market's current version differs, server rejects + with HTTP 409 / `market_version_mismatch`. Omit or leave unset to skip the check + (server default 0). + +### Spec notes + +- Core OpenAPI `info.version` still **3.32.0** (fills ticker description now allows + comma-separated list up to 100; RFQ obscure_creator_id docs clarified). +- AsyncAPI `lastUpdateReason` adds `SettlementBoundsCancel`. + ## 17.1.0 — 2026-09-30 Reconciles upstream OpenAPI **3.31.0 → 3.32.0** content drift plus matching diff --git a/docs/migration.md b/docs/migration.md index 7fafcd9..f148712 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,5 +1,39 @@ # Migration +## v17.1 → v18.0.0 + +Reconciles upstream OpenAPI **3.32.0** content drift plus matching perps and +AsyncAPI updates (Closes #527). **Breaking** only for code that constructs +perps `MarginMarket` without `market_version`. + +### Response model field changes + +- **Perps `MarginMarket.market_version`** — required `int`. Starts at 1 and + can increase on corporate actions (e.g. a stock split). Pass it as + `market_version` when creating an order so the server rejects with HTTP + 409 / `market_version_mismatch` if the market changed since you read it. + Live `markets.list` / `markets.get` callers are unaffected; constructors + and fixtures must pass the new field. + +```python +# Before (constructors / test fixtures): +# MarginMarket(..., exchange_index=0) + +# After: +MarginMarket(..., exchange_index=0, market_version=1) +``` + +### Added (non-breaking) + +- Optional `CreateMarginOrderRequest.market_version` and + `perps.orders.create(..., market_version=)` (sync + async). If set and + the market's current version differs, the server rejects with HTTP 409 / + `market_version_mismatch`. Omit or leave unset to skip the check (server + default 0). + +See the [changelog](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/CHANGELOG.md) +for the full list. + ## v17.0 → v17.1.0 Reconciles upstream OpenAPI **3.32.0** (Closes #525). **Non-breaking** diff --git a/docs/perps.md b/docs/perps.md index b32d8fd..0a8dcf2 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -72,6 +72,10 @@ 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**. `orders.amend(..., expiration_time=)` is int64 Unix seconds: omit it to keep the current expiry, or pass `0` to clear it (good-till-canceled). +`orders.create(..., market_version=)` is the expected market version (starts +at 1 on `MarginMarket.market_version`). If set and the market's current +version differs, the server rejects with HTTP 409 / `market_version_mismatch`. +Omit to skip the check. !!! warning "Deprecated in v7.2.0 — `list_fcm` / `list_all_fcm`" Kalshi removed `GET /margin/fcm/orders` from the perps OpenAPI. The SDK diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 68deb6d..2857383 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -427,4 +427,4 @@ "Withdrawal", ] -__version__ = "17.1.0" +__version__ = "18.0.0" diff --git a/kalshi/perps/models/markets.py b/kalshi/perps/models/markets.py index 6965a6b..b80f565 100644 --- a/kalshi/perps/models/markets.py +++ b/kalshi/perps/models/markets.py @@ -90,6 +90,9 @@ class MarginMarket(BaseModel): # Required exchange shard for order-group membership (markets and order # groups must share the same exchange_index). exchange_index: int + # Required market version (corporate-action counter). Pass to create() as + # market_version so the server rejects with 409 if the market changed. + market_version: int leverage_estimate: MultiplierDecimal | None = None # Leverage (1 / margin_rate) keyed by notional position size in dollars diff --git a/kalshi/perps/models/orders.py b/kalshi/perps/models/orders.py index d80af00..1fd4ba0 100644 --- a/kalshi/perps/models/orders.py +++ b/kalshi/perps/models/orders.py @@ -89,6 +89,10 @@ class CreateMarginOrderRequest(BaseModel): reduce_only: bool | None = None subaccount: StrictInt | None = Field(default=None, ge=0) order_group_id: str | None = None + # Expected market version. If set and the market's current version differs, + # the server rejects with HTTP 409 / market_version_mismatch. Omit (None) + # to skip the check — do not default to 0 so exclude_none omits the key. + market_version: StrictInt | None = None class DecreaseMarginOrderRequest(BaseModel): diff --git a/kalshi/perps/resources/orders.py b/kalshi/perps/resources/orders.py index fd7dc83..ac96222 100644 --- a/kalshi/perps/resources/orders.py +++ b/kalshi/perps/resources/orders.py @@ -86,6 +86,7 @@ def _build_create_body( reduce_only: bool | None, subaccount: int | None, order_group_id: str | None, + market_version: int | None, ) -> dict[str, Any]: _check_request_exclusive( request, @@ -102,6 +103,7 @@ def _build_create_body( reduce_only=reduce_only, subaccount=subaccount, order_group_id=order_group_id, + market_version=market_version, ) if request is None: if ( @@ -132,6 +134,7 @@ def _build_create_body( reduce_only=reduce_only, subaccount=subaccount, order_group_id=order_group_id, + market_version=market_version, ) return request.model_dump(exclude_none=True, by_alias=True, mode="json") @@ -260,6 +263,7 @@ def create( reduce_only: bool | None = ..., subaccount: int | None = ..., order_group_id: str | None = ..., + market_version: int | None = ..., extra_headers: dict[str, str] | None = None, ) -> CreateMarginOrderResponse: ... def create( @@ -279,12 +283,16 @@ def create( reduce_only: bool | None = None, subaccount: int | None = None, order_group_id: str | None = None, + market_version: int | None = None, extra_headers: dict[str, str] | None = None, ) -> CreateMarginOrderResponse: """Place a new margin order (POST /margin/orders). Not retried. ``expiration_time`` is int64 Unix seconds per spec. ``subaccount`` - is carried in the request *body* (0 = primary). + is carried in the request *body* (0 = primary). ``market_version`` + is the expected market version; if set and the market's current + version differs, the server rejects with HTTP 409 / + ``market_version_mismatch``. Omit to skip the check (server default 0). """ self._require_auth() body = _build_create_body( @@ -302,6 +310,7 @@ def create( reduce_only=reduce_only, subaccount=subaccount, order_group_id=order_group_id, + market_version=market_version, ) data = self._post("/margin/orders", json=body, extra_headers=extra_headers) return CreateMarginOrderResponse.model_validate(data) @@ -621,6 +630,7 @@ async def create( reduce_only: bool | None = ..., subaccount: int | None = ..., order_group_id: str | None = ..., + market_version: int | None = ..., extra_headers: dict[str, str] | None = None, ) -> CreateMarginOrderResponse: ... async def create( @@ -640,6 +650,7 @@ async def create( reduce_only: bool | None = None, subaccount: int | None = None, order_group_id: str | None = None, + market_version: int | None = None, extra_headers: dict[str, str] | None = None, ) -> CreateMarginOrderResponse: """Place a new margin order. See :meth:`MarginOrdersResource.create`.""" @@ -659,6 +670,7 @@ async def create( reduce_only=reduce_only, subaccount=subaccount, order_group_id=order_group_id, + market_version=market_version, ) data = await self._post("/margin/orders", json=body, extra_headers=extra_headers) return CreateMarginOrderResponse.model_validate(data) diff --git a/kalshi/resources/orders.py b/kalshi/resources/orders.py index 5b0cd99..6a85753 100644 --- a/kalshi/resources/orders.py +++ b/kalshi/resources/orders.py @@ -193,7 +193,10 @@ def fills( exchange_index: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Fill]: - """List trade fills.""" + """List trade fills. + + ``ticker`` accepts a comma-separated list of up to 100 market tickers. + """ self._require_auth() params = _fills_params( ticker=ticker, @@ -225,7 +228,10 @@ def fills_all( max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Iterator[Fill]: - """Auto-paginate trade fills.""" + """Auto-paginate trade fills. + + ``ticker`` accepts a comma-separated list of up to 100 market tickers. + """ self._require_auth() _validate_max_pages(max_pages) params = _fills_params( @@ -505,7 +511,10 @@ async def fills( exchange_index: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[Fill]: - """List trade fills (async).""" + """List trade fills (async). + + ``ticker`` accepts a comma-separated list of up to 100 market tickers. + """ self._require_auth() params = _fills_params( ticker=ticker, @@ -537,7 +546,10 @@ def fills_all( max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> AsyncIterator[Fill]: - """Auto-paginate trade fills (async). Use ``async for``.""" + """Auto-paginate trade fills (async). Use ``async for``. + + ``ticker`` accepts a comma-separated list of up to 100 market tickers. + """ self._require_auth() _validate_max_pages(max_pages) params = _fills_params( diff --git a/kalshi/resources/portfolio.py b/kalshi/resources/portfolio.py index 1f98010..344a716 100644 --- a/kalshi/resources/portfolio.py +++ b/kalshi/resources/portfolio.py @@ -245,6 +245,8 @@ def fills( ) -> Page[Fill]: """List trade fills (``GET /portfolio/fills``). + ``ticker`` accepts a comma-separated list of up to 100 market tickers. + Moved from :class:`OrdersResource` in v3.0.0 (issue #351) to group with the rest of the ``/portfolio/*`` family (``settlements``, ``deposits``, ``withdrawals``). @@ -277,7 +279,10 @@ def fills_all( max_pages: int | None = None, extra_headers: dict[str, str] | None = None, ) -> Iterator[Fill]: - """Auto-paginate trade fills. Moved from :class:`OrdersResource` in v3.0.0.""" + """Auto-paginate trade fills. Moved from :class:`OrdersResource` in v3.0.0. + + ``ticker`` accepts a comma-separated list of up to 100 market tickers. + """ self._require_auth() _validate_max_pages(max_pages) params = _fills_params( @@ -656,6 +661,8 @@ async def fills( ) -> Page[Fill]: """List trade fills (``GET /portfolio/fills``, async). + ``ticker`` accepts a comma-separated list of up to 100 market tickers. + Moved from :class:`AsyncOrdersResource` in v3.0.0 (issue #351). """ self._require_auth() @@ -688,6 +695,8 @@ def fills_all( ) -> AsyncIterator[Fill]: """Auto-paginate trade fills (async). Use ``async for``. + ``ticker`` accepts a comma-separated list of up to 100 market tickers. + Moved from :class:`AsyncOrdersResource` in v3.0.0 (issue #351). """ self._require_auth() diff --git a/pyproject.toml b/pyproject.toml index becb9f8..8f69548 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "17.1.0" +version = "18.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 e5db024..6b8ea1a 100644 --- a/specs/asyncapi.yaml +++ b/specs/asyncapi.yaml @@ -2361,7 +2361,7 @@ components: 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"] + enum: ["Decrease", "Amend", "MarginCancel", "SelfTradeCancel", "ExpiryCancel", "CloseCancel", "HaltCancel", "Trade", "PostOnlyCrossCancel", "ReduceOnlyCancel", "SettlementBoundsCancel"] orderAction: type: string @@ -3847,7 +3847,7 @@ components: description: Unique identifier for the RFQ creator_id: type: string - description: Public communications ID of the RFQ creator (anonymized). Set to "0" for other users when obscure_creator_id is enabled. + description: Public communications ID of the RFQ creator (pseudonymous). When obscure_creator_id is enabled, other users receive the shared placeholder "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" (SHA-256 of empty input). The creator sees their normal ID. market_ticker: type: string description: Market ticker for the RFQ @@ -3911,7 +3911,7 @@ components: description: Unique identifier for the RFQ creator_id: type: string - description: Public communications ID of the RFQ creator (anonymized). Set to "0" for other users when obscure_creator_id is enabled. + description: Public communications ID of the RFQ creator (pseudonymous). When obscure_creator_id is enabled, other users receive the shared placeholder "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" (SHA-256 of empty input). The creator sees their normal ID. market_ticker: type: string description: Market ticker for the RFQ @@ -3957,7 +3957,7 @@ components: description: Public communications ID of the quote creator (anonymized) rfq_creator_id: type: string - description: Public communications ID of the RFQ creator (anonymized). Set to "0" for other users when obscure_creator_id is enabled. + description: Public communications ID of the RFQ creator (pseudonymous). When obscure_creator_id is enabled, other users receive the shared placeholder "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" (SHA-256 of empty input). The creator sees their normal ID. market_ticker: type: string description: Market ticker for the quote @@ -4017,7 +4017,7 @@ components: description: Public communications ID of the quote creator (anonymized) rfq_creator_id: type: string - description: Public communications ID of the RFQ creator (anonymized). Set to "0" for other users when obscure_creator_id is enabled. + description: Public communications ID of the RFQ creator (pseudonymous). When obscure_creator_id is enabled, other users receive the shared placeholder "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" (SHA-256 of empty input). The creator sees their normal ID. market_ticker: type: string description: Market ticker for the quote diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 1d78688..eb7a346 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -1964,7 +1964,12 @@ paths: kalshiAccessTimestamp: [] - kalshiOauthAccessToken: [] parameters: - - $ref: '#/components/parameters/TickerQuery' + - name: ticker + in: query + description: Filter by market ticker. Accepts a comma-separated list of up to 100 market tickers. + schema: + type: string + x-go-type-skip-optional-pointer: true - $ref: '#/components/parameters/OrderIdQuery' - $ref: '#/components/parameters/MinTsQuery' - $ref: '#/components/parameters/MaxTsQuery' @@ -7816,7 +7821,7 @@ components: description: UUID of the RFQ. Preserve the exact returned string. creator_id: type: string - description: Public communications ID of the RFQ creator (anonymized). Set to "0" for other users when obscure_creator_id is enabled. + description: Public communications ID of the RFQ creator (pseudonymous). When obscure_creator_id is enabled, other users receive the shared placeholder "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" (SHA-256 of empty input). The creator sees their normal ID. After successful quote execution, subsequent RFQ responses show the normal public communications ID to all viewers. market_ticker: type: string description: The ticker of the market this RFQ is for @@ -7936,7 +7941,7 @@ components: x-go-type-skip-optional-pointer: true obscure_creator_id: type: boolean - description: Hide the RFQ creator ID from other users until successful execution. The creator always sees their own ID. + description: Replace the RFQ creator's public communications ID with a shared empty-input SHA-256 hash for other users. Applies to RFQ responses and broadcasts, and to quotes before successful execution. Successful execution reveals the normal ID in subsequent RFQ responses and in the executed quote. Accepting or confirming a quote does not reveal the ID. The creator always sees their normal ID. See the RFQ guide for the placeholder and visibility rules. default: false x-go-type-skip-optional-pointer: true rest_remainder: @@ -7990,7 +7995,7 @@ components: description: Public communications ID of the quote creator rfq_creator_id: type: string - description: Public communications ID of the RFQ creator (anonymized). Set to "0" for other users when obscure_creator_id is enabled. + description: Public communications ID of the RFQ creator (pseudonymous). When obscure_creator_id is enabled, other users receive the shared placeholder "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" (SHA-256 of empty input). The creator sees their normal ID. After this quote executes successfully, its counterparty sees the normal public communications ID. x-go-type-skip-optional-pointer: true market_ticker: type: string diff --git a/specs/perps_openapi.yaml b/specs/perps_openapi.yaml index 092ab92..6d998f2 100644 --- a/specs/perps_openapi.yaml +++ b/specs/perps_openapi.yaml @@ -2636,6 +2636,12 @@ components: type: string description: The order group this order is part of x-go-type-skip-optional-pointer: true + market_version: + type: integer + format: int32 + default: 0 + description: The market_version you expect the market to be at. If it is set and the market's current market_version differs, the order is rejected with HTTP 409 and error code market_version_mismatch. 0 skips the check. + x-go-type-skip-optional-pointer: true CreateMarginOrderResponse: type: object @@ -2901,11 +2907,16 @@ components: - fractional_trading_enabled - schedule - exchange_index + - market_version properties: ticker: type: string title: type: string + market_version: + type: integer + format: int32 + description: The market's current version. It starts at 1 and can increase when there is a corporate action, such as a stock split. Pass it as market_version when creating an order to have the order rejected if the version has changed since you read it. exchange_index: type: integer description: The group of markets this market belongs to for order groups. Order groups may only reference markets whose exchange_index matches theirs. diff --git a/tests/perps/test_markets.py b/tests/perps/test_markets.py index 960ffaa..53a69d9 100644 --- a/tests/perps/test_markets.py +++ b/tests/perps/test_markets.py @@ -45,6 +45,7 @@ def _market_dict(**overrides: object) -> dict[str, object]: "next_open_ts": None, }, "exchange_index": 0, + "market_version": 1, "leverage_estimate": 2.5, "leverage_estimates": {"1000": 2.5, "10000": 2.0, "100000": 1.5}, "long_leverage_estimates": {"1000": 2.4, "10000": 1.9}, @@ -131,6 +132,7 @@ def test_happy(self, perps_client: PerpsClient) -> None: assert m.schedule.next_close_ts == 1_700_000_000 assert m.schedule.next_open_ts is None assert m.exchange_index == 0 + assert m.market_version == 1 @respx.mock def test_status_filter(self, perps_client: PerpsClient) -> None: @@ -158,6 +160,7 @@ def test_null_leverage_and_missing_optionals(self, perps_client: PerpsClient) -> # required key present, null value = 24/7 market "schedule": None, "exchange_index": 0, + "market_version": 1, "leverage_estimate": None, } ] diff --git a/tests/perps/test_orders.py b/tests/perps/test_orders.py index 698c36e..5cacca6 100644 --- a/tests/perps/test_orders.py +++ b/tests/perps/test_orders.py @@ -98,6 +98,29 @@ def test_happy(self, perps_client: PerpsClient) -> None: # wire names are the short keys, not _dollars/_fp suffixed assert "price_dollars" not in body assert "count_fp" not in body + # unset optional market_version is omitted (exclude_none), not sent as 0 + assert "market_version" not in body + + @respx.mock + def test_market_version_serialized(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/margin/orders").mock( + return_value=httpx.Response( + 201, + json={"order_id": "ord-9", "fill_count": "0.00", "remaining_count": "100.00"}, + ) + ) + perps_client.orders.create( + ticker="BTC-PERP", + client_order_id="cid-9", + side="bid", + count="100", + price="0.56", + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + market_version=1, + ) + body = json.loads(route.calls[0].request.content) + assert body["market_version"] == 1 @respx.mock def test_conflict_maps(self, perps_client: PerpsClient) -> None: