diff --git a/CHANGELOG.md b/CHANGELOG.md index 18c7408..9ea4eb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,34 @@ All notable changes to kalshi-sdk will be documented in this file. +## 20.0.0 — 2026-10-07 + +Reconciles perps OpenAPI drift after nightly contract failures (Closes #531). +**Breaking** for `perps.fcm.create_subtrader` / `CreateMarginFCMSubtraderRequest` +callers that omit the new required `require_category_cap`. Re-vendored +`specs/openapi.yaml` (hash +`87f122efdfed4371a926ead54d64f044b0538c4b9e745cbd2a03ac983bcd7b0c`), +`specs/asyncapi.yaml` (hash +`eaf754def77f36cd016f1e852a5ce76f11b635140e879bee7a3f9cad40716f9f`), and +`specs/perps_openapi.yaml` (hash +`819fb5f56e0a4d57577ceb344c3e1b1ad12e79c8a5d8eb82922283892e99c139`). + +### Changed (breaking) + +- Perps **`CreateMarginFCMSubtraderRequest.require_category_cap`** (required + `bool`) and **`fcm.create_subtrader(..., require_category_cap=)`** (sync + + async). When true, ordinary orders require an explicit asset-class + initial-margin cap. Subtrader-wide and market caps do not satisfy this. + Dedicated liquidation remains allowed. Set at create time and cannot be + changed afterward. Callers must pass `True` or `False`. + +### Spec notes + +- Core OpenAPI `info.version` is **3.33.0** (version bump only; no schema + changes). +- Perps `MarginMarket.market_version` description now says the version + increases when trading resumes after a corporate action. + ## 19.0.0 — 2026-10-06 Reconciles upstream OpenAPI **3.32.0** content drift plus perps/AsyncAPI diff --git a/docs/migration.md b/docs/migration.md index 771df5d..79769a1 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,5 +1,34 @@ # Migration +## v19.0 → v20.0.0 + +Reconciles perps OpenAPI drift (Closes #531). **Breaking** for +`perps.fcm.create_subtrader(...)` and constructors of +`CreateMarginFCMSubtraderRequest` that omit the new required +`require_category_cap`. + +### Request model field changes + +- **Perps `CreateMarginFCMSubtraderRequest.require_category_cap`** — required + `bool`. When true, ordinary orders require an explicit asset-class + initial-margin cap. Subtrader-wide and market caps do not satisfy this + requirement. Dedicated liquidation remains allowed. Set at create time and + cannot be changed afterward. Also required on + `perps.fcm.create_subtrader(..., require_category_cap=)` (sync + async). + +```python +# Before: +# perps.fcm.create_subtrader(subtrader_suffix="desk1") +# CreateMarginFCMSubtraderRequest(subtrader_suffix="desk1") + +# After: +perps.fcm.create_subtrader(subtrader_suffix="desk1", require_category_cap=False) +CreateMarginFCMSubtraderRequest(subtrader_suffix="desk1", require_category_cap=False) +``` + +See the [changelog](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/CHANGELOG.md) +for the full list. + ## v18.0 → v19.0.0 Reconciles upstream OpenAPI **3.32.0** content drift plus matching perps diff --git a/docs/perps.md b/docs/perps.md index 883658d..d791997 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()`, `premium_index()`, `history()` | | `transfers` | `transfer_instance()`, `create_subaccount()`, `transfer_subaccount()` | -| `fcm` | `create_subtrader(subtrader_suffix=...)`; `risk_controls` / `update_risk_controls` / `delete_risk_controls`; `update_notional_risk_limit` / `delete_notional_risk_limit` | +| `fcm` | `create_subtrader(subtrader_suffix=..., require_category_cap=...)`; `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**. @@ -87,6 +87,11 @@ precedence when both are set). Omit so the key is not sent. once the removal is confirmed permanent. Prediction-API FCM (`client.fcm.*` on `/fcm/*`) is unchanged. +`fcm.create_subtrader(..., require_category_cap=)` is required (`True` or +`False`). When true, ordinary orders need an explicit asset-class IM cap; +subtrader-wide and market caps do not satisfy this. Dedicated liquidation +remains allowed. The flag is set at create time and cannot be changed. + FCM members can set per-subtrader initial-margin caps. A cap with no `market_ticker` applies across all markets; a ticker scopes it to one market. `im_cap` is a non-negative `OrderPrice` (fixed-point dollars): diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 0e99178..1fbb382 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -427,4 +427,4 @@ "Withdrawal", ] -__version__ = "19.0.0" +__version__ = "20.0.0" diff --git a/kalshi/perps/models/fcm.py b/kalshi/perps/models/fcm.py index fe51635..95480d9 100644 --- a/kalshi/perps/models/fcm.py +++ b/kalshi/perps/models/fcm.py @@ -28,9 +28,15 @@ class CreateMarginFCMSubtraderRequest(BaseModel): ``subtrader_suffix`` is the client-chosen suffix; the server composes the full id as ``{user_id}_{subtrader_suffix}``. Spec pattern: 1-16 lowercase alphanumeric characters. + + ``require_category_cap`` is required. When true, ordinary orders need an + explicit asset-class initial-margin cap; subtrader-wide and market caps + do not satisfy this. Dedicated liquidation remains allowed. Set at create + time and cannot be changed afterward. """ subtrader_suffix: str = Field(min_length=1, max_length=16, pattern=r"^[a-z0-9]{1,16}$") + require_category_cap: bool model_config = {"extra": "forbid"} diff --git a/kalshi/perps/resources/fcm.py b/kalshi/perps/resources/fcm.py index caa8295..9f024ee 100644 --- a/kalshi/perps/resources/fcm.py +++ b/kalshi/perps/resources/fcm.py @@ -32,14 +32,23 @@ def _build_create_subtrader_body( request: CreateMarginFCMSubtraderRequest | None, *, subtrader_suffix: str | None, + require_category_cap: bool | None, ) -> dict[str, object]: - _check_request_exclusive(request, subtrader_suffix=subtrader_suffix) + _check_request_exclusive( + request, + subtrader_suffix=subtrader_suffix, + require_category_cap=require_category_cap, + ) if request is None: - if subtrader_suffix is None: + if subtrader_suffix is None or require_category_cap is None: raise TypeError( - "create_subtrader() requires `subtrader_suffix` (or pass `request=...`)" + "create_subtrader() requires `subtrader_suffix` and " + "`require_category_cap` (or pass `request=...`)" ) - request = CreateMarginFCMSubtraderRequest(subtrader_suffix=subtrader_suffix) + request = CreateMarginFCMSubtraderRequest( + subtrader_suffix=subtrader_suffix, + require_category_cap=require_category_cap, + ) return request.model_dump(exclude_none=True, by_alias=True, mode="json") @@ -106,6 +115,7 @@ def create_subtrader( self, *, subtrader_suffix: str, + require_category_cap: bool, extra_headers: dict[str, str] | None = None, ) -> CreateMarginFCMSubtraderResponse: ... def create_subtrader( @@ -113,15 +123,21 @@ def create_subtrader( *, request: CreateMarginFCMSubtraderRequest | None = None, subtrader_suffix: str | None = None, + require_category_cap: bool | None = None, extra_headers: dict[str, str] | None = None, ) -> CreateMarginFCMSubtraderResponse: """``POST /margin/fcm/subtraders`` — create a margin FCM subtrader. The full ``subtrader_id`` is composed server-side as - ``{user_id}_{subtrader_suffix}``. + ``{user_id}_{subtrader_suffix}``. ``require_category_cap`` is required: + when true, ordinary orders need an explicit asset-class IM cap. """ self._require_auth() - body = _build_create_subtrader_body(request, subtrader_suffix=subtrader_suffix) + body = _build_create_subtrader_body( + request, + subtrader_suffix=subtrader_suffix, + require_category_cap=require_category_cap, + ) data = self._post("/margin/fcm/subtraders", json=body, extra_headers=extra_headers) return CreateMarginFCMSubtraderResponse.model_validate(data) @@ -255,6 +271,7 @@ async def create_subtrader( self, *, subtrader_suffix: str, + require_category_cap: bool, extra_headers: dict[str, str] | None = None, ) -> CreateMarginFCMSubtraderResponse: ... async def create_subtrader( @@ -262,11 +279,16 @@ async def create_subtrader( *, request: CreateMarginFCMSubtraderRequest | None = None, subtrader_suffix: str | None = None, + require_category_cap: bool | None = None, extra_headers: dict[str, str] | None = None, ) -> CreateMarginFCMSubtraderResponse: """Async :meth:`FcmResource.create_subtrader`.""" self._require_auth() - body = _build_create_subtrader_body(request, subtrader_suffix=subtrader_suffix) + body = _build_create_subtrader_body( + request, + subtrader_suffix=subtrader_suffix, + require_category_cap=require_category_cap, + ) data = await self._post("/margin/fcm/subtraders", json=body, extra_headers=extra_headers) return CreateMarginFCMSubtraderResponse.model_validate(data) diff --git a/pyproject.toml b/pyproject.toml index 33fff9f..dea69e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "19.0.0" +version = "20.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/openapi.yaml b/specs/openapi.yaml index 9a5fc84..986674c 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.32.0 + version: 3.33.0 description: Manually defined OpenAPI spec for endpoints being migrated to spec-first approach servers: diff --git a/specs/perps_openapi.yaml b/specs/perps_openapi.yaml index 52eb085..34d6dd3 100644 --- a/specs/perps_openapi.yaml +++ b/specs/perps_openapi.yaml @@ -1891,12 +1891,18 @@ components: type: object required: - subtrader_suffix + - require_category_cap properties: subtrader_suffix: type: string pattern: '^[A-Za-z0-9]{1,16}$' description: Suffix for the new subtrader. The full subtrader id is composed server-side as {user_id}_{subtrader_suffix}. + require_category_cap: + type: boolean + x-go-type: "*bool" + description: Required. Explicitly choose true or false. When true, ordinary orders require an explicit asset-class initial margin cap. Subtrader-wide and market caps do not satisfy this requirement. Dedicated liquidation remains allowed. Set only when explicitly creating a subtrader and cannot be changed afterward. + CreateMarginFCMSubtraderResponse: type: object required: @@ -2929,7 +2935,7 @@ components: 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. + description: The market's current version. It starts at 1 and increases when trading resumes after 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_fcm.py b/tests/perps/test_fcm.py index c1944ab..fe27319 100644 --- a/tests/perps/test_fcm.py +++ b/tests/perps/test_fcm.py @@ -24,19 +24,42 @@ class TestCreateMarginFCMSubtraderRequest: def test_serializes(self) -> None: - req = CreateMarginFCMSubtraderRequest(subtrader_suffix="desk1") + req = CreateMarginFCMSubtraderRequest( + subtrader_suffix="desk1", require_category_cap=True + ) assert req.model_dump(exclude_none=True, by_alias=True, mode="json") == { - "subtrader_suffix": "desk1" + "subtrader_suffix": "desk1", + "require_category_cap": True, } + def test_serializes_false(self) -> None: + req = CreateMarginFCMSubtraderRequest( + subtrader_suffix="desk1", require_category_cap=False + ) + dumped = req.model_dump(exclude_none=True, by_alias=True, mode="json") + assert dumped["require_category_cap"] is False + + def test_omitted_raises(self) -> None: + with pytest.raises(ValidationError): + CreateMarginFCMSubtraderRequest(subtrader_suffix="desk1") # type: ignore[call-arg] + + def test_rejects_wrong_type(self) -> None: + with pytest.raises(ValidationError): + CreateMarginFCMSubtraderRequest( + subtrader_suffix="desk1", + require_category_cap=["nope"], # type: ignore[arg-type] + ) + def test_rejects_bad_suffix(self) -> None: with pytest.raises(ValidationError): - CreateMarginFCMSubtraderRequest(subtrader_suffix="BAD_SUFFIX") + CreateMarginFCMSubtraderRequest( + subtrader_suffix="BAD_SUFFIX", require_category_cap=False + ) def test_forbids_extra(self) -> None: with pytest.raises(ValidationError): CreateMarginFCMSubtraderRequest( # type: ignore[call-arg] - subtrader_suffix="desk1", phantom=1 + subtrader_suffix="desk1", require_category_cap=False, phantom=1 ) @@ -46,21 +69,49 @@ def test_create_subtrader_sends_body(self, perps_client: PerpsClient) -> None: route = respx.post(f"{BASE}/margin/fcm/subtraders").mock( return_value=httpx.Response(200, json={"subtrader_id": "user_desk1"}) ) - resp = perps_client.fcm.create_subtrader(subtrader_suffix="desk1") + resp = perps_client.fcm.create_subtrader( + subtrader_suffix="desk1", require_category_cap=True + ) assert isinstance(resp, CreateMarginFCMSubtraderResponse) assert resp.subtrader_id == "user_desk1" assert json.loads(route.calls[0].request.content) == { - "subtrader_suffix": "desk1" + "subtrader_suffix": "desk1", + "require_category_cap": True, } + @respx.mock + def test_create_subtrader_sends_false(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/margin/fcm/subtraders").mock( + return_value=httpx.Response(200, json={"subtrader_id": "user_desk1"}) + ) + perps_client.fcm.create_subtrader( + subtrader_suffix="desk1", require_category_cap=False + ) + assert json.loads(route.calls[0].request.content) == { + "subtrader_suffix": "desk1", + "require_category_cap": False, + } + + def test_create_subtrader_omitted_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(TypeError, match="require_category_cap"): + perps_client.fcm.create_subtrader( # type: ignore[call-overload] + subtrader_suffix="desk1" + ) + @respx.mock def test_create_subtrader_with_request_model(self, perps_client: PerpsClient) -> None: route = respx.post(f"{BASE}/margin/fcm/subtraders").mock( return_value=httpx.Response(200, json={"subtrader_id": "user_a"}) ) - req = CreateMarginFCMSubtraderRequest(subtrader_suffix="a") + req = CreateMarginFCMSubtraderRequest( + subtrader_suffix="a", require_category_cap=False + ) resp = perps_client.fcm.create_subtrader(request=req) assert resp.subtrader_id == "user_a" + assert json.loads(route.calls[0].request.content) == { + "subtrader_suffix": "a", + "require_category_cap": False, + } assert route.called def test_requires_args(self, perps_client: PerpsClient) -> None: @@ -70,7 +121,50 @@ def test_requires_args(self, perps_client: PerpsClient) -> None: def test_unauthenticated_raises(self) -> None: client = PerpsClient(config=PerpsConfig.demo(max_retries=0)) with pytest.raises(AuthRequiredError): - client.fcm.create_subtrader(subtrader_suffix="desk1") + client.fcm.create_subtrader( + subtrader_suffix="desk1", require_category_cap=False + ) + + +class TestAsyncFcmCreateSubtrader: + @respx.mock + @pytest.mark.asyncio + async def test_create_subtrader_sends_true( + self, async_perps_client: AsyncPerpsClient + ) -> None: + route = respx.post(f"{BASE}/margin/fcm/subtraders").mock( + return_value=httpx.Response(200, json={"subtrader_id": "user_desk1"}) + ) + resp = await async_perps_client.fcm.create_subtrader( + subtrader_suffix="desk1", require_category_cap=True + ) + assert resp.subtrader_id == "user_desk1" + assert json.loads(route.calls[0].request.content) == { + "subtrader_suffix": "desk1", + "require_category_cap": True, + } + + @respx.mock + @pytest.mark.asyncio + async def test_create_subtrader_sends_false( + self, async_perps_client: AsyncPerpsClient + ) -> None: + route = respx.post(f"{BASE}/margin/fcm/subtraders").mock( + return_value=httpx.Response(200, json={"subtrader_id": "user_desk1"}) + ) + await async_perps_client.fcm.create_subtrader( + subtrader_suffix="desk1", require_category_cap=False + ) + assert json.loads(route.calls[0].request.content)["require_category_cap"] is False + + @pytest.mark.asyncio + async def test_create_subtrader_omitted_raises( + self, async_perps_client: AsyncPerpsClient + ) -> None: + with pytest.raises(TypeError, match="require_category_cap"): + await async_perps_client.fcm.create_subtrader( # type: ignore[call-overload] + subtrader_suffix="desk1" + ) class TestUpdateFCMSubtraderRiskControlsRequest: