Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 6 additions & 1 deletion docs/perps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand All @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion kalshi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,4 +427,4 @@
"Withdrawal",
]

__version__ = "19.0.0"
__version__ = "20.0.0"
6 changes: 6 additions & 0 deletions kalshi/perps/models/fcm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}

Expand Down
36 changes: 29 additions & 7 deletions kalshi/perps/resources/fcm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down Expand Up @@ -106,22 +115,29 @@ def create_subtrader(
self,
*,
subtrader_suffix: str,
require_category_cap: bool,
extra_headers: dict[str, str] | None = None,
) -> CreateMarginFCMSubtraderResponse: ...
def create_subtrader(
self,
*,
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)

Expand Down Expand Up @@ -255,18 +271,24 @@ async def create_subtrader(
self,
*,
subtrader_suffix: str,
require_category_cap: bool,
extra_headers: dict[str, str] | None = None,
) -> CreateMarginFCMSubtraderResponse: ...
async def create_subtrader(
self,
*,
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)

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion specs/openapi.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
8 changes: 7 additions & 1 deletion specs/perps_openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
110 changes: 102 additions & 8 deletions tests/perps/test_fcm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)


Expand All @@ -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:
Expand All @@ -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:
Expand Down
Loading