Skip to content

fix(types): preserve Decimal in DataFrames; positional serialization; reject bool#225

Merged
TexasCoding merged 3 commits into
mainfrom
audit/w0a-types-foundation
May 21, 2026
Merged

fix(types): preserve Decimal in DataFrames; positional serialization; reject bool#225
TexasCoding merged 3 commits into
mainfrom
audit/w0a-types-foundation

Conversation

@TexasCoding

Copy link
Copy Markdown
Owner

Wave 0-A of the 2026-05-21 SDK audit umbrella. Three independent type-layer fixes.

Changes

  • DollarDecimal / FixedPointCount PlainSerializer now uses when_used='json' so model_dump(mode='python') returns a live Decimal instead of str. Fixes Page.to_dataframe / to_polars silent string-concatenation corruption.
  • _decimal_to_str uses f'{v:f}' for positional format — eliminates scientific notation ('1E+10') on the wire.
  • _coerce_decimal + to_decimal reject bool explicitly with a clear TypeError. Previously raised decimal.InvalidOperation via str(True).

Tests

13 new tests; pre-existing 16 tests in scope still pass; mypy --strict clean.

Closes #190, Closes #191, Closes #216
Refs #224

… reject bool

- DollarDecimal/FixedPointCount serializer now when_used='json' so model_dump(mode='python') returns live Decimal (fixes Page.to_dataframe / to_polars silent str corruption).

- _decimal_to_str uses f'{v:f}' for positional format; eliminates scientific notation on the wire.

- _coerce_decimal + to_decimal reject bool explicitly (was raising decimal.InvalidOperation via str(True)).

Closes #190, Closes #191, Closes #216

Refs #224
@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown

Code Review — PR #225: fix(types): preserve Decimal in DataFrames; positional serialization; reject bool

Overview

Three tightly scoped, independent fixes to the type layer. All three are correct and well-reasoned. The when_used=\"json\" change is the highest-value fix — it prevents silent data corruption (string concatenation instead of numeric addition) when model_dump(mode=\"python\") feeds a DataFrame.


🔴 Blocking: CI Lint Failures

Two ruff errors are blocking CI. Both are in the new test code.

1. test_types.py — unsorted imports (I001)

from decimal import Decimal (stdlib) must precede from pydantic import BaseModel (third-party). isort expects stdlib → third-party → local.

# current (wrong order):
from __future__ import annotations

import pytest
from pydantic import BaseModel

from decimal import Decimal  # ← stdlib after third-party

from kalshi.types import ...

# fix:
from __future__ import annotations

from decimal import Decimal

import pytest
from pydantic import BaseModel

from kalshi.types import ...

2. test_page_dataframe.py:313 — unused variable pl (F841)

In test_page_to_polars_decimal_sum_is_decimal, the return value of pytest.importorskip is assigned but never used. The call itself is needed (it raises Skipped if polars is absent), but the binding is not.

# fix:
pytest.importorskip("polars")

🟡 Minor Issues

3. Mid-file import in test_page_dataframe.py

# line ~259 — after the existing test class body:
from kalshi.types import DollarDecimal  # noqa: E402

The # noqa: E402 suppresses ruff's E402 ("module level import not at top of file"). Functionally fine, but it's a code smell — ideally move this import to the top of the file with the existing imports. If keeping it mid-file, at least leave a one-blank-line gap after the class above (PEP 8).

4. TestFixedPointCountDumpMode is missing a JSON-mode test

TestDollarDecimalDumpMode tests both mode=\"python\" and mode=\"json\", but TestFixedPointCountDumpMode only has the Python-mode test. Since FixedPointCount has the identical serializer, a symmetrical JSON-mode test would complete coverage and guard against future divergence.

5. Bool guard is duplicated across _coerce_decimal and to_decimal

Both functions now contain the identical isinstance(value, bool) guard. This is intentional — the two functions serve different call sites — but a shared private helper (e.g., _reject_bool(value)) would eliminate the duplication. Not a blocker; mentioning as a future polish option.


✅ What's Well Done

  • when_used=\"json\": Correct Pydantic v2 idiom. Restricting PlainSerializer to JSON mode means model_dump(mode=\"python\") now returns native Decimal, fixing the DataFrame silent-corruption bug (Page.to_dataframe / Page.to_polars silently corrupt Decimal columns (str instead of Decimal) #190) without any change to wire serialization.
  • f\"{value:f}\": The :f format spec forces fixed-point notation for all Decimal magnitudes. Decimal(\"1E+10\")\"10000000000\", Decimal(\"1E-7\")\"0.0000001\", trailing zeros preserved (\"0.5600\"\"0.5600\"). This is the right fix for DollarDecimal serialization can emit scientific notation (Kalshi may reject) #191.
  • Bool rejection before int branch: Correct ordering — bool must be caught before isinstance(value, (int, float)) since bool is an int subclass. The error message is helpful and actionable.
  • Test coverage: The happy/error/edge structure is thorough. test_dollar_decimal_model_dump_json_returns_str uses Decimal(\"1E+10\") specifically to confirm the scientific-notation fix end-to-end.
  • mypy --strict: No new type: ignore directives except on intentional call-site tests (to_decimal(True) etc.).

Summary

Fix the two lint errors and this is ready to merge. The core logic changes are correct and the tests are solid.

@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown

Code Review — PR #225 (updated review)

Three independent, well-scoped fixes to the type layer. All three core logic changes are correct. This review supersedes the earlier automated comment which contained two false positives.

Core Logic — All Correct

when_used="json" on PlainSerializer: The right Pydantic v2 idiom. model_dump(mode="python") now returns live Decimal objects; model_dump(mode="json") (used by _base_client.py for wire serialization) still calls _decimal_to_str. Wire format is unchanged.

f"{value:f}" positional format: Correctly handles all edge cases: Decimal("1E+10")"10000000000", Decimal("1E-7")"0.0000001", trailing zeros preserved ("0.5600" stays "0.5600").

Bool rejection before the int branch: Ordering is correct — bool must be caught before isinstance(value, (int, float)) since bool is an int subclass. Error message is clear and actionable.


Corrections to Prior Review

The previous automated comment flagged two issues that do not exist in the actual PR branch:

  1. Import order in test_types.py — Already correct. from decimal import Decimal (stdlib) precedes import pytest (third-party). No action needed.

  2. F841 unused pl variablepl = pytest.importorskip("polars") is correctly bound where pl.Decimal is referenced in the assertion. test_page_to_polars_decimal_sum_is_decimal correctly omits the binding since it only checks isinstance(total, Decimal).


Actual Issues

E302 — missing blank lines in test_types.py

No blank lines separate TestFixedPointCountTypeFallback from TestDecimalToStrPositional. Ruff E302 requires two blank lines between top-level class definitions. This is the most likely CI lint failure.

Fix: add two blank lines between those two classes.

Mid-file import in test_page_dataframe.py (cosmetic)

from kalshi.types import DollarDecimal # noqa: E402 sits below the existing test classes. Moving DollarDecimal to the top-of-file imports eliminates the noqa suppression and is cleaner. The _DollarRow model and _dollar_items helper could also move to module level.

TestFixedPointCountDumpMode missing a JSON-mode test (minor)

TestDollarDecimalDumpMode tests both mode="python" and mode="json". TestFixedPointCountDumpMode only has the Python-mode test. A symmetrical JSON-mode test would complete coverage and guard against future divergence between the two types.


Summary

Fix the E302 blank-line issue (and optionally clean up the mid-file import) and this is ready to merge. The core logic is correct and the tests are solid.

…mal contract

Two pre-existing tests asserted that .model_dump() returned wire strings for Decimal fields. With the new when_used='json' serializer (which is the actual fix for #190), model_dump(mode='python') correctly returns live Decimal objects. Tests now exercise both modes.
@TexasCoding TexasCoding merged commit 90cae97 into main May 21, 2026
5 checks passed
@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown

Code Review — PR #225

Overview

Three independent fixes to the type layer: when_used='json' on PlainSerializer, positional :f formatting, and explicit bool rejection. All three address real bugs with clear reproduction paths. The changes are tight, well-scoped, and test coverage is solid.


Correctness

when_used='json' fix (core change)

This is the right fix. With when_used='always' (the old default), model_dump(mode='python') applied the serializer and returned strings — causing silent string-concatenation when Pandas/Polars summed a price column. Constraining to when_used='json' fixes this.

Confirmed by audit: every resource method in the repo already calls model.model_dump(exclude_none=True, by_alias=True, mode='json') — no wire-serialization regression anywhere.

Positional :f format

str(Decimal("1E+10")) produces the bad wire value "1E+10"; the new f'{value:f}' produces "10000000000". Trailing zeros are preserved ("0.5600" stays "0.5600"). One untested edge case: with the old str(), Decimal("NaN") and Decimal("Infinity") produced "NaN"/"Infinity"; with :f they raise decimal.InvalidOperation. Not a production risk, but worth a docstring note.

Bool rejection

Guard is correctly placed before isinstance(value, (int, float)). Error messages are clear. Minor annotation inconsistency in to_decimal: the signature says int | float | str | Decimal but the body rejects bool (a subtype of int). Tests use # type: ignore[arg-type] to paper over this. Low priority.


Test Coverage

13 new tests, good breadth. Two symmetry gaps worth closing before merge:

  1. TestFixedPointCountDumpMode is missing the JSON-mode assertion. TestDollarDecimalDumpMode has both _python_returns_decimal and _json_returns_str; the FixedPointCount counterpart only has the Python-mode test.

  2. No bool-rejection test for FixedPointCount. TestCoerceDecimalRejectsBool covers _DollarModel only. A parallel _CountModel.model_validate assertion would complete coverage.


Style / CLAUDE.md

Comment references issue numbers — minor violation of "Don't reference the current task, fix, or callers". The why (mode mismatch causes string-concat) is worth keeping; stripping the issue number and tightening to one line would align with convention.

# noqa: E402 in test_page_dataframe.py — moving the from kalshi.types import DollarDecimal import to the top-of-file block removes the suppressor entirely.


Summary

Area Status
Core fix correctness
Wire serialization regression risk None — all resources confirmed to use mode='json'
Test coverage Good; two symmetry gaps (non-blocking)
CLAUDE.md style Minor (issue-ref comment, mid-file import)
Type annotation consistency Minor (bool subtype rejected at runtime)

All three fixes are correct and safe to merge. The two coverage gaps (missing FixedPointCount JSON-mode test and bool-rejection test for _CountModel) are non-blocking but would complete the symmetry.

@TexasCoding TexasCoding deleted the audit/w0a-types-foundation branch May 21, 2026 10:19
TexasCoding pushed a commit that referenced this pull request May 21, 2026
`buy_max_cost` is integer cents, but bool is an `int` subclass.
`buy_max_cost=True` was slipping through as the integer 1 (= a 1 cent
cap) — the exact failure mode #225 closed for `DollarDecimal` and
`FixedPointCount`. A caller who accidentally passed a flag (e.g.
`risk_check_passed`, `dry_run`) where cents were expected ended up
placing a real order capped at $0.01 with no error.

Prepend an `isinstance(v, bool)` rejection before the Decimal/float
branches, mirroring the wording/style of the existing rejections.
Audited the file: `buy_max_cost` is the only field with a
Decimal/float guard, so no other validators need the same patch.

Closes #243
TexasCoding added a commit that referenced this pull request May 21, 2026
… in buy_max_cost (#281)

* fix(orders): require count + action on create() kwarg path

Pre-#242 the kwarg-form `client.orders.create(ticker=..., side='yes')`
silently defaulted missing `count` to 1 and missing `action` to 'buy'.
For a real-money SDK that converted a missing-arg bug into a real
1-contract BUY fill — money risk.

Drop the silent defaults on both sync and async kwarg paths and raise
`TypeError` before any HTTP request when either is None. Mirror the
spec at the model level by removing the `Decimal('1')` default on
`CreateOrderRequest.count` (spec lists `count` as required); the
`action` field was already required since #172.

The `request=CreateOrderRequest(...)` overload path is unaffected:
the model now declares both required so a fully-populated request
still dispatches normally.

BREAKING CHANGE: callers relying on `orders.create(ticker, side)`
defaulting to 1-contract buy must now pass `count=` and `action=`
explicitly (or build a `CreateOrderRequest`). This is technically
breaking but money-risk-driven.

Closes #242

* fix(orders): reject bool in CreateOrderRequest.buy_max_cost validator

`buy_max_cost` is integer cents, but bool is an `int` subclass.
`buy_max_cost=True` was slipping through as the integer 1 (= a 1 cent
cap) — the exact failure mode #225 closed for `DollarDecimal` and
`FixedPointCount`. A caller who accidentally passed a flag (e.g.
`risk_check_passed`, `dry_run`) where cents were expected ended up
placing a real order capped at $0.01 with no error.

Prepend an `isinstance(v, bool)` rejection before the Decimal/float
branches, mirroring the wording/style of the existing rejections.
Audited the file: `buy_max_cost` is the only field with a
Decimal/float guard, so no other validators need the same patch.

Closes #243

---------

Co-authored-by: worker <worker@local>
TexasCoding added a commit that referenced this pull request May 21, 2026
Replace the per-row model_dump(mode='python') walk in Page.to_dataframe
and Page.to_polars with a column-oriented dict[field, list[value]] built
once via getattr. For a 1000-row trades_history page this avoids 1000
nested-dict allocations plus per-field Python-level conversion, and
lets pandas/polars infer dtypes from homogeneous columns instead of
re-inferring from records.

Nested BaseModel cells are still dumped per-column (mode='python') so
that polars can infer Struct from the resulting dicts — a plain
BaseModel column raises 'Decimal without precision/scale set is not a
valid Polars datatype'. The #225 / #190 Decimal contract is preserved
(DollarDecimal lands as Decimal, list[Decimal] stays list[Decimal],
nested Decimal survives Struct round-trip).

Empty pages short-circuit to pd.DataFrame() / pl.DataFrame() to avoid
indexing items[0] on an empty list.

Tests: add 100-row column-oriented regression coverage for both
backends + empty-page sentinels in tests/test_page_dataframe.py.
Existing #101 nested-shape and #190 DollarDecimal pins still pass
unchanged.

Closes #264

Co-authored-by: worker <worker@local>
TexasCoding added a commit that referenced this pull request May 21, 2026
…258, #259) (#284)

* fix(ws)!: retype OrderGroup.contracts_limit and Ticker dollar fields to Decimal (#258)

BREAKING CHANGE: Three WS payload fields previously typed as str now
parse to Decimal per the #225/#230 SDK invariant that every _fp
field is FixedPointCount and every _dollars field is DollarDecimal.

- OrderGroupPayload.contracts_limit: str | None → FixedPointCount | None
- TickerPayload.dollar_volume: str → DollarDecimal
- TickerPayload.dollar_open_interest: str → DollarDecimal

Callers reading these as Python strings (e.g. concatenation, string
compares, JSON dumps without a Decimal serializer) will need to adjust.
The motivation is precisely the dual hazard the previous typing carried:
manual Decimal(x) wrapping was required for arithmetic, and an empty
server payload would trip Decimal('') → InvalidOperation at the call
site rather than at parse. With these annotations, arithmetic works
directly (msg.dollar_volume + msg.dollar_open_interest) and bool
inputs are rejected per the #225 invariant.

Wire keys are unchanged: contracts_limit_fp / contracts_limit for
the order-group alias choices; dollar_volume / dollar_open_interest
for ticker (these have no _fp/_dollars suffix on the wire).

Closes #258

* fix!: route strike + fee-multiplier fields through _coerce_decimal (#259)

BREAKING CHANGE: Five money-adjacent fields previously typed as bare
``Decimal`` or ``float`` now route through the ``_coerce_decimal``
invariant established in #225. Bare ``Decimal`` accepts a JSON number
``1.23`` by routing through ``float`` first \u2014 the precision-loss path
the SDK paid an audit to eliminate. ``float`` is strictly worse: the
value commits to binary float on parse (``0.65 -> 0.6500000000000000222``)
and that drift propagates into payout math.

- Market.floor_strike / cap_strike: Decimal | None \u2192 DollarDecimal | None
- Event.fee_multiplier_override: Decimal | None \u2192 MultiplierDecimal | None
- MarketLifecyclePayload.floor_strike: Decimal | None \u2192 DollarDecimal | None
- Series.fee_multiplier / SeriesFeeChange.fee_multiplier: float \u2192 MultiplierDecimal

Adds a new ``MultiplierDecimal`` alias in ``kalshi.types`` (internal \u2014
not re-exported via ``kalshi.__init__``). It applies the same
``_coerce_decimal`` + string-serializer treatment as
``DollarDecimal`` / ``FixedPointCount``, kept distinct to document that
these are rate multipliers, not dollar quantities.

User-visible consequences: callers reading ``series.fee_multiplier``
as a Python ``float`` will now see a ``Decimal`` (use it directly
for arithmetic; cast with ``float()`` only at display boundaries).
Bool inputs are rejected per the #225 invariant.

The stale doc-comment in ``tests/integration/test_assertions.py`` that
named ``Series.fee_multiplier`` as the canonical float-bearing example
is updated to reflect the post-#259 typing.

Closes #259
TexasCoding added a commit that referenced this pull request May 21, 2026
…geQueue, http2 test, benches (#289)

* fix(auth): recheck _closed under lock in _get_sign_executor (#267)

Pre-fix, sign_request_async and close() could race: thread A read
_closed=False outside the lock, close() ran end-to-end (set _closed,
shut down + nulled the executor), then thread A entered the locked
double-checked init and constructed a brand-new ThreadPoolExecutor on
a closed auth. The new pool was never shut down (close() already
returned), defeating the documented terminality and leaking a 2-thread
pool until interpreter shutdown.

Move the _closed check inside the lock: keep a lock-free fast path
for the already-constructed case, but on the slow path re-read
_closed under the lock and raise without instantiating a new
executor if close() has won the race.

Regression test simulates the interleaving by wrapping the lock's
__enter__ to call close() mid-acquire.

* fix(transport): clamp negative Retry-After delta to 0.0 for date-form parity (#267)

Pre-fix, Retry-After: -5 returned retry_after=None (falls back
to computed exponential backoff) while the semantically equivalent
``Retry-After: <past HTTP-date>`` returned retry_after=0.0 (retry
immediately). Tests that pinned one shape silently disagreed with the
other, and clock-skewed proxies emitting past timestamps got
inconsistent behavior depending on which form they used.

Pick the more permissive policy and clamp negative delta-seconds to
0.0 \u2014 matching the existing HTTP-date branch. Non-finite (NaN/inf)
still falls back to None since those would survive the transport
caller's min() cap and either crash time.sleep or schedule a
near-infinite delay.

Regression test asserts both forms produce identical retry_after.

* perf(ws,transport): drop MessageQueue._size; hoist asyncio import (#271)

Two small per-message hot-path savings:

- MessageQueue._size: an O(1) int counter incremented/decremented
  on every put / popleft purely so qsize() could exclude the single
  terminal sentinel. Two extra int ops per message on the WS recv hot
  path with no functional gain \u2014 derive qsize from len(self._buffer)
  minus a sentinel adjustment when the queue is closed. The deque's
  maxlen=maxsize+1 was already the real memory ceiling.

- import asyncio inside AsyncTransport.request: a sys.modules
  lookup on every async request. The module is already imported at
  top level by KalshiAuth.sign_request_async (and many others);
  move the import to the file header.

Updated the deque-maxlen regression to assert the cap via sustained
overflow rather than simulated counter drift (the counter no longer
exists).

* test(transport,bench): http2 wiring test + iterator/dataframe benches (#271)

#271 items 3 + 4 \u2014 testing-coverage polish.

tests/test_http2.py: verify KalshiConfig(http2=True) propagates to
the underlying httpx.Client / httpx.AsyncClient's connection pool, so
a regression that silently drops the kwarg surfaces here instead of
landing without any failing test. Guarded by skipif when h2 isn't
installed in the env (httpx rejects http2=True without it).

scripts/bench_orderbook_iterator.py: drives _OrderbookIterator.__anext__
end-to-end through a synthetic stream that mimics the recv loop
(apply_delta_inplace + yield). Existing bench_orderbook_delta only
measures the in-place mutation; this one captures the mgr.get +
Orderbook materialization that real async for book in ws.orderbook()
consumers pay.

scripts/bench_page_to_dataframe.py: builds a 1000-item Page[Market] and
times to_dataframe() / to_polars(). Catches regressions in the
column-oriented build path (#264) and confirms the DollarDecimal
contract (#225/#190) doesn't quietly fall back to per-row model_dump.

Closes #267
Closes #271
TexasCoding added a commit that referenced this pull request May 22, 2026
…#370)

The SDK's money-safety invariants depend on Pydantic v2 behaviors that
stabilized after 2.0:

- StrictInt boolean rejection (relied on by #295) was tightened in 2.4.
- model_validator(mode='before') inheritance semantics changed through 2.3.
- Decimal coercion edge cases (negative-zero, scientific input) used by
  _coerce_decimal (#270) were fixed across 2.4/2.5.

Allowing 2.0-2.3 lets resolvers land a runtime with subtler validators
than the test matrix exercises, quietly undermining #225 / #243 / #295.
Bump the floor to pydantic>=2.4,<3 so the installed runtime always
matches the invariants the changelog advertises.

Verified: ruff, mypy, and tests/test_models.py + tests/test_types.py
all pass under the regenerated lock (pydantic 2.13.4 resolved).

Closes #346
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant