Skip to content

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

Closed

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 FixedPointCount → FixedPointCount. 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)

Activity

  1. added this to the Perps API milestone on Jun 4, 2026
  2. added
    enhancementNew feature or request
    perpsPerps / margin (perpetual futures) API
    on Jun 4, 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) API

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions