diff --git a/CHANGELOG.md b/CHANGELOG.md index afe78ff9..516ac025 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ All notable changes to kalshi-sdk will be documented in this file. +## 7.3.0 — 2026-07-23 + +Syncs the upstream core OpenAPI **3.25.0 → 3.26.0** and closes the remaining +Klear settlement additive-optional drift (Closes #484). Additive only — no +breaking public-API changes. + +### Added + +- **`historical.positions()` / `positions_all()`** (sync + async) — + `GET /historical/positions`. Settled market positions archived to the + historical database (positions whose markets were archived before + `market_positions_last_updated_ts` on `GET /historical/cutoff`). Returns + `PositionsResponse` (same shape as `portfolio.positions()`). Query params + are a subset of the live portfolio surface: `limit`, `cursor`, `ticker`, + `event_ticker` (no `count_filter` / `subaccount`). Auth required. + `positions_all()` auto-paginates `market_positions` (mirrors + `portfolio.positions_all()`). +- **`HistoricalCutoff.market_positions_last_updated_ts`** + (`AwareDatetime | None`) — archival boundary for historical positions. + Unsettled positions remain on `GET /portfolio/positions`. +- **`GetSettlementEstimateResponse.omitted_subtrader_count`** and + **`AssetClassSettlementEstimate.omitted_subtrader_count`** (Klear SCM; + `int | None`) — number of subtraders omitted from `subtrader_breakdowns` + (their amounts remain included in `user_breakdown`). + +### Spec notes + +- Core OpenAPI `info.version` **3.25.0 → 3.26.0** (paths 91→92, 103 operations / + 102 mapped). Still unimplemented: + `POST /portfolio/intra_exchange_instance_transfer` (use + `PerpsClient.transfers.transfer_instance()` on the margin product). +- Perps OpenAPI / AsyncAPI / SCM specs unchanged in content for this release + beyond the Klear field already present on the SCM settlement schemas. + ## 7.2.0 — 2026-07-22 Reconciles upstream OpenAPI/perps OpenAPI drift under version string **3.25.0** diff --git a/CLAUDE.md b/CLAUDE.md index fd278b98..a6c536c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,7 @@ tests/ ## API Reference -- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.25.0, 102 operations; 101 mapped in the core SDK — `POST /portfolio/intra_exchange_instance_transfer` is currently not available upstream) +- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.26.0, 103 operations; 102 mapped in the core SDK — `POST /portfolio/intra_exchange_instance_transfer` is currently not available upstream) - AsyncAPI spec: https://docs.kalshi.com/asyncapi.yaml (13 WebSocket channels) - Base URL: https://api.elections.kalshi.com/trade-api/v2 - Demo URL: https://demo-api.kalshi.co/trade-api/v2 diff --git a/README.md b/README.md index d41f505a..53b36ac7 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) predi [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Type checked: mypy strict](https://img.shields.io/badge/mypy-strict-blue.svg)](https://mypy.readthedocs.io/) -- **Full coverage** of the Kalshi REST API (101 operations across 19 resources, OpenAPI v3.25.0) and WebSocket API (12 typed `subscribe_*` channels + 2 escape-hatch). +- **Full coverage** of the Kalshi REST API (102 operations across 19 resources, OpenAPI v3.26.0) and WebSocket API (12 typed `subscribe_*` channels + 2 escape-hatch). - **Perps (margin) API**: standalone `PerpsClient` / `AsyncPerpsClient` + `PerpsWebSocket` for the perpetual-futures exchange (34 REST operations, 6 WS channels), plus a `KlearClient` for the Self-Clearing-Member "Klear" settlement API (11 operations). See [Perps (margin) trading](#perps-margin-trading). - **FIX protocol**: an async-first FIX engine (FIXT.1.1 / FIX50SP2) for both products — order-entry, drop-copy, market-data, post-trade (prediction), and RFQ (prediction) sessions (plus order-group management over the order-entry session) with typed message models, sequence recovery, and order-book / settlement reassembly. `from kalshi import FixClient` / `MarginFixClient`. See [FIX protocol](#fix-protocol-low-latency-trading). - **V2 event-market orders**: `create_v2` / `amend_v2` / `decrease_v2` / `cancel_v2` plus batched variants on `/portfolio/events/orders/*` — the only order-write surface. diff --git a/ROADMAP.md b/ROADMAP.md index 26f17d46..184442ed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,10 @@ ## Shipped +- **v7.3.0 (2026-07-23)** — OpenAPI sync 3.25.0 → 3.26.0 (#484). Additive: + `historical.positions()` / `positions_all()` (`GET /historical/positions`), + `HistoricalCutoff.market_positions_last_updated_ts`, and Klear + `omitted_subtrader_count` on settlement-estimate responses. - **v7.2.0 (2026-07-22)** — Spec-drift reconcile under OpenAPI 3.25.0 (#481 / #482 / #483). Soft-deprecate perps `orders.list_fcm` / `list_all_fcm` (upstream removed `GET /margin/fcm/orders`). diff --git a/docs/index.md b/docs/index.md index e65a6827..5fe11057 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,7 +3,7 @@ A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) prediction markets API. -- **Full REST coverage** — 101 operations across 19 resources (OpenAPI v3.25.0), +- **Full REST coverage** — 102 operations across 19 resources (OpenAPI v3.26.0), every kwarg drift-tested against the spec. - **V2 event-market orders** — new `create_v2` / `amend_v2` / `decrease_v2` / `cancel_v2` family on `/portfolio/events/orders/*`. Legacy `/portfolio/orders` diff --git a/docs/migration.md b/docs/migration.md index 29f5c803..1a094672 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,5 +1,31 @@ # Migration +## v7.2 → v7.3.0 + +Syncs the SDK to core OpenAPI **3.26.0** and closes residual Klear settlement +additive-optional drift (Closes #484). **No breaking changes.** + +### Added + +- **`historical.positions()` / `positions_all()`** (sync + async) — + `GET /historical/positions`. Auth required. Returns `PositionsResponse` + (same shape as `portfolio.positions()`). Filters: `limit`, `cursor`, + `ticker`, `event_ticker` only — no `count_filter` / `subaccount`. +- **`HistoricalCutoff.market_positions_last_updated_ts`** + (`AwareDatetime | None`) — archival boundary for historical positions. +- **`omitted_subtrader_count`** (`int | None`) on + `GetSettlementEstimateResponse` and `AssetClassSettlementEstimate` (Klear). + +```python +co = client.historical.cutoff() +if co.market_positions_last_updated_ts is not None: + for mp in client.historical.positions_all(): + print(mp.ticker, mp.position) +``` + +See the [changelog](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/CHANGELOG.md) +for the full list. + ## v7.1 → v7.2.0 Reconciles in-place OpenAPI/perps OpenAPI edits under version string **3.25.0** diff --git a/docs/perps.md b/docs/perps.md index 923afdf0..fff7e391 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -197,6 +197,12 @@ currently-active settlement obligations (the plural sibling of the single-obliga `active_obligation()`), and `klear.margin.settlement_estimate_by_asset_class()` returns next-settlement estimates keyed by asset class. +Settlement-estimate responses also expose optional +`omitted_subtrader_count` (`int | None`, SDK v7.3.0) on +`GetSettlementEstimateResponse` and each `AssetClassSettlementEstimate` — the +number of subtraders left out of `subtrader_breakdowns` (their amounts remain +in `user_breakdown`). + Money fields on the Klear margin schemas are integer **centicents** (`1 USD = 10,000 centicents`); only the withdrawal `amount` is a fixed-point dollar string. `klear.margin.withdraw_settlement_balance(amount="500.00")` validates the amount as diff --git a/docs/resources/historical.md b/docs/resources/historical.md index 1fd0a559..05a8f2b7 100644 --- a/docs/resources/historical.md +++ b/docs/resources/historical.md @@ -15,17 +15,23 @@ analytics; live trading needs the real-time surfaces. | `trades(...)` / `trades_all(...)` | `GET /historical/trades` | no | | `fills(...)` / `fills_all(...)` | `GET /historical/fills` | **yes** | | `orders(...)` / `orders_all(...)` | `GET /historical/orders` | **yes** | +| `positions(...)` / `positions_all(...)` | `GET /historical/positions` | **yes** | ## Cutoff ```python co = client.historical.cutoff() -print(co.cutoff_ts) +print(co.market_settled_ts) # settled markets archival boundary +print(co.trades_created_ts) +print(co.orders_updated_ts) +print(co.market_positions_last_updated_ts) # v3.26.0; may be None on older payloads ``` -The "everything older than this timestamp is finalized" boundary. Use it to -decide whether to query the historical archive or the live `markets.*` / -`portfolio.*` endpoints. +Per-surface archival boundaries for the historical store. Data older than each +timestamp for that surface has been moved out of the live `markets.*` / +`portfolio.*` endpoints and must be read from the matching `historical.*` +method. Use `market_positions_last_updated_ts` to decide between +`historical.positions` and live `portfolio.positions` for settled positions. ## Historical markets @@ -68,6 +74,33 @@ for order in client.historical.orders_all(status="executed"): print(order.order_id, order.client_order_id) ``` +## Historical positions + +Auth required. Settled market positions archived to the historical database +(OpenAPI v3.26.0 / SDK v7.3.0). Positions whose markets were archived before +`market_positions_last_updated_ts` on `cutoff()` are available here; unsettled +positions remain on `portfolio.positions()`. Query params are a subset of the +live portfolio surface — no `count_filter` or `subaccount`. + +```python +resp = client.historical.positions( + ticker="KXPRES-24-DJT", + event_ticker="KXPRES-24", + limit=100, +) +for mp in resp.market_positions: + print(mp.ticker, mp.position, mp.realized_pnl) + +# Auto-paginate market_positions (event_positions are page-local aggregates; +# walk positions() page-by-page if you need the event view): +for mp in client.historical.positions_all(event_ticker="KXPRES-24"): + print(mp.ticker, mp.position) +``` + +`positions()` returns `PositionsResponse` — the same shape as +[`portfolio.positions`](portfolio.md#positions) (`market_positions`, +`event_positions`, `cursor` / `has_next`). + ## Historical candlesticks ```python diff --git a/docs/resources/index.md b/docs/resources/index.md index 237158a4..20a1d1b9 100644 --- a/docs/resources/index.md +++ b/docs/resources/index.md @@ -83,7 +83,8 @@ A few public-looking endpoints also require auth at the SDK layer: book behind auth. - `series.forecast_percentile_history()` — auth required. - `exchange.user_data_timestamp()` — auth required. -- All of `historical.fills` / `historical.orders` (user data). +- All of `historical.fills` / `historical.orders` / `historical.positions` + (user data). Use `client.is_authenticated` to branch on it explicitly: diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 5032456e..b15752bc 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -379,4 +379,4 @@ "Withdrawal", ] -__version__ = "7.2.0" +__version__ = "7.3.0" diff --git a/kalshi/models/historical.py b/kalshi/models/historical.py index 863a9152..383d09d2 100644 --- a/kalshi/models/historical.py +++ b/kalshi/models/historical.py @@ -21,6 +21,8 @@ class HistoricalCutoff(BaseModel): market_settled_ts: AwareDatetime trades_created_ts: AwareDatetime orders_updated_ts: AwareDatetime + # Spec v3.26.0 — archival boundary for GET /historical/positions. + market_positions_last_updated_ts: AwareDatetime | None = None model_config = {"extra": "allow"} diff --git a/kalshi/perps/klear/models/margin.py b/kalshi/perps/klear/models/margin.py index 8bfc4469..741eac88 100644 --- a/kalshi/perps/klear/models/margin.py +++ b/kalshi/perps/klear/models/margin.py @@ -245,12 +245,16 @@ class GetSettlementEstimateResponse(BaseModel): ``prev_settlement_prices`` (spec v3.22.0) maps market ticker → most recent settlement (mark) price in centicents; optional. + + ``omitted_subtrader_count`` is the number of subtraders omitted from + ``subtrader_breakdowns`` (their amounts remain in ``user_breakdown``). """ user_breakdown: SettlementEstimate subtrader_breakdowns: dict[str, SettlementEstimate] | None = None prev_settlement_prices: dict[str, int] | None = None settlement_balance_centicents: int + omitted_subtrader_count: int | None = None model_config = {"extra": "allow"} @@ -278,12 +282,16 @@ class AssetClassSettlementEstimate(BaseModel): + previous settlement prices) and adds ``next_runtime``, the next settlement-cycle time. Only ``next_runtime`` is spec-required; the breakdowns are optional. + + ``omitted_subtrader_count`` is the number of subtraders omitted from + ``subtrader_breakdowns`` (their amounts remain in ``user_breakdown``). """ next_runtime: AwareDatetime user_breakdown: SettlementEstimate | None = None subtrader_breakdowns: dict[str, SettlementEstimate] | None = None prev_settlement_prices: dict[str, int] | None = None + omitted_subtrader_count: int | None = None model_config = {"extra": "allow"} diff --git a/kalshi/resources/historical.py b/kalshi/resources/historical.py index 6606aa7b..7160f0bc 100644 --- a/kalshi/resources/historical.py +++ b/kalshi/resources/historical.py @@ -1,4 +1,4 @@ -"""Historical resource — cutoff, markets, fills, orders, trades.""" +"""Historical resource — cutoff, markets, fills, orders, trades, positions.""" from __future__ import annotations @@ -14,6 +14,7 @@ ) from kalshi.models.markets import Candlestick, Market from kalshi.models.orders import Fill, Order +from kalshi.models.portfolio import MarketPosition, PositionsResponse from kalshi.resources._base import ( AsyncResource, SyncResource, @@ -92,6 +93,27 @@ def _historical_trades_params( ) +def _historical_positions_params( + *, + limit: int | None, + cursor: str | None, + ticker: str | None, + event_ticker: str | None, +) -> dict[str, Any]: + """Query params for GET /historical/positions. + + Spec params are a subset of /portfolio/positions (no ``count_filter``, + no ``subaccount``). + """ + limit = _validate_limit(limit, hi=1000) + return _params( + limit=limit, + cursor=cursor, + ticker=ticker, + event_ticker=event_ticker, + ) + + class HistoricalResource(SyncResource): """Sync historical data API.""" @@ -324,6 +346,63 @@ def trades_all( extra_headers=extra_headers, ) + def positions( + self, + *, + limit: int | None = None, + cursor: str | None = None, + ticker: str | None = None, + event_ticker: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> PositionsResponse: + """Settled market positions archived to the historical database. + + Positions whose markets were archived before + ``market_positions_last_updated_ts`` on :meth:`cutoff` are available + here. Unsettled positions remain on ``GET /portfolio/positions``. + """ + self._require_auth() + params = _historical_positions_params( + limit=limit, + cursor=cursor, + ticker=ticker, + event_ticker=event_ticker, + ) + data = self._get("/historical/positions", params=params, extra_headers=extra_headers) + return PositionsResponse.model_validate(data) + + def positions_all( + self, + *, + limit: int | None = None, + ticker: str | None = None, + event_ticker: str | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Iterator[MarketPosition]: + """Auto-paginate ``/historical/positions``, yielding each ``MarketPosition``. + + Mirrors :meth:`kalshi.resources.portfolio.PortfolioResource.positions_all`. + ``event_positions`` aggregates are not iterated (page boundaries cut them + arbitrarily); use :meth:`positions` page-by-page for the event view. + """ + self._require_auth() + _validate_max_pages(max_pages) + params = _historical_positions_params( + limit=limit, + cursor=None, + ticker=ticker, + event_ticker=event_ticker, + ) + return self._list_all( + "/historical/positions", + MarketPosition, + "market_positions", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) + class AsyncHistoricalResource(AsyncResource): """Async historical data API.""" @@ -556,3 +635,62 @@ def trades_all( max_pages=max_pages, extra_headers=extra_headers, ) + + async def positions( + self, + *, + limit: int | None = None, + cursor: str | None = None, + ticker: str | None = None, + event_ticker: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> PositionsResponse: + """Settled market positions archived to the historical database. + + Positions whose markets were archived before + ``market_positions_last_updated_ts`` on :meth:`cutoff` are available + here. Unsettled positions remain on ``GET /portfolio/positions``. + """ + self._require_auth() + params = _historical_positions_params( + limit=limit, + cursor=cursor, + ticker=ticker, + event_ticker=event_ticker, + ) + data = await self._get( + "/historical/positions", params=params, extra_headers=extra_headers + ) + return PositionsResponse.model_validate(data) + + def positions_all( + self, + *, + limit: int | None = None, + ticker: str | None = None, + event_ticker: str | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> AsyncIterator[MarketPosition]: + """Auto-paginate ``/historical/positions``, yielding each ``MarketPosition``. + + Mirrors :meth:`kalshi.resources.portfolio.AsyncPortfolioResource.positions_all`. + ``event_positions`` aggregates are not iterated (page boundaries cut them + arbitrarily); use :meth:`positions` page-by-page for the event view. + """ + self._require_auth() + _validate_max_pages(max_pages) + params = _historical_positions_params( + limit=limit, + cursor=None, + ticker=ticker, + event_ticker=event_ticker, + ) + return self._list_all( + "/historical/positions", + MarketPosition, + "market_positions", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) diff --git a/pyproject.toml b/pyproject.toml index 59bf9281..435b0d3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "7.2.0" +version = "7.3.0" description = "A professional Python SDK for the Kalshi prediction markets and Perps (margin) APIs" readme = "README.md" license = { text = "MIT" } diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 1e644395..3d68fb81 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.25.0 + version: 3.26.0 description: Manually defined OpenAPI spec for endpoints being migrated to spec-first approach servers: @@ -1529,7 +1529,7 @@ paths: post: operationId: CreateSubaccount summary: Create Subaccount - description: 'Creates a new subaccount for the authenticated user. This endpoint is currently only available to institutions and market makers. Subaccounts are numbered sequentially starting from 1. Maximum 63 numbered subaccounts per user (64 including the primary account).' + description: 'Creates a new subaccount for the authenticated user. This endpoint is available to all users on the Advanced API tier and above. Subaccounts are numbered sequentially starting from 1. Maximum 63 numbered subaccounts per user (64 including the primary account).' tags: - portfolio security: @@ -3544,6 +3544,7 @@ paths: - `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`. tags: - historical responses: @@ -3669,6 +3670,36 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /historical/positions: + 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`.' + tags: + - historical + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/TickerQuery' + - $ref: '#/components/parameters/SingleEventTickerQuery' + - $ref: '#/components/parameters/LimitQuery' + - $ref: '#/components/parameters/CursorQuery' + responses: + '200': + description: Historical positions retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetPositionsResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '500': + $ref: '#/components/responses/InternalServerError' + /historical/trades: get: operationId: GetTradesHistorical @@ -4779,6 +4810,11 @@ components: format: date-time description: | Cutoff based on **order cancellation or execution time**. Orders 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: + type: string + format: date-time + description: | + Cutoff based on **position last-update time**. Settled positions archived from the live data set before this timestamp are served through the historical section of position reads. GetUserDataTimestampResponse: type: object diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 12d8e652..8dbc955d 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -351,6 +351,16 @@ class Exclusion: http_method="GET", path_template="/historical/trades", ), + MethodEndpointEntry( + sdk_method="kalshi.resources.historical.HistoricalResource.positions", + http_method="GET", + path_template="/historical/positions", + ), + MethodEndpointEntry( + sdk_method="kalshi.resources.historical.HistoricalResource.positions_all", + http_method="GET", + path_template="/historical/positions", + ), # ── orders ────────────────────────────────────────────────────────────── # V1 order-write endpoints (create/cancel/batch_create/batch_cancel/amend/ # decrease) were removed from the spec in v3.22.0 — only the V2 family below @@ -920,6 +930,10 @@ class Exclusion: reason="paginator-handled; not a caller-facing kwarg on list_all", kind="paginator_handled", ), + ("kalshi.resources.historical.HistoricalResource.positions_all", "cursor"): Exclusion( + reason="paginator-handled; not a caller-facing kwarg on list_all", + kind="paginator_handled", + ), ("kalshi.resources.orders.OrdersResource.list_all", "cursor"): Exclusion( reason="paginator-handled; not a caller-facing kwarg on list_all", kind="paginator_handled", @@ -1234,6 +1248,7 @@ class Exclusion: "kalshi.resources.historical.HistoricalResource.fills_all", "kalshi.resources.historical.HistoricalResource.orders_all", "kalshi.resources.historical.HistoricalResource.trades_all", + "kalshi.resources.historical.HistoricalResource.positions_all", "kalshi.resources.orders.OrdersResource.list_all", "kalshi.resources.orders.OrdersResource.fills_all", "kalshi.resources.communications.CommunicationsResource.list_all_rfqs", @@ -1264,6 +1279,7 @@ class Exclusion: "kalshi.resources.historical.AsyncHistoricalResource.fills_all", "kalshi.resources.historical.AsyncHistoricalResource.orders_all", "kalshi.resources.historical.AsyncHistoricalResource.trades_all", + "kalshi.resources.historical.AsyncHistoricalResource.positions_all", "kalshi.resources.orders.AsyncOrdersResource.list_all", "kalshi.resources.orders.AsyncOrdersResource.fills_all", "kalshi.resources.communications.AsyncCommunicationsResource.list_all_rfqs", diff --git a/tests/integration/test_historical.py b/tests/integration/test_historical.py index e7a13b8a..d980126f 100644 --- a/tests/integration/test_historical.py +++ b/tests/integration/test_historical.py @@ -10,6 +10,7 @@ from kalshi.models.historical import HistoricalCutoff, Trade from kalshi.models.markets import Candlestick, Market from kalshi.models.orders import Fill, Order +from kalshi.models.portfolio import MarketPosition, PositionsResponse from tests.integration.assertions import assert_model_fields from tests.integration.coverage_harness import register @@ -25,6 +26,8 @@ "markets_all", "orders", "orders_all", + "positions", + "positions_all", "trades", "trades_all", ], @@ -126,6 +129,21 @@ def test_trades_all(self, sync_client: KalshiClient) -> None: if count >= 2: break + def test_positions(self, sync_client: KalshiClient) -> None: + result = sync_client.historical.positions(limit=5) + assert isinstance(result, PositionsResponse) + for item in result.market_positions: + assert isinstance(item, MarketPosition) + assert_model_fields(item) + + def test_positions_all(self, sync_client: KalshiClient) -> None: + for count, position in enumerate(sync_client.historical.positions_all(limit=2)): + assert isinstance(position, MarketPosition) + assert_model_fields(position) + + if count >= 2: + break + @pytest.mark.integration class TestHistoricalAsync: @@ -223,3 +241,19 @@ async def test_trades_all(self, async_client: AsyncKalshiClient) -> None: count += 1 if count >= 3: break + + async def test_positions(self, async_client: AsyncKalshiClient) -> None: + result = await async_client.historical.positions(limit=5) + assert isinstance(result, PositionsResponse) + for item in result.market_positions: + assert isinstance(item, MarketPosition) + assert_model_fields(item) + + async def test_positions_all(self, async_client: AsyncKalshiClient) -> None: + count = 0 + async for position in async_client.historical.positions_all(limit=2): + assert isinstance(position, MarketPosition) + assert_model_fields(position) + count += 1 + if count >= 3: + break diff --git a/tests/perps/klear/test_margin.py b/tests/perps/klear/test_margin.py index 23a6373f..e4b200cc 100644 --- a/tests/perps/klear/test_margin.py +++ b/tests/perps/klear/test_margin.py @@ -330,6 +330,7 @@ def test_happy(self, auth_klear_client: KlearClient) -> None: "user_breakdown": _estimate(), "subtrader_breakdowns": {"st1": _estimate()}, "prev_settlement_prices": {"BTC-PERP": 5000}, + "omitted_subtrader_count": 2, } }, "settlement_balance_centicents": 123456, @@ -345,6 +346,7 @@ def test_happy(self, auth_klear_client: KlearClient) -> None: assert crypto.subtrader_breakdowns is not None assert crypto.subtrader_breakdowns["st1"].variation_margin_centicents == 1000 assert crypto.prev_settlement_prices == {"BTC-PERP": 5000} + assert crypto.omitted_subtrader_count == 2 auth_klear_client.close() @respx.mock @@ -363,6 +365,7 @@ def test_optional_breakdowns_omitted(self, auth_klear_client: KlearClient) -> No assert crypto.user_breakdown is None assert crypto.subtrader_breakdowns is None assert crypto.prev_settlement_prices is None + assert crypto.omitted_subtrader_count is None auth_klear_client.close() @respx.mock @@ -461,6 +464,7 @@ def test_happy_with_subtrader_breakdowns(self, auth_klear_client: KlearClient) - "user_breakdown": _estimate(), "subtrader_breakdowns": {"st1": _estimate(), "st2": _estimate()}, "settlement_balance_centicents": 50000, + "omitted_subtrader_count": 3, }, ) ) @@ -470,6 +474,7 @@ def test_happy_with_subtrader_breakdowns(self, auth_klear_client: KlearClient) - assert isinstance(resp.settlement_balance_centicents, int) assert set(resp.subtrader_breakdowns or {}) == {"st1", "st2"} assert isinstance(resp.subtrader_breakdowns["st1"], SettlementEstimate) # type: ignore[index] + assert resp.omitted_subtrader_count == 3 auth_klear_client.close() @respx.mock diff --git a/tests/test_historical.py b/tests/test_historical.py index 74200a76..dfbc3b96 100644 --- a/tests/test_historical.py +++ b/tests/test_historical.py @@ -11,9 +11,17 @@ from kalshi._base_client import AsyncTransport, SyncTransport from kalshi.auth import KalshiAuth from kalshi.config import KalshiConfig -from kalshi.errors import KalshiNotFoundError +from kalshi.errors import AuthRequiredError, KalshiNotFoundError from kalshi.resources.historical import AsyncHistoricalResource, HistoricalResource -from tests._model_fixtures import candlestick_dict, fill_dict, market_dict, order_dict, trade_dict +from tests._model_fixtures import ( + candlestick_dict, + event_position_dict, + fill_dict, + market_dict, + market_position_dict, + order_dict, + trade_dict, +) @pytest.fixture @@ -35,6 +43,16 @@ def async_historical(test_auth: KalshiAuth, config: KalshiConfig) -> AsyncHistor return AsyncHistoricalResource(AsyncTransport(test_auth, config)) +@pytest.fixture +def unauth_historical(config: KalshiConfig) -> HistoricalResource: + return HistoricalResource(SyncTransport(None, config)) + + +@pytest.fixture +def unauth_async_historical(config: KalshiConfig) -> AsyncHistoricalResource: + return AsyncHistoricalResource(AsyncTransport(None, config)) + + BASE = "https://test.kalshi.com/trade-api/v2" @@ -58,6 +76,27 @@ def test_returns_cutoff(self, historical: HistoricalResource) -> None: assert cutoff.market_settled_ts is not None assert cutoff.trades_created_ts is not None assert cutoff.orders_updated_ts is not None + assert cutoff.market_positions_last_updated_ts is None + + @respx.mock + def test_returns_cutoff_with_market_positions_ts( + self, historical: HistoricalResource + ) -> None: + """Spec v3.26.0: optional market_positions_last_updated_ts archival boundary.""" + respx.get(f"{BASE}/historical/cutoff").mock( + return_value=httpx.Response( + 200, + json={ + "market_settled_ts": "2026-04-01T00:00:00Z", + "trades_created_ts": "2026-04-01T00:00:00Z", + "orders_updated_ts": "2026-04-01T00:00:00Z", + "market_positions_last_updated_ts": "2026-03-15T12:00:00Z", + }, + ) + ) + cutoff = historical.cutoff() + assert cutoff.market_positions_last_updated_ts is not None + assert cutoff.market_positions_last_updated_ts.year == 2026 class TestHistoricalMarkets: @@ -766,3 +805,230 @@ async def test_orders_with_max_ts(self, async_historical: AsyncHistoricalResourc params = dict(route.calls[0].request.url.params) assert params["ticker"] == "MKT-A" assert params["max_ts"] == "1700099999" + + +# ── Historical positions (spec v3.26.0 / #484) ───────────────────────────── + + +class TestHistoricalPositions: + @respx.mock + def test_returns_positions(self, historical: HistoricalResource) -> None: + respx.get(f"{BASE}/historical/positions").mock( + return_value=httpx.Response( + 200, + json={ + "market_positions": [ + market_position_dict( + ticker="MKT-A", + total_traded_dollars="100.0000", + position_fp="50.00", + market_exposure_dollars="25.0000", + realized_pnl_dollars="10.0000", + fees_paid_dollars="1.5000", + resting_orders_count=0, + ) + ], + "event_positions": [ + event_position_dict( + event_ticker="EVT-1", + total_cost_dollars="200.0000", + total_cost_shares_fp="100.00", + event_exposure_dollars="50.0000", + realized_pnl_dollars="20.0000", + fees_paid_dollars="3.0000", + ) + ], + "cursor": "next-page", + }, + ) + ) + resp = historical.positions() + assert len(resp.market_positions) == 1 + assert resp.market_positions[0].ticker == "MKT-A" + assert resp.market_positions[0].position == Decimal("50.00") + assert resp.market_positions[0].total_traded == Decimal("100.0000") + assert len(resp.event_positions) == 1 + assert resp.event_positions[0].event_ticker == "EVT-1" + assert resp.has_next is True + assert resp.cursor == "next-page" + + @respx.mock + def test_empty_positions(self, historical: HistoricalResource) -> None: + respx.get(f"{BASE}/historical/positions").mock( + return_value=httpx.Response( + 200, + json={"market_positions": [], "event_positions": []}, + ) + ) + resp = historical.positions() + assert resp.market_positions == [] + assert resp.event_positions == [] + assert resp.has_next is False + + @respx.mock + def test_positions_with_filters(self, historical: HistoricalResource) -> None: + route = respx.get(f"{BASE}/historical/positions").mock( + return_value=httpx.Response( + 200, json={"market_positions": [], "event_positions": [], "cursor": ""} + ) + ) + historical.positions( + limit=50, + cursor="abc", + ticker="MKT-A", + event_ticker="EVT-X", + ) + params = dict(route.calls[0].request.url.params) + assert params["limit"] == "50" + assert params["cursor"] == "abc" + assert params["ticker"] == "MKT-A" + assert params["event_ticker"] == "EVT-X" + # Historical positions do not accept portfolio-only params. + assert "count_filter" not in params + assert "subaccount" not in params + + def test_positions_requires_auth(self, unauth_historical: HistoricalResource) -> None: + with pytest.raises(AuthRequiredError): + unauth_historical.positions() + + def test_count_filter_kwarg_rejected(self, historical: HistoricalResource) -> None: + """Historical positions is a subset of portfolio positions — no count_filter.""" + with pytest.raises(TypeError, match="count_filter"): + historical.positions(count_filter="position") # type: ignore[call-arg] + + +class TestHistoricalPositionsAll: + @respx.mock + def test_positions_all_paginates(self, historical: HistoricalResource) -> None: + respx.get(f"{BASE}/historical/positions").mock( + side_effect=[ + httpx.Response( + 200, + json={ + "market_positions": [ + market_position_dict(ticker="A"), + market_position_dict(ticker="B"), + ], + "event_positions": [], + "cursor": "page2", + }, + ), + httpx.Response( + 200, + json={ + "market_positions": [market_position_dict(ticker="C")], + "event_positions": [], + "cursor": "", + }, + ), + ] + ) + tickers = [p.ticker for p in historical.positions_all()] + assert tickers == ["A", "B", "C"] + + @respx.mock + def test_positions_all_forwards_filters_and_omits_cursor( + self, historical: HistoricalResource + ) -> None: + route = respx.get(f"{BASE}/historical/positions").mock( + return_value=httpx.Response( + 200, json={"market_positions": [], "event_positions": [], "cursor": ""} + ) + ) + list( + historical.positions_all( + limit=100, + ticker="MKT-A", + event_ticker="EVT-X", + ) + ) + params = dict(route.calls[0].request.url.params) + assert params["limit"] == "100" + assert params["ticker"] == "MKT-A" + assert params["event_ticker"] == "EVT-X" + assert "cursor" not in params + + def test_positions_all_requires_auth(self, unauth_historical: HistoricalResource) -> None: + with pytest.raises(AuthRequiredError): + list(unauth_historical.positions_all()) + + def test_positions_all_rejects_zero_max_pages(self, historical: HistoricalResource) -> None: + with pytest.raises(ValueError, match="max_pages must be positive"): + list(historical.positions_all(max_pages=0)) + + +class TestAsyncHistoricalPositions: + @respx.mock + @pytest.mark.asyncio + async def test_returns_positions(self, async_historical: AsyncHistoricalResource) -> None: + respx.get(f"{BASE}/historical/positions").mock( + return_value=httpx.Response( + 200, + json={ + "market_positions": [market_position_dict(ticker="MKT-A", position_fp="10.00")], + "event_positions": [], + "cursor": "", + }, + ) + ) + resp = await async_historical.positions() + assert len(resp.market_positions) == 1 + assert resp.market_positions[0].ticker == "MKT-A" + assert resp.market_positions[0].position == Decimal("10.00") + + @respx.mock + @pytest.mark.asyncio + async def test_positions_with_filters(self, async_historical: AsyncHistoricalResource) -> None: + route = respx.get(f"{BASE}/historical/positions").mock( + return_value=httpx.Response( + 200, json={"market_positions": [], "event_positions": [], "cursor": ""} + ) + ) + await async_historical.positions(limit=25, ticker="MKT-B", event_ticker="EVT-Y") + params = dict(route.calls[0].request.url.params) + assert params["limit"] == "25" + assert params["ticker"] == "MKT-B" + assert params["event_ticker"] == "EVT-Y" + + @pytest.mark.asyncio + async def test_positions_requires_auth( + self, unauth_async_historical: AsyncHistoricalResource + ) -> None: + with pytest.raises(AuthRequiredError): + await unauth_async_historical.positions() + + @respx.mock + @pytest.mark.asyncio + async def test_positions_all_paginates( + self, async_historical: AsyncHistoricalResource + ) -> None: + respx.get(f"{BASE}/historical/positions").mock( + side_effect=[ + httpx.Response( + 200, + json={ + "market_positions": [market_position_dict(ticker="A")], + "event_positions": [], + "cursor": "p2", + }, + ), + httpx.Response( + 200, + json={ + "market_positions": [market_position_dict(ticker="B")], + "event_positions": [], + "cursor": "", + }, + ), + ] + ) + tickers = [p.ticker async for p in async_historical.positions_all()] + assert tickers == ["A", "B"] + + @pytest.mark.asyncio + async def test_positions_all_requires_auth( + self, unauth_async_historical: AsyncHistoricalResource + ) -> None: + with pytest.raises(AuthRequiredError): + async for _ in unauth_async_historical.positions_all(): + pass