Skip to content

polish(models): AwareDatetime in WS payloads, V1 Literals, UnixSecondsTimestamp alias, Decimal NaN reject - #292

Merged
TexasCoding merged 3 commits into
mainfrom
polish/270-models
May 21, 2026
Merged

TexasCoding merged 3 commits into
mainfrom
polish/270-models

Conversation

@TexasCoding

Copy link
Copy Markdown
Owner

Closes #270

Part of #273 (post-v2.4 audit), Wave 3.

…nt market_lifecycle seconds unit

WS payload models predated #234's REST AwareDatetime rollout, so they
accepted naive RFC3339 strings that REST callers couldn't. Align the
two by retyping the datetime fields on UserOrdersPayload (4 fields)
and the communications payloads (5 fields) to pydantic.AwareDatetime —
a string without an offset now raises ValidationError at parse rather
than silently producing a tz-unaware datetime that breaks arithmetic
against tz-aware peers in MarketPosition/Settlement.

MarketLifecyclePayload's *_ts fields are intentionally left as bare
int. The AsyncAPI spec example payload uses Unix **seconds** (e.g.
open_ts: 1694635200, 2023-09-13), not ms — so the lack of the _ts_ms
suffix carried by sibling channels is correct. Added a comment so
readers stop wondering.
…le response fields; UnixSecondsTimestamp alias

- V1 CreateOrderRequest tightens side/action/time_in_force/self_trade_prevention_type
  from bare str to the same Literal aliases V2 uses (SideLiteral, ActionLiteral,
  TimeInForceLiteral, SelfTradePreventionTypeLiteral). V1 keeps its yes/no side
  vocabulary (V2 is bid/ask). Caller passing side='foo' now fails at construction,
  matching V2 — closes the deprecation-window drift before V1 sunset (~May 2026).

- portfolio.MarketPosition / historical.Trade keep total_traded, position,
  count as 'T | None' without default=None. The OpenAPI spec marks the
  underlying wire keys (total_traded_dollars, position_fp, count_fp) as
  **required**, so omission must still raise ValidationError — only the
  observed JSON null is admitted. Added docstring notes explaining the
  intentional non-default so the audit doesn't re-flag them.

- types.UnixSecondsTimestamp: Annotated[int, …] alias for int64 Unix-seconds
  wire timestamps. Internal type-aid only (not re-exported via kalshi.__init__).
  Applied to Balance.updated_ts, Deposit.created_ts/finalized_ts,
  Withdrawal.created_ts/finalized_ts so the int-vs-AwareDatetime split is
  greppable when balances/positions are read side-by-side.

- Drift checker (tests/test_contracts.py) now classifies pydantic
  AwareDatetime/NaiveDatetime and ForwardRef('datetime') as kind='datetime',
  matching what those models actually validate at runtime.
`f"{Decimal('NaN'):f}"` returns the literal string `"NaN"`, and
`_coerce_decimal` previously short-circuited on the `isinstance(value,
Decimal)` branch — a caller doing arithmetic that produced NaN and then
constructing a `CreateOrderRequest(yes_price=that_nan)` would ship
`"NaN"` straight to a real-money order placement endpoint.

`_coerce_decimal` now computes the Decimal first, then `is_finite()`-
guards it, raising `ValueError` on NaN/+Inf/-Inf. One branch protects
every DollarDecimal / FixedPointCount / MultiplierDecimal field at both
parse and serialize.

Closes #270
@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown

Code Review — PR #292

Wave 3 of the post-v2.4 audit. Solid batch of correctness improvements. Three minor issues worth addressing before merge, plus one lower-priority note.


Overview

Five independent items land together:

  1. WS payload datetime → AwareDatetime (matches REST models standardised in docs+polish: env-vars NameError, stale docs, limits default, WS perf, cancel idempotency, events default; populate_by_name on WS; AwareDatetime on REST #234)
  2. CreateOrderRequest enum fields narrowed from bare str to Literals
  3. UnixSecondsTimestamp documentation alias for Unix-seconds int fields
  4. _coerce_decimal NaN/Infinity guard — prevents shipping "NaN" to money endpoints
  5. Contract-test type classifier updated to recognise AwareDatetime/NaiveDatetime

All items have targeted tests. The changes are surgical and match existing conventions.


Issues

1. to_decimal() public helper bypasses the NaN guard — medium severity

_coerce_decimal now rejects non-finite Decimals, but to_decimal() in types.py short-circuits on isinstance(value, Decimal): return value (line 105-106) and never calls _coerce_decimal. A caller who does:

from kalshi.types import to_decimal
from decimal import Decimal
to_decimal(Decimal("NaN"))  # returns Decimal("NaN") silently

…can then pass the result as yes_price= to CreateOrderRequest — but only if they use to_decimal() directly instead of constructing the model with a raw Decimal("NaN"). The Pydantic BeforeValidator on DollarDecimal will still catch it at model construction time, so the protection is not entirely absent, but the public helper is inconsistent with the intent of this PR. Consider adding the same guard to to_decimal():

result = Decimal(str(value)) if not isinstance(value, Decimal) else value
if not result.is_finite():
    raise ValueError("Decimal must be finite (got NaN/Infinity)")
return result

2. WS tests only cover rejection; no happy-path acceptance test — low severity

TestWsPayloadsRejectNaiveDatetime verifies three rejection cases, but there is no test confirming that a valid timezone-aware string ("2026-01-01T00:00:00Z") actually parses into an AwareDatetime. This is useful as a regression guard — if someone accidentally swaps AwareDatetime back to NaiveDatetime, the rejection tests still pass (NaiveDatetime rejects +00:00 offsets too), but a parse test would fail. One test per class is enough:

def test_user_orders_payload_accepts_aware_created_time(self) -> None:
    payload = user_orders_payload_dict(created_time="2026-01-01T00:00:00Z")
    m = UserOrdersPayload.model_validate(payload)
    assert m.created_time.tzinfo is not None

3. ForwardRef string matching in test_contracts.py is fragile — low severity

The new set literal at line 604-608 matches __forward_arg__ by string value:

if isinstance(base, typing.ForwardRef) and base.__forward_arg__ in (
    "datetime", "datetime | None",
    "AwareDatetime", "AwareDatetime | None",
    "NaiveDatetime", "NaiveDatetime | None",
):

If Pydantic changes how it stringifies complex forward refs (e.g., Optional[AwareDatetime] vs AwareDatetime | None depending on Python version), this silently stops matching and fields would be classified as "unknown", causing drift false-positives. A short comment explaining why these exact strings appear (Pydantic's lazy annotation resolution before first model_validate) would make this more maintainable, and a fallback that logs a warning on "unknown" classification might catch future drift.


Minor notes (no action required)

  • UnixSecondsTimestamp: The alias is Annotated[int, "string metadata"] — there is no runtime validation added (the string is ignored by Pydantic). This is intentional per the docstring ("same wire shape as plain int") and is consistent with how NullableList/DollarDecimal document intent via Annotated. Fine as-is.
  • Verbose class docstrings on Trade and MarketPosition: The added docstrings are on the long side for inline class docs (explaining the required-but-nullable design decision). The content is non-obvious and worth keeping somewhere, but a commit message or inline field comment might be a lighter touch. Not blocking.
  • Bool check reordering in _coerce_decimal: Moving isinstance(value, bool) before isinstance(value, Decimal) is harmless (bool is never a Decimal instance), but the new order is logically cleaner. Good cleanup.

Summary

Items 1 and 2 are worth addressing before merge. Item 3 is a maintenance note. Everything else looks correct and well-tested.

@TexasCoding
TexasCoding merged commit 1e838ea into main May 21, 2026
5 checks passed
@TexasCoding
TexasCoding deleted the polish/270-models branch May 21, 2026 13:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Polish bundle: model small items (AwareDatetime in WS, V1 Literal, default=None, timestamp typing, Decimal NaN)

1 participant