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
Dependencies
- perps: foundation — PerpsClient/AsyncPerpsClient, PerpsConfig, base URLs, package scaffolding, and perps contract-test harness (loading
specs/perps_openapi.yaml)
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 existingkalshi/resources/markets.pypatterns (short Python field names +_dollars/_fpvalidation aliases,DollarDecimal/FixedPointCount,NullableList). All four endpoints areGET; none carry a request body. The margin-markets list is not paginated (no cursor inGetMarginMarketsResponse), so there is nolist_all()here.These operations live in the perps OpenAPI spec (
/tmp/perps_openapi.yaml, to be committed atspecs/perps_openapi.yaml), under themarkettag.Endpoints / scope
/margin/marketsGetMarginMarketsstatusquery (MarginMarketStatusenum). Returns{markets: [...]}— no cursor, no pagination → nolist_all()./margin/markets/{ticker}GetMarginMarket{market: {...}}. 404 on unknown ticker./margin/markets/{ticker}/orderbookGetMarginMarketOrderbookdepth(int ≥ 0, default 0 = all levels) andaggregation_tick_size(string, dollars) query. Returns{orderbook: {bids: [...], asks: [...]}}./margin/markets/{ticker}/candlesticksGetMarginMarketCandlesticksstart_ts,end_ts(int64 Unix seconds),period_interval(enum1/60/1440); optionalinclude_latest_before_start(bool, default false). Returns{ticker, candlesticks: [...]}.Notes that differ from the public-markets resource:
{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.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.pyReuse
from kalshi.types import DollarDecimal, FixedPointCount, NullableList. Mirror the alias style inkalshi/models/markets.py. Margin price fields areFixedPointDollars(spec$ref) →DollarDecimal; counts areFixedPointCount→FixedPointCount. REST timestamps in this slice are Unix epoch seconds integers (end_period_ts, and thestart_ts/end_tsquery params) — keep them asint(do NOT useAwareDatetimehere; the spec types theminteger, format int64).Enum —
MarginMarketStatus(spec schemaMarginMarketStatus,type: string):Use this as the
statusfield type onMarginMarketand as thestatusquery-param type onlist().MarginMarket(specMarginMarket; 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 fromPriceLevelDollarsCountFp, spec array tuple[dollars_string, fp]):price: DollarDecimalquantity: FixedPointCountmodel_config = {"extra": "allow"}price, element 1 →quantity. MirrorOrderbookLevel.)MarginOrderbook(parsed from specMarginOrderbookCount+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-nullFixedPointDollars):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 isnullable: truein the spec). Even though listed asrequired, values may be JSONnullwhen 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.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_aliasmodels in this issue.Resource methods
New file:
kalshi/perps/resources/markets.pywithPerpsMarketsResource(SyncResource)andAsyncPerpsMarketsResource(AsyncResource)(the foundation issue provides perps-specificSyncResource/AsyncResourcebase classes — or reusekalshi/resources/_base.pybases bound to aPerpsTransport; confirm against foundation). Inside resource classes annotate return lists asbuiltins.list[T](the.list()method shadows thelistbuiltin).Reuse helpers from
kalshi/resources/_base.py:_params,_seg,_bool_param. The orderbook level parsing should follow_parse_orderbookin the public markets resource but forbids/asks2-tuples.Sync signatures (async identical with
async def/await):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.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)._seg(ticker, name="ticker")for the path segment.Wire
PerpsMarketsResource/AsyncPerpsMarketsResourceintoPerpsClient.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 existingtests/test_contracts.pyloads onlyspecs/openapi.yamlvia_load_spec()/SPEC_FILE; the foundation issue owns parameterizing the harness (a_load_perps_spec()+ a perpsMETHOD_ENDPOINT_MAP/ drift parametrization). This issue adds the perps entries once that scaffolding exists.Add these
MethodEndpointEntryrows (perps map;sdk_methodFQNs are the sync class — async siblings are derived automatically viaAsync<ClassName>):BODY_MODEL_MAP: no entries — all four endpoints are GET with no request body, sorequest_body_schemastaysNoneandTestRequestBodyDriftskips them._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(mirrortests/test_markets.py). Userespx.mockand 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:listGET /margin/markets→{markets: [<MarginMarket>]}; assert parsedMarginMarketfields incl.statusenum,contract_size/tick_sizeasDecimal,leverage_estimateas float.status="active"; assert thestatusquery param is sent.leverage_estimate: nulland missing optionalprice/bid/ask/volume*→ fields default toNone; emptymarkets: []→[].get{market: {...}}parses; also accept a bare{...}(fallbackdata.get("market", data)).orderbook{orderbook: {bids: [["0.1500","100.00"]], asks: [["0.1600","50.00"]]}}→bids[0].price == Decimal("0.15"),bids[0].quantity == Decimal("100.00");tickerinjected.depth=10,aggregation_tick_size="0.10"appear in query.null/missingbids/asks→[]viaNullableList.candlesticks{ticker, candlesticks: [{end_period_ts, bid, ask, price, volume, open_interest}]}; assertbid/askOHLC parse,price.openetc. and the all-null trade case (price.*→None).start_ts/end_ts/period_intervalplusinclude_latest_before_start=Trueserialize correctly (true).period_intervalis server-rejected (mock 400) — assert it surfaces.async fornot applicable —listreturns a plain list).Acceptance criteria
PerpsMarketsResource.list/get/orderbook/candlesticksand async siblings implemented inkalshi/perps/resources/markets.py.kalshi/perps/models/markets.py:MarginMarket,MarginMarketStatusLiteral,MarginOrderbook,MarginOrderbookLevel,MarginMarketCandlestick,BidAskDistributionHistorical,PriceDistributionHistorical— usingDollarDecimal/FixedPointCount,AliasChoices,NullableList.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
specs/perps_openapi.yaml)