From 902ef627a744102a0a0058cbe5a5692648062476 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Tue, 19 May 2026 17:48:41 -0500 Subject: [PATCH] feat(models): backfill v3.18.0 fields on events / portfolio / historical / incentive_programs (#160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Response-side spec drift hardening, PR2 of the stack (#157). 9 new optional fields across 5 mid-tier response models. Eliminates exactly 9 additive-drift warning bullets (the issue's stated target). Fields added ------------ `Event` (+3): fee_type_override (str), fee_multiplier_override (Decimal — type:number,format:double per spec; matches Market floor_strike/cap_strike precedent), exchange_index (int) `EventMetadata` (+2): competition (str), competition_scope (str) `Settlement` (+1): value (int — integer cents per spec, NOT DollarDecimal) `Trade` (+2): taker_outcome_side (SideLiteral), taker_book_side (BookSideLiteral) — mirrors Order.outcome_side / book_side from PR1, superseding the deprecated `taker_side` `IncentiveProgram` (+1): incentive_description (str) Design decisions ---------------- - `fee_multiplier_override` typed as `Decimal | None`, not `DollarDecimal`. Spec says `type: number, format: double, nullable: true` — same shape as Market.floor_strike / cap_strike (already Decimal | None). DollarDecimal is for `type: string` dollar-suffix wire fields; this field is a numeric multiplier ratio, not a dollar amount. - `Settlement.value` stays `int | None`, NOT DollarDecimal. Spec: "Payout of a single yes contract in cents." Plain integer cents; auto- coercing to a decimal would break the contract. Test includes a regression guard asserting `isinstance(s.value, int)` and not bool. - `Trade.taker_outcome_side` / `taker_book_side` reuse the existing `SideLiteral` / `BookSideLiteral` aliases from kalshi.models.orders. This is the first cross-module import in kalshi/models/ for the new fields — no circular import (orders.py doesn't import historical.py). Import order in historical.py sorted alphabetically per ruff isort. - All new fields appended at the end of each model with a `# v3.18.0 backfill (#160)` marker. Append-only, matches PR1 pattern. Tests ----- 5 new test classes in `tests/test_models.py` (10 tests). Placed alongside the PR1 test classes (`TestMarketV3180Fields` etc.) rather than per- resource files, matching PR1 precedent that the reviewer accepted: - TestEventV3180Fields (2 tests) - TestEventMetadataV3180Fields (2 tests) - TestSettlementV3180Fields (2 tests — includes int-not-decimal regression) - TestTradeV3180Fields (2 tests) - TestIncentiveProgramV3180Fields (2 tests) Also hoisted Event / EventMetadata / Trade / IncentiveProgram / Settlement to module-level imports in `test_models.py` to avoid the inline-import pattern PR1's reviewer flagged. Pre-existing inline imports elsewhere in the file left untouched (out of scope). Verification ------------ uv run pytest tests/test_contracts.py::TestSpecDrift::test_additive_drift[Event,EventMetadata,Settlement,Trade,IncentiveProgram] -W error -> 5 passed (was: 5 failed) uv run pytest tests/ --ignore=tests/integration -q -> 1965 passed, 50 warnings (baseline 1955 + 10 new; warnings 54 -> 50) uv run mypy kalshi/ -> Success: no issues uv run ruff check . -> All checks passed! Warning delta breakdown: 9 additive bullets eliminated (Event:3, EventMetadata:2, Settlement:1, Trade:2, IncentiveProgram:1). One new required-but-optional warning appears for `IncentiveProgram.incentive_description` (spec marks it required, but project policy is `| None = None` for response fields). Net test count: 5 fewer additive-drift failures - 1 new required-drift failure = 4 fewer. Required-but-optional is explicitly out of scope per the issue. Closes #160. --- kalshi/models/events.py | 12 +++ kalshi/models/historical.py | 6 ++ kalshi/models/incentive_programs.py | 3 + kalshi/models/portfolio.py | 4 + tests/test_models.py | 114 ++++++++++++++++++++++++++++ 5 files changed, 139 insertions(+) diff --git a/kalshi/models/events.py b/kalshi/models/events.py index 0a778e3..6469957 100644 --- a/kalshi/models/events.py +++ b/kalshi/models/events.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime +from decimal import Decimal from typing import Any, Literal from pydantic import BaseModel @@ -31,6 +32,13 @@ class Event(BaseModel): last_updated_ts: datetime | None = None markets: NullableList[Market] = [] + # v3.18.0 backfill (#160). fee_multiplier_override is `type: number, + # format: double` per spec — Decimal (matches Market.floor_strike / + # cap_strike precedent for the same wire shape). + fee_type_override: str | None = None + fee_multiplier_override: Decimal | None = None + exchange_index: int | None = None + model_config = {"extra": "allow", "populate_by_name": True} @@ -61,4 +69,8 @@ class EventMetadata(BaseModel): market_details: list[MarketMetadata] | None = None settlement_sources: list[SettlementSource] | None = None + # v3.18.0 backfill (#160). + competition: str | None = None + competition_scope: str | None = None + model_config = {"extra": "allow"} diff --git a/kalshi/models/historical.py b/kalshi/models/historical.py index 4f328f3..38818c1 100644 --- a/kalshi/models/historical.py +++ b/kalshi/models/historical.py @@ -7,6 +7,7 @@ from pydantic import AliasChoices, BaseModel, Field +from kalshi.models.orders import BookSideLiteral, SideLiteral from kalshi.types import DollarDecimal, FixedPointCount # Single-value enum per spec (MveHistoricalFilterQuery). mypy rejects plain @@ -45,4 +46,9 @@ class Trade(BaseModel): taker_side: str | None = None created_time: datetime | None = None + # v3.18.0 backfill (#160). Mirrors Order.outcome_side / book_side from #159 + # — canonical direction encoding superseding the deprecated `taker_side`. + taker_outcome_side: SideLiteral | None = None + taker_book_side: BookSideLiteral | None = None + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/models/incentive_programs.py b/kalshi/models/incentive_programs.py index a74ae16..3ba5561 100644 --- a/kalshi/models/incentive_programs.py +++ b/kalshi/models/incentive_programs.py @@ -37,6 +37,9 @@ class IncentiveProgram(BaseModel): discount_factor_bps: int | None = None target_size_fp: FixedPointCount | None = None + # v3.18.0 backfill (#160). + incentive_description: str | None = None + model_config = {"extra": "allow"} diff --git a/kalshi/models/portfolio.py b/kalshi/models/portfolio.py index 67cf261..e7efecc 100644 --- a/kalshi/models/portfolio.py +++ b/kalshi/models/portfolio.py @@ -202,4 +202,8 @@ class Settlement(BaseModel): validation_alias=AliasChoices("fee_cost_dollars", "fee_cost"), ) + # v3.18.0 backfill (#160). `value` is integer cents per spec + # ("Payout of a single yes contract in cents") — plain int, NOT DollarDecimal. + value: int | None = None + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/tests/test_models.py b/tests/test_models.py index 5419555..1c317dd 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,8 +7,12 @@ import pytest +from kalshi.models.events import Event, EventMetadata +from kalshi.models.historical import Trade +from kalshi.models.incentive_programs import IncentiveProgram from kalshi.models.markets import Market from kalshi.models.orders import Fill, Order +from kalshi.models.portfolio import Settlement from kalshi.types import to_decimal @@ -353,6 +357,116 @@ def test_all_new_fields_default_to_none(self) -> None: assert getattr(f, name) is None, f"{name} should default to None" +class TestEventV3180Fields: + """v3.18.0 backfill (issue #160): 3 new optional fields on ``Event``.""" + + def test_parses_new_fields(self) -> None: + e = Event.model_validate({ + "event_ticker": "EVT-001", + "fee_type_override": "quadratic", + "fee_multiplier_override": "1.25", + "exchange_index": 7, + }) + assert e.fee_type_override == "quadratic" + assert e.fee_multiplier_override == Decimal("1.25") + assert isinstance(e.fee_multiplier_override, Decimal) + assert e.exchange_index == 7 + + def test_all_new_fields_default_to_none(self) -> None: + e = Event(event_ticker="EVT-001") + for name in ("fee_type_override", "fee_multiplier_override", "exchange_index"): + assert getattr(e, name) is None, f"{name} should default to None" + + +class TestEventMetadataV3180Fields: + """v3.18.0 backfill (issue #160): 2 new optional fields on ``EventMetadata``.""" + + def test_parses_new_fields(self) -> None: + m = EventMetadata.model_validate({ + "competition": "NBA Eastern Conference", + "competition_scope": "playoffs", + }) + assert m.competition == "NBA Eastern Conference" + assert m.competition_scope == "playoffs" + + def test_all_new_fields_default_to_none(self) -> None: + m = EventMetadata() + assert m.competition is None + assert m.competition_scope is None + + +class TestSettlementV3180Fields: + """v3.18.0 backfill (issue #160): 1 new optional field on ``Settlement``. + + ``value`` is integer cents per spec ("Payout of a single yes contract in + cents") — NOT ``DollarDecimal``. Distinct from the existing + ``yes_total_cost_dollars`` / ``no_total_cost_dollars`` fields. + """ + + def test_value_is_int_not_decimal(self) -> None: + s = Settlement.model_validate({"ticker": "T", "value": 100}) + assert s.value == 100 + assert isinstance(s.value, int) + assert not isinstance(s.value, bool) # bools are ints; guard + + def test_value_defaults_to_none(self) -> None: + s = Settlement(ticker="T") + assert s.value is None + + +class TestTradeV3180Fields: + """v3.18.0 backfill (issue #160): 2 new optional fields on ``Trade``. + + Mirrors ``Order.outcome_side`` / ``book_side`` from PR1 — canonical + direction encoding superseding the deprecated ``taker_side``. + """ + + def test_parses_new_fields(self) -> None: + t = Trade.model_validate({ + "trade_id": "tr-001", + "taker_outcome_side": "yes", + "taker_book_side": "bid", + }) + assert t.taker_outcome_side == "yes" + assert t.taker_book_side == "bid" + + def test_all_new_fields_default_to_none(self) -> None: + t = Trade(trade_id="tr-001") + assert t.taker_outcome_side is None + assert t.taker_book_side is None + + +class TestIncentiveProgramV3180Fields: + """v3.18.0 backfill (issue #160): 1 new optional field on ``IncentiveProgram``.""" + + def test_parses_new_field(self) -> None: + p = IncentiveProgram.model_validate({ + "id": "ip-001", + "market_id": "mkt-001", + "market_ticker": "MKT-001", + "incentive_type": "volume", + "start_date": "2026-01-01T00:00:00Z", + "end_date": "2026-02-01T00:00:00Z", + "period_reward": 10000, + "paid_out": False, + "incentive_description": "Liquidity provision rewards", + }) + assert p.incentive_description == "Liquidity provision rewards" + + def test_incentive_description_defaults_to_none(self) -> None: + p = IncentiveProgram( + id="ip-001", + market_id="mkt-001", + market_ticker="MKT-001", + incentive_type="volume", + start_date=datetime(2026, 1, 1), + end_date=datetime(2026, 2, 1), + period_reward=10000, + paid_out=False, + ) + assert p.incentive_description is None + + class TestErrorHierarchy: def test_all_errors_inherit_base(self) -> None: from kalshi.errors import (