Skip to content

feat!: spec sync 3.22.0 → 3.23.0 (v6.0.0) — drop multivariate lookup-history, additive drift fixes#466

Merged
TexasCoding merged 3 commits into
mainfrom
fix/spec-drift-463-v6
Jul 4, 2026
Merged

feat!: spec sync 3.22.0 → 3.23.0 (v6.0.0) — drop multivariate lookup-history, additive drift fixes#466
TexasCoding merged 3 commits into
mainfrom
fix/spec-drift-463-v6

Conversation

@TexasCoding

@TexasCoding TexasCoding commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Summary

Syncs the SDK to upstream OpenAPI/AsyncAPI 3.22.0 → 3.23.0 (v6.0.0). The headline change is breaking: Kalshi removed the multivariate lookup-history endpoint, so the SDK removes the matching method and model. Everything else is additive/defensive and backward-compatible. This PR resolves all 18 nightly strict contract-drift failures (#463) and implements the two new 3.23.0 subaccount capabilities (#464, #465), so the SDK adheres to 3.23.0 completely.

Changes

Breaking

  • Removed multivariate_collections.lookup_history() (sync + async) and the LookupPoint model — upstream deleted GET /multivariate_event_collections/{ticker}/lookup (GetMultivariateEventCollectionLookupHistory) and the LookupPoint schema in 3.23.0. lookup_tickers() (PUT) is unaffected.

New endpoints (3.23.0)

  • subaccounts.transfer_position(...) (sync + async) → POST /portfolio/subaccounts/positions/transfer — moves an open position between subaccounts; returns ApplySubaccountPositionTransferResponse. New models ApplySubaccountPositionTransferRequest / ...Response.
  • subaccounts.create(exchange_index=...)POST /portfolio/subaccounts gained an optional CreateSubaccountRequest body. New CreateSubaccountRequest model.
  • Both registered in CONTRACT_MAP / METHOD_ENDPOINT_MAP / BODY_MODEL_MAP so the drift suite validates them against the spec; unit + integration coverage added; 3 new models exported from kalshi / kalshi.models.

Additive fields (backward-compatible)

  • ApiKey / CreateApiKeyRequest / GenerateApiKeyRequest: +subaccount
  • SubaccountTransfer: +exchange_index, +transfer_type (cash/position), position-only +market_ticker / +side / +count / +price_cents
  • ApplySubaccountTransferRequest: +exchange_index
  • MarginOrder: +order_reason; MarginPosition / MarginRiskPosition: +is_portfolio
  • MarketLifecyclePayload (WS): +price_ranges
  • Removed the now-stale Market.response_price_units drift exclusion

Defensive

  • 3.23.0 removed/relaxed three fields the SDK still required (a latent ValidationError the spec→SDK drift suite can't catch). Made optional: Market.fractional_trading_enabled, MarketPosition.resting_orders_count, MarginPosition.margin_used.

Housekeeping

  • Version 5.0.1 → 6.0.0; CHANGELOG, docs/migration.md, docs/resources/multivariate.md + docs/resources/subaccounts.md updated (incl. fixing a stale cash-transfer() example that printed resp.transfer_id though it returns None). Regenerated specs/* + kalshi/_generated/models.py.

Test plan

  • uv run pytest tests/ --ignore=tests/integration -q4123 passed, 3 skipped
  • uv run ruff check . — clean
  • uv run mypy kalshi/ — clean (157 files)
  • Contract drift (tests/test_contracts.py) — green; new endpoints drift-validated against 3.23.0
  • Integration coverage exact-set assertion — transfer_position registered (discovered == registered)
  • Integration (tests/integration/) — not run (needs live demo creds; position-transfer smoke test skips gracefully when demo has no position)

Notes for reviewers

Issue links

Closes #463
Closes #464
Closes #465

…history, additive drift fixes

Reconciles the SDK with OpenAPI/AsyncAPI 3.23.0, resolving all 18 nightly
strict contract-drift failures from #463.

Breaking: Kalshi removed the GET /multivariate_event_collections/{ticker}/lookup
endpoint (GetMultivariateEventCollectionLookupHistory) and the LookupPoint
schema, so lookup_history() (sync + async) and LookupPoint are removed.

Additive (backward-compatible):
- ApiKey / CreateApiKeyRequest / GenerateApiKeyRequest: +subaccount
- SubaccountTransfer: +exchange_index, +transfer_type, and position-only
  +market_ticker/+side/+count/+price_cents
- ApplySubaccountTransferRequest: +exchange_index
- MarginOrder: +order_reason; MarginPosition/MarginRiskPosition: +is_portfolio
- MarketLifecyclePayload (WS): +price_ranges
- Removed the stale Market.response_price_units drift exclusion

Defensive (fields 3.23.0 removed/relaxed but the SDK still required — a latent
parse-break the spec→SDK drift suite cannot catch):
- Market.fractional_trading_enabled, MarketPosition.resting_orders_count,
  MarginPosition.margin_used → optional

New 3.23.0 capabilities are tracked as follow-ups: #464 (position-transfer
endpoint) and #465 (create-subaccount request body).

Closes #463

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown

Code Review

This PR syncs the SDK to spec 3.23.0, cleanly removes the deleted `lookup_history` endpoint and `LookupPoint` model, and makes defensive optionalizations for three fields the spec removed or relaxed (`Market.fractional_trading_enabled`, `MarketPosition.resting_orders_count`, `MarginPosition.margin_used`). The removal of the `Market.response_price_units` exclusion is safe — the field is fully gone from the spec and the stale-exclusion test would have caught leaving it in. Overall a clean and well-reasoned drift sync.

One confirmed bug:


Bug: missing bounds validation on subaccount in request models

kalshi/models/api_keys.py, lines ~63 and ~82

The spec explicitly constrains `subaccount` to `minimum: 0, maximum: 63` in both `CreateApiKeyRequest` and `GenerateApiKeyRequest`:

# specs/openapi.yaml
subaccount:
  type: integer
  minimum: 0
  maximum: 63

The SDK declares it as bare `int | None = None` in both request models, so a caller can pass `subaccount=100` (or `-1`) and the SDK will happily serialize it and send it to the server, which returns a 400 or silently misroutes the key. The rest of the codebase consistently mirrors spec numeric bounds via `Field` constraints (e.g. `ApplySubaccountTransferRequest.from_subaccount = StrictInt = Field(ge=0)`, `exchange_index = StrictInt | None = Field(default=None, ge=0)`).

Fix:

# CreateApiKeyRequest and GenerateApiKeyRequest
subaccount: int | None = Field(default=None, ge=0, le=63)

The read-back field `ApiKey.subaccount` is a response model so validation there is less critical, but consistency would suggest the same bounds.


Observation: is_portfolio added as required on MarginPosition / MarginRiskPosition

kalshi/perps/models/portfolio.py:69, kalshi/perps/models/margin_account.py:70

The spec 3.23.0 marks `is_portfolio` as required for both `MarginPosition` and `MarginRiskPosition`, and the PR follows suit. That's consistent with the PR's deliberate pattern: defensive optionalization applies only to fields the spec removed or relaxed (`margin_used`, `fractional_trading_enabled`, `resting_orders_count`), not to new required additions.

Worth a call-out only because the SDK documentation (CHANGELOG, PR description) already mentions that "3.23.0 removed/relaxed three fields the SDK still required — a latent `ValidationError` the spec→SDK drift suite can't catch." The same latency applies in reverse: if Kalshi's perps API isn't yet fully on 3.23.0, every `GET /margin/positions` or `GET /margin/risk` response would hard-fail. Nothing to change if you're confident the API is live on 3.23.0 for these endpoints — just noting the asymmetry in the defensive approach.

Completes the 3.23.0 spec surface for the subaccounts resource — the two
new capabilities #463 deferred as follow-ups.

- transfer_position() (sync + async) → POST /portfolio/subaccounts/positions/transfer:
  moves an open position (contracts) between subaccounts, returns
  ApplySubaccountPositionTransferResponse (position_transfer_id). New models
  ApplySubaccountPositionTransferRequest / ...Response.
- create(exchange_index=...) → the optional CreateSubaccountRequest body added
  to POST /portfolio/subaccounts. New CreateSubaccountRequest model.

Registered both in CONTRACT_MAP / METHOD_ENDPOINT_MAP / BODY_MODEL_MAP so the
drift suite validates them against the spec; added unit + integration
coverage; exported the 3 new models; updated docs + CHANGELOG. Also corrected
the subaccounts doc's cash-transfer example (transfer() returns None).

Closes #464
Closes #465

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown

Code Review — v6.0.0 / spec 3.22.0 → 3.23.0

Overall this is a clean spec-sync. The defensive optionalizations, contract-map registrations, and drift-test plumbing are all done correctly. A few issues worth addressing before merge.


Bugs

1. Duplicate model_config in ApplySubaccountTransferRequest (kalshi/models/subaccounts.py)

The diff adds exchange_index and then appends a second model_config = {"extra": "forbid"} without removing the original. Python silently takes the last definition (same value so no behavioral change), but it's a dead-code defect. The original trailing model_config line should be removed when inserting exchange_index before it.

2. SubaccountTransfer.exchange_index and .transfer_type are required fields (kalshi/models/subaccounts.py)

Both have no default, meaning any response from a pre-3.23.0 server that omits them will raise a ValidationError at parse time. This is inconsistent with the PR's own defensive posture: Market.fractional_trading_enabled, MarketPosition.resting_orders_count, and MarginPosition.margin_used were all made optional in this same PR to handle exactly this scenario. SubaccountTransfer appears in list/paginated responses where mixed-version server responses are a realistic risk. Consider:

exchange_index: int | None = None
transfer_type: Literal["cash", "position"] | None = None

Missing Validation

3. No bounds on CreateApiKeyRequest.subaccount and GenerateApiKeyRequest.subaccount

The spec declares minimum: 0, maximum: 63 for the subaccount field, but the models have no Field(ge=0, le=63). An out-of-range value like subaccount=99 passes silently and gets rejected by the server. Compare with CreateSubaccountRequest.exchange_index which correctly uses Field(default=None, ge=0). Consistency would be Field(default=None, ge=0, le=63) on both request models.


Test Coverage Gaps

4. No unit test for AsyncSubaccountsResource.transfer_position

TestSubaccountsTransferPosition only exercises the sync resource. The async path shares _build_position_transfer_body but the async HTTP dispatch and response parsing are untested at unit level (only covered by the skippable integration smoke test). Adding an async def test_transfer_position_async_happy_path mirroring the sync happy-path test would close this gap.

5. No tests for new ApiKey, CreateApiKeyRequest, GenerateApiKeyRequest subaccount field

Three models got a new optional field with no new test cases. At minimum: a round-trip serialization test with subaccount=5 and a model rejection test with subaccount=99 (once bounds are added per point 3).

6. No negative test for MarginPosition.is_portfolio absence

is_portfolio: bool is a required field (no default). If a server response omits it, parsing hard-fails. A test that confirms the expected ValidationError would document this behavior and guard against an accidental = None default being added later.

7. No unit test for MarketLifecyclePayload.price_ranges

The WS model addition has no test coverage. A simple parse test with price_ranges=[{"min": 0.1, "max": 0.9}] (and one without the field to confirm the None default) would cover it.

8. SubaccountTransfer required-field absence is not tested (related to point 2)

Whether exchange_index/transfer_type end up required or optional, there should be a test that documents the behavior when they're absent in a server response. Right now the fixture in test_subaccount_transfer_parses always includes them, so there's no coverage for the missing-field path.


Minor

9. _contract_map.py note for MarketLifecyclePayload says "all spec msg fields ... mapped" but additional_metadata is only tolerated via extra="allow", not explicitly typed. The note is technically accurate about coverage (extra="allow" does handle it) but could be more precise: "all typed spec fields mapped; extra fields tolerated via extra=allow".


What's Good

  • Removal of lookup_history + LookupPoint is surgical and complete — no orphaned imports or test debris.
  • ApplySubaccountPositionTransferRequest field constraints (price_cents: StrictInt = Field(ge=0, le=100), count: StrictInt = Field(gt=0), from_subaccount/to_subaccount: Field(ge=0)) are correctly bounded.
  • _build_position_transfer_body / UUID coercion / _check_request_exclusive guard follows the established _build_transfer_body pattern cleanly.
  • CreateSubaccountRequest correctly serializes to {} when exchange_index=None (preserving old behavior) and to {"exchange_index": 0} when explicitly set.
  • Drift-test plumbing (METHOD_ENDPOINT_MAP, BODY_MODEL_MAP, CONTRACT_MAP) is all registered correctly for the new endpoints.
  • Removal of the response_price_units drift exclusion is correct now that 3.23.0 drops the field from the spec entirely.

Review response (PR #466 bot review):
- api_keys: bound CreateApiKeyRequest/GenerateApiKeyRequest.subaccount to the
  spec's 0-63 range (ge=0, le=63); thread a `subaccount` kwarg through
  create()/generate() (sync + async) so it's settable without the request model.
- tests: subaccount round-trip + out-of-range rejection (api keys, request +
  resource), async transfer_position happy-path, WS price_ranges parse/absent,
  and required-absence tests for SubaccountTransfer + MarginPosition.

Kept the newly spec-required response fields (SubaccountTransfer.exchange_index
/ transfer_type, Margin*.is_portfolio) REQUIRED — this matches the repo's
test_required_drift spec-required => SDK-required enforcement and the ROADMAP's
stated "tighten toward required for reliably-sent fields" direction. The PR's
optional-izations were only for fields the spec REMOVED/RELAXED (a different case).

Docs (v6.0.0 readiness):
- OpenAPI version bump v3.22.0 -> v3.23.0 (README, docs/index, CLAUDE).
- api-keys.md (subaccount kwarg), subaccounts.md/multivariate.md (done earlier),
  reference.md (new request models), perps.md (is_portfolio / order_reason).
- ROADMAP.md: v6.0.0 shipped entry + backfilled the v4.x/v5.x gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@TexasCoding

Copy link
Copy Markdown
Owner Author

Thanks for the review. Addressed in bcbb5fe. Point-by-point:

1. Duplicate model_config in ApplySubaccountTransferRequest — Couldn't reproduce; the class has a single model_config (line 57). The added exchange_index sits above the existing config, not beside a second one. No change.

2. SubaccountTransfer.exchange_index / transfer_type required — Kept required (won't change). These are added-as-required in 3.23.0, so a current server always sends them. The PR's defensive optional-izations were only for fields the spec removed (fractional_trading_enabled, resting_orders_count) or relaxed (margin_used) — not-in-spec/no-longer-required → shouldn't be SDK-required. SubaccountTransfer.* is the opposite case. This also matches the repo's test_required_drift (spec-required ⇒ SDK-required) and ROADMAP.md's stated direction to tighten toward required for reliably-sent fields. Added a regression test documenting the required behavior (point 8).

3. Bounds on CreateApiKeyRequest/GenerateApiKeyRequest.subaccount — Fixed. Spec declares minimum:0, maximum:63, so both request models now use Field(default=None, ge=0, le=63). Also threaded a subaccount kwarg through api_keys.create()/generate() (sync + async) so it's usable without the request model. Response ApiKey.subaccount stays permissive.

4. Async transfer_position unit test — Added TestAsyncSubaccounts.test_transfer_position.

5. subaccount field tests (ApiKey/Create/Generate) — Added round-trip + out-of-range rejection tests (request models and resource level).

6. MarginPosition.is_portfolio required-absence — Added test_position_requires_is_portfolio (documents the deliberate required choice; see point 2).

7. MarketLifecyclePayload.price_ranges — Added parse test ({start,end,step} bands) + absent-→-None test.

8. SubaccountTransfer required-field absence — Added test_subaccount_transfer_requires_v3_23_fields.

9. _contract_map.py note precisionadditional_metadata is an explicit typed field on MarketLifecyclePayload (not extra="allow" tolerance), so "all spec msg fields mapped" is accurate. No change.

Separately, folded in #464 (transfer_position) and #465 (create(exchange_index=)) and a full docs pass for the v6.0.0 release.

@claude

claude Bot commented Jul 4, 2026

Copy link
Copy Markdown

Code Review — PR #466 (spec sync 3.22.0 → 3.23.0, v6.0.0)

This PR is a clean, well-structured spec sync. The removal of lookup_history() / LookupPoint is executed correctly (resources, models, contract map, tests, docs, migration guide all updated), the two new subaccount capabilities (transfer_position, create(exchange_index=)) follow the existing SDK patterns faithfully, and the three defensive optional-izations match the announced rationale. Test coverage is solid.

Two plausible correctness risks are worth resolving before merge, plus a minor test-thoroughness gap.


Finding 1 — SubaccountTransfer: newly required fields could hard-fail on historical server data (medium risk)

Files: kalshi/models/subaccounts.py:141-142, tests/test_subaccounts.py (test_subaccount_transfer_requires_v3_23_fields)

exchange_index: int and transfer_type: Literal["cash", "position"] are added without defaults, so SubaccountTransfer.model_validate(...) raises ValidationError if either field is absent. extra="allow" on the model only protects against extra keys — it does nothing for missing required fields.

Failure scenario: Any user who calls list_transfers() or list_all_transfers() immediately after upgrading to v6.0.0 will get a ValidationError for every historical transfer record if the Kalshi server doesn't backfill exchange_index / transfer_type onto those old rows. The entire paginated result is lost, not just the two fields.

Contrast with the defensive approach elsewhere in this same PR: Market.fractional_trading_enabled, MarketPosition.resting_orders_count, and MarginPosition.margin_used were all made | None = None because "3.23.0 dropped/relaxed them." The risk is symmetric — fields the spec adds as required are just as likely to be absent in server responses until backfill is confirmed, but the approach is asymmetric. The PR's test test_subaccount_transfer_requires_v3_23_fields explicitly encodes the hard-fail behaviour, so this is intentional; the question is whether it's safe.

Suggested fix (if backfill isn't guaranteed):

exchange_index: int | None = None
transfer_type: Literal["cash", "position"] | None = None

Or, if backfill is guaranteed by the Kalshi team, add a one-liner to docs/migration.md noting this assumption, so users who hit older API gateways know what to expect:

Note: SubaccountTransfer now requires exchange_index and transfer_type; the 3.23.0 server backfills these for all historical records.


Finding 2 — MarginPosition.is_portfolio and MarginRiskPosition.is_portfolio: required without defensive fallback (low-medium risk)

Files: kalshi/perps/models/portfolio.py:67, kalshi/perps/models/margin_account.py:68

is_portfolio: bool is added as a hard-required field (no default) on both perps response models. extra="allow" is present on MarginPosition, so unknown extra keys are tolerated — but a missing required field still raises ValidationError.

Failure scenario: Any user calling client.portfolio.positions() or client.risk.margin_risk() whose perps server node hasn't yet deployed the 3.23.0 payload will see a ValidationError on the entire response. Since this is a perps-specific subsystem with its own deployment cadence, there may be a window where the spec is at 3.23.0 but the perps backend hasn't shipped is_portfolio.

In the same PR, margin_used was defensively made optional with the comment "Optional/defensive so a response omitting it does not hard-fail parsing." That same logic applies here until the perps deployment is confirmed.

Suggested fix:

is_portfolio: bool | None = None   # spec v3.23.0 required; defensive until confirmed on all nodes

Finding 3 — Async test_transfer_position body assertions are partial (minor)

File: tests/test_subaccounts.py (async section, test_transfer_position)

The sync test_transfer_position_sends_body_and_returns_id does a full equality check on the serialized body:

assert json.loads(route.calls[0].request.content) == {
    "client_transfer_id": _TEST_XFER_ID, "from_subaccount": 0, ...
}

The async counterpart only spot-checks two fields:

assert body["side"] == "no"
assert body["price_cents"] == 25

This means the async path's client_transfer_id UUID serialization and market_ticker are not verified. At minimum, asserting body["client_transfer_id"] == _TEST_XFER_ID would confirm that the UUID-to-string coercion works through the async stack (the sync test already covers this, but the async overloads are separate code paths for the await self._post(...) branch).


Everything else looks correct

  • _build_position_transfer_body — the UUID coercion (isinstance(…, UUID) else UUID(…)) is clean and the explicit ValueError on malformed strings is tested.
  • CreateSubaccountRequest serializing to {} when exchange_index=None and still forcing Content-Type: application/json via json=body is the right workaround (consistent with the existing json={} pattern).
  • ApplySubaccountPositionTransferRequest.price_cents bounds (ge=0, le=100) match the spec's 0–100 cent range for Kalshi binary contracts.
  • The response_price_units exclusion removal in _contract_support.py is correct — the field is now fully absent from the spec.
  • LookupPoint / lookup_history removal is clean across all touchpoints (contract map, __init__ exports, tests, integration tests, docs).

@TexasCoding TexasCoding merged commit 88a849f into main Jul 4, 2026
6 checks passed
@TexasCoding TexasCoding deleted the fix/spec-drift-463-v6 branch July 4, 2026 15:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant