Skip to content

perps: WebSocket channels & payload models — orderbook_delta, ticker(+funding), trade, fill, user_orders, order_group_updates #398

Closed

Description

@TexasCoding

Epic: part of #387 · Depends on: #397

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 schema bookSide. Note the difference from equities (yes/no); do not reuse kalshi.models.orders.BookSideLiteral.
  • PerpsSelfTradePreventionType = Literal["taker_at_cross", "maker"] — from selfTradePreventionType.
  • PerpsLastUpdateReason = Literal["", "Decrease", "Amend", "MarginCancel", "SelfTradeCancel", "ExpiryCancel", "Trade", "PostOnlyCrossCancel"] — from lastUpdateReason.
  • OrderGroupEventType = Literal["created", "triggered", "reset", "deleted", "limit_updated"] — from orderGroupUpdatesPayload.msg.event_type.

kalshi/perps/ws/models/orderbook.py

MarginOrderbookSnapshotPayload (from marginOrderbookSnapshotPayload.msg):

  • market_ticker: str (required)
  • bid, ask — arrays of priceLevelDollarsCountFp = [price_in_dollars, contract_count_fp] string pairs. Mirror kalshi/ws/models/orderbook_delta.py's PriceCountMap (Annotated[dict[Decimal, Decimal], BeforeValidator(_levels_to_dict)]) so each [price, count] pair collapses to a dict[Decimal, Decimal] in one walk. Per the perps spec these two arrays are NOT required on msg, so default each to an empty map (this differs from the equities #268 both-required rule — call it out in the docstring). Field aliasing: validation_alias accepting both bid/ask (wire names already match short names, so no _dollars/_fp suffix 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 is type: number, format: double — use MultiplierDecimal from kalshi.types to avoid binary-float drift per the #225 invariant, matching how Series.fee_multiplier is 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 no yes_/no_ prefixes unlike equities)
  • bid_size: FixedPointCount (alias bid_size_fp), ask_size: FixedPointCount (alias ask_size_fp), last_trade_size: FixedPointCount (alias last_trade_size_fp) — use validation_alias=AliasChoices("bid_size_fp", "bid_size") etc.
  • volume: FixedPointCount, volume_24h: FixedPointCount, open_interest: FixedPointCount (wire names are bare strings, no _fp suffix per spec — but they're fixed-point counts, so type as FixedPointCount; no alias needed)
  • reference_price: TickerPrice | None = None, settlement_mark_price: TickerPrice | None = None, liquidation_mark_price: TickerPrice | None = None, funding_rate: FundingRate | None = None
  • ts_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 is ticker, not market_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 for created and limit_updated events)

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=ERROR

Use 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 each componentsmessages payload schema (marginOrderbookSnapshotPayload, marginOrderbookDeltaPayload, marginTickerPayload, marginTradePayload, marginFillPayload, marginUserOrderPayload, orderGroupUpdatesPayload), build a fixture frame from the schema's required fields + the spec's inline examples (e.g. the orderGroupLimitUpdated example at orderGroupUpdates message: {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 corresponding Margin*Message model validates it and that decimals land as Decimal.
  • A drift guard that fails if the AsyncAPI adds a required field on any owned payload schema that the model lacks (iterate required lists against model_fields) — mirrors the spirit of TestRequestParamDrift but 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 yields MarginOrderbookSnapshotMessage with bid/ask as dict[Decimal, Decimal], then a delta frame with side="bid", negative delta; error: malformed snapshot (non-numeric level) raises ValidationError; edge: snapshot with bid/ask omitted → empty maps (perps allows partial book, unlike equities #268).
  • subscribe_ticker — happy: ticker with funding_rate + all three tickerPrice mark fields parses; rate lands as Decimal (no float drift); edge: ticker with funding_rate/mark prices absent → all None; seq absent → None; error: missing required ts_ms raises.
  • subscribe_trade — happy: taker_side="ask", count Decimal, ts_ms int; error: taker_side="yes" (equities value) rejected by PerpsBookSide; edge: trade_id uuid string accepted.
  • subscribe_fill — happy: taker fill with fee_cost/post_position decimals; edge: optional client_order_id/subaccount omitted; error: missing post_position raises.
  • subscribe_user_orders — happy: order with order_group_id + self_trade_prevention_type="maker"; edge: ticker field (not market_ticker) maps correctly; optional *_ts_ms omitted; error: missing required created_ts_ms raises.
  • subscribe_order_group — happy: event_type="limit_updated" with contracts_limit_fp="150.00" → contracts_limit Decimal; edge: event_type="deleted" with contracts_limit absent → None; error: seq omitted raises (required on this channel); event_type="foo" rejected.
  • Out-of-scope guard: assert PerpsWebSocket has no subscribe_market_positions / subscribe_multivariate / subscribe_communications / subscribe_market_lifecycle attributes.

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 equities yes/no); all timestamps are ts_ms/*_ts_ms epoch-ms ints; funding_rate.rate uses MultiplierDecimal.
  • Six typed subscribe_* helpers on PerpsWebSocket returning AsyncIterator[Margin*Message]; builtins.list[T] used for tickers params.
  • Dispatcher type→model map registers all seven envelope types; sequenced channels (orderbook_delta, order_group_updates) require seq.
  • 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/ -v green, incl. the payload-parity + drift guard; full uv run pytest tests/ -v green (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 of KalshiAuth, the committed specs/perps_asyncapi.yaml, and the tests/perps/ws/ package + any shared WS-drift test base this issue extends).

Activity

  1. added this to the Perps API milestone on Jun 4, 2026
  2. added
    enhancementNew feature or request
    wsWebSocket-related
    perpsPerps / margin (perpetual futures) API
    on Jun 4, 2026
  3. added a commit that references this issue on Jun 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestperpsPerps / margin (perpetual futures) APIwsWebSocket-related

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions