diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ab462a..9939aa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ All notable changes to kalshi-sdk will be documented in this file. ## Unreleased +Reconciles nightly spec drift (Closes #544). **Breaking** for constructors +of `ObligationEntry` that omit the new required `corporate_action_payments`. +Re-vendored `specs/openapi.yaml` (hash +`3877d38d095f4004e325aaa8766f24dabeb1c2dc3fb8d37b9ccac82ca5734d8a`), +`specs/asyncapi.yaml` (hash +`3c86bae7d89b44baba1119b3a097a54054c1d851e1dcd8ad894c7cb35b9890b6`; +channel description only — no WebSocket payload schema change), and +`specs/perps_scm_openapi.yaml` (hash +`1f58b54b6bef44852b4a9a3fd7ad707668258a2516801a076ffe468a1807806f`). +Core OpenAPI remains 3.34.0. + +### Changed (breaking) + +- Klear **`ObligationEntry.corporate_action_payments`** (required + `list[CorporateActionPaymentDetail]`, may be empty). Cash and split + corporate-action payments already reflected in settlement PnL; do not + add them to the obligation total. Live responses include the array; + constructors and fixtures must pass it (use `[]` when none apply). + +### Added + +- **`events.list_multivariate` / `list_all_multivariate(..., tickers=)`** + (sync + async) — optional event-ticker filter on + `GET /events/multivariate`. A `list[str]` or a comma-separated string is + sent as one comma-separated query value. Empty elements and embedded + commas are rejected before the request. `None` and `[]` omit the param. + +- Klear **`margin.corporate_action_payments` / + `corporate_action_payments_all`** (sync + async) — + `GET /margin/obligations/{obligation_id}/corporate_action_payments`. + Pages the full set when `corporate_action_payments_truncated` is set. + Limit max 1000. + +- Klear **`ObligationEntry.corporate_action_payments_truncated`** + (`bool | None`). + +- Klear **`AssetClassLiteral`** also accepts `"Equities"`, `"Metals"`, + `"Indices"`, and `"Energy"` (alongside `"Crypto"`). + Reconciles perps spec drift after nightly contract failures (Closes #540). **Breaking** for constructors of `MarginMarket` that omit the new required `funding_invalid_premium_handling`. Re-vendored `specs/perps_openapi.yaml` diff --git a/docs/migration.md b/docs/migration.md index dc97427..5de6a88 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -42,6 +42,40 @@ MarginMarket( `GET /margin/markets/orderbooks`. Required `tickers` (1–100), comma-joined on the wire. Returns `list[MarginOrderbook]`. +### Klear corporate-action payments (Closes #544) + +**Breaking** for constructors and `model_validate` fixtures of +`ObligationEntry` that omit the new required `corporate_action_payments`. + +- **`ObligationEntry.corporate_action_payments`** — required + `list[CorporateActionPaymentDetail]` (may be empty). Cash and split + corporate-action payments already reflected in settlement PnL. Do not + add them to the obligation total. Live responses include the array; + constructors and fixtures must pass it. + +```python +# Before (constructors / test fixtures): +# ObligationEntry( +# ..., +# funding_payments=[], +# ) + +# After: +ObligationEntry( + ..., + funding_payments=[], + corporate_action_payments=[], +) +``` + +`corporate_action_payments_truncated` is optional. Page the full set with +`klear.margin.corporate_action_payments` / `corporate_action_payments_all` +(sync + async) when the flag is set. + +`AssetClassLiteral` also accepts `"Equities"`, `"Metals"`, `"Indices"`, and +`"Energy"` (previously `"Crypto"` only). Existing `"Crypto"` callers are +unchanged. + See the [changelog](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/CHANGELOG.md) for the full list. diff --git a/docs/perps.md b/docs/perps.md index 89db602..f405579 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -299,7 +299,8 @@ next-settlement estimates keyed by asset class. (The singular `active_obligation()` / `settlement_estimate()` endpoints were removed upstream in the v11.0.0 reconcile.) -`klear.margin.settlement_prices(asset_class="Crypto", settlement_time=...)` +`AssetClassLiteral` is `"Crypto"`, `"Equities"`, `"Metals"`, `"Indices"`, +or `"Energy"`. `klear.margin.settlement_prices(asset_class="Crypto", settlement_time=...)` returns a ticker → centicents map at a settlement cycle. `klear.margin.estimate_maintenance_margin(asset_class="Crypto", positions=[...])` margins a hypothetical portfolio. Optional `date=` (YYYY-MM-DD) and @@ -329,7 +330,17 @@ klear.margin.delete_fcm_api_key(created.api_key_id) ``` When an `ObligationEntry` inline detail array is capped at 1000 rows, the -matching `*_truncated` flag is set; page the full set via: +matching `*_truncated` flag is set; page the full set via the methods below. + +`corporate_action_payments` is required and may be empty. Those rows are +cash and split corporate-action payments already included in settlement PnL +— do not add them to the obligation total. +`CorporateActionPaymentDetail.payment_time` is always present and is `null` +for splits. Centicents fields on that model are plain integers; +`position_quantity_fp` and `post_position_quantity_fp` are fixed-point +contract counts. `corporate_action_payments` / `corporate_action_payments_all` +page the full set when `corporate_action_payments_truncated` is true (limit +max 1000): ```python for row in klear.margin.settlement_details_all(ob.id): @@ -338,6 +349,8 @@ for row in klear.margin.maintenance_margin_details_all(ob.id): ... for row in klear.margin.funding_payments_all(ob.id): ... +for row in klear.margin.corporate_action_payments_all(ob.id): + ... ``` Each `AssetClassSettlementEstimate` exposes optional diff --git a/docs/resources/events.md b/docs/resources/events.md index c2a2b86..0e30afc 100644 --- a/docs/resources/events.md +++ b/docs/resources/events.md @@ -45,8 +45,15 @@ one comma-separated value). ## Multivariate events `list_multivariate(...)` returns only events that participate in a -multivariate event collection. Same kwargs as `list`. See -[Multivariate](multivariate.md) for the surrounding API. +multivariate event collection. See [Multivariate](multivariate.md) for the +surrounding API. + +`tickers` (optional on `list_multivariate` and `list_all_multivariate`) +restricts the page to specific event tickers, same wire format as +[`list`](#list-events): a `list[str]` or a comma-separated string, sent as +one comma-separated query value. Individual tickers must be non-empty and +must not contain commas. Omit `tickers`, or pass `None` or `[]`, to leave +the filter off. ## Get one event diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index e0b1468..9ab6c91 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -986,6 +986,10 @@ class ContractEntry: sdk_model="kalshi.perps.klear.models.margin.FundingPaymentDetail", spec_schema="FundingPaymentDetail", ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.CorporateActionPaymentDetail", + spec_schema="CorporateActionPaymentDetail", + ), ContractEntry( sdk_model="kalshi.perps.klear.models.margin.ObligationEntry", spec_schema="ObligationEntry", @@ -1007,6 +1011,12 @@ class ContractEntry: sdk_model="kalshi.perps.klear.models.margin.GetObligationFundingPaymentsResponse", spec_schema="GetObligationFundingPaymentsResponse", ), + ContractEntry( + sdk_model=( + "kalshi.perps.klear.models.margin.GetObligationCorporateActionPaymentsResponse" + ), + spec_schema="GetObligationCorporateActionPaymentsResponse", + ), ContractEntry( sdk_model="kalshi.perps.klear.models.margin.MarketSettlementEstimate", spec_schema="MarketSettlementEstimate", diff --git a/kalshi/perps/__init__.py b/kalshi/perps/__init__.py index 1128c09..1c7c10f 100644 --- a/kalshi/perps/__init__.py +++ b/kalshi/perps/__init__.py @@ -31,10 +31,12 @@ KlearConfig, ) from kalshi.perps.klear.models.margin import ( + CorporateActionPaymentDetail, FundingPaymentDetail, GetActiveMarginObligationsResponse, GetGuarantyFundBalanceResponse, GetMarginReportsResponse, + GetObligationCorporateActionPaymentsResponse, GetObligationFundingPaymentsResponse, GetObligationHistoryResponse, GetObligationMaintenanceMarginDetailsResponse, @@ -249,6 +251,7 @@ "BookSideLiteral", "CancelMarginOrderResponse", "ConnectionState", + "CorporateActionPaymentDetail", "CreateMarginOrderRequest", "CreateMarginOrderResponse", "CreateOrderGroupRequest", @@ -291,6 +294,7 @@ "GetMarginRiskParametersResponse", "GetMarginRiskResponse", "GetMarginTradesResponse", + "GetObligationCorporateActionPaymentsResponse", "GetObligationFundingPaymentsResponse", "GetObligationHistoryResponse", "GetObligationMaintenanceMarginDetailsResponse", diff --git a/kalshi/perps/klear/models/__init__.py b/kalshi/perps/klear/models/__init__.py index 73db4c6..05b5c66 100644 --- a/kalshi/perps/klear/models/__init__.py +++ b/kalshi/perps/klear/models/__init__.py @@ -8,6 +8,7 @@ AssetClassLiteral, AssetClassSettlementEstimate, ClearingTypeLiteral, + CorporateActionPaymentDetail, CreateMarginFcmApiKeyRequest, CreateMarginFcmApiKeyResponse, CreateMarginSubtraderGroupRequest, @@ -27,6 +28,7 @@ GetMarginReportsResponse, GetMarginSubtraderGroupsResponse, GetMemberFundingPaymentsResponse, + GetObligationCorporateActionPaymentsResponse, GetObligationFundingPaymentsResponse, GetObligationHistoryResponse, GetObligationMaintenanceMarginDetailsResponse, @@ -64,6 +66,7 @@ "AssetClassLiteral", "AssetClassSettlementEstimate", "ClearingTypeLiteral", + "CorporateActionPaymentDetail", "CreateMarginFcmApiKeyRequest", "CreateMarginFcmApiKeyResponse", "CreateMarginSubtraderGroupRequest", @@ -84,6 +87,7 @@ "GetMarginReportsResponse", "GetMarginSubtraderGroupsResponse", "GetMemberFundingPaymentsResponse", + "GetObligationCorporateActionPaymentsResponse", "GetObligationFundingPaymentsResponse", "GetObligationHistoryResponse", "GetObligationMaintenanceMarginDetailsResponse", diff --git a/kalshi/perps/klear/models/margin.py b/kalshi/perps/klear/models/margin.py index a758c10..0b1fdfc 100644 --- a/kalshi/perps/klear/models/margin.py +++ b/kalshi/perps/klear/models/margin.py @@ -14,8 +14,10 @@ NOT :data:`DollarDecimal` / :data:`FixedPointCount`. This mirrors the integer-cents precedent in ``kalshi/models/portfolio.py`` (``Balance.balance``, ``Deposit.amount_cents``). Fixed-point **count** strings appear on -:attr:`SettlementDetail.position_quantity_fp` and -:attr:`FundingPaymentDetail.position_quantity_fp`. The fixed-point +:attr:`SettlementDetail.position_quantity_fp`, +:attr:`FundingPaymentDetail.position_quantity_fp`, and +:attr:`CorporateActionPaymentDetail.position_quantity_fp` / +:attr:`CorporateActionPaymentDetail.post_position_quantity_fp`. The fixed-point **dollar-string** fields are :attr:`WithdrawSettlementBalanceRequest.amount`, :attr:`GetSettlementBalanceWithdrawalResponse.amount`, and :attr:`MarketSettlementEstimate.session_avg_price_fp` (e.g. ``"500.00"``); @@ -79,10 +81,10 @@ def _require_positive_withdrawal(value: Decimal) -> Decimal: # Spec ``GetSettlementBalanceWithdrawalResponse.status`` enum. WithdrawalStatusLiteral = Literal["pending", "processing", "processed", "failed"] -# Spec ``AssetClass`` enum (single value today). Named alias mirrors the other -# *Literal aliases; shared by ObligationEntry.asset_class and the settlement -# estimates keyed by asset class (spec sync 3.24.0). -AssetClassLiteral = Literal["Crypto"] +# Spec ``AssetClass`` enum. Named alias mirrors the other *Literal aliases; +# shared by ObligationEntry.asset_class, corporate-action payments, and the +# settlement / funding estimates keyed by asset class. +AssetClassLiteral = Literal["Crypto", "Equities", "Metals", "Indices", "Energy"] """Spec withdrawal ``status`` — lifecycle of an async settlement-balance withdrawal.""" @@ -181,6 +183,40 @@ class FundingPaymentDetail(BaseModel): model_config = {"extra": "allow"} +class CorporateActionPaymentDetail(BaseModel): + """Spec ``CorporateActionPaymentDetail`` — cash or split already in settlement PnL. + + These rows explain amounts included in the obligation's settlement PnL. + They are not additional amounts to add to the obligation total. + + ``position_quantity_fp`` / ``post_position_quantity_fp`` are fixed-point + contract counts. Centicents fields are plain ``int``. ``payment_time`` is + a required key that is ``null`` for splits (no scheduled cash time). + ``pre_split_price_centicents`` / ``post_split_price_centicents`` / + ``exchange_ts`` are omitted when they do not apply. + """ + + id: str + type: str + ca_id: str + market_id: str + market_ticker: str + asset_class: AssetClassLiteral + subtrader_id: str + position_quantity_fp: FixedPointCount + post_position_quantity_fp: FixedPointCount + payment_amount_centicents: int + # Required key; null for splits. + payment_time: AwareDatetime | None + settlement_execution_time: AwareDatetime + created_ts: AwareDatetime + pre_split_price_centicents: int | None = None + post_split_price_centicents: int | None = None + exchange_ts: AwareDatetime | None = None + + model_config = {"extra": "allow"} + + class ObligationEntry(BaseModel): """Spec ``ObligationEntry`` — a settlement obligation (flattened ``allOf``). @@ -212,9 +248,11 @@ class ObligationEntry(BaseModel): settlement_details: NullableList[SettlementDetail] maintenance_margin_details: NullableList[MaintenanceMarginDetail] funding_payments: NullableList[FundingPaymentDetail] + corporate_action_payments: NullableList[CorporateActionPaymentDetail] settlement_details_truncated: bool | None = None maintenance_margin_details_truncated: bool | None = None funding_payments_truncated: bool | None = None + corporate_action_payments_truncated: bool | None = None model_config = {"extra": "allow"} @@ -275,6 +313,19 @@ def has_next(self) -> bool: model_config = {"extra": "allow"} +class GetObligationCorporateActionPaymentsResponse(BaseModel): + """Spec ``GetObligationCorporateActionPaymentsResponse`` — paged corporate actions.""" + + corporate_action_payments: NullableList[CorporateActionPaymentDetail] + cursor: str | None = None + + @property + def has_next(self) -> bool: + return bool(self.cursor) + + model_config = {"extra": "allow"} + + class MarketSettlementEstimate(BaseModel): """Spec ``MarketSettlementEstimate`` — per-market settlement breakdown (centi units).""" diff --git a/kalshi/perps/klear/resources/margin.py b/kalshi/perps/klear/resources/margin.py index c1d4924..30b8f38 100644 --- a/kalshi/perps/klear/resources/margin.py +++ b/kalshi/perps/klear/resources/margin.py @@ -11,8 +11,9 @@ /margin/obligation_history``: cursor-paginated (limit max 100). - ``settlement_details`` / ``settlement_details_all``, ``maintenance_margin_details`` / ``maintenance_margin_details_all``, - ``funding_payments`` / ``funding_payments_all`` — paged detail rows for one - obligation (limit max 1000) when the inline arrays on + ``funding_payments`` / ``funding_payments_all``, + ``corporate_action_payments`` / ``corporate_action_payments_all`` — paged + detail rows for one obligation (limit max 1000) when the inline arrays on :class:`~kalshi.perps.klear.models.margin.ObligationEntry` are truncated. - ``settlement_estimate_by_asset_class`` — ``GET /margin/settlement_estimate_by_asset_class``. @@ -41,6 +42,7 @@ from kalshi.perps.klear.models.margin import ( AssetClassLiteral, ClearingTypeLiteral, + CorporateActionPaymentDetail, CreateMarginFcmApiKeyRequest, CreateMarginFcmApiKeyResponse, CreateMarginSubtraderGroupRequest, @@ -308,6 +310,55 @@ def funding_payments_all( extra_headers=extra_headers, ) + def corporate_action_payments( + self, + obligation_id: str, + *, + limit: int | None = None, + cursor: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Page[CorporateActionPaymentDetail]: + """``GET /margin/obligations/{obligation_id}/corporate_action_payments``. + + Limit max 1000. Rows are already included in settlement PnL. + """ + params = _params(limit=_validate_limit(limit, hi=1000), cursor=cursor) + return self._list( + ( + f"/margin/obligations/{_seg(obligation_id, name='obligation_id')}" + "/corporate_action_payments" + ), + CorporateActionPaymentDetail, + "corporate_action_payments", + params=params, + cursor_key="cursor", + extra_headers=extra_headers, + ) + + def corporate_action_payments_all( + self, + obligation_id: str, + *, + limit: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Iterator[CorporateActionPaymentDetail]: + """Auto-paginate corporate-action payments for one obligation.""" + _validate_max_pages(max_pages) + params = _params(limit=_validate_limit(limit, hi=1000), cursor=None) + return self._list_all( + ( + f"/margin/obligations/{_seg(obligation_id, name='obligation_id')}" + "/corporate_action_payments" + ), + CorporateActionPaymentDetail, + "corporate_action_payments", + params=params, + max_pages=max_pages, + cursor_key="cursor", + extra_headers=extra_headers, + ) + def settlement_estimate_by_asset_class( self, *, extra_headers: dict[str, str] | None = None ) -> GetSettlementEstimateByAssetClassResponse: @@ -864,6 +915,52 @@ def funding_payments_all( extra_headers=extra_headers, ) + async def corporate_action_payments( + self, + obligation_id: str, + *, + limit: int | None = None, + cursor: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Page[CorporateActionPaymentDetail]: + """Async :meth:`MarginResource.corporate_action_payments`.""" + params = _params(limit=_validate_limit(limit, hi=1000), cursor=cursor) + return await self._list( + ( + f"/margin/obligations/{_seg(obligation_id, name='obligation_id')}" + "/corporate_action_payments" + ), + CorporateActionPaymentDetail, + "corporate_action_payments", + params=params, + cursor_key="cursor", + extra_headers=extra_headers, + ) + + def corporate_action_payments_all( + self, + obligation_id: str, + *, + limit: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> AsyncIterator[CorporateActionPaymentDetail]: + """Async iterator over corporate-action payments — use ``async for``.""" + _validate_max_pages(max_pages) + params = _params(limit=_validate_limit(limit, hi=1000), cursor=None) + return self._list_all( + ( + f"/margin/obligations/{_seg(obligation_id, name='obligation_id')}" + "/corporate_action_payments" + ), + CorporateActionPaymentDetail, + "corporate_action_payments", + params=params, + max_pages=max_pages, + cursor_key="cursor", + extra_headers=extra_headers, + ) + async def settlement_estimate_by_asset_class( self, *, extra_headers: dict[str, str] | None = None ) -> GetSettlementEstimateByAssetClassResponse: diff --git a/kalshi/resources/events.py b/kalshi/resources/events.py index b16141e..4d77ee8 100644 --- a/kalshi/resources/events.py +++ b/kalshi/resources/events.py @@ -52,6 +52,7 @@ def _list_multivariate_events_params( *, series_ticker: str | None, collection_ticker: str | None, + tickers: builtins.list[str] | str | None, with_nested_markets: bool | None, limit: int | None, cursor: str | None, @@ -60,6 +61,7 @@ def _list_multivariate_events_params( return _params( series_ticker=series_ticker, collection_ticker=collection_ticker, + tickers=_join_tickers(tickers), with_nested_markets=_bool_param(with_nested_markets), limit=limit, cursor=cursor, @@ -150,6 +152,7 @@ def list_multivariate( *, series_ticker: str | None = None, collection_ticker: str | None = None, + tickers: builtins.list[str] | str | None = None, with_nested_markets: bool | None = None, limit: int | None = None, cursor: str | None = None, @@ -158,6 +161,7 @@ def list_multivariate( params = _list_multivariate_events_params( series_ticker=series_ticker, collection_ticker=collection_ticker, + tickers=tickers, with_nested_markets=with_nested_markets, limit=limit, cursor=cursor, @@ -171,6 +175,7 @@ def list_all_multivariate( *, series_ticker: str | None = None, collection_ticker: str | None = None, + tickers: builtins.list[str] | str | None = None, with_nested_markets: bool | None = None, limit: int | None = None, max_pages: int | None = None, @@ -180,6 +185,7 @@ def list_all_multivariate( params = _list_multivariate_events_params( series_ticker=series_ticker, collection_ticker=collection_ticker, + tickers=tickers, with_nested_markets=with_nested_markets, limit=limit, cursor=None, @@ -338,6 +344,7 @@ async def list_multivariate( *, series_ticker: str | None = None, collection_ticker: str | None = None, + tickers: builtins.list[str] | str | None = None, with_nested_markets: bool | None = None, limit: int | None = None, cursor: str | None = None, @@ -346,6 +353,7 @@ async def list_multivariate( params = _list_multivariate_events_params( series_ticker=series_ticker, collection_ticker=collection_ticker, + tickers=tickers, with_nested_markets=with_nested_markets, limit=limit, cursor=cursor, @@ -359,6 +367,7 @@ def list_all_multivariate( *, series_ticker: str | None = None, collection_ticker: str | None = None, + tickers: builtins.list[str] | str | None = None, with_nested_markets: bool | None = None, limit: int | None = None, max_pages: int | None = None, @@ -368,6 +377,7 @@ def list_all_multivariate( params = _list_multivariate_events_params( series_ticker=series_ticker, collection_ticker=collection_ticker, + tickers=tickers, with_nested_markets=with_nested_markets, limit=limit, cursor=None, diff --git a/specs/asyncapi.yaml b/specs/asyncapi.yaml index f960eaa..fb7d322 100644 --- a/specs/asyncapi.yaml +++ b/specs/asyncapi.yaml @@ -500,29 +500,36 @@ channels: ## Commodities and underlying tickers - The reference table below includes spot metals, commodity futures, and indices. The gold and silver examples are only a subset of the feed coverage. - - | Pyth feed ID | Pyth symbol | Description | - |--------------|-------------|-------------| - | 345 | `Metal.XAG/USD` | Silver price in USD | - | 346 | `Metal.XAU/USD` | Gold price in USD | - | 2937 | `Commodities.CCU6/USD` | Cocoa futures (Sept 2026) in USD | - | 3052 | `Commodities.COU6/USD` | Crude Oil futures (Sept 2026) in USD | - | 3053 | `Commodities.COZ6/USD` | Crude Oil futures (Dec 2026) in USD | - | 3080 | `Commodities.WHU6/USD` | Wheat futures (Sept 2026) in USD | - | 3081 | `Commodities.WHZ6/USD` | Wheat futures (Dec 2026) in USD | - | 3085 | `Commodities.SOU6/USD` | Soybeans futures (Sept 2026) in USD | - | 3086 | `Commodities.SOX6/USD` | Soybeans futures (Nov 2026) in USD | - | 3063 | `Commodities.Index.PYTHOIL/USD` | Blended Oil Index in USD | - | 3153 | `Metal.Index.GOLD/USD` | Blended Gold Index in USD | - | 3712 | `Metal.Index.1OZGOLD/USD` | 1-Ounce Gold Index in USD | - | 3154 | `Metal.Index.SILVER/USD` | Blended Silver Index in USD | - | 3045 | `Commodities.BRENTX6/USD` | Brent Crude futures (Nov 2026) in USD | - | 3525 | `Commodities.Index.CU/USD` | Copper Index in USD | - | 3265 | `Commodities.Index.NATGAS/USD` | Natural Gas Index in USD | - | 3446 | `Commodities.Index.BRENT/USD` | Blended Brent Crude Index in USD | - | 1781 | `Metal.XPT/USD` | Platinum price in USD | - | 1780 | `Metal.XPD/USD` | Palladium price in USD | + The reference table below lists the underlyings currently streaming in production: spot metals, metal and commodity indices, an equity index, and FX pairs. Index descriptions note Pyth's publishing schedule (24/7, 24/5, or 23/5); spot metals and FX pairs follow their market sessions. + + | Pyth feed ID | Pyth symbol | Description | + |--------------|---------------------------------|---------------------------------------------------------| + | 345 | `Metal.XAG/USD` | Silver price in USD | + | 346 | `Metal.XAU/USD` | Gold price in USD | + | 1780 | `Metal.XPD/USD` | Palladium price in USD | + | 1781 | `Metal.XPT/USD` | Platinum price in USD | + | 3153 | `Metal.Index.GOLD/USD` | Blended Gold Index in USD (24/7) | + | 3712 | `Metal.Index.1OZGOLD/USD` | 1-Ounce Gold Index in USD (24/7) | + | 3154 | `Metal.Index.SILVER/USD` | Blended Silver Index in USD (24/7) | + | 3063 | `Commodities.Index.PYTHOIL/USD` | Blended Oil Index in USD (24/7) | + | 3707 | `Commodities.Index.WTI1M/USD` | Constant 1-Month WTI Crude Index in USD (23/5) | + | 3446 | `Commodities.Index.BRENT/USD` | Blended Brent Crude Index in USD (24/7) | + | 3265 | `Commodities.Index.NATGAS/USD` | Natural Gas Index in USD (24/7) | + | 3525 | `Commodities.Index.CU/USD` | Copper Index in USD (24/7) | + | 3266 | `Commodities.Index.COPPER/USD` | Copper Index in USD (23/5) | + | 3292 | `Equity.Index.US30/USD` | US30 (Dow Jones Industrial Average) Index in USD (24/5) | + | 327 | `FX.EUR/USD` | Euro / US Dollar | + | 333 | `FX.GBP/USD` | British Pound / US Dollar | + | 315 | `FX.AUD/USD` | Australian Dollar / US Dollar | + | 337 | `FX.NZD/USD` | New Zealand Dollar / US Dollar | + | 340 | `FX.USD/JPY` | US Dollar / Japanese Yen | + | 338 | `FX.USD/CAD` | US Dollar / Canadian Dollar | + | 339 | `FX.USD/CHF` | US Dollar / Swiss Franc | + | 1514 | `FX.USD/NOK` | US Dollar / Norwegian Krone | + | 1517 | `FX.USD/SEK` | US Dollar / Swedish Krona | + | 341 | `FX.USD/MXN` | US Dollar / Mexican Peso | + | 1512 | `FX.USD/INR` | US Dollar / Indian Rupee (offshore spot) | + | 1506 | `FX.USD/BRL` | US Dollar / Brazilian Real | Pass the exact **Pyth symbol** in `underlying_tickers`. The numeric Pyth feed IDs are reference information and are not subscription parameters for this channel. Kalshi market tickers are not Pyth underlying tickers. diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 3ba64fa..d33ca5e 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -661,7 +661,7 @@ paths: get: operationId: GetMultivariateEvents summary: Get Multivariate Events - description: 'Retrieve multivariate (combo) events. These are dynamically created events from multivariate event collections. Supports filtering by series and collection ticker.' + description: 'Retrieve multivariate (combo) events. These are dynamically created events from multivariate event collections. Supports filtering by series ticker, collection ticker, or a comma-separated list of event tickers.' tags: - events parameters: @@ -687,6 +687,7 @@ paths: description: Filter events by collection ticker. Returns only multivariate events belonging to the specified collection. Cannot be used together with series_ticker. schema: type: string + - $ref: '#/components/parameters/EventTickersQuery' - name: with_nested_markets in: query required: false diff --git a/specs/perps_scm_openapi.yaml b/specs/perps_scm_openapi.yaml index ec67301..8df4c62 100644 --- a/specs/perps_scm_openapi.yaml +++ b/specs/perps_scm_openapi.yaml @@ -141,6 +141,33 @@ paths: '404': { $ref: '#/components/responses/NotFoundError' } '500': { $ref: '#/components/responses/InternalServerError' } + /margin/obligations/{obligation_id}/corporate_action_payments: + get: + operationId: GetObligationCorporateActionPayments + summary: Get Obligation Corporate Action Payments + description: >- + Pages the cash and split corporate-action payments settled by one + obligation owned by the authenticated clearing member. The inline + corporate_action_payments array is capped at 1000 rows; + corporate_action_payments_truncated indicates when to use this endpoint + for the complete set. These rows explain amounts already included in + settlement PnL and must not be added to the obligation total again. + parameters: + - { name: obligation_id, in: path, required: true, schema: { type: string, format: uuid } } + - { name: limit, in: query, required: false, schema: { type: integer, default: 1000, maximum: 1000 }, description: Number of rows per page. } + - { name: cursor, in: query, required: false, schema: { type: string }, description: Opaque cursor from a previous response to fetch the next page. } + responses: + '200': + description: Successful response + content: + application/json: + schema: { $ref: '#/components/schemas/GetObligationCorporateActionPaymentsResponse' } + '400': { $ref: '#/components/responses/BadRequestError' } + '401': { $ref: '#/components/responses/UnauthorizedError' } + '403': { $ref: '#/components/responses/ForbiddenError' } + '404': { $ref: '#/components/responses/NotFoundError' } + '500': { $ref: '#/components/responses/InternalServerError' } + /margin/obligations/{obligation_id}/maintenance_margin_details: get: operationId: GetObligationMaintenanceMarginDetails @@ -210,7 +237,7 @@ paths: (https://www.ecfr.gov/current/title-17/chapter-I/part-15). parameters: - - { name: report_date, in: query, required: false, schema: { type: string, format: date }, description: ET settlement day to report, YYYY-MM-DD. Defaults to the most recent ET settlement day whose settlement window has closed. } + - { name: report_date, in: query, required: false, schema: { type: string, format: date }, description: "ET settlement day to report, YYYY-MM-DD. Defaults to the most recent ET settlement day whose settlement window has closed." } responses: '200': description: Successful response @@ -345,7 +372,7 @@ paths: parameters: - { name: asset_class, in: query, required: true, schema: { $ref: '#/components/schemas/AssetClass' } } - - { name: settlement_time, in: query, required: true, schema: { type: string, format: date-time }, description: Settlement cycle time, RFC3339. } + - { name: settlement_time, in: query, required: true, schema: { type: string, format: date-time }, description: "Settlement cycle time, RFC3339." } responses: '200': description: Successful response @@ -747,7 +774,7 @@ components: AssetClass: type: string # TODO(randy): add other enums once SCMs should be aware of other asset classes - enum: [Crypto] + enum: [Crypto, Equities, Metals, Indices, Energy] description: Asset class of the margin markets covered by this entry. ObligationInfo: @@ -807,7 +834,7 @@ components: market_ticker: { type: string } subtrader_id: { type: string } position_quantity_fp: { type: string, description: "Signed position at settlement as a fixed-point contract count (e.g. \"1.25\")." } - pnl_centicents: { type: integer, format: int64, description: Total PnL for this market and subtrader over the settlement period, including the market's net funding (itemized per funding period in the entry's funding_payments). } + pnl_centicents: { type: integer, format: int64, description: "Total PnL for this market and subtrader over the settlement period, including the market's net funding (itemized per funding period in the entry's funding_payments)." } total_fees_centicents: { type: integer, format: int64, description: Total fees. } total_amount_centicents: { type: integer, format: int64, description: Total amount. } @@ -828,7 +855,42 @@ components: funding_time: { type: string, format: date-time, description: The funding period this payment applies to. } position_quantity_fp: { type: string, description: "Signed position at funding time as a fixed-point contract count (e.g. \"1.25\")." } notional_value_centicents: { type: integer, format: int64, description: Signed notional value (position times mark price) the funding payment was computed from. } - funding_amount_centicents: { type: integer, format: int64, description: Signed funding amount; positive when received, negative when paid. } + funding_amount_centicents: { type: integer, format: int64, description: "Signed funding amount; positive when received, negative when paid." } + + CorporateActionPaymentDetail: + type: object + description: Cash distributions and split adjustments already reflected in settlement PnL; these are not additional amounts to add to the obligation total. + required: + - id + - type + - ca_id + - market_id + - market_ticker + - asset_class + - subtrader_id + - position_quantity_fp + - post_position_quantity_fp + - payment_amount_centicents + - payment_time + - settlement_execution_time + - created_ts + properties: + id: { type: string } + type: { type: string, description: Corporate-action type. } + ca_id: { type: string, description: Identifier of the corporate action that generated this payment. } + market_id: { type: string, format: uuid } + market_ticker: { type: string } + asset_class: { $ref: '#/components/schemas/AssetClass' } + subtrader_id: { type: string } + position_quantity_fp: { type: string, description: Signed position before the action as a fixed-point contract count. } + post_position_quantity_fp: { type: string, description: Signed position after the action as a fixed-point contract count; unchanged for cash distributions. } + payment_amount_centicents: { type: integer, format: int64, description: Signed cash amount; positive when received and negative when paid. For splits this is cash in lieu and excludes position revaluation. } + payment_time: { type: string, format: date-time, nullable: true, description: Scheduled cash payment time; null for splits. } + settlement_execution_time: { type: string, format: date-time, description: Settlement cycle that carries this payment. } + pre_split_price_centicents: { type: integer, format: int64, description: Mark price before the split; omitted when not applicable. } + post_split_price_centicents: { type: integer, format: int64, description: Mark price after the split; omitted when not applicable. } + exchange_ts: { type: string, format: date-time, description: Exchange event timestamp when available. } + created_ts: { type: string, format: date-time, description: Time the payment row was created. } MaintenanceMarginDetail: type: object @@ -848,7 +910,7 @@ components: allOf: - $ref: '#/components/schemas/ObligationInfo' - type: object - required: [receives, settlement_details, maintenance_margin_details, funding_payments] + required: [receives, settlement_details, maintenance_margin_details, funding_payments, corporate_action_payments] properties: receives: type: array @@ -872,6 +934,13 @@ components: description: | Per-market, per-subtrader, per-funding-period breakdown of the funding payments settled by this obligation. + corporate_action_payments: + type: array + items: { $ref: '#/components/schemas/CorporateActionPaymentDetail' } + description: Cash and split corporate-action payments settled by this obligation, already reflected in settlement PnL. Empty when no corporate-action payments apply. + corporate_action_payments_truncated: + type: boolean + description: Set when corporate_action_payments was capped at the 1000-row inline limit; page the full set via /margin/obligations/{obligation_id}/corporate_action_payments. settlement_details_truncated: type: boolean description: Set when settlement_details was capped at the 1000-row inline limit; page the full set via /margin/obligations/{obligation_id}/settlement_details. @@ -902,7 +971,7 @@ components: type: object properties: market_ticker: { type: string } - position: { type: integer, description: Signed contract count; positive long, negative short. Can be below the reportable threshold when exit_day is true. } + position: { type: integer, description: "Signed contract count; positive long, negative short. Can be below the reportable threshold when exit_day is true." } exit_day: { type: boolean, description: True when this row is included because the same market was reportable on the prior settlement day but is now below the reportable threshold. } reportable_threshold: { type: integer, format: int64, description: 'CFTC reporting level for this market, in contracts (17 CFR 15.03(b)). A position of at least this size in either direction is reportable.' } EstimatePortfolioMaintenanceMarginPosition: @@ -910,7 +979,7 @@ components: required: [market_ticker, quantity, price] properties: market_ticker: { type: string, description: Market to include in the hypothetical portfolio. Must belong to the request's asset class. } - quantity: { type: integer, format: int64, description: Signed whole contract count; positive long, negative short. Must be non-zero. } + quantity: { type: integer, format: int64, description: "Signed whole contract count; positive long, negative short. Must be non-zero." } price: { type: string, description: 'Price of one contract in USD, to at most four decimal places (e.g. "6.8000"). Must be positive.' } EstimatePortfolioMaintenanceMarginRequest: @@ -947,7 +1016,7 @@ components: type: object required: [market_tickers, returns] properties: - market_tickers: { type: array, items: { type: string }, description: Markets covered by the matrix, in column order. } + market_tickers: { type: array, items: { type: string }, description: "Markets covered by the matrix, in column order." } returns: type: array items: { type: array, items: { type: number, format: double } } @@ -1022,6 +1091,17 @@ components: type: string description: Opaque cursor; pass as the `cursor` query param to fetch the next page. Absent when there are no more results. + GetObligationCorporateActionPaymentsResponse: + type: object + required: [corporate_action_payments] + properties: + corporate_action_payments: + type: array + items: { $ref: '#/components/schemas/CorporateActionPaymentDetail' } + cursor: + type: string + description: Opaque cursor; pass as the `cursor` query param to fetch the next page until it is absent. The final page may be empty. + MemberFundingPayment: allOf: - $ref: '#/components/schemas/FundingPaymentDetail' @@ -1062,7 +1142,7 @@ components: quantity_centicount: { type: integer, format: int64, description: Position quantity in centicounts. } variation_margin_centicents: { type: integer, format: int64, description: Variation margin for this market. } notional_value_centicents: { type: integer, format: int64, description: Notional value for this market. } - session_avg_price_fp: { type: string, description: Average entry price for the current settlement session, as a fixed-point dollar string. Omitted when the position quantity is zero. } + session_avg_price_fp: { type: string, description: "Average entry price for the current settlement session, as a fixed-point dollar string. Omitted when the position quantity is zero." } SettlementEstimate: type: object diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 9d7c4ec..a81131e 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -1809,6 +1809,20 @@ class Exclusion: http_method="GET", path_template="/margin/obligations/{obligation_id}/funding_payments", ), + MethodEndpointEntry( + sdk_method=( + "kalshi.perps.klear.resources.margin.MarginResource.corporate_action_payments" + ), + http_method="GET", + path_template="/margin/obligations/{obligation_id}/corporate_action_payments", + ), + MethodEndpointEntry( + sdk_method=( + "kalshi.perps.klear.resources.margin.MarginResource.corporate_action_payments_all" + ), + http_method="GET", + path_template="/margin/obligations/{obligation_id}/corporate_action_payments", + ), MethodEndpointEntry( sdk_method=( "kalshi.perps.klear.resources.margin.MarginResource.settlement_estimate_by_asset_class" @@ -2052,6 +2066,20 @@ class Exclusion: reason="client-side page cap, no wire counterpart", kind="client_only", ), + ( + "kalshi.perps.klear.resources.margin.MarginResource.corporate_action_payments_all", + "cursor", + ): Exclusion( + reason="cursor consumed by _list_all paginator, not a caller kwarg", + kind="paginator_handled", + ), + ( + "kalshi.perps.klear.resources.margin.MarginResource.corporate_action_payments_all", + "max_pages", + ): Exclusion( + reason="client-side page cap, no wire counterpart", + kind="client_only", + ), ( "kalshi.perps.klear.resources.margin.MarginResource.settlement_balance_history_all", "cursor", diff --git a/tests/perps/klear/test_margin.py b/tests/perps/klear/test_margin.py index 5283434..3516597 100644 --- a/tests/perps/klear/test_margin.py +++ b/tests/perps/klear/test_margin.py @@ -106,6 +106,26 @@ def _funding_payment(**overrides: object) -> dict[str, object]: return base +def _corporate_action_payment(**overrides: object) -> dict[str, object]: + base: dict[str, object] = { + "id": "cap1", + "type": "cash_dividend", + "ca_id": "ca-1", + "market_id": "00000000-0000-0000-0000-000000000001", + "market_ticker": "AAPL-PERP", + "asset_class": "Equities", + "subtrader_id": "st1", + "position_quantity_fp": "1.25", + "post_position_quantity_fp": "2.50", + "payment_amount_centicents": 400, + "payment_time": "2026-06-01T12:00:00Z", + "settlement_execution_time": "2026-06-01T16:00:00Z", + "created_ts": "2026-06-01T16:00:01Z", + } + base.update(overrides) + return base + + def _obligation(amount: int = -12345) -> dict[str, object]: return { "id": "ob1", @@ -117,6 +137,7 @@ def _obligation(amount: int = -12345) -> dict[str, object]: "execution_time": "2026-06-01T00:00:00Z", "last_updated_ts": "2026-06-01T01:00:00Z", "asset_class": "Crypto", + "corporate_action_payments": [], "receives": [ { "id": "r1", @@ -554,6 +575,127 @@ async def test_async_funding_payments(self, auth_async_klear_client: AsyncKlearC await auth_async_klear_client.close() +# --------------------------------------------------------------------------- # +# corporate_action_payments +# --------------------------------------------------------------------------- # + + +class TestCorporateActionPayments: + @respx.mock + def test_page_parses_payment(self, auth_klear_client: KlearClient) -> None: + route = respx.get(f"{BASE}/margin/obligations/ob1/corporate_action_payments").mock( + return_value=httpx.Response( + 200, + json={ + "corporate_action_payments": [ + _corporate_action_payment( + pre_split_price_centicents=68000, + post_split_price_centicents=34000, + ) + ], + "cursor": "c1", + }, + ) + ) + page = auth_klear_client.margin.corporate_action_payments("ob1", limit=10, cursor="prev") + row = page.items[0] + assert row.asset_class == "Equities" + assert row.position_quantity_fp == Decimal("1.25") + assert row.post_position_quantity_fp == Decimal("2.50") + assert row.payment_amount_centicents == 400 + assert isinstance(row.payment_amount_centicents, int) + assert not isinstance(row.payment_amount_centicents, bool) + assert row.pre_split_price_centicents == 68000 + assert row.post_split_price_centicents == 34000 + assert row.payment_time is not None + assert row.payment_time.tzinfo is not None + assert page.cursor == "c1" + params = route.calls[0].request.url.params + assert params["limit"] == "10" + assert params["cursor"] == "prev" + auth_klear_client.close() + + @respx.mock + def test_limit_over_max_raises_before_http(self, auth_klear_client: KlearClient) -> None: + route = respx.get(f"{BASE}/margin/obligations/ob1/corporate_action_payments").mock( + return_value=httpx.Response(200, json={"corporate_action_payments": []}) + ) + with pytest.raises(ValueError): + auth_klear_client.margin.corporate_action_payments("ob1", limit=1001) + assert not route.called + auth_klear_client.close() + + def test_rejects_unknown_asset_class_and_missing_payments(self) -> None: + from kalshi.perps.klear.models.margin import CorporateActionPaymentDetail + + with pytest.raises(ValidationError): + CorporateActionPaymentDetail.model_validate( + _corporate_action_payment(asset_class="FX") + ) + payload = _obligation() + del payload["corporate_action_payments"] + with pytest.raises(ValidationError): + ObligationEntry.model_validate(payload) + + @respx.mock + def test_null_payment_time_and_paginator(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/obligations/ob1/corporate_action_payments").mock( + side_effect=[ + httpx.Response( + 200, + json={ + "corporate_action_payments": [ + _corporate_action_payment(payment_time=None, type="split") + ], + "cursor": "c1", + }, + ), + httpx.Response(200, json={"corporate_action_payments": []}), + ] + ) + rows = list(auth_klear_client.margin.corporate_action_payments_all("ob1")) + assert len(rows) == 1 + assert rows[0].payment_time is None + assert rows[0].type == "split" + assert rows[0].exchange_ts is None + assert rows[0].pre_split_price_centicents is None + auth_klear_client.close() + + def test_obligation_new_asset_classes_and_truncated_flag(self) -> None: + bare = ObligationEntry.model_validate(_obligation()) + assert bare.corporate_action_payments == [] + assert bare.corporate_action_payments_truncated is None + nulled = _obligation() | {"corporate_action_payments": None} + assert ObligationEntry.model_validate(nulled).corporate_action_payments == [] + for asset_class in ("Crypto", "Equities", "Metals", "Indices", "Energy"): + parsed = ObligationEntry.model_validate( + _obligation() + | { + "asset_class": asset_class, + "corporate_action_payments": [_corporate_action_payment(payment_time=None)], + "corporate_action_payments_truncated": True, + } + ) + assert parsed.asset_class == asset_class + assert parsed.corporate_action_payments_truncated is True + assert parsed.corporate_action_payments[0].payment_time is None + with pytest.raises(ValidationError): + ObligationEntry.model_validate(_obligation() | {"asset_class": "FX"}) + + @respx.mock + async def test_async_page(self, auth_async_klear_client: AsyncKlearClient) -> None: + respx.get(f"{BASE}/margin/obligations/ob1/corporate_action_payments").mock( + return_value=httpx.Response( + 200, + json={"corporate_action_payments": [_corporate_action_payment(id="cap-async")]}, + ) + ) + page = await auth_async_klear_client.margin.corporate_action_payments("ob1") + assert page.items[0].id == "cap-async" + assert page.items[0].payment_amount_centicents == 400 + await auth_async_klear_client.close() + + # --------------------------------------------------------------------------- # # settlement_balance # --------------------------------------------------------------------------- # @@ -1018,6 +1160,25 @@ def test_requires_args(self, auth_klear_client: KlearClient) -> None: auth_klear_client.margin.estimate_maintenance_margin() auth_klear_client.close() + def test_accepts_expanded_asset_class(self) -> None: + from kalshi.perps.klear.models.margin import ( + EstimatePortfolioMaintenanceMarginPosition, + EstimatePortfolioMaintenanceMarginRequest, + ) + + position = EstimatePortfolioMaintenanceMarginPosition( + market_ticker="CL-PERP", + quantity=1, + price=Decimal("70.0000"), + ) + request = EstimatePortfolioMaintenanceMarginRequest( + asset_class="Energy", + positions=[position], + ) + assert request.asset_class == "Energy" + with pytest.raises(ValidationError): + EstimatePortfolioMaintenanceMarginRequest(asset_class="FX", positions=[position]) + @respx.mock @pytest.mark.asyncio async def test_async(self, auth_async_klear_client: AsyncKlearClient) -> None: diff --git a/tests/test_events.py b/tests/test_events.py index 3adf6c4..a996814 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -395,6 +395,43 @@ def test_list_all_multivariate(self, events: EventsResource) -> None: items = list(events.list_all_multivariate()) assert len(items) == 2 + @respx.mock + def test_list_multivariate_tickers_joined(self, events: EventsResource) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/events/multivariate").mock( + return_value=httpx.Response(200, json={"events": [], "cursor": ""}) + ) + events.list_multivariate(tickers=["MVE-A", "MVE-B"]) + assert route.calls[0].request.url.params["tickers"] == "MVE-A,MVE-B" + + @respx.mock + def test_list_multivariate_tickers_rejects_bad_elements(self, events: EventsResource) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/events/multivariate").mock( + return_value=httpx.Response(200, json={"events": [], "cursor": ""}) + ) + with pytest.raises(ValueError, match=r"tickers\[1\] is empty"): + events.list_multivariate(tickers=["MVE-A", ""]) + with pytest.raises(ValueError, match="contains a comma"): + events.list_all_multivariate(tickers=["MVE-A,MVE-B"]) + assert not route.called + + @respx.mock + def test_list_multivariate_tickers_empty_omitted_string_passthrough( + self, events: EventsResource + ) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/events/multivariate").mock( + return_value=httpx.Response( + 200, + json={"events": [event_dict(event_ticker="MVE-1")], "cursor": ""}, + ) + ) + events.list_multivariate(tickers=[]) + assert "tickers" not in route.calls[0].request.url.params + events.list_multivariate(tickers="MVE-A,MVE-B") + assert route.calls[1].request.url.params["tickers"] == "MVE-A,MVE-B" + items = list(events.list_all_multivariate(tickers=["MVE-1"])) + assert [event.event_ticker for event in items] == ["MVE-1"] + assert route.calls[2].request.url.params["tickers"] == "MVE-1" + class TestEventsListDriftFix: @respx.mock @@ -528,6 +565,15 @@ async def test_list_all_multivariate(self, async_events: AsyncEventsResource) -> tickers = [e.event_ticker async for e in async_events.list_all_multivariate()] assert tickers == ["A", "B"] + @respx.mock + @pytest.mark.asyncio + async def test_list_multivariate_tickers(self, async_events: AsyncEventsResource) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/events/multivariate").mock( + return_value=httpx.Response(200, json={"events": [], "cursor": ""}) + ) + await async_events.list_multivariate(tickers=["MVE-A", "MVE-B"]) + assert route.calls[0].request.url.params["tickers"] == "MVE-A,MVE-B" + class TestAsyncEventsFeeChanges: @respx.mock