Repository navigation
Description
Summary
Implement the three margin (PERPS) portfolio/market read endpoints on the new standalone PerpsClient / AsyncPerpsClient: account margin positions, the authenticated user's margin fills, and public margin trades for a ticker. Fills and trades responses carry a cursor, so they get _all() auto-pagination (sync Iterator + async AsyncIterator); positions is a single non-paginated array. All three are GET, so they reuse the existing retry/error machinery unchanged.
This mirrors the existing kalshi/resources/portfolio.py family but lives under the new kalshi/perps/ packages against the perps OpenAPI spec (specs/perps_openapi.yaml).
Endpoints / scope
| Method | Path | operationId | Notes |
|---|---|---|---|
| GET | /margin/positions |
GetMarginPositions |
Auth required. Query: subaccount (int, default 0), ticker (optional). Response positions array has no cursor — single page only, no _all(). |
| GET | /margin/fills |
GetMarginFills |
Auth required. Query: subaccount (int, default 0), limit (1–1000, default 100), cursor, min_ts (int64), max_ts (int64). Cursor-paginated → fills_all(). |
| GET | /margin/trades |
GetMarginTrades |
Public (no auth in spec — tag market). Query: ticker (required), limit (1–1000, default 100), cursor, min_ts (int64), max_ts (int64). Cursor-paginated → trades_all(). |
Auth note: GetMarginPositions and GetMarginFills declare kalshiAccessKey/Signature/Timestamp; GetMarginTrades declares no security. Positions/fills methods call self._require_auth(); trades does not (it is a public market read, like MarketsResource.list_trades).
Base URL comes from PerpsConfig (foundation): prod https://external-api.kalshi.com/trade-api/v2, demo https://external-api.demo.kalshi.co/trade-api/v2.
Models
New file: kalshi/perps/models/portfolio.py
Import the shared types from kalshi.types (DollarDecimal, FixedPointCount) and kalshi.models.common.Page. All response models use model_config = {"extra": "allow"} to tolerate additive spec fields, matching the existing portfolio models.
BookSideLiteral
Spec BookSide is enum: [bid, ask]. Define as a module-level alias:
BookSideLiteral = Literal["bid", "ask"](Mirror SettlementStatusLiteral in kalshi/models/portfolio.py — a Literal, not a class.)
OrderSourceLiteral
MarginFill.order_source references spec OrderSource (enum: [user, system]; system = liquidation order). Optional field. Define:
OrderSourceLiteral = Literal["user", "system"]MarginPosition
Schema MarginPosition. All required keys are non-nullable except roe. Spec required: market_ticker, position, entry_price, unrealized_pnl, margin_used, fees.
| Python field | Type | Wire name / alias | Notes |
|---|---|---|---|
market_ticker |
str |
market_ticker |
|
position |
FixedPointCount |
position (spec key is position, a FixedPointCount ref — no _fp suffix here, so a plain field, no AliasChoices) |
positive = long, negative = short |
entry_price |
DollarDecimal |
entry_price (spec key, FixedPointDollars ref — no _dollars suffix, plain field) |
weighted avg entry |
unrealized_pnl |
DollarDecimal |
unrealized_pnl |
mark-to-market; may be negative |
margin_used |
DollarDecimal |
margin_used |
maintenance-margin capital usage |
fees |
DollarDecimal |
fees |
lifetime fees on current open position |
return_on_equity |
float | None = None |
validation_alias=AliasChoices("roe", "return_on_equity") |
spec field is roe (type: number, format: double, nullable: true); null when margin_used is zero. Expose under the friendlier Python name return_on_equity per the issue brief, mapping the wire roe via alias. Set populate_by_name=True. Plain float (matches spec format: double; it's a percentage ratio, not a money/count fixed-point string). |
Important — no _dollars/_fp aliases on this schema. Unlike the v2 MarketPosition (which uses position_fp/*_dollars wire names), the perps spec names these keys plainly (position, entry_price, unrealized_pnl, margin_used, fees) and types them via $ref: FixedPointCount / $ref: FixedPointDollars. So use bare FixedPointCount / DollarDecimal fields with no AliasChoices — adding _dollars aliases here would be wrong and would diverge from the wire. The only aliased field is roe → return_on_equity.
GetMarginPositionsResponse
Schema GetMarginPositionsResponse. required: [positions].
positions: NullableList[MarginPosition] # use NullableList from kalshi.types (tolerate server null)No cursor field. No has_next.
MarginFill
Schema MarginFill. required: [fill_id, order_id, is_taker, side, count, created_time, ticker, price, entry_price, fees, realized_pnl].
| Python field | Type | Notes |
|---|---|---|
fill_id |
str |
|
order_id |
str |
order that generated the fill |
is_taker |
bool |
|
side |
BookSideLiteral |
spec side → BookSide enum |
count |
FixedPointCount |
spec count → FixedPointCount ref, plain field |
created_time |
AwareDatetime |
spec format: date-time (RFC3339) — REST timestamp, NOT _ms/epoch. Use pydantic.AwareDatetime, like MarketPosition.last_updated_ts. |
ticker |
str |
|
price |
DollarDecimal |
spec type: string "fixed-point dollars" — plain field, no alias |
entry_price |
DollarDecimal |
"Position entry price used to compute incremental realized PnL" |
fees |
DollarDecimal |
dollars |
realized_pnl |
DollarDecimal |
incremental realized PnL, fixed-point dollars; may be negative |
order_source |
OrderSourceLiteral | None = None |
optional (not in required) |
GetMarginFillsResponse
Schema GetMarginFillsResponse. required: [fills, cursor].
fills: NullableList[MarginFill]
cursor: str | None = None
@property
def has_next(self) -> bool: return bool(self.cursor)MarginTrade
Schema MarginTrade. required: [trade_id, ticker, count, price, created_time, taker_side].
| Python field | Type | Notes |
|---|---|---|
trade_id |
str |
|
ticker |
str |
|
count |
FixedPointCount |
spec count → FixedPointCount ref |
price |
DollarDecimal |
spec type: string "Trade price in dollars" |
created_time |
AwareDatetime |
spec format: date-time (RFC3339) — REST timestamp, not _ms |
taker_side |
BookSideLiteral |
spec taker_side → BookSide enum |
GetMarginTradesResponse
Schema GetMarginTradesResponse. required: [trades, cursor].
trades: NullableList[MarginTrade]
cursor: str | None = None
@property
def has_next(self) -> bool: return bool(self.cursor)Timestamp note: every timestamp in these three schemas is RFC3339 format: date-time (AwareDatetime). There are no WS _ms epoch-millisecond fields in this issue — the _ms convention applies only to perps WebSocket payloads (separate issues). No request models are needed (all three endpoints are GET with query params only), so there is no extra="forbid" / serialization_alias work here.
Resource methods
New file: kalshi/perps/resources/portfolio.py — define both PerpsPortfolioResource(SyncResource) and AsyncPerpsPortfolioResource(AsyncResource), importing SyncResource, AsyncResource, _params, _validate_limit, _validate_max_pages from kalshi.resources._base (the foundation issue must confirm these base helpers are import-reusable from perps resources). Reuse Page semantics via self._list(...) / self._list_all(...) for the cursor endpoints and a plain self._get(...) + model_validate for positions.
Inside these classes the list builtin is shadowed by .list()-style names; any local list[T] annotation must be written builtins.list[T].
A small private param builder (mirroring _fills_params in kalshi/resources/portfolio.py) for the fills/trades query set is fine; keep it local to the module.
Sync — PerpsPortfolioResource
def positions(
self, *, subaccount: int | None = None, ticker: str | None = None,
extra_headers: dict[str, str] | None = None,
) -> GetMarginPositionsResponse: ...
# self._require_auth(); GET /margin/positions; return GetMarginPositionsResponse.model_validate(data)
def fills(
self, *, subaccount: int | None = None, min_ts: int | None = None, max_ts: int | None = None,
limit: int | None = None, cursor: str | None = None,
extra_headers: dict[str, str] | None = None,
) -> Page[MarginFill]: ...
# self._require_auth(); _validate_limit(limit, hi=1000); self._list("/margin/fills", MarginFill, "fills", ...)
def fills_all(
self, *, subaccount: int | None = None, min_ts: int | None = None, max_ts: int | None = None,
limit: int | None = None, max_pages: int | None = None,
extra_headers: dict[str, str] | None = None,
) -> Iterator[MarginFill]: ...
# self._require_auth(); _validate_max_pages(max_pages); cursor=None; self._list_all("/margin/fills", MarginFill, "fills", ...)
def trades(
self, *, ticker: str, min_ts: int | None = None, max_ts: int | None = None,
limit: int | None = None, cursor: str | None = None,
extra_headers: dict[str, str] | None = None,
) -> Page[MarginTrade]: ...
# NO _require_auth() (public). ticker is required (positional-via-keyword, no default).
# _validate_limit(limit, hi=1000); self._list("/margin/trades", MarginTrade, "trades", ...)
def trades_all(
self, *, ticker: str, min_ts: int | None = None, max_ts: int | None = None,
limit: int | None = None, max_pages: int | None = None,
extra_headers: dict[str, str] | None = None,
) -> Iterator[MarginTrade]: ...
# NO _require_auth(); _validate_max_pages(max_pages); cursor=None; self._list_all("/margin/trades", MarginTrade, "trades", ...)Async — AsyncPerpsPortfolioResource
Same signatures with async def for positions / fills / trades (awaiting self._get / self._list). The two *_all methods are non-async def returning AsyncIterator[MarginFill] / AsyncIterator[MarginTrade] (they return the iterator directly so async for item in client.portfolio.fills_all(): works — exactly as AsyncPortfolioResource.fills_all does in kalshi/resources/portfolio.py).
Wire both resource classes onto PerpsClient.portfolio / AsyncPerpsClient.portfolio in the foundation client (or here if foundation leaves portfolio as the first attachment — coordinate via the dependency). ticker on trades/trades_all has no default because the spec marks it required: true; passing ticker=None is invalid and _params must drop only the optional Nones.
Contract-test wiring
The contract harness currently hardcodes SPEC_FILE = specs/openapi.yaml (see tests/test_contracts.py:54). The foundation issue must parameterize _load_spec() so perps entries resolve against specs/perps_openapi.yaml (committed copy of /tmp/perps_openapi.yaml); this issue assumes that plumbing exists and registers entries keyed to the perps spec.
Add to METHOD_ENDPOINT_MAP in tests/_contract_support.py (a # ── perps portfolio ── section). All GET, so request_body_schema=None:
MethodEndpointEntry(
sdk_method="kalshi.perps.resources.portfolio.PerpsPortfolioResource.positions",
http_method="GET", path_template="/margin/positions"),
MethodEndpointEntry(
sdk_method="kalshi.perps.resources.portfolio.PerpsPortfolioResource.fills",
http_method="GET", path_template="/margin/fills"),
MethodEndpointEntry(
sdk_method="kalshi.perps.resources.portfolio.PerpsPortfolioResource.fills_all",
http_method="GET", path_template="/margin/fills"),
MethodEndpointEntry(
sdk_method="kalshi.perps.resources.portfolio.PerpsPortfolioResource.trades",
http_method="GET", path_template="/margin/trades"),
MethodEndpointEntry(
sdk_method="kalshi.perps.resources.portfolio.PerpsPortfolioResource.trades_all",
http_method="GET", path_template="/margin/trades"),BODY_MODEL_MAP (tests/test_contracts.py:1156): no entries — all three endpoints are GET with no request body.
EXCLUSIONS (tests/_contract_support.py): the param-drift check will see cursor on the _all methods is consumed by the paginator (not a caller kwarg) and max_pages is client-only. Add, matched to the perps spec method FQNs:
fills_all/trades_allcursor→Exclusion(kind="paginator_handled", reason="cursor consumed by _list_all paginator, not a caller kwarg")fills_all/trades_allmax_pages→Exclusion(kind="client_only", reason="client-side page cap, no wire counterpart")
(Follow the exact key shape the existing portfolio *_all entries use for cursor/max_pages — copy that pattern verbatim.) The return_on_equity Python name vs roe wire name is a response-side alias handled by AliasChoices, so it is NOT param-drift and needs no exclusion; if the response-field drift check flags the rename, add Exclusion(kind="kwarg_rename", reason="spec field 'roe' surfaced as 'return_on_equity' for readability; AliasChoices maps the wire name").
Tests
New file: tests/perps/test_portfolio.py (mirror tests/test_portfolio.py). Use respx.mock; build a perps-configured client off the conftest RSA-key fixtures (the foundation issue provides a perps_client / async_perps_client fixture, or construct PerpsClient with the test signer + a PerpsConfig pointed at the demo base URL). Run sync and async variants.
positions— happy: mockGET /margin/positionsreturning twoMarginPositions (one long, one short with negativepositionand negativeunrealized_pnl); assertDecimaltyping onentry_price/margin_used/fees,positionsign, andreturn_on_equityparsed from wireroe. Edge: one position withroe: null→return_on_equity is None. Edge: emptypositions: []and server-nullpositions (NullableList →[]). Error: 401 maps to the SDK auth error; assert_require_auth()raises before any HTTP when client is unauthenticated.fills/fills_all— happy: single page withcursor: ""→has_next is False. Pagination: two pages (cursor:"abc"thencursor:""); assertfills_allyields all fills across both pages and stops. Assertcreated_timeparses to an awaredatetimeandrealized_pnlmay be negative. Edge:order_sourcepresent ("system") vs absent →None. Edge:_validate_limit(1001)raises;_validate_max_pagescap honored. Error: 500 propagates as SDK server error. Auth: unauthenticated client raises before HTTP.trades/trades_all— happy: page returnsMarginTrades; asserttaker_sideis"bid"/"ask",count/pricetyped. Public: assert it works with an unauthenticated client (no_require_auth). Required-param: callingtrades()/trades_all()withouttickeris aTypeError(missing required kwarg) — assert. Pagination: two pages stop on empty cursor. Error: 400 (e.g. bad ticker) maps to SDK request error.- respx request assertions: verify query string carries
subaccount/ticker/limit/min_ts/max_tsonly when provided (Nones dropped by_params), and thattickeris always present on/margin/trades.
Acceptance criteria
-
kalshi/perps/models/portfolio.pydefinesMarginPosition,GetMarginPositionsResponse,MarginFill,GetMarginFillsResponse,MarginTrade,GetMarginTradesResponse, plusBookSideLiteralandOrderSourceLiteral, with the exact field names/types/aliases above. -
kalshi/perps/resources/portfolio.pydefinesPerpsPortfolioResourceandAsyncPerpsPortfolioResourcewithpositions,fills,fills_all,trades,trades_all; positions/fills require auth, trades does not; async*_allreturnAsyncIteratordirectly. - Models exported from
kalshi/perps/__init__.pyand re-exported fromkalshi/__init__.py. - Resources wired onto
PerpsClient.portfolio/AsyncPerpsClient.portfolio. - All five endpoints registered in
METHOD_ENDPOINT_MAPagainstspecs/perps_openapi.yaml; paginatorcursor+max_pagesexclusions added;roe→return_on_equityhandled (alias, with exclusion only if the response-drift check flags it). -
uv run mypy kalshi/strict-clean, usingbuiltins.list[T]for any list annotation inside the resource classes. -
uv run ruff check .clean. -
uv run pytest tests/ -vgreen, includingTestRequestParamDriftand the perps contract/drift suites; newtests/perps/test_portfolio.pycovers happy/error/edge per method for both sync and async.
Dependencies
- perps: foundation — provides
PerpsClient/AsyncPerpsClient,PerpsConfig, thekalshi/perps/package scaffolding (models/,resources/,__init__export chain), reuse ofKalshiAuth+SyncTransport/AsyncTransport, the committedspecs/perps_openapi.yaml, the multi-spec parameterization of the contract-test harness (_load_spec()/SPEC_FILE), and the perps test-client fixtures intests/perps/conftest.py. This issue cannot land until those exist.
Activity