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:
-
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).
-
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.
-
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
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.
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 againstspecs/perps_openapi.yaml.Endpoints / scope
/margin/funding_rates/estimateGetMarginFundingRateEstimateticker. Returns a single estimate object. No auth required (nosecurityblock in spec)./margin/funding_rates/historicalGetMarginHistoricalFundingRatesticker,start_ts,end_ts(Unix seconds,int64). Returns arrayfunding_rates. No auth./margin/funding_historyGetMarginFundingHistorykalshiAccessKey/Signature/Timestamp). Required querystart_date,end_date(YYYY-MM-DDstrings); optionalticker,subaccount(int ≥ 0). Returns arrayfunding_history.Base URL is the perps host configured by the foundation issue (prod
https://external-api.kalshi.com/trade-api/v2, demohttps://external-api.demo.kalshi.co/trade-api/v2), reusing the existing RSA-PSS auth + transport.Models
New file:
kalshi/perps/models/funding.pyImport
DollarDecimal, FixedPointCountfromkalshi.types;AwareDatetime, BaseModel, Field, AliasChoicesfrom pydantic.All response models use
model_config = {"extra": "allow", "populate_by_name": True}(matching the response-model convention inkalshi/models/historical.py). These are response models only — there are no request bodies in this issue, so noextra="forbid"/serialization_aliasmodels 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).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 returnsbuiltins.list[Model]); they do not need dedicated wrapper models — mirrorhistorical.candlesticks, which returnsbuiltins.list[Candlestick]fromdata.get("candlesticks", []).Resource methods
New file:
kalshi/perps/resources/funding.py— two classesFundingResource(SyncResource)andAsyncFundingResource(AsyncResource)(perps reuses the existingkalshi/resources/_base.pySyncResource/AsyncResource; import_params,_seg, and_require_authvia the base ashistorical.pydoes). Useimport builtinsand annotate list returns asbuiltins.list[T].A shared param builder for the historical endpoint (module-level, mirroring
_historical_*_params):both delegating to
_params(...)soNones are dropped.Sync signatures (async siblings are identical with
async def/await, same names):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.cursor, no*_all, noAsyncIterator— confirmed unpaginated above.Wire
funding = FundingResource(...)/AsyncFundingResource(...)into the foundation'sPerpsClient.__init__/AsyncPerpsClient.__init__.Contract-test wiring
Prerequisite (owned by the foundation issue): the contract harness in
tests/test_contracts.pymust be parameterized to also loadspecs/perps_openapi.yaml, andMethodEndpointEntry(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 toMETHOD_ENDPOINT_MAP(tests/_contract_support.py). All are GET with no body, sorequest_body_schemastaysNoneand there are noBODY_MODEL_MAPadditions:EXCLUSIONS (
tests/_contract_support.py) — the param-drift check compares method kwargs to spec query/path params. Potential entries, keyed on(sdk_fqn, kwarg):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).The response-side drift check (
TestSpecDrift) readsCONTRACT_MAP/spec response schemas; if perps response models are registered there by the foundation's perps-spec wiring, registerMarginFundingRate,MarginFundingHistoryEntry, andMarginFundingRateEstimateagainst 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— userespx.mock; reuse the conftest RSA key / auth / config fixtures (point them at aPerpsConfig/perps base URL per the foundation fixtures). Cover sync and async for each method.rate_estimate: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.next_funding_time(all optional fields absent) parses fine and leaves optionalsNone.next_funding_timeraisesValidationError.historical_rates:funding_ratesarray →list[MarginFundingRate]of length 2; assertstart_ts/end_ts/tickerappear in query only when passed.funding_ratesarray →[].history: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._require_auth), mirroringhistorical.fillstests.funding_history→[];subaccount_number: nulltolerated, missing key raisesValidationError.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.kalshi/perps/__init__.pyand re-exported fromkalshi/__init__.py.uv run mypy kalshi/), includingbuiltins.list[T]inside resource classes.uv run ruff check .).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
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.