From 55fc836c3e0e66d5ac223e114710c924dff41116 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 5 Jun 2026 11:20:44 -0500 Subject: [PATCH] perps: MultiplierDecimal for ratio fields + NullableList for margin-account arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #408, closes #407. Pre-release polish (3.2.0 unreleased), so no breaking change for any published version. #408 — ratio fields (spec `number/double`) were bare `float`, inconsistent with the perps WS ticker + exchange surfaces which already use `MultiplierDecimal`, and lossy for money-adjacent math (funding_rate × notional, fee_rate × value). Switched to `MultiplierDecimal` (exact `Decimal`, string-serialized): - markets: `leverage_estimate` - margin_account: `position_leverage`, `account_leverage`, `maker_fee_rates`/`taker_fee_rates` (dict values) - funding: `funding_rate` (×3) - portfolio: `return_on_equity` (roe) #407 — `GetMarginBalanceResponse.subaccount_balances` and `GetMarginRiskResponse.positions` were bare `list[...]`; switched to `NullableList[...]` (kept required) to tolerate a server-returned `null` like sibling perps responses. NullableList is a strict superset of `list` (adds null→[], never rejects valid data). The `roe` "required" half of #407 was a verified false positive (spec marks it nullable, not required) — left optional. Tests: updated float assertions to `Decimal`; added null-array regression tests for both margin-account list fields. mypy strict + ruff + contract drift green. --- CHANGELOG.md | 5 +++- kalshi/perps/models/funding.py | 12 ++++---- kalshi/perps/models/margin_account.py | 28 ++++++++++++------- kalshi/perps/models/markets.py | 4 +-- kalshi/perps/models/portfolio.py | 9 ++++-- tests/perps/test_funding.py | 10 +++---- tests/perps/test_margin_account.py | 40 +++++++++++++++++++++++---- tests/perps/test_markets.py | 4 +-- tests/perps/test_portfolio.py | 7 +++-- 9 files changed, 83 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e475cf7..04affb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,10 @@ prediction-API surface. Additive release: no changes to `KalshiClient`. notional risk limit / fee tiers), `funding` (rate estimate / historical / history), and `transfers` (intra-exchange-instance + margin subaccounts). Margin order side is `bid` / `ask`; prices are `DollarDecimal` - (FixedPointDollars), counts `FixedPointCount`. + (FixedPointDollars), counts `FixedPointCount`, and `number/double` ratios + (leverage, funding rate, ROE, fee tiers) are `MultiplierDecimal` (exact + `Decimal`, string-serialized) — consistent across the REST, WS, and exchange + surfaces. Margin-account list responses tolerate a server-returned `null`. - **`PerpsWebSocket`** — the perps margin WebSocket (`external-api-margin-ws.kalshi.com`, `/trade-api/ws/v2/margin`) with six typed channels (`subscribe_orderbook_delta`, `subscribe_ticker` — carrying diff --git a/kalshi/perps/models/funding.py b/kalshi/perps/models/funding.py index 0523a2b..9016dad 100644 --- a/kalshi/perps/models/funding.py +++ b/kalshi/perps/models/funding.py @@ -10,7 +10,9 @@ Field-type rules (verified against ``specs/perps_openapi.yaml``): - ``funding_rate`` is ``type: number, format: double`` and is **not** a price — - it is a plain :class:`float` (NOT :data:`~kalshi.types.DollarDecimal`). + it uses :data:`~kalshi.types.MultiplierDecimal` (exact ``Decimal``, + string-serialized), matching the perps WS ticker's ``funding_rate``, NOT + :data:`~kalshi.types.DollarDecimal`. - ``mark_price`` / ``funding_amount`` are ``$ref FixedPointDollars`` strings → :data:`~kalshi.types.DollarDecimal`. - ``quantity`` is ``$ref FixedPointCount`` → :data:`~kalshi.types.FixedPointCount`. @@ -27,7 +29,7 @@ from pydantic import AliasChoices, AwareDatetime, BaseModel, Field -from kalshi.types import DollarDecimal, FixedPointCount +from kalshi.types import DollarDecimal, FixedPointCount, MultiplierDecimal class MarginFundingRate(BaseModel): @@ -41,7 +43,7 @@ class MarginFundingRate(BaseModel): market_ticker: str funding_time: AwareDatetime - funding_rate: float + funding_rate: MultiplierDecimal mark_price: DollarDecimal = Field( validation_alias=AliasChoices("mark_price_dollars", "mark_price"), ) @@ -61,7 +63,7 @@ class MarginFundingHistoryEntry(BaseModel): market_ticker: str funding_time: AwareDatetime - funding_rate: float + funding_rate: MultiplierDecimal mark_price: DollarDecimal = Field( validation_alias=AliasChoices("mark_price_dollars", "mark_price"), ) @@ -89,7 +91,7 @@ class MarginFundingRateEstimate(BaseModel): market_ticker: str | None = None computed_time: AwareDatetime | None = None - funding_rate: float | None = None + funding_rate: MultiplierDecimal | None = None mark_price: DollarDecimal | None = Field( default=None, validation_alias=AliasChoices("mark_price_dollars", "mark_price"), diff --git a/kalshi/perps/models/margin_account.py b/kalshi/perps/models/margin_account.py index fdaa1ee..e71ddde 100644 --- a/kalshi/perps/models/margin_account.py +++ b/kalshi/perps/models/margin_account.py @@ -12,14 +12,21 @@ :data:`~kalshi.types.FixedPointCount`. Wire field names already match the short Python names (no ``_dollars`` suffix on this surface), so no ``validation_alias`` is used here. The fee-rate maps and leverage ratios are spec ``number/double`` -and are kept as plain ``float`` (decimal fractions of notional, not money). +and use :data:`~kalshi.types.MultiplierDecimal` (exact ``Decimal``, +string-serialized) — consistent with the perps exchange + WS ticker surfaces +and so callers can do exact fee/leverage math without binary-float drift. """ from __future__ import annotations from pydantic import BaseModel, ConfigDict -from kalshi.types import DollarDecimal, FixedPointCount +from kalshi.types import ( + DollarDecimal, + FixedPointCount, + MultiplierDecimal, + NullableList, +) class MarginSubaccountBalance(BaseModel): @@ -41,7 +48,7 @@ class GetMarginBalanceResponse(BaseModel): model_config = ConfigDict(extra="allow") - subaccount_balances: list[MarginSubaccountBalance] + subaccount_balances: NullableList[MarginSubaccountBalance] settled_funds: DollarDecimal @@ -56,7 +63,7 @@ class MarginRiskPosition(BaseModel): mark_price: DollarDecimal position_notional: DollarDecimal maintenance_margin_required: DollarDecimal | None = None - position_leverage: float | None = None + position_leverage: MultiplierDecimal | None = None estimated_liquidation_price: DollarDecimal | None = None @@ -65,10 +72,10 @@ class GetMarginRiskResponse(BaseModel): model_config = ConfigDict(extra="allow") - account_leverage: float | None = None + account_leverage: MultiplierDecimal | None = None total_position_notional: DollarDecimal total_maintenance_margin: DollarDecimal - positions: list[MarginRiskPosition] + positions: NullableList[MarginRiskPosition] class NotionalRiskLimitResponse(BaseModel): @@ -83,11 +90,12 @@ class NotionalRiskLimitResponse(BaseModel): class GetMarginFeeTiersResponse(BaseModel): """Spec ``GetMarginFeeTiersResponse`` — maker/taker fee-rate maps by ticker. - Values are decimal fractions of notional (e.g. ``0.0005`` = 5 bps), kept as - plain ``float`` per the spec's ``number/double`` typing — not money fields. + Values are decimal fractions of notional (e.g. ``0.0005`` = 5 bps), spec + ``number/double``. They use :data:`~kalshi.types.MultiplierDecimal` (exact + ``Decimal``) so ``notional * fee_rate`` stays exact for the caller. """ model_config = ConfigDict(extra="allow") - maker_fee_rates: dict[str, float] - taker_fee_rates: dict[str, float] + maker_fee_rates: dict[str, MultiplierDecimal] + taker_fee_rates: dict[str, MultiplierDecimal] diff --git a/kalshi/perps/models/markets.py b/kalshi/perps/models/markets.py index a87afb2..7278977 100644 --- a/kalshi/perps/models/markets.py +++ b/kalshi/perps/models/markets.py @@ -20,7 +20,7 @@ from pydantic import AliasChoices, BaseModel, Field -from kalshi.types import DollarDecimal, FixedPointCount, NullableList +from kalshi.types import DollarDecimal, FixedPointCount, MultiplierDecimal, NullableList MarginMarketStatusLiteral = Literal["inactive", "active", "closed"] """Status filter for GET /margin/markets and ``MarginMarket.status``. @@ -48,7 +48,7 @@ class MarginMarket(BaseModel): tick_size: DollarDecimal fractional_trading_enabled: bool - leverage_estimate: float | None = None + leverage_estimate: MultiplierDecimal | None = None price: DollarDecimal | None = None bid: DollarDecimal | None = None ask: DollarDecimal | None = None diff --git a/kalshi/perps/models/portfolio.py b/kalshi/perps/models/portfolio.py index 6a5e431..afd7a56 100644 --- a/kalshi/perps/models/portfolio.py +++ b/kalshi/perps/models/portfolio.py @@ -24,7 +24,12 @@ from pydantic import AliasChoices, AwareDatetime, BaseModel, Field -from kalshi.types import DollarDecimal, FixedPointCount, NullableList +from kalshi.types import ( + DollarDecimal, + FixedPointCount, + MultiplierDecimal, + NullableList, +) # Spec ``BookSide`` is ``enum: [bid, ask]`` — surfaced as a Literal alias # (mirroring ``SettlementStatusLiteral`` in kalshi/models/portfolio.py). @@ -52,7 +57,7 @@ class MarginPosition(BaseModel): unrealized_pnl: DollarDecimal margin_used: DollarDecimal fees: DollarDecimal - return_on_equity: float | None = Field( + return_on_equity: MultiplierDecimal | None = Field( default=None, validation_alias=AliasChoices("roe", "return_on_equity"), ) diff --git a/tests/perps/test_funding.py b/tests/perps/test_funding.py index 8768664..0e00b0a 100644 --- a/tests/perps/test_funding.py +++ b/tests/perps/test_funding.py @@ -48,11 +48,11 @@ def test_happy(self, perps_client: PerpsClient) -> None: ) ) est = perps_client.funding.rate_estimate("BTC-PERP") - # mark_price -> Decimal, funding_rate -> float, next_funding_time aware. + # mark_price -> Decimal, funding_rate -> Decimal, next_funding_time aware. assert isinstance(est.mark_price, Decimal) assert est.mark_price == Decimal("65000.50") - assert isinstance(est.funding_rate, float) - assert est.funding_rate == 0.000125 + assert isinstance(est.funding_rate, Decimal) + assert est.funding_rate == Decimal("0.000125") assert isinstance(est.next_funding_time, datetime) assert est.next_funding_time.tzinfo is not None assert est.market_ticker == "BTC-PERP" @@ -141,8 +141,8 @@ def test_happy(self, perps_client: PerpsClient) -> None: assert all(isinstance(r, MarginFundingRate) for r in rates) assert isinstance(rates[0].mark_price, Decimal) assert rates[0].mark_price == Decimal("64000.00") - assert isinstance(rates[1].funding_rate, float) - assert rates[1].funding_rate == -0.0002 + assert isinstance(rates[1].funding_rate, Decimal) + assert rates[1].funding_rate == Decimal("-0.0002") params = route.calls.last.request.url.params assert params["ticker"] == "BTC-PERP" assert params["start_ts"] == "1000" diff --git a/tests/perps/test_margin_account.py b/tests/perps/test_margin_account.py index 264150b..2c3de4c 100644 --- a/tests/perps/test_margin_account.py +++ b/tests/perps/test_margin_account.py @@ -78,6 +78,16 @@ def test_flag_omitted_by_default(self, perps_client: PerpsClient) -> None: # Param absent unless caller opts in. assert "compute_available_balance" not in route.calls.last.request.url.params + @respx.mock + def test_null_subaccount_balances_tolerated(self, perps_client: PerpsClient) -> None: + # #407: NullableList coerces a server-returned null array to []. + respx.get(f"{BASE}/margin/balance").mock( + return_value=httpx.Response( + 200, json={"subaccount_balances": None, "settled_funds": "0.0000"} + ) + ) + assert perps_client.margin.balance().subaccount_balances == [] + @respx.mock def test_flag_true_sends_query_param(self, perps_client: PerpsClient) -> None: route = respx.get(f"{BASE}/margin/balance").mock( @@ -195,7 +205,9 @@ def test_happy_signed_position_and_nullables(self, perps_client: PerpsClient) -> ) ) resp = perps_client.margin.risk() - assert resp.account_leverage == 2.5 + assert resp.account_leverage == Decimal("2.5") + assert isinstance(resp.account_leverage, Decimal) + assert isinstance(resp.positions[0].position_leverage, Decimal) assert resp.total_position_notional == Decimal("10000.0000") assert isinstance(resp.total_maintenance_margin, Decimal) first = resp.positions[0] @@ -226,6 +238,22 @@ def test_edge_null_leverage_empty_positions(self, perps_client: PerpsClient) -> assert resp.account_leverage is None assert resp.positions == [] + @respx.mock + def test_null_positions_array_tolerated(self, perps_client: PerpsClient) -> None: + # #407: Kalshi may send JSON null for a spec-required array; NullableList + # coerces it to [] rather than raising ValidationError. + respx.get(f"{BASE}/margin/risk").mock( + return_value=httpx.Response( + 200, + json={ + "total_position_notional": "0.0000", + "total_maintenance_margin": "0.0000", + "positions": None, + }, + ) + ) + assert perps_client.margin.risk().positions == [] + @respx.mock def test_server_error_maps(self, perps_client: PerpsClient) -> None: respx.get(f"{BASE}/margin/risk").mock(return_value=httpx.Response(500)) @@ -365,10 +393,10 @@ def test_happy(self, perps_client: PerpsClient) -> None: ) ) resp = perps_client.margin.fee_tiers() - assert resp.maker_fee_rates["BTC-PERP"] == 0.0005 - assert isinstance(resp.maker_fee_rates["BTC-PERP"], float) - assert resp.taker_fee_rates["ETH-PERP"] == 0.0015 - assert isinstance(resp.taker_fee_rates["ETH-PERP"], float) + assert resp.maker_fee_rates["BTC-PERP"] == Decimal("0.0005") + assert isinstance(resp.maker_fee_rates["BTC-PERP"], Decimal) + assert resp.taker_fee_rates["ETH-PERP"] == Decimal("0.0015") + assert isinstance(resp.taker_fee_rates["ETH-PERP"], Decimal) @respx.mock def test_edge_empty_maps(self, perps_client: PerpsClient) -> None: @@ -409,7 +437,7 @@ async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: ) ) resp = await async_perps_client.margin.fee_tiers() - assert resp.taker_fee_rates["X"] == 0.002 + assert resp.taker_fee_rates["X"] == Decimal("0.002") await async_perps_client.close() diff --git a/tests/perps/test_markets.py b/tests/perps/test_markets.py index a86f546..eed7363 100644 --- a/tests/perps/test_markets.py +++ b/tests/perps/test_markets.py @@ -86,8 +86,8 @@ def test_happy(self, perps_client: PerpsClient) -> None: assert isinstance(m.contract_size, Decimal) assert m.tick_size == Decimal("0.0100") assert isinstance(m.tick_size, Decimal) - assert m.leverage_estimate == 2.5 - assert isinstance(m.leverage_estimate, float) + assert m.leverage_estimate == Decimal("2.5") + assert isinstance(m.leverage_estimate, Decimal) assert m.volume == Decimal("1000.00") @respx.mock diff --git a/tests/perps/test_portfolio.py b/tests/perps/test_portfolio.py index 8d35124..aebc35d 100644 --- a/tests/perps/test_portfolio.py +++ b/tests/perps/test_portfolio.py @@ -99,9 +99,10 @@ def test_happy_long_and_short(self, perps_client: PerpsClient) -> None: assert long.position == Decimal("100.00") assert short.position == Decimal("-40.00") assert short.unrealized_pnl == Decimal("-5.5000") - # roe wire name surfaces as return_on_equity. - assert long.return_on_equity == 24.68 - assert short.return_on_equity == -27.5 + # roe wire name surfaces as return_on_equity (MultiplierDecimal). + assert long.return_on_equity == Decimal("24.68") + assert isinstance(long.return_on_equity, Decimal) + assert short.return_on_equity == Decimal("-27.5") assert route.called @respx.mock