Skip to content

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

Closed

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 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_all cursor → Exclusion(kind="paginator_handled", reason="cursor consumed by _list_all paginator, not a caller kwarg")
  • fills_all / trades_all max_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: 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: null → return_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; roe→return_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.

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