Repository navigation
Description
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 aPriceLevelDollarsCountFp2-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 ofyes_bid/yes_ask, and the response envelope carries a top-levelticker(not per-candle). period_intervalenum is1 | 60 | 1440(same as public);start_ts/end_ts/period_intervalare 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: strtitle: strstatus: MarginMarketStatusLiteralcontract_size: DollarDecimal— spec:type: string, "Fixed-point number with 6 decimal places". It is a dollar-style fixed-point string; useDollarDecimal(6-dp coercion). Field name on the wire iscontract_size(no_dollarssuffix) → no alias needed.tick_size: DollarDecimal— spec$ref FixedPointDollars, "Minimum price increment in dollars." Wire nametick_size; no alias.fractional_trading_enabled: boolleverage_estimate: float | None = None— spectype: number, format: double, nullable ("Null when margin config or price data is unavailable"). Plainfloat | None, defaultNone.price: DollarDecimal | None = None— last trade price (FixedPointDollars, optional in spec). Wire nameprice; 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— allFixedPointCount, optional in spec. Wire names have no_fpsuffix in this schema (volume,volume_24h,open_interest) — but to stay forward-compatible with the_fp-suffixed pattern used elsewhere, addvalidation_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}(matchMarket).
MarginOrderbookLevel (parsed from PriceLevelDollarsCountFp, spec array tuple [dollars_string, fp]):
price: DollarDecimalquantity: FixedPointCountmodel_config = {"extra": "allow"}- (Element 0 →
price, element 1 →quantity. MirrorOrderbookLevel.)
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(aliasAliasChoices("open_dollars", "open")), and likewisehigh,low,close.model_config = {"extra": "allow", "populate_by_name": True}. MirrorBidAskDistribution.
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 withvalidation_alias=AliasChoices("<name>_dollars", "<name>").model_config = {"extra": "allow", "populate_by_name": True}. MirrorPriceDistribution.- Note: this margin schema has only those 6 fields (no
min/max— those exist on the publicPriceDistributionbut NOT onPriceDistributionHistorical). Do not add them.
MarginMarketCandlestick (spec; required: end_period_ts, bid, ask, price, volume, open_interest):
end_period_ts: int(Unix seconds, int64)bid: BidAskDistributionHistoricalask: BidAskDistributionHistoricalprice: PriceDistributionHistoricalvolume: FixedPointCount(aliasAliasChoices("volume_fp", "volume"))open_interest: FixedPointCount(aliasAliasChoices("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 plainbuiltins.list[MarginMarket](NOT aPage) and has nolist_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()forlist/get/candlesticks. Fororderbook, check whether the spec marks asecurityblock (the snippet shown has none forGetMarginMarketOrderbook) — 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, sorequest_body_schemastaysNoneandTestRequestBodyDriftskips them.- EXCLUSIONS: likely none. The response-side drift checks compare model fields to the perps spec. The
_fp-suffix validation aliases onvolume/volume_24h/open_interestare accept-both aliases and should not register as drift. IfTestRequestParamDriftflagsaggregation_tick_sizeorinclude_latest_before_start(it should not — both are real spec query params), do NOT add an exclusion; fix the signature instead. Only add anExclusion(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 parsedMarginMarketfields incl.statusenum,contract_size/tick_sizeasDecimal,leverage_estimateas float. - status filter: pass
status="active"; assert thestatusquery param is sent. - edge:
leverage_estimate: nulland missing optionalprice/bid/ask/volume*→ fields default toNone; emptymarkets: []→[]. - error: 500 → mapped SDK error.
- happy: mock
get- happy:
{market: {...}}parses; also accept a bare{...}(fallbackdata.get("market", data)). - error: 404 unknown ticker → mapped error.
- happy:
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");tickerinjected. - params:
depth=10,aggregation_tick_size="0.10"appear in query. - edge:
null/missingbids/asks→[]viaNullableList.
- happy:
candlesticks- happy:
{ticker, candlesticks: [{end_period_ts, bid, ask, price, volume, open_interest}]}; assertbid/askOHLC parse,price.openetc. and the all-null trade case (price.*→None). - params: required
start_ts/end_ts/period_intervalplusinclude_latest_before_start=Trueserialize correctly (true). - error: 404 → mapped error; invalid
period_intervalis server-rejected (mock 400) — assert it surfaces.
- happy:
- async parity: at least one async test per method (
async fornot applicable —listreturns a plain list).
Acceptance criteria
-
PerpsMarketsResource.list/get/orderbook/candlesticksand async siblings implemented inkalshi/perps/resources/markets.py. - Models in
kalshi/perps/models/markets.py:MarginMarket,MarginMarketStatusLiteral,MarginOrderbook,MarginOrderbookLevel,MarginMarketCandlestick,BidAskDistributionHistorical,PriceDistributionHistorical— usingDollarDecimal/FixedPointCount,AliasChoices,NullableList. - All public models + the resource classes exported from
kalshi/perps/__init__.pyand re-exported fromkalshi/__init__.py. -
specs/perps_openapi.yamlcommitted; the four endpoints registered in the perpsMETHOD_ENDPOINT_MAP; noBODY_MODEL_MAPentries needed; EXCLUSIONS unchanged (or justified with areason). -
list()is non-paginated (nolist_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/ -vgreen, including request-param drift and response-side spec-drift tests againstspecs/perps_openapi.yaml.
Dependencies
- perps: foundation — PerpsClient/AsyncPerpsClient, PerpsConfig, base URLs, package scaffolding, and perps contract-test harness (loading
specs/perps_openapi.yaml)
Activity