Skip to content

perps: funding resource — rate estimate, historical rates, payment history #395

Description

@TexasCoding

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

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_history is "cursor paginated -> list_all". It is not. Per /tmp/perps_openapi.yaml, GetMarginFundingHistoryResponse has a single required funding_history array property and no cursor/next_cursor field; the endpoint is bounded by a required start_date/end_date UTC date range plus an optional ticker and subaccount filter. None of the three funding endpoints paginate. Therefore do NOT add *_all() / AsyncIterator paginators — every method returns a plain builtins.list[...] (or a single model for the estimate). Do not mirror the _list_all machinery from historical.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_rate is type: number, format: double → plain float (NOT a price; do not use DollarDecimal).
  • mark_price, funding_amount are $ref FixedPointDollarsDollarDecimal.
  • quantity is $ref FixedPointCountFixedPointCount.
  • funding_time, computed_time, next_funding_time are RFC3339 format: date-timeAwareDatetime (these are REST timestamps; do not treat as _ms epochs).

Models:

  1. MarginFundingRate (spec MarginFundingRate; required: market_ticker, funding_time, funding_rate, mark_price):

    • market_ticker: str
    • funding_time: AwareDatetime
    • funding_rate: float
    • mark_price: DollarDecimalField(validation_alias=AliasChoices("mark_price_dollars", "mark_price")) (accept both wire forms per the SDK FixedPointDollars convention).
  2. MarginFundingHistoryEntry (spec MarginFundingHistoryEntry; required: market_ticker, funding_time, funding_rate, mark_price, funding_amount, quantity, subaccount_number):

    • market_ticker: str
    • funding_time: AwareDatetime
    • funding_rate: float
    • mark_price: DollarDecimalvalidation_alias=AliasChoices("mark_price_dollars", "mark_price")
    • funding_amount: DollarDecimalvalidation_alias=AliasChoices("funding_amount_dollars", "funding_amount"). Spec note: positive = received, negative = paid.
    • quantity: FixedPointCountvalidation_alias=AliasChoices("quantity_fp", "quantity") (FixedPointCount fields carry the _fp suffix per SDK convention; both names accepted).
    • subaccount_number: int | None — spec marks it required and nullable: true, so type int | None with no default (a missing key must still raise ValidationError; only an explicit null is tolerated — same pattern as Trade.count in historical.py). 0 = primary.
  3. MarginFundingRateEstimate (spec GetMarginFundingRateEstimateResponse; the only required field is next_funding_time):

    • market_ticker: str | None = None
    • computed_time: AwareDatetime | None = None
    • funding_rate: float | None = None
    • mark_price: DollarDecimal | None = NoneField(default=None, validation_alias=AliasChoices("mark_price_dollars", "mark_price"))
    • next_funding_time: AwareDatetime (required, no default).
    • Name the model MarginFundingRateEstimate (drop the GetMarginFundingRateEstimateResponse wrapper 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 call self._require_auth() first (this is the only authed endpoint of the three), mirroring historical.fills.
  • start_date/end_date are str (YYYY-MM-DD); both are spec-required so they are keyword-only required params (no default). ticker/subaccount are optional.
  • No cursor, no *_all, no AsyncIterator — 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/ticker as matching spec params, no exclusion is needed (they map 1:1 to spec query names — subaccount matches the SubaccountQuery param name: 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); assert mark_price is Decimal, funding_rate is float, next_funding_time is aware datetime, ticker propagated to the query string.
    • edge: 200 with only next_funding_time (all optional fields absent) parses fine and leaves optionals None.
    • error path: 400 maps to the SDK error type; missing next_funding_time raises ValidationError.
  • historical_rates:
    • happy path: 200 with a 2-element funding_rates array → list[MarginFundingRate] of length 2; assert start_ts/end_ts/ticker appear in query only when passed.
    • edge: empty funding_rates array → [].
    • error path: 500 maps to the SDK server-error type.
  • history:
    • happy path: 200 with funding_history containing positive and negative funding_amount entries; assert funding_amount/mark_price are Decimal, quantity is the FixedPointCount type, subaccount_number parses (including a null case and a 0 case), and start_date/end_date/subaccount/ticker land in the query string.
    • auth: calling without credentials raises the SDK "auth required" error (_require_auth), mirroring historical.fills tests.
    • edge: empty funding_history[]; subaccount_number: null tolerated, missing key raises ValidationError.
    • error path: 401/403 map to the SDK auth-error types.

Acceptance criteria

  • kalshi/perps/models/funding.py adds MarginFundingRate, MarginFundingHistoryEntry, MarginFundingRateEstimate with the exact fields/types/aliases above (funding_rate is float; mark_price/funding_amount are DollarDecimal; quantity is FixedPointCount; timestamps are AwareDatetime; subaccount_number: int | None no-default).
  • kalshi/perps/resources/funding.py implements FundingResource + AsyncFundingResource with rate_estimate, historical_rates, history (sync + async, identical names); history calls _require_auth(); list returns annotated builtins.list[T]; no paginators.
  • funding wired onto PerpsClient / AsyncPerpsClient.
  • Models exported from kalshi/perps/__init__.py and re-exported from kalshi/__init__.py.
  • mypy strict clean (uv run mypy kalshi/), including builtins.list[T] inside resource classes.
  • ruff clean (uv run ruff check .).
  • All three endpoints registered in METHOD_ENDPOINT_MAP; no BODY_MODEL_MAP entries (no bodies); any param exclusions justified with a reason (expected: none).
  • uv run pytest tests/ -v green, including the param/body/response drift suites against specs/perps_openapi.yaml.

Dependencies

  • perps: foundation — provides PerpsClient/AsyncPerpsClient, PerpsConfig, perps base URLs, the kalshi/perps/ package skeleton, perps conftest fixtures, the committed specs/perps_openapi.yaml, and the parameterization of the contract-test harness to load and drift-check against the perps spec.

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