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
9 changes: 8 additions & 1 deletion kalshi/models/historical.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ class HistoricalCutoff(BaseModel):


class Trade(BaseModel):
"""A public trade on the exchange."""
"""A public trade on the exchange.

``count`` is typed ``FixedPointCount | None`` without ``default=None``
because the OpenAPI spec (``Trade.required``) marks ``count_fp`` as a
**required** response key. The ``| None`` admits server-side ``null``
on rare degenerate trades while a missing key still raises
ValidationError — schema regression, not a default.
"""

trade_id: str
ticker: str
Expand Down
8 changes: 4 additions & 4 deletions kalshi/models/orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,8 @@ class CreateOrderRequest(BaseModel):
"""

ticker: str
side: str
action: str
side: SideLiteral
action: ActionLiteral
count: FixedPointCount = Field(serialization_alias="count_fp")
yes_price: DollarDecimal | None = Field(
default=None,
Expand All @@ -185,10 +185,10 @@ class CreateOrderRequest(BaseModel):
client_order_id: str | None = None
expiration_ts: int | None = None
buy_max_cost: int | None = None
time_in_force: str | None = None
time_in_force: TimeInForceLiteral | None = None
post_only: bool | None = None
reduce_only: bool | None = None
self_trade_prevention_type: str | None = None
self_trade_prevention_type: SelfTradePreventionTypeLiteral | None = None
order_group_id: str | None = None
cancel_order_on_pause: bool | None = None
subaccount: int | None = None
Expand Down
23 changes: 16 additions & 7 deletions kalshi/models/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from pydantic import AliasChoices, AwareDatetime, BaseModel, Field

from kalshi.types import DollarDecimal, FixedPointCount, NullableList
from kalshi.types import DollarDecimal, FixedPointCount, NullableList, UnixSecondsTimestamp

SettlementStatusLiteral = Literal["all", "unsettled", "settled"]
"""Position settlement status filter for GET /fcm/positions. Spec: settlement_status query enum."""
Expand Down Expand Up @@ -44,7 +44,7 @@ class Balance(BaseModel):
balance: int
balance_dollars: DollarDecimal
portfolio_value: int
updated_ts: int
updated_ts: UnixSecondsTimestamp
balance_breakdown: list[IndexedBalance] | None = None

model_config = {"extra": "allow"}
Expand All @@ -63,7 +63,16 @@ class TotalRestingOrderValue(BaseModel):


class MarketPosition(BaseModel):
"""A position in a single market."""
"""A position in a single market.

``total_traded`` and ``position`` are typed ``DollarDecimal | None`` /
``FixedPointCount | None`` without ``default=None`` because the OpenAPI
spec (``MarketPosition.required``) marks ``total_traded_dollars`` and
``position_fp`` as **required** response keys. The ``| None`` admits the
server's observed ``null`` on flat positions while a missing key still
raises ValidationError — silent omission is treated as a schema regression,
not a default.
"""

ticker: str
total_traded: DollarDecimal | None = Field(
Expand Down Expand Up @@ -142,8 +151,8 @@ class Deposit(BaseModel):
type: PaymentTypeLiteral
amount_cents: int
fee_cents: int
created_ts: int
finalized_ts: int | None = None
created_ts: UnixSecondsTimestamp
finalized_ts: UnixSecondsTimestamp | None = None

model_config = {"extra": "allow"}

Expand All @@ -156,8 +165,8 @@ class Withdrawal(BaseModel):
type: PaymentTypeLiteral
amount_cents: int
fee_cents: int
created_ts: int
finalized_ts: int | None = None
created_ts: UnixSecondsTimestamp
finalized_ts: UnixSecondsTimestamp | None = None

model_config = {"extra": "allow"}

Expand Down
36 changes: 29 additions & 7 deletions kalshi/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,28 @@ def _coerce_decimal(value: Any) -> Decimal:
differ only in their canonical wire name and intent; the decimal coercion is the same.
Going through ``str(value)`` for ``int`` / ``float`` avoids the binary-float
representation drift (``Decimal(0.65) == Decimal('0.65000000000000002...')``).

Non-finite values (``NaN``, ``Infinity``, ``-Infinity``) are rejected after coercion.
``f"{Decimal('NaN'):f}"`` returns the literal string ``"NaN"``; without this guard
a caller doing arithmetic that produces NaN and then constructing a Dollar/FP-typed
request would ship the string ``"NaN"`` straight to a real-money endpoint.
"""
if isinstance(value, Decimal):
return value
if isinstance(value, bool):
raise TypeError(
"Cannot convert bool to Decimal — bool is an int subclass, "
"so this is almost always a typo (did you mean count=1?)."
)
if isinstance(value, (int, float)):
return Decimal(str(value))
if isinstance(value, str):
return Decimal(value)
raise TypeError(f"Cannot convert {type(value).__name__} to Decimal")
if isinstance(value, Decimal):
result = value
elif isinstance(value, (int, float)):
result = Decimal(str(value))
elif isinstance(value, str):
result = Decimal(value)
else:
raise TypeError(f"Cannot convert {type(value).__name__} to Decimal")
if not result.is_finite():
raise ValueError("Decimal must be finite (got NaN/Infinity)")
return result


def _decimal_to_str(value: Decimal) -> str:
Expand Down Expand Up @@ -116,3 +125,16 @@ def _none_to_empty_list(value: Any) -> Any:
the live API has been observed to return null. Do NOT use on request bodies —
those should enforce strict validation.
"""

# Internal type aid — not re-exported via kalshi.__init__. The bare int
# wire shape is preserved; this alias only documents intent and gives mypy/IDE
# a greppable name when balance/deposit/withdrawal timestamps are read
# alongside the AwareDatetime peers on MarketPosition/Settlement.
UnixSecondsTimestamp = Annotated[int, "Unix epoch seconds (int64) per OpenAPI spec"]
"""Server-side ``format: int64`` Unix-seconds timestamp.

Same wire shape as plain ``int``; the alias exists to make it obvious at the
field site that the value is *seconds* (not milliseconds, not a datetime).
Callers wanting a :class:`datetime.datetime` can use
``datetime.fromtimestamp(value, tz=timezone.utc)``.
"""
12 changes: 5 additions & 7 deletions kalshi/ws/models/communications.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
"""Communications channel message models (RFQ and quote notifications)."""
from __future__ import annotations

from datetime import datetime

from pydantic import AliasChoices, BaseModel, Field
from pydantic import AliasChoices, AwareDatetime, BaseModel, Field

from kalshi.types import DollarDecimal, FixedPointCount

Expand All @@ -19,7 +17,7 @@ class RfqCreatedPayload(BaseModel):
id: str
creator_id: str
market_ticker: str
created_ts: datetime
created_ts: AwareDatetime
event_ticker: str | None = None
contracts: FixedPointCount | None = Field(
default=None,
Expand Down Expand Up @@ -47,7 +45,7 @@ class RfqDeletedPayload(BaseModel):
id: str
creator_id: str
market_ticker: str
deleted_ts: datetime
deleted_ts: AwareDatetime

# v0.14+ backfill (#162). Same RFQ context as RfqCreatedPayload —
# surfaced again on delete for clients that subscribed mid-RFQ.
Expand Down Expand Up @@ -76,7 +74,7 @@ class QuoteCreatedPayload(BaseModel):
no_bid: DollarDecimal = Field(
validation_alias=AliasChoices("no_bid_dollars", "no_bid"),
)
created_ts: datetime
created_ts: AwareDatetime

# v0.14+ backfill (#162). Event linkage + RFQ offer/cost context echoed
# on the quote so subscribers don't need to look up the parent RFQ.
Expand Down Expand Up @@ -143,7 +141,7 @@ class QuoteExecutedPayload(BaseModel):
order_id: str
client_order_id: str
market_ticker: str
executed_ts: datetime
executed_ts: AwareDatetime
model_config = {"extra": "allow", "populate_by_name": True}


Expand Down
10 changes: 4 additions & 6 deletions kalshi/ws/models/user_orders.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
"""User orders channel message models."""
from __future__ import annotations

from datetime import datetime

from pydantic import AliasChoices, BaseModel, Field
from pydantic import AliasChoices, AwareDatetime, BaseModel, Field

from kalshi.models.orders import (
BookSideLiteral,
Expand Down Expand Up @@ -55,10 +53,10 @@ class UserOrdersPayload(BaseModel):
validation_alias=AliasChoices("maker_fees_dollars", "maker_fees"),
)
client_order_id: str
created_time: datetime
created_time: AwareDatetime
order_group_id: str | None = None
last_update_time: datetime | None = None
expiration_time: datetime | None = None
last_update_time: AwareDatetime | None = None
expiration_time: AwareDatetime | None = None
subaccount_number: int | None = None
# v0.14+ backfill (#162). outcome_side/book_side/self_trade_prevention_type
# mirror Order from #159. *_ts_ms (Unix ms) supersede the *_time RFC3339
Expand Down
24 changes: 24 additions & 0 deletions tests/test_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
yaml = None # type: ignore[assignment]

from pydantic import AliasChoices
from pydantic import AwareDatetime as _PydAware
from pydantic import BaseModel as PydanticBase
from pydantic import NaiveDatetime as _PydNaive
from pydantic.fields import FieldInfo

from kalshi._contract_map import CONTRACT_MAP, WS_CONTRACT_MAP, ContractEntry
Expand All @@ -43,6 +45,12 @@
_resolve_request_body_schema,
)

# pydantic v2 ships AwareDatetime / NaiveDatetime as concrete classes whose
# validation produces a stdlib datetime. The drift checker treats them as
# equivalent to ``datetime`` for kind-classification purposes (spec is just
# ``string`` + ``format: date-time``).
_PYDANTIC_DATETIME_TYPES: tuple[type, ...] = (_PydAware, _PydNaive)

SPEC_FILE = Path(__file__).parent.parent / "specs" / "openapi.yaml"


Expand Down Expand Up @@ -581,6 +589,22 @@ def _sdk_type_kind(ann: Any) -> str:
return "decimal"
if base is datetime:
return "datetime"
# Pydantic AwareDatetime / NaiveDatetime are concrete classes whose
# validation produces a ``datetime``; treat them as the same kind for
# spec-drift purposes (spec is just ``string`` + ``format: date-time``).
if base in _PYDANTIC_DATETIME_TYPES:
return "datetime"
# Pydantic stores ``AwareDatetime``/``NaiveDatetime`` field annotations
# as ``ForwardRef('datetime')`` (or ``'datetime | None'``) until the
# first model_validate triggers eager resolution. The drift suite runs
# before any frame parses, so resolve the forward-ref class-name here
# so a date-time field is classified ``datetime``, not ``unknown``.
if isinstance(base, typing.ForwardRef) and base.__forward_arg__ in (
"datetime", "datetime | None",
"AwareDatetime", "AwareDatetime | None",
"NaiveDatetime", "NaiveDatetime | None",
):
return "datetime"
origin = typing.get_origin(base)
if origin is list:
args = typing.get_args(base)
Expand Down
39 changes: 39 additions & 0 deletions tests/test_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import httpx
import pytest
import respx
from pydantic import ValidationError

from kalshi._base_client import SyncTransport
from kalshi.auth import KalshiAuth
Expand Down Expand Up @@ -1924,3 +1925,41 @@ def test_fills_rejects_limit_above_1000(
) -> None:
with pytest.raises(ValueError, match=r"limit must be in \[1, 1000\]"):
orders.fills(limit=10_000)


class TestCreateOrderRequestLiteralEnforcement:
"""#270 Item 2: V1 CreateOrderRequest narrows enum-style fields to Literals.

Before this change V1 callers could pass ``side="foo"`` and only fail server-
side. V2 already enforces literals at construction; aligning V1 closes the
deprecation-window inconsistency.
"""

def test_rejects_invalid_side(self) -> None:
with pytest.raises(ValidationError):
CreateOrderRequest(ticker="T", side="foo", action="buy", count=Decimal("1")) # type: ignore[arg-type]

def test_rejects_invalid_action(self) -> None:
with pytest.raises(ValidationError):
CreateOrderRequest(ticker="T", side="yes", action="trade", count=Decimal("1")) # type: ignore[arg-type]

def test_rejects_invalid_time_in_force(self) -> None:
with pytest.raises(ValidationError):
CreateOrderRequest(
ticker="T", side="yes", action="buy", count=Decimal("1"),
time_in_force="forever", # type: ignore[arg-type]
)

def test_rejects_invalid_self_trade_prevention_type(self) -> None:
with pytest.raises(ValidationError):
CreateOrderRequest(
ticker="T", side="yes", action="buy", count=Decimal("1"),
self_trade_prevention_type="never", # type: ignore[arg-type]
)

def test_accepts_valid_v1_yes_no_side(self) -> None:
# V1 uses yes/no (NOT bid/ask like V2)
req = CreateOrderRequest(ticker="T", side="no", action="sell", count=Decimal("1"))
assert req.side == "no"
assert req.action == "sell"

39 changes: 39 additions & 0 deletions tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,42 @@ class M(BaseModel):
result = m.model_dump(mode="python")
assert isinstance(result["count"], Decimal)
assert result["count"] == Decimal("42")


class TestCoerceDecimalRejectsNonFinite:
"""#270 Item 5: NaN/Infinity rejected so they never reach the wire.

``f"{Decimal('NaN'):f}"`` returns the literal string ``"NaN"``, and before this
guard ``_coerce_decimal`` short-circuited on the ``isinstance(value, Decimal)``
branch — a caller doing arithmetic that produced NaN would silently ship
``"NaN"`` to a real-money order placement endpoint. Same for Infinity.
"""

def test_rejects_nan_decimal_on_dollar_field(self) -> None:
with pytest.raises(ValueError, match="finite"):
_DollarModel.model_validate({"x": Decimal("NaN")})

def test_rejects_infinity_decimal_on_dollar_field(self) -> None:
with pytest.raises(ValueError, match="finite"):
_DollarModel.model_validate({"x": Decimal("Infinity")})

def test_rejects_negative_infinity_decimal_on_count_field(self) -> None:
with pytest.raises(ValueError, match="finite"):
_CountModel.model_validate({"x": Decimal("-Infinity")})

def test_rejects_nan_string_on_dollar_field(self) -> None:
with pytest.raises(ValueError, match="finite"):
_DollarModel.model_validate({"x": "NaN"})

def test_accepts_finite_decimal(self) -> None:
m = _DollarModel.model_validate({"x": Decimal("0.5")})
assert m.x == Decimal("0.5")

def test_create_order_request_rejects_nan_yes_price(self) -> None:
from kalshi.models.orders import CreateOrderRequest
with pytest.raises(ValueError, match="finite"):
CreateOrderRequest(
ticker="T", side="yes", action="buy",
count=Decimal("1"), yes_price=Decimal("NaN"),
)

Loading
Loading