diff --git a/CLAUDE.md b/CLAUDE.md index b221019..19c5c5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ tests/ with `_dollars` suffix field names. SDK models use short Python names (`yes_bid`) with `validation_alias=AliasChoices("yes_bid_dollars", "yes_bid")` to accept both. `CreateOrderRequest` serializes with `_dollars` suffix via `serialization_alias`. - Verified against OpenAPI spec v3.13.0 on 2026-04-12. + Verified against OpenAPI spec v3.18.0 on 2026-05-18. - All prices use `Decimal` via the `DollarDecimal` custom Pydantic type. - Auth signing payload: `str(timestamp_ms) + METHOD + path_only` (path from urlparse, no query params, no trailing slash). @@ -116,14 +116,14 @@ tests/ ## Testing -- pytest + pytest-asyncio + respx (httpx mock); 1455 tests collected (1407 passing, 48 skipped without live credentials). +- pytest + pytest-asyncio + respx (httpx mock); ~1920 tests across unit + contract drift suites. - Use `respx.mock` for HTTP mocking. Generate test RSA keys via conftest.py fixtures. - New function → write a test. Bug fix → write a regression test. New error path → write a test that triggers it. ## API Reference -- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.13.0, 90+ endpoints) -- AsyncAPI spec: https://docs.kalshi.com/asyncapi.yaml (11 WebSocket channels) +- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.18.0, 85 endpoints) +- AsyncAPI spec: https://docs.kalshi.com/asyncapi.yaml (13 WebSocket channels) - Base URL: https://api.elections.kalshi.com/trade-api/v2 - Demo URL: https://demo-api.kalshi.co/trade-api/v2 - Auth: RSA-PSS / SHA256 / MGF1(SHA256) / salt_length=DIGEST_LENGTH / base64 diff --git a/README.md b/README.md index b75bcca..237954b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) predi [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Type checked: mypy strict](https://img.shields.io/badge/mypy-strict-blue.svg)](https://mypy.readthedocs.io/) -- **Full coverage** of the Kalshi REST API (89 endpoints across 19 resources) and WebSocket API (11 channels). +- **Full coverage** of the Kalshi REST API (85 endpoints across 19 resources, OpenAPI v3.18.0) and WebSocket API (13 channels). +- **V2 event-market orders**: `create_v2` / `amend_v2` / `decrease_v2` / `cancel_v2` plus batched variants on `/portfolio/events/orders/*`. Legacy `/portfolio/orders` keeps working — deprecated no earlier than May 6, 2026. +- **Funding & cost introspection**: `portfolio.deposits()`, `portfolio.withdrawals()`, `account.endpoint_costs()`. - **Sync and async** clients sharing one transport — no thread-pool wrapping. - **Typed end-to-end**: Pydantic v2 models, `mypy --strict` clean, ships `py.typed`. `Literal` types on fixed-enum kwargs. - **Spec-aligned with drift guards**: hard-fail contract tests catch query, body, and WebSocket payload drift on every commit. @@ -141,6 +143,34 @@ client.orders.create(request=CreateOrderRequest( )) ``` +### V2 event-market orders + +Spec v3.18.0 introduced the V2 family on `/portfolio/events/orders/*` — +event-scoped semantics with single-book `bid`/`ask` sides and fixed-point +dollar prices. Legacy `/portfolio/orders` keeps working and will be +deprecated no earlier than May 6, 2026. + +```python +import uuid +from decimal import Decimal +from kalshi import KalshiClient, CreateOrderV2Request + +with KalshiClient.from_env() as client: + resp = client.orders.create_v2(request=CreateOrderV2Request( + ticker="EVENT-MKT", + client_order_id=str(uuid.uuid4()), # required + server idempotency key + side="bid", # BookSideLiteral: "bid" | "ask" + count=Decimal("10"), + price=Decimal("0.50"), + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + )) + print(resp.order_id, resp.remaining_count, resp.fill_count) +``` + +The V2 surface is model-only (no kwarg overload); pass a fully-constructed +request model. See [V2 orders docs](https://texascoding.github.io/kalshi-python-sdk/resources/orders/#v2-event-market-orders) for amend/decrease/batch variants. + ## WebSocket streaming ```python @@ -161,9 +191,10 @@ async def main() -> None: asyncio.run(main()) ``` -Available channels (11): `ticker`, `trade`, `orderbook_delta`, `fill`, -`market_positions`, `user_orders`, `order_group`, `market_lifecycle`, -`multivariate`, `multivariate_lifecycle`, `communications`. +Available channels (13): `ticker`, `trade`, `orderbook_delta`, `fill`, +`market_positions`, `user_orders`, `order_group_updates`, +`market_lifecycle_v2`, `multivariate`, `multivariate_market_lifecycle`, +`communications`, `control_frames`, `root`. ## Error handling diff --git a/ROADMAP.md b/ROADMAP.md index 3a63542..9b6916d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,6 +4,15 @@ See `CHANGELOG.md` for full release history. +- **v2.1.0 (2026-05-18)** — OpenAPI sync v3.13.0 → v3.18.0 (`#155`). V2 + event-market orders family (`create_v2` / `amend_v2` / `decrease_v2` / + `cancel_v2` + batched variants on `/portfolio/events/orders/*`), + deposit/withdrawal history, account endpoint-cost introspection, + `Balance.balance_dollars` (soft-breaking at construction sites only), + optional `exchange_index` / `user_filter` / `rfq_user_filter` / + `incentive_description` / `post_only` kwargs on existing endpoints. + Also fixes a recurring false-alarm in the weekly spec-drift workflow + by committing `specs/asyncapi.yaml` as a pinned snapshot. - **v2.0.0 (2026-05-17)** — audit-driven hardening. 30 audit findings closed across five parallel waves (`#77`–`#106`) plus follow-ups: WebSocket recv-loop overhaul (5 reconnect races + narrowed exceptions), @@ -36,7 +45,13 @@ See `CHANGELOG.md` for full release history. ## Next milestone -Not scoped. Candidates from the v2.0 audit backlog: +Not scoped. Carry-overs from v2.1 and v2.0 audit backlog: + +- **Response-side spec drift detection.** v2.1 contract tests cover request + bodies (`TestRequestBodyDrift`) but not response models, which is how + `Balance.balance_dollars` slipped through 5 rounds of review. Add a + `RESPONSE_MODEL_MAP` + walker that asserts spec-required fields exist on + the SDK model. - Apply `extra="allow"` policy to WS envelope models (`kalshi/ws/models/`) to mirror the response-model uniformity from `#114`. diff --git a/docs/concepts.md b/docs/concepts.md index a2abd9c..0c1c19e 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -66,6 +66,14 @@ anything with `_dollars` or `yes_price` / `no_price` is `Decimal` dollars. - **Position** — your aggregate exposure on a market (signed by side). - **Settlement** — what the exchange paid out when the market resolved. +Orders come in two families: + +- **V1** — `/portfolio/orders/*`. Yes/no sides, paired `yes_price` / + `no_price`. Stable surface; deprecation no earlier than May 6, 2026. +- **V2** — `/portfolio/events/orders/*`. Event-scoped, single-book + `bid` / `ask` sides, single `price` field, required `client_order_id` + acting as an idempotency key. Use this for new event-market integrations. + See [Orders](resources/orders.md), [Portfolio](resources/portfolio.md). ## RFQ and Quote diff --git a/docs/index.md b/docs/index.md index 985fa19..127bbb6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,9 +3,14 @@ A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) prediction markets API. -- **Full REST coverage** — 89 endpoints across 19 resources, every kwarg drift-tested - against the OpenAPI spec. -- **Full WebSocket coverage** — 11 channels with sequence-gap detection, automatic +- **Full REST coverage** — 85 endpoints across 19 resources (OpenAPI v3.18.0), + every kwarg drift-tested against the spec. +- **V2 event-market orders** — new `create_v2` / `amend_v2` / `decrease_v2` / + `cancel_v2` family on `/portfolio/events/orders/*`. Legacy `/portfolio/orders` + keeps working; deprecation no earlier than May 6, 2026. +- **Funding + cost introspection** — `portfolio.deposits()`, + `portfolio.withdrawals()`, `account.endpoint_costs()`. +- **Full WebSocket coverage** — 13 channels with sequence-gap detection, automatic reconnection, backpressure strategies, and an in-memory orderbook builder. - **Sync and async parity** — `KalshiClient` and `AsyncKalshiClient` share one transport implementation; method names, kwargs, return types, and error behavior diff --git a/docs/migration.md b/docs/migration.md index 381cf77..9bb7c66 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,5 +1,112 @@ # Migration +## v2.0 → v2.1 + +v2.1 syncs the SDK to OpenAPI spec v3.18.0. It's **additive at the resource +surface** — eight new endpoints, several new optional kwargs on existing +methods, and one soft-breaking model-construction change called out below. +Code that only consumes the SDK's responses needs no edits; code that +constructs models directly in tests/mocks needs one small update. + +### `Balance.balance_dollars` — required, soft-breaking at construction + +Spec v3.18.0 adds `balance_dollars: FixedPointDollars` to +`GetBalanceResponse` as a required field. The server now guarantees it, so +callers parsing API responses (`client.portfolio.balance()`) are unaffected. +**But** any code that builds `Balance(...)` directly — typically test mocks +— will hit `ValidationError` until it adds the field. + +```python +# v2.0 — broken in v2.1 +Balance(balance=50000, portfolio_value=75000, updated_ts=ts) + +# v2.1 +Balance( + balance=50000, + balance_dollars=Decimal("500.00"), # new required field + portfolio_value=75000, + updated_ts=ts, +) +``` + +The accompanying optional `balance_breakdown: list[IndexedBalance] | None` +splits the total across exchange shards when present. `IndexedBalance.balance` +is `DollarDecimal` (matching `balance_dollars` units), not cents — same +field name as `Balance.balance` but a different type. Be deliberate when +reading from `balance.balance_breakdown[i].balance`. + +### V2 event-market orders + +Six new methods on `OrdersResource` / `AsyncOrdersResource` hit the new +`/portfolio/events/orders/*` paths: + +- `create_v2(*, request: CreateOrderV2Request)` +- `cancel_v2(order_id, *, subaccount, exchange_index)` +- `amend_v2(order_id, *, request: AmendOrderV2Request, subaccount)` +- `decrease_v2(order_id, *, request: DecreaseOrderV2Request, subaccount)` +- `batch_create_v2(*, request: BatchCreateOrdersV2Request)` +- `batch_cancel_v2(*, request: BatchCancelOrdersV2Request)` + +Legacy `/portfolio/orders` keeps working and will be deprecated no earlier +than May 6, 2026. No migration is required to stay on v1 paths. New +event-market trading should target the V2 family for the cleaner shape +(single `bid`/`ask` side, single `price` field, explicit idempotency). + +Two important differences from V1: + +1. **`client_order_id` is required** on `CreateOrderV2Request` and acts as + the server-side idempotency key. Reusing a value returns the original + order rather than placing a new one. Use a fresh UUID4 per call. +2. **`side` uses `BookSideLiteral` (`"bid"` / `"ask"`)**, not V1's + `SideLiteral` (`"yes"` / `"no"`). + +### Spec-driven asymmetry on V2 amend/decrease + +`amend_v2` and `decrease_v2` accept `subaccount` as a **resource-method +kwarg** (query param on the wire) but read `exchange_index` from the +**request body**. `cancel_v2` differs again — both are query params there, +because that endpoint has no body. This mirrors the spec exactly: + +```python +client.orders.amend_v2( + "ord-1", + subaccount=3, # query param + request=AmendOrderV2Request( + ticker="EVENT-MKT", side="bid", + price=Decimal("0.55"), + count=Decimal("10"), + exchange_index=0, # body field + ), +) +``` + +### New optional kwargs on existing endpoints + +All additive — existing call sites keep working: + +- `orders.cancel(*, exchange_index)`, `order_groups.delete(*, exchange_index)` +- `communications.list_rfqs(*, user_filter)`, `communications.list_all_rfqs(*, user_filter)` +- `communications.list_quotes(*, user_filter, rfq_user_filter)`, `communications.list_all_quotes(*, user_filter, rfq_user_filter)`. `user_filter="self"` and `rfq_user_filter="self"` are now standalone satisfiers for the server-side filter requirement (previously only `quote_creator_user_id` / `rfq_creator_user_id` worked). +- `incentive_programs.list(*, incentive_description)` and the `list_all` variant. +- `CreateQuoteRequest.post_only` — pass `post_only=True` to `create_quote()`. +- `exchange_index` on order/amend/decrease/batch-cancel request models. + +### New endpoints (additive) + +- `portfolio.deposits()` / `portfolio.deposits_all()` — deposit history. +- `portfolio.withdrawals()` / `portfolio.withdrawals_all()` — withdrawal history. +- `account.endpoint_costs()` — token costs for endpoints whose cost differs + from the default. + +### New public types + +Added to `kalshi.*` and `kalshi.models.*`: + +- Literals: `BookSideLiteral`, `UserFilterLiteral`, `PaymentStatusLiteral`, `PaymentTypeLiteral`. +- Models: `Deposit`, `Withdrawal`, `IndexedBalance`, `AccountEndpointCosts`, `EndpointTokenCost`, and the V2 request/response family. + +--- + ## v1.x → v2.0 v2.0 is mostly additive (new `max_pages` kwarg, `KalshiConfig.http2`/`limits`, diff --git a/docs/request-models.md b/docs/request-models.md index bedda48..e03109f 100644 --- a/docs/request-models.md +++ b/docs/request-models.md @@ -64,6 +64,11 @@ OpenAPI spec. | `client.orders.batch_cancel` | `BatchCancelOrdersRequest` (wraps `list[BatchCancelOrdersRequestOrder]`) | | `client.orders.amend` | `AmendOrderRequest` | | `client.orders.decrease` | `DecreaseOrderRequest` | +| `client.orders.create_v2` | `CreateOrderV2Request` (V2 event-market — model-only, no kwarg form) | +| `client.orders.amend_v2` | `AmendOrderV2Request` (V2 — model-only) | +| `client.orders.decrease_v2` | `DecreaseOrderV2Request` (V2 — model-only, XOR `reduce_by`/`reduce_to`) | +| `client.orders.batch_create_v2` | `BatchCreateOrdersV2Request` (wraps `list[CreateOrderV2Request]`) | +| `client.orders.batch_cancel_v2` | `BatchCancelOrdersV2Request` (wraps `list[BatchCancelOrdersV2RequestOrder]`) | | `client.api_keys.create` | `CreateApiKeyRequest` | | `client.api_keys.generate` | `GenerateApiKeyRequest` | | `client.communications.create_rfq` | `CreateRFQRequest` | @@ -94,12 +99,42 @@ an awkward wire name: You never type these long names — they're an internal implementation detail. +## V2 surface: model-only + +The V2 event-market order endpoints (`create_v2` / `amend_v2` / `decrease_v2` +/ `batch_*_v2`) don't accept individual kwargs — pass a fully-constructed +request model. This is intentional: V2 took the opportunity to drop the +V1 kwarg-overload surface entirely, so the model is the single source of +truth for the payload shape. + +```python +import uuid +from decimal import Decimal +from kalshi import CreateOrderV2Request + +req = CreateOrderV2Request( + ticker="EVENT-MKT", + client_order_id=str(uuid.uuid4()), # required — idempotency key + side="bid", + count=Decimal("10"), + price=Decimal("0.50"), + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", +) +client.orders.create_v2(request=req) +``` + +`CreateOrderV2Request.client_order_id` is required (V1's was optional) and +the server treats it as an idempotency key — reusing one returns the +original order rather than placing a new one. Generate a fresh UUID4 per +call. + ## Cross-field invariants Some request models enforce relationships **before** the HTTP call: -- `DecreaseOrderRequest` — exactly one of `reduce_by` / `reduce_to` is - required (XOR enforced via a model validator). +- `DecreaseOrderRequest` / `DecreaseOrderV2Request` — exactly one of + `reduce_by` / `reduce_to` is required (XOR enforced via a model validator). - `AmendOrderRequest` — at least one of `yes_price` / `no_price` / `count` must be present (enforced in the resource method, before the body is built). diff --git a/docs/resources/account.md b/docs/resources/account.md index d778603..e6f2ea2 100644 --- a/docs/resources/account.md +++ b/docs/resources/account.md @@ -9,6 +9,7 @@ Auth required. | Method | Endpoint | |---|---| | `limits()` | `GET /account/limits` | +| `endpoint_costs()` | `GET /account/endpoint_costs` | ## Read tier limits @@ -29,6 +30,23 @@ calls. the production server returns the nested `RateLimit` objects shown above. The SDK normalizes to the live shape. +## Endpoint costs + +New in v2.1.0. Lists API v2 endpoints whose configured token cost differs +from the default — useful for budgeting against your write tier. + +```python +costs = client.account.endpoint_costs() +print(costs.default_cost) # int, the baseline (typically 10) +for entry in costs.endpoint_costs: + # EndpointTokenCost: method (str), path (str), cost (int) + print(entry.method, entry.path, entry.cost) +``` + +Endpoints not present in `endpoint_costs` use `default_cost`. Batch +endpoints typically appear here with a per-item multiplier (e.g. +`POST /portfolio/orders/batched` costs ~10 tokens per order in the batch). + ## Reference ::: kalshi.resources.account.AccountResource diff --git a/docs/resources/communications.md b/docs/resources/communications.md index a695529..791b8d8 100644 --- a/docs/resources/communications.md +++ b/docs/resources/communications.md @@ -71,10 +71,51 @@ client.communications.confirm_quote(resp.quote.quote_id) ``` !!! warning "`list_quotes` requires a user-id filter" - `list_quotes` and `list_all_quotes` **must** be called with one of - `quote_creator_user_id=` or `rfq_creator_user_id=`. Passing `rfq_id=` - alone raises `ValueError` locally before the round trip — this enforces - a server-side requirement. + `list_quotes` and `list_all_quotes` **must** be called with at least one of: + + - `quote_creator_user_id=` (filter to a specific quoter) + - `rfq_creator_user_id=` (filter to a specific RFQ originator) + - `user_filter="self"` (server-side shorthand for "the caller's quotes") + - `rfq_user_filter="self"` (server-side shorthand for "quotes on the caller's RFQs") + + `user_filter` / `rfq_user_filter` were added in spec v3.18.0 and let + you avoid round-tripping `get_id()` first. Passing `rfq_id=` alone + raises `ValueError` locally before the round trip — this enforces a + server-side requirement. + +## Filtering shortcuts (v2.1.0) + +```python +# All quotes you made — no get_id() needed. +for q in client.communications.list_all_quotes(user_filter="self"): + print(q.quote_id, q.yes_bid) + +# All quotes against RFQs you originated. +for q in client.communications.list_all_quotes(rfq_user_filter="self"): + ... + +# Same shortcut on RFQs: +for rfq in client.communications.list_all_rfqs(user_filter="self"): + ... +``` + +`UserFilterLiteral` only accepts `"self"` today — the spec leaves room for +server-side shorthands like `"organization"` in the future without an SDK +upgrade. + +## Post-only quotes + +`create_quote()` accepts `post_only=True` (added in v2.1.0) to ensure your +resting order is canceled rather than crossed if it would take liquidity: + +```python +client.communications.create_quote( + rfq_id="rfq_abc", + yes_bid="0.60", no_bid="0.40", + rest_remainder=True, + post_only=True, # cancel rather than cross +) +``` ## RFQ statuses diff --git a/docs/resources/incentive-programs.md b/docs/resources/incentive-programs.md index f11e7a9..a013964 100644 --- a/docs/resources/incentive-programs.md +++ b/docs/resources/incentive-programs.md @@ -18,13 +18,16 @@ Public — no auth required. page = client.incentive_programs.list( status="active", # IncentiveProgramStatusLiteral incentive_type="liquidity", # IncentiveProgramTypeLiteral — spec "type" - series_ticker="KXPRES", + incentive_description="Q2 maker", # new in v2.1.0 — exact-match filter limit=100, ) for p in page: print(p.program_id, p.title, p.start_ts, p.end_ts) ``` +`incentive_description` (added in v2.1.0) is an exact-string filter on the +program's description — useful when multiple programs share a status/type. + Use `list_all` to walk every program: ```python diff --git a/docs/resources/order-groups.md b/docs/resources/order-groups.md index 9ed37b3..6a4c1b3 100644 --- a/docs/resources/order-groups.md +++ b/docs/resources/order-groups.md @@ -11,8 +11,8 @@ Auth required throughout. |---|---| | `list(*, subaccount=None)` | `GET /portfolio/order_groups` | | `get(order_group_id, *, subaccount=None)` | `GET /portfolio/order_groups/{id}` | -| `create(*, contracts_limit, subaccount=None)` | `POST /portfolio/order_groups/create` | -| `delete(order_group_id, *, subaccount=None)` | `DELETE /portfolio/order_groups/{id}` | +| `create(*, contracts_limit, subaccount=None, exchange_index=None)` | `POST /portfolio/order_groups/create` | +| `delete(order_group_id, *, subaccount=None, exchange_index=None)` | `DELETE /portfolio/order_groups/{id}` | | `reset(order_group_id, *, subaccount=None)` | `PUT /portfolio/order_groups/{id}/reset` | | `trigger(order_group_id, *, subaccount=None)` | `PUT /portfolio/order_groups/{id}/trigger` | | `update_limit(order_group_id, *, contracts_limit)` | `PUT /portfolio/order_groups/{id}/limit` | @@ -67,6 +67,19 @@ client.order_groups.update_limit("og_abc", contracts_limit=200) subaccount query parameter on this path. Route via the group's own subaccount-on-create instead. +## Exchange index (v2.1.0) + +`create()` and `delete()` gained an optional `exchange_index: int | None` +parameter. Currently only `exchange_index=0` is supported per spec; the +field is reserved for future multi-shard fanout. Pass it for forward +compatibility if you're writing infrastructure that may target a non-zero +shard later: + +```python +client.order_groups.create(contracts_limit=100, exchange_index=0) +client.order_groups.delete("og_abc", exchange_index=0) +``` + ## Reference ::: kalshi.resources.order_groups.OrderGroupsResource diff --git a/docs/resources/orders.md b/docs/resources/orders.md index 6116e49..87456c6 100644 --- a/docs/resources/orders.md +++ b/docs/resources/orders.md @@ -7,13 +7,15 @@ Every method here requires auth. ## Quick reference +### V1 (legacy `/portfolio/orders/*`) — deprecated no earlier than May 6, 2026 + | Method | Endpoint | Retry | |---|---|---| | `create(...)` | `POST /portfolio/orders` | **never** — see [Retries & idempotency](../retries.md) | | `batch_create(orders)` | `POST /portfolio/orders/batched` | never | | `get(order_id)` | `GET /portfolio/orders/{order_id}` | yes (GET) | | `list(...)` / `list_all(...)` | `GET /portfolio/orders` | yes | -| `cancel(order_id, *, subaccount=None)` | `DELETE /portfolio/orders/{order_id}` | never | +| `cancel(order_id, *, subaccount=None, exchange_index=None)` | `DELETE /portfolio/orders/{order_id}` | never | | `batch_cancel(orders)` | `DELETE /portfolio/orders/batched` | never | | `amend(order_id, ...)` | `POST /portfolio/orders/{order_id}/amend` | never | | `decrease(order_id, *, reduce_by, reduce_to)` | `POST /portfolio/orders/{order_id}/decrease` | never | @@ -21,6 +23,22 @@ Every method here requires auth. | `queue_positions(*, market_tickers, event_ticker)` | `GET /portfolio/orders/queue_positions` | yes | | `queue_position(order_id)` | `GET /portfolio/orders/{order_id}/queue_position` | yes | +### V2 event-market orders (`/portfolio/events/orders/*`) — recommended for new code + +| Method | Endpoint | Retry | +|---|---|---| +| `create_v2(*, request)` | `POST /portfolio/events/orders` | never | +| `batch_create_v2(*, request)` | `POST /portfolio/events/orders/batched` | never | +| `cancel_v2(order_id, *, subaccount, exchange_index)` | `DELETE /portfolio/events/orders/{order_id}` | never | +| `batch_cancel_v2(*, request)` | `DELETE /portfolio/events/orders/batched` | never | +| `amend_v2(order_id, *, request, subaccount)` | `POST /portfolio/events/orders/{order_id}/amend` | never | +| `decrease_v2(order_id, *, request, subaccount)` | `POST /portfolio/events/orders/{order_id}/decrease` | never | + +V2 uses `BookSideLiteral` (`"bid"` / `"ask"`), a single `price` field +(not paired yes/no), and requires `client_order_id` as a server-side +idempotency key. The surface is **model-only** — every method takes a +pre-built request model rather than kwargs. + ## Place an order ```python @@ -181,6 +199,140 @@ q = client.orders.queue_position("ord_abc") # returns Decimal `queue_position(order_id)` returns a bare `Decimal`, not a model — that's the shape Kalshi's endpoint emits. +## V2 event-market orders + +Spec v3.18.0 introduced the `/portfolio/events/orders/*` family. Legacy +`/portfolio/orders` keeps working and will be deprecated **no earlier than +May 6, 2026** — start new event-market integrations on V2. + +### How V2 differs from V1 + +| | V1 (`/portfolio/orders`) | V2 (`/portfolio/events/orders`) | +|---|---|---| +| Side enum | `SideLiteral` — `"yes"` / `"no"` | `BookSideLiteral` — `"bid"` / `"ask"` | +| Price | Paired `yes_price` / `no_price` | Single `price: DollarDecimal` | +| `client_order_id` | Optional | **Required** + acts as server-side idempotency key | +| Kwarg API | Yes (kwargs or request model) | Model-only — pass a request model | +| Side filter | n/a | per-spec query/body asymmetry — see [Asymmetry](#asymmetry) | + +### Place a V2 order + +```python +import uuid +from decimal import Decimal +from kalshi import KalshiClient, CreateOrderV2Request + +with KalshiClient.from_env() as client: + resp = client.orders.create_v2(request=CreateOrderV2Request( + ticker="EVENT-MKT-A", + client_order_id=str(uuid.uuid4()), # required — idempotency key + side="bid", # BookSideLiteral + count=Decimal("10"), + price=Decimal("0.50"), + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + # post_only=False, reduce_only=False, cancel_order_on_pause=False, + # subaccount=0, order_group_id="og_abc", exchange_index=0, + )) + print(resp.order_id, resp.remaining_count, resp.fill_count) +``` + +Reusing a `client_order_id` for a second call returns the **original** +order rather than placing a new one. Generate a fresh UUID4 per request. + +### Cancel V2 + +```python +result = client.orders.cancel_v2("ord_v2_1", subaccount=0, exchange_index=0) +print(result.reduced_by, result.ts_ms) # FixedPointCount, int +``` + +`cancel_v2` returns a `CancelOrderV2Response` (with the count actually +canceled), not `None` like the V1 `cancel`. A 204 No Content from the +server is treated as a protocol violation and raises `KalshiError`. + +### Amend V2 / Decrease V2 + +```python +from decimal import Decimal +from kalshi import AmendOrderV2Request, DecreaseOrderV2Request + +resp = client.orders.amend_v2( + "ord_v2_1", + subaccount=0, # query param + request=AmendOrderV2Request( + ticker="EVENT-MKT-A", + side="bid", + price=Decimal("0.55"), + count=Decimal("12"), # total/max fillable count + exchange_index=0, # body field + ), +) + +resp = client.orders.decrease_v2( + "ord_v2_1", + subaccount=0, + request=DecreaseOrderV2Request( + reduce_by=Decimal("3"), # exactly one of reduce_by | reduce_to + ), +) +``` + +`DecreaseOrderV2Request` enforces XOR on `reduce_by` / `reduce_to` at +construction — passing both or neither raises `ValidationError`. + +### Batch V2 + +```python +from kalshi import ( + BatchCreateOrdersV2Request, BatchCancelOrdersV2Request, + BatchCancelOrdersV2RequestOrder, +) + +# Create +result = client.orders.batch_create_v2(request=BatchCreateOrdersV2Request( + orders=[ + CreateOrderV2Request(...), # per-order builds, same shape as create_v2 + CreateOrderV2Request(...), + ], +)) +for entry in result.orders: + if entry.error is not None: + print("failed:", entry.error) + else: + print(entry.order_id, entry.fill_count) + +# Cancel +result = client.orders.batch_cancel_v2(request=BatchCancelOrdersV2Request( + orders=[ + BatchCancelOrdersV2RequestOrder(order_id="ord_a", subaccount=0), + BatchCancelOrdersV2RequestOrder(order_id="ord_b"), + ], +)) +``` + +Per-entry error handling is built into `BatchCreateOrdersV2ResponseEntry` +and `BatchCancelOrdersV2ResponseEntry` — successful entries carry the +order/fill data, failed entries carry an `error` dict with the rest of the +fields nulled (create) or zeroed (cancel). + +### Asymmetry { #asymmetry } + +This trips people up. The V2 spec routes `subaccount` and `exchange_index` +to **different places** depending on the endpoint: + +| Endpoint | `subaccount` | `exchange_index` | +|---|---|---| +| `cancel_v2` | query | query | +| `amend_v2` | query | **body** (on `AmendOrderV2Request`) | +| `decrease_v2` | query | **body** (on `DecreaseOrderV2Request`) | +| `create_v2` / `batch_*_v2` | n/a — body on request model | body | + +This is faithful to OpenAPI v3.18.0 — `amend_v2`/`decrease_v2` only +declare `SubaccountQueryDefaultPrimary` in their `parameters` list, +while `cancel_v2` declares both that and `ExchangeIndexQuery`. Don't try +to "normalize" it; the SDK matches the spec. + ## Reference ::: kalshi.resources.orders.OrdersResource diff --git a/docs/resources/portfolio.md b/docs/resources/portfolio.md index 12df609..f4102e7 100644 --- a/docs/resources/portfolio.md +++ b/docs/resources/portfolio.md @@ -11,15 +11,43 @@ Auth required throughout. | `positions(*, ...)` | `GET /portfolio/positions` | | `settlements(...)` / `settlements_all(...)` | `GET /portfolio/settlements` | | `total_resting_order_value()` | `GET /portfolio/summary/total_resting_order_value` (FCM only) | +| `deposits(*, limit, cursor)` / `deposits_all(*, limit, max_pages)` | `GET /portfolio/deposits` | +| `withdrawals(*, limit, cursor)` / `withdrawals_all(*, limit, max_pages)` | `GET /portfolio/withdrawals` | ## Balance ```python bal = client.portfolio.balance() -print(bal.balance, bal.portfolio_value, bal.updated_ts) +print(bal.balance, bal.balance_dollars, bal.portfolio_value, bal.updated_ts) ``` -`Balance.balance` and `portfolio_value` are **integer cents**, not dollars. +`Balance.balance` and `portfolio_value` are **integer cents**. +`Balance.balance_dollars` (required, new in v2.1.0) is the same amount as a +`DollarDecimal` — use whichever shape your code expects without manually +dividing by 100. + +`Balance.balance_breakdown` (optional, also new in v2.1.0) splits the total +across exchange shards: + +```python +if bal.balance_breakdown is not None: + for shard in bal.balance_breakdown: + # IndexedBalance.balance is DollarDecimal, not cents — same name + # as Balance.balance but a different type. See note below. + print(shard.exchange_index, shard.balance) +``` + +!!! warning "Type collision: `Balance.balance` vs. `IndexedBalance.balance`" + `Balance.balance` is **integer cents**. `IndexedBalance.balance` (inside + `balance_breakdown`) is `DollarDecimal` (dollars), matching + `Balance.balance_dollars` units. Same field name, different types — be + deliberate when iterating the breakdown. + +!!! note "Constructing `Balance` directly" + v2.1.0 made `balance_dollars` required to match spec v3.18.0. Existing + code that calls `client.portfolio.balance()` is unaffected. Code that + builds `Balance(...)` directly (typically in tests/mocks) needs the + field added — see [Migration](../migration.md#v20-v21). ## Positions @@ -85,6 +113,34 @@ print(total.total_value) Non-FCM accounts get a `403` (mapped to `KalshiAuthError`). Demo mirrors production behavior here. +## Deposits and withdrawals + +New in v2.1.0. Standard `Page[T]` pagination — see [Pagination](../pagination.md). + +```python +# Most recent deposits +page = client.portfolio.deposits(limit=50) +for d in page: + print(d.id, d.status, d.type, d.amount_cents, d.created_ts) + +# All deposits +for d in client.portfolio.deposits_all(): + ... + +# Withdrawals follow the same shape +for w in client.portfolio.withdrawals_all(): + print(w.id, w.status, w.amount_cents, w.fee_cents) +``` + +`Deposit` and `Withdrawal` are structurally identical: `id`, `status` +(`PaymentStatusLiteral`: `"pending"` / `"applied"` / `"failed"` / +`"returned"`), `type` (`PaymentTypeLiteral`: `"ach"` / `"wire"` / +`"crypto"` / `"debit"` / `"apm"`), `amount_cents` (int), `fee_cents` +(int), `created_ts` (int Unix seconds), and `finalized_ts: int | None` +which is `None` until the transfer settles. + +Both `*_all` variants accept `max_pages=N` to bound iteration. + ## Position fields `MarketPosition` and `EventPosition` use the standard `_dollars` / `_fp` diff --git a/docs/types.md b/docs/types.md index 02e6763..b044c58 100644 --- a/docs/types.md +++ b/docs/types.md @@ -44,12 +44,20 @@ Some fields are plain `int` cents, **not** `DollarDecimal`: | Field | Where | Unit | |---|---|---| | `Balance.balance` / `portfolio_value` | `client.portfolio.balance()` | cents | +| `Deposit.amount_cents` / `fee_cents` | `client.portfolio.deposits()` | cents | +| `Withdrawal.amount_cents` / `fee_cents` | `client.portfolio.withdrawals()` | cents | | `CreateOrderRequest.buy_max_cost` | `client.orders.create(..., buy_max_cost=500)` | cents — `500` means $5.00 | | `ApplySubaccountTransferRequest.amount_cents` | `client.subaccounts.transfer(...)` | cents | Passing a `Decimal` or `float` to one of these raises `ValueError` at construction — by design, so silent ×100 corruption can't happen. +!!! warning "`Balance.balance` vs. `IndexedBalance.balance` — same name, different type" + `Balance.balance` is integer **cents**. `IndexedBalance.balance` (inside + `Balance.balance_breakdown`) is `DollarDecimal` (**dollars**) per the + spec. The dollars-form of the top-level total is exposed separately as + `Balance.balance_dollars` (required since v2.1.0). + ## Nullable lists `NullableList[T]` coerces a server `null` into `[]`. Used on response fields @@ -64,7 +72,8 @@ Every enum-like kwarg uses a `Literal` alias so your IDE auto-completes and | Alias | Values | |---|---| -| `SideLiteral` | `"yes"`, `"no"` | +| `SideLiteral` | `"yes"`, `"no"` (V1 orders) | +| `BookSideLiteral` | `"bid"`, `"ask"` (V2 event-market orders) | | `ActionLiteral` | `"buy"`, `"sell"` | | `TimeInForceLiteral` | `"fill_or_kill"`, `"good_till_canceled"`, `"immediate_or_cancel"` | | `SelfTradePreventionTypeLiteral` | `"taker_at_cross"`, `"maker"` | @@ -77,6 +86,9 @@ Every enum-like kwarg uses a `Literal` alias so your IDE auto-completes and | `SettlementStatusLiteral` | `"all"`, `"unsettled"`, `"settled"` | | `IncentiveProgramStatusLiteral` | `"all"`, `"active"`, `"upcoming"`, `"closed"`, … | | `IncentiveProgramTypeLiteral` | `"all"`, `"liquidity"`, `"volume"` | +| `UserFilterLiteral` | `"self"` (server-side shorthand for the caller's user-id; see [Communications](resources/communications.md#filtering-shortcuts-v210)) | +| `PaymentStatusLiteral` | `"pending"`, `"applied"`, `"failed"`, `"returned"` — used on `Deposit.status` and `Withdrawal.status` | +| `PaymentTypeLiteral` | `"ach"`, `"wire"`, `"crypto"`, `"debit"`, `"apm"` — used on `Deposit.type` and `Withdrawal.type` | !!! tip "Markets pause; events don't" `MarketStatusLiteral` has a `"paused"` value that `EventStatusLiteral`