Repository navigation
Description
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-44—liquidation_margin_ratio_threshold,queue_entry_margin_ratio_threshold,initial_margin_multiplier: dict[str, MultiplierDecimal]kalshi/perps/ws/models/ticker.py:28—FundingRate.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:59—position_leverage: float | Nonekalshi/perps/models/margin_account.py:68—account_leverage: float | Nonekalshi/perps/models/margin_account.py:92-93—maker_fee_rates: dict[str, float],taker_fee_rates: dict[str, float]kalshi/perps/models/funding.py:44—MarginFundingRate.funding_rate: floatkalshi/perps/models/funding.py:64—MarginFundingHistoryEntry.funding_rate: floatkalshi/perps/models/funding.py:92—MarginFundingRateEstimate.funding_rate: float | Nonekalshi/perps/models/portfolio.py:55—MarginPosition.return_on_equity: float | None(specroe,number/double)kalshi/perps/models/markets.py:51—MarginMarket.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:
MultiplierDecimalvsfloatfor perps REST ratio fields. - If adopting Decimal: the eight fields in
margin_account.py,funding.py,portfolio.py,markets.pyuseMultiplierDecimal(with| Nonewhere currently optional); the dict fields becomedict[str, MultiplierDecimal]. - If adopting Decimal: update the existing
isinstance(..., float)assertions toDecimal—tests/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 intests/integration/test_perps_funding.pyandtests/integration/test_perps_balance_risk.py. - If adopting Decimal: add a regression test that a parsed value like
0.000125round-trips exactly (no binary drift) and thatmodel_dump(mode="json")emits the fixed-point string form, mirroring the WS test attests/perps/ws/test_perps_ws_models.py:351. -
uv run mypy kalshi/passes (watchdict[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):
MultiplierDecimalserializes viaPlainSerializer(_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 doesresponse.model_dump(mode="json"). Hence thebreakinglabel and the CHANGELOG requirement. In-Python attribute access also changes fromfloattoDecimal, which affects callers doingfloat-typed arithmetic. - These are response models (
extra="allow"), so the contract-drift tests intests/_contract_support.py/tests/test_contracts.pywon'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
MultiplierDecimalprecedent isSeries.fee_multiplier(fix(types): preserve Decimal in DataFrames; positional serialization; reject bool #225 invariant),kalshi/models/series.py:31,48.
Activity