Repository navigation
Description
Summary
Add Pydantic payload/envelope models and typed per-channel subscribe helpers for the six Kalshi Perps (margin) WebSocket data channels: orderbook_delta (snapshot + delta), ticker (carries perps-unique funding_rate + reference/mark tickerPrice objects), trade, fill, user_orders, and order_group_updates. These models are wire-distinct from the existing equities/event WS models — perps uses bid/ask book sides (not yes/no), single-sided price/bid/ask fields, and exclusively epoch-millisecond ts_ms timestamps — so they live under the new kalshi/perps/ws/ package rather than reusing kalshi/ws/models/.
This issue depends on the perps WS foundation (the PerpsWebSocket client, connection manager, auth, subscription manager, and dispatcher). It adds the six channel models, wires them into the dispatcher's type→model map, and adds the typed subscribe helpers on the perps WS client.
Endpoints / scope
Source: /tmp/perps_asyncapi.yaml (committed at specs/perps_asyncapi.yaml per the foundation issue). AsyncAPI v3.0.0, server wss://external-api-margin-ws.kalshi.com/trade-api/ws/v2/margin (demo wss://external-api-margin-ws.demo.kalshi.co/trade-api/ws/v2/margin).
| Channel | Direction | Payload schema(s) | Notes |
|---|---|---|---|
orderbook_delta |
server→client | marginOrderbookSnapshotPayload, marginOrderbookDeltaPayload |
Sends orderbook_snapshot first (type: orderbook_snapshot), then incremental orderbook_delta. Both type and seq REQUIRED. Snapshot levels use priceLevelDollarsCountFp = [price_in_dollars, contract_count_fp] string pairs. Requires auth + market spec via market_ticker/market_tickers. |
ticker |
server→client | marginTickerPayload |
Perps-unique: carries funding_rate (fundingRate schema) and reference_price/settlement_mark_price/liquidation_mark_price (tickerPrice schema). seq NOT required. Coalesced ≤1/market/sec. Supports send_initial_snapshot. Market spec optional. |
trade |
server→client | marginTradePayload |
Public executed trades. taker_side is bookSide (bid/ask), not yes/no. seq NOT required. Market spec optional. |
fill |
server→client | marginFillPayload |
Private; authenticated user fills. side is bookSide. Supports update_subscription add/delete markets. seq NOT required. |
user_orders |
server→client | marginUserOrderPayload |
Private order create/update. Message name: user_order (envelope type: user_order). side is bookSide. seq NOT required. |
order_group_updates |
server→client | orderGroupUpdatesPayload |
Private order-group lifecycle/limit updates. type AND seq REQUIRED (sequenced channel). Market spec ignored. |
Explicitly out of scope (document in code comments + PR body): the perps AsyncAPI defines only these six data channels. The equities WS channels market_positions, market_lifecycle_v2 / event_fee_update, multivariate, multivariate_market_lifecycle, and communications have no perps counterpart and MUST NOT get perps subscribe helpers. The foundation's per-channel param allow-set (_CHANNEL_PARAMS equivalent) must list only the six channels above.
Models
New file: kalshi/perps/ws/models/__init__.py (re-exports) plus one module per channel under kalshi/perps/ws/models/. Mirror kalshi/ws/models/ conventions: every model sets model_config = {"extra": "allow", "populate_by_name": True}; use DollarDecimal for dollar prices and FixedPointCount for _fp counts (both from kalshi.types); envelope models carry type (Literal[...]), sid: int, optional/required seq, and msg: <Payload>.
These are inbound/read models — extra="allow" per existing convention, NOT request bodies, so do NOT use extra="forbid" here. Subscribe-command payloads belong to the foundation issue.
Shared enums / literals (define once, e.g. kalshi/perps/ws/models/_common.py)
PerpsBookSide = Literal["bid", "ask"]— from schemabookSide. Note the difference from equities (yes/no); do not reusekalshi.models.orders.BookSideLiteral.PerpsSelfTradePreventionType = Literal["taker_at_cross", "maker"]— fromselfTradePreventionType.PerpsLastUpdateReason = Literal["", "Decrease", "Amend", "MarginCancel", "SelfTradeCancel", "ExpiryCancel", "Trade", "PostOnlyCrossCancel"]— fromlastUpdateReason.OrderGroupEventType = Literal["created", "triggered", "reset", "deleted", "limit_updated"]— fromorderGroupUpdatesPayload.msg.event_type.
kalshi/perps/ws/models/orderbook.py
MarginOrderbookSnapshotPayload (from marginOrderbookSnapshotPayload.msg):
market_ticker: str(required)bid,ask— arrays ofpriceLevelDollarsCountFp=[price_in_dollars, contract_count_fp]string pairs. Mirrorkalshi/ws/models/orderbook_delta.py'sPriceCountMap(Annotated[dict[Decimal, Decimal], BeforeValidator(_levels_to_dict)]) so each[price, count]pair collapses to adict[Decimal, Decimal]in one walk. Per the perps spec these two arrays are NOTrequiredonmsg, so default each to an empty map (this differs from the equities#268both-required rule — call it out in the docstring). Field aliasing: validation_alias accepting bothbid/ask(wire names already match short names, so no_dollars/_fpsuffix mismatch here).
MarginOrderbookDeltaPayload (from marginOrderbookDeltaPayload.msg):
market_ticker: str,price: DollarDecimal(required),delta: FixedPointCount(required, may be negative),side: PerpsBookSide(required)last_update_reason: PerpsLastUpdateReason | None = None,client_order_id: str | None = None,subaccount: int | None = None,ts_ms: int | None = None(epoch ms;format: int64)
MarginOrderbookSnapshotMessage: type: Literal["orderbook_snapshot"], sid: int, seq: int (required), msg: MarginOrderbookSnapshotPayload.
MarginOrderbookDeltaMessage: type: Literal["orderbook_delta"], sid: int, seq: int (required), msg: MarginOrderbookDeltaPayload.
kalshi/perps/ws/models/ticker.py
FundingRate (from fundingRate schema — perps-unique):
rate: MultiplierDecimal(wire istype: number, format: double— useMultiplierDecimalfromkalshi.typesto avoid binary-float drift per the#225invariant, matching howSeries.fee_multiplieris handled)next_funding_time_ms: int(epoch ms,format: int64),ts_ms: int(epoch ms)
TickerPrice (from tickerPrice schema):
price: DollarDecimal(wire string, 4 decimals),ts_ms: int(epoch ms)
MarginTickerPayload (from marginTickerPayload.msg):
market_ticker: str,price: DollarDecimal,bid: DollarDecimal,ask: DollarDecimal(all required; single-sided — note noyes_/no_prefixes unlike equities)bid_size: FixedPointCount(aliasbid_size_fp),ask_size: FixedPointCount(aliasask_size_fp),last_trade_size: FixedPointCount(aliaslast_trade_size_fp) — usevalidation_alias=AliasChoices("bid_size_fp", "bid_size")etc.volume: FixedPointCount,volume_24h: FixedPointCount,open_interest: FixedPointCount(wire names are bare strings, no_fpsuffix per spec — but they're fixed-point counts, so type asFixedPointCount; no alias needed)reference_price: TickerPrice | None = None,settlement_mark_price: TickerPrice | None = None,liquidation_mark_price: TickerPrice | None = None,funding_rate: FundingRate | None = Nonets_ms: int(required, epoch ms)
MarginTickerMessage: type: Literal["ticker"], sid: int, seq: int | None = None (NOT required per spec), msg: MarginTickerPayload.
kalshi/perps/ws/models/trade.py
MarginTradePayload (from marginTradePayload.msg, all listed required):
trade_id: str(format: uuid),market_ticker: str,price: DollarDecimal,count: FixedPointCount,taker_side: PerpsBookSide,ts_ms: int
MarginTradeMessage: type: Literal["trade"], sid: int, seq: int | None = None, msg: MarginTradePayload.
kalshi/perps/ws/models/fill.py
MarginFillPayload (from marginFillPayload.msg):
- Required:
trade_id: str,order_id: str,market_ticker: str,is_taker: bool,side: PerpsBookSide,ts_ms: int,price: DollarDecimal,count: FixedPointCount,fee_cost: DollarDecimal,post_position: FixedPointCount - Optional:
client_order_id: str | None = None,subaccount: int | None = None
MarginFillMessage: type: Literal["fill"], sid: int, seq: int | None = None, msg: MarginFillPayload.
kalshi/perps/ws/models/user_orders.py
MarginUserOrderPayload (from marginUserOrderPayload.msg):
- Required:
order_id: str(uuid),user_id: str(uuid),client_order_id: str,ticker: str(note field isticker, notmarket_ticker),side: PerpsBookSide,price: DollarDecimal,fill_count: FixedPointCount,remaining_count: FixedPointCount,created_ts_ms: int - Optional:
self_trade_prevention_type: PerpsSelfTradePreventionType | None = None,order_group_id: str | None = None,expiration_ts_ms: int | None = None,last_updated_ts_ms: int | None = None,subaccount_number: int | None = None
MarginUserOrderMessage: type: Literal["user_order"], sid: int, seq: int | None = None, msg: MarginUserOrderPayload.
kalshi/perps/ws/models/order_group.py
OrderGroupUpdatesPayload (from orderGroupUpdatesPayload.msg):
- Required:
event_type: OrderGroupEventType,order_group_id: str,ts_ms: int(matching-engine epoch ms) - Optional:
contracts_limit: FixedPointCount | None = None—validation_alias=AliasChoices("contracts_limit_fp", "contracts_limit")(present only forcreatedandlimit_updatedevents)
OrderGroupUpdatesMessage: type: Literal["order_group_updates"], sid: int, seq: int (REQUIRED — sequenced channel, mirror equities OrderGroupMessage), msg: OrderGroupUpdatesPayload.
Resource methods
This is WS, not REST — the "resource methods" are typed subscribe helpers on the perps WS client. Add to the perps WS client created by the foundation issue: kalshi/perps/ws/client.py (PerpsWebSocket). Mirror the async-only signatures in kalshi/ws/client.py (lines ~760–829). Each returns an AsyncIterator[<Message>]; iterate via async for msg in await client.subscribe_*(...).
async def subscribe_orderbook_delta(
self, *, tickers: builtins.list[str] | None = None, maxsize: int = 1000,
) -> AsyncIterator[MarginOrderbookSnapshotMessage | MarginOrderbookDeltaMessage]: ...
# sets params["send_initial_snapshot"] = True; market_tickers from `tickers`;
# overflow=OverflowStrategy.ERROR (gap-sensitive sequenced channel)
async def subscribe_ticker(
self, *, tickers: builtins.list[str] | None = None,
send_initial_snapshot: bool = False, maxsize: int = 1000,
) -> AsyncIterator[MarginTickerMessage]: ...
# market_tickers from `tickers`; overflow=DROP_OLDEST
async def subscribe_trade(
self, *, tickers: builtins.list[str] | None = None, maxsize: int = 1000,
) -> AsyncIterator[MarginTradeMessage]: ... # overflow=DROP_OLDEST
async def subscribe_fill(
self, *, tickers: builtins.list[str] | None = None, maxsize: int = 1000,
) -> AsyncIterator[MarginFillMessage]: ... # overflow=DROP_OLDEST
async def subscribe_user_orders(
self, *, tickers: builtins.list[str] | None = None, maxsize: int = 1000,
) -> AsyncIterator[MarginUserOrderMessage]: ... # overflow=DROP_OLDEST
async def subscribe_order_group(
self, *, maxsize: int = 1000,
) -> AsyncIterator[OrderGroupUpdatesMessage]: ... # market spec ignored; overflow=ERRORUse builtins.list[T] for the tickers param annotation inside the client class (the list builtin is shadowed by .list() elsewhere in resource classes; apply the same rule here for consistency). No list_all()/cursor pagination — WS streams are unbounded iterators, not paged.
Dispatcher wiring (in the foundation's perps dispatch module, mirror kalshi/ws/dispatch.py MESSAGE_MODELS): register the envelope type → model map:
"orderbook_snapshot" → MarginOrderbookSnapshotMessage, "orderbook_delta" → MarginOrderbookDeltaMessage, "ticker" → MarginTickerMessage, "trade" → MarginTradeMessage, "fill" → MarginFillMessage, "user_order" → MarginUserOrderMessage, "order_group_updates" → OrderGroupUpdatesMessage. The orderbook channel feeds the perps OrderbookManager; the sequenced channels (orderbook_delta, order_group_updates) participate in the seq-watermark / gap-recovery path.
Contract-test wiring
The Kalshi REST contract harness (METHOD_ENDPOINT_MAP / BODY_MODEL_MAP) does not cover WebSocket messages — there are no WS entries in those maps today, so no METHOD_ENDPOINT_MAP / BODY_MODEL_MAP / EXCLUSIONS rows are added by this issue.
Instead add a perps-WS payload-parity test that asserts each model parses the AsyncAPI example/required fields, loading the committed specs/perps_asyncapi.yaml:
- New
tests/perps/ws/test_perps_ws_models.py. For eachcomponentsmessagespayload schema (marginOrderbookSnapshotPayload,marginOrderbookDeltaPayload,marginTickerPayload,marginTradePayload,marginFillPayload,marginUserOrderPayload,orderGroupUpdatesPayload), build a fixture frame from the schema'srequiredfields + the spec's inlineexamples(e.g. theorderGroupLimitUpdatedexample atorderGroupUpdatesmessage:{type: order_group_updates, sid: 21, seq: 7, msg: {event_type: limit_updated, order_group_id: og_123, contracts_limit_fp: "150.00"}}) and assert the correspondingMargin*Messagemodel validates it and that decimals land asDecimal. - A drift guard that fails if the AsyncAPI adds a
requiredfield on any owned payload schema that the model lacks (iteraterequiredlists againstmodel_fields) — mirrors the spirit ofTestRequestParamDriftbut for inbound WS payloads. Confirm the exact harness shape with the foundation issue (it commits the spec + the perps test package); if the foundation defines a shared WS-drift base, extend it rather than duplicating.
Tests
tests/perps/ws/ — reuse conftest RSA-key/auth/config fixtures; use respx.mock only where the foundation's connection mock needs HTTP. Per channel:
subscribe_orderbook_delta— happy: snapshot frame yieldsMarginOrderbookSnapshotMessagewithbid/askasdict[Decimal, Decimal], then a delta frame withside="bid", negativedelta; error: malformed snapshot (non-numeric level) raisesValidationError; edge: snapshot withbid/askomitted → empty maps (perps allows partial book, unlike equities#268).subscribe_ticker— happy: ticker withfunding_rate+ all threetickerPricemark fields parses;ratelands asDecimal(no float drift); edge: ticker withfunding_rate/mark prices absent → allNone;seqabsent →None; error: missing requiredts_msraises.subscribe_trade— happy:taker_side="ask",countDecimal,ts_msint; error:taker_side="yes"(equities value) rejected byPerpsBookSide; edge:trade_iduuid string accepted.subscribe_fill— happy: taker fill withfee_cost/post_positiondecimals; edge: optionalclient_order_id/subaccountomitted; error: missingpost_positionraises.subscribe_user_orders— happy: order withorder_group_id+self_trade_prevention_type="maker"; edge:tickerfield (notmarket_ticker) maps correctly; optional*_ts_msomitted; error: missing requiredcreated_ts_msraises.subscribe_order_group— happy:event_type="limit_updated"withcontracts_limit_fp="150.00"→contracts_limitDecimal; edge:event_type="deleted"withcontracts_limitabsent →None; error:seqomitted raises (required on this channel);event_type="foo"rejected.- Out-of-scope guard: assert
PerpsWebSockethas nosubscribe_market_positions/subscribe_multivariate/subscribe_communications/subscribe_market_lifecycleattributes.
Acceptance criteria
- Six payload modules created under
kalshi/perps/ws/models/with all envelope + payload models and shared enums above, matching spec field/required-ness exactly. - Perps book sides typed
Literal["bid", "ask"](NOT reusing equitiesyes/no); all timestamps arets_ms/*_ts_msepoch-ms ints;funding_rate.rateusesMultiplierDecimal. - Six typed
subscribe_*helpers onPerpsWebSocketreturningAsyncIterator[Margin*Message];builtins.list[T]used fortickersparams. - Dispatcher type→model map registers all seven envelope types; sequenced channels (
orderbook_delta,order_group_updates) requireseq. - Models exported from
kalshi/perps/ws/models/__init__.py→kalshi/perps/__init__.py→kalshi/__init__.py(Margin*Message/*Payload,FundingRate,TickerPrice, and the four enums). -
uv run mypy kalshi/strict clean (incl.builtins.list[T]inside the client class). -
uv run ruff check .clean. -
uv run pytest tests/perps/ws/ -vgreen, incl. the payload-parity + drift guard; fulluv run pytest tests/ -vgreen (no regression to existing equities WS drift tests). - Out-of-scope channels (
market_positions,market_lifecycle_v2/event_fee_update,multivariate,multivariate_market_lifecycle,communications) documented as having no perps counterpart and given no subscribe helpers.
Dependencies
- perps: WebSocket foundation — PerpsWebSocket client, connection, auth, subscribe/dispatch machinery (provides the WS client, connection manager, subscription manager, dispatcher with the type→model registry, the perps WS base URL /
PerpsConfig, reuse ofKalshiAuth, the committedspecs/perps_asyncapi.yaml, and thetests/perps/ws/package + any shared WS-drift test base this issue extends).
Activity
perps(ws): address PR #403 bot review — model-built orphan-unsubscrib…