diff --git a/CHANGELOG.md b/CHANGELOG.md index 657705e..ffeafd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,51 @@ All notable changes to kalshi-sdk will be documented in this file. +## 4.0.0 — 2026-06-09 + +Spec-drift reconciliation against the latest upstream OpenAPI (3.20.0) and AsyncAPI +specs (closes #443). Includes one **breaking** change: the Self-Clearing-Member +"Klear" API migrated from cookie-session login to Bearer-token auth. Everything +else is additive. + +### Breaking + +- **Klear (SCM) auth is now a Bearer token.** Upstream removed `POST /log_in` and + switched the Klear API to `Authorization: Bearer :`. + `KlearClient` / `AsyncKlearClient` now require `admin_user_id` and `access_token` + at construction (or via `from_env()`, which reads `KALSHI_KLEAR_ADMIN_USER_ID` / + `KALSHI_KLEAR_ACCESS_TOKEN`). Removed: the `login()` method, the + `is_authenticated` property, the `client.auth` resource, and the `LogInRequest` / + `LogInResponse` models. `KlearAuth` is now a Bearer-credential holder + (`KlearAuth(admin_user_id, access_token)`). Generate a token at + (the "Security" page). + +### Added + +- **`cfbenchmarks_value` WebSocket channel** — stream CF Benchmarks reference index + values (e.g. `BRTI`) with trailing 60-second and final-minute quarter-hour + averages via `KalshiWebSocket.subscribe_cfbenchmarks_value(index_ids=[...])`. New + models `CFBenchmarksValueMessage` / `CFBenchmarksValuePayload` / + `CFBenchmarksAvgData` / `CFBenchmarksIndexListMessage` / + `CFBenchmarksIndexListPayload` (exported from `kalshi.ws.models`). +- **`AccountResource.upgrade()`** — `POST /account/api_usage_level/upgrade` to + request a permanent Advanced API usage-level grant. +- **`AccountApiLimits.grants`** — the per-exchange-lane usage-level grant list, plus + a new `ApiUsageLevelGrant` model (`exchange_instance` / `level` / `source` / + `expires_ts`), exported from `kalshi`. +- **`MarginAccountResource.api_limits()`** — `GET /account/limits/perps` for the + Perps API tier limits (reuses `AccountApiLimits`). +- **Perps market notional/leverage fields** — `MarginMarket` gains + `leverage_estimates` and `volume`/`volume_24h`/`open_interest` notional-value + fields; `MarginMarketCandlestick` and the `ticker` WS payload gain notional-value + fields, all tracking the spec. + +### Changed + +- Re-vendored `specs/openapi.yaml`, `specs/asyncapi.yaml`, `specs/perps_openapi.yaml`, + `specs/perps_asyncapi.yaml`, and `specs/perps_scm_openapi.yaml`; the subaccount + range documented in prose is now 1–63 (no validation change). + ## 3.3.0 — 2026-06-06 Adds a complete **FIX protocol** subsystem (FIXT.1.1 / FIX50SP2) for both the diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 2c48c0a..7ebf5f5 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -72,11 +72,13 @@ with KalshiClient.from_env(config=config) as client: ## Klear (SCM) environment variables -`KlearClient` / `AsyncKlearClient` use cookie-session auth (no RSA keys), so they -read only environment selectors: +`KlearClient` / `AsyncKlearClient` use Bearer-token auth (no RSA keys). +`KlearClient.from_env()` reads the credentials plus the routing selectors: | Variable | Default | Effect | |---|---|---| +| `KALSHI_KLEAR_ADMIN_USER_ID` | unset | Klear admin user id (the Bearer `admin_user_id`). Required by `from_env()`. | +| `KALSHI_KLEAR_ACCESS_TOKEN` | unset | Klear access token (the Bearer `access_token`). Required by `from_env()`. Secret. | | `KALSHI_KLEAR_DEMO` | `false` | `true` (case-insensitive) selects the demo Klear endpoint. | | `KALSHI_KLEAR_API_BASE_URL` | unset | Overrides the Klear `base_url`. | | `KALSHI_KLEAR_ALLOW_UNKNOWN_HOST` | unset | Set to `1` to allow non-Kalshi Klear hosts. | diff --git a/docs/perps.md b/docs/perps.md index d3f759b..3aedebe 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -9,7 +9,7 @@ recommends **separate API keys** for it. |---|---|---|---| | Perps REST | `PerpsClient` / `AsyncPerpsClient` | `external-api.kalshi.com` / `external-api.demo.kalshi.co` (`/trade-api/v2`) | RSA-PSS (same signer as `KalshiClient`) | | Perps WebSocket | `PerpsWebSocket` | `external-api-margin-ws.kalshi.com` / `external-api-margin-ws.demo.kalshi.co` (`/trade-api/ws/v2/margin`) | RSA-PSS apiKey | -| SCM "Klear" | `KlearClient` / `AsyncKlearClient` | `api.klear.kalshi.com` / `demo-api.kalshi.co` (`/klear-api/v1`) | cookie-session + MFA | +| SCM "Klear" | `KlearClient` / `AsyncKlearClient` | `api.klear.kalshi.com` / `demo-api.kalshi.co` (`/klear-api/v1`) | Bearer `admin_user_id:access_token` | The RSA-PSS signer and the HTTP transport are **reused unchanged** from the prediction API — only the host, config, and credentials differ. @@ -135,21 +135,22 @@ The equities-only channels (`market_positions`, `multivariate*`, `communications ## Self-Clearing-Member "Klear" API The Klear API (settlement balances, obligations, margin reports, withdrawals) is a -third surface with a **completely different auth model** — email + password (+ MFA) -via `POST /log_in`, which sets a session cookie replayed on every subsequent -request. It is exposed via `KlearClient` / `AsyncKlearClient`: +third surface with a **different auth model** — a pre-generated Bearer token passed +as `Authorization: Bearer :` on every request. Generate +the token and find your admin user id at (the +"Security" page). It is exposed via `KlearClient` / `AsyncKlearClient`: ```python from kalshi import KlearClient -with KlearClient(demo=True) as klear: - resp = klear.login(email="...", password="...") - if resp.required_mfa_method: # MFA challenge - klear.login(email="...", password="...", code="123456") +with KlearClient(admin_user_id="...", access_token="...", demo=True) as klear: reports = klear.margin.margin_reports(start_date="2026-01-01", end_date="2026-02-01") bal = klear.margin.settlement_balance() ``` +Credentials can also come from the environment via `KlearClient.from_env()` (reads +`KALSHI_KLEAR_ADMIN_USER_ID` / `KALSHI_KLEAR_ACCESS_TOKEN`). + Money fields on the Klear margin schemas are integer **centicents** (`1 USD = 10,000 centicents`); only the withdrawal `amount` is a fixed-point dollar string. `klear.margin.withdraw_settlement_balance(amount="500.00")` validates the amount as diff --git a/kalshi/__init__.py b/kalshi/__init__.py index ac42a10..e300d3a 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -36,6 +36,7 @@ AmendOrderV2Response, Announcement, ApiKey, + ApiUsageLevelGrant, ApplySubaccountTransferRequest, AssociatedEvent, Balance, @@ -178,8 +179,6 @@ KlearAuth, KlearClient, KlearConfig, - LogInRequest, - LogInResponse, ) from kalshi.resources.communications import ( AsyncQuotesResource, @@ -201,6 +200,7 @@ "AmendOrderV2Response", "Announcement", "ApiKey", + "ApiUsageLevelGrant", "ApplySubaccountTransferRequest", "AssociatedEvent", "AsyncKalshiClient", @@ -310,8 +310,6 @@ "KlearClient", "KlearConfig", "LiveData", - "LogInRequest", - "LogInResponse", "LookupPoint", "LookupTickersForMarketInMultivariateEventCollectionRequest", "LookupTickersResponse", @@ -379,4 +377,4 @@ "Withdrawal", ] -__version__ = "3.3.0" +__version__ = "4.0.0" diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index 32e2c25..9c93668 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -217,9 +217,15 @@ class ContractEntry: notes=( "SPEC DRIFT: spec declares int 'read_limit'/'write_limit' but the " "live server returns nested 'read'/'write' token-bucket objects. " - "SDK model follows the server." + "SDK model follows the server. 'grants' is the per-exchange-lane " + "usage-level grant list (NullableList: null -> [])." ), ), + ContractEntry( + sdk_model="kalshi.models.account.ApiUsageLevelGrant", + spec_schema="ApiUsageLevelGrant", + notes="exchange_instance/level/source required; expires_ts nullable (None = permanent).", + ), ContractEntry( sdk_model="kalshi.models.structured_targets.StructuredTarget", spec_schema="StructuredTarget", @@ -502,6 +508,19 @@ class ContractEntry: spec_schema="tickerPayload", notes="Spec has price_dollars and time fields not in SDK (expected additive drift)", ), + ContractEntry( + sdk_model="kalshi.ws.models.cfbenchmarks.CFBenchmarksValuePayload", + spec_schema="cfbenchmarksValuePayload", + notes=( + "avg_60s_data required; last_60s_windowed_average_15min present only " + "near quarter-hour close." + ), + ), + ContractEntry( + sdk_model="kalshi.ws.models.cfbenchmarks.CFBenchmarksIndexListPayload", + spec_schema="cfbenchmarksIndexListPayload", + notes="indexlist response: msg.index_ids only.", + ), ContractEntry( sdk_model="kalshi.ws.models.fill.FillPayload", spec_schema="fillPayload", @@ -735,6 +754,13 @@ class ContractEntry: sdk_model="kalshi.perps.models.margin_account.GetMarginFeeTiersResponse", spec_schema="GetMarginFeeTiersResponse", ), + ContractEntry( + # /account/limits/perps returns the same shape as the prediction API's + # /account/limits, so the perps resource reuses kalshi.models.account.AccountApiLimits. + sdk_model="kalshi.models.account.AccountApiLimits", + spec_schema="GetAccountApiLimitsResponse", + notes="Reused prediction-API model; perps spec schema is field-identical.", + ), # ── perps funding (#395) ── ContractEntry( sdk_model="kalshi.perps.models.funding.MarginFundingRate", diff --git a/kalshi/fix/messages/order_entry.py b/kalshi/fix/messages/order_entry.py index 358fa0b..fb532c9 100644 --- a/kalshi/fix/messages/order_entry.py +++ b/kalshi/fix/messages/order_entry.py @@ -78,7 +78,7 @@ class NewOrderSingle(FixMessage): Tag.MAX_EXECUTION_COST, FixType.DECIMAL, default=None ) # Kalshi dictionary v1.03 types AllocAccount (79) as INT — the subaccount - # number (0 primary, 1-32) — not the FIX-standard STRING. + # number (0 primary, 1-63) — not the FIX-standard STRING. alloc_account: int | None = fixfield(Tag.ALLOC_ACCOUNT, FixType.INT, default=None) diff --git a/kalshi/fix/messages/order_groups.py b/kalshi/fix/messages/order_groups.py index 91f2aed..cd0ca83 100644 --- a/kalshi/fix/messages/order_groups.py +++ b/kalshi/fix/messages/order_groups.py @@ -38,7 +38,7 @@ class OrderGroupRequest(FixMessage): Tag.ORDER_GROUP_CONTRACTS_LIMIT, FixType.INT, default=None ) # Kalshi types AllocAccount (79) as INT — the subaccount number (0 primary, - # 1-32). Scopes the action to the group owned by that subaccount. + # 1-63). Scopes the action to the group owned by that subaccount. alloc_account: int | None = fixfield(Tag.ALLOC_ACCOUNT, FixType.INT, default=None) @classmethod diff --git a/kalshi/models/__init__.py b/kalshi/models/__init__.py index 01fa3b1..09aa71b 100644 --- a/kalshi/models/__init__.py +++ b/kalshi/models/__init__.py @@ -3,6 +3,7 @@ from kalshi.models.account import ( AccountApiLimits, AccountEndpointCosts, + ApiUsageLevelGrant, EndpointTokenCost, RateLimit, ) @@ -190,6 +191,7 @@ "AmendOrderV2Response", "Announcement", "ApiKey", + "ApiUsageLevelGrant", "ApplySubaccountTransferRequest", "AssociatedEvent", "Balance", diff --git a/kalshi/models/account.py b/kalshi/models/account.py index 4cd8f5e..54a66e3 100644 --- a/kalshi/models/account.py +++ b/kalshi/models/account.py @@ -4,6 +4,8 @@ from pydantic import BaseModel +from kalshi.types import NullableList + class RateLimit(BaseModel): """Per-direction (read/write) token-bucket rate limit. @@ -42,6 +44,24 @@ class AccountEndpointCosts(BaseModel): model_config = {"extra": "allow"} +class ApiUsageLevelGrant(BaseModel): + """One API usage-level grant for a single exchange lane. + + Spec ``ApiUsageLevelGrant``. Each grant applies to its ``exchange_instance`` + (``event_contract`` or ``margined``); ``level`` is the usage level it confers + (e.g. ``premier``/``paragon``/``prime``). ``source`` records how it was + created (``volume`` for trading-volume earned, ``manual`` for Kalshi-assigned). + ``expires_ts`` is a Unix-seconds expiry, absent (``None``) for permanent grants. + """ + + exchange_instance: str + level: str + source: str + expires_ts: int | None = None + + model_config = {"extra": "allow"} + + class AccountApiLimits(BaseModel): """Rate limits associated with the authenticated user's API tier. @@ -49,10 +69,14 @@ class AccountApiLimits(BaseModel): ``write_limit`` as ints, but the live server returns nested token-bucket objects under ``read`` and ``write``. The SDK matches the server. If the spec is corrected upstream, the contract-drift test will flag it. + + ``grants`` lists the caller's active usage-level grants across exchange + lanes; ``usage_tier`` is the effective tier reported by this endpoint. """ usage_tier: str read: RateLimit write: RateLimit + grants: NullableList[ApiUsageLevelGrant] model_config = {"extra": "allow"} diff --git a/kalshi/models/subaccounts.py b/kalshi/models/subaccounts.py index 8553b9e..bb048c0 100644 --- a/kalshi/models/subaccounts.py +++ b/kalshi/models/subaccounts.py @@ -25,7 +25,7 @@ class ApplySubaccountTransferRequest(BaseModel): a Decimal. ``from_subaccount`` / ``to_subaccount`` use ``0`` for the primary account and a positive integer for numbered subaccounts. The server is the source of truth for the upper bound: spec describes - ``1-32`` in prose but defines no JSON-schema maximum, and demo has + ``1-63`` in prose but defines no JSON-schema maximum, and demo has been observed allocating values above 32. The SDK validates only the lower bound (``ge=0``) so server-assigned numbers always round-trip. """ diff --git a/kalshi/perps/__init__.py b/kalshi/perps/__init__.py index 5fb5c7d..16c8b2c 100644 --- a/kalshi/perps/__init__.py +++ b/kalshi/perps/__init__.py @@ -29,8 +29,6 @@ KlearAuth, KlearClient, KlearConfig, - LogInRequest, - LogInResponse, ) from kalshi.perps.klear.models.margin import ( GetActiveMarginObligationResponse, @@ -264,8 +262,6 @@ "KlearConfig", "LastUpdateReason", "LastUpdateReasonLiteral", - "LogInRequest", - "LogInResponse", "MaintenanceMarginDetail", "MarginAccountResource", "MarginEnabledResponse", diff --git a/kalshi/perps/klear/__init__.py b/kalshi/perps/klear/__init__.py index c3bbb89..2edaaab 100644 --- a/kalshi/perps/klear/__init__.py +++ b/kalshi/perps/klear/__init__.py @@ -1,15 +1,14 @@ """Kalshi Self-Clearing-Member (SCM) "Klear" API. -The Klear API (``klear-api/v1``) authenticates with **email + password (+ MFA) -via** ``POST /log_in``, which sets a ``session`` cookie that is replayed on every -subsequent request — a completely different auth model from the RSA-PSS signing -used by the prediction and perps trade-api surfaces. It is therefore exposed via -standalone :class:`KlearClient` / :class:`AsyncKlearClient` with their own -:class:`KlearConfig` and a lightweight :class:`KlearAuth` session holder. +The Klear API (``klear-api/v1``) authenticates with a pre-generated Bearer token +passed as ``Authorization: Bearer :`` on every +request — a different auth model from the RSA-PSS signing used by the prediction +and perps trade-api surfaces. It is therefore exposed via standalone +:class:`KlearClient` / :class:`AsyncKlearClient` with their own +:class:`KlearConfig` and a :class:`KlearAuth` Bearer-credential holder. -Security: ``email`` / ``password`` / ``code`` and the session cookie are secrets -— they are never logged, never placed in exception messages, and redacted from -``repr()``. +Security: the ``access_token`` is a secret — it is never logged, never placed in +exception messages, and redacted from ``repr()``. """ from __future__ import annotations @@ -22,7 +21,6 @@ PRODUCTION_KLEAR_URL, KlearConfig, ) -from kalshi.perps.klear.models.auth import LogInRequest, LogInResponse from kalshi.perps.klear.models.common import Error __all__ = [ @@ -33,6 +31,4 @@ "KlearAuth", "KlearClient", "KlearConfig", - "LogInRequest", - "LogInResponse", ] diff --git a/kalshi/perps/klear/async_client.py b/kalshi/perps/klear/async_client.py index 831ff4e..4474302 100644 --- a/kalshi/perps/klear/async_client.py +++ b/kalshi/perps/klear/async_client.py @@ -10,8 +10,6 @@ from kalshi._base_client import AsyncTransport from kalshi.perps.klear.auth import KlearAuth from kalshi.perps.klear.config import DEMO_KLEAR_URL, KlearConfig -from kalshi.perps.klear.models.auth import LogInResponse -from kalshi.perps.klear.resources.auth import AsyncAuthResource from kalshi.perps.klear.resources.margin import AsyncMarginResource @@ -19,12 +17,14 @@ class AsyncKlearClient: """Asynchronous client for the Kalshi **Klear (SCM)** API. Async sibling of :class:`kalshi.perps.klear.KlearClient`; see that class for - the cookie-session / no-RSA rationale. + the Bearer-token / no-RSA rationale. """ def __init__( self, *, + admin_user_id: str, + access_token: str, config: KlearConfig | None = None, demo: bool = False, base_url: str | None = None, @@ -52,30 +52,41 @@ def __init__( self._config = KlearConfig(**config_kwargs) # type: ignore[arg-type] self._transport = AsyncTransport(None, self._config, transport=transport) - self._auth = KlearAuth() - self.auth = AsyncAuthResource(self._transport, self._auth) + self._auth = KlearAuth(admin_user_id, access_token) self.margin = AsyncMarginResource(self._transport, self._auth) - @property - def is_authenticated(self) -> bool: - """Whether a Klear session has been established via :meth:`login`.""" - return self._auth.is_authenticated - @classmethod def from_env( cls, *, + admin_user_id: str | None = None, + access_token: str | None = None, demo: bool | None = None, base_url: str | None = None, timeout: float | None = None, max_retries: int | None = None, transport: httpx.AsyncBaseTransport | None = None, ) -> AsyncKlearClient: - """Create an async Klear client, reading only environment routing. + """Create an async Klear client, reading credentials and routing from the environment. - See :meth:`kalshi.perps.klear.KlearClient.from_env` — credentials are - never read from the environment. ``transport`` is forwarded for tests. + See :meth:`kalshi.perps.klear.KlearClient.from_env`. ``transport`` is + forwarded for tests. """ + resolved_admin = ( + admin_user_id + if admin_user_id is not None + else os.environ.get("KALSHI_KLEAR_ADMIN_USER_ID") + ) + resolved_token = ( + access_token + if access_token is not None + else os.environ.get("KALSHI_KLEAR_ACCESS_TOKEN") + ) + if not resolved_admin or not resolved_token: + raise ValueError( + "AsyncKlearClient.from_env requires KALSHI_KLEAR_ADMIN_USER_ID and " + "KALSHI_KLEAR_ACCESS_TOKEN (or explicit admin_user_id/access_token)." + ) resolved_demo = ( demo if demo is not None else os.environ.get("KALSHI_KLEAR_DEMO", "").lower() == "true" ) @@ -83,6 +94,8 @@ def from_env( base_url if base_url is not None else os.environ.get("KALSHI_KLEAR_API_BASE_URL") ) return cls( + admin_user_id=resolved_admin, + access_token=resolved_token, demo=resolved_demo, base_url=resolved_base_url, timeout=timeout, @@ -90,16 +103,9 @@ def from_env( transport=transport, ) - async def login( - self, *, email: str, password: str, code: str | None = None - ) -> LogInResponse: - """Convenience wrapper for :meth:`AsyncAuthResource.log_in`.""" - return await self.auth.log_in(email=email, password=password, code=code) - async def close(self) -> None: - """Close the underlying async HTTP connection pool and clear session state.""" + """Close the underlying async HTTP connection pool.""" await self._transport.close() - self._auth.reset() async def __aenter__(self) -> AsyncKlearClient: return self diff --git a/kalshi/perps/klear/auth.py b/kalshi/perps/klear/auth.py index b10805d..4c4f128 100644 --- a/kalshi/perps/klear/auth.py +++ b/kalshi/perps/klear/auth.py @@ -1,45 +1,51 @@ -"""Cookie-session auth holder for the Klear (SCM) API. +"""Bearer-token credential holder for the Klear (SCM) API. Unlike :class:`kalshi.auth.KalshiAuth` (an RSA-PSS request signer), ``KlearAuth`` -is a lightweight **session-state holder**: it has no private key and no -``sign_request`` method. The actual ``session`` cookie is captured and replayed -by the transport's httpx cookie jar; this class only tracks whether a session is -active (and optionally the opaque login token for caller inspection). +holds a pre-generated Klear admin access token and produces the static +``Authorization: Bearer :`` header the Klear API +requires on every request. Generate the token and find your admin user id by +signing in at https://klearing.kalshi.com and opening the "Security" page. -Security: the token is a bearer credential — it is never logged and is redacted -from ``repr()``. +Security: the access token is a bearer credential — it is never logged and is +redacted from ``repr()``. """ from __future__ import annotations class KlearAuth: - """Tracks Klear session state. Holds no RSA key and signs nothing.""" - - def __init__(self) -> None: - self._authenticated: bool = False - self._token: str | None = None + """Holds Klear Bearer credentials and builds the ``Authorization`` header.""" + + def __init__(self, admin_user_id: str, access_token: str) -> None: + # Strip before storing so surrounding whitespace can't survive into a + # malformed ``Bearer id : token `` header. ``(x or "")`` also coerces + # a defensive ``None`` (off-type, but possible from ``os.environ.get``) to + # ""; the check below then rejects empty / whitespace-only / None alike. + admin_user_id = (admin_user_id or "").strip() + access_token = (access_token or "").strip() + if not admin_user_id or not access_token: + raise ValueError( + "KlearAuth requires a non-empty admin_user_id and access_token." + ) + self._admin_user_id = admin_user_id + self._access_token = access_token @property - def is_authenticated(self) -> bool: - """Whether a Klear session has been established via ``log_in``.""" - return self._authenticated + def admin_user_id(self) -> str: + """The Klear admin user id (not secret).""" + return self._admin_user_id @property - def token(self) -> str | None: - """The opaque login token, if the server returned one. Treat as secret.""" - return self._token - - def mark_logged_in(self, token: str | None = None) -> None: - """Flag the session active after a successful (non-MFA-challenge) login.""" - self._authenticated = True - self._token = token + def access_token(self) -> str: + """The Klear access token. Treat as secret.""" + return self._access_token - def reset(self) -> None: - """Clear session state (e.g. on logout / client close).""" - self._authenticated = False - self._token = None + def authorization_header(self) -> str: + """Value for the ``Authorization`` header: ``Bearer :``.""" + return f"Bearer {self._admin_user_id}:{self._access_token}" def __repr__(self) -> str: - # Never leak the token. - return f"KlearAuth(authenticated={self._authenticated})" + # access_token is a bearer credential; redact it. + return f"KlearAuth(admin_user_id={self._admin_user_id!r}, access_token=)" + + __str__ = __repr__ diff --git a/kalshi/perps/klear/client.py b/kalshi/perps/klear/client.py index 04388a3..b5e7bb3 100644 --- a/kalshi/perps/klear/client.py +++ b/kalshi/perps/klear/client.py @@ -10,32 +10,29 @@ from kalshi._base_client import SyncTransport from kalshi.perps.klear.auth import KlearAuth from kalshi.perps.klear.config import DEMO_KLEAR_URL, KlearConfig -from kalshi.perps.klear.models.auth import LogInResponse -from kalshi.perps.klear.resources.auth import AuthResource from kalshi.perps.klear.resources.margin import MarginResource class KlearClient: """Synchronous client for the Kalshi **Klear (Self-Clearing-Member)** API. - Authenticates with email + password (+ MFA) via :meth:`login`, which sets a - ``session`` cookie replayed on every subsequent request. The RSA-PSS signing - path is **not** used: the transport is constructed with ``auth=None`` so no - ``KALSHI-ACCESS-*`` headers are ever signed, and the session cookie travels - on the transport's httpx cookie jar. + Authenticates with a pre-generated Bearer token passed at construction: + every request carries ``Authorization: Bearer :``. + Generate the token and find your admin user id at https://klearing.kalshi.com + (the "Security" page). The RSA-PSS signing path is **not** used: the transport + is built with ``auth=None`` so no ``KALSHI-ACCESS-*`` headers are ever signed. Usage:: - with KlearClient(demo=True) as c: - resp = c.login(email="...", password="...") - if resp.required_mfa_method: - c.login(email="...", password="...", code="123456") - # ... call SCM endpoints (c.margin.*, added in #400) + with KlearClient(admin_user_id="...", access_token="...", demo=True) as c: + reports = c.margin.margin_reports(start_date="2026-01-01", end_date="2026-01-31") """ def __init__( self, *, + admin_user_id: str, + access_token: str, config: KlearConfig | None = None, demo: bool = False, base_url: str | None = None, @@ -63,33 +60,45 @@ def __init__( self._config = KlearConfig(**config_kwargs) # type: ignore[arg-type] # RSA-PSS signing is NOT used: auth=None means the transport never signs - # a request. The session cookie is captured/replayed by the httpx jar. + # a request. The Klear Bearer header is injected per-request by the + # Klear resource base from this KlearAuth. self._transport = SyncTransport(None, self._config, transport=transport) - self._auth = KlearAuth() - self.auth = AuthResource(self._transport, self._auth) + self._auth = KlearAuth(admin_user_id, access_token) self.margin = MarginResource(self._transport, self._auth) - @property - def is_authenticated(self) -> bool: - """Whether a Klear session has been established via :meth:`login`.""" - return self._auth.is_authenticated - @classmethod def from_env( cls, *, + admin_user_id: str | None = None, + access_token: str | None = None, demo: bool | None = None, base_url: str | None = None, timeout: float | None = None, max_retries: int | None = None, transport: httpx.BaseTransport | None = None, ) -> KlearClient: - """Create a Klear client, reading only environment routing (not credentials). + """Create a Klear client, reading credentials and routing from the environment. - Reads ``KALSHI_KLEAR_API_BASE_URL`` and ``KALSHI_KLEAR_DEMO``. Klear - credentials are supplied interactively via :meth:`login` — they are never - read from the environment. ``transport`` is forwarded for test injection. + Reads ``KALSHI_KLEAR_ADMIN_USER_ID`` / ``KALSHI_KLEAR_ACCESS_TOKEN`` (unless + passed explicitly) plus ``KALSHI_KLEAR_API_BASE_URL`` / ``KALSHI_KLEAR_DEMO``. + ``transport`` is forwarded for test injection. """ + resolved_admin = ( + admin_user_id + if admin_user_id is not None + else os.environ.get("KALSHI_KLEAR_ADMIN_USER_ID") + ) + resolved_token = ( + access_token + if access_token is not None + else os.environ.get("KALSHI_KLEAR_ACCESS_TOKEN") + ) + if not resolved_admin or not resolved_token: + raise ValueError( + "KlearClient.from_env requires KALSHI_KLEAR_ADMIN_USER_ID and " + "KALSHI_KLEAR_ACCESS_TOKEN (or explicit admin_user_id/access_token)." + ) resolved_demo = ( demo if demo is not None else os.environ.get("KALSHI_KLEAR_DEMO", "").lower() == "true" ) @@ -97,6 +106,8 @@ def from_env( base_url if base_url is not None else os.environ.get("KALSHI_KLEAR_API_BASE_URL") ) return cls( + admin_user_id=resolved_admin, + access_token=resolved_token, demo=resolved_demo, base_url=resolved_base_url, timeout=timeout, @@ -104,20 +115,9 @@ def from_env( transport=transport, ) - def login( - self, *, email: str, password: str, code: str | None = None - ) -> LogInResponse: - """Convenience wrapper for :meth:`AuthResource.log_in`. - - Returns the :class:`LogInResponse`; inspect ``required_mfa_method`` and - re-call with ``code`` if MFA is required. - """ - return self.auth.log_in(email=email, password=password, code=code) - def close(self) -> None: - """Close the underlying HTTP connection pool and clear session state.""" + """Close the underlying HTTP connection pool.""" self._transport.close() - self._auth.reset() def __enter__(self) -> KlearClient: return self diff --git a/kalshi/perps/klear/config.py b/kalshi/perps/klear/config.py index 1ba9f7a..2bf4388 100644 --- a/kalshi/perps/klear/config.py +++ b/kalshi/perps/klear/config.py @@ -35,9 +35,9 @@ class KlearConfig(KalshiConfig): Same field surface as :class:`kalshi.config.KalshiConfig` (timeouts, retry policy, ``extra_headers``, HTTP/2, custom REST JSON loader) but defaults to the Klear base URL and validates the ``/klear-api/v1`` path + Klear hosts. - Holds **no credentials** — email/password live only in the transient - ``LogInRequest`` and the session cookie lives in the transport's cookie jar - — so the default dataclass ``repr`` is already credential-safe. + Holds **no credentials** — the Bearer ``admin_user_id``/``access_token`` live + only on :class:`~kalshi.perps.klear.KlearAuth` — so the default dataclass + ``repr`` is already credential-safe. The ``allow_unknown_host`` escape hatch (and ``KALSHI_KLEAR_ALLOW_UNKNOWN_HOST=1``) relaxes the host check for mock servers / proxies. @@ -71,7 +71,7 @@ def __post_init__(self) -> None: raise ValueError( f"KlearConfig.base_url must use https:// for non-loopback hosts; " f"http:// is only allowed for {sorted(_LOCAL_HOSTS)} (url={self.base_url!r}). " - f"Plaintext to a remote host would expose the session cookie." + f"Plaintext to a remote host would expose the Bearer access token." ) if host not in _KLEAR_KNOWN_HOSTS and host not in _LOCAL_HOSTS: if not allow_unknown: @@ -84,7 +84,7 @@ def __post_init__(self) -> None: ) logger.warning( "KlearConfig.base_url host %r is not a known Kalshi Klear endpoint (%s). " - "The session cookie will be sent there — verify this is intentional.", + "The Bearer access token will be sent there — verify this is intentional.", host, sorted(_KLEAR_KNOWN_HOSTS), ) diff --git a/kalshi/perps/klear/models/__init__.py b/kalshi/perps/klear/models/__init__.py index b6d46e1..d800ab5 100644 --- a/kalshi/perps/klear/models/__init__.py +++ b/kalshi/perps/klear/models/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from kalshi.perps.klear.models.auth import LogInRequest, LogInResponse from kalshi.perps.klear.models.common import Error from kalshi.perps.klear.models.margin import ( GetActiveMarginObligationResponse, @@ -36,8 +35,6 @@ "GetSettlementBalanceResponse", "GetSettlementBalanceWithdrawalResponse", "GetSettlementEstimateResponse", - "LogInRequest", - "LogInResponse", "MaintenanceMarginDetail", "MarginReport", "MarginReportTypeLiteral", diff --git a/kalshi/perps/klear/models/auth.py b/kalshi/perps/klear/models/auth.py deleted file mode 100644 index 08cda95..0000000 --- a/kalshi/perps/klear/models/auth.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Klear (SCM) login request/response models. - -Security: :class:`LogInRequest` carries the ``email`` / ``password`` / ``code`` -secrets. It is serialized only into the request body (never logged — the -transport logs ``METHOD path`` only) and gets no field-leaking ``repr``. -""" - -from __future__ import annotations - -from pydantic import BaseModel, ConfigDict - - -class LogInRequest(BaseModel): - """Spec ``LogInRequest`` — body for ``POST /log_in`` (``extra="forbid"``). - - Two-phase: call first with ``email`` + ``password``; if the response carries - ``required_mfa_method``, re-call with the same credentials plus ``code``. - """ - - # ``hide_input_in_errors`` keeps the raw email/password/code out of any - # Pydantic ``ValidationError`` text (the ``__repr__`` redaction below does - # not cover Pydantic's internal error rendering of the offending input). - model_config = ConfigDict(extra="forbid", hide_input_in_errors=True) - - email: str - password: str - code: str | None = None - - def __repr__(self) -> str: - # Never leak credentials in repr / logs / tracebacks. - return "LogInRequest(email=, password=, code=)" - - __str__ = __repr__ - - -class LogInResponse(BaseModel): - """Spec ``LogInResponse`` — all fields optional. - - On success, ``token`` / ``user_id`` are present and the ``session`` cookie is - set via ``Set-Cookie`` (captured by the transport's cookie jar). When - ``required_mfa_method`` is non-null, MFA is required: re-call ``log_in`` with - ``code``. The SDK does not auto-loop on MFA — it returns this response so the - caller can supply the out-of-band code. - """ - - model_config = ConfigDict(extra="allow") - - token: str | None = None - user_id: str | None = None - access_level: str | None = None - required_mfa_method: str | None = None - - def __repr__(self) -> str: - # `token` is a bearer credential; redact it but surface the MFA signal. - return ( - f"LogInResponse(user_id={self.user_id!r}, " - f"access_level={self.access_level!r}, " - f"required_mfa_method={self.required_mfa_method!r}, token=)" - ) - - __str__ = __repr__ diff --git a/kalshi/perps/klear/resources/_base.py b/kalshi/perps/klear/resources/_base.py index 8d51b69..8e4faba 100644 --- a/kalshi/perps/klear/resources/_base.py +++ b/kalshi/perps/klear/resources/_base.py @@ -1,48 +1,100 @@ -"""Klear (SCM) resource bases with a session (cookie) auth guard. - -Klear authenticates with a session cookie, not RSA-PSS, so the prediction-API -``SyncResource._require_auth`` (which checks the transport's RSA signer) would -always fail. These bases reuse the same transport/HTTP helpers but add -``_require_session()`` — a guard that checks the :class:`KlearAuth` session -holder so an un-logged-in caller gets a clear ``AuthRequiredError`` instead of a -server 401. +"""Klear (SCM) resource bases that inject the Bearer ``Authorization`` header. + +Klear authenticates with a static ``Authorization: Bearer :`` +header (not RSA-PSS), so the transport is built with ``auth=None``. These bases +reuse the prediction-API transport/HTTP helpers but override ``_get``/``_post`` +to merge the Klear Bearer header onto every request — the paginators +(``_list``/``_list_all``) route through ``_get``, so they are covered too. + +Only ``_get``/``_post`` are overridden because the entire Klear surface is +GET/POST. If a future spec revision adds a PUT/DELETE Klear endpoint, override +the matching base helper here too (otherwise its requests would go out +unauthenticated). """ from __future__ import annotations +from typing import Any + from kalshi._base_client import AsyncTransport, SyncTransport -from kalshi.errors import AuthRequiredError from kalshi.perps.klear.auth import KlearAuth from kalshi.resources._base import AsyncResource, SyncResource +def _with_klear_auth( + auth: KlearAuth, extra_headers: dict[str, str] | None +) -> dict[str, str]: + """Merge the Klear ``Authorization: Bearer`` header onto ``extra_headers``. + + The Bearer header is set last (and unconditionally) so a caller-supplied + ``extra_headers`` can never suppress authentication. + """ + merged = dict(extra_headers) if extra_headers else {} + merged["Authorization"] = auth.authorization_header() + return merged + + class KlearSyncResource(SyncResource): - """Sync Klear resource base — transport + Klear session holder.""" + """Sync Klear resource base — transport + Bearer header injection.""" def __init__(self, transport: SyncTransport, auth: KlearAuth) -> None: super().__init__(transport) self._klear_auth = auth - def _require_session(self) -> None: - """Raise ``AuthRequiredError`` if no Klear session has been established.""" - if not self._klear_auth.is_authenticated: - raise AuthRequiredError( - "Klear endpoints require an active session. Call " - "client.auth.log_in(email=..., password=...) first." - ) + def _with_auth(self, extra_headers: dict[str, str] | None) -> dict[str, str]: + return _with_klear_auth(self._klear_auth, extra_headers) + + def _get( + self, + path: str, + *, + params: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, + ) -> dict[str, Any]: + return super()._get(path, params=params, extra_headers=self._with_auth(extra_headers)) + + def _post( + self, + path: str, + *, + params: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, + ) -> dict[str, Any]: + return super()._post( + path, params=params, json=json, extra_headers=self._with_auth(extra_headers) + ) class KlearAsyncResource(AsyncResource): - """Async Klear resource base — transport + Klear session holder.""" + """Async Klear resource base — transport + Bearer header injection.""" def __init__(self, transport: AsyncTransport, auth: KlearAuth) -> None: super().__init__(transport) self._klear_auth = auth - def _require_session(self) -> None: - """Raise ``AuthRequiredError`` if no Klear session has been established.""" - if not self._klear_auth.is_authenticated: - raise AuthRequiredError( - "Klear endpoints require an active session. Call " - "client.auth.log_in(email=..., password=...) first." - ) + def _with_auth(self, extra_headers: dict[str, str] | None) -> dict[str, str]: + return _with_klear_auth(self._klear_auth, extra_headers) + + async def _get( + self, + path: str, + *, + params: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, + ) -> dict[str, Any]: + return await super()._get( + path, params=params, extra_headers=self._with_auth(extra_headers) + ) + + async def _post( + self, + path: str, + *, + params: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, + ) -> dict[str, Any]: + return await super()._post( + path, params=params, json=json, extra_headers=self._with_auth(extra_headers) + ) diff --git a/kalshi/perps/klear/resources/auth.py b/kalshi/perps/klear/resources/auth.py deleted file mode 100644 index dff26b3..0000000 --- a/kalshi/perps/klear/resources/auth.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Klear (SCM) authentication resource — ``POST /log_in``.""" - -from __future__ import annotations - -from kalshi.perps.klear.models.auth import LogInRequest, LogInResponse -from kalshi.perps.klear.resources._base import KlearAsyncResource, KlearSyncResource - - -class AuthResource(KlearSyncResource): - """Sync Klear auth API (``log_in``).""" - - def log_in(self, *, email: str, password: str, code: str | None = None) -> LogInResponse: - """Log in to the Klear API (unauthenticated bootstrap; ``security: []``). - - Posts ``email`` + ``password`` (+ optional MFA ``code``). On success the - transport's cookie jar captures the ``session`` cookie and replays it on - subsequent requests, and the client's session is marked active. If the - response carries ``required_mfa_method``, the session is **not** marked - active — re-call with ``code`` (the SDK does not conjure the OOB code). - - ``POST /log_in`` is never retried (the transport enforces no-retry on - POST), so a login is never silently replayed. - - Any prior session is invalidated **before** the attempt: the - :class:`KlearAuth` state is reset and the stale ``session`` cookie is - dropped, so the client is definitively unauthenticated until a clean - (non-MFA-challenge) success — re-logging into a different account that - returns an MFA challenge can never leave the previous account active. - """ - self._klear_auth.reset() - self._transport.clear_cookie("session") - req = LogInRequest(email=email, password=password, code=code) - data = self._post( - "/log_in", - json=req.model_dump(exclude_none=True, by_alias=True, mode="json"), - ) - resp = LogInResponse.model_validate(data) - if resp.required_mfa_method is None: - self._klear_auth.mark_logged_in(resp.token) - return resp - - -class AsyncAuthResource(KlearAsyncResource): - """Async Klear auth API (``log_in``).""" - - async def log_in( - self, *, email: str, password: str, code: str | None = None - ) -> LogInResponse: - """Async :meth:`AuthResource.log_in`.""" - self._klear_auth.reset() - self._transport.clear_cookie("session") - req = LogInRequest(email=email, password=password, code=code) - data = await self._post( - "/log_in", - json=req.model_dump(exclude_none=True, by_alias=True, mode="json"), - ) - resp = LogInResponse.model_validate(data) - if resp.required_mfa_method is None: - self._klear_auth.mark_logged_in(resp.token) - return resp diff --git a/kalshi/perps/klear/resources/margin.py b/kalshi/perps/klear/resources/margin.py index 6df8147..ddeba53 100644 --- a/kalshi/perps/klear/resources/margin.py +++ b/kalshi/perps/klear/resources/margin.py @@ -19,10 +19,10 @@ - ``settlement_balance_withdrawal`` — ``GET /margin/settlement_balance_withdrawal``: withdrawal status by required ``id``. -These bind to the Klear session guard (``_require_session()`` from the Klear -resource base), NOT the RSA-specific ``_require_auth()``. Every method calls -``_require_session()`` first so an un-logged-in caller gets a clear -``AuthRequiredError`` with no wasted round trip. +The Klear resource base injects the ``Authorization: Bearer`` header on every +request (NOT the RSA-PSS ``KALSHI-ACCESS-*`` signing used by the trade-api +surfaces), so these methods carry no client-side auth guard — an invalid token +surfaces as a server 401 mapped to ``KalshiAuthError``. """ from __future__ import annotations @@ -96,7 +96,6 @@ def margin_reports( ``start_date`` / ``end_date`` are required ``YYYY-MM-DD`` strings. """ - self._require_session() _validate_date_range(start_date, end_date) params = _params(start_date=start_date, end_date=end_date) data = self._get("/margin/reports", params=params, extra_headers=extra_headers) @@ -106,7 +105,6 @@ def active_obligation( self, *, extra_headers: dict[str, str] | None = None ) -> GetActiveMarginObligationResponse: """``GET /margin/active_obligation`` — current-cycle obligation (nullable).""" - self._require_session() data = self._get("/margin/active_obligation", extra_headers=extra_headers) return GetActiveMarginObligationResponse.model_validate(data) @@ -118,7 +116,6 @@ def obligation_history( extra_headers: dict[str, str] | None = None, ) -> Page[ObligationEntry]: """``GET /margin/obligation_history`` — one cursor-paginated page (limit max 100).""" - self._require_session() params = _params(limit=_validate_limit(limit, hi=100), cursor=cursor) return self._list( "/margin/obligation_history", @@ -137,7 +134,6 @@ def obligation_history_all( extra_headers: dict[str, str] | None = None, ) -> Iterator[ObligationEntry]: """Auto-paginate obligation history, yielding each ``ObligationEntry``.""" - self._require_session() _validate_max_pages(max_pages) params = _params(limit=_validate_limit(limit, hi=100), cursor=None) return self._list_all( @@ -154,7 +150,6 @@ def settlement_estimate( self, *, extra_headers: dict[str, str] | None = None ) -> GetSettlementEstimateResponse: """``GET /margin/settlement_estimate`` — next-settlement estimate + breakdowns.""" - self._require_session() data = self._get("/margin/settlement_estimate", extra_headers=extra_headers) return GetSettlementEstimateResponse.model_validate(data) @@ -162,7 +157,6 @@ def settlement_balance( self, *, extra_headers: dict[str, str] | None = None ) -> GetSettlementBalanceResponse: """``GET /margin/settlement_balance`` — settlement-buffer balance.""" - self._require_session() data = self._get("/margin/settlement_balance", extra_headers=extra_headers) return GetSettlementBalanceResponse.model_validate(data) @@ -170,7 +164,6 @@ def guaranty_fund_balance( self, *, extra_headers: dict[str, str] | None = None ) -> GetGuarantyFundBalanceResponse: """``GET /margin/guaranty_fund_balance`` — guaranty-fund contribution balance.""" - self._require_session() data = self._get("/margin/guaranty_fund_balance", extra_headers=extra_headers) return GetGuarantyFundBalanceResponse.model_validate(data) @@ -182,7 +175,6 @@ def settlement_balance_history( extra_headers: dict[str, str] | None = None, ) -> Page[SettlementBalanceHistoryEntry]: """``GET /margin/settlement_balance_history`` — one page (limit max 500).""" - self._require_session() params = _params(limit=_validate_limit(limit, hi=500), cursor=cursor) return self._list( "/margin/settlement_balance_history", @@ -201,7 +193,6 @@ def settlement_balance_history_all( extra_headers: dict[str, str] | None = None, ) -> Iterator[SettlementBalanceHistoryEntry]: """Auto-paginate settlement-balance history, yielding each entry.""" - self._require_session() _validate_max_pages(max_pages) params = _params(limit=_validate_limit(limit, hi=500), cursor=None) return self._list_all( @@ -226,7 +217,6 @@ def withdraw_settlement_balance( request body is built from :class:`WithdrawSettlementBalanceRequest` and serialized to ``{"amount": "500.00"}``. POST is never retried. """ - self._require_session() req = WithdrawSettlementBalanceRequest(amount=to_decimal(amount)) data = self._post( "/margin/withdraw_settlement_balance", @@ -239,7 +229,6 @@ def settlement_balance_withdrawal( self, *, id: str, extra_headers: dict[str, str] | None = None ) -> GetSettlementBalanceWithdrawalResponse: """``GET /margin/settlement_balance_withdrawal`` — withdrawal status by ``id``.""" - self._require_session() params = _params(id=id) data = self._get( "/margin/settlement_balance_withdrawal", @@ -260,7 +249,6 @@ async def margin_reports( extra_headers: dict[str, str] | None = None, ) -> GetMarginReportsResponse: """Async :meth:`MarginResource.margin_reports`.""" - self._require_session() _validate_date_range(start_date, end_date) params = _params(start_date=start_date, end_date=end_date) data = await self._get("/margin/reports", params=params, extra_headers=extra_headers) @@ -270,7 +258,6 @@ async def active_obligation( self, *, extra_headers: dict[str, str] | None = None ) -> GetActiveMarginObligationResponse: """Async :meth:`MarginResource.active_obligation`.""" - self._require_session() data = await self._get("/margin/active_obligation", extra_headers=extra_headers) return GetActiveMarginObligationResponse.model_validate(data) @@ -282,7 +269,6 @@ async def obligation_history( extra_headers: dict[str, str] | None = None, ) -> Page[ObligationEntry]: """Async :meth:`MarginResource.obligation_history`.""" - self._require_session() params = _params(limit=_validate_limit(limit, hi=100), cursor=cursor) return await self._list( "/margin/obligation_history", @@ -301,7 +287,6 @@ def obligation_history_all( extra_headers: dict[str, str] | None = None, ) -> AsyncIterator[ObligationEntry]: """Returns an async iterator over obligation history — use ``async for``.""" - self._require_session() _validate_max_pages(max_pages) params = _params(limit=_validate_limit(limit, hi=100), cursor=None) return self._list_all( @@ -318,7 +303,6 @@ async def settlement_estimate( self, *, extra_headers: dict[str, str] | None = None ) -> GetSettlementEstimateResponse: """Async :meth:`MarginResource.settlement_estimate`.""" - self._require_session() data = await self._get("/margin/settlement_estimate", extra_headers=extra_headers) return GetSettlementEstimateResponse.model_validate(data) @@ -326,7 +310,6 @@ async def settlement_balance( self, *, extra_headers: dict[str, str] | None = None ) -> GetSettlementBalanceResponse: """Async :meth:`MarginResource.settlement_balance`.""" - self._require_session() data = await self._get("/margin/settlement_balance", extra_headers=extra_headers) return GetSettlementBalanceResponse.model_validate(data) @@ -334,7 +317,6 @@ async def guaranty_fund_balance( self, *, extra_headers: dict[str, str] | None = None ) -> GetGuarantyFundBalanceResponse: """Async :meth:`MarginResource.guaranty_fund_balance`.""" - self._require_session() data = await self._get("/margin/guaranty_fund_balance", extra_headers=extra_headers) return GetGuarantyFundBalanceResponse.model_validate(data) @@ -346,7 +328,6 @@ async def settlement_balance_history( extra_headers: dict[str, str] | None = None, ) -> Page[SettlementBalanceHistoryEntry]: """Async :meth:`MarginResource.settlement_balance_history`.""" - self._require_session() params = _params(limit=_validate_limit(limit, hi=500), cursor=cursor) return await self._list( "/margin/settlement_balance_history", @@ -365,7 +346,6 @@ def settlement_balance_history_all( extra_headers: dict[str, str] | None = None, ) -> AsyncIterator[SettlementBalanceHistoryEntry]: """Returns an async iterator over settlement-balance history — use ``async for``.""" - self._require_session() _validate_max_pages(max_pages) params = _params(limit=_validate_limit(limit, hi=500), cursor=None) return self._list_all( @@ -385,7 +365,6 @@ async def withdraw_settlement_balance( extra_headers: dict[str, str] | None = None, ) -> WithdrawSettlementBalanceResponse: """Async :meth:`MarginResource.withdraw_settlement_balance`.""" - self._require_session() req = WithdrawSettlementBalanceRequest(amount=to_decimal(amount)) data = await self._post( "/margin/withdraw_settlement_balance", @@ -398,7 +377,6 @@ async def settlement_balance_withdrawal( self, *, id: str, extra_headers: dict[str, str] | None = None ) -> GetSettlementBalanceWithdrawalResponse: """Async :meth:`MarginResource.settlement_balance_withdrawal`.""" - self._require_session() params = _params(id=id) data = await self._get( "/margin/settlement_balance_withdrawal", diff --git a/kalshi/perps/models/markets.py b/kalshi/perps/models/markets.py index aa6e71b..56664c1 100644 --- a/kalshi/perps/models/markets.py +++ b/kalshi/perps/models/markets.py @@ -49,6 +49,9 @@ class MarginMarket(BaseModel): fractional_trading_enabled: bool leverage_estimate: MultiplierDecimal | None = None + # Leverage (1 / margin_rate) keyed by notional position size in dollars + # ("1000", "10000", ...). Null when margin config or price data is missing. + leverage_estimates: dict[str, MultiplierDecimal] | None = None price: DollarDecimal | None = None bid: DollarDecimal | None = None ask: DollarDecimal | None = None @@ -56,14 +59,30 @@ class MarginMarket(BaseModel): default=None, validation_alias=AliasChoices("volume_fp", "volume"), ) + volume_notional_value: DollarDecimal | None = Field( + default=None, + validation_alias=AliasChoices("volume_notional_value_dollars", "volume_notional_value"), + ) volume_24h: FixedPointCount | None = Field( default=None, validation_alias=AliasChoices("volume_24h_fp", "volume_24h"), ) + volume_24h_notional_value: DollarDecimal | None = Field( + default=None, + validation_alias=AliasChoices( + "volume_24h_notional_value_dollars", "volume_24h_notional_value" + ), + ) open_interest: FixedPointCount | None = Field( default=None, validation_alias=AliasChoices("open_interest_fp", "open_interest"), ) + open_interest_notional_value: DollarDecimal | None = Field( + default=None, + validation_alias=AliasChoices( + "open_interest_notional_value_dollars", "open_interest_notional_value" + ), + ) model_config = {"extra": "allow", "populate_by_name": True} @@ -156,9 +175,21 @@ class MarginMarketCandlestick(BaseModel): volume: FixedPointCount = Field( validation_alias=AliasChoices("volume_fp", "volume"), ) + # Required (no default): the candlestick spec lists both notional fields under + # ``required``, they are inherent to a settled historical record, and the perps + # response-drift test hard-fails if a spec-required field is modeled optional. + # (Contrast the live ``MarginTickerPayload``, where they are optional.) + volume_notional_value: DollarDecimal = Field( + validation_alias=AliasChoices("volume_notional_value_dollars", "volume_notional_value"), + ) open_interest: FixedPointCount = Field( validation_alias=AliasChoices("open_interest_fp", "open_interest"), ) + open_interest_notional_value: DollarDecimal = Field( + validation_alias=AliasChoices( + "open_interest_notional_value_dollars", "open_interest_notional_value" + ), + ) model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/perps/models/order_groups.py b/kalshi/perps/models/order_groups.py index fb30ea6..2f58385 100644 --- a/kalshi/perps/models/order_groups.py +++ b/kalshi/perps/models/order_groups.py @@ -53,7 +53,7 @@ class GetOrderGroupResponse(BaseModel): class CreateOrderGroupResponse(BaseModel): """Spec ``CreateOrderGroupResponse`` — wraps the new group's id. - Required: ``order_group_id``, ``subaccount`` (0 for primary, 1-32 for + Required: ``order_group_id``, ``subaccount`` (0 for primary, 1-63 for subaccounts). The server echoes the routing context (subaccount + exchange shard) on the create response. """ diff --git a/kalshi/perps/models/transfers.py b/kalshi/perps/models/transfers.py index 54ea665..2dd37e6 100644 --- a/kalshi/perps/models/transfers.py +++ b/kalshi/perps/models/transfers.py @@ -62,7 +62,7 @@ class ApplySubaccountTransferRequest(BaseModel): ``amount_cents`` is integer **cents** (``int64``); pass ``500`` for $5.00, never a Decimal. ``from_subaccount``/``to_subaccount`` use ``0`` for the primary account and a positive integer for numbered subaccounts. Spec prose - says ``1-32`` but defines no JSON-schema maximum, so only the lower bound is + says ``1-63`` but defines no JSON-schema maximum, so only the lower bound is validated (``ge=0``) — server-assigned numbers always round-trip. """ diff --git a/kalshi/perps/resources/margin_account.py b/kalshi/perps/resources/margin_account.py index 114a249..4d871d0 100644 --- a/kalshi/perps/resources/margin_account.py +++ b/kalshi/perps/resources/margin_account.py @@ -1,15 +1,19 @@ -"""Perps margin-account resource — balance, risk, notional risk limit, fee tiers. - -Four read-only GET endpoints for the authenticated direct-margin user (#394). -All four carry a spec ``security`` block, so each method calls -``_require_auth()`` first — an unauthenticated caller gets ``AuthRequiredError`` -client-side instead of a server 401. None are paginated (no response carries a -cursor), so there are no ``list_all()`` iterators. All retry on 429/502/503/504 -(GET). +"""Perps margin-account resource — balance, risk, notional risk limit, fee tiers, API limits. + +Read-only GET endpoints for the authenticated direct-margin user (#394). All +carry a spec ``security`` block, so each method calls ``_require_auth()`` first +— an unauthenticated caller gets ``AuthRequiredError`` client-side instead of a +server 401. None are paginated (no response carries a cursor), so there are no +``list_all()`` iterators. All retry on 429/502/503/504 (GET). + +``api_limits`` (``GET /account/limits/perps``) returns the Perps API tier limits +in the same shape as the prediction API's :class:`~kalshi.models.account.AccountApiLimits`, +so the SDK reuses that model rather than duplicating it. """ from __future__ import annotations +from kalshi.models.account import AccountApiLimits from kalshi.perps.models.margin_account import ( GetMarginBalanceResponse, GetMarginFeeTiersResponse, @@ -52,6 +56,16 @@ def fee_tiers( data = self._get("/margin/fee_tiers", extra_headers=extra_headers) return GetMarginFeeTiersResponse.model_validate(data) + def api_limits(self, *, extra_headers: dict[str, str] | None = None) -> AccountApiLimits: + """Perps (margin) API tier limits for the authenticated user. + + ``GET /account/limits/perps``. Same response shape as the prediction + API's ``GET /account/limits`` (:class:`~kalshi.models.account.AccountApiLimits`). + """ + self._require_auth() + data = self._get("/account/limits/perps", extra_headers=extra_headers) + return AccountApiLimits.model_validate(data) + class AsyncMarginAccountResource(AsyncResource): """Async perps margin-account API (balance / risk / notional_risk_limit / fee_tiers).""" @@ -85,3 +99,15 @@ async def fee_tiers( self._require_auth() data = await self._get("/margin/fee_tiers", extra_headers=extra_headers) return GetMarginFeeTiersResponse.model_validate(data) + + async def api_limits( + self, *, extra_headers: dict[str, str] | None = None + ) -> AccountApiLimits: + """Perps (margin) API tier limits for the authenticated user. + + ``GET /account/limits/perps``. Same response shape as the prediction + API's ``GET /account/limits`` (:class:`~kalshi.models.account.AccountApiLimits`). + """ + self._require_auth() + data = await self._get("/account/limits/perps", extra_headers=extra_headers) + return AccountApiLimits.model_validate(data) diff --git a/kalshi/perps/resources/transfers.py b/kalshi/perps/resources/transfers.py index 6a2206c..ce9d8df 100644 --- a/kalshi/perps/resources/transfers.py +++ b/kalshi/perps/resources/transfers.py @@ -7,10 +7,10 @@ spec marks this endpoint **"currently not available"**; it is implemented for forward compatibility and may return an error until the server enables it. - ``create_subaccount`` — ``POST /portfolio/margin/subaccounts`` (no request - body; returns the new subaccount number, HTTP 201). Max 32 subaccounts/user, + body; returns the new subaccount number, HTTP 201). Max 63 subaccounts/user, numbered sequentially from 1. - ``transfer_subaccount`` — ``POST /portfolio/margin/subaccounts/transfer`` - (move funds between subaccounts ``0``-``32``; returns an empty body -> ``None``). + (move funds between subaccounts ``0``-``63``; returns an empty body -> ``None``). All three require RSA-PSS auth and are guarded client-side with ``_require_auth()`` so an unauthenticated caller gets ``AuthRequiredError`` diff --git a/kalshi/perps/ws/models/ticker.py b/kalshi/perps/ws/models/ticker.py index 7ed90b5..aad68ca 100644 --- a/kalshi/perps/ws/models/ticker.py +++ b/kalshi/perps/ws/models/ticker.py @@ -70,6 +70,28 @@ class MarginTickerPayload(BaseModel): volume: FixedPointCount volume_24h: FixedPointCount open_interest: FixedPointCount + # Notional dollar values. The AsyncAPI marks these required, but they are + # newly added on a *live* streaming channel — modeling them optional avoids + # breaking an entire ticker subscription if a server omits them mid-rollout + # (the WS payload-drift guard checks name coverage, not required-ness, and + # the REST ``MarginMarket`` models the same fields optional). Short Python + # names accept the ``_dollars``-suffixed wire keys. + volume_notional_value: DollarDecimal | None = Field( + default=None, + validation_alias=AliasChoices("volume_notional_value_dollars", "volume_notional_value"), + ) + volume_24h_notional_value: DollarDecimal | None = Field( + default=None, + validation_alias=AliasChoices( + "volume_24h_notional_value_dollars", "volume_24h_notional_value" + ), + ) + open_interest_notional_value: DollarDecimal | None = Field( + default=None, + validation_alias=AliasChoices( + "open_interest_notional_value_dollars", "open_interest_notional_value" + ), + ) reference_price: TickerPrice | None = None settlement_mark_price: TickerPrice | None = None liquidation_mark_price: TickerPrice | None = None diff --git a/kalshi/resources/account.py b/kalshi/resources/account.py index 54fcda0..b05eabd 100644 --- a/kalshi/resources/account.py +++ b/kalshi/resources/account.py @@ -22,6 +22,19 @@ def endpoint_costs( data = self._get("/account/endpoint_costs", extra_headers=extra_headers) return AccountEndpointCosts.model_validate(data) + def upgrade(self, *, extra_headers: dict[str, str] | None = None) -> None: + """Request a permanent Advanced API usage-level grant. + + POST ``/account/api_usage_level/upgrade``. Requires that at least one of + the user's last 100 Predictions orders was API-created (else the server + returns 403). Returns nothing; inspect the result via :meth:`limits`. + """ + self._require_auth() + # Spec defines no requestBody; ``json={}`` forces Content-Type: + # application/json (httpx omits it for a bodyless POST, which demo + # rejects) — same workaround as ``subaccounts.create``. + self._post("/account/api_usage_level/upgrade", json={}, extra_headers=extra_headers) + class AsyncAccountResource(AsyncResource): """Async account API.""" @@ -38,3 +51,18 @@ async def endpoint_costs( self._require_auth() data = await self._get("/account/endpoint_costs", extra_headers=extra_headers) return AccountEndpointCosts.model_validate(data) + + async def upgrade(self, *, extra_headers: dict[str, str] | None = None) -> None: + """Request a permanent Advanced API usage-level grant. + + POST ``/account/api_usage_level/upgrade``. Requires that at least one of + the user's last 100 Predictions orders was API-created (else the server + returns 403). Returns nothing; inspect the result via :meth:`limits`. + """ + self._require_auth() + # Spec defines no requestBody; ``json={}`` forces Content-Type: + # application/json (httpx omits it for a bodyless POST, which demo + # rejects) — same workaround as ``subaccounts.create``. + await self._post( + "/account/api_usage_level/upgrade", json={}, extra_headers=extra_headers + ) diff --git a/kalshi/resources/subaccounts.py b/kalshi/resources/subaccounts.py index f63157c..e9b71c7 100644 --- a/kalshi/resources/subaccounts.py +++ b/kalshi/resources/subaccounts.py @@ -95,7 +95,7 @@ class SubaccountsResource(SyncResource): """Sync subaccounts API. Subaccount 0 is the primary account; positive integers identify numbered - subaccounts (spec prose says ``1-32`` but defines no JSON-schema upper + subaccounts (spec prose says ``1-63`` but defines no JSON-schema upper bound, and demo has been observed allocating numbers above 32). POST /portfolio/subaccounts spins up the next subaccount with an empty body (spec takes no request payload). diff --git a/kalshi/ws/channels.py b/kalshi/ws/channels.py index a8ebbb5..7fbd0f7 100644 --- a/kalshi/ws/channels.py +++ b/kalshi/ws/channels.py @@ -33,6 +33,7 @@ "shard_key", "send_initial_snapshot", "skip_ticker_ack", + "index_ids", ) # Per-channel allow-set used by ``Subscription.to_subscribe_params`` to reject @@ -58,6 +59,9 @@ "multivariate": frozenset(), "multivariate_market_lifecycle": frozenset(), "communications": frozenset({"shard_factor", "shard_key"}), + # CF Benchmarks index value feed: seeded with index_ids only — market_* + # params are not supported on this channel. + "cfbenchmarks_value": frozenset({"index_ids"}), } @@ -354,12 +358,21 @@ async def unsubscribe(self, client_id: int) -> None: async def update_subscription( self, client_id: int, - action: str, # "add_markets" or "delete_markets" + # markets: "add_markets"/"delete_markets"; cfbenchmarks_value: + # "subscribe_indices"/"unsubscribe_indices"/"indexlist". + action: str, market_tickers: list[str] | None = None, market_ids: list[str] | None = None, send_initial_snapshot: bool | None = None, + index_ids: list[str] | None = None, ) -> None: - """Add or remove markets from an existing subscription.""" + """Mutate an existing subscription. + + Markets channels take ``add_markets``/``delete_markets`` with + ``market_tickers``/``market_ids``. The ``cfbenchmarks_value`` channel + takes ``subscribe_indices``/``unsubscribe_indices`` with ``index_ids``, + or ``indexlist`` (no ids) to request the available index list. + """ sub = self._subscriptions.get(client_id) if not sub or sub.server_sid is None: raise KalshiSubscriptionError( @@ -377,6 +390,11 @@ async def update_subscription( params["market_ids"] = market_ids if send_initial_snapshot is not None: params["send_initial_snapshot"] = send_initial_snapshot + # Truthy check (like market_tickers/market_ids above): an empty list is + # intentionally omitted — subscribe_indices/unsubscribe_indices require + # >=1 id (server returns error code 24 "Index IDs required" otherwise). + if index_ids: + params["index_ids"] = index_ids cmd = {"id": msg_id, "cmd": "update_subscription", "params": params} await self._connection.send(cmd) diff --git a/kalshi/ws/client.py b/kalshi/ws/client.py index 9793412..85a31f5 100644 --- a/kalshi/ws/client.py +++ b/kalshi/ws/client.py @@ -27,6 +27,10 @@ from kalshi.ws.connection import ConnectionManager, ConnectionState from kalshi.ws.dispatch import MessageDispatcher from kalshi.ws.models.base import ErrorMessage +from kalshi.ws.models.cfbenchmarks import ( + CFBenchmarksIndexListMessage, + CFBenchmarksValueMessage, +) from kalshi.ws.models.communications import CommunicationsMessage from kalshi.ws.models.event_fee import EventFeeUpdateMessage from kalshi.ws.models.fill import FillMessage @@ -884,6 +888,26 @@ async def subscribe_communications( overflow=OverflowStrategy.DROP_OLDEST, maxsize=maxsize, ) + async def subscribe_cfbenchmarks_value( + self, *, index_ids: list[str] | None = None, maxsize: int = 1000, + ) -> AsyncIterator[CFBenchmarksValueMessage | CFBenchmarksIndexListMessage]: + """Subscribe to the auth-required ``cfbenchmarks_value`` index feed. + + Seed ``index_ids`` (e.g. ``["BRTI", "ETHUSD_RTI"]`` or ``["all"]``) to + receive values immediately; subscribing with no ids yields nothing until + indices are added. The stream yields both ``cfbenchmarks_value`` data + messages and ``cfbenchmarks_value_indexlist`` control responses, so + discriminate with ``isinstance(msg, CFBenchmarksValueMessage)`` (or check + ``msg.type``) before reading ``msg.msg``. + """ + params: dict[str, Any] = {} + if index_ids: + params["index_ids"] = index_ids + return await self._do_subscribe( + "cfbenchmarks_value", params=params, + overflow=OverflowStrategy.DROP_OLDEST, maxsize=maxsize, + ) + # ------------------------------------------------------------------ # Generic subscribe # ------------------------------------------------------------------ diff --git a/kalshi/ws/dispatch.py b/kalshi/ws/dispatch.py index 64485d2..a7ed805 100644 --- a/kalshi/ws/dispatch.py +++ b/kalshi/ws/dispatch.py @@ -9,6 +9,10 @@ from kalshi.ws.channels import SubscriptionManager from kalshi.ws.models.base import ErrorMessage, ErrorPayload +from kalshi.ws.models.cfbenchmarks import ( + CFBenchmarksIndexListMessage, + CFBenchmarksValueMessage, +) from kalshi.ws.models.communications import CommunicationsMessage from kalshi.ws.models.event_fee import EventFeeUpdateMessage from kalshi.ws.models.fill import FillMessage @@ -40,6 +44,8 @@ "multivariate_lookup": MultivariateMessage, "multivariate_market_lifecycle": MultivariateLifecycleMessage, "communications": CommunicationsMessage, + "cfbenchmarks_value": CFBenchmarksValueMessage, + "cfbenchmarks_value_indexlist": CFBenchmarksIndexListMessage, } # Control message types (not routed to subscription queues) diff --git a/kalshi/ws/models/__init__.py b/kalshi/ws/models/__init__.py index f3bb6b8..d72c36e 100644 --- a/kalshi/ws/models/__init__.py +++ b/kalshi/ws/models/__init__.py @@ -8,6 +8,13 @@ SubscriptionInfo, UnsubscribedMessage, ) +from kalshi.ws.models.cfbenchmarks import ( + CFBenchmarksAvgData, + CFBenchmarksIndexListMessage, + CFBenchmarksIndexListPayload, + CFBenchmarksValueMessage, + CFBenchmarksValuePayload, +) from kalshi.ws.models.communications import ( CommunicationsMessage, QuoteAcceptedPayload, @@ -52,6 +59,12 @@ __all__ = [ # Base envelope "BaseMessage", + # CF Benchmarks value feed + "CFBenchmarksAvgData", + "CFBenchmarksIndexListMessage", + "CFBenchmarksIndexListPayload", + "CFBenchmarksValueMessage", + "CFBenchmarksValuePayload", # Communications "CommunicationsMessage", "ErrorMessage", diff --git a/kalshi/ws/models/cfbenchmarks.py b/kalshi/ws/models/cfbenchmarks.py new file mode 100644 index 0000000..a8d7d9e --- /dev/null +++ b/kalshi/ws/models/cfbenchmarks.py @@ -0,0 +1,88 @@ +"""CF Benchmarks value channel message models (``cfbenchmarks_value``). + +The auth-required ``cfbenchmarks_value`` channel streams CF Benchmarks reference +index values (e.g. ``BRTI``) keyed by ``index_id``, each carrying the raw upstream +frame plus a trailing 60-second average and — only in the final minute before a +quarter-hour close — a quarter-hour windowed average. The ``indexlist`` action +yields a separate ``cfbenchmarks_value_indexlist`` message listing available IDs. + +Two inbound message types arrive on the same subscription (both carry ``sid``): +``cfbenchmarks_value`` (data) and ``cfbenchmarks_value_indexlist`` (the response +to an ``indexlist`` ``update_subscription`` action). +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + +from kalshi.types import DollarDecimal + + +class CFBenchmarksAvgData(BaseModel): + """Windowed-average metadata for a CF Benchmarks index value. + + ``value`` is an exact 8-dp decimal string. The tracked indices (e.g. ``BRTI``, + ``ETHUSD_RTI``) are USD-denominated reference rates, so it is typed + :data:`~kalshi.types.DollarDecimal` — exact ``Decimal`` from the string, no + binary-float drift. ``window_size`` counts the ticks in the window; + ``window_start_ts_ms``/``window_end_ts_exclusive`` bound it in Unix + milliseconds (end-exclusive). + """ + + value: DollarDecimal + window_size: int + window_start_ts_ms: int + window_end_ts_exclusive: int + model_config = {"extra": "allow", "populate_by_name": True} + + +class CFBenchmarksValuePayload(BaseModel): + """``cfbenchmarks_value.msg`` — one index value with trailing averages. + + ``data`` is the raw CF Benchmarks JSON frame as a string (upstream-defined and + index-specific, so it is left unparsed — call ``json.loads(msg.data)`` to read + it). ``avg_60s_data`` is always present (trailing 60-second average). + ``last_60s_windowed_average_15min`` is present only in the final minute before a + quarter-hour close (``:00``/``:15``/``:30``/``:45``), so it is optional. + """ + + index_id: str + received_at: int + data: str + avg_60s_data: CFBenchmarksAvgData + last_60s_windowed_average_15min: CFBenchmarksAvgData | None = None + model_config = {"extra": "allow", "populate_by_name": True} + + +class CFBenchmarksValueMessage(BaseModel): + """``cfbenchmarks_value`` data message envelope.""" + + type: Literal["cfbenchmarks_value"] = "cfbenchmarks_value" + sid: int + seq: int | None = None + msg: CFBenchmarksValuePayload + model_config = {"extra": "allow", "populate_by_name": True} + + +class CFBenchmarksIndexListPayload(BaseModel): + """``cfbenchmarks_value_indexlist.msg`` — the available index IDs.""" + + index_ids: list[str] + model_config = {"extra": "allow", "populate_by_name": True} + + +class CFBenchmarksIndexListMessage(BaseModel): + """``cfbenchmarks_value_indexlist`` message — response to the ``indexlist`` action. + + ``id`` echoes the command id that requested the list; it is absent on + unsolicited frames, so it is optional. + """ + + type: Literal["cfbenchmarks_value_indexlist"] = "cfbenchmarks_value_indexlist" + id: int | None = None + sid: int + seq: int | None = None + msg: CFBenchmarksIndexListPayload + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/pyproject.toml b/pyproject.toml index 8093f30..b590260 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "3.3.0" +version = "4.0.0" description = "A professional Python SDK for the Kalshi prediction markets and Perps (margin) APIs" readme = "README.md" license = { text = "MIT" } diff --git a/specs/asyncapi.yaml b/specs/asyncapi.yaml index 2e38715..4e9cec0 100644 --- a/specs/asyncapi.yaml +++ b/specs/asyncapi.yaml @@ -133,6 +133,8 @@ channels: $ref: '#/components/messages/updateSubscriptionDeleteCommand' updateSubscriptionSingleSidCommand: $ref: '#/components/messages/updateSubscriptionSingleSidCommand' + cfbenchmarksUpdateSubscriptionCommand: + $ref: '#/components/messages/cfbenchmarksUpdateSubscriptionCommand' listSubscriptionsCommand: $ref: '#/components/messages/listSubscriptionsCommand' subscribedResponse: @@ -459,6 +461,100 @@ channels: messages: userOrder: $ref: '#/components/messages/userOrder' + cfbenchmarks_value: + address: cfbenchmarks_value + title: CF Benchmarks Value Feed + description: > + Real-time CF Benchmarks index value updates, each carrying the raw + upstream frame plus trailing 60-second and quarter-hour final-minute + averages. Requires authentication. + + + **Requirements:** + + - Authentication required + + - Index specification via `index_ids` (array of CF Benchmarks index IDs, + for example `["BRTI", "ETHUSD_RTI"]`) + + - `market_ticker`/`market_tickers`/`market_id`/`market_ids` are not + supported for this channel + + - You can seed `index_ids` in the initial subscribe, or subscribe first + and add indices later + + - Use `index_ids: ["all"]` to receive every available index + + - Supports `update_subscription` with `subscribe_indices` / + `unsubscribe_indices` / `indexlist` actions + + - `indexlist` returns the available index IDs (as a + `cfbenchmarks_value_indexlist` message) without modifying the subscription + + - Ticks are emitted roughly once per second; duplicate or out-of-order + upstream source timestamps are ignored + + + **Use case:** Consuming CF Benchmarks reference index values and their + short-window averages + + + **Subscription workflow:** + + 1. Subscribe to `cfbenchmarks_value` (optionally seeding `index_ids`). A + successful subscribe returns a `subscribed` response with the assigned + `sid`. + + 2. Discover available index IDs with the `indexlist` action; the server + replies with a `cfbenchmarks_value_indexlist` message. + + 3. Add or remove tracked index IDs with `subscribe_indices` / + `unsubscribe_indices`, or use `index_ids: ["all"]` to track everything. + + + **Averaging semantics:** + + + `avg_60s_data` (always present): + + - Window is trailing and per tick: `[source_ts_ms - 60000, source_ts_ms)` + + - `window_size` counts prior ticks only + + - If there are no prior ticks in the trailing window, the average falls + back to the current tick value + + + `last_60s_windowed_average_15min` (present only in the final minute before + quarter-hour close: `:00`, `:15`, `:30`, `:45`): + + - Active accumulation window is `(quarter_close_ts_ms - 60000, + quarter_close_ts_ms]` + + - The start-boundary tick is excluded and the close tick is included + + - This produces second-indexed counts: `:01 -> 1`, `:14 -> 14`, `:59 -> + 59`, close tick (`:00/:15/:30/:45`) -> `60` + + - The field is omitted outside that final-minute window + + + **Integration notes:** + + - If you subscribe without any `index_ids`, no value events flow until you + add indices or switch to `["all"]` + + - `sid` identifies the subscription stream; use it for + `update_subscription` and `unsubscribe` + + - Missing `index_ids` for `subscribe_indices`/`unsubscribe_indices` + returns an `error` with `code: 24` ("Index IDs required"); unsupported + actions return a standard websocket `error` + messages: + cfbenchmarksValue: + $ref: '#/components/messages/cfbenchmarksValue' + cfbenchmarksIndexList: + $ref: '#/components/messages/cfbenchmarksIndexList' operations: sendPing: action: receive @@ -540,6 +636,18 @@ operations: - $ref: '#/channels/root/messages/updateSubscriptionSingleSidCommand' tags: - name: commands + sendCFBenchmarksUpdateSubscription: + action: receive + title: Update Subscription - CF Benchmarks Indices + summary: >- + Add or remove tracked index IDs, or list available indices, on a + cfbenchmarks_value subscription + channel: + $ref: '#/channels/root' + messages: + - $ref: '#/channels/root/messages/cfbenchmarksUpdateSubscriptionCommand' + tags: + - name: commands receivePing: action: send title: Receive Ping @@ -814,6 +922,28 @@ operations: - $ref: '#/channels/user_orders/messages/userOrder' tags: - name: private + receiveCFBenchmarksValue: + action: send + title: CF Benchmarks Value Update + summary: Receive real-time CF Benchmarks index values with trailing averages + channel: + $ref: '#/channels/cfbenchmarks_value' + messages: + - $ref: '#/channels/cfbenchmarks_value/messages/cfbenchmarksValue' + tags: + - name: market-data + receiveCFBenchmarksIndexList: + action: send + title: CF Benchmarks Index List + summary: >- + Receive the set of available CF Benchmarks index IDs in response to an + indexlist action + channel: + $ref: '#/channels/cfbenchmarks_value' + messages: + - $ref: '#/channels/cfbenchmarks_value/messages/cfbenchmarksIndexList' + tags: + - name: market-data components: messages: incomingPing: @@ -937,6 +1067,17 @@ components: market_tickers: - FED-23DEC-T3.00 send_initial_snapshot: true + - name: subscribeCFBenchmarksValue + summary: Subscribe to cfbenchmarks_value, seeding index IDs + payload: + id: 9 + cmd: subscribe + params: + channels: + - cfbenchmarks_value + index_ids: + - BRTI + - ETHUSD_RTI unsubscribeCommand: name: unsubscribe title: Unsubscribe Command @@ -1046,6 +1187,54 @@ components: - NEW-MARKET-3 - NEW-MARKET-4 action: add_markets + cfbenchmarksUpdateSubscriptionCommand: + name: cfbenchmarks_update_subscription + title: Update Subscription - CF Benchmarks Indices + summary: >- + Add or remove tracked index IDs, or list available indices, on a + cfbenchmarks_value subscription + contentType: application/json + payload: + $ref: '#/components/schemas/cfbenchmarksUpdateSubscriptionCommandPayload' + examples: + - name: indexList + summary: List the available index IDs (does not modify the subscription) + payload: + id: 2 + cmd: update_subscription + params: + sid: 1 + action: indexlist + - name: subscribeIndices + summary: Add index IDs to the subscription + payload: + id: 3 + cmd: update_subscription + params: + sid: 1 + action: subscribe_indices + index_ids: + - BRTI + - name: unsubscribeIndices + summary: Remove index IDs from the subscription + payload: + id: 4 + cmd: update_subscription + params: + sid: 1 + action: unsubscribe_indices + index_ids: + - BRTI + - name: subscribeAllIndices + summary: Receive all available index IDs + payload: + id: 5 + cmd: update_subscription + params: + sid: 1 + action: subscribe_indices + index_ids: + - all subscribedResponse: name: subscribed title: Subscribed Response @@ -1180,6 +1369,14 @@ components: | 21 | shard_key must be >= 0 and < shard_factor | Invalid shard_key | | 22 | shard_factor must be <= 100 | shard_factor too large | + + | 23 | Match IDs required | Missing match_ids for the channel/action | + + | 24 | Index IDs required | Missing index_ids for + subscribe_indices/unsubscribe_indices on cfbenchmarks_value | + + | 25 | Subscription buffer overflow | The subscription's outbound buffer + was exceeded | contentType: application/json payload: $ref: '#/components/schemas/errorResponsePayload' @@ -1298,6 +1495,60 @@ components: side: 'yes' ts: '2022-11-22T20:44:01Z' ts_ms: 1669149841000 + cfbenchmarksValue: + name: cfbenchmarks_value + title: CF Benchmarks Value Update + summary: >- + Real-time CF Benchmarks index value with trailing 60-second and + quarter-hour averages + contentType: application/json + payload: + $ref: '#/components/schemas/cfbenchmarksValuePayload' + examples: + - name: cfbenchmarksValueUpdate + summary: >- + CF Benchmarks value update with 60s and final-minute quarter-hour + averages + payload: + type: cfbenchmarks_value + sid: 1 + seq: 42 + msg: + index_id: BRTI + received_at: 1710000000123 + data: >- + {"type":"value","id":"BRTI","time":1710000000123,"value":"68000.12"} + avg_60s_data: + value: '68000.12000000' + window_size: 3 + window_start_ts_ms: 1709999940123 + window_end_ts_exclusive: 1710000000123 + last_60s_windowed_average_15min: + value: '68000.23000000' + window_size: 14 + window_start_ts_ms: 1709999980000 + window_end_ts_exclusive: 1710000000123 + cfbenchmarksIndexList: + name: cfbenchmarks_value_indexlist + title: CF Benchmarks Index List + summary: >- + The set of available CF Benchmarks index IDs, sent in response to an + indexlist action + contentType: application/json + payload: + $ref: '#/components/schemas/cfbenchmarksIndexListPayload' + examples: + - name: indexListResponse + summary: Available index IDs + payload: + type: cfbenchmarks_value_indexlist + id: 2 + sid: 1 + seq: 1 + msg: + index_ids: + - BRTI + - ETHUSD_RTI ticker: name: ticker title: Ticker Update @@ -1825,6 +2076,7 @@ components: - communications - order_group_updates - user_orders + - cfbenchmarks_value minItems: 1 market_ticker: description: >- @@ -1908,6 +2160,16 @@ components: Shard key for communications channel fanout (requires shard_factor) minimum: 0 + index_ids: + type: array + description: >- + cfbenchmarks_value channel only. CF Benchmarks index IDs to seed + on the initial subscribe (omit to subscribe with no indices and + add them later via update_subscription; use ["all"] to track + every available index). + items: + type: string + minItems: 1 unsubscribeCommandPayload: type: object required: @@ -1989,6 +2251,59 @@ components: - add_markets - delete_markets - get_snapshot + cfbenchmarksUpdateSubscriptionCommandPayload: + type: object + required: + - id + - cmd + - params + properties: + id: + $ref: '#/components/schemas/commandId' + cmd: + type: string + const: update_subscription + params: + type: object + required: + - action + properties: + sid: + $ref: '#/components/schemas/subscriptionId' + description: Single subscription ID to update (alternative to sids array) + sids: + type: array + description: >- + Array containing exactly one subscription ID (alternative to + sid). Either sid or sids must be provided, not both. + items: + $ref: '#/components/schemas/subscriptionId' + minItems: 1 + maxItems: 1 + action: + type: string + description: > + - `subscribe_indices`: add the supplied `index_ids` to the + subscription (requires `index_ids`) + + - `unsubscribe_indices`: remove the supplied `index_ids` from + the subscription (requires `index_ids`) + + - `indexlist`: respond with the available index IDs without + modifying the subscription + enum: + - subscribe_indices + - unsubscribe_indices + - indexlist + index_ids: + type: array + description: >- + CF Benchmarks index IDs to add or remove. Use ["all"] to track + every available index. Required for subscribe_indices and + unsubscribe_indices. + items: + type: string + minItems: 1 subscribedResponsePayload: type: object required: @@ -2125,8 +2440,17 @@ components: shard_key - 22: shard_factor must be <= 100 - shard_factor too large + + - 23: Match IDs required - Missing match_ids for the + channel/action + + - 24: Index IDs required - Missing index_ids for + subscribe_indices/unsubscribe_indices on cfbenchmarks_value + + - 25: Subscription buffer overflow - The subscription's outbound + buffer was exceeded minimum: 1 - maximum: 22 + maximum: 25 msg: type: string description: Human-readable error message @@ -2173,6 +2497,111 @@ components: description: Name of the subscribed channel sid: $ref: '#/components/schemas/subscriptionId' + cfbenchmarksAvgData: + type: object + description: Windowed-average metadata for a CF Benchmarks index value. + required: + - value + - window_size + - window_start_ts_ms + - window_end_ts_exclusive + properties: + value: + type: string + description: Average value over the window, formatted to 8 decimal places + window_size: + type: integer + description: Number of ticks counted in the window + minimum: 0 + window_start_ts_ms: + type: integer + description: Window start boundary (unix ms) + window_end_ts_exclusive: + type: integer + description: Window end boundary, exclusive (unix ms) + cfbenchmarksValuePayload: + type: object + required: + - type + - sid + - seq + - msg + properties: + type: + type: string + const: cfbenchmarks_value + sid: + $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' + msg: + type: object + required: + - index_id + - received_at + - data + - avg_60s_data + properties: + index_id: + type: string + description: CF Benchmarks index ID (for example "BRTI") + received_at: + type: integer + description: When Kalshi received the upstream frame (unix ms) + data: + type: string + description: The raw CF Benchmarks JSON frame, as a string + avg_60s_data: + $ref: '#/components/schemas/cfbenchmarksAvgData' + description: > + Trailing 60-second average metadata. The window is per-tick and + trailing: + + `[source_ts_ms - 60000, source_ts_ms)`, and `window_size` counts + prior ticks only. + + If there are no prior ticks in the trailing window, the average + falls back to the current tick value. + last_60s_windowed_average_15min: + $ref: '#/components/schemas/cfbenchmarksAvgData' + description: > + Optional - present only during the final minute before + quarter-hour close (:00, :15, :30, :45). + + The accumulation window is `(quarter_close_ts_ms - 60000, + quarter_close_ts_ms]` (start boundary + + tick excluded, close tick included), producing second-indexed + counts up to 60 at close. Omitted + + outside that final-minute window. + cfbenchmarksIndexListPayload: + type: object + required: + - type + - sid + - seq + - msg + properties: + type: + type: string + const: cfbenchmarks_value_indexlist + id: + $ref: '#/components/schemas/commandId' + sid: + $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' + msg: + type: object + required: + - index_ids + properties: + index_ids: + type: array + description: Available CF Benchmarks index IDs + items: + type: string orderbookSnapshotPayload: type: object required: @@ -3155,7 +3584,7 @@ components: format: int64 subaccount_number: type: integer - description: Subaccount number (0 for primary, 1-32 for subaccounts) + description: Subaccount number (0 for primary, 1-63 for subaccounts) rfqCreatedPayload: type: object required: @@ -3510,3 +3939,14 @@ x-error-codes: - code: 22 name: shard_factor must be <= 100 description: shard_factor too large + - code: 23 + name: Match IDs required + description: Missing match_ids for the channel/action + - code: 24 + name: Index IDs required + description: >- + Missing index_ids for subscribe_indices/unsubscribe_indices on + cfbenchmarks_value + - code: 25 + name: Subscription buffer overflow + description: The subscription's outbound buffer was exceeded diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 9a42263..12ec60b 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -1042,6 +1042,15 @@ paths: operationId: CreateOrder summary: Create Order description: ' Endpoint for submitting orders in a market. Each user is limited to 200 000 open orders at a time.' + x-mint: + content: > + + + **Rate limit:** 100 tokens per request. See `GET + /trade-api/v2/account/endpoint_costs` for current non-default endpoint + costs. + + tags: - orders security: @@ -1080,9 +1089,9 @@ paths: content: > - **Rate limit:** 2 tokens per request. Other endpoints use the default - cost of 10 tokens per request unless noted on their own page. See - [Rate Limits and Tiers](/getting_started/rate_limits). + **Rate limit:** 2 tokens per request. See `GET + /trade-api/v2/account/endpoint_costs` for current non-default endpoint + costs. tags: @@ -1114,9 +1123,9 @@ paths: content: > - **Rate limit:** 2 tokens per request. Other endpoints use the default - cost of 10 tokens per request unless noted on their own page. See - [Rate Limits and Tiers](/getting_started/rate_limits). + **Rate limit:** 2 tokens per request. See `GET + /trade-api/v2/account/endpoint_costs` for current non-default endpoint + costs. tags: @@ -1155,9 +1164,9 @@ paths: **Rate limit:** 10 tokens per order in the batch — billed per item, so - total cost for a batch of N orders is N × 10. Other endpoints cost 10 - tokens per request (not per item) unless noted on their own page. See - [Rate Limits and Tiers](/getting_started/rate_limits). + total cost for a batch of N orders is N × 10. See `GET + /trade-api/v2/account/endpoint_costs` for current non-default endpoint + costs. tags: @@ -1199,9 +1208,9 @@ paths: **Rate limit:** 2 tokens per order in the batch — billed per item, so - total cost for a batch of N cancels is N × 2. Other endpoints cost 10 - tokens per request unless noted on their own page. See [Rate Limits - and Tiers](/getting_started/rate_limits). + total cost for a batch of N cancels is N × 2. See `GET + /trade-api/v2/account/endpoint_costs` for current non-default endpoint + costs. tags: @@ -1280,6 +1289,15 @@ paths: operationId: DecreaseOrder summary: Decrease Order description: ' Endpoint for decreasing the number of contracts in an existing order. This is the only kind of edit available on order quantity. Cancelling an order is equivalent to decreasing an order amount to zero.' + x-mint: + content: > + + + **Rate limit:** 100 tokens per request. See `GET + /trade-api/v2/account/endpoint_costs` for current non-default endpoint + costs. + + tags: - orders security: @@ -1424,9 +1442,9 @@ paths: **Rate limit:** 10 tokens per order in the batch — billed per item, so - total cost for a batch of N orders is N × 10. Other endpoints cost 10 - tokens per request (not per item) unless noted on their own page. See - [Rate Limits and Tiers](/getting_started/rate_limits). + total cost for a batch of N orders is N × 10. See `GET + /trade-api/v2/account/endpoint_costs` for current non-default endpoint + costs. tags: @@ -1468,9 +1486,9 @@ paths: **Rate limit:** 2 tokens per order in the batch — billed per item, so - total cost for a batch of N cancels is N × 2. Other endpoints cost 10 - tokens per request unless noted on their own page. See [Rate Limits - and Tiers](/getting_started/rate_limits). + total cost for a batch of N cancels is N × 2. See `GET + /trade-api/v2/account/endpoint_costs` for current non-default endpoint + costs. tags: @@ -1508,6 +1526,15 @@ paths: Endpoint for cancelling event-market orders using the V2 response shape. Returns `{order_id, client_order_id, reduced_by}` rather than a full order object. + x-mint: + content: > + + + **Rate limit:** 2 tokens per request. See `GET + /trade-api/v2/account/endpoint_costs` for current non-default endpoint + costs. + + tags: - orders security: @@ -1863,8 +1890,8 @@ paths: description: >- Creates a new subaccount for the authenticated user. This endpoint is currently only available to institutions and market makers. Subaccounts - are numbered sequentially starting from 1. Maximum 32 subaccounts per - user. + are numbered sequentially starting from 1. Maximum 63 numbered + subaccounts per user (64 including the primary account). tags: - portfolio security: @@ -1890,7 +1917,7 @@ paths: summary: Transfer Between Subaccounts description: >- Transfers funds between the authenticated user's subaccounts. Use 0 for - the primary account, or 1-32 for numbered subaccounts. + the primary account, or 1-63 for numbered subaccounts. tags: - portfolio security: @@ -1971,7 +1998,7 @@ paths: summary: Update Subaccount Netting description: >- Updates the netting enabled setting for a specific subaccount. Use 0 for - the primary account, or 1-32 for numbered subaccounts. + the primary account, or 1-63 for numbered subaccounts. tags: - portfolio security: @@ -2450,9 +2477,9 @@ paths: content: > - **Rate limit:** 2 tokens per request. Other endpoints use the default - cost of 10 tokens per request unless noted on their own page. See - [Rate Limits and Tiers](/getting_started/rate_limits). + **Rate limit:** 2 tokens per request. See `GET + /trade-api/v2/account/endpoint_costs` for current non-default endpoint + costs. tags: @@ -2514,9 +2541,9 @@ paths: content: > - **Rate limit:** 2 tokens per request. Other endpoints use the default - cost of 10 tokens per request unless noted on their own page. See - [Rate Limits and Tiers](/getting_started/rate_limits). + **Rate limit:** 2 tokens per request. See `GET + /trade-api/v2/account/endpoint_costs` for current non-default endpoint + costs. tags: @@ -2728,6 +2755,45 @@ paths: description: Unauthorized '500': description: Internal server error + /account/api_usage_level/upgrade: + post: + operationId: UpgradeAccountApiUsageLevel + summary: Upgrade Account API Usage Level + description: >- + Grants a permanent Advanced API usage-level grant. Currently only the + Predictions exchange instance is supported. Criteria: at least 1 of the + user's last 100 Predictions orders was created via API. Use Get Account + API Limits to inspect the resulting usage tier and grants. + x-mint: + content: > + + + **Rate limit:** 30 tokens per request. See `GET + /trade-api/v2/account/endpoint_costs` for current non-default endpoint + costs. + + + tags: + - account + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + responses: + '201': + description: Advanced API usage-level grant created or refreshed successfully + '401': + description: Unauthorized + '403': + description: >- + No API-created order was found in the user's latest 100 Predictions + orders + '429': + description: >- + Rate limit exceeded. This endpoint costs 30 tokens and uses the + Predictions Write bucket. + '500': + description: Internal server error /account/endpoint_costs: get: operationId: GetAccountEndpointCosts @@ -3309,9 +3375,9 @@ paths: - **Rate limit:** 2 tokens per request. Other endpoints use the default - cost of 10 tokens per request unless noted on their own page. See - [Rate Limits and Tiers](/getting_started/rate_limits). + **Rate limit:** 2 tokens per request. See `GET + /trade-api/v2/account/endpoint_costs` for current non-default endpoint + costs. tags: @@ -3911,9 +3977,8 @@ components: $ref: '#/components/schemas/ErrorResponse' RateLimitError: description: >- - Rate limit exceeded. The default cost is 10 tokens per request; - endpoints that deviate show a **Rate limit** callout at the top of their - own page. See [Rate Limits and Tiers](/getting_started/rate_limits). + Rate limit exceeded. The default cost is 10 tokens per request. Use GET + /trade-api/v2/account/endpoint_costs to list non-default endpoint costs. content: application/json: schema: @@ -4082,14 +4147,14 @@ components: name: subaccount in: query description: >- - Subaccount number (0 for primary, 1-32 for subaccounts). If omitted, + Subaccount number (0 for primary, 1-63 for subaccounts). If omitted, defaults to all subaccounts. schema: type: integer SubaccountQueryDefaultPrimary: name: subaccount in: query - description: Subaccount number (0 for primary, 1-32 for subaccounts). Defaults to 0. + description: Subaccount number (0 for primary, 1-63 for subaccounts). Defaults to 0. schema: type: integer ExchangeIndexQuery: @@ -4629,6 +4694,7 @@ components: - usage_tier - read - write + - grants properties: usage_tier: type: string @@ -4637,6 +4703,38 @@ components: $ref: '#/components/schemas/BucketLimit' write: $ref: '#/components/schemas/BucketLimit' + grants: + type: array + description: >- + The caller's active API usage level grants across exchange lanes, + where each grant applies to its exchange_instance and usage_tier + reflects the effective tier for the lane reported by this endpoint. + items: + $ref: '#/components/schemas/ApiUsageLevelGrant' + ApiUsageLevelGrant: + type: object + required: + - exchange_instance + - level + - source + properties: + exchange_instance: + $ref: '#/components/schemas/ExchangeInstance' + level: + type: string + description: API usage level this grant confers (e.g. premier, paragon, prime). + expires_ts: + type: integer + format: int64 + nullable: true + description: >- + Unix timestamp (seconds) when the grant expires. Absent for + permanent grants. + source: + type: string + description: >- + How the grant was created: "volume" (earned from trading volume) or + "manual" (assigned by Kalshi). EndpointTokenCost: type: object required: @@ -5179,7 +5277,7 @@ components: properties: subaccount_number: type: integer - description: The sequential number assigned to this subaccount (1-32). + description: The sequential number assigned to this subaccount (1-63). ApplySubaccountTransferRequest: type: object required: @@ -5197,12 +5295,12 @@ components: from_subaccount: type: integer description: >- - Source subaccount number (0 for primary, 1-32 for numbered + Source subaccount number (0 for primary, 1-63 for numbered subaccounts). to_subaccount: type: integer description: >- - Destination subaccount number (0 for primary, 1-32 for numbered + Destination subaccount number (0 for primary, 1-63 for numbered subaccounts). amount_cents: type: integer @@ -5229,7 +5327,7 @@ components: properties: subaccount_number: type: integer - description: Subaccount number (0 for primary, 1-32 for subaccounts). + description: Subaccount number (0 for primary, 1-63 for subaccounts). balance: $ref: '#/components/schemas/FixedPointDollars' description: Balance in dollars. @@ -5263,10 +5361,10 @@ components: description: Unique identifier for this transfer. from_subaccount: type: integer - description: Source subaccount number (0 for primary, 1-32 for subaccounts). + description: Source subaccount number (0 for primary, 1-63 for subaccounts). to_subaccount: type: integer - description: Destination subaccount number (0 for primary, 1-32 for subaccounts). + description: Destination subaccount number (0 for primary, 1-63 for subaccounts). amount_cents: type: integer format: int64 @@ -5283,7 +5381,7 @@ components: properties: subaccount_number: type: integer - description: Subaccount number (0 for primary, 1-32 for subaccounts). + description: Subaccount number (0 for primary, 1-63 for subaccounts). enabled: type: boolean description: Whether netting is enabled for this subaccount. @@ -5304,7 +5402,7 @@ components: properties: subaccount_number: type: integer - description: Subaccount number (0 for primary, 1-32 for subaccounts). + description: Subaccount number (0 for primary, 1-63 for subaccounts). enabled: type: boolean description: Whether netting is enabled for this subaccount. @@ -5671,7 +5769,7 @@ components: type: integer nullable: true x-omitempty: true - description: Subaccount number (0 for primary, 1-32 for subaccounts). + description: Subaccount number (0 for primary, 1-63 for subaccounts). exchange_index: allOf: - $ref: '#/components/schemas/ExchangeIndex' @@ -6200,7 +6298,7 @@ components: nullable: true x-omitempty: true description: >- - Subaccount number (0 for primary, 1-32 for subaccounts). Present for + Subaccount number (0 for primary, 1-63 for subaccounts). Present for direct users. ts: type: integer @@ -6369,7 +6467,7 @@ components: minimum: 0 description: >- Optional subaccount number to use for this order group (0 for - primary, 1-32 for subaccounts) + primary, 1-63 for subaccounts) default: 0 x-go-type-skip-optional-pointer: true contracts_limit: @@ -6434,7 +6532,7 @@ components: minimum: 0 description: >- Subaccount number that owns the created order group (0 for primary, - 1-32 for subaccounts). + 1-63 for subaccounts). x-go-type-skip-optional-pointer: true exchange_index: allOf: @@ -6554,17 +6652,16 @@ components: contracts: type: integer description: >- - The number of contracts for the RFQ. Whole contracts only. Contracts - may be provided via contracts or contracts_fp; if both provided they - must match. + Whole-contract count for the RFQ. Use contracts_fp for partial + contract values; if both are provided, they must match. x-go-type-skip-optional-pointer: true contracts_fp: $ref: '#/components/schemas/FixedPointCount' nullable: true description: >- - String representation of the number of contracts for the RFQ. - Contracts may be provided via contracts or contracts_fp; if both - provided they must match. + Fixed-point number of contracts for the RFQ. Supports partial + contracts in 0.01-contract increments; if contracts is also + provided, both values must match. target_cost_centi_cents: type: integer format: int64 @@ -6593,7 +6690,7 @@ components: type: integer description: >- The subaccount number to create the RFQ for (direct members only; 0 - for primary, 1-32 for subaccounts) + for primary, 1-63 for subaccounts) x-go-type-skip-optional-pointer: true CreateRFQResponse: type: object @@ -6782,7 +6879,7 @@ components: type: integer description: >- Optional subaccount number to place the quote under (0 for primary, - 1-32 for subaccounts) + 1-63 for subaccounts) CreateQuoteResponse: type: object required: @@ -7014,7 +7111,7 @@ components: default: 0 description: >- Optional subaccount number to use for this cancellation (0 for - primary, 1-32 for subaccounts) + primary, 1-63 for subaccounts) x-go-type-skip-optional-pointer: true exchange_index: allOf: @@ -7079,7 +7176,7 @@ components: minimum: 0 description: >- Optional subaccount number to use for this amendment (0 for primary, - 1-32 for subaccounts) + 1-63 for subaccounts) default: 0 x-go-type-skip-optional-pointer: true ticker: @@ -7166,7 +7263,7 @@ components: minimum: 0 description: >- Optional subaccount number to use for this decrease (0 for primary, - 1-32 for subaccounts) + 1-63 for subaccounts) default: 0 x-go-type-skip-optional-pointer: true reduce_by: @@ -7222,6 +7319,19 @@ components: - price - time_in_force - self_trade_prevention_type + example: + ticker: HIGHNY-24JAN01-T60 + client_order_id: 8c35ecb3-328f-4f52-8c7c-0f4b9862f8d1 + side: bid + count: '10.00' + price: '0.5600' + time_in_force: good_till_canceled + self_trade_prevention_type: taker_at_cross + post_only: false + cancel_order_on_pause: false + reduce_only: false + subaccount: 0 + exchange_index: 0 properties: ticker: type: string @@ -7320,6 +7430,12 @@ components: - fill_count - remaining_count - ts_ms + example: + order_id: 3b23c1c7-f4ef-4f0d-8b9a-9e53c61f1a0d + client_order_id: 8c35ecb3-328f-4f52-8c7c-0f4b9862f8d1 + fill_count: '0.00' + remaining_count: '10.00' + ts_ms: 1715793600123 properties: order_id: type: string @@ -7355,6 +7471,11 @@ components: - order_id - reduced_by - ts_ms + example: + order_id: 3b23c1c7-f4ef-4f0d-8b9a-9e53c61f1a0d + client_order_id: 8c35ecb3-328f-4f52-8c7c-0f4b9862f8d1 + reduced_by: '10.00' + ts_ms: 1715793660456 properties: order_id: type: string @@ -7373,6 +7494,9 @@ components: as Unix epoch milliseconds. DecreaseOrderV2Request: type: object + example: + reduce_by: '2.00' + exchange_index: 0 properties: reduce_by: $ref: '#/components/schemas/FixedPointCount' @@ -7397,6 +7521,11 @@ components: - order_id - remaining_count - ts_ms + example: + order_id: 3b23c1c7-f4ef-4f0d-8b9a-9e53c61f1a0d + client_order_id: 8c35ecb3-328f-4f52-8c7c-0f4b9862f8d1 + remaining_count: '8.00' + ts_ms: 1715793680789 properties: order_id: type: string @@ -7418,6 +7547,14 @@ components: - side - price - count + example: + ticker: HIGHNY-24JAN01-T60 + side: bid + price: '0.5700' + count: '8.00' + client_order_id: 8c35ecb3-328f-4f52-8c7c-0f4b9862f8d1 + updated_client_order_id: 2a0e3fc9-b593-4aa3-96e5-82f7f7566c2a + exchange_index: 0 properties: ticker: type: string @@ -7458,6 +7595,12 @@ components: required: - order_id - ts_ms + example: + order_id: 3b23c1c7-f4ef-4f0d-8b9a-9e53c61f1a0d + client_order_id: 2a0e3fc9-b593-4aa3-96e5-82f7f7566c2a + remaining_count: '8.00' + fill_count: '0.00' + ts_ms: 1715793690123 properties: order_id: type: string @@ -7503,6 +7646,24 @@ components: type: object required: - orders + example: + orders: + - ticker: HIGHNY-24JAN01-T60 + client_order_id: 8c35ecb3-328f-4f52-8c7c-0f4b9862f8d1 + side: bid + count: '10.00' + price: '0.5600' + time_in_force: good_till_canceled + self_trade_prevention_type: taker_at_cross + exchange_index: 0 + - ticker: HIGHNY-24JAN01-T60 + client_order_id: 2a0e3fc9-b593-4aa3-96e5-82f7f7566c2a + side: ask + count: '5.00' + price: '0.5800' + time_in_force: immediate_or_cancel + self_trade_prevention_type: maker + exchange_index: 0 properties: orders: type: array @@ -7514,6 +7675,20 @@ components: type: object required: - orders + example: + orders: + - order_id: 3b23c1c7-f4ef-4f0d-8b9a-9e53c61f1a0d + client_order_id: 8c35ecb3-328f-4f52-8c7c-0f4b9862f8d1 + fill_count: '0.00' + remaining_count: '10.00' + ts_ms: 1715793600123 + - order_id: a6d6010d-6d5f-40a1-a7e7-5501386bb621 + client_order_id: 2a0e3fc9-b593-4aa3-96e5-82f7f7566c2a + fill_count: '5.00' + remaining_count: '0.00' + average_fill_price: '0.5800' + average_fee_paid: '0.0012' + ts_ms: 1715793600456 properties: orders: type: array @@ -7565,6 +7740,14 @@ components: type: object required: - orders + example: + orders: + - order_id: 3b23c1c7-f4ef-4f0d-8b9a-9e53c61f1a0d + subaccount: 0 + exchange_index: 0 + - order_id: a6d6010d-6d5f-40a1-a7e7-5501386bb621 + subaccount: 0 + exchange_index: 0 properties: orders: type: array @@ -7587,7 +7770,7 @@ components: default: 0 description: >- Optional subaccount number to use for this cancellation (0 for - primary, 1-32 for subaccounts). + primary, 1-63 for subaccounts). x-go-type-skip-optional-pointer: true exchange_index: allOf: @@ -7598,6 +7781,16 @@ components: type: object required: - orders + example: + orders: + - order_id: 3b23c1c7-f4ef-4f0d-8b9a-9e53c61f1a0d + client_order_id: 8c35ecb3-328f-4f52-8c7c-0f4b9862f8d1 + reduced_by: '10.00' + ts_ms: 1715793660456 + - order_id: a6d6010d-6d5f-40a1-a7e7-5501386bb621 + client_order_id: 2a0e3fc9-b593-4aa3-96e5-82f7f7566c2a + reduced_by: '5.00' + ts_ms: 1715793660789 properties: orders: type: array diff --git a/specs/perps_asyncapi.yaml b/specs/perps_asyncapi.yaml index 1aa197e..2cf06ea 100644 --- a/specs/perps_asyncapi.yaml +++ b/specs/perps_asyncapi.yaml @@ -1009,8 +1009,11 @@ components: - ask_size_fp - last_trade_size_fp - volume + - volume_notional_value_dollars - volume_24h + - volume_24h_notional_value_dollars - open_interest + - open_interest_notional_value_dollars - ts_ms properties: market_ticker: @@ -1034,10 +1037,24 @@ components: type: string volume: type: string + description: One sided total trade volume in contracts. + volume_notional_value_dollars: + type: string + description: Total notional value of one sided trade volume in dollars. volume_24h: type: string + description: One sided trade volume in the last 24 hours in contracts. + volume_24h_notional_value_dollars: + type: string + description: >- + Total notional value of one sided trade volume in the last 24 + hours in dollars. open_interest: type: string + description: One sided open interest in contracts. + open_interest_notional_value_dollars: + type: string + description: Total notional value of one sided open interest in dollars. reference_price: description: Reference price of underlying asset, when available. allOf: diff --git a/specs/perps_openapi.yaml b/specs/perps_openapi.yaml index 3e46142..1168e3c 100644 --- a/specs/perps_openapi.yaml +++ b/specs/perps_openapi.yaml @@ -2,20 +2,43 @@ openapi: 3.0.0 info: title: Kalshi Trade API Manual Endpoints version: 0.0.1 - description: >- - Manually defined OpenAPI spec for endpoints being migrated to spec-first - approach + description: Manually defined OpenAPI spec for endpoints being migrated to spec-first approach + servers: - url: https://external-api.kalshi.com/trade-api/v2 description: Production perps REST API server - url: https://external-api.demo.kalshi.co/trade-api/v2 description: Demo perps REST API server + paths: + /account/limits/perps: + get: + operationId: GetPerpsAccountApiLimits + summary: Get Perps Account API Limits + description: ' Endpoint to retrieve the Perps (margin) API tier limits associated with the authenticated user.' + tags: + - account + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + responses: + '200': + description: Perps account API tier limits retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetAccountApiLimitsResponse' + '401': + description: Unauthorized + '500': + description: Internal server error + /margin/exchange/status: get: operationId: GetMarginExchangeStatus summary: Get Exchange Status - description: Endpoint for getting the margin exchange status. + description: 'Endpoint for getting the margin exchange status.' tags: - exchange responses: @@ -43,13 +66,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ExchangeStatus' + /margin/risk_parameters: get: operationId: GetMarginRiskParameters summary: Get Risk Parameters - description: >- - Returns system-wide margin risk parameters including liquidation - thresholds and per-market initial margin multipliers. + description: 'Returns system-wide margin risk parameters including liquidation thresholds and per-market initial margin multipliers.' tags: - risk responses: @@ -59,11 +81,12 @@ paths: application/json: schema: $ref: '#/components/schemas/GetMarginRiskParametersResponse' + /margin/orders: get: operationId: GetMarginOrders summary: Get Orders - description: Endpoint for listing margin orders with optional filtering. + description: 'Endpoint for listing margin orders with optional filtering.' tags: - orders security: @@ -75,7 +98,7 @@ paths: - $ref: '#/components/parameters/MinTsQuery' - $ref: '#/components/parameters/MaxTsQuery' - $ref: '#/components/parameters/StatusQuery' - - $ref: '#/components/parameters/LimitQuery' + - $ref: '#/components/parameters/MarginOrdersLimitQuery' - $ref: '#/components/parameters/CursorQuery' - $ref: '#/components/parameters/SubaccountQuery' responses: @@ -124,15 +147,14 @@ paths: $ref: '#/components/responses/RateLimitError' '500': $ref: '#/components/responses/InternalServerError' + /margin/fcm/orders: get: operationId: GetMarginFCMOrders summary: Get FCM Orders - description: > + description: | Endpoint for FCM members to get margin orders filtered by subtrader ID. - - This endpoint requires FCM member access level and allows filtering - margin orders by subtrader ID. + This endpoint requires FCM member access level and allows filtering margin orders by subtrader ID. tags: - fcm security: @@ -143,16 +165,14 @@ paths: - name: subtrader_id in: query required: true - description: >- - Restricts the response to margin orders for a specific subtrader - (FCM members only) + description: Restricts the response to margin orders for a specific subtrader (FCM members only) schema: type: string - $ref: '#/components/parameters/TickerQuery' - $ref: '#/components/parameters/MinTsQuery' - $ref: '#/components/parameters/MaxTsQuery' - $ref: '#/components/parameters/StatusQuery' - - $ref: '#/components/parameters/LimitQuery' + - $ref: '#/components/parameters/MarginOrdersLimitQuery' - $ref: '#/components/parameters/CursorQuery' responses: '200': @@ -167,6 +187,7 @@ paths: $ref: '#/components/responses/UnauthorizedError' '500': $ref: '#/components/responses/InternalServerError' + /margin/orders/{order_id}: get: operationId: GetMarginOrder @@ -193,12 +214,11 @@ paths: $ref: '#/components/responses/NotFoundError' '500': $ref: '#/components/responses/InternalServerError' + delete: operationId: CancelMarginOrder summary: Cancel Order - description: >- - Endpoint for canceling an order. Cancels all remaining resting contracts - and returns the canceled order details. + description: Endpoint for canceling an order. Cancels all remaining resting contracts and returns the canceled order details. tags: - orders security: @@ -221,14 +241,12 @@ paths: $ref: '#/components/responses/NotFoundError' '500': $ref: '#/components/responses/InternalServerError' + /margin/orders/{order_id}/decrease: post: operationId: DecreaseMarginOrder summary: Decrease Order - description: >- - Endpoint for decreasing the number of contracts in an existing order. - Exactly one of `reduce_by` or `reduce_to` must be provided. Canceling an - order is equivalent to decreasing to zero. + description: Endpoint for decreasing the number of contracts in an existing order. Exactly one of `reduce_by` or `reduce_to` must be provided. Canceling an order is equivalent to decreasing to zero. tags: - orders security: @@ -259,22 +277,16 @@ paths: $ref: '#/components/responses/NotFoundError' '500': $ref: '#/components/responses/InternalServerError' + /margin/orders/{order_id}/amend: post: operationId: AmendMarginOrder summary: Amend Order - description: >- - Endpoint for amending the price and/or max number of fillable contracts - in an existing margin order. + description: Endpoint for amending the price and/or max number of fillable contracts in an existing margin order. x-mint: - content: > + content: | - - Amending a resting order preserves queue position only when the - amendment decreases size. All other amendments — like increasing size - or changing price forfeit queue position and place the order at the - back of the queue. - + Amending a resting order preserves queue position only when the amendment decreases size. All other amendments — like increasing size or changing price forfeit queue position and place the order at the back of the queue. tags: - orders @@ -306,6 +318,7 @@ paths: $ref: '#/components/responses/NotFoundError' '500': $ref: '#/components/responses/InternalServerError' + /margin/markets: get: operationId: GetMarginMarkets @@ -334,13 +347,12 @@ paths: $ref: '#/components/responses/RateLimitError' '500': $ref: '#/components/responses/InternalServerError' + /margin/markets/{ticker}: get: operationId: GetMarginMarket summary: Get Market - description: >- - Endpoint for fetching a margin market with trading stats (price, volume, - open interest). + description: Endpoint for fetching a margin market with trading stats (price, volume, open interest). tags: - market parameters: @@ -365,6 +377,7 @@ paths: $ref: '#/components/responses/RateLimitError' '500': $ref: '#/components/responses/InternalServerError' + /margin/markets/{ticker}/orderbook: get: operationId: GetMarginMarketOrderbook @@ -389,9 +402,7 @@ paths: default: 0 - name: aggregation_tick_size in: query - description: >- - Tick size in dollars for aggregating price levels (e.g., 0.10 for 10 - cent buckets) + description: Tick size in dollars for aggregating price levels (e.g., 0.10 for 10 cent buckets) required: false schema: type: string @@ -410,6 +421,7 @@ paths: $ref: '#/components/responses/RateLimitError' '500': $ref: '#/components/responses/InternalServerError' + /margin/markets/{ticker}/candlesticks: get: operationId: GetMarginMarketCandlesticks @@ -427,49 +439,34 @@ paths: - name: start_ts in: query required: true - description: >- - Start timestamp (Unix timestamp). Candlesticks will include those - ending on or after this time. + description: Start timestamp (Unix timestamp). Candlesticks will include those ending on or after this time. schema: type: integer format: int64 - name: end_ts in: query required: true - description: >- - End timestamp (Unix timestamp). Candlesticks will include those - ending on or before this time. + description: End timestamp (Unix timestamp). Candlesticks will include those ending on or before this time. schema: type: integer format: int64 - name: period_interval in: query required: true - description: >- - Time period length of each candlestick in minutes. Valid values are - 1 (1 minute), 60 (1 hour), or 1440 (1 day). + description: Time period length of each candlestick in minutes. Valid values are 1 (1 minute), 60 (1 hour), or 1440 (1 day). schema: type: integer - enum: - - 1 - - 60 - - 1440 + enum: [1, 60, 1440] x-oapi-codegen-extra-tags: - validate: required,oneof=1 60 1440 + validate: "required,oneof=1 60 1440" - name: include_latest_before_start in: query required: false - description: > - If true, prepends the latest candlestick available before the - start_ts. This synthetic candlestick is created by: - + description: | + If true, prepends the latest candlestick available before the start_ts. This synthetic candlestick is created by: 1. Finding the most recent real candlestick before start_ts - - 2. Projecting it forward to the first period boundary (calculated as - the next period interval after start_ts) - - 3. Setting all OHLC prices to null, and `price.previous` to the - close price from the real candlestick + 2. Projecting it forward to the first period boundary (calculated as the next period interval after start_ts) + 3. Setting all OHLC prices to null, and `price.previous` to the close price from the real candlestick schema: type: boolean default: false @@ -486,6 +483,7 @@ paths: $ref: '#/components/responses/NotFoundError' '500': $ref: '#/components/responses/InternalServerError' + /margin/fills: get: operationId: GetMarginFills @@ -501,7 +499,7 @@ paths: - name: subaccount in: query required: false - description: Subaccount number (0 for primary, 1-32 for subaccounts) + description: Subaccount number (0 for primary, 1-63 for subaccounts) schema: type: integer default: 0 @@ -549,11 +547,12 @@ paths: $ref: '#/components/responses/UnauthorizedError' '500': $ref: '#/components/responses/InternalServerError' + /margin/positions: get: operationId: GetMarginPositions summary: Get Positions - description: Endpoint for retrieving the authenticated user's margin positions. + description: 'Endpoint for retrieving the authenticated user''s margin positions.' tags: - portfolio security: @@ -564,7 +563,7 @@ paths: - name: subaccount in: query required: false - description: Subaccount number (0 for primary, 1-32 for subaccounts) + description: Subaccount number (0 for primary, 1-63 for subaccounts) schema: type: integer - name: ticker @@ -587,14 +586,12 @@ paths: $ref: '#/components/responses/UnauthorizedError' '500': $ref: '#/components/responses/InternalServerError' + /margin/trades: get: operationId: GetMarginTrades summary: Get Trades - description: >- - Endpoint for retrieving public margin trades for a given market ticker. - Returns a paginated response. Use the cursor value from the previous - response to get the next page. + description: 'Endpoint for retrieving public margin trades for a given market ticker. Returns a paginated response. Use the cursor value from the previous response to get the next page.' tags: - market parameters: @@ -646,13 +643,12 @@ paths: $ref: '#/components/responses/BadRequestError' '500': $ref: '#/components/responses/InternalServerError' + /margin/enabled: get: operationId: GetMarginEnabled summary: Get Enabled Status - description: >- - Endpoint for checking if margin trading is enabled for the authenticated - user. + description: Endpoint for checking if margin trading is enabled for the authenticated user. tags: - exchange security: @@ -670,13 +666,12 @@ paths: $ref: '#/components/responses/UnauthorizedError' '500': $ref: '#/components/responses/InternalServerError' + /margin/notional_risk_limit: get: operationId: GetMarginNotionalRiskLimit summary: Get Notional Risk Limit - description: >- - Endpoint for retrieving the notional value risk limit for the - authenticated margin user. + description: 'Endpoint for retrieving the notional value risk limit for the authenticated margin user.' tags: - risk security: @@ -694,24 +689,16 @@ paths: $ref: '#/components/responses/UnauthorizedError' '500': $ref: '#/components/responses/InternalServerError' + /margin/balance: get: operationId: GetMarginBalance summary: Get Balance - description: >- - Endpoint for retrieving the balance breakdown for the authenticated - direct margin user. Returns cash balance (aggregate and per-subaccount), - position value, total balance, and maintenance margin requirement. + description: 'Endpoint for retrieving the balance breakdown for the authenticated direct margin user. Returns cash balance (aggregate and per-subaccount), position value, total balance, and maintenance margin requirement.' x-mint: - content: > + content: | - - **Rate limit:** 5 tokens per request, or 50 tokens when - `compute_available_balance=true` (the available-balance computation - scans all resting orders). Other endpoints use the default cost of 10 - tokens per request unless noted on their own page. See [Rate Limits - and Tiers](/getting_started/rate_limits). - + **Rate limit:** 5 tokens per request, or 50 tokens when `compute_available_balance=true` (the available-balance computation scans all resting orders). See `GET /trade-api/v2/account/endpoint_costs` for current non-default endpoint costs. tags: - portfolio @@ -727,10 +714,7 @@ paths: type: boolean default: false x-go-type-skip-optional-pointer: true - description: >- - When true, computes available_balance per subaccount at an increased - rate limit cost. Available balance is 0 when the flag is false or - omitted. + description: 'When true, computes available_balance per subaccount at an increased rate limit cost. Available balance is 0 when the flag is false or omitted.' responses: '200': description: Margin balance retrieved successfully @@ -746,15 +730,12 @@ paths: $ref: '#/components/responses/RateLimitError' '500': $ref: '#/components/responses/InternalServerError' + /margin/risk: get: operationId: GetMarginRisk summary: Get Risk - description: >- - Endpoint for retrieving leverage and liquidation price data for the - authenticated direct margin user. Returns account-level leverage plus - per-position leverage and liquidation prices, grouped by subaccount and - market. + description: 'Endpoint for retrieving leverage and liquidation price data for the authenticated direct margin user. Returns account-level leverage plus per-position leverage and liquidation prices, grouped by subaccount and market.' tags: - risk security: @@ -774,14 +755,12 @@ paths: $ref: '#/components/responses/ForbiddenError' '500': $ref: '#/components/responses/InternalServerError' + /margin/fee_tiers: get: operationId: GetMarginFeeTiers summary: Get Fee Tiers - description: >- - Endpoint for retrieving the margin fee tiers for the authenticated - direct margin user. Returns a map of margin market tickers to their fee - tier strings. + description: 'Endpoint for retrieving the margin fee tiers for the authenticated direct margin user. Returns a map of margin market tickers to their fee tier strings.' tags: - fees security: @@ -799,15 +778,12 @@ paths: $ref: '#/components/responses/UnauthorizedError' '500': $ref: '#/components/responses/InternalServerError' + /margin/funding_history: get: operationId: GetMarginFundingHistory summary: Get Funding History - description: >- - Endpoint for retrieving the authenticated user's historical margin - funding payments joined with funding rates for a specific market, or - across all markets when ticker is empty, over an inclusive UTC date - range. + description: 'Endpoint for retrieving the authenticated user''s historical margin funding payments joined with funding rates for a specific market, or across all markets when ticker is empty, over an inclusive UTC date range.' tags: - funding security: @@ -818,18 +794,14 @@ paths: - name: ticker in: query required: false - description: >- - Market ticker for funding history. Leave empty to query across all - markets. + description: Market ticker for funding history. Leave empty to query across all markets. schema: type: string x-go-type-skip-optional-pointer: true - name: start_date in: query required: true - description: >- - Inclusive UTC start date for funding history range (YYYY-MM-DD - format) + description: Inclusive UTC start date for funding history range (YYYY-MM-DD format) schema: type: string format: date @@ -862,13 +834,12 @@ paths: $ref: '#/components/responses/ForbiddenError' '500': $ref: '#/components/responses/InternalServerError' + /margin/funding_rates/historical: get: operationId: GetMarginHistoricalFundingRates summary: Get Historical Funding Rates - description: >- - Endpoint for retrieving historical margin funding rates for a market, or - across all markets when ticker is empty. + description: Endpoint for retrieving historical margin funding rates for a market, or across all markets when ticker is empty. tags: - funding parameters: @@ -882,18 +853,14 @@ paths: - name: start_ts in: query required: false - description: >- - Start timestamp (Unix timestamp in seconds). If omitted, defaults to - the earliest available data. + description: Start timestamp (Unix timestamp in seconds). If omitted, defaults to the earliest available data. schema: type: integer format: int64 - name: end_ts in: query required: false - description: >- - End timestamp (Unix timestamp in seconds). If omitted, defaults to - the current time. + description: End timestamp (Unix timestamp in seconds). If omitted, defaults to the current time. schema: type: integer format: int64 @@ -908,16 +875,13 @@ paths: $ref: '#/components/responses/BadRequestError' '500': $ref: '#/components/responses/InternalServerError' + /margin/funding_rates/estimate: get: operationId: GetMarginFundingRateEstimate summary: Get Funding Rate Estimate - description: > - Returns the estimated funding rate for the current, in-progress funding - period. The value is a time-weighted average of the premium index - computed over `[last_funding_time, now)`, so it continues to move as new - data accumulates through the window and is only finalized at - `next_funding_time`. + description: | + Returns the estimated funding rate for the current, in-progress funding period. The value is a time-weighted average of the premium index computed over `[last_funding_time, now)`, so it continues to move as new data accumulates through the window and is only finalized at `next_funding_time`. tags: - funding parameters: @@ -941,6 +905,7 @@ paths: $ref: '#/components/responses/BadRequestError' '500': $ref: '#/components/responses/InternalServerError' + /portfolio/intra_exchange_instance_transfer: post: operationId: IntraExchangeInstanceTransfer @@ -973,14 +938,12 @@ paths: $ref: '#/components/responses/ForbiddenError' '500': $ref: '#/components/responses/InternalServerError' + /portfolio/margin/subaccounts: post: operationId: CreateMarginSubaccount summary: Create Subaccount - description: >- - Creates a new subaccount for the authenticated user in the margin - exchange. Subaccounts are numbered sequentially starting from 1. Maximum - 32 subaccounts per user. + description: 'Creates a new subaccount for the authenticated user in the margin exchange. Subaccounts are numbered sequentially starting from 1. Maximum 63 numbered subaccounts per user (64 including the primary account).' tags: - portfolio security: @@ -1002,13 +965,12 @@ paths: $ref: '#/components/responses/ForbiddenError' '500': $ref: '#/components/responses/InternalServerError' + /portfolio/margin/subaccounts/transfer: post: operationId: ApplyMarginSubaccountTransfer summary: Transfer Between Subaccounts - description: >- - Transfers funds between the authenticated user's margin subaccounts. Use - 0 for the primary account, or 1-32 for numbered subaccounts. + description: 'Transfers funds between the authenticated user''s margin subaccounts. Use 0 for the primary account, or 1-63 for numbered subaccounts.' tags: - portfolio security: @@ -1034,13 +996,12 @@ paths: $ref: '#/components/responses/UnauthorizedError' '500': $ref: '#/components/responses/InternalServerError' + /margin/order_groups: get: operationId: GetMarginOrderGroups summary: Get Order Groups - description: >- - Retrieves all order groups for the authenticated user on the margin - exchange. + description: 'Retrieves all order groups for the authenticated user on the margin exchange.' tags: - order-groups security: @@ -1062,14 +1023,12 @@ paths: $ref: '#/components/responses/UnauthorizedError' '500': $ref: '#/components/responses/InternalServerError' + /margin/order_groups/create: post: operationId: CreateMarginOrderGroup summary: Create Order Group - description: >- - Creates a new order group on the margin exchange with a contracts limit - measured over a rolling window. When the limit is hit, all orders in the - group are cancelled and no new orders can be placed until reset. + description: 'Creates a new order group on the margin exchange with a contracts limit measured over a rolling window. When the limit is hit, all orders in the group are cancelled and no new orders can be placed until reset.' tags: - order-groups security: @@ -1095,13 +1054,12 @@ paths: $ref: '#/components/responses/UnauthorizedError' '500': $ref: '#/components/responses/InternalServerError' + /margin/order_groups/{order_group_id}: get: operationId: GetMarginOrderGroup summary: Get Order Group - description: >- - Retrieves details for a single order group on the margin exchange - including all order IDs and auto-cancel status. + description: 'Retrieves details for a single order group on the margin exchange including all order IDs and auto-cancel status.' tags: - order-groups security: @@ -1127,9 +1085,7 @@ paths: delete: operationId: DeleteMarginOrderGroup summary: Delete Order Group - description: >- - Deletes an order group on the margin exchange and cancels all orders - within it. + description: 'Deletes an order group on the margin exchange and cancels all orders within it.' tags: - order-groups security: @@ -1152,14 +1108,12 @@ paths: $ref: '#/components/responses/NotFoundError' '500': $ref: '#/components/responses/InternalServerError' + /margin/order_groups/{order_group_id}/reset: put: operationId: ResetMarginOrderGroup summary: Reset Order Group - description: >- - Resets the order group matched contracts counter to zero on the margin - exchange, allowing new orders to be placed again after the limit was - hit. + description: 'Resets the order group matched contracts counter to zero on the margin exchange, allowing new orders to be placed again after the limit was hit.' tags: - order-groups security: @@ -1188,13 +1142,12 @@ paths: $ref: '#/components/responses/NotFoundError' '500': $ref: '#/components/responses/InternalServerError' + /margin/order_groups/{order_group_id}/trigger: put: operationId: TriggerMarginOrderGroup summary: Trigger Order Group - description: >- - Triggers the order group on the margin exchange, canceling all orders in - the group and preventing new orders until the group is reset. + description: 'Triggers the order group on the margin exchange, canceling all orders in the group and preventing new orders until the group is reset.' tags: - order-groups security: @@ -1223,14 +1176,12 @@ paths: $ref: '#/components/responses/NotFoundError' '500': $ref: '#/components/responses/InternalServerError' + /margin/order_groups/{order_group_id}/limit: put: operationId: UpdateMarginOrderGroupLimit summary: Update Order Group Limit - description: >- - Updates the order group contracts limit on the margin exchange. If the - updated limit would immediately trigger the group, all orders in the - group are canceled and the group is triggered. + description: 'Updates the order group contracts limit on the margin exchange. If the updated limit would immediately trigger the group, all orders in the group are canceled and the group is triggered.' tags: - order-groups security: @@ -1261,6 +1212,7 @@ paths: $ref: '#/components/responses/NotFoundError' '500': $ref: '#/components/responses/InternalServerError' + components: securitySchemes: kalshiAccessKey: @@ -1310,10 +1262,7 @@ components: schema: $ref: '#/components/schemas/ErrorResponse' RateLimitError: - description: >- - Rate limit exceeded. The default cost is 10 tokens per request; - endpoints that deviate show a **Rate limit** callout at the top of their - own page. See [Rate Limits and Tiers](/getting_started/rate_limits). + description: 'Rate limit exceeded. The default cost is 10 tokens per request. Use GET /trade-api/v2/account/endpoint_costs to list non-default endpoint costs.' content: application/json: schema: @@ -1338,17 +1287,13 @@ components: format: uuid description: Unique client-provided transfer ID for idempotency. x-oapi-codegen-extra-tags: - validate: required + validate: "required" from_subaccount: type: integer - description: >- - Source subaccount number (0 for primary, 1-32 for numbered - subaccounts). + description: Source subaccount number (0 for primary, 1-63 for numbered subaccounts). to_subaccount: type: integer - description: >- - Destination subaccount number (0 for primary, 1-32 for numbered - subaccounts). + description: Destination subaccount number (0 for primary, 1-63 for numbered subaccounts). amount_cents: type: integer format: int64 @@ -1362,31 +1307,21 @@ components: subaccount: type: integer minimum: 0 - description: >- - Optional subaccount number to use for this order group (0 for - primary, 1-32 for subaccounts) + description: Optional subaccount number to use for this order group (0 for primary, 1-63 for subaccounts) default: 0 x-go-type-skip-optional-pointer: true contracts_limit: type: integer format: int64 minimum: 1 - description: >- - Specifies the maximum number of contracts that can be matched within - this group over a rolling 15-second window. Whole contracts only. - Provide contracts_limit or contracts_limit_fp; if both provided they - must match. + description: Specifies the maximum number of contracts that can be matched within this group over a rolling 15-second window. Whole contracts only. Provide contracts_limit or contracts_limit_fp; if both provided they must match. x-go-type-skip-optional-pointer: true x-oapi-codegen-extra-tags: validate: omitempty,gte=1 contracts_limit_fp: $ref: '#/components/schemas/FixedPointCount' nullable: true - description: >- - String representation of the maximum number of contracts that can be - matched within this group over a rolling 15-second window. Provide - contracts_limit or contracts_limit_fp; if both provided they must - match. + description: String representation of the maximum number of contracts that can be matched within this group over a rolling 15-second window. Provide contracts_limit or contracts_limit_fp; if both provided they must match. exchange_index: allOf: - $ref: '#/components/schemas/ExchangeIndex' @@ -1404,9 +1339,7 @@ components: subaccount: type: integer minimum: 0 - description: >- - Subaccount number that owns the created order group (0 for primary, - 1-32 for subaccounts). + description: Subaccount number that owns the created order group (0 for primary, 1-63 for subaccounts). x-go-type-skip-optional-pointer: true exchange_index: allOf: @@ -1419,7 +1352,8 @@ components: properties: subaccount_number: type: integer - description: The sequential number assigned to this subaccount (1-32). + description: The sequential number assigned to this subaccount (1-63). + # Order Group schemas EmptyResponse: type: object description: An empty response body @@ -1440,35 +1374,84 @@ components: description: The name of the service that generated the error ExchangeIndex: type: integer - description: >- - Identifier for an exchange shard. Defaults to 0 if unspecified. Note: - currently only 0 supported. + description: "Identifier for an exchange shard. Defaults to 0 if unspecified. Note: currently only 0 supported." example: 0 ExchangeInstance: type: string - enum: - - event_contract - - margined + enum: ['event_contract', 'margined'] description: The exchange instance type + BucketLimit: + type: object + description: | + Token-bucket budget for one rate-limit bucket. Each request deducts + tokens equal to its endpoint cost; the bucket refills at refill_rate + tokens per second up to bucket_capacity. A request is allowed if the + bucket holds enough tokens to cover its cost; otherwise the request + is rejected with HTTP 429. + required: + - refill_rate + - bucket_capacity + properties: + refill_rate: + type: integer + description: Tokens added to the bucket per second. + bucket_capacity: + type: integer + description: | + Maximum tokens the bucket can hold. When equal to refill_rate the + bucket holds one second of budget; larger values represent burst + headroom that idle clients accumulate and can spend in a single + pulse (e.g. write buckets at non-Basic tiers hold two seconds of + budget). + GetAccountApiLimitsResponse: + type: object + required: + - usage_tier + - read + - write + - grants + properties: + usage_tier: + type: string + description: User's API usage tier. + read: + $ref: '#/components/schemas/BucketLimit' + write: + $ref: '#/components/schemas/BucketLimit' + grants: + type: array + description: The caller's active API usage level grants across exchange lanes, where each grant applies to its exchange_instance and usage_tier reflects the effective tier for the lane reported by this endpoint. + items: + $ref: '#/components/schemas/ApiUsageLevelGrant' + ApiUsageLevelGrant: + type: object + required: + - exchange_instance + - level + - source + properties: + exchange_instance: + $ref: '#/components/schemas/ExchangeInstance' + level: + type: string + description: API usage level this grant confers (e.g. premier, paragon, prime). + expires_ts: + type: integer + format: int64 + nullable: true + description: Unix timestamp (seconds) when the grant expires. Absent for permanent grants. + source: + type: string + description: 'How the grant was created: "volume" (earned from trading volume) or "manual" (assigned by Kalshi).' FixedPointCount: type: string - description: >- - Fixed-point contract count string (2 decimals, e.g., "10.00"; referred - to as "fp" in field names). Requests accept 0–2 decimal places (e.g., - "10", "10.0", "10.00"); responses always emit 2 decimals. Fractional - contract values (e.g., "2.50") are supported on markets with fractional - trading enabled; the minimum granularity is 0.01 contracts. Integer - contract count fields are legacy and will be deprecated; when both - integer and fp fields are provided, they must match. - example: '10.00' + description: Fixed-point contract count string (2 decimals, e.g., "10.00"; referred to as "fp" in field names). Requests accept 0–2 decimal places (e.g., "10", "10.0", "10.00"); responses always emit 2 decimals. Fractional contract values (e.g., "2.50") are supported on markets with fractional trading enabled; the minimum granularity is 0.01 contracts. Integer contract count fields are legacy and will be deprecated; when both integer and fp fields are provided, they must match. + example: "10.00" + # Common schemas FixedPointDollars: type: string - description: >- - US dollar amount as a fixed-point decimal string with up to 6 decimal - places of precision. This is the maximum supported precision; valid - quote intervals for a given market are constrained by that market's - price level structure. - example: '0.5600' + description: US dollar amount as a fixed-point decimal string with up to 6 decimal places of precision. This is the maximum supported precision; valid quote intervals for a given market are constrained by that market's price level structure. + example: "0.5600" GetOrderGroupResponse: type: object required: @@ -1480,9 +1463,7 @@ components: description: Whether auto-cancel is enabled for this order group contracts_limit_fp: $ref: '#/components/schemas/FixedPointCount' - description: >- - String representation of the current maximum contracts allowed over - a rolling 15-second window. + description: String representation of the current maximum contracts allowed over a rolling 15-second window. x-go-type-skip-optional-pointer: true orders: type: array @@ -1549,9 +1530,7 @@ components: x-go-type-skip-optional-pointer: true contracts_limit_fp: $ref: '#/components/schemas/FixedPointCount' - description: >- - String representation of the current maximum contracts allowed over - a rolling 15-second window. + description: String representation of the current maximum contracts allowed over a rolling 15-second window. x-go-type-skip-optional-pointer: true is_auto_cancel_enabled: type: boolean @@ -1561,31 +1540,20 @@ components: allOf: - $ref: '#/components/schemas/ExchangeIndex' x-go-type-skip-optional-pointer: true + # Market Orderbook schemas PriceLevelDollarsCountFp: type: array minItems: 2 maxItems: 2 - example: - - '0.1500' - - '100.00' + example: ["0.1500", "100.00"] items: type: string - description: >- - Price level in dollars represented as [dollars_string, fp] where - dollars_string is like "0.1500" and fp is a FixedPointCount string - (fixed-point contract count). The second element is the contract - quantity (not price). + description: Price level in dollars represented as [dollars_string, fp] where dollars_string is like "0.1500" and fp is a FixedPointCount string (fixed-point contract count). The second element is the contract quantity (not price). SelfTradePreventionType: type: string - enum: - - taker_at_cross - - maker - description: > - The self-trade prevention type for orders. `taker_at_cross` cancels the - taker order when it would trade against another order from the same - user; execution stops and any partial fills already matched are - executed. `maker` cancels the resting maker order and continues - matching. + enum: ['taker_at_cross', 'maker'] + description: | + The self-trade prevention type for orders. `taker_at_cross` cancels the taker order when it would trade against another order from the same user; execution stops and any partial fills already matched are executed. `maker` cancels the resting maker order and continues matching. UpdateOrderGroupLimitRequest: type: object properties: @@ -1593,22 +1561,14 @@ components: type: integer format: int64 minimum: 1 - description: >- - New maximum number of contracts that can be matched within this - group over a rolling 15-second window. Whole contracts only. Provide - contracts_limit or contracts_limit_fp; if both provided they must - match. + description: New maximum number of contracts that can be matched within this group over a rolling 15-second window. Whole contracts only. Provide contracts_limit or contracts_limit_fp; if both provided they must match. x-go-type-skip-optional-pointer: true x-oapi-codegen-extra-tags: validate: omitempty,gte=1 contracts_limit_fp: $ref: '#/components/schemas/FixedPointCount' nullable: true - description: >- - String representation of the new maximum number of contracts that - can be matched within this group over a rolling 15-second window. - Provide contracts_limit or contracts_limit_fp; if both provided they - must match. + description: String representation of the new maximum number of contracts that can be matched within this group over a rolling 15-second window. Provide contracts_limit or contracts_limit_fp; if both provided they must match. ExchangeStatus: type: object required: @@ -1617,14 +1577,11 @@ components: properties: exchange_active: type: boolean - description: >- - False if the exchange is no longer taking any state changes at all. - True unless under maintenance. + description: False if the exchange is no longer taking any state changes at all. True unless under maintenance. trading_active: type: boolean - description: >- - True if trading is currently permitted on the exchange. False - outside exchange hours or during pauses. + description: True if trading is currently permitted on the exchange. False outside exchange hours or during pauses. + GetMarginRiskParametersResponse: type: object required: @@ -1645,10 +1602,7 @@ components: additionalProperties: type: number format: double - description: >- - Map of market ticker to initial margin multiplier. The initial - margin requirement is the maintenance margin multiplied by this - value. + description: Map of market ticker to initial margin multiplier. The initial margin requirement is the maintenance margin multiplied by this value. CreateMarginOrderRequest: type: object required: @@ -1683,10 +1637,7 @@ components: format: int64 time_in_force: type: string - enum: - - fill_or_kill - - good_till_canceled - - immediate_or_cancel + enum: ['fill_or_kill', 'good_till_canceled', 'immediate_or_cancel'] x-oapi-codegen-extra-tags: validate: required,oneof=fill_or_kill good_till_canceled immediate_or_cancel x-go-type-skip-optional-pointer: true @@ -1700,26 +1651,21 @@ components: x-go-type-skip-optional-pointer: true cancel_order_on_pause: type: boolean - description: >- - If this flag is set to true, the order will be canceled if the order - is open and trading on the exchange is paused for any reason. + description: If this flag is set to true, the order will be canceled if the order is open and trading on the exchange is paused for any reason. reduce_only: type: boolean - description: >- - Specifies whether the order place count should be capped by the - member's current position. + description: Specifies whether the order place count should be capped by the member's current position. Orders with reduce_only set to true will be rejected unless time_in_force is immediate_or_cancel or fill_or_kill. subaccount: type: integer minimum: 0 default: 0 - description: >- - The subaccount number to use for this margin order. 0 is the primary - subaccount. + description: The subaccount number to use for this margin order. 0 is the primary subaccount. x-go-type-skip-optional-pointer: true order_group_id: type: string description: The order group this order is part of x-go-type-skip-optional-pointer: true + CreateMarginOrderResponse: type: object required: @@ -1736,19 +1682,14 @@ components: description: Number of contracts filled immediately upon placement. remaining_count: $ref: '#/components/schemas/FixedPointCount' - description: >- - Number of contracts remaining after placement. For IOC orders, this - reflects the final state after unfilled contracts are canceled. + description: Number of contracts remaining after placement. For IOC orders, this reflects the final state after unfilled contracts are canceled. average_fill_price: $ref: '#/components/schemas/FixedPointDollars' - description: >- - Volume-weighted average fill price. Only present when fill_count > - 0. + description: Volume-weighted average fill price. Only present when fill_count > 0. average_fee_paid: $ref: '#/components/schemas/FixedPointDollars' - description: >- - Volume-weighted average fee paid per contract for fills resulting - from this request. Only present when fill_count > 0. + description: Volume-weighted average fee paid per contract for fills resulting from this request. Only present when fill_count > 0. + GetMarginOrderResponse: type: object required: @@ -1756,6 +1697,7 @@ components: properties: order: $ref: '#/components/schemas/MarginOrder' + GetMarginOrdersResponse: type: object required: @@ -1768,6 +1710,7 @@ components: $ref: '#/components/schemas/MarginOrder' cursor: type: string + MarginOrder: type: object required: @@ -1824,17 +1767,14 @@ components: x-omitempty: false cancel_order_on_pause: type: boolean - description: >- - If this flag is set to true, the order will be canceled if the order - is open and trading on the exchange is paused for any reason. + description: If this flag is set to true, the order will be canceled if the order is open and trading on the exchange is paused for any reason. order_group_id: type: string description: The order group this order is part of order_source: $ref: '#/components/schemas/OrderSource' - description: >- - The source of the order. Indicates whether the order was placed by - the user or by the system on behalf of the user. + description: The source of the order. Indicates whether the order was placed by the user or by the system on behalf of the user. + CancelMarginOrderResponse: type: object required: @@ -1847,24 +1787,20 @@ components: type: string reduced_by: $ref: '#/components/schemas/FixedPointCount' - description: >- - Number of contracts that were canceled (i.e. the remaining count at - time of cancellation). + description: Number of contracts that were canceled (i.e. the remaining count at time of cancellation). + DecreaseMarginOrderRequest: type: object properties: reduce_by: $ref: '#/components/schemas/FixedPointCount' nullable: true - description: >- - String representation of the number of contracts to reduce by. - Exactly one of `reduce_by` or `reduce_to` must be provided. + description: String representation of the number of contracts to reduce by. Exactly one of `reduce_by` or `reduce_to` must be provided. reduce_to: $ref: '#/components/schemas/FixedPointCount' nullable: true - description: >- - String representation of the number of contracts to reduce to. - Exactly one of `reduce_by` or `reduce_to` must be provided. + description: String representation of the number of contracts to reduce to. Exactly one of `reduce_by` or `reduce_to` must be provided. + DecreaseMarginOrderResponse: type: object required: @@ -1878,6 +1814,7 @@ components: remaining_count: $ref: '#/components/schemas/FixedPointCount' description: Number of contracts remaining after the decrease. + AmendMarginOrderRequest: type: object required: @@ -1912,6 +1849,7 @@ components: type: string description: The new client-specified order ID after amendment x-go-type-skip-optional-pointer: true + AmendMarginOrderResponse: type: object required: @@ -1925,30 +1863,23 @@ components: $ref: '#/components/schemas/FixedPointCount' nullable: true x-omitempty: false - description: >- - Number of contracts remaining after the amend. Only present when the - amend caused a fill or changed the resting size. + description: Number of contracts remaining after the amend. Only present when the amend caused a fill or changed the resting size. fill_count: $ref: '#/components/schemas/FixedPointCount' nullable: true x-omitempty: false - description: >- - Number of contracts filled as a result of the amend crossing the - book. Only present when fills occurred or remaining size changed. + description: Number of contracts filled as a result of the amend crossing the book. Only present when fills occurred or remaining size changed. average_fill_price: $ref: '#/components/schemas/FixedPointDollars' nullable: true x-omitempty: false - description: >- - Volume-weighted average fill price for fills resulting from the - amend. Only present when fills occurred. + description: Volume-weighted average fill price for fills resulting from the amend. Only present when fills occurred. average_fee_paid: $ref: '#/components/schemas/FixedPointDollars' nullable: true x-omitempty: false - description: >- - Volume-weighted average fee paid per contract for fills resulting - from the amend. Only present when fills occurred. + description: Volume-weighted average fee paid per contract for fills resulting from the amend. Only present when fills occurred. + MarginOrderbookCount: type: object required: @@ -1957,18 +1888,15 @@ components: properties: bids: type: array - description: >- - Bid price levels, ordered from best bid downward. Each level is - [price, quantity]. + description: Bid price levels, ordered from best bid downward. Each level is [price, quantity]. items: $ref: '#/components/schemas/PriceLevelDollarsCountFp' asks: type: array - description: >- - Ask price levels, ordered from best ask upward. Each level is - [price, quantity]. + description: Ask price levels, ordered from best ask upward. Each level is [price, quantity]. items: $ref: '#/components/schemas/PriceLevelDollarsCountFp' + MarginOrderbookResponse: type: object required: @@ -1976,6 +1904,7 @@ components: properties: orderbook: $ref: '#/components/schemas/MarginOrderbookCount' + MarginMarket: type: object required: @@ -2004,9 +1933,18 @@ components: type: number format: double description: > - Leverage estimate (1 / margin_rate) evaluated at a small - retail-sized notional position. Actual leverage may be lower for - larger positions as the liquidation margin rate grows with size. + Leverage estimate (1 / margin_rate) evaluated at a small retail-sized notional position. + Actual leverage may be lower for larger positions as the liquidation margin rate grows with size. + Null when margin config or price data is unavailable. + leverage_estimates: + type: object + additionalProperties: + type: number + format: double + description: > + Leverage estimates (1 / margin_rate) keyed by notional position size in dollars + ("1000", "10000", "100000", "1000000"). Leverage decreases at larger notionals as + the liquidation margin rate grows with size. Null when margin config or price data is unavailable. price: $ref: '#/components/schemas/FixedPointDollars' @@ -2014,44 +1952,42 @@ components: volume: $ref: '#/components/schemas/FixedPointCount' description: One sided total trade volume. + volume_notional_value_dollars: + $ref: '#/components/schemas/FixedPointDollars' + description: Total notional value of one sided trade volume in dollars. open_interest: $ref: '#/components/schemas/FixedPointCount' description: One sided open interest. + open_interest_notional_value_dollars: + $ref: '#/components/schemas/FixedPointDollars' + description: Total notional value of one sided open interest in dollars. volume_24h: $ref: '#/components/schemas/FixedPointCount' description: One sided trade volume in the last 24 hours. + volume_24h_notional_value_dollars: + $ref: '#/components/schemas/FixedPointDollars' + description: Total notional value of one sided trade volume in the last 24 hours in dollars. bid: $ref: '#/components/schemas/FixedPointDollars' description: Best bid price in dollars. ask: $ref: '#/components/schemas/FixedPointDollars' description: Best ask price in dollars. + MarginMarketStatus: type: string - enum: - - inactive - - active - - closed + enum: [inactive, active, closed] description: The status of a margin market + OrderSource: type: string - enum: - - user - - system - description: >- - The source of the order. 'user' indicates a user-placed order, 'system' - indicates a system-generated liquidation order. + enum: ['user', 'system'] + description: The source of the order. 'user' indicates a user-placed order, 'system' indicates a system-generated liquidation order. + LastUpdateReason: type: string - enum: - - '' - - Decrease - - Amend - - MarginCancel - - SelfTradeCancel - - ExpiryCancel - - Trade - - PostOnlyCrossCancel + enum: ['', 'Decrease', 'Amend', 'MarginCancel', 'SelfTradeCancel', 'ExpiryCancel', 'Trade', 'PostOnlyCrossCancel'] + MarginMarketResponse: type: object required: @@ -2059,6 +1995,7 @@ components: properties: market: $ref: '#/components/schemas/MarginMarket' + GetMarginMarketsResponse: type: object required: @@ -2068,6 +2005,7 @@ components: type: array items: $ref: '#/components/schemas/MarginMarket' + GetMarginFillsResponse: type: object required: @@ -2080,6 +2018,7 @@ components: $ref: '#/components/schemas/MarginFill' cursor: type: string + MarginFill: type: object required: @@ -2120,19 +2059,16 @@ components: description: Fill price in fixed-point dollars entry_price: type: string - description: >- - Position entry price used to compute incremental realized PnL for - this fill + description: Position entry price used to compute incremental realized PnL for this fill fees: type: string description: Fees paid on filled contracts, in dollars realized_pnl: type: string - description: >- - Incremental realized PnL contributed by this fill, in fixed-point - dollars + description: Incremental realized PnL contributed by this fill, in fixed-point dollars order_source: $ref: '#/components/schemas/OrderSource' + GetMarginPositionsResponse: type: object required: @@ -2142,6 +2078,7 @@ components: type: array items: $ref: '#/components/schemas/MarginPosition' + MarginPosition: type: object required: @@ -2157,9 +2094,7 @@ components: description: Market ticker symbol position: $ref: '#/components/schemas/FixedPointCount' - description: >- - Position size as a fixed-point count string (positive = long, - negative = short) + description: Position size as a fixed-point count string (positive = long, negative = short) entry_price: $ref: '#/components/schemas/FixedPointDollars' description: Weighted average entry price of the open position @@ -2171,16 +2106,13 @@ components: description: Maintenance-margin-based capital usage for the open position fees: $ref: '#/components/schemas/FixedPointDollars' - description: >- - Total fees accumulated over the lifetime of the current open - position, resets when position is fully closed + description: Total fees accumulated over the lifetime of the current open position, resets when position is fully closed roe: type: number format: double nullable: true - description: >- - Return on equity as a percentage (unrealized_pnl / margin_used * - 100), null when margin_used is zero + description: Return on equity as a percentage (unrealized_pnl / margin_used * 100), null when margin_used is zero + GetMarginTradesResponse: type: object required: @@ -2193,6 +2125,7 @@ components: $ref: '#/components/schemas/MarginTrade' cursor: type: string + MarginTrade: type: object required: @@ -2224,10 +2157,9 @@ components: description: Side of the taker in this trade BookSide: type: string - enum: - - bid - - ask + enum: ['bid', 'ask'] description: The side of an order or trade (bid or ask) + MarginEnabledResponse: type: object required: @@ -2236,6 +2168,7 @@ components: enabled: type: boolean description: Indicates whether margin trading is enabled for the user + NotionalRiskLimitResponse: type: object required: @@ -2244,21 +2177,16 @@ components: properties: default_notional_value_risk_limit: type: string - description: >- - The notional value risk limit for the user as a fixed-point dollar - string with 4 decimal places (e.g., "5000.0000") - example: '5000.0000' + description: The notional value risk limit for the user as a fixed-point dollar string with 4 decimal places (e.g., "5000.0000") + example: "5000.0000" notional_value_risk_limits_by_market_ticker: type: object additionalProperties: type: string - description: >- - Map of market_ticker to notional value risk limit as a fixed-point - dollar string with 4 decimal places (e.g., "5000.0000"). If present, - the market-level risk limit overrides the default notional value - risk limit. + description: Map of market_ticker to notional value risk limit as a fixed-point dollar string with 4 decimal places (e.g., "5000.0000"). If present, the market-level risk limit overrides the default notional value risk limit. example: - market-abc-123: '5000.0000' + "market-abc-123": "5000.0000" + MarginSubaccountBalance: type: object required: @@ -2272,37 +2200,26 @@ components: properties: subaccount: type: integer - description: The subaccount number (0 for primary, 1-32 for subaccounts) + description: The subaccount number (0 for primary, 1-63 for subaccounts) position_value: $ref: '#/components/schemas/FixedPointDollars' - description: >- - Mark-to-market value of open positions for this subaccount in - fixed-point dollars + description: Mark-to-market value of open positions for this subaccount in fixed-point dollars account_equity: $ref: '#/components/schemas/FixedPointDollars' - description: >- - Account equity for this subaccount in fixed-point dollars. 0 for - self clearing members. + description: Account equity for this subaccount in fixed-point dollars. 0 for self clearing members. maintenance_margin: $ref: '#/components/schemas/FixedPointDollars' - description: >- - Maintenance margin requirement for this subaccount in fixed-point - dollars + description: Maintenance margin requirement for this subaccount in fixed-point dollars initial_margin: $ref: '#/components/schemas/FixedPointDollars' - description: >- - Initial margin requirement for this subaccount in fixed-point - dollars. 0 for self clearing members. + description: Initial margin requirement for this subaccount in fixed-point dollars. 0 for self clearing members. resting_orders_margin: $ref: '#/components/schemas/FixedPointDollars' - description: >- - Margin locked by resting orders for this subaccount in fixed-point - dollars. 0 unless compute_available_balance is passed. + description: Margin locked by resting orders for this subaccount in fixed-point dollars. 0 unless compute_available_balance is passed. available_balance: $ref: '#/components/schemas/FixedPointDollars' - description: >- - Available balance for this subaccount in fixed-point dollars. 0 for - institutional users or if compute_available_balance was not passed. + description: Available balance for this subaccount in fixed-point dollars. 0 for institutional users or if compute_available_balance was not passed. + GetMarginBalanceResponse: type: object required: @@ -2317,6 +2234,7 @@ components: settled_funds: $ref: '#/components/schemas/FixedPointDollars' description: Total settled funds across all subaccounts in fixed-point dollars + MarginRiskPosition: type: object required: @@ -2328,7 +2246,7 @@ components: properties: subaccount: type: integer - description: The subaccount number (0 for primary, 1-32 for subaccounts) + description: The subaccount number (0 for primary, 1-63 for subaccounts) market_ticker: type: string description: Market ticker symbol @@ -2340,29 +2258,21 @@ components: description: Current mark price for the market in fixed-point dollars position_notional: $ref: '#/components/schemas/FixedPointDollars' - description: >- - Absolute notional value of the position (|qty| * mark_price) in - fixed-point dollars + description: Absolute notional value of the position (|qty| * mark_price) in fixed-point dollars maintenance_margin_required: $ref: '#/components/schemas/FixedPointDollars' - description: >- - Maintenance margin requirement for this position in fixed-point - dollars. Null if margin config is missing. + description: Maintenance margin requirement for this position in fixed-point dollars. Null if margin config is missing. nullable: true position_leverage: type: number format: double - description: >- - Position leverage ratio (position_notional / - maintenance_margin_required). Null when maintenance margin is zero - or config is missing. + description: 'Position leverage ratio (position_notional / maintenance_margin_required). Null when maintenance margin is zero or config is missing.' nullable: true estimated_liquidation_price: $ref: '#/components/schemas/FixedPointDollars' - description: >- - Estimated portfolio-aware liquidation price for this position within - the subaccount. Null when no valid liquidation price exists. + description: 'Estimated portfolio-aware liquidation price for this position within the subaccount. Null when no valid liquidation price exists.' nullable: true + GetMarginRiskResponse: type: object required: @@ -2373,26 +2283,20 @@ components: account_leverage: type: number format: double - description: >- - Account-level leverage (total_position_notional / - total_maintenance_margin). Null when total maintenance margin is - zero. + description: 'Account-level leverage (total_position_notional / total_maintenance_margin). Null when total maintenance margin is zero.' nullable: true total_position_notional: $ref: '#/components/schemas/FixedPointDollars' - description: >- - Sum of absolute position notional values across all positions in - fixed-point dollars + description: Sum of absolute position notional values across all positions in fixed-point dollars total_maintenance_margin: $ref: '#/components/schemas/FixedPointDollars' - description: >- - Sum of maintenance margin requirements across all positions in - fixed-point dollars + description: Sum of maintenance margin requirements across all positions in fixed-point dollars positions: type: array items: $ref: '#/components/schemas/MarginRiskPosition' description: Per-position risk breakdown grouped by subaccount and market + GetMarginFeeTiersResponse: type: object required: @@ -2404,19 +2308,14 @@ components: additionalProperties: type: number format: double - description: >- - A map of margin market ticker to the maker-side fee rate as a - decimal fraction of notional (e.g. 0.0005 = 0.05% = 5 bps). Multiply - notional by this value to compute the fee. + description: A map of margin market ticker to the maker-side fee rate as a decimal fraction of notional (e.g. 0.0005 = 0.05% = 5 bps). Multiply notional by this value to compute the fee. taker_fee_rates: type: object additionalProperties: type: number format: double - description: >- - A map of margin market ticker to the taker-side fee rate as a - decimal fraction of notional (e.g. 0.0012 = 0.12% = 12 bps). - Multiply notional by this value to compute the fee. + description: A map of margin market ticker to the taker-side fee rate as a decimal fraction of notional (e.g. 0.0012 = 0.12% = 12 bps). Multiply notional by this value to compute the fee. + MarginFundingHistoryEntry: type: object required: @@ -2444,9 +2343,7 @@ components: description: Mark price at the time of funding funding_amount: $ref: '#/components/schemas/FixedPointDollars' - description: >- - Dollar amount of the funding payment (positive = received, negative - = paid) + description: Dollar amount of the funding payment (positive = received, negative = paid) quantity: $ref: '#/components/schemas/FixedPointCount' description: Position size at time of funding as a fixed-point count string @@ -2454,6 +2351,7 @@ components: type: integer nullable: true description: Subaccount number (0 for primary) + GetMarginFundingHistoryResponse: type: object required: @@ -2464,6 +2362,7 @@ components: items: $ref: '#/components/schemas/MarginFundingHistoryEntry' description: Array of historical funding payment entries + MarginFundingRate: type: object required: @@ -2486,6 +2385,7 @@ components: mark_price: $ref: '#/components/schemas/FixedPointDollars' description: Mark price at the time of funding + GetMarginHistoricalFundingRatesResponse: type: object required: @@ -2496,6 +2396,7 @@ components: items: $ref: '#/components/schemas/MarginFundingRate' description: Array of historical funding rate entries + GetMarginFundingRateEstimateResponse: type: object required: @@ -2519,6 +2420,7 @@ components: type: string format: date-time description: Timestamp of the next scheduled funding event + GetMarginMarketCandlesticksResponse: type: object required: @@ -2533,6 +2435,7 @@ components: description: Array of candlestick data points for the specified time range. items: $ref: '#/components/schemas/MarginMarketCandlestick' + MarginMarketCandlestick: type: object required: @@ -2541,7 +2444,9 @@ components: - ask - price - volume + - volume_notional_value_dollars - open_interest + - open_interest_notional_value_dollars properties: end_period_ts: type: integer @@ -2549,29 +2454,26 @@ components: description: Unix timestamp for the inclusive end of the candlestick period. bid: $ref: '#/components/schemas/BidAskDistributionHistorical' - description: >- - Open, high, low, close (OHLC) data for buy offers on the market - during the candlestick period. + description: Open, high, low, close (OHLC) data for buy offers on the market during the candlestick period. ask: $ref: '#/components/schemas/BidAskDistributionHistorical' - description: >- - Open, high, low, close (OHLC) data for sell offers on the market - during the candlestick period. + description: Open, high, low, close (OHLC) data for sell offers on the market during the candlestick period. price: $ref: '#/components/schemas/PriceDistributionHistorical' - description: >- - Open, high, low, close (OHLC) and more data for trade prices on the - market during the candlestick period. + description: Open, high, low, close (OHLC) and more data for trade prices on the market during the candlestick period. volume: $ref: '#/components/schemas/FixedPointCount' - description: >- - Number of contracts traded on the market during the candlestick - period. + description: Number of contracts traded on the market during the candlestick period. + volume_notional_value_dollars: + $ref: '#/components/schemas/FixedPointDollars' + description: Notional value of contracts traded on the market during the candlestick period. open_interest: $ref: '#/components/schemas/FixedPointCount' - description: >- - Number of contracts held on the market by end of the candlestick - period (end_period_ts). + description: Number of contracts held on the market by end of the candlestick period (end_period_ts). + open_interest_notional_value_dollars: + $ref: '#/components/schemas/FixedPointDollars' + description: Notional value of contracts held on the market by end of the candlestick period (end_period_ts). + BidAskDistributionHistorical: type: object required: @@ -2579,10 +2481,7 @@ components: - low - high - close - description: >- - OHLC data for quoted prices on one side of the orderbook during the - candlestick period. These values reflect bid or ask quotes, not executed - trade prices. + description: OHLC data for quoted prices on one side of the orderbook during the candlestick period. These values reflect bid or ask quotes, not executed trade prices. properties: open: $ref: '#/components/schemas/FixedPointDollars' @@ -2596,6 +2495,7 @@ components: close: $ref: '#/components/schemas/FixedPointDollars' description: Quoted price at the end of the candlestick period (in dollars). + PriceDistributionHistorical: type: object required: @@ -2610,52 +2510,38 @@ components: allOf: - $ref: '#/components/schemas/FixedPointDollars' nullable: true - description: >- - Price of the first trade during the candlestick period (in dollars). - Null if no trades occurred. + description: Price of the first trade during the candlestick period (in dollars). Null if no trades occurred. low: allOf: - $ref: '#/components/schemas/FixedPointDollars' nullable: true - description: >- - Lowest trade price during the candlestick period (in dollars). Null - if no trades occurred. + description: Lowest trade price during the candlestick period (in dollars). Null if no trades occurred. high: allOf: - $ref: '#/components/schemas/FixedPointDollars' nullable: true - description: >- - Highest trade price during the candlestick period (in dollars). Null - if no trades occurred. + description: Highest trade price during the candlestick period (in dollars). Null if no trades occurred. close: allOf: - $ref: '#/components/schemas/FixedPointDollars' nullable: true - description: >- - Price of the last trade during the candlestick period (in dollars). - Null if no trades occurred. + description: Price of the last trade during the candlestick period (in dollars). Null if no trades occurred. mean: allOf: - $ref: '#/components/schemas/FixedPointDollars' nullable: true - description: >- - Volume-weighted average price during the candlestick period (in - dollars). Null if no trades occurred. + description: Volume-weighted average price during the candlestick period (in dollars). Null if no trades occurred. previous: allOf: - $ref: '#/components/schemas/FixedPointDollars' nullable: true - description: >- - Close price from the previous candlestick period (in dollars). Null - if this is the first candlestick or no prior trade exists. + description: Close price from the previous candlestick period (in dollars). Null if this is the first candlestick or no prior trade exists. + parameters: CursorQuery: name: cursor in: query - description: >- - Pagination cursor. Use the cursor value returned from the previous - response to get the next page of results. Leave empty for the first - page. + description: Pagination cursor. Use the cursor value returned from the previous response to get the next page of results. Leave empty for the first page. schema: type: string x-go-type-skip-optional-pointer: true @@ -2670,7 +2556,19 @@ components: maximum: 1000 default: 100 x-oapi-codegen-extra-tags: - validate: omitempty,min=1,max=1000 + validate: "omitempty,min=1,max=1000" + MarginOrdersLimitQuery: + name: limit + in: query + description: Number of margin orders per page. Defaults to 100. + schema: + type: integer + format: int64 + minimum: 1 + maximum: 10000 + default: 100 + x-oapi-codegen-extra-tags: + validate: "omitempty,min=1,max=10000" MaxTsQuery: name: max_ts in: query @@ -2716,9 +2614,7 @@ components: name: subaccount in: query required: false - description: >- - Subaccount number (0 for primary, 1-32 for subaccounts). If omitted, - defaults to all subaccounts. + description: Subaccount number (0 for primary, 1-63 for subaccounts). If omitted, defaults to all subaccounts. schema: type: integer minimum: 0 @@ -2726,12 +2622,14 @@ components: name: subaccount in: query required: false - description: Subaccount number (0 for primary, 1-32 for subaccounts). Defaults to 0. + description: Subaccount number (0 for primary, 1-63 for subaccounts). Defaults to 0. schema: type: integer minimum: 0 default: 0 tags: + - name: account + description: Account information endpoints - name: exchange description: Exchange status and information endpoints - name: market diff --git a/specs/perps_scm_openapi.yaml b/specs/perps_scm_openapi.yaml index 46426f2..07aa3df 100644 --- a/specs/perps_scm_openapi.yaml +++ b/specs/perps_scm_openapi.yaml @@ -2,23 +2,33 @@ openapi: 3.0.0 info: title: Kalshi Self-Clearing Member API version: 0.0.1 - description: | + description: > Klear API endpoints available to Self-Clearing Members (SCMs) — + institutional users who clear their own margin activity directly with + Kalshi. + ## Authentication - All endpoints require an authenticated session. To obtain one: - 1. Call `POST /log_in` with your email and password. - 2. If MFA is enabled, the response contains `required_mfa_method`. - Re-call `POST /log_in` with the same credentials plus the `code` field. - 3. On success, a session cookie is returned via `Set-Cookie`. Include - this cookie on all subsequent requests. + All endpoints require an admin access token passed in the `Authorization` + header: + + + `Authorization: Bearer :` + + + To generate an access token and obtain your admin user id, sign in at + + https://klearing.kalshi.com, and navigate to the "Security" page. + If you are in the process of onboarding to be a self-clearing member, + please contact your contact at Kalshi for credentials. Otherwise, + please contact institutional@kalshi.com. servers: - url: https://api.klear.kalshi.com/klear-api/v1 @@ -26,46 +36,8 @@ servers: - url: https://demo-api.kalshi.co/klear-api/v1 description: Demo security: - - kalshiSession: [] + - kalshiBearer: [] paths: - /log_in: - post: - operationId: LogIn - summary: Log In - description: | - Authenticate with your email and password. If MFA is enabled on - your account the first call returns `required_mfa_method`; re-call - with the `code` field set to complete login. - - On success the response includes `token` and `user_id`, and a - session cookie is set. Pass the cookie on subsequent requests. - security: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/LogInRequest' - responses: - '200': - description: Successful login or MFA challenge - content: - application/json: - schema: - $ref: '#/components/schemas/LogInResponse' - headers: - Set-Cookie: - schema: - type: string - description: Session cookie returned on successful authentication. - '401': - $ref: '#/components/responses/UnauthorizedError' - '429': - description: Rate limited - content: - application/json: - schema: - $ref: '#/components/schemas/Error' /margin/reports: get: operationId: GetMarginReports @@ -310,13 +282,13 @@ paths: $ref: '#/components/responses/InternalServerError' components: securitySchemes: - kalshiSession: - type: apiKey - in: cookie - name: session - description: | - Session cookie returned by `POST /log_in`. Pass it on all - subsequent requests. + kalshiBearer: + type: http + scheme: bearer + bearerFormat: admin_user_id:access_token + description: > + Pass `Authorization: Bearer :` on each + request. responses: BadRequestError: description: Bad request - invalid input @@ -611,35 +583,6 @@ components: type: integer format: int64 description: Current settlement buffer balance. - LogInRequest: - type: object - required: - - email - - password - properties: - email: - type: string - password: - type: string - code: - type: string - description: >- - MFA code, required when the initial login returns - `required_mfa_method`. - LogInResponse: - type: object - properties: - token: - type: string - description: Session token (present on successful auth). - user_id: - type: string - description: Admin user ID (present on successful auth). - access_level: - type: string - required_mfa_method: - type: string - description: If set, MFA is required. Re-call `/log_in` with the `code` field. GetSettlementBalanceResponse: type: object required: diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 571492d..a283a23 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -162,6 +162,13 @@ class Exclusion: http_method="GET", path_template="/account/endpoint_costs", ), + MethodEndpointEntry( + # Spec defines no requestBody for this POST, so there is no + # request_body_schema to drift-check (same as subaccounts.create). + sdk_method="kalshi.resources.account.AccountResource.upgrade", + http_method="POST", + path_template="/account/api_usage_level/upgrade", + ), # ── api keys ──────────────────────────────────────────────────────────── MethodEndpointEntry( sdk_method="kalshi.resources.api_keys.ApiKeysResource.list", @@ -1538,6 +1545,11 @@ class Exclusion: http_method="GET", path_template="/margin/fee_tiers", ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.margin_account.MarginAccountResource.api_limits", + http_method="GET", + path_template="/account/limits/perps", + ), # ── perps funding (#395) ─────────────────────────────────────────── MethodEndpointEntry( sdk_method="kalshi.perps.resources.funding.FundingResource.rate_estimate", @@ -1576,13 +1588,8 @@ class Exclusion: # SCM/Klear endpoints — validated against ``specs/perps_scm_openapi.yaml``. PERPS_SCM_METHOD_ENDPOINT_MAP: list[MethodEndpointEntry] = [ - # ── perps SCM/Klear auth (#399) ────────────────────────────────────── - MethodEndpointEntry( - sdk_method="kalshi.perps.klear.resources.auth.AuthResource.log_in", - http_method="POST", - path_template="/log_in", - request_body_schema="#/components/schemas/LogInRequest", - ), + # SCM/Klear auth migrated from POST /log_in (cookie session) to a static + # Bearer header (#443); there is no longer a log_in endpoint to register. # ── perps SCM/Klear margin endpoints (#400) ────────────────────────── MethodEndpointEntry( sdk_method="kalshi.perps.klear.resources.margin.MarginResource.margin_reports", diff --git a/tests/integration/test_perps_klear.py b/tests/integration/test_perps_klear.py index d725df1..9588644 100644 --- a/tests/integration/test_perps_klear.py +++ b/tests/integration/test_perps_klear.py @@ -1,15 +1,15 @@ """Integration tests for the perps SCM (Klear) client — live demo. -The Klear (Self-Clearing-Member) API authenticates with email + password (+ MFA) -via ``POST /log_in`` — a session-cookie model, NOT RSA-PSS — and the demo server -generally cannot service it without a margin-enabled SCM account. So only a -construction smoke test runs under the default ``@pytest.mark.integration`` -marker; the actual login + SCM data calls are gated behind +The Klear (Self-Clearing-Member) API authenticates with a pre-generated Bearer +token (``Authorization: Bearer :``), NOT RSA-PSS, and +the demo server generally cannot service it without a margin-enabled SCM account. +So only a construction smoke test runs under the default ``@pytest.mark.integration`` +marker; the actual SCM data calls are gated behind ``@pytest.mark.integration_real_api_only`` (skipped unless ``KALSHI_ENABLE_REAL_API_ONLY=1`` against a prod-like SCM account). Credentials for the real-api-only path are read from the environment: - KALSHI_KLEAR_EMAIL, KALSHI_KLEAR_PASSWORD, KALSHI_KLEAR_MFA_CODE (optional) + KALSHI_KLEAR_ADMIN_USER_ID, KALSHI_KLEAR_ACCESS_TOKEN """ from __future__ import annotations @@ -20,41 +20,38 @@ from kalshi.perps.klear import KlearClient from kalshi.perps.klear.config import DEMO_KLEAR_URL -from kalshi.perps.klear.models.auth import LogInResponse +from kalshi.perps.klear.models.margin import GetSettlementBalanceResponse @pytest.mark.integration -def test_klear_client_constructs_unauthenticated() -> None: - """A fresh demo Klear client is unauthenticated until ``login`` succeeds. +def test_klear_client_constructs_with_bearer_credentials() -> None: + """A demo Klear client constructs with Bearer credentials and demo routing. - No network call — this verifies the demo routing and the pre-login auth - state, which is safe to run on every integration session. + No network call — this verifies the demo routing and that the Bearer header + is wired, which is safe to run on every integration session. """ - with KlearClient(demo=True) as client: + with KlearClient( + admin_user_id="smoke-admin", access_token="smoke-token", demo=True + ) as client: assert client._config.base_url == DEMO_KLEAR_URL - assert client.is_authenticated is False + assert client._auth.authorization_header() == "Bearer smoke-admin:smoke-token" @pytest.mark.integration @pytest.mark.integration_real_api_only -class TestKlearLoginRealApiOnly: - def test_login(self) -> None: - """Log in to the Klear API with real SCM credentials. +class TestKlearRealApiOnly: + def test_settlement_balance(self) -> None: + """Call a Klear SCM endpoint with real Bearer credentials. Skipped by default (demo cannot service Klear without a margin-enabled SCM account). Enable with ``KALSHI_ENABLE_REAL_API_ONLY=1`` and the - ``KALSHI_KLEAR_*`` credentials set. + ``KALSHI_KLEAR_ADMIN_USER_ID`` / ``KALSHI_KLEAR_ACCESS_TOKEN`` set. """ - email = os.environ.get("KALSHI_KLEAR_EMAIL") - password = os.environ.get("KALSHI_KLEAR_PASSWORD") - if not email or not password: - pytest.skip("KALSHI_KLEAR_EMAIL / KALSHI_KLEAR_PASSWORD not set") - code = os.environ.get("KALSHI_KLEAR_MFA_CODE") + if not os.environ.get("KALSHI_KLEAR_ADMIN_USER_ID") or not os.environ.get( + "KALSHI_KLEAR_ACCESS_TOKEN" + ): + pytest.skip("KALSHI_KLEAR_ADMIN_USER_ID / KALSHI_KLEAR_ACCESS_TOKEN not set") with KlearClient.from_env() as client: - resp = client.login(email=email, password=password, code=code) - assert isinstance(resp, LogInResponse) - # If MFA is required and no code was supplied, re-call with the code. - if resp.required_mfa_method and code: - resp = client.login(email=email, password=password, code=code) - assert isinstance(resp, LogInResponse) + resp = client.margin.settlement_balance() + assert isinstance(resp, GetSettlementBalanceResponse) diff --git a/tests/perps/klear/conftest.py b/tests/perps/klear/conftest.py index 168b91a..863667e 100644 --- a/tests/perps/klear/conftest.py +++ b/tests/perps/klear/conftest.py @@ -9,11 +9,21 @@ from kalshi.perps.klear import AsyncKlearClient, KlearClient, KlearConfig # Hermetic env: strip any Klear overrides the developer's shell may export. -for _v in ("KALSHI_KLEAR_API_BASE_URL", "KALSHI_KLEAR_DEMO", "KALSHI_KLEAR_ALLOW_UNKNOWN_HOST"): +for _v in ( + "KALSHI_KLEAR_API_BASE_URL", + "KALSHI_KLEAR_DEMO", + "KALSHI_KLEAR_ALLOW_UNKNOWN_HOST", + "KALSHI_KLEAR_ADMIN_USER_ID", + "KALSHI_KLEAR_ACCESS_TOKEN", +): os.environ.pop(_v, None) KLEAR_BASE = "https://demo-api.kalshi.co/klear-api/v1" +# Test Bearer credentials shared across the Klear test package. +TEST_ADMIN_USER_ID = "test-admin" +TEST_ACCESS_TOKEN = "test-token" + @pytest.fixture def klear_config() -> KlearConfig: @@ -22,9 +32,13 @@ def klear_config() -> KlearConfig: @pytest.fixture def klear_client(klear_config: KlearConfig) -> KlearClient: - return KlearClient(config=klear_config) + return KlearClient( + admin_user_id=TEST_ADMIN_USER_ID, access_token=TEST_ACCESS_TOKEN, config=klear_config + ) @pytest.fixture def async_klear_client(klear_config: KlearConfig) -> AsyncKlearClient: - return AsyncKlearClient(config=klear_config) + return AsyncKlearClient( + admin_user_id=TEST_ADMIN_USER_ID, access_token=TEST_ACCESS_TOKEN, config=klear_config + ) diff --git a/tests/perps/klear/test_auth.py b/tests/perps/klear/test_auth.py index 15155ed..7a14d63 100644 --- a/tests/perps/klear/test_auth.py +++ b/tests/perps/klear/test_auth.py @@ -1,203 +1,136 @@ -"""Tests for the Klear (SCM) auth foundation (#399).""" +"""Tests for the Klear (SCM) Bearer auth (migrated from cookie-session, #443).""" from __future__ import annotations -import json import logging import httpx import pytest import respx -from kalshi.errors import KalshiAuthError, KalshiRateLimitError -from kalshi.perps.klear import ( - AsyncKlearClient, - KlearAuth, - KlearClient, - KlearConfig, - LogInRequest, - LogInResponse, -) +from kalshi.errors import KalshiAuthError +from kalshi.perps.klear import AsyncKlearClient, KlearAuth, KlearClient, KlearConfig BASE = "https://demo-api.kalshi.co/klear-api/v1" - - -class TestLogInHappyPath: +EXPECTED_HEADER = "Bearer test-admin:test-token" +_BALANCE_BODY = {"user_id": "u1", "balance_available_centicents": 0} + + +class TestKlearAuth: + def test_authorization_header_format(self) -> None: + auth = KlearAuth("admin-123", "tok-abc") + assert auth.authorization_header() == "Bearer admin-123:tok-abc" + assert auth.admin_user_id == "admin-123" + assert auth.access_token == "tok-abc" + + def test_empty_credentials_rejected(self) -> None: + with pytest.raises(ValueError): + KlearAuth("", "tok") + with pytest.raises(ValueError): + KlearAuth("admin", "") + # Whitespace-only is also rejected (would yield a malformed header). + with pytest.raises(ValueError): + KlearAuth(" ", "tok") + with pytest.raises(ValueError): + KlearAuth("admin", " ") + + def test_padded_credentials_are_stripped(self) -> None: + # Surrounding whitespace is stripped before storage so the header is + # well-formed (not "Bearer admin-id : tok "). + auth = KlearAuth(" admin-id ", " tok ") + assert auth.admin_user_id == "admin-id" + assert auth.access_token == "tok" + assert auth.authorization_header() == "Bearer admin-id:tok" + + def test_repr_redacts_access_token(self) -> None: + auth = KlearAuth("admin-123", "SECRET-TOKEN") + assert "SECRET-TOKEN" not in repr(auth) + assert "SECRET-TOKEN" not in str(auth) + assert "admin-123" in repr(auth) + + +class TestBearerHeaderInjection: @respx.mock - def test_login_captures_cookie_and_marks_session(self, klear_client: KlearClient) -> None: - login = respx.post(f"{BASE}/log_in").mock( - return_value=httpx.Response( - 200, - json={"token": "TOK", "user_id": "u1", "access_level": "admin"}, - headers={"Set-Cookie": "session=COOKIEVAL; Path=/"}, - ) + def test_header_sent_on_every_request(self, klear_client: KlearClient) -> None: + route = respx.get(f"{BASE}/margin/settlement_balance").mock( + return_value=httpx.Response(200, json=_BALANCE_BODY) ) - probe = respx.get(f"{BASE}/margin/settlement_balance").mock( - return_value=httpx.Response(200, json={}) - ) - assert klear_client.is_authenticated is False - resp = klear_client.login(email="a@b.com", password="pw") - assert resp.required_mfa_method is None - assert resp.token == "TOK" - assert klear_client.is_authenticated is True - # Body omits `code` when None. - sent = json.loads(login.calls.last.request.content) - assert sent == {"email": "a@b.com", "password": "pw"} - # The captured session cookie is replayed on the next request. - klear_client._transport.request("GET", "/margin/settlement_balance") - assert "session=COOKIEVAL" in probe.calls.last.request.headers.get("cookie", "") + klear_client.margin.settlement_balance() + assert route.calls.last.request.headers.get("authorization") == EXPECTED_HEADER klear_client.close() @respx.mock - def test_login_body_includes_code_when_provided(self, klear_client: KlearClient) -> None: - login = respx.post(f"{BASE}/log_in").mock( - return_value=httpx.Response( - 200, json={"token": "T"}, headers={"Set-Cookie": "session=x"} - ) + def test_header_on_paginated_endpoint(self, klear_client: KlearClient) -> None: + route = respx.get(f"{BASE}/margin/obligation_history").mock( + return_value=httpx.Response(200, json={"obligations": [], "cursor": ""}) ) - klear_client.login(email="a@b.com", password="pw", code="123456") - body = json.loads(login.calls.last.request.content) - assert body["code"] == "123456" + klear_client.margin.obligation_history() + assert route.calls.last.request.headers.get("authorization") == EXPECTED_HEADER klear_client.close() - -class TestMfaChallenge: @respx.mock - def test_mfa_returns_without_autoretry(self, klear_client: KlearClient) -> None: - route = respx.post(f"{BASE}/log_in").mock( - return_value=httpx.Response(200, json={"required_mfa_method": "totp"}) + def test_401_maps_to_auth_error(self, klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/settlement_balance").mock( + return_value=httpx.Response(401, json={"code": "unauthorized", "message": "bad token"}) ) - resp = klear_client.login(email="a@b.com", password="pw") - assert resp.required_mfa_method == "totp" - assert klear_client.is_authenticated is False # not active until code supplied - assert route.call_count == 1 # SDK does not conjure the OOB code / auto-loop - klear_client.close() - - -class TestErrorPaths: - @respx.mock - def test_401_maps_and_does_not_leak_credentials(self, klear_client: KlearClient) -> None: - respx.post(f"{BASE}/log_in").mock( - return_value=httpx.Response(401, json={"code": "unauthorized", "message": "bad creds"}) - ) - with pytest.raises(KalshiAuthError) as ei: - klear_client.login(email="leak@x.com", password="leak-secret") - msg = str(ei.value) - assert "leak@x.com" not in msg and "leak-secret" not in msg + with pytest.raises(KalshiAuthError): + klear_client.margin.settlement_balance() klear_client.close() - @respx.mock - def test_429_not_retried(self) -> None: - route = respx.post(f"{BASE}/log_in").mock( - return_value=httpx.Response(429, json={"code": "rl", "message": "slow"}) - ) - client = KlearClient(config=KlearConfig.demo(max_retries=3)) - with pytest.raises(KalshiRateLimitError): - client.login(email="a@b", password="p") - assert route.call_count == 1 # POST /log_in is never retried - client.close() - class TestSecurityRedaction: - def test_login_request_repr_redacts(self) -> None: - r = LogInRequest(email="a@b.com", password="hunter2", code="999") - for secret in ("a@b.com", "hunter2", "999"): - assert secret not in repr(r) - assert secret not in str(r) - - def test_login_response_repr_redacts_token(self) -> None: - r = LogInResponse(token="SECRET-TOKEN", user_id="u", required_mfa_method=None) - assert "SECRET-TOKEN" not in repr(r) - assert "required_mfa_method=None" in repr(r) # the MFA signal is surfaced - - def test_klear_auth_repr_redacts_token(self) -> None: - a = KlearAuth() - a.mark_logged_in("SECRET") - assert "SECRET" not in repr(a) - assert repr(a) == "KlearAuth(authenticated=True)" - @respx.mock - def test_no_credentials_in_logs( - self, klear_client: KlearClient, caplog: pytest.LogCaptureFixture + def test_access_token_not_in_logs( + self, klear_config: KlearConfig, caplog: pytest.LogCaptureFixture ) -> None: - respx.post(f"{BASE}/log_in").mock( - return_value=httpx.Response( - 200, json={"token": "T"}, headers={"Set-Cookie": "session=S"} - ) + respx.get(f"{BASE}/margin/settlement_balance").mock( + return_value=httpx.Response(200, json=_BALANCE_BODY) + ) + client = KlearClient( + admin_user_id="admin-x", access_token="LOG-SECRET-TOKEN", config=klear_config ) with caplog.at_level(logging.DEBUG, logger="kalshi"): - klear_client.login(email="log-leak@x.com", password="log-secret") + client.margin.settlement_balance() blob = "\n".join(rec.getMessage() for rec in caplog.records) - for secret in ("log-leak@x.com", "log-secret", "session=S", "token"): - assert secret not in blob, f"{secret!r} leaked into logs" - klear_client.close() - - -class TestRequestModel: - def test_extra_forbid(self) -> None: - from pydantic import ValidationError - - with pytest.raises(ValidationError): - LogInRequest(email="a", password="b", bogus=1) + assert "LOG-SECRET-TOKEN" not in blob + client.close() - def test_validation_error_redacts_input(self) -> None: - # hide_input_in_errors keeps a fat-fingered secret-bearing field out of - # the ValidationError text (the repr redaction alone does not cover it). - from pydantic import ValidationError - with pytest.raises(ValidationError) as ei: - LogInRequest(email="a@b.com", password="pw", mfa_secret="TOPSECRET") - assert "TOPSECRET" not in str(ei.value) +class TestFromEnv: + def test_from_env_reads_credentials(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("KALSHI_KLEAR_ADMIN_USER_ID", "env-admin") + monkeypatch.setenv("KALSHI_KLEAR_ACCESS_TOKEN", "env-token") + monkeypatch.setenv("KALSHI_KLEAR_DEMO", "true") + client = KlearClient.from_env() + assert client._auth.authorization_header() == "Bearer env-admin:env-token" + client.close() + def test_from_env_missing_credentials_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("KALSHI_KLEAR_ADMIN_USER_ID", raising=False) + monkeypatch.delenv("KALSHI_KLEAR_ACCESS_TOKEN", raising=False) + with pytest.raises(ValueError): + KlearClient.from_env() -class TestReloginResetsSession: - @respx.mock - def test_relogin_mfa_challenge_clears_prior_account( - self, klear_client: KlearClient + def test_from_env_one_credential_missing_raises( + self, monkeypatch: pytest.MonkeyPatch ) -> None: - respx.post(f"{BASE}/log_in").mock( - side_effect=[ - httpx.Response( - 200, - json={"token": "TOK_A", "user_id": "A"}, - headers={"Set-Cookie": "session=COOKIE_A; Path=/"}, - ), - httpx.Response(200, json={"required_mfa_method": "totp"}), - ] - ) - # Log in as account A. - klear_client.login(email="a@x.com", password="pw") - assert klear_client.is_authenticated is True - - # Re-login to account B returns an MFA challenge. The prior session must - # be reset BEFORE the attempt so a subsequent real-money call can't be - # replayed against account A. - resp = klear_client.login(email="b@x.com", password="pw") - assert resp.required_mfa_method == "totp" - assert klear_client.is_authenticated is False - # The stale account-A session cookie was dropped before the re-login. - assert klear_client._transport._client.cookies.get("session") is None - klear_client.close() + # Either credential alone is insufficient. + monkeypatch.setenv("KALSHI_KLEAR_ADMIN_USER_ID", "env-admin") + monkeypatch.delenv("KALSHI_KLEAR_ACCESS_TOKEN", raising=False) + with pytest.raises(ValueError): + KlearClient.from_env() + monkeypatch.delenv("KALSHI_KLEAR_ADMIN_USER_ID", raising=False) + monkeypatch.setenv("KALSHI_KLEAR_ACCESS_TOKEN", "env-token") + with pytest.raises(ValueError): + KlearClient.from_env() class TestAsync: @respx.mock - async def test_async_login_happy(self, async_klear_client: AsyncKlearClient) -> None: - respx.post(f"{BASE}/log_in").mock( - return_value=httpx.Response( - 200, json={"token": "T"}, headers={"Set-Cookie": "session=S"} - ) + async def test_async_header_injection(self, async_klear_client: AsyncKlearClient) -> None: + route = respx.get(f"{BASE}/margin/settlement_balance").mock( + return_value=httpx.Response(200, json=_BALANCE_BODY) ) - resp = await async_klear_client.login(email="a@b.com", password="pw") - assert resp.token == "T" - assert async_klear_client.is_authenticated is True - await async_klear_client.close() - - @respx.mock - async def test_async_login_401(self, async_klear_client: AsyncKlearClient) -> None: - respx.post(f"{BASE}/log_in").mock( - return_value=httpx.Response(401, json={"code": "x", "message": "no"}) - ) - with pytest.raises(KalshiAuthError): - await async_klear_client.login(email="a@b.com", password="pw") + await async_klear_client.margin.settlement_balance() + assert route.calls.last.request.headers.get("authorization") == EXPECTED_HEADER await async_klear_client.close() diff --git a/tests/perps/klear/test_margin.py b/tests/perps/klear/test_margin.py index a32bab7..4320a27 100644 --- a/tests/perps/klear/test_margin.py +++ b/tests/perps/klear/test_margin.py @@ -1,11 +1,10 @@ """Tests for the Klear (SCM) margin endpoints (#400). Covers the nine SCM margin endpoints (plus the two ``*_all`` paginators) on both -``KlearClient`` and ``AsyncKlearClient``. These endpoints require an active -session, so each test marks the session active via -``client._auth.mark_logged_in("test")`` (mirroring a successful ``/log_in``) -before calling. The un-logged-in guard (``AuthRequiredError`` *before* any HTTP) -is asserted separately. +``KlearClient`` and ``AsyncKlearClient``. The clients carry Bearer credentials +(see the conftest fixtures), so the Klear resource base injects the +``Authorization: Bearer`` header on every request — there is no client-side +un-logged-in guard; an invalid token surfaces as a server 401. Money-typing invariants under test: ``_centicents`` fields stay plain ``int`` (never ``Decimal``), only the withdraw/withdrawal ``amount`` fields are @@ -24,7 +23,6 @@ from pydantic import ValidationError from kalshi.errors import ( - AuthRequiredError, KalshiAuthError, KalshiError, KalshiServerError, @@ -58,15 +56,13 @@ @pytest.fixture def auth_klear_client(klear_client: KlearClient) -> KlearClient: - """A ``KlearClient`` with an active (stubbed) session — no real login needed.""" - klear_client._auth.mark_logged_in("test") + """A ``KlearClient`` carrying Bearer credentials (from the conftest fixture).""" return klear_client @pytest.fixture def auth_async_klear_client(async_klear_client: AsyncKlearClient) -> AsyncKlearClient: - """An ``AsyncKlearClient`` with an active (stubbed) session.""" - async_klear_client._auth.mark_logged_in("test") + """An ``AsyncKlearClient`` carrying Bearer credentials.""" return async_klear_client @@ -211,17 +207,6 @@ def test_rejects_malformed_or_inverted_dates_client_side( assert not route.called auth_klear_client.close() - @respx.mock - def test_unauthenticated_raises_before_http(self) -> None: - route = respx.get(f"{BASE}/margin/reports").mock( - return_value=httpx.Response(200, json={"reports": []}) - ) - client = KlearClient(config=KlearConfig.demo()) - with pytest.raises(AuthRequiredError): - client.margin.margin_reports(start_date="2026-05-01", end_date="2026-06-01") - assert not route.called - client.close() - @respx.mock async def test_async_happy(self, auth_async_klear_client: AsyncKlearClient) -> None: respx.get(f"{BASE}/margin/reports").mock( @@ -337,17 +322,6 @@ def test_cursor_loop_guard(self, auth_klear_client: KlearClient) -> None: list(auth_klear_client.margin.obligation_history_all()) auth_klear_client.close() - @respx.mock - def test_all_unauthenticated_raises_before_http(self) -> None: - route = respx.get(f"{BASE}/margin/obligation_history").mock( - return_value=httpx.Response(200, json={"obligations": [], "cursor": ""}) - ) - client = KlearClient(config=KlearConfig.demo()) - with pytest.raises(AuthRequiredError): - list(client.margin.obligation_history_all()) - assert not route.called - client.close() - @respx.mock async def test_async_all_paginates( self, auth_async_klear_client: AsyncKlearClient @@ -519,17 +493,6 @@ def test_zero_balance(self, auth_klear_client: KlearClient) -> None: assert resp.amount_centicents == 0 auth_klear_client.close() - @respx.mock - def test_unauthenticated_raises_before_http(self) -> None: - route = respx.get(f"{BASE}/margin/guaranty_fund_balance").mock( - return_value=httpx.Response(200, json={}) - ) - client = KlearClient(config=KlearConfig.demo()) - with pytest.raises(AuthRequiredError): - client.margin.guaranty_fund_balance() - assert not route.called - client.close() - @respx.mock async def test_async_happy(self, auth_async_klear_client: AsyncKlearClient) -> None: respx.get(f"{BASE}/margin/guaranty_fund_balance").mock( @@ -666,25 +629,17 @@ def test_post_not_retried(self) -> None: route = respx.post(f"{BASE}/margin/withdraw_settlement_balance").mock( return_value=httpx.Response(503, json={"code": "x", "message": "down"}) ) - client = KlearClient(config=KlearConfig.demo(max_retries=3)) - client._auth.mark_logged_in("test") + client = KlearClient( + admin_user_id="test-admin", + access_token="test-token", + config=KlearConfig.demo(max_retries=3), + ) with pytest.raises(KalshiServerError): client.margin.withdraw_settlement_balance(amount="100.00") # POST is never retried even on a retryable 503. assert route.call_count == 1 client.close() - @respx.mock - def test_unauthenticated_raises_before_http(self) -> None: - route = respx.post(f"{BASE}/margin/withdraw_settlement_balance").mock( - return_value=httpx.Response(200, json={"id": "x"}) - ) - client = KlearClient(config=KlearConfig.demo()) - with pytest.raises(AuthRequiredError): - client.margin.withdraw_settlement_balance(amount="500.00") - assert not route.called - client.close() - @respx.mock async def test_async_happy_wire_shape( self, auth_async_klear_client: AsyncKlearClient diff --git a/tests/perps/test_contract_harness.py b/tests/perps/test_contract_harness.py index d561cc0..d540c6d 100644 --- a/tests/perps/test_contract_harness.py +++ b/tests/perps/test_contract_harness.py @@ -38,7 +38,8 @@ def test_load_perps_spec_parses(self) -> None: def test_load_perps_scm_spec_parses(self) -> None: spec = _load_perps_scm_spec() assert "paths" in spec - assert "/log_in" in spec["paths"] + # SCM auth migrated to Bearer (#443) — /log_in is gone; a margin path remains. + assert "/margin/reports" in spec["paths"] class TestPerpsMapsWellFormed: diff --git a/tests/perps/test_margin_account.py b/tests/perps/test_margin_account.py index 2c3de4c..f4cda6b 100644 --- a/tests/perps/test_margin_account.py +++ b/tests/perps/test_margin_account.py @@ -441,6 +441,79 @@ async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: await async_perps_client.close() +class TestApiLimits: + """GET /account/limits/perps — reuses kalshi.models.account.AccountApiLimits.""" + + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/account/limits/perps").mock( + return_value=httpx.Response( + 200, + json={ + "usage_tier": "standard", + "read": {"bucket_capacity": 200, "refill_rate": 100}, + "write": {"bucket_capacity": 20, "refill_rate": 10}, + "grants": [ + { + "exchange_instance": "margined", + "level": "prime", + "source": "manual", + } + ], + }, + ) + ) + resp = perps_client.margin.api_limits() + assert resp.usage_tier == "standard" + assert resp.read.bucket_capacity == 200 + assert resp.grants[0].exchange_instance == "margined" + assert resp.grants[0].expires_ts is None + + @respx.mock + def test_null_grants_coerced(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/account/limits/perps").mock( + return_value=httpx.Response( + 200, + json={ + "usage_tier": "standard", + "read": {"bucket_capacity": 200, "refill_rate": 100}, + "write": {"bucket_capacity": 20, "refill_rate": 10}, + "grants": None, + }, + ) + ) + assert perps_client.margin.api_limits().grants == [] + + @respx.mock + def test_unauthenticated_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/account/limits/perps").mock( + return_value=httpx.Response(200, json={}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + client.margin.api_limits() + assert not route.called + client.close() + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/account/limits/perps").mock( + return_value=httpx.Response( + 200, + json={ + "usage_tier": "elevated", + "read": {"bucket_capacity": 1000, "refill_rate": 500}, + "write": {"bucket_capacity": 100, "refill_rate": 50}, + "grants": [], + }, + ) + ) + resp = await async_perps_client.margin.api_limits() + assert resp.usage_tier == "elevated" + assert resp.grants == [] + await async_perps_client.close() + + # ── base URL / extra-field tolerance ────────────────────────────────────────── diff --git a/tests/perps/test_markets.py b/tests/perps/test_markets.py index f41dfe7..20389ff 100644 --- a/tests/perps/test_markets.py +++ b/tests/perps/test_markets.py @@ -36,12 +36,16 @@ def _market_dict(**overrides: object) -> dict[str, object]: "tick_size": "0.0100", "fractional_trading_enabled": True, "leverage_estimate": 2.5, + "leverage_estimates": {"1000": 2.5, "10000": 2.0, "100000": 1.5}, "price": "0.5600", "bid": "0.5500", "ask": "0.5700", "volume": "1000.00", + "volume_notional_value_dollars": "560.0000", "volume_24h": "250.00", + "volume_24h_notional_value_dollars": "140.0000", "open_interest": "500.00", + "open_interest_notional_value_dollars": "280.0000", } base.update(overrides) return base @@ -61,7 +65,9 @@ def _candle_dict(**overrides: object) -> dict[str, object]: "previous": "0.5580", }, "volume": "100.00", + "volume_notional_value_dollars": "56.0000", "open_interest": "500.00", + "open_interest_notional_value_dollars": "280.0000", } base.update(overrides) return base @@ -89,7 +95,16 @@ def test_happy(self, perps_client: PerpsClient) -> None: assert isinstance(m.tick_size, Decimal) assert m.leverage_estimate == Decimal("2.5") assert isinstance(m.leverage_estimate, Decimal) + assert m.leverage_estimates == { + "1000": Decimal("2.5"), + "10000": Decimal("2.0"), + "100000": Decimal("1.5"), + } assert m.volume == Decimal("1000.00") + assert m.volume_notional_value == Decimal("560.0000") + assert m.volume_24h_notional_value == Decimal("140.0000") + assert m.open_interest_notional_value == Decimal("280.0000") + assert isinstance(m.volume_notional_value, Decimal) @respx.mock def test_status_filter(self, perps_client: PerpsClient) -> None: @@ -325,7 +340,9 @@ def test_happy(self, perps_client: PerpsClient) -> None: assert c.price.mean == Decimal("0.5620") assert c.volume == Decimal("100.00") assert isinstance(c.volume, Decimal) + assert c.volume_notional_value == Decimal("56.0000") assert c.open_interest == Decimal("500.00") + assert c.open_interest_notional_value == Decimal("280.0000") @respx.mock def test_all_null_trade_prices(self, perps_client: PerpsClient) -> None: @@ -359,6 +376,15 @@ def test_all_null_trade_prices(self, perps_client: PerpsClient) -> None: # bid/ask OHLC are still required + present. assert c.bid.open == Decimal("0.5500") + def test_notional_value_required_on_candlestick(self) -> None: + # Spec lists both notional fields under `required`; the model enforces it + # (unlike the live MarginTickerPayload, where they are optional). + for missing in ("volume_notional_value_dollars", "open_interest_notional_value_dollars"): + frame = _candle_dict() + del frame[missing] + with pytest.raises(ValidationError): + MarginMarketCandlestick.model_validate(frame) + @respx.mock def test_missing_ticker_or_candlesticks_raises(self, perps_client: PerpsClient) -> None: # #404: both `ticker` and `candlesticks` are spec-required on this diff --git a/tests/perps/ws/test_perps_ws_channels.py b/tests/perps/ws/test_perps_ws_channels.py index 6e958d3..91f6f59 100644 --- a/tests/perps/ws/test_perps_ws_channels.py +++ b/tests/perps/ws/test_perps_ws_channels.py @@ -50,6 +50,9 @@ async def test_subscribe_ticker_yields_typed_message( "bid_size_fp": "10.00", "ask_size_fp": "12.00", "last_trade_size_fp": "1.00", "volume": "1000.00", "volume_24h": "5000.00", "open_interest": "200.00", + "volume_notional_value_dollars": "100000.0000", + "volume_24h_notional_value_dollars": "500000.0000", + "open_interest_notional_value_dollars": "20000.0000", "funding_rate": {"rate": 0.0001, "next_funding_time_ms": 1, "ts_ms": 2}, "ts_ms": 1700000000000, }, diff --git a/tests/perps/ws/test_perps_ws_models.py b/tests/perps/ws/test_perps_ws_models.py index 730a43e..e0749f6 100644 --- a/tests/perps/ws/test_perps_ws_models.py +++ b/tests/perps/ws/test_perps_ws_models.py @@ -321,6 +321,9 @@ def _full_frame(self) -> dict[str, Any]: "volume": "1000.00", "volume_24h": "5000.00", "open_interest": "200.00", + "volume_notional_value_dollars": "100000.0000", + "volume_24h_notional_value_dollars": "500000.0000", + "open_interest_notional_value_dollars": "20000.0000", "reference_price": {"price": "100.1000", "ts_ms": 1700000000000}, "settlement_mark_price": {"price": "100.2000", "ts_ms": 1700000000001}, "liquidation_mark_price": {"price": "100.3000", "ts_ms": 1700000000002}, @@ -341,6 +344,10 @@ def test_full_ticker_with_funding_and_marks(self) -> None: assert p.bid == Decimal("99.5000") and p.ask == Decimal("100.5000") assert p.bid_size == Decimal("10.00") assert p.volume == Decimal("1000.00") + assert p.volume_notional_value == Decimal("100000.0000") + assert p.volume_24h_notional_value == Decimal("500000.0000") + assert p.open_interest_notional_value == Decimal("20000.0000") + assert isinstance(p.volume_notional_value, Decimal) assert isinstance(p.reference_price, TickerPrice) assert p.reference_price.price == Decimal("100.1000") assert isinstance(p.settlement_mark_price, TickerPrice) @@ -368,6 +375,21 @@ def test_ticker_marks_and_funding_absent_default_none(self) -> None: assert msg.msg.funding_rate is None assert msg.seq is None + def test_ticker_notional_values_optional(self) -> None: + # Newly-added notional fields: a server omitting them (e.g. mid-rollout) + # must NOT break the whole ticker — they default to None, not raise. + frame = self._full_frame() + for key in ( + "volume_notional_value_dollars", + "volume_24h_notional_value_dollars", + "open_interest_notional_value_dollars", + ): + frame["msg"].pop(key) + msg = MarginTickerMessage.model_validate(frame) + assert msg.msg.volume_notional_value is None + assert msg.msg.volume_24h_notional_value is None + assert msg.msg.open_interest_notional_value is None + def test_ticker_missing_ts_ms_raises(self) -> None: frame = self._full_frame() frame["msg"].pop("ts_ms") diff --git a/tests/test_account.py b/tests/test_account.py index 257cd30..bc48819 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -54,6 +54,14 @@ def test_returns_limits(self, account: AccountResource) -> None: "usage_tier": "standard", "read": {"bucket_capacity": 200, "refill_rate": 100}, "write": {"bucket_capacity": 20, "refill_rate": 10}, + "grants": [ + { + "exchange_instance": "event_contract", + "level": "premier", + "source": "volume", + "expires_ts": 1893456000, + } + ], }, ) ) @@ -63,6 +71,11 @@ def test_returns_limits(self, account: AccountResource) -> None: assert limits.read.refill_rate == 100 assert limits.write.bucket_capacity == 20 assert limits.write.refill_rate == 10 + assert len(limits.grants) == 1 + assert limits.grants[0].exchange_instance == "event_contract" + assert limits.grants[0].level == "premier" + assert limits.grants[0].source == "volume" + assert limits.grants[0].expires_ts == 1893456000 def test_requires_auth(self, unauth_account: AccountResource) -> None: with pytest.raises(AuthRequiredError): @@ -90,6 +103,8 @@ async def test_returns_limits( "usage_tier": "elevated", "read": {"bucket_capacity": 1000, "refill_rate": 500}, "write": {"bucket_capacity": 100, "refill_rate": 50}, + # Kalshi's empty-as-null convention: NullableList coerces to []. + "grants": None, }, ) ) @@ -99,6 +114,7 @@ async def test_returns_limits( assert limits.read.refill_rate == 500 assert limits.write.bucket_capacity == 100 assert limits.write.refill_rate == 50 + assert limits.grants == [] @pytest.mark.asyncio async def test_requires_auth( @@ -204,3 +220,48 @@ async def test_requires_auth( ) -> None: with pytest.raises(AuthRequiredError): await unauth_async_account.endpoint_costs() + + +class TestAccountUpgrade: + @respx.mock + def test_upgrade_succeeds(self, account: AccountResource) -> None: + route = respx.post( + "https://test.kalshi.com/trade-api/v2/account/api_usage_level/upgrade", + ).mock(return_value=httpx.Response(201, json={})) + assert account.upgrade() is None + assert route.called + # No requestBody in the spec; SDK sends an empty {} body so httpx pins + # Content-Type: application/json (matches subaccounts.create). + assert route.calls[0].request.content == b"{}" + + @respx.mock + def test_upgrade_403_no_api_order(self, account: AccountResource) -> None: + respx.post( + "https://test.kalshi.com/trade-api/v2/account/api_usage_level/upgrade", + ).mock(return_value=httpx.Response(403, json={"message": "no API order"})) + with pytest.raises(KalshiAuthError): + account.upgrade() + + def test_requires_auth(self, unauth_account: AccountResource) -> None: + with pytest.raises(AuthRequiredError): + unauth_account.upgrade() + + +class TestAsyncAccountUpgrade: + @respx.mock + @pytest.mark.asyncio + async def test_upgrade_succeeds( + self, async_account: AsyncAccountResource, + ) -> None: + route = respx.post( + "https://test.kalshi.com/trade-api/v2/account/api_usage_level/upgrade", + ).mock(return_value=httpx.Response(201, json={})) + assert await async_account.upgrade() is None + assert route.called + + @pytest.mark.asyncio + async def test_requires_auth( + self, unauth_async_account: AsyncAccountResource, + ) -> None: + with pytest.raises(AuthRequiredError): + await unauth_async_account.upgrade() diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 3690ac5..ac8612a 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -555,6 +555,9 @@ def test_from_env_with_pem_string( def test_from_env_demo_flag(self, monkeypatch: pytest.MonkeyPatch, pem_string: str) -> None: monkeypatch.setenv("KALSHI_KEY_ID", "env-key") monkeypatch.setenv("KALSHI_PRIVATE_KEY", pem_string) + # Hermetic: drop any ambient PEM path (e.g. bridged demo creds) so + # from_env() doesn't see both PRIVATE_KEY and PRIVATE_KEY_PATH (#445). + monkeypatch.delenv("KALSHI_PRIVATE_KEY_PATH", raising=False) monkeypatch.setenv("KALSHI_DEMO", "true") monkeypatch.delenv("KALSHI_API_BASE_URL", raising=False) client = AsyncKalshiClient.from_env() @@ -566,6 +569,7 @@ def test_from_env_base_url_override( custom = "https://custom.api.com/trade-api/v2" monkeypatch.setenv("KALSHI_KEY_ID", "env-key") monkeypatch.setenv("KALSHI_PRIVATE_KEY", pem_string) + monkeypatch.delenv("KALSHI_PRIVATE_KEY_PATH", raising=False) monkeypatch.delenv("KALSHI_DEMO", raising=False) monkeypatch.setenv("KALSHI_API_BASE_URL", custom) client = AsyncKalshiClient.from_env() diff --git a/tests/test_client.py b/tests/test_client.py index 14c02cd..95676ed 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -885,6 +885,9 @@ def test_from_env_with_key_path( def test_from_env_demo_flag(self, monkeypatch: pytest.MonkeyPatch, pem_string: str) -> None: monkeypatch.setenv("KALSHI_KEY_ID", "env-key") monkeypatch.setenv("KALSHI_PRIVATE_KEY", pem_string) + # Hermetic: drop any ambient PEM path (e.g. bridged demo creds) so + # from_env() doesn't see both PRIVATE_KEY and PRIVATE_KEY_PATH (#445). + monkeypatch.delenv("KALSHI_PRIVATE_KEY_PATH", raising=False) monkeypatch.setenv("KALSHI_DEMO", "true") monkeypatch.delenv("KALSHI_API_BASE_URL", raising=False) client = KalshiClient.from_env() @@ -897,6 +900,7 @@ def test_from_env_base_url_override( custom = "https://custom.api.com/trade-api/v2" monkeypatch.setenv("KALSHI_KEY_ID", "env-key") monkeypatch.setenv("KALSHI_PRIVATE_KEY", pem_string) + monkeypatch.delenv("KALSHI_PRIVATE_KEY_PATH", raising=False) monkeypatch.delenv("KALSHI_DEMO", raising=False) monkeypatch.setenv("KALSHI_API_BASE_URL", custom) client = KalshiClient.from_env() diff --git a/tests/test_contracts.py b/tests/test_contracts.py index ce79b50..008e75a 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -1272,8 +1272,7 @@ def _assert_params_match( } PERPS_SCM_BODY_MODEL_MAP: dict[str, str] = { - # ── perps SCM/Klear auth (#399) ── - "#/components/schemas/LogInRequest": "kalshi.perps.klear.models.auth.LogInRequest", + # SCM/Klear auth migrated to Bearer header (#443); LogInRequest removed. # ── perps SCM/Klear margin (#400) ── "#/components/schemas/WithdrawSettlementBalanceRequest": ( "kalshi.perps.klear.models.margin.WithdrawSettlementBalanceRequest" diff --git a/tests/ws/test_channels.py b/tests/ws/test_channels.py index 27fb0c2..c2d8870 100644 --- a/tests/ws/test_channels.py +++ b/tests/ws/test_channels.py @@ -241,6 +241,51 @@ async def test_update_subscription_sends_command( assert update_cmds[0]["params"]["action"] == "add_markets" assert update_cmds[0]["params"]["market_tickers"] == ["T2"] + async def test_update_subscription_subscribe_indices( + self, + sub_mgr: SubscriptionManager, + fake_ws, # type: ignore[no-untyped-def] + ) -> None: + # cfbenchmarks_value: subscribe_indices carries index_ids. + sub = await sub_mgr.subscribe("cfbenchmarks_value") + await sub_mgr.update_subscription( + sub.client_id, "subscribe_indices", index_ids=["BRTI", "ETHUSD_RTI"] + ) + cmd = next( + c for c in fake_ws.received_commands if c.get("cmd") == "update_subscription" + ) + assert cmd["params"]["action"] == "subscribe_indices" + assert cmd["params"]["index_ids"] == ["BRTI", "ETHUSD_RTI"] + + async def test_update_subscription_indexlist_omits_index_ids( + self, + sub_mgr: SubscriptionManager, + fake_ws, # type: ignore[no-untyped-def] + ) -> None: + # indexlist requests the available ids without modifying the subscription. + sub = await sub_mgr.subscribe("cfbenchmarks_value") + await sub_mgr.update_subscription(sub.client_id, "indexlist") + cmd = next( + c for c in fake_ws.received_commands if c.get("cmd") == "update_subscription" + ) + assert cmd["params"]["action"] == "indexlist" + assert "index_ids" not in cmd["params"] + + async def test_update_subscription_empty_index_ids_omitted( + self, + sub_mgr: SubscriptionManager, + fake_ws, # type: ignore[no-untyped-def] + ) -> None: + # An empty list is intentionally dropped (server requires >=1 id). + sub = await sub_mgr.subscribe("cfbenchmarks_value") + await sub_mgr.update_subscription( + sub.client_id, "subscribe_indices", index_ids=[] + ) + cmd = next( + c for c in fake_ws.received_commands if c.get("cmd") == "update_subscription" + ) + assert "index_ids" not in cmd["params"] + # --------------------------------------------------------------------------- # SubscriptionManager — resubscribe_all diff --git a/tests/ws/test_client.py b/tests/ws/test_client.py index 9da4abf..173b599 100644 --- a/tests/ws/test_client.py +++ b/tests/ws/test_client.py @@ -5,6 +5,7 @@ import asyncio import gc import logging +from decimal import Decimal from typing import Any import pytest @@ -171,6 +172,64 @@ async def test_subscribe_ticker_no_tickers(self, fake_ws, test_auth) -> None: # assert "market_tickers" not in cmd["params"] +class TestSubscribeCFBenchmarks: + async def test_subscribe_seeds_index_ids(self, fake_ws, test_auth) -> None: # type: ignore[no-untyped-def] + config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) + ws = KalshiWebSocket(auth=test_auth, config=config) + async with ws.connect() as session: + await session.subscribe_cfbenchmarks_value(index_ids=["BRTI", "ETHUSD_RTI"]) + cmd = fake_ws.received_commands[0] + assert cmd["cmd"] == "subscribe" + assert "cfbenchmarks_value" in cmd["params"]["channels"] + assert cmd["params"]["index_ids"] == ["BRTI", "ETHUSD_RTI"] + # market_* params must NOT leak onto this channel. + assert "market_tickers" not in cmd["params"] + + async def test_subscribe_without_index_ids_omits_key(self, fake_ws, test_auth) -> None: # type: ignore[no-untyped-def] + config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) + ws = KalshiWebSocket(auth=test_auth, config=config) + async with ws.connect() as session: + await session.subscribe_cfbenchmarks_value() + cmd = fake_ws.received_commands[0] + assert "index_ids" not in cmd["params"] + + async def test_subscribe_all_indices_passthrough(self, fake_ws, test_auth) -> None: # type: ignore[no-untyped-def] + # The spec's special ["all"] value is forwarded verbatim. + config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) + ws = KalshiWebSocket(auth=test_auth, config=config) + async with ws.connect() as session: + await session.subscribe_cfbenchmarks_value(index_ids=["all"]) + cmd = fake_ws.received_commands[0] + assert cmd["params"]["index_ids"] == ["all"] + + async def test_receives_value_message(self, fake_ws, test_auth) -> None: # type: ignore[no-untyped-def] + config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) + ws = KalshiWebSocket(auth=test_auth, config=config) + async with ws.connect() as session: + stream = await session.subscribe_cfbenchmarks_value(index_ids=["BRTI"]) + await fake_ws.send_to_all( + { + "type": "cfbenchmarks_value", + "sid": 1, + "seq": 1, + "msg": { + "index_id": "BRTI", + "received_at": 1715793600123, + "data": "{}", + "avg_60s_data": { + "value": "65000.12345678", + "window_size": 1, + "window_start_ts_ms": 1715793540123, + "window_end_ts_exclusive": 1715793600123, + }, + }, + } + ) + msg = await asyncio.wait_for(stream.__anext__(), timeout=2.0) + assert msg.msg.index_id == "BRTI" + assert msg.msg.avg_60s_data.value == Decimal("65000.12345678") + + class TestSubscribeOrderbookDelta: async def test_sets_snapshot_flag(self, fake_ws, test_auth) -> None: # type: ignore[no-untyped-def] config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) diff --git a/tests/ws/test_dispatch.py b/tests/ws/test_dispatch.py index d6851ab..27e8e44 100644 --- a/tests/ws/test_dispatch.py +++ b/tests/ws/test_dispatch.py @@ -393,6 +393,8 @@ async def test_all_channel_types_have_models(self) -> None: "multivariate_lookup", "multivariate_market_lifecycle", "communications", + "cfbenchmarks_value", + "cfbenchmarks_value_indexlist", } assert expected == set(MESSAGE_MODELS.keys()) diff --git a/tests/ws/test_models.py b/tests/ws/test_models.py index e59e8c0..884b696 100644 --- a/tests/ws/test_models.py +++ b/tests/ws/test_models.py @@ -16,6 +16,10 @@ SubscribedMessage, UnsubscribedMessage, ) +from kalshi.ws.models.cfbenchmarks import ( + CFBenchmarksIndexListMessage, + CFBenchmarksValueMessage, +) from kalshi.ws.models.communications import ( CommunicationsMessage, QuoteAcceptedPayload, @@ -245,6 +249,89 @@ def test_dollar_fields_reject_bool(self) -> None: ) +# ---------- CF Benchmarks value feed ---------- + + +class TestCFBenchmarksModels: + def test_parse_value_with_both_averages(self) -> None: + raw = { + "type": "cfbenchmarks_value", + "sid": 1, + "seq": 42, + "msg": { + "index_id": "BRTI", + "received_at": 1715793600123, + "data": '{"price": "65000.12345678"}', + "avg_60s_data": { + "value": "65000.12345678", + "window_size": 59, + "window_start_ts_ms": 1715793540123, + "window_end_ts_exclusive": 1715793600123, + }, + "last_60s_windowed_average_15min": { + "value": "65001.00000000", + "window_size": 60, + "window_start_ts_ms": 1715793540000, + "window_end_ts_exclusive": 1715793600001, + }, + }, + } + msg = CFBenchmarksValueMessage.model_validate(raw) + assert msg.type == "cfbenchmarks_value" + assert msg.sid == 1 + assert msg.seq == 42 + assert msg.msg.index_id == "BRTI" + # 8-dp value preserved exactly as Decimal (no binary-float drift). + assert msg.msg.avg_60s_data.value == Decimal("65000.12345678") + assert isinstance(msg.msg.avg_60s_data.value, Decimal) + assert msg.msg.avg_60s_data.window_size == 59 + assert msg.msg.last_60s_windowed_average_15min is not None + assert msg.msg.last_60s_windowed_average_15min.window_size == 60 + + def test_value_omits_quarter_hour_average(self) -> None: + raw = { + "type": "cfbenchmarks_value", + "sid": 1, + "seq": 7, + "msg": { + "index_id": "ETHUSD_RTI", + "received_at": 1715793600123, + "data": "{}", + "avg_60s_data": { + "value": "3500.00000000", + "window_size": 0, + "window_start_ts_ms": 1715793540123, + "window_end_ts_exclusive": 1715793600123, + }, + }, + } + msg = CFBenchmarksValueMessage.model_validate(raw) + assert msg.msg.last_60s_windowed_average_15min is None + + def test_value_missing_avg_60s_data_raises(self) -> None: + raw = { + "type": "cfbenchmarks_value", + "sid": 1, + "seq": 1, + "msg": {"index_id": "BRTI", "received_at": 1, "data": "{}"}, + } + with pytest.raises(ValidationError): + CFBenchmarksValueMessage.model_validate(raw) + + def test_parse_index_list(self) -> None: + raw = { + "type": "cfbenchmarks_value_indexlist", + "id": 2, + "sid": 1, + "seq": 1, + "msg": {"index_ids": ["BRTI", "ETHUSD_RTI"]}, + } + msg = CFBenchmarksIndexListMessage.model_validate(raw) + assert msg.type == "cfbenchmarks_value_indexlist" + assert msg.id == 2 + assert msg.msg.index_ids == ["BRTI", "ETHUSD_RTI"] + + # ---------- Trade ----------