diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e3d21..8080254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to kalshi-sdk will be documented in this file. +## 17.1.0 — 2026-09-30 + +Reconciles upstream OpenAPI **3.31.0 → 3.32.0** content drift plus matching +perps and AsyncAPI updates after nightly contract failures (Closes #525). +**Non-breaking**: every addition is an optional parameter or a new method. +Re-vendored `specs/openapi.yaml` (hash +`b8a5c773e8f5bf1f9836fe29dc5449bbded8c8510e11e3471df86e552bbfe507`), +`specs/asyncapi.yaml` (hash +`902d9a36fdf52821eac5577caa1d8f694baa1788144c76b5527731e851089522`), and +`specs/perps_openapi.yaml` (hash +`e3b906ebbb3eb26cbb555b778ee52ef08843f174bc78b0b6d1052dfb3589a6ef`). + +### Added + +- Optional **`settlement_status`** (`"unsettled"` / `"settled"` / `"all"`) on + `portfolio.positions` and `portfolio.positions_all` (sync + async). This + kwarg was a phantom removed in v0.7.0; OpenAPI 3.32.0 adds it as a real + query param on `GET /portfolio/positions`. Omit it to keep the server + default (`unsettled`). +- Perps **`funding.premium_index(*, ticker, start_ts, end_ts)`** (sync + + async), public `GET /margin/funding_rates/premium_index`. Returns + `list[MarginPremiumIndexPoint]`. `MarginFundingRateEstimate` gains optional + `premium_index` (`Decimal | None`) and `premium_index_ts` + (`datetime | None`). + +### Spec notes + +- Core OpenAPI `info.version` is **3.32.0**. Still unimplemented on the core + client: `POST /portfolio/intra_exchange_instance_transfer`. +- AsyncAPI and perps AsyncAPI (`specs/perps_asyncapi.yaml`, hash + `e5cc0f026b8e306e917860b870e23c9152d0d782c33137d42698f051a9dbe824`) add + optional `sending_ts_ms` on message envelopes (when Kalshi queued the + frame). Response models keep `extra="allow"` and do not surface the field. + ## 17.0.0 — 2026-09-29 Reconciles upstream OpenAPI **3.31.0** content drift plus matching perps and diff --git a/docs/migration.md b/docs/migration.md index 40d2667..7fafcd9 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,5 +1,21 @@ # Migration +## v17.0 → v17.1.0 + +Reconciles upstream OpenAPI **3.32.0** (Closes #525). **Non-breaking** +additive APIs. + +### Added + +- `portfolio.positions` / `positions_all(..., settlement_status=)` on the + sync and async clients. Values are `"unsettled"`, `"settled"`, and + `"all"`. This filter was removed in v0.7.0 because it was not in the spec; + OpenAPI 3.32.0 adds it for real. Omit the kwarg to keep the server default + (`unsettled`). +- Perps `funding.premium_index(*, ticker, start_ts, end_ts)` (public, no + auth) plus `MarginPremiumIndexPoint`. `MarginFundingRateEstimate` gains + optional `premium_index` and `premium_index_ts`. + ## v16.0 → v17.0.0 Reconciles upstream OpenAPI **3.31.0** content drift plus matching perps and diff --git a/docs/perps.md b/docs/perps.md index b6a3ac5..b32d8fd 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -64,7 +64,7 @@ async with AsyncPerpsClient.from_env(demo=True) as perps: | `order_groups` | `list()`, `get()`, `create()`, `delete()`, `reset()`, `trigger()`, `update_limit()` | | `portfolio` | `positions()`, `fills()` / `fills_all()`, `trades()` / `trades_all()`, cross/isolated exit triggers | | `margin` | `balance()`, `risk()`, `notional_risk_limit()`, `fee_tiers()`, `fee_tier_rates()`, `api_limits()` | -| `funding` | `rate_estimate()`, `historical_rates()`, `history()` | +| `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` | @@ -185,6 +185,10 @@ user's per-payment history: ```python est = perps.funding.rate_estimate(ticker="BTC-PERP") print(est.funding_rate, est.next_funding_time) # in-progress estimate +print(est.premium_index, est.premium_index_ts) # optional final-second premium +points = perps.funding.premium_index( + ticker="BTC-PERP", start_ts=1_700_000_000, end_ts=1_700_003_600 +) rows = perps.funding.history(start_date="2026-01-01", end_date="2026-02-01") ``` diff --git a/docs/resources/fcm.md b/docs/resources/fcm.md index c171d15..f2b9c98 100644 --- a/docs/resources/fcm.md +++ b/docs/resources/fcm.md @@ -89,8 +89,9 @@ for mp in client.fcm.positions_all(subtrader_id="st_alpha", settlement_status="u # async: `async for mp in client.fcm.positions_all(...)` ``` -`settlement_status` is the FCM-specific kwarg that does **not** exist on -`portfolio.positions()`. +`settlement_status` is also accepted on +[`portfolio.positions`](portfolio.md#positions) / `positions_all` (OpenAPI +3.32.0). Both endpoints default to `unsettled` when the kwarg is omitted. ## Subtrader admin diff --git a/docs/resources/portfolio.md b/docs/resources/portfolio.md index f7e3a1e..bd8bc0d 100644 --- a/docs/resources/portfolio.md +++ b/docs/resources/portfolio.md @@ -69,6 +69,7 @@ if bal.balance_breakdown is not None: resp = client.portfolio.positions( limit=200, count_filter="position", # only return rows with non-zero `position` (etc.) + settlement_status="unsettled", # "unsettled" (server default) | "settled" | "all" ticker="KXPRES-24-DJT", event_ticker="KXPRES-24", ) @@ -109,6 +110,10 @@ for ep in resp.event_positions: `count_filter` filters which fields the response **includes a row for** — filtering by `"position"` returns only markets where your position is non-zero. +`settlement_status` (`SettlementStatusLiteral`: `"unsettled"`, `"settled"`, +`"all"`) selects which live positions to return. Omit it to keep the server +default (`unsettled`). Archived positions stay on `GET /historical/positions`. + ## Settlements ```python diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 3a03bda..68deb6d 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -427,4 +427,4 @@ "Withdrawal", ] -__version__ = "17.0.0" +__version__ = "17.1.0" diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index acad4dc..623c626 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -880,6 +880,14 @@ class ContractEntry: sdk_model="kalshi.perps.models.funding.GetMarginFundingHistoryResponse", spec_schema="GetMarginFundingHistoryResponse", ), + ContractEntry( + sdk_model="kalshi.perps.models.funding.MarginPremiumIndexPoint", + spec_schema="MarginPremiumIndexPoint", + ), + ContractEntry( + sdk_model="kalshi.perps.models.funding.GetMarginPremiumIndexResponse", + spec_schema="GetMarginPremiumIndexResponse", + ), # ── perps transfers (#396) ── ContractEntry( sdk_model="kalshi.perps.models.transfers.IntraExchangeInstanceTransferResponse", diff --git a/kalshi/models/portfolio.py b/kalshi/models/portfolio.py index 5ffc02a..054a5e2 100644 --- a/kalshi/models/portfolio.py +++ b/kalshi/models/portfolio.py @@ -9,7 +9,10 @@ from kalshi.types import DollarDecimal, FixedPointCount, NullableList, UnixSecondsTimestamp SettlementStatusLiteral = Literal["all", "unsettled", "settled"] -"""Position settlement status filter for GET /fcm/positions. Spec: settlement_status query enum.""" +"""Position settlement status filter for GET /portfolio/positions and GET /fcm/positions. + +Spec: ``settlement_status`` query enum. +""" class IndexedBalance(BaseModel): diff --git a/kalshi/perps/__init__.py b/kalshi/perps/__init__.py index ced7303..1128c09 100644 --- a/kalshi/perps/__init__.py +++ b/kalshi/perps/__init__.py @@ -86,9 +86,11 @@ UpdateFCMSubtraderRiskControlsRequest, ) from kalshi.perps.models.funding import ( + GetMarginPremiumIndexResponse, MarginFundingHistoryEntry, MarginFundingRate, MarginFundingRateEstimate, + MarginPremiumIndexPoint, ) from kalshi.perps.models.margin_account import ( FeeScheduleLiteral, @@ -284,6 +286,7 @@ "GetMarginOrderResponse", "GetMarginOrdersResponse", "GetMarginPositionsResponse", + "GetMarginPremiumIndexResponse", "GetMarginReportsResponse", "GetMarginRiskParametersResponse", "GetMarginRiskResponse", @@ -328,6 +331,7 @@ "MarginOrderbookSnapshotPayload", "MarginOrdersResource", "MarginPosition", + "MarginPremiumIndexPoint", "MarginReport", "MarginReportTypeLiteral", "MarginResource", diff --git a/kalshi/perps/models/__init__.py b/kalshi/perps/models/__init__.py index b3bba23..5e228a6 100644 --- a/kalshi/perps/models/__init__.py +++ b/kalshi/perps/models/__init__.py @@ -34,9 +34,11 @@ UpdateFCMSubtraderRiskControlsRequest, ) from kalshi.perps.models.funding import ( + GetMarginPremiumIndexResponse, MarginFundingHistoryEntry, MarginFundingRate, MarginFundingRateEstimate, + MarginPremiumIndexPoint, ) from kalshi.perps.models.margin_account import ( FeeScheduleLiteral, @@ -147,6 +149,7 @@ "GetMarginOrderResponse", "GetMarginOrdersResponse", "GetMarginPositionsResponse", + "GetMarginPremiumIndexResponse", "GetMarginRiskParametersResponse", "GetMarginRiskResponse", "GetMarginTradesResponse", @@ -170,6 +173,7 @@ "MarginOrderbook", "MarginOrderbookLevel", "MarginPosition", + "MarginPremiumIndexPoint", "MarginRiskPosition", "MarginSubaccountBalance", "MarginTrade", diff --git a/kalshi/perps/models/funding.py b/kalshi/perps/models/funding.py index 2dbfe8d..30883c7 100644 --- a/kalshi/perps/models/funding.py +++ b/kalshi/perps/models/funding.py @@ -1,11 +1,12 @@ -"""Perps (margin) funding models — rate estimate, historical rates, payment history. +"""Perps (margin) funding models — rate estimate, historical rates, premium index, payment history. -Response models for the three read-only perps **funding** endpoints (#395): +Response models for the four read-only perps **funding** endpoints (#395): - :class:`MarginFundingRate` — a single applied historical funding rate. - :class:`MarginFundingHistoryEntry` — one row of the authenticated user's per-payment funding history (the rate joined with the realized payment). - :class:`MarginFundingRateEstimate` — the current in-progress rate estimate. +- :class:`MarginPremiumIndexPoint` — one per-second informational premium index. Field-type rules (verified against ``specs/perps_openapi.yaml``): @@ -16,9 +17,11 @@ - ``mark_price`` / ``funding_amount`` are ``$ref FixedPointDollars`` strings → :data:`~kalshi.types.DollarDecimal`. - ``quantity`` is ``$ref FixedPointCount`` → :data:`~kalshi.types.FixedPointCount`. -- ``funding_time`` / ``computed_time`` / ``next_funding_time`` are RFC3339 - ``format: date-time`` REST timestamps → :class:`~pydantic.AwareDatetime` - (NOT ``_ms`` epoch ints). +- ``funding_time`` / ``computed_time`` / ``next_funding_time`` / + ``premium_index_ts`` / ``second_ts`` are RFC3339 ``format: date-time`` REST + timestamps → :class:`~pydantic.AwareDatetime` (NOT ``_ms`` epoch ints). +- ``premium_index`` is a signed decimal fraction (spec ``type: string``) → + :data:`~kalshi.types.MultiplierDecimal`. These are response models only — this issue has no request bodies, so every model uses ``extra="allow"`` (tolerate additive server fields) and never @@ -102,6 +105,38 @@ class MarginFundingRateEstimate(BaseModel): validation_alias=AliasChoices("mark_price_dollars", "mark_price"), ) next_funding_time: AwareDatetime + # Final-second premium captured with this estimate. Omitted when that + # second has no available premium (spec optional). + premium_index: MultiplierDecimal | None = None + premium_index_ts: AwareDatetime | None = None + + +class MarginPremiumIndexPoint(BaseModel): + """Spec ``MarginPremiumIndexPoint`` — one per-second informational premium. + + Both properties are spec-required. ``premium_index`` is a signed decimal + fraction of the index price (``"0"`` when no premium was measurable), not + basis points. Informational only — it may not match the premium used in + the actual funding calculation. + """ + + model_config = {"extra": "allow", "populate_by_name": True} + + second_ts: AwareDatetime + premium_index: MultiplierDecimal + + +class GetMarginPremiumIndexResponse(BaseModel): + """Spec ``GetMarginPremiumIndexResponse`` — premium-index envelope. + + ``points`` is spec-required and uses + :data:`~kalshi.types.NullableList`: a missing key raises ``ValidationError`` + (surfacing drift), while a ``null`` array coerces to ``[]``. + """ + + points: NullableList[MarginPremiumIndexPoint] + + model_config = {"extra": "allow"} class GetMarginHistoricalFundingRatesResponse(BaseModel): diff --git a/kalshi/perps/resources/funding.py b/kalshi/perps/resources/funding.py index 4913db1..48be3f0 100644 --- a/kalshi/perps/resources/funding.py +++ b/kalshi/perps/resources/funding.py @@ -1,15 +1,16 @@ -"""Perps funding resource — rate estimate, historical rates, payment history (#395). - -Three read-only GET endpoints. ``rate_estimate`` and ``historical_rates`` are -public (no spec ``security`` block); ``history`` requires RSA-PSS auth and is -guarded client-side with ``_require_auth()`` so an unauthenticated caller gets -``AuthRequiredError`` instead of a server 401. All three retry on 429/502/503/504 -(GET). - -None of the three funding endpoints paginate — ``GetMarginFundingHistoryResponse`` -and ``GetMarginHistoricalFundingRatesResponse`` carry a single array property and -**no cursor** — so there are no ``*_all()`` / ``Iterator`` paginators here. The -two list endpoints unwrap their array key and return ``builtins.list[Model]``, +"""Perps funding resource — rate estimate, historical rates, premium index, payment history (#395). + +Four read-only GET endpoints. ``rate_estimate``, ``historical_rates``, and +``premium_index`` are public (no spec ``security`` block); ``history`` requires +RSA-PSS auth and is guarded client-side with ``_require_auth()`` so an +unauthenticated caller gets ``AuthRequiredError`` instead of a server 401. +All four retry on 429/502/503/504 (GET). + +None of the four funding endpoints paginate — +``GetMarginFundingHistoryResponse``, ``GetMarginHistoricalFundingRatesResponse``, +and ``GetMarginPremiumIndexResponse`` carry a single array property and **no +cursor** — so there are no ``*_all()`` / ``Iterator`` paginators here. The +three list endpoints unwrap their array key and return ``builtins.list[Model]``, mirroring ``historical.candlesticks``. """ @@ -21,9 +22,11 @@ from kalshi.perps.models.funding import ( GetMarginFundingHistoryResponse, GetMarginHistoricalFundingRatesResponse, + GetMarginPremiumIndexResponse, MarginFundingHistoryEntry, MarginFundingRate, MarginFundingRateEstimate, + MarginPremiumIndexPoint, ) from kalshi.resources._base import AsyncResource, SyncResource, _params @@ -38,6 +41,16 @@ def _funding_historical_params( return _params(ticker=ticker, start_ts=start_ts, end_ts=end_ts) +def _premium_index_params( + *, + ticker: str, + start_ts: int, + end_ts: int, +) -> dict[str, Any]: + """Build query params for ``GET /margin/funding_rates/premium_index``.""" + return _params(ticker=ticker, start_ts=start_ts, end_ts=end_ts) + + def _funding_history_params( *, ticker: str | None, @@ -55,7 +68,10 @@ def _funding_history_params( class FundingResource(SyncResource): - """Sync perps funding API (``rate_estimate`` / ``historical_rates`` / ``history``).""" + """Sync perps funding API. + + ``rate_estimate`` / ``historical_rates`` / ``premium_index`` / ``history``. + """ def rate_estimate( self, ticker: str, *, extra_headers: dict[str, str] | None = None @@ -81,6 +97,20 @@ def historical_rates( ) return GetMarginHistoricalFundingRatesResponse.model_validate(data).funding_rates + def premium_index( + self, + *, + ticker: str, + start_ts: int, + end_ts: int, + extra_headers: dict[str, str] | None = None, + ) -> builtins.list[MarginPremiumIndexPoint]: + params = _premium_index_params(ticker=ticker, start_ts=start_ts, end_ts=end_ts) + data = self._get( + "/margin/funding_rates/premium_index", params=params, extra_headers=extra_headers + ) + return GetMarginPremiumIndexResponse.model_validate(data).points + def history( self, *, @@ -102,7 +132,10 @@ def history( class AsyncFundingResource(AsyncResource): - """Async perps funding API (``rate_estimate`` / ``historical_rates`` / ``history``).""" + """Async perps funding API. + + ``rate_estimate`` / ``historical_rates`` / ``premium_index`` / ``history``. + """ async def rate_estimate( self, ticker: str, *, extra_headers: dict[str, str] | None = None @@ -128,6 +161,20 @@ async def historical_rates( ) return GetMarginHistoricalFundingRatesResponse.model_validate(data).funding_rates + async def premium_index( + self, + *, + ticker: str, + start_ts: int, + end_ts: int, + extra_headers: dict[str, str] | None = None, + ) -> builtins.list[MarginPremiumIndexPoint]: + params = _premium_index_params(ticker=ticker, start_ts=start_ts, end_ts=end_ts) + data = await self._get( + "/margin/funding_rates/premium_index", params=params, extra_headers=extra_headers + ) + return GetMarginPremiumIndexResponse.model_validate(data).points + async def history( self, *, diff --git a/kalshi/resources/portfolio.py b/kalshi/resources/portfolio.py index 16953d9..1f98010 100644 --- a/kalshi/resources/portfolio.py +++ b/kalshi/resources/portfolio.py @@ -17,6 +17,7 @@ RestingMarginReservationLiteral, SetTargetBalanceAllocationRequest, Settlement, + SettlementStatusLiteral, TargetBalanceAllocationInput, TotalRestingOrderValue, Withdrawal, @@ -40,6 +41,7 @@ def _positions_params( limit: int | None, cursor: str | None, count_filter: str | None, + settlement_status: SettlementStatusLiteral | None, ticker: str | None, event_ticker: str | None, subaccount: int | None, @@ -50,6 +52,7 @@ def _positions_params( limit=limit, cursor=cursor, count_filter=count_filter, + settlement_status=settlement_status, ticker=ticker, event_ticker=event_ticker, subaccount=subaccount, @@ -101,6 +104,7 @@ def positions( limit: int | None = None, cursor: str | None = None, count_filter: str | None = None, + settlement_status: SettlementStatusLiteral | None = None, ticker: str | None = None, event_ticker: str | None = None, subaccount: int | None = None, @@ -112,6 +116,7 @@ def positions( limit=limit, cursor=cursor, count_filter=count_filter, + settlement_status=settlement_status, ticker=ticker, event_ticker=event_ticker, subaccount=subaccount, @@ -125,6 +130,7 @@ def positions_all( *, limit: int | None = None, count_filter: str | None = None, + settlement_status: SettlementStatusLiteral | None = None, ticker: str | None = None, event_ticker: str | None = None, subaccount: int | None = None, @@ -147,6 +153,7 @@ def positions_all( limit=limit, cursor=None, count_filter=count_filter, + settlement_status=settlement_status, ticker=ticker, event_ticker=event_ticker, subaccount=subaccount, @@ -507,6 +514,7 @@ async def positions( limit: int | None = None, cursor: str | None = None, count_filter: str | None = None, + settlement_status: SettlementStatusLiteral | None = None, ticker: str | None = None, event_ticker: str | None = None, subaccount: int | None = None, @@ -518,6 +526,7 @@ async def positions( limit=limit, cursor=cursor, count_filter=count_filter, + settlement_status=settlement_status, ticker=ticker, event_ticker=event_ticker, subaccount=subaccount, @@ -531,6 +540,7 @@ def positions_all( *, limit: int | None = None, count_filter: str | None = None, + settlement_status: SettlementStatusLiteral | None = None, ticker: str | None = None, event_ticker: str | None = None, subaccount: int | None = None, @@ -553,6 +563,7 @@ def positions_all( limit=limit, cursor=None, count_filter=count_filter, + settlement_status=settlement_status, ticker=ticker, event_ticker=event_ticker, subaccount=subaccount, diff --git a/pyproject.toml b/pyproject.toml index 90b88a9..becb9f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "17.0.0" +version = "17.1.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 802e91e..e5db024 100644 --- a/specs/asyncapi.yaml +++ b/specs/asyncapi.yaml @@ -1430,6 +1430,7 @@ components: payload: id: 1 type: subscribed + sending_ts_ms: 1669149841234 msg: channel: orderbook_delta sid: 1 @@ -1449,6 +1450,7 @@ components: sid: 2 seq: 7 type: unsubscribed + sending_ts_ms: 1669149841234 subscribedIndicesResponse: name: ok @@ -1481,6 +1483,7 @@ components: sid: 456 seq: 222 type: ok + sending_ts_ms: 1669149841234 msg: market_tickers: ["MARKET-1", "MARKET-2", "MARKET-3"] @@ -1497,6 +1500,7 @@ components: payload: id: 3 type: ok + sending_ts_ms: 1669149841234 msg: - channel: "orderbook_delta" sid: 1 @@ -1553,6 +1557,7 @@ components: payload: id: 123 type: error + sending_ts_ms: 1669149841234 msg: code: 7 msg: "Unknown subscription ID" @@ -1561,6 +1566,7 @@ components: payload: id: 124 type: error + sending_ts_ms: 1669149841234 msg: code: 8 msg: "Unknown channel name" @@ -1569,6 +1575,7 @@ components: payload: id: 125 type: error + sending_ts_ms: 1669149841234 msg: code: 2 msg: "Params required" @@ -1577,6 +1584,7 @@ components: payload: id: 126 type: error + sending_ts_ms: 1669149841234 msg: code: 9 msg: "Authentication required" @@ -1585,6 +1593,7 @@ components: payload: id: 127 type: error + sending_ts_ms: 1669149841234 msg: code: 14 msg: "Market Ticker required" @@ -1629,6 +1638,7 @@ components: summary: Orderbook snapshot with yes and no sides payload: type: orderbook_snapshot + sending_ts_ms: 1669149841234 sid: 2 seq: 2 msg: @@ -1649,6 +1659,7 @@ components: summary: Orderbook price level update payload: type: orderbook_delta + sending_ts_ms: 1669149841123 sid: 2 seq: 3 msg: @@ -1672,6 +1683,7 @@ components: summary: CF Benchmarks value update with 60s and final-minute quarter-hour averages payload: type: cfbenchmarks_value + sending_ts_ms: 1669149841234 sid: 1 seq: 42 msg: @@ -1701,6 +1713,7 @@ components: summary: Example available index IDs (illustrative subset) payload: type: cfbenchmarks_value_indexlist + sending_ts_ms: 1669149841234 id: 2 sid: 1 seq: 1 @@ -1719,6 +1732,7 @@ components: summary: CF Benchmarks 5Hz value update (raw tick, no averages) payload: type: cfbenchmarks_value_5hz + sending_ts_ms: 1669149841234 sid: 1 seq: 42 msg: @@ -1740,6 +1754,7 @@ components: summary: Index IDs streaming on the 5Hz channel payload: type: cfbenchmarks_value_5hz_indexlist + sending_ts_ms: 1669149841234 id: 2 sid: 1 seq: 1 @@ -1757,6 +1772,7 @@ components: - name: pythValueUpdate payload: type: pyth_value + sending_ts_ms: 1669149841234 sid: 1 seq: 42 msg: @@ -1776,6 +1792,7 @@ components: - name: underlyingListResponse payload: type: pyth_value_underlying_list + sending_ts_ms: 1669149841234 id: 2 sid: 1 seq: 1 @@ -1794,6 +1811,7 @@ components: summary: Ticker update payload: type: ticker + sending_ts_ms: 1669149841123 sid: 11 msg: market_id: "9b0f6b43-5b68-4f9f-9f02-9a2d1b8ac1a1" @@ -1824,6 +1842,7 @@ components: summary: Trade notification payload: type: trade + sending_ts_ms: 1669149841123 sid: 11 seq: 2 msg: @@ -1851,6 +1870,7 @@ components: summary: User fill notification payload: type: fill + sending_ts_ms: 1671899397123 sid: 13 msg: trade_id: "d91bc706-ee49-470d-82d8-11418bda6fed" @@ -1884,6 +1904,7 @@ components: summary: Market created event payload: type: market_lifecycle_v2 + sending_ts_ms: 1669149841234 sid: 13 seq: 3 msg: @@ -1910,6 +1931,7 @@ components: summary: Price level structure updated event payload: type: market_lifecycle_v2 + sending_ts_ms: 1669149841234 sid: 13 seq: 4 msg: @@ -1933,6 +1955,7 @@ components: summary: Market metadata updated event payload: type: market_lifecycle_v2 + sending_ts_ms: 1669149841234 sid: 13 seq: 5 msg: @@ -1945,6 +1968,7 @@ components: summary: Market metadata updated event (subtitle) payload: type: market_lifecycle_v2 + sending_ts_ms: 1669149841234 sid: 13 seq: 6 msg: @@ -1964,6 +1988,7 @@ components: summary: Multivariate market created event payload: type: multivariate_market_lifecycle + sending_ts_ms: 1669149841234 sid: 14 seq: 7 msg: @@ -1995,6 +2020,7 @@ components: summary: Event created payload: type: event_lifecycle + sending_ts_ms: 1669149841234 sid: 5 seq: 8 msg: @@ -2018,6 +2044,7 @@ components: summary: Event fee override set payload: type: event_fee_update + sending_ts_ms: 1669149841234 sid: 5 seq: 9 msg: @@ -2028,6 +2055,7 @@ components: summary: Event fee override cleared payload: type: event_fee_update + sending_ts_ms: 1669149841234 sid: 5 seq: 10 msg: @@ -2047,6 +2075,7 @@ components: summary: User position update payload: type: market_position + sending_ts_ms: 1669149841234 sid: 14 msg: user_id: "user123" @@ -2070,6 +2099,7 @@ components: summary: Order group limit updated payload: type: order_group_updates + sending_ts_ms: 1733047200123 sid: 21 seq: 7 msg: @@ -2090,6 +2120,7 @@ components: summary: User order created notification payload: type: user_order + sending_ts_ms: 1669149841234 sid: 22 msg: order_id: "ee587a1c-8b87-4dcf-b721-9f6f790619fa" @@ -2130,6 +2161,7 @@ components: summary: RFQ created notification payload: type: rfq_created + sending_ts_ms: 1669149841234 sid: 15 seq: 11 msg: @@ -2143,6 +2175,7 @@ components: summary: MVE RFQ created notification payload: type: rfq_created + sending_ts_ms: 1669149841234 sid: 15 seq: 11 msg: @@ -2174,6 +2207,7 @@ components: summary: RFQ deleted notification payload: type: rfq_deleted + sending_ts_ms: 1669149841234 sid: 15 seq: 12 msg: @@ -2197,6 +2231,7 @@ components: summary: Quote created notification payload: type: quote_created + sending_ts_ms: 1669149841234 sid: 15 seq: 13 msg: @@ -2226,6 +2261,7 @@ components: summary: Quote accepted notification payload: type: quote_accepted + sending_ts_ms: 1669149841234 sid: 15 seq: 14 msg: @@ -2260,6 +2296,7 @@ components: summary: Quote executed notification payload: type: quote_executed + sending_ts_ms: 1669149841234 sid: 15 seq: 15 msg: @@ -2274,6 +2311,11 @@ components: subaccount: 3 schemas: + sendingTimestampMs: + type: integer + format: int64 + description: Unix timestamp in milliseconds when Kalshi queued this message at the network layer. + # Base schemas commandId: type: integer @@ -2574,6 +2616,8 @@ components: type: string sid: $ref: '#/components/schemas/subscriptionId' + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' unsubscribedResponsePayload: type: object @@ -2588,6 +2632,8 @@ components: $ref: '#/components/schemas/subscriptionId' seq: $ref: '#/components/schemas/sequenceNumber' + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' subscribedIndicesResponsePayload: type: object @@ -2611,6 +2657,8 @@ components: description: Current CF Benchmarks index filter after an update; all-mode is ["all"] items: type: string + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' subscribedUnderlyingsResponsePayload: type: object @@ -2634,6 +2682,8 @@ components: description: Current Pyth underlying ticker filter after an update; all-mode is ["all"] items: type: string + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' okResponsePayload: type: object @@ -2661,6 +2711,8 @@ components: description: Full list of market IDs after update items: $ref: '#/components/schemas/marketId' + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' errorResponsePayload: type: object @@ -2725,6 +2777,8 @@ components: description: Optional market tickers associated with the error items: type: string + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' listSubscriptionsCommandPayload: type: object @@ -2759,6 +2813,8 @@ components: $ref: '#/components/schemas/subscriptionId' # Channel message payloads + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' cfbenchmarksAvgData: type: object description: Windowed-average metadata for a CF Benchmarks index value. @@ -2815,6 +2871,8 @@ components: The accumulation window is `(quarter_close_ts_ms - 60000, quarter_close_ts_ms]` (start boundary tick excluded, close tick included), producing second-indexed counts up to 60 at close. Omitted outside that final-minute window. + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' cfbenchmarksIndexListPayload: type: object @@ -2838,6 +2896,8 @@ components: description: Available CF Benchmarks index IDs items: type: string + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' cfbenchmarksValue5HzPayload: type: object @@ -2869,6 +2929,8 @@ components: data: type: string description: The raw CF Benchmarks JSON frame, as a string + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' cfbenchmarks5HzIndexListPayload: type: object @@ -2892,6 +2954,8 @@ components: description: Index IDs recently observed on the 5Hz stream items: type: string + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' pythValuePayload: type: object @@ -2920,6 +2984,8 @@ components: received_at: type: integer description: When Kalshi received the Pyth update (unix ms) + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' pythUnderlyingListPayload: type: object @@ -2943,6 +3009,8 @@ components: description: Underlying tickers observed on the Pyth stream in the last two hours items: type: string + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' orderbookSnapshotPayload: type: object @@ -2990,6 +3058,8 @@ components: type: string minItems: 2 maxItems: 2 + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' orderbookDeltaPayload: type: object @@ -3026,7 +3096,7 @@ components: ts: type: string deprecated: true - description: Deprecated - Optional timestamp for when the orderbook change was recorded (RFC3339). Use ts_ms instead. + description: Deprecated - Optional timestamp for when the orderbook change was recorded (RFC3339, truncated to milliseconds). Use ts_ms instead. format: date-time ts_ms: type: integer @@ -3037,6 +3107,8 @@ components: description: | Optional - Present only when you caused this orderbook change and are using subaccounts. Contains the subaccount number of your order that triggered this delta. + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' tickerPayload: type: object @@ -3099,6 +3171,8 @@ components: deprecated: true description: Deprecated - Timestamp for when the update happened (RFC3339). Use ts_ms instead. format: date-time + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' tradePayload: type: object @@ -3161,6 +3235,8 @@ components: type: integer description: Unix timestamp in milliseconds format: int64 + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' fillPayload: type: object @@ -3248,6 +3324,8 @@ components: subaccount: type: integer description: Optional subaccount number for the fill + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' marketLifecycleV2Payload: type: object @@ -3332,6 +3410,8 @@ components: step: type: string description: Tick size (minimum price increment) within this band, in dollars + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' multivariateMarketLifecyclePayload: type: object @@ -3396,6 +3476,8 @@ components: price_level_structure: $ref: '#/components/schemas/lifecyclePriceLevelStructure' description: Optional - The market price level structure on creation + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' lifecycleAdditionalMetadata: type: object @@ -3470,6 +3552,8 @@ components: yes_sub_title: type: string description: Optional - This key will ONLY exist for metadata_updated events. The updated yes subtitle for the market + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' eventLifecyclePayload: type: object @@ -3512,6 +3596,8 @@ components: strike_period: type: string description: Optional - String to indicate the strike period of the event if there is one + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' eventFeeUpdatePayload: type: object @@ -3538,6 +3624,8 @@ components: fee_multiplier_override: type: [number, "null"] description: Event fee multiplier override. `null` when the override has been cleared. + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' marketPositionPayload: type: object @@ -3579,6 +3667,8 @@ components: subaccount: type: integer description: Optional subaccount number for the position + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' orderGroupUpdatesPayload: type: object @@ -3609,6 +3699,8 @@ components: type: integer format: int64 description: Matching engine timestamp at which the event was processed, as Unix epoch milliseconds. + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' userOrderPayload: type: object @@ -3732,6 +3824,8 @@ components: subaccount_number: type: integer description: Subaccount number (0 for primary, 1-63 for subaccounts) + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' rfqCreatedPayload: type: object @@ -3794,6 +3888,8 @@ components: yes_settlement_value_dollars: type: string description: Yes settlement value in dollars for the selected leg. Omitted when unavailable. + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' rfqDeletedPayload: type: object @@ -3832,6 +3928,8 @@ components: type: string description: Timestamp when the RFQ was deleted format: date-time + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' quoteCreatedPayload: type: object @@ -3890,6 +3988,8 @@ components: description: | Optional - Present only when your side of this quote used a subaccount. Contains your own subaccount number; the counterparty's subaccount is never shared. + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' quoteAcceptedPayload: type: object @@ -3951,6 +4051,8 @@ components: description: | Optional - Present only when your side of this quote used a subaccount. Contains your own subaccount number; the counterparty's subaccount is never shared. + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' quoteExecutedPayload: type: object @@ -3998,6 +4100,9 @@ components: Optional - Present only when your side of this quote used a subaccount. Contains your own subaccount number; the counterparty's subaccount is never shared. + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' + securitySchemes: apiKey: type: apiKey diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 5773017..1d78688 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.31.0 + version: 3.32.0 description: Manually defined OpenAPI spec for endpoints being migrated to spec-first approach servers: @@ -1802,7 +1802,9 @@ paths: operationId: GetPositions summary: Get Positions description: | - Restricts the positions to those with any of following fields with non-zero values, as a comma separated list. The following values are accepted: position, total_traded. + Returns unsettled positions by default. Set `settlement_status=settled` to page through settled positions that are still in the live data set, or `settlement_status=all` for both live states. Positions already archived are available via `GET /historical/positions`. + Settled and all-state positions are paginated by event ticker to keep large histories bounded. Cursors are specific to the selected settlement status. + `count_filter` restricts positions to those with a non-zero position or total traded count. Registered partners may also use a user OAuth access token with the explicitly granted read::compliance_partner scope. tags: - portfolio @@ -1815,6 +1817,7 @@ paths: - $ref: '#/components/parameters/PositionsCursorQuery' - $ref: '#/components/parameters/PositionsLimitQuery' - $ref: '#/components/parameters/CountFilterQuery' + - $ref: '#/components/parameters/PositionSettlementStatusQuery' - $ref: '#/components/parameters/TickerQuery' - $ref: '#/components/parameters/SingleEventTickerQuery' - $ref: '#/components/parameters/SubaccountQueryDefaultPrimary' @@ -1837,7 +1840,7 @@ paths: get: operationId: GetSettlements summary: Get Settlements - description: ' Endpoint for getting the member''s settlements historical track.' + description: 'Returns settlement records still in the live data set. Archived positions are available via `GET /historical/positions`, which has different fields; this endpoint does not return archived settlement records.' tags: - portfolio security: @@ -4130,13 +4133,13 @@ paths: operationId: GetHistoricalCutoff summary: Get Historical Cutoff Timestamps description: | - Returns the cutoff timestamps that define the boundary between **live** and **historical** data. + Returns the archive's cutoff timestamps. For positions, the cutoff is the backfill horizon; visibility in the historical API waits for a buffered, whole-event handoff. ## Cutoff fields - `market_settled_ts` : Markets that **settled** before this timestamp, and their candlesticks, must be accessed via `GET /historical/markets` and `GET /historical/markets/{ticker}/candlesticks`. - `trades_created_ts` : Trades that were **filled** before this timestamp must be accessed via `GET /historical/fills`. - `orders_updated_ts` : Orders that were **canceled or fully executed** before this timestamp must be accessed via `GET /historical/orders`. Resting (active) orders are always available in `GET /portfolio/orders`. - - `market_positions_last_updated_ts` : Settled positions **archived from the live data set** before this timestamp must be accessed via `GET /historical/positions`. Unsettled positions are always available in `GET /portfolio/positions`. + - `market_positions_last_updated_ts` : Backfill horizon for settled positions, not a guaranteed archive-visibility boundary. An event moves to `GET /historical/positions` only after its buffered handoff completes. Until then, its settled positions remain available via `GET /portfolio/positions?settlement_status=settled`. Unsettled positions remain available in `GET /portfolio/positions`. tags: - historical responses: @@ -4276,7 +4279,7 @@ paths: get: operationId: GetHistoricalPositions summary: Get Historical Positions - description: ' Endpoint for getting settled market positions that have been archived to the historical database. Positions whose markets were archived before `market_positions_last_updated_ts` on `GET /historical/cutoff` are available via this endpoint. Positions are archived per whole event: a settled event''s positions move here together and are never split between this endpoint and `GET /portfolio/positions`. Unsettled positions are always available via `GET /portfolio/positions`.' + description: 'Returns settled market positions after their event completes the live-to-historical handoff. Until then, settled positions remain available via `GET /portfolio/positions?settlement_status=settled`. The `market_positions_last_updated_ts` value on `GET /historical/cutoff` is the backfill horizon, not a guarantee that every older position has moved. Unsettled positions are available via `GET /portfolio/positions`.' tags: - historical security: @@ -4609,6 +4612,15 @@ components: schema: type: string + PositionSettlementStatusQuery: + name: settlement_status + in: query + description: Return unsettled positions by default, settled positions that have not yet been archived, or all positions in the live data set. + schema: + type: string + enum: [unsettled, settled, all] + default: unsettled + OrderIdQuery: name: order_id in: query diff --git a/specs/perps_asyncapi.yaml b/specs/perps_asyncapi.yaml index 6f4e318..268484f 100644 --- a/specs/perps_asyncapi.yaml +++ b/specs/perps_asyncapi.yaml @@ -597,6 +597,7 @@ components: summary: Order group limit updated payload: type: order_group_updates + sending_ts_ms: 1700000000246 sid: 21 seq: 7 msg: @@ -606,6 +607,11 @@ components: ts_ms: 1700000000123 schemas: + sendingTimestampMs: + type: integer + format: int64 + description: Unix timestamp in milliseconds when Kalshi queued this message at the network layer. + commandId: type: integer minimum: 0 @@ -792,6 +798,8 @@ components: type: string sid: $ref: '#/components/schemas/subscriptionId' + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' unsubscribedResponsePayload: type: object @@ -806,6 +814,8 @@ components: $ref: '#/components/schemas/subscriptionId' seq: $ref: '#/components/schemas/sequenceNumber' + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' okResponsePayload: type: object @@ -833,6 +843,8 @@ components: items: type: string format: uuid + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' listSubscriptionsResponsePayload: type: object @@ -853,6 +865,8 @@ components: type: string sid: $ref: '#/components/schemas/subscriptionId' + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' errorResponsePayload: type: object @@ -893,6 +907,8 @@ components: description: Optional market tickers associated with the error items: type: string + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' marginOrderbookSnapshotPayload: type: object @@ -922,6 +938,8 @@ components: type: array items: $ref: '#/components/schemas/priceLevelDollarsCountFp' + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' marginOrderbookDeltaPayload: type: object @@ -956,6 +974,8 @@ components: description: Unix timestamp in milliseconds. subaccount: type: integer + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' marginTickerPayload: type: object @@ -1019,6 +1039,8 @@ components: type: integer format: int64 description: Unix timestamp in milliseconds. + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' marginTradePayload: type: object @@ -1050,6 +1072,8 @@ components: type: integer format: int64 description: Unix timestamp in milliseconds. + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' marginFillPayload: type: object @@ -1098,6 +1122,8 @@ components: description: | `system` for liquidations and margin exit or trailing-stop triggers. `user` for every other order. + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' marginUserOrderPayload: type: object @@ -1158,6 +1184,8 @@ components: description: | `system` for liquidations and margin exit or trailing-stop triggers. `user` for every other order. + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' orderGroupUpdatesPayload: type: object @@ -1189,6 +1217,9 @@ components: format: int64 description: Matching engine timestamp at which the event was processed, as Unix epoch milliseconds. + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' + securitySchemes: apiKey: type: apiKey diff --git a/specs/perps_openapi.yaml b/specs/perps_openapi.yaml index 85a597a..092ab92 100644 --- a/specs/perps_openapi.yaml +++ b/specs/perps_openapi.yaml @@ -1153,6 +1153,8 @@ paths: summary: Get Funding Rate Estimate description: | Returns the estimated funding rate for the current, in-progress funding period. The value is a time-weighted average of the premium index computed over `[last_funding_time, now)`, so it continues to move as new data accumulates through the window and is only finalized at `next_funding_time`. + + The returned `premium_index` is the premium for the final second evaluated by this estimate, captured from the same calculation inputs. Its observation timestamp can lag the computation time when input feeds are delayed. It is omitted when that second has no available premium. The estimate remains provisional until funding is finalized. tags: - funding parameters: @@ -1177,6 +1179,55 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /margin/funding_rates/premium_index: + get: + operationId: GetMarginPremiumIndex + summary: Get Premium Index + description: | + Returns an informational per-second premium index for a market, for a window of up to one hour. Each point is the signed fraction by which the impact-price book sat above or below the underlying index at that second, before any time weighting. + + **These values are for informational purposes only and may not exactly match the premium-index values used in actual funding calculations.** + + A second with no measurable premium reports `0` — the book was halted, the underlying was closed, or an input was unavailable. Points are only those recorded, so the response may be sparse or empty for a range that predates the retention period or was never recorded. + + tags: + - funding + parameters: + - name: ticker + in: query + required: true + description: Market ticker + schema: + type: string + x-go-type-skip-optional-pointer: true + x-oapi-codegen-extra-tags: + validate: required + - name: start_ts + in: query + required: true + description: Start of the window, inclusive (Unix timestamp in seconds). + schema: + type: integer + format: int64 + - name: end_ts + in: query + required: true + description: End of the window, exclusive (Unix timestamp in seconds). Must be after start_ts and no more than one hour later. + schema: + type: integer + format: int64 + responses: + '200': + description: Premium index points retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginPremiumIndexResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '500': + $ref: '#/components/responses/InternalServerError' + /portfolio/intra_exchange_instance_transfer: post: operationId: IntraExchangeInstanceTransfer @@ -3537,6 +3588,38 @@ components: type: string format: date-time description: Timestamp of the next scheduled funding event + premium_index: + type: string + description: Premium index for the final second evaluated by this funding estimate, as a signed decimal fraction before time weighting. Captured with the estimate from the same calculation inputs. Omitted when that second has no available premium. + premium_index_ts: + type: string + format: date-time + description: Timestamp of the final second evaluated by the funding estimate. Can precede computed_time when input feeds are delayed. + + MarginPremiumIndexPoint: + type: object + required: + - second_ts + - premium_index + properties: + second_ts: + type: string + format: date-time + description: The one-second bucket this point measures + premium_index: + type: string + description: Signed decimal fraction of the index price, not basis points. For informational purposes only; may not exactly match the premium index used in actual funding calculations. "0" when no premium was measurable for that second. + + GetMarginPremiumIndexResponse: + type: object + required: + - points + properties: + points: + type: array + items: + $ref: '#/components/schemas/MarginPremiumIndexPoint' + description: Per-second premium index points, ascending by second_ts GetMarginMarketCandlesticksResponse: type: object diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 458ee3c..80921fa 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -1619,6 +1619,11 @@ class Exclusion: http_method="GET", path_template="/margin/funding_rates/historical", ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.funding.FundingResource.premium_index", + http_method="GET", + path_template="/margin/funding_rates/premium_index", + ), MethodEndpointEntry( sdk_method="kalshi.perps.resources.funding.FundingResource.history", http_method="GET", diff --git a/tests/integration/test_perps_funding.py b/tests/integration/test_perps_funding.py index f3ae924..5b5775e 100644 --- a/tests/integration/test_perps_funding.py +++ b/tests/integration/test_perps_funding.py @@ -1,8 +1,9 @@ """Integration tests for the perps (margin) funding resource — live demo. -Covers ``rate_estimate`` / ``historical_rates`` / ``history``. ``rate_estimate`` -and ``historical_rates`` are public; ``history`` is auth-gated (per-user payment -history) and requires a margin-enabled demo account. +Covers ``rate_estimate`` / ``historical_rates`` / ``premium_index`` / ``history``. +``rate_estimate``, ``historical_rates``, and ``premium_index`` are public; +``history`` is auth-gated (per-user payment history) and requires a +margin-enabled demo account. Field-type note: ``funding_rate`` is a spec ``number/format: double`` and is a plain ``float`` (NOT a price ``DollarDecimal``) on every funding model. @@ -21,13 +22,14 @@ MarginFundingHistoryEntry, MarginFundingRate, MarginFundingRateEstimate, + MarginPremiumIndexPoint, ) from tests.integration.conftest import skip_if_not_margin_enabled from tests.integration.coverage_harness import register_perps register_perps( "FundingResource", - ["history", "historical_rates", "rate_estimate"], + ["history", "historical_rates", "premium_index", "rate_estimate"], ) @@ -43,6 +45,20 @@ def test_rate_estimate( assert isinstance(est.funding_rate, float) assert isinstance(est.next_funding_time, datetime) + def test_premium_index( + self, perps_sync_client: PerpsClient, perps_market_ticker: str + ) -> None: + now = int(time.time()) + points = perps_sync_client.funding.premium_index( + ticker=perps_market_ticker, + start_ts=now - 60, + end_ts=now, + ) + assert isinstance(points, list) + for point in points: + assert isinstance(point, MarginPremiumIndexPoint) + assert isinstance(point.second_ts, datetime) + def test_historical_rates(self, perps_sync_client: PerpsClient) -> None: rates = perps_sync_client.funding.historical_rates() assert isinstance(rates, list) @@ -79,6 +95,21 @@ async def test_rate_estimate(self, perps_async_client: AsyncPerpsClient) -> None assert isinstance(est.funding_rate, float) assert isinstance(est.next_funding_time, datetime) + async def test_premium_index(self, perps_async_client: AsyncPerpsClient) -> None: + markets = await perps_async_client.markets.list() + if not markets: + pytest.skip("No margin markets on demo server") + now = int(time.time()) + points = await perps_async_client.funding.premium_index( + ticker=markets[0].ticker, + start_ts=now - 60, + end_ts=now, + ) + assert isinstance(points, list) + for point in points: + assert isinstance(point, MarginPremiumIndexPoint) + assert isinstance(point.second_ts, datetime) + async def test_historical_rates( self, perps_async_client: AsyncPerpsClient ) -> None: diff --git a/tests/perps/test_funding.py b/tests/perps/test_funding.py index 5bd743e..449ece1 100644 --- a/tests/perps/test_funding.py +++ b/tests/perps/test_funding.py @@ -1,7 +1,8 @@ """Tests for the perps funding resource (#395). -Covers ``rate_estimate``, ``historical_rates``, and ``history`` (sync + async): -happy path, edge cases, error mapping, and the auth gate on ``history``. +Covers ``rate_estimate``, ``historical_rates``, ``premium_index``, and ``history`` +(sync + async): happy path, edge cases, error mapping, and the auth gate on +``history``. """ from __future__ import annotations @@ -24,6 +25,7 @@ from kalshi.perps.models.funding import ( MarginFundingHistoryEntry, MarginFundingRate, + MarginPremiumIndexPoint, ) BASE = "https://external-api.demo.kalshi.co/trade-api/v2" @@ -44,6 +46,8 @@ def test_happy(self, perps_client: PerpsClient) -> None: "funding_rate": 0.000125, "mark_price_dollars": "65000.50", "next_funding_time": "2026-06-04T16:00:00Z", + "premium_index": "0.000125", + "premium_index_ts": "2026-06-04T11:59:59Z", }, ) ) @@ -55,6 +59,9 @@ def test_happy(self, perps_client: PerpsClient) -> None: assert est.funding_rate == Decimal("0.000125") assert isinstance(est.next_funding_time, datetime) assert est.next_funding_time.tzinfo is not None + assert est.premium_index == Decimal("0.000125") + assert isinstance(est.premium_index_ts, datetime) + assert est.premium_index_ts.tzinfo is not None assert est.market_ticker == "BTC-PERP" # ticker propagated to the query string. assert route.calls.last.request.url.params["ticker"] == "BTC-PERP" @@ -71,6 +78,8 @@ def test_edge_only_required_field(self, perps_client: PerpsClient) -> None: assert est.computed_time is None assert est.funding_rate is None assert est.mark_price is None + assert est.premium_index is None + assert est.premium_index_ts is None assert isinstance(est.next_funding_time, datetime) @respx.mock @@ -107,6 +116,124 @@ async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: await async_perps_client.close() +# ── premium_index ───────────────────────────────────────────────────────────── + + +class TestPremiumIndex: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/funding_rates/premium_index").mock( + return_value=httpx.Response( + 200, + json={ + "points": [ + { + "second_ts": "2026-06-04T12:00:00Z", + "premium_index": "0.000125", + }, + { + "second_ts": "2026-06-04T12:00:01Z", + "premium_index": "0", + }, + ] + }, + ) + ) + points = perps_client.funding.premium_index( + ticker="BTC-PERP", start_ts=1_000, end_ts=2_000 + ) + assert len(points) == 2 + assert all(isinstance(p, MarginPremiumIndexPoint) for p in points) + assert isinstance(points[0].second_ts, datetime) + assert points[0].second_ts.tzinfo is not None + assert points[0].premium_index == Decimal("0.000125") + assert points[1].premium_index == Decimal("0") + params = route.calls.last.request.url.params + assert params["ticker"] == "BTC-PERP" + assert params["start_ts"] == "1000" + assert params["end_ts"] == "2000" + + @respx.mock + def test_public_without_auth(self) -> None: + route = respx.get(f"{BASE}/margin/funding_rates/premium_index").mock( + return_value=httpx.Response(200, json={"points": []}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + assert client.funding.premium_index(ticker="BTC-PERP", start_ts=1, end_ts=2) == [] + assert route.called + client.close() + + @respx.mock + def test_edge_empty_array(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/funding_rates/premium_index").mock( + return_value=httpx.Response(200, json={"points": []}) + ) + assert ( + perps_client.funding.premium_index(ticker="BTC-PERP", start_ts=1, end_ts=2) == [] + ) + + @respx.mock + def test_missing_key_raises_but_null_tolerated(self, perps_client: PerpsClient) -> None: + # `points` is spec-required: MISSING hard-fails, NULL -> [] (NullableList). + route = respx.get(f"{BASE}/margin/funding_rates/premium_index") + route.mock(return_value=httpx.Response(200, json={})) + with pytest.raises(ValidationError): + perps_client.funding.premium_index(ticker="BTC-PERP", start_ts=1, end_ts=2) + route.mock(return_value=httpx.Response(200, json={"points": None})) + assert perps_client.funding.premium_index(ticker="BTC-PERP", start_ts=1, end_ts=2) == [] + + @respx.mock + def test_point_missing_required_raises(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/funding_rates/premium_index").mock( + return_value=httpx.Response( + 200, json={"points": [{"second_ts": "2026-06-04T12:00:00Z"}]} + ) + ) + with pytest.raises(ValidationError): + perps_client.funding.premium_index(ticker="BTC-PERP", start_ts=1, end_ts=2) + + @respx.mock + def test_error_400_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/funding_rates/premium_index").mock( + return_value=httpx.Response(400, json={"error": {"code": "bad_request"}}) + ) + with pytest.raises(KalshiValidationError): + perps_client.funding.premium_index(ticker="BTC-PERP", start_ts=1, end_ts=2) + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/funding_rates/premium_index").mock( + return_value=httpx.Response( + 200, + json={ + "points": [ + { + "second_ts": "2026-06-04T12:00:00Z", + "premium_index": "-0.0002", + } + ] + }, + ) + ) + points = await async_perps_client.funding.premium_index( + ticker="ETH-PERP", start_ts=10, end_ts=20 + ) + assert len(points) == 1 + assert points[0].premium_index == Decimal("-0.0002") + await async_perps_client.close() + + @respx.mock + async def test_async_error_500_maps(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/funding_rates/premium_index").mock( + return_value=httpx.Response(500) + ) + with pytest.raises(KalshiServerError): + await async_perps_client.funding.premium_index( + ticker="ETH-PERP", start_ts=10, end_ts=20 + ) + await async_perps_client.close() + + # ── historical_rates ────────────────────────────────────────────────────────── diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index 04ab23b..f219e8c 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -237,20 +237,24 @@ def test_pagination_cursor(self, portfolio: PortfolioResource) -> None: assert route.calls[0].request.url.params["limit"] == "10" assert resp.has_next is False # empty cursor string - def test_settlement_status_kwarg_removed(self, portfolio: PortfolioResource) -> None: - """Regression: v0.7.0 dropped phantom settlement_status kwarg. + @respx.mock + def test_settlement_status_forwarded(self, portfolio: PortfolioResource) -> None: + """OpenAPI 3.32.0: settlement_status is a real /portfolio/positions query param. - It is NOT a valid /portfolio/positions param per spec lines 1055-1090 - (only /fcm/positions has it). NO direct replacement: count_filter is - a different filter (non-zero numeric fields, not settlement state). - Migration: filter client-side, OR use /fcm/positions if FCM member. + The kwarg was a phantom removed in v0.7.0. The spec now accepts + unsettled (server default), settled, or all. """ - with pytest.raises(TypeError, match="settlement_status"): - portfolio.positions(settlement_status="unsettled") # type: ignore[call-arg] + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/positions").mock( + return_value=httpx.Response( + 200, json={"market_positions": [], "event_positions": [], "cursor": ""} + ) + ) + portfolio.positions(settlement_status="unsettled") + assert route.calls[0].request.url.params["settlement_status"] == "unsettled" @respx.mock def test_positions_with_all_new_filters(self, portfolio: PortfolioResource) -> None: - """v0.7.0 ADDs: count_filter, ticker, subaccount.""" + """v0.7.0 ADDs: count_filter, ticker, subaccount. v17.1.0: settlement_status.""" route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/positions").mock( return_value=httpx.Response( 200, json={"market_positions": [], "event_positions": [], "cursor": ""} @@ -260,6 +264,7 @@ def test_positions_with_all_new_filters(self, portfolio: PortfolioResource) -> N limit=50, cursor="abc", count_filter="position", + settlement_status="settled", ticker="MKT-A", event_ticker="EVT-X", subaccount=7, @@ -269,6 +274,7 @@ def test_positions_with_all_new_filters(self, portfolio: PortfolioResource) -> N assert params["limit"] == "50" assert params["cursor"] == "abc" assert params["count_filter"] == "position" + assert params["settlement_status"] == "settled" assert params["ticker"] == "MKT-A" assert params["event_ticker"] == "EVT-X" assert params["subaccount"] == "7" @@ -317,6 +323,7 @@ def test_positions_all_forwards_filters_and_omits_cursor( portfolio.positions_all( limit=100, count_filter="position", + settlement_status="all", ticker="MKT-A", event_ticker="EVT-X", subaccount=3, @@ -326,6 +333,7 @@ def test_positions_all_forwards_filters_and_omits_cursor( params = dict(route.calls[0].request.url.params) assert params["limit"] == "100" assert params["count_filter"] == "position" + assert params["settlement_status"] == "all" assert params["ticker"] == "MKT-A" assert params["event_ticker"] == "EVT-X" assert params["subaccount"] == "3" @@ -785,20 +793,26 @@ async def test_empty_positions(self, async_portfolio: AsyncPortfolioResource) -> assert resp.market_positions == [] assert resp.has_next is False + @respx.mock @pytest.mark.asyncio - async def test_settlement_status_kwarg_removed( + async def test_settlement_status_forwarded( self, async_portfolio: AsyncPortfolioResource ) -> None: - """Regression: v0.7.0 dropped phantom settlement_status kwarg.""" - with pytest.raises(TypeError, match="settlement_status"): - await async_portfolio.positions(settlement_status="unsettled") # type: ignore[call-arg] + """OpenAPI 3.32.0: async positions forwards settlement_status.""" + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/positions").mock( + return_value=httpx.Response( + 200, json={"market_positions": [], "event_positions": [], "cursor": ""} + ) + ) + await async_portfolio.positions(settlement_status="unsettled") + assert route.calls[0].request.url.params["settlement_status"] == "unsettled" @respx.mock @pytest.mark.asyncio async def test_positions_with_all_new_filters( self, async_portfolio: AsyncPortfolioResource ) -> None: - """v0.7.0 ADDs: count_filter, ticker, subaccount.""" + """v0.7.0 ADDs: count_filter, ticker, subaccount. v17.1.0: settlement_status.""" route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/positions").mock( return_value=httpx.Response( 200, json={"market_positions": [], "event_positions": [], "cursor": ""} @@ -808,6 +822,7 @@ async def test_positions_with_all_new_filters( limit=50, cursor="abc", count_filter="position", + settlement_status="settled", ticker="MKT-A", event_ticker="EVT-X", subaccount=7, @@ -817,6 +832,7 @@ async def test_positions_with_all_new_filters( assert params["limit"] == "50" assert params["cursor"] == "abc" assert params["count_filter"] == "position" + assert params["settlement_status"] == "settled" assert params["ticker"] == "MKT-A" assert params["event_ticker"] == "EVT-X" assert params["subaccount"] == "7" @@ -1142,6 +1158,21 @@ async def test_positions_all_paginates(self, async_portfolio: AsyncPortfolioReso tickers = [p.ticker async for p in async_portfolio.positions_all()] assert tickers == ["A", "B"] + @pytest.mark.asyncio + @respx.mock + async def test_positions_all_forwards_settlement_status( + self, async_portfolio: AsyncPortfolioResource + ) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/positions").mock( + return_value=httpx.Response( + 200, json={"market_positions": [], "event_positions": [], "cursor": ""} + ) + ) + async for _ in async_portfolio.positions_all(settlement_status="settled"): + pass + assert route.calls[0].request.url.params["settlement_status"] == "settled" + assert "cursor" not in route.calls[0].request.url.params + @pytest.mark.asyncio async def test_positions_all_requires_auth( self, unauth_async_portfolio: AsyncPortfolioResource