From 07dc32e45997e159e9113e242e6db18588c8f315 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Tue, 20 Oct 2026 09:50:44 -0500 Subject: [PATCH] feat(perps): add margin.corporate_actions (GET /margin/corporate_actions); release 22.2.0 Closes #550 --- CHANGELOG.md | 13 ++ docs/perps.md | 34 ++- kalshi/__init__.py | 2 +- kalshi/_contract_map.py | 24 ++ kalshi/perps/__init__.py | 16 ++ kalshi/perps/models/__init__.py | 16 ++ kalshi/perps/models/margin_account.py | 127 ++++++++++- kalshi/perps/resources/margin_account.py | 52 ++++- kalshi/perps/ws/client.py | 6 +- kalshi/perps/ws/models/control.py | 1 + pyproject.toml | 2 +- specs/asyncapi.yaml | 9 +- specs/openapi.yaml | 33 ++- specs/perps_asyncapi.yaml | 145 ++++++++++++- specs/perps_openapi.yaml | 163 +++++++++++++- specs/perps_scm_openapi.yaml | 4 +- tests/_contract_support.py | 5 + tests/perps/test_margin_account.py | 265 ++++++++++++++++++++++- 18 files changed, 875 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77f34de3..47bf37f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to kalshi-sdk will be documented in this file. +## 22.2.0 — 2026-10-20 + +### Added + +- Perps **`margin.corporate_actions(*, ticker=, market_id=)`** (sync + + async) — `GET /margin/corporate_actions`. Lists scheduled notices and + notices updated in the last seven days for margin markets (cash + dividends, special dividends, spin-offs, stock splits, final + settlements, ticker changes). Optional `ticker` and `market_id` + filters; omit both for all margin markets. Public market data (no + auth). Amounts are `DollarDecimal`. Cancelled notices omit terms and + `execution_time`. (Closes #550). + ## 22.1.0 — 2026-10-17 Reconciles nightly spec drift (#547). Additive and optional only — no diff --git a/docs/perps.md b/docs/perps.md index f4055791..1d000214 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -63,7 +63,7 @@ async with AsyncPerpsClient.from_env(demo=True) as perps: | `orders` | `create()`, `get()`, `list()` / `list_all()`, `cancel()`, `cancel_all()`, `decrease()`, `amend()` | | `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()` | +| `margin` | `balance()`, `risk()`, `notional_risk_limit()`, `fee_tiers()`, `fee_tier_rates()`, `api_limits()`, `corporate_actions()` | | `funding` | `rate_estimate()`, `historical_rates()`, `premium_index()`, `history()` | | `transfers` | `transfer_instance()`, `create_subaccount()`, `transfer_subaccount()` | | `fcm` | `create_subtrader(subtrader_suffix=..., require_category_cap=...)`; `risk_controls` / `update_risk_controls` / `delete_risk_controls`; `update_notional_risk_limit` / `delete_notional_risk_limit` | @@ -254,6 +254,22 @@ tier limits in the same shape as the prediction API's `client.account.limits()` (an `AccountApiLimits` with `usage_tier`, `read`/`write` token buckets, and the `grants` list of `ApiUsageLevelGrant`). +`perps.margin.corporate_actions()` (`GET /margin/corporate_actions`) lists +scheduled notices and notices updated in the last seven days. Optional +`ticker` / `market_id` filters; omit both for all margin markets. Public +market data (no auth). Scheduled, applied, and lapsed notices include +exactly one terms object matching `type` (`cash`, `split`, +`final_settlement`, or `ticker_change`). Cancelled notices omit terms and +`execution_time`. Amounts are `DollarDecimal` US dollar strings. + +```python +notices = perps.margin.corporate_actions(ticker="AAPL-PERP") +for action in notices.corporate_actions: + print(action.id, action.type, action.status) + if action.cash is not None: + print(action.cash.cash_per_contract, action.cash.ex_date) +``` + ## WebSocket streaming ```python @@ -267,12 +283,16 @@ async with ws.connect() as session: print(msg.msg.funding_rate.rate, msg.msg.funding_rate.next_funding_time_ms) ``` -Six data channels — `orderbook_delta` (snapshot + delta, sequenced), `ticker`, -`trade`, `fill`, `user_orders`, `order_group_updates`. The connection, sequence-gap -detection, reconnect, and backpressure machinery are reused from the event-contract -WS stack (see [WebSocket](websockets.md)); the perps orderbook is `bid` / `ask`. -The prediction-only channels (`market_positions`, `multivariate*`, `communications`, -`market_lifecycle_v2`, `cfbenchmarks_value`) have no perps counterpart. +Seven data channels — `orderbook_delta` (snapshot + delta, sequenced), `ticker`, +`trade`, `fill`, `user_orders`, `order_group_updates`, `corporate_actions`. The +connection, sequence-gap detection, reconnect, and backpressure machinery are +reused from the event-contract WS stack (see [WebSocket](websockets.md)); the +perps orderbook is `bid` / `ask`. The prediction-only channels +(`market_positions`, `multivariate*`, `communications`, `market_lifecycle_v2`, +`cfbenchmarks_value`) have no perps counterpart. `corporate_actions` streams +applied split / final-settlement / ticker-change notices (cash actions are +REST-only); subscribe with `session.subscribe(channel="corporate_actions")`. +Upcoming and recent notices are listed by `margin.corporate_actions()`. ## Self-Clearing-Member "Klear" API diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 560aadb6..a9eb22ce 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -431,4 +431,4 @@ "Withdrawal", ] -__version__ = "22.1.0" +__version__ = "22.2.0" diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index 9ab6c91c..eae60ca0 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -864,6 +864,30 @@ class ContractEntry: sdk_model="kalshi.perps.models.margin_account.GetMarginFeeTierRatesResponse", spec_schema="GetMarginFeeTierRatesResponse", ), + ContractEntry( + sdk_model="kalshi.perps.models.margin_account.GetMarginCorporateActionsResponse", + spec_schema="GetMarginCorporateActionsResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.models.margin_account.MarginCorporateAction", + spec_schema="MarginCorporateAction", + ), + ContractEntry( + sdk_model="kalshi.perps.models.margin_account.MarginCorporateActionCashTerms", + spec_schema="MarginCorporateActionCashTerms", + ), + ContractEntry( + sdk_model="kalshi.perps.models.margin_account.MarginCorporateActionSplitTerms", + spec_schema="MarginCorporateActionSplitTerms", + ), + ContractEntry( + sdk_model="kalshi.perps.models.margin_account.MarginCorporateActionFinalSettlementTerms", + spec_schema="MarginCorporateActionFinalSettlementTerms", + ), + ContractEntry( + sdk_model="kalshi.perps.models.margin_account.MarginCorporateActionTickerChangeTerms", + spec_schema="MarginCorporateActionTickerChangeTerms", + ), ContractEntry( # /account/limits/perps returns the same shape as the prediction API's # /account/limits, so the perps resource reuses kalshi.models.account.AccountApiLimits. diff --git a/kalshi/perps/__init__.py b/kalshi/perps/__init__.py index 1c7c10fa..cd0327bf 100644 --- a/kalshi/perps/__init__.py +++ b/kalshi/perps/__init__.py @@ -97,9 +97,17 @@ from kalshi.perps.models.margin_account import ( FeeScheduleLiteral, GetMarginBalanceResponse, + GetMarginCorporateActionsResponse, GetMarginFeeTierRatesResponse, GetMarginFeeTiersResponse, GetMarginRiskResponse, + MarginCorporateAction, + MarginCorporateActionCashTerms, + MarginCorporateActionFinalSettlementTerms, + MarginCorporateActionSplitTerms, + MarginCorporateActionStatusLiteral, + MarginCorporateActionTickerChangeTerms, + MarginCorporateActionTypeLiteral, MarginFeeTierRate, MarginRiskPosition, MarginSubaccountBalance, @@ -283,6 +291,7 @@ "GetFCMSubtraderRiskControlsResponse", "GetGuarantyFundBalanceResponse", "GetMarginBalanceResponse", + "GetMarginCorporateActionsResponse", "GetMarginFeeTierRatesResponse", "GetMarginFeeTiersResponse", "GetMarginFillsResponse", @@ -313,6 +322,13 @@ "LastUpdateReasonLiteral", "MaintenanceMarginDetail", "MarginAccountResource", + "MarginCorporateAction", + "MarginCorporateActionCashTerms", + "MarginCorporateActionFinalSettlementTerms", + "MarginCorporateActionSplitTerms", + "MarginCorporateActionStatusLiteral", + "MarginCorporateActionTickerChangeTerms", + "MarginCorporateActionTypeLiteral", "MarginEnabledResponse", "MarginFeeTierRate", "MarginFill", diff --git a/kalshi/perps/models/__init__.py b/kalshi/perps/models/__init__.py index 5e228a68..01143d66 100644 --- a/kalshi/perps/models/__init__.py +++ b/kalshi/perps/models/__init__.py @@ -43,9 +43,17 @@ from kalshi.perps.models.margin_account import ( FeeScheduleLiteral, GetMarginBalanceResponse, + GetMarginCorporateActionsResponse, GetMarginFeeTierRatesResponse, GetMarginFeeTiersResponse, GetMarginRiskResponse, + MarginCorporateAction, + MarginCorporateActionCashTerms, + MarginCorporateActionFinalSettlementTerms, + MarginCorporateActionSplitTerms, + MarginCorporateActionStatusLiteral, + MarginCorporateActionTickerChangeTerms, + MarginCorporateActionTypeLiteral, MarginFeeTierRate, MarginRiskPosition, MarginSubaccountBalance, @@ -143,6 +151,7 @@ "GetExitTriggersResponse", "GetFCMSubtraderRiskControlsResponse", "GetMarginBalanceResponse", + "GetMarginCorporateActionsResponse", "GetMarginFeeTierRatesResponse", "GetMarginFeeTiersResponse", "GetMarginFillsResponse", @@ -158,6 +167,13 @@ "IntraExchangeInstanceTransferResponse", "LastUpdateReason", "LastUpdateReasonLiteral", + "MarginCorporateAction", + "MarginCorporateActionCashTerms", + "MarginCorporateActionFinalSettlementTerms", + "MarginCorporateActionSplitTerms", + "MarginCorporateActionStatusLiteral", + "MarginCorporateActionTickerChangeTerms", + "MarginCorporateActionTypeLiteral", "MarginEnabledResponse", "MarginFeeTierRate", "MarginFill", diff --git a/kalshi/perps/models/margin_account.py b/kalshi/perps/models/margin_account.py index 1da939aa..1308ffbc 100644 --- a/kalshi/perps/models/margin_account.py +++ b/kalshi/perps/models/margin_account.py @@ -1,11 +1,10 @@ """Perps margin-account models — balance, risk, notional risk limit, fee tiers. -Response models for the four read-only direct-margin account endpoints (#394): -``GetMarginBalance``, ``GetMarginRisk``, ``GetMarginNotionalRiskLimit`` and -``GetMarginFeeTiers``. All are pure response models — every one carries -``model_config = {"extra": "allow"}`` so additive spec fields don't break -parsing, and none have request bodies (the endpoints are GETs with at most a -single query flag). +Response models for the read-only direct-margin account endpoints (#394) plus +``GetMarginCorporateActions`` (#550). All are pure response models — every one +carries ``model_config = {"extra": "allow"}`` so additive spec fields don't +break parsing, and none have request bodies (the endpoints are GETs with at +most query flags). Money fields use :data:`~kalshi.types.DollarDecimal` (the perps spec's ``FixedPointDollars`` string), and the signed position quantity uses @@ -19,9 +18,10 @@ from __future__ import annotations +from datetime import date from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field from kalshi.types import ( DollarDecimal, @@ -136,3 +136,116 @@ class GetMarginFeeTierRatesResponse(BaseModel): model_config = ConfigDict(extra="allow") fee_tier_rates: list[MarginFeeTierRate] + + +MarginCorporateActionTypeLiteral = Literal[ + "cash_dividend", + "special_dividend", + "spin_off", + "stock_split", + "final_settlement", + "ticker_change", +] +"""Spec ``MarginCorporateAction.type`` — which terms object is present.""" + + +MarginCorporateActionStatusLiteral = Literal[ + "scheduled", + "cancelled", + "applied", + "lapsed", +] +"""Spec ``MarginCorporateAction.status`` — public notice status.""" + + +class MarginCorporateActionCashTerms(BaseModel): + """Terms for ``cash_dividend``, ``special_dividend``, or ``spin_off``. + + Spec ``MarginCorporateActionCashTerms``. Omitted for other types and + cancelled notices. All properties are optional. + """ + + model_config = ConfigDict(extra="allow") + + cash_per_share: DollarDecimal | None = None + cash_per_contract: DollarDecimal | None = None + ex_date: date | None = None + + +class MarginCorporateActionSplitTerms(BaseModel): + """Terms for ``stock_split``. + + Spec ``MarginCorporateActionSplitTerms``. Omitted for other types and + cancelled notices. All properties are optional. + """ + + model_config = ConfigDict(extra="allow") + + ratio_old: str | None = None + ratio_new: str | None = None + ex_date: date | None = None + + +class MarginCorporateActionFinalSettlementTerms(BaseModel): + """Terms for ``final_settlement``. + + Spec ``MarginCorporateActionFinalSettlementTerms``. Omitted for other + types and cancelled notices. ``reason`` is spec-required when the object + is present. + """ + + model_config = ConfigDict(extra="allow") + + reason: str + settlement_price_per_share: DollarDecimal | None = None + settlement_price_per_contract: DollarDecimal | None = None + + +class MarginCorporateActionTickerChangeTerms(BaseModel): + """Terms for ``ticker_change``. + + Spec ``MarginCorporateActionTickerChangeTerms``. Omitted for other types + and cancelled notices. ``old_ticker`` and ``new_ticker`` are required + when the object is present. + """ + + model_config = ConfigDict(extra="allow") + + old_ticker: str + new_ticker: str + + +class MarginCorporateAction(BaseModel): + """Spec ``MarginCorporateAction`` — a margin corporate action notice. + + Scheduled, applied, and lapsed notices have exactly one terms object + matching ``type``. Cancelled notices omit all four terms objects and + ``execution_time``. + """ + + model_config = ConfigDict(extra="allow") + + id: str + market_id: str + market_ticker: str + type: MarginCorporateActionTypeLiteral + status: MarginCorporateActionStatusLiteral + updated_ts: AwareDatetime + execution_time: AwareDatetime | None = None + cash: MarginCorporateActionCashTerms | None = None + split: MarginCorporateActionSplitTerms | None = None + final_settlement: MarginCorporateActionFinalSettlementTerms | None = None + ticker_change: MarginCorporateActionTickerChangeTerms | None = None + + +class GetMarginCorporateActionsResponse(BaseModel): + """Spec ``GetMarginCorporateActionsResponse`` — notices envelope. + + ``corporate_actions`` is spec-required and uses + :data:`~kalshi.types.NullableList`: a missing key raises + ``ValidationError``, while a ``null`` array coerces to ``[]``. + """ + + model_config = ConfigDict(extra="allow") + + corporate_actions: NullableList[MarginCorporateAction] diff --git a/kalshi/perps/resources/margin_account.py b/kalshi/perps/resources/margin_account.py index 6e8962f0..36b3c8cd 100644 --- a/kalshi/perps/resources/margin_account.py +++ b/kalshi/perps/resources/margin_account.py @@ -1,10 +1,13 @@ """Perps margin-account resource — balance, risk, notional risk limit, fee tiers, API limits. -Read-only GET endpoints for the authenticated direct-margin user (#394). All -carry a spec ``security`` block, so each method calls ``_require_auth()`` first -— an unauthenticated caller gets ``AuthRequiredError`` client-side instead of a -server 401. None are paginated (no response carries a cursor), so there are no -``list_all()`` iterators. All retry on 429/502/503/504 (GET). +Read-only GET endpoints. ``balance`` / ``risk`` / ``notional_risk_limit`` / +``fee_tiers`` / ``fee_tier_rates`` / ``api_limits`` carry a spec ``security`` +block, so each of those methods calls ``_require_auth()`` first — an +unauthenticated caller gets ``AuthRequiredError`` client-side instead of a +server 401. ``corporate_actions`` is public market data (no spec ``security`` +block) and does not require auth. None are paginated (no response carries a +cursor), so there are no ``list_all()`` iterators. All retry on 429/502/503/504 +(GET). ``api_limits`` (``GET /account/limits/perps``) returns the Perps API tier limits in the same shape as the prediction API's :class:`~kalshi.models.account.AccountApiLimits`, @@ -16,6 +19,7 @@ from kalshi.models.account import AccountApiLimits from kalshi.perps.models.margin_account import ( GetMarginBalanceResponse, + GetMarginCorporateActionsResponse, GetMarginFeeTierRatesResponse, GetMarginFeeTiersResponse, GetMarginRiskResponse, @@ -25,7 +29,7 @@ class MarginAccountResource(SyncResource): - """Sync perps margin-account API (balance / risk / notional_risk_limit / fee_tiers).""" + """Sync perps margin-account API (balance / risk / fee tiers / corporate_actions).""" def balance( self, @@ -75,9 +79,29 @@ def api_limits(self, *, extra_headers: dict[str, str] | None = None) -> AccountA data = self._get("/account/limits/perps", extra_headers=extra_headers) return AccountApiLimits.model_validate(data) + def corporate_actions( + self, + *, + ticker: str | None = None, + market_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetMarginCorporateActionsResponse: + """Scheduled notices and notices updated in the last seven days. + + ``GET /margin/corporate_actions``. Public market data — no auth. + Optional ``ticker`` / ``market_id`` filter to one market; omit both + for notices across all margin markets. Cancelled notices omit terms + and ``execution_time``. + """ + params = _params(ticker=ticker, market_id=market_id) + data = self._get( + "/margin/corporate_actions", params=params, extra_headers=extra_headers + ) + return GetMarginCorporateActionsResponse.model_validate(data) + class AsyncMarginAccountResource(AsyncResource): - """Async perps margin-account API (balance / risk / notional_risk_limit / fee_tiers).""" + """Async perps margin-account API (balance / risk / fee tiers / corporate_actions).""" async def balance( self, @@ -128,3 +152,17 @@ async def api_limits( self._require_auth() data = await self._get("/account/limits/perps", extra_headers=extra_headers) return AccountApiLimits.model_validate(data) + + async def corporate_actions( + self, + *, + ticker: str | None = None, + market_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetMarginCorporateActionsResponse: + """Async :meth:`MarginAccountResource.corporate_actions`.""" + params = _params(ticker=ticker, market_id=market_id) + data = await self._get( + "/margin/corporate_actions", params=params, extra_headers=extra_headers + ) + return GetMarginCorporateActionsResponse.model_validate(data) diff --git a/kalshi/perps/ws/client.py b/kalshi/perps/ws/client.py index 3c261c83..a69f6191 100644 --- a/kalshi/perps/ws/client.py +++ b/kalshi/perps/ws/client.py @@ -592,11 +592,13 @@ async def subscribe( # ------------------------------------------------------------------ # Per-channel typed subscribe helpers (#398) # - # The perps AsyncAPI defines exactly six data channels — all wired below. - # The prediction-API channels ``market_positions``, + # Typed ``subscribe_*`` helpers for the perps data channels. The + # prediction-API channels ``market_positions``, # ``market_lifecycle_v2`` / ``event_fee_update``, ``multivariate``, # ``multivariate_market_lifecycle``, and ``communications`` have NO perps # counterpart and intentionally get NO ``subscribe_*`` helper here. + # ``corporate_actions`` is on :class:`PerpsChannel` and is subscribed + # via the generic :meth:`subscribe`. # # ``builtins.list[str]`` is used for ``tickers`` params: the surrounding # API has a ``list_subscriptions`` method, so the ``list`` builtin is diff --git a/kalshi/perps/ws/models/control.py b/kalshi/perps/ws/models/control.py index 2cd35ae2..5c7df43c 100644 --- a/kalshi/perps/ws/models/control.py +++ b/kalshi/perps/ws/models/control.py @@ -51,6 +51,7 @@ class PerpsChannel(StrEnum): FILL = "fill" USER_ORDERS = "user_orders" ORDER_GROUP_UPDATES = "order_group_updates" + CORPORATE_ACTIONS = "corporate_actions" class UpdateSubscriptionAction(StrEnum): diff --git a/pyproject.toml b/pyproject.toml index b3e47f70..b8e35e3c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "22.1.0" +version = "22.2.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 db02790c..896ed5c1 100644 --- a/specs/asyncapi.yaml +++ b/specs/asyncapi.yaml @@ -2346,7 +2346,6 @@ components: marketTicker: type: string description: Unique market identifier - pattern: '^[A-Z0-9-]+$' examples: - "FED-23DEC-T3.00" - "HIGHNY-22DEC23-B53.5" @@ -2368,7 +2367,7 @@ components: lastUpdateReason: type: string - description: Order update reason. ReduceOnlyCancel means reduce_only capped the order at placement. + description: Order update reason. The value ReduceOnlyCancel means reduce_only capped the order at placement. enum: ["Decrease", "Amend", "MarginCancel", "SelfTradeCancel", "ExpiryCancel", "CloseCancel", "HaltCancel", "Trade", "PostOnlyCrossCancel", "ReduceOnlyCancel", "SettlementBoundsCancel"] orderAction: @@ -3887,17 +3886,17 @@ components: created_time: type: [string, "null"] deprecated: true - description: Deprecated - Order creation time in RFC3339 format. Use created_ts_ms instead. + description: Deprecated - Order creation time in RFC3339 format, truncated to milliseconds. Use created_ts_ms instead. format: date-time last_update_time: type: string deprecated: true - description: Deprecated - Last update time in RFC3339 format. Use last_updated_ts_ms instead. + description: Deprecated - Last update time in RFC3339 format, truncated to milliseconds. Use last_updated_ts_ms instead. format: date-time expiration_time: type: string deprecated: true - description: Deprecated - Order expiration time in RFC3339 format. Use expiration_ts_ms instead. + description: Deprecated - Order expiration time in RFC3339 format, truncated to milliseconds. Use expiration_ts_ms instead. format: date-time expiration_ts_ms: type: integer diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 62e5cb72..4f6dea6b 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -1586,6 +1586,8 @@ paths: $ref: '#/components/responses/UnauthorizedError' '403': $ref: '#/components/responses/ForbiddenError' + '409': + $ref: '#/components/responses/ConflictError' '500': $ref: '#/components/responses/InternalServerError' @@ -2970,7 +2972,9 @@ paths: get: operationId: GetLiveDataByMilestone summary: Get Live Data - description: Get live data for a specific milestone. + description: >- + Get live data for a specific milestone. + The response's `live_data.details.status` is updated far more often than the milestone's own `details.status` and is the preferred source for game status. It is not guaranteed to be current: if the provider feed falls behind, a cached board may still be served, with `live_data.details.live_data_delayed` set to `true` only once it is significantly stale (some feeds never set it), so a missing flag does not by itself mean the data is current. tags: - live-data parameters: @@ -3048,7 +3052,14 @@ paths: get: operationId: GetLiveDatas summary: Get Multiple Live Data - description: Get live data for multiple milestones + description: >- + Get live data for multiple milestones. + Each entry's `details.status` is updated far more often than the milestone's + own `details.status` and is the preferred source for game status. It is not + guaranteed to be current: if the provider feed falls behind, a cached board may + still be served, with `details.live_data_delayed` set to `true` only once it is + significantly stale (some feeds never set it), so a missing flag does not by + itself mean the data is current. tags: - live-data parameters: @@ -3349,7 +3360,9 @@ paths: get: operationId: GetMilestone summary: Get Milestone - description: ' Endpoint for getting data about a specific milestone by its ID.' + description: >- + Endpoint for getting data about a specific milestone by its ID. + The milestone's `details.status`, when present, is maintained by a periodic background job. It lags the game's live state and may be stale or inaccurate, for example remaining `live` after the game ends. For game status, prefer live data, which is updated far more often: `live_data.details.status` from `GET /live_data/milestone/{milestone_id}`, or each entry's `details.status` from `GET /live_data/batch`. Scores are in the sport-specific fields of that live data `details` object. tags: - milestone parameters: @@ -3379,7 +3392,9 @@ paths: get: operationId: GetMilestones summary: Get Milestones - description: 'Minimum start date to filter milestones. Format: RFC3339 timestamp' + description: >- + Endpoint for listing milestones. + A milestone's `details.status`, when present, is maintained by a periodic background job. It lags the game's live state and may be stale or inaccurate, for example remaining `live` after the game ends. For game status, prefer live data, which is updated far more often: `live_data.details.status` from `GET /live_data/milestone/{milestone_id}`, or each entry's `details.status` from `GET /live_data/batch`. Scores are in the sport-specific fields of that live data `details` object. tags: - milestone parameters: @@ -6707,7 +6722,9 @@ components: details: type: object additionalProperties: true - description: Additional details about the milestone. + description: >- + Additional details about the milestone. + `details.status`, when present, is maintained by a periodic background job. It lags the game's live state and may be stale or inaccurate, for example remaining `live` after the game ends. For game status, prefer live data, which is updated far more often: `live_data.details.status` from `GET /live_data/milestone/{milestone_id}`, or each entry's `details.status` from `GET /live_data/batch`. Scores are in the sport-specific fields of that live data `details` object. primary_event_tickers: type: array items: @@ -7517,7 +7534,7 @@ components: type: string format: uuid description: | - Client ID for duplicate prevention. Reuse only for identical requests. Scoped to the user and both exchange instances and shards. Accepted transfers cannot be repeated with the same ID on the same route. + Client ID for duplicate prevention. Reuse only to retry the same request: if a transfer from the same source exchange instance and shard was already accepted with this ID, the request returns 409 and creates no new transfer. The returned transfer_id is always generated by Kalshi. Only supported for primary accounts. Transfers involving numbered subaccounts reject this field. If omitted, each request creates a new transfer. source: $ref: '#/components/schemas/ExchangeInstance' @@ -7592,6 +7609,10 @@ components: transfer_id: type: string description: Unique transfer id + client_transfer_id: + type: string + x-go-type-skip-optional-pointer: true + description: The client_transfer_id provided when the transfer was created. Omitted if none was provided. source: $ref: '#/components/schemas/ExchangeInstance' description: Source exchange instance diff --git a/specs/perps_asyncapi.yaml b/specs/perps_asyncapi.yaml index 29f3c478..dbd69691 100644 --- a/specs/perps_asyncapi.yaml +++ b/specs/perps_asyncapi.yaml @@ -12,6 +12,7 @@ info: - `fill` - `user_orders` - `order_group_updates` + - `corporate_actions` contact: name: Kalshi Support url: https://kalshi.com @@ -116,6 +117,26 @@ channels: orderbookDelta: $ref: '#/components/messages/orderbookDelta' + corporate_actions: + address: corporate_actions + title: Corporate Action Notices + description: | + Sent when the exchange applies a stock split, final settlement or ticker change to a + margin market. Each message has a type and one matching terms object: `split`, + `final_settlement` or `ticker_change`. Cash corporate actions are not streamed. + + Positions are not streamed over WebSocket, and an applied corporate action can change + positions and balances without a fill. Re-fetch them from `GET /trade-api/v2/margin/positions` + and `GET /trade-api/v2/margin/balance` after each message. + + Requirements: + - market specification optional via `market_ticker` or `market_tickers`; filters follow ticker changes + - no snapshot on subscribe; `GET /trade-api/v2/margin/corporate_actions` lists upcoming and recent notices + - supports `update_subscription` with `add_markets` and `delete_markets` + messages: + corporateActionsDelta: + $ref: '#/components/messages/corporateActionsDelta' + ticker: address: ticker title: Market Ticker @@ -192,6 +213,16 @@ channels: $ref: '#/components/messages/orderGroupUpdates' operations: + receiveCorporateActions: + action: send + title: Receive Corporate Action Notices + channel: + $ref: '#/channels/corporate_actions' + messages: + - $ref: '#/channels/corporate_actions/messages/corporateActionsDelta' + tags: + - name: market-data + sendPing: action: receive title: Send Ping @@ -557,6 +588,12 @@ components: contentType: application/json payload: $ref: '#/components/schemas/marginOrderbookDeltaPayload' + corporateActionsDelta: + name: corporate_actions_delta + title: Corporate Action Delta + contentType: application/json + payload: + $ref: '#/components/schemas/marginCorporateActionsDeltaPayload' ticker: name: ticker title: Ticker Update @@ -651,7 +688,7 @@ components: lastUpdateReason: type: string enum: ["Decrease", "Amend", "MarginCancel", "SelfTradeCancel", "ExpiryCancel", "CloseCancel", "HaltCancel", "Trade", "PostOnlyCrossCancel", "ReduceOnlyCancel"] - description: Margin order update reason when the delta corresponds to the authenticated user's order. CloseCancel and HaltCancel are reserved; the current orderbook stream filters out these operations. The field is omitted when no reason applies. + description: Margin order update reason. The value ReduceOnlyCancel means reduce_only capped the order at placement. The field is omitted when no reason applies. tickerPrice: type: object @@ -711,7 +748,7 @@ components: minItems: 1 items: type: string - enum: ["orderbook_delta", "ticker", "trade", "fill", "user_orders", "order_group_updates"] + enum: ["orderbook_delta", "ticker", "trade", "fill", "user_orders", "order_group_updates", "corporate_actions"] market_ticker: type: string market_tickers: @@ -985,6 +1022,7 @@ components: $ref: '#/components/schemas/bookSide' last_update_reason: $ref: '#/components/schemas/lastUpdateReason' + description: Margin order update reason. Present only when the delta comes from the authenticated user's order and a reason applies. client_order_id: type: string ts_ms: @@ -996,6 +1034,107 @@ components: sending_ts_ms: $ref: '#/components/schemas/sendingTimestampMs' + marginCorporateAction: + type: object + description: >- + A corporate action the exchange applied to a margin market. Exactly one terms object + matches type: split for stock_split, final_settlement for final_settlement, or + ticker_change for ticker_change. + required: [id, market_id, market_ticker, type, ts_ms] + properties: + id: + type: string + description: "Corporate action notice id, matching `id` in `GET /trade-api/v2/margin/corporate_actions`." + market_id: + type: string + description: "Stable market UUID." + market_ticker: + type: string + description: "Market ticker; the new ticker for ticker_change." + type: + type: string + enum: [stock_split, final_settlement, ticker_change] + description: "Corporate action type, identifying which terms object is present." + ts_ms: + type: integer + format: int64 + description: "Unix timestamp in milliseconds when the exchange applied the action." + split: + $ref: '#/components/schemas/marginCorporateActionSplitTerms' + final_settlement: + $ref: '#/components/schemas/marginCorporateActionFinalSettlementTerms' + ticker_change: + $ref: '#/components/schemas/marginCorporateActionTickerChangeTerms' + + marginCorporateActionSplitTerms: + type: object + description: "Terms for stock_split. Omitted for other types." + required: [ratio_old, ratio_new, pre_split_mark_price, post_split_mark_price] + properties: + ratio_old: + type: string + description: "Old-share component of the split ratio. A 4-for-1 split is ratio_new 4, ratio_old 1." + ratio_new: + type: string + description: "New-share component of the split ratio." + pre_split_mark_price: + type: string + description: "Mark price in US dollars before the split." + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars + post_split_mark_price: + type: string + description: "Mark price in US dollars after the split." + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars + + marginCorporateActionFinalSettlementTerms: + type: object + description: "Terms for final_settlement. Omitted for other types." + required: [settlement_price_per_contract] + properties: + settlement_price_per_contract: + type: string + description: "Final settlement price in US dollars per contract." + x-rust-type: common::types::Price + x-rust-serialize-with: ws::dto::serde_money::serialize_price_dollars + + marginCorporateActionTickerChangeTerms: + type: object + description: "Terms for ticker_change. Omitted for other types." + required: [old_ticker, new_ticker] + properties: + old_ticker: + type: string + description: "Previous market ticker." + new_ticker: + type: string + description: "New market ticker." + + marginCorporateActionsDeltaPayload: + type: object + required: + - type + - sid + - seq + - msg + properties: + type: + type: string + const: corporate_actions_delta + sid: + $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' + msg: + type: object + required: [corporate_action] + properties: + corporate_action: + $ref: '#/components/schemas/marginCorporateAction' + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' + marginTickerPayload: type: object required: ["type", "sid", "msg"] @@ -1230,7 +1369,7 @@ components: description: Unix timestamp in milliseconds. last_update_reason: $ref: '#/components/schemas/lastUpdateReason' - description: Margin order update reason; ReduceOnlyCancel means reduce_only capped the order at placement. The field is omitted when no reason applies. + description: Margin order update reason. The value ReduceOnlyCancel means reduce_only capped the order at placement. The field is omitted when no reason applies. subaccount_number: type: integer order_source: diff --git a/specs/perps_openapi.yaml b/specs/perps_openapi.yaml index 446f858c..90959548 100644 --- a/specs/perps_openapi.yaml +++ b/specs/perps_openapi.yaml @@ -11,6 +11,50 @@ servers: description: Demo perps REST API server paths: + /margin/corporate_actions: + get: + operationId: GetMarginCorporateActions + summary: Get Margin Corporate Actions + description: | + Returns scheduled notices and notices updated in the last seven days for margin markets. + Notices are sorted by ex-date ascending (earliest first), with notices without an ex-date last. + Ties are ordered by updated_ts ascending, then id ascending. + Covers cash dividends, special dividends, cash spin-offs, stock splits, final settlements and ticker changes. + Each notice identifies its type and has a scheduled, cancelled, applied or lapsed status. + Scheduled, applied and lapsed notices include exactly one terms object matching the type: + cash, split, final_settlement or ticker_change. Cancelled notices omit all terms and + execution_time; use the stable notice id to associate a cancellation with an earlier announcement. + Amounts are US dollar strings. Per-contract amounts are computed by clearing and are + not recalculated from contract size by this endpoint. + tags: + - corporate-actions + parameters: + - name: ticker + in: query + required: false + description: Filter by market ticker. Omit to return notices across all margin markets. + schema: + type: string + x-go-type-skip-optional-pointer: true + - name: market_id + in: query + required: false + description: Filter by market UUID. Omit to return notices across all margin markets. + schema: + type: string + format: uuid + responses: + '200': + description: Corporate action notices + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginCorporateActionsResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/fcm/subtraders: post: operationId: CreateMarginFCMSubtrader @@ -1297,6 +1341,8 @@ paths: $ref: '#/components/responses/UnauthorizedError' '403': $ref: '#/components/responses/ForbiddenError' + '409': + $ref: '#/components/responses/ConflictError' '500': $ref: '#/components/responses/InternalServerError' @@ -2511,7 +2557,7 @@ components: type: string format: uuid description: | - Client ID for duplicate prevention. Reuse only for identical requests. Scoped to the user and both exchange instances and shards. Accepted transfers cannot be repeated with the same ID on the same route. + Client ID for duplicate prevention. Reuse only to retry the same request: if a transfer from the same source exchange instance and shard was already accepted with this ID, the request returns 409 and creates no new transfer. The returned transfer_id is always generated by Kalshi. Only supported for primary accounts. Transfers involving numbered subaccounts reject this field. If omitted, each request creates a new transfer. source: $ref: '#/components/schemas/ExchangeInstance' @@ -3724,6 +3770,121 @@ components: $ref: '#/components/schemas/MarginFundingHistoryEntry' description: Array of historical funding payment entries + GetMarginCorporateActionsResponse: + type: object + required: [corporate_actions] + properties: + corporate_actions: + type: array + items: + $ref: '#/components/schemas/MarginCorporateAction' + + MarginCorporateAction: + type: object + description: >- + A margin corporate action notice. Scheduled, applied and lapsed notices have exactly one + terms object matching type: cash for cash_dividend, special_dividend or spin_off, split for stock_split, + final_settlement for final_settlement, or ticker_change for ticker_change. + Cancelled notices omit all four terms objects and execution_time. + required: [id, market_id, market_ticker, type, status, updated_ts] + properties: + id: + type: string + description: "Stable notice id, equal to the Klear review id." + market_id: + type: string + description: "Stable market UUID." + market_ticker: + type: string + description: "Market ticker." + type: + type: string + enum: [cash_dividend, special_dividend, spin_off, stock_split, final_settlement, ticker_change] + x-enum-varnames: [MarginCorporateActionTypeCashDividend, MarginCorporateActionTypeSpecialDividend, MarginCorporateActionTypeSpinOff, MarginCorporateActionTypeStockSplit, MarginCorporateActionTypeFinalSettlement, MarginCorporateActionTypeTickerChange] + description: "Corporate action type, identifying which terms object is present." + status: + type: string + enum: [scheduled, cancelled, applied, lapsed] + description: "Public notice status." + updated_ts: + type: string + format: date-time + description: "Time of the latest clearing update." + execution_time: + type: string + format: date-time + nullable: true + description: "Review execution time. Omitted for cancelled notices." + cash: + $ref: '#/components/schemas/MarginCorporateActionCashTerms' + split: + $ref: '#/components/schemas/MarginCorporateActionSplitTerms' + final_settlement: + $ref: '#/components/schemas/MarginCorporateActionFinalSettlementTerms' + ticker_change: + $ref: '#/components/schemas/MarginCorporateActionTickerChangeTerms' + + MarginCorporateActionCashTerms: + type: object + nullable: true + description: "Terms for cash_dividend, special_dividend or spin_off. Omitted for other types and cancelled notices." + properties: + cash_per_share: + $ref: '#/components/schemas/FixedPointDollars' + description: "Cash in US dollars per underlying share." + cash_per_contract: + $ref: '#/components/schemas/FixedPointDollars' + description: "Cash in US dollars per contract. A positive amount credits longs and debits shorts." + ex_date: + type: string + format: date + description: "Corporate action ex-date, where available." + + MarginCorporateActionSplitTerms: + type: object + nullable: true + description: "Terms for stock_split. Omitted for other types and cancelled notices." + properties: + ratio_old: + type: string + description: "Old-share component of the split ratio." + ratio_new: + type: string + description: "New-share component of the split ratio." + ex_date: + type: string + format: date + description: "Corporate action ex-date, where available." + + MarginCorporateActionFinalSettlementTerms: + type: object + nullable: true + description: "Terms for final_settlement. Omitted for other types and cancelled notices." + required: [reason] + properties: + reason: + type: string + description: "Final settlement reason: bankruptcy, merger, delisting or other." + settlement_price_per_share: + $ref: '#/components/schemas/FixedPointDollars' + description: "Final settlement price in US dollars per underlying share." + settlement_price_per_contract: + $ref: '#/components/schemas/FixedPointDollars' + description: "Final settlement price in US dollars per contract, rounded by clearing." + + MarginCorporateActionTickerChangeTerms: + type: object + nullable: true + description: "Terms for ticker_change. Omitted for other types and cancelled notices." + required: [old_ticker, new_ticker] + properties: + old_ticker: + type: string + description: "Previous underlying ticker for a ticker change." + new_ticker: + type: string + description: "Approved replacement underlying ticker." + MarginFundingRate: type: object required: diff --git a/specs/perps_scm_openapi.yaml b/specs/perps_scm_openapi.yaml index 8df4c62f..7530222b 100644 --- a/specs/perps_scm_openapi.yaml +++ b/specs/perps_scm_openapi.yaml @@ -731,10 +731,12 @@ components: type: string enum: - trade_audit + - position_transfer_audit - position_snapshot - market_price_snapshot - funding_periods - settlement_periods + - corporate_action_payments - maintenance_margin - maintenance_margin_aggregate - cash_balance_daily @@ -1272,7 +1274,7 @@ components: properties: id: { type: string } amount: { type: string, description: "Withdrawal amount in USD as a fixed-point string (e.g. \"500.00\")." } - status: { type: string, enum: [pending, processing, processed, failed] } + status: { type: string, enum: [pending, processing, processed, failed, cancelled] } created_ts: { type: string, format: date-time } WithdrawSettlementBalanceRequest: diff --git a/tests/_contract_support.py b/tests/_contract_support.py index a81131e5..02170dc0 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -1619,6 +1619,11 @@ class Exclusion: http_method="GET", path_template="/account/limits/perps", ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.margin_account.MarginAccountResource.corporate_actions", + http_method="GET", + path_template="/margin/corporate_actions", + ), # ── perps funding (#395) ─────────────────────────────────────────── MethodEndpointEntry( sdk_method="kalshi.perps.resources.funding.FundingResource.rate_estimate", diff --git a/tests/perps/test_margin_account.py b/tests/perps/test_margin_account.py index 7bb7bd0e..5391633a 100644 --- a/tests/perps/test_margin_account.py +++ b/tests/perps/test_margin_account.py @@ -10,13 +10,15 @@ from __future__ import annotations +from datetime import date, datetime from decimal import Decimal import httpx import pytest import respx +from pydantic import ValidationError -from kalshi.errors import AuthRequiredError, KalshiServerError +from kalshi.errors import AuthRequiredError, KalshiServerError, KalshiValidationError from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig BASE = "https://external-api.demo.kalshi.co/trade-api/v2" @@ -599,6 +601,267 @@ async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: await async_perps_client.close() +# ── corporate_actions ───────────────────────────────────────────────────────── + + +def _cash_notice() -> dict[str, object]: + return { + "id": "ca-1", + "market_id": "11111111-1111-1111-1111-111111111111", + "market_ticker": "AAPL-PERP", + "type": "cash_dividend", + "status": "scheduled", + "updated_ts": "2026-10-01T12:00:00Z", + "execution_time": "2026-10-15T00:00:00Z", + "cash": { + "cash_per_share": "0.2500", + "cash_per_contract": "25.0000", + "ex_date": "2026-10-14", + }, + } + + +class TestCorporateActions: + @respx.mock + def test_happy_cash_dividend(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/corporate_actions").mock( + return_value=httpx.Response(200, json={"corporate_actions": [_cash_notice()]}) + ) + resp = perps_client.margin.corporate_actions() + assert route.called + assert len(resp.corporate_actions) == 1 + action = resp.corporate_actions[0] + assert action.id == "ca-1" + assert action.market_ticker == "AAPL-PERP" + assert action.type == "cash_dividend" + assert action.status == "scheduled" + assert isinstance(action.updated_ts, datetime) + assert action.updated_ts.tzinfo is not None + assert isinstance(action.execution_time, datetime) + assert action.cash is not None + assert action.cash.cash_per_share == Decimal("0.2500") + assert isinstance(action.cash.cash_per_share, Decimal) + assert action.cash.cash_per_contract == Decimal("25.0000") + assert action.cash.ex_date == date(2026, 10, 14) + assert action.split is None + assert action.final_settlement is None + assert action.ticker_change is None + assert "ticker" not in route.calls.last.request.url.params + assert "market_id" not in route.calls.last.request.url.params + + @respx.mock + def test_filters_sent_as_query_params(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/corporate_actions").mock( + return_value=httpx.Response(200, json={"corporate_actions": []}) + ) + perps_client.margin.corporate_actions( + ticker="AAPL-PERP", + market_id="11111111-1111-1111-1111-111111111111", + ) + params = route.calls.last.request.url.params + assert params["ticker"] == "AAPL-PERP" + assert params["market_id"] == "11111111-1111-1111-1111-111111111111" + + @respx.mock + def test_empty_list(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/corporate_actions").mock( + return_value=httpx.Response(200, json={"corporate_actions": []}) + ) + assert perps_client.margin.corporate_actions().corporate_actions == [] + + @respx.mock + def test_cancelled_notice_without_terms(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/corporate_actions").mock( + return_value=httpx.Response( + 200, + json={ + "corporate_actions": [ + { + "id": "ca-cancel", + "market_id": "22222222-2222-2222-2222-222222222222", + "market_ticker": "MSFT-PERP", + "type": "stock_split", + "status": "cancelled", + "updated_ts": "2026-10-02T08:00:00Z", + } + ] + }, + ) + ) + action = perps_client.margin.corporate_actions().corporate_actions[0] + assert action.status == "cancelled" + assert action.execution_time is None + assert action.cash is None + assert action.split is None + assert action.final_settlement is None + assert action.ticker_change is None + + @respx.mock + def test_split_final_settlement_and_ticker_change(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/corporate_actions").mock( + return_value=httpx.Response( + 200, + json={ + "corporate_actions": [ + { + "id": "ca-split", + "market_id": "33333333-3333-3333-3333-333333333333", + "market_ticker": "NVDA-PERP", + "type": "stock_split", + "status": "applied", + "updated_ts": "2026-10-03T00:00:00Z", + "execution_time": "2026-10-03T00:00:00Z", + "split": { + "ratio_old": "1", + "ratio_new": "4", + "ex_date": "2026-10-03", + }, + }, + { + "id": "ca-fs", + "market_id": "44444444-4444-4444-4444-444444444444", + "market_ticker": "XYZ-PERP", + "type": "final_settlement", + "status": "lapsed", + "updated_ts": "2026-10-04T00:00:00Z", + "execution_time": "2026-10-04T00:00:00Z", + "final_settlement": { + "reason": "delisting", + "settlement_price_per_share": "12.5000", + "settlement_price_per_contract": "1250.0000", + }, + }, + { + "id": "ca-tc", + "market_id": "55555555-5555-5555-5555-555555555555", + "market_ticker": "NEW-PERP", + "type": "ticker_change", + "status": "scheduled", + "updated_ts": "2026-10-05T00:00:00Z", + "execution_time": "2026-10-06T00:00:00Z", + "ticker_change": { + "old_ticker": "OLD-PERP", + "new_ticker": "NEW-PERP", + }, + }, + ] + }, + ) + ) + actions = perps_client.margin.corporate_actions().corporate_actions + assert actions[0].split is not None + assert actions[0].split.ratio_old == "1" + assert actions[0].split.ratio_new == "4" + assert actions[0].split.ex_date == date(2026, 10, 3) + fs = actions[1].final_settlement + assert fs is not None + assert fs.reason == "delisting" + assert fs.settlement_price_per_share == Decimal("12.5000") + assert isinstance(fs.settlement_price_per_contract, Decimal) + tc = actions[2].ticker_change + assert tc is not None + assert tc.old_ticker == "OLD-PERP" + assert tc.new_ticker == "NEW-PERP" + + @respx.mock + def test_null_array_coerced_to_empty(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/corporate_actions").mock( + return_value=httpx.Response(200, json={"corporate_actions": None}) + ) + assert perps_client.margin.corporate_actions().corporate_actions == [] + + @respx.mock + def test_missing_required_key_raises(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/corporate_actions").mock( + return_value=httpx.Response(200, json={}) + ) + with pytest.raises(ValidationError): + perps_client.margin.corporate_actions() + + @respx.mock + def test_error_400_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/corporate_actions").mock( + return_value=httpx.Response(400, json={"error": {"code": "bad_request"}}) + ) + with pytest.raises(KalshiValidationError): + perps_client.margin.corporate_actions() + + @respx.mock + def test_error_500_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/corporate_actions").mock( + return_value=httpx.Response(500) + ) + with pytest.raises(KalshiServerError): + perps_client.margin.corporate_actions() + + @respx.mock + def test_unauthenticated_does_not_raise(self) -> None: + route = respx.get(f"{BASE}/margin/corporate_actions").mock( + return_value=httpx.Response(200, json={"corporate_actions": []}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + resp = client.margin.corporate_actions() + assert route.called + assert resp.corporate_actions == [] + client.close() + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/corporate_actions").mock( + return_value=httpx.Response(200, json={"corporate_actions": [_cash_notice()]}) + ) + resp = await async_perps_client.margin.corporate_actions() + assert resp.corporate_actions[0].cash is not None + assert resp.corporate_actions[0].cash.cash_per_contract == Decimal("25.0000") + await async_perps_client.close() + + @respx.mock + async def test_async_filters_and_cancelled( + self, async_perps_client: AsyncPerpsClient + ) -> None: + route = respx.get(f"{BASE}/margin/corporate_actions").mock( + return_value=httpx.Response( + 200, + json={ + "corporate_actions": [ + { + "id": "ca-async", + "market_id": "66666666-6666-6666-6666-666666666666", + "market_ticker": "AAPL-PERP", + "type": "special_dividend", + "status": "cancelled", + "updated_ts": "2026-10-07T00:00:00Z", + } + ] + }, + ) + ) + resp = await async_perps_client.margin.corporate_actions(ticker="AAPL-PERP") + assert route.calls.last.request.url.params["ticker"] == "AAPL-PERP" + assert "market_id" not in route.calls.last.request.url.params + assert resp.corporate_actions[0].status == "cancelled" + assert resp.corporate_actions[0].cash is None + await async_perps_client.close() + + @respx.mock + async def test_async_error_400(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/corporate_actions").mock( + return_value=httpx.Response(400, json={"error": {"code": "bad_request"}}) + ) + with pytest.raises(KalshiValidationError): + await async_perps_client.margin.corporate_actions() + await async_perps_client.close() + + @respx.mock + async def test_async_error_500(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/corporate_actions").mock( + return_value=httpx.Response(500) + ) + with pytest.raises(KalshiServerError): + await async_perps_client.margin.corporate_actions() + await async_perps_client.close() + + # ── base URL / extra-field tolerance ──────────────────────────────────────────