Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions kalshi/perps/models/funding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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):
Expand All @@ -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"),
)
Expand All @@ -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"),
)
Expand Down Expand Up @@ -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"),
Expand Down
28 changes: 18 additions & 10 deletions kalshi/perps/models/margin_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -41,7 +48,7 @@ class GetMarginBalanceResponse(BaseModel):

model_config = ConfigDict(extra="allow")

subaccount_balances: list[MarginSubaccountBalance]
subaccount_balances: NullableList[MarginSubaccountBalance]
settled_funds: DollarDecimal


Expand All @@ -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


Expand All @@ -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):
Expand All @@ -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]
4 changes: 2 additions & 2 deletions kalshi/perps/models/markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions kalshi/perps/models/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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"),
)
Expand Down
10 changes: 5 additions & 5 deletions tests/perps/test_funding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
40 changes: 34 additions & 6 deletions tests/perps/test_margin_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()


Expand Down
4 changes: 2 additions & 2 deletions tests/perps/test_markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions tests/perps/test_portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down