From c5530c930d0b0e3feef9f2aeac8426f153306a5b Mon Sep 17 00:00:00 2001 From: Jeff West Date: Tue, 19 May 2026 22:15:09 -0500 Subject: [PATCH 1/3] test(contracts): map remaining 42 REST sub-models + promote completeness gate (#171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps all REST sub-models, V2 orders family, and internal containers into CONTRACT_MAP (49 → 91 entries). Promotes test_contract_map_completeness from warnings.warn to pytest.fail so future unmapped models block CI. Adds dotted-path syntax to _get_schema_fields / _get_required_fields (\`Parent.field.items\`) for inline-object schemas the spec doesn't name at the top level (Batch*OrdersV2* per-entry shapes). Mapping the new models surfaced drift that was previously invisible. Tightened (per #172 policy): - BidAskDistribution (4 OHLC fields) - Candlestick (6 fields) - MarketMetadata (2 fields) - Schedule + WeeklySchedule - PositionsResponse - EventCandlesticks + ForecastPercentilesPoint - AssociatedEvent + LookupPoint (multivariate) PriceDistribution gains 4 v3.18.0 spec fields (mean/previous/min/max), all optional per spec (no required-drift, just additive). OrderbookLevel maps to spec's PriceLevelDollarsCountFp (a positional 2-tuple). _get_schema_fields returns {} for array-typed schemas, so the field-by-field drift checks skip cleanly. 3 new fixture builders (candlestick_dict, bid_ask_distribution_dict, price_distribution_dict). 6 test files migrated to use them. Closes #171 --- CHANGELOG.md | 39 +++++++ kalshi/_contract_map.py | 204 ++++++++++++++++++++++++++++++++-- kalshi/models/events.py | 4 +- kalshi/models/exchange.py | 18 +-- kalshi/models/markets.py | 44 +++++--- kalshi/models/multivariate.py | 8 +- kalshi/models/portfolio.py | 4 +- kalshi/models/series.py | 8 +- tests/_model_fixtures.py | 33 ++++++ tests/test_contracts.py | 92 ++++++++++----- tests/test_historical.py | 26 ++--- tests/test_markets.py | 4 +- tests/test_models.py | 13 ++- tests/test_series.py | 3 +- tests/test_series_models.py | 9 +- 15 files changed, 412 insertions(+), 97 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 367e600..c2e9526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ All notable changes to kalshi-sdk will be documented in this file. ## Unreleased +### Contract-map completeness (#171) + +Maps the remaining 42 REST sub-models, V2 orders family, and internal +containers into `CONTRACT_MAP` (49 entries → 91). Promotes +`test_contract_map_completeness` from `warnings.warn` to `pytest.fail` so +the next unmapped model fails CI loudly. + +`_get_schema_fields` / `_get_required_fields` gain a dotted-path syntax +(`Parent.field.items`) so inline-object schemas the spec doesn't name at the +top level (`Batch*OrdersV2*` per-entry shapes) can still flow through the +drift pipeline. + +Newly-surfaced drift caught by mapping these models: + +- `BidAskDistribution` (OHLC): all four price fields tightened to required. +- `PriceDistribution`: gains 4 v3.18.0 spec fields (`mean_dollars`, + `previous_dollars`, `min_dollars`, `max_dollars`), all optional per spec. +- `Candlestick`: 6 fields tightened to required. +- `MarketMetadata`: `image_url` + `color_code` tightened. +- `Schedule` / `WeeklySchedule`: tightened. +- `PositionsResponse`, `EventCandlesticks`, `ForecastPercentilesPoint`: + tightened. +- `AssociatedEvent` (multivariate): `is_yes_only` + `active_quoters` tightened. +- `LookupPoint` (multivariate): `selected_markets` + `last_queried_ts` tightened. + +`OrderbookLevel` is mapped to spec's `PriceLevelDollarsCountFp`, a positional +2-tuple `["", ""]`. The SDK wraps it as a +named `{price, quantity}` object — no field-by-field comparison possible. +`_get_schema_fields` returns `{}` for the array-typed spec schema, so drift +checks skip it cleanly. + +Fixture builders for `Candlestick`, `BidAskDistribution`, `PriceDistribution` +added to `tests/_model_fixtures.py` (3 new). Test fixtures parsing +`Candlestick` / `EventCandlesticks` / `MarketCandlesticks` now use those +builders. + + +### Required-but-optional drift closure (#172) + Required-but-optional drift closure (#172). Drops `None` defaults on 226 spec-required Pydantic model fields across 34 response models (21 REST, 13 WS). The SDK now matches the OpenAPI v3.18.0 / AsyncAPI v0.14 `required` set diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index de65794..e09831c 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -252,14 +252,202 @@ class ContractEntry: sdk_model="kalshi.models.order_groups.CreateOrderGroupResponse", spec_schema="CreateOrderGroupResponse", ), - # Intentionally excluded from contract map: - # - Candlestick, BidAskDistribution, PriceDistribution, OrderbookLevel: - # Nested/composite models, no direct 1:1 spec schema match - # - DailySchedule, WeeklySchedule, MaintenanceWindow, Schedule: - # Simple sub-schemas validated through parent model tests - # - PositionsResponse: SDK-internal container shape - # - MarketMetadata, SettlementSource: Simple sub-schemas - # - Page[T]: SDK-internal pagination wrapper + # -- markets sub-models (#171) --------------------------------------- + ContractEntry( + sdk_model="kalshi.models.markets.BidAskDistribution", + spec_schema="BidAskDistribution", + notes="OHLC sub-model on Candlestick.yes_bid / .yes_ask", + ), + ContractEntry( + sdk_model="kalshi.models.markets.PriceDistribution", + spec_schema="PriceDistribution", + notes="OHLC sub-model on Candlestick.price", + ), + ContractEntry( + sdk_model="kalshi.models.markets.Candlestick", + spec_schema="MarketCandlestick", + notes="Spec uses 'MarketCandlestick'; SDK calls it 'Candlestick'", + ), + ContractEntry( + sdk_model="kalshi.models.markets.OrderbookLevel", + spec_schema="PriceLevelDollarsCountFp", + notes=( + "Spec encodes price levels as a positional 2-tuple " + "['', '']; the SDK exposes it " + "as a named object with `price` / `quantity`. No field-by-field " + "comparison is meaningful — _get_schema_fields returns {} for " + "this array-typed schema." + ), + ), + # -- orders V2 family (#171) ----------------------------------------- + ContractEntry( + sdk_model="kalshi.models.orders.AmendOrderRequest", + spec_schema="AmendOrderRequest", + notes="Request body for /portfolio/orders/{order_id}/amend", + ), + ContractEntry( + sdk_model="kalshi.models.orders.DecreaseOrderRequest", + spec_schema="DecreaseOrderRequest", + notes="Request body for /portfolio/orders/{order_id}/decrease", + ), + ContractEntry( + sdk_model="kalshi.models.orders.BatchCreateOrdersRequest", + spec_schema="BatchCreateOrdersRequest", + ), + ContractEntry( + sdk_model="kalshi.models.orders.BatchCancelOrdersRequest", + spec_schema="BatchCancelOrdersRequest", + ), + ContractEntry( + sdk_model="kalshi.models.orders.BatchCancelOrdersRequestOrder", + spec_schema="BatchCancelOrdersRequest.orders.items", + notes="Inline object schema (no named component)", + ), + ContractEntry( + sdk_model="kalshi.models.orders.CreateOrderV2Request", + spec_schema="CreateOrderV2Request", + ), + ContractEntry( + sdk_model="kalshi.models.orders.CreateOrderV2Response", + spec_schema="CreateOrderV2Response", + ), + ContractEntry( + sdk_model="kalshi.models.orders.AmendOrderV2Request", + spec_schema="AmendOrderV2Request", + ), + ContractEntry( + sdk_model="kalshi.models.orders.AmendOrderV2Response", + spec_schema="AmendOrderV2Response", + ), + ContractEntry( + sdk_model="kalshi.models.orders.DecreaseOrderV2Request", + spec_schema="DecreaseOrderV2Request", + ), + ContractEntry( + sdk_model="kalshi.models.orders.DecreaseOrderV2Response", + spec_schema="DecreaseOrderV2Response", + ), + ContractEntry( + sdk_model="kalshi.models.orders.CancelOrderV2Response", + spec_schema="CancelOrderV2Response", + ), + ContractEntry( + sdk_model="kalshi.models.orders.BatchCreateOrdersV2Request", + spec_schema="BatchCreateOrdersV2Request", + ), + ContractEntry( + sdk_model="kalshi.models.orders.BatchCreateOrdersV2Response", + spec_schema="BatchCreateOrdersV2Response", + ), + ContractEntry( + sdk_model="kalshi.models.orders.BatchCreateOrdersV2ResponseEntry", + spec_schema="BatchCreateOrdersV2Response.orders.items", + notes="Inline object schema (no named component)", + ), + ContractEntry( + sdk_model="kalshi.models.orders.BatchCancelOrdersV2Request", + spec_schema="BatchCancelOrdersV2Request", + ), + ContractEntry( + sdk_model="kalshi.models.orders.BatchCancelOrdersV2RequestOrder", + spec_schema="BatchCancelOrdersV2Request.orders.items", + notes="Inline object schema (no named component)", + ), + ContractEntry( + sdk_model="kalshi.models.orders.BatchCancelOrdersV2Response", + spec_schema="BatchCancelOrdersV2Response", + ), + ContractEntry( + sdk_model="kalshi.models.orders.BatchCancelOrdersV2ResponseEntry", + spec_schema="BatchCancelOrdersV2Response.orders.items", + notes="Inline object schema (no named component)", + ), + # -- events sub-models (#171) ---------------------------------------- + ContractEntry( + sdk_model="kalshi.models.events.MarketMetadata", + spec_schema="MarketMetadata", + ), + ContractEntry( + sdk_model="kalshi.models.events.SettlementSource", + spec_schema="SettlementSource", + ), + # -- exchange sub-models (#171) -------------------------------------- + ContractEntry( + sdk_model="kalshi.models.exchange.Schedule", + spec_schema="Schedule", + ), + ContractEntry( + sdk_model="kalshi.models.exchange.DailySchedule", + spec_schema="DailySchedule", + ), + ContractEntry( + sdk_model="kalshi.models.exchange.WeeklySchedule", + spec_schema="WeeklySchedule", + ), + ContractEntry( + sdk_model="kalshi.models.exchange.MaintenanceWindow", + spec_schema="MaintenanceWindow", + ), + # -- portfolio sub-models (#171) ------------------------------------- + ContractEntry( + sdk_model="kalshi.models.portfolio.IndexedBalance", + spec_schema="IndexedBalance", + notes="Per-shard balance entry on Balance.balance_breakdown", + ), + ContractEntry( + sdk_model="kalshi.models.portfolio.PositionsResponse", + spec_schema="GetPositionsResponse", + notes="Spec uses 'GetPositionsResponse'; SDK exposes as 'PositionsResponse'", + ), + ContractEntry( + sdk_model="kalshi.models.portfolio.Deposit", + spec_schema="Deposit", + ), + ContractEntry( + sdk_model="kalshi.models.portfolio.Withdrawal", + spec_schema="Withdrawal", + ), + # -- series sub-models (#171) ---------------------------------------- + ContractEntry( + sdk_model="kalshi.models.series.EventCandlesticks", + spec_schema="GetEventCandlesticksResponse", + notes="Spec wraps event candlesticks in GetEventCandlesticksResponse", + ), + ContractEntry( + sdk_model="kalshi.models.series.PercentilePoint", + spec_schema="PercentilePoint", + ), + ContractEntry( + sdk_model="kalshi.models.series.ForecastPercentilesPoint", + spec_schema="ForecastPercentilesPoint", + ), + # -- multivariate sub-models (#171) ---------------------------------- + ContractEntry( + sdk_model="kalshi.models.multivariate.AssociatedEvent", + spec_schema="AssociatedEvent", + ), + ContractEntry( + sdk_model="kalshi.models.multivariate.CreateMarketInMultivariateEventCollectionRequest", + spec_schema="CreateMarketInMultivariateEventCollectionRequest", + ), + ContractEntry( + sdk_model="kalshi.models.multivariate.CreateMarketResponse", + spec_schema="CreateMarketInMultivariateEventCollectionResponse", + notes="Long-form CreateMarketInMultivariateEventCollectionResponse", + ), + ContractEntry( + sdk_model="kalshi.models.multivariate.LookupTickersForMarketInMultivariateEventCollectionRequest", + spec_schema="LookupTickersForMarketInMultivariateEventCollectionRequest", + ), + ContractEntry( + sdk_model="kalshi.models.multivariate.LookupTickersResponse", + spec_schema="LookupTickersForMarketInMultivariateEventCollectionResponse", + notes="Spec name is the long-form ...Response; SDK shortens", + ), + ContractEntry( + sdk_model="kalshi.models.multivariate.LookupPoint", + spec_schema="LookupPoint", + ), ] # WS payload models → AsyncAPI schema components. diff --git a/kalshi/models/events.py b/kalshi/models/events.py index 4bc2662..d06e206 100644 --- a/kalshi/models/events.py +++ b/kalshi/models/events.py @@ -46,8 +46,8 @@ class MarketMetadata(BaseModel): """Metadata for a market within an event.""" market_ticker: str - image_url: str | None = None - color_code: str | None = None + image_url: str + color_code: str model_config = {"extra": "allow"} diff --git a/kalshi/models/exchange.py b/kalshi/models/exchange.py index dabb00e..aa6f41a 100644 --- a/kalshi/models/exchange.py +++ b/kalshi/models/exchange.py @@ -33,13 +33,13 @@ class WeeklySchedule(BaseModel): start_time: datetime end_time: datetime - monday: NullableList[DailySchedule] = [] - tuesday: NullableList[DailySchedule] = [] - wednesday: NullableList[DailySchedule] = [] - thursday: NullableList[DailySchedule] = [] - friday: NullableList[DailySchedule] = [] - saturday: NullableList[DailySchedule] = [] - sunday: NullableList[DailySchedule] = [] + monday: NullableList[DailySchedule] + tuesday: NullableList[DailySchedule] + wednesday: NullableList[DailySchedule] + thursday: NullableList[DailySchedule] + friday: NullableList[DailySchedule] + saturday: NullableList[DailySchedule] + sunday: NullableList[DailySchedule] model_config = {"extra": "allow"} @@ -56,8 +56,8 @@ class MaintenanceWindow(BaseModel): class Schedule(BaseModel): """Exchange operating schedule.""" - standard_hours: NullableList[WeeklySchedule] = [] - maintenance_windows: NullableList[MaintenanceWindow] = [] + standard_hours: NullableList[WeeklySchedule] + maintenance_windows: NullableList[MaintenanceWindow] model_config = {"extra": "allow"} diff --git a/kalshi/models/markets.py b/kalshi/models/markets.py index bc4b1de..672d827 100644 --- a/kalshi/models/markets.py +++ b/kalshi/models/markets.py @@ -164,20 +164,16 @@ class Orderbook(BaseModel): class BidAskDistribution(BaseModel): """OHLC data for bid/ask prices within a candlestick period.""" - open: DollarDecimal | None = Field( - default=None, + open: DollarDecimal = Field( validation_alias=AliasChoices("open_dollars", "open"), ) - high: DollarDecimal | None = Field( - default=None, + high: DollarDecimal = Field( validation_alias=AliasChoices("high_dollars", "high"), ) - low: DollarDecimal | None = Field( - default=None, + low: DollarDecimal = Field( validation_alias=AliasChoices("low_dollars", "low"), ) - close: DollarDecimal | None = Field( - default=None, + close: DollarDecimal = Field( validation_alias=AliasChoices("close_dollars", "close"), ) @@ -206,6 +202,24 @@ class PriceDistribution(BaseModel): default=None, validation_alias=AliasChoices("close_dollars", "close"), ) + # v3.18.0 spec additions (#171). All four nullable per spec since the + # candlestick window may contain no trades. + mean: DollarDecimal | None = Field( + default=None, + validation_alias=AliasChoices("mean_dollars", "mean"), + ) + previous: DollarDecimal | None = Field( + default=None, + validation_alias=AliasChoices("previous_dollars", "previous"), + ) + min: DollarDecimal | None = Field( + default=None, + validation_alias=AliasChoices("min_dollars", "min"), + ) + max: DollarDecimal | None = Field( + default=None, + validation_alias=AliasChoices("max_dollars", "max"), + ) model_config = {"extra": "allow", "populate_by_name": True} @@ -217,16 +231,14 @@ class Candlestick(BaseModel): plus volume and open interest as FixedPointCount strings. """ - end_period_ts: int | None = None - yes_bid: BidAskDistribution | None = None - yes_ask: BidAskDistribution | None = None - price: PriceDistribution | None = None - volume: FixedPointCount | None = Field( - default=None, + end_period_ts: int + yes_bid: BidAskDistribution + yes_ask: BidAskDistribution + price: PriceDistribution + volume: FixedPointCount = Field( validation_alias=AliasChoices("volume_fp", "volume"), ) - open_interest: FixedPointCount | None = Field( - default=None, + open_interest: FixedPointCount = Field( validation_alias=AliasChoices("open_interest_fp", "open_interest"), ) diff --git a/kalshi/models/multivariate.py b/kalshi/models/multivariate.py index 077b5dc..9723791 100644 --- a/kalshi/models/multivariate.py +++ b/kalshi/models/multivariate.py @@ -25,10 +25,10 @@ class AssociatedEvent(BaseModel): """An event associated with a multivariate collection.""" ticker: str - is_yes_only: bool = False + is_yes_only: bool size_max: int | None = None size_min: int | None = None - active_quoters: NullableList[str] = [] + active_quoters: NullableList[str] model_config = {"extra": "allow"} @@ -142,7 +142,7 @@ class LookupPoint(BaseModel): event_ticker: str market_ticker: str - selected_markets: NullableList[TickerPair] = [] - last_queried_ts: datetime | None = None + selected_markets: NullableList[TickerPair] + last_queried_ts: datetime model_config = {"extra": "allow"} diff --git a/kalshi/models/portfolio.py b/kalshi/models/portfolio.py index 8a51045..36b4c32 100644 --- a/kalshi/models/portfolio.py +++ b/kalshi/models/portfolio.py @@ -114,8 +114,8 @@ class EventPosition(BaseModel): class PositionsResponse(BaseModel): """Response from the positions endpoint containing both market and event positions.""" - market_positions: NullableList[MarketPosition] = [] - event_positions: NullableList[EventPosition] = [] + market_positions: NullableList[MarketPosition] + event_positions: NullableList[EventPosition] cursor: str | None = None @property diff --git a/kalshi/models/series.py b/kalshi/models/series.py index f46f345..e1a0383 100644 --- a/kalshi/models/series.py +++ b/kalshi/models/series.py @@ -59,9 +59,9 @@ class EventCandlesticks(BaseModel): return a nested structure: one candlestick list per market in the event. """ - market_tickers: NullableList[str] = [] - market_candlesticks: NullableList[list[Candlestick]] = [] - adjusted_end_ts: int = 0 + market_tickers: NullableList[str] + market_candlesticks: NullableList[list[Candlestick]] + adjusted_end_ts: int model_config = {"extra": "allow"} @@ -83,6 +83,6 @@ class ForecastPercentilesPoint(BaseModel): event_ticker: str end_period_ts: int period_interval: int - percentile_points: NullableList[PercentilePoint] = [] + percentile_points: NullableList[PercentilePoint] model_config = {"extra": "allow"} diff --git a/tests/_model_fixtures.py b/tests/_model_fixtures.py index 9bbc976..1ab7859 100644 --- a/tests/_model_fixtures.py +++ b/tests/_model_fixtures.py @@ -583,3 +583,36 @@ def series_fee_change_dict(**overrides: Any) -> dict[str, Any]: } base.update(overrides) return base + + +def bid_ask_distribution_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped BidAskDistribution dict (Candlestick.yes_bid / .yes_ask).""" + base: dict[str, Any] = { + "open_dollars": "0.5000", + "high_dollars": "0.5100", + "low_dollars": "0.4900", + "close_dollars": "0.5000", + } + base.update(overrides) + return base + + +def price_distribution_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped PriceDistribution dict (Candlestick.price). All optional.""" + base: dict[str, Any] = {} + base.update(overrides) + return base + + +def candlestick_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped Candlestick dict (#171).""" + base: dict[str, Any] = { + "end_period_ts": 1700000000, + "yes_bid": bid_ask_distribution_dict(), + "yes_ask": bid_ask_distribution_dict(), + "price": price_distribution_dict(), + "volume_fp": "0.00", + "open_interest_fp": "0.00", + } + base.update(overrides) + return base diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 0b7bd91..b8451ee 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -16,7 +16,6 @@ import importlib import inspect import typing -import warnings from decimal import Decimal from pathlib import Path from types import UnionType @@ -261,12 +260,46 @@ def _resolve_ref(spec: dict[str, Any], ref: str) -> dict[str, Any]: return node # type: ignore[return-value] -def _get_schema_fields(spec: dict[str, Any], schema_name: str) -> dict[str, dict[str, Any]]: - """Extract field names and their properties from a spec schema.""" +def _resolve_schema(spec: dict[str, Any], schema_name: str) -> dict[str, Any]: + """Resolve a schema by name, supporting dotted-path syntax for inline schemas. + + Plain ``"Foo"`` looks up ``components.schemas.Foo``. + Dotted ``"Foo.bar.items"`` walks ``components.schemas.Foo.properties.bar.items`` + where each segment after the first navigates ``properties[segment]`` unless the + segment is the literal ``"items"`` (in which case it descends into the array + items schema). Used to address inline object schemas that the spec doesn't + name at the top level — e.g., the per-entry shapes inside ``Batch*Orders*`` + response wrappers. + """ schemas = spec.get("components", {}).get("schemas", {}) - schema = schemas.get(schema_name) - if schema is None: - pytest.fail(f"Schema '{schema_name}' not found in OpenAPI spec") + head, *rest = schema_name.split(".") + node = schemas.get(head) + if node is None: + pytest.fail(f"Schema '{head}' not found in OpenAPI spec") + for segment in rest: + if "$ref" in node: + node = _resolve_ref(spec, node["$ref"]) + if segment == "items": + node = node.get("items", {}) + else: + node = node.get("properties", {}).get(segment, {}) + if not node: + pytest.fail( + f"Schema path '{schema_name}' failed at segment {segment!r}" + ) + if "$ref" in node: + node = _resolve_ref(spec, node["$ref"]) + return node + + +def _get_schema_fields(spec: dict[str, Any], schema_name: str) -> dict[str, dict[str, Any]]: + """Extract field names and their properties from a spec schema. + + Returns ``{}`` for schemas whose top-level type is not ``object`` (e.g., the + positional ``PriceLevelDollarsCountFp`` 2-tuple), since they have no + field-by-field shape to compare. + """ + schema = _resolve_schema(spec, schema_name) # Handle allOf composition (merge all sub-schemas) if "allOf" in schema: @@ -292,9 +325,16 @@ def _get_schema_fields(spec: dict[str, Any], schema_name: str) -> dict[str, dict def _get_required_fields(spec: dict[str, Any], schema_name: str) -> set[str]: - """Extract required field names from a spec schema, merging from allOf/oneOf/anyOf.""" + """Extract required field names from a spec schema, merging from allOf/oneOf/anyOf. + + Supports the same dotted-path syntax as :func:`_get_schema_fields`. Returns + an empty set for non-object schemas. + """ schemas = spec.get("components", {}).get("schemas", {}) - schema = schemas.get(schema_name, {}) + if "." in schema_name: + schema = _resolve_schema(spec, schema_name) + else: + schema = schemas.get(schema_name, {}) required: set[str] = set(schema.get("required", [])) @@ -679,16 +719,20 @@ def test_required_drift(self, entry: ContractEntry) -> None: ) def test_schema_coverage(self) -> None: - """Every mapped schema must exist in the spec.""" - schemas = self.spec.get("components", {}).get("schemas", {}) + """Every mapped schema must resolve (supports dotted-path syntax).""" for entry in CONTRACT_MAP: - assert entry.spec_schema in schemas, ( - f"Contract map references '{entry.spec_schema}' " - f"but it doesn't exist in the OpenAPI spec" - ) + # _resolve_schema fails the test internally if the path doesn't resolve. + _resolve_schema(self.spec, entry.spec_schema) def test_contract_map_completeness(self) -> None: - """Warn if SDK models exist without contract map entries.""" + """Fail if any SDK model under ``kalshi.models.*`` lacks a ``CONTRACT_MAP`` entry. + + Hard-fail since #171. Was warn-only while ~42 sub-models / V2 family / + internal containers were unmapped. Now every Pydantic class in the + listed modules MUST have a ``ContractEntry`` (use the dotted-path + ``Parent.field.items`` syntax for inline schemas; see notes in + ``kalshi/_contract_map.py``). + """ mapped_models = {e.sdk_model for e in CONTRACT_MAP} unmapped: list[str] = [] @@ -714,16 +758,9 @@ def test_contract_map_completeness(self) -> None: unmapped.append(fqn) if unmapped: - # REST completeness stays as warn-only pending sub-model + V2 model - # mapping work (~42 entries: nested model classes like Candlestick / - # OrderbookLevel, V2 request/response families, internal containers - # like PositionsResponse). Out of scope for #163; the WS completeness - # check IS hard-fail since ErrorPayload registration cleared it. - # Track follow-up in a separate issue if mapping these is desired. - warnings.warn( + pytest.fail( "SDK models without contract map entries:\n" - + "\n".join(f" - {m}" for m in unmapped), - stacklevel=1, + + "\n".join(f" - {m}" for m in unmapped) ) @@ -997,10 +1034,9 @@ class TestRequestParamDrift: drives both sync and async tests. Asserts the async class exists; a missing sibling is a bug we want to see immediately. - Hard-fails via ``pytest.fail`` (NOT ``warnings.warn``). Request-side drift - is a user-facing capability gap. See the docstring of - ``tests/_contract_support.py`` for the rationale on the asymmetry vs - ``TestSpecDrift`` (response-side, which warns rather than fails). + Hard-fails via ``pytest.fail``. See the docstring of + ``tests/_contract_support.py`` for the broader rationale; as of #172/#171 + every contract gate in this file is now hard-fail. """ spec: dict[str, Any] diff --git a/tests/test_historical.py b/tests/test_historical.py index 98e0503..fb4cbcb 100644 --- a/tests/test_historical.py +++ b/tests/test_historical.py @@ -13,7 +13,7 @@ from kalshi.config import KalshiConfig from kalshi.errors import KalshiNotFoundError from kalshi.resources.historical import AsyncHistoricalResource, HistoricalResource -from tests._model_fixtures import fill_dict, market_dict, order_dict, trade_dict +from tests._model_fixtures import candlestick_dict, fill_dict, market_dict, order_dict, trade_dict @pytest.fixture @@ -197,18 +197,18 @@ def test_returns_candlesticks(self, historical: HistoricalResource) -> None: 200, json={ "candlesticks": [ - { - "end_period_ts": 1700000000, - "yes_bid": { + candlestick_dict( + end_period_ts=1700000000, + yes_bid={ "open_dollars": "0.40", "high_dollars": "0.50", "low_dollars": "0.35", "close_dollars": "0.45", }, - "price": {"open_dollars": "0.45", "close_dollars": "0.50"}, - "volume_fp": "500.00", - "open_interest_fp": "1000.00", - } + price={"open_dollars": "0.45", "close_dollars": "0.50"}, + volume_fp="500.00", + open_interest_fp="1000.00", + ) ] }, ) @@ -694,16 +694,16 @@ async def test_returns_candlesticks(self, async_historical: AsyncHistoricalResou 200, json={ "candlesticks": [ - { - "end_period_ts": 1700000000, - "yes_bid": { + candlestick_dict( + end_period_ts=1700000000, + yes_bid={ "open_dollars": "0.40", "high_dollars": "0.50", "low_dollars": "0.35", "close_dollars": "0.45", }, - "volume_fp": "500.00", - } + volume_fp="500.00", + ) ] }, ) diff --git a/tests/test_markets.py b/tests/test_markets.py index fe85636..c30eedd 100644 --- a/tests/test_markets.py +++ b/tests/test_markets.py @@ -15,7 +15,7 @@ from kalshi.models.historical import Trade from kalshi.models.markets import MarketCandlesticks, Orderbook from kalshi.resources.markets import MarketsResource -from tests._model_fixtures import market_dict, trade_dict +from tests._model_fixtures import candlestick_dict, market_dict, trade_dict @pytest.fixture @@ -501,7 +501,7 @@ def test_returns_per_market_bundles(self, markets: MarketsResource) -> None: { "market_ticker": "MKT-A", "candlesticks": [ - {"end_period_ts": 1, "volume_fp": "0"}, + candlestick_dict(end_period_ts=1, volume_fp="0"), ], }, {"market_ticker": "MKT-B", "candlesticks": []}, diff --git a/tests/test_models.py b/tests/test_models.py index 4e6da0e..5a7f860 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -23,6 +23,7 @@ from kalshi.types import to_decimal from tests._model_fixtures import ( create_order_group_response_dict, + candlestick_dict, event_dict, event_metadata_dict, fill_dict, @@ -191,20 +192,20 @@ def test_candlestick_nested_structure(self) -> None: from kalshi.models.markets import Candlestick c = Candlestick.model_validate( - { - "end_period_ts": 1700000000, - "yes_bid": { + candlestick_dict( + end_period_ts=1700000000, + yes_bid={ "open_dollars": "0.4000", "high_dollars": "0.5000", "low_dollars": "0.3500", "close_dollars": "0.4500", }, - "price": { + price={ "open_dollars": "0.5000", "close_dollars": "0.5500", }, - "volume_fp": "100.00", - } + volume_fp="100.00", + ) ) assert c.yes_bid is not None assert c.yes_bid.open == Decimal("0.4000") diff --git a/tests/test_series.py b/tests/test_series.py index 797a5a6..fadd12c 100644 --- a/tests/test_series.py +++ b/tests/test_series.py @@ -11,6 +11,7 @@ from kalshi.config import KalshiConfig from kalshi.errors import AuthRequiredError from kalshi.resources.series import AsyncSeriesResource, SeriesResource +from tests._model_fixtures import candlestick_dict @pytest.fixture @@ -134,7 +135,7 @@ def test_event_candlesticks(self, series_resource: SeriesResource) -> None: respx.get(f"{BASE}/series/SER/events/EVT/candlesticks").mock( return_value=httpx.Response(200, json={ "market_tickers": ["MKT-A"], - "market_candlesticks": [[{"end_period_ts": 1000, "volume_fp": "10.00"}]], + "market_candlesticks": [[candlestick_dict(end_period_ts=1000, volume_fp="10.00")]], "adjusted_end_ts": 2000, }) ) diff --git a/tests/test_series_models.py b/tests/test_series_models.py index 7c1bcb3..68a451d 100644 --- a/tests/test_series_models.py +++ b/tests/test_series_models.py @@ -5,6 +5,8 @@ from decimal import Decimal from typing import Any +from tests._model_fixtures import candlestick_dict + from kalshi.models.series import ( EventCandlesticks, ForecastPercentilesPoint, @@ -188,8 +190,11 @@ def test_parse_nested_arrays(self) -> None: ec = EventCandlesticks.model_validate({ "market_tickers": ["MKT-A", "MKT-B"], "market_candlesticks": [ - [{"end_period_ts": 1000, "volume_fp": "50.00"}], - [{"end_period_ts": 1000, "volume_fp": "30.00"}, {"end_period_ts": 2000}], + [candlestick_dict(end_period_ts=1000, volume_fp="50.00")], + [ + candlestick_dict(end_period_ts=1000, volume_fp="30.00"), + candlestick_dict(end_period_ts=2000), + ], ], "adjusted_end_ts": 3000, }) From d858d90debb38253abf3c75e93339d2e39d822f0 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Tue, 19 May 2026 22:20:45 -0500 Subject: [PATCH 2/3] fixup: CI lint + PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ruff I001: sort \`candlestick_dict\` alphabetically in test_models.py and test_series_models.py import blocks. Project lints I001 in CI; the unsorted insert broke test (3.13). Mechanical fix. - _get_required_fields now goes through _resolve_schema unconditionally (was special-casing dotted paths only, with silent fallback to {} for missing plain names). Matches _get_schema_fields behavior and removes the latent silent-pass case. - Drop tautological \`assert c.yes_bid is not None\` / \`assert c.price is not None\` from test_candlestick_nested_structure. Both fields became required (non-nullable) in this PR, so the is-not-None checks were always true and assert nothing now. The downstream attribute assertions (\`c.yes_bid.open == …\`) still exercise the structure. --- tests/test_contracts.py | 6 +----- tests/test_models.py | 4 +--- tests/test_series_models.py | 3 +-- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/test_contracts.py b/tests/test_contracts.py index b8451ee..2157544 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -330,11 +330,7 @@ def _get_required_fields(spec: dict[str, Any], schema_name: str) -> set[str]: Supports the same dotted-path syntax as :func:`_get_schema_fields`. Returns an empty set for non-object schemas. """ - schemas = spec.get("components", {}).get("schemas", {}) - if "." in schema_name: - schema = _resolve_schema(spec, schema_name) - else: - schema = schemas.get(schema_name, {}) + schema = _resolve_schema(spec, schema_name) required: set[str] = set(schema.get("required", [])) diff --git a/tests/test_models.py b/tests/test_models.py index 5a7f860..5a11c2a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -22,8 +22,8 @@ from kalshi.models.portfolio import Settlement from kalshi.types import to_decimal from tests._model_fixtures import ( - create_order_group_response_dict, candlestick_dict, + create_order_group_response_dict, event_dict, event_metadata_dict, fill_dict, @@ -207,10 +207,8 @@ def test_candlestick_nested_structure(self) -> None: volume_fp="100.00", ) ) - assert c.yes_bid is not None assert c.yes_bid.open == Decimal("0.4000") assert c.yes_bid.high == Decimal("0.5000") - assert c.price is not None assert c.price.open == Decimal("0.5000") assert c.price.close == Decimal("0.5500") assert c.volume == Decimal("100.00") diff --git a/tests/test_series_models.py b/tests/test_series_models.py index 68a451d..62c2aeb 100644 --- a/tests/test_series_models.py +++ b/tests/test_series_models.py @@ -5,14 +5,13 @@ from decimal import Decimal from typing import Any -from tests._model_fixtures import candlestick_dict - from kalshi.models.series import ( EventCandlesticks, ForecastPercentilesPoint, Series, SeriesFeeChange, ) +from tests._model_fixtures import candlestick_dict class TestSeriesModel: From 1736b7c48d05c23e7b4d306d7c17bd54b59fbb91 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Tue, 19 May 2026 22:28:17 -0500 Subject: [PATCH 3/3] fixup: round-2 review feedback - Drop 7 more tautological \`is not None\` assertions across test_async_markets.py, test_historical.py, and test_markets.py. Same shape as the round-1 cleanup in test_models.py; the bot caught the ones I missed. - Rewrite _resolve_schema's path walk to distinguish a missing-key failure from a legitimately empty schema. Was using \`.get(segment, {})\` defaults followed by \`if not node\`, which would false-fail on any spec schema that happens to be \`{}\` (unusual but allowed). Now uses explicit \`in\` checks and emits a specific message per failure mode. - Document price_distribution_dict's intentionally empty base. - Drop the double blank line between #171 and #172 changelog sections. --- CHANGELOG.md | 1 - tests/_model_fixtures.py | 7 ++++++- tests/test_async_markets.py | 2 -- tests/test_contracts.py | 19 +++++++++++++------ tests/test_historical.py | 2 -- tests/test_markets.py | 3 --- 6 files changed, 19 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e9526..c888dc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,6 @@ added to `tests/_model_fixtures.py` (3 new). Test fixtures parsing `Candlestick` / `EventCandlesticks` / `MarketCandlesticks` now use those builders. - ### Required-but-optional drift closure (#172) Required-but-optional drift closure (#172). Drops `None` defaults on 226 diff --git a/tests/_model_fixtures.py b/tests/_model_fixtures.py index 1ab7859..69d40ff 100644 --- a/tests/_model_fixtures.py +++ b/tests/_model_fixtures.py @@ -598,7 +598,12 @@ def bid_ask_distribution_dict(**overrides: Any) -> dict[str, Any]: def price_distribution_dict(**overrides: Any) -> dict[str, Any]: - """Spec-shaped PriceDistribution dict (Candlestick.price). All optional.""" + """Spec-shaped PriceDistribution dict (Candlestick.price). + + All fields are optional per spec — the OHLC window may contain no + trades — so the default base is intentionally ``{}``. Pass overrides + when the test exercises specific fields. + """ base: dict[str, Any] = {} base.update(overrides) return base diff --git a/tests/test_async_markets.py b/tests/test_async_markets.py index 9cefbfd..e6adf88 100644 --- a/tests/test_async_markets.py +++ b/tests/test_async_markets.py @@ -316,9 +316,7 @@ async def test_returns_nested_candlesticks(self, markets: AsyncMarketsResource) assert len(candles) == 1 c = candles[0] assert c.end_period_ts == 1700000000 - assert c.yes_bid is not None assert c.yes_bid.open == Decimal("0.4000") - assert c.price is not None assert c.price.close == Decimal("0.5000") assert c.volume == Decimal("1234.50") assert c.open_interest == Decimal("5000.00") diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 2157544..fbff578 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -280,13 +280,20 @@ def _resolve_schema(spec: dict[str, Any], schema_name: str) -> dict[str, Any]: if "$ref" in node: node = _resolve_ref(spec, node["$ref"]) if segment == "items": - node = node.get("items", {}) + if "items" not in node: + pytest.fail( + f"Schema path '{schema_name}' failed at segment {segment!r}: " + f"parent has no 'items'" + ) + node = node["items"] else: - node = node.get("properties", {}).get(segment, {}) - if not node: - pytest.fail( - f"Schema path '{schema_name}' failed at segment {segment!r}" - ) + properties = node.get("properties", {}) + if segment not in properties: + pytest.fail( + f"Schema path '{schema_name}' failed at segment {segment!r}: " + f"not in parent properties" + ) + node = properties[segment] if "$ref" in node: node = _resolve_ref(spec, node["$ref"]) return node diff --git a/tests/test_historical.py b/tests/test_historical.py index fb4cbcb..74200a7 100644 --- a/tests/test_historical.py +++ b/tests/test_historical.py @@ -217,7 +217,6 @@ def test_returns_candlesticks(self, historical: HistoricalResource) -> None: "MKT", start_ts=1700000000, end_ts=1700100000, period_interval=60 ) assert len(candles) == 1 - assert candles[0].yes_bid is not None assert candles[0].yes_bid.open == Decimal("0.40") assert candles[0].volume == Decimal("500.00") @@ -712,7 +711,6 @@ async def test_returns_candlesticks(self, async_historical: AsyncHistoricalResou "MKT", start_ts=1700000000, end_ts=1700100000, period_interval=60 ) assert len(candles) == 1 - assert candles[0].yes_bid is not None assert candles[0].yes_bid.open == Decimal("0.40") diff --git a/tests/test_markets.py b/tests/test_markets.py index c30eedd..c259f17 100644 --- a/tests/test_markets.py +++ b/tests/test_markets.py @@ -363,12 +363,9 @@ def test_returns_nested_candlesticks(self, markets: MarketsResource) -> None: assert len(candles) == 1 c = candles[0] assert c.end_period_ts == 1700000000 - assert c.yes_bid is not None assert c.yes_bid.open == Decimal("0.4000") assert c.yes_bid.close == Decimal("0.4500") - assert c.yes_ask is not None assert c.yes_ask.high == Decimal("0.6000") - assert c.price is not None assert c.price.open == Decimal("0.5000") assert c.volume == Decimal("1234.50") assert c.open_interest == Decimal("5000.00")