Skip to content

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

Closed

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 FixedPointDollars → DollarDecimal.
  • quantity is $ref FixedPointCount → FixedPointCount.
  • funding_time, computed_time, next_funding_time are RFC3339 format: date-time → AwareDatetime (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: DollarDecimal — Field(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: 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 _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 = None — Field(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.

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