diff --git a/CHANGELOG.md b/CHANGELOG.md index 55a70e6..3edbb33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to kalshi-sdk will be documented in this file. +## 22.8.0 — 2026-11-04 + +Reconciles nightly spec drift (#569) against OpenAPI 3.36.0 and the latest +Klear (SCM) spec. Additive only. No breaking changes. + +### Added + +- Optional **`netting_enabled`** (`bool | None`) on `MarketPosition`. Spec + 3.36.0 marks it required; the SDK keeps it optional (contract exclusion + `server_omits_despite_required`) so payloads omitting the key still parse. + (Closes #569). +- Optional **`settlement_start_ts` / `settlement_end_ts`** (RFC3339 + `datetime` or `str`) on Klear `margin.obligation_history` / + `obligation_history_all` (sync and async). Inclusive bounds on settlement + time; omitted keys are not sent. +- Optional **`start_ts` / `end_ts`** (RFC3339 `datetime` or `str`) on Klear + `margin.settlement_balance_history` / `settlement_balance_history_all` + (sync and async). Inclusive bounds on entry creation time; omitted keys + are not sent. + ## 22.7.0 — 2026-11-03 Reconciles nightly spec drift (#567) against the latest perps OpenAPI / diff --git a/docs/perps.md b/docs/perps.md index a5b044c..af7a98b 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -340,7 +340,13 @@ Credentials can also come from the environment via `KlearClient.from_env()` (rea `KALSHI_KLEAR_ADMIN_USER_ID` / `KALSHI_KLEAR_ACCESS_TOKEN`). `klear.margin.active_obligations()` returns all currently-active settlement -obligations. `klear.margin.settlement_estimate_by_asset_class()` returns +obligations. `klear.margin.obligation_history()` / `obligation_history_all()` +page historical obligations; optional `settlement_start_ts` / +`settlement_end_ts` (RFC3339 `datetime` or `str`) bound settlement time. +`klear.margin.settlement_balance_history()` / +`settlement_balance_history_all()` page settlement-buffer history; optional +`start_ts` / `end_ts` bound entry creation time. Omit a bound to leave it +unfiltered. `klear.margin.settlement_estimate_by_asset_class()` returns next-settlement estimates keyed by asset class. (The singular `active_obligation()` / `settlement_estimate()` endpoints were removed upstream in the v11.0.0 reconcile.) diff --git a/docs/resources/portfolio.md b/docs/resources/portfolio.md index 21ee53b..7eb1d3e 100644 --- a/docs/resources/portfolio.md +++ b/docs/resources/portfolio.md @@ -300,7 +300,9 @@ are unaffected; constructors/fixtures must pass it. wire-format aliases — on the wire you see `total_traded_dollars`, `market_exposure_dollars`, `realized_pnl_dollars`, `fees_paid_dollars`, `position_fp`. The SDK normalizes to short names returning `Decimal`. -`realized_pnl` is signed. +`realized_pnl` is signed. Optional `netting_enabled` (`bool | None`) reports +whether netting applies to the position's event (`None` when the server +omits the key). ## Reference diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 80e15d1..7e2c0a8 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -503,4 +503,4 @@ "Withdrawal", ] -__version__ = "22.7.0" +__version__ = "22.8.0" diff --git a/kalshi/models/portfolio.py b/kalshi/models/portfolio.py index 649b51b..00fae68 100644 --- a/kalshi/models/portfolio.py +++ b/kalshi/models/portfolio.py @@ -104,6 +104,10 @@ class MarketPosition(BaseModel): validation_alias=AliasChoices("fees_paid_dollars", "fees_paid"), ) last_updated_ts: AwareDatetime + # Spec 3.36.0 marks netting_enabled required. Kept optional (None when + # the key is absent) so payloads and fixtures from before the field + # still validate. See EXCLUSIONS. + netting_enabled: bool | None = None model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/perps/klear/resources/margin.py b/kalshi/perps/klear/resources/margin.py index 035b4f1..af7cbc5 100644 --- a/kalshi/perps/klear/resources/margin.py +++ b/kalshi/perps/klear/resources/margin.py @@ -8,7 +8,9 @@ - ``active_obligations`` — ``GET /margin/active_obligations``: all currently-active obligations. - ``obligation_history`` / ``obligation_history_all`` — ``GET - /margin/obligation_history``: cursor-paginated (limit max 100). + /margin/obligation_history``: cursor-paginated (limit max 100); + optional ``settlement_start_ts`` / ``settlement_end_ts`` (RFC3339) bound + settlement time. - ``settlement_details`` / ``settlement_details_all``, ``maintenance_margin_details`` / ``maintenance_margin_details_all``, ``funding_payments`` / ``funding_payments_all``, @@ -20,7 +22,8 @@ - ``settlement_balance`` — ``GET /margin/settlement_balance``. - ``guaranty_fund_balance`` — ``GET /margin/guaranty_fund_balance``. - ``settlement_balance_history`` / ``settlement_balance_history_all`` — ``GET - /margin/settlement_balance_history``: cursor-paginated (limit max 500). + /margin/settlement_balance_history``: cursor-paginated (limit max 500); + optional ``start_ts`` / ``end_ts`` (RFC3339) bound entry creation time. - ``withdraw_settlement_balance`` — ``POST /margin/settlement_balance_withdrawal``: ``WithdrawSettlementBalanceRequest`` body; NOT retried (POST). - ``settlement_balance_withdrawal`` — ``GET /margin/settlement_balance_withdrawal``: @@ -117,6 +120,21 @@ def _validate_date_range(start_date: str, end_date: str) -> None: raise ValueError(f"end_date ({end_date}) must be on or after start_date ({start_date})") +def _rfc3339(value: datetime.datetime | str | None) -> str | None: + """Coerce a ``datetime`` to RFC3339; pass strings through. ``None`` stays ``None``. + + Naive datetimes (no ``tzinfo``) are assumed UTC. Spec requires RFC3339, + which mandates a timezone offset. Strings are forwarded unchanged. + """ + if value is None: + return None + if isinstance(value, datetime.datetime): + if value.tzinfo is None: + value = value.replace(tzinfo=datetime.UTC) + return value.isoformat() + return value + + class MarginResource(KlearSyncResource): """Sync Klear (SCM) margin API — obligations, estimates, balances, groups.""" @@ -148,10 +166,21 @@ def obligation_history( *, limit: int | None = None, cursor: str | None = None, + settlement_start_ts: datetime.datetime | str | None = None, + settlement_end_ts: datetime.datetime | str | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[ObligationEntry]: - """``GET /margin/obligation_history`` — one cursor-paginated page (limit max 100).""" - params = _params(limit=_validate_limit(limit, hi=100), cursor=cursor) + """``GET /margin/obligation_history`` — one cursor-paginated page (limit max 100). + + ``settlement_start_ts`` / ``settlement_end_ts`` are optional RFC3339 + bounds on settlement time (``datetime`` or string; omitted when ``None``). + """ + params = _params( + limit=_validate_limit(limit, hi=100), + cursor=cursor, + settlement_start_ts=_rfc3339(settlement_start_ts), + settlement_end_ts=_rfc3339(settlement_end_ts), + ) return self._list( "/margin/obligation_history", ObligationEntry, @@ -166,11 +195,22 @@ def obligation_history_all( *, limit: int | None = None, max_pages: int | None = None, + settlement_start_ts: datetime.datetime | str | None = None, + settlement_end_ts: datetime.datetime | str | None = None, extra_headers: dict[str, str] | None = None, ) -> Iterator[ObligationEntry]: - """Auto-paginate obligation history, yielding each ``ObligationEntry``.""" + """Auto-paginate obligation history, yielding each ``ObligationEntry``. + + ``settlement_start_ts`` / ``settlement_end_ts`` are optional RFC3339 + bounds on settlement time (``datetime`` or string; omitted when ``None``). + """ _validate_max_pages(max_pages) - params = _params(limit=_validate_limit(limit, hi=100), cursor=None) + params = _params( + limit=_validate_limit(limit, hi=100), + cursor=None, + settlement_start_ts=_rfc3339(settlement_start_ts), + settlement_end_ts=_rfc3339(settlement_end_ts), + ) return self._list_all( "/margin/obligation_history", ObligationEntry, @@ -388,10 +428,21 @@ def settlement_balance_history( *, limit: int | None = None, cursor: str | None = None, + start_ts: datetime.datetime | str | None = None, + end_ts: datetime.datetime | str | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[SettlementBalanceHistoryEntry]: - """``GET /margin/settlement_balance_history`` — one page (limit max 500).""" - params = _params(limit=_validate_limit(limit, hi=500), cursor=cursor) + """``GET /margin/settlement_balance_history`` — one page (limit max 500). + + ``start_ts`` / ``end_ts`` are optional RFC3339 bounds on entry + creation time (``datetime`` or string; omitted when ``None``). + """ + params = _params( + limit=_validate_limit(limit, hi=500), + cursor=cursor, + start_ts=_rfc3339(start_ts), + end_ts=_rfc3339(end_ts), + ) return self._list( "/margin/settlement_balance_history", SettlementBalanceHistoryEntry, @@ -406,11 +457,22 @@ def settlement_balance_history_all( *, limit: int | None = None, max_pages: int | None = None, + start_ts: datetime.datetime | str | None = None, + end_ts: datetime.datetime | str | None = None, extra_headers: dict[str, str] | None = None, ) -> Iterator[SettlementBalanceHistoryEntry]: - """Auto-paginate settlement-balance history, yielding each entry.""" + """Auto-paginate settlement-balance history, yielding each entry. + + ``start_ts`` / ``end_ts`` are optional RFC3339 bounds on entry + creation time (``datetime`` or string; omitted when ``None``). + """ _validate_max_pages(max_pages) - params = _params(limit=_validate_limit(limit, hi=500), cursor=None) + params = _params( + limit=_validate_limit(limit, hi=500), + cursor=None, + start_ts=_rfc3339(start_ts), + end_ts=_rfc3339(end_ts), + ) return self._list_all( "/margin/settlement_balance_history", SettlementBalanceHistoryEntry, @@ -759,10 +821,17 @@ async def obligation_history( *, limit: int | None = None, cursor: str | None = None, + settlement_start_ts: datetime.datetime | str | None = None, + settlement_end_ts: datetime.datetime | str | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[ObligationEntry]: """Async :meth:`MarginResource.obligation_history`.""" - params = _params(limit=_validate_limit(limit, hi=100), cursor=cursor) + params = _params( + limit=_validate_limit(limit, hi=100), + cursor=cursor, + settlement_start_ts=_rfc3339(settlement_start_ts), + settlement_end_ts=_rfc3339(settlement_end_ts), + ) return await self._list( "/margin/obligation_history", ObligationEntry, @@ -777,11 +846,18 @@ def obligation_history_all( *, limit: int | None = None, max_pages: int | None = None, + settlement_start_ts: datetime.datetime | str | None = None, + settlement_end_ts: datetime.datetime | str | None = None, extra_headers: dict[str, str] | None = None, ) -> AsyncIterator[ObligationEntry]: """Returns an async iterator over obligation history — use ``async for``.""" _validate_max_pages(max_pages) - params = _params(limit=_validate_limit(limit, hi=100), cursor=None) + params = _params( + limit=_validate_limit(limit, hi=100), + cursor=None, + settlement_start_ts=_rfc3339(settlement_start_ts), + settlement_end_ts=_rfc3339(settlement_end_ts), + ) return self._list_all( "/margin/obligation_history", ObligationEntry, @@ -992,10 +1068,17 @@ async def settlement_balance_history( *, limit: int | None = None, cursor: str | None = None, + start_ts: datetime.datetime | str | None = None, + end_ts: datetime.datetime | str | None = None, extra_headers: dict[str, str] | None = None, ) -> Page[SettlementBalanceHistoryEntry]: """Async :meth:`MarginResource.settlement_balance_history`.""" - params = _params(limit=_validate_limit(limit, hi=500), cursor=cursor) + params = _params( + limit=_validate_limit(limit, hi=500), + cursor=cursor, + start_ts=_rfc3339(start_ts), + end_ts=_rfc3339(end_ts), + ) return await self._list( "/margin/settlement_balance_history", SettlementBalanceHistoryEntry, @@ -1010,11 +1093,18 @@ def settlement_balance_history_all( *, limit: int | None = None, max_pages: int | None = None, + start_ts: datetime.datetime | str | None = None, + end_ts: datetime.datetime | str | None = None, extra_headers: dict[str, str] | None = None, ) -> AsyncIterator[SettlementBalanceHistoryEntry]: """Returns an async iterator over settlement-balance history — use ``async for``.""" _validate_max_pages(max_pages) - params = _params(limit=_validate_limit(limit, hi=500), cursor=None) + params = _params( + limit=_validate_limit(limit, hi=500), + cursor=None, + start_ts=_rfc3339(start_ts), + end_ts=_rfc3339(end_ts), + ) return self._list_all( "/margin/settlement_balance_history", SettlementBalanceHistoryEntry, diff --git a/pyproject.toml b/pyproject.toml index 496cb40..dd9111c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "22.7.0" +version = "22.8.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 9a23505..005d60c 100644 --- a/specs/asyncapi.yaml +++ b/specs/asyncapi.yaml @@ -4426,7 +4426,8 @@ components: Identifier of the RFQ batch this RFQ is a child of. Omitted for an RFQ outside a batch. A child can be quoted like a single RFQ, with these differences: a quote counts only for 30 seconds after it was - sent, and resending an unchanged bid keeps its time priority; + sent, and resending an unchanged bid keeps its time priority only + within those 30 seconds; nothing executes until every winning maker of the batch has confirmed, so a confirmed quote can still close without a fill; an accepted quote can't be cancelled, and not confirming it lapses @@ -4569,7 +4570,7 @@ components: properties: quote_id: type: string - description: Unique identifier for the quote + description: Unique identifier of the quote that executes. It can differ from the quote the RFQ creator named rfq_id: type: string description: Identifier of the RFQ this quote is for diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 432c33b..6a77632 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.35.0 + version: 3.36.0 description: Manually defined OpenAPI spec for endpoints being migrated to spec-first approach servers: @@ -2564,7 +2564,10 @@ paths: put: operationId: AcceptRFQQuote summary: Accept RFQ Quote - description: ' Endpoint for accepting a quote scoped to its RFQ. This will require the quoter to confirm.' + description: >- + Endpoint for accepting a quote scoped to its RFQ. This will require the quoter to confirm. + The quote that fills can be another maker's quote at the same price; see + [RFQs](/getting_started/rfqs#flow). tags: - communications security: @@ -3054,8 +3057,11 @@ paths: operationId: GetQuotes summary: Get Quotes description: >- - List quotes. The rfq_id filter requires an RFQ UUID. Pass pagination - cursors back unchanged; malformed RFQ IDs or cursors return HTTP 400. + List quotes. The rfq_id filter requires an RFQ UUID. With rfq_id, quotes + are sorted on the RFQ's main side (no for a target-cost RFQ, yes + otherwise): the best price first, then the earliest place in line at that + price. Pass pagination cursors back unchanged; malformed RFQ IDs or + cursors return HTTP 400. tags: - communications security: @@ -3262,7 +3268,7 @@ paths: operationId: AcceptQuote summary: Accept Quote deprecated: true - description: 'DEPRECATED: Use PUT /communications/rfqs/{rfq_id}/quotes/{quote_id}/accept instead. Endpoint for accepting a quote. This will require the quoter to confirm.' + description: 'DEPRECATED: Use PUT /communications/rfqs/{rfq_id}/quotes/{quote_id}/accept instead.' x-mint: content: | @@ -7815,6 +7821,7 @@ components: - realized_pnl_dollars - fees_paid_dollars - last_updated_ts + - netting_enabled properties: ticker: type: string @@ -7841,6 +7848,9 @@ components: type: string format: date-time description: Last time the position is updated + netting_enabled: + type: boolean + description: Whether netting applies to this position's event. Set at the subaccount's first order in the event. EventPosition: type: object diff --git a/specs/perps_scm_openapi.yaml b/specs/perps_scm_openapi.yaml index cecab03..79f57bd 100644 --- a/specs/perps_scm_openapi.yaml +++ b/specs/perps_scm_openapi.yaml @@ -80,6 +80,8 @@ paths: parameters: - { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 }, description: Number of results per page. } - { name: cursor, in: query, required: false, schema: { type: string }, description: Opaque cursor from a previous response to fetch the next page. } + - { name: settlement_start_ts, in: query, required: false, schema: { type: string, format: date-time }, description: Inclusive lower bound on settlement time. } + - { name: settlement_end_ts, in: query, required: false, schema: { type: string, format: date-time }, description: Inclusive upper bound on settlement time. } responses: '200': description: Successful response @@ -425,6 +427,8 @@ paths: parameters: - { name: limit, in: query, required: false, schema: { type: integer, default: 100, maximum: 500 }, description: Number of results per page. } - { name: cursor, in: query, required: false, schema: { type: string }, description: Cursor from a previous response to fetch the next page. } + - { name: start_ts, in: query, required: false, schema: { type: string, format: date-time }, description: Inclusive lower bound on entry creation time. } + - { name: end_ts, in: query, required: false, schema: { type: string, format: date-time }, description: Inclusive upper bound on entry creation time. } responses: '200': description: Successful response diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 5a14f31..f527040 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -1444,6 +1444,14 @@ class Exclusion: ), kind="server_omits_despite_required", ), + ("kalshi.models.portfolio.MarketPosition", "netting_enabled"): Exclusion( + reason=( + "spec 3.36.0 marks netting_enabled required; the SDK keeps it " + "optional (None when absent) so payloads and fixtures predating the " + "field still validate and the change ships as a minor release (22.8.0)." + ), + kind="server_omits_despite_required", + ), # --- server_omits_despite_required (#183, post-#172 nightly findings) --- # Fields the OpenAPI spec marks `required: true` but the live demo server # omits in practice. Each entry MUST cite a demo+prod observation in diff --git a/tests/perps/klear/test_margin.py b/tests/perps/klear/test_margin.py index 3820eeb..0f18f7c 100644 --- a/tests/perps/klear/test_margin.py +++ b/tests/perps/klear/test_margin.py @@ -19,6 +19,7 @@ from __future__ import annotations import json +from datetime import UTC, datetime from decimal import Decimal import httpx @@ -512,6 +513,138 @@ async def test_async_all_paginates(self, auth_async_klear_client: AsyncKlearClie assert len(items) == 2 await auth_async_klear_client.close() + @respx.mock + def test_settlement_window_sent_on_wire(self, auth_klear_client: KlearClient) -> None: + route = respx.get(f"{BASE}/margin/obligation_history").mock( + return_value=httpx.Response( + 200, json={"obligations": [_obligation()], "cursor": ""} + ) + ) + auth_klear_client.margin.obligation_history( + settlement_start_ts="2026-01-01T00:00:00Z", + settlement_end_ts="2026-01-31T23:59:59Z", + ) + params = route.calls[0].request.url.params + assert params["settlement_start_ts"] == "2026-01-01T00:00:00Z" + assert params["settlement_end_ts"] == "2026-01-31T23:59:59Z" + start = datetime(2026, 2, 1, tzinfo=UTC) + end = datetime(2026, 2, 28, tzinfo=UTC) + auth_klear_client.margin.obligation_history( + settlement_start_ts=start, settlement_end_ts=end + ) + params = route.calls[1].request.url.params + assert params["settlement_start_ts"] == start.isoformat() + assert params["settlement_end_ts"] == end.isoformat() + auth_klear_client.close() + + @respx.mock + def test_settlement_window_omitted_when_none(self, auth_klear_client: KlearClient) -> None: + route = respx.get(f"{BASE}/margin/obligation_history").mock( + return_value=httpx.Response( + 200, json={"obligations": [_obligation()], "cursor": ""} + ) + ) + auth_klear_client.margin.obligation_history() + params = route.calls[0].request.url.params + assert "settlement_start_ts" not in params + assert "settlement_end_ts" not in params + auth_klear_client.close() + + @respx.mock + def test_all_settlement_window_sent_on_wire(self, auth_klear_client: KlearClient) -> None: + route = respx.get(f"{BASE}/margin/obligation_history").mock( + return_value=httpx.Response( + 200, json={"obligations": [_obligation()], "cursor": ""} + ) + ) + list( + auth_klear_client.margin.obligation_history_all( + settlement_start_ts="2026-01-01T00:00:00Z", + settlement_end_ts="2026-01-31T23:59:59Z", + ) + ) + params = route.calls[0].request.url.params + assert params["settlement_start_ts"] == "2026-01-01T00:00:00Z" + assert params["settlement_end_ts"] == "2026-01-31T23:59:59Z" + list(auth_klear_client.margin.obligation_history_all()) + params = route.calls[1].request.url.params + assert "settlement_start_ts" not in params + assert "settlement_end_ts" not in params + auth_klear_client.close() + + @respx.mock + async def test_async_settlement_window_sent_on_wire( + self, auth_async_klear_client: AsyncKlearClient + ) -> None: + route = respx.get(f"{BASE}/margin/obligation_history").mock( + return_value=httpx.Response( + 200, json={"obligations": [_obligation()], "cursor": ""} + ) + ) + await auth_async_klear_client.margin.obligation_history( + settlement_start_ts="2026-01-01T00:00:00Z", + settlement_end_ts="2026-01-31T23:59:59Z", + ) + params = route.calls[0].request.url.params + assert params["settlement_start_ts"] == "2026-01-01T00:00:00Z" + assert params["settlement_end_ts"] == "2026-01-31T23:59:59Z" + await auth_async_klear_client.margin.obligation_history() + params = route.calls[1].request.url.params + assert "settlement_start_ts" not in params + assert "settlement_end_ts" not in params + await auth_async_klear_client.close() + + @respx.mock + async def test_async_all_settlement_window_sent_on_wire( + self, auth_async_klear_client: AsyncKlearClient + ) -> None: + route = respx.get(f"{BASE}/margin/obligation_history").mock( + return_value=httpx.Response( + 200, json={"obligations": [_obligation()], "cursor": ""} + ) + ) + items = [ + o + async for o in auth_async_klear_client.margin.obligation_history_all( + settlement_start_ts="2026-01-01T00:00:00Z", + settlement_end_ts="2026-01-31T23:59:59Z", + ) + ] + assert len(items) == 1 + params = route.calls[0].request.url.params + assert params["settlement_start_ts"] == "2026-01-01T00:00:00Z" + assert params["settlement_end_ts"] == "2026-01-31T23:59:59Z" + items = [o async for o in auth_async_klear_client.margin.obligation_history_all()] + assert len(items) == 1 + params = route.calls[1].request.url.params + assert "settlement_start_ts" not in params + assert "settlement_end_ts" not in params + await auth_async_klear_client.close() + + @respx.mock + def test_settlement_window_server_4xx_raises(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/obligation_history").mock( + return_value=httpx.Response(400, json={"code": "bad", "message": "bad window"}) + ) + with pytest.raises(KalshiValidationError): + auth_klear_client.margin.obligation_history( + settlement_start_ts="2026-01-01T00:00:00Z" + ) + auth_klear_client.close() + + @respx.mock + async def test_async_settlement_window_server_4xx_raises( + self, auth_async_klear_client: AsyncKlearClient + ) -> None: + respx.get(f"{BASE}/margin/obligation_history").mock( + return_value=httpx.Response(400, json={"code": "bad", "message": "bad window"}) + ) + with pytest.raises(KalshiValidationError): + await auth_async_klear_client.margin.obligation_history( + settlement_end_ts="2026-01-31T23:59:59Z" + ) + await auth_async_klear_client.close() + # --------------------------------------------------------------------------- # # obligation detail pages (settlement / MM / funding) @@ -915,6 +1048,136 @@ async def test_async_all_paginates(self, auth_async_klear_client: AsyncKlearClie assert len(items) == 2 await auth_async_klear_client.close() + @respx.mock + def test_creation_window_sent_on_wire(self, auth_klear_client: KlearClient) -> None: + route = respx.get(f"{BASE}/margin/settlement_balance_history").mock( + return_value=httpx.Response( + 200, json={"entries": [_balance_history_entry()], "cursor": ""} + ) + ) + auth_klear_client.margin.settlement_balance_history( + start_ts="2026-01-01T00:00:00Z", + end_ts="2026-01-31T23:59:59Z", + ) + params = route.calls[0].request.url.params + assert params["start_ts"] == "2026-01-01T00:00:00Z" + assert params["end_ts"] == "2026-01-31T23:59:59Z" + start = datetime(2026, 2, 1, tzinfo=UTC) + end = datetime(2026, 2, 28, tzinfo=UTC) + auth_klear_client.margin.settlement_balance_history(start_ts=start, end_ts=end) + params = route.calls[1].request.url.params + assert params["start_ts"] == start.isoformat() + assert params["end_ts"] == end.isoformat() + auth_klear_client.close() + + @respx.mock + def test_creation_window_omitted_when_none(self, auth_klear_client: KlearClient) -> None: + route = respx.get(f"{BASE}/margin/settlement_balance_history").mock( + return_value=httpx.Response( + 200, json={"entries": [_balance_history_entry()], "cursor": ""} + ) + ) + auth_klear_client.margin.settlement_balance_history() + params = route.calls[0].request.url.params + assert "start_ts" not in params + assert "end_ts" not in params + auth_klear_client.close() + + @respx.mock + def test_all_creation_window_sent_on_wire(self, auth_klear_client: KlearClient) -> None: + route = respx.get(f"{BASE}/margin/settlement_balance_history").mock( + return_value=httpx.Response( + 200, json={"entries": [_balance_history_entry()], "cursor": ""} + ) + ) + list( + auth_klear_client.margin.settlement_balance_history_all( + start_ts="2026-01-01T00:00:00Z", + end_ts="2026-01-31T23:59:59Z", + ) + ) + params = route.calls[0].request.url.params + assert params["start_ts"] == "2026-01-01T00:00:00Z" + assert params["end_ts"] == "2026-01-31T23:59:59Z" + list(auth_klear_client.margin.settlement_balance_history_all()) + params = route.calls[1].request.url.params + assert "start_ts" not in params + assert "end_ts" not in params + auth_klear_client.close() + + @respx.mock + async def test_async_creation_window_sent_on_wire( + self, auth_async_klear_client: AsyncKlearClient + ) -> None: + route = respx.get(f"{BASE}/margin/settlement_balance_history").mock( + return_value=httpx.Response( + 200, json={"entries": [_balance_history_entry()], "cursor": ""} + ) + ) + await auth_async_klear_client.margin.settlement_balance_history( + start_ts="2026-01-01T00:00:00Z", + end_ts="2026-01-31T23:59:59Z", + ) + params = route.calls[0].request.url.params + assert params["start_ts"] == "2026-01-01T00:00:00Z" + assert params["end_ts"] == "2026-01-31T23:59:59Z" + await auth_async_klear_client.margin.settlement_balance_history() + params = route.calls[1].request.url.params + assert "start_ts" not in params + assert "end_ts" not in params + await auth_async_klear_client.close() + + @respx.mock + async def test_async_all_creation_window_sent_on_wire( + self, auth_async_klear_client: AsyncKlearClient + ) -> None: + route = respx.get(f"{BASE}/margin/settlement_balance_history").mock( + return_value=httpx.Response( + 200, json={"entries": [_balance_history_entry()], "cursor": ""} + ) + ) + items = [ + e + async for e in auth_async_klear_client.margin.settlement_balance_history_all( + start_ts="2026-01-01T00:00:00Z", + end_ts="2026-01-31T23:59:59Z", + ) + ] + assert len(items) == 1 + params = route.calls[0].request.url.params + assert params["start_ts"] == "2026-01-01T00:00:00Z" + assert params["end_ts"] == "2026-01-31T23:59:59Z" + items = [ + e async for e in auth_async_klear_client.margin.settlement_balance_history_all() + ] + assert len(items) == 1 + params = route.calls[1].request.url.params + assert "start_ts" not in params + assert "end_ts" not in params + await auth_async_klear_client.close() + + @respx.mock + def test_creation_window_server_4xx_raises(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/settlement_balance_history").mock( + return_value=httpx.Response(400, json={"code": "bad", "message": "bad window"}) + ) + with pytest.raises(KalshiValidationError): + auth_klear_client.margin.settlement_balance_history(start_ts="2026-01-01T00:00:00Z") + auth_klear_client.close() + + @respx.mock + async def test_async_creation_window_server_4xx_raises( + self, auth_async_klear_client: AsyncKlearClient + ) -> None: + respx.get(f"{BASE}/margin/settlement_balance_history").mock( + return_value=httpx.Response(400, json={"code": "bad", "message": "bad window"}) + ) + with pytest.raises(KalshiValidationError): + await auth_async_klear_client.margin.settlement_balance_history( + end_ts="2026-01-31T23:59:59Z" + ) + await auth_async_klear_client.close() + # --------------------------------------------------------------------------- # # withdraw_settlement_balance diff --git a/tests/test_models.py b/tests/test_models.py index 6e0e0b4..16cdeb4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -20,7 +20,7 @@ OrderGroup, ) from kalshi.models.orders import Fill, Order -from kalshi.models.portfolio import Settlement +from kalshi.models.portfolio import MarketPosition, Settlement from kalshi.types import to_decimal from tests._model_fixtures import ( candlestick_dict, @@ -31,6 +31,7 @@ get_order_group_response_dict, incentive_program_dict, market_dict, + market_position_dict, order_dict, settlement_dict, trade_dict, @@ -567,6 +568,25 @@ def test_is_block_trade_missing_raises(self) -> None: Trade.model_validate(data) +class TestMarketPositionNettingEnabled: + """Spec 3.36.0 marks ``netting_enabled`` required; SDK keeps it optional.""" + + def test_parses_netting_enabled(self) -> None: + assert ( + MarketPosition.model_validate(market_position_dict(netting_enabled=True)).netting_enabled + is True + ) + assert ( + MarketPosition.model_validate( + market_position_dict(netting_enabled=False) + ).netting_enabled + is False + ) + + def test_parses_without_netting_enabled(self) -> None: + assert MarketPosition.model_validate(market_position_dict()).netting_enabled is None + + class TestIncentiveProgramV3180Fields: """v3.18.0 backfill (issue #160): 1 new optional field on ``IncentiveProgram``."""