diff --git a/CHANGELOG.md b/CHANGELOG.md index b8cc9a5..367e600 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,67 @@ All notable changes to kalshi-sdk will be documented in this file. +## Unreleased + +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 +on the wire. Promotes `test_required_drift` and `test_ws_required_drift` +from warning to hard CI failure, closing the regression class that allowed +required-but-typed-Optional fields to drift unnoticed. + +### Breaking (response-parse side) + +- **226 fields are no longer `Optional[T] | None` in response models** — + see the full list per model in #172. Wire format is unchanged; the SDK + now refuses to parse responses that omit a spec-required field, where + previously the field defaulted to `None`. If the live server omits a + spec-required field, `pydantic.ValidationError` is raised on parse. +- **`CreateOrderRequest.action` no longer defaults to `"buy"`** — callers + constructing the request model directly must pass `action` explicitly. + The `OrdersResource.create(action=None, ...)` kwarg path still defaults + to `"buy"` for back-compat; only the model-construction surface changed. +- **Test fixtures constructing these models with partial dicts will + raise `ValidationError`.** A new helper module `tests/_model_fixtures` + provides complete spec-shaped builders (`market_dict`, `order_dict`, + `fill_dict`, etc.) that accept `**overrides` for fields tests care about. + +### Changed + +- `test_required_drift` (REST) and `test_ws_required_drift` (WS) promoted + from `warnings.warn` to `pytest.fail`. Future drift on these gates is + CI-blocking. +- New `ExclusionKind` value `"server_omits_despite_required"` registered + in `tests/_contract_support.py` for fields the spec marks required but + the live server omits. Entries MUST cite a demo+prod observation. + +### Migration + +- Code that builds these models from server responses: no change. The + server-side wire shape is what it always was — the SDK type just stopped + lying about which fields are guaranteed. +- Code that builds these models in tests / mocks / fixtures: pass all + spec-required fields, or use the `tests/_model_fixtures` builders. The + builders are test-only (live under `tests/`, never shipped in the + wheel) — production code does not import them. +- Callers who relied on `Optional` narrowing (`if order.outcome_side is + not None: ...`) can drop the guard. `mypy --strict` will now flag the + redundant check. + +### Affected models + +21 REST (136 fields): `Market`, `Order`, `Fill`, `MultivariateEventCollection`, +`Settlement`, `Trade`, `Event`, `Series`, `MarketPosition`, `EventPosition`, +`EventMetadata`, `Milestone`, `SportFilterDetails`, `IncentiveProgram`, +`ApiKey`, `SeriesFeeChange`, `MarketCandlesticks`, `ScopeList`, +`GetOrderGroupResponse`, `CreateOrderGroupResponse`, `CreateOrderRequest`. + +13 WS payloads (90 fields): `UserOrdersPayload`, `FillPayload`, +`TickerPayload`, `TradePayload`, `MarketPositionsPayload`, +`QuoteExecutedPayload`, `QuoteCreatedPayload`, `QuoteAcceptedPayload`, +`MultivariatePayload`, `RfqCreatedPayload`, `RfqDeletedPayload`, +`MarketLifecyclePayload`, `OrderGroupPayload`. + ## 2.2.0 — 2026-05-19 Response-side spec drift hardening stack (#157). Backfills the remaining diff --git a/kalshi/models/api_keys.py b/kalshi/models/api_keys.py index be1138a..4d7b39c 100644 --- a/kalshi/models/api_keys.py +++ b/kalshi/models/api_keys.py @@ -25,7 +25,7 @@ class ApiKey(BaseModel): api_key_id: str name: str - scopes: NullableList[str] = [] + scopes: NullableList[str] model_config = {"extra": "allow"} diff --git a/kalshi/models/events.py b/kalshi/models/events.py index 6469957..4bc2662 100644 --- a/kalshi/models/events.py +++ b/kalshi/models/events.py @@ -19,16 +19,16 @@ class Event(BaseModel): """A Kalshi event (container for one or more markets).""" event_ticker: str - series_ticker: str | None = None - title: str | None = None - sub_title: str | None = None - collateral_return_type: str | None = None - mutually_exclusive: bool | None = None + series_ticker: str + title: str + sub_title: str + collateral_return_type: str + mutually_exclusive: bool category: str | None = None strike_date: datetime | None = None strike_period: str | None = None - available_on_brokers: bool | None = None - product_metadata: dict[str, Any] | None = None + available_on_brokers: bool + product_metadata: dict[str, Any] last_updated_ts: datetime | None = None markets: NullableList[Market] = [] @@ -64,10 +64,10 @@ class SettlementSource(BaseModel): class EventMetadata(BaseModel): """Metadata for an event including images and settlement sources.""" - image_url: str | None = None + image_url: str featured_image_url: str | None = None - market_details: list[MarketMetadata] | None = None - settlement_sources: list[SettlementSource] | None = None + market_details: list[MarketMetadata] + settlement_sources: list[SettlementSource] # v3.18.0 backfill (#160). competition: str | None = None diff --git a/kalshi/models/historical.py b/kalshi/models/historical.py index 38818c1..b134e8e 100644 --- a/kalshi/models/historical.py +++ b/kalshi/models/historical.py @@ -30,25 +30,22 @@ class Trade(BaseModel): """A public trade on the exchange.""" trade_id: str - ticker: str | None = None + ticker: str count: FixedPointCount | None = Field( - default=None, validation_alias=AliasChoices("count_fp", "count"), ) yes_price: DollarDecimal | None = Field( - default=None, validation_alias=AliasChoices("yes_price_dollars", "yes_price"), ) no_price: DollarDecimal | None = Field( - default=None, validation_alias=AliasChoices("no_price_dollars", "no_price"), ) - taker_side: str | None = None - created_time: datetime | None = None + taker_side: str + created_time: datetime # 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 + taker_outcome_side: SideLiteral + taker_book_side: BookSideLiteral model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/models/incentive_programs.py b/kalshi/models/incentive_programs.py index 3ba5561..20edd97 100644 --- a/kalshi/models/incentive_programs.py +++ b/kalshi/models/incentive_programs.py @@ -38,7 +38,7 @@ class IncentiveProgram(BaseModel): target_size_fp: FixedPointCount | None = None # v3.18.0 backfill (#160). - incentive_description: str | None = None + incentive_description: str model_config = {"extra": "allow"} diff --git a/kalshi/models/markets.py b/kalshi/models/markets.py index e26a242..bc4b1de 100644 --- a/kalshi/models/markets.py +++ b/kalshi/models/markets.py @@ -26,67 +26,55 @@ class Market(BaseModel): """ ticker: str - event_ticker: str | None = None - market_type: str | None = None + event_ticker: str + market_type: str title: str | None = None subtitle: str | None = None - yes_sub_title: str | None = None - no_sub_title: str | None = None - status: str | None = None + yes_sub_title: str + no_sub_title: str + status: str # Price fields (FixedPointDollars) - yes_bid: DollarDecimal | None = Field( - default=None, + yes_bid: DollarDecimal = Field( validation_alias=AliasChoices("yes_bid_dollars", "yes_bid"), ) - yes_ask: DollarDecimal | None = Field( - default=None, + yes_ask: DollarDecimal = Field( validation_alias=AliasChoices("yes_ask_dollars", "yes_ask"), ) - no_bid: DollarDecimal | None = Field( - default=None, + no_bid: DollarDecimal = Field( validation_alias=AliasChoices("no_bid_dollars", "no_bid"), ) - no_ask: DollarDecimal | None = Field( - default=None, + no_ask: DollarDecimal = Field( validation_alias=AliasChoices("no_ask_dollars", "no_ask"), ) - last_price: DollarDecimal | None = Field( - default=None, + last_price: DollarDecimal = Field( validation_alias=AliasChoices("last_price_dollars", "last_price"), ) - previous_yes_bid: DollarDecimal | None = Field( - default=None, + previous_yes_bid: DollarDecimal = Field( validation_alias=AliasChoices("previous_yes_bid_dollars", "previous_yes_bid"), ) - previous_yes_ask: DollarDecimal | None = Field( - default=None, + previous_yes_ask: DollarDecimal = Field( validation_alias=AliasChoices("previous_yes_ask_dollars", "previous_yes_ask"), ) - previous_price: DollarDecimal | None = Field( - default=None, + previous_price: DollarDecimal = Field( validation_alias=AliasChoices("previous_price_dollars", "previous_price"), ) - notional_value: DollarDecimal | None = Field( - default=None, + notional_value: DollarDecimal = Field( validation_alias=AliasChoices("notional_value_dollars", "notional_value"), ) settlement_value: DollarDecimal | None = Field( default=None, validation_alias=AliasChoices("settlement_value_dollars", "settlement_value"), ) - liquidity: DollarDecimal | None = Field( - default=None, + liquidity: DollarDecimal = Field( validation_alias=AliasChoices("liquidity_dollars", "liquidity"), ) # Size/volume fields (FixedPointCount) - yes_bid_size: FixedPointCount | None = Field( - default=None, + yes_bid_size: FixedPointCount = Field( validation_alias=AliasChoices("yes_bid_size_fp", "yes_bid_size"), ) - yes_ask_size: FixedPointCount | None = Field( - default=None, + yes_ask_size: FixedPointCount = Field( validation_alias=AliasChoices("yes_ask_size_fp", "yes_ask_size"), ) no_bid_size: FixedPointCount | None = Field( @@ -97,43 +85,40 @@ class Market(BaseModel): default=None, validation_alias=AliasChoices("no_ask_size_fp", "no_ask_size"), ) - volume: FixedPointCount | None = Field( - default=None, + volume: FixedPointCount = Field( validation_alias=AliasChoices("volume_fp", "volume"), ) - volume_24h: FixedPointCount | None = Field( - default=None, + volume_24h: FixedPointCount = Field( validation_alias=AliasChoices("volume_24h_fp", "volume_24h"), ) - open_interest: FixedPointCount | None = Field( - default=None, + open_interest: FixedPointCount = Field( validation_alias=AliasChoices("open_interest_fp", "open_interest"), ) # Timestamps - created_time: datetime | None = None - updated_time: datetime | None = None - open_time: datetime | None = None - close_time: datetime | None = None - latest_expiration_time: datetime | None = None + created_time: datetime + updated_time: datetime + open_time: datetime + close_time: datetime + latest_expiration_time: datetime expected_expiration_time: datetime | None = None expiration_time: datetime | None = None settlement_ts: datetime | None = None occurrence_datetime: datetime | None = None # Metadata - settlement_timer_seconds: int | None = None - result: str | None = None - can_close_early: bool | None = None - fractional_trading_enabled: bool | None = None - expiration_value: str | None = None + settlement_timer_seconds: int + result: str + can_close_early: bool + fractional_trading_enabled: bool + expiration_value: str category: str | None = None risk_limit_cents: int | None = None strike_type: str | None = None floor_strike: Decimal | None = None cap_strike: Decimal | None = None - rules_primary: str | None = None - rules_secondary: str | None = None + rules_primary: str + rules_secondary: str # v3.18.0 backfill (#159). mve_selected_legs is list[MveSelectedLeg] and # price_ranges is list[PriceRange] on the wire — kept as list[dict] here; @@ -146,8 +131,8 @@ class Market(BaseModel): is_provisional: bool | None = None mve_collection_ticker: str | None = None mve_selected_legs: list[dict[str, Any]] | None = None - price_level_structure: str | None = None - price_ranges: list[dict[str, Any]] | None = None + price_level_structure: str + price_ranges: NullableList[dict[str, Any]] primary_participant_key: str | None = None model_config = {"extra": "allow", "populate_by_name": True} @@ -263,6 +248,6 @@ class MarketCandlesticks(BaseModel): # NullableList: Kalshi has returned JSON null for required list fields # in other envelopes (v0.9.0 Series fix). Coerce None -> [] to match the # pattern used on Orderbook.yes/no and envelope-level list fields. - candlesticks: NullableList[Candlestick] = [] + candlesticks: NullableList[Candlestick] model_config = {"extra": "allow"} diff --git a/kalshi/models/milestones.py b/kalshi/models/milestones.py index 889874e..e209db9 100644 --- a/kalshi/models/milestones.py +++ b/kalshi/models/milestones.py @@ -28,13 +28,13 @@ class Milestone(BaseModel): # Spec marks these required but Kalshi has historically returned JSON null # for required list fields (see v0.9.0 fix for Series.tags). NullableList # coerces None -> [] so demo/prod inconsistencies don't break parsing. - related_event_tickers: NullableList[str] = [] + related_event_tickers: NullableList[str] title: str notification_message: str source_id: str | None = None source_ids: dict[str, str] | None = None - details: dict[str, Any] = {} - primary_event_tickers: NullableList[str] = [] + details: dict[str, Any] + primary_event_tickers: NullableList[str] last_updated_ts: datetime model_config = {"extra": "allow"} diff --git a/kalshi/models/multivariate.py b/kalshi/models/multivariate.py index 0a94939..077b5dc 100644 --- a/kalshi/models/multivariate.py +++ b/kalshi/models/multivariate.py @@ -37,21 +37,21 @@ class MultivariateEventCollection(BaseModel): """A multivariate event collection (combo contract template).""" collection_ticker: str - series_ticker: str = "" - title: str = "" - description: str = "" - open_date: datetime | None = None - close_date: datetime | None = None - associated_events: NullableList[AssociatedEvent] = [] + series_ticker: str + title: str + description: str + open_date: datetime + close_date: datetime + associated_events: NullableList[AssociatedEvent] # Deprecated fields — still returned by API - associated_event_tickers: NullableList[str] = [] - is_single_market_per_event: bool = False - is_all_yes: bool = False + associated_event_tickers: NullableList[str] + is_single_market_per_event: bool + is_all_yes: bool # Active fields - is_ordered: bool = False - size_min: int = 0 - size_max: int = 0 - functional_description: str = "" + is_ordered: bool + size_min: int + size_max: int + functional_description: str model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/models/order_groups.py b/kalshi/models/order_groups.py index ecf76fe..83796aa 100644 --- a/kalshi/models/order_groups.py +++ b/kalshi/models/order_groups.py @@ -27,7 +27,7 @@ class GetOrderGroupResponse(BaseModel): """Single-group response — omits id (path param), adds member order IDs.""" is_auto_cancel_enabled: bool - orders: NullableList[str] = [] + orders: NullableList[str] contracts_limit: FixedPointCount | None = Field( default=None, validation_alias=AliasChoices("contracts_limit_fp", "contracts_limit"), @@ -46,7 +46,7 @@ class CreateOrderGroupResponse(BaseModel): # v3.18.0 backfill (#161). Server echoes the routing context (subaccount + # exchange shard) on the create response. - subaccount: int | None = None + subaccount: int exchange_index: int | None = None model_config = {"extra": "allow"} diff --git a/kalshi/models/orders.py b/kalshi/models/orders.py index 9b7e5ca..ff60963 100644 --- a/kalshi/models/orders.py +++ b/kalshi/models/orders.py @@ -42,69 +42,59 @@ class Order(BaseModel): """ order_id: str - ticker: str | None = None - user_id: str | None = None - status: str | None = None - side: str | None = None + ticker: str + user_id: str + status: str + side: str is_yes: bool | None = None # Spec field is named ``type`` (enum: limit, market). Renamed to # ``order_type`` on the SDK side to avoid shadowing the Python builtin — # same rationale as milestone_type / target_type / incentive_type # elsewhere. The wire still sends ``type``; validation alias accepts both. - order_type: str | None = Field( - default=None, + order_type: str = Field( validation_alias=AliasChoices("type", "order_type"), ) - yes_price: DollarDecimal | None = Field( - default=None, + yes_price: DollarDecimal = Field( validation_alias=AliasChoices("yes_price_dollars", "yes_price"), ) - no_price: DollarDecimal | None = Field( - default=None, + no_price: DollarDecimal = Field( validation_alias=AliasChoices("no_price_dollars", "no_price"), ) count: FixedPointCount | None = Field( default=None, validation_alias=AliasChoices("count_fp", "count"), ) - initial_count: FixedPointCount | None = Field( - default=None, + initial_count: FixedPointCount = Field( validation_alias=AliasChoices("initial_count_fp", "initial_count"), ) - remaining_count: FixedPointCount | None = Field( - default=None, + remaining_count: FixedPointCount = Field( validation_alias=AliasChoices("remaining_count_fp", "remaining_count"), ) - fill_count: FixedPointCount | None = Field( - default=None, + fill_count: FixedPointCount = Field( validation_alias=AliasChoices("fill_count_fp", "fill_count"), ) - taker_fill_cost: DollarDecimal | None = Field( - default=None, + taker_fill_cost: DollarDecimal = Field( validation_alias=AliasChoices("taker_fill_cost_dollars", "taker_fill_cost"), ) - maker_fill_cost: DollarDecimal | None = Field( - default=None, + maker_fill_cost: DollarDecimal = Field( validation_alias=AliasChoices("maker_fill_cost_dollars", "maker_fill_cost"), ) - taker_fees: DollarDecimal | None = Field( - default=None, + taker_fees: DollarDecimal = Field( validation_alias=AliasChoices("taker_fees_dollars", "taker_fees"), ) - maker_fees: DollarDecimal | None = Field( - default=None, + maker_fees: DollarDecimal = Field( validation_alias=AliasChoices("maker_fees_dollars", "maker_fees"), ) created_time: datetime | None = None expiration_time: datetime | None = None - client_order_id: str | None = None + client_order_id: str subaccount: int | None = None # v3.18.0 backfill (#159). outcome_side/book_side are the canonical # direction encoding going forward; deprecated action/side/is_yes stay # for back-compat. subaccount_number is distinct from subaccount. - outcome_side: SideLiteral | None = None - book_side: BookSideLiteral | None = None + outcome_side: SideLiteral + book_side: BookSideLiteral last_update_time: datetime | None = None self_trade_prevention_type: SelfTradePreventionTypeLiteral | None = None order_group_id: str | None = None @@ -123,35 +113,31 @@ class Fill(BaseModel): """ trade_id: str - fill_id: str | None = None - order_id: str | None = None - ticker: str | None = None - market_ticker: str | None = None - side: str | None = None - action: str | None = None - is_taker: bool | None = None - count: FixedPointCount | None = Field( - default=None, + fill_id: str + order_id: str + ticker: str + market_ticker: str + side: str + action: str + is_taker: bool + count: FixedPointCount = Field( validation_alias=AliasChoices("count_fp", "count"), ) - yes_price: DollarDecimal | None = Field( - default=None, + yes_price: DollarDecimal = Field( validation_alias=AliasChoices("yes_price_dollars", "yes_price"), ) - no_price: DollarDecimal | None = Field( - default=None, + no_price: DollarDecimal = Field( validation_alias=AliasChoices("no_price_dollars", "no_price"), ) - fee_cost: DollarDecimal | None = Field( - default=None, + fee_cost: DollarDecimal = Field( validation_alias=AliasChoices("fee_cost_dollars", "fee_cost"), ) created_time: datetime | None = None # v3.18.0 backfill (#159). ts is Unix-ms int per spec — distinct from # the typed created_time: datetime; do NOT coerce. - outcome_side: SideLiteral | None = None - book_side: BookSideLiteral | None = None + outcome_side: SideLiteral + book_side: BookSideLiteral subaccount_number: int | None = None ts: int | None = None @@ -175,9 +161,9 @@ class CreateOrderRequest(BaseModel): it. Callers passing ``type="market"`` (or similar) now get a ``ValidationError`` at construction time. - ``action`` defaults to ``"buy"`` — the pre-v0.8.0 default is - preserved to keep existing call sites working. ``ticker`` and - ``side`` remain required. + ``ticker``, ``side``, and ``action`` are all required by the spec. + Pre-v2.3.0 the SDK defaulted ``action`` to ``"buy"`` as a convenience; + that default has been removed to match the spec required-set (#172). See ``kalshi.resources.orders.OrdersResource.create`` for the user-facing method that builds this model internally. @@ -185,7 +171,7 @@ class CreateOrderRequest(BaseModel): ticker: str side: str - action: str = "buy" + action: str count: FixedPointCount = Field(default=Decimal("1"), serialization_alias="count_fp") yes_price: DollarDecimal | None = Field( default=None, diff --git a/kalshi/models/portfolio.py b/kalshi/models/portfolio.py index e7efecc..8a51045 100644 --- a/kalshi/models/portfolio.py +++ b/kalshi/models/portfolio.py @@ -68,27 +68,22 @@ class MarketPosition(BaseModel): ticker: str total_traded: DollarDecimal | None = Field( - default=None, validation_alias=AliasChoices("total_traded_dollars", "total_traded"), ) position: FixedPointCount | None = Field( - default=None, validation_alias=AliasChoices("position_fp", "position"), ) market_exposure: DollarDecimal | None = Field( - default=None, validation_alias=AliasChoices("market_exposure_dollars", "market_exposure"), ) realized_pnl: DollarDecimal | None = Field( - default=None, validation_alias=AliasChoices("realized_pnl_dollars", "realized_pnl"), ) - resting_orders_count: int | None = None + resting_orders_count: int fees_paid: DollarDecimal | None = Field( - default=None, validation_alias=AliasChoices("fees_paid_dollars", "fees_paid"), ) - last_updated_ts: datetime | None = None + last_updated_ts: datetime model_config = {"extra": "allow", "populate_by_name": True} @@ -98,23 +93,18 @@ class EventPosition(BaseModel): event_ticker: str total_cost: DollarDecimal | None = Field( - default=None, validation_alias=AliasChoices("total_cost_dollars", "total_cost"), ) total_cost_shares: FixedPointCount | None = Field( - default=None, validation_alias=AliasChoices("total_cost_shares_fp", "total_cost_shares"), ) event_exposure: DollarDecimal | None = Field( - default=None, validation_alias=AliasChoices("event_exposure_dollars", "event_exposure"), ) realized_pnl: DollarDecimal | None = Field( - default=None, validation_alias=AliasChoices("realized_pnl_dollars", "realized_pnl"), ) fees_paid: DollarDecimal | None = Field( - default=None, validation_alias=AliasChoices("fees_paid_dollars", "fees_paid"), ) @@ -177,28 +167,23 @@ class Settlement(BaseModel): """A settled market position.""" ticker: str - event_ticker: str | None = None - market_result: str | None = None + event_ticker: str + market_result: str yes_count: FixedPointCount | None = Field( - default=None, validation_alias=AliasChoices("yes_count_fp", "yes_count"), ) yes_total_cost: DollarDecimal | None = Field( - default=None, validation_alias=AliasChoices("yes_total_cost_dollars", "yes_total_cost"), ) no_count: FixedPointCount | None = Field( - default=None, validation_alias=AliasChoices("no_count_fp", "no_count"), ) no_total_cost: DollarDecimal | None = Field( - default=None, validation_alias=AliasChoices("no_total_cost_dollars", "no_total_cost"), ) - revenue: int | None = None - settled_time: datetime | None = None + revenue: int + settled_time: datetime fee_cost: DollarDecimal | None = Field( - default=None, validation_alias=AliasChoices("fee_cost_dollars", "fee_cost"), ) diff --git a/kalshi/models/search.py b/kalshi/models/search.py index cd09dee..be3e654 100644 --- a/kalshi/models/search.py +++ b/kalshi/models/search.py @@ -20,7 +20,7 @@ class ScopeList(BaseModel): """Scopes available for a specific competition within a sport.""" - scopes: NullableList[str] = [] + scopes: NullableList[str] model_config = {"extra": "allow"} @@ -33,8 +33,8 @@ class SportFilterDetails(BaseModel): subset. """ - scopes: NullableList[str] = [] - competitions: dict[str, ScopeList] = {} + scopes: NullableList[str] + competitions: dict[str, ScopeList] model_config = {"extra": "allow"} diff --git a/kalshi/models/series.py b/kalshi/models/series.py index 951dd1d..f46f345 100644 --- a/kalshi/models/series.py +++ b/kalshi/models/series.py @@ -23,14 +23,14 @@ class Series(BaseModel): frequency: str title: str category: str - tags: NullableList[str] = [] - settlement_sources: NullableList[dict[str, Any]] = [] - contract_url: str = "" - contract_terms_url: str = "" + tags: NullableList[str] + settlement_sources: NullableList[dict[str, Any]] + contract_url: str + contract_terms_url: str product_metadata: dict[str, Any] | None = None - fee_type: str = "" - fee_multiplier: float = 0.0 - additional_prohibitions: NullableList[str] = [] + fee_type: str + fee_multiplier: float + additional_prohibitions: NullableList[str] volume: FixedPointCount | None = Field( default=None, validation_alias=AliasChoices("volume_fp", "volume"), @@ -47,7 +47,7 @@ class SeriesFeeChange(BaseModel): series_ticker: str fee_type: str fee_multiplier: float - scheduled_ts: datetime | None = None + scheduled_ts: datetime model_config = {"extra": "allow"} diff --git a/kalshi/ws/models/communications.py b/kalshi/ws/models/communications.py index 84493a8..0490894 100644 --- a/kalshi/ws/models/communications.py +++ b/kalshi/ws/models/communications.py @@ -23,9 +23,9 @@ class RfqCreatedPayload(BaseModel): """ id: str - creator_id: str | None = None - market_ticker: str | None = None - created_ts: str | None = None + creator_id: str + market_ticker: str + created_ts: str event_ticker: str | None = None contracts: str | None = Field( default=None, @@ -52,9 +52,9 @@ class RfqDeletedPayload(BaseModel): """ id: str - creator_id: str | None = None - market_ticker: str | None = None - deleted_ts: str | None = None + creator_id: str + market_ticker: str + deleted_ts: str # v0.14+ backfill (#162). Same RFQ context as RfqCreatedPayload — # surfaced again on delete for clients that subscribed mid-RFQ. @@ -74,18 +74,16 @@ class QuoteCreatedPayload(BaseModel): """Quote created notification payload.""" quote_id: str - rfq_id: str | None = None - quote_creator_id: str | None = None - market_ticker: str | None = None - yes_bid: DollarDecimal | None = Field( - default=None, + rfq_id: str + quote_creator_id: str + market_ticker: str + yes_bid: DollarDecimal = Field( validation_alias=AliasChoices("yes_bid_dollars", "yes_bid"), ) - no_bid: DollarDecimal | None = Field( - default=None, + no_bid: DollarDecimal = Field( validation_alias=AliasChoices("no_bid_dollars", "no_bid"), ) - created_ts: str | None = None + created_ts: str # v0.14+ backfill (#162). Event linkage + RFQ offer/cost context echoed # on the quote so subscribers don't need to look up the parent RFQ. @@ -109,15 +107,13 @@ class QuoteAcceptedPayload(BaseModel): """Quote accepted notification payload.""" quote_id: str - rfq_id: str | None = None - quote_creator_id: str | None = None - market_ticker: str | None = None - yes_bid: DollarDecimal | None = Field( - default=None, + rfq_id: str + quote_creator_id: str + market_ticker: str + yes_bid: DollarDecimal = Field( validation_alias=AliasChoices("yes_bid_dollars", "yes_bid"), ) - no_bid: DollarDecimal | None = Field( - default=None, + no_bid: DollarDecimal = Field( validation_alias=AliasChoices("no_bid_dollars", "no_bid"), ) accepted_side: str | None = None @@ -148,13 +144,13 @@ class QuoteExecutedPayload(BaseModel): """Quote executed notification payload.""" quote_id: str - rfq_id: str | None = None - quote_creator_id: str | None = None - rfq_creator_id: str | None = None - order_id: str | None = None - client_order_id: str | None = None - market_ticker: str | None = None - executed_ts: str | None = None + rfq_id: str + quote_creator_id: str + rfq_creator_id: str + order_id: str + client_order_id: str + market_ticker: str + executed_ts: str model_config = {"extra": "allow"} diff --git a/kalshi/ws/models/fill.py b/kalshi/ws/models/fill.py index 22ffbbe..121c84f 100644 --- a/kalshi/ws/models/fill.py +++ b/kalshi/ws/models/fill.py @@ -17,33 +17,30 @@ class FillPayload(BaseModel): """ trade_id: str - order_id: str | None = None - market_ticker: str | None = None - is_taker: bool | None = None - side: str | None = None - yes_price: DollarDecimal | None = Field( - default=None, + order_id: str + market_ticker: str + is_taker: bool + side: str + yes_price: DollarDecimal = Field( validation_alias=AliasChoices("yes_price_dollars", "yes_price"), ) - count: str | None = Field( - default=None, + count: str = Field( validation_alias=AliasChoices("count_fp", "count"), ) # _fp format - fee_cost: DollarDecimal | None = None - action: str | None = None # buy/sell - ts: int | None = None - post_position: str | None = Field( - default=None, + fee_cost: DollarDecimal + action: str # buy/sell + ts: int + post_position: str = Field( validation_alias=AliasChoices("post_position_fp", "post_position"), ) # _fp format - purchased_side: str | None = None + purchased_side: str client_order_id: str | None = None subaccount: int | None = None # v0.14+ backfill (#162). outcome_side/book_side are the canonical # direction encoding; ts_ms supersedes ts. action/side stay for compat. - outcome_side: SideLiteral | None = None - book_side: BookSideLiteral | None = None - ts_ms: int | None = None + outcome_side: SideLiteral + book_side: BookSideLiteral + ts_ms: int model_config = {"extra": "allow"} diff --git a/kalshi/ws/models/market_lifecycle.py b/kalshi/ws/models/market_lifecycle.py index 7ff5b89..c3f8e3d 100644 --- a/kalshi/ws/models/market_lifecycle.py +++ b/kalshi/ws/models/market_lifecycle.py @@ -20,7 +20,8 @@ class MarketLifecyclePayload(BaseModel): """ event_type: str # created/activated/deactivated/close_date_updated/determined/settled/etc - market_ticker: str | None = None + # #172: tightened; all observed lifecycle event_types carry market_ticker. + market_ticker: str event_ticker: str | None = None # Conditional fields depending on event_type open_ts: int | None = None diff --git a/kalshi/ws/models/market_positions.py b/kalshi/ws/models/market_positions.py index 74d23c6..b714d29 100644 --- a/kalshi/ws/models/market_positions.py +++ b/kalshi/ws/models/market_positions.py @@ -13,30 +13,24 @@ class MarketPositionsPayload(BaseModel): convention. ``position`` is a fixed-point contract count (string). """ - user_id: str | None = None + user_id: str market_ticker: str - position: str | None = Field( - default=None, + position: str = Field( validation_alias=AliasChoices("position_fp", "position"), ) # _fp format - position_cost: DollarDecimal | None = Field( - default=None, + position_cost: DollarDecimal = Field( validation_alias=AliasChoices("position_cost_dollars", "position_cost"), ) - realized_pnl: DollarDecimal | None = Field( - default=None, + realized_pnl: DollarDecimal = Field( validation_alias=AliasChoices("realized_pnl_dollars", "realized_pnl"), ) - fees_paid: DollarDecimal | None = Field( - default=None, + fees_paid: DollarDecimal = Field( validation_alias=AliasChoices("fees_paid_dollars", "fees_paid"), ) - position_fee_cost: DollarDecimal | None = Field( - default=None, + position_fee_cost: DollarDecimal = Field( validation_alias=AliasChoices("position_fee_cost_dollars", "position_fee_cost"), ) - volume: str | None = Field( - default=None, + volume: str = Field( validation_alias=AliasChoices("volume_fp", "volume"), ) # _fp format subaccount: int | None = None diff --git a/kalshi/ws/models/multivariate.py b/kalshi/ws/models/multivariate.py index fbbaa14..6dccec2 100644 --- a/kalshi/ws/models/multivariate.py +++ b/kalshi/ws/models/multivariate.py @@ -3,6 +3,7 @@ from pydantic import BaseModel +from kalshi.types import NullableList from kalshi.ws.models.market_lifecycle import MarketLifecyclePayload @@ -18,10 +19,10 @@ class SelectedMarket(BaseModel): class MultivariatePayload(BaseModel): """Payload for multivariate messages (public channel).""" - collection_ticker: str | None = None - event_ticker: str | None = None - market_ticker: str | None = None - selected_markets: list[SelectedMarket] = [] + collection_ticker: str + event_ticker: str + market_ticker: str + selected_markets: NullableList[SelectedMarket] model_config = {"extra": "allow"} diff --git a/kalshi/ws/models/order_group.py b/kalshi/ws/models/order_group.py index c38ba2d..23d56be 100644 --- a/kalshi/ws/models/order_group.py +++ b/kalshi/ws/models/order_group.py @@ -14,7 +14,7 @@ class OrderGroupPayload(BaseModel): validation_alias=AliasChoices("contracts_limit_fp", "contracts_limit"), ) # _fp format # v0.14+ backfill (#162). Matching engine timestamp in Unix ms. - ts_ms: int | None = None + ts_ms: int model_config = {"extra": "allow"} diff --git a/kalshi/ws/models/ticker.py b/kalshi/ws/models/ticker.py index 1b3c63e..eec6caf 100644 --- a/kalshi/ws/models/ticker.py +++ b/kalshi/ws/models/ticker.py @@ -15,13 +15,11 @@ class TickerPayload(BaseModel): """ market_ticker: str - market_id: str | None = None - yes_bid: DollarDecimal | None = Field( - default=None, + market_id: str + yes_bid: DollarDecimal = Field( validation_alias=AliasChoices("yes_bid_dollars", "yes_bid"), ) - yes_ask: DollarDecimal | None = Field( - default=None, + yes_ask: DollarDecimal = Field( validation_alias=AliasChoices("yes_ask_dollars", "yes_ask"), ) no_bid: DollarDecimal | None = Field( @@ -32,36 +30,30 @@ class TickerPayload(BaseModel): default=None, validation_alias=AliasChoices("no_ask_dollars", "no_ask"), ) - volume: str | None = Field( - default=None, + volume: str = Field( validation_alias=AliasChoices("volume_fp", "volume"), ) # _fp format - open_interest: str | None = Field( - default=None, + open_interest: str = Field( validation_alias=AliasChoices("open_interest_fp", "open_interest"), ) # _fp format - dollar_volume: str | None = None - dollar_open_interest: str | None = None - yes_bid_size: str | None = Field( - default=None, + dollar_volume: str + dollar_open_interest: str + yes_bid_size: str = Field( validation_alias=AliasChoices("yes_bid_size_fp", "yes_bid_size"), ) # _fp format - yes_ask_size: str | None = Field( - default=None, + yes_ask_size: str = Field( validation_alias=AliasChoices("yes_ask_size_fp", "yes_ask_size"), ) # _fp format - last_trade_size: str | None = Field( - default=None, + last_trade_size: str = Field( validation_alias=AliasChoices("last_trade_size_fp", "last_trade_size"), ) # _fp format - ts: int | None = None + ts: int # v0.14+ backfill (#162). Spec promotes ts_ms (Unix ms) as the primary # timestamp; ts (seconds) stays for compat. Do NOT auto-convert. - price: DollarDecimal | None = Field( - default=None, + price: DollarDecimal = Field( validation_alias=AliasChoices("price_dollars", "price"), ) - ts_ms: int | None = None + ts_ms: int model_config = {"extra": "allow"} diff --git a/kalshi/ws/models/trade.py b/kalshi/ws/models/trade.py index c4a2112..b8617d5 100644 --- a/kalshi/ws/models/trade.py +++ b/kalshi/ws/models/trade.py @@ -17,25 +17,22 @@ class TradePayload(BaseModel): trade_id: str market_ticker: str - yes_price: DollarDecimal | None = Field( - default=None, + yes_price: DollarDecimal = Field( validation_alias=AliasChoices("yes_price_dollars", "yes_price"), ) - no_price: DollarDecimal | None = Field( - default=None, + no_price: DollarDecimal = Field( validation_alias=AliasChoices("no_price_dollars", "no_price"), ) - count: str | None = Field( - default=None, + count: str = Field( validation_alias=AliasChoices("count_fp", "count"), ) # _fp format - taker_side: str | None = None - ts: int | None = None + taker_side: str + ts: int # v0.14+ backfill (#162). taker_outcome_side/taker_book_side are the # canonical direction encoding; ts_ms supersedes ts. taker_side stays. - taker_outcome_side: SideLiteral | None = None - taker_book_side: BookSideLiteral | None = None - ts_ms: int | None = None + taker_outcome_side: SideLiteral + taker_book_side: BookSideLiteral + ts_ms: int model_config = {"extra": "allow"} diff --git a/kalshi/ws/models/user_orders.py b/kalshi/ws/models/user_orders.py index 75f473a..6e68edf 100644 --- a/kalshi/ws/models/user_orders.py +++ b/kalshi/ws/models/user_orders.py @@ -23,45 +23,37 @@ class UserOrdersPayload(BaseModel): """ order_id: str - user_id: str | None = None - ticker: str | None = None - status: str | None = None # resting/canceled/executed - side: str | None = None - is_yes: bool | None = None - yes_price: DollarDecimal | None = Field( - default=None, + user_id: str + ticker: str + status: str # resting/canceled/executed + side: str + is_yes: bool + yes_price: DollarDecimal = Field( validation_alias=AliasChoices("yes_price_dollars", "yes_price"), ) - fill_count: str | None = Field( - default=None, + fill_count: str = Field( validation_alias=AliasChoices("fill_count_fp", "fill_count"), ) # _fp format - remaining_count: str | None = Field( - default=None, + remaining_count: str = Field( validation_alias=AliasChoices("remaining_count_fp", "remaining_count"), ) # _fp format - initial_count: str | None = Field( - default=None, + initial_count: str = Field( validation_alias=AliasChoices("initial_count_fp", "initial_count"), ) # _fp format - taker_fill_cost: DollarDecimal | None = Field( - default=None, + taker_fill_cost: DollarDecimal = Field( validation_alias=AliasChoices("taker_fill_cost_dollars", "taker_fill_cost"), ) - maker_fill_cost: DollarDecimal | None = Field( - default=None, + maker_fill_cost: DollarDecimal = Field( validation_alias=AliasChoices("maker_fill_cost_dollars", "maker_fill_cost"), ) - taker_fees: DollarDecimal | None = Field( - default=None, + taker_fees: DollarDecimal = Field( validation_alias=AliasChoices("taker_fees_dollars", "taker_fees"), ) - maker_fees: DollarDecimal | None = Field( - default=None, + maker_fees: DollarDecimal = Field( validation_alias=AliasChoices("maker_fees_dollars", "maker_fees"), ) - client_order_id: str | None = None - created_time: str | None = None + client_order_id: str + created_time: str order_group_id: str | None = None last_update_time: str | None = None expiration_time: str | None = None @@ -69,10 +61,10 @@ class UserOrdersPayload(BaseModel): # v0.14+ backfill (#162). outcome_side/book_side/self_trade_prevention_type # mirror Order from #159. *_ts_ms (Unix ms) supersede the *_time RFC3339 # siblings — both stay; do NOT auto-convert. - outcome_side: SideLiteral | None = None - book_side: BookSideLiteral | None = None + outcome_side: SideLiteral + book_side: BookSideLiteral self_trade_prevention_type: SelfTradePreventionTypeLiteral | None = None - created_ts_ms: int | None = None + created_ts_ms: int last_updated_ts_ms: int | None = None expiration_ts_ms: int | None = None model_config = {"extra": "allow"} diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 2e8ca44..524a238 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -39,6 +39,7 @@ "wire_normalization", "kwarg_rename", "client_only", + "server_omits_despite_required", ] @@ -83,6 +84,11 @@ class Exclusion: - ``client_only``: SDK kwarg with no wire counterpart — purely a client-side knob (e.g., ``max_pages`` cap on ``*_all`` paginators). Legitimately present in the signature; not drift. + - ``server_omits_despite_required``: spec marks the field + ``required: true`` but the live server omits it in practice. + Added in #172 for response-side fields that would otherwise force + the SDK to raise ``ValidationError`` on real responses. Each entry + MUST cite a demo+prod observation in ``reason``. """ reason: str diff --git a/tests/_model_fixtures.py b/tests/_model_fixtures.py new file mode 100644 index 0000000..9bbc976 --- /dev/null +++ b/tests/_model_fixtures.py @@ -0,0 +1,585 @@ +"""Builder helpers for response-model wire-shape dicts. + +After #172 dropped the ``None`` defaults on ~226 spec-required fields, raw +inline dicts like ``{"ticker": "MKT-A"}`` no longer parse into ``Market`` / +``Order`` / ``Fill`` etc. without a long list of placeholder fields. These +builders return complete spec-shaped dicts that pass strict validation, with +``**overrides`` for callers that want to customize specific fields. + +Each builder returns the **wire shape** (the JSON the server sends), not the +post-parse model. Pass the returned dict to ``Model.model_validate(...)``, +``respx``'s ``json=...``, ``RecordingTransport``, or test mock responses. + +Usage: + + from tests._model_fixtures import market_dict, order_dict + + body = {"markets": [market_dict(ticker="A"), market_dict(ticker="B")]} + json = order_dict(order_id="o1", yes_price_dollars="0.65") +""" + +from __future__ import annotations + +from typing import Any + +# --------------------------------------------------------------------------- +# REST response models +# --------------------------------------------------------------------------- + + +def market_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped Market response dict with all required fields populated.""" + base: dict[str, Any] = { + "ticker": "MKT-A", + "event_ticker": "EVT-A", + "market_type": "binary", + "yes_sub_title": "Yes", + "no_sub_title": "No", + "status": "open", + "yes_bid_dollars": "0.5000", + "yes_ask_dollars": "0.5100", + "no_bid_dollars": "0.4900", + "no_ask_dollars": "0.5000", + "last_price_dollars": "0.5000", + "previous_yes_bid_dollars": "0.5000", + "previous_yes_ask_dollars": "0.5100", + "previous_price_dollars": "0.5000", + "notional_value_dollars": "1.0000", + "liquidity_dollars": "100.0000", + "yes_bid_size_fp": "0.00", + "yes_ask_size_fp": "0.00", + "volume_fp": "0.00", + "volume_24h_fp": "0.00", + "open_interest_fp": "0.00", + "created_time": "2026-01-01T00:00:00Z", + "updated_time": "2026-01-01T00:00:00Z", + "open_time": "2026-01-01T00:00:00Z", + "close_time": "2026-12-31T23:59:59Z", + "latest_expiration_time": "2026-12-31T23:59:59Z", + "settlement_timer_seconds": 0, + "result": "", + "can_close_early": False, + "fractional_trading_enabled": False, + "expiration_value": "", + "rules_primary": "Rules.", + "rules_secondary": "", + "price_level_structure": "binary", + "price_ranges": [], + } + base.update(overrides) + return base + + +def order_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped Order response dict.""" + base: dict[str, Any] = { + "order_id": "ord-1", + "ticker": "MKT-A", + "user_id": "user-1", + "status": "resting", + "side": "yes", + "type": "limit", + "yes_price_dollars": "0.5000", + "no_price_dollars": "0.5000", + "initial_count_fp": "10.00", + "remaining_count_fp": "10.00", + "fill_count_fp": "0.00", + "taker_fill_cost_dollars": "0.0000", + "maker_fill_cost_dollars": "0.0000", + "taker_fees_dollars": "0.0000", + "maker_fees_dollars": "0.0000", + "client_order_id": "cli-1", + "outcome_side": "yes", + "book_side": "bid", + } + base.update(overrides) + return base + + +def fill_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped Fill response dict.""" + base: dict[str, Any] = { + "trade_id": "trd-1", + "fill_id": "fil-1", + "order_id": "ord-1", + "ticker": "MKT-A", + "market_ticker": "MKT-A", + "side": "yes", + "action": "buy", + "is_taker": True, + "count_fp": "10.00", + "yes_price_dollars": "0.5000", + "no_price_dollars": "0.5000", + "fee_cost_dollars": "0.0050", + "outcome_side": "yes", + "book_side": "bid", + } + base.update(overrides) + return base + + +def trade_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped Trade response dict (historical channel).""" + base: dict[str, Any] = { + "trade_id": "trd-1", + "ticker": "MKT-A", + "count_fp": "10.00", + "yes_price_dollars": "0.5000", + "no_price_dollars": "0.5000", + "created_time": "2026-01-01T00:00:00Z", + "taker_side": "yes", + "taker_book_side": "bid", + "taker_outcome_side": "yes", + } + base.update(overrides) + return base + + +def event_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped Event response dict.""" + base: dict[str, Any] = { + "event_ticker": "EVT-A", + "series_ticker": "SER-A", + "title": "Event Title", + "sub_title": "Event subtitle", + "mutually_exclusive": False, + "collateral_return_type": "self_collateralized", + "available_on_brokers": False, + "product_metadata": {}, + } + base.update(overrides) + return base + + +def event_metadata_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped EventMetadata dict.""" + base: dict[str, Any] = { + "image_url": "", + "market_details": [], + "settlement_sources": [], + } + base.update(overrides) + return base + + +def series_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped Series response dict.""" + base: dict[str, Any] = { + "ticker": "SER-A", + "frequency": "weekly", + "title": "Series title", + "category": "Politics", + "tags": [], + "contract_url": "", + "contract_terms_url": "", + "fee_type": "quadratic", + "fee_multiplier": 0.0, + "additional_prohibitions": [], + "settlement_sources": [], + } + base.update(overrides) + return base + + +def settlement_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped Settlement response dict.""" + base: dict[str, Any] = { + "ticker": "MKT-A", + "event_ticker": "EVT-A", + "yes_count_fp": "0.00", + "no_count_fp": "0.00", + "yes_total_cost_dollars": "0.0000", + "no_total_cost_dollars": "0.0000", + "revenue": 0, + "settled_time": "2026-01-01T00:00:00Z", + "fee_cost_dollars": "0.0000", + "market_result": "no", + } + base.update(overrides) + return base + + +def market_position_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped MarketPosition response dict.""" + base: dict[str, Any] = { + "ticker": "MKT-A", + "total_traded_dollars": "0.0000", + "position_fp": "0.00", + "market_exposure_dollars": "0.0000", + "realized_pnl_dollars": "0.0000", + "resting_orders_count": 0, + "fees_paid_dollars": "0.0000", + "last_updated_ts": "2026-01-01T00:00:00Z", + } + base.update(overrides) + return base + + +def event_position_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped EventPosition response dict.""" + base: dict[str, Any] = { + "event_ticker": "EVT-A", + "event_exposure_dollars": "0.0000", + "total_cost_dollars": "0.0000", + "realized_pnl_dollars": "0.0000", + "total_cost_shares_fp": "0.00", + "fees_paid_dollars": "0.0000", + } + base.update(overrides) + return base + + +def multivariate_event_collection_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped MultivariateEventCollection response dict.""" + base: dict[str, Any] = { + "collection_ticker": "COLL-A", + "series_ticker": "SER-A", + "title": "Collection title", + "description": "Description.", + "open_date": "2026-01-01T00:00:00Z", + "close_date": "2026-12-31T23:59:59Z", + "associated_events": [], + "associated_event_tickers": [], + "is_single_market_per_event": False, + "is_all_yes": False, + "is_ordered": False, + "size_min": 0, + "size_max": 0, + "functional_description": "", + } + base.update(overrides) + return base + + +def milestone_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped Milestone response dict.""" + base: dict[str, Any] = { + "id": "mile-1", + "category": "sports", + "type": "football_game", + "start_date": "2026-01-01T00:00:00Z", + "title": "Milestone title", + "notification_message": "", + "related_event_tickers": [], + "details": {}, + "primary_event_tickers": [], + "last_updated_ts": "2026-01-01T00:00:00Z", + } + base.update(overrides) + return base + + +def incentive_program_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped IncentiveProgram response dict.""" + base: dict[str, Any] = { + "id": "ip-1", + "market_id": "mkt-1", + "market_ticker": "MKT-A", + "incentive_type": "liquidity", + "start_date": "2026-01-01T00:00:00Z", + "end_date": "2026-12-31T23:59:59Z", + "period_reward": 0, + "paid_out": False, + "incentive_description": "", + } + base.update(overrides) + return base + + +def api_key_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped ApiKey response dict.""" + base: dict[str, Any] = { + "api_key_id": "key-1", + "name": "My Key", + "scopes": ["read"], + } + base.update(overrides) + return base + + +def get_order_group_response_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped GetOrderGroupResponse dict.""" + base: dict[str, Any] = { + "is_auto_cancel_enabled": False, + "orders": [], + } + base.update(overrides) + return base + + +def create_order_group_response_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped CreateOrderGroupResponse dict.""" + base: dict[str, Any] = { + "order_group_id": "og-1", + "subaccount": 0, + } + base.update(overrides) + return base + + +def sport_filter_details_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped SportFilterDetails dict.""" + base: dict[str, Any] = { + "competitions": {}, + "scopes": [], + } + base.update(overrides) + return base + + +def scope_list_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped ScopeList dict.""" + base: dict[str, Any] = { + "scopes": [], + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# Request bodies +# --------------------------------------------------------------------------- + + +def create_order_request_kwargs(**overrides: Any) -> dict[str, Any]: + """Kwargs for ``CreateOrderRequest(**)`` with all required fields. + + Pre-#172 ``action`` defaulted to ``"buy"``; tests now must pass it. + """ + base: dict[str, Any] = { + "ticker": "MKT-A", + "side": "yes", + "action": "buy", + } + base.update(overrides) + return base + + +# --------------------------------------------------------------------------- +# WebSocket payloads +# --------------------------------------------------------------------------- + + +def user_orders_payload_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped UserOrdersPayload msg dict.""" + base: dict[str, Any] = { + "order_id": "ord-1", + "user_id": "user-1", + "ticker": "MKT-A", + "status": "resting", + "side": "yes", + "is_yes": True, + "yes_price_dollars": "0.5000", + "initial_count_fp": "10.00", + "remaining_count_fp": "10.00", + "fill_count_fp": "0.00", + "taker_fill_cost_dollars": "0.0000", + "maker_fill_cost_dollars": "0.0000", + "taker_fees_dollars": "0.0000", + "maker_fees_dollars": "0.0000", + "client_order_id": "cli-1", + "created_time": "2026-01-01T00:00:00Z", + "outcome_side": "yes", + "book_side": "bid", + "created_ts_ms": 1735689600000, + } + base.update(overrides) + return base + + +def fill_payload_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped FillPayload msg dict.""" + base: dict[str, Any] = { + "trade_id": "trd-1", + "order_id": "ord-1", + "market_ticker": "MKT-A", + "is_taker": True, + "side": "yes", + "action": "buy", + "count_fp": "10.00", + "yes_price_dollars": "0.5000", + "fee_cost": "0.0000", + "ts": 1735689600, + "ts_ms": 1735689600000, + "post_position_fp": "10.00", + "outcome_side": "yes", + "book_side": "bid", + "purchased_side": "yes", + } + base.update(overrides) + return base + + +def ticker_payload_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped TickerPayload msg dict.""" + base: dict[str, Any] = { + "market_ticker": "MKT-A", + "market_id": "mkt-1", + "yes_bid_dollars": "0.5000", + "yes_ask_dollars": "0.5100", + "yes_bid_size_fp": "100.00", + "yes_ask_size_fp": "100.00", + "volume_fp": "0.00", + "dollar_volume": "0", + "open_interest_fp": "0.00", + "dollar_open_interest": "0", + "last_trade_size_fp": "0.00", + "price_dollars": "0.5000", + "ts": 1735689600, + "ts_ms": 1735689600000, + } + base.update(overrides) + return base + + +def trade_payload_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped TradePayload msg dict.""" + base: dict[str, Any] = { + "trade_id": "trd-1", + "market_ticker": "MKT-A", + "count_fp": "10.00", + "yes_price_dollars": "0.5000", + "no_price_dollars": "0.5000", + "taker_side": "yes", + "taker_book_side": "bid", + "taker_outcome_side": "yes", + "ts": 1735689600, + "ts_ms": 1735689600000, + } + base.update(overrides) + return base + + +def market_positions_payload_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped MarketPositionsPayload msg dict.""" + base: dict[str, Any] = { + "user_id": "user-1", + "market_ticker": "MKT-A", + "position_fp": "0.00", + "volume_fp": "0.00", + "realized_pnl_dollars": "0.0000", + "fees_paid_dollars": "0.0000", + "position_cost_dollars": "0.0000", + "position_fee_cost_dollars": "0.0000", + } + base.update(overrides) + return base + + +def rfq_created_payload_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped RfqCreatedPayload msg dict.""" + base: dict[str, Any] = { + "id": "rfq-1", + "creator_id": "user-1", + "market_ticker": "MKT-A", + "created_ts": "2026-01-01T00:00:00Z", + } + base.update(overrides) + return base + + +def rfq_deleted_payload_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped RfqDeletedPayload msg dict.""" + base: dict[str, Any] = { + "id": "rfq-1", + "creator_id": "user-1", + "market_ticker": "MKT-A", + "deleted_ts": "2026-01-01T00:00:00Z", + } + base.update(overrides) + return base + + +def quote_created_payload_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped QuoteCreatedPayload msg dict.""" + base: dict[str, Any] = { + "quote_id": "q-1", + "rfq_id": "rfq-1", + "quote_creator_id": "user-1", + "market_ticker": "MKT-A", + "yes_bid_dollars": "0.5000", + "no_bid_dollars": "0.5000", + "created_ts": "2026-01-01T00:00:00Z", + } + base.update(overrides) + return base + + +def quote_accepted_payload_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped QuoteAcceptedPayload msg dict.""" + base: dict[str, Any] = { + "quote_id": "q-1", + "rfq_id": "rfq-1", + "quote_creator_id": "user-1", + "market_ticker": "MKT-A", + "yes_bid_dollars": "0.5000", + "no_bid_dollars": "0.5000", + } + base.update(overrides) + return base + + +def quote_executed_payload_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped QuoteExecutedPayload msg dict.""" + base: dict[str, Any] = { + "quote_id": "q-1", + "rfq_id": "rfq-1", + "quote_creator_id": "user-1", + "rfq_creator_id": "user-2", + "order_id": "ord-1", + "client_order_id": "cli-1", + "market_ticker": "MKT-A", + "executed_ts": "2026-01-01T00:00:00Z", + } + base.update(overrides) + return base + + +def multivariate_payload_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped MultivariatePayload msg dict.""" + base: dict[str, Any] = { + "collection_ticker": "COLL-A", + "selected_markets": [], + "market_ticker": "MKT-A", + "event_ticker": "EVT-A", + } + base.update(overrides) + return base + + +def market_lifecycle_payload_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped MarketLifecyclePayload msg dict. + + `event_type` and `market_ticker` are required; all per-event_type + conditional fields are left out (override per test). + """ + base: dict[str, Any] = { + "event_type": "created", + "market_ticker": "MKT-A", + } + base.update(overrides) + return base + + +def order_group_payload_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped OrderGroupPayload msg dict.""" + base: dict[str, Any] = { + "event_type": "created", + "order_group_id": "og-1", + "ts_ms": 1735689600000, + } + base.update(overrides) + return base + + +def series_fee_change_dict(**overrides: Any) -> dict[str, Any]: + """Spec-shaped SeriesFeeChange response dict.""" + base: dict[str, Any] = { + "id": "sfc-1", + "series_ticker": "SER-A", + "fee_type": "quadratic", + "fee_multiplier": 0.0, + "scheduled_ts": "2026-01-01T00:00:00Z", + } + base.update(overrides) + return base diff --git a/tests/test_async_markets.py b/tests/test_async_markets.py index c51a419..9cefbfd 100644 --- a/tests/test_async_markets.py +++ b/tests/test_async_markets.py @@ -13,6 +13,7 @@ from kalshi.config import KalshiConfig from kalshi.errors import AuthRequiredError, KalshiNotFoundError from kalshi.resources.markets import AsyncMarketsResource +from tests._model_fixtures import market_dict @pytest.fixture @@ -25,35 +26,21 @@ def config() -> KalshiConfig: @pytest.fixture -def markets( - test_auth: KalshiAuth, config: KalshiConfig -) -> AsyncMarketsResource: +def markets(test_auth: KalshiAuth, config: KalshiConfig) -> AsyncMarketsResource: return AsyncMarketsResource(AsyncTransport(test_auth, config)) class TestAsyncMarketsList: @respx.mock @pytest.mark.asyncio - async def test_returns_page_of_markets( - self, markets: AsyncMarketsResource - ) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/markets" - ).mock( + async def test_returns_page_of_markets(self, markets: AsyncMarketsResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( return_value=httpx.Response( 200, json={ "markets": [ - { - "ticker": "MKT-A", - "title": "Market A", - "yes_bid_dollars": "0.4500", - }, - { - "ticker": "MKT-B", - "title": "Market B", - "yes_bid_dollars": "0.6000", - }, + market_dict(ticker="MKT-A", title="Market A", yes_bid_dollars="0.4500"), + market_dict(ticker="MKT-B", title="Market B", yes_bid_dollars="0.6000"), ], "cursor": "page2", }, @@ -68,32 +55,22 @@ async def test_returns_page_of_markets( @respx.mock @pytest.mark.asyncio - async def test_with_status_filter( - self, markets: AsyncMarketsResource - ) -> None: - route = respx.get( - "https://test.kalshi.com/trade-api/v2/markets" - ).mock( - return_value=httpx.Response( - 200, json={"markets": [], "cursor": None} - ) + async def test_with_status_filter(self, markets: AsyncMarketsResource) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( + return_value=httpx.Response(200, json={"markets": [], "cursor": None}) ) await markets.list(status="open") assert route.calls[0].request.url.params["status"] == "open" @pytest.mark.asyncio - async def test_market_type_kwarg_removed( - self, markets: AsyncMarketsResource - ) -> None: + async def test_market_type_kwarg_removed(self, markets: AsyncMarketsResource) -> None: """Regression: v0.7.0 dropped phantom market_type kwarg (not in spec).""" with pytest.raises(TypeError, match="market_type"): await markets.list(market_type="binary") # type: ignore[call-arg] @respx.mock @pytest.mark.asyncio - async def test_list_with_all_new_filters( - self, markets: AsyncMarketsResource - ) -> None: + async def test_list_with_all_new_filters(self, markets: AsyncMarketsResource) -> None: """v0.7.0 ADDs: tickers, mve_filter, 7 *_ts filters.""" route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( return_value=httpx.Response(200, json={"markets": [], "cursor": None}) @@ -158,15 +135,9 @@ async def test_tickers_serialized_as_comma_join_string( @respx.mock @pytest.mark.asyncio - async def test_empty_result( - self, markets: AsyncMarketsResource - ) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/markets" - ).mock( - return_value=httpx.Response( - 200, json={"markets": []} - ) + async def test_empty_result(self, markets: AsyncMarketsResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( + return_value=httpx.Response(200, json={"markets": []}) ) page = await markets.list() assert len(page) == 0 @@ -176,19 +147,15 @@ async def test_empty_result( class TestAsyncMarketsListAll: @respx.mock @pytest.mark.asyncio - async def test_auto_paginates( - self, markets: AsyncMarketsResource - ) -> None: - route = respx.get( - "https://test.kalshi.com/trade-api/v2/markets" - ).mock( + async def test_auto_paginates(self, markets: AsyncMarketsResource) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( side_effect=[ httpx.Response( 200, json={ "markets": [ - {"ticker": "A"}, - {"ticker": "B"}, + market_dict(ticker="A"), + market_dict(ticker="B"), ], "cursor": "page2", }, @@ -196,7 +163,7 @@ async def test_auto_paginates( httpx.Response( 200, json={ - "markets": [{"ticker": "C"}], + "markets": [market_dict(ticker="C")], "cursor": None, }, ), @@ -208,21 +175,24 @@ async def test_auto_paginates( @respx.mock @pytest.mark.asyncio - async def test_list_all_with_all_new_filters( - self, markets: AsyncMarketsResource - ) -> None: + async def test_list_all_with_all_new_filters(self, markets: AsyncMarketsResource) -> None: """v0.7.0 ADDs on list_all match list (no cursor).""" route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( - return_value=httpx.Response(200, json={"markets": [{"ticker": "A"}], "cursor": ""}) + return_value=httpx.Response( + 200, json={"markets": [market_dict(ticker="A")], "cursor": ""} + ) ) - _ = [m async for m in markets.list_all( - status="open", - tickers=["MKT-A", "MKT-B"], - mve_filter="some_filter", - min_created_ts=1000, - max_close_ts=4000, - limit=50, - )] + _ = [ + m + async for m in markets.list_all( + status="open", + tickers=["MKT-A", "MKT-B"], + mve_filter="some_filter", + min_created_ts=1000, + max_close_ts=4000, + limit=50, + ) + ] params = dict(route.calls[0].request.url.params) assert params["status"] == "open" assert params["tickers"] == "MKT-A,MKT-B" @@ -235,20 +205,14 @@ async def test_list_all_with_all_new_filters( class TestAsyncMarketsGet: @respx.mock @pytest.mark.asyncio - async def test_returns_market( - self, markets: AsyncMarketsResource - ) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/markets/TEST-MKT" - ).mock( + async def test_returns_market(self, markets: AsyncMarketsResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/markets/TEST-MKT").mock( return_value=httpx.Response( 200, json={ - "market": { - "ticker": "TEST-MKT", - "title": "Test Market", - "yes_ask_dollars": "0.7200", - } + "market": market_dict( + ticker="TEST-MKT", title="Test Market", yes_ask_dollars="0.7200" + ), }, ) ) @@ -258,15 +222,9 @@ async def test_returns_market( @respx.mock @pytest.mark.asyncio - async def test_not_found( - self, markets: AsyncMarketsResource - ) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/markets/FAKE" - ).mock( - return_value=httpx.Response( - 404, json={"message": "market not found"} - ) + async def test_not_found(self, markets: AsyncMarketsResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/markets/FAKE").mock( + return_value=httpx.Response(404, json={"message": "market not found"}) ) with pytest.raises(KalshiNotFoundError): await markets.get("FAKE") @@ -275,12 +233,8 @@ async def test_not_found( class TestAsyncMarketsOrderbook: @respx.mock @pytest.mark.asyncio - async def test_returns_orderbook( - self, markets: AsyncMarketsResource - ) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/markets/TEST-MKT/orderbook" - ).mock( + async def test_returns_orderbook(self, markets: AsyncMarketsResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/markets/TEST-MKT/orderbook").mock( return_value=httpx.Response( 200, json={ @@ -303,13 +257,9 @@ async def test_returns_orderbook( @respx.mock @pytest.mark.asyncio - async def test_orderbook_with_depth( - self, markets: AsyncMarketsResource - ) -> None: + async def test_orderbook_with_depth(self, markets: AsyncMarketsResource) -> None: """v0.7.0 ADD: depth kwarg reaches the wire.""" - route = respx.get( - "https://test.kalshi.com/trade-api/v2/markets/TEST-MKT/orderbook" - ).mock( + route = respx.get("https://test.kalshi.com/trade-api/v2/markets/TEST-MKT/orderbook").mock( return_value=httpx.Response( 200, json={"orderbook_fp": {"yes_dollars": [], "no_dollars": []}} ) @@ -327,13 +277,8 @@ async def test_orderbook_requires_auth(self, config: KalshiConfig) -> None: class TestAsyncMarketsCandlesticks: @respx.mock @pytest.mark.asyncio - async def test_returns_nested_candlesticks( - self, markets: AsyncMarketsResource - ) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2" - "/series/SER/markets/MKT/candlesticks" - ).mock( + async def test_returns_nested_candlesticks(self, markets: AsyncMarketsResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/series/SER/markets/MKT/candlesticks").mock( return_value=httpx.Response( 200, json={ @@ -399,9 +344,7 @@ async def test_candlesticks_with_include_latest_before_start_true( @respx.mock @pytest.mark.asyncio - async def test_candlesticks_sends_explicit_false( - self, markets: AsyncMarketsResource - ) -> None: + async def test_candlesticks_sends_explicit_false(self, markets: AsyncMarketsResource) -> None: """Tri-state bool: False must send 'false' (opt-out survives server default flips).""" route = respx.get( "https://test.kalshi.com/trade-api/v2/series/SER/markets/MKT/candlesticks" @@ -432,9 +375,7 @@ async def test_candlesticks_omits_include_latest_when_none( end_ts=1700100000, period_interval=60, ) - assert "include_latest_before_start" not in dict( - route.calls[0].request.url.params - ) + assert "include_latest_before_start" not in dict(route.calls[0].request.url.params) class TestAsyncMarketsBulkCandlesticksValidation: @@ -442,37 +383,46 @@ class TestAsyncMarketsBulkCandlesticksValidation: @pytest.mark.asyncio async def test_rejects_over_100_list( - self, markets: AsyncMarketsResource, + self, + markets: AsyncMarketsResource, ) -> None: with pytest.raises(ValueError, match="at most 100"): await markets.bulk_candlesticks( market_tickers=[f"MKT-{i}" for i in range(101)], - start_ts=1, end_ts=2, period_interval=60, + start_ts=1, + end_ts=2, + period_interval=60, ) @pytest.mark.asyncio async def test_rejects_over_100_string( - self, markets: AsyncMarketsResource, + self, + markets: AsyncMarketsResource, ) -> None: """Regression: the split+filter counter must apply to the async path too.""" joined = ",".join(f"MKT-{i}" for i in range(101)) with pytest.raises(ValueError, match="at most 100"): await markets.bulk_candlesticks( market_tickers=joined, - start_ts=1, end_ts=2, period_interval=60, + start_ts=1, + end_ts=2, + period_interval=60, ) @respx.mock @pytest.mark.asyncio async def test_trailing_commas_do_not_inflate_count( - self, markets: AsyncMarketsResource, + self, + markets: AsyncMarketsResource, ) -> None: - """"A,B,," counts as 2 real tickers, not 4 — must not spuriously fail.""" + """ "A,B,," counts as 2 real tickers, not 4 — must not spuriously fail.""" respx.get( "https://test.kalshi.com/trade-api/v2/markets/candlesticks", ).mock(return_value=httpx.Response(200, json={"markets": []})) # Does not raise even with trailing commas. await markets.bulk_candlesticks( market_tickers="A,B,,", - start_ts=1, end_ts=2, period_interval=60, + start_ts=1, + end_ts=2, + period_interval=60, ) diff --git a/tests/test_async_orders.py b/tests/test_async_orders.py index d0a648c..c322f7c 100644 --- a/tests/test_async_orders.py +++ b/tests/test_async_orders.py @@ -31,6 +31,7 @@ DecreaseOrderV2Request, ) from kalshi.resources.orders import AsyncOrdersResource +from tests._model_fixtures import fill_dict, order_dict @pytest.fixture @@ -43,9 +44,7 @@ def config() -> KalshiConfig: @pytest.fixture -def orders( - test_auth: KalshiAuth, config: KalshiConfig -) -> AsyncOrdersResource: +def orders(test_auth: KalshiAuth, config: KalshiConfig) -> AsyncOrdersResource: return AsyncOrdersResource(AsyncTransport(test_auth, config)) @@ -58,16 +57,12 @@ def unauth_orders_async(config: KalshiConfig) -> AsyncOrdersResource: def client(test_auth: KalshiAuth) -> AsyncKalshiClient: """AsyncKalshiClient wired to the demo base URL (matches wire-shape test mocks).""" from kalshi.config import DEMO_BASE_URL + cfg = KalshiConfig(base_url=DEMO_BASE_URL, timeout=5.0, max_retries=0) return AsyncKalshiClient(auth=test_auth, config=cfg) -_MINIMAL_ORDER = { - "order_id": "ord-123", - "ticker": "MKT", - "side": "yes", - "status": "resting", -} +_MINIMAL_ORDER = order_dict(order_id="ord-123", ticker="MKT", side="yes", status="resting") class TestCreateOrderWireShapeAsync: @@ -78,11 +73,13 @@ class TestCreateOrderWireShapeAsync: @respx.mock @pytest.mark.asyncio async def test_no_phantom_type_in_wire( - self, client: AsyncKalshiClient, respx_mock: respx.MockRouter, + self, + client: AsyncKalshiClient, + respx_mock: respx.MockRouter, ) -> None: - route = respx_mock.post( - "https://demo-api.kalshi.co/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER})) + route = respx_mock.post("https://demo-api.kalshi.co/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}) + ) await client.orders.create(ticker="MKT", side="yes", yes_price=0.5) @@ -92,11 +89,13 @@ async def test_no_phantom_type_in_wire( @respx.mock @pytest.mark.asyncio async def test_count_fp_not_count_in_wire( - self, client: AsyncKalshiClient, respx_mock: respx.MockRouter, + self, + client: AsyncKalshiClient, + respx_mock: respx.MockRouter, ) -> None: - route = respx_mock.post( - "https://demo-api.kalshi.co/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER})) + route = respx_mock.post("https://demo-api.kalshi.co/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}) + ) await client.orders.create(ticker="MKT", side="yes", yes_price=0.5, count=3) @@ -108,14 +107,18 @@ async def test_count_fp_not_count_in_wire( @respx.mock @pytest.mark.asyncio async def test_time_in_force_reaches_wire( - self, client: AsyncKalshiClient, respx_mock: respx.MockRouter, + self, + client: AsyncKalshiClient, + respx_mock: respx.MockRouter, ) -> None: - route = respx_mock.post( - "https://demo-api.kalshi.co/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER})) + route = respx_mock.post("https://demo-api.kalshi.co/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}) + ) await client.orders.create( - ticker="MKT", side="yes", yes_price=0.5, + ticker="MKT", + side="yes", + yes_price=0.5, time_in_force="fill_or_kill", ) @@ -125,15 +128,20 @@ async def test_time_in_force_reaches_wire( @respx.mock @pytest.mark.asyncio async def test_post_only_reduce_only_reach_wire( - self, client: AsyncKalshiClient, respx_mock: respx.MockRouter, + self, + client: AsyncKalshiClient, + respx_mock: respx.MockRouter, ) -> None: - route = respx_mock.post( - "https://demo-api.kalshi.co/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER})) + route = respx_mock.post("https://demo-api.kalshi.co/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}) + ) await client.orders.create( - ticker="MKT", side="yes", yes_price=0.5, - post_only=True, reduce_only=False, + ticker="MKT", + side="yes", + yes_price=0.5, + post_only=True, + reduce_only=False, ) body = json.loads(route.calls[0].request.content) @@ -143,15 +151,20 @@ async def test_post_only_reduce_only_reach_wire( @respx.mock @pytest.mark.asyncio async def test_buy_max_cost_int_cents_wire( - self, client: AsyncKalshiClient, respx_mock: respx.MockRouter, + self, + client: AsyncKalshiClient, + respx_mock: respx.MockRouter, ) -> None: """Spec says cents. SDK must send int on the wire.""" - route = respx_mock.post( - "https://demo-api.kalshi.co/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER})) + route = respx_mock.post("https://demo-api.kalshi.co/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}) + ) await client.orders.create( - ticker="MKT", side="yes", yes_price=0.5, buy_max_cost=500, + ticker="MKT", + side="yes", + yes_price=0.5, + buy_max_cost=500, ) body = json.loads(route.calls[0].request.content) @@ -161,15 +174,20 @@ async def test_buy_max_cost_int_cents_wire( @respx.mock @pytest.mark.asyncio async def test_subaccount_order_group_cancel_on_pause_stp_wire( - self, client: AsyncKalshiClient, respx_mock: respx.MockRouter, + self, + client: AsyncKalshiClient, + respx_mock: respx.MockRouter, ) -> None: - route = respx_mock.post( - "https://demo-api.kalshi.co/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER})) + route = respx_mock.post("https://demo-api.kalshi.co/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}) + ) await client.orders.create( - ticker="MKT", side="yes", yes_price=0.5, - subaccount=2, order_group_id="grp-x", + ticker="MKT", + side="yes", + yes_price=0.5, + subaccount=2, + order_group_id="grp-x", cancel_order_on_pause=True, self_trade_prevention_type="maker", ) @@ -185,7 +203,8 @@ async def test_type_kwarg_removed(self, client: AsyncKalshiClient) -> None: """v0.8.0 removed the `type` kwarg from orders.create().""" with pytest.raises(TypeError): await client.orders.create( - ticker="MKT", side="yes", + ticker="MKT", + side="yes", type="market", # type: ignore[call-arg] ) @@ -193,49 +212,35 @@ async def test_type_kwarg_removed(self, client: AsyncKalshiClient) -> None: class TestAsyncOrdersCreate: @respx.mock @pytest.mark.asyncio - async def test_create_limit_order( - self, orders: AsyncOrdersResource - ) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock( + async def test_create_limit_order(self, orders: AsyncOrdersResource) -> None: + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( return_value=httpx.Response( 200, json={ - "order": { - "order_id": "ord-123", - "ticker": "TEST-MKT", - "side": "yes", - "status": "resting", - "yes_price_dollars": "0.6500", - "count": 10, - } + "order": order_dict( + order_id="ord-123", + ticker="TEST-MKT", + side="yes", + status="resting", + yes_price_dollars="0.6500", + count_fp="10", + ) }, ) ) - order = await orders.create( - ticker="TEST-MKT", side="yes", count=10, yes_price=0.65 - ) + order = await orders.create(ticker="TEST-MKT", side="yes", count=10, yes_price=0.65) assert order.order_id == "ord-123" assert order.yes_price == Decimal("0.6500") assert order.count == 10 @respx.mock @pytest.mark.asyncio - async def test_create_order_no_price( - self, orders: AsyncOrdersResource - ) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock( + async def test_create_order_no_price(self, orders: AsyncOrdersResource) -> None: + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( return_value=httpx.Response( 200, json={ - "order": { - "order_id": "ord-456", - "ticker": "TEST-MKT", - "status": "executed", - } + "order": order_dict(order_id="ord-456", ticker="TEST-MKT", status="executed") }, ) ) @@ -244,17 +249,11 @@ async def test_create_order_no_price( @respx.mock @pytest.mark.asyncio - async def test_decimal_price_conversion( - self, orders: AsyncOrdersResource - ) -> None: - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock( + async def test_decimal_price_conversion(self, orders: AsyncOrdersResource) -> None: + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( return_value=httpx.Response( 200, - json={ - "order": {"order_id": "ord-789", "ticker": "T"} - }, + json={"order": order_dict(order_id="ord-789", ticker="T")}, ) ) await orders.create(ticker="T", side="yes", yes_price=0.65) @@ -265,15 +264,9 @@ async def test_decimal_price_conversion( @respx.mock @pytest.mark.asyncio - async def test_validation_error( - self, orders: AsyncOrdersResource - ) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock( - return_value=httpx.Response( - 400, json={"message": "invalid ticker"} - ) + async def test_validation_error(self, orders: AsyncOrdersResource) -> None: + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(400, json={"message": "invalid ticker"}) ) with pytest.raises(KalshiValidationError): await orders.create(ticker="INVALID", side="yes") @@ -282,21 +275,11 @@ async def test_validation_error( class TestAsyncOrdersGet: @respx.mock @pytest.mark.asyncio - async def test_returns_order( - self, orders: AsyncOrdersResource - ) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-123" - ).mock( + async def test_returns_order(self, orders: AsyncOrdersResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-123").mock( return_value=httpx.Response( 200, - json={ - "order": { - "order_id": "ord-123", - "ticker": "MKT", - "status": "resting", - } - }, + json={"order": order_dict(order_id="ord-123", ticker="MKT", status="resting")}, ) ) order = await orders.get("ord-123") @@ -305,15 +288,9 @@ async def test_returns_order( @respx.mock @pytest.mark.asyncio - async def test_not_found( - self, orders: AsyncOrdersResource - ) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/fake" - ).mock( - return_value=httpx.Response( - 404, json={"message": "order not found"} - ) + async def test_not_found(self, orders: AsyncOrdersResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders/fake").mock( + return_value=httpx.Response(404, json={"message": "order not found"}) ) with pytest.raises(KalshiNotFoundError): await orders.get("fake") @@ -322,22 +299,18 @@ async def test_not_found( class TestAsyncOrdersCancel: @respx.mock @pytest.mark.asyncio - async def test_cancel_order( - self, orders: AsyncOrdersResource - ) -> None: - respx.delete( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-123" - ).mock(return_value=httpx.Response(200, json={})) + async def test_cancel_order(self, orders: AsyncOrdersResource) -> None: + respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-123").mock( + return_value=httpx.Response(200, json={}) + ) await orders.cancel("ord-123") # should not raise @respx.mock @pytest.mark.asyncio - async def test_cancel_with_subaccount( - self, orders: AsyncOrdersResource - ) -> None: - route = respx.delete( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-456" - ).mock(return_value=httpx.Response(200, json={})) + async def test_cancel_with_subaccount(self, orders: AsyncOrdersResource) -> None: + route = respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-456").mock( + return_value=httpx.Response(200, json={}) + ) await orders.cancel("ord-456", subaccount=42) params = dict(route.calls[0].request.url.params) assert params["subaccount"] == "42" @@ -346,18 +319,14 @@ async def test_cancel_with_subaccount( class TestAsyncOrdersList: @respx.mock @pytest.mark.asyncio - async def test_returns_page( - self, orders: AsyncOrdersResource - ) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock( + async def test_returns_page(self, orders: AsyncOrdersResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( return_value=httpx.Response( 200, json={ "orders": [ - {"order_id": "ord-1", "ticker": "A"}, - {"order_id": "ord-2", "ticker": "B"}, + order_dict(order_id="ord-1", ticker="A"), + order_dict(order_id="ord-2", ticker="B"), ], "cursor": "next", }, @@ -370,15 +339,9 @@ async def test_returns_page( @respx.mock @pytest.mark.asyncio - async def test_with_filters( - self, orders: AsyncOrdersResource - ) -> None: - route = respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock( - return_value=httpx.Response( - 200, json={"orders": []} - ) + async def test_with_filters(self, orders: AsyncOrdersResource) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"orders": []}) ) await orders.list(status="resting", ticker="MKT-A") params = dict(route.calls[0].request.url.params) @@ -387,13 +350,11 @@ async def test_with_filters( @respx.mock @pytest.mark.asyncio - async def test_list_with_all_new_filters( - self, orders: AsyncOrdersResource - ) -> None: + async def test_list_with_all_new_filters(self, orders: AsyncOrdersResource) -> None: """Consolidated coverage for v0.7.0 ADDs: event_ticker, min_ts, max_ts, subaccount.""" - route = respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"orders": []})) + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"orders": []}) + ) await orders.list( ticker="MKT-A", event_ticker="EVT-X", @@ -416,26 +377,22 @@ async def test_list_with_all_new_filters( @respx.mock @pytest.mark.asyncio - async def test_empty_string_ticker_passes_through( - self, orders: AsyncOrdersResource - ) -> None: + async def test_empty_string_ticker_passes_through(self, orders: AsyncOrdersResource) -> None: """Regression: empty-string ticker reaches the wire after _params() standardization.""" - route = respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"orders": []})) + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"orders": []}) + ) await orders.list(ticker="") params = dict(route.calls[0].request.url.params) assert params["ticker"] == "" @respx.mock @pytest.mark.asyncio - async def test_empty_string_status_passes_through( - self, orders: AsyncOrdersResource - ) -> None: + async def test_empty_string_status_passes_through(self, orders: AsyncOrdersResource) -> None: """Regression: same fix as ticker — empty string status now reaches wire.""" - route = respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"orders": []})) + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"orders": []}) + ) await orders.list(status="") params = dict(route.calls[0].request.url.params) assert params["status"] == "" @@ -444,18 +401,14 @@ async def test_empty_string_status_passes_through( class TestAsyncOrdersListAll: @respx.mock @pytest.mark.asyncio - async def test_auto_paginates( - self, orders: AsyncOrdersResource - ) -> None: - route = respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock( + async def test_auto_paginates(self, orders: AsyncOrdersResource) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( side_effect=[ httpx.Response( 200, json={ "orders": [ - {"order_id": "o1", "ticker": "A"}, + order_dict(order_id="o1", ticker="A"), ], "cursor": "page2", }, @@ -464,42 +417,39 @@ async def test_auto_paginates( 200, json={ "orders": [ - {"order_id": "o2", "ticker": "B"}, + order_dict(order_id="o2", ticker="B"), ], "cursor": None, }, ), ] ) - order_ids = [ - o.order_id async for o in orders.list_all() - ] + order_ids = [o.order_id async for o in orders.list_all()] assert order_ids == ["o1", "o2"] assert route.call_count == 2 @respx.mock @pytest.mark.asyncio - async def test_list_all_with_all_new_filters( - self, orders: AsyncOrdersResource - ) -> None: + async def test_list_all_with_all_new_filters(self, orders: AsyncOrdersResource) -> None: """v0.7.0 ADDs on list_all: event_ticker, min_ts, max_ts, subaccount.""" - route = respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock( + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( return_value=httpx.Response( 200, - json={"orders": [{"order_id": "ord-x", "ticker": "MKT-A"}], "cursor": ""}, + json={"orders": [order_dict(order_id="ord-x", ticker="MKT-A")], "cursor": ""}, ) ) - _ = [o async for o in orders.list_all( - ticker="MKT-A", - event_ticker="EVT-X", - status="resting", - min_ts=1700000000, - max_ts=1700099999, - limit=50, - subaccount=7, - )] + _ = [ + o + async for o in orders.list_all( + ticker="MKT-A", + event_ticker="EVT-X", + status="resting", + min_ts=1700000000, + max_ts=1700099999, + limit=50, + subaccount=7, + ) + ] params = dict(route.calls[0].request.url.params) assert params["ticker"] == "MKT-A" assert params["event_ticker"] == "EVT-X" @@ -513,26 +463,21 @@ async def test_list_all_with_all_new_filters( class TestAsyncOrdersBatch: @respx.mock @pytest.mark.asyncio - async def test_batch_create( - self, orders: AsyncOrdersResource - ) -> None: - route = respx.post( - "https://test.kalshi.com/trade-api/v2" - "/portfolio/orders/batched" - ).mock( + async def test_batch_create(self, orders: AsyncOrdersResource) -> None: + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( return_value=httpx.Response( 200, json={ "orders": [ - {"order_id": "b1", "ticker": "A"}, - {"order_id": "b2", "ticker": "B"}, + order_dict(order_id="b1", ticker="A"), + order_dict(order_id="b2", ticker="B"), ] }, ) ) reqs = [ - CreateOrderRequest(ticker="A", side="yes"), - CreateOrderRequest(ticker="B", side="no"), + CreateOrderRequest(ticker="A", side="yes", action="buy"), + CreateOrderRequest(ticker="B", side="no", action="buy"), ] result = await orders.batch_create(reqs) assert len(result) == 2 @@ -545,35 +490,25 @@ async def test_batch_create( @respx.mock @pytest.mark.asyncio - async def test_batch_cancel( - self, orders: AsyncOrdersResource - ) -> None: - respx.delete( - "https://test.kalshi.com/trade-api/v2" - "/portfolio/orders/batched" - ).mock(return_value=httpx.Response(200, json={})) + async def test_batch_cancel(self, orders: AsyncOrdersResource) -> None: + respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(200, json={}) + ) await orders.batch_cancel(["o1", "o2"]) # should not raise class TestAsyncOrdersFills: @respx.mock @pytest.mark.asyncio - async def test_returns_fills( - self, orders: AsyncOrdersResource - ) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/fills" - ).mock( + async def test_returns_fills(self, orders: AsyncOrdersResource) -> None: + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/fills").mock( return_value=httpx.Response( 200, json={ "fills": [ - { - "trade_id": "t1", - "order_id": "o1", - "yes_price_dollars": "0.5000", - "count": 5, - } + fill_dict( + trade_id="t1", order_id="o1", yes_price_dollars="0.5000", count_fp="5" + ) ] }, ) @@ -585,15 +520,9 @@ async def test_returns_fills( @respx.mock @pytest.mark.asyncio - async def test_fills_with_filters( - self, orders: AsyncOrdersResource - ) -> None: - route = respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/fills" - ).mock( - return_value=httpx.Response( - 200, json={"fills": []} - ) + async def test_fills_with_filters(self, orders: AsyncOrdersResource) -> None: + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/fills").mock( + return_value=httpx.Response(200, json={"fills": []}) ) await orders.fills(ticker="MKT-A", order_id="ord-1") params = dict(route.calls[0].request.url.params) @@ -602,13 +531,11 @@ async def test_fills_with_filters( @respx.mock @pytest.mark.asyncio - async def test_fills_with_all_new_filters( - self, orders: AsyncOrdersResource - ) -> None: + async def test_fills_with_all_new_filters(self, orders: AsyncOrdersResource) -> None: """Consolidated coverage for v0.7.0 ADDs: min_ts, max_ts, subaccount.""" - route = respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/fills" - ).mock(return_value=httpx.Response(200, json={"fills": []})) + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/fills").mock( + return_value=httpx.Response(200, json={"fills": []}) + ) await orders.fills( ticker="MKT-A", order_id="ord-1", @@ -637,14 +564,14 @@ async def test_auto_paginates(self, orders: AsyncOrdersResource) -> None: httpx.Response( 200, json={ - "fills": [{"trade_id": "a", "yes_price_dollars": "0.50"}], + "fills": [fill_dict(trade_id="a", yes_price_dollars="0.50")], "cursor": "p2", }, ), httpx.Response( 200, json={ - "fills": [{"trade_id": "b", "yes_price_dollars": "0.60"}], + "fills": [fill_dict(trade_id="b", yes_price_dollars="0.60")], "cursor": "", }, ), @@ -655,26 +582,25 @@ async def test_auto_paginates(self, orders: AsyncOrdersResource) -> None: @respx.mock @pytest.mark.asyncio - async def test_fills_all_with_all_new_filters( - self, orders: AsyncOrdersResource - ) -> None: + async def test_fills_all_with_all_new_filters(self, orders: AsyncOrdersResource) -> None: """Consolidated coverage for v0.7.0 ADDs on fills_all: min_ts, max_ts, subaccount.""" - route = respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/fills" - ).mock( + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/fills").mock( return_value=httpx.Response( 200, - json={"fills": [{"trade_id": "x", "yes_price_dollars": "0.5"}], "cursor": ""}, + json={"fills": [fill_dict(trade_id="x", yes_price_dollars="0.5")], "cursor": ""}, ) ) - _ = [f async for f in orders.fills_all( - ticker="MKT-A", - order_id="ord-1", - min_ts=1700000000, - max_ts=1700099999, - limit=50, - subaccount=7, - )] + _ = [ + f + async for f in orders.fills_all( + ticker="MKT-A", + order_id="ord-1", + min_ts=1700000000, + max_ts=1700099999, + limit=50, + subaccount=7, + ) + ] params = dict(route.calls[0].request.url.params) assert params["ticker"] == "MKT-A" assert params["order_id"] == "ord-1" @@ -689,10 +615,15 @@ class TestAsyncOrdersAmend: @pytest.mark.asyncio async def test_amend_price(self, orders: AsyncOrdersResource) -> None: respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-123/amend").mock( - return_value=httpx.Response(200, json={ - "old_order": {"order_id": "ord-123", "ticker": "T", "yes_price_dollars": "0.5000"}, - "order": {"order_id": "ord-456", "ticker": "T", "yes_price_dollars": "0.6500"}, - }) + return_value=httpx.Response( + 200, + json={ + "old_order": order_dict( + order_id="ord-123", ticker="T", yes_price_dollars="0.5000" + ), + "order": order_dict(order_id="ord-456", ticker="T", yes_price_dollars="0.6500"), + }, + ) ) result = await orders.amend("ord-123", ticker="T", side="yes", action="buy", yes_price=0.65) assert isinstance(result, AmendOrderResponse) @@ -715,10 +646,13 @@ async def test_amend_serializes_dollars_and_count(self, orders: AsyncOrdersResou route = respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-123/amend" ).mock( - return_value=httpx.Response(200, json={ - "old_order": {"order_id": "ord-123", "ticker": "T"}, - "order": {"order_id": "ord-123", "ticker": "T"}, - }) + return_value=httpx.Response( + 200, + json={ + "old_order": order_dict(order_id="ord-123", ticker="T"), + "order": order_dict(order_id="ord-123", ticker="T"), + }, + ) ) await orders.amend( "ord-123", ticker="T", side="yes", action="buy", yes_price=0.55, count=20 @@ -732,9 +666,9 @@ async def test_amend_serializes_dollars_and_count(self, orders: AsyncOrdersResou @respx.mock @pytest.mark.asyncio async def test_amend_validation_error(self, orders: AsyncOrdersResource) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-123/amend" - ).mock(return_value=httpx.Response(400, json={"message": "invalid"})) + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-123/amend").mock( + return_value=httpx.Response(400, json={"message": "invalid"}) + ) with pytest.raises(KalshiValidationError): await orders.amend("ord-123", ticker="T", side="bad", action="buy", yes_price=0.50) @@ -751,7 +685,7 @@ async def test_decrease_by(self, orders: AsyncOrdersResource) -> None: respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-123/decrease").mock( return_value=httpx.Response( 200, - json={"order": {"order_id": "ord-123", "remaining_count_fp": "5"}}, + json={"order": order_dict(order_id="ord-123", remaining_count_fp="5")}, ) ) order = await orders.decrease("ord-123", reduce_by=5) @@ -769,12 +703,10 @@ async def test_decrease_validation_error(self, orders: AsyncOrdersResource) -> N @respx.mock @pytest.mark.asyncio async def test_decrease_to(self, orders: AsyncOrdersResource) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-123/decrease" - ).mock( + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-123/decrease").mock( return_value=httpx.Response( 200, - json={"order": {"order_id": "ord-123", "remaining_count_fp": "0"}}, + json={"order": order_dict(order_id="ord-123", remaining_count_fp="0")}, ) ) order = await orders.decrease("ord-123", reduce_to=0) @@ -783,9 +715,9 @@ async def test_decrease_to(self, orders: AsyncOrdersResource) -> None: @respx.mock @pytest.mark.asyncio async def test_decrease_not_found(self, orders: AsyncOrdersResource) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/fake/decrease" - ).mock(return_value=httpx.Response(404, json={"message": "not found"})) + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/fake/decrease").mock( + return_value=httpx.Response(404, json={"message": "not found"}) + ) with pytest.raises(KalshiNotFoundError): await orders.decrease("fake", reduce_by=1) @@ -805,12 +737,20 @@ class TestAsyncOrdersQueuePositions: @pytest.mark.asyncio async def test_queue_positions(self, orders: AsyncOrdersResource) -> None: from kalshi.models.orders import OrderQueuePosition + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders/queue_positions").mock( - return_value=httpx.Response(200, json={ - "queue_positions": [ - {"order_id": "ord-1", "market_ticker": "MKT-A", "queue_position_fp": "42.00"}, - ], - }) + return_value=httpx.Response( + 200, + json={ + "queue_positions": [ + { + "order_id": "ord-1", + "market_ticker": "MKT-A", + "queue_position_fp": "42.00", + }, + ], + }, + ) ) positions = await orders.queue_positions() assert len(positions) == 1 @@ -819,9 +759,9 @@ async def test_queue_positions(self, orders: AsyncOrdersResource) -> None: @respx.mock @pytest.mark.asyncio async def test_queue_position_single(self, orders: AsyncOrdersResource) -> None: - respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-123/queue_position").mock( - return_value=httpx.Response(200, json={"queue_position_fp": "15.00"}) - ) + respx.get( + "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-123/queue_position" + ).mock(return_value=httpx.Response(200, json={"queue_position_fp": "15.00"})) position = await orders.queue_position("ord-123") assert position == Decimal("15.00") @@ -847,9 +787,9 @@ async def test_queue_position_fallback_key(self, orders: AsyncOrdersResource) -> @respx.mock @pytest.mark.asyncio async def test_queue_position_not_found(self, orders: AsyncOrdersResource) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/fake/queue_position" - ).mock(return_value=httpx.Response(404, json={"message": "not found"})) + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders/fake/queue_position").mock( + return_value=httpx.Response(404, json={"message": "not found"}) + ) with pytest.raises(KalshiNotFoundError): await orders.queue_position("fake") @@ -869,28 +809,34 @@ class TestAsyncOrdersAuthGuards: @pytest.mark.asyncio async def test_amend_requires_auth(self, unauth_orders_async: AsyncOrdersResource) -> None: from kalshi.errors import AuthRequiredError + with pytest.raises(AuthRequiredError): await unauth_orders_async.amend("ord-123", ticker="T", side="yes", action="buy") @pytest.mark.asyncio async def test_decrease_requires_auth(self, unauth_orders_async: AsyncOrdersResource) -> None: from kalshi.errors import AuthRequiredError + with pytest.raises(AuthRequiredError): await unauth_orders_async.decrease("ord-123", reduce_by=1) @pytest.mark.asyncio async def test_queue_positions_requires_auth( - self, unauth_orders_async: AsyncOrdersResource, + self, + unauth_orders_async: AsyncOrdersResource, ) -> None: from kalshi.errors import AuthRequiredError + with pytest.raises(AuthRequiredError): await unauth_orders_async.queue_positions() @pytest.mark.asyncio async def test_queue_position_requires_auth( - self, unauth_orders_async: AsyncOrdersResource, + self, + unauth_orders_async: AsyncOrdersResource, ) -> None: from kalshi.errors import AuthRequiredError + with pytest.raises(AuthRequiredError): await unauth_orders_async.queue_position("ord-123") @@ -898,12 +844,10 @@ async def test_queue_position_requires_auth( class TestBatchCancelWireShapeAsync: @respx.mock @pytest.mark.asyncio - async def test_wraps_str_ids_into_orders( - self, orders: AsyncOrdersResource - ) -> None: - route = respx.delete( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/batched" - ).mock(return_value=httpx.Response(200, json={})) + async def test_wraps_str_ids_into_orders(self, orders: AsyncOrdersResource) -> None: + route = respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(200, json={}) + ) await orders.batch_cancel(["ord-1", "ord-2"]) @@ -917,19 +861,19 @@ async def test_wraps_str_ids_into_orders( @respx.mock @pytest.mark.asyncio - async def test_accepts_typed_order_entries( - self, orders: AsyncOrdersResource - ) -> None: + async def test_accepts_typed_order_entries(self, orders: AsyncOrdersResource) -> None: from kalshi.models.orders import BatchCancelOrdersRequestOrder - route = respx.delete( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/batched" - ).mock(return_value=httpx.Response(200, json={})) + route = respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(200, json={}) + ) - await orders.batch_cancel([ - BatchCancelOrdersRequestOrder(order_id="ord-1", subaccount=5), - BatchCancelOrdersRequestOrder(order_id="ord-2"), - ]) + await orders.batch_cancel( + [ + BatchCancelOrdersRequestOrder(order_id="ord-1", subaccount=5), + BatchCancelOrdersRequestOrder(order_id="ord-2"), + ] + ) body = json.loads(route.calls[0].request.content) assert body["orders"] == [ @@ -950,15 +894,16 @@ class TestAsyncBatchCancelRoutesThroughDeleteWithBody: async def test_batch_cancel_uses_delete_with_body_helper( self, orders: AsyncOrdersResource ) -> None: - respx.delete( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/batched" - ).mock(return_value=httpx.Response(200, json={})) + respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(200, json={}) + ) # AsyncMock + wraps forwards every call to the real async method, # so the respx mock above still resolves and the helper's # status-code branching runs end-to-end; the spy only records. with patch.object( - orders, "_delete_with_body", + orders, + "_delete_with_body", wraps=orders._delete_with_body, new_callable=AsyncMock, ) as spy: @@ -970,15 +915,13 @@ async def test_batch_cancel_uses_delete_with_body_helper( @respx.mock @pytest.mark.asyncio - async def test_batch_cancel_handles_204_no_content( - self, orders: AsyncOrdersResource - ) -> None: + async def test_batch_cancel_handles_204_no_content(self, orders: AsyncOrdersResource) -> None: """Helper returns None on 204 — verifies it goes through the shared response-handling path, not a raw ``transport.request`` call. """ - respx.delete( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/batched" - ).mock(return_value=httpx.Response(204)) + respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(204) + ) await orders.batch_cancel(["ord-1"]) # must not raise on empty body @@ -990,15 +933,18 @@ class TestAmendWireShapeAsync: @respx.mock @pytest.mark.asyncio - async def test_price_serializes_dollars_alias( - self, orders: AsyncOrdersResource - ) -> None: + async def test_price_serializes_dollars_alias(self, orders: AsyncOrdersResource) -> None: route = respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-99/amend" - ).mock(return_value=httpx.Response(200, json={ - "old_order": {"order_id": "ord-99", "ticker": "MKT"}, - "order": {"order_id": "ord-99", "ticker": "MKT"}, - })) + ).mock( + return_value=httpx.Response( + 200, + json={ + "old_order": order_dict(order_id="ord-99", ticker="MKT"), + "order": order_dict(order_id="ord-99", ticker="MKT"), + }, + ) + ) await orders.amend( "ord-99", @@ -1014,15 +960,18 @@ async def test_price_serializes_dollars_alias( @respx.mock @pytest.mark.asyncio - async def test_count_serializes_fp_alias( - self, orders: AsyncOrdersResource - ) -> None: + async def test_count_serializes_fp_alias(self, orders: AsyncOrdersResource) -> None: route = respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-99/amend" - ).mock(return_value=httpx.Response(200, json={ - "old_order": {"order_id": "ord-99", "ticker": "MKT"}, - "order": {"order_id": "ord-99", "ticker": "MKT"}, - })) + ).mock( + return_value=httpx.Response( + 200, + json={ + "old_order": order_dict(order_id="ord-99", ticker="MKT"), + "order": order_dict(order_id="ord-99", ticker="MKT"), + }, + ) + ) await orders.amend( "ord-99", @@ -1038,15 +987,18 @@ async def test_count_serializes_fp_alias( @respx.mock @pytest.mark.asyncio - async def test_required_and_optional_fields( - self, orders: AsyncOrdersResource - ) -> None: + async def test_required_and_optional_fields(self, orders: AsyncOrdersResource) -> None: route = respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-99/amend" - ).mock(return_value=httpx.Response(200, json={ - "old_order": {"order_id": "ord-99", "ticker": "MKT"}, - "order": {"order_id": "ord-99", "ticker": "MKT"}, - })) + ).mock( + return_value=httpx.Response( + 200, + json={ + "old_order": order_dict(order_id="ord-99", ticker="MKT"), + "order": order_dict(order_id="ord-99", ticker="MKT"), + }, + ) + ) await orders.amend( "ord-99", @@ -1075,15 +1027,18 @@ async def test_required_and_optional_fields( @respx.mock @pytest.mark.asyncio - async def test_no_price_absent_when_not_passed( - self, orders: AsyncOrdersResource - ) -> None: + async def test_no_price_absent_when_not_passed(self, orders: AsyncOrdersResource) -> None: route = respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-99/amend" - ).mock(return_value=httpx.Response(200, json={ - "old_order": {"order_id": "ord-99", "ticker": "MKT"}, - "order": {"order_id": "ord-99", "ticker": "MKT"}, - })) + ).mock( + return_value=httpx.Response( + 200, + json={ + "old_order": order_dict(order_id="ord-99", ticker="MKT"), + "order": order_dict(order_id="ord-99", ticker="MKT"), + }, + ) + ) await orders.amend( "ord-99", @@ -1108,9 +1063,16 @@ class TestDecreaseWireShapeAsync: async def test_reduce_by_body(self, orders: AsyncOrdersResource) -> None: route = respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-99/decrease" - ).mock(return_value=httpx.Response(200, json={ - "order": {"order_id": "ord-99", "ticker": "MKT", "side": "yes", "status": "resting"}, - })) + ).mock( + return_value=httpx.Response( + 200, + json={ + "order": order_dict( + order_id="ord-99", ticker="MKT", side="yes", status="resting" + ), + }, + ) + ) await orders.decrease("ord-99", reduce_by=5, subaccount=1) @@ -1122,9 +1084,16 @@ async def test_reduce_by_body(self, orders: AsyncOrdersResource) -> None: async def test_reduce_to_body(self, orders: AsyncOrdersResource) -> None: route = respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-99/decrease" - ).mock(return_value=httpx.Response(200, json={ - "order": {"order_id": "ord-99", "ticker": "MKT", "side": "yes", "status": "resting"}, - })) + ).mock( + return_value=httpx.Response( + 200, + json={ + "order": order_dict( + order_id="ord-99", ticker="MKT", side="yes", status="resting" + ), + }, + ) + ) await orders.decrease("ord-99", reduce_to=2) @@ -1138,14 +1107,16 @@ class TestBatchCreateWireShapeAsync: @respx.mock @pytest.mark.asyncio async def test_wraps_orders_key(self, orders: AsyncOrdersResource) -> None: - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/batched" - ).mock(return_value=httpx.Response(200, json={"orders": []})) + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(200, json={"orders": []}) + ) - await orders.batch_create([ - CreateOrderRequest(ticker="A", side="yes"), - CreateOrderRequest(ticker="B", side="no"), - ]) + await orders.batch_create( + [ + CreateOrderRequest(ticker="A", side="yes", action="buy"), + CreateOrderRequest(ticker="B", side="no", action="buy"), + ] + ) body = json.loads(route.calls[0].request.content) assert "orders" in body @@ -1160,7 +1131,8 @@ class TestAsyncCreateOrderV2: @respx.mock @pytest.mark.asyncio async def test_returns_response( - self, orders: AsyncOrdersResource, + self, + orders: AsyncOrdersResource, ) -> None: respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/events/orders", @@ -1191,7 +1163,8 @@ async def test_returns_response( @respx.mock @pytest.mark.asyncio async def test_serializes_body( - self, orders: AsyncOrdersResource, + self, + orders: AsyncOrdersResource, ) -> None: """Async parity with TestCreateOrderV2.test_serializes_body — guards DollarDecimal / FixedPointCount mode="json" serialization @@ -1239,7 +1212,8 @@ class TestAsyncCancelOrderV2: @respx.mock @pytest.mark.asyncio async def test_returns_response( - self, orders: AsyncOrdersResource, + self, + orders: AsyncOrdersResource, ) -> None: respx.delete( "https://test.kalshi.com/trade-api/v2/portfolio/events/orders/ord-1", @@ -1268,7 +1242,8 @@ async def test_204_raises(self, orders: AsyncOrdersResource) -> None: @respx.mock @pytest.mark.asyncio async def test_passes_query_params( - self, orders: AsyncOrdersResource, + self, + orders: AsyncOrdersResource, ) -> None: """Async parity with sync TestCancelOrderV2.test_passes_query_params: cancel_v2 routes BOTH subaccount and exchange_index to query params @@ -1292,13 +1267,15 @@ class TestAsyncAmendOrderV2: @respx.mock @pytest.mark.asyncio async def test_returns_response( - self, orders: AsyncOrdersResource, + self, + orders: AsyncOrdersResource, ) -> None: respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/events/orders/ord-1/amend", ).mock( return_value=httpx.Response( - 200, json={"order_id": "ord-1", "ts_ms": 1700000000000}, + 200, + json={"order_id": "ord-1", "ts_ms": 1700000000000}, ) ) result = await orders.amend_v2( @@ -1317,7 +1294,8 @@ class TestAsyncDecreaseOrderV2: @respx.mock @pytest.mark.asyncio async def test_returns_response( - self, orders: AsyncOrdersResource, + self, + orders: AsyncOrdersResource, ) -> None: respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/events/orders/ord-1/decrease", @@ -1342,7 +1320,8 @@ class TestAsyncBatchCreateV2: @respx.mock @pytest.mark.asyncio async def test_returns_response( - self, orders: AsyncOrdersResource, + self, + orders: AsyncOrdersResource, ) -> None: respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/events/orders/batched", @@ -1383,7 +1362,8 @@ class TestAsyncBatchCancelV2: @respx.mock @pytest.mark.asyncio async def test_returns_response( - self, orders: AsyncOrdersResource, + self, + orders: AsyncOrdersResource, ) -> None: respx.delete( "https://test.kalshi.com/trade-api/v2/portfolio/events/orders/batched", @@ -1434,14 +1414,17 @@ async def test_amend_v2(self, orders: AsyncOrdersResource) -> None: "https://test.kalshi.com/trade-api/v2/portfolio/events/orders/ord-1/amend", ).mock( return_value=httpx.Response( - 200, json={"order_id": "ord-1", "ts_ms": 0}, + 200, + json={"order_id": "ord-1", "ts_ms": 0}, ) ) await orders.amend_v2( "ord-1", request=AmendOrderV2Request( - ticker="MKT-A", side="bid", - price=Decimal("0.55"), count=Decimal("10"), + ticker="MKT-A", + side="bid", + price=Decimal("0.55"), + count=Decimal("10"), exchange_index=0, ), subaccount=7, @@ -1465,7 +1448,8 @@ async def test_decrease_v2(self, orders: AsyncOrdersResource) -> None: await orders.decrease_v2( "ord-1", request=DecreaseOrderV2Request( - reduce_by=Decimal("2"), exchange_index=0, + reduce_by=Decimal("2"), + exchange_index=0, ), subaccount=4, ) @@ -1500,27 +1484,32 @@ async def test_create_v2( @pytest.mark.asyncio async def test_cancel_v2( - self, unauth_orders_async: AsyncOrdersResource, + self, + unauth_orders_async: AsyncOrdersResource, ) -> None: with pytest.raises(AuthRequiredError): await unauth_orders_async.cancel_v2("ord-1") @pytest.mark.asyncio async def test_amend_v2( - self, unauth_orders_async: AsyncOrdersResource, + self, + unauth_orders_async: AsyncOrdersResource, ) -> None: with pytest.raises(AuthRequiredError): await unauth_orders_async.amend_v2( "ord-1", request=AmendOrderV2Request( - ticker="MKT-A", side="bid", - price=Decimal("0.55"), count=Decimal("10"), + ticker="MKT-A", + side="bid", + price=Decimal("0.55"), + count=Decimal("10"), ), ) @pytest.mark.asyncio async def test_decrease_v2( - self, unauth_orders_async: AsyncOrdersResource, + self, + unauth_orders_async: AsyncOrdersResource, ) -> None: with pytest.raises(AuthRequiredError): await unauth_orders_async.decrease_v2( @@ -1541,7 +1530,8 @@ async def test_batch_create_v2( @pytest.mark.asyncio async def test_batch_cancel_v2( - self, unauth_orders_async: AsyncOrdersResource, + self, + unauth_orders_async: AsyncOrdersResource, ) -> None: with pytest.raises(AuthRequiredError): await unauth_orders_async.batch_cancel_v2( diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 0fab5df..0b7bd91 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -72,7 +72,8 @@ def test_exclusions_bootstrap_has_cursor_entries(self) -> None: """ cursor_keys = {k for k in EXCLUSIONS if k[1] == "cursor"} paginator_methods = { - e.sdk_method for e in METHOD_ENDPOINT_MAP + e.sdk_method + for e in METHOD_ENDPOINT_MAP if e.sdk_method.endswith("_all") or "list_all_" in e.sdk_method } covered = {k[0] for k in cursor_keys} @@ -85,16 +86,15 @@ def test_exclusions_bootstrap_has_cursor_entries(self) -> None: assert EXCLUSIONS[key].kind == "paginator_handled" def test_exclusions_bootstrap_has_create_order_request_entries(self) -> None: - create_keys = [ - k for k in EXCLUSIONS - if k[0] == "kalshi.models.orders.CreateOrderRequest" - ] + create_keys = [k for k in EXCLUSIONS if k[0] == "kalshi.models.orders.CreateOrderRequest"] field_names = {k[1] for k in create_keys} assert {"yes_price", "no_price", "sell_position_floor"} <= field_names def test_method_endpoint_entry_has_request_body_schema(self) -> None: entry = MethodEndpointEntry( - sdk_method="x", http_method="GET", path_template="/y", + sdk_method="x", + http_method="GET", + path_template="/y", ) assert entry.request_body_schema is None @@ -200,7 +200,9 @@ def test_resolve_request_body_schema_delete_with_body(self) -> None: }, } result = _resolve_request_body_schema( - spec, "/portfolio/orders/batched", "DELETE", + spec, + "/portfolio/orders/batched", + "DELETE", ) assert result is not None assert "orders" in result["properties"] @@ -212,7 +214,8 @@ def test_batch_cancel_body_drift_is_actually_exercised(self) -> None: could silently vacuum up a None schema and skip drift checks. """ entries = [ - e for e in METHOD_ENDPOINT_MAP + e + for e in METHOD_ENDPOINT_MAP if e.http_method == "DELETE" and e.request_body_schema is not None ] assert entries, "Expected at least one DELETE entry with a body schema" @@ -220,7 +223,9 @@ def test_batch_cancel_body_drift_is_actually_exercised(self) -> None: spec = _load_spec() for entry in entries: resolved = _resolve_request_body_schema( - spec, entry.path_template, entry.http_method, + spec, + entry.path_template, + entry.http_method, ) assert resolved is not None, ( f"DELETE {entry.path_template} has request_body_schema " @@ -597,11 +602,7 @@ def _ws_field_type_violations( # Rule 2: spec string with format=date-time (ISO timestamp) must be str # on the SDK. An int-typed SDK field rejects the wire string # "2026-04-19T18:43:37.662364Z". - if ( - spec_type == "string" - and spec_format == "date-time" - and sdk_kind not in ("str",) - ): + if spec_type == "string" and spec_format == "date-time" and sdk_kind not in ("str",): problems.append( f"{sdk_name!r}: spec '{spec_name}' is string (date-time), " f"SDK typed as {sdk_kind}. Use str." @@ -620,8 +621,7 @@ def _ws_field_type_violations( and "str" in expected ): problems.append( - f"{sdk_name!r}: spec '{spec_name}' is {expected}, " - f"SDK typed as {sdk_kind}." + f"{sdk_name!r}: spec '{spec_name}' is {expected}, SDK typed as {sdk_kind}." ) return problems @@ -653,8 +653,7 @@ def test_additive_drift(self, entry: ContractEntry) -> None: additive, _ = _classify_drift(entry, self.spec, spec_fields, model_class) if additive: pytest.fail( - f"Additive drift in {entry.sdk_model}:\n" - + "\n".join(f" - {a}" for a in additive), + f"Additive drift in {entry.sdk_model}:\n" + "\n".join(f" - {a}" for a in additive), ) @pytest.mark.parametrize( @@ -663,15 +662,20 @@ def test_additive_drift(self, entry: ContractEntry) -> None: ids=[e.sdk_model.rsplit(".", 1)[1] for e in CONTRACT_MAP], ) def test_required_drift(self, entry: ContractEntry) -> None: - """Warn about required mismatches (spec required, SDK optional).""" + """Fail when SDK fields are optional but spec marks them required. + + Hard-fail since #172. Was warn-only while the SDK kept ~134 fields + Optional[T] | None against spec's required: true. The deviation is + either resolved (drop None default) or recorded in EXCLUSIONS with + kind='server_omits_despite_required' citing a demo+prod observation. + """ spec_fields = _get_schema_fields(self.spec, entry.spec_schema) model_class = _get_sdk_model_class(entry.sdk_model) _, required_issues = _classify_drift(entry, self.spec, spec_fields, model_class) if required_issues: - warnings.warn( + pytest.fail( f"Required drift in {entry.sdk_model}:\n" - + "\n".join(f" - {r}" for r in required_issues), - stacklevel=1, + + "\n".join(f" - {r}" for r in required_issues) ) def test_schema_coverage(self) -> None: @@ -766,7 +770,10 @@ def test_ws_additive_drift(self, entry: ContractEntry) -> None: ids=[e.sdk_model.rsplit(".", 1)[1] for e in WS_CONTRACT_MAP], ) def test_ws_required_drift(self, entry: ContractEntry) -> None: - """Warn about required mismatches in WS models.""" + """Fail when SDK WS fields are optional but spec marks them required. + + Hard-fail since #172, matching the REST sibling. + """ model_class = _get_sdk_model_class(entry.sdk_model) # Use WS-specific required extractor instead of REST _get_required_fields ws_required = _get_ws_required_fields(self.spec, entry.spec_schema) @@ -785,10 +792,9 @@ def test_ws_required_drift(self, entry: ContractEntry) -> None: f"Spec requires '{req_field}' but SDK field '{sdk_name}' is optional" ) if required_issues: - warnings.warn( + pytest.fail( f"WS required drift in {entry.sdk_model}:\n" - + "\n".join(f" - {r}" for r in required_issues), - stacklevel=1, + + "\n".join(f" - {r}" for r in required_issues) ) def test_ws_schema_coverage(self) -> None: @@ -848,10 +854,7 @@ def test_ws_envelope_type_drift(self) -> None: # Filter out documented divergences (Branch-B style intentional exceptions). # Allowlist compares tuples directly, so the assertion output format can # change without silently breaking the filter. - unexpected = [ - m for m in mismatches - if (m[0], m[3]) not in self._DEMO_DIVERGENCE_ALLOWLIST - ] + unexpected = [m for m in mismatches if (m[0], m[3]) not in self._DEMO_DIVERGENCE_ALLOWLIST] assert not unexpected, ( "WS envelope type drift (not on documented divergence allowlist):\n" @@ -892,13 +895,15 @@ def test_ws_payload_field_type_drift(self, entry: ContractEntry) -> None: field_info = model_class.model_fields[sdk_name] violations.extend( _ws_field_type_violations( - sdk_name, field_info.annotation, spec_name, spec_prop, + sdk_name, + field_info.annotation, + spec_name, + spec_prop, ) ) - assert not violations, ( - f"WS payload field type drift in {entry.sdk_model}:\n" - + "\n".join(f" - {v}" for v in violations) + assert not violations, f"WS payload field type drift in {entry.sdk_model}:\n" + "\n".join( + f" - {v}" for v in violations ) def test_ws_contract_map_completeness(self) -> None: @@ -954,8 +959,11 @@ def _signature_params(sdk_method_fqn: str) -> set[str]: func = getattr(cls, method) sig = inspect.signature(func) return { - name for name, param in sig.parameters.items() - if name != "self" and param.kind in ( + name + for name, param in sig.parameters.items() + if name != "self" + and param.kind + in ( inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY, ) @@ -965,6 +973,7 @@ def _signature_params(sdk_method_fqn: str) -> set[str]: def _path_params_from_template(path_template: str) -> set[str]: """Extract ``{name}`` placeholders from a path template.""" import re + return set(re.findall(r"\{([^}]+)\}", path_template)) @@ -1007,7 +1016,10 @@ def test_async_params_match_spec(self, entry: MethodEndpointEntry) -> None: self._assert_params_match(entry, async_=True) def _assert_params_match( - self, entry: MethodEndpointEntry, *, async_: bool, + self, + entry: MethodEndpointEntry, + *, + async_: bool, ) -> None: sdk_fqn = entry.sdk_method if async_: @@ -1018,18 +1030,15 @@ def _assert_params_match( module_name = ".".join(parts[:-2]) cls_name = parts[-2] module = importlib.import_module(module_name) - assert hasattr(module, cls_name), ( - f"Missing async sibling {cls_name} in {module_name}" - ) + assert hasattr(module, cls_name), f"Missing async sibling {cls_name} in {module_name}" sdk_params = _signature_params(sdk_fqn) spec_params_list = _resolve_path_params( - self.spec, entry.path_template, entry.http_method, + self.spec, + entry.path_template, + entry.http_method, ) - spec_params = { - p["name"] for p in spec_params_list - if p.get("in") in ("query", "path") - } + spec_params = {p["name"] for p in spec_params_list if p.get("in") in ("query", "path")} # Spec path params like ``{order_id}`` should appear in the path # template placeholders too; union them in so the test catches # cases where a spec operation declares a path param that the @@ -1043,28 +1052,16 @@ def _assert_params_match( # ADD drift: spec has it, SDK missing missing = spec_params - sdk_params - missing_unallowed = { - p for p in missing - if (lookup_fqn, p) not in EXCLUSIONS - } + missing_unallowed = {p for p in missing if (lookup_fqn, p) not in EXCLUSIONS} # REMOVE drift: SDK has it, spec doesn't extra = sdk_params - spec_params - extra_unallowed = { - p for p in extra - if (lookup_fqn, p) not in EXCLUSIONS - } + extra_unallowed = {p for p in extra if (lookup_fqn, p) not in EXCLUSIONS} errors: list[str] = [] if missing_unallowed: - errors.append( - f"[ADD drift] spec has params SDK missing: " - f"{sorted(missing_unallowed)}" - ) + errors.append(f"[ADD drift] spec has params SDK missing: {sorted(missing_unallowed)}") if extra_unallowed: - errors.append( - f"[REMOVE drift] SDK has params spec doesn't: " - f"{sorted(extra_unallowed)}" - ) + errors.append(f"[REMOVE drift] SDK has params spec doesn't: {sorted(extra_unallowed)}") if errors: pytest.fail( f"{sdk_fqn} <-> {entry.http_method} {entry.path_template}\n" @@ -1081,30 +1078,18 @@ def _assert_params_match( # Registry: spec $ref → SDK request model FQN. # Update whenever a new POST/PUT/DELETE-with-body endpoint gets a request model. BODY_MODEL_MAP: dict[str, str] = { - "#/components/schemas/CreateOrderRequest": ( - "kalshi.models.orders.CreateOrderRequest" - ), - "#/components/schemas/AmendOrderRequest": ( - "kalshi.models.orders.AmendOrderRequest" - ), - "#/components/schemas/DecreaseOrderRequest": ( - "kalshi.models.orders.DecreaseOrderRequest" - ), + "#/components/schemas/CreateOrderRequest": ("kalshi.models.orders.CreateOrderRequest"), + "#/components/schemas/AmendOrderRequest": ("kalshi.models.orders.AmendOrderRequest"), + "#/components/schemas/DecreaseOrderRequest": ("kalshi.models.orders.DecreaseOrderRequest"), "#/components/schemas/BatchCreateOrdersRequest": ( "kalshi.models.orders.BatchCreateOrdersRequest" ), "#/components/schemas/BatchCancelOrdersRequest": ( "kalshi.models.orders.BatchCancelOrdersRequest" ), - "#/components/schemas/CreateOrderV2Request": ( - "kalshi.models.orders.CreateOrderV2Request" - ), - "#/components/schemas/AmendOrderV2Request": ( - "kalshi.models.orders.AmendOrderV2Request" - ), - "#/components/schemas/DecreaseOrderV2Request": ( - "kalshi.models.orders.DecreaseOrderV2Request" - ), + "#/components/schemas/CreateOrderV2Request": ("kalshi.models.orders.CreateOrderV2Request"), + "#/components/schemas/AmendOrderV2Request": ("kalshi.models.orders.AmendOrderV2Request"), + "#/components/schemas/DecreaseOrderV2Request": ("kalshi.models.orders.DecreaseOrderV2Request"), "#/components/schemas/BatchCreateOrdersV2Request": ( "kalshi.models.orders.BatchCreateOrdersV2Request" ), @@ -1112,12 +1097,10 @@ def _assert_params_match( "kalshi.models.orders.BatchCancelOrdersV2Request" ), "#/components/schemas/CreateMarketInMultivariateEventCollectionRequest": ( - "kalshi.models.multivariate." - "CreateMarketInMultivariateEventCollectionRequest" + "kalshi.models.multivariate.CreateMarketInMultivariateEventCollectionRequest" ), "#/components/schemas/LookupTickersForMarketInMultivariateEventCollectionRequest": ( - "kalshi.models.multivariate." - "LookupTickersForMarketInMultivariateEventCollectionRequest" + "kalshi.models.multivariate.LookupTickersForMarketInMultivariateEventCollectionRequest" ), "#/components/schemas/CreateOrderGroupRequest": ( "kalshi.models.order_groups.CreateOrderGroupRequest" @@ -1125,27 +1108,17 @@ def _assert_params_match( "#/components/schemas/UpdateOrderGroupLimitRequest": ( "kalshi.models.order_groups.UpdateOrderGroupLimitRequest" ), - "#/components/schemas/CreateRFQRequest": ( - "kalshi.models.communications.CreateRFQRequest" - ), - "#/components/schemas/CreateQuoteRequest": ( - "kalshi.models.communications.CreateQuoteRequest" - ), - "#/components/schemas/AcceptQuoteRequest": ( - "kalshi.models.communications.AcceptQuoteRequest" - ), + "#/components/schemas/CreateRFQRequest": ("kalshi.models.communications.CreateRFQRequest"), + "#/components/schemas/CreateQuoteRequest": ("kalshi.models.communications.CreateQuoteRequest"), + "#/components/schemas/AcceptQuoteRequest": ("kalshi.models.communications.AcceptQuoteRequest"), "#/components/schemas/ApplySubaccountTransferRequest": ( "kalshi.models.subaccounts.ApplySubaccountTransferRequest" ), "#/components/schemas/UpdateSubaccountNettingRequest": ( "kalshi.models.subaccounts.UpdateSubaccountNettingRequest" ), - "#/components/schemas/CreateApiKeyRequest": ( - "kalshi.models.api_keys.CreateApiKeyRequest" - ), - "#/components/schemas/GenerateApiKeyRequest": ( - "kalshi.models.api_keys.GenerateApiKeyRequest" - ), + "#/components/schemas/CreateApiKeyRequest": ("kalshi.models.api_keys.CreateApiKeyRequest"), + "#/components/schemas/GenerateApiKeyRequest": ("kalshi.models.api_keys.GenerateApiKeyRequest"), } @@ -1249,14 +1222,10 @@ def _check_model_drift( # ADD drift: spec has property SDK model doesn't emit missing = spec_props - sdk_wire_names - missing_unallowed = { - p for p in missing if (model_fqn, p) not in EXCLUSIONS - } + missing_unallowed = {p for p in missing if (model_fqn, p) not in EXCLUSIONS} # REMOVE drift: SDK emits wire name spec doesn't have extra = sdk_wire_names - spec_props - extra_unallowed = { - p for p in extra if (model_fqn, p) not in EXCLUSIONS - } + extra_unallowed = {p for p in extra if (model_fqn, p) not in EXCLUSIONS} prefix = f"[nested {context} -> {model_fqn}] " if context else "" if missing_unallowed: @@ -1271,10 +1240,7 @@ def _check_model_drift( ) -_BODY_ENTRIES = [ - e for e in METHOD_ENDPOINT_MAP - if e.request_body_schema is not None -] +_BODY_ENTRIES = [e for e in METHOD_ENDPOINT_MAP if e.request_body_schema is not None] @pytest.mark.parametrize( @@ -1301,7 +1267,8 @@ def _load(self) -> None: self.spec = _load_spec() def test_body_properties_match_spec( - self, entry: MethodEndpointEntry, + self, + entry: MethodEndpointEntry, ) -> None: assert entry.request_body_schema is not None model_fqn = BODY_MODEL_MAP.get(entry.request_body_schema) @@ -1312,11 +1279,12 @@ def test_body_properties_match_spec( model_cls = _get_model_class_from_fqn(model_fqn) schema = _resolve_request_body_schema( - self.spec, entry.path_template, entry.http_method, + self.spec, + entry.path_template, + entry.http_method, ) assert schema is not None, ( - f"Spec has no body schema for {entry.http_method} " - f"{entry.path_template}" + f"Spec has no body schema for {entry.http_method} {entry.path_template}" ) errors: list[str] = [] @@ -1329,7 +1297,9 @@ def test_body_properties_match_spec( # Issue #52: also drift-check nested BaseModel fields (one level deep). for field_name, nested_cls, nested_schema in _iter_nested_body_models( - model_cls, schema, self.spec, + model_cls, + schema, + self.spec, ): nested_fqn = f"{nested_cls.__module__}.{nested_cls.__qualname__}" _check_model_drift( @@ -1341,10 +1311,7 @@ def test_body_properties_match_spec( ) if errors: - pytest.fail( - f"{model_fqn} <-> {entry.request_body_schema}\n" - + "\n".join(errors) - ) + pytest.fail(f"{model_fqn} <-> {entry.request_body_schema}\n" + "\n".join(errors)) def test_exclusion_map_is_current() -> None: @@ -1382,7 +1349,9 @@ def test_exclusion_map_is_current() -> None: for e in METHOD_ENDPOINT_MAP: if e.request_body_schema == spec_ref: body_schema = _resolve_request_body_schema( - spec, e.path_template, e.http_method, + spec, + e.path_template, + e.http_method, ) break if body_schema is None: @@ -1464,7 +1433,10 @@ def test_exclusion_map_is_current() -> None: # kwarg legitimately exists in the signature. Distinguish by the # typed ``kind`` field (issue #51). sig_mismatch_kinds = { - "body_param", "wire_normalization", "kwarg_rename", "client_only", + "body_param", + "wire_normalization", + "kwarg_rename", + "client_only", } allowed_in_sig = excl.kind in sig_mismatch_kinds if name in sdk_params and not allowed_in_sig: diff --git a/tests/test_events.py b/tests/test_events.py index e811c83..d1b4410 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -13,6 +13,7 @@ from kalshi.config import KalshiConfig from kalshi.errors import KalshiNotFoundError from kalshi.resources.events import AsyncEventsResource, EventsResource +from tests._model_fixtures import event_dict, event_metadata_dict, market_dict @pytest.fixture @@ -30,9 +31,7 @@ def events(test_auth: KalshiAuth, config: KalshiConfig) -> EventsResource: @pytest.fixture -def async_events( - test_auth: KalshiAuth, config: KalshiConfig -) -> AsyncEventsResource: +def async_events(test_auth: KalshiAuth, config: KalshiConfig) -> AsyncEventsResource: return AsyncEventsResource(AsyncTransport(test_auth, config)) @@ -47,17 +46,13 @@ def test_returns_page_of_events(self, events: EventsResource) -> None: 200, json={ "events": [ - { - "event_ticker": "EVT-A", - "title": "Event A", - "series_ticker": "SER-1", - "mutually_exclusive": True, - }, - { - "event_ticker": "EVT-B", - "title": "Event B", - "series_ticker": "SER-2", - }, + event_dict( + event_ticker="EVT-A", + title="Event A", + series_ticker="SER-1", + mutually_exclusive=True, + ), + event_dict(event_ticker="EVT-B", title="Event B", series_ticker="SER-2"), ], "cursor": "page2", }, @@ -96,13 +91,13 @@ def test_auto_paginates(self, events: EventsResource) -> None: httpx.Response( 200, json={ - "events": [{"event_ticker": "A"}, {"event_ticker": "B"}], + "events": [event_dict(event_ticker="A"), event_dict(event_ticker="B")], "cursor": "page2", }, ), httpx.Response( 200, - json={"events": [{"event_ticker": "C"}], "cursor": ""}, + json={"events": [event_dict(event_ticker="C")], "cursor": ""}, ), ] ) @@ -117,12 +112,12 @@ def test_returns_event(self, events: EventsResource) -> None: return_value=httpx.Response( 200, json={ - "event": { - "event_ticker": "EVT-1", - "title": "Test Event", - "series_ticker": "SER-1", - "mutually_exclusive": False, - }, + "event": event_dict( + event_ticker="EVT-1", + title="Test Event", + series_ticker="SER-1", + mutually_exclusive=False, + ), "markets": [], }, ) @@ -139,11 +134,10 @@ def test_with_nested_markets(self, events: EventsResource) -> None: 200, json={ "event": { - "event_ticker": "EVT-1", - "title": "Test Event", + **event_dict(event_ticker="EVT-1", title="Test Event"), "markets": [ - {"ticker": "MKT-1", "yes_bid_dollars": "0.45"}, - {"ticker": "MKT-2", "yes_bid_dollars": "0.55"}, + market_dict(ticker="MKT-1", yes_bid_dollars="0.45"), + market_dict(ticker="MKT-2", yes_bid_dollars="0.55"), ], }, "markets": [], @@ -173,8 +167,10 @@ def test_returns_metadata(self, events: EventsResource) -> None: return_value=httpx.Response( 200, json={ - "image_url": "https://example.com/event.png", - "featured_image_url": "https://example.com/featured.png", + **event_metadata_dict( + image_url="https://example.com/event.png", + featured_image_url="https://example.com/featured.png", + ), "market_details": [ { "market_ticker": "MKT-1", @@ -209,10 +205,13 @@ class TestEventsListMultivariate: @respx.mock def test_list_multivariate(self, events: EventsResource) -> None: respx.get("https://test.kalshi.com/trade-api/v2/events/multivariate").mock( - return_value=httpx.Response(200, json={ - "events": [{"event_ticker": "MVE-1", "series_ticker": "SER-1"}], - "cursor": "next", - }) + return_value=httpx.Response( + 200, + json={ + "events": [event_dict(event_ticker="MVE-1", series_ticker="SER-1")], + "cursor": "next", + }, + ) ) page = events.list_multivariate() assert len(page.items) == 1 @@ -238,14 +237,20 @@ def test_list_multivariate_filters(self, events: EventsResource) -> None: def test_list_all_multivariate(self, events: EventsResource) -> None: respx.get("https://test.kalshi.com/trade-api/v2/events/multivariate").mock( side_effect=[ - httpx.Response(200, json={ - "events": [{"event_ticker": "MVE-1"}], - "cursor": "page2", - }), - httpx.Response(200, json={ - "events": [{"event_ticker": "MVE-2"}], - "cursor": "", - }), + httpx.Response( + 200, + json={ + "events": [event_dict(event_ticker="MVE-1")], + "cursor": "page2", + }, + ), + httpx.Response( + 200, + json={ + "events": [event_dict(event_ticker="MVE-2")], + "cursor": "", + }, + ), ] ) items = list(events.list_all_multivariate()) @@ -276,7 +281,7 @@ async def test_returns_page(self, async_events: AsyncEventsResource) -> None: return_value=httpx.Response( 200, json={ - "events": [{"event_ticker": "EVT-A", "title": "Event A"}], + "events": [event_dict(event_ticker="EVT-A", title="Event A")], "cursor": "p2", }, ) @@ -296,13 +301,13 @@ async def test_auto_paginates(self, async_events: AsyncEventsResource) -> None: httpx.Response( 200, json={ - "events": [{"event_ticker": "A"}], + "events": [event_dict(event_ticker="A")], "cursor": "p2", }, ), httpx.Response( 200, - json={"events": [{"event_ticker": "B"}], "cursor": ""}, + json={"events": [event_dict(event_ticker="B")], "cursor": ""}, ), ] ) @@ -318,10 +323,7 @@ async def test_returns_event(self, async_events: AsyncEventsResource) -> None: return_value=httpx.Response( 200, json={ - "event": { - "event_ticker": "EVT-1", - "title": "Async Event", - }, + "event": event_dict(event_ticker="EVT-1", title="Async Event"), "markets": [], }, ) @@ -362,10 +364,13 @@ class TestAsyncEventsListMultivariate: @pytest.mark.asyncio async def test_list_multivariate(self, async_events: AsyncEventsResource) -> None: respx.get("https://test.kalshi.com/trade-api/v2/events/multivariate").mock( - return_value=httpx.Response(200, json={ - "events": [{"event_ticker": "MVE-1"}], - "cursor": "", - }) + return_value=httpx.Response( + 200, + json={ + "events": [event_dict(event_ticker="MVE-1")], + "cursor": "", + }, + ) ) page = await async_events.list_multivariate() assert len(page.items) == 1 @@ -375,8 +380,10 @@ async def test_list_multivariate(self, async_events: AsyncEventsResource) -> Non async def test_list_all_multivariate(self, async_events: AsyncEventsResource) -> None: respx.get("https://test.kalshi.com/trade-api/v2/events/multivariate").mock( side_effect=[ - httpx.Response(200, json={"events": [{"event_ticker": "A"}], "cursor": "p2"}), - httpx.Response(200, json={"events": [{"event_ticker": "B"}], "cursor": ""}), + httpx.Response( + 200, json={"events": [event_dict(event_ticker="A")], "cursor": "p2"} + ), + httpx.Response(200, json={"events": [event_dict(event_ticker="B")], "cursor": ""}), ] ) tickers = [e.event_ticker async for e in async_events.list_all_multivariate()] @@ -402,9 +409,7 @@ def test_list_with_nested_markets_false_is_sent(self, events: EventsResource) -> assert params["with_milestones"] == "false" @respx.mock - def test_list_with_nested_markets_none_is_dropped( - self, events: EventsResource - ) -> None: + def test_list_with_nested_markets_none_is_dropped(self, events: EventsResource) -> None: route = respx.get("https://test.kalshi.com/trade-api/v2/events").mock( return_value=httpx.Response(200, json={"events": [], "cursor": ""}) ) @@ -415,8 +420,8 @@ def test_list_with_nested_markets_none_is_dropped( @respx.mock def test_list_multivariate_false_is_sent(self, events: EventsResource) -> None: - route = respx.get( - "https://test.kalshi.com/trade-api/v2/events/multivariate" - ).mock(return_value=httpx.Response(200, json={"events": [], "cursor": ""})) + route = respx.get("https://test.kalshi.com/trade-api/v2/events/multivariate").mock( + return_value=httpx.Response(200, json={"events": [], "cursor": ""}) + ) events.list_multivariate(with_nested_markets=False) assert route.calls[0].request.url.params["with_nested_markets"] == "false" diff --git a/tests/test_fcm.py b/tests/test_fcm.py index b7966a4..49d5fe2 100644 --- a/tests/test_fcm.py +++ b/tests/test_fcm.py @@ -11,6 +11,7 @@ from kalshi.config import KalshiConfig from kalshi.errors import AuthRequiredError, KalshiAuthError from kalshi.resources.fcm import AsyncFcmResource, FcmResource +from tests._model_fixtures import order_dict @pytest.fixture @@ -29,7 +30,8 @@ def fcm(test_auth: KalshiAuth, config: KalshiConfig) -> FcmResource: @pytest.fixture def async_fcm( - test_auth: KalshiAuth, config: KalshiConfig, + test_auth: KalshiAuth, + config: KalshiConfig, ) -> AsyncFcmResource: return AsyncFcmResource(AsyncTransport(test_auth, config)) @@ -47,17 +49,17 @@ def test_returns_page(self, fcm: FcmResource) -> None: 200, json={ "orders": [ - { - "order_id": "ord-1", - "user_id": "user-1", - "client_order_id": "client-1", - "ticker": "TEST-MKT", - "side": "yes", - "action": "buy", - "type": "limit", - "status": "resting", - "yes_price_dollars": "0.55", - }, + order_dict( + order_id="ord-1", + user_id="user-1", + client_order_id="client-1", + ticker="TEST-MKT", + side="yes", + action="buy", + type="limit", + status="resting", + yes_price_dollars="0.55", + ), ], "cursor": "", }, @@ -125,7 +127,8 @@ def test_forwards_filters(self, fcm: FcmResource) -> None: "https://test.kalshi.com/trade-api/v2/fcm/positions", ).mock( return_value=httpx.Response( - 200, json={"market_positions": [], "event_positions": []}, + 200, + json={"market_positions": [], "event_positions": []}, ) ) fcm.positions( @@ -163,7 +166,8 @@ async def test_orders(self, async_fcm: AsyncFcmResource) -> None: async def test_positions(self, async_fcm: AsyncFcmResource) -> None: respx.get("https://test.kalshi.com/trade-api/v2/fcm/positions").mock( return_value=httpx.Response( - 200, json={"market_positions": [], "event_positions": []}, + 200, + json={"market_positions": [], "event_positions": []}, ) ) result = await async_fcm.positions(subtrader_id="sub-1") diff --git a/tests/test_historical.py b/tests/test_historical.py index a71c993..98e0503 100644 --- a/tests/test_historical.py +++ b/tests/test_historical.py @@ -13,6 +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 @pytest.fixture @@ -67,8 +68,8 @@ def test_returns_page(self, historical: HistoricalResource) -> None: 200, json={ "markets": [ - {"ticker": "HIST-A", "yes_bid_dollars": "0.9000"}, - {"ticker": "HIST-B", "yes_bid_dollars": "0.1000"}, + market_dict(ticker="HIST-A", yes_bid_dollars="0.9000"), + market_dict(ticker="HIST-B", yes_bid_dollars="0.1000"), ], "cursor": "page2", }, @@ -87,13 +88,13 @@ def test_markets_all_paginates(self, historical: HistoricalResource) -> None: httpx.Response( 200, json={ - "markets": [{"ticker": "A"}], + "markets": [market_dict(ticker="A")], "cursor": "p2", }, ), httpx.Response( 200, - json={"markets": [{"ticker": "B"}], "cursor": ""}, + json={"markets": [market_dict(ticker="B")], "cursor": ""}, ), ] ) @@ -119,9 +120,7 @@ def test_ticker_kwarg_removed(self, historical: HistoricalResource) -> None: historical.markets(ticker="X") # type: ignore[call-arg] @respx.mock - def test_markets_with_all_new_filters( - self, historical: HistoricalResource - ) -> None: + def test_markets_with_all_new_filters(self, historical: HistoricalResource) -> None: """v0.7.0: tickers RENAME (list form) + mve_filter ADD.""" route = respx.get(f"{BASE}/historical/markets").mock( return_value=httpx.Response(200, json={"markets": [], "cursor": ""}) @@ -143,9 +142,7 @@ def test_markets_with_all_new_filters( assert params["mve_filter"] == "filter-z" @respx.mock - def test_tickers_serialized_as_comma_join_list( - self, historical: HistoricalResource - ) -> None: + def test_tickers_serialized_as_comma_join_list(self, historical: HistoricalResource) -> None: """Spec says tickers is type:string (comma-separated), NOT explode:true.""" route = respx.get(f"{BASE}/historical/markets").mock( return_value=httpx.Response(200, json={"markets": []}) @@ -156,9 +153,7 @@ def test_tickers_serialized_as_comma_join_list( assert url.count("tickers=") == 1 @respx.mock - def test_tickers_serialized_as_comma_join_string( - self, historical: HistoricalResource - ) -> None: + def test_tickers_serialized_as_comma_join_string(self, historical: HistoricalResource) -> None: """Pre-joined string passes through unchanged.""" route = respx.get(f"{BASE}/historical/markets").mock( return_value=httpx.Response(200, json={"markets": []}) @@ -175,11 +170,9 @@ def test_returns_market(self, historical: HistoricalResource) -> None: return_value=httpx.Response( 200, json={ - "market": { - "ticker": "HIST-MKT", - "result": "yes", - "yes_bid_dollars": "1.0000", - } + "market": market_dict( + ticker="HIST-MKT", result="yes", yes_bid_dollars="1.0000" + ), }, ) ) @@ -247,19 +240,19 @@ def test_returns_page(self, historical: HistoricalResource) -> None: 200, json={ "fills": [ - { - "trade_id": "f1", - "fill_id": "f1", - "order_id": "o1", - "ticker": "MKT-A", - "side": "yes", - "action": "buy", - "count_fp": "10.00", - "yes_price_dollars": "0.5000", - "no_price_dollars": "0.5000", - "is_taker": True, - "fee_cost": "0.0500", - } + fill_dict( + trade_id="f1", + fill_id="f1", + order_id="o1", + ticker="MKT-A", + side="yes", + action="buy", + count_fp="10.00", + yes_price_dollars="0.5000", + no_price_dollars="0.5000", + is_taker=True, + fee_cost_dollars="0.0500", + ) ], "cursor": "p2", }, @@ -281,10 +274,10 @@ def test_fills_all_paginates(self, historical: HistoricalResource) -> None: respx.get(f"{BASE}/historical/fills").mock( side_effect=[ httpx.Response( - 200, json={"fills": [{"trade_id": "a", "count_fp": "1"}], "cursor": "p2"} + 200, json={"fills": [fill_dict(trade_id="a", count_fp="1")], "cursor": "p2"} ), httpx.Response( - 200, json={"fills": [{"trade_id": "b", "count_fp": "2"}], "cursor": ""} + 200, json={"fills": [fill_dict(trade_id="b", count_fp="2")], "cursor": ""} ), ] ) @@ -311,7 +304,7 @@ def test_returns_page(self, historical: HistoricalResource) -> None: 200, json={ "orders": [ - {"order_id": "o1", "ticker": "MKT-A", "status": "executed"}, + order_dict(order_id="o1", ticker="MKT-A", status="executed"), ], "cursor": "", }, @@ -325,8 +318,8 @@ def test_returns_page(self, historical: HistoricalResource) -> None: def test_orders_all_paginates(self, historical: HistoricalResource) -> None: respx.get(f"{BASE}/historical/orders").mock( side_effect=[ - httpx.Response(200, json={"orders": [{"order_id": "a"}], "cursor": "p2"}), - httpx.Response(200, json={"orders": [{"order_id": "b"}], "cursor": ""}), + httpx.Response(200, json={"orders": [order_dict(order_id="a")], "cursor": "p2"}), + httpx.Response(200, json={"orders": [order_dict(order_id="b")], "cursor": ""}), ] ) ids = [o.order_id for o in historical.orders_all()] @@ -352,15 +345,15 @@ def test_returns_page(self, historical: HistoricalResource) -> None: 200, json={ "trades": [ - { - "trade_id": "t1", - "ticker": "MKT-A", - "count_fp": "5.00", - "yes_price_dollars": "0.6000", - "no_price_dollars": "0.4000", - "taker_side": "yes", - "created_time": "2026-04-12T12:00:00Z", - } + trade_dict( + trade_id="t1", + ticker="MKT-A", + count_fp="5.00", + yes_price_dollars="0.6000", + no_price_dollars="0.4000", + taker_side="yes", + created_time="2026-04-12T12:00:00Z", + ) ], "cursor": "", }, @@ -382,13 +375,13 @@ def test_trades_all_paginates(self, historical: HistoricalResource) -> None: 200, json={ "trades": [ - { - "trade_id": "a", - "count_fp": "1", - "yes_price_dollars": "0.5", - "no_price_dollars": "0.5", - "taker_side": "yes", - } + trade_dict( + trade_id="a", + count_fp="1", + yes_price_dollars="0.5", + no_price_dollars="0.5", + taker_side="yes", + ) ], "cursor": "p2", }, @@ -397,13 +390,13 @@ def test_trades_all_paginates(self, historical: HistoricalResource) -> None: 200, json={ "trades": [ - { - "trade_id": "b", - "count_fp": "2", - "yes_price_dollars": "0.6", - "no_price_dollars": "0.4", - "taker_side": "no", - } + trade_dict( + trade_id="b", + count_fp="2", + yes_price_dollars="0.6", + no_price_dollars="0.4", + taker_side="no", + ) ], "cursor": "", }, @@ -419,9 +412,7 @@ def test_trades_with_min_max_ts(self, historical: HistoricalResource) -> None: route = respx.get(f"{BASE}/historical/trades").mock( return_value=httpx.Response(200, json={"trades": []}) ) - historical.trades( - ticker="MKT-A", min_ts=1700000000, max_ts=1700099999 - ) + historical.trades(ticker="MKT-A", min_ts=1700000000, max_ts=1700099999) params = dict(route.calls[0].request.url.params) assert params["ticker"] == "MKT-A" assert params["min_ts"] == "1700000000" @@ -457,7 +448,7 @@ async def test_returns_page(self, async_historical: AsyncHistoricalResource) -> return_value=httpx.Response( 200, json={ - "markets": [{"ticker": "HIST-A", "yes_bid_dollars": "0.90"}], + "markets": [market_dict(ticker="HIST-A", yes_bid_dollars="0.90")], "cursor": "p2", }, ) @@ -471,17 +462,15 @@ async def test_returns_page(self, async_historical: AsyncHistoricalResource) -> async def test_markets_all(self, async_historical: AsyncHistoricalResource) -> None: respx.get(f"{BASE}/historical/markets").mock( side_effect=[ - httpx.Response(200, json={"markets": [{"ticker": "A"}], "cursor": "p2"}), - httpx.Response(200, json={"markets": [{"ticker": "B"}], "cursor": ""}), + httpx.Response(200, json={"markets": [market_dict(ticker="A")], "cursor": "p2"}), + httpx.Response(200, json={"markets": [market_dict(ticker="B")], "cursor": ""}), ] ) tickers = [m.ticker async for m in async_historical.markets_all()] assert tickers == ["A", "B"] @pytest.mark.asyncio - async def test_ticker_kwarg_removed( - self, async_historical: AsyncHistoricalResource - ) -> None: + async def test_ticker_kwarg_removed(self, async_historical: AsyncHistoricalResource) -> None: """Regression: v0.7.0 renamed `ticker` -> `tickers` (BREAKING).""" with pytest.raises(TypeError, match="ticker"): await async_historical.markets(ticker="X") # type: ignore[call-arg] @@ -548,14 +537,14 @@ async def test_returns_page(self, async_historical: AsyncHistoricalResource) -> 200, json={ "trades": [ - { - "trade_id": "t1", - "ticker": "MKT", - "count_fp": "5.00", - "yes_price_dollars": "0.60", - "no_price_dollars": "0.40", - "taker_side": "yes", - } + trade_dict( + trade_id="t1", + ticker="MKT", + count_fp="5.00", + yes_price_dollars="0.60", + no_price_dollars="0.40", + taker_side="yes", + ) ], "cursor": "", }, @@ -574,13 +563,13 @@ async def test_trades_all(self, async_historical: AsyncHistoricalResource) -> No 200, json={ "trades": [ - { - "trade_id": "a", - "count_fp": "1", - "yes_price_dollars": "0.5", - "no_price_dollars": "0.5", - "taker_side": "yes", - } + trade_dict( + trade_id="a", + count_fp="1", + yes_price_dollars="0.5", + no_price_dollars="0.5", + taker_side="yes", + ) ], "cursor": "p2", }, @@ -589,13 +578,13 @@ async def test_trades_all(self, async_historical: AsyncHistoricalResource) -> No 200, json={ "trades": [ - { - "trade_id": "b", - "count_fp": "2", - "yes_price_dollars": "0.6", - "no_price_dollars": "0.4", - "taker_side": "no", - } + trade_dict( + trade_id="b", + count_fp="2", + yes_price_dollars="0.6", + no_price_dollars="0.4", + taker_side="no", + ) ], "cursor": "", }, @@ -607,16 +596,12 @@ async def test_trades_all(self, async_historical: AsyncHistoricalResource) -> No @respx.mock @pytest.mark.asyncio - async def test_trades_with_min_max_ts( - self, async_historical: AsyncHistoricalResource - ) -> None: + async def test_trades_with_min_max_ts(self, async_historical: AsyncHistoricalResource) -> None: """v0.7.0 ADDs: min_ts AND max_ts kwargs reach the wire.""" route = respx.get(f"{BASE}/historical/trades").mock( return_value=httpx.Response(200, json={"trades": []}) ) - await async_historical.trades( - ticker="MKT-A", min_ts=1700000000, max_ts=1700099999 - ) + await async_historical.trades(ticker="MKT-A", min_ts=1700000000, max_ts=1700099999) params = dict(route.calls[0].request.url.params) assert params["ticker"] == "MKT-A" assert params["min_ts"] == "1700000000" @@ -632,13 +617,13 @@ async def test_returns_page(self, async_historical: AsyncHistoricalResource) -> 200, json={ "fills": [ - { - "trade_id": "f1", - "count_fp": "10", - "yes_price_dollars": "0.50", - "no_price_dollars": "0.50", - "fee_cost": "0.05", - } + fill_dict( + trade_id="f1", + count_fp="10", + yes_price_dollars="0.50", + no_price_dollars="0.50", + fee_cost_dollars="0.05", + ), ], "cursor": "", }, @@ -656,14 +641,14 @@ async def test_fills_all(self, async_historical: AsyncHistoricalResource) -> Non httpx.Response( 200, json={ - "fills": [{"trade_id": "a", "count_fp": "1"}], + "fills": [fill_dict(trade_id="a", count_fp="1")], "cursor": "p2", }, ), httpx.Response( 200, json={ - "fills": [{"trade_id": "b", "count_fp": "2"}], + "fills": [fill_dict(trade_id="b", count_fp="2")], "cursor": "", }, ), @@ -674,9 +659,7 @@ async def test_fills_all(self, async_historical: AsyncHistoricalResource) -> Non @respx.mock @pytest.mark.asyncio - async def test_fills_with_max_ts( - self, async_historical: AsyncHistoricalResource - ) -> None: + async def test_fills_with_max_ts(self, async_historical: AsyncHistoricalResource) -> None: """v0.7.0 ADD: max_ts kwarg reaches the wire.""" route = respx.get(f"{BASE}/historical/fills").mock( return_value=httpx.Response(200, json={"fills": []}) @@ -694,7 +677,7 @@ async def test_returns_market(self, async_historical: AsyncHistoricalResource) - respx.get(f"{BASE}/historical/markets/HIST-MKT").mock( return_value=httpx.Response( 200, - json={"market": {"ticker": "HIST-MKT", "result": "yes"}}, + json={"market": market_dict(ticker="HIST-MKT", result="yes")}, ) ) market = await async_historical.market("HIST-MKT") @@ -705,9 +688,7 @@ async def test_returns_market(self, async_historical: AsyncHistoricalResource) - class TestAsyncHistoricalCandlesticks: @respx.mock @pytest.mark.asyncio - async def test_returns_candlesticks( - self, async_historical: AsyncHistoricalResource - ) -> None: + async def test_returns_candlesticks(self, async_historical: AsyncHistoricalResource) -> None: respx.get(f"{BASE}/historical/markets/MKT/candlesticks").mock( return_value=httpx.Response( 200, @@ -743,7 +724,7 @@ async def test_returns_page(self, async_historical: AsyncHistoricalResource) -> return_value=httpx.Response( 200, json={ - "orders": [{"order_id": "o1", "status": "executed"}], + "orders": [order_dict(order_id="o1", status="executed")], "cursor": "", }, ) @@ -760,14 +741,14 @@ async def test_orders_all(self, async_historical: AsyncHistoricalResource) -> No httpx.Response( 200, json={ - "orders": [{"order_id": "a"}], + "orders": [order_dict(order_id="a")], "cursor": "p2", }, ), httpx.Response( 200, json={ - "orders": [{"order_id": "b"}], + "orders": [order_dict(order_id="b")], "cursor": "", }, ), @@ -778,9 +759,7 @@ async def test_orders_all(self, async_historical: AsyncHistoricalResource) -> No @respx.mock @pytest.mark.asyncio - async def test_orders_with_max_ts( - self, async_historical: AsyncHistoricalResource - ) -> None: + async def test_orders_with_max_ts(self, async_historical: AsyncHistoricalResource) -> None: """v0.7.0 ADD: max_ts kwarg reaches the wire.""" route = respx.get(f"{BASE}/historical/orders").mock( return_value=httpx.Response(200, json={"orders": []}) diff --git a/tests/test_incentive_programs.py b/tests/test_incentive_programs.py index efc83af..3391463 100644 --- a/tests/test_incentive_programs.py +++ b/tests/test_incentive_programs.py @@ -15,6 +15,7 @@ AsyncIncentiveProgramsResource, IncentiveProgramsResource, ) +from tests._model_fixtures import incentive_program_dict @pytest.fixture @@ -28,30 +29,32 @@ def config() -> KalshiConfig: @pytest.fixture def resource( - test_auth: KalshiAuth, config: KalshiConfig, + test_auth: KalshiAuth, + config: KalshiConfig, ) -> IncentiveProgramsResource: return IncentiveProgramsResource(SyncTransport(test_auth, config)) @pytest.fixture def async_resource( - test_auth: KalshiAuth, config: KalshiConfig, + test_auth: KalshiAuth, + config: KalshiConfig, ) -> AsyncIncentiveProgramsResource: return AsyncIncentiveProgramsResource(AsyncTransport(test_auth, config)) -_SAMPLE_PROGRAM = { - "id": "prog-1", - "market_id": "mkt-abc", - "market_ticker": "TEST-MKT", - "incentive_type": "liquidity", - "start_date": "2026-04-01T00:00:00Z", - "end_date": "2026-04-30T23:59:59Z", - "period_reward": 1_000_000, # centi-cents - "paid_out": False, - "discount_factor_bps": 25, - "target_size_fp": "100.5000", -} +_SAMPLE_PROGRAM = incentive_program_dict( + id="prog-1", + market_id="mkt-abc", + market_ticker="TEST-MKT", + incentive_type="liquidity", + start_date="2026-04-01T00:00:00Z", + end_date="2026-04-30T23:59:59Z", + period_reward=1_000_000, # centi-cents + paid_out=False, + discount_factor_bps=25, + target_size_fp="100.5000", +) class TestList: @@ -85,7 +88,8 @@ def test_no_next_cursor(self, resource: IncentiveProgramsResource) -> None: "https://test.kalshi.com/trade-api/v2/incentive_programs", ).mock( return_value=httpx.Response( - 200, json={"incentive_programs": [_SAMPLE_PROGRAM]}, + 200, + json={"incentive_programs": [_SAMPLE_PROGRAM]}, ) ) page = resource.list() @@ -98,7 +102,8 @@ def test_forwards_filters(self, resource: IncentiveProgramsResource) -> None: "https://test.kalshi.com/trade-api/v2/incentive_programs", ).mock( return_value=httpx.Response( - 200, json={"incentive_programs": []}, + 200, + json={"incentive_programs": []}, ) ) resource.list(status="active", incentive_type="volume", limit=50) @@ -111,14 +116,16 @@ def test_forwards_filters(self, resource: IncentiveProgramsResource) -> None: @respx.mock def test_null_target_size_fp( - self, resource: IncentiveProgramsResource, + self, + resource: IncentiveProgramsResource, ) -> None: prog = {**_SAMPLE_PROGRAM, "target_size_fp": None, "discount_factor_bps": None} respx.get( "https://test.kalshi.com/trade-api/v2/incentive_programs", ).mock( return_value=httpx.Response( - 200, json={"incentive_programs": [prog]}, + 200, + json={"incentive_programs": [prog]}, ) ) page = resource.list() @@ -129,7 +136,8 @@ def test_null_target_size_fp( class TestListAll: @respx.mock def test_paginates_next_cursor( - self, resource: IncentiveProgramsResource, + self, + resource: IncentiveProgramsResource, ) -> None: route = respx.get( "https://test.kalshi.com/trade-api/v2/incentive_programs", @@ -165,13 +173,15 @@ class TestAsync: @respx.mock @pytest.mark.asyncio async def test_list( - self, async_resource: AsyncIncentiveProgramsResource, + self, + async_resource: AsyncIncentiveProgramsResource, ) -> None: respx.get( "https://test.kalshi.com/trade-api/v2/incentive_programs", ).mock( return_value=httpx.Response( - 200, json={"incentive_programs": [_SAMPLE_PROGRAM]}, + 200, + json={"incentive_programs": [_SAMPLE_PROGRAM]}, ) ) page = await async_resource.list() @@ -180,18 +190,22 @@ async def test_list( @respx.mock @pytest.mark.asyncio async def test_forwards_filters( - self, async_resource: AsyncIncentiveProgramsResource, + self, + async_resource: AsyncIncentiveProgramsResource, ) -> None: """Regression guard: SDK kwarg `incentive_type` must serialize to wire `type`.""" route = respx.get( "https://test.kalshi.com/trade-api/v2/incentive_programs", ).mock( return_value=httpx.Response( - 200, json={"incentive_programs": []}, + 200, + json={"incentive_programs": []}, ) ) await async_resource.list( - status="active", incentive_type="volume", limit=50, + status="active", + incentive_type="volume", + limit=50, ) assert route.called url = route.calls.last.request.url @@ -202,13 +216,15 @@ async def test_forwards_filters( @respx.mock @pytest.mark.asyncio async def test_list_all( - self, async_resource: AsyncIncentiveProgramsResource, + self, + async_resource: AsyncIncentiveProgramsResource, ) -> None: respx.get( "https://test.kalshi.com/trade-api/v2/incentive_programs", ).mock( return_value=httpx.Response( - 200, json={"incentive_programs": [_SAMPLE_PROGRAM]}, + 200, + json={"incentive_programs": [_SAMPLE_PROGRAM]}, ) ) items = [item async for item in async_resource.list_all()] @@ -217,7 +233,8 @@ async def test_list_all( @respx.mock @pytest.mark.asyncio async def test_paginates_next_cursor( - self, async_resource: AsyncIncentiveProgramsResource, + self, + async_resource: AsyncIncentiveProgramsResource, ) -> None: """next_cursor (not cursor) drives async pagination for this endpoint.""" route = respx.get( diff --git a/tests/test_markets.py b/tests/test_markets.py index bbfdac3..fe85636 100644 --- a/tests/test_markets.py +++ b/tests/test_markets.py @@ -15,6 +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 @pytest.fixture @@ -39,8 +40,8 @@ def test_returns_page_of_markets(self, markets: MarketsResource) -> None: 200, json={ "markets": [ - {"ticker": "MKT-A", "title": "Market A", "yes_bid_dollars": "0.4500"}, - {"ticker": "MKT-B", "title": "Market B", "yes_bid_dollars": "0.6000"}, + market_dict(ticker="MKT-A", title="Market A", yes_bid_dollars="0.4500"), + market_dict(ticker="MKT-B", title="Market B", yes_bid_dollars="0.6000"), ], "cursor": "page2", }, @@ -188,13 +189,13 @@ def test_auto_paginates(self, markets: MarketsResource) -> None: httpx.Response( 200, json={ - "markets": [{"ticker": "A"}, {"ticker": "B"}], + "markets": [market_dict(ticker="A"), market_dict(ticker="B")], "cursor": "page2", }, ), httpx.Response( 200, - json={"markets": [{"ticker": "C"}], "cursor": None}, + json={"markets": [market_dict(ticker="C")], "cursor": None}, ), ] ) @@ -212,7 +213,7 @@ def _make_response(request: httpx.Request) -> httpx.Response: n = counter["n"] return httpx.Response( 200, - json={"markets": [{"ticker": f"M-{n}"}], "cursor": f"cur-{n}"}, + json={"markets": [market_dict(ticker=f"M-{n}")], "cursor": f"cur-{n}"}, ) route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( @@ -234,7 +235,9 @@ def test_max_pages_zero_rejected(self, markets: MarketsResource) -> None: def test_list_all_with_all_new_filters(self, markets: MarketsResource) -> None: """v0.7.0 ADDs on list_all match list (no cursor).""" route = respx.get("https://test.kalshi.com/trade-api/v2/markets").mock( - return_value=httpx.Response(200, json={"markets": [{"ticker": "A"}], "cursor": ""}) + return_value=httpx.Response( + 200, json={"markets": [market_dict(ticker="A")], "cursor": ""} + ) ) list( markets.list_all( @@ -262,11 +265,9 @@ def test_returns_market(self, markets: MarketsResource) -> None: return_value=httpx.Response( 200, json={ - "market": { - "ticker": "TEST-MKT", - "title": "Test Market", - "yes_ask_dollars": "0.7200", - } + "market": market_dict( + ticker="TEST-MKT", title="Test Market", yes_ask_dollars="0.7200" + ), }, ) ) @@ -307,9 +308,7 @@ def test_returns_orderbook(self, markets: MarketsResource) -> None: @respx.mock def test_orderbook_with_depth(self, markets: MarketsResource) -> None: """v0.7.0 ADD: depth kwarg reaches the wire.""" - route = respx.get( - "https://test.kalshi.com/trade-api/v2/markets/TEST-MKT/orderbook" - ).mock( + route = respx.get("https://test.kalshi.com/trade-api/v2/markets/TEST-MKT/orderbook").mock( return_value=httpx.Response( 200, json={"orderbook_fp": {"yes_dollars": [], "no_dollars": []}} ) @@ -326,9 +325,7 @@ def test_orderbook_requires_auth(self, config: KalshiConfig) -> None: class TestMarketsCandlesticks: @respx.mock def test_returns_nested_candlesticks(self, markets: MarketsResource) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/series/SER/markets/MKT/candlesticks" - ).mock( + respx.get("https://test.kalshi.com/trade-api/v2/series/SER/markets/MKT/candlesticks").mock( return_value=httpx.Response( 200, json={ @@ -378,9 +375,7 @@ def test_returns_nested_candlesticks(self, markets: MarketsResource) -> None: @respx.mock def test_empty_candlesticks(self, markets: MarketsResource) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/series/SER/markets/MKT/candlesticks" - ).mock( + respx.get("https://test.kalshi.com/trade-api/v2/series/SER/markets/MKT/candlesticks").mock( return_value=httpx.Response(200, json={"candlesticks": []}) ) candles = markets.candlesticks( @@ -407,9 +402,7 @@ def test_candlesticks_with_include_latest_before_start_true( assert route.calls[0].request.url.params["include_latest_before_start"] == "true" @respx.mock - def test_candlesticks_sends_explicit_false( - self, markets: MarketsResource - ) -> None: + def test_candlesticks_sends_explicit_false(self, markets: MarketsResource) -> None: """Tri-state bool: False must send 'false' (opt-out survives server default flips).""" route = respx.get( "https://test.kalshi.com/trade-api/v2/series/SER/markets/MKT/candlesticks" @@ -425,9 +418,7 @@ def test_candlesticks_sends_explicit_false( assert route.calls[0].request.url.params["include_latest_before_start"] == "false" @respx.mock - def test_candlesticks_omits_include_latest_when_none( - self, markets: MarketsResource - ) -> None: + def test_candlesticks_omits_include_latest_when_none(self, markets: MarketsResource) -> None: """Tri-state bool: None drops the param entirely.""" route = respx.get( "https://test.kalshi.com/trade-api/v2/series/SER/markets/MKT/candlesticks" @@ -439,9 +430,7 @@ def test_candlesticks_omits_include_latest_when_none( end_ts=1700100000, period_interval=60, ) - assert "include_latest_before_start" not in dict( - route.calls[0].request.url.params - ) + assert "include_latest_before_start" not in dict(route.calls[0].request.url.params) class TestMarketsListTrades: @@ -452,15 +441,15 @@ def test_returns_page_of_trades(self, markets: MarketsResource) -> None: 200, json={ "trades": [ - { - "trade_id": "t-1", - "ticker": "MKT-A", - "count_fp": "50.00", - "yes_price_dollars": "0.4500", - "no_price_dollars": "0.5500", - "taker_side": "yes", - "created_time": "2026-04-18T12:00:00Z", - }, + trade_dict( + trade_id="t-1", + ticker="MKT-A", + count_fp="50.00", + yes_price_dollars="0.4500", + no_price_dollars="0.5500", + taker_side="yes", + created_time="2026-04-18T12:00:00Z", + ), ], "cursor": "next", }, @@ -475,15 +464,15 @@ def test_returns_page_of_trades(self, markets: MarketsResource) -> None: @respx.mock def test_list_trades_all_paginates(self, markets: MarketsResource) -> None: - base_trade = { - "trade_id": "t-1", - "ticker": "MKT-A", - "count_fp": "1.00", - "yes_price_dollars": "0.50", - "no_price_dollars": "0.50", - "taker_side": "yes", - "created_time": "2026-04-18T12:00:00Z", - } + base_trade = trade_dict( + trade_id="t-1", + ticker="MKT-A", + count_fp="1.00", + yes_price_dollars="0.50", + no_price_dollars="0.50", + taker_side="yes", + created_time="2026-04-18T12:00:00Z", + ) page1 = {"trades": [base_trade], "cursor": "p2"} page2 = { "trades": [{**base_trade, "trade_id": "t-2"}], @@ -535,7 +524,8 @@ def test_returns_per_market_bundles(self, markets: MarketsResource) -> None: @respx.mock def test_bulk_candlesticks_handles_null_candlesticks( - self, markets: MarketsResource, + self, + markets: MarketsResource, ) -> None: """NullableList[Candlestick]: server-sent ``null`` coerces to ``[]``.""" respx.get( @@ -560,7 +550,8 @@ def test_bulk_candlesticks_handles_null_candlesticks( @respx.mock def test_bulk_candlesticks_include_flag( - self, markets: MarketsResource, + self, + markets: MarketsResource, ) -> None: route = respx.get( "https://test.kalshi.com/trade-api/v2/markets/candlesticks", @@ -577,7 +568,8 @@ def test_bulk_candlesticks_include_flag( @respx.mock def test_bulk_candlesticks_include_flag_false( - self, markets: MarketsResource, + self, + markets: MarketsResource, ) -> None: """Tri-state bool: explicit False must send 'false' on the wire.""" route = respx.get( @@ -595,7 +587,8 @@ def test_bulk_candlesticks_include_flag_false( @respx.mock def test_bulk_candlesticks_omits_include_flag_when_none( - self, markets: MarketsResource, + self, + markets: MarketsResource, ) -> None: route = respx.get( "https://test.kalshi.com/trade-api/v2/markets/candlesticks", @@ -612,51 +605,65 @@ def test_bulk_candlesticks_omits_include_flag_when_none( class TestMarketsBulkEmptyValidation: def test_bulk_candlesticks_rejects_empty_list( - self, markets: MarketsResource, + self, + markets: MarketsResource, ) -> None: with pytest.raises(ValueError, match="non-empty"): markets.bulk_candlesticks( market_tickers=[], - start_ts=1, end_ts=2, period_interval=60, + start_ts=1, + end_ts=2, + period_interval=60, ) def test_bulk_candlesticks_rejects_empty_string( - self, markets: MarketsResource, + self, + markets: MarketsResource, ) -> None: with pytest.raises(ValueError, match="non-empty"): markets.bulk_candlesticks( market_tickers="", - start_ts=1, end_ts=2, period_interval=60, + start_ts=1, + end_ts=2, + period_interval=60, ) def test_bulk_candlesticks_rejects_over_100_list( - self, markets: MarketsResource, + self, + markets: MarketsResource, ) -> None: with pytest.raises(ValueError, match="at most 100"): markets.bulk_candlesticks( market_tickers=[f"MKT-{i}" for i in range(101)], - start_ts=1, end_ts=2, period_interval=60, + start_ts=1, + end_ts=2, + period_interval=60, ) def test_bulk_candlesticks_rejects_over_100_string( - self, markets: MarketsResource, + self, + markets: MarketsResource, ) -> None: """Pre-joined string input must hit the same upper-bound guard as list input.""" joined = ",".join(f"MKT-{i}" for i in range(101)) with pytest.raises(ValueError, match="at most 100"): markets.bulk_candlesticks( market_tickers=joined, - start_ts=1, end_ts=2, period_interval=60, + start_ts=1, + end_ts=2, + period_interval=60, ) def test_bulk_orderbooks_rejects_empty_list( - self, markets: MarketsResource, + self, + markets: MarketsResource, ) -> None: with pytest.raises(ValueError, match="non-empty"): markets.bulk_orderbooks(tickers=[]) def test_bulk_orderbooks_rejects_over_100( - self, markets: MarketsResource, + self, + markets: MarketsResource, ) -> None: with pytest.raises(ValueError, match="at most 100"): markets.bulk_orderbooks(tickers=[f"MKT-{i}" for i in range(101)]) @@ -708,7 +715,8 @@ def test_bulk_orderbooks_requires_auth(self, config: KalshiConfig) -> None: @respx.mock def test_bulk_orderbooks_handles_missing_sides( - self, markets: MarketsResource, + self, + markets: MarketsResource, ) -> None: respx.get( "https://test.kalshi.com/trade-api/v2/markets/orderbooks", @@ -724,7 +732,8 @@ def test_bulk_orderbooks_handles_missing_sides( @respx.mock def test_bulk_orderbooks_rejects_item_with_missing_ticker( - self, markets: MarketsResource, + self, + markets: MarketsResource, ) -> None: """Server omitting per-item ticker must raise, not silently return ``ticker=''``.""" respx.get( @@ -744,23 +753,28 @@ class TestOrderbookFromItem: def test_missing_ticker_raises_kalshi_error(self) -> None: from kalshi.resources.markets import _orderbook_from_item + with pytest.raises(KalshiError, match="empty or missing 'ticker'"): _orderbook_from_item({"orderbook_fp": {}}) def test_empty_string_ticker_raises(self) -> None: from kalshi.resources.markets import _orderbook_from_item + with pytest.raises(KalshiError, match="empty or missing 'ticker'"): _orderbook_from_item({"ticker": "", "orderbook_fp": {}}) def test_parses_new_shape(self) -> None: from kalshi.resources.markets import _orderbook_from_item - ob = _orderbook_from_item({ - "ticker": "MKT-A", - "orderbook_fp": { - "yes_dollars": [["0.42", "100"]], - "no_dollars": [["0.58", "50"]], - }, - }) + + ob = _orderbook_from_item( + { + "ticker": "MKT-A", + "orderbook_fp": { + "yes_dollars": [["0.42", "100"]], + "no_dollars": [["0.58", "50"]], + }, + } + ) assert ob.ticker == "MKT-A" assert ob.yes[0].price == Decimal("0.42") assert ob.no[0].price == Decimal("0.58") @@ -769,20 +783,22 @@ def test_parses_new_shape(self) -> None: class TestMarketModel: def test_new_fields_from_api(self) -> None: """Market model accepts new v0.2 fields from the /markets endpoint.""" - market = Market.model_validate({ - "ticker": "TEST", - "market_type": "binary", - "yes_sub_title": "Yes", - "no_sub_title": "No", - "volume_fp": "1234.50", - "volume_24h_fp": "500.00", - "open_interest_fp": "10000.00", - "yes_bid_size_fp": "200.00", - "yes_ask_size_fp": "300.00", - "settlement_value_dollars": "1.0000", - "fractional_trading_enabled": True, - "settlement_timer_seconds": 3600, - }) + market = Market.model_validate( + market_dict( + ticker="TEST", + market_type="binary", + yes_sub_title="Yes", + no_sub_title="No", + volume_fp="1234.50", + volume_24h_fp="500.00", + open_interest_fp="10000.00", + yes_bid_size_fp="200.00", + yes_ask_size_fp="300.00", + settlement_value_dollars="1.0000", + fractional_trading_enabled=True, + settlement_timer_seconds=3600, + ) + ) assert market.market_type == "binary" assert market.yes_sub_title == "Yes" assert market.volume == Decimal("1234.50") @@ -795,11 +811,9 @@ def test_new_fields_from_api(self) -> None: def test_backward_compat_short_names(self) -> None: """Market model still accepts short names for price fields.""" - market = Market.model_validate({ - "ticker": "TEST", - "yes_bid": "0.45", - "volume": "100", - }) + market = Market.model_validate( + market_dict(ticker="TEST", yes_bid_dollars="0.45", volume_fp="100") + ) assert market.yes_bid == Decimal("0.45") assert market.volume == Decimal("100") diff --git a/tests/test_models.py b/tests/test_models.py index 05fbbe2..4e6da0e 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -21,6 +21,18 @@ from kalshi.models.orders import Fill, Order from kalshi.models.portfolio import Settlement from kalshi.types import to_decimal +from tests._model_fixtures import ( + create_order_group_response_dict, + event_dict, + event_metadata_dict, + fill_dict, + get_order_group_response_dict, + incentive_program_dict, + market_dict, + order_dict, + settlement_dict, + trade_dict, +) class TestToDecimal: @@ -52,25 +64,27 @@ def test_decimal_passthrough(self) -> None: class TestDollarDecimalField: def test_market_parses_string_price(self) -> None: - m = Market(ticker="T", yes_ask="0.65") + m = Market.model_validate(market_dict(ticker="T", yes_ask_dollars="0.65")) assert m.yes_ask == Decimal("0.65") assert isinstance(m.yes_ask, Decimal) def test_market_parses_float_price(self) -> None: - m = Market(ticker="T", yes_bid=0.45) + m = Market.model_validate(market_dict(ticker="T", yes_bid_dollars=0.45)) assert m.yes_bid == Decimal("0.45") def test_market_parses_int_price(self) -> None: - m = Market(ticker="T", last_price=1) + m = Market.model_validate(market_dict(ticker="T", last_price_dollars=1)) assert m.last_price == Decimal("1") def test_market_model_dump_serializes(self) -> None: - m = Market(ticker="T", yes_ask="0.72") + m = Market.model_validate(market_dict(ticker="T", yes_ask_dollars="0.72")) data = m.model_dump() assert data["yes_ask"] == "0.72" def test_order_decimal_fields(self) -> None: - o = Order(order_id="x", yes_price="0.65", no_price="0.35") + o = Order.model_validate( + order_dict(order_id="x", yes_price_dollars="0.65", no_price_dollars="0.35") + ) assert o.yes_price == Decimal("0.65") assert o.no_price == Decimal("0.35") @@ -83,12 +97,12 @@ class TestMarketOccurrenceDatetime: """ def test_parses_iso_string(self) -> None: - m = Market(ticker="T", occurrence_datetime="2026-01-15T10:30:00Z") + m = Market.model_validate(market_dict(occurrence_datetime="2026-01-15T10:30:00Z")) assert isinstance(m.occurrence_datetime, datetime) assert m.occurrence_datetime.year == 2026 def test_absent_defaults_to_none(self) -> None: - m = Market(ticker="T") + m = Market.model_validate(market_dict()) assert m.occurrence_datetime is None @@ -102,14 +116,15 @@ class TestDollarsAliasFields: """ def test_market_accepts_dollars_suffix(self) -> None: - m = Market.model_validate({ - "ticker": "T", - "yes_bid_dollars": "0.4500", - "yes_ask_dollars": "0.5500", - "no_bid_dollars": "0.3000", - "no_ask_dollars": "0.7000", - "last_price_dollars": "0.5000", - }) + m = Market.model_validate( + market_dict( + yes_bid_dollars="0.4500", + yes_ask_dollars="0.5500", + no_bid_dollars="0.3000", + no_ask_dollars="0.7000", + last_price_dollars="0.5000", + ) + ) assert m.yes_bid == Decimal("0.4500") assert m.yes_ask == Decimal("0.5500") assert m.no_bid == Decimal("0.3000") @@ -117,19 +132,22 @@ def test_market_accepts_dollars_suffix(self) -> None: assert m.last_price == Decimal("0.5000") def test_market_accepts_bare_names(self) -> None: - m = Market(ticker="T", yes_bid="0.45") + data = market_dict(yes_bid="0.45") + data.pop("yes_bid_dollars") # bare-name path requires absence of _dollars alias + m = Market.model_validate(data) assert m.yes_bid == Decimal("0.45") def test_order_accepts_dollars_suffix(self) -> None: - o = Order.model_validate({ - "order_id": "x", - "yes_price_dollars": "0.6500", - "no_price_dollars": "0.3500", - "taker_fill_cost_dollars": "6.5000", - "maker_fill_cost_dollars": "0.0000", - "taker_fees_dollars": "0.0650", - "maker_fees_dollars": "0.0000", - }) + o = Order.model_validate( + order_dict( + yes_price_dollars="0.6500", + no_price_dollars="0.3500", + taker_fill_cost_dollars="6.5000", + maker_fill_cost_dollars="0.0000", + taker_fees_dollars="0.0650", + maker_fees_dollars="0.0000", + ) + ) assert o.yes_price == Decimal("0.6500") assert o.no_price == Decimal("0.3500") assert o.taker_fill_cost == Decimal("6.5000") @@ -144,12 +162,14 @@ def test_order_type_populated_from_wire_field(self) -> None: actually see the value (previous dead-field state always returned ``None``). """ - o = Order.model_validate({"order_id": "x", "type": "limit"}) + o = Order.model_validate(order_dict(type="limit")) assert o.order_type == "limit" def test_order_type_accepts_python_name(self) -> None: """Pydantic ``populate_by_name`` keeps the SDK kwarg name working.""" - o = Order.model_validate({"order_id": "x", "order_type": "market"}) + data = order_dict(order_type="market") + data.pop("type") # exercises the populate_by_name branch + o = Order.model_validate(data) assert o.order_type == "market" def test_order_has_no_type_field(self) -> None: @@ -158,31 +178,34 @@ def test_order_has_no_type_field(self) -> None: assert "order_type" in Order.model_fields def test_fill_accepts_dollars_suffix(self) -> None: - f = Fill.model_validate({ - "trade_id": "t1", - "yes_price_dollars": "0.5000", - "no_price_dollars": "0.5000", - }) + f = Fill.model_validate( + fill_dict( + yes_price_dollars="0.5000", + no_price_dollars="0.5000", + ) + ) assert f.yes_price == Decimal("0.5000") assert f.no_price == Decimal("0.5000") def test_candlestick_nested_structure(self) -> None: from kalshi.models.markets import Candlestick - c = Candlestick.model_validate({ - "end_period_ts": 1700000000, - "yes_bid": { - "open_dollars": "0.4000", - "high_dollars": "0.5000", - "low_dollars": "0.3500", - "close_dollars": "0.4500", - }, - "price": { - "open_dollars": "0.5000", - "close_dollars": "0.5500", - }, - "volume_fp": "100.00", - }) + c = Candlestick.model_validate( + { + "end_period_ts": 1700000000, + "yes_bid": { + "open_dollars": "0.4000", + "high_dollars": "0.5000", + "low_dollars": "0.3500", + "close_dollars": "0.4500", + }, + "price": { + "open_dollars": "0.5000", + "close_dollars": "0.5500", + }, + "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") @@ -197,6 +220,7 @@ def test_create_order_serializes_with_dollars_alias(self) -> None: req = CreateOrderRequest( ticker="T", side="yes", + action="buy", yes_price=Decimal("0.65"), ) data = req.model_dump(exclude_none=True, by_alias=True) @@ -214,17 +238,19 @@ class TestMarketV3180Fields: """ def test_parses_all_new_scalar_fields(self) -> None: - m = Market.model_validate({ - "ticker": "T", - "early_close_condition": "if_settled_early", - "exchange_index": 7, - "fee_waiver_expiration_time": "2026-06-01T00:00:00Z", - "functional_strike": "x**2", - "is_provisional": True, - "mve_collection_ticker": "COLL-001", - "price_level_structure": "tick_5_cent", - "primary_participant_key": "team-a", - }) + m = Market.model_validate( + market_dict( + ticker="T", + early_close_condition="if_settled_early", + exchange_index=7, + fee_waiver_expiration_time="2026-06-01T00:00:00Z", + functional_strike="x**2", + is_provisional=True, + mve_collection_ticker="COLL-001", + price_level_structure="tick_5_cent", + primary_participant_key="team-a", + ) + ) assert m.early_close_condition == "if_settled_early" assert m.exchange_index == 7 assert m.fee_waiver_expiration_time is not None @@ -237,16 +263,18 @@ def test_parses_all_new_scalar_fields(self) -> None: assert m.primary_participant_key == "team-a" def test_parses_object_and_array_fields(self) -> None: - m = Market.model_validate({ - "ticker": "T", - "custom_strike": {"yes_threshold": 50, "no_threshold": 100}, - "mve_selected_legs": [ - {"event_ticker": "EVT-001", "market_ticker": "MKT-001", "side": "yes"}, - ], - "price_ranges": [ - {"start": "0.01", "end": "0.99", "step": "0.01"}, - ], - }) + m = Market.model_validate( + market_dict( + ticker="T", + custom_strike={"yes_threshold": 50, "no_threshold": 100}, + mve_selected_legs=[ + {"event_ticker": "EVT-001", "market_ticker": "MKT-001", "side": "yes"}, + ], + price_ranges=[ + {"start": "0.01", "end": "0.99", "step": "0.01"}, + ], + ) + ) assert m.custom_strike == {"yes_threshold": 50, "no_threshold": 100} assert m.mve_selected_legs is not None assert m.mve_selected_legs[0]["event_ticker"] == "EVT-001" @@ -254,7 +282,7 @@ def test_parses_object_and_array_fields(self) -> None: assert m.price_ranges[0]["step"] == "0.01" def test_all_new_fields_default_to_none(self) -> None: - m = Market(ticker="T") + m = Market.model_validate(market_dict(ticker="T")) for name in ( "custom_strike", "early_close_condition", @@ -264,8 +292,9 @@ def test_all_new_fields_default_to_none(self) -> None: "is_provisional", "mve_collection_ticker", "mve_selected_legs", - "price_level_structure", - "price_ranges", + # NOTE: price_level_structure / price_ranges were optional in v3.18.0 + # but #172 promoted them to required-on-the-wire. They no longer + # default to None; removed from this list. "primary_participant_key", ): assert getattr(m, name) is None, f"{name} should default to None" @@ -281,24 +310,28 @@ class TestOrderV3180Fields: """ def test_parses_outcome_and_book_side(self) -> None: - o = Order.model_validate({ - "order_id": "x", - "outcome_side": "yes", - "book_side": "bid", - }) + o = Order.model_validate( + order_dict( + order_id="x", + outcome_side="yes", + book_side="bid", + ) + ) assert o.outcome_side == "yes" assert o.book_side == "bid" def test_parses_remaining_new_fields(self) -> None: - o = Order.model_validate({ - "order_id": "x", - "cancel_order_on_pause": True, - "exchange_index": 3, - "last_update_time": "2026-05-01T12:00:00Z", - "order_group_id": "group-42", - "self_trade_prevention_type": "taker_at_cross", - "subaccount_number": 5, - }) + o = Order.model_validate( + order_dict( + order_id="x", + cancel_order_on_pause=True, + exchange_index=3, + last_update_time="2026-05-01T12:00:00Z", + order_group_id="group-42", + self_trade_prevention_type="taker_at_cross", + subaccount_number=5, + ) + ) assert o.cancel_order_on_pause is True assert o.exchange_index == 3 assert o.last_update_time is not None @@ -310,19 +343,21 @@ def test_parses_remaining_new_fields(self) -> None: def test_subaccount_number_is_distinct_from_subaccount(self) -> None: """``subaccount_number`` was added in v3.18.0 separately from the existing ``subaccount`` field. Both must coexist.""" - o = Order.model_validate({ - "order_id": "x", - "subaccount": 1, - "subaccount_number": 5, - }) + o = Order.model_validate( + order_dict( + order_id="x", + subaccount=1, + subaccount_number=5, + ) + ) assert o.subaccount == 1 assert o.subaccount_number == 5 def test_all_new_fields_default_to_none(self) -> None: - o = Order(order_id="x") + o = Order.model_validate(order_dict(order_id="x")) for name in ( - "outcome_side", - "book_side", + # NOTE: outcome_side / book_side were optional in v3.18.0 but + # #172 promoted them to required; they no longer default to None. "last_update_time", "self_trade_prevention_type", "order_group_id", @@ -337,13 +372,15 @@ class TestFillV3180Fields: """v3.18.0 backfill (issue #159): 4 new optional fields on ``Fill``.""" def test_parses_new_fields(self) -> None: - f = Fill.model_validate({ - "trade_id": "t1", - "outcome_side": "no", - "book_side": "ask", - "subaccount_number": 2, - "ts": 1733047200000, - }) + f = Fill.model_validate( + fill_dict( + trade_id="t1", + outcome_side="no", + book_side="ask", + subaccount_number=2, + ts=1733047200000, + ) + ) assert f.outcome_side == "no" assert f.book_side == "ask" assert f.subaccount_number == 2 @@ -353,14 +390,16 @@ def test_ts_is_int_not_datetime(self) -> None: """Spec declares ``ts: integer`` (Unix-ms timestamp). The SDK must NOT coerce it to ``datetime`` — it's the legacy companion to the typed ``created_time: datetime`` field, and callers depend on the raw int.""" - f = Fill.model_validate({"trade_id": "t1", "ts": 1733047200000}) + f = Fill.model_validate(fill_dict(trade_id="t1", ts=1733047200000)) assert f.ts == 1733047200000 assert isinstance(f.ts, int) assert not isinstance(f.ts, bool) # bools are ints; guard against ambiguity def test_all_new_fields_default_to_none(self) -> None: - f = Fill(trade_id="t1") - for name in ("outcome_side", "book_side", "subaccount_number", "ts"): + f = Fill.model_validate(fill_dict(trade_id="t1")) + # NOTE: outcome_side / book_side were optional in v3.18.0 but + # #172 promoted them to required; they no longer default to None. + for name in ("subaccount_number", "ts"): assert getattr(f, name) is None, f"{name} should default to None" @@ -368,19 +407,21 @@ 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, - }) + e = Event.model_validate( + event_dict( + 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") + e = Event.model_validate(event_dict(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" @@ -389,15 +430,17 @@ 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", - }) + m = EventMetadata.model_validate( + event_metadata_dict( + 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() + m = EventMetadata.model_validate(event_metadata_dict()) assert m.competition is None assert m.competition_scope is None @@ -411,13 +454,13 @@ class TestSettlementV3180Fields: """ def test_value_is_int_not_decimal(self) -> None: - s = Settlement.model_validate({"ticker": "T", "value": 100}) + s = Settlement.model_validate(settlement_dict(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") + s = Settlement.model_validate(settlement_dict(ticker="T")) assert s.value is None @@ -429,75 +472,86 @@ class TestTradeV3180Fields: """ def test_parses_new_fields(self) -> None: - t = Trade.model_validate({ - "trade_id": "tr-001", - "taker_outcome_side": "yes", - "taker_book_side": "bid", - }) + t = Trade.model_validate( + trade_dict( + 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 + def test_taker_outcome_side_missing_raises(self) -> None: + """Post-#172: ``taker_outcome_side`` / ``taker_book_side`` are required. + Omitting them on parse must raise instead of defaulting to None.""" + from pydantic import ValidationError + + data = trade_dict() + data.pop("taker_outcome_side") + data.pop("taker_book_side") + with pytest.raises(ValidationError): + Trade.model_validate(data) 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", - }) + p = IncentiveProgram.model_validate( + incentive_program_dict( + 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 + def test_incentive_description_missing_raises(self) -> None: + """Post-#172: ``incentive_description`` is required. + Omitting it on parse must raise instead of defaulting to None.""" + from pydantic import ValidationError + + data = incentive_program_dict() + data.pop("incentive_description") + with pytest.raises(ValidationError): + IncentiveProgram.model_validate(data) class TestRFQV3180Fields: """v3.18.0 backfill (issue #161): 1 new optional field on ``RFQ``.""" def test_parses_creator_subaccount(self) -> None: - r = RFQ.model_validate({ - "id": "rfq-1", - "creator_id": "user-1", - "market_ticker": "MKT", - "contracts_fp": "10.00", - "status": "open", - "created_ts": "2026-05-01T00:00:00Z", - "creator_subaccount": 3, - }) + r = RFQ.model_validate( + { + "id": "rfq-1", + "creator_id": "user-1", + "market_ticker": "MKT", + "contracts_fp": "10.00", + "status": "open", + "created_ts": "2026-05-01T00:00:00Z", + "creator_subaccount": 3, + } + ) assert r.creator_subaccount == 3 def test_creator_subaccount_defaults_to_none(self) -> None: - r = RFQ.model_validate({ - "id": "rfq-1", - "creator_id": "user-1", - "market_ticker": "MKT", - "contracts_fp": "10.00", - "status": "open", - "created_ts": "2026-05-01T00:00:00Z", - }) + r = RFQ.model_validate( + { + "id": "rfq-1", + "creator_id": "user-1", + "market_ticker": "MKT", + "contracts_fp": "10.00", + "status": "open", + "created_ts": "2026-05-01T00:00:00Z", + } + ) assert r.creator_subaccount is None @@ -520,7 +574,8 @@ class TestQuoteV3180Fields: def test_parses_all_new_fields(self) -> None: q = Quote.model_validate( - self._MINIMAL | { + self._MINIMAL + | { "creator_subaccount": 3, "rfq_creator_subaccount": 5, "post_only": True, @@ -541,41 +596,52 @@ class TestOrderGroupV3180Fields: """v3.18.0 backfill (issue #161): exchange_index on OrderGroup + responses.""" def test_order_group_parses_exchange_index(self) -> None: - g = OrderGroup.model_validate({ - "id": "g-1", - "is_auto_cancel_enabled": True, - "exchange_index": 7, - }) + g = OrderGroup.model_validate( + { + "id": "g-1", + "is_auto_cancel_enabled": True, + "exchange_index": 7, + } + ) assert g.exchange_index == 7 def test_order_group_exchange_index_defaults_to_none(self) -> None: - g = OrderGroup.model_validate({ - "id": "g-1", - "is_auto_cancel_enabled": True, - }) + g = OrderGroup.model_validate( + { + "id": "g-1", + "is_auto_cancel_enabled": True, + } + ) assert g.exchange_index is None def test_get_order_group_response_parses_exchange_index(self) -> None: - r = GetOrderGroupResponse.model_validate({ - "is_auto_cancel_enabled": True, - "orders": ["ord-1"], - "exchange_index": 7, - }) + r = GetOrderGroupResponse.model_validate( + get_order_group_response_dict( + is_auto_cancel_enabled=True, + orders=["ord-1"], + exchange_index=7, + ) + ) assert r.exchange_index == 7 def test_create_order_group_response_parses_subaccount_and_exchange_index(self) -> None: """Server echoes the routing context (subaccount + shard) on create.""" - r = CreateOrderGroupResponse.model_validate({ - "order_group_id": "g-1", - "subaccount": 4, - "exchange_index": 7, - }) + r = CreateOrderGroupResponse.model_validate( + create_order_group_response_dict( + order_group_id="g-1", + subaccount=4, + exchange_index=7, + ) + ) assert r.subaccount == 4 assert r.exchange_index == 7 def test_create_order_group_response_defaults_to_none(self) -> None: - r = CreateOrderGroupResponse.model_validate({"order_group_id": "g-1"}) - assert r.subaccount is None + r = CreateOrderGroupResponse.model_validate( + create_order_group_response_dict(order_group_id="g-1") + ) + # NOTE: subaccount was optional in v3.18.0 but #172 promoted it to + # required. exchange_index stays optional. assert r.exchange_index is None @@ -614,18 +680,18 @@ def test_parses_old_and_new_order(self) -> None: from kalshi.models.orders import AmendOrderResponse data = { - "old_order": { - "order_id": "ord-old", - "ticker": "MKT-A", - "yes_price_dollars": "0.5000", - "count": 5, - }, - "order": { - "order_id": "ord-new", - "ticker": "MKT-A", - "yes_price_dollars": "0.6500", - "count": 5, - }, + "old_order": order_dict( + order_id="ord-old", + ticker="MKT-A", + yes_price_dollars="0.5000", + initial_count_fp=5, + ), + "order": order_dict( + order_id="ord-new", + ticker="MKT-A", + yes_price_dollars="0.6500", + initial_count_fp=5, + ), } result = AmendOrderResponse.model_validate(data) assert result.old_order.order_id == "ord-old" @@ -654,7 +720,9 @@ def test_accepts_time_in_force(self) -> None: from kalshi.models.orders import CreateOrderRequest req = CreateOrderRequest( - ticker="MKT", side="yes", action="buy", + ticker="MKT", + side="yes", + action="buy", time_in_force="fill_or_kill", ) body = req.model_dump(exclude_none=True, by_alias=True) @@ -664,8 +732,11 @@ def test_accepts_post_only_and_reduce_only(self) -> None: from kalshi.models.orders import CreateOrderRequest req = CreateOrderRequest( - ticker="MKT", side="yes", action="buy", - post_only=True, reduce_only=False, + ticker="MKT", + side="yes", + action="buy", + post_only=True, + reduce_only=False, ) body = req.model_dump(exclude_none=True, by_alias=True) assert body["post_only"] is True @@ -675,7 +746,9 @@ def test_accepts_self_trade_prevention_and_order_group(self) -> None: from kalshi.models.orders import CreateOrderRequest req = CreateOrderRequest( - ticker="MKT", side="yes", action="buy", + ticker="MKT", + side="yes", + action="buy", self_trade_prevention_type="maker", order_group_id="grp-123", ) @@ -687,8 +760,11 @@ def test_accepts_cancel_on_pause_and_subaccount(self) -> None: from kalshi.models.orders import CreateOrderRequest req = CreateOrderRequest( - ticker="MKT", side="yes", action="buy", - cancel_order_on_pause=True, subaccount=5, + ticker="MKT", + side="yes", + action="buy", + cancel_order_on_pause=True, + subaccount=5, ) body = req.model_dump(exclude_none=True, by_alias=True) assert body["cancel_order_on_pause"] is True @@ -699,7 +775,9 @@ def test_buy_max_cost_is_int_cents(self) -> None: from kalshi.models.orders import CreateOrderRequest req = CreateOrderRequest( - ticker="MKT", side="yes", action="buy", + ticker="MKT", + side="yes", + action="buy", buy_max_cost=500, ) body = req.model_dump(exclude_none=True, by_alias=True) @@ -720,7 +798,9 @@ def test_buy_max_cost_rejects_fractional_value(self) -> None: with pytest.raises(ValidationError): CreateOrderRequest( - ticker="MKT", side="yes", action="buy", + ticker="MKT", + side="yes", + action="buy", buy_max_cost="5.5", # type: ignore[arg-type] ) @@ -736,12 +816,16 @@ def test_buy_max_cost_rejects_decimal(self) -> None: # silent coercion to cents regardless of the numeric value. with pytest.raises(ValidationError): CreateOrderRequest( - ticker="MKT", side="yes", action="buy", + ticker="MKT", + side="yes", + action="buy", buy_max_cost=Decimal("500"), # type: ignore[arg-type] ) with pytest.raises(ValidationError): CreateOrderRequest( - ticker="MKT", side="yes", action="buy", + ticker="MKT", + side="yes", + action="buy", buy_max_cost=Decimal("5.00"), # type: ignore[arg-type] ) @@ -753,7 +837,9 @@ def test_buy_max_cost_rejects_float(self) -> None: with pytest.raises(ValidationError): CreateOrderRequest( - ticker="MKT", side="yes", action="buy", + ticker="MKT", + side="yes", + action="buy", buy_max_cost=5.0, # type: ignore[arg-type] ) @@ -762,7 +848,9 @@ def test_buy_max_cost_accepts_int_string(self) -> None: from kalshi.models.orders import CreateOrderRequest req = CreateOrderRequest( - ticker="MKT", side="yes", action="buy", + ticker="MKT", + side="yes", + action="buy", buy_max_cost="500", # type: ignore[arg-type] ) body = req.model_dump(exclude_none=True, by_alias=True) @@ -789,7 +877,9 @@ def test_phantom_type_field_removed(self) -> None: with pytest.raises(ValidationError): CreateOrderRequest( - ticker="MKT", side="yes", action="buy", + ticker="MKT", + side="yes", + action="buy", type="limit", # type: ignore[call-arg] ) @@ -800,7 +890,9 @@ def test_forbid_extra_rejects_unknown_kwarg(self) -> None: with pytest.raises(ValidationError): CreateOrderRequest( - ticker="MKT", side="yes", action="buy", + ticker="MKT", + side="yes", + action="buy", bogus_field="x", # type: ignore[call-arg] ) @@ -808,7 +900,9 @@ def test_serializes_count_fp_not_count(self) -> None: from kalshi.models.orders import CreateOrderRequest req = CreateOrderRequest( - ticker="MKT", side="yes", action="buy", + ticker="MKT", + side="yes", + action="buy", count=Decimal("7"), ) body = req.model_dump(exclude_none=True, by_alias=True) @@ -835,7 +929,9 @@ def test_serializes_yes_price_dollars(self) -> None: from kalshi.models.orders import AmendOrderRequest req = AmendOrderRequest( - ticker="MKT", side="yes", action="buy", + ticker="MKT", + side="yes", + action="buy", yes_price=Decimal("0.55"), ) body = req.model_dump(exclude_none=True, by_alias=True, mode="json") @@ -848,7 +944,9 @@ def test_serializes_no_price_dollars(self) -> None: from kalshi.models.orders import AmendOrderRequest req = AmendOrderRequest( - ticker="MKT", side="no", action="sell", + ticker="MKT", + side="no", + action="sell", no_price=Decimal("0.75"), ) body = req.model_dump(exclude_none=True, by_alias=True, mode="json") @@ -861,7 +959,9 @@ def test_serializes_count_fp(self) -> None: from kalshi.models.orders import AmendOrderRequest req = AmendOrderRequest( - ticker="MKT", side="yes", action="buy", + ticker="MKT", + side="yes", + action="buy", count=Decimal("3"), ) body = req.model_dump(exclude_none=True, by_alias=True, mode="json") @@ -876,7 +976,9 @@ def test_forbid_extra(self) -> None: with pytest.raises(ValidationError): AmendOrderRequest( - ticker="MKT", side="yes", action="buy", + ticker="MKT", + side="yes", + action="buy", bogus_field="x", # type: ignore[call-arg] ) @@ -884,7 +986,9 @@ def test_accepts_client_order_ids(self) -> None: from kalshi.models.orders import AmendOrderRequest req = AmendOrderRequest( - ticker="MKT", side="yes", action="buy", + ticker="MKT", + side="yes", + action="buy", client_order_id="old-id", updated_client_order_id="new-id", ) @@ -896,7 +1000,10 @@ def test_accepts_subaccount(self) -> None: from kalshi.models.orders import AmendOrderRequest req = AmendOrderRequest( - ticker="MKT", side="yes", action="buy", subaccount=3, + ticker="MKT", + side="yes", + action="buy", + subaccount=3, ) body = req.model_dump(exclude_none=True, by_alias=True) assert body["subaccount"] == 3 diff --git a/tests/test_multivariate.py b/tests/test_multivariate.py index 059533d..0b1fdcc 100644 --- a/tests/test_multivariate.py +++ b/tests/test_multivariate.py @@ -17,22 +17,23 @@ AsyncMultivariateCollectionsResource, MultivariateCollectionsResource, ) +from tests._model_fixtures import multivariate_event_collection_dict BASE = "https://test.kalshi.com/trade-api/v2" -COLLECTION_PAYLOAD = { - "collection_ticker": "MVC-1", - "series_ticker": "SER-1", - "title": "Test", - "description": "", - "open_date": "2026-01-01T00:00:00Z", - "close_date": "2026-12-31T00:00:00Z", - "associated_events": [], - "is_ordered": False, - "size_min": 2, - "size_max": 5, - "functional_description": "", -} +COLLECTION_PAYLOAD = multivariate_event_collection_dict( + collection_ticker="MVC-1", + series_ticker="SER-1", + title="Test", + description="", + open_date="2026-01-01T00:00:00Z", + close_date="2026-12-31T00:00:00Z", + associated_events=[], + is_ordered=False, + size_min=2, + size_max=5, + functional_description="", +) @pytest.fixture @@ -54,10 +55,13 @@ class TestMultivariateList: @respx.mock def test_list_returns_page(self, mv: MultivariateCollectionsResource) -> None: respx.get(f"{BASE}/multivariate_event_collections").mock( - return_value=httpx.Response(200, json={ - "multivariate_contracts": [COLLECTION_PAYLOAD], - "cursor": "next-page", - }) + return_value=httpx.Response( + 200, + json={ + "multivariate_contracts": [COLLECTION_PAYLOAD], + "cursor": "next-page", + }, + ) ) page = mv.list() assert len(page.items) == 1 @@ -79,16 +83,22 @@ def test_list_with_filters(self, mv: MultivariateCollectionsResource) -> None: def test_list_all_auto_paginates(self, mv: MultivariateCollectionsResource) -> None: respx.get(f"{BASE}/multivariate_event_collections").mock( side_effect=[ - httpx.Response(200, json={ - "multivariate_contracts": [COLLECTION_PAYLOAD], - "cursor": "page2", - }), - httpx.Response(200, json={ - "multivariate_contracts": [ - {**COLLECTION_PAYLOAD, "collection_ticker": "MVC-2"}, - ], - "cursor": "", - }), + httpx.Response( + 200, + json={ + "multivariate_contracts": [COLLECTION_PAYLOAD], + "cursor": "page2", + }, + ), + httpx.Response( + 200, + json={ + "multivariate_contracts": [ + {**COLLECTION_PAYLOAD, "collection_ticker": "MVC-2"}, + ], + "cursor": "", + }, + ), ] ) items = list(mv.list_all()) @@ -111,10 +121,13 @@ class TestMultivariateCreateMarket: @respx.mock def test_create_market(self, mv: MultivariateCollectionsResource) -> None: route = respx.post(f"{BASE}/multivariate_event_collections/MVC-1").mock( - return_value=httpx.Response(200, json={ - "event_ticker": "EVT-1", - "market_ticker": "MKT-1", - }) + return_value=httpx.Response( + 200, + json={ + "event_ticker": "EVT-1", + "market_ticker": "MKT-1", + }, + ) ) pairs = [TickerPair(market_ticker="M-A", event_ticker="E-A", side="yes")] result = mv.create_market("MVC-1", selected_markets=pairs) @@ -130,10 +143,13 @@ class TestMultivariateLookupTickers: @respx.mock def test_lookup_tickers(self, mv: MultivariateCollectionsResource) -> None: respx.put(f"{BASE}/multivariate_event_collections/MVC-1/lookup").mock( - return_value=httpx.Response(200, json={ - "event_ticker": "EVT-1", - "market_ticker": "MKT-1", - }) + return_value=httpx.Response( + 200, + json={ + "event_ticker": "EVT-1", + "market_ticker": "MKT-1", + }, + ) ) pairs = [TickerPair(market_ticker="M-A", event_ticker="E-A", side="yes")] result = mv.lookup_tickers("MVC-1", selected_markets=pairs) @@ -145,7 +161,8 @@ def test_lookup_tickers_auth_guard(self, unauth_mv: MultivariateCollectionsResou @respx.mock def test_lookup_tickers_raises_on_204_spec_drift( - self, mv: MultivariateCollectionsResource, + self, + mv: MultivariateCollectionsResource, ) -> None: # Spec says this endpoint returns 200 with a body. If it ever # regresses to 204, we want a clear RuntimeError, not an opaque @@ -161,14 +178,19 @@ class TestMultivariateLookupHistory: @respx.mock def test_lookup_history(self, mv: MultivariateCollectionsResource) -> None: respx.get(f"{BASE}/multivariate_event_collections/MVC-1/lookup").mock( - return_value=httpx.Response(200, json={ - "lookup_points": [{ - "event_ticker": "EVT-1", - "market_ticker": "MKT-1", - "selected_markets": [], - "last_queried_ts": "2026-04-16T10:00:00Z", - }] - }) + return_value=httpx.Response( + 200, + json={ + "lookup_points": [ + { + "event_ticker": "EVT-1", + "market_ticker": "MKT-1", + "selected_markets": [], + "last_queried_ts": "2026-04-16T10:00:00Z", + } + ] + }, + ) ) result = mv.lookup_history("MVC-1", lookback_seconds=60) assert len(result) == 1 @@ -178,7 +200,9 @@ def test_lookup_history(self, mv: MultivariateCollectionsResource) -> None: class TestAsyncMultivariateCollectionsResource: @pytest.fixture def async_mv( - self, test_auth: KalshiAuth, config: KalshiConfig, + self, + test_auth: KalshiAuth, + config: KalshiConfig, ) -> AsyncMultivariateCollectionsResource: return AsyncMultivariateCollectionsResource( AsyncTransport(test_auth, config), @@ -192,9 +216,13 @@ def unauth_async_mv(self, config: KalshiConfig) -> AsyncMultivariateCollectionsR @pytest.mark.asyncio async def test_list(self, async_mv: AsyncMultivariateCollectionsResource) -> None: respx.get(f"{BASE}/multivariate_event_collections").mock( - return_value=httpx.Response(200, json={ - "multivariate_contracts": [COLLECTION_PAYLOAD], "cursor": "", - }) + return_value=httpx.Response( + 200, + json={ + "multivariate_contracts": [COLLECTION_PAYLOAD], + "cursor": "", + }, + ) ) page = await async_mv.list() assert len(page.items) == 1 @@ -219,7 +247,8 @@ async def test_create_market(self, async_mv: AsyncMultivariateCollectionsResourc @pytest.mark.asyncio async def test_create_market_auth_guard( - self, unauth_async_mv: AsyncMultivariateCollectionsResource, + self, + unauth_async_mv: AsyncMultivariateCollectionsResource, ) -> None: with pytest.raises(AuthRequiredError): await unauth_async_mv.create_market("MVC-1", selected_markets=[]) @@ -235,7 +264,8 @@ async def test_lookup_tickers(self, async_mv: AsyncMultivariateCollectionsResour @pytest.mark.asyncio async def test_lookup_tickers_auth_guard( - self, unauth_async_mv: AsyncMultivariateCollectionsResource, + self, + unauth_async_mv: AsyncMultivariateCollectionsResource, ) -> None: with pytest.raises(AuthRequiredError): await unauth_async_mv.lookup_tickers("MVC-1", selected_markets=[]) @@ -243,7 +273,8 @@ async def test_lookup_tickers_auth_guard( @respx.mock @pytest.mark.asyncio async def test_lookup_tickers_raises_on_204_spec_drift( - self, async_mv: AsyncMultivariateCollectionsResource, + self, + async_mv: AsyncMultivariateCollectionsResource, ) -> None: # Async sibling of the sync spec-drift guard. Issue #72. respx.put(f"{BASE}/multivariate_event_collections/MVC-1/lookup").mock( @@ -267,9 +298,7 @@ class TestCreateMarketWireShape: internally and serializes via model_dump.""" @respx.mock - def test_selected_markets_in_body( - self, mv: MultivariateCollectionsResource - ) -> None: + def test_selected_markets_in_body(self, mv: MultivariateCollectionsResource) -> None: import json route = respx.post(f"{BASE}/multivariate_event_collections/MVC-1").mock( @@ -376,9 +405,7 @@ class TestLookupTickersWireShape: internally and serializes via model_dump.""" @respx.mock - def test_only_selected_markets_in_body( - self, mv: MultivariateCollectionsResource - ) -> None: + def test_only_selected_markets_in_body(self, mv: MultivariateCollectionsResource) -> None: import json route = respx.put(f"{BASE}/multivariate_event_collections/MVC-1/lookup").mock( diff --git a/tests/test_multivariate_models.py b/tests/test_multivariate_models.py index 24f9477..be0b1da 100644 --- a/tests/test_multivariate_models.py +++ b/tests/test_multivariate_models.py @@ -9,35 +9,46 @@ MultivariateEventCollection, TickerPair, ) +from tests._model_fixtures import market_dict, multivariate_event_collection_dict class TestMultivariateEventCollectionModel: def test_parse_full(self) -> None: - c = MultivariateEventCollection.model_validate({ - "collection_ticker": "MVC-1", - "series_ticker": "SER-1", - "title": "Test Collection", - "description": "A test multivariate collection", - "open_date": "2026-01-01T00:00:00Z", - "close_date": "2026-12-31T23:59:59Z", - "associated_events": [ - { - "ticker": "EVT-A", "is_yes_only": True, - "size_max": 10, "size_min": 2, "active_quoters": ["q1"], - }, - { - "ticker": "EVT-B", "is_yes_only": False, - "size_max": None, "size_min": None, "active_quoters": [], - }, - ], - "associated_event_tickers": ["EVT-A", "EVT-B"], - "is_ordered": True, - "is_single_market_per_event": True, - "is_all_yes": False, - "size_min": 2, - "size_max": 10, - "functional_description": "Pick 2-10 outcomes", - }) + c = MultivariateEventCollection.model_validate( + { + **multivariate_event_collection_dict( + collection_ticker="MVC-1", + series_ticker="SER-1", + title="Test Collection", + description="A test multivariate collection", + open_date="2026-01-01T00:00:00Z", + close_date="2026-12-31T23:59:59Z", + is_ordered=True, + size_min=2, + size_max=10, + functional_description="Pick 2-10 outcomes", + ), + "associated_events": [ + { + "ticker": "EVT-A", + "is_yes_only": True, + "size_max": 10, + "size_min": 2, + "active_quoters": ["q1"], + }, + { + "ticker": "EVT-B", + "is_yes_only": False, + "size_max": None, + "size_min": None, + "active_quoters": [], + }, + ], + "associated_event_tickers": ["EVT-A", "EVT-B"], + "is_single_market_per_event": True, + "is_all_yes": False, + } + ) assert c.collection_ticker == "MVC-1" assert len(c.associated_events) == 2 assert c.associated_events[0].ticker == "EVT-A" @@ -46,30 +57,36 @@ def test_parse_full(self) -> None: assert c.size_min == 2 def test_extra_fields_allowed(self) -> None: - c = MultivariateEventCollection.model_validate({ - "collection_ticker": "T", - "series_ticker": "T", - "title": "T", - "description": "", - "open_date": "2026-01-01T00:00:00Z", - "close_date": "2026-01-02T00:00:00Z", - "associated_events": [], - "is_ordered": False, - "size_min": 1, - "size_max": 1, - "functional_description": "", - "brand_new_field": 42, - }) + c = MultivariateEventCollection.model_validate( + { + **multivariate_event_collection_dict( + collection_ticker="T", + series_ticker="T", + title="T", + description="", + open_date="2026-01-01T00:00:00Z", + close_date="2026-01-02T00:00:00Z", + associated_events=[], + is_ordered=False, + size_min=1, + size_max=1, + functional_description="", + ), + "brand_new_field": 42, + } + ) assert c.collection_ticker == "T" class TestTickerPairModel: def test_parse_and_serialize(self) -> None: - tp = TickerPair.model_validate({ - "market_ticker": "MKT-1", - "event_ticker": "EVT-1", - "side": "yes", - }) + tp = TickerPair.model_validate( + { + "market_ticker": "MKT-1", + "event_ticker": "EVT-1", + "side": "yes", + } + ) assert tp.market_ticker == "MKT-1" assert tp.side == "yes" @@ -80,43 +97,51 @@ def test_parse_and_serialize(self) -> None: class TestCreateMarketResponseModel: def test_with_market(self) -> None: - r = CreateMarketResponse.model_validate({ - "event_ticker": "EVT-1", - "market_ticker": "MKT-1", - "market": {"ticker": "MKT-1", "status": "open"}, - }) + r = CreateMarketResponse.model_validate( + { + "event_ticker": "EVT-1", + "market_ticker": "MKT-1", + "market": market_dict(ticker="MKT-1", status="open"), + } + ) assert r.market_ticker == "MKT-1" assert r.market is not None assert r.market.ticker == "MKT-1" def test_without_market(self) -> None: - r = CreateMarketResponse.model_validate({ - "event_ticker": "EVT-1", - "market_ticker": "MKT-1", - }) + r = CreateMarketResponse.model_validate( + { + "event_ticker": "EVT-1", + "market_ticker": "MKT-1", + } + ) assert r.market is None class TestLookupTickersResponseModel: def test_parse(self) -> None: - r = LookupTickersResponse.model_validate({ - "event_ticker": "EVT-1", - "market_ticker": "MKT-1", - }) + r = LookupTickersResponse.model_validate( + { + "event_ticker": "EVT-1", + "market_ticker": "MKT-1", + } + ) assert r.event_ticker == "EVT-1" class TestLookupPointModel: def test_parse_with_selected_markets(self) -> None: - lp = LookupPoint.model_validate({ - "event_ticker": "EVT-1", - "market_ticker": "MKT-1", - "selected_markets": [ - {"market_ticker": "M-A", "event_ticker": "E-A", "side": "yes"}, - {"market_ticker": "M-B", "event_ticker": "E-B", "side": "no"}, - ], - "last_queried_ts": "2026-04-16T10:00:00Z", - }) + lp = LookupPoint.model_validate( + { + "event_ticker": "EVT-1", + "market_ticker": "MKT-1", + "selected_markets": [ + {"market_ticker": "M-A", "event_ticker": "E-A", "side": "yes"}, + {"market_ticker": "M-B", "event_ticker": "E-B", "side": "no"}, + ], + "last_queried_ts": "2026-04-16T10:00:00Z", + } + ) assert len(lp.selected_markets) == 2 assert lp.selected_markets[0].side == "yes" assert lp.last_queried_ts is not None diff --git a/tests/test_order_groups.py b/tests/test_order_groups.py index f6fe3b4..1c0b9c5 100644 --- a/tests/test_order_groups.py +++ b/tests/test_order_groups.py @@ -31,6 +31,9 @@ AsyncOrderGroupsResource, OrderGroupsResource, ) +from tests._model_fixtures import ( + create_order_group_response_dict, +) @pytest.fixture @@ -49,7 +52,8 @@ def order_groups(test_auth: KalshiAuth, config: KalshiConfig) -> OrderGroupsReso @pytest.fixture def async_order_groups( - test_auth: KalshiAuth, config: KalshiConfig, + test_auth: KalshiAuth, + config: KalshiConfig, ) -> AsyncOrderGroupsResource: return AsyncOrderGroupsResource(AsyncTransport(test_auth, config)) @@ -103,7 +107,9 @@ def test_get_order_group_response_parses_orders(self) -> None: assert resp.contracts_limit == Decimal("10") def test_create_order_group_response_parses(self) -> None: - resp = CreateOrderGroupResponse.model_validate({"order_group_id": "grp-new"}) + resp = CreateOrderGroupResponse.model_validate( + create_order_group_response_dict(order_group_id="grp-new") + ) assert resp.order_group_id == "grp-new" @@ -147,11 +153,10 @@ def test_update_limit_request_forbids_extra(self) -> None: class TestOrderGroupsList: @respx.mock def test_list_returns_typed_order_groups( - self, order_groups: OrderGroupsResource, + self, + order_groups: OrderGroupsResource, ) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/order_groups" - ).mock( + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/order_groups").mock( return_value=httpx.Response( 200, json={ @@ -167,28 +172,27 @@ def test_list_returns_typed_order_groups( @respx.mock def test_list_sends_subaccount_query( - self, order_groups: OrderGroupsResource, + self, + order_groups: OrderGroupsResource, ) -> None: - route = respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/order_groups" - ).mock(return_value=httpx.Response(200, json={"order_groups": []})) + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/order_groups").mock( + return_value=httpx.Response(200, json={"order_groups": []}) + ) order_groups.list(subaccount=3) assert route.calls[0].request.url.params["subaccount"] == "3" @respx.mock def test_list_empty_response(self, order_groups: OrderGroupsResource) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/order_groups" - ).mock(return_value=httpx.Response(200, json={"order_groups": []})) + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/order_groups").mock( + return_value=httpx.Response(200, json={"order_groups": []}) + ) assert order_groups.list() == [] class TestOrderGroupsGet: @respx.mock def test_get_returns_full_response(self, order_groups: OrderGroupsResource) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/order_groups/grp-1" - ).mock( + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/order_groups/grp-1").mock( return_value=httpx.Response( 200, json={ @@ -204,9 +208,7 @@ def test_get_returns_full_response(self, order_groups: OrderGroupsResource) -> N @respx.mock def test_get_404_maps_to_not_found(self, order_groups: OrderGroupsResource) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/order_groups/missing" - ).mock( + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/order_groups/missing").mock( return_value=httpx.Response(404, json={"message": "order group not found"}) ) with pytest.raises(KalshiNotFoundError): @@ -219,7 +221,11 @@ def test_create_sends_correct_body(self, order_groups: OrderGroupsResource) -> N route = respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/order_groups/create" - ).mock(return_value=httpx.Response(201, json={"order_group_id": "grp-new"})) + ).mock( + return_value=httpx.Response( + 201, json=create_order_group_response_dict(order_group_id="grp-new") + ) + ) resp = order_groups.create(contracts_limit=5, subaccount=1) @@ -230,12 +236,17 @@ def test_create_sends_correct_body(self, order_groups: OrderGroupsResource) -> N @respx.mock def test_create_omits_subaccount_when_none( - self, order_groups: OrderGroupsResource, + self, + order_groups: OrderGroupsResource, ) -> None: route = respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/order_groups/create" - ).mock(return_value=httpx.Response(201, json={"order_group_id": "grp-new"})) + ).mock( + return_value=httpx.Response( + 201, json=create_order_group_response_dict(order_group_id="grp-new") + ) + ) order_groups.create(contracts_limit=5) @@ -244,11 +255,12 @@ def test_create_omits_subaccount_when_none( @respx.mock def test_create_400_maps_to_validation_error( - self, order_groups: OrderGroupsResource, + self, + order_groups: OrderGroupsResource, ) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/order_groups/create" - ).mock(return_value=httpx.Response(400, json={"message": "bad limit"})) + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/order_groups/create").mock( + return_value=httpx.Response(400, json={"message": "bad limit"}) + ) with pytest.raises(KalshiValidationError): order_groups.create(contracts_limit=5) @@ -295,23 +307,24 @@ def test_trigger_sends_put(self, order_groups: OrderGroupsResource) -> None: @respx.mock def test_reset_404(self, order_groups: OrderGroupsResource) -> None: - respx.put( - "https://test.kalshi.com/trade-api/v2/portfolio/order_groups/gone/reset" - ).mock(return_value=httpx.Response(404, json={"message": "not found"})) + respx.put("https://test.kalshi.com/trade-api/v2/portfolio/order_groups/gone/reset").mock( + return_value=httpx.Response(404, json={"message": "not found"}) + ) with pytest.raises(KalshiNotFoundError): order_groups.reset("gone") @respx.mock def test_trigger_404(self, order_groups: OrderGroupsResource) -> None: - respx.put( - "https://test.kalshi.com/trade-api/v2/portfolio/order_groups/gone/trigger" - ).mock(return_value=httpx.Response(404, json={"message": "not found"})) + respx.put("https://test.kalshi.com/trade-api/v2/portfolio/order_groups/gone/trigger").mock( + return_value=httpx.Response(404, json={"message": "not found"}) + ) with pytest.raises(KalshiNotFoundError): order_groups.trigger("gone") @respx.mock def test_update_limit_sends_put_with_body( - self, order_groups: OrderGroupsResource, + self, + order_groups: OrderGroupsResource, ) -> None: route = respx.put( @@ -325,9 +338,9 @@ def test_update_limit_sends_put_with_body( @respx.mock def test_update_limit_404(self, order_groups: OrderGroupsResource) -> None: - respx.put( - "https://test.kalshi.com/trade-api/v2/portfolio/order_groups/gone/limit" - ).mock(return_value=httpx.Response(404, json={"message": "not found"})) + respx.put("https://test.kalshi.com/trade-api/v2/portfolio/order_groups/gone/limit").mock( + return_value=httpx.Response(404, json={"message": "not found"}) + ) with pytest.raises(KalshiNotFoundError): order_groups.update_limit("gone", contracts_limit=5) @@ -335,12 +348,11 @@ def test_update_limit_404(self, order_groups: OrderGroupsResource) -> None: @pytest.mark.asyncio class TestAsyncOrderGroups: async def test_list( - self, async_order_groups: AsyncOrderGroupsResource, + self, + async_order_groups: AsyncOrderGroupsResource, respx_mock: respx.MockRouter, ) -> None: - respx_mock.get( - "https://test.kalshi.com/trade-api/v2/portfolio/order_groups" - ).mock( + respx_mock.get("https://test.kalshi.com/trade-api/v2/portfolio/order_groups").mock( return_value=httpx.Response(200, json={"order_groups": [_MINIMAL_OG]}) ) result = await async_order_groups.list() @@ -348,32 +360,35 @@ async def test_list( assert isinstance(result[0], OrderGroup) async def test_get( - self, async_order_groups: AsyncOrderGroupsResource, + self, + async_order_groups: AsyncOrderGroupsResource, respx_mock: respx.MockRouter, ) -> None: - respx_mock.get( - "https://test.kalshi.com/trade-api/v2/portfolio/order_groups/grp-1" - ).mock( - return_value=httpx.Response( - 200, json={"is_auto_cancel_enabled": True, "orders": []} - ) + respx_mock.get("https://test.kalshi.com/trade-api/v2/portfolio/order_groups/grp-1").mock( + return_value=httpx.Response(200, json={"is_auto_cancel_enabled": True, "orders": []}) ) resp = await async_order_groups.get("grp-1") assert isinstance(resp, GetOrderGroupResponse) async def test_create( - self, async_order_groups: AsyncOrderGroupsResource, + self, + async_order_groups: AsyncOrderGroupsResource, respx_mock: respx.MockRouter, ) -> None: route = respx_mock.post( "https://test.kalshi.com/trade-api/v2/portfolio/order_groups/create" - ).mock(return_value=httpx.Response(201, json={"order_group_id": "grp-x"})) + ).mock( + return_value=httpx.Response( + 201, json=create_order_group_response_dict(order_group_id="grp-x") + ) + ) resp = await async_order_groups.create(contracts_limit=3) assert resp.order_group_id == "grp-x" assert route.called async def test_delete( - self, async_order_groups: AsyncOrderGroupsResource, + self, + async_order_groups: AsyncOrderGroupsResource, respx_mock: respx.MockRouter, ) -> None: route = respx_mock.delete( @@ -383,7 +398,8 @@ async def test_delete( assert route.called async def test_reset( - self, async_order_groups: AsyncOrderGroupsResource, + self, + async_order_groups: AsyncOrderGroupsResource, respx_mock: respx.MockRouter, ) -> None: route = respx_mock.put( @@ -394,7 +410,8 @@ async def test_reset( assert route.calls[0].request.content == b"{}" async def test_trigger( - self, async_order_groups: AsyncOrderGroupsResource, + self, + async_order_groups: AsyncOrderGroupsResource, respx_mock: respx.MockRouter, ) -> None: route = respx_mock.put( @@ -405,7 +422,8 @@ async def test_trigger( assert route.calls[0].request.content == b"{}" async def test_update_limit( - self, async_order_groups: AsyncOrderGroupsResource, + self, + async_order_groups: AsyncOrderGroupsResource, respx_mock: respx.MockRouter, ) -> None: @@ -427,13 +445,15 @@ def test_get_requires_auth(self, unauth_order_groups: OrderGroupsResource) -> No unauth_order_groups.get("grp-1") def test_create_requires_auth( - self, unauth_order_groups: OrderGroupsResource, + self, + unauth_order_groups: OrderGroupsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_order_groups.create(contracts_limit=1) def test_delete_requires_auth( - self, unauth_order_groups: OrderGroupsResource, + self, + unauth_order_groups: OrderGroupsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_order_groups.delete("grp-1") @@ -443,13 +463,15 @@ def test_reset_requires_auth(self, unauth_order_groups: OrderGroupsResource) -> unauth_order_groups.reset("grp-1") def test_trigger_requires_auth( - self, unauth_order_groups: OrderGroupsResource, + self, + unauth_order_groups: OrderGroupsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_order_groups.trigger("grp-1") def test_update_limit_requires_auth( - self, unauth_order_groups: OrderGroupsResource, + self, + unauth_order_groups: OrderGroupsResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_order_groups.update_limit("grp-1", contracts_limit=1) @@ -457,11 +479,13 @@ def test_update_limit_requires_auth( class TestClientWiring: def test_sync_client_exposes_order_groups( - self, client: KalshiClient, + self, + client: KalshiClient, ) -> None: assert isinstance(client.order_groups, OrderGroupsResource) def test_async_client_exposes_order_groups( - self, async_client: AsyncKalshiClient, + self, + async_client: AsyncKalshiClient, ) -> None: assert isinstance(async_client.order_groups, AsyncOrderGroupsResource) diff --git a/tests/test_orders.py b/tests/test_orders.py index c08a7eb..fdc5147 100644 --- a/tests/test_orders.py +++ b/tests/test_orders.py @@ -30,6 +30,7 @@ DecreaseOrderV2Request, ) from kalshi.resources.orders import OrdersResource +from tests._model_fixtures import fill_dict, order_dict @pytest.fixture @@ -50,6 +51,7 @@ def orders(test_auth: KalshiAuth, config: KalshiConfig) -> OrdersResource: def client(test_auth: KalshiAuth) -> KalshiClient: """KalshiClient wired to the demo base URL (matches wire-shape test mocks).""" from kalshi.config import DEMO_BASE_URL + cfg = KalshiConfig(base_url=DEMO_BASE_URL, timeout=5.0, max_retries=0) return KalshiClient(auth=test_auth, config=cfg) @@ -59,12 +61,7 @@ def unauth_orders(config: KalshiConfig) -> OrdersResource: return OrdersResource(SyncTransport(None, config)) -_MINIMAL_ORDER = { - "order_id": "ord-123", - "ticker": "MKT", - "side": "yes", - "status": "resting", -} +_MINIMAL_ORDER = order_dict(order_id="ord-123", ticker="MKT", side="yes", status="resting") class TestCreateOrderWireShape: @@ -73,13 +70,15 @@ class TestCreateOrderWireShape: field; count must serialize as count_fp; new fields reach the wire.""" def test_no_phantom_type_in_wire( - self, client: KalshiClient, respx_mock: respx.MockRouter, + self, + client: KalshiClient, + respx_mock: respx.MockRouter, ) -> None: import json - route = respx_mock.post( - "https://demo-api.kalshi.co/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER})) + route = respx_mock.post("https://demo-api.kalshi.co/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}) + ) client.orders.create(ticker="MKT", side="yes", yes_price=0.5) @@ -87,13 +86,15 @@ def test_no_phantom_type_in_wire( assert "type" not in body def test_count_fp_not_count_in_wire( - self, client: KalshiClient, respx_mock: respx.MockRouter, + self, + client: KalshiClient, + respx_mock: respx.MockRouter, ) -> None: import json - route = respx_mock.post( - "https://demo-api.kalshi.co/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER})) + route = respx_mock.post("https://demo-api.kalshi.co/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}) + ) client.orders.create(ticker="MKT", side="yes", yes_price=0.5, count=3) @@ -103,16 +104,20 @@ def test_count_fp_not_count_in_wire( assert "count" not in body def test_time_in_force_reaches_wire( - self, client: KalshiClient, respx_mock: respx.MockRouter, + self, + client: KalshiClient, + respx_mock: respx.MockRouter, ) -> None: import json - route = respx_mock.post( - "https://demo-api.kalshi.co/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER})) + route = respx_mock.post("https://demo-api.kalshi.co/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}) + ) client.orders.create( - ticker="MKT", side="yes", yes_price=0.5, + ticker="MKT", + side="yes", + yes_price=0.5, time_in_force="fill_or_kill", ) @@ -120,17 +125,22 @@ def test_time_in_force_reaches_wire( assert body["time_in_force"] == "fill_or_kill" def test_post_only_reduce_only_reach_wire( - self, client: KalshiClient, respx_mock: respx.MockRouter, + self, + client: KalshiClient, + respx_mock: respx.MockRouter, ) -> None: import json - route = respx_mock.post( - "https://demo-api.kalshi.co/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER})) + route = respx_mock.post("https://demo-api.kalshi.co/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}) + ) client.orders.create( - ticker="MKT", side="yes", yes_price=0.5, - post_only=True, reduce_only=False, + ticker="MKT", + side="yes", + yes_price=0.5, + post_only=True, + reduce_only=False, ) body = json.loads(route.calls[0].request.content) @@ -138,14 +148,16 @@ def test_post_only_reduce_only_reach_wire( assert body["reduce_only"] is False def test_buy_max_cost_int_cents_wire( - self, client: KalshiClient, respx_mock: respx.MockRouter, + self, + client: KalshiClient, + respx_mock: respx.MockRouter, ) -> None: """Spec says cents. SDK must send int on the wire.""" import json - route = respx_mock.post( - "https://demo-api.kalshi.co/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER})) + route = respx_mock.post("https://demo-api.kalshi.co/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}) + ) client.orders.create(ticker="MKT", side="yes", yes_price=0.5, buy_max_cost=500) @@ -154,17 +166,22 @@ def test_buy_max_cost_int_cents_wire( assert isinstance(body["buy_max_cost"], int) def test_subaccount_order_group_cancel_on_pause_stp_wire( - self, client: KalshiClient, respx_mock: respx.MockRouter, + self, + client: KalshiClient, + respx_mock: respx.MockRouter, ) -> None: import json - route = respx_mock.post( - "https://demo-api.kalshi.co/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER})) + route = respx_mock.post("https://demo-api.kalshi.co/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json={"order": _MINIMAL_ORDER}) + ) client.orders.create( - ticker="MKT", side="yes", yes_price=0.5, - subaccount=2, order_group_id="grp-x", + ticker="MKT", + side="yes", + yes_price=0.5, + subaccount=2, + order_group_id="grp-x", cancel_order_on_pause=True, self_trade_prevention_type="maker", ) @@ -179,7 +196,8 @@ def test_type_kwarg_removed(self, client: KalshiClient) -> None: """v0.8.0 removed the `type` kwarg from orders.create().""" with pytest.raises(TypeError): client.orders.create( - ticker="MKT", side="yes", + ticker="MKT", + side="yes", type="market", # type: ignore[call-arg] ) @@ -191,14 +209,14 @@ def test_create_limit_order(self, orders: OrdersResource) -> None: return_value=httpx.Response( 200, json={ - "order": { - "order_id": "ord-123", - "ticker": "TEST-MKT", - "side": "yes", - "status": "resting", - "yes_price_dollars": "0.6500", - "count": 10, - } + "order": order_dict( + order_id="ord-123", + ticker="TEST-MKT", + side="yes", + status="resting", + yes_price_dollars="0.6500", + count_fp="10", + ) }, ) ) @@ -213,11 +231,7 @@ def test_create_order_no_price(self, orders: OrdersResource) -> None: return_value=httpx.Response( 200, json={ - "order": { - "order_id": "ord-456", - "ticker": "TEST-MKT", - "status": "executed", - } + "order": order_dict(order_id="ord-456", ticker="TEST-MKT", status="executed") }, ) ) @@ -228,12 +242,13 @@ def test_create_order_no_price(self, orders: OrdersResource) -> None: def test_decimal_price_conversion(self, orders: OrdersResource) -> None: route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( return_value=httpx.Response( - 200, json={"order": {"order_id": "ord-789", "ticker": "T"}} + 200, json={"order": order_dict(order_id="ord-789", ticker="T")} ) ) orders.create(ticker="T", side="yes", yes_price=0.65) import json + body = json.loads(route.calls[0].request.content) # Must be sent as yes_price_dollars (FixedPointDollars string) assert body["yes_price_dollars"] == "0.65" @@ -254,7 +269,7 @@ def test_returns_order(self, orders: OrdersResource) -> None: respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-123").mock( return_value=httpx.Response( 200, - json={"order": {"order_id": "ord-123", "ticker": "MKT", "status": "resting"}}, + json={"order": order_dict(order_id="ord-123", ticker="MKT", status="resting")}, ) ) order = orders.get("ord-123") @@ -280,9 +295,9 @@ def test_cancel_order(self, orders: OrdersResource) -> None: @respx.mock def test_cancel_with_subaccount(self, orders: OrdersResource) -> None: - route = respx.delete( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-456" - ).mock(return_value=httpx.Response(200, json={})) + route = respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-456").mock( + return_value=httpx.Response(200, json={}) + ) orders.cancel("ord-456", subaccount=42) params = dict(route.calls[0].request.url.params) assert params["subaccount"] == "42" @@ -296,8 +311,8 @@ def test_returns_page(self, orders: OrdersResource) -> None: 200, json={ "orders": [ - {"order_id": "ord-1", "ticker": "A"}, - {"order_id": "ord-2", "ticker": "B"}, + order_dict(order_id="ord-1", ticker="A"), + order_dict(order_id="ord-2", ticker="B"), ], "cursor": "next", }, @@ -373,7 +388,7 @@ def test_list_all_with_all_new_filters(self, orders: OrdersResource) -> None: route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( return_value=httpx.Response( 200, - json={"orders": [{"order_id": "ord-x", "ticker": "MKT-A"}], "cursor": ""}, + json={"orders": [order_dict(order_id="ord-x", ticker="MKT-A")], "cursor": ""}, ) ) list( @@ -405,15 +420,15 @@ def test_batch_create(self, orders: OrdersResource) -> None: 200, json={ "orders": [ - {"order_id": "b1", "ticker": "A"}, - {"order_id": "b2", "ticker": "B"}, + order_dict(order_id="b1", ticker="A"), + order_dict(order_id="b2", ticker="B"), ] }, ) ) reqs = [ - CreateOrderRequest(ticker="A", side="yes"), - CreateOrderRequest(ticker="B", side="no"), + CreateOrderRequest(ticker="A", side="yes", action="buy"), + CreateOrderRequest(ticker="B", side="no", action="buy"), ] result = orders.batch_create(reqs) assert len(result) == 2 @@ -428,12 +443,9 @@ def test_returns_fills(self, orders: OrdersResource) -> None: 200, json={ "fills": [ - { - "trade_id": "t1", - "order_id": "o1", - "yes_price_dollars": "0.5000", - "count": 5, - } + fill_dict( + trade_id="t1", order_id="o1", yes_price_dollars="0.5000", count_fp="5" + ) ] }, ) @@ -476,14 +488,14 @@ def test_auto_paginates(self, orders: OrdersResource) -> None: httpx.Response( 200, json={ - "fills": [{"trade_id": "a", "yes_price_dollars": "0.50"}], + "fills": [fill_dict(trade_id="a", yes_price_dollars="0.50")], "cursor": "p2", }, ), httpx.Response( 200, json={ - "fills": [{"trade_id": "b", "yes_price_dollars": "0.60"}], + "fills": [fill_dict(trade_id="b", yes_price_dollars="0.60")], "cursor": "", }, ), @@ -497,7 +509,8 @@ def test_fills_all_with_all_new_filters(self, orders: OrdersResource) -> None: """Consolidated coverage for v0.7.0 ADDs on fills_all: min_ts, max_ts, subaccount.""" route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/fills").mock( return_value=httpx.Response( - 200, json={"fills": [{"trade_id": "x", "yes_price_dollars": "0.5"}], "cursor": ""} + 200, + json={"fills": [fill_dict(trade_id="x", yes_price_dollars="0.5")], "cursor": ""}, ) ) list( @@ -522,28 +535,26 @@ def test_fills_all_with_all_new_filters(self, orders: OrdersResource) -> None: class TestOrdersAmend: @respx.mock def test_amend_price(self, orders: OrdersResource) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-100/amend" - ).mock( + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-100/amend").mock( return_value=httpx.Response( 200, json={ - "old_order": { - "order_id": "ord-100", - "ticker": "MKT-A", - "side": "yes", - "status": "resting", - "yes_price_dollars": "0.5000", - "count": 10, - }, - "order": { - "order_id": "ord-100", - "ticker": "MKT-A", - "side": "yes", - "status": "resting", - "yes_price_dollars": "0.6000", - "count": 10, - }, + "old_order": order_dict( + order_id="ord-100", + ticker="MKT-A", + side="yes", + status="resting", + yes_price_dollars="0.5000", + count_fp="10", + ), + "order": order_dict( + order_id="ord-100", + ticker="MKT-A", + side="yes", + status="resting", + yes_price_dollars="0.6000", + count_fp="10", + ), }, ) ) @@ -566,8 +577,8 @@ def test_amend_serializes_dollars_and_count(self, orders: OrdersResource) -> Non return_value=httpx.Response( 200, json={ - "old_order": {"order_id": "ord-200", "ticker": "T"}, - "order": {"order_id": "ord-200", "ticker": "T"}, + "old_order": order_dict(order_id="ord-200", ticker="T"), + "order": order_dict(order_id="ord-200", ticker="T"), }, ) ) @@ -590,17 +601,17 @@ def test_amend_serializes_dollars_and_count(self, orders: OrdersResource) -> Non @respx.mock def test_amend_not_found(self, orders: OrdersResource) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/fake/amend" - ).mock(return_value=httpx.Response(404, json={"message": "order not found"})) + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/fake/amend").mock( + return_value=httpx.Response(404, json={"message": "order not found"}) + ) with pytest.raises(KalshiNotFoundError): orders.amend("fake", ticker="T", side="yes", action="buy", yes_price=0.50) @respx.mock def test_amend_validation_error(self, orders: OrdersResource) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-300/amend" - ).mock(return_value=httpx.Response(400, json={"message": "invalid side"})) + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-300/amend").mock( + return_value=httpx.Response(400, json={"message": "invalid side"}) + ) with pytest.raises(KalshiValidationError): orders.amend("ord-300", ticker="T", side="invalid", action="buy", yes_price=0.50) @@ -612,18 +623,13 @@ def test_amend_requires_price_or_count(self, orders: OrdersResource) -> None: class TestOrdersDecrease: @respx.mock def test_decrease_by(self, orders: OrdersResource) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-400/decrease" - ).mock( + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-400/decrease").mock( return_value=httpx.Response( 200, json={ - "order": { - "order_id": "ord-400", - "ticker": "MKT-B", - "status": "resting", - "remaining_count_fp": "5", - } + "order": order_dict( + order_id="ord-400", ticker="MKT-B", status="resting", remaining_count_fp="5" + ) }, ) ) @@ -633,18 +639,16 @@ def test_decrease_by(self, orders: OrdersResource) -> None: @respx.mock def test_decrease_to(self, orders: OrdersResource) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-500/decrease" - ).mock( + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-500/decrease").mock( return_value=httpx.Response( 200, json={ - "order": { - "order_id": "ord-500", - "ticker": "MKT-C", - "status": "cancelled", - "remaining_count_fp": "0", - } + "order": order_dict( + order_id="ord-500", + ticker="MKT-C", + status="cancelled", + remaining_count_fp="0", + ) }, ) ) @@ -654,17 +658,15 @@ def test_decrease_to(self, orders: OrdersResource) -> None: @respx.mock def test_decrease_not_found(self, orders: OrdersResource) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/fake/decrease" - ).mock(return_value=httpx.Response(404, json={"message": "order not found"})) + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/fake/decrease").mock( + return_value=httpx.Response(404, json={"message": "order not found"}) + ) with pytest.raises(KalshiNotFoundError): orders.decrease("fake", reduce_by=1) @respx.mock def test_decrease_validation_error(self, orders: OrdersResource) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-600/decrease" - ).mock( + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-600/decrease").mock( return_value=httpx.Response(400, json={"message": "reduce_by must be > 0"}) ) with pytest.raises(KalshiValidationError): @@ -682,9 +684,7 @@ def test_decrease_rejects_both_reduce_args(self, orders: OrdersResource) -> None class TestOrdersQueuePositions: @respx.mock def test_queue_positions(self, orders: OrdersResource) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/queue_positions" - ).mock( + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders/queue_positions").mock( return_value=httpx.Response( 200, json={ @@ -713,18 +713,16 @@ def test_queue_positions(self, orders: OrdersResource) -> None: def test_queue_positions_with_filter(self, orders: OrdersResource) -> None: route = respx.get( "https://test.kalshi.com/trade-api/v2/portfolio/orders/queue_positions" - ).mock( - return_value=httpx.Response(200, json={"queue_positions": []}) - ) + ).mock(return_value=httpx.Response(200, json={"queue_positions": []})) orders.queue_positions(event_ticker="EVT-1") params = dict(route.calls[0].request.url.params) assert params["event_ticker"] == "EVT-1" @respx.mock def test_queue_positions_empty(self, orders: OrdersResource) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/queue_positions" - ).mock(return_value=httpx.Response(200, json={"queue_positions": []})) + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders/queue_positions").mock( + return_value=httpx.Response(200, json={"queue_positions": []}) + ) positions = orders.queue_positions() assert positions == [] @@ -732,9 +730,7 @@ def test_queue_positions_empty(self, orders: OrdersResource) -> None: def test_queue_positions_with_list_tickers(self, orders: OrdersResource) -> None: route = respx.get( "https://test.kalshi.com/trade-api/v2/portfolio/orders/queue_positions" - ).mock( - return_value=httpx.Response(200, json={"queue_positions": []}) - ) + ).mock(return_value=httpx.Response(200, json={"queue_positions": []})) orders.queue_positions(market_tickers=["MKT-A", "MKT-B"]) params = dict(route.calls[0].request.url.params) assert params["market_tickers"] == "MKT-A,MKT-B" @@ -743,11 +739,7 @@ def test_queue_positions_with_list_tickers(self, orders: OrdersResource) -> None def test_queue_position_single(self, orders: OrdersResource) -> None: respx.get( "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-800/queue_position" - ).mock( - return_value=httpx.Response( - 200, json={"queue_position_fp": "5"} - ) - ) + ).mock(return_value=httpx.Response(200, json={"queue_position_fp": "5"})) pos = orders.queue_position("ord-800") assert pos == Decimal("5") @@ -755,11 +747,7 @@ def test_queue_position_single(self, orders: OrdersResource) -> None: def test_queue_position_fallback_key(self, orders: OrdersResource) -> None: respx.get( "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-900/queue_position" - ).mock( - return_value=httpx.Response( - 200, json={"queue_position": "12"} - ) - ) + ).mock(return_value=httpx.Response(200, json={"queue_position": "12"})) pos = orders.queue_position("ord-900") assert pos == Decimal("12") @@ -775,9 +763,9 @@ def test_queue_position_missing_key_raises(self, orders: OrdersResource) -> None @respx.mock def test_queue_position_not_found(self, orders: OrdersResource) -> None: - respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/fake/queue_position" - ).mock(return_value=httpx.Response(404, json={"message": "order not found"})) + respx.get("https://test.kalshi.com/trade-api/v2/portfolio/orders/fake/queue_position").mock( + return_value=httpx.Response(404, json={"message": "order not found"}) + ) with pytest.raises(KalshiNotFoundError): orders.queue_position("fake") @@ -785,62 +773,63 @@ def test_queue_position_not_found(self, orders: OrdersResource) -> None: class TestOrdersAuthGuards: def test_amend_requires_auth(self, unauth_orders: OrdersResource) -> None: from kalshi.errors import AuthRequiredError + with pytest.raises(AuthRequiredError): unauth_orders.amend("ord-123", ticker="T", side="yes", action="buy") def test_decrease_requires_auth(self, unauth_orders: OrdersResource) -> None: from kalshi.errors import AuthRequiredError + with pytest.raises(AuthRequiredError): unauth_orders.decrease("ord-123", reduce_by=1) def test_queue_positions_requires_auth(self, unauth_orders: OrdersResource) -> None: from kalshi.errors import AuthRequiredError + with pytest.raises(AuthRequiredError): unauth_orders.queue_positions() def test_queue_position_requires_auth(self, unauth_orders: OrdersResource) -> None: from kalshi.errors import AuthRequiredError + with pytest.raises(AuthRequiredError): unauth_orders.queue_position("ord-123") class TestBatchCancelWireShape: @respx.mock - def test_wraps_str_ids_into_orders( - self, orders: OrdersResource - ) -> None: + def test_wraps_str_ids_into_orders(self, orders: OrdersResource) -> None: import json - route = respx.delete( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/batched" - ).mock(return_value=httpx.Response(200, json={})) + route = respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(200, json={}) + ) orders.batch_cancel(["ord-1", "ord-2"]) body = json.loads(route.calls[0].request.content) assert "ids" not in body # deprecated field no longer used - assert "orders" in body assert body["orders"] == [ {"order_id": "ord-1"}, {"order_id": "ord-2"}, ] @respx.mock - def test_accepts_typed_order_entries( - self, orders: OrdersResource - ) -> None: + def test_accepts_typed_order_entries(self, orders: OrdersResource) -> None: import json from kalshi.models.orders import BatchCancelOrdersRequestOrder - route = respx.delete( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/batched" - ).mock(return_value=httpx.Response(200, json={})) + route = respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(200, json={}) + ) - orders.batch_cancel([ - BatchCancelOrdersRequestOrder(order_id="ord-1", subaccount=5), - BatchCancelOrdersRequestOrder(order_id="ord-2"), - ]) + orders.batch_cancel( + [ + BatchCancelOrdersRequestOrder(order_id="ord-1", subaccount=5), + BatchCancelOrdersRequestOrder(order_id="ord-2"), + ] + ) body = json.loads(route.calls[0].request.content) assert body["orders"] == [ @@ -857,17 +846,16 @@ class TestBatchCancelRoutesThroughDeleteWithBody: """ @respx.mock - def test_batch_cancel_uses_delete_with_body_helper( - self, orders: OrdersResource - ) -> None: - respx.delete( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/batched" - ).mock(return_value=httpx.Response(200, json={})) + def test_batch_cancel_uses_delete_with_body_helper(self, orders: OrdersResource) -> None: + respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(200, json={}) + ) # patch.object + wraps forwards every call to the real method, # so the respx mock above still resolves; the spy only records. with patch.object( - orders, "_delete_with_body", + orders, + "_delete_with_body", wraps=orders._delete_with_body, ) as spy: orders.batch_cancel(["ord-1", "ord-2"]) @@ -877,37 +865,31 @@ def test_batch_cancel_uses_delete_with_body_helper( ) @respx.mock - def test_batch_cancel_parses_200_with_json_body( - self, orders: OrdersResource - ) -> None: + def test_batch_cancel_parses_200_with_json_body(self, orders: OrdersResource) -> None: """New base-class helper reads the response body on non-204; the old local helper discarded it. Confirms the sync path parses JSON without raising when the server returns 200 with content. """ - respx.delete( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/batched" - ).mock(return_value=httpx.Response(200, json={"cancelled": 2})) + respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(200, json={"cancelled": 2}) + ) orders.batch_cancel(["ord-1", "ord-2"]) # must not raise on JSON body @respx.mock - def test_batch_cancel_handles_204_no_content( - self, orders: OrdersResource - ) -> None: + def test_batch_cancel_handles_204_no_content(self, orders: OrdersResource) -> None: """Helper returns None on 204 — verifies sync goes through the shared response-handling path, not a raw ``transport.request`` call. Mirrors the async sibling. """ - respx.delete( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/batched" - ).mock(return_value=httpx.Response(204)) + respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(204) + ) orders.batch_cancel(["ord-1"]) # must not raise on empty body @respx.mock - def test_batch_cancel_200_with_empty_body_raises( - self, orders: OrdersResource - ) -> None: + def test_batch_cancel_200_with_empty_body_raises(self, orders: OrdersResource) -> None: """Documents an intentional behavior change: the old local helper discarded the response, so a hypothetical 200 with an empty body would have been silent. The new shared helper parses JSON on @@ -917,9 +899,9 @@ def test_batch_cancel_200_with_empty_body_raises( """ import json as _json - respx.delete( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/batched" - ).mock(return_value=httpx.Response(200)) + respx.delete("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(200) + ) with pytest.raises(_json.JSONDecodeError): orders.batch_cancel(["ord-1"]) @@ -936,10 +918,15 @@ def test_price_serializes_dollars_alias(self, orders: OrdersResource) -> None: route = respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-99/amend" - ).mock(return_value=httpx.Response(200, json={ - "old_order": {"order_id": "ord-99", "ticker": "MKT"}, - "order": {"order_id": "ord-99", "ticker": "MKT"}, - })) + ).mock( + return_value=httpx.Response( + 200, + json={ + "old_order": order_dict(order_id="ord-99", ticker="MKT"), + "order": order_dict(order_id="ord-99", ticker="MKT"), + }, + ) + ) orders.amend( "ord-99", @@ -959,10 +946,15 @@ def test_count_serializes_fp_alias(self, orders: OrdersResource) -> None: route = respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-99/amend" - ).mock(return_value=httpx.Response(200, json={ - "old_order": {"order_id": "ord-99", "ticker": "MKT"}, - "order": {"order_id": "ord-99", "ticker": "MKT"}, - })) + ).mock( + return_value=httpx.Response( + 200, + json={ + "old_order": order_dict(order_id="ord-99", ticker="MKT"), + "order": order_dict(order_id="ord-99", ticker="MKT"), + }, + ) + ) orders.amend( "ord-99", @@ -982,10 +974,15 @@ def test_required_and_optional_fields(self, orders: OrdersResource) -> None: route = respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-99/amend" - ).mock(return_value=httpx.Response(200, json={ - "old_order": {"order_id": "ord-99", "ticker": "MKT"}, - "order": {"order_id": "ord-99", "ticker": "MKT"}, - })) + ).mock( + return_value=httpx.Response( + 200, + json={ + "old_order": order_dict(order_id="ord-99", ticker="MKT"), + "order": order_dict(order_id="ord-99", ticker="MKT"), + }, + ) + ) orders.amend( "ord-99", @@ -1019,10 +1016,15 @@ def test_no_price_absent_when_not_passed(self, orders: OrdersResource) -> None: route = respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-99/amend" - ).mock(return_value=httpx.Response(200, json={ - "old_order": {"order_id": "ord-99", "ticker": "MKT"}, - "order": {"order_id": "ord-99", "ticker": "MKT"}, - })) + ).mock( + return_value=httpx.Response( + 200, + json={ + "old_order": order_dict(order_id="ord-99", ticker="MKT"), + "order": order_dict(order_id="ord-99", ticker="MKT"), + }, + ) + ) orders.amend( "ord-99", @@ -1048,9 +1050,16 @@ def test_reduce_by_body(self, orders: OrdersResource) -> None: route = respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-99/decrease" - ).mock(return_value=httpx.Response(200, json={ - "order": {"order_id": "ord-99", "ticker": "MKT", "side": "yes", "status": "resting"}, - })) + ).mock( + return_value=httpx.Response( + 200, + json={ + "order": order_dict( + order_id="ord-99", ticker="MKT", side="yes", status="resting" + ), + }, + ) + ) orders.decrease("ord-99", reduce_by=5, subaccount=1) @@ -1063,9 +1072,16 @@ def test_reduce_to_body(self, orders: OrdersResource) -> None: route = respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-99/decrease" - ).mock(return_value=httpx.Response(200, json={ - "order": {"order_id": "ord-99", "ticker": "MKT", "side": "yes", "status": "resting"}, - })) + ).mock( + return_value=httpx.Response( + 200, + json={ + "order": order_dict( + order_id="ord-99", ticker="MKT", side="yes", status="resting" + ), + }, + ) + ) orders.decrease("ord-99", reduce_to=2) @@ -1080,14 +1096,16 @@ class TestBatchCreateWireShape: def test_wraps_orders_key(self, orders: OrdersResource) -> None: import json - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/batched" - ).mock(return_value=httpx.Response(200, json={"orders": []})) + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(200, json={"orders": []}) + ) - orders.batch_create([ - CreateOrderRequest(ticker="A", side="yes"), - CreateOrderRequest(ticker="B", side="no"), - ]) + orders.batch_create( + [ + CreateOrderRequest(ticker="A", side="yes", action="buy"), + CreateOrderRequest(ticker="B", side="no", action="buy"), + ] + ) body = json.loads(route.calls[0].request.content) assert "orders" in body @@ -1240,7 +1258,8 @@ def test_returns_response(self, orders: OrdersResource) -> None: "https://test.kalshi.com/trade-api/v2/portfolio/events/orders/ord-1/amend", ).mock( return_value=httpx.Response( - 200, json={"order_id": "ord-1", "ts_ms": 1700000000000}, + 200, + json={"order_id": "ord-1", "ts_ms": 1700000000000}, ) ) result = orders.amend_v2( @@ -1273,14 +1292,17 @@ def test_passes_subaccount_query(self, orders: OrdersResource) -> None: "https://test.kalshi.com/trade-api/v2/portfolio/events/orders/ord-1/amend", ).mock( return_value=httpx.Response( - 200, json={"order_id": "ord-1", "ts_ms": 0}, + 200, + json={"order_id": "ord-1", "ts_ms": 0}, ) ) orders.amend_v2( "ord-1", request=AmendOrderV2Request( - ticker="MKT-A", side="bid", - price=Decimal("0.55"), count=Decimal("10"), + ticker="MKT-A", + side="bid", + price=Decimal("0.55"), + count=Decimal("10"), exchange_index=0, ), subaccount=7, @@ -1317,7 +1339,8 @@ def test_returns_response(self, orders: OrdersResource) -> None: def test_xor_rejects_both(self) -> None: with pytest.raises(ValueError, match="not both"): DecreaseOrderV2Request( - reduce_by=Decimal("2"), reduce_to=Decimal("5"), + reduce_by=Decimal("2"), + reduce_to=Decimal("5"), ) def test_xor_requires_one(self) -> None: @@ -1337,7 +1360,8 @@ def test_passes_subaccount_query(self, orders: OrdersResource) -> None: orders.decrease_v2( "ord-1", request=DecreaseOrderV2Request( - reduce_by=Decimal("2"), exchange_index=0, + reduce_by=Decimal("2"), + exchange_index=0, ), subaccount=4, ) @@ -1510,7 +1534,9 @@ def _create_request(self) -> CreateOrderV2Request: ) def test_create_v2( - self, unauth_orders: OrdersResource, _create_request: CreateOrderV2Request, + self, + unauth_orders: OrdersResource, + _create_request: CreateOrderV2Request, ) -> None: with pytest.raises(AuthRequiredError): unauth_orders.create_v2(request=_create_request) @@ -1524,8 +1550,10 @@ def test_amend_v2(self, unauth_orders: OrdersResource) -> None: unauth_orders.amend_v2( "ord-1", request=AmendOrderV2Request( - ticker="MKT-A", side="bid", - price=Decimal("0.55"), count=Decimal("10"), + ticker="MKT-A", + side="bid", + price=Decimal("0.55"), + count=Decimal("10"), ), ) @@ -1537,7 +1565,9 @@ def test_decrease_v2(self, unauth_orders: OrdersResource) -> None: ) def test_batch_create_v2( - self, unauth_orders: OrdersResource, _create_request: CreateOrderV2Request, + self, + unauth_orders: OrdersResource, + _create_request: CreateOrderV2Request, ) -> None: with pytest.raises(AuthRequiredError): unauth_orders.batch_create_v2( diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 5727198..cebabd7 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -4,10 +4,11 @@ from kalshi.models.common import Page from kalshi.models.markets import Market +from tests._model_fixtures import market_dict def _market(ticker: str) -> Market: - return Market(ticker=ticker) + return Market.model_validate(market_dict(ticker=ticker)) class TestPage: diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index 5c93773..a8b0214 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -13,6 +13,11 @@ from kalshi.config import KalshiConfig from kalshi.errors import AuthRequiredError, KalshiAuthError from kalshi.resources.portfolio import AsyncPortfolioResource, PortfolioResource +from tests._model_fixtures import ( + event_position_dict, + market_position_dict, + settlement_dict, +) @pytest.fixture @@ -30,9 +35,7 @@ def portfolio(test_auth: KalshiAuth, config: KalshiConfig) -> PortfolioResource: @pytest.fixture -def async_portfolio( - test_auth: KalshiAuth, config: KalshiConfig -) -> AsyncPortfolioResource: +def async_portfolio(test_auth: KalshiAuth, config: KalshiConfig) -> AsyncPortfolioResource: return AsyncPortfolioResource(AsyncTransport(test_auth, config)) @@ -161,25 +164,25 @@ def test_returns_positions(self, portfolio: PortfolioResource) -> None: 200, json={ "market_positions": [ - { - "ticker": "MKT-A", - "total_traded_dollars": "100.0000", - "position_fp": "50.00", - "market_exposure_dollars": "25.0000", - "realized_pnl_dollars": "10.0000", - "fees_paid_dollars": "1.5000", - "resting_orders_count": 2, - } + market_position_dict( + ticker="MKT-A", + total_traded_dollars="100.0000", + position_fp="50.00", + market_exposure_dollars="25.0000", + realized_pnl_dollars="10.0000", + fees_paid_dollars="1.5000", + resting_orders_count=2, + ) ], "event_positions": [ - { - "event_ticker": "EVT-1", - "total_cost_dollars": "200.0000", - "total_cost_shares_fp": "100.00", - "event_exposure_dollars": "50.0000", - "realized_pnl_dollars": "20.0000", - "fees_paid_dollars": "3.0000", - } + event_position_dict( + event_ticker="EVT-1", + total_cost_dollars="200.0000", + total_cost_shares_fp="100.00", + event_exposure_dollars="50.0000", + realized_pnl_dollars="20.0000", + fees_paid_dollars="3.0000", + ) ], "cursor": "next-page", }, @@ -247,9 +250,7 @@ def test_settlement_status_kwarg_removed(self, portfolio: PortfolioResource) -> @respx.mock def test_positions_with_all_new_filters(self, portfolio: PortfolioResource) -> None: """v0.7.0 ADDs: count_filter, ticker, subaccount.""" - route = respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/positions" - ).mock( + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/positions").mock( return_value=httpx.Response( 200, json={"market_positions": [], "event_positions": [], "cursor": ""} ) @@ -315,17 +316,17 @@ def test_void_settlement(self, portfolio: PortfolioResource) -> None: 200, json={ "settlements": [ - { - "ticker": "MKT-V", - "market_result": "void", - "yes_count_fp": "5.00", - "yes_total_cost_dollars": "3.0000", - "no_count_fp": "0.00", - "no_total_cost_dollars": "0.0000", - "revenue": 300, - "settled_time": "2026-04-12T12:00:00Z", - "fee_cost": "0.0000", - } + settlement_dict( + ticker="MKT-V", + market_result="void", + yes_count_fp="5.00", + yes_total_cost_dollars="3.0000", + no_count_fp="0.00", + no_total_cost_dollars="0.0000", + revenue=300, + settled_time="2026-04-12T12:00:00Z", + fee_cost="0.0000", + ) ], "cursor": "", }, @@ -350,17 +351,17 @@ def test_settlements_all_paginates(self, portfolio: PortfolioResource) -> None: 200, json={ "settlements": [ - { - "ticker": "A", - "market_result": "yes", - "yes_count_fp": "1.00", - "yes_total_cost_dollars": "0.50", - "no_count_fp": "0", - "no_total_cost_dollars": "0", - "revenue": 100, - "settled_time": "2026-04-12T12:00:00Z", - "fee_cost": "0.01", - } + settlement_dict( + ticker="A", + market_result="yes", + yes_count_fp="1.00", + yes_total_cost_dollars="0.50", + no_count_fp="0", + no_total_cost_dollars="0", + revenue=100, + settled_time="2026-04-12T12:00:00Z", + fee_cost="0.01", + ) ], "cursor": "page2", }, @@ -369,17 +370,17 @@ def test_settlements_all_paginates(self, portfolio: PortfolioResource) -> None: 200, json={ "settlements": [ - { - "ticker": "B", - "market_result": "no", - "yes_count_fp": "0", - "yes_total_cost_dollars": "0", - "no_count_fp": "2.00", - "no_total_cost_dollars": "1.00", - "revenue": 200, - "settled_time": "2026-04-12T13:00:00Z", - "fee_cost": "0.02", - } + settlement_dict( + ticker="B", + market_result="no", + yes_count_fp="0", + yes_total_cost_dollars="0", + no_count_fp="2.00", + no_total_cost_dollars="1.00", + revenue=200, + settled_time="2026-04-12T13:00:00Z", + fee_cost="0.02", + ) ], "cursor": "", }, @@ -390,13 +391,11 @@ def test_settlements_all_paginates(self, portfolio: PortfolioResource) -> None: assert tickers == ["A", "B"] @respx.mock - def test_settlements_with_all_new_filters( - self, portfolio: PortfolioResource - ) -> None: + def test_settlements_with_all_new_filters(self, portfolio: PortfolioResource) -> None: """v0.7.0 ADDs: event_ticker, min_ts, max_ts, subaccount.""" - route = respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/settlements" - ).mock(return_value=httpx.Response(200, json={"settlements": []})) + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/settlements").mock( + return_value=httpx.Response(200, json={"settlements": []}) + ) portfolio.settlements( ticker="MKT-A", event_ticker="EVT-X", @@ -412,16 +411,10 @@ def test_settlements_with_all_new_filters( assert params["subaccount"] == "7" @respx.mock - def test_settlements_all_with_all_new_filters( - self, portfolio: PortfolioResource - ) -> None: + def test_settlements_all_with_all_new_filters(self, portfolio: PortfolioResource) -> None: """v0.7.0 ADDs on settlements_all match settlements (no cursor).""" - route = respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/settlements" - ).mock( - return_value=httpx.Response( - 200, json={"settlements": [], "cursor": ""} - ) + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/settlements").mock( + return_value=httpx.Response(200, json={"settlements": [], "cursor": ""}) ) list( portfolio.settlements_all( @@ -447,7 +440,8 @@ def test_returns_value(self, portfolio: PortfolioResource) -> None: "https://test.kalshi.com/trade-api/v2/portfolio/summary/total_resting_order_value", ).mock( return_value=httpx.Response( - 200, json={"total_resting_order_value": 12345}, + 200, + json={"total_resting_order_value": 12345}, ) ) result = portfolio.total_resting_order_value() @@ -464,14 +458,22 @@ def test_unauthorized(self, portfolio: PortfolioResource) -> None: _DEPOSIT = { - "id": "dep_1", "status": "applied", "type": "ach", - "amount_cents": 10000, "fee_cents": 0, "created_ts": 1700000000, + "id": "dep_1", + "status": "applied", + "type": "ach", + "amount_cents": 10000, + "fee_cents": 0, + "created_ts": 1700000000, "finalized_ts": 1700001000, } _WITHDRAWAL = { - "id": "wd_1", "status": "pending", "type": "wire", - "amount_cents": 5000, "fee_cents": 25, "created_ts": 1700000000, + "id": "wd_1", + "status": "pending", + "type": "wire", + "amount_cents": 5000, + "fee_cents": 25, + "created_ts": 1700000000, "finalized_ts": None, } @@ -481,7 +483,8 @@ class TestPortfolioDeposits: def test_returns_page(self, portfolio: PortfolioResource) -> None: respx.get("https://test.kalshi.com/trade-api/v2/portfolio/deposits").mock( return_value=httpx.Response( - 200, json={"deposits": [_DEPOSIT], "cursor": "abc"}, + 200, + json={"deposits": [_DEPOSIT], "cursor": "abc"}, ) ) page = portfolio.deposits(limit=10) @@ -524,7 +527,8 @@ def test_all_max_pages_caps(self, portfolio: PortfolioResource) -> None: respx.get("https://test.kalshi.com/trade-api/v2/portfolio/deposits").mock( side_effect=[ httpx.Response( - 200, json={"deposits": [_DEPOSIT], "cursor": "p2"}, + 200, + json={"deposits": [_DEPOSIT], "cursor": "p2"}, ), httpx.Response( 200, @@ -545,13 +549,15 @@ def test_auth_failure(self, portfolio: PortfolioResource) -> None: portfolio.deposits() def test_deposits_requires_auth( - self, unauth_portfolio: PortfolioResource, + self, + unauth_portfolio: PortfolioResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_portfolio.deposits() def test_deposits_all_requires_auth( - self, unauth_portfolio: PortfolioResource, + self, + unauth_portfolio: PortfolioResource, ) -> None: # *_all returns an iterator — must raise eagerly, not on first iteration. with pytest.raises(AuthRequiredError): @@ -565,7 +571,8 @@ def test_returns_page(self, portfolio: PortfolioResource) -> None: "https://test.kalshi.com/trade-api/v2/portfolio/withdrawals", ).mock( return_value=httpx.Response( - 200, json={"withdrawals": [_WITHDRAWAL], "cursor": None}, + 200, + json={"withdrawals": [_WITHDRAWAL], "cursor": None}, ) ) page = portfolio.withdrawals() @@ -582,7 +589,8 @@ def test_all_paginates(self, portfolio: PortfolioResource) -> None: ).mock( side_effect=[ httpx.Response( - 200, json={"withdrawals": [_WITHDRAWAL], "cursor": "p2"}, + 200, + json={"withdrawals": [_WITHDRAWAL], "cursor": "p2"}, ), httpx.Response( 200, @@ -597,20 +605,20 @@ def test_all_paginates(self, portfolio: PortfolioResource) -> None: def test_auth_failure(self, portfolio: PortfolioResource) -> None: respx.get( "https://test.kalshi.com/trade-api/v2/portfolio/withdrawals", - ).mock( - return_value=httpx.Response(401, json={"error": "unauthorized"}) - ) + ).mock(return_value=httpx.Response(401, json={"error": "unauthorized"})) with pytest.raises(KalshiAuthError): portfolio.withdrawals() def test_withdrawals_requires_auth( - self, unauth_portfolio: PortfolioResource, + self, + unauth_portfolio: PortfolioResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_portfolio.withdrawals() def test_withdrawals_all_requires_auth( - self, unauth_portfolio: PortfolioResource, + self, + unauth_portfolio: PortfolioResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_portfolio.withdrawals_all() @@ -622,9 +630,7 @@ def test_withdrawals_all_requires_auth( class TestAsyncPortfolioBalance: @respx.mock @pytest.mark.asyncio - async def test_returns_balance( - self, async_portfolio: AsyncPortfolioResource - ) -> None: + async def test_returns_balance(self, async_portfolio: AsyncPortfolioResource) -> None: respx.get("https://test.kalshi.com/trade-api/v2/portfolio/balance").mock( return_value=httpx.Response( 200, @@ -643,13 +649,9 @@ async def test_returns_balance( @respx.mock @pytest.mark.asyncio - async def test_balance_with_subaccount( - self, async_portfolio: AsyncPortfolioResource - ) -> None: + async def test_balance_with_subaccount(self, async_portfolio: AsyncPortfolioResource) -> None: """v0.7.0 ADD: subaccount kwarg reaches the wire.""" - route = respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/balance" - ).mock( + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/balance").mock( return_value=httpx.Response( 200, json={ @@ -667,22 +669,20 @@ async def test_balance_with_subaccount( class TestAsyncPortfolioPositions: @respx.mock @pytest.mark.asyncio - async def test_returns_positions( - self, async_portfolio: AsyncPortfolioResource - ) -> None: + async def test_returns_positions(self, async_portfolio: AsyncPortfolioResource) -> None: respx.get("https://test.kalshi.com/trade-api/v2/portfolio/positions").mock( return_value=httpx.Response( 200, json={ "market_positions": [ - { - "ticker": "MKT-A", - "total_traded_dollars": "100.0000", - "position_fp": "50.00", - "market_exposure_dollars": "25.0000", - "realized_pnl_dollars": "10.0000", - "fees_paid_dollars": "1.5000", - } + market_position_dict( + ticker="MKT-A", + total_traded_dollars="100.0000", + position_fp="50.00", + market_exposure_dollars="25.0000", + realized_pnl_dollars="10.0000", + fees_paid_dollars="1.5000", + ) ], "event_positions": [], "cursor": "next", @@ -696,9 +696,7 @@ async def test_returns_positions( @respx.mock @pytest.mark.asyncio - async def test_empty_positions( - self, async_portfolio: AsyncPortfolioResource - ) -> None: + async def test_empty_positions(self, async_portfolio: AsyncPortfolioResource) -> None: respx.get("https://test.kalshi.com/trade-api/v2/portfolio/positions").mock( return_value=httpx.Response( 200, @@ -723,9 +721,7 @@ async def test_positions_with_all_new_filters( self, async_portfolio: AsyncPortfolioResource ) -> None: """v0.7.0 ADDs: count_filter, ticker, subaccount.""" - route = respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/positions" - ).mock( + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/positions").mock( return_value=httpx.Response( 200, json={"market_positions": [], "event_positions": [], "cursor": ""} ) @@ -750,25 +746,23 @@ async def test_positions_with_all_new_filters( class TestAsyncPortfolioSettlements: @respx.mock @pytest.mark.asyncio - async def test_returns_settlements( - self, async_portfolio: AsyncPortfolioResource - ) -> None: + async def test_returns_settlements(self, async_portfolio: AsyncPortfolioResource) -> None: respx.get("https://test.kalshi.com/trade-api/v2/portfolio/settlements").mock( return_value=httpx.Response( 200, json={ "settlements": [ - { - "ticker": "MKT-A", - "market_result": "yes", - "yes_count_fp": "10.00", - "yes_total_cost_dollars": "6.5000", - "no_count_fp": "0", - "no_total_cost_dollars": "0", - "revenue": 1000, - "settled_time": "2026-04-12T12:00:00Z", - "fee_cost": "0.34", - } + settlement_dict( + ticker="MKT-A", + market_result="yes", + yes_count_fp="10.00", + yes_total_cost_dollars="6.5000", + no_count_fp="0", + no_total_cost_dollars="0", + revenue=1000, + settled_time="2026-04-12T12:00:00Z", + fee_cost="0.34", + ) ], "cursor": "", }, @@ -780,26 +774,24 @@ async def test_returns_settlements( @respx.mock @pytest.mark.asyncio - async def test_settlements_all_paginates( - self, async_portfolio: AsyncPortfolioResource - ) -> None: + async def test_settlements_all_paginates(self, async_portfolio: AsyncPortfolioResource) -> None: respx.get("https://test.kalshi.com/trade-api/v2/portfolio/settlements").mock( side_effect=[ httpx.Response( 200, json={ "settlements": [ - { - "ticker": "A", - "market_result": "yes", - "yes_count_fp": "1", - "yes_total_cost_dollars": "0.5", - "no_count_fp": "0", - "no_total_cost_dollars": "0", - "revenue": 100, - "settled_time": "2026-04-12T12:00:00Z", - "fee_cost": "0.01", - } + settlement_dict( + ticker="A", + market_result="yes", + yes_count_fp="1", + yes_total_cost_dollars="0.5", + no_count_fp="0", + no_total_cost_dollars="0", + revenue=100, + settled_time="2026-04-12T12:00:00Z", + fee_cost="0.01", + ) ], "cursor": "p2", }, @@ -808,17 +800,17 @@ async def test_settlements_all_paginates( 200, json={ "settlements": [ - { - "ticker": "B", - "market_result": "no", - "yes_count_fp": "0", - "yes_total_cost_dollars": "0", - "no_count_fp": "1", - "no_total_cost_dollars": "0.5", - "revenue": 100, - "settled_time": "2026-04-12T13:00:00Z", - "fee_cost": "0.01", - } + settlement_dict( + ticker="B", + market_result="no", + yes_count_fp="0", + yes_total_cost_dollars="0", + no_count_fp="1", + no_total_cost_dollars="0.5", + revenue=100, + settled_time="2026-04-12T13:00:00Z", + fee_cost="0.01", + ) ], "cursor": "", }, @@ -834,9 +826,9 @@ async def test_settlements_with_all_new_filters( self, async_portfolio: AsyncPortfolioResource ) -> None: """v0.7.0 ADDs: event_ticker, min_ts, max_ts, subaccount.""" - route = respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/settlements" - ).mock(return_value=httpx.Response(200, json={"settlements": []})) + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/settlements").mock( + return_value=httpx.Response(200, json={"settlements": []}) + ) await async_portfolio.settlements( ticker="MKT-A", event_ticker="EVT-X", @@ -857,20 +849,19 @@ async def test_settlements_all_with_all_new_filters( self, async_portfolio: AsyncPortfolioResource ) -> None: """v0.7.0 ADDs on settlements_all match settlements (no cursor).""" - route = respx.get( - "https://test.kalshi.com/trade-api/v2/portfolio/settlements" - ).mock( - return_value=httpx.Response( - 200, json={"settlements": [], "cursor": ""} - ) + route = respx.get("https://test.kalshi.com/trade-api/v2/portfolio/settlements").mock( + return_value=httpx.Response(200, json={"settlements": [], "cursor": ""}) ) - _ = [s async for s in async_portfolio.settlements_all( - ticker="MKT-A", - event_ticker="EVT-X", - min_ts=1700000000, - max_ts=1700099999, - subaccount=7, - )] + _ = [ + s + async for s in async_portfolio.settlements_all( + ticker="MKT-A", + event_ticker="EVT-X", + min_ts=1700000000, + max_ts=1700099999, + subaccount=7, + ) + ] params = dict(route.calls[0].request.url.params) assert params["ticker"] == "MKT-A" assert params["event_ticker"] == "EVT-X" @@ -883,13 +874,15 @@ class TestAsyncPortfolioTotalRestingOrderValue: @respx.mock @pytest.mark.asyncio async def test_returns_value( - self, async_portfolio: AsyncPortfolioResource, + self, + async_portfolio: AsyncPortfolioResource, ) -> None: respx.get( "https://test.kalshi.com/trade-api/v2/portfolio/summary/total_resting_order_value", ).mock( return_value=httpx.Response( - 200, json={"total_resting_order_value": 99999}, + 200, + json={"total_resting_order_value": 99999}, ) ) result = await async_portfolio.total_resting_order_value() @@ -898,7 +891,8 @@ async def test_returns_value( @respx.mock @pytest.mark.asyncio async def test_unauthorized( - self, async_portfolio: AsyncPortfolioResource, + self, + async_portfolio: AsyncPortfolioResource, ) -> None: """Demo returns 401/403 for non-FCM accounts — verify error mapping.""" respx.get( @@ -912,11 +906,13 @@ class TestAsyncPortfolioDeposits: @respx.mock @pytest.mark.asyncio async def test_returns_page( - self, async_portfolio: AsyncPortfolioResource, + self, + async_portfolio: AsyncPortfolioResource, ) -> None: respx.get("https://test.kalshi.com/trade-api/v2/portfolio/deposits").mock( return_value=httpx.Response( - 200, json={"deposits": [_DEPOSIT], "cursor": None}, + 200, + json={"deposits": [_DEPOSIT], "cursor": None}, ) ) page = await async_portfolio.deposits() @@ -926,12 +922,14 @@ async def test_returns_page( @respx.mock @pytest.mark.asyncio async def test_all_paginates( - self, async_portfolio: AsyncPortfolioResource, + self, + async_portfolio: AsyncPortfolioResource, ) -> None: respx.get("https://test.kalshi.com/trade-api/v2/portfolio/deposits").mock( side_effect=[ httpx.Response( - 200, json={"deposits": [_DEPOSIT], "cursor": "p2"}, + 200, + json={"deposits": [_DEPOSIT], "cursor": "p2"}, ), httpx.Response( 200, @@ -947,13 +945,15 @@ class TestAsyncPortfolioWithdrawals: @respx.mock @pytest.mark.asyncio async def test_returns_page( - self, async_portfolio: AsyncPortfolioResource, + self, + async_portfolio: AsyncPortfolioResource, ) -> None: respx.get( "https://test.kalshi.com/trade-api/v2/portfolio/withdrawals", ).mock( return_value=httpx.Response( - 200, json={"withdrawals": [_WITHDRAWAL]}, + 200, + json={"withdrawals": [_WITHDRAWAL]}, ) ) page = await async_portfolio.withdrawals() @@ -962,14 +962,16 @@ async def test_returns_page( @respx.mock @pytest.mark.asyncio async def test_all_max_pages_caps( - self, async_portfolio: AsyncPortfolioResource, + self, + async_portfolio: AsyncPortfolioResource, ) -> None: respx.get( "https://test.kalshi.com/trade-api/v2/portfolio/withdrawals", ).mock( side_effect=[ httpx.Response( - 200, json={"withdrawals": [_WITHDRAWAL], "cursor": "p2"}, + 200, + json={"withdrawals": [_WITHDRAWAL], "cursor": "p2"}, ), httpx.Response( 200, @@ -986,25 +988,26 @@ async def test_all_max_pages_caps( @respx.mock @pytest.mark.asyncio async def test_auth_failure( - self, async_portfolio: AsyncPortfolioResource, + self, + async_portfolio: AsyncPortfolioResource, ) -> None: respx.get( "https://test.kalshi.com/trade-api/v2/portfolio/withdrawals", - ).mock( - return_value=httpx.Response(401, json={"error": "unauthorized"}) - ) + ).mock(return_value=httpx.Response(401, json={"error": "unauthorized"})) with pytest.raises(KalshiAuthError): await async_portfolio.withdrawals() @pytest.mark.asyncio async def test_withdrawals_requires_auth( - self, unauth_async_portfolio: AsyncPortfolioResource, + self, + unauth_async_portfolio: AsyncPortfolioResource, ) -> None: with pytest.raises(AuthRequiredError): await unauth_async_portfolio.withdrawals() def test_withdrawals_all_requires_auth( - self, unauth_async_portfolio: AsyncPortfolioResource, + self, + unauth_async_portfolio: AsyncPortfolioResource, ) -> None: # withdrawals_all is plain `def` returning AsyncIterator; auth check # must fire at call time, not on first iteration. @@ -1015,13 +1018,15 @@ def test_withdrawals_all_requires_auth( class TestAsyncPortfolioDepositsAuth: @pytest.mark.asyncio async def test_deposits_requires_auth( - self, unauth_async_portfolio: AsyncPortfolioResource, + self, + unauth_async_portfolio: AsyncPortfolioResource, ) -> None: with pytest.raises(AuthRequiredError): await unauth_async_portfolio.deposits() def test_deposits_all_requires_auth( - self, unauth_async_portfolio: AsyncPortfolioResource, + self, + unauth_async_portfolio: AsyncPortfolioResource, ) -> None: with pytest.raises(AuthRequiredError): unauth_async_portfolio.deposits_all() diff --git a/tests/test_request_overload.py b/tests/test_request_overload.py index a9dba68..9a8961c 100644 --- a/tests/test_request_overload.py +++ b/tests/test_request_overload.py @@ -33,6 +33,7 @@ from kalshi.resources.api_keys import ApiKeysResource from kalshi.resources.multivariate import MultivariateCollectionsResource from kalshi.resources.orders import OrdersResource +from tests._model_fixtures import order_dict @pytest.fixture @@ -56,14 +57,15 @@ def api_keys(test_auth: KalshiAuth, config: KalshiConfig) -> ApiKeysResource: @pytest.fixture def multivariate( - test_auth: KalshiAuth, config: KalshiConfig, + test_auth: KalshiAuth, + config: KalshiConfig, ) -> MultivariateCollectionsResource: return MultivariateCollectionsResource(SyncTransport(test_auth, config)) _AMEND_RESPONSE = { - "old_order": {"order_id": "ord-1", "ticker": "MKT"}, - "order": {"order_id": "ord-1", "ticker": "MKT"}, + "old_order": order_dict(order_id="ord-1", ticker="MKT"), + "order": order_dict(order_id="ord-1", ticker="MKT"), } @@ -72,23 +74,33 @@ class TestAmendRequestOverload: @respx.mock def test_request_model_produces_same_body_as_kwargs( - self, orders: OrdersResource, + self, + orders: OrdersResource, ) -> None: route = respx.post( "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-1/amend" ).mock(return_value=httpx.Response(200, json=_AMEND_RESPONSE)) orders.amend( - "ord-1", ticker="MKT", side="yes", action="buy", - yes_price="0.55", count=3, subaccount=2, + "ord-1", + ticker="MKT", + side="yes", + action="buy", + yes_price="0.55", + count=3, + subaccount=2, ) kwarg_body = json.loads(route.calls[0].request.content) orders.amend( "ord-1", request=AmendOrderRequest( - ticker="MKT", side="yes", action="buy", - yes_price="0.55", count=3, subaccount=2, # type: ignore[arg-type] + ticker="MKT", + side="yes", + action="buy", + yes_price="0.55", + count=3, + subaccount=2, # type: ignore[arg-type] ), ) model_body = json.loads(route.calls[1].request.content) @@ -99,23 +111,27 @@ def test_request_model_produces_same_body_as_kwargs( @respx.mock def test_passing_request_and_kwarg_raises( - self, orders: OrdersResource, + self, + orders: OrdersResource, ) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-1/amend" - ).mock(return_value=httpx.Response(200, json=_AMEND_RESPONSE)) + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/ord-1/amend").mock( + return_value=httpx.Response(200, json=_AMEND_RESPONSE) + ) with pytest.raises(TypeError, match=r"Pass either `request=\.\.\.` or"): orders.amend( "ord-1", request=AmendOrderRequest( - ticker="MKT", side="yes", action="buy", yes_price="0.55", # type: ignore[arg-type] + ticker="MKT", + side="yes", + action="buy", + yes_price="0.55", # type: ignore[arg-type] ), yes_price="0.60", ) -_ORDER_RESPONSE = {"order": {"order_id": "ord-x", "ticker": "MKT", "side": "yes"}} +_ORDER_RESPONSE = {"order": order_dict(order_id="ord-x", ticker="MKT", side="yes")} class TestCreateOrderRequestOverload: @@ -123,20 +139,27 @@ class TestCreateOrderRequestOverload: @respx.mock def test_request_model_produces_same_body_as_kwargs( - self, orders: OrdersResource, + self, + orders: OrdersResource, ) -> None: - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json=_ORDER_RESPONSE)) + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json=_ORDER_RESPONSE) + ) orders.create( - ticker="MKT", side="yes", count=2, yes_price="0.50", action="buy", + ticker="MKT", + side="yes", + count=2, + yes_price="0.50", + action="buy", ) kwarg_body = json.loads(route.calls[0].request.content) orders.create( request=CreateOrderRequest( - ticker="MKT", side="yes", count=2, # type: ignore[arg-type] + ticker="MKT", + side="yes", + count=2, # type: ignore[arg-type] yes_price="0.50", # type: ignore[arg-type] action="buy", ), @@ -147,15 +170,16 @@ def test_request_model_produces_same_body_as_kwargs( @respx.mock def test_passing_request_and_kwarg_raises( - self, orders: OrdersResource, + self, + orders: OrdersResource, ) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders" - ).mock(return_value=httpx.Response(200, json=_ORDER_RESPONSE)) + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders").mock( + return_value=httpx.Response(200, json=_ORDER_RESPONSE) + ) with pytest.raises(TypeError, match=r"Pass either `request=\.\.\.` or"): orders.create( - request=CreateOrderRequest(ticker="MKT", side="yes"), + request=CreateOrderRequest(ticker="MKT", side="yes", action="buy"), ticker="OTHER", ) @@ -165,15 +189,16 @@ class TestBatchCreateRequestOverload: @respx.mock def test_request_model_produces_same_body_as_kwargs( - self, orders: OrdersResource, + self, + orders: OrdersResource, ) -> None: - route = respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/batched" - ).mock(return_value=httpx.Response(200, json={"orders": []})) + route = respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(200, json={"orders": []}) + ) inner = [ - CreateOrderRequest(ticker="MKT-A", side="yes"), - CreateOrderRequest(ticker="MKT-B", side="no"), + CreateOrderRequest(ticker="MKT-A", side="yes", action="buy"), + CreateOrderRequest(ticker="MKT-B", side="no", action="buy"), ] orders.batch_create(inner) kwarg_body = json.loads(route.calls[0].request.content) @@ -185,16 +210,18 @@ def test_request_model_produces_same_body_as_kwargs( @respx.mock def test_passing_request_and_kwarg_raises( - self, orders: OrdersResource, + self, + orders: OrdersResource, ) -> None: - respx.post( - "https://test.kalshi.com/trade-api/v2/portfolio/orders/batched" - ).mock(return_value=httpx.Response(200, json={"orders": []})) + respx.post("https://test.kalshi.com/trade-api/v2/portfolio/orders/batched").mock( + return_value=httpx.Response(200, json={"orders": []}) + ) - inner = [CreateOrderRequest(ticker="MKT-A", side="yes")] + inner = [CreateOrderRequest(ticker="MKT-A", side="yes", action="buy")] with pytest.raises(TypeError, match=r"Pass either `request=\.\.\.` or"): orders.batch_create( - inner, request=BatchCreateOrdersRequest(orders=inner), + inner, + request=BatchCreateOrdersRequest(orders=inner), ) @@ -203,11 +230,12 @@ class TestCreateApiKeyRequestOverload: @respx.mock def test_request_model_produces_same_body_as_kwargs( - self, api_keys: ApiKeysResource, + self, + api_keys: ApiKeysResource, ) -> None: - route = respx.post( - "https://test.kalshi.com/trade-api/v2/api_keys" - ).mock(return_value=httpx.Response(200, json={"api_key_id": "k-id"})) + route = respx.post("https://test.kalshi.com/trade-api/v2/api_keys").mock( + return_value=httpx.Response(200, json={"api_key_id": "k-id"}) + ) api_keys.create(name="k", public_key="PK") kwarg_body = json.loads(route.calls[0].request.content) @@ -227,25 +255,29 @@ class TestMissingRequiredKwargsRaisesTypeError: """ def test_single_required_kwarg_missing( - self, api_keys: ApiKeysResource, + self, + api_keys: ApiKeysResource, ) -> None: # generate() requires `name` (one required kwarg). with pytest.raises(TypeError, match=r"generate\(\) requires `name`"): api_keys.generate() def test_multi_required_kwargs_missing( - self, orders: OrdersResource, + self, + orders: OrdersResource, ) -> None: # create() requires `ticker` and `side`. Passing only one raises. with pytest.raises(TypeError, match=r"create\(\) requires `ticker` and `side`"): orders.create(ticker="MKT") def test_list_required_kwarg_missing( - self, orders: OrdersResource, + self, + orders: OrdersResource, ) -> None: # batch_create() requires `orders` (the list). Zero-args raises. with pytest.raises( - TypeError, match=r"batch_create\(\) requires `orders`", + TypeError, + match=r"batch_create\(\) requires `orders`", ): orders.batch_create() @@ -255,13 +287,17 @@ class TestCreateMarketRequestOverload: @respx.mock def test_request_model_produces_same_body_as_kwargs( - self, multivariate: MultivariateCollectionsResource, + self, + multivariate: MultivariateCollectionsResource, ) -> None: route = respx.post( "https://test.kalshi.com/trade-api/v2/multivariate_event_collections/COL-1" - ).mock(return_value=httpx.Response( - 200, json={"market_ticker": "M", "event_ticker": "E"}, - )) + ).mock( + return_value=httpx.Response( + 200, + json={"market_ticker": "M", "event_ticker": "E"}, + ) + ) selected = [TickerPair(event_ticker="EVT", market_ticker="MKT", side="yes")] @@ -280,13 +316,17 @@ def test_request_model_produces_same_body_as_kwargs( @respx.mock def test_passing_request_and_kwarg_raises( - self, multivariate: MultivariateCollectionsResource, + self, + multivariate: MultivariateCollectionsResource, ) -> None: respx.post( "https://test.kalshi.com/trade-api/v2/multivariate_event_collections/COL-1" - ).mock(return_value=httpx.Response( - 200, json={"market_ticker": "M", "event_ticker": "E"}, - )) + ).mock( + return_value=httpx.Response( + 200, + json={"market_ticker": "M", "event_ticker": "E"}, + ) + ) selected = [TickerPair(event_ticker="EVT", market_ticker="MKT", side="yes")] with pytest.raises(TypeError, match=r"Pass either `request=\.\.\.` or"): diff --git a/tests/ws/test_channels.py b/tests/ws/test_channels.py index adf103c..7a2bbec 100644 --- a/tests/ws/test_channels.py +++ b/tests/ws/test_channels.py @@ -176,14 +176,14 @@ async def test_active_subscriptions(self, sub_mgr: SubscriptionManager) -> None: @pytest.mark.asyncio class TestUpdateSubscription: - async def test_update_subscription_not_found_raises( - self, sub_mgr: SubscriptionManager - ) -> None: + async def test_update_subscription_not_found_raises(self, sub_mgr: SubscriptionManager) -> None: with pytest.raises(KalshiSubscriptionError): await sub_mgr.update_subscription(999, "add_markets", market_tickers=["T"]) async def test_update_subscription_sends_command( - self, sub_mgr: SubscriptionManager, fake_ws # type: ignore[no-untyped-def] + self, + sub_mgr: SubscriptionManager, + fake_ws, # type: ignore[no-untyped-def] ) -> None: sub = await sub_mgr.subscribe("ticker") await sub_mgr.update_subscription( @@ -209,7 +209,9 @@ async def test_update_subscription_sends_command( @pytest.mark.asyncio class TestResubscribeAll: async def test_resubscribe_all_after_reconnect( - self, fake_ws, test_auth # type: ignore[no-untyped-def] + self, + fake_ws, + test_auth, # type: ignore[no-untyped-def] ) -> None: """Simulate reconnect: subscribe, disconnect, reconnect, resubscribe.""" config = KalshiConfig( @@ -259,7 +261,9 @@ async def test_resubscribe_empty_is_noop(self, sub_mgr: SubscriptionManager) -> assert len(sub_mgr.active_subscriptions) == 0 async def test_resubscribe_orderbook_gets_snapshot( - self, fake_ws, test_auth # type: ignore[no-untyped-def] + self, + fake_ws, + test_auth, # type: ignore[no-untyped-def] ) -> None: """orderbook_delta channels get send_initial_snapshot on resubscribe.""" config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) @@ -293,7 +297,9 @@ async def test_resubscribe_orderbook_gets_snapshot( @pytest.mark.asyncio class TestSubscribeError: async def test_subscribe_error_response_raises( - self, fake_ws, test_auth # type: ignore[no-untyped-def] + self, + fake_ws, + test_auth, # type: ignore[no-untyped-def] ) -> None: """When the server returns an error response, subscribe should raise.""" config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) @@ -318,7 +324,9 @@ async def test_subscribe_error_response_raises( @pytest.mark.asyncio class TestMsgIdAutoIncrement: async def test_msg_ids_are_sequential( - self, sub_mgr: SubscriptionManager, fake_ws # type: ignore[no-untyped-def] + self, + sub_mgr: SubscriptionManager, + fake_ws, # type: ignore[no-untyped-def] ) -> None: await sub_mgr.subscribe("ticker") await sub_mgr.subscribe("fill") diff --git a/tests/ws/test_client.py b/tests/ws/test_client.py index 8dc311e..6ab435a 100644 --- a/tests/ws/test_client.py +++ b/tests/ws/test_client.py @@ -1,4 +1,5 @@ """Tests for KalshiWebSocket client.""" + from __future__ import annotations import asyncio @@ -6,6 +7,11 @@ from kalshi.config import KalshiConfig from kalshi.ws.client import KalshiWebSocket, _WebSocketSession from kalshi.ws.connection import ConnectionState +from tests._model_fixtures import ( + fill_payload_dict, + ticker_payload_dict, + trade_payload_dict, +) # --------------------------------------------------------------------------- # Context manager lifecycle @@ -71,10 +77,15 @@ async def test_subscribe_receives_messages(self, fake_ws, test_auth) -> None: # async with ws.connect() as session: stream = await session.subscribe_ticker(tickers=["T1"]) - await fake_ws.send_to_all({ - "type": "ticker", "sid": 1, - "msg": {"market_ticker": "T1", "market_id": "x", "yes_bid": 55}, - }) + await fake_ws.send_to_all( + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict( + market_ticker="T1", market_id="x", yes_bid_dollars="55" + ), + } + ) msg = await asyncio.wait_for(stream.__anext__(), timeout=2.0) assert msg.msg.market_ticker == "T1" @@ -103,13 +114,19 @@ async def test_receives_snapshot(self, fake_ws, test_auth) -> None: # type: ign ws = KalshiWebSocket(auth=test_auth, config=config) async with ws.connect() as session: stream = await session.subscribe_orderbook_delta(tickers=["T1"]) - await fake_ws.send_to_all({ - "type": "orderbook_snapshot", "sid": 1, "seq": 1, - "msg": { - "market_ticker": "T1", "market_id": "x", - "yes": [["0.50", "100"]], "no": [], - }, - }) + await fake_ws.send_to_all( + { + "type": "orderbook_snapshot", + "sid": 1, + "seq": 1, + "msg": { + "market_ticker": "T1", + "market_id": "x", + "yes": [["0.50", "100"]], + "no": [], + }, + } + ) msg = await asyncio.wait_for(stream.__anext__(), timeout=2.0) assert msg.type == "orderbook_snapshot" @@ -120,10 +137,13 @@ async def test_subscribe_trade(self, fake_ws, test_auth) -> None: # type: ignor ws = KalshiWebSocket(auth=test_auth, config=config) async with ws.connect() as session: stream = await session.subscribe_trade(tickers=["T1"]) - await fake_ws.send_to_all({ - "type": "trade", "sid": 1, - "msg": {"trade_id": "t1", "market_ticker": "T1"}, - }) + await fake_ws.send_to_all( + { + "type": "trade", + "sid": 1, + "msg": trade_payload_dict(trade_id="t1", market_ticker="T1"), + } + ) msg = await asyncio.wait_for(stream.__anext__(), timeout=2.0) assert msg.msg.trade_id == "t1" @@ -134,10 +154,13 @@ async def test_subscribe_fill(self, fake_ws, test_auth) -> None: # type: ignore ws = KalshiWebSocket(auth=test_auth, config=config) async with ws.connect() as session: stream = await session.subscribe_fill() - await fake_ws.send_to_all({ - "type": "fill", "sid": 1, - "msg": {"trade_id": "t1", "order_id": "o1"}, - }) + await fake_ws.send_to_all( + { + "type": "fill", + "sid": 1, + "msg": fill_payload_dict(trade_id="t1", order_id="o1"), + } + ) msg = await asyncio.wait_for(stream.__anext__(), timeout=2.0) assert msg.msg.trade_id == "t1" @@ -153,10 +176,13 @@ async def test_subscribe_arbitrary_channel(self, fake_ws, test_auth) -> None: # ws = KalshiWebSocket(auth=test_auth, config=config) async with ws.connect() as session: stream = await session.subscribe("fill") - await fake_ws.send_to_all({ - "type": "fill", "sid": 1, - "msg": {"trade_id": "t1", "order_id": "o1"}, - }) + await fake_ws.send_to_all( + { + "type": "fill", + "sid": 1, + "msg": fill_payload_dict(trade_id="t1", order_id="o1"), + } + ) msg = await asyncio.wait_for(stream.__anext__(), timeout=2.0) assert msg.msg.trade_id == "t1" # type: ignore[union-attr] @@ -173,13 +199,19 @@ async def test_orderbook_yields_full_book(self, fake_ws, test_auth) -> None: # async with ws.connect() as session: stream = await session.orderbook("T1") - await fake_ws.send_to_all({ - "type": "orderbook_snapshot", "sid": 1, "seq": 1, - "msg": { - "market_ticker": "T1", "market_id": "x", - "yes": [["0.50", "100"]], "no": [], - }, - }) + await fake_ws.send_to_all( + { + "type": "orderbook_snapshot", + "sid": 1, + "seq": 1, + "msg": { + "market_ticker": "T1", + "market_id": "x", + "yes": [["0.50", "100"]], + "no": [], + }, + } + ) book = await asyncio.wait_for(stream.__anext__(), timeout=2.0) assert book.ticker == "T1" @@ -205,10 +237,13 @@ async def on_fill(msg: object) -> None: got_one.set() await session.subscribe_fill() - await fake_ws.send_to_all({ - "type": "fill", "sid": 1, - "msg": {"trade_id": "t1", "order_id": "o1"}, - }) + await fake_ws.send_to_all( + { + "type": "fill", + "sid": 1, + "msg": fill_payload_dict(trade_id="t1", order_id="o1"), + } + ) # Deterministic wait: the callback signals us; we don't sleep blindly. await asyncio.wait_for(got_one.wait(), timeout=2.0) @@ -229,14 +264,22 @@ async def test_two_channels_on_same_connection(self, fake_ws, test_auth) -> None fill_stream = await session.subscribe_fill() # Server assigns sid=1 to ticker, sid=2 to fill - await fake_ws.send_to_all({ - "type": "ticker", "sid": 1, - "msg": {"market_ticker": "T1", "market_id": "x", "yes_bid": 55}, - }) - await fake_ws.send_to_all({ - "type": "fill", "sid": 2, - "msg": {"trade_id": "t1", "order_id": "o1"}, - }) + await fake_ws.send_to_all( + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict( + market_ticker="T1", market_id="x", yes_bid_dollars="55" + ), + } + ) + await fake_ws.send_to_all( + { + "type": "fill", + "sid": 2, + "msg": fill_payload_dict(trade_id="t1", order_id="o1"), + } + ) ticker_msg = await asyncio.wait_for(ticker_stream.__anext__(), timeout=2.0) fill_msg = await asyncio.wait_for(fill_stream.__anext__(), timeout=2.0) @@ -259,30 +302,39 @@ async def test_two_subs_same_channel_distinct_params(self, fake_ws, test_auth) - # Two distinct subscribes -> two distinct server sids assert session._sub_mgr is not None - sids = [ - sub.server_sid - for sub in session._sub_mgr.active_subscriptions.values() - ] + sids = [sub.server_sid for sub in session._sub_mgr.active_subscriptions.values()] assert len(sids) == 2 assert sids[0] != sids[1] sid_a, sid_b = sids[0], sids[1] assert sid_a is not None and sid_b is not None # Push one message to each sid - await fake_ws.send_to_all({ - "type": "orderbook_snapshot", "sid": sid_a, "seq": 1, - "msg": { - "market_ticker": "T1", "market_id": "x", - "yes": [["0.50", "100"]], "no": [], - }, - }) - await fake_ws.send_to_all({ - "type": "orderbook_snapshot", "sid": sid_b, "seq": 1, - "msg": { - "market_ticker": "T2", "market_id": "y", - "yes": [["0.60", "200"]], "no": [], - }, - }) + await fake_ws.send_to_all( + { + "type": "orderbook_snapshot", + "sid": sid_a, + "seq": 1, + "msg": { + "market_ticker": "T1", + "market_id": "x", + "yes": [["0.50", "100"]], + "no": [], + }, + } + ) + await fake_ws.send_to_all( + { + "type": "orderbook_snapshot", + "sid": sid_b, + "seq": 1, + "msg": { + "market_ticker": "T2", + "market_id": "y", + "yes": [["0.60", "200"]], + "no": [], + }, + } + ) msg_a = await asyncio.wait_for(stream_a.__anext__(), timeout=2.0) msg_b = await asyncio.wait_for(stream_b.__anext__(), timeout=2.0) @@ -310,7 +362,9 @@ async def test_run_forever_blocks_until_close(self, fake_ws, test_auth) -> None: # Stopping the session (via context manager exit) will end run_forever async def test_run_forever_returns_immediately_without_subscribe( - self, fake_ws, test_auth, # type: ignore[no-untyped-def] + self, + fake_ws, + test_auth, # type: ignore[no-untyped-def] ) -> None: config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) ws = KalshiWebSocket(auth=test_auth, config=config) @@ -340,10 +394,12 @@ async def on_err(err: object) -> None: await session.subscribe_ticker(tickers=["T1"]) # Send an error message - await fake_ws.send_to_all({ - "type": "error", - "msg": {"code": 400, "msg": "bad request"}, - }) + await fake_ws.send_to_all( + { + "type": "error", + "msg": {"code": 400, "msg": "bad request"}, + } + ) # Deterministic wait: the callback signals us; we don't sleep blindly. await asyncio.wait_for(got_one.wait(), timeout=2.0) assert len(errors) == 1 diff --git a/tests/ws/test_connection.py b/tests/ws/test_connection.py index b5aaaf9..cc55ab1 100644 --- a/tests/ws/test_connection.py +++ b/tests/ws/test_connection.py @@ -36,9 +36,7 @@ def test_state_count(self) -> None: class TestConnectionManagerConnect: - async def test_connect_to_fake_server( - self, fake_ws: FakeKalshiWS, test_auth: object - ) -> None: + async def test_connect_to_fake_server(self, fake_ws: FakeKalshiWS, test_auth: object) -> None: config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) mgr = ConnectionManager(auth=test_auth, config=config) # type: ignore[arg-type] await mgr.connect() @@ -48,16 +46,12 @@ async def test_connect_to_fake_server( closed_state: ConnectionState = mgr.state assert closed_state == ConnectionState.CLOSED - async def test_initial_state_is_disconnected( - self, test_auth: object - ) -> None: + async def test_initial_state_is_disconnected(self, test_auth: object) -> None: config = KalshiConfig(timeout=5.0) mgr = ConnectionManager(auth=test_auth, config=config) # type: ignore[arg-type] assert mgr.state == ConnectionState.DISCONNECTED - async def test_auth_rejection( - self, fake_ws: FakeKalshiWS, test_auth: object - ) -> None: + async def test_auth_rejection(self, fake_ws: FakeKalshiWS, test_auth: object) -> None: fake_ws.reject_auth = True config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) mgr = ConnectionManager(auth=test_auth, config=config) # type: ignore[arg-type] @@ -66,9 +60,7 @@ async def test_auth_rejection( assert mgr.state == ConnectionState.CLOSED async def test_connect_invalid_url(self, test_auth: object) -> None: - config = KalshiConfig( - ws_base_url="ws://127.0.0.1:1", timeout=1.0 - ) + config = KalshiConfig(ws_base_url="ws://127.0.0.1:1", timeout=1.0) mgr = ConnectionManager(auth=test_auth, config=config) # type: ignore[arg-type] with pytest.raises(KalshiConnectionError): await mgr.connect() @@ -86,7 +78,9 @@ async def on_state(old: ConnectionState, new: ConnectionState) -> None: config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) mgr = ConnectionManager( - auth=test_auth, config=config, on_state_change=on_state, # type: ignore[arg-type] + auth=test_auth, + config=config, + on_state_change=on_state, # type: ignore[arg-type] ) await mgr.connect() states.clear() @@ -95,9 +89,7 @@ async def on_state(old: ConnectionState, new: ConnectionState) -> None: assert states == [(ConnectionState.CONNECTED, ConnectionState.STREAMING)] await mgr.close() - async def test_connect_error_does_not_leak_url( - self, test_auth: object - ) -> None: + async def test_connect_error_does_not_leak_url(self, test_auth: object) -> None: """Issue #84 F-O-09: connection-failure str() must not include the ws URL (which may contain token-like query params).""" # URL with a sensitive-looking query param @@ -116,18 +108,14 @@ async def test_connect_error_does_not_leak_url( # so urlparse returns "" — assert by not crashing rather than substring. assert "WebSocket connection failed" in msg - async def test_close_when_already_disconnected( - self, test_auth: object - ) -> None: + async def test_close_when_already_disconnected(self, test_auth: object) -> None: config = KalshiConfig(timeout=5.0) mgr = ConnectionManager(auth=test_auth, config=config) # type: ignore[arg-type] # Should not raise await mgr.close() assert mgr.state == ConnectionState.CLOSED - async def test_double_close( - self, fake_ws: FakeKalshiWS, test_auth: object - ) -> None: + async def test_double_close(self, fake_ws: FakeKalshiWS, test_auth: object) -> None: config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) mgr = ConnectionManager(auth=test_auth, config=config) # type: ignore[arg-type] await mgr.connect() @@ -143,9 +131,7 @@ async def test_double_close( class TestConnectionManagerSendRecv: - async def test_send_and_recv( - self, fake_ws: FakeKalshiWS, test_auth: object - ) -> None: + async def test_send_and_recv(self, fake_ws: FakeKalshiWS, test_auth: object) -> None: config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) mgr = ConnectionManager(auth=test_auth, config=config) # type: ignore[arg-type] await mgr.connect() @@ -164,17 +150,13 @@ async def test_send_and_recv( assert data["msg"]["channel"] == "ticker" await mgr.close() - async def test_send_when_not_connected_raises( - self, test_auth: object - ) -> None: + async def test_send_when_not_connected_raises(self, test_auth: object) -> None: config = KalshiConfig(timeout=5.0) mgr = ConnectionManager(auth=test_auth, config=config) # type: ignore[arg-type] with pytest.raises(KalshiConnectionError, match="Not connected"): await mgr.send({"cmd": "subscribe"}) - async def test_recv_when_not_connected_raises( - self, test_auth: object - ) -> None: + async def test_recv_when_not_connected_raises(self, test_auth: object) -> None: config = KalshiConfig(timeout=5.0) mgr = ConnectionManager(auth=test_auth, config=config) # type: ignore[arg-type] with pytest.raises(KalshiConnectionError, match="Not connected"): @@ -202,9 +184,7 @@ async def test_subscribe_multiple_channels( assert channels == {"ticker", "orderbook_delta"} await mgr.close() - async def test_unsubscribe( - self, fake_ws: FakeKalshiWS, test_auth: object - ) -> None: + async def test_unsubscribe(self, fake_ws: FakeKalshiWS, test_auth: object) -> None: config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) mgr = ConnectionManager(auth=test_auth, config=config) # type: ignore[arg-type] await mgr.connect() @@ -219,18 +199,14 @@ async def test_unsubscribe( raw = await mgr.recv() sid = json.loads(raw)["msg"]["sid"] # Unsubscribe - await mgr.send( - {"id": 2, "cmd": "unsubscribe", "params": {"sids": [sid]}} - ) + await mgr.send({"id": 2, "cmd": "unsubscribe", "params": {"sids": [sid]}}) raw = await mgr.recv() data = json.loads(raw) assert data["type"] == "unsubscribed" assert data["sid"] == sid await mgr.close() - async def test_list_subscriptions( - self, fake_ws: FakeKalshiWS, test_auth: object - ) -> None: + async def test_list_subscriptions(self, fake_ws: FakeKalshiWS, test_auth: object) -> None: config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) mgr = ConnectionManager(auth=test_auth, config=config) # type: ignore[arg-type] await mgr.connect() @@ -264,9 +240,7 @@ async def test_state_change_callback_on_connect_close( ) -> None: states: list[tuple[str, str]] = [] - async def on_change( - old: ConnectionState, new: ConnectionState - ) -> None: + async def on_change(old: ConnectionState, new: ConnectionState) -> None: states.append((old.value, new.value)) config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) @@ -287,9 +261,7 @@ async def test_state_change_callback_on_failed_connect( fake_ws.reject_auth = True states: list[tuple[str, str]] = [] - async def on_change( - old: ConnectionState, new: ConnectionState - ) -> None: + async def on_change(old: ConnectionState, new: ConnectionState) -> None: states.append((old.value, new.value)) config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) @@ -303,9 +275,7 @@ async def on_change( assert ("disconnected", "connecting") in states assert ("connecting", "closed") in states - async def test_no_callback_when_none( - self, fake_ws: FakeKalshiWS, test_auth: object - ) -> None: + async def test_no_callback_when_none(self, fake_ws: FakeKalshiWS, test_auth: object) -> None: """Ensure no error when on_state_change is None.""" config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) mgr = ConnectionManager( @@ -323,9 +293,7 @@ async def test_no_callback_when_none( class TestConnectionManagerWsProperty: - async def test_ws_property_raises_when_not_connected( - self, test_auth: object - ) -> None: + async def test_ws_property_raises_when_not_connected(self, test_auth: object) -> None: config = KalshiConfig(timeout=5.0) mgr = ConnectionManager(auth=test_auth, config=config) # type: ignore[arg-type] with pytest.raises(KalshiConnectionError, match="Not connected"): @@ -348,9 +316,7 @@ async def test_ws_property_returns_connection( class TestConnectionManagerReconnect: - async def test_reconnect_succeeds( - self, fake_ws: FakeKalshiWS, test_auth: object - ) -> None: + async def test_reconnect_succeeds(self, fake_ws: FakeKalshiWS, test_auth: object) -> None: config = KalshiConfig( ws_base_url=fake_ws.url, timeout=5.0, @@ -368,9 +334,7 @@ async def test_reconnect_state_transitions( ) -> None: states: list[tuple[str, str]] = [] - async def on_change( - old: ConnectionState, new: ConnectionState - ) -> None: + async def on_change(old: ConnectionState, new: ConnectionState) -> None: states.append((old.value, new.value)) config = KalshiConfig( @@ -392,7 +356,8 @@ async def on_change( await mgr.close() async def test_reconnect_max_retries_exceeded( - self, test_auth: object, + self, + test_auth: object, ) -> None: """When the server is unreachable, reconnect should fail after max retries.""" config = KalshiConfig( @@ -403,9 +368,7 @@ async def test_reconnect_max_retries_exceeded( ws_max_retries=2, ) mgr = ConnectionManager(auth=test_auth, config=config) # type: ignore[arg-type] - with pytest.raises( - KalshiConnectionError, match="Max reconnect attempts" - ): + with pytest.raises(KalshiConnectionError, match="Max reconnect attempts"): await mgr.reconnect() assert mgr.state == ConnectionState.CLOSED @@ -422,9 +385,7 @@ async def test_reconnect_eventually_succeeds( fake_ws.reject_auth = True attempt_count = 0 - async def on_change( - old: ConnectionState, new: ConnectionState - ) -> None: + async def on_change(old: ConnectionState, new: ConnectionState) -> None: nonlocal attempt_count if new == ConnectionState.CONNECTING: attempt_count += 1 @@ -454,9 +415,7 @@ async def on_change( class TestConnectionManagerAuth: - async def test_auth_headers_sent( - self, fake_ws: FakeKalshiWS, test_auth: object - ) -> None: + async def test_auth_headers_sent(self, fake_ws: FakeKalshiWS, test_auth: object) -> None: """Verify the connection succeeds (auth headers accepted by the server).""" config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) mgr = ConnectionManager(auth=test_auth, config=config) # type: ignore[arg-type] @@ -465,9 +424,7 @@ async def test_auth_headers_sent( # The fake server accepted us (no 401 rejection) await mgr.close() - async def test_build_auth_headers_uses_ws_path( - self, test_auth: object - ) -> None: + async def test_build_auth_headers_uses_ws_path(self, test_auth: object) -> None: """_build_auth_headers should sign with the WS URL path.""" config = KalshiConfig( ws_base_url="wss://api.elections.kalshi.com/trade-api/ws/v2", @@ -486,9 +443,7 @@ async def test_build_auth_headers_uses_ws_path( class TestFakeKalshiWSBroadcast: - async def test_send_to_all( - self, fake_ws: FakeKalshiWS, test_auth: object - ) -> None: + async def test_send_to_all(self, fake_ws: FakeKalshiWS, test_auth: object) -> None: config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) mgr = ConnectionManager(auth=test_auth, config=config) # type: ignore[arg-type] await mgr.connect() diff --git a/tests/ws/test_count_migration.py b/tests/ws/test_count_migration.py index e0c2f2d..ff2a1f0 100644 --- a/tests/ws/test_count_migration.py +++ b/tests/ws/test_count_migration.py @@ -1,48 +1,50 @@ """Verify REST order count fields use FixedPointCount (Decimal).""" + from __future__ import annotations from decimal import Decimal from kalshi.models.orders import CreateOrderRequest, Order +from tests._model_fixtures import order_dict class TestOrderCountMigration: def test_order_count_is_decimal(self) -> None: - order = Order.model_validate({"order_id": "abc", "count": "100.00"}) + order = Order.model_validate(order_dict(count="100.00")) assert isinstance(order.count, Decimal) assert order.count == Decimal("100.00") def test_order_count_accepts_int(self) -> None: - order = Order.model_validate({"order_id": "abc", "count": 42}) + order = Order.model_validate(order_dict(count=42)) assert isinstance(order.count, Decimal) assert order.count == Decimal("42") def test_order_count_fp_alias(self) -> None: - order = Order.model_validate({"order_id": "abc", "count_fp": "50.00"}) + order = Order.model_validate(order_dict(count_fp="50.00")) assert order.count == Decimal("50.00") def test_initial_count_fp_alias(self) -> None: - order = Order.model_validate({"order_id": "abc", "initial_count_fp": "25.00"}) + order = Order.model_validate(order_dict(initial_count_fp="25.00")) assert order.initial_count == Decimal("25.00") def test_remaining_count_fp_alias(self) -> None: - order = Order.model_validate({"order_id": "abc", "remaining_count_fp": "10.00"}) + order = Order.model_validate(order_dict(remaining_count_fp="10.00")) assert order.remaining_count == Decimal("10.00") def test_fill_count_fp_alias(self) -> None: - order = Order.model_validate({"order_id": "abc", "fill_count_fp": "15.00"}) + order = Order.model_validate(order_dict(fill_count_fp="15.00")) assert order.fill_count == Decimal("15.00") def test_create_order_count_is_decimal(self) -> None: - req = CreateOrderRequest(ticker="ECON-GDP", side="yes", count=Decimal("10")) + req = CreateOrderRequest(ticker="ECON-GDP", side="yes", count=Decimal("10"), action="buy") assert isinstance(req.count, Decimal) def test_create_order_count_default(self) -> None: - req = CreateOrderRequest(ticker="ECON-GDP", side="yes") + req = CreateOrderRequest(ticker="ECON-GDP", side="yes", action="buy") assert req.count == Decimal("1") assert isinstance(req.count, Decimal) def test_create_order_count_serializes(self) -> None: - req = CreateOrderRequest(ticker="ECON-GDP", side="yes", count=Decimal("10")) + req = CreateOrderRequest(ticker="ECON-GDP", side="yes", count=Decimal("10"), action="buy") data = req.model_dump(mode="json") assert isinstance(data["count"], str) diff --git a/tests/ws/test_dispatch.py b/tests/ws/test_dispatch.py index 5687b98..79cf881 100644 --- a/tests/ws/test_dispatch.py +++ b/tests/ws/test_dispatch.py @@ -1,4 +1,5 @@ """Tests for MessageDispatcher.""" + from __future__ import annotations import asyncio @@ -14,6 +15,11 @@ from kalshi.ws.models.multivariate import MultivariateMessage from kalshi.ws.models.user_orders import UserOrdersMessage from kalshi.ws.sequence import SequenceTracker +from tests._model_fixtures import ( + market_positions_payload_dict, + ticker_payload_dict, + user_orders_payload_dict, +) class FakeSubManager: @@ -26,7 +32,11 @@ def __init__(self) -> None: self._sid_to_client: dict[int, int] = {} def add( - self, sid: int, channel: str, *, client_id: int | None = None, + self, + sid: int, + channel: str, + *, + client_id: int | None = None, ) -> Subscription: """Add a sub. ``client_id`` defaults to ``sid`` for simple tests, but can be passed distinct to exercise the production mapping path @@ -58,7 +68,11 @@ async def test_dispatch_ticker(self) -> None: sub = mgr.add(1, "ticker") dispatcher = MessageDispatcher(sub_mgr=mgr) # type: ignore[arg-type] raw = json.dumps( - {"type": "ticker", "sid": 1, "msg": {"market_ticker": "T", "market_id": "x"}} + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict(market_ticker="T", market_id="x"), + } ) await dispatcher.dispatch(json.loads(raw)) msg = await sub.queue.get() @@ -70,6 +84,7 @@ async def test_dispatch_pre_validated_skips_revalidation(self) -> None: instance lands on the queue. """ from kalshi.ws.models.orderbook_delta import OrderbookSnapshotMessage + mgr = FakeSubManager() sub = mgr.add(2, "orderbook_delta") dispatcher = MessageDispatcher(sub_mgr=mgr) # type: ignore[arg-type] @@ -110,9 +125,7 @@ async def test_dispatch_control_message_skipped(self) -> None: mgr = FakeSubManager() sub = mgr.add(1, "ticker") dispatcher = MessageDispatcher(sub_mgr=mgr) # type: ignore[arg-type] - raw = json.dumps( - {"type": "subscribed", "id": 1, "msg": {"channel": "ticker", "sid": 1}} - ) + raw = json.dumps({"type": "subscribed", "id": 1, "msg": {"channel": "ticker", "sid": 1}}) await dispatcher.dispatch(json.loads(raw)) assert sub.queue.qsize() == 0 # control messages don't go to queue @@ -120,7 +133,11 @@ async def test_dispatch_unknown_sid(self) -> None: mgr = FakeSubManager() dispatcher = MessageDispatcher(sub_mgr=mgr) # type: ignore[arg-type] raw = json.dumps( - {"type": "ticker", "sid": 999, "msg": {"market_ticker": "T", "market_id": "x"}} + { + "type": "ticker", + "sid": 999, + "msg": ticker_payload_dict(market_ticker="T", market_id="x"), + } ) await dispatcher.dispatch(json.loads(raw)) # should not crash @@ -136,7 +153,11 @@ async def on_ticker(msg: object) -> None: dispatcher = MessageDispatcher(sub_mgr=mgr) # type: ignore[arg-type] dispatcher.register_callback("ticker", on_ticker) raw = json.dumps( - {"type": "ticker", "sid": 1, "msg": {"market_ticker": "T", "market_id": "x"}} + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict(market_ticker="T", market_id="x"), + } ) await dispatcher.dispatch(json.loads(raw)) assert len(received) == 1 @@ -165,7 +186,11 @@ async def on_ticker(msg: object) -> None: assert any("active subscription exists" in r.message for r in caplog.records) raw = json.dumps( - {"type": "ticker", "sid": 1, "msg": {"market_ticker": "T", "market_id": "x"}} + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict(market_ticker="T", market_id="x"), + } ) await dispatcher.dispatch(json.loads(raw)) await dispatcher.dispatch(json.loads(raw)) @@ -187,9 +212,7 @@ async def cb(msg: object) -> None: dispatcher = MessageDispatcher(sub_mgr=mgr) # type: ignore[arg-type] with caplog.at_level(logging.WARNING, logger="kalshi.ws"): dispatcher.register_callback("ticker", cb) - assert not any( - "active subscription exists" in r.message for r in caplog.records - ) + assert not any("active subscription exists" in r.message for r in caplog.records) async def test_error_callback(self) -> None: mgr = FakeSubManager() @@ -234,14 +257,10 @@ async def test_channel_level_error_logged_when_no_handler( mgr = FakeSubManager() dispatcher = MessageDispatcher(sub_mgr=mgr) # type: ignore[arg-type] - raw = json.dumps( - {"type": "ticker", "sid": 1, "error": {"code": 7, "msg": "boom"}} - ) + raw = json.dumps({"type": "ticker", "sid": 1, "error": {"code": 7, "msg": "boom"}}) with caplog.at_level(logging.WARNING, logger="kalshi.ws"): await dispatcher.dispatch(json.loads(raw)) - assert any( - "Channel-level error envelope" in r.message for r in caplog.records - ) + assert any("Channel-level error envelope" in r.message for r in caplog.records) async def test_channel_level_error_unrecognized_sid_still_surfaced(self) -> None: """Error with a sid the dispatcher no longer knows still reaches on_error.""" @@ -282,7 +301,7 @@ async def on_error(err: object) -> None: "type": "ticker", "sid": 1, "error": None, - "msg": {"market_ticker": "T", "market_id": "x"}, + "msg": ticker_payload_dict(market_ticker="T", market_id="x"), } ) await dispatcher.dispatch(json.loads(raw)) @@ -306,16 +325,11 @@ async def on_error(err: object) -> None: dispatcher = MessageDispatcher(sub_mgr=mgr, on_error=on_error) # type: ignore[arg-type] # Error field is a non-dict, non-string sentinel that fails ErrorPayload validation. - raw = json.dumps( - {"type": "ticker", "sid": 1, "error": 42} - ) + raw = json.dumps({"type": "ticker", "sid": 1, "error": 42}) with caplog.at_level(logging.ERROR, logger="kalshi.ws"): await dispatcher.dispatch(json.loads(raw)) assert len(errors) == 1, "on_error not called on validation failure" - assert any( - "failed strict ErrorMessage validation" in r.message - for r in caplog.records - ) + assert any("failed strict ErrorMessage validation" in r.message for r in caplog.records) async def test_all_channel_types_have_models(self) -> None: """Verify every expected channel type is in the dispatch map.""" @@ -352,7 +366,11 @@ async def on_ticker(msg: object) -> None: dispatcher.unregister_callback("ticker") raw = json.dumps( - {"type": "ticker", "sid": 1, "msg": {"market_ticker": "T", "market_id": "x"}} + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict(market_ticker="T", market_id="x"), + } ) await dispatcher.dispatch(json.loads(raw)) assert len(received) == 0 # callback was removed @@ -448,7 +466,9 @@ async def test_dispatch_message_without_sid(self) -> None: """Messages without sid are logged but don't crash.""" mgr = FakeSubManager() dispatcher = MessageDispatcher(sub_mgr=mgr) # type: ignore[arg-type] - raw = json.dumps({"type": "ticker", "msg": {"market_ticker": "T", "market_id": "x"}}) + raw = json.dumps( + {"type": "ticker", "msg": ticker_payload_dict(market_ticker="T", market_id="x")} + ) await dispatcher.dispatch(json.loads(raw)) # should not crash @@ -463,7 +483,11 @@ async def test_dispatch_routes_user_order_singular() -> None: mgr = FakeSubManager() sub = mgr.add(42, "user_orders") dispatcher = MessageDispatcher(sub_mgr=mgr) # type: ignore[arg-type] - raw = '{"type":"user_order","sid":42,"msg":{"order_id":"ORD1"}}' + raw = ( + '{"type":"user_order","sid":42,"msg":' + + json.dumps(user_orders_payload_dict(order_id="ORD1")) + + "}" + ) await dispatcher.dispatch(json.loads(raw)) msg = await asyncio.wait_for(sub.queue.get(), timeout=1.0) @@ -489,7 +513,11 @@ async def test_dispatch_routes_market_position_singular() -> None: mgr = FakeSubManager() sub = mgr.add(42, "market_positions") dispatcher = MessageDispatcher(sub_mgr=mgr) # type: ignore[arg-type] - raw = '{"type":"market_position","sid":42,"msg":{"ticker":"X","market_ticker":"X"}}' + raw = ( + '{"type":"market_position","sid":42,"msg":' + + json.dumps(market_positions_payload_dict(market_ticker="X")) + + "}" + ) await dispatcher.dispatch(json.loads(raw)) msg = await asyncio.wait_for(sub.queue.get(), timeout=1.0) @@ -513,7 +541,18 @@ async def test_dispatch_routes_multivariate_lookup() -> None: mgr = FakeSubManager() sub = mgr.add(17, "multivariate") dispatcher = MessageDispatcher(sub_mgr=mgr) # type: ignore[arg-type] - raw = '{"type":"multivariate_lookup","sid":17,"msg":{"event_ticker":"E1"}}' + raw = ( + '{"type":"multivariate_lookup","sid":17,"msg":' + + json.dumps( + { + "collection_ticker": "C1", + "selected_markets": [], + "market_ticker": "M1", + "event_ticker": "E1", + } + ) + + "}" + ) await dispatcher.dispatch(json.loads(raw)) msg = await asyncio.wait_for(sub.queue.get(), timeout=1.0) diff --git a/tests/ws/test_integration.py b/tests/ws/test_integration.py index 993385e..c540e73 100644 --- a/tests/ws/test_integration.py +++ b/tests/ws/test_integration.py @@ -4,6 +4,7 @@ MessageDispatcher, SequenceTracker, OrderbookManager, and KalshiWebSocket all wired together against the FakeKalshiWS test server. """ + from __future__ import annotations import asyncio @@ -18,6 +19,7 @@ from kalshi.ws.connection import ConnectionState from kalshi.ws.models.base import ErrorMessage from kalshi.ws.sequence import SequenceGap +from tests._model_fixtures import fill_payload_dict, ticker_payload_dict, trade_payload_dict from .conftest import FakeKalshiWS @@ -51,16 +53,15 @@ async def test_subscribe_and_receive_ticker( async with ws.connect() as session: stream = await session.subscribe_ticker(tickers=["ECON-GDP-25Q1"]) - await fake_ws.send_to_all({ - "type": "ticker", - "sid": 1, - "msg": { - "market_ticker": "ECON-GDP-25Q1", - "market_id": "x", - "yes_bid": 55, - "yes_ask": 58, - }, - }) + await fake_ws.send_to_all( + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict( + market_ticker="ECON-GDP-25Q1", yes_bid_dollars="55", yes_ask_dollars="58" + ), + } + ) msg = await asyncio.wait_for(stream.__anext__(), timeout=2.0) assert msg.type == "ticker" @@ -80,11 +81,13 @@ async def test_multiple_ticker_updates( stream = await session.subscribe_ticker(tickers=["T1"]) for price in [50, 55, 60]: - await fake_ws.send_to_all({ - "type": "ticker", - "sid": 1, - "msg": {"market_ticker": "T1", "market_id": "x", "yes_bid": price}, - }) + await fake_ws.send_to_all( + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict(market_ticker="T1", yes_bid_dollars=price), + } + ) received: list[Any] = [] for _ in range(3): @@ -112,17 +115,19 @@ async def test_orderbook_snapshot_and_delta( stream = await session.orderbook("T1") # Server sends snapshot: yes side has two levels - await fake_ws.send_to_all({ - "type": "orderbook_snapshot", - "sid": 1, - "seq": 1, - "msg": { - "market_ticker": "T1", - "market_id": "x", - "yes": [["0.50", "100"], ["0.55", "200"]], - "no": [], - }, - }) + await fake_ws.send_to_all( + { + "type": "orderbook_snapshot", + "sid": 1, + "seq": 1, + "msg": { + "market_ticker": "T1", + "market_id": "x", + "yes": [["0.50", "100"], ["0.55", "200"]], + "no": [], + }, + } + ) book = await asyncio.wait_for(stream.__anext__(), timeout=2.0) assert book.ticker == "T1" @@ -131,18 +136,20 @@ async def test_orderbook_snapshot_and_delta( assert book.yes[1].quantity == Decimal("200") # Server sends delta: add 50 contracts at $0.50 - await fake_ws.send_to_all({ - "type": "orderbook_delta", - "sid": 1, - "seq": 2, - "msg": { - "market_ticker": "T1", - "market_id": "x", - "price_dollars": "0.50", - "delta_fp": "50", - "side": "yes", - }, - }) + await fake_ws.send_to_all( + { + "type": "orderbook_delta", + "sid": 1, + "seq": 2, + "msg": { + "market_ticker": "T1", + "market_id": "x", + "price_dollars": "0.50", + "delta_fp": "50", + "side": "yes", + }, + } + ) book2 = await asyncio.wait_for(stream.__anext__(), timeout=2.0) assert book2.yes[0].quantity == Decimal("150") @@ -159,33 +166,37 @@ async def test_orderbook_delta_removes_level( stream = await session.orderbook("T1") # Snapshot with one yes level of 100 cents - await fake_ws.send_to_all({ - "type": "orderbook_snapshot", - "sid": 1, - "seq": 1, - "msg": { - "market_ticker": "T1", - "market_id": "x", - "yes": [["0.50", "100"]], - "no": [], - }, - }) + await fake_ws.send_to_all( + { + "type": "orderbook_snapshot", + "sid": 1, + "seq": 1, + "msg": { + "market_ticker": "T1", + "market_id": "x", + "yes": [["0.50", "100"]], + "no": [], + }, + } + ) book = await asyncio.wait_for(stream.__anext__(), timeout=2.0) assert len(book.yes) == 1 # Delta: subtract 100 contracts (removes level) - await fake_ws.send_to_all({ - "type": "orderbook_delta", - "sid": 1, - "seq": 2, - "msg": { - "market_ticker": "T1", - "market_id": "x", - "price_dollars": "0.50", - "delta_fp": "-100", - "side": "yes", - }, - }) + await fake_ws.send_to_all( + { + "type": "orderbook_delta", + "sid": 1, + "seq": 2, + "msg": { + "market_ticker": "T1", + "market_id": "x", + "price_dollars": "0.50", + "delta_fp": "-100", + "side": "yes", + }, + } + ) book2 = await asyncio.wait_for(stream.__anext__(), timeout=2.0) assert len(book2.yes) == 0 @@ -209,26 +220,28 @@ async def test_two_channels_same_connection( fill_stream = await session.subscribe_fill() # Ticker goes to sid=1, fill goes to sid=2 - await fake_ws.send_to_all({ - "type": "ticker", - "sid": 1, - "msg": { - "market_ticker": "T1", - "market_id": "x", - "yes_bid": 50, - }, - }) - await fake_ws.send_to_all({ - "type": "fill", - "sid": 2, - "msg": {"trade_id": "t1", "order_id": "o1"}, - }) + await fake_ws.send_to_all( + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict(market_ticker="T1", yes_bid_dollars="50"), + } + ) + await fake_ws.send_to_all( + { + "type": "fill", + "sid": 2, + "msg": fill_payload_dict(trade_id="t1", order_id="o1"), + } + ) ticker_msg = await asyncio.wait_for( - ticker_stream.__anext__(), timeout=2.0, + ticker_stream.__anext__(), + timeout=2.0, ) fill_msg = await asyncio.wait_for( - fill_stream.__anext__(), timeout=2.0, + fill_stream.__anext__(), + timeout=2.0, ) assert ticker_msg.msg.market_ticker == "T1" @@ -249,21 +262,27 @@ async def test_three_channels( fill_stream = await session.subscribe_fill() trade_stream = await session.subscribe_trade(tickers=["T1"]) - await fake_ws.send_to_all({ - "type": "ticker", - "sid": 1, - "msg": {"market_ticker": "T1", "market_id": "x", "yes_bid": 42}, - }) - await fake_ws.send_to_all({ - "type": "fill", - "sid": 2, - "msg": {"trade_id": "f1", "order_id": "o1"}, - }) - await fake_ws.send_to_all({ - "type": "trade", - "sid": 3, - "msg": {"trade_id": "tr1", "market_ticker": "T1"}, - }) + await fake_ws.send_to_all( + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict(market_ticker="T1", yes_bid_dollars="42"), + } + ) + await fake_ws.send_to_all( + { + "type": "fill", + "sid": 2, + "msg": fill_payload_dict(trade_id="f1", order_id="o1"), + } + ) + await fake_ws.send_to_all( + { + "type": "trade", + "sid": 3, + "msg": trade_payload_dict(trade_id="tr1", market_ticker="T1"), + } + ) t = await asyncio.wait_for(ticker_stream.__anext__(), timeout=2.0) f = await asyncio.wait_for(fill_stream.__anext__(), timeout=2.0) @@ -298,11 +317,13 @@ async def test_reconnect_after_server_disconnect( stream = await session.subscribe_ticker(tickers=["T1"]) # First message triggers disconnect_after - await fake_ws.send_to_all({ - "type": "ticker", - "sid": 1, - "msg": {"market_ticker": "T1", "market_id": "x", "yes_bid": 50}, - }) + await fake_ws.send_to_all( + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict(market_ticker="T1", yes_bid_dollars="50"), + } + ) # Read the first message msg1 = await asyncio.wait_for(stream.__anext__(), timeout=2.0) @@ -318,17 +339,15 @@ async def test_reconnect_after_server_disconnect( # After reconnect, server assigns new sids starting from where it left off # Resubscribe gets a new sid. Find the latest sid. - latest_sid = ( - max(fake_ws.subscriptions.keys()) - if fake_ws.subscriptions - else 1 - ) + latest_sid = max(fake_ws.subscriptions.keys()) if fake_ws.subscriptions else 1 - await fake_ws.send_to_all({ - "type": "ticker", - "sid": latest_sid, - "msg": {"market_ticker": "T1", "market_id": "x", "yes_bid": 75}, - }) + await fake_ws.send_to_all( + { + "type": "ticker", + "sid": latest_sid, + "msg": ticker_payload_dict(market_ticker="T1", yes_bid_dollars="75"), + } + ) msg2 = await asyncio.wait_for(stream.__anext__(), timeout=3.0) assert msg2.msg.yes_bid == 75 @@ -369,10 +388,13 @@ async def test_reconnect_failure_broadcasts_sentinel_to_iterators( fake_ws.reject_auth = True # First message arrives, then the server closes the connection. - await fake_ws.send_to_all({ - "type": "ticker", "sid": 1, - "msg": {"market_ticker": "T1", "market_id": "x", "yes_bid": 42}, - }) + await fake_ws.send_to_all( + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict(market_ticker="T1", yes_bid_dollars="42"), + } + ) # We should get the first message via the iterator, # then the sentinel — async-for exits with no further items. @@ -423,47 +445,53 @@ async def on_gap(gap: SequenceGap) -> None: stream = await session.subscribe_orderbook_delta(tickers=["T1"]) # Send snapshot (seq=1) - await fake_ws.send_to_all({ - "type": "orderbook_snapshot", - "sid": 1, - "seq": 1, - "msg": { - "market_ticker": "T1", - "market_id": "x", - "yes": [["0.50", "100"]], - "no": [], - }, - }) + await fake_ws.send_to_all( + { + "type": "orderbook_snapshot", + "sid": 1, + "seq": 1, + "msg": { + "market_ticker": "T1", + "market_id": "x", + "yes": [["0.50", "100"]], + "no": [], + }, + } + ) await asyncio.wait_for(stream.__anext__(), timeout=2.0) # Send seq=2 (OK) - await fake_ws.send_to_all({ - "type": "orderbook_delta", - "sid": 1, - "seq": 2, - "msg": { - "market_ticker": "T1", - "market_id": "x", - "price_dollars": "0.50", - "delta_fp": "10", - "side": "yes", - }, - }) + await fake_ws.send_to_all( + { + "type": "orderbook_delta", + "sid": 1, + "seq": 2, + "msg": { + "market_ticker": "T1", + "market_id": "x", + "price_dollars": "0.50", + "delta_fp": "10", + "side": "yes", + }, + } + ) await asyncio.wait_for(stream.__anext__(), timeout=2.0) # Send seq=5 (skip 3, 4 -> gap!) - await fake_ws.send_to_all({ - "type": "orderbook_delta", - "sid": 1, - "seq": 5, - "msg": { - "market_ticker": "T1", - "market_id": "x", - "price_dollars": "0.55", - "delta_fp": "20", - "side": "yes", - }, - }) + await fake_ws.send_to_all( + { + "type": "orderbook_delta", + "sid": 1, + "seq": 5, + "msg": { + "market_ticker": "T1", + "market_id": "x", + "price_dollars": "0.55", + "delta_fp": "20", + "side": "yes", + }, + } + ) # Gap message is NOT dispatched (skipped by recv loop). # Deterministic wait: gap handler signals us; no blind sleep. @@ -505,21 +533,26 @@ async def on_fill(msg: object) -> None: await session.subscribe_fill() # Send ticker (goes to iterator queue) - await fake_ws.send_to_all({ - "type": "ticker", - "sid": 1, - "msg": {"market_ticker": "T1", "market_id": "x", "yes_bid": 55}, - }) + await fake_ws.send_to_all( + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict(market_ticker="T1", yes_bid_dollars="55"), + } + ) # Send fill (goes to callback) - await fake_ws.send_to_all({ - "type": "fill", - "sid": 2, - "msg": {"trade_id": "t1", "order_id": "o1"}, - }) + await fake_ws.send_to_all( + { + "type": "fill", + "sid": 2, + "msg": fill_payload_dict(trade_id="t1", order_id="o1"), + } + ) # Read ticker from iterator ticker_msg = await asyncio.wait_for( - ticker_stream.__anext__(), timeout=2.0, + ticker_stream.__anext__(), + timeout=2.0, ) assert ticker_msg.msg.market_ticker == "T1" assert ticker_msg.msg.yes_bid == 55 @@ -547,11 +580,13 @@ async def test_graceful_shutdown_stops_iterators( async with ws.connect() as session: stream = await session.subscribe_ticker(tickers=["T1"]) # Session is active, send a message to prove it works - await fake_ws.send_to_all({ - "type": "ticker", - "sid": 1, - "msg": {"market_ticker": "T1", "market_id": "x", "yes_bid": 42}, - }) + await fake_ws.send_to_all( + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict(market_ticker="T1", yes_bid_dollars="42"), + } + ) msg = await asyncio.wait_for(stream.__anext__(), timeout=2.0) assert msg.msg.yes_bid == 42 # Context manager exit happens here @@ -604,17 +639,21 @@ async def on_error(err: ErrorMessage) -> None: got_error.set() ws = KalshiWebSocket( - auth=test_auth, config=ws_config, on_error=on_error, + auth=test_auth, + config=ws_config, + on_error=on_error, ) async with ws.connect() as session: # Subscribe to start the recv loop await session.subscribe_ticker(tickers=["T1"]) - await fake_ws.send_to_all({ - "type": "error", - "id": 0, - "msg": {"code": 5, "msg": "something went wrong"}, - }) + await fake_ws.send_to_all( + { + "type": "error", + "id": 0, + "msg": {"code": 5, "msg": "something went wrong"}, + } + ) # Deterministic wait: callback signals; no blind sleep. await asyncio.wait_for(got_error.wait(), timeout=2.0) @@ -633,18 +672,22 @@ async def test_error_without_callback_does_not_crash( async with ws.connect() as session: await session.subscribe_ticker(tickers=["T1"]) - await fake_ws.send_to_all({ - "type": "error", - "id": 0, - "msg": {"code": 99, "msg": "ignored error"}, - }) + await fake_ws.send_to_all( + { + "type": "error", + "id": 0, + "msg": {"code": 99, "msg": "ignored error"}, + } + ) # Should not crash; just ignored. Verify recv loop is still alive. - await fake_ws.send_to_all({ - "type": "ticker", - "sid": 1, - "msg": {"market_ticker": "T1", "market_id": "x", "yes_bid": 30}, - }) + await fake_ws.send_to_all( + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict(market_ticker="T1", yes_bid_dollars="30"), + } + ) # Wait a bit then check recv loop is still operational await asyncio.sleep(0.2) @@ -665,12 +708,15 @@ async def test_state_transitions_during_session( states: list[tuple[ConnectionState, ConnectionState]] = [] async def on_state( - old: ConnectionState, new: ConnectionState, + old: ConnectionState, + new: ConnectionState, ) -> None: states.append((old, new)) ws = KalshiWebSocket( - auth=test_auth, config=ws_config, on_state_change=on_state, + auth=test_auth, + config=ws_config, + on_state_change=on_state, ) async with ws.connect() as session: # Should have transitioned to CONNECTED diff --git a/tests/ws/test_models.py b/tests/ws/test_models.py index e00c046..1af87c1 100644 --- a/tests/ws/test_models.py +++ b/tests/ws/test_models.py @@ -1,8 +1,12 @@ """Tests for WebSocket message models.""" + from __future__ import annotations from decimal import Decimal +import pytest +from pydantic import ValidationError + from kalshi.ws.models.base import ( BaseMessage, ErrorMessage, @@ -32,6 +36,13 @@ from kalshi.ws.models.ticker import TickerMessage from kalshi.ws.models.trade import TradeMessage from kalshi.ws.models.user_orders import UserOrdersMessage +from tests._model_fixtures import ( + fill_payload_dict, + market_positions_payload_dict, + ticker_payload_dict, + trade_payload_dict, + user_orders_payload_dict, +) class TestBaseMessage: @@ -148,17 +159,15 @@ def test_parse_ticker(self) -> None: raw = { "type": "ticker", "sid": 1, - "msg": { - "market_ticker": "ECON-GDP-25Q1", - "market_id": "abc-123", - "yes_bid_dollars": "0.55", - "yes_ask_dollars": "0.60", - "no_bid_dollars": "0.40", - "no_ask_dollars": "0.45", - "volume": "1000", - "open_interest": "500", - "ts": 1700000000, - }, + "msg": ticker_payload_dict( + market_ticker="ECON-GDP-25Q1", + market_id="abc-123", + yes_bid_dollars="0.55", + yes_ask_dollars="0.60", + volume_fp="1000", + open_interest_fp="500", + ts=1700000000, + ), } msg = TickerMessage.model_validate(raw) assert msg.type == "ticker" @@ -172,27 +181,24 @@ def test_ticker_no_seq(self) -> None: raw = { "type": "ticker", "sid": 1, - "msg": {"market_ticker": "T", "market_id": "x"}, + "msg": ticker_payload_dict(market_ticker="T", market_id="x"), } msg = TickerMessage.model_validate(raw) assert msg.seq is None - def test_ticker_minimal_payload(self) -> None: - raw = { - "type": "ticker", - "sid": 1, - "msg": {"market_ticker": "T"}, - } - msg = TickerMessage.model_validate(raw) - assert msg.msg.market_id is None - assert msg.msg.yes_bid is None - assert msg.msg.no_ask is None + def test_ticker_missing_required_raises(self) -> None: + """Post-#172: TickerPayload requires `yes_bid`, `yes_ask`, etc. + A minimal payload omitting them must raise instead of defaulting to None.""" + raw = {"type": "ticker", "sid": 1, "msg": {"market_ticker": "T"}} + with pytest.raises(ValidationError): + TickerMessage.model_validate(raw) def test_ticker_extra_fields(self) -> None: + """Extra fields are tolerated via `extra="allow"` on the envelope+payload.""" raw = { "type": "ticker", "sid": 1, - "msg": {"market_ticker": "T", "new_field": "surprise"}, + "msg": ticker_payload_dict(market_ticker="T", market_id="x", new_field="surprise"), } msg = TickerMessage.model_validate(raw) assert msg.msg.market_ticker == "T" @@ -206,15 +212,15 @@ def test_parse_trade(self) -> None: raw = { "type": "trade", "sid": 2, - "msg": { - "trade_id": "trade-001", - "market_ticker": "ECON-GDP-25Q1", - "yes_price_dollars": "0.55", - "no_price_dollars": "0.45", - "count_fp": "10", - "taker_side": "yes", - "ts": 1700000000, - }, + "msg": trade_payload_dict( + trade_id="trade-001", + market_ticker="ECON-GDP-25Q1", + yes_price_dollars="0.55", + no_price_dollars="0.45", + count_fp="10", + taker_side="yes", + ts=1700000000, + ), } msg = TradeMessage.model_validate(raw) assert msg.type == "trade" @@ -227,20 +233,17 @@ def test_trade_no_seq(self) -> None: raw = { "type": "trade", "sid": 2, - "msg": {"trade_id": "t1", "market_ticker": "T"}, + "msg": trade_payload_dict(trade_id="t1", market_ticker="T"), } msg = TradeMessage.model_validate(raw) assert msg.seq is None - def test_trade_minimal(self) -> None: - raw = { - "type": "trade", - "sid": 1, - "msg": {"trade_id": "t1", "market_ticker": "T"}, - } - msg = TradeMessage.model_validate(raw) - assert msg.msg.yes_price is None - assert msg.msg.taker_side is None + def test_trade_missing_required_raises(self) -> None: + """Post-#172: TradePayload requires yes_price/taker_side/etc. + A minimal payload must raise instead of defaulting to None.""" + raw = {"type": "trade", "sid": 1, "msg": {"trade_id": "t1", "market_ticker": "T"}} + with pytest.raises(ValidationError): + TradeMessage.model_validate(raw) # ---------- Fill ---------- @@ -251,20 +254,20 @@ def test_parse_fill(self) -> None: raw = { "type": "fill", "sid": 3, - "msg": { - "trade_id": "fill-001", - "order_id": "ord-123", - "market_ticker": "ECON-GDP-25Q1", - "is_taker": True, - "side": "yes", - "yes_price_dollars": "0.55", - "count_fp": "5", - "fee_cost": "0.50", - "action": "buy", - "ts": 1700000000, - "post_position_fp": "10", - "purchased_side": "yes", - }, + "msg": fill_payload_dict( + trade_id="fill-001", + order_id="ord-123", + market_ticker="ECON-GDP-25Q1", + is_taker=True, + side="yes", + yes_price_dollars="0.55", + count_fp="5", + fee_cost="0.50", + action="buy", + ts=1700000000, + post_position_fp="10", + purchased_side="yes", + ), } msg = FillMessage.model_validate(raw) assert msg.type == "fill" @@ -278,7 +281,7 @@ def test_fill_no_seq(self) -> None: raw = { "type": "fill", "sid": 3, - "msg": {"trade_id": "f1"}, + "msg": fill_payload_dict(trade_id="f1"), } msg = FillMessage.model_validate(raw) assert msg.seq is None @@ -287,11 +290,11 @@ def test_fill_with_subaccount(self) -> None: raw = { "type": "fill", "sid": 3, - "msg": { - "trade_id": "f1", - "subaccount": 42, - "client_order_id": "my-order", - }, + "msg": fill_payload_dict( + trade_id="f1", + subaccount=42, + client_order_id="my-order", + ), } msg = FillMessage.model_validate(raw) assert msg.msg.subaccount == 42 @@ -306,15 +309,15 @@ def test_parse_market_positions(self) -> None: raw = { "type": "market_positions", "sid": 4, - "msg": { - "user_id": "user-1", - "market_ticker": "ECON-GDP-25Q1", - "position": "100", - "position_cost": "55.00", - "realized_pnl": "10.50", - "fees_paid": "1.25", - "volume": "200", - }, + "msg": market_positions_payload_dict( + user_id="user-1", + market_ticker="ECON-GDP-25Q1", + position_fp="100", + position_cost_dollars="55.00", + realized_pnl_dollars="10.50", + fees_paid_dollars="1.25", + volume_fp="200", + ), } msg = MarketPositionsMessage.model_validate(raw) assert msg.type == "market_positions" @@ -326,7 +329,7 @@ def test_market_positions_no_seq(self) -> None: raw = { "type": "market_positions", "sid": 4, - "msg": {"market_ticker": "T"}, + "msg": market_positions_payload_dict(market_ticker="T"), } msg = MarketPositionsMessage.model_validate(raw) assert msg.seq is None @@ -335,7 +338,10 @@ def test_market_positions_with_subaccount(self) -> None: raw = { "type": "market_positions", "sid": 4, - "msg": {"market_ticker": "T", "subaccount": 7}, + "msg": market_positions_payload_dict( + market_ticker="T", + subaccount=7, + ), } msg = MarketPositionsMessage.model_validate(raw) assert msg.msg.subaccount == 7 @@ -349,23 +355,23 @@ def test_parse_user_orders(self) -> None: raw = { "type": "user_orders", "sid": 5, - "msg": { - "order_id": "ord-001", - "user_id": "user-1", - "ticker": "ECON-GDP-25Q1", - "status": "resting", - "side": "yes", - "is_yes": True, - "yes_price_dollars": "0.55", - "fill_count_fp": "3", - "remaining_count_fp": "7", - "initial_count_fp": "10", - "taker_fill_cost_dollars": "1.65", - "maker_fill_cost_dollars": "0.00", - "taker_fees_dollars": "0.05", - "maker_fees_dollars": "0.00", - "created_time": "2025-01-01T00:00:00Z", - }, + "msg": user_orders_payload_dict( + order_id="ord-001", + user_id="user-1", + ticker="ECON-GDP-25Q1", + status="resting", + side="yes", + is_yes=True, + yes_price_dollars="0.55", + fill_count_fp="3", + remaining_count_fp="7", + initial_count_fp="10", + taker_fill_cost_dollars="1.65", + maker_fill_cost_dollars="0.00", + taker_fees_dollars="0.05", + maker_fees_dollars="0.00", + created_time="2025-01-01T00:00:00Z", + ), } msg = UserOrdersMessage.model_validate(raw) assert msg.type == "user_orders" @@ -379,7 +385,7 @@ def test_user_orders_no_seq(self) -> None: raw = { "type": "user_orders", "sid": 5, - "msg": {"order_id": "ord-001"}, + "msg": user_orders_payload_dict(order_id="ord-001"), } msg = UserOrdersMessage.model_validate(raw) assert msg.seq is None @@ -388,12 +394,12 @@ def test_user_orders_canceled(self) -> None: raw = { "type": "user_orders", "sid": 5, - "msg": { - "order_id": "ord-002", - "status": "canceled", - "remaining_count": "0", - "last_update_time": "2025-01-02T00:00:00Z", - }, + "msg": user_orders_payload_dict( + order_id="ord-002", + status="canceled", + remaining_count_fp="0", + created_time="2025-01-02T00:00:00Z", + ), } msg = UserOrdersMessage.model_validate(raw) assert msg.msg.status == "canceled" @@ -413,6 +419,7 @@ def test_parse_order_group(self) -> None: "event_type": "created", "order_group_id": "og-001", "contracts_limit": "100", + "ts_ms": 1700000000000, }, } msg = OrderGroupMessage.model_validate(raw) @@ -443,6 +450,7 @@ def test_order_group_triggered(self) -> None: "msg": { "event_type": "triggered", "order_group_id": "og-003", + "ts_ms": 1700000000000, }, } msg = OrderGroupMessage.model_validate(raw) @@ -524,6 +532,7 @@ def test_parse_multivariate(self) -> None: "msg": { "collection_ticker": "COL-1", "event_ticker": "EVT-1", + "market_ticker": "MKT-A", "selected_markets": [ { "event_ticker": "EVT-1", @@ -546,22 +555,30 @@ def test_parse_multivariate(self) -> None: assert msg.msg.selected_markets[1].side == "no" def test_multivariate_no_seq(self) -> None: + """`seq` is optional on this channel — full payload must still parse without it.""" raw = { "type": "multivariate", "sid": 8, - "msg": {"collection_ticker": "COL-1"}, + "msg": { + "collection_ticker": "COL-1", + "selected_markets": [], + "market_ticker": "MKT-A", + "event_ticker": "EVT-1", + }, } msg = MultivariateMessage.model_validate(raw) assert msg.seq is None - def test_multivariate_empty_selected_markets(self) -> None: + def test_multivariate_missing_required_raises(self) -> None: + """Post-#172: MultivariatePayload requires market_ticker / event_ticker. + Omitting them must raise instead of leaving them None.""" raw = { "type": "multivariate", "sid": 8, "msg": {"collection_ticker": "COL-1", "selected_markets": []}, } - msg = MultivariateMessage.model_validate(raw) - assert msg.msg.selected_markets == [] + with pytest.raises(ValidationError): + MultivariateMessage.model_validate(raw) def test_multivariate_lifecycle(self) -> None: raw = { @@ -635,6 +652,7 @@ def test_rfq_created_payload_model(self) -> None: "id": "rfq-001", "creator_id": "user-1", "market_ticker": "T", + "created_ts": "2026-01-01T00:00:00Z", "contracts": "50", } ) @@ -646,6 +664,10 @@ def test_quote_accepted_payload_model(self) -> None: { "quote_id": "q-001", "rfq_id": "rfq-001", + "quote_creator_id": "user-2", + "market_ticker": "T", + "yes_bid_dollars": "0.55", + "no_bid_dollars": "0.45", "accepted_side": "yes", "contracts_accepted": "10", } @@ -659,6 +681,10 @@ def test_quote_executed_payload_model(self) -> None: "quote_id": "q-001", "rfq_id": "rfq-001", "order_id": "ord-001", + "quote_creator_id": "user-2", + "rfq_creator_id": "user-1", + "client_order_id": "cli-001", + "market_ticker": "T", "executed_ts": "2026-04-19T18:43:37Z", } ) @@ -678,75 +704,87 @@ class TestWsV0140Backfill: """ def test_ticker_price_and_ts_ms(self) -> None: - msg = TickerMessage.model_validate({ - "type": "ticker", - "sid": 1, - "msg": {"market_ticker": "T", "price_dollars": "0.5500", "ts_ms": 1700000000000}, - }) + msg = TickerMessage.model_validate( + { + "type": "ticker", + "sid": 1, + "msg": ticker_payload_dict( + market_ticker="T", price_dollars="0.5500", ts_ms=1700000000000 + ), + } + ) assert msg.msg.price == Decimal("0.5500") assert msg.msg.ts_ms == 1700000000000 def test_fill_outcome_book_side_and_ts_ms(self) -> None: - msg = FillMessage.model_validate({ - "type": "fill", - "sid": 1, - "msg": { - "trade_id": "t1", - "outcome_side": "yes", - "book_side": "bid", - "ts_ms": 1700000000000, - }, - }) + msg = FillMessage.model_validate( + { + "type": "fill", + "sid": 1, + "msg": fill_payload_dict( + trade_id="t1", + outcome_side="yes", + book_side="bid", + ts_ms=1700000000000, + ), + } + ) assert msg.msg.outcome_side == "yes" assert msg.msg.book_side == "bid" assert msg.msg.ts_ms == 1700000000000 def test_orderbook_delta_ts_ms(self) -> None: - msg = OrderbookDeltaMessage.model_validate({ - "type": "orderbook_delta", - "sid": 1, - "seq": 1, - "msg": { - "market_ticker": "T", - "market_id": "x", - "price_dollars": "0.5500", - "delta_fp": "10.00", - "side": "yes", - "ts_ms": 1700000000000, - }, - }) + msg = OrderbookDeltaMessage.model_validate( + { + "type": "orderbook_delta", + "sid": 1, + "seq": 1, + "msg": { + "market_ticker": "T", + "market_id": "x", + "price_dollars": "0.5500", + "delta_fp": "10.00", + "side": "yes", + "ts_ms": 1700000000000, + }, + } + ) assert msg.msg.ts_ms == 1700000000000 def test_trade_taker_outcome_book_side_and_ts_ms(self) -> None: - msg = TradeMessage.model_validate({ - "type": "trade", - "sid": 1, - "msg": { - "trade_id": "t1", - "market_ticker": "T", - "taker_outcome_side": "no", - "taker_book_side": "ask", - "ts_ms": 1700000000000, - }, - }) + msg = TradeMessage.model_validate( + { + "type": "trade", + "sid": 1, + "msg": trade_payload_dict( + trade_id="t1", + market_ticker="T", + taker_outcome_side="no", + taker_book_side="ask", + ts_ms=1700000000000, + ), + } + ) assert msg.msg.taker_outcome_side == "no" assert msg.msg.taker_book_side == "ask" assert msg.msg.ts_ms == 1700000000000 def test_user_orders_outcome_book_stp_and_ts_ms_trio(self) -> None: - msg = UserOrdersMessage.model_validate({ - "type": "user_order", - "sid": 1, - "msg": { - "order_id": "o1", - "outcome_side": "yes", - "book_side": "bid", - "self_trade_prevention_type": "taker_at_cross", - "created_ts_ms": 1700000000000, - "last_updated_ts_ms": 1700000001000, - "expiration_ts_ms": 1700000002000, - }, - }) + msg = UserOrdersMessage.model_validate( + { + "type": "user_order", + "sid": 1, + "msg": user_orders_payload_dict( + order_id="o1", + outcome_side="yes", + book_side="bid", + self_trade_prevention_type="taker_at_cross", + created_ts_ms=1700000000000, + last_updated_ts_ms=1700000001000, + expiration_ts_ms=1700000002000, + ), + } + ) assert msg.msg.outcome_side == "yes" assert msg.msg.book_side == "bid" assert msg.msg.self_trade_prevention_type == "taker_at_cross" @@ -755,44 +793,53 @@ def test_user_orders_outcome_book_stp_and_ts_ms_trio(self) -> None: assert msg.msg.expiration_ts_ms == 1700000002000 def test_market_lifecycle_metadata_strike_structure_subtitle(self) -> None: - msg = MarketLifecycleMessage.model_validate({ - "type": "market_lifecycle_v2", - "sid": 1, - "msg": { - "event_type": "metadata_updated", - "market_ticker": "T", - "additional_metadata": {"title": "Updated", "rules_primary": "rules"}, - "floor_strike": 50.5, - "price_level_structure": "linear_cent", - "yes_sub_title": "Will it happen?", - }, - }) + msg = MarketLifecycleMessage.model_validate( + { + "type": "market_lifecycle_v2", + "sid": 1, + "msg": { + "event_type": "metadata_updated", + "market_ticker": "T", + "additional_metadata": {"title": "Updated", "rules_primary": "rules"}, + "floor_strike": 50.5, + "price_level_structure": "linear_cent", + "yes_sub_title": "Will it happen?", + }, + } + ) assert msg.msg.additional_metadata == {"title": "Updated", "rules_primary": "rules"} assert msg.msg.floor_strike == Decimal("50.5") assert msg.msg.price_level_structure == "linear_cent" assert msg.msg.yes_sub_title == "Will it happen?" def test_order_group_ts_ms(self) -> None: - msg = OrderGroupMessage.model_validate({ - "type": "order_group_updates", - "sid": 1, - "seq": 1, - "msg": { - "event_type": "created", - "order_group_id": "g1", - "ts_ms": 1700000000000, - }, - }) + msg = OrderGroupMessage.model_validate( + { + "type": "order_group_updates", + "sid": 1, + "seq": 1, + "msg": { + "event_type": "created", + "order_group_id": "g1", + "ts_ms": 1700000000000, + }, + } + ) assert msg.msg.ts_ms == 1700000000000 def test_rfq_created_mve_fields(self) -> None: - payload = RfqCreatedPayload.model_validate({ - "id": "rfq-1", - "mve_collection_ticker": "COLL-001", - "mve_selected_legs": [ - {"event_ticker": "EVT-1", "market_ticker": "MKT-1", "side": "yes"}, - ], - }) + payload = RfqCreatedPayload.model_validate( + { + "id": "rfq-1", + "creator_id": "user-1", + "market_ticker": "MKT-1", + "created_ts": "2026-01-01T00:00:00Z", + "mve_collection_ticker": "COLL-001", + "mve_selected_legs": [ + {"event_ticker": "EVT-1", "market_ticker": "MKT-1", "side": "yes"}, + ], + } + ) assert payload.mve_collection_ticker == "COLL-001" assert payload.mve_selected_legs is not None assert payload.mve_selected_legs[0]["event_ticker"] == "EVT-1" @@ -800,37 +847,57 @@ def test_rfq_created_mve_fields(self) -> None: def test_rfq_deleted_rfq_context(self) -> None: from kalshi.ws.models.communications import RfqDeletedPayload - payload = RfqDeletedPayload.model_validate({ - "id": "rfq-1", - "event_ticker": "EVT-1", - "contracts_fp": "10.00", - "target_cost_dollars": "5.5000", - }) + payload = RfqDeletedPayload.model_validate( + { + "id": "rfq-1", + "creator_id": "user-1", + "market_ticker": "MKT-1", + "deleted_ts": "2026-01-01T00:00:00Z", + "event_ticker": "EVT-1", + "contracts_fp": "10.00", + "target_cost_dollars": "5.5000", + } + ) assert payload.event_ticker == "EVT-1" assert payload.contracts == Decimal("10.00") assert payload.target_cost == Decimal("5.5000") def test_quote_created_rfq_context(self) -> None: - payload = QuoteCreatedPayload.model_validate({ - "quote_id": "q-1", - "event_ticker": "EVT-1", - "yes_contracts_offered_fp": "5.00", - "no_contracts_offered_fp": "3.00", - "rfq_target_cost_dollars": "1.5000", - }) + payload = QuoteCreatedPayload.model_validate( + { + "quote_id": "q-1", + "quote_creator_id": "user-2", + "rfq_id": "rfq-001", + "created_ts": "2026-01-01T00:00:00Z", + "market_ticker": "T", + "yes_bid_dollars": "0.55", + "no_bid_dollars": "0.45", + "event_ticker": "EVT-1", + "yes_contracts_offered_fp": "5.00", + "no_contracts_offered_fp": "3.00", + "rfq_target_cost_dollars": "1.5000", + } + ) assert payload.event_ticker == "EVT-1" assert payload.yes_contracts_offered == Decimal("5.00") assert payload.no_contracts_offered == Decimal("3.00") assert payload.rfq_target_cost == Decimal("1.5000") def test_quote_accepted_rfq_context(self) -> None: - payload = QuoteAcceptedPayload.model_validate({ - "quote_id": "q-1", - "event_ticker": "EVT-1", - "yes_contracts_offered_fp": "5.00", - "no_contracts_offered_fp": "3.00", - "rfq_target_cost_dollars": "1.5000", - }) + payload = QuoteAcceptedPayload.model_validate( + { + "quote_id": "q-1", + "quote_creator_id": "user-2", + "rfq_id": "rfq-001", + "market_ticker": "T", + "yes_bid_dollars": "0.55", + "no_bid_dollars": "0.45", + "event_ticker": "EVT-1", + "yes_contracts_offered_fp": "5.00", + "no_contracts_offered_fp": "3.00", + "rfq_target_cost_dollars": "1.5000", + } + ) assert payload.event_ticker == "EVT-1" assert payload.yes_contracts_offered == Decimal("5.00") assert payload.no_contracts_offered == Decimal("3.00") diff --git a/tests/ws/test_recv_loop_hardening.py b/tests/ws/test_recv_loop_hardening.py index 4e2f4a2..25f6b5e 100644 --- a/tests/ws/test_recv_loop_hardening.py +++ b/tests/ws/test_recv_loop_hardening.py @@ -19,6 +19,7 @@ from kalshi.ws.channels import SubscriptionManager from kalshi.ws.client import KalshiWebSocket from kalshi.ws.connection import ConnectionManager +from tests._model_fixtures import ticker_payload_dict # --------------------------------------------------------------------------- # F-P-01 — per-sub resubscribe failure no longer aborts the whole reconnect @@ -28,7 +29,9 @@ @pytest.mark.asyncio class TestResubscribeIsolation: async def test_one_failed_resubscribe_does_not_kill_others( - self, fake_ws, test_auth # type: ignore[no-untyped-def] + self, + fake_ws, + test_auth, # type: ignore[no-untyped-def] ) -> None: """F-P-01: if one sub's resubscribe errors, the others still succeed.""" config = KalshiConfig( @@ -61,11 +64,15 @@ async def selective_handler(ws, msg): # type: ignore[no-untyped-def] call_count["n"] += 1 if call_count["n"] == 2: msg_id = msg.get("id", 0) - await ws.send(json.dumps({ - "id": msg_id, - "type": "error", - "msg": {"code": 400, "msg": "simulated failure"}, - })) + await ws.send( + json.dumps( + { + "id": msg_id, + "type": "error", + "msg": {"code": 400, "msg": "simulated failure"}, + } + ) + ) return await original_handle(ws, msg) @@ -94,7 +101,9 @@ async def selective_handler(ws, msg): # type: ignore[no-untyped-def] @pytest.mark.asyncio class TestSubscribeConnectionClosed: async def test_connection_closed_mid_subscribe_raises_kalshi_error( - self, fake_ws, test_auth # type: ignore[no-untyped-def] + self, + fake_ws, + test_auth, # type: ignore[no-untyped-def] ) -> None: """F-P-05: a connection drop during _wait_for_response surfaces as KalshiConnectionError, not raw websockets ConnectionClosed.""" @@ -123,7 +132,9 @@ async def closing_handler(ws, msg): # type: ignore[no-untyped-def] @pytest.mark.asyncio class TestUnsubscribeSentinel: async def test_unsubscribe_pushes_sentinel( - self, fake_ws, test_auth # type: ignore[no-untyped-def] + self, + fake_ws, + test_auth, # type: ignore[no-untyped-def] ) -> None: """F-P-08: unsubscribe must push a sentinel so iterators stop hanging.""" config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) @@ -151,7 +162,9 @@ async def test_unsubscribe_pushes_sentinel( @pytest.mark.asyncio class TestPauseDoesNotDropFrames: async def test_inflight_frame_dispatched_when_recv_task_cancelled( - self, fake_ws, test_auth # type: ignore[no-untyped-def] + self, + fake_ws, + test_auth, # type: ignore[no-untyped-def] ) -> None: """F-P-04: a frame mid-dispatch when the recv task is cancelled must still be delivered. We monkey-patch the dispatcher's dispatch() with @@ -170,7 +183,9 @@ async def test_inflight_frame_dispatched_when_recv_task_cancelled( allow_dispatch_finish = asyncio.Event() async def slow_dispatch( - data: dict[str, Any], *, pre_validated: Any = None, + data: dict[str, Any], + *, + pre_validated: Any = None, ) -> None: dispatch_started.set() await allow_dispatch_finish.wait() @@ -181,12 +196,13 @@ async def slow_dispatch( sid = next(iter(session._sub_mgr.active_subscriptions.values())).server_sid # type: ignore[union-attr] # Send one frame. recv loop reads it, enters _process_frame, # calls slow_dispatch which blocks on the event. - await fake_ws.send_to_all({ - "type": "ticker", "sid": sid, - "msg": { - "market_ticker": "T1", "market_id": "x", "yes_bid": 22, - }, - }) + await fake_ws.send_to_all( + { + "type": "ticker", + "sid": sid, + "msg": ticker_payload_dict(market_ticker="T1", yes_bid_dollars="22"), + } + ) await asyncio.wait_for(dispatch_started.wait(), timeout=2.0) # Now request a pause (cancellation). The shield must keep the @@ -217,7 +233,9 @@ async def slow_dispatch( @pytest.mark.asyncio class TestReconnectHoldsSubscribeLock: async def test_handle_reconnect_acquires_subscribe_lock( - self, fake_ws, test_auth # type: ignore[no-untyped-def] + self, + fake_ws, + test_auth, # type: ignore[no-untyped-def] ) -> None: """F-P-03: reconnect must hold _subscribe_lock so concurrent user subscribe_* cannot race the sid-remap. @@ -251,8 +269,7 @@ async def wait_for_lock_contention() -> None: await asyncio.wait_for(wait_for_lock_contention(), timeout=2.0) assert not reconnect_task.done(), ( - "F-P-03 regression: _handle_reconnect did not take " - "_subscribe_lock" + "F-P-03 regression: _handle_reconnect did not take _subscribe_lock" ) # Release; reconnect proceeds @@ -268,7 +285,9 @@ async def wait_for_lock_contention() -> None: @pytest.mark.asyncio class TestRecvLoopExceptionPolicy: async def test_backpressure_error_breaks_loop_and_sentinels( - self, fake_ws, test_auth # type: ignore[no-untyped-def] + self, + fake_ws, + test_auth, # type: ignore[no-untyped-def] ) -> None: """#83: KalshiBackpressureError no longer swallowed. The recv loop exits and consumers see sentinel. @@ -282,7 +301,8 @@ async def test_backpressure_error_breaks_loop_and_sentinels( async with ws.connect() as session: # ERROR strategy with maxsize=1 -> second message overflows stream = await session.subscribe_orderbook_delta( - tickers=["T1"], maxsize=1, + tickers=["T1"], + maxsize=1, ) assert session._sub_mgr is not None @@ -290,24 +310,37 @@ async def test_backpressure_error_breaks_loop_and_sentinels( assert sid is not None # Fill the queue: snapshot lands as the single allowed item. - await fake_ws.send_to_all({ - "type": "orderbook_snapshot", "sid": sid, "seq": 1, - "msg": { - "market_ticker": "T1", "market_id": "x", - "yes": [["0.50", "100"]], "no": [], - }, - }) + await fake_ws.send_to_all( + { + "type": "orderbook_snapshot", + "sid": sid, + "seq": 1, + "msg": { + "market_ticker": "T1", + "market_id": "x", + "yes": [["0.50", "100"]], + "no": [], + }, + } + ) # Yield to let recv loop process the snapshot await asyncio.sleep(0.1) # Second frame overflows -> KalshiBackpressureError - await fake_ws.send_to_all({ - "type": "orderbook_delta", "sid": sid, "seq": 2, - "msg": { - "market_ticker": "T1", "market_id": "x", - "price": "0.51", "delta": "10", "side": "yes", - }, - }) + await fake_ws.send_to_all( + { + "type": "orderbook_delta", + "sid": sid, + "seq": 2, + "msg": { + "market_ticker": "T1", + "market_id": "x", + "price": "0.51", + "delta": "10", + "side": "yes", + }, + } + ) # Wait for the recv loop to process the delta and exit on # BackpressureError. The recv task should complete (loop broke). @@ -354,13 +387,13 @@ async def test_malformed_frame_logged_with_traceback_and_continues( for conn in fake_ws.connections: await conn.send("{not valid json") - await fake_ws.send_to_all({ - "type": "ticker", "sid": sid, - "msg": { - "market_ticker": "T1", "market_id": "x", - "yes_bid": 77, - }, - }) + await fake_ws.send_to_all( + { + "type": "ticker", + "sid": sid, + "msg": ticker_payload_dict(market_ticker="T1", yes_bid_dollars="77"), + } + ) msg = await asyncio.wait_for(stream.__anext__(), timeout=2.0) assert msg.msg.yes_bid == 77 @@ -397,19 +430,19 @@ async def boom(_data: dict[str, Any], **_kw: Any) -> None: session._dispatcher.dispatch = boom # type: ignore[method-assign] with caplog.at_level(logging.ERROR, logger="kalshi.ws"): - await fake_ws.send_to_all({ - "type": "ticker", "sid": sid, - "msg": {"market_ticker": "T1", "market_id": "x", "yes_bid": 1}, - }) + await fake_ws.send_to_all( + { + "type": "ticker", + "sid": sid, + "msg": ticker_payload_dict(market_ticker="T1", yes_bid_dollars="1"), + } + ) # Iterator must see sentinel (StopAsyncIteration) within timeout. with pytest.raises(StopAsyncIteration): await asyncio.wait_for(stream.__anext__(), timeout=2.0) - assert any( - "Unexpected error in recv loop" in r.message - for r in caplog.records - ) + assert any("Unexpected error in recv loop" in r.message for r in caplog.records) # Recv task is in failed state (we re-raised after sentinel). recv_task = session._recv_task assert recv_task is not None