You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
perps: decide Decimal-vs-float for REST ratio fields (leverage / funding_rate / roe / fee_rates) — currently bare float, inconsistent with WS + exchange surfaces #408
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:
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:
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 Decimal — 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 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.
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 RESTnumber/doubleratio fields are typed as barefloat/dict[str, float]rather than the SDK'sMultiplierDecimalconvention. 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
MultiplierDecimaltype (kalshi/types.py:78-91) for spectype: number, format: doubleratio fields. It coerces viaDecimal(str(value))to avoid binary-float drift (0.65 -> 0.65000000000000002...), the same #225 invariantDollarDecimal/FixedPointCounthonor. 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: MultiplierDecimalBut the perps REST response models keep the analogous ratio fields as bare
float, even though the spec (specs/perps_openapi.yaml) types every one astype: 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 | NoneConcrete inconsistency: the same logical quantity — perps funding rate — is
Decimalon the WS surface andfloaton the REST surface.tests/perps/ws/test_perps_ws_models.py:351assertsisinstance(p.funding_rate.rate, Decimal), whiletests/perps/test_funding.py:54assertsisinstance(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 like0.000125parses to a binary float, sofunding_rate * notionaland 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
floatbecause they are specnumber/doubleratios, "not money" (margin_account.py:14-15and83-88,funding.py:11-13,portfolio.py:13-14). The contract-drift tests do not distinguishfloatfromDecimal, 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 toMultiplierDecimal(preserving the| Noneoptionality and theroe/return_on_equityalias), and update the docstrings to match theexchange.pyrationale. Apply consistently across all four files in one change. If the decision is to keepfloat, instead add a one-line note to the perps EPIC documenting that REST ratios are deliberatelyfloatwhile WS/exchange ratios areDecimal, so the split is intentional and recorded.Acceptance criteria
MultiplierDecimalvsfloatfor perps REST ratio fields.margin_account.py,funding.py,portfolio.py,markets.pyuseMultiplierDecimal(with| Nonewhere currently optional); the dict fields becomedict[str, MultiplierDecimal].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.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).Notes
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.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.MultiplierDecimalprecedent isSeries.fee_multiplier(fix(types): preserve Decimal in DataFrames; positional serialization; reject bool #225 invariant),kalshi/models/series.py:31,48.