Skip to content

perps: portfolio reads — positions, fills, trades #393

Description

@TexasCoding

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

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 roereturn_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 sideBookSide enum
count FixedPointCount spec countFixedPointCount 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 countFixedPointCount 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_sideBookSide 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_all cursorExclusion(kind="paginator_handled", reason="cursor consumed by _list_all paginator, not a caller kwarg")
  • fills_all / trades_all max_pagesExclusion(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: mock GET /margin/positions returning two MarginPositions (one long, one short with negative position and negative unrealized_pnl); assert Decimal typing on entry_price/margin_used/fees, position sign, and return_on_equity parsed from wire roe. Edge: one position with roe: nullreturn_on_equity is None. Edge: empty positions: [] and server-null positions (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 with cursor: ""has_next is False. Pagination: two pages (cursor:"abc" then cursor:""); assert fills_all yields all fills across both pages and stops. Assert created_time parses to an aware datetime and realized_pnl may be negative. Edge: order_source present ("system") vs absent → None. Edge: _validate_limit(1001) raises; _validate_max_pages cap honored. Error: 500 propagates as SDK server error. Auth: unauthenticated client raises before HTTP.
  • trades / trades_all — happy: page returns MarginTrades; assert taker_side is "bid"/"ask", count/price typed. Public: assert it works with an unauthenticated client (no _require_auth). Required-param: calling trades() / trades_all() without ticker is a TypeError (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_ts only when provided (Nones dropped by _params), and that ticker is always present on /margin/trades.

Acceptance criteria

  • kalshi/perps/models/portfolio.py defines MarginPosition, GetMarginPositionsResponse, MarginFill, GetMarginFillsResponse, MarginTrade, GetMarginTradesResponse, plus BookSideLiteral and OrderSourceLiteral, with the exact field names/types/aliases above.
  • kalshi/perps/resources/portfolio.py defines PerpsPortfolioResource and AsyncPerpsPortfolioResource with positions, fills, fills_all, trades, trades_all; positions/fills require auth, trades does not; async *_all return AsyncIterator directly.
  • Models exported from kalshi/perps/__init__.py and re-exported from kalshi/__init__.py.
  • Resources wired onto PerpsClient.portfolio / AsyncPerpsClient.portfolio.
  • All five endpoints registered in METHOD_ENDPOINT_MAP against specs/perps_openapi.yaml; paginator cursor + max_pages exclusions added; roereturn_on_equity handled (alias, with exclusion only if the response-drift check flags it).
  • uv run mypy kalshi/ strict-clean, using builtins.list[T] for any list annotation inside the resource classes.
  • uv run ruff check . clean.
  • uv run pytest tests/ -v green, including TestRequestParamDrift and the perps contract/drift suites; new tests/perps/test_portfolio.py covers happy/error/edge per method for both sync and async.

Dependencies

  • perps: foundation — provides PerpsClient/AsyncPerpsClient, PerpsConfig, the kalshi/perps/ package scaffolding (models/, resources/, __init__ export chain), reuse of KalshiAuth + SyncTransport/AsyncTransport, the committed specs/perps_openapi.yaml, the multi-spec parameterization of the contract-test harness (_load_spec() / SPEC_FILE), and the perps test-client fixtures in tests/perps/conftest.py. This issue cannot land until those exist.

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