Repository navigation
Description
Summary
Implement the perps funding resource on the standalone PerpsClient / AsyncPerpsClient. Funding is the core perpetual-futures mechanic: periodic payments exchanged between longs and shorts to tether the perp's mark price to its index. This issue adds three read-only endpoints — the current in-progress rate estimate, historical applied rates, and the authenticated user's per-payment funding history — plus their Pydantic response models and contract-test wiring against specs/perps_openapi.yaml.
Spec correction (important — the planning brief was wrong here): the brief stated
funding_historyis "cursor paginated -> list_all". It is not. Per/tmp/perps_openapi.yaml,GetMarginFundingHistoryResponsehas a single requiredfunding_historyarray property and nocursor/next_cursorfield; the endpoint is bounded by a requiredstart_date/end_dateUTC date range plus an optionaltickerandsubaccountfilter. None of the three funding endpoints paginate. Therefore do NOT add*_all()/AsyncIteratorpaginators — every method returns a plainbuiltins.list[...](or a single model for the estimate). Do not mirror the_list_allmachinery fromhistorical.py; mirror only the simple list-returning shape (e.g.historical.candlesticks).
Endpoints / scope
| Method | Path | operationId | Notes |
|---|---|---|---|
| GET | /margin/funding_rates/estimate |
GetMarginFundingRateEstimate |
Required query ticker. Returns a single estimate object. No auth required (no security block in spec). |
| GET | /margin/funding_rates/historical |
GetMarginHistoricalFundingRates |
Optional query ticker, start_ts, end_ts (Unix seconds, int64). Returns array funding_rates. No auth. |
| GET | /margin/funding_history |
GetMarginFundingHistory |
Auth required (kalshiAccessKey/Signature/Timestamp). Required query start_date, end_date (YYYY-MM-DD strings); optional ticker, subaccount (int ≥ 0). Returns array funding_history. |
Base URL is the perps host configured by the foundation issue (prod https://external-api.kalshi.com/trade-api/v2, demo https://external-api.demo.kalshi.co/trade-api/v2), reusing the existing RSA-PSS auth + transport.
Models
New file: kalshi/perps/models/funding.py
Import DollarDecimal, FixedPointCount from kalshi.types; AwareDatetime, BaseModel, Field, AliasChoices from pydantic.
All response models use model_config = {"extra": "allow", "populate_by_name": True} (matching the response-model convention in kalshi/models/historical.py). These are response models only — there are no request bodies in this issue, so no extra="forbid" / serialization_alias models here.
Field-type rules confirmed against spec:
funding_rateistype: number, format: double→ plainfloat(NOT a price; do not useDollarDecimal).mark_price,funding_amountare$ref FixedPointDollars→DollarDecimal.quantityis$ref FixedPointCount→FixedPointCount.funding_time,computed_time,next_funding_timeare RFC3339format: date-time→AwareDatetime(these are REST timestamps; do not treat as_msepochs).
Models:
-
MarginFundingRate(specMarginFundingRate; required:market_ticker,funding_time,funding_rate,mark_price):market_ticker: strfunding_time: AwareDatetimefunding_rate: floatmark_price: DollarDecimal—Field(validation_alias=AliasChoices("mark_price_dollars", "mark_price"))(accept both wire forms per the SDK FixedPointDollars convention).
-
MarginFundingHistoryEntry(specMarginFundingHistoryEntry; required:market_ticker,funding_time,funding_rate,mark_price,funding_amount,quantity,subaccount_number):market_ticker: strfunding_time: AwareDatetimefunding_rate: floatmark_price: DollarDecimal—validation_alias=AliasChoices("mark_price_dollars", "mark_price")funding_amount: DollarDecimal—validation_alias=AliasChoices("funding_amount_dollars", "funding_amount"). Spec note: positive = received, negative = paid.quantity: FixedPointCount—validation_alias=AliasChoices("quantity_fp", "quantity")(FixedPointCount fields carry the_fpsuffix per SDK convention; both names accepted).subaccount_number: int | None— spec marks itrequiredandnullable: true, so typeint | Nonewith no default (a missing key must still raiseValidationError; only an explicitnullis tolerated — same pattern asTrade.countinhistorical.py).0= primary.
-
MarginFundingRateEstimate(specGetMarginFundingRateEstimateResponse; the only required field isnext_funding_time):market_ticker: str | None = Nonecomputed_time: AwareDatetime | None = Nonefunding_rate: float | None = Nonemark_price: DollarDecimal | None = None—Field(default=None, validation_alias=AliasChoices("mark_price_dollars", "mark_price"))next_funding_time: AwareDatetime(required, no default).- Name the model
MarginFundingRateEstimate(drop theGetMarginFundingRateEstimateResponsewrapper name) since the SDK returns the estimate object directly.
No enums in any of these schemas.
The two list responses (GetMarginHistoricalFundingRatesResponse.funding_rates, GetMarginFundingHistoryResponse.funding_history) are unwrapped in the resource layer (the method reads the array key and returns builtins.list[Model]); they do not need dedicated wrapper models — mirror historical.candlesticks, which returns builtins.list[Candlestick] from data.get("candlesticks", []).
Resource methods
New file: kalshi/perps/resources/funding.py — two classes FundingResource(SyncResource) and AsyncFundingResource(AsyncResource) (perps reuses the existing kalshi/resources/_base.py SyncResource/AsyncResource; import _params, _seg, and _require_auth via the base as historical.py does). Use import builtins and annotate list returns as builtins.list[T].
A shared param builder for the historical endpoint (module-level, mirroring _historical_*_params):
def _funding_historical_params(*, ticker, start_ts, end_ts) -> dict[str, Any]
def _funding_history_params(*, ticker, start_date, end_date, subaccount) -> dict[str, Any]
both delegating to _params(...) so Nones are dropped.
Sync signatures (async siblings are identical with async def / await, same names):
def rate_estimate(
self, ticker: str, *, extra_headers: dict[str, str] | None = None
) -> MarginFundingRateEstimate: ...
# GET /margin/funding_rates/estimate?ticker=... -> MarginFundingRateEstimate.model_validate(data)
def historical_rates(
self,
*,
ticker: str | None = None,
start_ts: int | None = None,
end_ts: int | None = None,
extra_headers: dict[str, str] | None = None,
) -> builtins.list[MarginFundingRate]: ...
# GET /margin/funding_rates/historical -> [MarginFundingRate.model_validate(r) for r in data.get("funding_rates", [])]
def history(
self,
*,
start_date: str,
end_date: str,
ticker: str | None = None,
subaccount: int | None = None,
extra_headers: dict[str, str] | None = None,
) -> builtins.list[MarginFundingHistoryEntry]: ...
# self._require_auth(); GET /margin/funding_history
# -> [MarginFundingHistoryEntry.model_validate(e) for e in data.get("funding_history", [])]Notes:
history()MUST callself._require_auth()first (this is the only authed endpoint of the three), mirroringhistorical.fills.start_date/end_datearestr(YYYY-MM-DD); both are spec-required so they are keyword-only required params (no default).ticker/subaccountare optional.- No
cursor, no*_all, noAsyncIterator— confirmed unpaginated above.
Wire funding = FundingResource(...) / AsyncFundingResource(...) into the foundation's PerpsClient.__init__ / AsyncPerpsClient.__init__.
Contract-test wiring
Prerequisite (owned by the foundation issue): the contract harness in tests/test_contracts.py must be parameterized to also load specs/perps_openapi.yaml, and MethodEndpointEntry (or the test that consumes it) must know which spec each entry belongs to. Assuming the foundation adds a perps spec loader and the param/drift tests iterate perps entries against the perps spec, add these GET entries to METHOD_ENDPOINT_MAP (tests/_contract_support.py). All are GET with no body, so request_body_schema stays None and there are no BODY_MODEL_MAP additions:
MethodEndpointEntry(
sdk_method="kalshi.perps.resources.funding.FundingResource.rate_estimate",
http_method="GET",
path_template="/margin/funding_rates/estimate",
),
MethodEndpointEntry(
sdk_method="kalshi.perps.resources.funding.FundingResource.historical_rates",
http_method="GET",
path_template="/margin/funding_rates/historical",
),
MethodEndpointEntry(
sdk_method="kalshi.perps.resources.funding.FundingResource.history",
http_method="GET",
path_template="/margin/funding_history",
),EXCLUSIONS (tests/_contract_support.py) — the param-drift check compares method kwargs to spec query/path params. Potential entries, keyed on (sdk_fqn, kwarg):
- If the harness flags
start_date/end_date/subaccount/start_ts/end_ts/tickeras matching spec params, no exclusion is needed (they map 1:1 to spec query names —subaccountmatches theSubaccountQueryparamname: subaccount). - Add an exclusion only if the harness treats the kwarg name differently than the spec param. None expected here since all SDK kwarg names equal their spec query-param names. Document this explicitly in the PR (no exclusions added) so a reviewer can confirm the 1:1 mapping.
The response-side drift check (TestSpecDrift) reads CONTRACT_MAP/spec response schemas; if perps response models are registered there by the foundation's perps-spec wiring, register MarginFundingRate, MarginFundingHistoryEntry, and MarginFundingRateEstimate against their spec schema names (MarginFundingRate, MarginFundingHistoryEntry, GetMarginFundingRateEstimateResponse). Confirm the registration mechanism with the foundation issue before adding.
Tests
New file: tests/perps/test_funding.py — use respx.mock; reuse the conftest RSA key / auth / config fixtures (point them at a PerpsConfig/perps base URL per the foundation fixtures). Cover sync and async for each method.
rate_estimate:- happy path: mock 200 with full body (
market_ticker,computed_time,funding_rate,mark_price_dollars,next_funding_time); assertmark_priceisDecimal,funding_rateisfloat,next_funding_timeis aware datetime,tickerpropagated to the query string. - edge: 200 with only
next_funding_time(all optional fields absent) parses fine and leaves optionalsNone. - error path: 400 maps to the SDK error type; missing
next_funding_timeraisesValidationError.
- happy path: mock 200 with full body (
historical_rates:- happy path: 200 with a 2-element
funding_ratesarray →list[MarginFundingRate]of length 2; assertstart_ts/end_ts/tickerappear in query only when passed. - edge: empty
funding_ratesarray →[]. - error path: 500 maps to the SDK server-error type.
- happy path: 200 with a 2-element
history:- happy path: 200 with
funding_historycontaining positive and negativefunding_amountentries; assertfunding_amount/mark_priceareDecimal,quantityis the FixedPointCount type,subaccount_numberparses (including anullcase and a0case), andstart_date/end_date/subaccount/tickerland in the query string. - auth: calling without credentials raises the SDK "auth required" error (
_require_auth), mirroringhistorical.fillstests. - edge: empty
funding_history→[];subaccount_number: nulltolerated, missing key raisesValidationError. - error path: 401/403 map to the SDK auth-error types.
- happy path: 200 with
Acceptance criteria
-
kalshi/perps/models/funding.pyaddsMarginFundingRate,MarginFundingHistoryEntry,MarginFundingRateEstimatewith the exact fields/types/aliases above (funding_rateisfloat;mark_price/funding_amountareDollarDecimal;quantityisFixedPointCount; timestamps areAwareDatetime;subaccount_number: int | Noneno-default). -
kalshi/perps/resources/funding.pyimplementsFundingResource+AsyncFundingResourcewithrate_estimate,historical_rates,history(sync + async, identical names);historycalls_require_auth(); list returns annotatedbuiltins.list[T]; no paginators. -
fundingwired ontoPerpsClient/AsyncPerpsClient. - Models exported from
kalshi/perps/__init__.pyand re-exported fromkalshi/__init__.py. - mypy strict clean (
uv run mypy kalshi/), includingbuiltins.list[T]inside resource classes. - ruff clean (
uv run ruff check .). - All three endpoints registered in
METHOD_ENDPOINT_MAP; noBODY_MODEL_MAPentries (no bodies); any param exclusions justified with areason(expected: none). -
uv run pytest tests/ -vgreen, including the param/body/response drift suites againstspecs/perps_openapi.yaml.
Dependencies
- perps: foundation — provides
PerpsClient/AsyncPerpsClient,PerpsConfig, perps base URLs, thekalshi/perps/package skeleton, perps conftest fixtures, the committedspecs/perps_openapi.yaml, and the parameterization of the contract-test harness to load and drift-check against the perps spec.
Activity