Skip to content

fix(types): to_decimal rejects NaN/Inf; Page._columns handles None-first nullable nested; DollarDecimal bounds on requests#369

Merged
TexasCoding merged 1 commit into
mainfrom
r3/W2-B
May 22, 2026
Merged

fix(types): to_decimal rejects NaN/Inf; Page._columns handles None-first nullable nested; DollarDecimal bounds on requests#369
TexasCoding merged 1 commit into
mainfrom
r3/W2-B

Conversation

@TexasCoding

Copy link
Copy Markdown
Owner

Summary

Three MEDIUM/LOW correctness fixes from the Round-3 audit closure plan (wave W2).
Closes a NaN/Inf hole in the public to_decimal helper, repairs a Page._columns
column-detection bug that broke nullable nested Struct inference under polars,
and adds sign / tick bounds to request-side price fields so invalid inputs fail
at construction instead of round-tripping to a server 400.

Issues closed

Behavioral changes

  • to_decimal(float('nan')) / to_decimal(Decimal('Infinity')) / to_decimal("NaN") now raise ValueError("Decimal must be finite ...") instead of returning a non-finite Decimal.
  • CreateOrderRequest, AmendOrderRequest, CreateOrderV2Request, AmendOrderV2Request now reject negative prices and prices finer than the $0.0001 tick at construction (ValidationError). Valid (zero or tick-aligned, non-negative) prices behave identically.
  • Page.to_polars() / Page.to_dataframe() now correctly dump nested-model columns even when the first row's nested value is None (visible as a polars Struct dtype rather than an object column or a ValueError).

Tests

  • tests/test_types.py::TestIssue325ToDecimalRejectsNanInfto_decimal rejects NaN/Infinity/-Infinity from Decimal / str / float inputs; finite Decimal identity is preserved.
  • tests/test_models.py::TestIssue328PageColumnsNoneFirstNestedPage._columns and Page.to_polars() over a None-first nullable nested column produce the dumped dict cell and the polars Struct dtype.
  • tests/test_models.py::TestIssue343DollarDecimalRequestBounds — V1 and V2 create/amend requests reject negative prices and sub-tick precision; zero and tick-aligned values still construct; V2 response averages keep bare DollarDecimal (negatives accepted).

Source

Round-3 independent audit closure plan, wave W2.

to_decimal accepted NaN/Infinity despite docstring promising safety; now delegates to _coerce_decimal so the public helper and the DollarDecimal / FixedPointCount validators share the same is_finite() guard (#325).

Page._columns peeked only col[0] to decide whether to dump nested BaseModel cells. An Optional[NestedModel] column whose first row is None (common across Market/Event/Series/Settlement nullable struct columns) skipped the dump pass and leaked raw BaseModel instances into pandas/polars, breaking the polars Struct dtype the docstring promises. Now probes the whole column for the first non-None BaseModel (#328).

Request-side DollarDecimal price fields had no sign or tick bounds — Decimal('-0.65') or Decimal('0.12345') constructed cleanly and round-tripped to a server 400. Introduce an OrderPrice annotated alias (BeforeValidator coerce + AfterValidator non-negative + $0.0001 tick) applied to CreateOrderRequest / AmendOrderRequest yes_price/no_price and CreateOrderV2Request / AmendOrderV2Request price. Response-side fields (PnL/fee/settlement averages) keep bare DollarDecimal since negatives are legitimate there (#343).

Closes #325, #328, #343
@TexasCoding TexasCoding added bug Something isn't working polish Code-quality and DX improvements; non-functional labels May 22, 2026
@claude

claude Bot commented May 22, 2026

Copy link
Copy Markdown

Code Review

Three clean, targeted correctness fixes. The PR description is accurate and the test coverage is solid. A few observations below — all minor; nothing blocks merge.


Fix 1 — to_decimal delegates to _coerce_decimal (Issue #325)

Correct approach. Eliminating the divergent code path removes the class of bug entirely rather than patching to_decimal in isolation.

One subtle point: the old to_decimal returned the same Decimal object for Decimal input (return value). The new path sets result = value and then return result, which also preserves object identity for finite Decimal inputs — the test assert to_decimal(d) is d correctly pins this invariant.

Minor nit: _coerce_decimal is typed Any for its value parameter, while to_decimal's signature still reads int | float | str | Decimal. The delegate now raises TypeError for inputs outside that set (e.g., to_decimal(object())), where the old code would attempt Decimal(str(value)). This is a safer behaviour change, but the public signature could optionally be updated to Any to match the reality — or kept tight to document intent. Either is defensible.


Fix 2 — Page._columns probes full column (Issue #328)

The generator expression with next() is correct and short-circuits at the first populated BaseModel, so the common case (first row is not None) remains O(1). No concerns here.

The inline comment is appropriately scoped (explains a non-obvious invariant tied to a specific bug) — fits the project's "only when WHY is non-obvious" guideline.


Fix 3 — OrderPrice alias with sign + tick guards (Issue #343)

Design is sound. Using an AfterValidator on top of the existing BeforeValidator(_coerce_decimal) means:

  1. Non-finite values are rejected first (by _coerce_decimal), so _ensure_request_price always receives a valid Decimal.
  2. The wire shape (BeforeValidator + PlainSerializer) is identical to DollarDecimal, so serialization/drift tests are unaffected.

One edge case worth a quick mental check: Decimal("0.56") has exponent -2 and _REQUEST_PRICE_TICK has exponent -4. Decimal("0.56").quantize(Decimal("0.0001")) returns Decimal("0.5600"), and Decimal("0.56") != Decimal("0.5600") is False in Python's decimal module (numeric equality, not representation equality) — so tick-aligned values with fewer trailing zeros still pass. ✓

Missing export: OrderPrice is not added to kalshi/__init__.py. If users ever want to build a custom request model that reuses the same validation semantics, they'd have to import directly from kalshi.types. Probably intentional (it's an implementation detail), but worth a conscious decision. If you decide it belongs in the public surface, a one-liner addition to __init__.py is all that's needed.


Tests

Coverage is thorough across all three cases. A couple of small observations:

  • tests/test_types.py has an extra blank line before class TestIssue325ToDecimalRejectsNanInf (three blank lines instead of two). Won't break anything, but ruff will flag it.
  • No explicit regression test for to_decimal(True)TypeError. The bool guard was previously an explicit branch in to_decimal and is now inherited from _coerce_decimal. An existing test may already cover it; if not, a one-liner is worth adding to the new class.
  • test_issue_343_accepts_zero_and_tick_aligned_prices only exercises CreateOrderRequest. A single AmendOrderRequest positive case would make the symmetry explicit, though the negative-path tests already cover all four models.

Summary

Area Verdict
Correctness ✅ All three fixes are accurate
Wire compatibility OrderPrice serializes identically to DollarDecimal
Test coverage ✅ Happy path, negative path, edge cases covered
Style / conventions Minor: extra blank line in test_types.py
Public API surface Consider whether OrderPrice belongs in __init__.py

Nothing here blocks merge. The extra blank line is the only ruff-flagged concern.

@TexasCoding
TexasCoding merged commit 7240e75 into main May 22, 2026
5 checks passed
@TexasCoding
TexasCoding deleted the r3/W2-B branch May 22, 2026 23:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working polish Code-quality and DX improvements; non-functional

Projects

None yet

1 participant