diff --git a/docs/authentication.md b/docs/authentication.md index 877f68b..65ba5cb 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -3,13 +3,16 @@ Kalshi uses RSA-PSS request signing. You'll need: - A **key ID** (string, identifies the key on Kalshi's side). -- A **private key** — RSA, PEM-encoded. +- A **private key** — RSA, **PEM-encoded, PKCS#8, unencrypted**. Generate the pair in your [Kalshi account settings](https://kalshi.com/account/profile) and download the PEM file. The signing scheme used internally is RSA-PSS / SHA256 / MGF1(SHA256) / salt = digest length / base64 — you don't need to implement any of that yourself; the SDK does it for you. +You can also mint keys programmatically once authenticated; see +[API keys](resources/api-keys.md). + ## Option 1 — Key file path (most common) ```python @@ -22,7 +25,8 @@ with KalshiClient( ... ``` -`~` is expanded for you. Pass a `pathlib.Path` or a string. +`~` is expanded for you. Pass a `pathlib.Path` or a string. The constructor is +keyword-only; an empty `key_id` raises `ValueError` immediately. ## Option 2 — Environment variables @@ -46,10 +50,11 @@ with KalshiClient.from_env() as client: ``` If `KALSHI_KEY_ID` / `KALSHI_PRIVATE_KEY_PATH` are unset, `from_env()` returns -an **unauthenticated** client. Public endpoints still work; private endpoints -raise `AuthRequiredError`. +an **unauthenticated** client. Public endpoints still work. See +[Environment variables](environment-variables.md) for the full table and +precedence rules. -## Option 3 — In-memory PEM +## Option 3 — In-memory PEM (env var) If you store the private key in a secret manager (Vault, AWS Secrets Manager, GCP Secret Manager, …), set `KALSHI_PRIVATE_KEY` to the PEM contents: @@ -61,7 +66,24 @@ MIIEv... ``` Then `KalshiClient.from_env()` will load the key directly without touching the -filesystem. +filesystem. `KALSHI_PRIVATE_KEY` takes precedence over `KALSHI_PRIVATE_KEY_PATH` +when both are set. + +## Option 4 — In-memory PEM (constructor) + +You can also pass the PEM string straight to the constructor: + +```python +from kalshi import KalshiClient + +pem = secret_manager.get("kalshi/private_key") # str returning the PEM body + +with KalshiClient(key_id="...", private_key=pem) as client: + ... +``` + +The constructor accepts both `private_key_path=` and `private_key=`; supply +exactly one. ## Demo vs. production @@ -101,7 +123,7 @@ asyncio.run(main()) ## Public / unauthenticated usage -You don't need credentials for public market data: +You don't need credentials for most public market data: ```python from kalshi import KalshiClient @@ -111,9 +133,16 @@ with KalshiClient(demo=True) as client: markets = client.markets.list(status="open", limit=5) ``` -Any private endpoint call on an unauthenticated client raises -`AuthRequiredError` (a subclass of `KalshiAuthError`) immediately, before -hitting the network. +A handful of "public-looking" endpoints still require auth at the server +(`markets.orderbook`, `markets.bulk_orderbooks`, +`series.forecast_percentile_history`, `exchange.user_data_timestamp`). +Calling those on an unauthenticated client raises +[`AuthRequiredError`](errors.md) preflight — no network round-trip. + +If you instead call a private endpoint with the wrong scope or expired +credentials, the server returns 401/403 and the transport maps it to +[`KalshiAuthError`](errors.md). `AuthRequiredError` is a subclass of +`KalshiAuthError`, so catching the parent covers both. ## Direct `KalshiAuth` usage @@ -126,8 +155,47 @@ from kalshi import KalshiAuth # From a key file auth = KalshiAuth.from_key_path("your-key-id", "~/.kalshi/private_key.pem") -# From the environment (returns None if unset; use from_env() to raise instead) +# From a PEM string already in memory +auth = KalshiAuth.from_pem("your-key-id", pem_string) + +# From the environment — raises if vars are missing +auth = KalshiAuth.from_env() + +# From the environment — returns None on missing creds, but still raises +# KalshiAuthError if vars are set but malformed maybe_auth = KalshiAuth.try_from_env() ``` +### Key format constraints + +`from_pem` and `from_key_path` are strict about format. If your key fails to +load, check: + +- **Must be RSA.** EC, Ed25519, DSA keys are rejected. +- **Must be PKCS#8** (`-----BEGIN PRIVATE KEY-----`). Legacy PKCS#1 + (`-----BEGIN RSA PRIVATE KEY-----`) is supported by the underlying + `cryptography` library on a best-effort basis. +- **Must be unencrypted.** Passphrase-protected keys raise `KalshiAuthError` + with a hint. Strip the passphrase with `openssl pkey`: + + ```bash + openssl pkey -in encrypted.pem -out unencrypted.pem + ``` + +### Manual signing + +`KalshiAuth.sign_request(method, path, timestamp_ms=None)` is part of the +public API for callers building custom transports. The path is the URL path +only — query string stripped, trailing slash stripped (except for the literal +`/`), with percent-encoded sequences normalized to uppercase hex per RFC 3986. + +```python +from kalshi import KalshiAuth + +auth = KalshiAuth.from_env() +headers = auth.sign_request("GET", "/trade-api/v2/exchange/status") +# headers = {"KALSHI-ACCESS-KEY": ..., "KALSHI-ACCESS-SIGNATURE": ..., +# "KALSHI-ACCESS-TIMESTAMP": ...} +``` + See the [API reference](reference.md) for the full surface. diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..a2abd9c --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,141 @@ +# Concepts + +Short glossary of the Kalshi domain objects you'll hit. Each link goes to the +resource page that operates on them. + +## Series, event, market + +The three-level ticker hierarchy: + +- **Series** — a recurring family (e.g. `KXPRES` covers presidential elections). + Series tickers look like `KXPRES`. See [Series](resources/series.md). +- **Event** — one instance of a series (e.g. `KXPRES-24`, the 2024 election). + See [Events](resources/events.md). +- **Market** — a single YES/NO contract under an event (e.g. `KXPRES-24-DJT`, + "will Trump win"). See [Markets](resources/markets.md). + +Every market belongs to exactly one event; every event belongs to exactly one +series. + +## YES, NO, side, action + +A Kalshi market trades two complementary contracts that always sum to $1: +**YES** and **NO**. Each trade has a `side` (which contract) and an `action`: + +- `side="yes"`, `action="buy"` — buying YES. +- `side="yes"`, `action="sell"` — selling YES. +- `side="no"`, `action="buy"` — buying NO (equivalent to selling YES against + the orderbook, but accounted separately). + +These are `Literal` types — see [Types & literals](types.md). + +## Prices + +Prices live in `[0.00, 1.00]` and represent dollars (a YES at $0.65 implies a +65% market-implied probability). Always pass them as strings or `Decimal`: + +```python +order = client.orders.create(..., yes_price="0.65") +order = client.orders.create(..., yes_price=Decimal("0.65")) +``` + +Float is a footgun; the SDK accepts it but normalizes through `str()` to avoid +`0.65 → 0.6499999…`. See [`DollarDecimal`](types.md). + +The Kalshi API returns prices as JSON strings with a `_dollars` suffix +(`yes_bid_dollars: "0.5600"`). The SDK maps these to short field names +(`yes_bid: Decimal("0.5600")`). Both directions round-trip. + +## Cents vs dollars + +A few fields are **integer cents**, not dollars: + +- `Balance.balance` / `portfolio_value` — cents. +- `CreateOrderRequest.buy_max_cost` — cents. +- `ApplySubaccountTransferRequest.amount_cents` — cents. + +These are typed `int`. Passing a `Decimal` or `float` raises `ValueError` at +construction. The rule: anything with `_cents` / `buy_max_cost` is cents; +anything with `_dollars` or `yes_price` / `no_price` is `Decimal` dollars. + +## Order, fill, position, settlement + +- **Order** — your intent. Has a status (`resting`, `canceled`, `executed`). +- **Fill** — an actual execution against an order. One order can produce many + fills. +- **Position** — your aggregate exposure on a market (signed by side). +- **Settlement** — what the exchange paid out when the market resolved. + +See [Orders](resources/orders.md), [Portfolio](resources/portfolio.md). + +## RFQ and Quote + +**RFQ** ("Request For Quote") — a private "Can someone make me a market on this +contract at this size?" message. **Quote** — a counterparty's answer ("I'll sell +you YES at $0.62 / buy from you at $0.60"). The requester picks a side and +accepts; the maker confirms. + +This is Kalshi's bilateral block-trade rail, alongside the public order book. +See [Communications](resources/communications.md). + +## Multivariate event collection + +A template for combo bets: "Will it rain in NYC AND the Yankees win Saturday?" +The collection holds the building-block markets; calling `create_market` with a +list of leg selections mints a derived YES/NO contract. + +See [Multivariate](resources/multivariate.md). + +## Milestone + +A real-world reference event a market is anchored to — a baseball game, an +economic release, an election. Live data (scores, clocks, weather) is keyed by +milestone id. + +See [Milestones](resources/milestones.md), [Live data](resources/live-data.md). + +## Structured target + +The entity a market is "about" — a team, player, candidate, company. Two +markets pointing at the same Yankees roster share a structured target id, so +you can group markets by underlying entity. + +See [Structured targets](resources/structured-targets.md). + +## Incentive program + +Time-boxed reward campaigns (maker rebates, volume bonuses) on specific +markets or series. + +See [Incentive programs](resources/incentive-programs.md). + +## Subaccount + +A logical wallet partition under your main account. Used to isolate strategies +or risk pools. Subaccount `0` is your primary account; `1`–`32` are numbered +extras. Most resource methods accept a `subaccount=` kwarg to route the call. + +See [Subaccounts](resources/subaccounts.md). + +## Order group + +A rolling contracts-limit bucket that several orders can share. Trips when the +group hits its cap; can be `reset()` or `trigger()`ed manually. + +See [Order groups](resources/order-groups.md). + +## FCM + +**Futures Commission Merchant.** Kalshi-side broker designation. FCM members +get a separate `/fcm/*` surface that takes a `subtrader_id` discriminator and +returns the same shapes as `portfolio.*`. Non-FCM accounts get 401/403. + +See [FCM](resources/fcm.md). + +## API key, scope + +Authentication identity. Has `read` and `write` scopes; `write` requires +`read`. Can be created via the web UI or via +`client.api_keys.generate()` / `client.api_keys.create()`. + +See [API keys](resources/api-keys.md). diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..2d37726 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,146 @@ +# Configuration + +Every client option lives on `KalshiConfig` — a frozen dataclass passed to +`KalshiClient(..., config=...)` or built implicitly by the convenience +constructors (`KalshiConfig.demo()`, `KalshiConfig.production()`). + +## Quick reference + +```python +from kalshi import KalshiClient, KalshiConfig +import httpx + +config = KalshiConfig( + base_url="https://api.elections.kalshi.com/trade-api/v2", + timeout=30.0, + max_retries=3, + retry_base_delay=0.5, + retry_max_delay=30.0, + extra_headers={"User-Agent": "my-bot/1.2"}, + ws_base_url="wss://api.elections.kalshi.com/trade-api/ws/v2", + ws_max_retries=10, + http2=False, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), +) + +with KalshiClient(key_id="...", private_key_path="...", config=config) as client: + ... +``` + +## Fields + +| Field | Default | Meaning | +|---|---|---| +| `base_url` | `https://api.elections.kalshi.com/trade-api/v2` | REST base URL. Trailing slash is auto-stripped. | +| `timeout` | `30.0` | `httpx` timeout (seconds). Single scalar — applies to connect, read, write, and pool together. | +| `max_retries` | `3` | Maximum retry attempts on retryable methods. | +| `retry_base_delay` | `0.5` | Base for exponential backoff (seconds). | +| `retry_max_delay` | `30.0` | Cap on any single retry sleep — also caps `Retry-After`. | +| `extra_headers` | `{}` | Extra HTTP headers added to every request. Useful for custom User-Agent. | +| `ws_base_url` | `wss://api.elections.kalshi.com/trade-api/ws/v2` | WebSocket base URL. | +| `ws_max_retries` | `10` | Maximum reconnect attempts before the WS gives up. | +| `http2` | `False` | Enable HTTP/2 (requires `httpx[http2]` install extra). | +| `limits` | `httpx.Limits()` defaults | Connection-pool limits passed straight to `httpx`. | + +See [Retries & idempotency](retries.md) for what `max_retries`, +`retry_base_delay`, `retry_max_delay` do at runtime. + +## Convenience constructors + +```python +config = KalshiConfig.demo() # demo URLs + defaults +config = KalshiConfig.production() # prod URLs + defaults +config = KalshiConfig.demo(timeout=10.0, max_retries=5) +``` + +Both accept any keyword override the full constructor accepts (except the URLs, +which they set themselves). + +The `KalshiClient(demo=True, ...)` convenience flag is equivalent to passing +`config=KalshiConfig.demo()` — but you can layer on a full custom `config` if +you also need other tuning. + +## URL validation + +`KalshiConfig` enforces a small security policy on its URLs: + +- `base_url` must be `https://` for remote hosts; `http://` is only allowed + against `localhost`, `127.0.0.1`, or `::1` (for local fixtures / proxies). +- `ws_base_url` must be `wss://` for remote hosts; `ws://` is only allowed + against the same loopback set. +- Trailing slashes are auto-stripped from both. +- Unknown hosts log a warning (Kalshi's known hosts are + `api.elections.kalshi.com` and `demo-api.kalshi.co`). + +This catches plaintext config slip-ups before any request is sent. + +## Custom User-Agent + +```python +KalshiConfig(extra_headers={"User-Agent": "acme-trader/2.1 (+ops@acme.co)"}) +``` + +`extra_headers` is merged into every request after the SDK's own headers, so +you can override anything the SDK sets (rarely a good idea outside +User-Agent). + +## HTTP/2 + +```python +KalshiConfig(http2=True) +``` + +Requires `pip install 'httpx[http2]'` — the SDK doesn't pin h2 as a hard +dependency. Once enabled, the same setting flows into the WebSocket client too +(WebSocket runs on top of an HTTP/1.1 upgrade today; this only affects REST). + +## Connection-pool limits + +```python +import httpx + +KalshiConfig(limits=httpx.Limits(max_connections=200, max_keepalive_connections=50)) +``` + +Useful when you're driving a large fan-out workload (many async tasks). +Otherwise leave the defaults. + +## Custom transport + +`KalshiClient(..., transport=...)` plumbs an `httpx.BaseTransport` straight to +the underlying `httpx.Client`. Use it for: + +- **Testing** — see [`kalshi.testing.ReplayTransport`](testing.md). +- **HTTP mocking in tests** — `respx.MockTransport()`. +- **Outbound proxies** — `httpx.HTTPTransport(proxy="http://corp-proxy:8080")`. + +```python +import respx +from kalshi import KalshiClient + +with respx.mock(base_url="https://api.elections.kalshi.com") as router: + router.get("/trade-api/v2/exchange/status").respond(200, json={"exchange_active": True}) + with KalshiClient(transport=router) as client: + assert client.exchange.status().exchange_active +``` + +The async client accepts an `httpx.AsyncBaseTransport` the same way. + +## Pre-built `KalshiAuth` + +If you've already constructed a `KalshiAuth` (e.g. for the WebSocket), reuse it: + +```python +from kalshi import KalshiAuth, KalshiClient + +auth = KalshiAuth.from_key_path("kid", "~/.kalshi/key.pem") +with KalshiClient(auth=auth) as client: + ... +``` + +`auth=` and the credential kwargs (`key_id` / `private_key_path` / +`private_key`) are mutually exclusive — supply one or the other. + +## Reference + +::: kalshi.config.KalshiConfig diff --git a/docs/dataframes.md b/docs/dataframes.md new file mode 100644 index 0000000..a9093cc --- /dev/null +++ b/docs/dataframes.md @@ -0,0 +1,87 @@ +# DataFrames + +`Page[T]` carries `.to_dataframe()` (pandas) and `.to_polars()` (polars). +Neither library is a hard dependency. + +## Install + +```bash +pip install 'kalshi-sdk[pandas]' +# or +pip install 'kalshi-sdk[polars]' +# or both +pip install 'kalshi-sdk[all]' +``` + +Calling either method without the corresponding library installed raises +`ImportError` with the exact `pip install` hint. + +## Quick example + +```python +df = client.markets.list(status="open", limit=500).to_dataframe() +print(df[["ticker", "yes_bid", "yes_ask", "volume_24h"]].head()) +``` + +## How it works + +Both methods walk `page.items` and call `item.model_dump(mode="python")` on +each, then hand the records to `pd.DataFrame(...)` / `pl.DataFrame(...)`. + +This means: + +- **`Decimal` stays `Decimal`.** Prices come through as Decimals in a pandas + object-dtype column. Cast to float yourself if you want numeric ops: + + ```python + df["yes_bid"] = df["yes_bid"].astype(float) + ``` + +- **`datetime` stays `datetime`.** Timestamp fields are real datetimes, not + ISO strings. + +- **Nested models become nested structures.** A `Candlestick` row has + `yes_bid: BidAskDistribution` (a `BaseModel`). After `model_dump`, that field + becomes a `dict`. Polars infers a struct; pandas keeps it as an object column. + +## One page only + +Both methods convert **the current page**, not every page across cursors. To +flatten a multi-page result into one frame: + +```python +import pandas as pd +from kalshi import KalshiClient + +with KalshiClient.from_env() as client: + rows = list(client.orders.list_all(status="resting")) + +df = pd.DataFrame([o.model_dump(mode="python") for o in rows]) +``` + +`list_all()` returns the items already unwrapped (not `Page` objects), so you +work with `BaseModel`s directly. + +## Async + +The async client's `list_all()` returns an `AsyncIterator[T]`. Materialize it +once before converting: + +```python +import asyncio +import polars as pl +from kalshi import AsyncKalshiClient + +async def collect() -> list: + async with AsyncKalshiClient.from_env() as c: + return [o async for o in c.orders.list_all(status="resting")] + +rows = asyncio.run(collect()) +df = pl.DataFrame([o.model_dump(mode="python") for o in rows]) +``` + +## Reference + +::: kalshi.models.common.Page.to_dataframe + +::: kalshi.models.common.Page.to_polars diff --git a/docs/environment-variables.md b/docs/environment-variables.md new file mode 100644 index 0000000..91c0934 --- /dev/null +++ b/docs/environment-variables.md @@ -0,0 +1,52 @@ +# Environment variables + +`KalshiClient.from_env()` and `KalshiAuth.from_env()` / `try_from_env()` read +these: + +| Variable | Default | Effect | +|---|---|---| +| `KALSHI_KEY_ID` | unset | Key ID. If unset, `from_env()` returns an unauthenticated client. | +| `KALSHI_PRIVATE_KEY` | unset | PEM string. Takes precedence over `KALSHI_PRIVATE_KEY_PATH`. | +| `KALSHI_PRIVATE_KEY_PATH` | unset | Path to PEM file. `~` is expanded. | +| `KALSHI_DEMO` | `false` | Truthy values (`true`, `1`, `yes`, `on`, case-insensitive) flip the client to the demo URLs. | +| `KALSHI_API_BASE_URL` | unset | Overrides `base_url` entirely. Wins over `KALSHI_DEMO`. | + +## Precedence + +1. **Credentials**: `KALSHI_PRIVATE_KEY` (in-memory PEM) beats + `KALSHI_PRIVATE_KEY_PATH` (file). +2. **Base URL**: an explicit `KALSHI_API_BASE_URL` wins over the demo/production + pair selected by `KALSHI_DEMO`. +3. **Constructor overrides**: anything passed positionally / by kwarg to + `KalshiClient(...)` wins over the corresponding env var. `from_env(**kwargs)` + merges in user kwargs after reading the env. + +## `from_env()` vs `try_from_env()` + +- `KalshiAuth.from_env()` raises `KalshiAuthError` if `KALSHI_KEY_ID` is missing. +- `KalshiAuth.try_from_env()` returns `None` if credentials are absent — but + **still raises** if the env vars are present but malformed (invalid PEM, + unreadable file, encrypted key, …). It is "are creds available?" not "swallow + every error". + +`KalshiClient.from_env()` uses `try_from_env()` under the hood — missing creds +fall through to an unauthenticated client. + +## Retry / timeout knobs + +There are **no environment variables** for `timeout`, `max_retries`, +`retry_base_delay`, `retry_max_delay`, `ws_max_retries`, `http2`, or `limits`. +Pass a `KalshiConfig` explicitly: + +```python +import os +from kalshi import KalshiClient, KalshiConfig + +config = ( + KalshiConfig.demo() if os.getenv("KALSHI_DEMO", "").lower() == "true" + else KalshiConfig.production() +) +config = dataclasses.replace(config, timeout=10.0, max_retries=5) +with KalshiClient.from_env(config=config) as client: + ... +``` diff --git a/docs/errors.md b/docs/errors.md index 31287c5..0875aa2 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -1,4 +1,4 @@ -# Error Handling +# Errors All SDK exceptions inherit from `KalshiError`. HTTP responses are mapped to typed exceptions in the transport layer before any resource code sees them, @@ -19,18 +19,18 @@ except KalshiRateLimitError as e: ## Hierarchy ``` -KalshiError -├── KalshiAuthError # 401 / 403 -│ └── AuthRequiredError # private endpoint called on unauth'd client -├── KalshiNotFoundError # 404 -├── KalshiValidationError # 400 (carries .details: dict[str, str]) -├── KalshiRateLimitError # 429 (carries .retry_after: float | None) -├── KalshiServerError # 5xx -└── KalshiWebSocketError - ├── KalshiConnectionError # handshake / reconnect failure - ├── KalshiSequenceGapError # unresolved sequence gap on a stream - ├── KalshiBackpressureError # queue full with ERROR overflow strategy - └── KalshiSubscriptionError # subscribe / unsubscribe rejected +KalshiError # base, .status_code: int | None +├── KalshiAuthError # 401 / 403 +│ └── AuthRequiredError # preflight on unauth'd client +├── KalshiNotFoundError # 404 +├── KalshiValidationError # 400 (carries .details: dict[str, str]) +├── KalshiRateLimitError # 429 (carries .retry_after: float | None) +├── KalshiServerError # 5xx +└── KalshiWebSocketError # base for WS errors + ├── KalshiConnectionError # handshake / reconnect failure + ├── KalshiSequenceGapError # exposed for custom resync handlers + ├── KalshiBackpressureError # queue full with ERROR overflow + └── KalshiSubscriptionError # subscribe / unsubscribe rejected ``` The `KalshiError` base carries an optional `status_code: int | None`. @@ -39,58 +39,71 @@ leave it `None`. ## HTTP status → exception -The mapping is done by `_map_error` in -[`kalshi/_base_client.py`](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/_base_client.py). -It runs against the response status code, with the message pulled from -`body["message"]`, then `body["error"]`, then the raw response text: - | Status | Exception | Notes | |---|---|---| -| `400` | `KalshiValidationError` | `details` is populated from `body["details"]` or `body["errors"]` when present and dict-shaped. | +| `400` | `KalshiValidationError` | `.details` is populated from `body["details"]` or `body["errors"]` when present and dict-shaped. | | `401` / `403` | `KalshiAuthError` | Bad signature, expired key, missing scope. | | `404` | `KalshiNotFoundError` | Unknown ticker, missing order, etc. | -| `429` | `KalshiRateLimitError` | `retry_after` parsed from the `Retry-After` header if it's a numeric value (HTTP-date form falls back to computed backoff). | +| `429` | `KalshiRateLimitError` | `.retry_after` parsed from the `Retry-After` header if it's a non-negative finite numeric (HTTP-date form falls back to computed backoff). | | `5xx` | `KalshiServerError` | All server-side failures. | | anything else | `KalshiError` | Catch-all, with `status_code` set. | -`AuthRequiredError` is the one exception that fires *before* the network — -calling a private endpoint on an unauthenticated client raises it -immediately, without sending the request. +`AuthRequiredError` is the one HTTP-shaped exception that fires *before* the +network — calling a private endpoint on an unauthenticated client raises it +preflight, without sending the request. `status_code` is `None`. Since it +subclasses `KalshiAuthError`, catching the parent covers both. + +The mapping is performed by `_map_error` in +[`kalshi/_base_client.py`](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/_base_client.py). + +## Validation errors + +Two distinct things can go wrong with payloads: + +- **Server-side request validation** (`400 Bad Request`) — surfaces as + `KalshiValidationError`, with `details: dict[str, str]` populated from the + server's response when available. Use it to report field-level problems back + to the user. +- **Pydantic validation on the response** — if the server returns a body that + doesn't match the SDK's typed model (a wire-format drift), Pydantic's own + `ValidationError` bubbles up. It is **not** a subclass of `KalshiError` — + treat it as a bug report against the SDK's model layer, not as a transient + error. + +Client-side validation on request bodies (Pydantic models with `extra="forbid"`) +also raises Pydantic's `ValidationError` directly, **before** the network. A +misspelled kwarg in a resource method raises `TypeError` first; phantom keys +passed via `request=Model(...)` fail at `Model(...)` construction. -## Retry behavior +## Transport-level wrapping -Retries are conservative by design: the wrong retry on the wrong verb is -how duplicate orders happen. +Non-HTTP failures are wrapped to a typed exception with the original as +`__cause__`: -- **Retried** on `429`, `500`, `502`, `503`, `504` — and **only** for - `GET`, `HEAD`, `OPTIONS`. `POST` and `DELETE` are never retried, even on - transient 5xx, to avoid duplicate-order / duplicate-cancel risk. -- **Backoff** is exponential with jitter: `retry_base_delay * 2**attempt - + random.uniform(0, 0.5)`, capped at `retry_max_delay` (default 30s). -- **`Retry-After`** is honored on 429 but also capped at `retry_max_delay` - — a hostile or misconfigured server cannot stall the client with - arbitrary sleep values. -- **Timeouts** on retryable methods retry with the same backoff. Timeouts - on `POST` / `DELETE` raise immediately. +- **Timeouts** on retryable methods (`GET`, `HEAD`, `OPTIONS`) retry; once + retries are exhausted, the last `httpx.TimeoutException` is re-raised wrapped + in `KalshiError`. Timeouts on `POST` / `DELETE` raise `KalshiError` + immediately, no retry. +- **Connection failures** (DNS, TLS, RST) raise `KalshiError` immediately on + any verb, no retry. -Tune via `KalshiConfig`: +There is no `KalshiTimeoutError` class. Inspect `e.__cause__` if you need to +distinguish. + +## Catching everything from the SDK ```python -from kalshi import KalshiClient, KalshiConfig - -config = KalshiConfig( - timeout=10.0, - max_retries=5, - retry_base_delay=0.5, - retry_max_delay=15.0, -) -client = KalshiClient(key_id="...", private_key_path="...", config=config) +from kalshi import KalshiError + +try: + do_things(client) +except KalshiError as e: + log.exception("SDK call failed (status=%s)", e.status_code) ``` -If `max_retries` runs out, the last typed exception is re-raised. Application -code never sees a bare `httpx` exception — non-HTTP failures (DNS, TLS, -connection-reset) are wrapped in `KalshiError` with the original exception -as `__cause__`. +A bare `except KalshiError` covers every SDK-raised exception **except** the +Pydantic `ValidationError` you'd get from a malformed response (that one is a +bug, not a runtime error). ## WebSocket errors @@ -98,46 +111,31 @@ WebSocket failures are a separate sub-hierarchy under `KalshiWebSocketError`: - **`KalshiConnectionError`** — raised when the initial connect fails, when - the auth handshake is rejected, or when `ws_max_retries` is exhausted on - a reconnect attempt. Also surfaces from `ConnectionManager.send()` / - `recv()` if you call them without being connected. + the auth handshake is rejected, or when `ws_max_retries` is exhausted on a + reconnect attempt. Also surfaces from `ConnectionManager.send()` / `recv()` + if you call them without being connected. - **`KalshiSubscriptionError`** — server rejected a `subscribe` / `unsubscribe` / `update_subscription` command. Carries an optional `error_code` field with the server's machine-readable code. -- **`KalshiBackpressureError`** — raised from `MessageQueue.put()` when - the queue is full and the overflow strategy is `ERROR`. See - [WebSocket Streaming → Backpressure](websockets.md#backpressure). -- **`KalshiSequenceGapError`** — defined for callers that wire their own - resync logic on top of the SDK's primitives. The built-in receive loop - recovers from gaps silently (drops the message, clears local state, - waits for the next snapshot) rather than raising. +- **`KalshiBackpressureError`** — raised from `MessageQueue.put()` when the + queue is full and the overflow strategy is `ERROR`. The receive loop treats + this as **fatal**: it broadcasts sentinels to every active iterator and + exits. See [WebSocket → Backpressure](websockets.md#backpressure). +- **`KalshiSequenceGapError`** — exposed for callers wiring their own resync + logic on top of the SDK's primitives. The built-in receive loop **does not + raise** this — it recovers from gaps silently (drops the message, clears + local orderbook state, waits for the next snapshot). A subscription's iterator continues to yield across reconnects — the SDK -re-issues the subscribe and patches the new server-side `sid` into the -durable client-side id. You won't see `KalshiConnectionError` from inside -`async for`; you'll see it from the `connect()` context manager if the -socket can't be re-established at all. - -## Validation errors +re-issues the subscribe and patches the new server-side `sid` into the durable +client-side id. You won't see `KalshiConnectionError` from inside `async for`; +you'll see it from the `connect()` context manager if the socket can't be +re-established at all. -Two distinct things can go wrong with payloads, and they raise different -exceptions: +## See also -- **Server-side request validation** (`400 Bad Request`) — surfaces as - `KalshiValidationError`, with `details: dict[str, str]` populated from - the server's response when available. Use it to report field-level - problems back to the user. -- **Pydantic validation on the response** — if the server returns a body - that doesn't match the SDK's typed model (a wire-format drift, or a - new field in an unexpected shape), Pydantic's own `ValidationError` - bubbles up. It is *not* a subclass of `KalshiError` — treat it as a - bug report against the SDK's model layer, not as a transient error. - -Client-side validation on request bodies (Pydantic models with -`extra="forbid"`) also raises Pydantic's `ValidationError` directly, -before the network. A misspelled kwarg in a resource method raises -`TypeError` first; phantom keys passed via `request=Model(...)` fail at -`Model(...)` construction. +- [Retries & idempotency](retries.md) — what does and doesn't get retried, why + POST/DELETE never retry, recommended patterns for safely retrying writes. ## Exception reference diff --git a/docs/getting-started.md b/docs/getting-started.md index 316c9c8..c7031ba 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,4 +1,4 @@ -# Getting Started +# Quickstart This page walks you from a blank environment to your first authenticated request against Kalshi's demo API. @@ -9,7 +9,7 @@ against Kalshi's demo API. pip install kalshi-sdk ``` -Requires Python 3.12 or newer. +Requires Python 3.12 or newer. Optional extras: `pandas`, `polars`, or `all`. ## Create an API key @@ -27,7 +27,10 @@ Requires Python 3.12 or newer. A demo key cannot authenticate against production and vice versa. The rest of this guide assumes a demo key and `demo=True`. -You do **not** need an API key to read public market data — skip ahead to +You can also mint keys programmatically once authenticated — see +[API keys](resources/api-keys.md). + +You do **not** need an API key to read most public market data — skip ahead to ["Hello, markets" (no auth)](#hello-markets-no-auth) if you just want to browse. ## Hello, world (authenticated) @@ -48,10 +51,12 @@ with KalshiClient( The client is a context manager — the underlying `httpx.Client` is closed on exit. If you can't use a `with` block, call `client.close()` yourself. +The constructor is keyword-only; passing an empty `key_id` raises `ValueError`. + ## Hello, markets (no auth) -Public endpoints work without credentials. The client is "unauthenticated" but -all read-only resource methods still function: +Most public endpoints work without credentials. The client is "unauthenticated" but +read-only resource methods on public endpoints still function: ```python from kalshi import KalshiClient @@ -63,8 +68,13 @@ with KalshiClient(demo=True) as client: print(market.ticker) ``` -Calling a private endpoint (e.g. placing an order) on an unauthenticated client -raises [`AuthRequiredError`](errors.md). +A small number of "public-looking" endpoints actually require auth — notably +`markets.orderbook()`, `markets.bulk_orderbooks()`, and +`series.forecast_percentile_history()`. Calling those on an unauthenticated +client raises [`AuthRequiredError`](errors.md) before the network. Calling a +private endpoint (orders, portfolio, …) on an authenticated-but-wrong-scope +client comes back as a server 401/403 mapped to +[`KalshiAuthError`](errors.md). ## Async @@ -90,6 +100,7 @@ asyncio.run(main()) ## Place an order (demo) ```python +import uuid from kalshi import KalshiClient with KalshiClient.from_env() as client: @@ -98,17 +109,32 @@ with KalshiClient.from_env() as client: side="yes", action="buy", count=10, - yes_price="0.65", # 65 cents — strings or Decimals, never float + yes_price="0.65", # 65¢ — strings or Decimals, never float time_in_force="good_till_canceled", - client_order_id="my-unique-id", # idempotency key + client_order_id=str(uuid.uuid4()), # idempotency key (see below) ) print(order.order_id, order.status) ``` +!!! warning "POST is never retried automatically" + The transport never retries `POST` (or `DELETE`) requests, to avoid duplicate + orders. Pass a fresh `client_order_id` on every `create()` call so you can safely + retry from your application layer without double-filling. See + [Retries & idempotency](retries.md). + +!!! warning "`buy_max_cost` is integer cents, not dollars" + `CreateOrderRequest.buy_max_cost` is `int` cents — `500` means $5.00. Passing a + `Decimal` or `float` raises `ValueError` at construction. Other price fields + (`yes_price`, `no_price`) are `DollarDecimal` and accept strings or + `Decimal`. + Prices are decimal dollars per the Kalshi spec. Internally the SDK uses -`Decimal` via the `DollarDecimal` type — never `float`. +`Decimal` via the [`DollarDecimal`](types.md) type — never `float`. ## Where to next - [Authentication](authentication.md) — all the ways to provide credentials. -- [API Reference](reference.md) — the full auto-generated reference. +- [Configuration](configuration.md) — timeouts, retries, HTTP/2, custom transports. +- [Concepts](concepts.md) — RFQs, milestones, multivariate, subaccounts. +- [Resources overview](resources/index.md) — every resource grouped by area. +- [API Reference](reference.md) — full auto-generated reference. diff --git a/docs/index.md b/docs/index.md index 4be1f82..985fa19 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,9 +1,20 @@ # Kalshi Python SDK A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) prediction -markets API. Full coverage of the REST API (89 endpoints across 19 resources) and the -WebSocket API (11 channels), with sync and async clients sharing one transport, -typed end-to-end with Pydantic v2 and `mypy --strict` clean. +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 + 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 + are identical. +- **Typed end-to-end** — Pydantic v2 models, `Literal` types for enums, + `mypy --strict` clean. Request bodies use `extra="forbid"` so phantom kwargs fail + fast. +- **Money safety** — prices are `Decimal` via a custom `DollarDecimal` type. No + floats anywhere on the price path. ## Install @@ -11,12 +22,38 @@ typed end-to-end with Pydantic v2 and `mypy --strict` clean. pip install kalshi-sdk ``` -Requires Python 3.12+. +Requires Python 3.12+. Optional DataFrame extras: + +```bash +pip install 'kalshi-sdk[pandas]' # pandas +pip install 'kalshi-sdk[polars]' # polars +pip install 'kalshi-sdk[all]' # both +``` + +## Hello, markets + +```python +from kalshi import KalshiClient + +with KalshiClient(demo=True) as client: + for market in client.markets.list_all(status="open"): + print(market.ticker, market.yes_bid, market.yes_ask) +``` + +No credentials needed for most market data. To place orders, see +[Authentication](authentication.md). -## Next steps +## Where to go next -- [Getting Started](getting-started.md) — install, authenticate, make your first request. -- [Authentication](authentication.md) — env vars, key files, in-memory PEM, demo mode. -- [API Reference](reference.md) — full auto-generated reference for the `kalshi` package. +| If you want to… | Read | +|---|---| +| Place your first authenticated request | [Quickstart](getting-started.md) | +| Understand RFQs, milestones, subaccounts, etc. | [Concepts](concepts.md) | +| Tune timeouts, retries, HTTP/2 | [Configuration](configuration.md) | +| Walk pages or convert to DataFrames | [Pagination](pagination.md) · [DataFrames](dataframes.md) | +| Build a live-data app | [WebSocket](websockets.md) | +| Catch the right exception | [Errors](errors.md) · [Retries & idempotency](retries.md) | +| Find every method on a resource | [Resources](resources/index.md) | +| Test your code without hitting the API | [Testing](testing.md) | Source: [github.com/TexasCoding/kalshi-python-sdk](https://github.com/TexasCoding/kalshi-python-sdk). diff --git a/docs/pagination.md b/docs/pagination.md new file mode 100644 index 0000000..58764c1 --- /dev/null +++ b/docs/pagination.md @@ -0,0 +1,111 @@ +# Pagination + +Every "list" endpoint that can return more than a server-imposed cap returns a +`Page[T]`. A page is iterable, knows whether there's a next page, and carries +the opaque cursor you'd hand back to fetch it. + +## Anatomy + +```python +page = client.markets.list(status="open", limit=200) + +for market in page: # Page is iterable + print(market.ticker) + +len(page) # items on this page +page.items # the underlying list[Market] +page.cursor # next-page cursor (None or "" if you're on the last page) +page.has_next # bool — True iff cursor is set and non-empty +``` + +`Page` is a Pydantic model with `extra="allow"`, so any envelope fields the +server attaches (totals, debug keys) survive on the instance and you can read +them with normal attribute access. + +## Walking pages manually + +```python +cursor = None +while True: + page = client.orders.list(status="resting", cursor=cursor, limit=200) + for order in page: + process(order) + if not page.has_next: + break + cursor = page.cursor +``` + +## `list_all()` — the easy way + +Every resource that has a `list()` also has a `list_all()` that walks cursors +for you: + +```python +# Sync: returns an Iterator[T] +for market in client.markets.list_all(status="open"): + ... + +# Async: returns an AsyncIterator[T] — async for works directly +async for market in async_client.markets.list_all(status="open"): + ... +``` + +`list_all()` accepts the same kwargs as `list()` (minus `cursor`) plus an +optional `max_pages` cap. It walks until the server returns no more pages. + +### Cursor-loop safety + +`list_all()` keeps a set of cursors it has already used. If the server returns +a cursor that already appeared, it raises `KalshiError("Cursor loop detected +on …")`. This catches server-side pagination bugs that would otherwise spin +forever. + +There is **no** built-in 1000-page cap. If you want one, pass +`max_pages=1000`. + +## Endpoints that don't paginate + +A few list endpoints return a plain `list[T]`, not `Page[T]`: + +- `client.series.list(...)` — flat list of series. +- `client.exchange.announcements()` — a flat list. +- `client.order_groups.list()` — no cursor. +- `client.account.limits()` / `client.exchange.status()` — single objects. +- `client.markets.bulk_orderbooks(...)` / `bulk_candlesticks(...)` — flat list of up to 100 items. + +These don't need pagination; they're sized at most by the API's natural caps. + +## Endpoints with a non-`Page` shape + +`client.portfolio.positions()` and `client.fcm.positions()` return +`PositionsResponse`, not `Page`. It carries two parallel lists +(`market_positions` and `event_positions`) plus its own `cursor` / +`has_next`. There is no `positions_all()` helper — walk it manually: + +```python +cursor = None +while True: + resp = client.portfolio.positions(cursor=cursor) + for pos in resp.market_positions: + ... + if not resp.has_next: + break + cursor = resp.cursor +``` + +`client.incentive_programs.list()` uses the server-side cursor key +`next_cursor` instead of `cursor`. The SDK normalizes this — pass `cursor=` +and read `.cursor` exactly like any other list call. + +## DataFrames + +`Page[T]` carries `.to_dataframe()` and `.to_polars()`. They convert **only the +current page** — not all pages. See [DataFrames](dataframes.md). + +```python +df = client.markets.list(status="open", limit=500).to_dataframe() +``` + +## Reference + +::: kalshi.models.common.Page diff --git a/docs/reference.md b/docs/reference.md index 1c7aa8b..aca43e5 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1,27 +1,141 @@ # API Reference -Auto-generated from the public surface of the `kalshi` package. +Auto-generated from docstrings on the public surface of the `kalshi` package. + +For narrative coverage of any of these, follow the section links — every +class here also has a dedicated user-facing page elsewhere in the docs. ## Clients -::: kalshi.KalshiClient +::: kalshi.client.KalshiClient + +::: kalshi.async_client.AsyncKalshiClient -::: kalshi.AsyncKalshiClient +## Configuration -## Configuration & auth +::: kalshi.config.KalshiConfig -::: kalshi.KalshiConfig +## Authentication -::: kalshi.KalshiAuth +::: kalshi.auth.KalshiAuth ## Errors -See [Error Handling](errors.md) for the full exception hierarchy. +See [Errors](errors.md) — that page is the canonical autodoc reference for +every exception class. + +## Pagination + +::: kalshi.models.common.Page + +## Custom types + +::: kalshi.types + +## Request models + +::: kalshi.models.orders.CreateOrderRequest + +::: kalshi.models.orders.AmendOrderRequest + +::: kalshi.models.orders.DecreaseOrderRequest + +::: kalshi.models.orders.BatchCreateOrdersRequest + +::: kalshi.models.orders.BatchCancelOrdersRequest + +::: kalshi.models.orders.BatchCancelOrdersRequestOrder + +::: kalshi.models.api_keys.CreateApiKeyRequest + +::: kalshi.models.api_keys.GenerateApiKeyRequest + +::: kalshi.models.communications.CreateRFQRequest + +::: kalshi.models.communications.CreateQuoteRequest + +::: kalshi.models.communications.AcceptQuoteRequest + +::: kalshi.models.multivariate.CreateMarketInMultivariateEventCollectionRequest + +::: kalshi.models.multivariate.LookupTickersForMarketInMultivariateEventCollectionRequest + +::: kalshi.models.order_groups.CreateOrderGroupRequest + +::: kalshi.models.order_groups.UpdateOrderGroupLimitRequest + +::: kalshi.models.subaccounts.ApplySubaccountTransferRequest + +::: kalshi.models.subaccounts.UpdateSubaccountNettingRequest + +## Response models + +### Markets, events, series + +::: kalshi.models.markets + +::: kalshi.models.events + +::: kalshi.models.series + +### Trading + +::: kalshi.models.orders.Order + +::: kalshi.models.orders.Fill + +::: kalshi.models.orders.AmendOrderResponse + +::: kalshi.models.orders.OrderQueuePosition + +::: kalshi.models.portfolio + +### Account, subaccounts, API keys, FCM + +::: kalshi.models.account + +::: kalshi.models.subaccounts + +::: kalshi.models.api_keys + +### Exchange, historical, search + +::: kalshi.models.exchange + +::: kalshi.models.historical + +### RFQ / Quote, multivariate, live data, milestones, structured targets, incentive programs, order groups + +::: kalshi.models.communications + +::: kalshi.models.multivariate + +::: kalshi.models.live_data + +::: kalshi.models.milestones + +::: kalshi.models.structured_targets + +::: kalshi.models.incentive_programs + +::: kalshi.models.order_groups + +## WebSocket + +See [WebSocket](websockets.md) for the narrative version. + +::: kalshi.ws.client.KalshiWebSocket + +::: kalshi.ws.connection.ConnectionState + +::: kalshi.ws.backpressure.OverflowStrategy + +::: kalshi.ws.backpressure.MessageQueue + +::: kalshi.ws.models + +## Testing helpers -## Package contents +See [Testing](testing.md) for the narrative version. -::: kalshi - options: - members: false - show_root_heading: false - show_root_toc_entry: false +::: kalshi.testing diff --git a/docs/request-models.md b/docs/request-models.md new file mode 100644 index 0000000..bedda48 --- /dev/null +++ b/docs/request-models.md @@ -0,0 +1,124 @@ +# Request models + +Every mutating endpoint (`POST`, `PUT`, `DELETE` with a body) accepts its +parameters two ways: as individual keyword arguments or as a pre-built request +model. + +## The two forms + +```python +# Form 1 — individual kwargs (default for most call sites) +order = client.orders.create( + ticker="KXPRES-24-DJT", + side="yes", + action="buy", + count=10, + yes_price="0.65", + time_in_force="good_till_canceled", +) + +# Form 2 — pass a pre-built request model +from kalshi import CreateOrderRequest + +req = CreateOrderRequest( + ticker="KXPRES-24-DJT", + side="yes", + action="buy", + count=10, + yes_price="0.65", + time_in_force="good_till_canceled", +) +order = client.orders.create(request=req) +``` + +The two forms are **mutually exclusive** — passing `request=` together with +any other kwarg raises `TypeError`. The request-model form is useful when you +build orders out of band (config, queue, test harness) and want to type-check +the whole payload at construction time. + +## `extra="forbid"` everywhere + +Every request model has `model_config = {"extra": "forbid"}`. A misspelled or +removed field fails at construction with a Pydantic `ValidationError` — +**before** the HTTP request goes out. + +```python +from kalshi import CreateOrderRequest + +CreateOrderRequest( + ticker="X", side="yes", action="buy", count=1, yes_price="0.65", + typo_field=123, # raises ValidationError immediately +) +``` + +The same models drive the body-drift contract tests in CI, so the kwargs +exposed on each resource method and the wire format stay in lockstep with the +OpenAPI spec. + +## Inventory + +| Resource | Request model | +|---|---| +| `client.orders.create` | `CreateOrderRequest` | +| `client.orders.batch_create` | `BatchCreateOrdersRequest` (wraps `list[CreateOrderRequest]`) | +| `client.orders.batch_cancel` | `BatchCancelOrdersRequest` (wraps `list[BatchCancelOrdersRequestOrder]`) | +| `client.orders.amend` | `AmendOrderRequest` | +| `client.orders.decrease` | `DecreaseOrderRequest` | +| `client.api_keys.create` | `CreateApiKeyRequest` | +| `client.api_keys.generate` | `GenerateApiKeyRequest` | +| `client.communications.create_rfq` | `CreateRFQRequest` | +| `client.communications.create_quote` | `CreateQuoteRequest` | +| `client.communications.accept_quote` | `AcceptQuoteRequest` | +| `client.multivariate_collections.create_market` | `CreateMarketInMultivariateEventCollectionRequest` | +| `client.multivariate_collections.lookup_tickers` | `LookupTickersForMarketInMultivariateEventCollectionRequest` | +| `client.order_groups.create` | `CreateOrderGroupRequest` | +| `client.order_groups.update_limit` | `UpdateOrderGroupLimitRequest` | +| `client.subaccounts.transfer` | `ApplySubaccountTransferRequest` | +| `client.subaccounts.update_netting` | `UpdateSubaccountNettingRequest` | + +All are importable from the top-level `kalshi` package. + +## Wire-format aliases + +Several fields use `serialization_alias` to bridge a friendly Python name to +an awkward wire name: + +| Model field | Wire name | +|---|---| +| `CreateOrderRequest.count` | `count_fp` | +| `CreateOrderRequest.yes_price` | `yes_price_dollars` | +| `CreateOrderRequest.no_price` | `no_price_dollars` | +| `AmendOrderRequest.yes_price` / `.no_price` / `.count` | `*_dollars` / `count_fp` | +| `CreateQuoteRequest.target_cost` | `target_cost_dollars` | +| `StructuredTarget.target_type` (response) | `type` (avoids shadowing the builtin) | + +You never type these long names — they're an internal implementation detail. + +## 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). +- `AmendOrderRequest` — at least one of `yes_price` / `no_price` / `count` + must be present (enforced in the resource method, before the body is built). + +Bad input fails locally with a clear traceback; no wasted round trip. + +## Building bodies by hand + +If you need to inspect or mutate the wire body, request models serialize like +this: + +```python +from kalshi import CreateOrderRequest + +req = CreateOrderRequest(ticker="X", side="yes", action="buy", count=1, yes_price="0.65") +body = req.model_dump(exclude_none=True, by_alias=True, mode="json") +# {'ticker': 'X', 'side': 'yes', 'action': 'buy', 'count_fp': '1', +# 'yes_price_dollars': '0.65'} +``` + +`exclude_none=True` and `by_alias=True` are exactly what every resource method +uses internally. `mode="json"` gives you a JSON-serializable dict (Decimals +become strings). diff --git a/docs/resources/account.md b/docs/resources/account.md new file mode 100644 index 0000000..d778603 --- /dev/null +++ b/docs/resources/account.md @@ -0,0 +1,40 @@ +# Account + +API tier limits — what your read/write rate-limit buckets look like. + +Auth required. + +## Quick reference + +| Method | Endpoint | +|---|---| +| `limits()` | `GET /account/limits` | + +## Read tier limits + +```python +limits = client.account.limits() +print(limits.usage_tier) +print(limits.read.bucket_capacity, limits.read.refill_rate) +print(limits.write.bucket_capacity, limits.write.refill_rate) +``` + +`AccountApiLimits.read` and `.write` are `RateLimit` objects with +`bucket_capacity` and `refill_rate` fields (token-bucket parameters). +Use them to drive client-side throttling if you fan out many concurrent +calls. + +!!! note "Differs from the OpenAPI spec shape" + The published spec describes `read_limit` and `write_limit` as integers; + the production server returns the nested `RateLimit` objects shown above. + The SDK normalizes to the live shape. + +## Reference + +::: kalshi.resources.account.AccountResource + options: + heading_level: 3 + +::: kalshi.resources.account.AsyncAccountResource + options: + heading_level: 3 diff --git a/docs/resources/api-keys.md b/docs/resources/api-keys.md new file mode 100644 index 0000000..81e9cfa --- /dev/null +++ b/docs/resources/api-keys.md @@ -0,0 +1,93 @@ +# API keys + +Manage RSA API keys programmatically — list, create with a BYO public key, +have the server mint a fresh pair, or delete. + +Auth required throughout (you need an existing key to manage keys). + +## Quick reference + +| Method | Endpoint | +|---|---| +| `list()` | `GET /api_keys` | +| `create(*, name, public_key, scopes=None)` | `POST /api_keys` | +| `generate(*, name, scopes=None)` | `POST /api_keys/generate` | +| `delete(api_key)` | `DELETE /api_keys/{api_key}` | + +## List + +```python +resp = client.api_keys.list() +for key in resp.api_keys: + print(key.api_key, key.name, key.scopes, key.created_ts) +``` + +## Server-minted pair (`generate`) + +The simplest path — Kalshi mints the keypair, you store the private key once: + +```python +resp = client.api_keys.generate(name="ci-bot-2026", scopes=["read", "write"]) +private_pem = resp.private_key.get_secret_value() # SecretStr — see warning +print(resp.api_key.api_key) # the key id +# Persist private_pem somewhere safe; you will not see it again. +``` + +!!! danger "`private_key` is a `SecretStr` — and you only see it once" + `resp.private_key` is a `pydantic.SecretStr`. `print(resp.private_key)` + will print `**********`, **not** the key. Use `.get_secret_value()` to + extract the PEM, and store it before the response goes out of scope. + Kalshi cannot retrieve a server-minted private key after this call. + +### Scopes + +| Scope | Allows | +|---|---| +| `read` | All `GET` endpoints | +| `write` | All write endpoints — **requires `read`** | + +The server default when you omit `scopes` is `["read", "write"]`. + +## BYO public key (`create`) + +If you'd rather mint the keypair yourself (better for HSM / KMS workflows), +generate locally and upload only the public half: + +```python +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization + +key = rsa.generate_private_key(public_exponent=65537, key_size=2048) +public_pem = key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, +).decode() + +resp = client.api_keys.create(name="self-minted", public_key=public_pem) +print(resp.api_key.api_key) +``` + +The private half never touches Kalshi. + +## Rotate a key + +```python +# 1) Mint the replacement. +new = client.api_keys.generate(name="prod-bot-2026-q2") +new_pem = new.private_key.get_secret_value() + +# 2) Swap your application's credentials over to `new`. + +# 3) Once you've confirmed the new key works: +client.api_keys.delete("old-key-id") +``` + +## Reference + +::: kalshi.resources.api_keys.ApiKeysResource + options: + heading_level: 3 + +::: kalshi.resources.api_keys.AsyncApiKeysResource + options: + heading_level: 3 diff --git a/docs/resources/communications.md b/docs/resources/communications.md new file mode 100644 index 0000000..a695529 --- /dev/null +++ b/docs/resources/communications.md @@ -0,0 +1,92 @@ +# Communications (RFQ / Quote) + +Kalshi's bilateral block-trade rail. An **RFQ** is a private "make me a market +on this contract at this size?" message; a **Quote** is a counterparty's +answer. The requester accepts a side; the maker confirms; the trade settles. + +Auth required throughout. + +## Quick reference + +| Method | Endpoint | +|---|---| +| `get_id()` | `GET /communications/id` | +| `list_rfqs(...)` / `list_all_rfqs(...)` | `GET /communications/rfqs` | +| `get_rfq(rfq_id)` | `GET /communications/rfqs/{rfq_id}` | +| `create_rfq(...)` | `POST /communications/rfqs` | +| `delete_rfq(rfq_id)` | `DELETE /communications/rfqs/{rfq_id}` | +| `list_quotes(...)` / `list_all_quotes(...)` | `GET /communications/quotes` | +| `get_quote(quote_id)` | `GET /communications/quotes/{quote_id}` | +| `create_quote(...)` | `POST /communications/quotes` | +| `delete_quote(quote_id)` | `DELETE /communications/quotes/{quote_id}` | +| `accept_quote(quote_id, *, accepted_side)` | `POST /communications/quotes/{quote_id}/accept` | +| `confirm_quote(quote_id)` | `POST /communications/quotes/{quote_id}/confirm` | + +`get_id()` returns your `participant_id` — the value you'll pass as +`quote_creator_user_id` / `rfq_creator_user_id` when filtering lists. + +## Requester flow + +```python +# Identify yourself for filtering downstream. +me = client.communications.get_id() + +# 1) Post an RFQ asking for a price on 500 contracts. +rfq = client.communications.create_rfq( + market_ticker="KXPRES-24-DJT", + contracts=500, + rest_remainder=True, +) +print(rfq.rfq.rfq_id) + +# 2) Poll for incoming quotes (or subscribe to the `communications` WS channel). +quotes = client.communications.list_quotes(rfq_creator_user_id=me.user_id) +for q in quotes: + print(q.quote_id, q.yes_bid, q.no_bid) + +# 3) Accept a side on one of them. +accepted = client.communications.accept_quote(quotes.items[0].quote_id, accepted_side="yes") +``` + +## Maker flow + +```python +me = client.communications.get_id() + +# 1) Watch for incoming RFQs. +for rfq in client.communications.list_all_rfqs(status="open"): + print(rfq.rfq_id, rfq.market_ticker, rfq.contracts) + +# 2) Quote one. +resp = client.communications.create_quote( + rfq_id="rfq_abc", + yes_bid="0.60", + no_bid="0.40", + rest_remainder=False, +) + +# 3) Wait for the counterparty to accept. +# 4) Confirm to lock the fill. +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. + +## RFQ statuses + +`open`, `accepted`, `confirmed`, `canceled`. Status filtering accepts these +literal strings. + +## Reference + +::: kalshi.resources.communications.CommunicationsResource + options: + heading_level: 3 + +::: kalshi.resources.communications.AsyncCommunicationsResource + options: + heading_level: 3 diff --git a/docs/resources/events.md b/docs/resources/events.md new file mode 100644 index 0000000..812e5ee --- /dev/null +++ b/docs/resources/events.md @@ -0,0 +1,71 @@ +# Events + +An **event** is one instance of a series (e.g. `KXPRES-24`). It groups one or +more markets that share a resolution. + +## Quick reference + +| Method | Endpoint | Auth | +|---|---|---| +| `list(...)` | `GET /events` | no | +| `list_all(...)` | walks `list` | no | +| `list_multivariate(...)` | `GET /events/multivariate` | no | +| `list_all_multivariate(...)` | walks `list_multivariate` | no | +| `get(event_ticker, *, with_nested_markets=False)` | `GET /events/{event_ticker}` | no | +| `metadata(event_ticker)` | `GET /events/{event_ticker}/metadata` | no | + +## List events + +```python +page = client.events.list( + status="open", # EventStatusLiteral + series_ticker="KXPRES", + with_nested_markets=False, + with_milestones=False, + min_close_ts=1_700_000_000, + min_updated_ts=1_700_000_000, + collection_ticker=None, # only events under a specific multivariate collection + limit=200, +) +for event in page: + print(event.event_ticker, event.title, event.status) +``` + +`EventStatusLiteral` has values `"unopened" | "open" | "closed" | "settled"`. +Unlike [`MarketStatusLiteral`](../types.md), there is no `"paused"`. + +## Multivariate events + +`list_multivariate(...)` returns only events that participate in a +multivariate event collection. Same kwargs as `list`. See +[Multivariate](multivariate.md) for the surrounding API. + +## Get one event + +```python +event = client.events.get("KXPRES-24", with_nested_markets=True) +for market in event.markets: + print(market.ticker) +``` + +`with_nested_markets` defaults to `False` — you'll get the event metadata only +unless you opt in. + +## Event metadata + +```python +md = client.events.metadata("KXPRES-24") +print(md.tags, md.category) +``` + +`EventMetadata` carries tags, categories, and other non-trading attributes. + +## Reference + +::: kalshi.resources.events.EventsResource + options: + heading_level: 3 + +::: kalshi.resources.events.AsyncEventsResource + options: + heading_level: 3 diff --git a/docs/resources/exchange.md b/docs/resources/exchange.md new file mode 100644 index 0000000..7c686eb --- /dev/null +++ b/docs/resources/exchange.md @@ -0,0 +1,65 @@ +# Exchange + +Top-level exchange status, schedule, announcements, and your account's +"user data updated at" timestamp. + +## Quick reference + +| Method | Endpoint | Auth | +|---|---|---| +| `status()` | `GET /exchange/status` | no | +| `schedule()` | `GET /exchange/schedule` | no | +| `announcements()` | `GET /exchange/announcements` | no | +| `user_data_timestamp()` | `GET /exchange/user_data_timestamp` | **yes** | + +## Exchange status + +```python +status = client.exchange.status() +print(status.exchange_active, status.trading_active) +``` + +Use this as a liveness check before placing orders. + +## Exchange schedule + +```python +sched = client.exchange.schedule() +for week in sched.standard_hours: + print(week.start_time, week.end_time) +for window in sched.maintenance_windows: + print(window.start, window.end) +``` + +`Schedule.standard_hours` is a list of weekly recurring windows; +`maintenance_windows` is the upcoming downtime calendar. + +## Announcements + +```python +for a in client.exchange.announcements(): + print(a.title, a.message, a.delivery_time) +``` + +Plain list, not a `Page`. + +## User-data timestamp + +```python +ts = client.exchange.user_data_timestamp() +print(ts.last_updated_ts) +``` + +Auth-required. The SDK enforces auth client-side even though the OpenAPI +spec omits a security requirement, because the endpoint reports user-scoped +freshness. + +## Reference + +::: kalshi.resources.exchange.ExchangeResource + options: + heading_level: 3 + +::: kalshi.resources.exchange.AsyncExchangeResource + options: + heading_level: 3 diff --git a/docs/resources/fcm.md b/docs/resources/fcm.md new file mode 100644 index 0000000..3d7f623 --- /dev/null +++ b/docs/resources/fcm.md @@ -0,0 +1,68 @@ +# FCM + +Futures Commission Merchant routes. **FCM-member accounts only** — non-FCM +calls come back 401/403. Auth required throughout. + +`subtrader_id` is the required discriminator on every call — every FCM +request scopes to one subtrader under your member account. + +## Quick reference + +| Method | Endpoint | +|---|---| +| `orders(*, subtrader_id, ...)` | `GET /fcm/orders` | +| `orders_all(*, subtrader_id, ...)` | walks `orders` | +| `positions(*, subtrader_id, ...)` | `GET /fcm/positions` | + +## List orders + +```python +page = client.fcm.orders( + subtrader_id="st_alpha", + ticker="KXPRES-24-DJT", + event_ticker="KXPRES-24", + status="resting", # OrderStatusLiteral + min_ts=1_700_000_000, + max_ts=1_800_000_000, + limit=200, +) +for o in page: + print(o.order_id, o.status, o.remaining_count) + +for o in client.fcm.orders_all(subtrader_id="st_alpha", status="resting"): + ... +``` + +Same `Order` model as [Orders](orders.md). Standard `Page[Order]` pagination +on `orders()`. + +## Positions + +```python +resp = client.fcm.positions( + subtrader_id="st_alpha", + event_ticker="KXPRES-24", + count_filter="position", + settlement_status="unsettled", # SettlementStatusLiteral + limit=200, +) +for mp in resp.market_positions: + print(mp.ticker, mp.position) +``` + +`positions()` returns a `PositionsResponse` (same shape as +[`portfolio.positions`](portfolio.md#positions)), **not** a `Page`. There is +no `positions_all()` helper — walk it manually if you need cursor traversal. + +`settlement_status` is the FCM-specific kwarg that does **not** exist on +`portfolio.positions()`. + +## Reference + +::: kalshi.resources.fcm.FcmResource + options: + heading_level: 3 + +::: kalshi.resources.fcm.AsyncFcmResource + options: + heading_level: 3 diff --git a/docs/resources/historical.md b/docs/resources/historical.md new file mode 100644 index 0000000..e60a053 --- /dev/null +++ b/docs/resources/historical.md @@ -0,0 +1,89 @@ +# Historical + +Historical-data archives served from a slower, cheaper backing store than the +live `markets.*` and `portfolio.*` paths. Use these for backtests and +analytics; live trading needs the real-time surfaces. + +## Quick reference + +| Method | Endpoint | Auth | +|---|---|---| +| `cutoff()` | `GET /historical/cutoff` | no | +| `markets(...)` / `markets_all(...)` | `GET /historical/markets` | no | +| `market(ticker)` | `GET /historical/markets/{ticker}` | no | +| `candlesticks(ticker, *, start_ts, end_ts, period_interval)` | `GET /historical/markets/{ticker}/candlesticks` | no | +| `trades(...)` / `trades_all(...)` | `GET /historical/trades` | no | +| `fills(...)` / `fills_all(...)` | `GET /historical/fills` | **yes** | +| `orders(...)` / `orders_all(...)` | `GET /historical/orders` | **yes** | + +## Cutoff + +```python +co = client.historical.cutoff() +print(co.cutoff_ts) +``` + +The "everything older than this timestamp is finalized" boundary. Use it to +decide whether to query the historical archive or the live `markets.*` / +`portfolio.*` endpoints. + +## Historical markets + +```python +page = client.historical.markets( + series_ticker="KXPRES", + event_ticker="KXPRES-24", + tickers=["KXPRES-24-DJT"], # comma-joined wire form + status="settled", # MarketStatusLiteral + min_close_ts=1_600_000_000, + max_close_ts=1_650_000_000, + mve_filter="exclude", + limit=500, +) +for snapshot in page: + print(snapshot.ticker, snapshot.settled_at) +``` + +## Historical trades + +```python +trades = client.historical.trades( + ticker="KXPRES-24-DJT", + min_ts=1_600_000_000, + max_ts=1_650_000_000, + limit=1000, +) +``` + +## Historical fills and orders + +Both require auth — these are your own trade history. + +```python +for fill in client.historical.fills_all(ticker="KXPRES-24-DJT"): + print(fill.fill_id, fill.price, fill.count) + +for order in client.historical.orders_all(status="executed"): + print(order.order_id, order.client_order_id) +``` + +## Historical candlesticks + +```python +candles = client.historical.candlesticks( + ticker="KXPRES-24-DJT", + start_ts=1_600_000_000, + end_ts=1_650_000_000, + period_interval=3600, +) +``` + +## Reference + +::: kalshi.resources.historical.HistoricalResource + options: + heading_level: 3 + +::: kalshi.resources.historical.AsyncHistoricalResource + options: + heading_level: 3 diff --git a/docs/resources/incentive-programs.md b/docs/resources/incentive-programs.md new file mode 100644 index 0000000..f11e7a9 --- /dev/null +++ b/docs/resources/incentive-programs.md @@ -0,0 +1,59 @@ +# Incentive programs + +Time-boxed reward campaigns (maker rebates, volume bonuses) on specific +markets or series. List them to discover what's currently rewarded and on +what terms. + +Public — no auth required. + +## Quick reference + +| Method | Endpoint | +|---|---| +| `list(...)` / `list_all(...)` | `GET /incentive_programs` | + +## List active programs + +```python +page = client.incentive_programs.list( + status="active", # IncentiveProgramStatusLiteral + incentive_type="liquidity", # IncentiveProgramTypeLiteral — spec "type" + series_ticker="KXPRES", + limit=100, +) +for p in page: + print(p.program_id, p.title, p.start_ts, p.end_ts) +``` + +Use `list_all` to walk every program: + +```python +for p in client.incentive_programs.list_all(status="active"): + ... +``` + +!!! info "Uses `next_cursor` on the wire" + The Kalshi endpoint returns the next page cursor under the key + `next_cursor` rather than the SDK-wide `cursor`. The SDK normalizes both + directions — you pass `cursor=` and read `.cursor` just like any other + list call. + +!!! info "`incentive_type` is the SDK's name for spec `type`" + Same renaming logic as [milestones](milestones.md) and + [structured targets](structured-targets.md). The wire still sends `type=`. + +### Available statuses and types + +- `IncentiveProgramStatusLiteral`: `"all"`, `"active"`, `"upcoming"`, + `"closed"`, and additional server-side categories. +- `IncentiveProgramTypeLiteral`: `"all"`, `"liquidity"`, `"volume"`. + +## Reference + +::: kalshi.resources.incentive_programs.IncentiveProgramsResource + options: + heading_level: 3 + +::: kalshi.resources.incentive_programs.AsyncIncentiveProgramsResource + options: + heading_level: 3 diff --git a/docs/resources/index.md b/docs/resources/index.md index 16310bb..dd24479 100644 --- a/docs/resources/index.md +++ b/docs/resources/index.md @@ -12,123 +12,74 @@ with KalshiClient.from_env() as client: Every resource exists in two parity-checked flavors — sync (e.g. `MarketsResource`, exposed as `client.markets`) and async (`AsyncMarketsResource`, exposed as `async_client.markets`). Method names, -keyword arguments, return shapes, and error behavior are identical. Pick -sync or async based on the rest of your codebase — internally the two -share one transport implementation, so neither is "the real one wrapping -the other". +keyword arguments, return shapes, and error behavior are identical. Pick sync +or async based on the rest of your codebase — internally the two share one +transport implementation. -## Common patterns +## Resource catalog -### Pagination +| Resource | Class | Page | +|---|---|---| +| `client.markets` | `MarketsResource` | [Markets](markets.md) | +| `client.events` | `EventsResource` | [Events](events.md) | +| `client.series` | `SeriesResource` | [Series](series.md) | +| `client.exchange` | `ExchangeResource` | [Exchange](exchange.md) | +| `client.historical` | `HistoricalResource` | [Historical](historical.md) | +| `client.search` | `SearchResource` | [Search](search.md) | +| `client.orders` | `OrdersResource` | [Orders](orders.md) | +| `client.portfolio` | `PortfolioResource` | [Portfolio](portfolio.md) | +| `client.order_groups` | `OrderGroupsResource` | [Order groups](order-groups.md) | +| `client.account` | `AccountResource` | [Account](account.md) | +| `client.api_keys` | `ApiKeysResource` | [API keys](api-keys.md) | +| `client.subaccounts` | `SubaccountsResource` | [Subaccounts](subaccounts.md) | +| `client.fcm` | `FcmResource` | [FCM](fcm.md) | +| `client.communications` | `CommunicationsResource` | [Communications (RFQ/Quote)](communications.md) | +| `client.multivariate_collections` | `MultivariateCollectionsResource` | [Multivariate](multivariate.md) | +| `client.milestones` | `MilestonesResource` | [Milestones](milestones.md) | +| `client.structured_targets` | `StructuredTargetsResource` | [Structured targets](structured-targets.md) | +| `client.incentive_programs` | `IncentiveProgramsResource` | [Incentive programs](incentive-programs.md) | +| `client.live_data` | `LiveDataResource` | [Live data](live-data.md) | -List endpoints return a `Page[T]`: +The async equivalents share the same attribute names on `AsyncKalshiClient` +(`async_client.markets` returns `AsyncMarketsResource`, etc.). -```python -page = client.markets.list(status="open", limit=200) -for market in page: # Page is iterable - print(market.ticker) +## Common patterns -len(page) # items on this page -page.cursor # next-page cursor (None if you're on the last page) -page.has_next # bool -``` +### Pagination -For "give me everything across pages", use `list_all()`: +See [Pagination](../pagination.md) for the full story. Quick form: ```python -# Sync: returns an Iterator -for market in client.markets.list_all(status="open"): +page = client.markets.list(status="open", limit=200) +for market in page: ... - -# Async: returns an AsyncIterator — works directly with `async for` -async for market in async_client.markets.list_all(status="open"): +for market in client.markets.list_all(status="open"): ... ``` -`list_all()` walks cursors until the server returns no more pages, with a -1000-page safety cap and a cursor-repeat guard that raises `KalshiError` if -the server returns the same cursor twice (a known way to detect server-side -pagination bugs in production). +### Request models or kwargs -### DataFrames - -`Page[T]` carries `.to_dataframe()` (pandas) and `.to_polars()` (polars). -Both are optional extras: - -```bash -pip install 'kalshi-sdk[pandas]' -# or -pip install 'kalshi-sdk[polars]' -``` - -```python -df = client.markets.list(status="open", limit=500).to_dataframe() -``` - -Calling either method without the corresponding library installed raises -`ImportError` with the exact `pip install` hint. - -### Request models or kwargs (your call) - -Mutating endpoints (POST / PUT / DELETE-with-body) accept their parameters -two ways: - -```python -# Form 1 — individual kwargs (default for most call sites) -order = client.orders.create( - ticker="KXPRES-24-DJT", - side="yes", - action="buy", - count=10, - yes_price="0.65", - time_in_force="good_till_canceled", -) - -# Form 2 — pass a pre-built request model -from kalshi import CreateOrderRequest - -req = CreateOrderRequest( - ticker="KXPRES-24-DJT", - side="yes", - action="buy", - count=10, - yes_price="0.65", - time_in_force="good_till_canceled", -) -order = client.orders.create(request=req) -``` - -The two forms are **mutually exclusive** — passing `request=` together with -any kwarg raises `TypeError`. The request-model form is useful when you -build orders out of band (config, queue, test harness) and want to type-check -the whole payload at construction time. - -Request models all have `extra="forbid"` — a misspelled or removed field -fails at construction. The same models drive the body-drift contract tests, -so the kwargs and the wire format stay in lockstep with the OpenAPI spec. +Mutating endpoints (POST / PUT / DELETE with a body) accept their parameters +two ways — individual kwargs or a pre-built request model. See +[Request models](../request-models.md). ### Typed enum kwargs -Kwargs that map to fixed enums use `Literal[...]` types so your IDE -auto-completes the values and `mypy` rejects typos: - -```python -from kalshi import SideLiteral, ActionLiteral, TimeInForceLiteral -# SideLiteral = Literal["yes", "no"] -# ActionLiteral = Literal["buy", "sell"] -# TimeInForceLiteral = Literal["fill_or_kill", "good_till_canceled", "immediate_or_cancel"] -``` - -The literal aliases are re-exported from `kalshi` for use in your own type -signatures. +Kwargs that map to fixed enums use `Literal[...]`. See [Types & literals](../types.md). ### Auth requirements Every resource method that touches user data calls `self._require_auth()`, -which raises [`AuthRequiredError`](../errors.md) *before* the network if -the client was constructed without credentials. Public endpoints (market -data, events, exchange status, series, historical) work on an -unauthenticated client. +which raises [`AuthRequiredError`](../errors.md) **before** the network if the +client was constructed without credentials. + +A few public-looking endpoints also require auth at the SDK layer: + +- `markets.orderbook()` and `markets.bulk_orderbooks()` — Kalshi gates the L2 + book behind auth. +- `series.forecast_percentile_history()` — auth required. +- `exchange.user_data_timestamp()` — auth required. +- All of `historical.fills` / `historical.orders` (user data). Use `client.is_authenticated` to branch on it explicitly: @@ -139,32 +90,29 @@ with KalshiClient.from_env() as client: markets = client.markets.list(status="open") # works either way ``` -## Resource catalog +## Ticker formats -| Attribute | Class | Source | +| Form | Example | Used for | |---|---|---| -| `client.markets` | `MarketsResource` | [markets.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/markets.py) | -| `client.events` | `EventsResource` | [events.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/events.py) | -| `client.series` | `SeriesResource` | [series.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/series.py) | -| `client.exchange` | `ExchangeResource` | [exchange.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/exchange.py) | -| `client.historical` | `HistoricalResource` | [historical.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/historical.py) | -| `client.orders` | `OrdersResource` | [orders.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/orders.py) | -| `client.portfolio` | `PortfolioResource` | [portfolio.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/portfolio.py) | -| `client.order_groups` | `OrderGroupsResource` | [order_groups.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/order_groups.py) | -| `client.subaccounts` | `SubaccountsResource` | [subaccounts.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/subaccounts.py) | -| `client.api_keys` | `ApiKeysResource` | [api_keys.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/api_keys.py) | -| `client.communications` | `CommunicationsResource` | [communications.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/communications.py) | -| `client.multivariate_collections` | `MultivariateCollectionsResource` | [multivariate.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/multivariate.py) | -| `client.fcm` | `FcmResource` | [fcm.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/fcm.py) | -| `client.milestones` | `MilestonesResource` | [milestones.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/milestones.py) | -| `client.structured_targets` | `StructuredTargetsResource` | [structured_targets.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/structured_targets.py) | -| `client.incentive_programs` | `IncentiveProgramsResource` | [incentive_programs.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/incentive_programs.py) | -| `client.live_data` | `LiveDataResource` | [live_data.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/live_data.py) | -| `client.search` | `SearchResource` | [search.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/search.py) | -| `client.account` | `AccountResource` | [account.py](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/resources/account.py) | - -The async equivalents share the same attribute names on `AsyncKalshiClient` -(`async_client.markets` returns `AsyncMarketsResource`, etc.). +| series_ticker | `KXPRES` | A recurring family of events | +| event_ticker | `KXPRES-24` | One instance under a series | +| market_ticker | `KXPRES-24-DJT` | A single YES/NO contract | + +A few methods take a path-shaped pair (`series_ticker, ticker`) where `ticker` +is actually an **event** ticker — for example +`series.event_candlesticks(series_ticker, ticker)`. The pages call those out +where they appear. + +## Wire-format quirks + +- **Prices** are returned as FixedPointDollars strings (`"0.5600"`) with a + `_dollars` suffix in the response key (`yes_bid_dollars`). The SDK normalizes + them to short names (`yes_bid: Decimal`). See [Types & literals](../types.md). +- **Tickers in batches** use two different wire formats depending on the + endpoint. `markets.list`, `historical.markets`, `markets.bulk_candlesticks` + comma-join (`?tickers=a,b,c`). `markets.bulk_orderbooks` and + `series.forecast_percentile_history` use explode form (`?tickers=a&tickers=b`). + Both surfaces accept `list[str] | str`; the SDK builds the right wire form. ## Reference diff --git a/docs/resources/live-data.md b/docs/resources/live-data.md new file mode 100644 index 0000000..64bf024 --- /dev/null +++ b/docs/resources/live-data.md @@ -0,0 +1,72 @@ +# Live data + +Real-time state attached to a [milestone](milestones.md) — score, clock, +period, weather, etc. Pair with milestones to render live UI alongside +Kalshi markets. + +Public — no auth required. + +## Quick reference + +| Method | Endpoint | +|---|---| +| `get(milestone_id, *, include_player_stats=None)` | `GET /live_data/{milestone_id}` | +| `batch(milestone_ids, *, include_player_stats=None)` | `GET /live_data` | +| `game_stats(milestone_id)` | `GET /live_data/{milestone_id}/game_stats` | +| `get_typed(live_data_type, milestone_id)` | `GET /live_data/{type}/milestone/{milestone_id}` (legacy) | + +## Get one milestone's live data + +```python +live = client.live_data.get("ms_abc", include_player_stats=True) +print(live.live_data_type, live.payload) +``` + +## Batch (up to 100 milestones) + +```python +resp = client.live_data.batch( + milestone_ids=["ms_a", "ms_b", "ms_c"], + include_player_stats=False, +) +for entry in resp.live_data: + print(entry.milestone_id, entry.payload) +``` + +`milestone_ids` is required and non-empty — passing `[]` raises `ValueError`. +Cap: 100 ids per call. + +## Game stats / play-by-play + +```python +pbp = client.live_data.game_stats("ms_abc") +if pbp.pbp is None: + print("no play-by-play for this milestone type") +else: + for period in pbp.pbp.periods: + for play in period.plays: + print(play.timestamp, play.description) +``` + +`game_stats` works only for sports milestones with play-by-play coverage. +Other milestone types return `pbp=None`. + +## Legacy `get_typed` + +```python +live = client.live_data.get_typed("sports_game", "ms_abc") +``` + +Prefer `get()` over `get_typed()`. The latter wraps the legacy +`/live_data/{type}/milestone/{id}` path and is retained only for callers that +still depend on it. + +## Reference + +::: kalshi.resources.live_data.LiveDataResource + options: + heading_level: 3 + +::: kalshi.resources.live_data.AsyncLiveDataResource + options: + heading_level: 3 diff --git a/docs/resources/markets.md b/docs/resources/markets.md new file mode 100644 index 0000000..b316d1f --- /dev/null +++ b/docs/resources/markets.md @@ -0,0 +1,141 @@ +# Markets + +Operate on Kalshi markets — the leaf-level YES/NO contracts. List, fetch a +single market, pull its orderbook, get candlesticks (single or bulk), list +recent trades. + +## Quick reference + +| Method | Endpoint | Auth | +|---|---|---| +| `list(...)` | `GET /markets` | no | +| `list_all(...)` | walks `list` | no | +| `get(ticker)` | `GET /markets/{ticker}` | no | +| `orderbook(ticker, depth=None)` | `GET /markets/{ticker}/orderbook` | **yes** | +| `candlesticks(series_ticker, ticker, *, start_ts, end_ts, period_interval, ...)` | `GET /series/{series_ticker}/markets/{ticker}/candlesticks` | no | +| `bulk_candlesticks(*, market_tickers, ...)` | `GET /markets/candlesticks` | no | +| `bulk_orderbooks(*, tickers)` | `GET /markets/orderbooks` | **yes** | +| `list_trades(...)` | `GET /markets/trades` | no | +| `list_trades_all(...)` | walks `list_trades` | no | + +!!! warning "Orderbook endpoints require auth" + `orderbook()` and `bulk_orderbooks()` raise `AuthRequiredError` on an + unauthenticated client. The rest of the market-data surface is public. + +## List markets + +```python +page = client.markets.list( + status="open", # MarketStatusLiteral + series_ticker="KXPRES", + event_ticker="KXPRES-24", + tickers=["KXPRES-24-DJT", "KXPRES-24-KH"], # or "KXPRES-24-DJT,KXPRES-24-KH" + min_close_ts=1_700_000_000, + max_close_ts=1_800_000_000, + mve_filter="exclude", # MveFilterLiteral + limit=200, +) +for market in page: + print(market.ticker, market.yes_bid, market.yes_ask) +``` + +`tickers` accepts a `list[str]` or a comma-joined string — the SDK comma-joins +for the wire (this endpoint uses comma-join form, **not** the explode form +used by `bulk_orderbooks`). + +All the `*_ts` filters (`min_close_ts`, `max_close_ts`, `min_open_ts`, +`max_open_ts`, `min_settle_ts`, `max_settle_ts`, `min_volume_24h`, +`min_total_volume`, `min_total_open_interest`) are Unix-second ints. Pair them +with `min_open_ts`/`max_open_ts` etc. as needed. + +`list_all(...)` walks cursors and returns an iterator — see +[Pagination](../pagination.md). + +## Get one market + +```python +market = client.markets.get("KXPRES-24-DJT") +print(market.status, market.yes_bid, market.yes_ask, market.volume) +``` + +## Orderbook (single) + +```python +ob = client.markets.orderbook("KXPRES-24-DJT", depth=20) +print(ob.yes[:5]) # list[OrderbookLevel], best-first +print(ob.no[:5]) +``` + +`depth` is the number of price levels per side (default unbounded). Wire keys +`orderbook_fp.yes_dollars` / `no_dollars` are normalized to `ob.yes` / `ob.no` +of type `Decimal`. + +## Bulk orderbooks + +```python +books = client.markets.bulk_orderbooks(tickers=["KXPRES-24-DJT", "KXPRES-24-KH"]) +for ob in books: + print(ob.market_ticker, ob.yes[0]) +``` + +- **Cap: 100 tickers per call.** +- **Wire format:** explode (`?tickers=a&tickers=b`). The SDK builds this for + you regardless of whether you pass `list[str]` or a comma-joined string. +- Auth required. + +## Candlesticks + +```python +candles = client.markets.candlesticks( + series_ticker="KXPRES", + ticker="KXPRES-24-DJT", + start_ts=1_700_000_000, + end_ts=1_700_100_000, + period_interval=60, # seconds: 60, 3600, 86400 + include_latest_before_start=True, +) +for c in candles.candlesticks: + print(c.end_period_ts, c.yes_bid) +``` + +`include_latest_before_start` includes the most recent candle before +`start_ts` for seamless rendering. + +## Bulk candlesticks + +```python +batches = client.markets.bulk_candlesticks( + market_tickers=["KXPRES-24-DJT", "KXPRES-24-KH"], # 1–100 tickers + series_ticker="KXPRES", # optional shard hint + start_ts=1_700_000_000, + end_ts=1_700_100_000, + period_interval=3600, +) +for batch in batches: # list[MarketCandlesticks] + print(batch.market_ticker, len(batch.candlesticks)) +``` + +Comma-joined wire form (`?market_tickers=a,b,c`). 100-ticker cap. + +## List trades + +```python +page = client.markets.list_trades( + ticker="KXPRES-24-DJT", + min_ts=1_700_000_000, + max_ts=1_700_100_000, + limit=200, +) +for trade in page: + print(trade.trade_id, trade.taker_side, trade.yes_price, trade.count) +``` + +## Reference + +::: kalshi.resources.markets.MarketsResource + options: + heading_level: 3 + +::: kalshi.resources.markets.AsyncMarketsResource + options: + heading_level: 3 diff --git a/docs/resources/milestones.md b/docs/resources/milestones.md new file mode 100644 index 0000000..6fa836c --- /dev/null +++ b/docs/resources/milestones.md @@ -0,0 +1,63 @@ +# Milestones + +A **milestone** is a real-world reference event (a baseball game, an +election, an economic release) that Kalshi markets and live-data feeds anchor +to. The milestone id is the join key between a market and its live state. + +Public — no auth required. + +## Quick reference + +| Method | Endpoint | +|---|---| +| `list(*, limit, ...)` | `GET /milestones` | +| `list_all(*, limit=None, max_pages=None, ...)` | walks `list` | +| `get(milestone_id)` | `GET /milestones/{milestone_id}` | + +## List milestones + +```python +from datetime import datetime, timezone + +page = client.milestones.list( + limit=100, # required, 1–500 + milestone_type="sports_game", # spec field name "type"; renamed + competition="NFL", + minimum_start_date=datetime(2026, 5, 17, tzinfo=timezone.utc), + min_updated_ts=1_700_000_000, # Unix seconds, for incremental polling +) +for m in page: + print(m.milestone_id, m.title, m.scheduled_start) +``` + +!!! info "`limit` is required" + Unlike most list endpoints, `milestones.list` requires `limit` (1–500). + `list_all` handles this internally; pass `limit=` if you want a non-default + page size. + +!!! info "Datetime handling" + `minimum_start_date` accepts a `datetime`. Naive datetimes are coerced to + UTC. If you pass an RFC3339 string instead, it's forwarded unchanged — you + own format correctness. + +!!! info "`milestone_type` is the SDK's name for spec `type`" + The OpenAPI spec calls this query parameter `type`. The SDK renames it to + `milestone_type` to avoid shadowing the Python builtin. The wire still + sends `type=`. + +## Get one milestone + +```python +m = client.milestones.get("ms_abc") +print(m.title, m.milestone_type, m.scheduled_start) +``` + +## Reference + +::: kalshi.resources.milestones.MilestonesResource + options: + heading_level: 3 + +::: kalshi.resources.milestones.AsyncMilestonesResource + options: + heading_level: 3 diff --git a/docs/resources/multivariate.md b/docs/resources/multivariate.md new file mode 100644 index 0000000..1e06d4d --- /dev/null +++ b/docs/resources/multivariate.md @@ -0,0 +1,92 @@ +# Multivariate event collections + +A **multivariate event collection** is a template that lets you bet on +combinations of outcomes — e.g. "Will it rain in NYC AND the Yankees win +Saturday?" The collection holds the building-block markets; `create_market` +with a list of leg selections mints a derived YES/NO contract. + +Public listing, auth-required minting. Attribute name on the client: +`multivariate_collections`. + +## Quick reference + +| Method | Endpoint | Auth | +|---|---|---| +| `list(...)` / `list_all(...)` | `GET /multivariate_event_collections` | no | +| `get(collection_ticker)` | `GET /multivariate_event_collections/{ticker}` | no | +| `create_market(collection_ticker, *, selected_markets, with_market_payload=False)` | `POST /multivariate_event_collections/{ticker}` | yes | +| `lookup_tickers(collection_ticker, *, selected_markets)` | `PUT /multivariate_event_collections/{ticker}/lookup` | no | +| `lookup_history(collection_ticker, *, lookback_seconds)` | `GET /multivariate_event_collections/{ticker}/lookup_history` | no | + +## List collections + +```python +page = client.multivariate_collections.list( + status="open", # MultivariateCollectionStatusLiteral + series_ticker="KXWEATHER", + limit=100, +) +for c in page: + print(c.collection_ticker, c.title) +``` + +## Select legs + +A leg is a `TickerPair(event_ticker=..., market_ticker=...)` — one event-side +selection. To bet on "rain in NYC AND Yankees win": + +```python +from kalshi import TickerPair + +legs = [ + TickerPair(event_ticker="KXNYRAIN-26", market_ticker="KXNYRAIN-26-YES"), + TickerPair(event_ticker="KXYANKEES-26-SAT", market_ticker="KXYANKEES-26-SAT-WIN"), +] +``` + +## Lookup the auto-generated ticker (no mint) + +```python +resp = client.multivariate_collections.lookup_tickers( + "KXWEATHER-SPORTS-COMBO", + selected_markets=legs, +) +print(resp.market_ticker, resp.event_ticker) +``` + +Wire-level note: this endpoint is a `PUT` — unusual for a read operation, but +matches the OpenAPI spec. + +## Mint a combo market + +```python +resp = client.multivariate_collections.create_market( + "KXWEATHER-SPORTS-COMBO", + selected_markets=legs, + with_market_payload=True, # also return the full Market body +) +print(resp.market_ticker, resp.event_ticker) +if resp.market is not None: + print(resp.market.yes_bid, resp.market.yes_ask) +``` + +## Recent lookup history + +```python +hist = client.multivariate_collections.lookup_history( + "KXWEATHER-SPORTS-COMBO", + lookback_seconds=3600, +) +for entry in hist.lookups: + print(entry.market_ticker, entry.created_ts) +``` + +## Reference + +::: kalshi.resources.multivariate.MultivariateCollectionsResource + options: + heading_level: 3 + +::: kalshi.resources.multivariate.AsyncMultivariateCollectionsResource + options: + heading_level: 3 diff --git a/docs/resources/order-groups.md b/docs/resources/order-groups.md new file mode 100644 index 0000000..9ed37b3 --- /dev/null +++ b/docs/resources/order-groups.md @@ -0,0 +1,78 @@ +# Order groups + +A rolling contracts-limit bucket that several orders can share. Trips when +the group hits its cap; can be `reset()` or `trigger()`ed manually. + +Auth required throughout. + +## Quick reference + +| Method | Endpoint | +|---|---| +| `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}` | +| `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` | + +!!! info "Non-standard create path" + `create()` POSTs to `/portfolio/order_groups/create`, not `/portfolio/order_groups`. + The other methods follow the conventional pattern. + +## Lifecycle + +```python +from kalshi import CreateOrderRequest + +# 1) Create a group with a 100-contract cap. +group = client.order_groups.create(contracts_limit=100) +print(group.order_group_id) + +# 2) Attach orders by id. +client.orders.create( + ticker="KXPRES-24-DJT", side="yes", action="buy", count=10, + yes_price="0.65", order_group_id=group.order_group_id, +) + +# 3) Inspect. +detail = client.order_groups.get(group.order_group_id) +print(detail.orders) # list[str] of order IDs in the group + +# 4) Reset (clears the counter) or trigger (forces the cap). +client.order_groups.reset(group.order_group_id) +client.order_groups.trigger(group.order_group_id) + +# 5) Tear down. +client.order_groups.delete(group.order_group_id) +``` + +## List + +```python +for g in client.order_groups.list(): + print(g.order_group_id, g.contracts_limit, g.contracts_used) +``` + +Plain `list[OrderGroup]`, no cursor. + +## Update limit + +```python +client.order_groups.update_limit("og_abc", contracts_limit=200) +``` + +`update_limit` has **no `subaccount=` kwarg** — the OpenAPI spec omits the +subaccount query parameter on this path. Route via the group's own +subaccount-on-create instead. + +## Reference + +::: kalshi.resources.order_groups.OrderGroupsResource + options: + heading_level: 3 + +::: kalshi.resources.order_groups.AsyncOrderGroupsResource + options: + heading_level: 3 diff --git a/docs/resources/orders.md b/docs/resources/orders.md new file mode 100644 index 0000000..6116e49 --- /dev/null +++ b/docs/resources/orders.md @@ -0,0 +1,192 @@ +# Orders + +Place, amend, cancel, and inspect orders. The largest single resource in the +SDK and the surface you'll spend the most time with as a trader. + +Every method here requires auth. + +## Quick reference + +| 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 | +| `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 | +| `fills(...)` / `fills_all(...)` | `GET /portfolio/fills` | yes | +| `queue_positions(*, market_tickers, event_ticker)` | `GET /portfolio/orders/queue_positions` | yes | +| `queue_position(order_id)` | `GET /portfolio/orders/{order_id}/queue_position` | yes | + +## Place an order + +```python +import uuid +from kalshi import KalshiClient + +with KalshiClient.from_env() as client: + order = client.orders.create( + ticker="KXPRES-24-DJT", + side="yes", # SideLiteral + action="buy", # ActionLiteral, defaults to "buy" + count=10, + yes_price="0.65", # str or Decimal — never float + client_order_id=str(uuid.uuid4()), + time_in_force="good_till_canceled", # TimeInForceLiteral + post_only=False, + reduce_only=False, + self_trade_prevention_type="taker_at_cross", + cancel_order_on_pause=False, + # buy_max_cost=500, # integer cents — $5.00 limit. Not dollars. + # expiration_ts=1_800_000_000, # Unix seconds + # order_group_id="og_abc", # attach to an order group + # subaccount=1, # route to subaccount 1 + ) + print(order.order_id, order.status) +``` + +### Limit vs market + +A market order is just a `create()` with no `yes_price` / `no_price`. The +order type is server-derived from price presence. + +### Time-in-force + +| Value | Behavior | +|---|---| +| `"fill_or_kill"` | Fill instantly in full or cancel. | +| `"good_till_canceled"` | Rest until canceled or filled. Server default if you omit. | +| `"immediate_or_cancel"` | Fill whatever you can immediately, cancel the rest. | + +### `buy_max_cost` is integer cents + +```python +client.orders.create(..., buy_max_cost=500) # cap at $5.00 +``` + +The model rejects `Decimal` or `float` at construction. The rule: any field +whose name ends in `_cents` or that is `buy_max_cost` is `int` cents; price +fields (`yes_price`, `no_price`) are `Decimal` dollars. + +### `client_order_id` for safe retries + +`POST /portfolio/orders` is **never automatically retried**. Set a fresh +`client_order_id` per call so you can safely retry from your app layer +without double-filling. See [Retries & idempotency](../retries.md). + +### Self-trade prevention + +`SelfTradePreventionTypeLiteral` values: + +- `"taker_at_cross"` — your taker order is canceled if it would cross your own resting order. +- `"maker"` — your resting maker order is canceled if a taker side of yours crosses it. + +## Batch create + +```python +from kalshi import CreateOrderRequest + +orders = [ + CreateOrderRequest(ticker="X-YES", side="yes", action="buy", count=10, yes_price="0.60"), + CreateOrderRequest(ticker="X-NO", side="no", action="buy", count=10, no_price="0.42"), +] +created = client.orders.batch_create(orders) +for o in created: + print(o.order_id, o.status) +``` + +Each child has `extra="forbid"`, so a typo in any leg fails at construction +before the round trip. + +## Cancel + +```python +client.orders.cancel("ord_abc") # single +client.orders.batch_cancel(["ord_abc", "ord_def"]) # convenience: list of strings +``` + +For per-entry subaccount routing, build the request explicitly: + +```python +from kalshi import BatchCancelOrdersRequestOrder + +client.orders.batch_cancel([ + BatchCancelOrdersRequestOrder(order_id="ord_abc", subaccount=0), + BatchCancelOrdersRequestOrder(order_id="ord_def", subaccount=1), +]) +``` + +!!! note "v0.8.0 wire change" + The body field is `orders` (an array of objects). The previous `ids` + field is no longer emitted. If you talk to the API directly elsewhere, + keep that in mind. + +## Amend + +```python +resp = client.orders.amend( + "ord_abc", + ticker="KXPRES-24-DJT", # required — same shape as create + side="yes", # required + action="buy", # required + yes_price="0.66", # at least one of yes_price / no_price / count + updated_client_order_id="cid-2", +) +print(resp.old_order.order_id, resp.order.order_id) +``` + +`AmendOrderResponse` carries both the previous and the new order. `ticker`, +`side`, and `action` are required even though you're amending an existing +order — they're part of the API's amend payload. + +## Decrease + +Reduce the size of a resting order without canceling. Exactly one of +`reduce_by` or `reduce_to`: + +```python +order = client.orders.decrease("ord_abc", reduce_by=3) # decrease size by 3 +order = client.orders.decrease("ord_abc", reduce_to=5) # decrease size to 5 +``` + +Passing neither or both raises `ValidationError` at construction (XOR enforced +by the model). + +## List orders and fills + +```python +for order in client.orders.list_all(status="resting"): + print(order.order_id, order.ticker, order.remaining_count) + +for fill in client.orders.fills_all(ticker="KXPRES-24-DJT"): + print(fill.fill_id, fill.price, fill.count, fill.is_taker) +``` + +`status` accepts an `OrderStatusLiteral`: `"resting"`, `"canceled"`, +`"executed"`. `min_ts` / `max_ts` (Unix seconds) bound by created time. + +## Queue position + +```python +positions = client.orders.queue_positions(market_tickers=["KXPRES-24-DJT"]) +for p in positions: + print(p.order_id, p.queue_position) + +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. + +## Reference + +::: kalshi.resources.orders.OrdersResource + options: + heading_level: 3 + +::: kalshi.resources.orders.AsyncOrdersResource + options: + heading_level: 3 diff --git a/docs/resources/portfolio.md b/docs/resources/portfolio.md new file mode 100644 index 0000000..12df609 --- /dev/null +++ b/docs/resources/portfolio.md @@ -0,0 +1,104 @@ +# Portfolio + +Account balance, positions, settlements, and total resting order value. +Auth required throughout. + +## Quick reference + +| Method | Endpoint | +|---|---| +| `balance(*, subaccount=None)` | `GET /portfolio/balance` | +| `positions(*, ...)` | `GET /portfolio/positions` | +| `settlements(...)` / `settlements_all(...)` | `GET /portfolio/settlements` | +| `total_resting_order_value()` | `GET /portfolio/summary/total_resting_order_value` (FCM only) | + +## Balance + +```python +bal = client.portfolio.balance() +print(bal.balance, bal.portfolio_value, bal.updated_ts) +``` + +`Balance.balance` and `portfolio_value` are **integer cents**, not dollars. + +## Positions + +```python +resp = client.portfolio.positions( + limit=200, + count_filter="position", # only return rows with non-zero `position` (etc.) + ticker="KXPRES-24-DJT", + event_ticker="KXPRES-24", +) +for mp in resp.market_positions: + print(mp.ticker, mp.position, mp.realized_pnl) +for ep in resp.event_positions: + print(ep.event_ticker, ep.event_exposure) +``` + +!!! warning "`positions()` does not return Page[T]" + It returns `PositionsResponse` — two parallel lists (`market_positions` + and `event_positions`) plus its own `cursor` and `has_next`. There is no + `positions_all()` helper. Walk it manually: + + ```python + cursor = None + while True: + resp = client.portfolio.positions(cursor=cursor) + ... + if not resp.has_next: + break + cursor = resp.cursor + ``` + +`count_filter` filters which fields the response **includes a row for** — +filtering by `"position"` returns only markets where your position is non-zero. + +## Settlements + +```python +page = client.portfolio.settlements( + ticker="KXPRES-24-DJT", + event_ticker="KXPRES-24", + min_ts=1_700_000_000, + max_ts=1_800_000_000, + limit=200, +) +for s in page: + print(s.ticker, s.settled_at, s.market_result, s.revenue) + +# Or: +for s in client.portfolio.settlements_all(): + ... +``` + +Standard `Page[Settlement]` pagination — see [Pagination](../pagination.md). + +## Total resting order value + +```python +total = client.portfolio.total_resting_order_value() +print(total.total_value) +``` + +!!! warning "FCM members only" + Non-FCM accounts get a `403` (mapped to `KalshiAuthError`). Demo mirrors + production behavior here. + +## Position fields + +`MarketPosition` and `EventPosition` use the standard `_dollars` / `_fp` +wire-format aliases — on the wire you see `total_traded_dollars`, +`market_exposure_dollars`, `realized_pnl_dollars`, `fees_paid_dollars`, +`position_fp`. The SDK normalizes to short names returning `Decimal`. +`realized_pnl` is signed. + +## Reference + +::: kalshi.resources.portfolio.PortfolioResource + options: + heading_level: 3 + +::: kalshi.resources.portfolio.AsyncPortfolioResource + options: + heading_level: 3 diff --git a/docs/resources/search.md b/docs/resources/search.md new file mode 100644 index 0000000..6aee7f9 --- /dev/null +++ b/docs/resources/search.md @@ -0,0 +1,41 @@ +# Search + +Discovery surfaces for tags and sport filters. Two methods, both public. + +## Quick reference + +| Method | Endpoint | Auth | +|---|---|---| +| `tags_by_categories()` | `GET /search/tags_by_categories` | no | +| `filters_by_sport()` | `GET /search/filters_by_sport` | no | + +## Tags by category + +```python +resp = client.search.tags_by_categories() +for category in resp.categories: + print(category.name, [tag.name for tag in category.tags]) +``` + +Returns a grouped tag tree for use in UI filters. + +## Filters by sport + +```python +resp = client.search.filters_by_sport() +for sport in resp.sports: + print(sport.sport, sport.competitions, sport.target_types) +``` + +Lists the sports, competitions, and structured-target types available for use +as filter inputs in markets / events / [structured targets](structured-targets.md). + +## Reference + +::: kalshi.resources.search.SearchResource + options: + heading_level: 3 + +::: kalshi.resources.search.AsyncSearchResource + options: + heading_level: 3 diff --git a/docs/resources/series.md b/docs/resources/series.md new file mode 100644 index 0000000..7d879a8 --- /dev/null +++ b/docs/resources/series.md @@ -0,0 +1,90 @@ +# Series + +A **series** is a recurring family of events — `KXPRES` for presidential +elections, `KXNYRAIN` for rain in NYC, etc. List, fetch, inspect fee history, +pull event/forecast candlesticks. + +## Quick reference + +| Method | Endpoint | Auth | +|---|---|---| +| `list(*, category, tags, include_product_metadata, include_volume, min_updated_ts)` | `GET /series` | no | +| `get(series_ticker, *, include_volume=None)` | `GET /series/{series_ticker}` | no | +| `fee_changes(*, series_ticker=None, show_historical=None)` | `GET /series/fee_changes` | no | +| `event_candlesticks(series_ticker, ticker, *, start_ts, end_ts, period_interval)` | `GET /series/{series_ticker}/events/{ticker}/candlesticks` | no | +| `forecast_percentile_history(series_ticker, ticker, *, percentiles, start_ts, end_ts, period_interval)` | `GET /series/{series_ticker}/events/{ticker}/forecast_percentile_history` | **yes** | + +!!! note "`series.list()` returns a flat list, not a Page" + Series are a small finite set; the endpoint doesn't paginate. + +## List series + +```python +all_series = client.series.list( + category="Elections", + tags=["2024"], + include_product_metadata=True, + include_volume=True, +) +for s in all_series: + print(s.series_ticker, s.title) +``` + +## Get one series + +```python +s = client.series.get("KXPRES", include_volume=True) +print(s.title, s.volume) +``` + +## Fee history + +```python +changes = client.series.fee_changes(series_ticker="KXPRES", show_historical=True) +for ch in changes: + print(ch.effective_ts, ch.maker_fee, ch.taker_fee) +``` + +## Event candlesticks + +Note: the second path parameter is an **event** ticker, not a market ticker. + +```python +candles = client.series.event_candlesticks( + series_ticker="KXPRES", + ticker="KXPRES-24", # event ticker + start_ts=1_700_000_000, + end_ts=1_700_100_000, + period_interval=3600, +) +``` + +## Forecast percentile history + +Auth-required. Returns historical percentile points for a forecasted event. + +```python +hist = client.series.forecast_percentile_history( + series_ticker="KXTEMP", + ticker="KXTEMP-24-DEC", # event ticker + percentiles=[25, 50, 75], # explode form on the wire + start_ts=1_700_000_000, + end_ts=1_700_100_000, + period_interval=86400, +) +for point in hist.forecast_percentiles: + print(point.ts, point.percentile, point.value) +``` + +`percentiles` serializes as `?percentiles=25&percentiles=50&percentiles=75` +(explode form, unlike `tickers` on other endpoints). + +## Reference + +::: kalshi.resources.series.SeriesResource + options: + heading_level: 3 + +::: kalshi.resources.series.AsyncSeriesResource + options: + heading_level: 3 diff --git a/docs/resources/structured-targets.md b/docs/resources/structured-targets.md new file mode 100644 index 0000000..3d3818d --- /dev/null +++ b/docs/resources/structured-targets.md @@ -0,0 +1,58 @@ +# Structured targets + +A **structured target** is the entity a market is "about" — a team, player, +candidate, company. Two markets pointing at the same Yankees roster share a +structured target id, so you can group markets by underlying entity. + +Public — no auth required. + +## Quick reference + +| Method | Endpoint | +|---|---| +| `list(...)` / `list_all(...)` | `GET /structured_targets` | +| `get(structured_target_id)` | `GET /structured_targets/{id}` | + +## List structured targets + +```python +page = client.structured_targets.list( + target_type="team", # spec field "type"; renamed + competition="MLB", + page_size=500, # 1–2000, default 100 — note: not `limit` + ids=["st_abc", "st_def"], # bulk lookup +) +for t in page: + print(t.structured_target_id, t.name, t.target_type) +``` + +!!! info "`page_size`, not `limit`" + Unlike every other paginated endpoint, this one uses `page_size`. Range: + 1–2000. Default 100. The SDK accepts a `cursor` for pagination as usual. + +!!! info "`target_type` is the SDK's name for spec `type`" + Same renaming logic as [milestones](milestones.md) — avoids shadowing + the Python builtin. The wire still sends `type=`. + +## Get one structured target + +```python +t = client.structured_targets.get("st_abc") +if t is None: + print("not found") +else: + print(t.name, t.target_type) +``` + +`get()` may return `None` for unknown IDs (the underlying endpoint returns a +404 that's mapped to `None` rather than `KalshiNotFoundError`). + +## Reference + +::: kalshi.resources.structured_targets.StructuredTargetsResource + options: + heading_level: 3 + +::: kalshi.resources.structured_targets.AsyncStructuredTargetsResource + options: + heading_level: 3 diff --git a/docs/resources/subaccounts.md b/docs/resources/subaccounts.md new file mode 100644 index 0000000..e313ab4 --- /dev/null +++ b/docs/resources/subaccounts.md @@ -0,0 +1,92 @@ +# Subaccounts + +Logical wallet partitions under your main account. Subaccount `0` is your +primary; `1`–`32` are numbered extras. Auth required throughout. + +## Quick reference + +| Method | Endpoint | +|---|---| +| `create()` | `POST /portfolio/subaccounts` | +| `transfer(*, client_transfer_id, from_subaccount, to_subaccount, amount_cents)` | `POST /portfolio/subaccounts/transfer` | +| `list_balances()` | `GET /portfolio/subaccounts/balances` | +| `list_transfers(*, cursor=None, limit=None)` | `GET /portfolio/subaccounts/transfers` | +| `list_all_transfers(*, limit=None, max_pages=None)` | walks `list_transfers` | +| `update_netting(*, subaccount_number, enabled)` | `PUT /portfolio/subaccounts/netting` | +| `get_netting()` | `GET /portfolio/subaccounts/netting` | + +## Create a subaccount + +```python +resp = client.subaccounts.create() +print(resp.subaccount_number) +``` + +The body is intentionally empty — the SDK sends `json={}` to force a JSON +content-type so demo doesn't reject the call. + +## Transfer between subaccounts + +`amount_cents` is **integer cents**. `client_transfer_id` is a UUID — the +idempotency key for the transfer: + +```python +import uuid + +resp = client.subaccounts.transfer( + client_transfer_id=uuid.uuid4(), # or str + from_subaccount=0, # primary + to_subaccount=1, + amount_cents=500, # $5.00 +) +print(resp.transfer_id) +``` + +`client_transfer_id` accepts a `UUID` or a `str`. On a network failure, retry +with the same id; the server dedupes. List transfers to reconcile (see +below). + +## List balances + +```python +resp = client.subaccounts.list_balances() +for bal in resp.balances: + print(bal.subaccount_number, bal.balance, bal.portfolio_value, bal.updated_ts) +``` + +`bal.balance` and `bal.portfolio_value` are **integer cents**. +`bal.updated_ts` is Unix seconds (not ISO datetime). + +## List transfers + +```python +page = client.subaccounts.list_transfers(limit=100) +for t in page: + print(t.transfer_id, t.amount_cents, t.from_subaccount, t.to_subaccount) + +for t in client.subaccounts.list_all_transfers(): + ... +``` + +Standard `Page[SubaccountTransfer]` pagination. `t.created_ts` is Unix +seconds. + +## Netting + +Netting offsets positions across subaccounts so they consume one margin pool. + +```python +client.subaccounts.update_netting(subaccount_number=1, enabled=True) +config = client.subaccounts.get_netting() +print(config.netting_enabled_subaccounts) +``` + +## Reference + +::: kalshi.resources.subaccounts.SubaccountsResource + options: + heading_level: 3 + +::: kalshi.resources.subaccounts.AsyncSubaccountsResource + options: + heading_level: 3 diff --git a/docs/retries.md b/docs/retries.md new file mode 100644 index 0000000..b4e3c42 --- /dev/null +++ b/docs/retries.md @@ -0,0 +1,167 @@ +# Retries & idempotency + +Retries are conservative by design: the wrong retry on the wrong verb is how +duplicate orders happen. + +## What retries + +| Verb | Retries on | +|---|---| +| `GET`, `HEAD`, `OPTIONS` | `429`, `500`, `502`, `503`, `504` | +| `POST`, `DELETE`, `PUT`, `PATCH` | **never** | + +POST/DELETE/PUT are write paths. Retrying them risks duplicate orders, +duplicate cancels, duplicate transfers — even with `client_order_id`, you +still need to know whether the first attempt landed. The SDK forces that +decision back to your application code. + +What does **not** retry, even on a retryable verb: + +- `400`, `401`, `403`, `404` — permanent client errors. Fix the request. +- Non-timeout transport errors (DNS resolution failure, TLS handshake, + connection reset) — raised immediately as `KalshiError`. +- Pydantic response-validation failure — raised immediately. + +## Backoff + +When a retry is scheduled, the wait time uses **AWS Full Jitter**: + +``` +sleep = random.uniform(0, min(retry_base_delay * 2 ** attempt, retry_max_delay)) +``` + +Full jitter spreads colliding clients evenly across the whole capped window — +the opposite of fixed-magnitude jitter, which bunches retries into a narrow +sub-window and amplifies thundering-herd patterns. + +With defaults (`retry_base_delay=0.5`, `retry_max_delay=30.0`, +`max_retries=3`), worst-case sleeps are: + +| Attempt | Cap (s) | Range (s) | +|---|---|---| +| 0 | 0.5 | 0 – 0.5 | +| 1 | 1.0 | 0 – 1.0 | +| 2 | 2.0 | 0 – 2.0 | + +Total worst-case retry wait: **~3.5 seconds** with defaults. Three attempts +plus the original = four requests max. + +## `Retry-After` handling + +On `429`, the server's `Retry-After` header is honored — **capped** at +`retry_max_delay`. The cap is a safety: a hostile or misconfigured server +can't stall the client with `Retry-After: 99999`. + +The header is only honored when it parses as a non-negative finite number. +`Retry-After: 0` is honored (sleeps 0). HTTP-date form, `NaN`, negatives, and +non-numeric values fall back to the computed full-jitter backoff. + +When retries are exhausted, the last `KalshiRateLimitError` is raised with +`.retry_after` populated from the header — so application-level retry logic +can see the hint. + +## Timeouts + +`KalshiConfig.timeout` is a single float that maps to `httpx.Timeout(timeout)` +— it applies to connect, read, write, and pool together. There is no per-phase +configuration today; pass a single value or accept the 30-second default. + +- Timeout on a retryable verb (`GET`/`HEAD`/`OPTIONS`) retries with the same + backoff schedule. The final timeout is wrapped in `KalshiError` with + `__cause__` set to the underlying `httpx.TimeoutException`. +- Timeout on `POST`/`DELETE` raises `KalshiError` immediately, no retry. **The + request may or may not have landed** — reconcile with a follow-up `get` or + `list`. + +## Tuning + +```python +from kalshi import KalshiClient, KalshiConfig + +config = KalshiConfig( + timeout=10.0, + max_retries=5, + retry_base_delay=0.25, + retry_max_delay=15.0, +) +client = KalshiClient(key_id="...", private_key_path="...", config=config) +``` + +There are no environment variables for these knobs — pass a `KalshiConfig` +explicitly. + +## Idempotency + +POST/DELETE/PUT not retrying means **idempotency is your responsibility** on +write paths. The patterns that work: + +### `client_order_id` on `orders.create` / `orders.amend` + +Set a unique `client_order_id` (a UUID is fine). On a network failure between +"server processed the order" and "you got the response": + +1. Catch the exception. +2. Call `client.orders.list(min_ts=..., max_ts=...)` and look for the + `client_order_id` you supplied. +3. If it's there, the order landed. If it isn't, retry safely. + +```python +import uuid +from kalshi import KalshiClient, KalshiError + +with KalshiClient.from_env() as client: + cid = str(uuid.uuid4()) + try: + order = client.orders.create( + ticker="X", side="yes", action="buy", count=10, + yes_price="0.65", client_order_id=cid, + ) + except KalshiError: + # Did it land? Find out before retrying. + existing = next( + (o for o in client.orders.list_all() if o.client_order_id == cid), + None, + ) + if existing is None: + order = client.orders.create( + ticker="X", side="yes", action="buy", count=10, + yes_price="0.65", client_order_id=cid, + ) + else: + order = existing +``` + +### `client_transfer_id` on `subaccounts.transfer` + +Same pattern as `client_order_id`, but for inter-subaccount money movement. +Reconcile via `subaccounts.list_transfers()`. + +### Cancels + +`client.orders.cancel(order_id)` and `client.orders.batch_cancel(...)` are +already idempotent server-side: re-canceling a canceled order is a no-op. You +can safely retry these from your app layer without dedupe logic. + +### Reads + +Just call them again. The transport's built-in retry covers transient 5xx. + +## Reconciliation after a 5xx on a write + +`KalshiServerError` from `POST /portfolio/orders` is ambiguous — the order +might have made it into the book before the server gave up. The recovery: + +1. Wait briefly (a few seconds — orders propagate fast). +2. List recent orders matching your `client_order_id`. +3. If present, you're done. If absent, retry with the same `client_order_id`. + +The `client_order_id` round-trips on `Order.client_order_id`, so step 2 is +just a `next(...)` over `client.orders.list_all(status="resting")`. + +## WebSocket retries + +The WebSocket has its own retry budget: `KalshiConfig.ws_max_retries` (default +10). Same full-jitter formula, same `retry_base_delay` / `retry_max_delay` +caps. If the budget runs out, the receive loop pushes sentinels to every +active iterator (so `async for` terminates cleanly) and the connection state +ends at `CLOSED`. Call `ws.connect()` again from your app to restart. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..544db05 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,129 @@ +# Testing + +The SDK ships a record/replay test layer that lets you capture real HTTP +traffic once and serve it from disk forever after — no network, no auth, no +flakiness. + +```python +from kalshi.testing import ( + RecordingTransport, + AsyncRecordingTransport, + ReplayTransport, + AsyncReplayTransport, + FixtureNotFoundError, +) +``` + +## Record once, replay forever + +```python +from pathlib import Path +from kalshi import KalshiClient +from kalshi.testing import RecordingTransport, ReplayTransport + +FIXTURES = Path("tests/fixtures/kalshi") + +# Record once against demo (or real) API: +def record() -> None: + with KalshiClient.from_env(transport=RecordingTransport(FIXTURES)) as c: + c.exchange.status() + c.markets.list(status="open", limit=5) + +# Replay in tests — no network, no auth required: +def test_status_offline() -> None: + with KalshiClient(transport=ReplayTransport(FIXTURES)) as c: + assert c.exchange.status().exchange_active +``` + +Async mirror with `AsyncRecordingTransport` / `AsyncReplayTransport` and +`AsyncKalshiClient`. + +## How matching works + +Requests are fingerprinted as **HTTP method + URL path + sorted query +parameters**. Two things are deliberately ignored: + +- The request body. Two POSTs with different bodies to the same path replay + the same fixture. +- The `KALSHI-ACCESS-SIGNATURE` and `KALSHI-ACCESS-TIMESTAMP` headers. Your + replays don't need a valid key; the signature drift between record and + replay never causes a miss. + +## On-disk layout + +Each `(method, path)` pair gets one JSON file: + +``` +fixtures/ + GET_trade-api_v2_exchange_status.json + GET_trade-api_v2_markets.json + POST_trade-api_v2_portfolio_orders.json +``` + +Each file holds a list of `{request, response}` pairs. Replays cycle through +the list in FIFO order; when exhausted, they wrap to the first entry. This +lets you record the same call twice with different responses (e.g. +pre-and-post-order state). + +## Re-entering a fixtures dir + +Recording into an existing dir **appends** pairs to the right file rather +than overwriting it. If you want a clean re-record, delete the dir first. + +## Misses + +```python +def test_unknown_endpoint() -> None: + with KalshiClient(transport=ReplayTransport(FIXTURES)) as c: + try: + c.markets.get("UNRECORDED-TICKER") + except FixtureNotFoundError as e: + print("missing:", e) +``` + +`FixtureNotFoundError` (a `LookupError` subclass) signals a request that has +no matching fixture file. Catch it in tests where you expect uncovered paths. + +## When to re-record + +Re-record after: + +- An SDK upgrade that adds new request kwargs or response fields. +- A wire-spec drift (Kalshi adds a field; your fixtures are now stale). +- Any test that fails because the model can no longer parse the recorded body. + +The contract-drift tests in CI catch model/spec misalignment; recorded +fixtures are a separate caching layer below that. + +## Security + +Recorded fixtures contain the **full** response body returned by Kalshi — +balances, positions, order history, anything else the API returns: + +!!! danger "Treat fixture directories like secrets unless scrubbed" + Always add the fixtures directory to `.gitignore` unless you've manually + scrubbed the JSON. Prefer recording against the demo environment so the + captured account state isn't real money. + +## Mocking with `respx` (alternative) + +If you don't want a disk fixture layer, the SDK accepts any +`httpx.BaseTransport`, so `respx.mock` works too: + +```python +import respx +from kalshi import KalshiClient + +with respx.mock(base_url="https://api.elections.kalshi.com") as router: + router.get("/trade-api/v2/exchange/status").respond(200, json={"exchange_active": True}) + with KalshiClient(transport=router) as client: + assert client.exchange.status().exchange_active +``` + +`kalshi.testing` is the better choice when you want to capture real responses +end-to-end; `respx` is the better choice when you want fully synthetic +responses with no record step. + +## Reference + +::: kalshi.testing diff --git a/docs/types.md b/docs/types.md new file mode 100644 index 0000000..02e6763 --- /dev/null +++ b/docs/types.md @@ -0,0 +1,106 @@ +# Types & literals + +The SDK exports a handful of helper types from `kalshi.types` and a family of +`Literal` aliases from `kalshi` (re-exported from `kalshi.models`). + +## Money + +### `DollarDecimal` + +Used for every price field (`yes_bid`, `yes_ask`, `no_bid`, `no_ask`, +`yes_price`, `no_price`, `target_cost`, …). + +- **On input** — accepts `Decimal`, `int`, `float`, or `str`. Anything that + isn't one of those raises `TypeError`. Floats are normalized via `str(value)` + so `Decimal(0.65)` rounding artifacts never make it to the wire. +- **On serialize** — emits a plain `str(decimal)`. +- **Recommended input** — strings (`"0.65"`) or `Decimal("0.65")`. Float works + but `Decimal` is the canonical form. + +```python +from decimal import Decimal +from kalshi import CreateOrderRequest + +CreateOrderRequest(ticker="X", side="yes", action="buy", count=1, yes_price="0.65") +CreateOrderRequest(ticker="X", side="yes", action="buy", count=1, yes_price=Decimal("0.65")) +``` + +The Kalshi API uses **FixedPointDollars** strings (`"0.5600"`) with up to six +decimal places. Field names on the wire have a `_dollars` suffix +(`yes_bid_dollars`). The SDK's Pydantic models accept both the short and long +form on input (`validation_alias=AliasChoices("yes_bid_dollars", "yes_bid")`) +and emit the long form on output where required. + +### `FixedPointCount` + +Same machinery as `DollarDecimal`, used for count-like fields the API +fixed-points (e.g. queue position, `count_fp`). You shouldn't need to touch it +directly. + +### Integer cents + +Some fields are plain `int` cents, **not** `DollarDecimal`: + +| Field | Where | Unit | +|---|---|---| +| `Balance.balance` / `portfolio_value` | `client.portfolio.balance()` | 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. + +## Nullable lists + +`NullableList[T]` coerces a server `null` into `[]`. Used on response fields +like `Orderbook.yes`, `Orderbook.no`, and `MarketCandlesticks.candlesticks`, +where the server may omit the array entirely on empty results. You read these +as plain `list[T]`. + +## Literal aliases + +Every enum-like kwarg uses a `Literal` alias so your IDE auto-completes and +`mypy` rejects typos. + +| Alias | Values | +|---|---| +| `SideLiteral` | `"yes"`, `"no"` | +| `ActionLiteral` | `"buy"`, `"sell"` | +| `TimeInForceLiteral` | `"fill_or_kill"`, `"good_till_canceled"`, `"immediate_or_cancel"` | +| `SelfTradePreventionTypeLiteral` | `"taker_at_cross"`, `"maker"` | +| `OrderStatusLiteral` | `"resting"`, `"canceled"`, `"executed"` | +| `MarketStatusLiteral` | `"unopened"`, `"open"`, `"paused"`, `"closed"`, `"settled"` | +| `EventStatusLiteral` | `"unopened"`, `"open"`, `"closed"`, `"settled"` | +| `MveFilterLiteral` | `"only"`, `"exclude"` | +| `MveHistoricalFilterLiteral` | `"exclude"` | +| `MultivariateCollectionStatusLiteral` | `"unopened"`, `"open"`, `"closed"` | +| `SettlementStatusLiteral` | `"all"`, `"unsettled"`, `"settled"` | +| `IncentiveProgramStatusLiteral` | `"all"`, `"active"`, `"upcoming"`, `"closed"`, … | +| `IncentiveProgramTypeLiteral` | `"all"`, `"liquidity"`, `"volume"` | + +!!! tip "Markets pause; events don't" + `MarketStatusLiteral` has a `"paused"` value that `EventStatusLiteral` + lacks. Filtering events by status will never see `"paused"`. + +All aliases are exported from the top-level `kalshi` package: + +```python +from kalshi import SideLiteral, ActionLiteral, TimeInForceLiteral + +def place(side: SideLiteral, action: ActionLiteral, tif: TimeInForceLiteral) -> None: + ... +``` + +## Wire-format suffixes + +When you crack open a model and see a long alias on a field, here's what each +suffix means: + +| Suffix | Wire type | SDK type | +|---|---|---| +| `_dollars` | FixedPointDollars string (e.g. `"0.5600"`) | `Decimal` via `DollarDecimal` | +| `_fp` | Integer fixed-point | `Decimal` via `FixedPointCount` | +| `_ts` | Unix seconds (int) | `int` | +| `_at` / no suffix | ISO-8601 datetime string | `datetime` | + +A few legacy fields use `_cents`; those are plain `int` cents. diff --git a/docs/websockets.md b/docs/websockets.md index 1fdc8e3..9674713 100644 --- a/docs/websockets.md +++ b/docs/websockets.md @@ -1,4 +1,4 @@ -# WebSocket Streaming +# WebSocket The SDK ships an async-only WebSocket client, `KalshiWebSocket`, that covers all 11 Kalshi channels. It handles RSA-PSS auth on the upgrade handshake, @@ -6,9 +6,8 @@ per-subscription sequence-gap detection, automatic reconnection with re-subscription, and a configurable backpressure strategy on each per-channel queue. -There is no sync WebSocket client. The Kalshi API is push-driven and live -data is fundamentally async — wrap it in `asyncio.run(...)` if you need to -call it from sync code. +There is no sync WebSocket client. Wrap calls in `asyncio.run(...)` from sync +code. The wire protocol is documented in the [AsyncAPI spec](https://docs.kalshi.com/asyncapi.yaml). This page is the @@ -20,33 +19,39 @@ SDK's perspective on it. - A `connect()` async context manager that opens the underlying socket and starts the background receive loop. -- A typed `subscribe_()` method per channel, each returning an - async iterator of fully-parsed Pydantic messages. -- A generic `subscribe(channel, params=...)` for forward compatibility. -- An `on(channel)` decorator for a callback-style API. -- An `orderbook(ticker)` helper that yields a maintained `Orderbook` - snapshot on every delta. - -The 11 channels: - -| Channel | Auth | Per-message | Description | -|---|---|---|---| -| `ticker` | public | `TickerMessage` | Best bid/ask + volume per market. | -| `trade` | public | `TradeMessage` | Executed prints. | -| `orderbook_delta` | public | `OrderbookSnapshotMessage` then `OrderbookDeltaMessage` | Full L2 book deltas, sequenced. | -| `market_lifecycle_v2` | public | `MarketLifecycleMessage` | Market open/close/settle transitions. | -| `multivariate` | public | `MultivariateMessage` | Multivariate market events. | -| `multivariate_market_lifecycle` | public | `MultivariateLifecycleMessage` | Multivariate market lifecycle. | -| `fill` | private | `FillMessage` | Your fills. | -| `user_orders` | private | `UserOrdersMessage` | Your order status changes. | -| `market_positions` | private | `MarketPositionsMessage` | Your position deltas. | -| `order_group_updates` | private | `OrderGroupMessage` | Order-group state changes, sequenced. | -| `communications` | private | `CommunicationsMessage` | RFQ / quote events. | - -`subscribe_market_lifecycle()`, `subscribe_order_group()`, -`subscribe_multivariate_lifecycle()` map to the wire channel names in the -table above. The SDK method names drop the `_v2` / `_updates` / -`_market_lifecycle` suffixes for ergonomics. +- A typed `subscribe_()` method per channel, each returning an async + iterator of fully-parsed Pydantic messages. +- A generic `subscribe(channel, params=..., overflow=..., maxsize=...)` for + forward compatibility. +- An `@ws.on(channel)` decorator for a callback-style API (which **fans out + alongside** iterators rather than replacing them). +- An `orderbook(ticker)` helper that yields a maintained `Orderbook` snapshot + on every delta. +- `on_state_change=` and `on_error=` hooks on the constructor for observability. + +## The 11 channels + +| SDK method | Wire channel | Message `type` field | Message class | Auth | +|---|---|---|---|---| +| `subscribe_ticker` | `ticker` | `ticker` | `TickerMessage` | public | +| `subscribe_trade` | `trade` | `trade` | `TradeMessage` | public | +| `subscribe_orderbook_delta` | `orderbook_delta` | `orderbook_snapshot` → `orderbook_delta` | `OrderbookSnapshotMessage` / `OrderbookDeltaMessage` | public | +| `subscribe_market_lifecycle` | `market_lifecycle_v2` | `market_lifecycle_v2` | `MarketLifecycleMessage` | public | +| `subscribe_multivariate` | `multivariate` | `multivariate_lookup` | `MultivariateMessage` | public | +| `subscribe_multivariate_lifecycle` | `multivariate_market_lifecycle` | `multivariate_market_lifecycle` | `MultivariateLifecycleMessage` | public | +| `subscribe_fill` | `fill` | `fill` | `FillMessage` | private | +| `subscribe_user_orders` | `user_orders` | `user_order` (singular) | `UserOrdersMessage` | private | +| `subscribe_market_positions` | `market_positions` | `market_position` (singular) | `MarketPositionsMessage` | private | +| `subscribe_order_group` | `order_group_updates` | `order_group_updates` | `OrderGroupMessage` | private | +| `subscribe_communications` | `communications` | `communications` | `CommunicationsMessage` | private | + +The `type` column matters when filtering raw logs — note the singular forms +for `user_order`, `market_position`, and the `multivariate_lookup` / +`multivariate` mismatch. + +Two channels carry monotonic `seq` numbers and have built-in sequence-gap +recovery: `orderbook_delta` (which delivers both snapshot and delta envelopes +under one subscription) and `order_group_updates`. ## Connect and subscribe @@ -74,7 +79,8 @@ open. Exiting the block sends graceful sentinels to all active iterators and closes the socket with code 1000. `subscribe_*` methods return an async iterator. Iterate it directly with -`async for`; the iterator stops when the socket closes. +`async for`; the iterator stops when the socket closes or the subscription is +torn down. You can hold multiple subscriptions in parallel — each has its own bounded queue, and the background receive loop fans messages out: @@ -97,9 +103,8 @@ async with ws.connect() as session: ### Callback style -If you'd rather register handlers than iterate, use `ws.on(channel)`. The -message passed to your callback is the typed Pydantic model for that channel -— `TickerMessage` here, `FillMessage` for `fill`, etc. — not a raw `dict`. +Register handlers with `@ws.on(channel)`. The message passed to your callback +is the typed Pydantic model for that channel. ```python from kalshi.ws.models import TickerMessage @@ -117,132 +122,65 @@ async with ws.connect(): `on()` works both before and after `connect()`; callbacks registered before the socket opens are buffered and applied when the session starts. -Registering a callback for a channel **takes over routing** for that channel — -messages on that channel won't appear in an iterator returned by -`subscribe_()`. Pick one style per channel. +!!! info "Callbacks fan out, they don't replace iterators" + When a callback is registered for a channel that also has an active + `subscribe_*` iterator, **both** the callback and the iterator receive the + message. A warning is logged so you know it's happening. If you want + callback-only routing, don't call `subscribe_*` on the same channel. -## Channel reference +### Generic `subscribe()` -Each `subscribe_*` method returns an async iterator of one Pydantic message -type. Messages have a uniform envelope: `type: str`, `sid: int` (server -subscription id), `seq: int | None`, and `msg: `. - -### `subscribe_ticker(tickers=...)` - -Yields [`TickerMessage`](reference.md). `tickers` filters to a market list; -omit it for the full-firehose. Latest-wins channel — the SDK's queue uses -`DROP_OLDEST` so a slow consumer falls behind silently instead of erroring. - -### `subscribe_trade(tickers=...)` - -Yields [`TradeMessage`](reference.md). Each print: ticker, side, count, price, -ts. `DROP_OLDEST` overflow. - -### `subscribe_orderbook_delta(tickers=...)` - -Yields [`OrderbookSnapshotMessage`](reference.md) (initial; one per ticker -when subscribing) then [`OrderbookDeltaMessage`](reference.md) updates. -**Sequenced** — gaps trigger an automatic resync (see below). -`ERROR` overflow because deltas are stateful — silently dropping one -corrupts the book. - -If you want full books rather than raw deltas: +For channels the SDK adds later than your installed version, the generic +escape hatch is: ```python -async with ws.connect() as session: - async for book in await session.orderbook("KXPRES-24-DJT"): - print(book.yes[0], book.no[0]) +stream = await session.subscribe( + "some_new_channel", + params={"market_tickers": [...]}, +) +async for raw in stream: + ... # raw is a dict; you parse it ``` -`orderbook()` wraps `subscribe_orderbook_delta`, applies deltas to an -internal `OrderbookManager`, and yields a fresh `Orderbook` on each update. - -### `subscribe_fill()` - -Yields [`FillMessage`](reference.md). Private — requires auth. Fired when one -of your orders fills. `DROP_OLDEST`. - -### `subscribe_user_orders()` - -Yields [`UserOrdersMessage`](reference.md). Private. Order lifecycle -transitions: `resting`, `canceled`, `executed`, etc. - -### `subscribe_market_positions()` - -Yields [`MarketPositionsMessage`](reference.md). Private. Your aggregate -position per market. - -### `subscribe_order_group()` - -Yields [`OrderGroupMessage`](reference.md). Private, **sequenced**. State -changes on order groups. `ERROR` overflow. - -### `subscribe_market_lifecycle(tickers=...)` - -Yields [`MarketLifecycleMessage`](reference.md). Market created, opened, -closed, settled. `DROP_OLDEST`. - -### `subscribe_multivariate()` - -Yields [`MultivariateMessage`](reference.md). Multivariate market updates. -`DROP_OLDEST`. - -### `subscribe_multivariate_lifecycle()` - -Yields [`MultivariateLifecycleMessage`](reference.md). Lifecycle for -multivariate markets. `DROP_OLDEST`. - -### `subscribe_communications(shard_factor=..., shard_key=...)` - -Yields [`CommunicationsMessage`](reference.md). RFQ / quote lifecycle. -`shard_factor` / `shard_key` let you partition the stream across consumers. -`DROP_OLDEST`. - -The Pydantic models live in -[`kalshi.ws.models`](https://github.com/TexasCoding/kalshi-python-sdk/tree/main/kalshi/ws/models). -Field aliases match the AsyncAPI wire format (`yes_bid_dollars` → -`yes_bid: Decimal`, `volume_fp` → `volume: str`, etc.). +Only these param keys are forwarded to the server (others are silently +dropped): `market_ticker`, `market_tickers`, `market_id`, `market_ids`, +`shard_factor`, `shard_key`, `send_initial_snapshot`, `skip_ticker_ack`. ## Sequence-gap detection -The `orderbook_delta` subscription (which delivers both snapshot and delta -messages) and `order_group_updates` carry monotonic `seq` numbers. The SDK -tracks the last `seq` per `sid` and flags a gap when it sees `seq > last + 1`. +`orderbook_delta` and `order_group_updates` messages carry a monotonic `seq`. +The SDK tracks the last `seq` per server `sid` and flags a gap when it sees +`seq > last + 1`. When a gap is detected: 1. The offending message is **dropped** without being dispatched. -2. For `orderbook_delta`, the SDK clears the affected ticker's local book - and the per-`sid` sequence tracker — the next snapshot from the server - re-bootstraps it. -3. Duplicates (`seq <= last`) are silently ignored. +2. The per-`sid` sequence tracker is reset, and for `orderbook_delta` the + local book for the affected ticker is cleared. +3. The next server snapshot rebootstraps state. +4. Duplicates (`seq <= last`) are silently ignored. -If recovery never lands — e.g. the server stops sending the channel — your -iterator stays open but produces nothing. The `KalshiSequenceGapError` -exception class exists in the hierarchy for callers that want to wire their -own resync logic via `subscribe(channel, ...)` against a custom tracker, but -the default path is silent recovery; it is not raised by the built-in -managers today. +The built-in receive loop **does not raise** `KalshiSequenceGapError` — it +recovers silently. The exception class exists for callers wiring their own +resync logic on top of `subscribe(channel, ...)` against a custom tracker. -The list of sequenced channels lives in -[`SEQUENCED_CHANNELS`](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/kalshi/ws/sequence.py) -— add your own resync hook there if you fork the client. +If recovery never lands — e.g. the server stops sending the channel — your +iterator stays open but produces nothing. Watch +[connection state](#connection-state) for clues. ## Backpressure -Every per-channel iterator is fed by a bounded `MessageQueue` (default -`maxsize=1000`). What happens when the queue fills depends on -`OverflowStrategy`: +Every per-channel iterator is fed by a bounded `MessageQueue`. What happens +when the queue fills depends on `OverflowStrategy`: -| Strategy | Behavior | SDK default for | +| Strategy | Behavior | Default for | |---|---|---| | `DROP_OLDEST` | Ring-buffer: evict oldest, keep newest. | `ticker`, `trade`, `fill`, `user_orders`, `market_positions`, `market_lifecycle`, `multivariate`, `multivariate_lifecycle`, `communications` | | `ERROR` | Raise `KalshiBackpressureError` from the producer side. | `orderbook_delta`, `order_group_updates` | The choice tracks state semantics: latest-wins channels (`ticker`) survive a -drop; stateful, sequenced channels (`orderbook_delta`) can't — a missed -delta is a corrupted book, which is exactly what sequence-gap detection -catches. +drop; stateful, sequenced channels (`orderbook_delta`) can't — a missed delta +is a corrupted book, which is exactly what sequence-gap detection catches. Override per call: @@ -258,29 +196,55 @@ stream = await session.subscribe( ) ``` -`KalshiBackpressureError` is raised inside the receive loop, logged, and the -loop continues — your iterator keeps yielding (after some lost messages). -If you want hard failure on backpressure, catch it via a custom callback -registered with `ws.on(...)` or watch the connection state via -`on_state_change` on `KalshiWebSocket(...)`. +Default `maxsize=1000` for explicit subscriptions, `100` for the `orderbook()` +helper. + +!!! danger "Backpressure on ERROR channels is fatal" + When `KalshiBackpressureError` fires in the receive loop, it is treated as + **fatal**: the loop broadcasts sentinels to every active iterator and + exits. Your `async for` blocks end via `StopAsyncIteration`. The connection + state moves to `CLOSED`. Wire `on_error=` and `on_state_change=` on the + constructor to observe this. + + The same fatal-teardown behavior applies to `KalshiSubscriptionError` + encountered mid-stream. + +## Orderbook helper + +If you want full books rather than raw deltas: + +```python +async with ws.connect() as session: + async for book in await session.orderbook("KXPRES-24-DJT"): + print(book.yes[0], book.no[0]) +``` + +`orderbook()` wraps `subscribe_orderbook_delta`, applies snapshots/deltas to +an internal `OrderbookManager`, and yields a fresh `kalshi.models.markets.Orderbook` +on each update. Each yielded book is a new instance — your consumer can hold +on to it without worrying about mutation. + +A delta arriving before a snapshot logs a warning and is dropped. A `seq` gap +triggers a snapshot-driven rebuild as described above. ## Reconnection -If the underlying socket drops (server hangup, transient network error, -ping timeout), the receive loop transitions to `RECONNECTING` and retries -the connect with the same exponential-backoff-and-jitter formula as the -REST transport (`retry_base_delay * 2**attempt + jitter`, capped at -`retry_max_delay`), up to `KalshiConfig.ws_max_retries` (default 10). +If the underlying socket drops (server hangup, transient network error, ping +timeout), the receive loop transitions to `RECONNECTING` and retries the +connect with the same full-jitter formula as the REST transport +(`random.uniform(0, min(retry_base_delay * 2 ** attempt, retry_max_delay))`), +up to `KalshiConfig.ws_max_retries` (default 10). On a successful reconnect: 1. All active subscriptions are re-issued. Server `sid`s change; the SDK tracks each subscription by a durable client-side id and rebuilds the - `sid → client_id` map. -2. Sequence trackers are reset (`SequenceTracker.reset_all()`). -3. The local orderbook cache is cleared. `orderbook_delta` subscriptions - are re-issued with `send_initial_snapshot: true` so the book is - re-bootstrapped from a fresh snapshot. + `sid → client_id` map. Per-sub failures are isolated — a failing + resubscribe drops just that one queue, the rest continue. +2. Sequence trackers are reset. +3. The local orderbook cache is cleared. `orderbook_delta` subscriptions are + re-issued with `send_initial_snapshot: true` so the book is re-bootstrapped + from a fresh snapshot. 4. Active iterators keep yielding — they reference the durable client-side ids, not the server `sid`s. @@ -288,7 +252,7 @@ If `ws_max_retries` is exhausted, the receive loop pushes sentinels to all active queues (so `async for` terminates cleanly) and exits. The connection state ends at `CLOSED`. -Wire the `on_state_change` callback to observe transitions: +### Connection state ```python from kalshi.ws import ConnectionState @@ -299,4 +263,48 @@ async def on_state(old: ConnectionState, new: ConnectionState) -> None: ws = KalshiWebSocket(auth=auth, config=config, on_state_change=on_state) ``` -See `kalshi.ws.ConnectionState` for the full state machine. +Possible states: `DISCONNECTED`, `CONNECTING`, `CONNECTED`, `STREAMING`, +`RECONNECTING`, `CLOSED`. + +### Heartbeat + +Heartbeat uses `websockets`' built-in keepalive: `ping_interval=20`, +`ping_timeout=heartbeat_timeout` (constructor arg, default 30s). A missed pong +trips reconnect. + +## Error observability + +```python +from kalshi.ws.models import ErrorMessage + +async def on_error(err: ErrorMessage) -> None: + print("WS error:", err.msg.code, err.msg.message) + +ws = KalshiWebSocket(auth=auth, config=config, on_error=on_error) +``` + +The `on_error` hook receives both server-sent error envelopes and synthesized +errors from internal failures (e.g. unknown message types, dispatch +exceptions). Pair it with `on_state_change` for a complete picture of session +health. + +## Auth on the WebSocket + +`KalshiWebSocket` signs an RSA-PSS `GET` against the WebSocket URL's path and +sends the signature as headers on the upgrade handshake — same scheme as +REST. There's no token in the URL, no signed message after open. The signature +is re-computed on every reconnect attempt. + +Public channels (ticker, trade, orderbook_delta, market_lifecycle, +multivariate, multivariate_lifecycle) work without auth — pass +`auth=None` if you don't need private channels. + +## Reference + +::: kalshi.ws.client.KalshiWebSocket + +::: kalshi.ws.connection.ConnectionState + +::: kalshi.ws.backpressure.OverflowStrategy + +::: kalshi.ws.backpressure.MessageQueue diff --git a/mkdocs.yml b/mkdocs.yml index c241875..61e0ce8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,12 +5,16 @@ repo_url: https://github.com/TexasCoding/kalshi-python-sdk repo_name: TexasCoding/kalshi-python-sdk edit_uri: edit/main/docs/ +not_in_nav: | + /AUDIT-resource-params.md + /RELEASING.md + theme: name: material features: - navigation.sections - - navigation.expand - navigation.top + - navigation.indexes - content.code.copy - content.action.edit - search.suggest @@ -41,24 +45,63 @@ plugins: separate_signature: true docstring_style: google merge_init_into_class: true + members_order: source + filters: + - "!^_" markdown_extensions: - admonition + - attr_list + - md_in_html + - tables - pymdownx.details - pymdownx.superfences - pymdownx.highlight: anchor_linenums: true - pymdownx.inlinehilite - pymdownx.snippets + - pymdownx.tabbed: + alternate_style: true - toc: permalink: true nav: - Home: index.md - - Getting Started: getting-started.md - - Authentication: authentication.md - - Resources: resources/index.md - - WebSocket Streaming: websockets.md - - Error Handling: errors.md + - Getting Started: + - Quickstart: getting-started.md + - Concepts: concepts.md + - Authentication: authentication.md + - Configuration: configuration.md + - Environment variables: environment-variables.md + - Core: + - Pagination: pagination.md + - Types & literals: types.md + - Request models: request-models.md + - DataFrames: dataframes.md + - Errors: errors.md + - Retries & idempotency: retries.md + - Resources: + - Overview: resources/index.md + - Markets: resources/markets.md + - Events: resources/events.md + - Series: resources/series.md + - Exchange: resources/exchange.md + - Historical: resources/historical.md + - Search: resources/search.md + - Orders: resources/orders.md + - Portfolio: resources/portfolio.md + - Order groups: resources/order-groups.md + - Account: resources/account.md + - API keys: resources/api-keys.md + - Subaccounts: resources/subaccounts.md + - FCM: resources/fcm.md + - Communications (RFQ/Quote): resources/communications.md + - Multivariate: resources/multivariate.md + - Milestones: resources/milestones.md + - Structured targets: resources/structured-targets.md + - Incentive programs: resources/incentive-programs.md + - Live data: resources/live-data.md + - WebSocket: websockets.md + - Testing: testing.md - Migration: migration.md - API Reference: reference.md