Skip to content

perps: markets resource — list/get, orderbook, candlesticks #390

Description

@TexasCoding

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

Summary

Implement the read-only margin (PERPS) markets surface on the new standalone PerpsClient / AsyncPerpsClient: list margin markets, fetch one by ticker, fetch its orderbook, and fetch its candlesticks. This is the first market-data slice of the perps API and mirrors the existing kalshi/resources/markets.py patterns (short Python field names + _dollars/_fp validation aliases, DollarDecimal/FixedPointCount, NullableList). All four endpoints are GET; none carry a request body. The margin-markets list is not paginated (no cursor in GetMarginMarketsResponse), so there is no list_all() here.

These operations live in the perps OpenAPI spec (/tmp/perps_openapi.yaml, to be committed at specs/perps_openapi.yaml), under the market tag.

Endpoints / scope

Method Path operationId Notes
GET /margin/markets GetMarginMarkets Optional status query (MarginMarketStatus enum). Returns {markets: [...]} — no cursor, no pagination → no list_all().
GET /margin/markets/{ticker} GetMarginMarket Returns {market: {...}}. 404 on unknown ticker.
GET /margin/markets/{ticker}/orderbook GetMarginMarketOrderbook Optional depth (int ≥ 0, default 0 = all levels) and aggregation_tick_size (string, dollars) query. Returns {orderbook: {bids: [...], asks: [...]}}.
GET /margin/markets/{ticker}/candlesticks GetMarginMarketCandlesticks Required start_ts, end_ts (int64 Unix seconds), period_interval (enum 1/60/1440); optional include_latest_before_start (bool, default false). Returns {ticker, candlesticks: [...]}.

Notes that differ from the public-markets resource:

  • Orderbook is {bids, asks} (margin), not {yes, no}. Each level is a PriceLevelDollarsCountFp 2-tuple [dollars_string, fp_count_string] where element 0 is the price in dollars and element 1 is the contract quantity (FixedPointCount), not a price.
  • Candlesticks use bid/ask (margin) instead of yes_bid/yes_ask, and the response envelope carries a top-level ticker (not per-candle).
  • period_interval enum is 1 | 60 | 1440 (same as public); start_ts/end_ts/period_interval are all required here (public makes them required too, but confirm in signature).

Models

New file: kalshi/perps/models/markets.py

Reuse from kalshi.types import DollarDecimal, FixedPointCount, NullableList. Mirror the alias style in kalshi/models/markets.py. Margin price fields are FixedPointDollars (spec $ref) → DollarDecimal; counts are FixedPointCountFixedPointCount. REST timestamps in this slice are Unix epoch seconds integers (end_period_ts, and the start_ts/end_ts query params) — keep them as int (do NOT use AwareDatetime here; the spec types them integer, format int64).

Enum — MarginMarketStatus (spec schema MarginMarketStatus, type: string):

MarginMarketStatusLiteral = Literal["inactive", "active", "closed"]

Use this as the status field type on MarginMarket and as the status query-param type on list().

MarginMarket (spec MarginMarket; required: ticker, status, title, contract_size, tick_size, fractional_trading_enabled):

  • ticker: str
  • title: str
  • status: MarginMarketStatusLiteral
  • contract_size: DollarDecimal — spec: type: string, "Fixed-point number with 6 decimal places". It is a dollar-style fixed-point string; use DollarDecimal (6-dp coercion). Field name on the wire is contract_size (no _dollars suffix) → no alias needed.
  • tick_size: DollarDecimal — spec $ref FixedPointDollars, "Minimum price increment in dollars." Wire name tick_size; no alias.
  • fractional_trading_enabled: bool
  • leverage_estimate: float | None = None — spec type: number, format: double, nullable ("Null when margin config or price data is unavailable"). Plain float | None, default None.
  • price: DollarDecimal | None = None — last trade price (FixedPointDollars, optional in spec). Wire name price; no alias.
  • bid: DollarDecimal | None = None, ask: DollarDecimal | None = None — best bid/ask (FixedPointDollars, optional).
  • volume: FixedPointCount | None = None, volume_24h: FixedPointCount | None = None, open_interest: FixedPointCount | None = None — all FixedPointCount, optional in spec. Wire names have no _fp suffix in this schema (volume, volume_24h, open_interest) — but to stay forward-compatible with the _fp-suffixed pattern used elsewhere, add validation_alias=AliasChoices("volume_fp", "volume") etc. so both forms validate. (If the spec only emits bare names, the alias is harmless.)
  • model_config = {"extra": "allow", "populate_by_name": True} (match Market).

MarginOrderbookLevel (parsed from PriceLevelDollarsCountFp, spec array tuple [dollars_string, fp]):

  • price: DollarDecimal
  • quantity: FixedPointCount
  • model_config = {"extra": "allow"}
  • (Element 0 → price, element 1 → quantity. Mirror OrderbookLevel.)

MarginOrderbook (parsed from spec MarginOrderbookCount + MarginOrderbookResponse):

  • ticker: str (injected by the resource from the path arg; not on the wire)
  • bids: NullableList[MarginOrderbookLevel] = []
  • asks: NullableList[MarginOrderbookLevel] = []
  • model_config = {"extra": "allow"}

BidAskDistributionHistorical (spec; required: open, low, high, close — all non-null FixedPointDollars):

  • open: DollarDecimal (alias AliasChoices("open_dollars", "open")), and likewise high, low, close.
  • model_config = {"extra": "allow", "populate_by_name": True}. Mirror BidAskDistribution.

PriceDistributionHistorical (spec; required: open, low, high, close, mean, previous — but every field is nullable: true in the spec). Even though listed as required, values may be JSON null when no trades occurred:

  • open/low/high/close/mean/previous: DollarDecimal | None = None, each with validation_alias=AliasChoices("<name>_dollars", "<name>").
  • model_config = {"extra": "allow", "populate_by_name": True}. Mirror PriceDistribution.
  • Note: this margin schema has only those 6 fields (no min/max — those exist on the public PriceDistribution but NOT on PriceDistributionHistorical). Do not add them.

MarginMarketCandlestick (spec; required: end_period_ts, bid, ask, price, volume, open_interest):

  • end_period_ts: int (Unix seconds, int64)
  • bid: BidAskDistributionHistorical
  • ask: BidAskDistributionHistorical
  • price: PriceDistributionHistorical
  • volume: FixedPointCount (alias AliasChoices("volume_fp", "volume"))
  • open_interest: FixedPointCount (alias AliasChoices("open_interest_fp", "open_interest"))
  • model_config = {"extra": "allow", "populate_by_name": True}

No request models needed (all GET, no bodies) — so no extra="forbid" / serialization_alias models in this issue.

Resource methods

New file: kalshi/perps/resources/markets.py with PerpsMarketsResource(SyncResource) and AsyncPerpsMarketsResource(AsyncResource) (the foundation issue provides perps-specific SyncResource/AsyncResource base classes — or reuse kalshi/resources/_base.py bases bound to a PerpsTransport; confirm against foundation). Inside resource classes annotate return lists as builtins.list[T] (the .list() method shadows the list builtin).

Reuse helpers from kalshi/resources/_base.py: _params, _seg, _bool_param. The orderbook level parsing should follow _parse_orderbook in the public markets resource but for bids/asks 2-tuples.

Sync signatures (async identical with async def / await):

def list(
    self,
    *,
    status: MarginMarketStatusLiteral | None = None,
    extra_headers: dict[str, str] | None = None,
) -> builtins.list[MarginMarket]: ...
# GET /margin/markets ; parse data["markets"]; NO pagination (no cursor in spec).

def get(
    self, ticker: str, *, extra_headers: dict[str, str] | None = None
) -> MarginMarket: ...
# GET /margin/markets/{ticker} ; parse data.get("market", data).

def orderbook(
    self,
    ticker: str,
    *,
    depth: int | None = None,
    aggregation_tick_size: str | None = None,
    extra_headers: dict[str, str] | None = None,
) -> MarginOrderbook: ...
# GET /margin/markets/{ticker}/orderbook
# params via _params(depth=depth, aggregation_tick_size=aggregation_tick_size)
# parse data["orderbook"] {bids, asks}; build MarginOrderbookLevel(price=pair[0], quantity=pair[1]); inject ticker.

def candlesticks(
    self,
    ticker: str,
    *,
    start_ts: int,
    end_ts: int,
    period_interval: int,
    include_latest_before_start: bool | None = None,
    extra_headers: dict[str, str] | None = None,
) -> builtins.list[MarginMarketCandlestick]: ...
# GET /margin/markets/{ticker}/candlesticks
# params via _params(start_ts=..., end_ts=..., period_interval=...,
#   include_latest_before_start=_bool_param(include_latest_before_start))
# parse data["candlesticks"].

Notes:

  • list() returns a plain builtins.list[MarginMarket] (NOT a Page) and has no list_all() / AsyncIterator — the response has no cursor. Flag this divergence from the public markets resource explicitly so reviewers don't expect pagination.
  • The margin endpoints are public/market data; do NOT call self._require_auth() for list/get/candlesticks. For orderbook, check whether the spec marks a security block (the snippet shown has none for GetMarginMarketOrderbook) — if absent, do NOT require auth (unlike the public-markets orderbook which does).
  • Use _seg(ticker, name="ticker") for the path segment.

Wire PerpsMarketsResource / AsyncPerpsMarketsResource into PerpsClient.markets / AsyncPerpsClient.markets (the client classes come from the foundation issue).

Contract-test wiring

The perps contract harness must load specs/perps_openapi.yaml (committed from /tmp/perps_openapi.yaml). The existing tests/test_contracts.py loads only specs/openapi.yaml via _load_spec() / SPEC_FILE; the foundation issue owns parameterizing the harness (a _load_perps_spec() + a perps METHOD_ENDPOINT_MAP / drift parametrization). This issue adds the perps entries once that scaffolding exists.

Add these MethodEndpointEntry rows (perps map; sdk_method FQNs are the sync class — async siblings are derived automatically via Async<ClassName>):

MethodEndpointEntry(
    sdk_method="kalshi.perps.resources.markets.PerpsMarketsResource.list",
    http_method="GET",
    path_template="/margin/markets",
),
MethodEndpointEntry(
    sdk_method="kalshi.perps.resources.markets.PerpsMarketsResource.get",
    http_method="GET",
    path_template="/margin/markets/{ticker}",
),
MethodEndpointEntry(
    sdk_method="kalshi.perps.resources.markets.PerpsMarketsResource.orderbook",
    http_method="GET",
    path_template="/margin/markets/{ticker}/orderbook",
),
MethodEndpointEntry(
    sdk_method="kalshi.perps.resources.markets.PerpsMarketsResource.candlesticks",
    http_method="GET",
    path_template="/margin/markets/{ticker}/candlesticks",
),
  • BODY_MODEL_MAP: no entries — all four endpoints are GET with no request body, so request_body_schema stays None and TestRequestBodyDrift skips them.
  • EXCLUSIONS: likely none. The response-side drift checks compare model fields to the perps spec. The _fp-suffix validation aliases on volume/volume_24h/open_interest are accept-both aliases and should not register as drift. If TestRequestParamDrift flags aggregation_tick_size or include_latest_before_start (it should not — both are real spec query params), do NOT add an exclusion; fix the signature instead. Only add an Exclusion(reason=..., kind=...) for a genuine, justified deviation.

Tests

New file: tests/perps/test_markets.py (mirror tests/test_markets.py). Use respx.mock and reuse the conftest RSA-key/config fixtures (perps client should be constructible from the same RSA fixtures with the perps base URL). Per public method:

  • list
    • happy: mock GET /margin/markets{markets: [<MarginMarket>]}; assert parsed MarginMarket fields incl. status enum, contract_size/tick_size as Decimal, leverage_estimate as float.
    • status filter: pass status="active"; assert the status query param is sent.
    • edge: leverage_estimate: null and missing optional price/bid/ask/volume* → fields default to None; empty markets: [][].
    • error: 500 → mapped SDK error.
  • get
    • happy: {market: {...}} parses; also accept a bare {...} (fallback data.get("market", data)).
    • error: 404 unknown ticker → mapped error.
  • orderbook
    • happy: {orderbook: {bids: [["0.1500","100.00"]], asks: [["0.1600","50.00"]]}}bids[0].price == Decimal("0.15"), bids[0].quantity == Decimal("100.00"); ticker injected.
    • params: depth=10, aggregation_tick_size="0.10" appear in query.
    • edge: null/missing bids/asks[] via NullableList.
  • candlesticks
    • happy: {ticker, candlesticks: [{end_period_ts, bid, ask, price, volume, open_interest}]}; assert bid/ask OHLC parse, price.open etc. and the all-null trade case (price.*None).
    • params: required start_ts/end_ts/period_interval plus include_latest_before_start=True serialize correctly (true).
    • error: 404 → mapped error; invalid period_interval is server-rejected (mock 400) — assert it surfaces.
  • async parity: at least one async test per method (async for not applicable — list returns a plain list).

Acceptance criteria

  • PerpsMarketsResource.list/get/orderbook/candlesticks and async siblings implemented in kalshi/perps/resources/markets.py.
  • Models in kalshi/perps/models/markets.py: MarginMarket, MarginMarketStatusLiteral, MarginOrderbook, MarginOrderbookLevel, MarginMarketCandlestick, BidAskDistributionHistorical, PriceDistributionHistorical — using DollarDecimal/FixedPointCount, AliasChoices, NullableList.
  • All public models + the resource classes exported from kalshi/perps/__init__.py and re-exported from kalshi/__init__.py.
  • specs/perps_openapi.yaml committed; the four endpoints registered in the perps METHOD_ENDPOINT_MAP; no BODY_MODEL_MAP entries needed; EXCLUSIONS unchanged (or justified with a reason).
  • list() is non-paginated (no list_all/cursor); divergence from public markets noted in a docstring.
  • uv run mypy kalshi/ strict clean (builtins.list[T] inside resource classes).
  • uv run ruff check . clean.
  • uv run pytest tests/ -v green, including request-param drift and response-side spec-drift tests against specs/perps_openapi.yaml.

Dependencies

  • perps: foundation — PerpsClient/AsyncPerpsClient, PerpsConfig, base URLs, package scaffolding, and perps contract-test harness (loading specs/perps_openapi.yaml)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestperpsPerps / margin (perpetual futures) API

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions