Skip to content

perps: decide Decimal-vs-float for REST ratio fields (leverage / funding_rate / roe / fee_rates) — currently bare float, inconsistent with WS + exchange surfaces #408

Description

@TexasCoding

Context

Surfaced by the multi-LLM review of PR #403 (perps/margin API, feat/perps-api). All three reviewers (codex/gemini/grok) flagged that the perps REST number/double ratio fields are typed as bare float / dict[str, float] rather than the SDK's MultiplierDecimal convention. This is a deliberate, documented choice in the code — but it is internally inconsistent with the rest of the perps surface, so the team should make a conscious call rather than leave the split as-is.

Problem

The SDK has a dedicated MultiplierDecimal type (kalshi/types.py:78-91) for spec type: number, format: double ratio fields. It coerces via Decimal(str(value)) to avoid binary-float drift (0.65 -> 0.65000000000000002...), the same #225 invariant DollarDecimal / FixedPointCount honor. The perps surface already uses it for ratio fields in two places:

  • kalshi/perps/models/exchange.py:42-44liquidation_margin_ratio_threshold, queue_entry_margin_ratio_threshold, initial_margin_multiplier: dict[str, MultiplierDecimal]
  • kalshi/perps/ws/models/ticker.py:28FundingRate.rate: MultiplierDecimal

But the perps REST response models keep the analogous ratio fields as bare float, even though the spec (specs/perps_openapi.yaml) types every one as type: number, format: double:

  • kalshi/perps/models/margin_account.py:59position_leverage: float | None
  • kalshi/perps/models/margin_account.py:68account_leverage: float | None
  • kalshi/perps/models/margin_account.py:92-93maker_fee_rates: dict[str, float], taker_fee_rates: dict[str, float]
  • kalshi/perps/models/funding.py:44MarginFundingRate.funding_rate: float
  • kalshi/perps/models/funding.py:64MarginFundingHistoryEntry.funding_rate: float
  • kalshi/perps/models/funding.py:92MarginFundingRateEstimate.funding_rate: float | None
  • kalshi/perps/models/portfolio.py:55MarginPosition.return_on_equity: float | None (spec roe, number/double)
  • kalshi/perps/models/markets.py:51MarginMarket.leverage_estimate: float | None

Concrete inconsistency: the same logical quantity — perps funding rate — is Decimal on the WS surface and float on the REST surface. tests/perps/ws/test_perps_ws_models.py:351 asserts isinstance(p.funding_rate.rate, Decimal), while tests/perps/test_funding.py:54 asserts isinstance(est.funding_rate, float). A caller correlating the live WS funding rate against the historical REST rate gets two different numeric types for the same field.

Concrete failure mode for float: JSON like 0.000125 parses to a binary float, so funding_rate * notional and equality checks can drift in the low bits, and serializing a parsed response re-emits a float (e.g. 0.00012500000000000001) rather than the wire string.

Caveat / why this is a judgment call, not a clear bug: the docstrings explicitly state these are intentionally float because they are spec number/double ratios, "not money" (margin_account.py:14-15 and 83-88, funding.py:11-13, portfolio.py:13-14). The contract-drift tests do not distinguish float from Decimal, so the current typing is spec-conformant.

Proposed fix

Decide whether ratio precision warrants Decimal. If yes (recommended for consistency, since the WS + exchange surfaces already use it for the identical wire shape), switch the eight REST fields above to MultiplierDecimal (preserving the | None optionality and the roe/return_on_equity alias), and update the docstrings to match the exchange.py rationale. Apply consistently across all four files in one change. If the decision is to keep float, instead add a one-line note to the perps EPIC documenting that REST ratios are deliberately float while WS/exchange ratios are Decimal, so the split is intentional and recorded.

Acceptance criteria

  • Team decision recorded: MultiplierDecimal vs float for perps REST ratio fields.
  • If adopting Decimal: the eight fields in margin_account.py, funding.py, portfolio.py, markets.py use MultiplierDecimal (with | None where currently optional); the dict fields become dict[str, MultiplierDecimal].
  • If adopting Decimal: update the existing isinstance(..., float) assertions to Decimaltests/perps/test_funding.py:54,144, tests/perps/test_margin_account.py:369,371, tests/perps/test_markets.py:90, tests/perps/test_portfolio.py (add type assertion), and the integration assertions in tests/integration/test_perps_funding.py and tests/integration/test_perps_balance_risk.py.
  • If adopting Decimal: add a regression test that a parsed value like 0.000125 round-trips exactly (no binary drift) and that model_dump(mode="json") emits the fixed-point string form, mirroring the WS test at tests/perps/ws/test_perps_ws_models.py:351.
  • uv run mypy kalshi/ passes (watch dict[str, MultiplierDecimal] annotations).
  • CHANGELOG notes the output-shape change for serialized responses (float -> str) if Decimal is adopted.

Notes

  • Breaking-change tradeoff (the key reason this was deferred): MultiplierDecimal serializes via PlainSerializer(_decimal_to_str, when_used="json") (kalshi/types.py:78-82), so switching changes the public JSON output shape of these fields from a number (0.000125) to a string ("0.000125") for anyone who does response.model_dump(mode="json"). Hence the breaking label and the CHANGELOG requirement. In-Python attribute access also changes from float to Decimal, which affects callers doing float-typed arithmetic.
  • These are response models (extra="allow"), so the contract-drift tests in tests/_contract_support.py / tests/test_contracts.py won't catch or block this either way — they assert presence/spec-ref, not the Python numeric class.
  • Deferred from PR perps: full Perps (margin) API — REST + WebSocket + SCM/Klear (#387) #403 because the headline review findings were fixed in f8cc960 (ws), da953b0 (klear), f2ef973 (rest/models) and these files were untouched by those commits; this one is a deliberate-choice/consistency call rather than a defect.
  • Related: perps EPIC perps: [EPIC] Perps (margin) API — full SDK implementation tracker #387. The MultiplierDecimal precedent is Series.fee_multiplier (fix(types): preserve Decimal in DataFrames; positional serialization; reject bool #225 invariant), kalshi/models/series.py:31,48.

Metadata

Metadata

Assignees

No one assigned

    Labels

    breakingBackwards-incompatible changeenhancementNew feature or requestperpsPerps / margin (perpetual futures) API

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions