You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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().
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.)
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.
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.
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:
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")
(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.
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.
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 acursor, so they get_all()auto-pagination (syncIterator+ asyncAsyncIterator); 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.pyfamily but lives under the newkalshi/perps/packages against the perps OpenAPI spec (specs/perps_openapi.yaml).Endpoints / scope
/margin/positionsGetMarginPositionssubaccount(int, default 0),ticker(optional). Responsepositionsarray has no cursor — single page only, no_all()./margin/fillsGetMarginFillssubaccount(int, default 0),limit(1–1000, default 100),cursor,min_ts(int64),max_ts(int64). Cursor-paginated →fills_all()./margin/tradesGetMarginTradesmarket). Query:ticker(required),limit(1–1000, default 100),cursor,min_ts(int64),max_ts(int64). Cursor-paginated →trades_all().Auth note:
GetMarginPositionsandGetMarginFillsdeclarekalshiAccessKey/Signature/Timestamp;GetMarginTradesdeclares no security. Positions/fills methods callself._require_auth(); trades does not (it is a public market read, likeMarketsResource.list_trades).Base URL comes from
PerpsConfig(foundation): prodhttps://external-api.kalshi.com/trade-api/v2, demohttps://external-api.demo.kalshi.co/trade-api/v2.Models
New file:
kalshi/perps/models/portfolio.pyImport the shared types from
kalshi.types(DollarDecimal,FixedPointCount) andkalshi.models.common.Page. All response models usemodel_config = {"extra": "allow"}to tolerate additive spec fields, matching the existing portfolio models.BookSideLiteralSpec
BookSideisenum: [bid, ask]. Define as a module-level alias:(Mirror
SettlementStatusLiteralinkalshi/models/portfolio.py— aLiteral, not a class.)OrderSourceLiteralMarginFill.order_sourcereferences specOrderSource(enum: [user, system];system= liquidation order). Optional field. Define:MarginPositionSchema
MarginPosition. Allrequiredkeys are non-nullable exceptroe. Specrequired:market_ticker, position, entry_price, unrealized_pnl, margin_used, fees.market_tickerstrmarket_tickerpositionFixedPointCountposition(spec key isposition, aFixedPointCountref — no_fpsuffix here, so a plain field, noAliasChoices)entry_priceDollarDecimalentry_price(spec key,FixedPointDollarsref — no_dollarssuffix, plain field)unrealized_pnlDollarDecimalunrealized_pnlmargin_usedDollarDecimalmargin_usedfeesDollarDecimalfeesreturn_on_equityfloat | None = Nonevalidation_alias=AliasChoices("roe", "return_on_equity")roe(type: number, format: double, nullable: true); null whenmargin_usedis zero. Expose under the friendlier Python namereturn_on_equityper the issue brief, mapping the wireroevia alias. Setpopulate_by_name=True. Plainfloat(matches specformat: double; it's a percentage ratio, not a money/count fixed-point string).Important — no
_dollars/_fpaliases on this schema. Unlike the v2MarketPosition(which usesposition_fp/*_dollarswire 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 bareFixedPointCount/DollarDecimalfields with noAliasChoices— adding_dollarsaliases here would be wrong and would diverge from the wire. The only aliased field isroe→return_on_equity.GetMarginPositionsResponseSchema
GetMarginPositionsResponse.required: [positions].No
cursorfield. Nohas_next.MarginFillSchema
MarginFill.required: [fill_id, order_id, is_taker, side, count, created_time, ticker, price, entry_price, fees, realized_pnl].fill_idstrorder_idstris_takerboolsideBookSideLiteralside→BookSideenumcountFixedPointCountcount→FixedPointCountref, plain fieldcreated_timeAwareDatetimeformat: date-time(RFC3339) — REST timestamp, NOT_ms/epoch. Usepydantic.AwareDatetime, likeMarketPosition.last_updated_ts.tickerstrpriceDollarDecimaltype: string"fixed-point dollars" — plain field, no aliasentry_priceDollarDecimalfeesDollarDecimalrealized_pnlDollarDecimalorder_sourceOrderSourceLiteral | None = Nonerequired)GetMarginFillsResponseSchema
GetMarginFillsResponse.required: [fills, cursor].MarginTradeSchema
MarginTrade.required: [trade_id, ticker, count, price, created_time, taker_side].trade_idstrtickerstrcountFixedPointCountcount→FixedPointCountrefpriceDollarDecimaltype: string"Trade price in dollars"created_timeAwareDatetimeformat: date-time(RFC3339) — REST timestamp, not_mstaker_sideBookSideLiteraltaker_side→BookSideenumGetMarginTradesResponseSchema
GetMarginTradesResponse.required: [trades, cursor].Timestamp note: every timestamp in these three schemas is RFC3339
format: date-time(AwareDatetime). There are no WS_msepoch-millisecond fields in this issue — the_msconvention 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 noextra="forbid"/serialization_aliaswork here.Resource methods
New file:
kalshi/perps/resources/portfolio.py— define bothPerpsPortfolioResource(SyncResource)andAsyncPerpsPortfolioResource(AsyncResource), importingSyncResource,AsyncResource,_params,_validate_limit,_validate_max_pagesfromkalshi.resources._base(the foundation issue must confirm these base helpers are import-reusable from perps resources). ReusePagesemantics viaself._list(...)/self._list_all(...)for the cursor endpoints and a plainself._get(...) + model_validatefor positions.Inside these classes the
listbuiltin is shadowed by.list()-style names; any locallist[T]annotation must be writtenbuiltins.list[T].A small private param builder (mirroring
_fills_paramsinkalshi/resources/portfolio.py) for the fills/trades query set is fine; keep it local to the module.Sync —
PerpsPortfolioResourceAsync —
AsyncPerpsPortfolioResourceSame signatures with
async defforpositions/fills/trades(awaitingself._get/self._list). The two*_allmethods are non-asyncdefreturningAsyncIterator[MarginFill]/AsyncIterator[MarginTrade](they return the iterator directly soasync for item in client.portfolio.fills_all():works — exactly asAsyncPortfolioResource.fills_alldoes inkalshi/resources/portfolio.py).Wire both resource classes onto
PerpsClient.portfolio/AsyncPerpsClient.portfolioin the foundation client (or here if foundation leavesportfolioas the first attachment — coordinate via the dependency).tickerontrades/trades_allhas no default because the spec marks itrequired: true; passingticker=Noneis invalid and_paramsmust drop only the optional Nones.Contract-test wiring
The contract harness currently hardcodes
SPEC_FILE = specs/openapi.yaml(seetests/test_contracts.py:54). The foundation issue must parameterize_load_spec()so perps entries resolve againstspecs/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_MAPintests/_contract_support.py(a# ── perps portfolio ──section). All GET, sorequest_body_schema=None: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 seecursoron the_allmethods is consumed by the paginator (not a caller kwarg) andmax_pagesis 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
*_allentries use forcursor/max_pages— copy that pattern verbatim.) Thereturn_on_equityPython name vsroewire name is a response-side alias handled byAliasChoices, so it is NOT param-drift and needs no exclusion; if the response-field drift check flags the rename, addExclusion(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(mirrortests/test_portfolio.py). Userespx.mock; build a perps-configured client off the conftest RSA-key fixtures (the foundation issue provides aperps_client/async_perps_clientfixture, or constructPerpsClientwith the test signer + aPerpsConfigpointed 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.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.kalshi/perps/__init__.pyand re-exported fromkalshi/__init__.py.PerpsClient.portfolio/AsyncPerpsClient.portfolio.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
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.