diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml index b71ab4c..8f27f06 100644 --- a/.github/workflows/spec-sync.yml +++ b/.github/workflows/spec-sync.yml @@ -52,18 +52,20 @@ jobs: id: snapshot run: | set -euo pipefail - had_old_openapi=false - had_old_asyncapi=false - if [ -f specs/openapi.yaml ]; then - cp specs/openapi.yaml /tmp/openapi.yaml.old - had_old_openapi=true - fi - if [ -f specs/asyncapi.yaml ]; then - cp specs/asyncapi.yaml /tmp/asyncapi.yaml.old - had_old_asyncapi=true - fi - echo "had_old_openapi=${had_old_openapi}" >> "$GITHUB_OUTPUT" - echo "had_old_asyncapi=${had_old_asyncapi}" >> "$GITHUB_OUTPUT" + snapshot_one() { + # $1 = spec filename, $2 = GITHUB_OUTPUT key + if [ -f "specs/$1" ]; then + cp "specs/$1" "/tmp/$1.old" + echo "$2=true" >> "$GITHUB_OUTPUT" + else + echo "$2=false" >> "$GITHUB_OUTPUT" + fi + } + snapshot_one openapi.yaml had_old_openapi + snapshot_one asyncapi.yaml had_old_asyncapi + snapshot_one perps_openapi.yaml had_old_perps_openapi + snapshot_one perps_asyncapi.yaml had_old_perps_asyncapi + snapshot_one perps_scm_openapi.yaml had_old_perps_scm_openapi - name: Download latest specs run: uv run python scripts/sync_spec.py @@ -72,24 +74,34 @@ jobs: id: diff run: | set -euo pipefail - openapi_changed=true - asyncapi_changed=true - if [ "${{ steps.snapshot.outputs.had_old_openapi }}" = "true" ] \ - && cmp -s /tmp/openapi.yaml.old specs/openapi.yaml; then - openapi_changed=false - fi - if [ "${{ steps.snapshot.outputs.had_old_asyncapi }}" = "true" ] \ - && cmp -s /tmp/asyncapi.yaml.old specs/asyncapi.yaml; then - asyncapi_changed=false - fi - if $openapi_changed || $asyncapi_changed; then + # changed_one: echoes "false" when a prior snapshot exists and is + # byte-identical to the freshly downloaded spec; "true" otherwise + # (changed, or first-ever fetch). + changed_one() { + # $1 = spec filename, $2 = had_old flag + if [ "$2" = "true" ] && cmp -s "/tmp/$1.old" "specs/$1"; then + echo false + else + echo true + fi + } + openapi_changed=$(changed_one openapi.yaml "${{ steps.snapshot.outputs.had_old_openapi }}") + asyncapi_changed=$(changed_one asyncapi.yaml "${{ steps.snapshot.outputs.had_old_asyncapi }}") + perps_openapi_changed=$(changed_one perps_openapi.yaml "${{ steps.snapshot.outputs.had_old_perps_openapi }}") + perps_asyncapi_changed=$(changed_one perps_asyncapi.yaml "${{ steps.snapshot.outputs.had_old_perps_asyncapi }}") + perps_scm_changed=$(changed_one perps_scm_openapi.yaml "${{ steps.snapshot.outputs.had_old_perps_scm_openapi }}") + if $openapi_changed || $asyncapi_changed || $perps_openapi_changed \ + || $perps_asyncapi_changed || $perps_scm_changed; then echo "changed=true" >> "$GITHUB_OUTPUT" echo "openapi_changed=${openapi_changed}" >> "$GITHUB_OUTPUT" echo "asyncapi_changed=${asyncapi_changed}" >> "$GITHUB_OUTPUT" - echo "Spec changes detected — openapi=${openapi_changed}, asyncapi=${asyncapi_changed}" + echo "perps_openapi_changed=${perps_openapi_changed}" >> "$GITHUB_OUTPUT" + echo "perps_asyncapi_changed=${perps_asyncapi_changed}" >> "$GITHUB_OUTPUT" + echo "perps_scm_changed=${perps_scm_changed}" >> "$GITHUB_OUTPUT" + echo "Spec changes detected — openapi=${openapi_changed}, asyncapi=${asyncapi_changed}, perps_openapi=${perps_openapi_changed}, perps_asyncapi=${perps_asyncapi_changed}, perps_scm=${perps_scm_changed}" else echo "changed=false" >> "$GITHUB_OUTPUT" - echo "Both specs unchanged — nothing to do." + echo "All specs unchanged — nothing to do." fi - name: Extract spec metadata for report @@ -154,8 +166,14 @@ jobs: set -euo pipefail openapi_sha=$(sha256sum specs/openapi.yaml | awk '{print $1}') asyncapi_sha=$(sha256sum specs/asyncapi.yaml | awk '{print $1}') + perps_openapi_sha=$(sha256sum specs/perps_openapi.yaml | awk '{print $1}') + perps_asyncapi_sha=$(sha256sum specs/perps_asyncapi.yaml | awk '{print $1}') + perps_scm_sha=$(sha256sum specs/perps_scm_openapi.yaml | awk '{print $1}') echo "openapi_sha=${openapi_sha}" >> "$GITHUB_OUTPUT" echo "asyncapi_sha=${asyncapi_sha}" >> "$GITHUB_OUTPUT" + echo "perps_openapi_sha=${perps_openapi_sha}" >> "$GITHUB_OUTPUT" + echo "perps_asyncapi_sha=${perps_asyncapi_sha}" >> "$GITHUB_OUTPUT" + echo "perps_scm_sha=${perps_scm_sha}" >> "$GITHUB_OUTPUT" - name: Ensure spec-drift label exists if: steps.diff.outputs.changed == 'true' @@ -189,13 +207,22 @@ jobs: REMOVED_CHANNELS: ${{ steps.meta.outputs.removed_channels }} OPENAPI_SHA: ${{ steps.checksums.outputs.openapi_sha }} ASYNCAPI_SHA: ${{ steps.checksums.outputs.asyncapi_sha }} + PERPS_OPENAPI_CHANGED: ${{ steps.diff.outputs.perps_openapi_changed }} + PERPS_ASYNCAPI_CHANGED: ${{ steps.diff.outputs.perps_asyncapi_changed }} + PERPS_SCM_CHANGED: ${{ steps.diff.outputs.perps_scm_changed }} + PERPS_OPENAPI_SHA: ${{ steps.checksums.outputs.perps_openapi_sha }} + PERPS_ASYNCAPI_SHA: ${{ steps.checksums.outputs.perps_asyncapi_sha }} + PERPS_SCM_SHA: ${{ steps.checksums.outputs.perps_scm_sha }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -euo pipefail - # Drift fingerprint: sha256(openapi_sha || asyncapi_sha). Stable across - # re-runs against the same upstream; changes the moment either spec does. - FINGERPRINT=$(printf '%s%s' "${OPENAPI_SHA}" "${ASYNCAPI_SHA}" | sha256sum | awk '{print $1}') + # Drift fingerprint: sha256 over every spec's sha. Stable across re-runs + # against the same upstream; changes the moment ANY spec does (so a + # perps-only change still opens a fresh drift issue). + FINGERPRINT=$(printf '%s%s%s%s%s' \ + "${OPENAPI_SHA}" "${ASYNCAPI_SHA}" "${PERPS_OPENAPI_SHA}" \ + "${PERPS_ASYNCAPI_SHA}" "${PERPS_SCM_SHA}" | sha256sum | awk '{print $1}') export FINGERPRINT # Dedup: skip if an open spec-drift issue already embeds this fingerprint. diff --git a/CHANGELOG.md b/CHANGELOG.md index 33720df..e475cf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,59 @@ All notable changes to kalshi-sdk will be documented in this file. +## 3.2.0 — 2026-06-05 + +Adds full SDK support for the Kalshi **Perps (margin) API** — a separate +perpetual-futures exchange — as standalone clients alongside the existing +prediction-API surface. Additive release: no changes to `KalshiClient`. + +### Added + +- **`PerpsClient` / `AsyncPerpsClient`** — standalone clients for the perps + exchange (`external-api.kalshi.com` / demo `external-api.demo.kalshi.co`, + `/trade-api/v2`), with their own `PerpsConfig` and a separate `KALSHI_PERPS_*` + credential namespace. They reuse the prediction-API RSA-PSS signer and HTTP + transport unchanged. Resource families: `exchange` (status / enabled gate / + risk parameters), `markets` (list / get / orderbook / candlesticks), `orders` + (create / get / list / cancel / decrease / amend + FCM), `order_groups`, + `portfolio` (positions / fills / trades), `margin` (balance / risk / + notional risk limit / fee tiers), `funding` (rate estimate / historical / + history), and `transfers` (intra-exchange-instance + margin subaccounts). + Margin order side is `bid` / `ask`; prices are `DollarDecimal` + (FixedPointDollars), counts `FixedPointCount`. +- **`PerpsWebSocket`** — the perps margin WebSocket + (`external-api-margin-ws.kalshi.com`, `/trade-api/ws/v2/margin`) with six typed + channels (`subscribe_orderbook_delta`, `subscribe_ticker` — carrying + `funding_rate` + `next_funding_time_ms`, `subscribe_trade`, `subscribe_fill`, + `subscribe_user_orders`, `subscribe_order_group`). Reuses the event-contract + WS connection / sequence-gap / backpressure machinery; perps WS timestamps are + Unix epoch **milliseconds** (`*_ms` fields). +- **`KlearClient` / `AsyncKlearClient`** — the Self-Clearing-Member "Klear" + settlement API (`api.klear.kalshi.com` / demo `demo-api.kalshi.co`, + `/klear-api/v1`) with a third auth model: **cookie-session + MFA** via + `login(email=..., password=..., code=...)`. Resource `margin` covers reports, + active/historical obligations, settlement estimate, settlement + guaranty-fund + balances, settlement-balance history, and settlement-balance withdrawal. + Klear money fields are integer centicents; the single real-money write + (`withdraw_settlement_balance`) validates a positive amount at construction. + Credentials and the session cookie are never logged or shown in `repr()`. +- `docs/perps.md` (+ mkdocs nav), README "Perps (margin) trading" section, and + runnable `examples/perps_create_order.py` / `perps_stream_ticker.py` / + `perps_balance_risk.py`. + +### Internal + +- Vendored the three perps specs (`specs/perps_openapi.yaml`, + `perps_asyncapi.yaml`, `perps_scm_openapi.yaml`); `scripts/sync_spec.py` and the + weekly spec-sync workflow now fetch/diff/checksum them and fold their sha256 + into the drift fingerprint (preserving the `contents: read` + `issues: write` + security model). +- Parameterized the contract-drift harness per spec: `TestPerps*Drift` / + `TestPerpsScm*Drift` validate the perps REST + SCM surfaces against their own + specs, alongside the existing prediction-API drift suites. +- README / `docs/index.md` banners note the perps surface (34 REST operations, + 6 WS channels, 10 SCM operations). + ## 3.1.0 — 2026-06-03 OpenAPI + AsyncAPI spec sync from v3.19.0 → v3.20.0 (`#385`). Adds the diff --git a/README.md b/README.md index eed792c..29521de 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) predi [![Type checked: mypy strict](https://img.shields.io/badge/mypy-strict-blue.svg)](https://mypy.readthedocs.io/) - **Full coverage** of the Kalshi REST API (99 operations across 19 resources, OpenAPI v3.20.0) and WebSocket API (11 typed `subscribe_*` channels + 2 escape-hatch). +- **Perps (margin) API**: standalone `PerpsClient` / `AsyncPerpsClient` + `PerpsWebSocket` for the perpetual-futures exchange (34 REST operations, 6 WS channels), plus a `KlearClient` for the Self-Clearing-Member "Klear" settlement API (10 operations). See [Perps (margin) trading](#perps-margin-trading). - **V2 event-market orders**: `create_v2` / `amend_v2` / `decrease_v2` / `cancel_v2` plus batched variants on `/portfolio/events/orders/*`. Legacy `/portfolio/orders` keeps working — deprecated no earlier than May 6, 2026. - **Funding & cost introspection**: `portfolio.deposits()`, `portfolio.withdrawals()`, `account.endpoint_costs()`. - **Sync and async** clients sharing one transport — no thread-pool wrapping. @@ -202,6 +203,53 @@ through the generic `subscribe(channel, ...)` escape hatch. See [docs/websockets.md](docs/websockets.md#the-11-channels) for the full channel table. +## Perps (margin) trading + +Kalshi's **Perps** (perpetual futures / margin) API is a separate exchange on its +own host. The SDK exposes it through standalone `PerpsClient` / `AsyncPerpsClient` +(and `PerpsWebSocket`), reusing the same RSA-PSS auth as `KalshiClient` but with +**separate API keys** issued for the perps exchange — prod base URL +`https://external-api.kalshi.com/trade-api/v2`, demo +`https://external-api.demo.kalshi.co/trade-api/v2`. + +```python +from kalshi import PerpsClient + +# Reads KALSHI_PERPS_KEY_ID + KALSHI_PERPS_PRIVATE_KEY[_PATH] (separate from KALSHI_*). +with PerpsClient.from_env(demo=True) as perps: + print(perps.exchange.status()) # is the margin exchange trading? + for m in perps.markets.list(status="active"): + print(m.ticker, m.bid, m.ask) + print(perps.margin.balance()) # per-subaccount balance breakdown +``` + +```python +import asyncio +from kalshi import AsyncPerpsClient + +async def main() -> None: + async with AsyncPerpsClient.from_env(demo=True) as perps: + async for order in perps.orders.list_all(): + print(order.order_id, order.price, order.remaining_count) + +asyncio.run(main()) +``` + +Resource families on the perps client: `exchange`, `markets`, `orders`, +`order_groups`, `portfolio`, `margin` (balance/risk/fees), `funding`, `transfers`. +Real-time streaming via `PerpsWebSocket` — `subscribe_ticker` (carries +`funding_rate` + `next_funding_time_ms`), `subscribe_orderbook_delta`, +`subscribe_trade`, `subscribe_fill`, `subscribe_user_orders`, +`subscribe_order_group`. + +Prices are `DollarDecimal` (FixedPointDollars, up to 6 decimals); counts are +`FixedPointCount` (2 decimals, `_fp` wire suffix). REST timestamps are RFC3339; +**WebSocket timestamps are Unix epoch milliseconds** (`*_ms` fields). The +Self-Clearing-Member "Klear" settlement API (margin reports, settlement balances, +obligations, withdrawals) is a third surface exposed via `KlearClient`, which uses +**cookie-session + MFA** login (`client.login(email=..., password=..., code=...)`) +rather than RSA-PSS. Full guide: [docs/perps.md](docs/perps.md). + ## Error handling All SDK errors inherit from `KalshiError`: diff --git a/docs/index.md b/docs/index.md index 007811d..1b131a1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,6 +14,10 @@ markets API. reconnection (with resubscribe-window frame stashing for high-volume channels), backpressure strategies, and an in-memory orderbook builder. Async-only — access via `AsyncKalshiClient.ws`. +- **Perps (margin) API** — standalone `PerpsClient` / `AsyncPerpsClient` + + `PerpsWebSocket` for the perpetual-futures exchange (34 REST operations, 6 WS + channels), and a `KlearClient` for the Self-Clearing-Member settlement API + (10 operations, cookie-session + MFA auth). See [Perps](perps.md). - **Sync and async parity** — `KalshiClient` and `AsyncKalshiClient` share one transport implementation; method names, kwargs, return types, and error behavior are identical. The one exception is WebSocket access, which is async-only. diff --git a/docs/perps.md b/docs/perps.md new file mode 100644 index 0000000..4350d77 --- /dev/null +++ b/docs/perps.md @@ -0,0 +1,155 @@ +# Perps (margin) trading + +Kalshi's **Perps** (perpetual futures / margin) API is a separate exchange on its +own host. The SDK exposes it through standalone clients rather than a namespace on +`KalshiClient`, because the perps exchange uses a different host and Kalshi +recommends **separate API keys** for it. + +| Surface | Client | Host (prod / demo) | Auth | +|---|---|---|---| +| 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 | + +The RSA-PSS signer and the HTTP transport are **reused unchanged** from the +prediction API — only the host, config, and credentials differ. + +## Client construction + +```python +from kalshi import PerpsClient + +# Explicit credentials: +with PerpsClient( + key_id="your-perps-key-id", + private_key_path="~/.kalshi/perps_key.pem", + demo=True, +) as perps: + print(perps.exchange.status()) +``` + +`PerpsConfig.production()` / `PerpsConfig.demo()` build the canonical environments; +pass a `PerpsConfig` for full control (timeouts, retry policy, custom JSON loaders). +See [Authentication](authentication.md) for how RSA-PSS keys are generated and +[Configuration](configuration.md) for the config surface (shared with `KalshiConfig`). + +### Environment variables (`from_env`) + +Perps credentials live under a **separate** `KALSHI_PERPS_*` namespace so they +never collide with the prediction-API `KALSHI_*` vars: + +| Variable | Purpose | +|---|---| +| `KALSHI_PERPS_KEY_ID` | perps API key id (omit for an unauthenticated client) | +| `KALSHI_PERPS_PRIVATE_KEY` / `KALSHI_PERPS_PRIVATE_KEY_PATH` | RSA private key (PEM string or file) | +| `KALSHI_PERPS_API_BASE_URL` | optional base-URL override | +| `KALSHI_PERPS_DEMO` | `"true"` routes to the demo exchange | + +```python +from kalshi import AsyncPerpsClient + +async with AsyncPerpsClient.from_env(demo=True) as perps: + ... +``` + +## Resource families + +| Attribute | Endpoints | +|---|---| +| `exchange` | `status()`, `enabled()` (per-member access gate), `risk_parameters()` | +| `markets` | `list()`, `get()`, `orderbook()`, `candlesticks()` | +| `orders` | `create()`, `get()`, `list()` / `list_all()`, `cancel()`, `decrease()`, `amend()`, `list_fcm()` / `list_all_fcm()` | +| `order_groups` | `list()`, `get()`, `create()`, `delete()`, `reset()`, `trigger()`, `update_limit()` | +| `portfolio` | `positions()`, `fills()` / `fills_all()`, `trades()` / `trades_all()` | +| `margin` | `balance()`, `risk()`, `notional_risk_limit()`, `fee_tiers()` | +| `funding` | `rate_estimate()`, `historical_rates()`, `history()` | +| `transfers` | `transfer_instance()`, `create_subaccount()`, `transfer_subaccount()` | + +The margin order side is `bid` / `ask` (not the prediction API's `yes` / `no`). +Orders create/cancel/decrease/amend are POSTs/DELETEs and are **never retried**. + +## Value types & timestamps + +- Prices are `DollarDecimal` — `FixedPointDollars` strings with up to 6 decimal + places, parsed to `Decimal` (no float on the price path). +- Counts are `FixedPointCount` — 2-decimal fixed-point with an `_fp` wire suffix. +- `number/double` ratios (margin thresholds, funding rate) use `MultiplierDecimal` + to avoid binary-float drift. +- **REST** timestamps are RFC3339 (`AwareDatetime`); some query/response fields are + Unix-seconds `int`. +- **WebSocket** timestamps are Unix epoch **milliseconds** on `*_ms`-suffixed + fields (`ts_ms`, `created_ts_ms`, `next_funding_time_ms`, …) — a real parsing + difference from the event-contract WS. + +## Funding mechanics + +Funding is the perpetual-futures tether: periodic payments between longs and shorts +keep the perp's mark price near its index. The `funding` resource exposes the +current in-progress estimate, historical applied rates, and the authenticated +user's per-payment history: + +```python +est = perps.funding.rate_estimate(ticker="BTC-PERP") +print(est.funding_rate, est.next_funding_time) # in-progress estimate +rows = perps.funding.history(start_date="2026-01-01", end_date="2026-02-01") +``` + +`subscribe_ticker` on the WS also carries the live `funding_rate` and +`next_funding_time_ms`. + +## Risk & balance introspection + +```python +bal = perps.margin.balance(compute_available_balance=True) +for sub in bal.subaccount_balances: + print(sub.subaccount, sub.account_equity, sub.maintenance_margin) + +risk = perps.margin.risk() +print(risk.account_leverage, risk.total_maintenance_margin) +for pos in risk.positions: + print(pos.market_ticker, pos.position_leverage, pos.estimated_liquidation_price) +``` + +## WebSocket streaming + +```python +from kalshi import PerpsWebSocket +from kalshi.perps import PerpsConfig + +ws = PerpsWebSocket(auth=perps._auth, config=PerpsConfig.demo()) +async with ws.connect() as session: + async for msg in await session.subscribe_ticker(tickers=["BTC-PERP"]): + if msg.msg.funding_rate is not None: + print(msg.msg.funding_rate.rate, msg.msg.funding_rate.next_funding_time_ms) +``` + +Six data channels — `orderbook_delta` (snapshot + delta, sequenced), `ticker`, +`trade`, `fill`, `user_orders`, `order_group_updates`. The connection, sequence-gap +detection, reconnect, and backpressure machinery are reused from the event-contract +WS stack (see [WebSocket](websockets.md)); the perps orderbook is `bid` / `ask`. +The equities-only channels (`market_positions`, `multivariate*`, `communications`, +`market_lifecycle_v2`) have no perps counterpart. + +## 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`: + +```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") + reports = klear.margin.margin_reports(start_date="2026-01-01", end_date="2026-02-01") + bal = klear.margin.settlement_balance() +``` + +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 +positive at construction (the single real-money write) before any request is sent. +Credentials and the session cookie are never logged or shown in `repr()`. diff --git a/examples/perps_balance_risk.py b/examples/perps_balance_risk.py new file mode 100644 index 0000000..7827c1a --- /dev/null +++ b/examples/perps_balance_risk.py @@ -0,0 +1,50 @@ +"""Print perps margin balance and per-position risk. + +Calls ``GetMarginBalance`` and ``GetMarginRisk`` and prints the account's balance +breakdown, maintenance-margin requirement, leverage, and per-position estimated +liquidation prices. + +Run: + python examples/perps_balance_risk.py + +Required environment variables: + KALSHI_PERPS_KEY_ID + KALSHI_PERPS_PRIVATE_KEY_PATH +""" + +from __future__ import annotations + +from kalshi import PerpsClient + + +def main() -> None: + with PerpsClient.from_env(demo=True) as perps: + if not perps.is_authenticated: + raise SystemExit("Set KALSHI_PERPS_KEY_ID + KALSHI_PERPS_PRIVATE_KEY_PATH first.") + + bal = perps.margin.balance(compute_available_balance=True) + print(f"settled funds: {bal.settled_funds}") + for sub in bal.subaccount_balances: + print( + f" subaccount {sub.subaccount}: equity={sub.account_equity} " + f"position_value={sub.position_value} " + f"maintenance_margin={sub.maintenance_margin} " + f"available={sub.available_balance}" + ) + + risk = perps.margin.risk() + print(f"account leverage: {risk.account_leverage}") + print( + f"total notional={risk.total_position_notional} " + f"total maintenance_margin={risk.total_maintenance_margin}" + ) + for pos in risk.positions: + print( + f" {pos.market_ticker}: position={pos.position} " + f"leverage={pos.position_leverage} " + f"est_liq_price={pos.estimated_liquidation_price}" + ) + + +if __name__ == "__main__": + main() diff --git a/examples/perps_create_order.py b/examples/perps_create_order.py new file mode 100644 index 0000000..f70a465 --- /dev/null +++ b/examples/perps_create_order.py @@ -0,0 +1,54 @@ +"""Place (and immediately cancel) a resting margin order on the perps demo exchange. + +Demonstrates the standalone ``PerpsClient`` order flow with a safe, non-marketable +limit price (far from the market) so the order rests rather than fills. + +Run: + python examples/perps_create_order.py + +Required environment variables (perps-specific — separate from the KALSHI_* vars): + KALSHI_PERPS_KEY_ID + KALSHI_PERPS_PRIVATE_KEY_PATH (or KALSHI_PERPS_PRIVATE_KEY) +Optional: + KALSHI_PERPS_DEMO=true (defaulted on below; this example always uses demo) +""" + +from __future__ import annotations + +import uuid +from decimal import Decimal + +from kalshi import PerpsClient + + +def main() -> None: + # Always demo for this example — never place real orders from a sample script. + with PerpsClient.from_env(demo=True) as perps: + if not perps.is_authenticated: + raise SystemExit("Set KALSHI_PERPS_KEY_ID + KALSHI_PERPS_PRIVATE_KEY_PATH first.") + if not perps.exchange.enabled().enabled: + raise SystemExit("This account is not margin-enabled on demo.") + + markets = perps.markets.list(status="active") + if not markets: + raise SystemExit("No active margin markets on demo right now.") + ticker = markets[0].ticker + + # A bid far below market rests; it will not fill. + order = perps.orders.create( + ticker=ticker, + client_order_id=str(uuid.uuid4()), + side="bid", + count="1", + price=Decimal("0.0100"), + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + ) + print(f"created resting order {order.order_id} (remaining={order.remaining_count})") + + cancelled = perps.orders.cancel(order.order_id) + print(f"cancelled {cancelled.order_id} (reduced_by={cancelled.reduced_by})") + + +if __name__ == "__main__": + main() diff --git a/examples/perps_stream_ticker.py b/examples/perps_stream_ticker.py new file mode 100644 index 0000000..c734217 --- /dev/null +++ b/examples/perps_stream_ticker.py @@ -0,0 +1,49 @@ +"""Stream the perps ``ticker`` channel and print live funding mechanics. + +Connects to the perps margin WebSocket and prints each ticker update's +``funding_rate`` and ``next_funding_time_ms`` (the perpetual-futures funding +tether) as messages arrive. + +Run: + python examples/perps_stream_ticker.py [TICKER] + +Required environment variables: + KALSHI_PERPS_KEY_ID + KALSHI_PERPS_PRIVATE_KEY_PATH +""" + +from __future__ import annotations + +import asyncio +import os +import sys + +from kalshi.auth import KalshiAuth +from kalshi.perps import PerpsConfig, PerpsWebSocket + + +async def main() -> None: + key_id = os.environ.get("KALSHI_PERPS_KEY_ID") + key_path = os.environ.get("KALSHI_PERPS_PRIVATE_KEY_PATH") + if not key_id or not key_path: + raise SystemExit("Set KALSHI_PERPS_KEY_ID + KALSHI_PERPS_PRIVATE_KEY_PATH first.") + + ticker = sys.argv[1] if len(sys.argv) > 1 else "BTC-PERP" + auth = KalshiAuth.from_key_path(key_id, key_path) + ws = PerpsWebSocket(auth=auth, config=PerpsConfig.demo()) + + async with ws.connect() as session: + async for msg in await session.subscribe_ticker(tickers=[ticker]): + fr = msg.msg.funding_rate + if fr is not None: + print( + f"{msg.msg.market_ticker} funding_rate={fr.rate} " + f"next={fr.next_funding_time_ms}" + ) + else: + print(f"{msg.msg.market_ticker} price={msg.msg.price} (no funding update)") + auth.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 27712f2..3405df2 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -170,6 +170,15 @@ WeeklySchedule, Withdrawal, ) +from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig, PerpsWebSocket +from kalshi.perps.klear import ( + AsyncKlearClient, + KlearAuth, + KlearClient, + KlearConfig, + LogInRequest, + LogInResponse, +) from kalshi.resources.communications import ( AsyncQuotesResource, AsyncRFQsResource, @@ -193,6 +202,8 @@ "ApplySubaccountTransferRequest", "AssociatedEvent", "AsyncKalshiClient", + "AsyncKlearClient", + "AsyncPerpsClient", "AsyncQuotesResource", "AsyncRFQsResource", "AuthRequiredError", @@ -289,7 +300,12 @@ "KalshiTimeoutError", "KalshiValidationError", "KalshiWebSocketError", + "KlearAuth", + "KlearClient", + "KlearConfig", "LiveData", + "LogInRequest", + "LogInResponse", "LookupPoint", "LookupTickersForMarketInMultivariateEventCollectionRequest", "LookupTickersResponse", @@ -316,6 +332,9 @@ "PaymentStatusLiteral", "PaymentTypeLiteral", "PercentilePoint", + "PerpsClient", + "PerpsConfig", + "PerpsWebSocket", "PlayByPlay", "PlayByPlayPeriod", "PositionsResponse", @@ -353,4 +372,4 @@ "Withdrawal", ] -__version__ = "3.1.0" +__version__ = "3.2.0" diff --git a/kalshi/_base_client.py b/kalshi/_base_client.py index 62ddb36..2e47492 100644 --- a/kalshi/_base_client.py +++ b/kalshi/_base_client.py @@ -452,6 +452,14 @@ def close(self) -> None: self._closed = True self._client.close() + def clear_cookie(self, name: str) -> None: + """Drop a cookie (all domains/paths) from the httpx jar. + + Used by the Klear session client to invalidate a stale ``session`` + cookie before a re-login, so a prior account's cookie is never replayed. + """ + self._client.cookies.delete(name) + class AsyncTransport: """Asynchronous HTTP transport using httpx.AsyncClient.""" @@ -669,3 +677,11 @@ async def close(self) -> None: return await self._client.aclose() self._closed = True + + def clear_cookie(self, name: str) -> None: + """Drop a cookie (all domains/paths) from the httpx jar. + + Used by the Klear session client to invalidate a stale ``session`` + cookie before a re-login, so a prior account's cookie is never replayed. + """ + self._client.cookies.delete(name) diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index 1e23b9f..635c940 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -581,3 +581,220 @@ class ContractEntry: # - multivariateMarketLifecyclePayload: allOf with marketLifecycleV2Payload, covered by base # - Control envelopes (SubscriptionInfo, OkMessage): structural, low drift risk ] + + +# ── Perps (margin) API response-side contract maps ────────────────────────── +# Map perps SDK response models to their schema names in the perps specs. These +# are consumed by ``TestPerpsSpecDrift`` / ``TestPerpsScmSpecDrift`` and resolved +# against ``specs/perps_openapi.yaml`` / ``specs/perps_scm_openapi.yaml`` rather +# than the prediction-API spec. The foundation issue (#388) ships them empty; +# the per-resource perps issues append their entries. +# +# NOTE: ``TestSpecDrift.test_contract_map_completeness`` only scans the +# prediction-API ``kalshi.models.*`` modules, so perps models under +# ``kalshi.perps.models.*`` are not subject to that completeness gate — these +# maps are additive coverage, populated as response models land. +PERPS_CONTRACT_MAP: list[ContractEntry] = [ + # ── perps exchange (#389) ─────────────────────────────────────────── + ContractEntry( + sdk_model="kalshi.perps.models.exchange.ExchangeStatus", + spec_schema="ExchangeStatus", + ), + ContractEntry( + sdk_model="kalshi.perps.models.exchange.MarginEnabledResponse", + spec_schema="MarginEnabledResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.models.exchange.GetMarginRiskParametersResponse", + spec_schema="GetMarginRiskParametersResponse", + ), + # ── perps markets (#390) ── + ContractEntry( + sdk_model="kalshi.perps.models.markets.MarginMarket", + spec_schema="MarginMarket", + ), + ContractEntry( + sdk_model="kalshi.perps.models.markets.MarginMarketCandlestick", + spec_schema="MarginMarketCandlestick", + ), + ContractEntry( + sdk_model="kalshi.perps.models.markets.BidAskDistributionHistorical", + spec_schema="BidAskDistributionHistorical", + ), + ContractEntry( + sdk_model="kalshi.perps.models.markets.PriceDistributionHistorical", + spec_schema="PriceDistributionHistorical", + ), + # ── perps orders (#391) ── + ContractEntry( + sdk_model="kalshi.perps.models.orders.CreateMarginOrderResponse", + spec_schema="CreateMarginOrderResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.models.orders.MarginOrder", + spec_schema="MarginOrder", + ), + ContractEntry( + sdk_model="kalshi.perps.models.orders.GetMarginOrderResponse", + spec_schema="GetMarginOrderResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.models.orders.GetMarginOrdersResponse", + spec_schema="GetMarginOrdersResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.models.orders.CancelMarginOrderResponse", + spec_schema="CancelMarginOrderResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.models.orders.DecreaseMarginOrderResponse", + spec_schema="DecreaseMarginOrderResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.models.orders.AmendMarginOrderResponse", + spec_schema="AmendMarginOrderResponse", + ), + # ── perps order_groups (#392) ── + ContractEntry( + sdk_model="kalshi.perps.models.order_groups.OrderGroup", + spec_schema="OrderGroup", + ), + ContractEntry( + sdk_model="kalshi.perps.models.order_groups.GetOrderGroupResponse", + spec_schema="GetOrderGroupResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.models.order_groups.CreateOrderGroupResponse", + spec_schema="CreateOrderGroupResponse", + ), + # ── perps portfolio (#393) ── + ContractEntry( + sdk_model="kalshi.perps.models.portfolio.MarginPosition", + spec_schema="MarginPosition", + ), + ContractEntry( + sdk_model="kalshi.perps.models.portfolio.GetMarginPositionsResponse", + spec_schema="GetMarginPositionsResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.models.portfolio.MarginFill", + spec_schema="MarginFill", + ), + ContractEntry( + sdk_model="kalshi.perps.models.portfolio.MarginTrade", + spec_schema="MarginTrade", + ), + # NOTE: GetMarginFillsResponse / GetMarginTradesResponse are intentionally + # NOT response-drift-mapped — their `cursor` is spec-`required` but the SDK + # keeps it Optional for safe terminal-page parsing (the leaf MarginFill / + # MarginTrade above carry the field coverage). Mirrors how the prediction-API + # Page envelopes are excluded from CONTRACT_MAP. + # ── perps margin_account (#394) ── + ContractEntry( + sdk_model="kalshi.perps.models.margin_account.MarginSubaccountBalance", + spec_schema="MarginSubaccountBalance", + ), + ContractEntry( + sdk_model="kalshi.perps.models.margin_account.GetMarginBalanceResponse", + spec_schema="GetMarginBalanceResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.models.margin_account.MarginRiskPosition", + spec_schema="MarginRiskPosition", + ), + ContractEntry( + sdk_model="kalshi.perps.models.margin_account.GetMarginRiskResponse", + spec_schema="GetMarginRiskResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.models.margin_account.NotionalRiskLimitResponse", + spec_schema="NotionalRiskLimitResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.models.margin_account.GetMarginFeeTiersResponse", + spec_schema="GetMarginFeeTiersResponse", + ), + # ── perps funding (#395) ── + ContractEntry( + sdk_model="kalshi.perps.models.funding.MarginFundingRate", + spec_schema="MarginFundingRate", + ), + ContractEntry( + sdk_model="kalshi.perps.models.funding.MarginFundingHistoryEntry", + spec_schema="MarginFundingHistoryEntry", + ), + ContractEntry( + sdk_model="kalshi.perps.models.funding.MarginFundingRateEstimate", + spec_schema="GetMarginFundingRateEstimateResponse", + ), + # ── perps transfers (#396) ── + ContractEntry( + sdk_model="kalshi.perps.models.transfers.IntraExchangeInstanceTransferResponse", + spec_schema="IntraExchangeInstanceTransferResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.models.transfers.CreateSubaccountResponse", + spec_schema="CreateSubaccountResponse", + ), +] + +PERPS_SCM_CONTRACT_MAP: list[ContractEntry] = [ + # ── perps SCM/Klear (#400) ────────────────────────────────────────── + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.MarginReport", + spec_schema="MarginReport", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.GetMarginReportsResponse", + spec_schema="GetMarginReportsResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.ObligationReceiveInfo", + spec_schema="ObligationReceiveInfo", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.SettlementDetail", + spec_schema="SettlementDetail", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.MaintenanceMarginDetail", + spec_schema="MaintenanceMarginDetail", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.ObligationEntry", + spec_schema="ObligationEntry", + notes="Folds spec allOf[ObligationInfo, inline] into one model", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.GetActiveMarginObligationResponse", + spec_schema="GetActiveMarginObligationResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.SettlementEstimate", + spec_schema="SettlementEstimate", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.GetSettlementEstimateResponse", + spec_schema="GetSettlementEstimateResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.GetSettlementBalanceResponse", + spec_schema="GetSettlementBalanceResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.GetGuarantyFundBalanceResponse", + spec_schema="GetGuarantyFundBalanceResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.WithdrawSettlementBalanceResponse", + spec_schema="WithdrawSettlementBalanceResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.GetSettlementBalanceWithdrawalResponse", + spec_schema="GetSettlementBalanceWithdrawalResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.SettlementBalanceHistoryEntry", + spec_schema="SettlementBalanceHistoryEntry", + ), +] diff --git a/kalshi/perps/__init__.py b/kalshi/perps/__init__.py new file mode 100644 index 0000000..5fb5c7d --- /dev/null +++ b/kalshi/perps/__init__.py @@ -0,0 +1,341 @@ +"""Kalshi Perps (margin) API — standalone client surface. + +The Perps (perpetual futures / margin) API lives on a **separate host** from the +prediction (event-contract) API and Kalshi recommends a **separate API key** for +it. It is therefore exposed through standalone :class:`PerpsClient` / +:class:`AsyncPerpsClient` classes with their own :class:`PerpsConfig`. + +Perps model and resource classes are exported from this package namespace +(``kalshi.perps``) rather than the top-level ``kalshi`` namespace, because many +perps schema names are intentional twins of the prediction-API models and would +collide in ``kalshi.__all__``. Only the headline entry points (``PerpsClient`` / +``AsyncPerpsClient`` / ``PerpsConfig``) are re-exported from ``kalshi``. +""" + +from __future__ import annotations + +from kalshi.perps.async_client import AsyncPerpsClient +from kalshi.perps.client import PerpsClient +from kalshi.perps.config import ( + PERPS_DEMO_BASE_URL, + PERPS_DEMO_WS_URL, + PERPS_PRODUCTION_BASE_URL, + PERPS_PRODUCTION_WS_URL, + PerpsConfig, +) +from kalshi.perps.klear import ( + AsyncKlearClient, + Error, + KlearAuth, + KlearClient, + KlearConfig, + LogInRequest, + LogInResponse, +) +from kalshi.perps.klear.models.margin import ( + GetActiveMarginObligationResponse, + GetGuarantyFundBalanceResponse, + GetMarginReportsResponse, + GetObligationHistoryResponse, + GetSettlementBalanceHistoryResponse, + GetSettlementBalanceResponse, + GetSettlementBalanceWithdrawalResponse, + GetSettlementEstimateResponse, + MaintenanceMarginDetail, + MarginReport, + MarginReportTypeLiteral, + ObligationEntry, + ObligationReceiveInfo, + SettlementBalanceHistoryEntry, + SettlementDetail, + SettlementEstimate, + WithdrawalStatusLiteral, + WithdrawSettlementBalanceRequest, + WithdrawSettlementBalanceResponse, +) +from kalshi.perps.klear.resources.margin import ( + AsyncMarginResource, + MarginResource, +) +from kalshi.perps.models.common import ( + BookSide, + EmptyResponse, + ErrorResponse, + ExchangeIndex, + ExchangeInstance, + LastUpdateReason, + MarginMarketStatus, + OrderSource, + PriceLevelDollarsCountFp, + SelfTradePreventionType, +) +from kalshi.perps.models.exchange import ( + ExchangeStatus, + GetMarginRiskParametersResponse, + MarginEnabledResponse, +) +from kalshi.perps.models.funding import ( + MarginFundingHistoryEntry, + MarginFundingRate, + MarginFundingRateEstimate, +) +from kalshi.perps.models.margin_account import ( + GetMarginBalanceResponse, + GetMarginFeeTiersResponse, + GetMarginRiskResponse, + MarginRiskPosition, + MarginSubaccountBalance, + NotionalRiskLimitResponse, +) +from kalshi.perps.models.markets import ( + BidAskDistributionHistorical, + MarginMarket, + MarginMarketCandlestick, + MarginMarketStatusLiteral, + MarginOrderbook, + MarginOrderbookLevel, + PriceDistributionHistorical, +) +from kalshi.perps.models.order_groups import ( + CreateOrderGroupRequest, + CreateOrderGroupResponse, + GetOrderGroupResponse, + OrderGroup, + UpdateOrderGroupLimitRequest, +) +from kalshi.perps.models.orders import ( + AmendMarginOrderRequest, + AmendMarginOrderResponse, + BookSideLiteral, + CancelMarginOrderResponse, + CreateMarginOrderRequest, + CreateMarginOrderResponse, + DecreaseMarginOrderRequest, + DecreaseMarginOrderResponse, + GetMarginOrderResponse, + GetMarginOrdersResponse, + LastUpdateReasonLiteral, + MarginOrder, + OrderSourceLiteral, + SelfTradePreventionTypeLiteral, + TimeInForceLiteral, +) +from kalshi.perps.models.portfolio import ( + GetMarginFillsResponse, + GetMarginPositionsResponse, + GetMarginTradesResponse, + MarginFill, + MarginPosition, + MarginTrade, +) +from kalshi.perps.models.transfers import ( + ApplySubaccountTransferRequest, + CreateSubaccountResponse, + ExchangeInstanceLiteral, + IntraExchangeInstanceTransferRequest, + IntraExchangeInstanceTransferResponse, +) +from kalshi.perps.resources.exchange import ( + AsyncPerpsExchangeResource, + PerpsExchangeResource, +) +from kalshi.perps.resources.funding import ( + AsyncFundingResource, + FundingResource, +) +from kalshi.perps.resources.margin_account import ( + AsyncMarginAccountResource, + MarginAccountResource, +) +from kalshi.perps.resources.markets import ( + AsyncPerpsMarketsResource, + PerpsMarketsResource, +) +from kalshi.perps.resources.order_groups import ( + AsyncOrderGroupsResource, + OrderGroupsResource, +) +from kalshi.perps.resources.orders import ( + AsyncMarginOrdersResource, + MarginOrdersResource, +) +from kalshi.perps.resources.portfolio import ( + AsyncPerpsPortfolioResource, + PerpsPortfolioResource, +) +from kalshi.perps.resources.transfers import ( + AsyncTransfersResource, + TransfersResource, +) +from kalshi.perps.ws import ( + ConnectionState, + MessageQueue, + OverflowStrategy, + PerpsConnectionManager, + PerpsWebSocket, +) +from kalshi.perps.ws.models import ( + FundingRate, + MarginFillMessage, + MarginFillPayload, + MarginOrderbookDeltaMessage, + MarginOrderbookDeltaPayload, + MarginOrderbookSnapshotMessage, + MarginOrderbookSnapshotPayload, + MarginTickerMessage, + MarginTickerPayload, + MarginTradeMessage, + MarginTradePayload, + MarginUserOrderMessage, + MarginUserOrderPayload, + OrderGroupEventType, + OrderGroupUpdatesMessage, + OrderGroupUpdatesPayload, + PerpsBookSide, + PerpsChannel, + PerpsLastUpdateReason, + PerpsSelfTradePreventionType, + TickerPrice, + UpdateSubscriptionAction, +) + +__all__ = [ + "PERPS_DEMO_BASE_URL", + "PERPS_DEMO_WS_URL", + "PERPS_PRODUCTION_BASE_URL", + "PERPS_PRODUCTION_WS_URL", + "AmendMarginOrderRequest", + "AmendMarginOrderResponse", + "ApplySubaccountTransferRequest", + "AsyncFundingResource", + "AsyncKlearClient", + "AsyncMarginAccountResource", + "AsyncMarginOrdersResource", + "AsyncMarginResource", + "AsyncOrderGroupsResource", + "AsyncPerpsClient", + "AsyncPerpsExchangeResource", + "AsyncPerpsMarketsResource", + "AsyncPerpsPortfolioResource", + "AsyncTransfersResource", + "BidAskDistributionHistorical", + "BookSide", + "BookSideLiteral", + "CancelMarginOrderResponse", + "ConnectionState", + "CreateMarginOrderRequest", + "CreateMarginOrderResponse", + "CreateOrderGroupRequest", + "CreateOrderGroupResponse", + "CreateSubaccountResponse", + "DecreaseMarginOrderRequest", + "DecreaseMarginOrderResponse", + "EmptyResponse", + "Error", + "ErrorResponse", + "ExchangeIndex", + "ExchangeInstance", + "ExchangeInstanceLiteral", + "ExchangeStatus", + "FundingRate", + "FundingResource", + "GetActiveMarginObligationResponse", + "GetGuarantyFundBalanceResponse", + "GetMarginBalanceResponse", + "GetMarginFeeTiersResponse", + "GetMarginFillsResponse", + "GetMarginOrderResponse", + "GetMarginOrdersResponse", + "GetMarginPositionsResponse", + "GetMarginReportsResponse", + "GetMarginRiskParametersResponse", + "GetMarginRiskResponse", + "GetMarginTradesResponse", + "GetObligationHistoryResponse", + "GetOrderGroupResponse", + "GetSettlementBalanceHistoryResponse", + "GetSettlementBalanceResponse", + "GetSettlementBalanceWithdrawalResponse", + "GetSettlementEstimateResponse", + "IntraExchangeInstanceTransferRequest", + "IntraExchangeInstanceTransferResponse", + "KlearAuth", + "KlearClient", + "KlearConfig", + "LastUpdateReason", + "LastUpdateReasonLiteral", + "LogInRequest", + "LogInResponse", + "MaintenanceMarginDetail", + "MarginAccountResource", + "MarginEnabledResponse", + "MarginFill", + "MarginFillMessage", + "MarginFillPayload", + "MarginFundingHistoryEntry", + "MarginFundingRate", + "MarginFundingRateEstimate", + "MarginMarket", + "MarginMarketCandlestick", + "MarginMarketStatus", + "MarginMarketStatusLiteral", + "MarginOrder", + "MarginOrderbook", + "MarginOrderbookDeltaMessage", + "MarginOrderbookDeltaPayload", + "MarginOrderbookLevel", + "MarginOrderbookSnapshotMessage", + "MarginOrderbookSnapshotPayload", + "MarginOrdersResource", + "MarginPosition", + "MarginReport", + "MarginReportTypeLiteral", + "MarginResource", + "MarginRiskPosition", + "MarginSubaccountBalance", + "MarginTickerMessage", + "MarginTickerPayload", + "MarginTrade", + "MarginTradeMessage", + "MarginTradePayload", + "MarginUserOrderMessage", + "MarginUserOrderPayload", + "MessageQueue", + "NotionalRiskLimitResponse", + "ObligationEntry", + "ObligationReceiveInfo", + "OrderGroup", + "OrderGroupEventType", + "OrderGroupUpdatesMessage", + "OrderGroupUpdatesPayload", + "OrderGroupsResource", + "OrderSource", + "OrderSourceLiteral", + "OverflowStrategy", + "PerpsBookSide", + "PerpsChannel", + "PerpsClient", + "PerpsConfig", + "PerpsConnectionManager", + "PerpsExchangeResource", + "PerpsLastUpdateReason", + "PerpsMarketsResource", + "PerpsPortfolioResource", + "PerpsSelfTradePreventionType", + "PerpsWebSocket", + "PriceDistributionHistorical", + "PriceLevelDollarsCountFp", + "SelfTradePreventionType", + "SelfTradePreventionTypeLiteral", + "SettlementBalanceHistoryEntry", + "SettlementDetail", + "SettlementEstimate", + "TickerPrice", + "TimeInForceLiteral", + "TransfersResource", + "UpdateOrderGroupLimitRequest", + "UpdateSubscriptionAction", + "WithdrawSettlementBalanceRequest", + "WithdrawSettlementBalanceResponse", + "WithdrawalStatusLiteral", +] diff --git a/kalshi/perps/_env.py b/kalshi/perps/_env.py new file mode 100644 index 0000000..33ca1dd --- /dev/null +++ b/kalshi/perps/_env.py @@ -0,0 +1,65 @@ +"""Perps credential loading from the separate ``KALSHI_PERPS_*`` env vars. + +Kalshi recommends a **separate API key** for the perps exchange, so perps +credentials live under their own environment-variable namespace rather than +reusing the prediction-API ``KALSHI_*`` vars. Mirrors +:meth:`kalshi.auth.KalshiAuth.try_from_env` semantics. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Callable + +from kalshi.auth import KalshiAuth +from kalshi.errors import KalshiAuthError + +logger = logging.getLogger("kalshi") + + +def try_perps_auth_from_env( + *, + password: bytes | str | Callable[[], bytes | str] | None = None, +) -> KalshiAuth | None: + """Build a :class:`KalshiAuth` from ``KALSHI_PERPS_*`` env vars, or ``None``. + + Reads: + KALSHI_PERPS_KEY_ID (required — return ``None`` if unset) + KALSHI_PERPS_PRIVATE_KEY (PEM string) or KALSHI_PERPS_PRIVATE_KEY_PATH (path) + KALSHI_PERPS_PRIVATE_KEY_PASSPHRASE (optional; used when the PEM is encrypted) + + Returns ``None`` (with a warning) when ``KALSHI_PERPS_KEY_ID`` is set but no + private-key source is configured, mirroring the prediction-API behaviour so + a partial credential bundle yields an unauthenticated client rather than a + hard error. The signer itself is reused unchanged — perps keys sign the same + ``str(ts_ms) + METHOD + path`` payload. + """ + key_id = os.environ.get("KALSHI_PERPS_KEY_ID") + if not key_id: + return None + + resolved_password = ( + password + if password is not None + else os.environ.get("KALSHI_PERPS_PRIVATE_KEY_PASSPHRASE") + ) + + pem_string = os.environ.get("KALSHI_PERPS_PRIVATE_KEY") + key_path = os.environ.get("KALSHI_PERPS_PRIVATE_KEY_PATH") + if pem_string and key_path: + raise KalshiAuthError( + "Both KALSHI_PERPS_PRIVATE_KEY and KALSHI_PERPS_PRIVATE_KEY_PATH are " + "set. Provide exactly one — unset whichever you don't intend to use." + ) + if pem_string: + return KalshiAuth.from_pem(key_id, pem_string, password=resolved_password) + if key_path: + return KalshiAuth.from_key_path(key_id, key_path, password=resolved_password) + + logger.warning( + "KALSHI_PERPS_KEY_ID is set but neither KALSHI_PERPS_PRIVATE_KEY nor " + "KALSHI_PERPS_PRIVATE_KEY_PATH is configured. Returning an " + "unauthenticated perps client." + ) + return None diff --git a/kalshi/perps/async_client.py b/kalshi/perps/async_client.py new file mode 100644 index 0000000..e612d63 --- /dev/null +++ b/kalshi/perps/async_client.py @@ -0,0 +1,180 @@ +"""Asynchronous Kalshi Perps (margin) client.""" + +from __future__ import annotations + +import os +from pathlib import Path +from types import TracebackType +from typing import TypedDict, Unpack + +import httpx + +from kalshi._base_client import AsyncTransport +from kalshi.auth import KalshiAuth +from kalshi.perps._env import try_perps_auth_from_env +from kalshi.perps.config import ( + PERPS_DEMO_BASE_URL, + PERPS_DEMO_WS_URL, + PerpsConfig, +) +from kalshi.perps.resources.exchange import AsyncPerpsExchangeResource +from kalshi.perps.resources.funding import AsyncFundingResource +from kalshi.perps.resources.margin_account import AsyncMarginAccountResource +from kalshi.perps.resources.markets import AsyncPerpsMarketsResource +from kalshi.perps.resources.order_groups import AsyncOrderGroupsResource +from kalshi.perps.resources.orders import AsyncMarginOrdersResource +from kalshi.perps.resources.portfolio import AsyncPerpsPortfolioResource +from kalshi.perps.resources.transfers import AsyncTransfersResource + + +class PerpsClientInitKwargs(TypedDict, total=False): + """Typed forwarder shape for :meth:`AsyncPerpsClient.from_env`.""" + + key_id: str | None + private_key: str | bytes | None + private_key_path: str | Path | None + auth: KalshiAuth | None + config: PerpsConfig | None + demo: bool + base_url: str | None + timeout: float | None + max_retries: int | None + transport: httpx.AsyncBaseTransport | None + + +class AsyncPerpsClient: + """Asynchronous client for the Kalshi **Perps (margin)** API. + + Async sibling of :class:`kalshi.perps.PerpsClient`; see that class for the + separate-host / separate-key rationale. Reuses the RSA-PSS signer and the + async transport unchanged. + + Usage:: + + async with AsyncPerpsClient.from_env(demo=True) as c: + status = await c.exchange.status() + """ + + def __init__( + self, + *, + key_id: str | None = None, + private_key_path: str | Path | None = None, + private_key: str | bytes | None = None, + auth: KalshiAuth | None = None, + config: PerpsConfig | None = None, + demo: bool = False, + base_url: str | None = None, + timeout: float | None = None, + max_retries: int | None = None, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + if key_id is not None and not key_id.strip(): + raise ValueError("key_id must not be empty. Omit it for unauthenticated access.") + if private_key_path is not None and private_key is not None: + raise ValueError("Provide either private_key_path or private_key, not both.") + self._auth: KalshiAuth | None + self._auth_owned: bool + if auth is not None: + self._auth = auth + self._auth_owned = False + elif key_id and private_key_path: + self._auth = KalshiAuth.from_key_path(key_id, private_key_path) + self._auth_owned = True + elif key_id and private_key: + self._auth = KalshiAuth.from_pem(key_id, private_key) + self._auth_owned = True + elif key_id or private_key or private_key_path: + # Partial credentials — fail fast instead of silently building an + # unauthenticated client (which would surface later as confusing + # 401s). Reaching here means exactly one half of the pair is set. + raise ValueError( + "Incomplete credentials: provide BOTH key_id and one of " + "private_key / private_key_path to authenticate, or omit all " + "of them for unauthenticated access." + ) + else: + self._auth = None + self._auth_owned = False + + if config is not None: + self._config: PerpsConfig = config + else: + if demo and base_url is not None and base_url.rstrip("/") != PERPS_DEMO_BASE_URL: + raise ValueError( + "Conflicting environment: demo=True together with explicit " + f"base_url={base_url!r}. demo=True implies base_url=" + f"{PERPS_DEMO_BASE_URL!r}; passing a different REST endpoint " + "would leave ws_base_url pinned to the demo WS feed, producing " + "a split REST/WS environment. Drop demo=True and pass both " + "base_url and ws_base_url via a PerpsConfig, or use " + "PerpsConfig.demo() / PerpsConfig.production()." + ) + config_kwargs: dict[str, object] = {} + if base_url: + config_kwargs["base_url"] = base_url + if demo: + config_kwargs.setdefault("base_url", PERPS_DEMO_BASE_URL) + config_kwargs.setdefault("ws_base_url", PERPS_DEMO_WS_URL) + if timeout is not None: + config_kwargs["timeout"] = timeout + if max_retries is not None: + config_kwargs["max_retries"] = max_retries + self._config = PerpsConfig(**config_kwargs) # type: ignore[arg-type] + + self._transport = AsyncTransport(self._auth, self._config, transport=transport) + self.exchange = AsyncPerpsExchangeResource(self._transport) + self.markets = AsyncPerpsMarketsResource(self._transport) + self.orders = AsyncMarginOrdersResource(self._transport) + self.order_groups = AsyncOrderGroupsResource(self._transport) + self.portfolio = AsyncPerpsPortfolioResource(self._transport) + self.margin = AsyncMarginAccountResource(self._transport) + self.funding = AsyncFundingResource(self._transport) + self.transfers = AsyncTransfersResource(self._transport) + + @property + def is_authenticated(self) -> bool: + """Whether this client has auth credentials configured.""" + return self._auth is not None + + @classmethod + def from_env(cls, **kwargs: Unpack[PerpsClientInitKwargs]) -> AsyncPerpsClient: + """Create an async perps client from the ``KALSHI_PERPS_*`` env vars. + + See :meth:`kalshi.perps.PerpsClient.from_env` for the variable list and + the separate-credential rationale. + """ + caller_supplied_auth = ( + "auth" in kwargs + or kwargs.get("key_id") is not None + or kwargs.get("private_key") is not None + or kwargs.get("private_key_path") is not None + ) + env_built_auth: KalshiAuth | None = None + if not caller_supplied_auth: + env_built_auth = try_perps_auth_from_env() + if env_built_auth is not None: + kwargs["auth"] = env_built_auth + kwargs.setdefault("demo", os.environ.get("KALSHI_PERPS_DEMO", "").lower() == "true") + kwargs.setdefault("base_url", os.environ.get("KALSHI_PERPS_API_BASE_URL")) + client = cls(**kwargs) + if env_built_auth is not None: + client._auth_owned = True + return client + + async def close(self) -> None: + """Close the underlying async HTTP connection pool.""" + await self._transport.close() + if self._auth is not None and self._auth_owned: + self._auth.close() + + async def __aenter__(self) -> AsyncPerpsClient: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.close() diff --git a/kalshi/perps/client.py b/kalshi/perps/client.py new file mode 100644 index 0000000..cb56181 --- /dev/null +++ b/kalshi/perps/client.py @@ -0,0 +1,196 @@ +"""Synchronous Kalshi Perps (margin) client.""" + +from __future__ import annotations + +import os +from pathlib import Path +from types import TracebackType +from typing import TypedDict, Unpack + +import httpx + +from kalshi._base_client import SyncTransport +from kalshi.auth import KalshiAuth +from kalshi.perps._env import try_perps_auth_from_env +from kalshi.perps.config import ( + PERPS_DEMO_BASE_URL, + PERPS_DEMO_WS_URL, + PerpsConfig, +) +from kalshi.perps.resources.exchange import PerpsExchangeResource +from kalshi.perps.resources.funding import FundingResource +from kalshi.perps.resources.margin_account import MarginAccountResource +from kalshi.perps.resources.markets import PerpsMarketsResource +from kalshi.perps.resources.order_groups import OrderGroupsResource +from kalshi.perps.resources.orders import MarginOrdersResource +from kalshi.perps.resources.portfolio import PerpsPortfolioResource +from kalshi.perps.resources.transfers import TransfersResource + + +class PerpsClientInitKwargs(TypedDict, total=False): + """Typed forwarder shape for :meth:`PerpsClient.from_env`. + + Mirrors the ``__init__`` keyword surface so ``mypy --strict`` catches typos + like ``from_env(time_out=10)`` instead of silently swallowing them. + """ + + key_id: str | None + private_key: str | bytes | None + private_key_path: str | Path | None + auth: KalshiAuth | None + config: PerpsConfig | None + demo: bool + base_url: str | None + timeout: float | None + max_retries: int | None + transport: httpx.BaseTransport | None + + +class PerpsClient: + """Synchronous client for the Kalshi **Perps (margin)** API. + + The perps API lives on a separate host (``external-api.kalshi.com``) and + Kalshi recommends a **separate API key** for it, so this is a standalone + client rather than a namespace on :class:`kalshi.KalshiClient`. It reuses the + same RSA-PSS signer (:class:`kalshi.auth.KalshiAuth`) and HTTP transport + unchanged. + + Usage:: + + with PerpsClient(key_id="...", private_key_path="~/.kalshi/perps.pem") as c: + status = c.exchange.status() + + ``from_env`` reads a **separate** ``KALSHI_PERPS_*`` credential namespace — + perps requires keys issued for the perps exchange. + """ + + def __init__( + self, + *, + key_id: str | None = None, + private_key_path: str | Path | None = None, + private_key: str | bytes | None = None, + auth: KalshiAuth | None = None, + config: PerpsConfig | None = None, + demo: bool = False, + base_url: str | None = None, + timeout: float | None = None, + max_retries: int | None = None, + transport: httpx.BaseTransport | None = None, + ) -> None: + if key_id is not None and not key_id.strip(): + raise ValueError("key_id must not be empty. Omit it for unauthenticated access.") + if private_key_path is not None and private_key is not None: + raise ValueError("Provide either private_key_path or private_key, not both.") + self._auth: KalshiAuth | None + self._auth_owned: bool + if auth is not None: + self._auth = auth + self._auth_owned = False + elif key_id and private_key_path: + self._auth = KalshiAuth.from_key_path(key_id, private_key_path) + self._auth_owned = True + elif key_id and private_key: + self._auth = KalshiAuth.from_pem(key_id, private_key) + self._auth_owned = True + elif key_id or private_key or private_key_path: + # Partial credentials — fail fast instead of silently building an + # unauthenticated client (which would surface later as confusing + # 401s). Reaching here means exactly one half of the pair is set. + raise ValueError( + "Incomplete credentials: provide BOTH key_id and one of " + "private_key / private_key_path to authenticate, or omit all " + "of them for unauthenticated access." + ) + else: + self._auth = None + self._auth_owned = False + + if config is not None: + self._config: PerpsConfig = config + else: + if demo and base_url is not None and base_url.rstrip("/") != PERPS_DEMO_BASE_URL: + raise ValueError( + "Conflicting environment: demo=True together with explicit " + f"base_url={base_url!r}. demo=True implies base_url=" + f"{PERPS_DEMO_BASE_URL!r}; passing a different REST endpoint " + "would leave ws_base_url pinned to the demo WS feed, producing " + "a split REST/WS environment. Drop demo=True and pass both " + "base_url and ws_base_url via a PerpsConfig, or use " + "PerpsConfig.demo() / PerpsConfig.production()." + ) + config_kwargs: dict[str, object] = {} + if base_url: + config_kwargs["base_url"] = base_url + if demo: + config_kwargs.setdefault("base_url", PERPS_DEMO_BASE_URL) + config_kwargs.setdefault("ws_base_url", PERPS_DEMO_WS_URL) + if timeout is not None: + config_kwargs["timeout"] = timeout + if max_retries is not None: + config_kwargs["max_retries"] = max_retries + self._config = PerpsConfig(**config_kwargs) # type: ignore[arg-type] + + self._transport = SyncTransport(self._auth, self._config, transport=transport) + self.exchange = PerpsExchangeResource(self._transport) + self.markets = PerpsMarketsResource(self._transport) + self.orders = MarginOrdersResource(self._transport) + self.order_groups = OrderGroupsResource(self._transport) + self.portfolio = PerpsPortfolioResource(self._transport) + self.margin = MarginAccountResource(self._transport) + self.funding = FundingResource(self._transport) + self.transfers = TransfersResource(self._transport) + + @property + def is_authenticated(self) -> bool: + """Whether this client has auth credentials configured.""" + return self._auth is not None + + @classmethod + def from_env(cls, **kwargs: Unpack[PerpsClientInitKwargs]) -> PerpsClient: + """Create a perps client from the ``KALSHI_PERPS_*`` environment variables. + + Reads: + KALSHI_PERPS_KEY_ID (optional — omit for unauthenticated access) + KALSHI_PERPS_PRIVATE_KEY (PEM string) or KALSHI_PERPS_PRIVATE_KEY_PATH + KALSHI_PERPS_API_BASE_URL (optional, overrides base_url) + KALSHI_PERPS_DEMO (optional, "true" for the demo environment) + + Perps credentials are intentionally separate from the prediction-API + ``KALSHI_*`` vars — Kalshi recommends a distinct API key for the perps + exchange. Returns an unauthenticated client if no credentials are set. + """ + caller_supplied_auth = ( + "auth" in kwargs + or kwargs.get("key_id") is not None + or kwargs.get("private_key") is not None + or kwargs.get("private_key_path") is not None + ) + env_built_auth: KalshiAuth | None = None + if not caller_supplied_auth: + env_built_auth = try_perps_auth_from_env() + if env_built_auth is not None: + kwargs["auth"] = env_built_auth + kwargs.setdefault("demo", os.environ.get("KALSHI_PERPS_DEMO", "").lower() == "true") + kwargs.setdefault("base_url", os.environ.get("KALSHI_PERPS_API_BASE_URL")) + client = cls(**kwargs) + if env_built_auth is not None: + client._auth_owned = True + return client + + def close(self) -> None: + """Close the underlying HTTP connection pool.""" + self._transport.close() + if self._auth is not None and self._auth_owned: + self._auth.close() + + def __enter__(self) -> PerpsClient: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() diff --git a/kalshi/perps/config.py b/kalshi/perps/config.py new file mode 100644 index 0000000..37abe66 --- /dev/null +++ b/kalshi/perps/config.py @@ -0,0 +1,207 @@ +"""Configuration for the Kalshi Perps (margin) SDK client. + +``PerpsConfig`` subclasses :class:`kalshi.config.KalshiConfig` so that the +reused :class:`kalshi._base_client.SyncTransport` / ``AsyncTransport`` (whose +constructors are annotated ``config: KalshiConfig``) accept it unchanged. The +subclass exists because ``KalshiConfig.__post_init__`` hard-fails on hosts +outside the prediction-API ``_KNOWN_HOSTS`` set — the perps hosts +``external-api.kalshi.com`` / ``external-api.demo.kalshi.co`` are not in that +set, so reusing ``KalshiConfig`` directly would raise at construction. The +``/trade-api/v2`` path component is identical for perps and is validated the +same way. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from types import MappingProxyType +from urllib.parse import urlparse + +from kalshi._constants import AUTH_HEADER_PREFIX +from kalshi.config import KalshiConfig + +PERPS_PRODUCTION_BASE_URL = "https://external-api.kalshi.com/trade-api/v2" +PERPS_DEMO_BASE_URL = "https://external-api.demo.kalshi.co/trade-api/v2" + +PERPS_PRODUCTION_WS_URL = "wss://external-api-margin-ws.kalshi.com/trade-api/ws/v2/margin" +PERPS_DEMO_WS_URL = "wss://external-api-margin-ws.demo.kalshi.co/trade-api/ws/v2/margin" + +# REST + WS hosts both validate against this union (the perps WS host differs +# from the perps REST host, unlike the prediction API where both share a host). +_PERPS_REST_HOSTS = frozenset( + {"external-api.kalshi.com", "external-api.demo.kalshi.co"} +) +_PERPS_WS_HOSTS = frozenset( + {"external-api-margin-ws.kalshi.com", "external-api-margin-ws.demo.kalshi.co"} +) +_PERPS_KNOWN_HOSTS = _PERPS_REST_HOSTS | _PERPS_WS_HOSTS +_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) + +# Perps prod/demo host pairs, used by the split REST/WS environment guard. +_PERPS_PROD_REST_HOST = "external-api.kalshi.com" +_PERPS_DEMO_REST_HOST = "external-api.demo.kalshi.co" +_PERPS_PROD_WS_HOST = "external-api-margin-ws.kalshi.com" +_PERPS_DEMO_WS_HOST = "external-api-margin-ws.demo.kalshi.co" + +logger = logging.getLogger("kalshi") + + +@dataclass(frozen=True) +class PerpsConfig(KalshiConfig): + """Perps (margin) client configuration. + + Same field surface as :class:`kalshi.config.KalshiConfig` (timeouts, retry + policy, ``extra_headers``, HTTP/2, custom JSON loaders, WS keepalive knobs), + but defaults to the perps REST/WS base URLs and validates against the perps + host set. Use :meth:`production` / :meth:`demo` for the canonical + environments. + + The ``allow_unknown_host`` escape hatch (and the process-wide + ``KALSHI_PERPS_ALLOW_UNKNOWN_HOST=1`` env var) relaxes the host check for + mock servers / proxies, exactly mirroring the prediction-API config. + """ + + base_url: str = PERPS_PRODUCTION_BASE_URL # trailing slash stripped automatically + ws_base_url: str = PERPS_PRODUCTION_WS_URL # trailing slash stripped automatically + + def __post_init__(self) -> None: + # NB: deliberately does NOT call super().__post_init__() — that would + # validate against the prediction-API host set and reject perps hosts. + # The checks below manually mirror KalshiConfig.__post_init__ (minus the + # host/path specifics): base_url/ws_base_url trailing-slash strip, scheme + # + plaintext-to-remote guards, KALSHI-ACCESS-* rejection in extra_headers, + # extra_headers freeze to MappingProxyType, and the http2/h2 check. When + # KalshiConfig.__post_init__ gains a new guard, mirror it here too. + if self.base_url.endswith("/"): + object.__setattr__(self, "base_url", self.base_url.rstrip("/")) + if self.ws_base_url.endswith("/"): + object.__setattr__(self, "ws_base_url", self.ws_base_url.rstrip("/")) + allow_unknown = self.allow_unknown_host or ( + os.environ.get("KALSHI_PERPS_ALLOW_UNKNOWN_HOST", "").strip() == "1" + ) + PerpsConfig._validate_perps_url( + self.base_url, + "base_url", + secure="https", + plaintext="http", + allow_unknown_host=allow_unknown, + ) + PerpsConfig._validate_perps_url( + self.ws_base_url, + "ws_base_url", + secure="wss", + plaintext="ws", + allow_unknown_host=allow_unknown, + ) + # Enforce the /trade-api/v2 REST path component (identical to the + # prediction API). Trailing slashes already stripped above. + base_path = urlparse(self.base_url).path + if base_path != "/trade-api/v2": + raise ValueError( + f"PerpsConfig.base_url must include the /trade-api/v2 path " + f"component, got base_url={self.base_url!r}" + ) + # Reject a split REST/WS environment (prod REST + demo WS, or vice + # versa) — the same real-money-vs-demo footgun KalshiConfig guards. + base_host = (urlparse(self.base_url).hostname or "").lower() + ws_host = (urlparse(self.ws_base_url).hostname or "").lower() + is_split = ( + base_host == _PERPS_PROD_REST_HOST and ws_host == _PERPS_DEMO_WS_HOST + ) or (base_host == _PERPS_DEMO_REST_HOST and ws_host == _PERPS_PROD_WS_HOST) + if is_split: + raise ValueError( + "PerpsConfig: split REST/WS environment is not allowed. " + f"base_url={self.base_url!r} and ws_base_url={self.ws_base_url!r} " + "resolve to different perps environments (production vs demo). " + "Use PerpsConfig.production() / PerpsConfig.demo(), or pass both " + "base_url and ws_base_url explicitly." + ) + # Reject KALSHI-ACCESS-* prefix at construction so callers cannot forge auth. + if self.extra_headers: + leaked = sorted( + k for k in self.extra_headers if k.lower().startswith(AUTH_HEADER_PREFIX) + ) + if leaked: + raise ValueError( + f"PerpsConfig.extra_headers must not include KALSHI-ACCESS-* " + f"keys (got: {leaked!r}). These are reserved for SDK-managed " + f"RSA-PSS signing." + ) + object.__setattr__( + self, "extra_headers", MappingProxyType(dict(self.extra_headers)) + ) + if self.http2: + import importlib.util + + if importlib.util.find_spec("h2") is None: + raise ValueError( + "http2=True requires the 'h2' package — install with " + "`pip install kalshi-sdk[http2]`" + ) + + @staticmethod + def _validate_perps_url( + url: str, + field_name: str, + *, + secure: str, + plaintext: str, + allow_unknown_host: bool, + ) -> None: + """Reject URLs that would expose credentials (bad scheme or plaintext-to-remote).""" + parsed = urlparse(url) + scheme = parsed.scheme.lower() + host = (parsed.hostname or "").lower() + + if scheme not in (secure, plaintext): + raise ValueError( + f"PerpsConfig.{field_name} must use {secure}:// or {plaintext}://, " + f"got scheme={scheme!r} (url={url!r})" + ) + if not host: + raise ValueError(f"PerpsConfig.{field_name} is missing a host: {url!r}") + if scheme == plaintext and host not in _LOCAL_HOSTS: + raise ValueError( + f"PerpsConfig.{field_name} must use {secure}:// for non-loopback " + f"hosts; {plaintext}:// is only allowed for {sorted(_LOCAL_HOSTS)} " + f"(url={url!r}). Plaintext to a remote host would " + f"expose the KALSHI-ACCESS-KEY header and request signature." + ) + if host not in _PERPS_KNOWN_HOSTS and host not in _LOCAL_HOSTS: + if not allow_unknown_host: + raise ValueError( + f"PerpsConfig.{field_name} host {host!r} is not a known " + f"Kalshi perps endpoint. Known hosts: {sorted(_PERPS_KNOWN_HOSTS)}. " + "If this is an intentional mock server, proxy, or alternate " + "perps region, opt in with PerpsConfig(allow_unknown_host=True) " + "or set the environment variable KALSHI_PERPS_ALLOW_UNKNOWN_HOST=1." + ) + logger.warning( + "PerpsConfig.%s host %r is not a known Kalshi perps endpoint (%s). " + "Requests will be signed and sent there with your API key — " + "verify this is intentional. (allow_unknown_host=True or " + "KALSHI_PERPS_ALLOW_UNKNOWN_HOST=1 silenced the default error.)", + field_name, + host, + sorted(_PERPS_KNOWN_HOSTS), + ) + + @classmethod + def production(cls, **kwargs: object) -> PerpsConfig: + """Create config for the Kalshi perps production environment.""" + return cls( + base_url=PERPS_PRODUCTION_BASE_URL, + ws_base_url=PERPS_PRODUCTION_WS_URL, + **kwargs, # type: ignore[arg-type] + ) + + @classmethod + def demo(cls, **kwargs: object) -> PerpsConfig: + """Create config for the Kalshi perps demo/sandbox environment.""" + return cls( + base_url=PERPS_DEMO_BASE_URL, + ws_base_url=PERPS_DEMO_WS_URL, + **kwargs, # type: ignore[arg-type] + ) diff --git a/kalshi/perps/klear/__init__.py b/kalshi/perps/klear/__init__.py new file mode 100644 index 0000000..c3bbb89 --- /dev/null +++ b/kalshi/perps/klear/__init__.py @@ -0,0 +1,38 @@ +"""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. + +Security: ``email`` / ``password`` / ``code`` and the session cookie are secrets +— they are never logged, never placed in exception messages, and redacted from +``repr()``. +""" + +from __future__ import annotations + +from kalshi.perps.klear.async_client import AsyncKlearClient +from kalshi.perps.klear.auth import KlearAuth +from kalshi.perps.klear.client import KlearClient +from kalshi.perps.klear.config import ( + DEMO_KLEAR_URL, + PRODUCTION_KLEAR_URL, + KlearConfig, +) +from kalshi.perps.klear.models.auth import LogInRequest, LogInResponse +from kalshi.perps.klear.models.common import Error + +__all__ = [ + "DEMO_KLEAR_URL", + "PRODUCTION_KLEAR_URL", + "AsyncKlearClient", + "Error", + "KlearAuth", + "KlearClient", + "KlearConfig", + "LogInRequest", + "LogInResponse", +] diff --git a/kalshi/perps/klear/async_client.py b/kalshi/perps/klear/async_client.py new file mode 100644 index 0000000..831ff4e --- /dev/null +++ b/kalshi/perps/klear/async_client.py @@ -0,0 +1,113 @@ +"""Asynchronous Kalshi Klear (SCM) client.""" + +from __future__ import annotations + +import os +from types import TracebackType + +import httpx + +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 + + +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. + """ + + def __init__( + self, + *, + config: KlearConfig | None = None, + demo: bool = False, + base_url: str | None = None, + timeout: float | None = None, + max_retries: int | None = None, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + if config is not None: + self._config: KlearConfig = config + else: + if demo and base_url is not None and base_url.rstrip("/") != DEMO_KLEAR_URL: + raise ValueError( + "Conflicting environment: demo=True together with explicit " + f"base_url={base_url!r}. demo=True implies base_url={DEMO_KLEAR_URL!r}." + ) + config_kwargs: dict[str, object] = {} + if base_url: + config_kwargs["base_url"] = base_url + if demo: + config_kwargs.setdefault("base_url", DEMO_KLEAR_URL) + if timeout is not None: + config_kwargs["timeout"] = timeout + if max_retries is not None: + config_kwargs["max_retries"] = max_retries + 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.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, + *, + 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. + + See :meth:`kalshi.perps.klear.KlearClient.from_env` — credentials are + never read from the environment. ``transport`` is forwarded for tests. + """ + resolved_demo = ( + demo if demo is not None else os.environ.get("KALSHI_KLEAR_DEMO", "").lower() == "true" + ) + resolved_base_url = ( + base_url if base_url is not None else os.environ.get("KALSHI_KLEAR_API_BASE_URL") + ) + return cls( + demo=resolved_demo, + base_url=resolved_base_url, + timeout=timeout, + max_retries=max_retries, + 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.""" + await self._transport.close() + self._auth.reset() + + async def __aenter__(self) -> AsyncKlearClient: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.close() diff --git a/kalshi/perps/klear/auth.py b/kalshi/perps/klear/auth.py new file mode 100644 index 0000000..b10805d --- /dev/null +++ b/kalshi/perps/klear/auth.py @@ -0,0 +1,45 @@ +"""Cookie-session auth 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). + +Security: the 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 + + @property + def is_authenticated(self) -> bool: + """Whether a Klear session has been established via ``log_in``.""" + return self._authenticated + + @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 reset(self) -> None: + """Clear session state (e.g. on logout / client close).""" + self._authenticated = False + self._token = None + + def __repr__(self) -> str: + # Never leak the token. + return f"KlearAuth(authenticated={self._authenticated})" diff --git a/kalshi/perps/klear/client.py b/kalshi/perps/klear/client.py new file mode 100644 index 0000000..04388a3 --- /dev/null +++ b/kalshi/perps/klear/client.py @@ -0,0 +1,131 @@ +"""Synchronous Kalshi Klear (SCM) client.""" + +from __future__ import annotations + +import os +from types import TracebackType + +import httpx + +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. + + 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) + """ + + def __init__( + self, + *, + config: KlearConfig | None = None, + demo: bool = False, + base_url: str | None = None, + timeout: float | None = None, + max_retries: int | None = None, + transport: httpx.BaseTransport | None = None, + ) -> None: + if config is not None: + self._config: KlearConfig = config + else: + if demo and base_url is not None and base_url.rstrip("/") != DEMO_KLEAR_URL: + raise ValueError( + "Conflicting environment: demo=True together with explicit " + f"base_url={base_url!r}. demo=True implies base_url={DEMO_KLEAR_URL!r}." + ) + config_kwargs: dict[str, object] = {} + if base_url: + config_kwargs["base_url"] = base_url + if demo: + config_kwargs.setdefault("base_url", DEMO_KLEAR_URL) + if timeout is not None: + config_kwargs["timeout"] = timeout + if max_retries is not None: + config_kwargs["max_retries"] = max_retries + 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. + self._transport = SyncTransport(None, self._config, transport=transport) + self._auth = KlearAuth() + self.auth = AuthResource(self._transport, self._auth) + 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, + *, + 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). + + 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. + """ + resolved_demo = ( + demo if demo is not None else os.environ.get("KALSHI_KLEAR_DEMO", "").lower() == "true" + ) + resolved_base_url = ( + base_url if base_url is not None else os.environ.get("KALSHI_KLEAR_API_BASE_URL") + ) + return cls( + demo=resolved_demo, + base_url=resolved_base_url, + timeout=timeout, + max_retries=max_retries, + 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.""" + self._transport.close() + self._auth.reset() + + def __enter__(self) -> KlearClient: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.close() diff --git a/kalshi/perps/klear/config.py b/kalshi/perps/klear/config.py new file mode 100644 index 0000000..1ba9f7a --- /dev/null +++ b/kalshi/perps/klear/config.py @@ -0,0 +1,127 @@ +"""Configuration for the Kalshi Self-Clearing-Member (Klear / SCM) API. + +``KlearConfig`` subclasses :class:`kalshi.config.KalshiConfig` so the reused +:class:`kalshi._base_client.SyncTransport` / ``AsyncTransport`` (annotated +``config: KalshiConfig``) accept it unchanged. The Klear API lives on its own +host with a ``/klear-api/v1`` path (NOT ``/trade-api/v2``) and has no WebSocket +surface, so ``__post_init__`` validates the Klear hosts/path and skips the WS +checks. +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from types import MappingProxyType +from urllib.parse import urlparse + +from kalshi._constants import AUTH_HEADER_PREFIX +from kalshi.config import KalshiConfig + +PRODUCTION_KLEAR_URL = "https://api.klear.kalshi.com/klear-api/v1" +DEMO_KLEAR_URL = "https://demo-api.kalshi.co/klear-api/v1" + +_KLEAR_KNOWN_HOSTS = frozenset({"api.klear.kalshi.com", "demo-api.kalshi.co"}) +_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) + +logger = logging.getLogger("kalshi") + + +@dataclass(frozen=True) +class KlearConfig(KalshiConfig): + """Klear (SCM) client configuration. + + 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. + + The ``allow_unknown_host`` escape hatch (and ``KALSHI_KLEAR_ALLOW_UNKNOWN_HOST=1``) + relaxes the host check for mock servers / proxies. + """ + + base_url: str = PRODUCTION_KLEAR_URL # trailing slash stripped automatically + + def __post_init__(self) -> None: + # NB: deliberately does NOT call super().__post_init__() — the parent + # validates the /trade-api/v2 path + prediction hosts and the WS URL, + # none of which apply to Klear. The checks below manually mirror the + # host-agnostic KalshiConfig.__post_init__ guards (base_url slash strip, + # scheme + plaintext-to-remote, KALSHI-ACCESS-* rejection, extra_headers + # freeze, http2/h2 check); mirror any newly added KalshiConfig guard here. + if self.base_url.endswith("/"): + object.__setattr__(self, "base_url", self.base_url.rstrip("/")) + allow_unknown = self.allow_unknown_host or ( + os.environ.get("KALSHI_KLEAR_ALLOW_UNKNOWN_HOST", "").strip() == "1" + ) + parsed = urlparse(self.base_url) + scheme = parsed.scheme.lower() + host = (parsed.hostname or "").lower() + if scheme not in ("https", "http"): + raise ValueError( + f"KlearConfig.base_url must use https:// or http://, got " + f"scheme={scheme!r} (url={self.base_url!r})" + ) + if not host: + raise ValueError(f"KlearConfig.base_url is missing a host: {self.base_url!r}") + if scheme == "http" and host not in _LOCAL_HOSTS: + 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." + ) + if host not in _KLEAR_KNOWN_HOSTS and host not in _LOCAL_HOSTS: + if not allow_unknown: + raise ValueError( + f"KlearConfig.base_url host {host!r} is not a known Kalshi Klear " + f"endpoint. Known hosts: {sorted(_KLEAR_KNOWN_HOSTS)}. If this is an " + "intentional mock server or proxy, opt in with " + "KlearConfig(allow_unknown_host=True) or set " + "KALSHI_KLEAR_ALLOW_UNKNOWN_HOST=1." + ) + 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.", + host, + sorted(_KLEAR_KNOWN_HOSTS), + ) + # #202-style path guard: enforce /klear-api/v1 (NOT /trade-api/v2). + if parsed.path != "/klear-api/v1": + raise ValueError( + f"KlearConfig.base_url must include the /klear-api/v1 path component, " + f"got base_url={self.base_url!r}" + ) + # Reject KALSHI-ACCESS-* in extra_headers (harmless for Klear, kept for parity). + if self.extra_headers: + leaked = sorted( + k for k in self.extra_headers if k.lower().startswith(AUTH_HEADER_PREFIX) + ) + if leaked: + raise ValueError( + f"KlearConfig.extra_headers must not include KALSHI-ACCESS-* keys " + f"(got: {leaked!r})." + ) + object.__setattr__( + self, "extra_headers", MappingProxyType(dict(self.extra_headers)) + ) + if self.http2: + import importlib.util + + if importlib.util.find_spec("h2") is None: + raise ValueError( + "http2=True requires the 'h2' package — install with " + "`pip install kalshi-sdk[http2]`" + ) + + @classmethod + def production(cls, **kwargs: object) -> KlearConfig: + """Create config for the Kalshi Klear production environment.""" + return cls(base_url=PRODUCTION_KLEAR_URL, **kwargs) # type: ignore[arg-type] + + @classmethod + def demo(cls, **kwargs: object) -> KlearConfig: + """Create config for the Kalshi Klear demo environment.""" + return cls(base_url=DEMO_KLEAR_URL, **kwargs) # type: ignore[arg-type] diff --git a/kalshi/perps/klear/models/__init__.py b/kalshi/perps/klear/models/__init__.py new file mode 100644 index 0000000..b6d46e1 --- /dev/null +++ b/kalshi/perps/klear/models/__init__.py @@ -0,0 +1,52 @@ +"""Pydantic models for the Kalshi Klear (SCM) API.""" + +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, + GetGuarantyFundBalanceResponse, + GetMarginReportsResponse, + GetObligationHistoryResponse, + GetSettlementBalanceHistoryResponse, + GetSettlementBalanceResponse, + GetSettlementBalanceWithdrawalResponse, + GetSettlementEstimateResponse, + MaintenanceMarginDetail, + MarginReport, + MarginReportTypeLiteral, + ObligationEntry, + ObligationReceiveInfo, + SettlementBalanceHistoryEntry, + SettlementDetail, + SettlementEstimate, + WithdrawalStatusLiteral, + WithdrawSettlementBalanceRequest, + WithdrawSettlementBalanceResponse, +) + +__all__ = [ + "Error", + "GetActiveMarginObligationResponse", + "GetGuarantyFundBalanceResponse", + "GetMarginReportsResponse", + "GetObligationHistoryResponse", + "GetSettlementBalanceHistoryResponse", + "GetSettlementBalanceResponse", + "GetSettlementBalanceWithdrawalResponse", + "GetSettlementEstimateResponse", + "LogInRequest", + "LogInResponse", + "MaintenanceMarginDetail", + "MarginReport", + "MarginReportTypeLiteral", + "ObligationEntry", + "ObligationReceiveInfo", + "SettlementBalanceHistoryEntry", + "SettlementDetail", + "SettlementEstimate", + "WithdrawSettlementBalanceRequest", + "WithdrawSettlementBalanceResponse", + "WithdrawalStatusLiteral", +] diff --git a/kalshi/perps/klear/models/auth.py b/kalshi/perps/klear/models/auth.py new file mode 100644 index 0000000..08cda95 --- /dev/null +++ b/kalshi/perps/klear/models/auth.py @@ -0,0 +1,61 @@ +"""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/models/common.py b/kalshi/perps/klear/models/common.py new file mode 100644 index 0000000..c290807 --- /dev/null +++ b/kalshi/perps/klear/models/common.py @@ -0,0 +1,21 @@ +"""Shared Klear (SCM) models.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +class Error(BaseModel): + """Spec ``Error`` — a structured Klear API error body. + + The transport's ``_map_error`` already reads ``message`` / ``details`` to + build the SDK exception hierarchy; this typed model is provided for callers + that want structured access. ``code`` and ``message`` are spec-required. + """ + + model_config = ConfigDict(extra="allow") + + code: str + message: str + details: str | None = None + service: str | None = None diff --git a/kalshi/perps/klear/models/margin.py b/kalshi/perps/klear/models/margin.py new file mode 100644 index 0000000..2fc88e7 --- /dev/null +++ b/kalshi/perps/klear/models/margin.py @@ -0,0 +1,326 @@ +"""Klear (SCM) margin models — settlement obligations, estimates, balances (#400). + +Response and request models for the nine Self-Clearing-Member margin endpoints +on the Klear API (``klear-api/v1``): margin reports, the active settlement +obligation, obligation history, the settlement estimate, the settlement-buffer +balance + its history, the guaranty-fund balance, and settlement-balance +withdrawal (initiate + status-by-id). + +**Money typing — read carefully.** Unlike the perps prediction/REST framing +(``FixedPointDollars`` / ``FixedPointCount`` strings), almost every monetary +field on the Klear margin schemas is an **integer ``int64`` in _centicents_** +(the spec states ``1 USD = 10,000 centicents``), serialized as a JSON number +with a ``_centicents`` field-name suffix. Those are **plain ``int``** — they are +NOT :data:`DollarDecimal` / :data:`FixedPointCount`. This mirrors the +integer-cents precedent in ``kalshi/models/portfolio.py`` (``Balance.balance``, +``Deposit.amount_cents``). The ONLY two fixed-point **dollar-string** fields in +this whole surface are :attr:`WithdrawSettlementBalanceRequest.amount` and +:attr:`GetSettlementBalanceWithdrawalResponse.amount` (e.g. ``"500.00"``); those +use :data:`DollarDecimal`. + +**Timestamps.** Every REST timestamp is RFC3339 (``format: date-time``) and uses +:class:`pydantic.AwareDatetime`; the single ``format: date`` field +(:attr:`MarginReport.date`) uses :class:`datetime.date`. + +Response models use ``model_config = {"extra": "allow"}`` (forward-compat with +additive spec drift, matching the prediction-API portfolio models). The one +request model — :class:`WithdrawSettlementBalanceRequest` — uses +``extra="forbid"`` so a typo'd body field fails at construction. +""" + +from __future__ import annotations + +import datetime +from decimal import Decimal +from typing import Annotated, Literal + +from pydantic import AfterValidator, AwareDatetime, BaseModel + +from kalshi.types import DollarDecimal, NullableList + + +def _require_positive_withdrawal(value: Decimal) -> Decimal: + """Reject a non-positive settlement-withdrawal amount at construction. + + ``WithdrawSettlementBalanceRequest`` is the single real-money write in the + Klear surface and the spec requires the amount to be positive. ``DollarDecimal`` + alone only rejects non-finite values (negatives/zero are legitimate on + response-side balance/PnL fields), so this boundary guard mirrors the + ``OrderPrice`` non-negative check used on the prediction-API order requests — + a typo'd sign or zero fails here instead of shipping to the withdrawal endpoint. + """ + if value <= 0: + raise ValueError( + f"withdrawal amount must be positive (got {value}); " + "a non-positive amount must never reach the settlement-withdrawal endpoint." + ) + return value + + +WithdrawalAmount = Annotated[DollarDecimal, AfterValidator(_require_positive_withdrawal)] +"""A request-side withdrawal amount: a positive fixed-point dollar value.""" + +# Spec ``MarginReport.report_type`` enum — surfaced as a Literal alias (mirrors +# the ``PaymentStatusLiteral`` style in kalshi/models/portfolio.py). +MarginReportTypeLiteral = Literal[ + "trade_audit", + "position_snapshot", + "market_price_snapshot", + "funding_periods", + "settlement_periods", +] +"""Spec ``MarginReport.report_type`` — the kind of margin report.""" + +# Spec ``GetSettlementBalanceWithdrawalResponse.status`` enum. +WithdrawalStatusLiteral = Literal["pending", "processing", "processed", "failed"] +"""Spec withdrawal ``status`` — lifecycle of an async settlement-balance withdrawal.""" + + +class MarginReport(BaseModel): + """Spec ``MarginReport`` — one downloadable margin report. + + ``url`` is a presigned download URL — treat it as sensitive and keep it out + of logs. + """ + + report_type: MarginReportTypeLiteral + url: str + date: datetime.date + created_ts: AwareDatetime + is_end_of_day: bool + + model_config = {"extra": "allow"} + + def __repr__(self) -> str: + # The presigned `url` grants download access — redact it from repr/logs. + return ( + f"MarginReport(report_type={self.report_type!r}, date={self.date!r}, " + f"created_ts={self.created_ts!r}, is_end_of_day={self.is_end_of_day!r}, " + f"url=)" + ) + + __str__ = __repr__ + + +class GetMarginReportsResponse(BaseModel): + """Spec ``GetMarginReportsResponse`` — flat array of reports (not paginated).""" + + reports: NullableList[MarginReport] + + model_config = {"extra": "allow"} + + +class ObligationReceiveInfo(BaseModel): + """Spec ``ObligationReceiveInfo`` — a receivable line within an obligation.""" + + id: str + type: str + amount_centicents: int + external_reference: str + created_ts: AwareDatetime + + model_config = {"extra": "allow"} + + +class SettlementDetail(BaseModel): + """Spec ``SettlementDetail`` — per-market, per-subtrader settlement breakdown.""" + + id: str + market_ticker: str + subtrader_id: str + pnl_centicents: int + total_fees_centicents: int + total_amount_centicents: int + + model_config = {"extra": "allow"} + + +class MaintenanceMarginDetail(BaseModel): + """Spec ``MaintenanceMarginDetail`` — maintenance-margin requirement + delta. + + ``subtrader_id`` may be an empty string when not populated. + """ + + id: str + subtrader_id: str + maintenance_margin_centicents: int + maintenance_margin_delta_centicents: int + + model_config = {"extra": "allow"} + + +class ObligationEntry(BaseModel): + """Spec ``ObligationEntry`` — a settlement obligation (flattened ``allOf``). + + The spec models ``ObligationEntry`` as ``allOf: [ObligationInfo, {inline}]``; + the SDK **flattens** ``ObligationInfo`` and the inline object into this one + model (the spec only ever returns the composed shape, never bare + ``ObligationInfo``). ``amount_centicents`` is the net settlement amount: + negative means the SCM pays Kalshi Klear, positive means Kalshi Klear pays + the SCM. All ``_centicents`` fields are plain ``int``. + """ + + # From ObligationInfo. + id: str + user_id: str + amount_centicents: int + fees_centicents: int + maintenance_margin_centicents: int + pnl_centicents: int + execution_time: AwareDatetime + last_updated_ts: AwareDatetime + # From the inline allOf object. + receives: NullableList[ObligationReceiveInfo] + settlement_details: NullableList[SettlementDetail] + maintenance_margin_details: NullableList[MaintenanceMarginDetail] + + model_config = {"extra": "allow"} + + +class GetActiveMarginObligationResponse(BaseModel): + """Spec ``GetActiveMarginObligationResponse`` — current-cycle obligation, if any. + + ``obligation`` is optional and nullable (the spec has no ``required`` block); + null/absent both mean no obligation is pending. + """ + + obligation: ObligationEntry | None = None + + model_config = {"extra": "allow"} + + +class GetObligationHistoryResponse(BaseModel): + """Spec ``GetObligationHistoryResponse`` — cursor-paginated obligation page. + + ``cursor`` is an RFC3339 date-time string; absent on the last page. The + resource consumes this via the generic ``Page[ObligationEntry]`` envelope. + """ + + obligations: NullableList[ObligationEntry] + cursor: str | None = None + + @property + def has_next(self) -> bool: + return bool(self.cursor) + + model_config = {"extra": "allow"} + + +class SettlementEstimate(BaseModel): + """Spec ``SettlementEstimate`` — estimated next-settlement amounts (centicents).""" + + variation_margin_centicents: int + total_fees_centicents: int + maintenance_margin_delta_centicents: int + maintenance_margin_required_centicents: int + total_amount_centicents: int + + model_config = {"extra": "allow"} + + +class GetSettlementEstimateResponse(BaseModel): + """Spec ``GetSettlementEstimateResponse`` — estimate + per-subtrader breakdowns. + + ``subtrader_breakdowns`` is the spec ``additionalProperties`` map + (subtrader-id → :class:`SettlementEstimate`); optional. + """ + + user_breakdown: SettlementEstimate + subtrader_breakdowns: dict[str, SettlementEstimate] | None = None + settlement_balance_centicents: int + + model_config = {"extra": "allow"} + + +class GetSettlementBalanceResponse(BaseModel): + """Spec ``GetSettlementBalanceResponse`` — settlement-buffer balance. + + ``locked_balance_centicents`` is optional (locked e.g. by pending withdrawals). + """ + + user_id: str + balance_available_centicents: int + locked_balance_centicents: int | None = None + + model_config = {"extra": "allow"} + + +class GetGuarantyFundBalanceResponse(BaseModel): + """Spec ``GetGuarantyFundBalanceResponse`` — guaranty-fund contribution balance. + + ``amount_centicents`` is zero when no contribution has been made yet. + """ + + user_id: str + amount_centicents: int + updated_ts: AwareDatetime + + model_config = {"extra": "allow"} + + +class WithdrawSettlementBalanceRequest(BaseModel): + """Spec ``WithdrawSettlementBalanceRequest`` — initiate a settlement withdrawal. + + The only request body in this surface. ``amount`` is a fixed-point **dollar + string** (``"500.00"``), NOT centicents — wire name equals the Python name, so + no ``serialization_alias`` is needed. It is validated **positive at construction** + (this is the single real-money write; a negative/zero amount must never reach + the endpoint). Uses ``extra="forbid"`` so a typo'd field fails at construction; + serializes via ``model.model_dump(exclude_none=True, by_alias=True, mode="json")`` + to ``{"amount": "500.00"}``. + """ + + amount: WithdrawalAmount + + model_config = {"extra": "forbid"} + + +class WithdrawSettlementBalanceResponse(BaseModel): + """Spec ``WithdrawSettlementBalanceResponse`` — id of the async withdrawal.""" + + id: str + + model_config = {"extra": "allow"} + + +class GetSettlementBalanceWithdrawalResponse(BaseModel): + """Spec ``GetSettlementBalanceWithdrawalResponse`` — withdrawal status by id. + + ``amount`` is a fixed-point **dollar string** (``"500.00"``), NOT centicents. + """ + + id: str + amount: DollarDecimal + status: WithdrawalStatusLiteral + created_ts: AwareDatetime + + model_config = {"extra": "allow"} + + +class SettlementBalanceHistoryEntry(BaseModel): + """Spec ``SettlementBalanceHistoryEntry`` — one settlement-balance change.""" + + balance_delta_centicents: int + locked_balance_delta_centicents: int + reason: str + business_transaction_id: str + created_ts: AwareDatetime + + model_config = {"extra": "allow"} + + +class GetSettlementBalanceHistoryResponse(BaseModel): + """Spec ``GetSettlementBalanceHistoryResponse`` — cursor-paginated history page. + + ``cursor`` is an opaque string; absent on the last page. The resource + consumes this via the generic ``Page[SettlementBalanceHistoryEntry]`` envelope. + """ + + entries: NullableList[SettlementBalanceHistoryEntry] + cursor: str | None = None + + @property + def has_next(self) -> bool: + return bool(self.cursor) + + model_config = {"extra": "allow"} diff --git a/kalshi/perps/klear/resources/__init__.py b/kalshi/perps/klear/resources/__init__.py new file mode 100644 index 0000000..2e269ab --- /dev/null +++ b/kalshi/perps/klear/resources/__init__.py @@ -0,0 +1 @@ +"""Kalshi Klear (SCM) resource modules.""" diff --git a/kalshi/perps/klear/resources/_base.py b/kalshi/perps/klear/resources/_base.py new file mode 100644 index 0000000..8d51b69 --- /dev/null +++ b/kalshi/perps/klear/resources/_base.py @@ -0,0 +1,48 @@ +"""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. +""" + +from __future__ import annotations + +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 + + +class KlearSyncResource(SyncResource): + """Sync Klear resource base — transport + Klear session holder.""" + + 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." + ) + + +class KlearAsyncResource(AsyncResource): + """Async Klear resource base — transport + Klear session holder.""" + + 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." + ) diff --git a/kalshi/perps/klear/resources/auth.py b/kalshi/perps/klear/resources/auth.py new file mode 100644 index 0000000..dff26b3 --- /dev/null +++ b/kalshi/perps/klear/resources/auth.py @@ -0,0 +1,60 @@ +"""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 new file mode 100644 index 0000000..40d19a5 --- /dev/null +++ b/kalshi/perps/klear/resources/margin.py @@ -0,0 +1,395 @@ +"""Klear (SCM) margin resource — settlement obligations, estimates, balances (#400). + +Nine Self-Clearing-Member margin endpoints on the Klear client (``klear-api/v1``), +all requiring an active **session cookie** (not RSA-PSS): + +- ``margin_reports`` — ``GET /margin/reports``: flat array, required ``start_date`` + / ``end_date`` (``YYYY-MM-DD``). +- ``active_obligation`` — ``GET /margin/active_obligation``: current-cycle + obligation (nullable). +- ``obligation_history`` / ``obligation_history_all`` — ``GET + /margin/obligation_history``: cursor-paginated (limit max 100). +- ``settlement_estimate`` — ``GET /margin/settlement_estimate``. +- ``settlement_balance`` — ``GET /margin/settlement_balance``. +- ``guaranty_fund_balance`` — ``GET /margin/guaranty_fund_balance``. +- ``settlement_balance_history`` / ``settlement_balance_history_all`` — ``GET + /margin/settlement_balance_history``: cursor-paginated (limit max 500). +- ``withdraw_settlement_balance`` — ``POST /margin/withdraw_settlement_balance``: + ``WithdrawSettlementBalanceRequest`` body; NOT retried (POST). +- ``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. +""" + +from __future__ import annotations + +import datetime +from collections.abc import AsyncIterator, Iterator + +from kalshi.models.common import Page +from kalshi.perps.klear.models.margin import ( + GetActiveMarginObligationResponse, + GetGuarantyFundBalanceResponse, + GetMarginReportsResponse, + GetSettlementBalanceResponse, + GetSettlementBalanceWithdrawalResponse, + GetSettlementEstimateResponse, + ObligationEntry, + SettlementBalanceHistoryEntry, + WithdrawSettlementBalanceRequest, + WithdrawSettlementBalanceResponse, +) +from kalshi.perps.klear.resources._base import KlearAsyncResource, KlearSyncResource +from kalshi.resources._base import _params, _validate_limit, _validate_max_pages +from kalshi.types import DollarDecimal, to_decimal + + +def _validate_date_range(start_date: str, end_date: str) -> None: + """Validate ``YYYY-MM-DD`` ``start_date``/``end_date`` at the SDK boundary. + + The spec requires ISO ``date`` strings with ``end_date >= start_date``; + catching a malformed or inverted range here surfaces a clear error instead + of an opaque server 400. + """ + try: + start = datetime.date.fromisoformat(start_date) + end = datetime.date.fromisoformat(end_date) + except ValueError as exc: + raise ValueError( + f"start_date / end_date must be YYYY-MM-DD strings (got " + f"start_date={start_date!r}, end_date={end_date!r}): {exc}" + ) from exc + if end < start: + raise ValueError( + f"end_date ({end_date}) must be on or after start_date ({start_date})" + ) + + +class MarginResource(KlearSyncResource): + """Sync Klear (SCM) margin API — all nine endpoints + two paginators.""" + + def margin_reports( + self, + *, + start_date: str, + end_date: str, + extra_headers: dict[str, str] | None = None, + ) -> GetMarginReportsResponse: + """``GET /margin/reports`` — reports in the ``[start_date, end_date]`` window. + + ``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) + return GetMarginReportsResponse.model_validate(data) + + 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) + + def obligation_history( + self, + *, + limit: int | None = None, + cursor: str | None = None, + 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", + ObligationEntry, + "obligations", + params=params, + cursor_key="cursor", + extra_headers=extra_headers, + ) + + def obligation_history_all( + self, + *, + limit: int | None = None, + max_pages: int | None = None, + 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( + "/margin/obligation_history", + ObligationEntry, + "obligations", + params=params, + max_pages=max_pages, + cursor_key="cursor", + extra_headers=extra_headers, + ) + + 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) + + 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) + + 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) + + def settlement_balance_history( + self, + *, + limit: int | None = None, + cursor: str | None = None, + 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", + SettlementBalanceHistoryEntry, + "entries", + params=params, + cursor_key="cursor", + extra_headers=extra_headers, + ) + + def settlement_balance_history_all( + self, + *, + limit: int | None = None, + max_pages: int | None = None, + 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( + "/margin/settlement_balance_history", + SettlementBalanceHistoryEntry, + "entries", + params=params, + max_pages=max_pages, + cursor_key="cursor", + extra_headers=extra_headers, + ) + + def withdraw_settlement_balance( + self, + *, + amount: DollarDecimal | str, + extra_headers: dict[str, str] | None = None, + ) -> WithdrawSettlementBalanceResponse: + """``POST /margin/withdraw_settlement_balance`` — initiate a wire withdrawal. + + ``amount`` is a fixed-point dollar string (``"500.00"``, positive). The + 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", + json=req.model_dump(exclude_none=True, by_alias=True, mode="json"), + extra_headers=extra_headers, + ) + return WithdrawSettlementBalanceResponse.model_validate(data) + + 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", + params=params, + extra_headers=extra_headers, + ) + return GetSettlementBalanceWithdrawalResponse.model_validate(data) + + +class AsyncMarginResource(KlearAsyncResource): + """Async Klear (SCM) margin API — all nine endpoints + two paginators.""" + + async def margin_reports( + self, + *, + start_date: str, + end_date: str, + 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) + return GetMarginReportsResponse.model_validate(data) + + 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) + + async def obligation_history( + self, + *, + limit: int | None = None, + cursor: str | None = None, + 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", + ObligationEntry, + "obligations", + params=params, + cursor_key="cursor", + extra_headers=extra_headers, + ) + + def obligation_history_all( + self, + *, + limit: int | None = None, + max_pages: int | None = None, + 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( + "/margin/obligation_history", + ObligationEntry, + "obligations", + params=params, + max_pages=max_pages, + cursor_key="cursor", + extra_headers=extra_headers, + ) + + 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) + + 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) + + 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) + + async def settlement_balance_history( + self, + *, + limit: int | None = None, + cursor: str | None = None, + 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", + SettlementBalanceHistoryEntry, + "entries", + params=params, + cursor_key="cursor", + extra_headers=extra_headers, + ) + + def settlement_balance_history_all( + self, + *, + limit: int | None = None, + max_pages: int | None = None, + 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( + "/margin/settlement_balance_history", + SettlementBalanceHistoryEntry, + "entries", + params=params, + max_pages=max_pages, + cursor_key="cursor", + extra_headers=extra_headers, + ) + + async def withdraw_settlement_balance( + self, + *, + amount: DollarDecimal | str, + 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", + json=req.model_dump(exclude_none=True, by_alias=True, mode="json"), + extra_headers=extra_headers, + ) + return WithdrawSettlementBalanceResponse.model_validate(data) + + 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", + params=params, + extra_headers=extra_headers, + ) + return GetSettlementBalanceWithdrawalResponse.model_validate(data) diff --git a/kalshi/perps/models/__init__.py b/kalshi/perps/models/__init__.py new file mode 100644 index 0000000..19b5335 --- /dev/null +++ b/kalshi/perps/models/__init__.py @@ -0,0 +1,151 @@ +"""Pydantic models for the Kalshi Perps (margin) API. + +Shared enums and value-objects live in :mod:`kalshi.perps.models.common` and are +imported (never redefined) by the per-area model modules. The shared +``BookSideLiteral`` / ``OrderSourceLiteral`` aliases are re-exported once (from +``orders``); ``portfolio`` defines its own structurally-identical copies. +""" + +from __future__ import annotations + +from kalshi.perps.models.common import ( + BookSide, + EmptyResponse, + ErrorResponse, + ExchangeIndex, + ExchangeInstance, + LastUpdateReason, + MarginMarketStatus, + OrderSource, + PriceLevelDollarsCountFp, + SelfTradePreventionType, +) +from kalshi.perps.models.exchange import ( + ExchangeStatus, + GetMarginRiskParametersResponse, + MarginEnabledResponse, +) +from kalshi.perps.models.funding import ( + MarginFundingHistoryEntry, + MarginFundingRate, + MarginFundingRateEstimate, +) +from kalshi.perps.models.margin_account import ( + GetMarginBalanceResponse, + GetMarginFeeTiersResponse, + GetMarginRiskResponse, + MarginRiskPosition, + MarginSubaccountBalance, + NotionalRiskLimitResponse, +) +from kalshi.perps.models.markets import ( + BidAskDistributionHistorical, + MarginMarket, + MarginMarketCandlestick, + MarginMarketStatusLiteral, + MarginOrderbook, + MarginOrderbookLevel, + PriceDistributionHistorical, +) +from kalshi.perps.models.order_groups import ( + CreateOrderGroupRequest, + CreateOrderGroupResponse, + GetOrderGroupResponse, + OrderGroup, + UpdateOrderGroupLimitRequest, +) +from kalshi.perps.models.orders import ( + AmendMarginOrderRequest, + AmendMarginOrderResponse, + BookSideLiteral, + CancelMarginOrderResponse, + CreateMarginOrderRequest, + CreateMarginOrderResponse, + DecreaseMarginOrderRequest, + DecreaseMarginOrderResponse, + GetMarginOrderResponse, + GetMarginOrdersResponse, + LastUpdateReasonLiteral, + MarginOrder, + OrderSourceLiteral, + SelfTradePreventionTypeLiteral, + TimeInForceLiteral, +) +from kalshi.perps.models.portfolio import ( + GetMarginFillsResponse, + GetMarginPositionsResponse, + GetMarginTradesResponse, + MarginFill, + MarginPosition, + MarginTrade, +) +from kalshi.perps.models.transfers import ( + ApplySubaccountTransferRequest, + CreateSubaccountResponse, + ExchangeInstanceLiteral, + IntraExchangeInstanceTransferRequest, + IntraExchangeInstanceTransferResponse, +) + +__all__ = [ + "AmendMarginOrderRequest", + "AmendMarginOrderResponse", + "ApplySubaccountTransferRequest", + "BidAskDistributionHistorical", + "BookSide", + "BookSideLiteral", + "CancelMarginOrderResponse", + "CreateMarginOrderRequest", + "CreateMarginOrderResponse", + "CreateOrderGroupRequest", + "CreateOrderGroupResponse", + "CreateSubaccountResponse", + "DecreaseMarginOrderRequest", + "DecreaseMarginOrderResponse", + "EmptyResponse", + "ErrorResponse", + "ExchangeIndex", + "ExchangeInstance", + "ExchangeInstanceLiteral", + "ExchangeStatus", + "GetMarginBalanceResponse", + "GetMarginFeeTiersResponse", + "GetMarginFillsResponse", + "GetMarginOrderResponse", + "GetMarginOrdersResponse", + "GetMarginPositionsResponse", + "GetMarginRiskParametersResponse", + "GetMarginRiskResponse", + "GetMarginTradesResponse", + "GetOrderGroupResponse", + "IntraExchangeInstanceTransferRequest", + "IntraExchangeInstanceTransferResponse", + "LastUpdateReason", + "LastUpdateReasonLiteral", + "MarginEnabledResponse", + "MarginFill", + "MarginFundingHistoryEntry", + "MarginFundingRate", + "MarginFundingRateEstimate", + "MarginMarket", + "MarginMarketCandlestick", + "MarginMarketStatus", + "MarginMarketStatusLiteral", + "MarginOrder", + "MarginOrderbook", + "MarginOrderbookLevel", + "MarginPosition", + "MarginRiskPosition", + "MarginSubaccountBalance", + "MarginTrade", + "NotionalRiskLimitResponse", + "OrderGroup", + "OrderSource", + "OrderSourceLiteral", + "PriceDistributionHistorical", + "PriceLevelDollarsCountFp", + "SelfTradePreventionType", + "SelfTradePreventionTypeLiteral", + "TimeInForceLiteral", + "UpdateOrderGroupLimitRequest", +] diff --git a/kalshi/perps/models/common.py b/kalshi/perps/models/common.py new file mode 100644 index 0000000..3de4c43 --- /dev/null +++ b/kalshi/perps/models/common.py @@ -0,0 +1,127 @@ +"""Shared enums and value-objects for the Kalshi Perps (margin) API. + +These primitives are **defined once here** and imported by every per-area perps +model module — they must not be redefined. Field/value semantics are verified +against ``specs/perps_openapi.yaml``. + +Note: perps order side is ``bid``/``ask`` (:class:`BookSide`), **not** the +prediction API's ``yes``/``no``. + +``DollarDecimal`` and ``FixedPointCount`` are reused from :mod:`kalshi.types` +as-is — the perps spec's ``FixedPointDollars`` (string, up to 6 decimals) and +``FixedPointCount`` (string, 2 decimals, ``_fp`` suffix in field names) have +identical decimal semantics to the prediction API, so no new custom type is +needed. +""" + +from __future__ import annotations + +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict + +from kalshi.types import DollarDecimal, FixedPointCount + +# ExchangeIndex: spec ``ExchangeIndex`` is ``type: integer``, default 0, +# "only 0 currently supported". A documented bare-int alias (mirrors the +# lightweight ``UnixSecondsTimestamp`` alias in kalshi.types); a full model +# would be overkill for a single shard identifier. +ExchangeIndex = int +"""Spec ``ExchangeIndex`` — an exchange-shard identifier (``type: integer``). + +Defaults to ``0``; only ``0`` is currently supported. Modeled as a bare ``int``. +""" + +# PriceLevelDollarsCountFp: spec positional 2-tuple ``[dollars_string, fp]`` +# where element 0 is a FixedPointDollars price string and element 1 is a +# FixedPointCount contract-count string. The second element is the contract +# quantity (NOT a price). Pydantic validates a 2-element JSON array into this. +PriceLevelDollarsCountFp = tuple[DollarDecimal, FixedPointCount] +"""Spec ``PriceLevelDollarsCountFp`` — ``(price_in_dollars, contract_count_fp)``. + +A positional 2-tuple parsed from a JSON array like ``["0.1500", "100.00"]``: +element 0 is the price (:data:`~kalshi.types.DollarDecimal`), element 1 is the +contract quantity (:data:`~kalshi.types.FixedPointCount`). +""" + + +class BookSide(StrEnum): + """Spec ``BookSide`` — the side of an order or trade. + + Perps uses ``bid``/``ask`` (NOT the prediction API's ``yes``/``no``). + """ + + BID = "bid" + ASK = "ask" + + +class SelfTradePreventionType(StrEnum): + """Spec ``SelfTradePreventionType`` — how self-crossing orders are handled.""" + + TAKER_AT_CROSS = "taker_at_cross" + MAKER = "maker" + + +class LastUpdateReason(StrEnum): + """Spec ``LastUpdateReason`` — why an order was last updated. + + The empty-string member (``NONE = ""``) is a real wire value. Members are the + literal PascalCase wire strings (no snake_case rename). + """ + + NONE = "" + DECREASE = "Decrease" + AMEND = "Amend" + MARGIN_CANCEL = "MarginCancel" + SELF_TRADE_CANCEL = "SelfTradeCancel" + EXPIRY_CANCEL = "ExpiryCancel" + TRADE = "Trade" + POST_ONLY_CROSS_CANCEL = "PostOnlyCrossCancel" + + +class OrderSource(StrEnum): + """Spec ``OrderSource`` — ``user`` (user-placed) or ``system`` (liquidation).""" + + USER = "user" + SYSTEM = "system" + + +class MarginMarketStatus(StrEnum): + """Spec ``MarginMarketStatus`` — the status of a margin market.""" + + INACTIVE = "inactive" + ACTIVE = "active" + CLOSED = "closed" + + +class ExchangeInstance(StrEnum): + """Spec ``ExchangeInstance`` — ``event_contract`` or ``margined``.""" + + EVENT_CONTRACT = "event_contract" + MARGINED = "margined" + + +class EmptyResponse(BaseModel): + """Spec ``EmptyResponse`` — an empty object body. + + The success response for delete/reset/trigger/update-limit order-group + operations. ``extra="allow"`` so a future additive server field doesn't break + parsing. + """ + + model_config = ConfigDict(extra="allow") + + +class ErrorResponse(BaseModel): + """Spec ``ErrorResponse`` — a structured API error body. + + Parsed by the transport's ``_map_error`` path already; provided here for + typed access. All fields are optional per spec. + """ + + model_config = ConfigDict(extra="allow") + + code: str | None = None + message: str | None = None + details: str | None = None + service: str | None = None diff --git a/kalshi/perps/models/exchange.py b/kalshi/perps/models/exchange.py new file mode 100644 index 0000000..2fbfaf6 --- /dev/null +++ b/kalshi/perps/models/exchange.py @@ -0,0 +1,44 @@ +"""Perps (margin) exchange models — status, access gate, risk parameters. + +These are the response models for the three read-only perps "exchange health / +access" endpoints (#389). ``number/format: double`` ratio fields use +:data:`~kalshi.types.MultiplierDecimal` (not bare ``float``) to avoid binary +float drift, matching the prediction-API convention for double-typed ratios. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from kalshi.types import MultiplierDecimal + + +class ExchangeStatus(BaseModel): + """Spec ``ExchangeStatus`` — margin exchange operational status. + + Distinct from :class:`kalshi.models.exchange.ExchangeStatus`; the perps + version is slimmer (no schedule / resume-time fields). + """ + + model_config = ConfigDict(extra="allow") + + exchange_active: bool + trading_active: bool + + +class MarginEnabledResponse(BaseModel): + """Spec ``MarginEnabledResponse`` — the perps per-member access gate.""" + + model_config = ConfigDict(extra="allow") + + enabled: bool + + +class GetMarginRiskParametersResponse(BaseModel): + """Spec ``GetMarginRiskParametersResponse`` — system-wide risk parameters.""" + + model_config = ConfigDict(extra="allow") + + liquidation_margin_ratio_threshold: MultiplierDecimal + queue_entry_margin_ratio_threshold: MultiplierDecimal + initial_margin_multiplier: dict[str, MultiplierDecimal] diff --git a/kalshi/perps/models/funding.py b/kalshi/perps/models/funding.py new file mode 100644 index 0000000..0523a2b --- /dev/null +++ b/kalshi/perps/models/funding.py @@ -0,0 +1,97 @@ +"""Perps (margin) funding models — rate estimate, historical rates, payment history. + +Response models for the three read-only perps **funding** endpoints (#395): + +- :class:`MarginFundingRate` — a single applied historical funding rate. +- :class:`MarginFundingHistoryEntry` — one row of the authenticated user's + per-payment funding history (the rate joined with the realized payment). +- :class:`MarginFundingRateEstimate` — the current in-progress rate estimate. + +Field-type rules (verified against ``specs/perps_openapi.yaml``): + +- ``funding_rate`` is ``type: number, format: double`` and is **not** a price — + it is a plain :class:`float` (NOT :data:`~kalshi.types.DollarDecimal`). +- ``mark_price`` / ``funding_amount`` are ``$ref FixedPointDollars`` strings → + :data:`~kalshi.types.DollarDecimal`. +- ``quantity`` is ``$ref FixedPointCount`` → :data:`~kalshi.types.FixedPointCount`. +- ``funding_time`` / ``computed_time`` / ``next_funding_time`` are RFC3339 + ``format: date-time`` REST timestamps → :class:`~pydantic.AwareDatetime` + (NOT ``_ms`` epoch ints). + +These are response models only — this issue has no request bodies, so every +model uses ``extra="allow"`` (tolerate additive server fields) and never +``extra="forbid"``. +""" + +from __future__ import annotations + +from pydantic import AliasChoices, AwareDatetime, BaseModel, Field + +from kalshi.types import DollarDecimal, FixedPointCount + + +class MarginFundingRate(BaseModel): + """Spec ``MarginFundingRate`` — a single applied historical funding rate. + + All four properties are spec-required (``market_ticker``, ``funding_time``, + ``funding_rate``, ``mark_price``). + """ + + model_config = {"extra": "allow", "populate_by_name": True} + + market_ticker: str + funding_time: AwareDatetime + funding_rate: float + mark_price: DollarDecimal = Field( + validation_alias=AliasChoices("mark_price_dollars", "mark_price"), + ) + + +class MarginFundingHistoryEntry(BaseModel): + """Spec ``MarginFundingHistoryEntry`` — one realized funding payment. + + All seven properties are spec-required. ``subaccount_number`` is marked + ``required`` **and** ``nullable: true`` in the spec, so it is typed + ``int | None`` with **no default** — a missing key still raises + ``ValidationError`` while an explicit ``null`` is tolerated (same pattern as + :class:`kalshi.models.historical.Trade.count`). ``0`` = primary subaccount. + """ + + model_config = {"extra": "allow", "populate_by_name": True} + + market_ticker: str + funding_time: AwareDatetime + funding_rate: float + mark_price: DollarDecimal = Field( + validation_alias=AliasChoices("mark_price_dollars", "mark_price"), + ) + # Positive = received, negative = paid (per spec). + funding_amount: DollarDecimal = Field( + validation_alias=AliasChoices("funding_amount_dollars", "funding_amount"), + ) + quantity: FixedPointCount = Field( + validation_alias=AliasChoices("quantity_fp", "quantity"), + ) + # Spec-required + nullable: no default so a missing key raises + # ValidationError, while an explicit null is tolerated. + subaccount_number: int | None + + +class MarginFundingRateEstimate(BaseModel): + """Spec ``GetMarginFundingRateEstimateResponse`` — the in-progress rate estimate. + + The SDK returns the estimate object directly (the resource validates the + response body), so the model drops the ``GetMargin...Response`` wrapper name. + Only ``next_funding_time`` is spec-required; every other field is optional. + """ + + model_config = {"extra": "allow", "populate_by_name": True} + + market_ticker: str | None = None + computed_time: AwareDatetime | None = None + funding_rate: float | None = None + mark_price: DollarDecimal | None = Field( + default=None, + validation_alias=AliasChoices("mark_price_dollars", "mark_price"), + ) + next_funding_time: AwareDatetime diff --git a/kalshi/perps/models/margin_account.py b/kalshi/perps/models/margin_account.py new file mode 100644 index 0000000..fdaa1ee --- /dev/null +++ b/kalshi/perps/models/margin_account.py @@ -0,0 +1,93 @@ +"""Perps margin-account models — balance, risk, notional risk limit, fee tiers. + +Response models for the four read-only direct-margin account endpoints (#394): +``GetMarginBalance``, ``GetMarginRisk``, ``GetMarginNotionalRiskLimit`` and +``GetMarginFeeTiers``. All are pure response models — every one carries +``model_config = {"extra": "allow"}`` so additive spec fields don't break +parsing, and none have request bodies (the endpoints are GETs with at most a +single query flag). + +Money fields use :data:`~kalshi.types.DollarDecimal` (the perps spec's +``FixedPointDollars`` string), and the signed position quantity uses +:data:`~kalshi.types.FixedPointCount`. Wire field names already match the short +Python names (no ``_dollars`` suffix on this surface), so no ``validation_alias`` +is used here. The fee-rate maps and leverage ratios are spec ``number/double`` +and are kept as plain ``float`` (decimal fractions of notional, not money). +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from kalshi.types import DollarDecimal, FixedPointCount + + +class MarginSubaccountBalance(BaseModel): + """Spec ``MarginSubaccountBalance`` — one subaccount's balance breakdown.""" + + model_config = ConfigDict(extra="allow") + + subaccount: int + position_value: DollarDecimal + account_equity: DollarDecimal + maintenance_margin: DollarDecimal + initial_margin: DollarDecimal + resting_orders_margin: DollarDecimal + available_balance: DollarDecimal + + +class GetMarginBalanceResponse(BaseModel): + """Spec ``GetMarginBalanceResponse`` — per-subaccount balances + settled funds.""" + + model_config = ConfigDict(extra="allow") + + subaccount_balances: list[MarginSubaccountBalance] + settled_funds: DollarDecimal + + +class MarginRiskPosition(BaseModel): + """Spec ``MarginRiskPosition`` — leverage/liquidation risk for one position.""" + + model_config = ConfigDict(extra="allow") + + subaccount: int + market_ticker: str + position: FixedPointCount + mark_price: DollarDecimal + position_notional: DollarDecimal + maintenance_margin_required: DollarDecimal | None = None + position_leverage: float | None = None + estimated_liquidation_price: DollarDecimal | None = None + + +class GetMarginRiskResponse(BaseModel): + """Spec ``GetMarginRiskResponse`` — account leverage + per-position risk array.""" + + model_config = ConfigDict(extra="allow") + + account_leverage: float | None = None + total_position_notional: DollarDecimal + total_maintenance_margin: DollarDecimal + positions: list[MarginRiskPosition] + + +class NotionalRiskLimitResponse(BaseModel): + """Spec ``NotionalRiskLimitResponse`` — default limit + per-ticker overrides.""" + + model_config = ConfigDict(extra="allow") + + default_notional_value_risk_limit: DollarDecimal + notional_value_risk_limits_by_market_ticker: dict[str, DollarDecimal] + + +class GetMarginFeeTiersResponse(BaseModel): + """Spec ``GetMarginFeeTiersResponse`` — maker/taker fee-rate maps by ticker. + + Values are decimal fractions of notional (e.g. ``0.0005`` = 5 bps), kept as + plain ``float`` per the spec's ``number/double`` typing — not money fields. + """ + + model_config = ConfigDict(extra="allow") + + maker_fee_rates: dict[str, float] + taker_fee_rates: dict[str, float] diff --git a/kalshi/perps/models/markets.py b/kalshi/perps/models/markets.py new file mode 100644 index 0000000..a87afb2 --- /dev/null +++ b/kalshi/perps/models/markets.py @@ -0,0 +1,163 @@ +"""Perps (margin) markets models — markets, orderbook, candlesticks (#390). + +Response models for the read-only margin market-data endpoints. Mirrors the +alias style of :mod:`kalshi.models.markets` (short Python field names + accept +both ``_dollars``/``_fp``-suffixed and bare wire names). Margin price fields are +``FixedPointDollars`` → :data:`~kalshi.types.DollarDecimal`; counts are +``FixedPointCount`` → :data:`~kalshi.types.FixedPointCount`. + +Note: REST timestamps in this slice (``end_period_ts``) are Unix epoch **seconds** +integers (spec ``type: integer, format: int64``) — kept as bare ``int``, not +``AwareDatetime``. + +The orderbook side is ``bids``/``asks`` (margin), **not** the prediction API's +``yes``/``no``; each level is a 2-tuple ``[price_dollars, contract_count_fp]``. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import AliasChoices, BaseModel, Field + +from kalshi.types import DollarDecimal, FixedPointCount, NullableList + +MarginMarketStatusLiteral = Literal["inactive", "active", "closed"] +"""Status filter for GET /margin/markets and ``MarginMarket.status``. + +Spec schema ``MarginMarketStatus`` (``type: string``). Defined here as a +``Literal`` for the query-param signature; the ``StrEnum`` form lives in +:mod:`kalshi.perps.models.common` as :class:`~kalshi.perps.models.common.MarginMarketStatus`. +""" + + +class MarginMarket(BaseModel): + """Spec ``MarginMarket`` — a margin market with trading stats. + + Price fields are ``FixedPointDollars`` strings (e.g. ``"0.5600"``); count + fields are ``FixedPointCount`` strings. ``contract_size`` is a 6-decimal + fixed-point dollar-style string (spec ``type: string``). ``_fp``-suffixed + validation aliases on volume/count fields accept both the bare names the + spec emits and the forward-compatible ``_fp`` form. + """ + + ticker: str + title: str + status: MarginMarketStatusLiteral + contract_size: DollarDecimal + tick_size: DollarDecimal + fractional_trading_enabled: bool + + leverage_estimate: float | None = None + price: DollarDecimal | None = None + bid: DollarDecimal | None = None + ask: DollarDecimal | None = None + volume: FixedPointCount | None = Field( + default=None, + validation_alias=AliasChoices("volume_fp", "volume"), + ) + volume_24h: FixedPointCount | None = Field( + default=None, + validation_alias=AliasChoices("volume_24h_fp", "volume_24h"), + ) + open_interest: FixedPointCount | None = Field( + default=None, + validation_alias=AliasChoices("open_interest_fp", "open_interest"), + ) + + model_config = {"extra": "allow", "populate_by_name": True} + + +class MarginOrderbookLevel(BaseModel): + """A single margin orderbook price level. + + Parsed from spec ``PriceLevelDollarsCountFp`` — a positional 2-tuple + ``[price_dollars, contract_count_fp]``. Element 0 is the price in dollars, + element 1 is the contract quantity (NOT a price). + """ + + price: DollarDecimal + quantity: FixedPointCount + + model_config = {"extra": "allow"} + + +class MarginOrderbook(BaseModel): + """Margin orderbook for a market (spec ``MarginOrderbookCount``). + + ``ticker`` is injected by the resource from the path arg (not on the wire). + ``bids``/``asks`` tolerate a server-returned null via :data:`NullableList`. + """ + + ticker: str + bids: NullableList[MarginOrderbookLevel] = [] + asks: NullableList[MarginOrderbookLevel] = [] + + model_config = {"extra": "allow"} + + +class BidAskDistributionHistorical(BaseModel): + """Spec ``BidAskDistributionHistorical`` — OHLC for quoted bid/ask prices. + + All four fields are required and non-null per spec. + """ + + open: DollarDecimal = Field( + validation_alias=AliasChoices("open_dollars", "open"), + ) + high: DollarDecimal = Field( + validation_alias=AliasChoices("high_dollars", "high"), + ) + low: DollarDecimal = Field( + validation_alias=AliasChoices("low_dollars", "low"), + ) + close: DollarDecimal = Field( + validation_alias=AliasChoices("close_dollars", "close"), + ) + + model_config = {"extra": "allow", "populate_by_name": True} + + +class PriceDistributionHistorical(BaseModel): + """Spec ``PriceDistributionHistorical`` — OHLC for executed trade prices. + + Every field is ``nullable: true`` in the spec (values are JSON ``null`` when + no trades occurred in the period), so all are ``DollarDecimal | None``. This + margin schema has only these 6 fields — no ``min``/``max`` (those exist only + on the public ``PriceDistribution``). + """ + + # Spec marks all 6 ``required`` AND ``nullable`` — the keys are present but + # the values are JSON ``null`` when no trades occurred. Modeled as + # required-key / nullable-value (no default) so required-drift stays aligned. + open: DollarDecimal | None = Field(validation_alias=AliasChoices("open_dollars", "open")) + high: DollarDecimal | None = Field(validation_alias=AliasChoices("high_dollars", "high")) + low: DollarDecimal | None = Field(validation_alias=AliasChoices("low_dollars", "low")) + close: DollarDecimal | None = Field(validation_alias=AliasChoices("close_dollars", "close")) + mean: DollarDecimal | None = Field(validation_alias=AliasChoices("mean_dollars", "mean")) + previous: DollarDecimal | None = Field( + validation_alias=AliasChoices("previous_dollars", "previous") + ) + + model_config = {"extra": "allow", "populate_by_name": True} + + +class MarginMarketCandlestick(BaseModel): + """Spec ``MarginMarketCandlestick`` — a margin candlestick data point. + + Uses ``bid``/``ask`` (margin) nested OHLC objects instead of the public + ``yes_bid``/``yes_ask``. ``end_period_ts`` is a Unix-seconds int64. + """ + + end_period_ts: int + bid: BidAskDistributionHistorical + ask: BidAskDistributionHistorical + price: PriceDistributionHistorical + volume: FixedPointCount = Field( + validation_alias=AliasChoices("volume_fp", "volume"), + ) + open_interest: FixedPointCount = Field( + validation_alias=AliasChoices("open_interest_fp", "open_interest"), + ) + + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/perps/models/order_groups.py b/kalshi/perps/models/order_groups.py new file mode 100644 index 0000000..fb30ea6 --- /dev/null +++ b/kalshi/perps/models/order_groups.py @@ -0,0 +1,93 @@ +"""Perps (margin) order groups — rolling 15-second contracts-limit groups for linked orders. + +The margin-exchange twin of :mod:`kalshi.models.order_groups`. Schemas are +near-identical; the perps ``CreateOrderGroupResponse`` requires both +``order_group_id`` and ``subaccount`` and adds an optional ``exchange_index``. +Verified field-by-field against ``specs/perps_openapi.yaml``. +""" + +from __future__ import annotations + +from pydantic import AliasChoices, BaseModel, Field + +from kalshi.types import FixedPointCount, NullableList, StrictInt + + +class OrderGroup(BaseModel): + """Spec ``OrderGroup`` — a single order group (list-response entry). + + Required: ``id``, ``is_auto_cancel_enabled``. The spec exposes only + ``contracts_limit_fp`` (a :data:`~kalshi.types.FixedPointCount` string, + 2-decimal "fp"); the short Python name + alias mirrors the portfolio model. + """ + + id: str + contracts_limit: FixedPointCount | None = Field( + default=None, + validation_alias=AliasChoices("contracts_limit_fp", "contracts_limit"), + ) + is_auto_cancel_enabled: bool + exchange_index: int | None = None + + model_config = {"extra": "allow", "populate_by_name": True} + + +class GetOrderGroupResponse(BaseModel): + """Spec ``GetOrderGroupResponse`` — a single group plus its member order IDs. + + Required: ``is_auto_cancel_enabled``, ``orders``. Omits ``id`` (it is the + path param). + """ + + is_auto_cancel_enabled: bool + orders: NullableList[str] + contracts_limit: FixedPointCount | None = Field( + default=None, + validation_alias=AliasChoices("contracts_limit_fp", "contracts_limit"), + ) + exchange_index: int | None = None + + model_config = {"extra": "allow", "populate_by_name": True} + + +class CreateOrderGroupResponse(BaseModel): + """Spec ``CreateOrderGroupResponse`` — wraps the new group's id. + + Required: ``order_group_id``, ``subaccount`` (0 for primary, 1-32 for + subaccounts). The server echoes the routing context (subaccount + exchange + shard) on the create response. + """ + + order_group_id: str + subaccount: int + exchange_index: int | None = None + + model_config = {"extra": "allow"} + + +class CreateOrderGroupRequest(BaseModel): + """Spec ``CreateOrderGroupRequest`` — POST body (forbid). + + The SDK sends the integer ``contracts_limit`` form; the spec's alternate + ``contracts_limit_fp`` string variant is unused (Kalshi accepts either). + ``contracts_limit`` is required at the SDK level even though the spec marks + it optional — mirrors the portfolio model. + """ + + contracts_limit: StrictInt = Field(..., ge=1) + subaccount: StrictInt | None = Field(default=None, ge=0) + exchange_index: StrictInt | None = None + + model_config = {"extra": "forbid"} + + +class UpdateOrderGroupLimitRequest(BaseModel): + """Spec ``UpdateOrderGroupLimitRequest`` — PUT ``/limit`` body (forbid). + + SDK sends the integer ``contracts_limit`` form; the spec's + ``contracts_limit_fp`` string variant is unused. + """ + + contracts_limit: StrictInt = Field(..., ge=1) + + model_config = {"extra": "forbid"} diff --git a/kalshi/perps/models/orders.py b/kalshi/perps/models/orders.py new file mode 100644 index 0000000..ae97ca7 --- /dev/null +++ b/kalshi/perps/models/orders.py @@ -0,0 +1,248 @@ +"""Perps (margin) order models — create/get/list/cancel/decrease/amend (#391). + +Response models tolerate additive server fields (``extra="allow"``); request +models reject unknown keys (``extra="forbid"``) so a phantom kwarg fails at +construction instead of round-tripping to a server 400. + +Wire-name note: unlike the prediction API's V1 order family (``yes_price_dollars`` +/ ``count_fp``), the perps order schemas use the *short* keys directly — +``price``, ``count``, ``fill_count``, ``remaining_count`` are the literal wire +names. So no ``validation_alias`` / ``serialization_alias`` is needed here; +verified against ``specs/perps_openapi.yaml`` schemas at lines 1652-1951. + +Request prices use :data:`~kalshi.types.OrderPrice` (rejects negatives and +sub-$0.0001-tick precision at construction); response prices use +:data:`~kalshi.types.DollarDecimal` (negatives/precision are legitimate on +fee/fill fields). +""" + +from __future__ import annotations + +import builtins +from typing import Literal + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator + +from kalshi.types import DollarDecimal, FixedPointCount, OrderPrice, StrictInt + +# ── Literal aliases (fixed-enum kwargs / fields) ───────────────────────────── +# Source of truth: specs/perps_openapi.yaml. Perps order side is bid/ask +# (BookSide), NOT the prediction API's yes/no. + +BookSideLiteral = Literal["bid", "ask"] +"""Side of the book. Spec ``BookSide`` (line 2225).""" + +TimeInForceLiteral = Literal["fill_or_kill", "good_till_canceled", "immediate_or_cancel"] +"""Order time-in-force. Inline enum on ``CreateMarginOrderRequest.time_in_force`` (line 1686).""" + +SelfTradePreventionTypeLiteral = Literal["taker_at_cross", "maker"] +"""Self-trade prevention behavior. Spec ``SelfTradePreventionType`` (line 1578).""" + +OrderSourceLiteral = Literal["user", "system"] +"""Order source — user-placed or system (liquidation). Spec ``OrderSource`` (line 2036).""" + +LastUpdateReasonLiteral = Literal[ + "", + "Decrease", + "Amend", + "MarginCancel", + "SelfTradeCancel", + "ExpiryCancel", + "Trade", + "PostOnlyCrossCancel", +] +"""Why an order was last updated. Spec ``LastUpdateReason`` (line 2044). + +The empty-string member ``""`` is a real wire value — keep it. +""" + + +# ── Request models (extra="forbid") ────────────────────────────────────────── + + +class CreateMarginOrderRequest(BaseModel): + """Body for ``POST /margin/orders``. Spec ``CreateMarginOrderRequest`` (line 1652). + + ``price``/``count`` wire names match the field names (no ``_dollars``/``_fp`` + suffix), so ``by_alias=True`` is a no-op for them — confirmed against the spec. + ``subaccount`` is carried in the *body* here (the cancel/decrease/amend + endpoints route it as a query param instead). + """ + + model_config = ConfigDict(extra="forbid") + + ticker: str + client_order_id: str + side: BookSideLiteral + count: FixedPointCount = Field(gt=0) + price: OrderPrice + time_in_force: TimeInForceLiteral + self_trade_prevention_type: SelfTradePreventionTypeLiteral + expiration_time: StrictInt | None = None + post_only: bool | None = None + cancel_order_on_pause: bool | None = None + reduce_only: bool | None = None + subaccount: StrictInt | None = Field(default=None, ge=0) + order_group_id: str | None = None + + +class DecreaseMarginOrderRequest(BaseModel): + """Body for ``POST /margin/orders/{order_id}/decrease``. Spec line 1853. + + Spec marks both fields optional/nullable, but the server requires exactly + one of ``reduce_by`` / ``reduce_to``. Enforced at construction (mirrors + :class:`kalshi.models.orders.DecreaseOrderV2Request`). ``subaccount`` is a + query param on the resource method, not a body field. + """ + + model_config = ConfigDict(extra="forbid") + + # reduce_by is an amount to subtract (must be positive); reduce_to is a + # target remaining count, where 0 (decrease to nothing) is valid — so it + # only rejects negatives. + reduce_by: FixedPointCount | None = Field(default=None, gt=0) + reduce_to: FixedPointCount | None = Field(default=None, ge=0) + + @model_validator(mode="after") + def _enforce_reduce_xor(self) -> DecreaseMarginOrderRequest: + if self.reduce_by is not None and self.reduce_to is not None: + raise ValueError( + "DecreaseMarginOrderRequest accepts reduce_by or reduce_to, not both" + ) + if self.reduce_by is None and self.reduce_to is None: + raise ValueError( + "DecreaseMarginOrderRequest requires either reduce_by or reduce_to" + ) + return self + + +class AmendMarginOrderRequest(BaseModel): + """Body for ``POST /margin/orders/{order_id}/amend`` (spec ``AmendMarginOrderRequest``). + + Amending increases-in-size or price changes forfeit queue position + (server-side). ``price``/``count`` wire names match the field names. + ``subaccount`` is a query param on the resource method, not a body field. + """ + + model_config = ConfigDict(extra="forbid") + + ticker: str + side: BookSideLiteral + price: OrderPrice + count: FixedPointCount = Field(gt=0) + client_order_id: str | None = None + updated_client_order_id: str | None = None + + +# ── Response models (extra="allow") ────────────────────────────────────────── + + +class CreateMarginOrderResponse(BaseModel): + """Response for ``POST /margin/orders``. Spec ``CreateMarginOrderResponse`` (line 1723). + + ``average_fill_price`` / ``average_fee_paid`` are present only when + ``fill_count > 0``. + """ + + model_config = ConfigDict(extra="allow") + + order_id: str + fill_count: FixedPointCount + remaining_count: FixedPointCount + client_order_id: str | None = None + average_fill_price: DollarDecimal | None = None + average_fee_paid: DollarDecimal | None = None + + +class MarginOrder(BaseModel): + """A perps (margin) order. Spec ``MarginOrder`` (line 1771). + + Wire field names match the Python names (``price``, ``fill_count``, + ``remaining_count`` — no ``_dollars``/``_fp`` suffixes), so no + ``validation_alias`` is needed. ``last_update_reason`` includes the + empty-string member. + """ + + model_config = ConfigDict(extra="allow") + + order_id: str + user_id: str + client_order_id: str + ticker: str + side: BookSideLiteral + last_update_reason: LastUpdateReasonLiteral + price: DollarDecimal + fill_count: FixedPointCount + remaining_count: FixedPointCount + expiration_time: AwareDatetime | None = None + created_time: AwareDatetime | None = None + last_update_time: AwareDatetime | None = None + self_trade_prevention_type: SelfTradePreventionTypeLiteral | None = None + cancel_order_on_pause: bool | None = None + order_group_id: str | None = None + order_source: OrderSourceLiteral | None = None + + +class GetMarginOrderResponse(BaseModel): + """Response for ``GET /margin/orders/{order_id}``. Spec line 1752.""" + + model_config = ConfigDict(extra="allow") + + order: MarginOrder + + +class GetMarginOrdersResponse(BaseModel): + """Paginated envelope for ``GET /margin/orders`` and ``GET /margin/fcm/orders``. + + Spec ``GetMarginOrdersResponse`` (line 1759). ``cursor`` drives + ``list_all()`` / ``list_all_fcm()``. + """ + + model_config = ConfigDict(extra="allow") + + orders: builtins.list[MarginOrder] + # Spec marks ``cursor`` required, but Kalshi omits the key on the final page + # (rather than returning ``""``) — a bare ``str`` would crash ``list_all()`` + # on the last page. Optional matches the sibling fills/trades responses. + cursor: str | None = None + + +class CancelMarginOrderResponse(BaseModel): + """Response for ``DELETE /margin/orders/{order_id}``. Spec line 1838. + + ``reduced_by`` is the number of contracts canceled (the remaining count at + cancellation time). + """ + + model_config = ConfigDict(extra="allow") + + order_id: str + reduced_by: FixedPointCount + client_order_id: str | None = None + + +class DecreaseMarginOrderResponse(BaseModel): + """Response for ``POST /margin/orders/{order_id}/decrease``. Spec line 1868.""" + + model_config = ConfigDict(extra="allow") + + order_id: str + remaining_count: FixedPointCount + client_order_id: str | None = None + + +class AmendMarginOrderResponse(BaseModel): + """Response for ``POST /margin/orders/{order_id}/amend``. Spec line 1915. + + All fields except ``order_id`` are nullable — the fill/size fields are + present only when the amend crossed the book or changed the resting size. + """ + + model_config = ConfigDict(extra="allow") + + order_id: str + client_order_id: str | None = None + remaining_count: FixedPointCount | None = None + fill_count: FixedPointCount | None = None + average_fill_price: DollarDecimal | None = None + average_fee_paid: DollarDecimal | None = None diff --git a/kalshi/perps/models/portfolio.py b/kalshi/perps/models/portfolio.py new file mode 100644 index 0000000..6a5e431 --- /dev/null +++ b/kalshi/perps/models/portfolio.py @@ -0,0 +1,129 @@ +"""Perps (margin) portfolio models — positions, fills, trades (#393). + +Response models for the three margin portfolio/market read endpoints: +``GET /margin/positions``, ``GET /margin/fills``, ``GET /margin/trades``. + +All models use ``model_config = {"extra": "allow"}`` to tolerate additive +spec fields, matching the prediction-API portfolio models. + +**Wire-name note:** unlike the v2 ``MarketPosition`` (which uses ``position_fp`` / +``*_dollars`` wire keys), the perps spec names these keys plainly +(``position``, ``entry_price``, ``unrealized_pnl``, …) and types them via +``$ref: FixedPointCount`` / ``$ref: FixedPointDollars``. So the count/dollar +fields are **plain fields with no ``AliasChoices``**. The only aliased field is +``roe`` → :attr:`MarginPosition.return_on_equity`. + +**Timestamp note:** every timestamp here is RFC3339 (``format: date-time``) and +uses :class:`pydantic.AwareDatetime` — there are no ``_ms`` epoch-millisecond +fields in this issue (that convention applies only to perps WebSocket payloads). +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import AliasChoices, AwareDatetime, BaseModel, Field + +from kalshi.types import DollarDecimal, FixedPointCount, NullableList + +# Spec ``BookSide`` is ``enum: [bid, ask]`` — surfaced as a Literal alias +# (mirroring ``SettlementStatusLiteral`` in kalshi/models/portfolio.py). +BookSideLiteral = Literal["bid", "ask"] +"""Spec ``BookSide`` — the side of an order or trade (``bid``/``ask``).""" + +# Spec ``OrderSource`` is ``enum: [user, system]``; ``system`` = liquidation order. +OrderSourceLiteral = Literal["user", "system"] +"""Spec ``OrderSource`` — ``user`` (user-placed) or ``system`` (liquidation).""" + + +class MarginPosition(BaseModel): + """Spec ``MarginPosition`` — an open margin position in a single market. + + All ``required`` keys are non-nullable except ``roe`` (spec + ``nullable: true``; null when ``margin_used`` is zero). The dollar/count + fields are plain — the perps spec names them without ``_dollars``/``_fp`` + suffixes — so no ``AliasChoices`` are used. Only ``roe`` is renamed, to the + friendlier :attr:`return_on_equity`. + """ + + market_ticker: str + position: FixedPointCount + entry_price: DollarDecimal + unrealized_pnl: DollarDecimal + margin_used: DollarDecimal + fees: DollarDecimal + return_on_equity: float | None = Field( + default=None, + validation_alias=AliasChoices("roe", "return_on_equity"), + ) + + model_config = {"extra": "allow", "populate_by_name": True} + + +class GetMarginPositionsResponse(BaseModel): + """Spec ``GetMarginPositionsResponse`` — single-page positions array. + + No ``cursor`` field; this endpoint is not paginated. + """ + + positions: NullableList[MarginPosition] + + model_config = {"extra": "allow"} + + +class MarginFill(BaseModel): + """Spec ``MarginFill`` — a single margin fill for the authenticated user.""" + + fill_id: str + order_id: str + is_taker: bool + side: BookSideLiteral + count: FixedPointCount + created_time: AwareDatetime + ticker: str + price: DollarDecimal + entry_price: DollarDecimal + fees: DollarDecimal + realized_pnl: DollarDecimal + order_source: OrderSourceLiteral | None = None + + model_config = {"extra": "allow"} + + +class GetMarginFillsResponse(BaseModel): + """Spec ``GetMarginFillsResponse`` — cursor-paginated fills page.""" + + fills: NullableList[MarginFill] + cursor: str | None = None + + @property + def has_next(self) -> bool: + return bool(self.cursor) + + model_config = {"extra": "allow"} + + +class MarginTrade(BaseModel): + """Spec ``MarginTrade`` — a public margin trade for a ticker.""" + + trade_id: str + ticker: str + count: FixedPointCount + price: DollarDecimal + created_time: AwareDatetime + taker_side: BookSideLiteral + + model_config = {"extra": "allow"} + + +class GetMarginTradesResponse(BaseModel): + """Spec ``GetMarginTradesResponse`` — cursor-paginated trades page.""" + + trades: NullableList[MarginTrade] + cursor: str | None = None + + @property + def has_next(self) -> bool: + return bool(self.cursor) + + model_config = {"extra": "allow"} diff --git a/kalshi/perps/models/transfers.py b/kalshi/perps/models/transfers.py new file mode 100644 index 0000000..54ea665 --- /dev/null +++ b/kalshi/perps/models/transfers.py @@ -0,0 +1,94 @@ +"""Perps (margin) transfers & subaccounts models (#396). + +Request/response bodies for the three ``/portfolio/*`` perps fund-movement +endpoints: intra-exchange-instance transfer, create margin subaccount, and +transfer between margin subaccounts. + +**Units note:** unlike most of the SDK, none of these bodies use +``DollarDecimal``/``FixedPointCount``. Per spec the transfer amounts are plain +integers — ``IntraExchangeInstanceTransferRequest.amount`` is **centicents** +and ``ApplySubaccountTransferRequest.amount_cents`` is **cents** +(both ``integer``/``int64``). Introducing ``DollarDecimal`` here would be a +wire mismatch. ``StrictInt`` (rejects ``bool``) is used on the request bodies, +matching the event-contract ``ApplySubaccountTransferRequest``. + +These FQNs are intentionally distinct from the event-contract +``kalshi.models.subaccounts.*`` models even though two schema names collide +(``CreateSubaccountResponse``, ``ApplySubaccountTransferRequest``) — they target +the perps spec/host and are not re-exports. +""" + +from __future__ import annotations + +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, Field + +from kalshi.types import StrictInt + +# Spec ``ExchangeInstance`` (string enum) — modeled as a Literal alias, mirroring +# the ``SettlementStatusLiteral`` pattern in kalshi.models.portfolio. The full +# StrEnum (kalshi.perps.models.common.ExchangeInstance) is the runtime enum; this +# alias is what the request-body fields below are annotated with. +ExchangeInstanceLiteral = Literal["event_contract", "margined"] +"""Spec ``ExchangeInstance`` — ``event_contract`` or ``margined``.""" + + +class IntraExchangeInstanceTransferRequest(BaseModel): + """Body for ``POST /portfolio/intra_exchange_instance_transfer``. + + Moves funds between the ``event_contract`` and ``margined`` exchange + instances. ``amount`` is **centicents** (integer, ``int64``) — not dollars. + Shard fields default to ``0`` (only ``0`` is currently supported); because + the body is serialized with ``exclude_none=True`` (not ``exclude_defaults``), + shard ``0`` values are emitted on the wire, which is fine. + """ + + source: ExchangeInstanceLiteral + destination: ExchangeInstanceLiteral + amount: StrictInt = Field(gt=0) + source_exchange_shard: StrictInt = Field(default=0, ge=0) + destination_exchange_shard: StrictInt = Field(default=0, ge=0) + + model_config = {"extra": "forbid"} + + +class ApplySubaccountTransferRequest(BaseModel): + """Body for ``POST /portfolio/margin/subaccounts/transfer``. + + Mirrors the event-contract + :class:`kalshi.models.subaccounts.ApplySubaccountTransferRequest` exactly. + ``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 + validated (``ge=0``) — server-assigned numbers always round-trip. + """ + + client_transfer_id: UUID + from_subaccount: StrictInt = Field(ge=0) + to_subaccount: StrictInt = Field(ge=0) + amount_cents: StrictInt = Field(gt=0) + + model_config = {"extra": "forbid"} + + +class IntraExchangeInstanceTransferResponse(BaseModel): + """Response from ``POST /portfolio/intra_exchange_instance_transfer``.""" + + transfer_id: str + + model_config = {"extra": "allow"} + + +class CreateSubaccountResponse(BaseModel): + """Response from ``POST /portfolio/margin/subaccounts`` — the new number. + + Distinct from the event-contract + :class:`kalshi.models.subaccounts.CreateSubaccountResponse`. + """ + + subaccount_number: int + + model_config = {"extra": "allow"} diff --git a/kalshi/perps/resources/__init__.py b/kalshi/perps/resources/__init__.py new file mode 100644 index 0000000..482bacb --- /dev/null +++ b/kalshi/perps/resources/__init__.py @@ -0,0 +1,8 @@ +"""Kalshi Perps (margin) API resource modules. + +Each module defines a sync ``*Resource`` and an ``Async*Resource`` pair that +reuse the prediction-API :class:`kalshi.resources._base.SyncResource` / +``AsyncResource`` bases bound to a perps-configured transport. The foundation +issue ships these as minimal stubs; the per-resource perps issues fill in the +endpoint methods. +""" diff --git a/kalshi/perps/resources/exchange.py b/kalshi/perps/resources/exchange.py new file mode 100644 index 0000000..0176f52 --- /dev/null +++ b/kalshi/perps/resources/exchange.py @@ -0,0 +1,57 @@ +"""Perps exchange resource — status, margin-enabled gate, risk parameters (#389). + +Three read-only GET endpoints. ``status`` and ``risk_parameters`` are public +(no spec ``security`` block); ``enabled`` requires RSA-PSS auth and is guarded +client-side with ``_require_auth()`` so an unauthenticated caller gets +``AuthRequiredError`` instead of a server 401. All three retry on 429/502/503/504 +(GET). +""" + +from __future__ import annotations + +from kalshi.perps.models.exchange import ( + ExchangeStatus, + GetMarginRiskParametersResponse, + MarginEnabledResponse, +) +from kalshi.resources._base import AsyncResource, SyncResource + + +class PerpsExchangeResource(SyncResource): + """Sync perps exchange API.""" + + def status(self, *, extra_headers: dict[str, str] | None = None) -> ExchangeStatus: + data = self._get("/margin/exchange/status", extra_headers=extra_headers) + return ExchangeStatus.model_validate(data) + + def enabled(self, *, extra_headers: dict[str, str] | None = None) -> MarginEnabledResponse: + self._require_auth() + data = self._get("/margin/enabled", extra_headers=extra_headers) + return MarginEnabledResponse.model_validate(data) + + def risk_parameters( + self, *, extra_headers: dict[str, str] | None = None + ) -> GetMarginRiskParametersResponse: + data = self._get("/margin/risk_parameters", extra_headers=extra_headers) + return GetMarginRiskParametersResponse.model_validate(data) + + +class AsyncPerpsExchangeResource(AsyncResource): + """Async perps exchange API.""" + + async def status(self, *, extra_headers: dict[str, str] | None = None) -> ExchangeStatus: + data = await self._get("/margin/exchange/status", extra_headers=extra_headers) + return ExchangeStatus.model_validate(data) + + async def enabled( + self, *, extra_headers: dict[str, str] | None = None + ) -> MarginEnabledResponse: + self._require_auth() + data = await self._get("/margin/enabled", extra_headers=extra_headers) + return MarginEnabledResponse.model_validate(data) + + async def risk_parameters( + self, *, extra_headers: dict[str, str] | None = None + ) -> GetMarginRiskParametersResponse: + data = await self._get("/margin/risk_parameters", extra_headers=extra_headers) + return GetMarginRiskParametersResponse.model_validate(data) diff --git a/kalshi/perps/resources/funding.py b/kalshi/perps/resources/funding.py new file mode 100644 index 0000000..3d334fe --- /dev/null +++ b/kalshi/perps/resources/funding.py @@ -0,0 +1,152 @@ +"""Perps funding resource — rate estimate, historical rates, payment history (#395). + +Three read-only GET endpoints. ``rate_estimate`` and ``historical_rates`` are +public (no spec ``security`` block); ``history`` requires RSA-PSS auth and is +guarded client-side with ``_require_auth()`` so an unauthenticated caller gets +``AuthRequiredError`` instead of a server 401. All three retry on 429/502/503/504 +(GET). + +None of the three funding endpoints paginate — ``GetMarginFundingHistoryResponse`` +and ``GetMarginHistoricalFundingRatesResponse`` carry a single array property and +**no cursor** — so there are no ``*_all()`` / ``Iterator`` paginators here. The +two list endpoints unwrap their array key and return ``builtins.list[Model]``, +mirroring ``historical.candlesticks``. +""" + +from __future__ import annotations + +import builtins +from typing import Any + +from kalshi.perps.models.funding import ( + MarginFundingHistoryEntry, + MarginFundingRate, + MarginFundingRateEstimate, +) +from kalshi.resources._base import AsyncResource, SyncResource, _params + + +def _funding_historical_params( + *, + ticker: str | None, + start_ts: int | None, + end_ts: int | None, +) -> dict[str, Any]: + """Build query params for ``GET /margin/funding_rates/historical`` (None dropped).""" + return _params(ticker=ticker, start_ts=start_ts, end_ts=end_ts) + + +def _funding_history_params( + *, + ticker: str | None, + start_date: str, + end_date: str, + subaccount: int | None, +) -> dict[str, Any]: + """Build query params for ``GET /margin/funding_history`` (None dropped).""" + return _params( + ticker=ticker, + start_date=start_date, + end_date=end_date, + subaccount=subaccount, + ) + + +class FundingResource(SyncResource): + """Sync perps funding API (``rate_estimate`` / ``historical_rates`` / ``history``).""" + + def rate_estimate( + self, ticker: str, *, extra_headers: dict[str, str] | None = None + ) -> MarginFundingRateEstimate: + data = self._get( + "/margin/funding_rates/estimate", + params=_params(ticker=ticker), + extra_headers=extra_headers, + ) + return MarginFundingRateEstimate.model_validate(data) + + def historical_rates( + self, + *, + ticker: str | None = None, + start_ts: int | None = None, + end_ts: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> builtins.list[MarginFundingRate]: + params = _funding_historical_params(ticker=ticker, start_ts=start_ts, end_ts=end_ts) + data = self._get( + "/margin/funding_rates/historical", params=params, extra_headers=extra_headers + ) + return [MarginFundingRate.model_validate(r) for r in data.get("funding_rates", [])] + + def history( + self, + *, + start_date: str, + end_date: str, + ticker: str | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> builtins.list[MarginFundingHistoryEntry]: + self._require_auth() + params = _funding_history_params( + ticker=ticker, + start_date=start_date, + end_date=end_date, + subaccount=subaccount, + ) + data = self._get("/margin/funding_history", params=params, extra_headers=extra_headers) + return [ + MarginFundingHistoryEntry.model_validate(e) for e in data.get("funding_history", []) + ] + + +class AsyncFundingResource(AsyncResource): + """Async perps funding API (``rate_estimate`` / ``historical_rates`` / ``history``).""" + + async def rate_estimate( + self, ticker: str, *, extra_headers: dict[str, str] | None = None + ) -> MarginFundingRateEstimate: + data = await self._get( + "/margin/funding_rates/estimate", + params=_params(ticker=ticker), + extra_headers=extra_headers, + ) + return MarginFundingRateEstimate.model_validate(data) + + async def historical_rates( + self, + *, + ticker: str | None = None, + start_ts: int | None = None, + end_ts: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> builtins.list[MarginFundingRate]: + params = _funding_historical_params(ticker=ticker, start_ts=start_ts, end_ts=end_ts) + data = await self._get( + "/margin/funding_rates/historical", params=params, extra_headers=extra_headers + ) + return [MarginFundingRate.model_validate(r) for r in data.get("funding_rates", [])] + + async def history( + self, + *, + start_date: str, + end_date: str, + ticker: str | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> builtins.list[MarginFundingHistoryEntry]: + self._require_auth() + params = _funding_history_params( + ticker=ticker, + start_date=start_date, + end_date=end_date, + subaccount=subaccount, + ) + data = await self._get( + "/margin/funding_history", params=params, extra_headers=extra_headers + ) + return [ + MarginFundingHistoryEntry.model_validate(e) for e in data.get("funding_history", []) + ] diff --git a/kalshi/perps/resources/margin_account.py b/kalshi/perps/resources/margin_account.py new file mode 100644 index 0000000..114a249 --- /dev/null +++ b/kalshi/perps/resources/margin_account.py @@ -0,0 +1,87 @@ +"""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). +""" + +from __future__ import annotations + +from kalshi.perps.models.margin_account import ( + GetMarginBalanceResponse, + GetMarginFeeTiersResponse, + GetMarginRiskResponse, + NotionalRiskLimitResponse, +) +from kalshi.resources._base import AsyncResource, SyncResource, _params + + +class MarginAccountResource(SyncResource): + """Sync perps margin-account API (balance / risk / notional_risk_limit / fee_tiers).""" + + def balance( + self, + *, + compute_available_balance: bool | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetMarginBalanceResponse: + self._require_auth() + params = _params(compute_available_balance=compute_available_balance) + data = self._get("/margin/balance", params=params, extra_headers=extra_headers) + return GetMarginBalanceResponse.model_validate(data) + + def risk(self, *, extra_headers: dict[str, str] | None = None) -> GetMarginRiskResponse: + self._require_auth() + data = self._get("/margin/risk", extra_headers=extra_headers) + return GetMarginRiskResponse.model_validate(data) + + def notional_risk_limit( + self, *, extra_headers: dict[str, str] | None = None + ) -> NotionalRiskLimitResponse: + self._require_auth() + data = self._get("/margin/notional_risk_limit", extra_headers=extra_headers) + return NotionalRiskLimitResponse.model_validate(data) + + def fee_tiers( + self, *, extra_headers: dict[str, str] | None = None + ) -> GetMarginFeeTiersResponse: + self._require_auth() + data = self._get("/margin/fee_tiers", extra_headers=extra_headers) + return GetMarginFeeTiersResponse.model_validate(data) + + +class AsyncMarginAccountResource(AsyncResource): + """Async perps margin-account API (balance / risk / notional_risk_limit / fee_tiers).""" + + async def balance( + self, + *, + compute_available_balance: bool | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetMarginBalanceResponse: + self._require_auth() + params = _params(compute_available_balance=compute_available_balance) + data = await self._get("/margin/balance", params=params, extra_headers=extra_headers) + return GetMarginBalanceResponse.model_validate(data) + + async def risk(self, *, extra_headers: dict[str, str] | None = None) -> GetMarginRiskResponse: + self._require_auth() + data = await self._get("/margin/risk", extra_headers=extra_headers) + return GetMarginRiskResponse.model_validate(data) + + async def notional_risk_limit( + self, *, extra_headers: dict[str, str] | None = None + ) -> NotionalRiskLimitResponse: + self._require_auth() + data = await self._get("/margin/notional_risk_limit", extra_headers=extra_headers) + return NotionalRiskLimitResponse.model_validate(data) + + async def fee_tiers( + self, *, extra_headers: dict[str, str] | None = None + ) -> GetMarginFeeTiersResponse: + self._require_auth() + data = await self._get("/margin/fee_tiers", extra_headers=extra_headers) + return GetMarginFeeTiersResponse.model_validate(data) diff --git a/kalshi/perps/resources/markets.py b/kalshi/perps/resources/markets.py new file mode 100644 index 0000000..bef3856 --- /dev/null +++ b/kalshi/perps/resources/markets.py @@ -0,0 +1,187 @@ +"""Perps markets resource — list/get, orderbook, candlesticks (#390). + +Four read-only ``GET`` endpoints, all public market data (the spec marks no +``security`` block on any of them) — so none call ``_require_auth()``, unlike the +public-markets ``orderbook`` which does. All retry on 429/502/503/504 (GET). + +Divergence from :mod:`kalshi.resources.markets`: ``list()`` returns a plain +``builtins.list[MarginMarket]`` (NOT a ``Page``) and has **no** ``list_all()`` — +``GetMarginMarketsResponse`` carries no cursor, so there is nothing to paginate. +The orderbook is ``{bids, asks}`` (margin) rather than ``{yes, no}``. +""" + +from __future__ import annotations + +import builtins +from typing import Any + +from kalshi.perps.models.markets import ( + MarginMarket, + MarginMarketCandlestick, + MarginMarketStatusLiteral, + MarginOrderbook, + MarginOrderbookLevel, +) +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _bool_param, + _params, + _seg, +) + + +def _parse_orderbook(data: dict[str, Any], ticker: str) -> MarginOrderbook: + """Parse the GET /margin/markets/{ticker}/orderbook envelope. + + Shape is ``{"orderbook": {"bids": [[price, qty], ...], "asks": [...]}}``. + Each level is a 2-tuple ``[price_dollars, contract_count_fp]`` → + ``MarginOrderbookLevel(price=pair[0], quantity=pair[1])``. ``ticker`` is + injected from the path arg (not on the wire). + """ + ob = data.get("orderbook", data) + bids_raw = ob.get("bids") or [] + asks_raw = ob.get("asks") or [] + bids = [ + MarginOrderbookLevel(price=pair[0], quantity=pair[1]) for pair in bids_raw if len(pair) >= 2 + ] + asks = [ + MarginOrderbookLevel(price=pair[0], quantity=pair[1]) for pair in asks_raw if len(pair) >= 2 + ] + return MarginOrderbook(ticker=ticker, bids=bids, asks=asks) + + +class PerpsMarketsResource(SyncResource): + """Sync perps markets API (``list`` / ``get`` / ``orderbook`` / ``candlesticks``).""" + + def list( + self, + *, + status: MarginMarketStatusLiteral | None = None, + extra_headers: dict[str, str] | None = None, + ) -> builtins.list[MarginMarket]: + """List margin markets. + + Non-paginated: ``GetMarginMarketsResponse`` has no cursor, so there is + no ``list_all()`` here (unlike :class:`kalshi.resources.markets.MarketsResource`). + """ + params = _params(status=status) + data = self._get("/margin/markets", params=params, extra_headers=extra_headers) + raw = data.get("markets") or [] + return [MarginMarket.model_validate(m) for m in raw] + + def get(self, ticker: str, *, extra_headers: dict[str, str] | None = None) -> MarginMarket: + data = self._get( + f"/margin/markets/{_seg(ticker, name='ticker')}", extra_headers=extra_headers + ) + market = data.get("market", data) + return MarginMarket.model_validate(market) + + def orderbook( + self, + ticker: str, + *, + depth: int | None = None, + aggregation_tick_size: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> MarginOrderbook: + params = _params(depth=depth, aggregation_tick_size=aggregation_tick_size) + data = self._get( + f"/margin/markets/{_seg(ticker, name='ticker')}/orderbook", + params=params, + extra_headers=extra_headers, + ) + return _parse_orderbook(data, ticker) + + def candlesticks( + self, + ticker: str, + *, + start_ts: int, + end_ts: int, + period_interval: int, + include_latest_before_start: bool | None = None, + extra_headers: dict[str, str] | None = None, + ) -> builtins.list[MarginMarketCandlestick]: + params = _params( + start_ts=start_ts, + end_ts=end_ts, + period_interval=period_interval, + include_latest_before_start=_bool_param(include_latest_before_start), + ) + data = self._get( + f"/margin/markets/{_seg(ticker, name='ticker')}/candlesticks", + params=params, + extra_headers=extra_headers, + ) + raw = data.get("candlesticks") or [] + return [MarginMarketCandlestick.model_validate(c) for c in raw] + + +class AsyncPerpsMarketsResource(AsyncResource): + """Async perps markets API (``list`` / ``get`` / ``orderbook`` / ``candlesticks``).""" + + async def list( + self, + *, + status: MarginMarketStatusLiteral | None = None, + extra_headers: dict[str, str] | None = None, + ) -> builtins.list[MarginMarket]: + """List margin markets. + + Non-paginated: ``GetMarginMarketsResponse`` has no cursor, so there is + no ``list_all()`` here (unlike :class:`kalshi.resources.markets.MarketsResource`). + """ + params = _params(status=status) + data = await self._get("/margin/markets", params=params, extra_headers=extra_headers) + raw = data.get("markets") or [] + return [MarginMarket.model_validate(m) for m in raw] + + async def get( + self, ticker: str, *, extra_headers: dict[str, str] | None = None + ) -> MarginMarket: + data = await self._get( + f"/margin/markets/{_seg(ticker, name='ticker')}", extra_headers=extra_headers + ) + market = data.get("market", data) + return MarginMarket.model_validate(market) + + async def orderbook( + self, + ticker: str, + *, + depth: int | None = None, + aggregation_tick_size: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> MarginOrderbook: + params = _params(depth=depth, aggregation_tick_size=aggregation_tick_size) + data = await self._get( + f"/margin/markets/{_seg(ticker, name='ticker')}/orderbook", + params=params, + extra_headers=extra_headers, + ) + return _parse_orderbook(data, ticker) + + async def candlesticks( + self, + ticker: str, + *, + start_ts: int, + end_ts: int, + period_interval: int, + include_latest_before_start: bool | None = None, + extra_headers: dict[str, str] | None = None, + ) -> builtins.list[MarginMarketCandlestick]: + params = _params( + start_ts=start_ts, + end_ts=end_ts, + period_interval=period_interval, + include_latest_before_start=_bool_param(include_latest_before_start), + ) + data = await self._get( + f"/margin/markets/{_seg(ticker, name='ticker')}/candlesticks", + params=params, + extra_headers=extra_headers, + ) + raw = data.get("candlesticks") or [] + return [MarginMarketCandlestick.model_validate(c) for c in raw] diff --git a/kalshi/perps/resources/order_groups.py b/kalshi/perps/resources/order_groups.py new file mode 100644 index 0000000..537a873 --- /dev/null +++ b/kalshi/perps/resources/order_groups.py @@ -0,0 +1,381 @@ +"""Perps (margin) order groups resource (#392). + +The margin-exchange twin of :mod:`kalshi.resources.order_groups`, targeting +``/margin/order_groups`` on the perps base URL. Order groups are rolling +15-second contracts-limit buckets: when the matched-contracts counter hits the +limit the whole group's orders are cancelled (auto-cancel) and no new orders +place until reset. + +Every endpoint carries a spec ``security`` block, so every method calls +``self._require_auth()`` first. ``GetOrderGroupsResponse`` has no cursor, so +``list`` returns a plain ``builtins.list[OrderGroup]`` (no ``list_all`` / +``Iterator``). POST/PUT/DELETE are never retried (transport-enforced). + +Unlike the portfolio resource, the perps ``/limit`` endpoint DOES carry +``SubaccountQueryDefaultPrimary`` — perps ``update_limit`` accepts ``subaccount``. +""" + +from __future__ import annotations + +import builtins +from typing import Any, overload + +from kalshi.perps.models.order_groups import ( + CreateOrderGroupRequest, + CreateOrderGroupResponse, + GetOrderGroupResponse, + OrderGroup, + UpdateOrderGroupLimitRequest, +) +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _check_request_exclusive, + _params, + _seg, +) + + +def _build_create_order_group_body( + request: CreateOrderGroupRequest | None, + *, + contracts_limit: int | None, + subaccount: int | None, + exchange_index: int | None, +) -> dict[str, Any]: + _check_request_exclusive( + request, + contracts_limit=contracts_limit, + subaccount=subaccount, + exchange_index=exchange_index, + ) + if request is None: + if contracts_limit is None: + raise TypeError("create() requires `contracts_limit` (or pass `request=...`)") + request = CreateOrderGroupRequest( + contracts_limit=contracts_limit, + subaccount=subaccount, + exchange_index=exchange_index, + ) + return request.model_dump(exclude_none=True, by_alias=True, mode="json") + + +def _build_update_limit_body( + request: UpdateOrderGroupLimitRequest | None, + *, + contracts_limit: int | None, +) -> dict[str, Any]: + _check_request_exclusive(request, contracts_limit=contracts_limit) + if request is None: + if contracts_limit is None: + raise TypeError("update_limit() requires `contracts_limit` (or pass `request=...`)") + request = UpdateOrderGroupLimitRequest(contracts_limit=contracts_limit) + return request.model_dump(exclude_none=True, by_alias=True, mode="json") + + +class OrderGroupsResource(SyncResource): + """Sync perps order groups API.""" + + def list( + self, *, subaccount: int | None = None, extra_headers: dict[str, str] | None = None + ) -> builtins.list[OrderGroup]: + # Returns plain list (not Page) — spec response has no cursor. + self._require_auth() + params = _params(subaccount=subaccount) + data = self._get("/margin/order_groups", params=params, extra_headers=extra_headers) + raw = data.get("order_groups", []) + return [OrderGroup.model_validate(item) for item in raw] + + def get( + self, + order_group_id: str, + *, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetOrderGroupResponse: + self._require_auth() + params = _params(subaccount=subaccount) + data = self._get( + f"/margin/order_groups/{_seg(order_group_id, name='order_group_id')}", + params=params, + extra_headers=extra_headers, + ) + return GetOrderGroupResponse.model_validate(data) + + @overload + def create( + self, *, request: CreateOrderGroupRequest, extra_headers: dict[str, str] | None = None + ) -> CreateOrderGroupResponse: ... + @overload + def create( + self, + *, + contracts_limit: int, + subaccount: int | None = ..., + exchange_index: int | None = ..., + extra_headers: dict[str, str] | None = None, + ) -> CreateOrderGroupResponse: ... + def create( + self, + *, + request: CreateOrderGroupRequest | None = None, + contracts_limit: int | None = None, + subaccount: int | None = None, + exchange_index: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> CreateOrderGroupResponse: + # POST path is /margin/order_groups/create, not /margin/order_groups. + self._require_auth() + body = _build_create_order_group_body( + request, + contracts_limit=contracts_limit, + subaccount=subaccount, + exchange_index=exchange_index, + ) + data = self._post("/margin/order_groups/create", json=body, extra_headers=extra_headers) + return CreateOrderGroupResponse.model_validate(data) + + def delete( + self, + order_group_id: str, + *, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + # Spec DELETE carries only SubaccountQueryDefaultPrimary (no exchange_index). + self._require_auth() + params = _params(subaccount=subaccount) + self._delete( + f"/margin/order_groups/{_seg(order_group_id, name='order_group_id')}", + params=params, + extra_headers=extra_headers, + ) + + def reset( + self, + order_group_id: str, + *, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + self._require_auth() + params = _params(subaccount=subaccount) + # json={} forces Content-Type: application/json — demo rejects the PUT without it. + self._put( + f"/margin/order_groups/{_seg(order_group_id, name='order_group_id')}/reset", + params=params, + json={}, + extra_headers=extra_headers, + ) + + def trigger( + self, + order_group_id: str, + *, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + self._require_auth() + params = _params(subaccount=subaccount) + # json={} forces Content-Type: application/json — demo rejects the PUT without it. + self._put( + f"/margin/order_groups/{_seg(order_group_id, name='order_group_id')}/trigger", + params=params, + json={}, + extra_headers=extra_headers, + ) + + @overload + def update_limit( + self, + order_group_id: str, + *, + request: UpdateOrderGroupLimitRequest, + subaccount: int | None = ..., + extra_headers: dict[str, str] | None = None, + ) -> None: ... + @overload + def update_limit( + self, + order_group_id: str, + *, + contracts_limit: int, + subaccount: int | None = ..., + extra_headers: dict[str, str] | None = None, + ) -> None: ... + def update_limit( + self, + order_group_id: str, + *, + request: UpdateOrderGroupLimitRequest | None = None, + contracts_limit: int | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + # Unlike portfolio, perps /limit carries SubaccountQueryDefaultPrimary. + self._require_auth() + body = _build_update_limit_body( + request, + contracts_limit=contracts_limit, + ) + params = _params(subaccount=subaccount) + self._put( + f"/margin/order_groups/{_seg(order_group_id, name='order_group_id')}/limit", + params=params, + json=body, + extra_headers=extra_headers, + ) + + +class AsyncOrderGroupsResource(AsyncResource): + """Async perps order groups API.""" + + async def list( + self, *, subaccount: int | None = None, extra_headers: dict[str, str] | None = None + ) -> builtins.list[OrderGroup]: + self._require_auth() + params = _params(subaccount=subaccount) + data = await self._get("/margin/order_groups", params=params, extra_headers=extra_headers) + raw = data.get("order_groups", []) + return [OrderGroup.model_validate(item) for item in raw] + + async def get( + self, + order_group_id: str, + *, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetOrderGroupResponse: + self._require_auth() + params = _params(subaccount=subaccount) + data = await self._get( + f"/margin/order_groups/{_seg(order_group_id, name='order_group_id')}", + params=params, + extra_headers=extra_headers, + ) + return GetOrderGroupResponse.model_validate(data) + + @overload + async def create( + self, *, request: CreateOrderGroupRequest, extra_headers: dict[str, str] | None = None + ) -> CreateOrderGroupResponse: ... + @overload + async def create( + self, + *, + contracts_limit: int, + subaccount: int | None = ..., + exchange_index: int | None = ..., + extra_headers: dict[str, str] | None = None, + ) -> CreateOrderGroupResponse: ... + async def create( + self, + *, + request: CreateOrderGroupRequest | None = None, + contracts_limit: int | None = None, + subaccount: int | None = None, + exchange_index: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> CreateOrderGroupResponse: + self._require_auth() + body = _build_create_order_group_body( + request, + contracts_limit=contracts_limit, + subaccount=subaccount, + exchange_index=exchange_index, + ) + data = await self._post( + "/margin/order_groups/create", json=body, extra_headers=extra_headers + ) + return CreateOrderGroupResponse.model_validate(data) + + async def delete( + self, + order_group_id: str, + *, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + # Spec DELETE carries only SubaccountQueryDefaultPrimary (no exchange_index). + self._require_auth() + params = _params(subaccount=subaccount) + await self._delete( + f"/margin/order_groups/{_seg(order_group_id, name='order_group_id')}", + params=params, + extra_headers=extra_headers, + ) + + async def reset( + self, + order_group_id: str, + *, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + self._require_auth() + params = _params(subaccount=subaccount) + # json={} forces Content-Type: application/json — demo rejects the PUT without it. + await self._put( + f"/margin/order_groups/{_seg(order_group_id, name='order_group_id')}/reset", + params=params, + json={}, + extra_headers=extra_headers, + ) + + async def trigger( + self, + order_group_id: str, + *, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + self._require_auth() + params = _params(subaccount=subaccount) + # json={} forces Content-Type: application/json — demo rejects the PUT without it. + await self._put( + f"/margin/order_groups/{_seg(order_group_id, name='order_group_id')}/trigger", + params=params, + json={}, + extra_headers=extra_headers, + ) + + @overload + async def update_limit( + self, + order_group_id: str, + *, + request: UpdateOrderGroupLimitRequest, + subaccount: int | None = ..., + extra_headers: dict[str, str] | None = None, + ) -> None: ... + @overload + async def update_limit( + self, + order_group_id: str, + *, + contracts_limit: int, + subaccount: int | None = ..., + extra_headers: dict[str, str] | None = None, + ) -> None: ... + async def update_limit( + self, + order_group_id: str, + *, + request: UpdateOrderGroupLimitRequest | None = None, + contracts_limit: int | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + # Unlike portfolio, perps /limit carries SubaccountQueryDefaultPrimary. + self._require_auth() + body = _build_update_limit_body( + request, + contracts_limit=contracts_limit, + ) + params = _params(subaccount=subaccount) + await self._put( + f"/margin/order_groups/{_seg(order_group_id, name='order_group_id')}/limit", + params=params, + json=body, + extra_headers=extra_headers, + ) diff --git a/kalshi/perps/resources/orders.py b/kalshi/perps/resources/orders.py new file mode 100644 index 0000000..4db9413 --- /dev/null +++ b/kalshi/perps/resources/orders.py @@ -0,0 +1,858 @@ +"""Perps (margin) orders resource — create/get/list/cancel/decrease/amend + FCM (#391). + +The trade-execution surface of the perps API. Mirrors the prediction-API V2 +order family (``kalshi/resources/orders.py``) but talks to the perps base URL. + +Every endpoint here carries a spec ``security`` block, so each method calls +``_require_auth()`` first — an unauthenticated caller gets ``AuthRequiredError`` +before any HTTP request. ``create``, ``cancel``, ``decrease``, and ``amend`` are +POST/DELETE, which the transport never retries (duplicate-order / cancel- +idempotency risk); only the GET ``get``/``list``/``list_fcm`` retry. + +``subaccount`` routing follows the spec split: ``create`` carries it in the +request *body* (``CreateMarginOrderRequest.subaccount``), while +cancel/decrease/amend route it as a *query* param. ``list_fcm`` has no +``subaccount`` at all — it filters by the required ``subtrader_id``. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterator +from typing import Any, overload + +from kalshi.errors import KalshiError +from kalshi.perps.models.orders import ( + AmendMarginOrderRequest, + AmendMarginOrderResponse, + BookSideLiteral, + CancelMarginOrderResponse, + CreateMarginOrderRequest, + CreateMarginOrderResponse, + DecreaseMarginOrderRequest, + DecreaseMarginOrderResponse, + GetMarginOrderResponse, + GetMarginOrdersResponse, + MarginOrder, + SelfTradePreventionTypeLiteral, + TimeInForceLiteral, +) +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _check_request_exclusive, + _params, + _seg, + _validate_limit, + _validate_max_pages, +) +from kalshi.types import to_decimal + +# --------------------------------------------------------------------------- +# Shared request-body builders (pure: kwargs in, dict[str, Any] out). Both the +# sync and async resource methods call the same builder so model construction +# and serialization live in one place. +# --------------------------------------------------------------------------- + + +def _build_create_body( + request: CreateMarginOrderRequest | None, + *, + ticker: str | None, + client_order_id: str | None, + side: BookSideLiteral | None, + count: int | float | str | None, + price: int | float | str | None, + time_in_force: TimeInForceLiteral | None, + self_trade_prevention_type: SelfTradePreventionTypeLiteral | None, + expiration_time: int | None, + post_only: bool | None, + cancel_order_on_pause: bool | None, + reduce_only: bool | None, + subaccount: int | None, + order_group_id: str | None, +) -> dict[str, Any]: + _check_request_exclusive( + request, + ticker=ticker, + client_order_id=client_order_id, + side=side, + count=count, + price=price, + time_in_force=time_in_force, + self_trade_prevention_type=self_trade_prevention_type, + expiration_time=expiration_time, + post_only=post_only, + cancel_order_on_pause=cancel_order_on_pause, + reduce_only=reduce_only, + subaccount=subaccount, + order_group_id=order_group_id, + ) + if request is None: + if ( + ticker is None + or client_order_id is None + or side is None + or count is None + or price is None + or time_in_force is None + or self_trade_prevention_type is None + ): + raise TypeError( + "create() requires `ticker`, `client_order_id`, `side`, `count`, " + "`price`, `time_in_force`, and `self_trade_prevention_type` " + "(or pass `request=...`)." + ) + request = CreateMarginOrderRequest( + ticker=ticker, + client_order_id=client_order_id, + side=side, + count=to_decimal(count), + price=to_decimal(price), + time_in_force=time_in_force, + self_trade_prevention_type=self_trade_prevention_type, + expiration_time=expiration_time, + post_only=post_only, + cancel_order_on_pause=cancel_order_on_pause, + reduce_only=reduce_only, + subaccount=subaccount, + order_group_id=order_group_id, + ) + return request.model_dump(exclude_none=True, by_alias=True, mode="json") + + +def _build_decrease_body( + request: DecreaseMarginOrderRequest | None, + *, + reduce_by: int | float | str | None, + reduce_to: int | float | str | None, +) -> dict[str, Any]: + _check_request_exclusive(request, reduce_by=reduce_by, reduce_to=reduce_to) + if request is None: + # The model validator enforces the reduce_by/reduce_to XOR; build it + # directly so both the kwarg and request= paths share one rule. + request = DecreaseMarginOrderRequest( + reduce_by=to_decimal(reduce_by) if reduce_by is not None else None, + reduce_to=to_decimal(reduce_to) if reduce_to is not None else None, + ) + return request.model_dump(exclude_none=True, by_alias=True, mode="json") + + +def _build_amend_body( + request: AmendMarginOrderRequest | None, + *, + ticker: str | None, + side: BookSideLiteral | None, + price: int | float | str | None, + count: int | float | str | None, + client_order_id: str | None, + updated_client_order_id: str | None, +) -> dict[str, Any]: + _check_request_exclusive( + request, + ticker=ticker, + side=side, + price=price, + count=count, + client_order_id=client_order_id, + updated_client_order_id=updated_client_order_id, + ) + if request is None: + if ticker is None or side is None or price is None or count is None: + raise TypeError( + "amend() requires `ticker`, `side`, `price`, and `count` " + "(or pass `request=...`)." + ) + request = AmendMarginOrderRequest( + ticker=ticker, + side=side, + price=to_decimal(price), + count=to_decimal(count), + client_order_id=client_order_id, + updated_client_order_id=updated_client_order_id, + ) + return request.model_dump(exclude_none=True, by_alias=True, mode="json") + + +def _list_orders_params( + *, + ticker: str | None, + status: str | None, + min_ts: int | None, + max_ts: int | None, + limit: int | None, + cursor: str | None, + subaccount: int | None, +) -> dict[str, Any]: + limit = _validate_limit(limit, hi=1000) + return _params( + ticker=ticker, + status=status, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=cursor, + subaccount=subaccount, + ) + + +def _list_fcm_params( + *, + subtrader_id: str, + ticker: str | None, + status: str | None, + min_ts: int | None, + max_ts: int | None, + limit: int | None, + cursor: str | None, +) -> dict[str, Any]: + limit = _validate_limit(limit, hi=1000) + return _params( + subtrader_id=subtrader_id, + ticker=ticker, + status=status, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=cursor, + ) + + +class MarginOrdersResource(SyncResource): + """Sync perps orders API (create/get/list/cancel/decrease/amend + FCM orders).""" + + @overload + def create( + self, *, request: CreateMarginOrderRequest, extra_headers: dict[str, str] | None = None + ) -> CreateMarginOrderResponse: ... + @overload + def create( + self, + *, + ticker: str, + client_order_id: str, + side: BookSideLiteral, + count: int | float | str, + price: int | float | str, + time_in_force: TimeInForceLiteral, + self_trade_prevention_type: SelfTradePreventionTypeLiteral, + expiration_time: int | None = ..., + post_only: bool | None = ..., + cancel_order_on_pause: bool | None = ..., + reduce_only: bool | None = ..., + subaccount: int | None = ..., + order_group_id: str | None = ..., + extra_headers: dict[str, str] | None = None, + ) -> CreateMarginOrderResponse: ... + def create( + self, + *, + request: CreateMarginOrderRequest | None = None, + ticker: str | None = None, + client_order_id: str | None = None, + side: BookSideLiteral | None = None, + count: int | float | str | None = None, + price: int | float | str | None = None, + time_in_force: TimeInForceLiteral | None = None, + self_trade_prevention_type: SelfTradePreventionTypeLiteral | None = None, + expiration_time: int | None = None, + post_only: bool | None = None, + cancel_order_on_pause: bool | None = None, + reduce_only: bool | None = None, + subaccount: int | None = None, + order_group_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> CreateMarginOrderResponse: + """Place a new margin order (POST /margin/orders). Not retried. + + ``expiration_time`` is int64 Unix seconds per spec. ``subaccount`` + is carried in the request *body* (0 = primary). + """ + self._require_auth() + body = _build_create_body( + request, + ticker=ticker, + client_order_id=client_order_id, + side=side, + count=count, + price=price, + time_in_force=time_in_force, + self_trade_prevention_type=self_trade_prevention_type, + expiration_time=expiration_time, + post_only=post_only, + cancel_order_on_pause=cancel_order_on_pause, + reduce_only=reduce_only, + subaccount=subaccount, + order_group_id=order_group_id, + ) + data = self._post("/margin/orders", json=body, extra_headers=extra_headers) + return CreateMarginOrderResponse.model_validate(data) + + def get(self, order_id: str, *, extra_headers: dict[str, str] | None = None) -> MarginOrder: + """Retrieve a single margin order (GET /margin/orders/{order_id}).""" + self._require_auth() + data = self._get( + f"/margin/orders/{_seg(order_id, name='order_id')}", extra_headers=extra_headers + ) + return GetMarginOrderResponse.model_validate(data).order + + def list( + self, + *, + ticker: str | None = None, + status: str | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + cursor: str | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetMarginOrdersResponse: + """List margin orders, single page (GET /margin/orders). + + Returns the full envelope including ``cursor``. Use :meth:`list_all` + to auto-paginate. + """ + self._require_auth() + params = _list_orders_params( + ticker=ticker, + status=status, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=cursor, + subaccount=subaccount, + ) + data = self._get("/margin/orders", params=params, extra_headers=extra_headers) + return GetMarginOrdersResponse.model_validate(data) + + def list_all( + self, + *, + ticker: str | None = None, + status: str | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + subaccount: int | None = None, + limit: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Iterator[MarginOrder]: + """Auto-paginate all margin orders (GET /margin/orders).""" + self._require_auth() + _validate_max_pages(max_pages) + params = _list_orders_params( + ticker=ticker, + status=status, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=None, + subaccount=subaccount, + ) + return self._list_all( + "/margin/orders", + MarginOrder, + "orders", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) + + def cancel( + self, + order_id: str, + *, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> CancelMarginOrderResponse: + """Cancel a margin order (DELETE /margin/orders/{order_id}). Not retried.""" + self._require_auth() + params = _params(subaccount=subaccount) + data = self._delete( + f"/margin/orders/{_seg(order_id, name='order_id')}", + params=params, + extra_headers=extra_headers, + ) + if data is None: + raise KalshiError("Expected CancelMarginOrderResponse body, got 204 No Content.") + return CancelMarginOrderResponse.model_validate(data) + + @overload + def decrease( + self, + order_id: str, + *, + request: DecreaseMarginOrderRequest, + subaccount: int | None = ..., + extra_headers: dict[str, str] | None = None, + ) -> DecreaseMarginOrderResponse: ... + @overload + def decrease( + self, + order_id: str, + *, + reduce_by: int | float | str | None = ..., + reduce_to: int | float | str | None = ..., + subaccount: int | None = ..., + extra_headers: dict[str, str] | None = None, + ) -> DecreaseMarginOrderResponse: ... + def decrease( + self, + order_id: str, + *, + request: DecreaseMarginOrderRequest | None = None, + reduce_by: int | float | str | None = None, + reduce_to: int | float | str | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> DecreaseMarginOrderResponse: + """Decrease a margin order's size (POST /margin/orders/{order_id}/decrease). + + Exactly one of ``reduce_by`` / ``reduce_to`` is required (enforced by + the request model). ``subaccount`` is a query param. Not retried. + """ + self._require_auth() + body = _build_decrease_body(request, reduce_by=reduce_by, reduce_to=reduce_to) + params = _params(subaccount=subaccount) + data = self._post( + f"/margin/orders/{_seg(order_id, name='order_id')}/decrease", + params=params, + json=body, + extra_headers=extra_headers, + ) + return DecreaseMarginOrderResponse.model_validate(data) + + @overload + def amend( + self, + order_id: str, + *, + request: AmendMarginOrderRequest, + subaccount: int | None = ..., + extra_headers: dict[str, str] | None = None, + ) -> AmendMarginOrderResponse: ... + @overload + def amend( + self, + order_id: str, + *, + ticker: str, + side: BookSideLiteral, + price: int | float | str, + count: int | float | str, + client_order_id: str | None = ..., + updated_client_order_id: str | None = ..., + subaccount: int | None = ..., + extra_headers: dict[str, str] | None = None, + ) -> AmendMarginOrderResponse: ... + def amend( + self, + order_id: str, + *, + request: AmendMarginOrderRequest | None = None, + ticker: str | None = None, + side: BookSideLiteral | None = None, + price: int | float | str | None = None, + count: int | float | str | None = None, + client_order_id: str | None = None, + updated_client_order_id: str | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> AmendMarginOrderResponse: + """Amend a margin order's price/size (POST /margin/orders/{order_id}/amend). + + ``subaccount`` is a query param. Increasing size or changing price + forfeits queue position (server-side). Not retried. + """ + self._require_auth() + body = _build_amend_body( + request, + ticker=ticker, + side=side, + price=price, + count=count, + client_order_id=client_order_id, + updated_client_order_id=updated_client_order_id, + ) + params = _params(subaccount=subaccount) + data = self._post( + f"/margin/orders/{_seg(order_id, name='order_id')}/amend", + params=params, + json=body, + extra_headers=extra_headers, + ) + return AmendMarginOrderResponse.model_validate(data) + + def list_fcm( + self, + *, + subtrader_id: str, + ticker: str | None = None, + status: str | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + cursor: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetMarginOrdersResponse: + """List margin orders for an FCM subtrader, single page (GET /margin/fcm/orders). + + ``subtrader_id`` is required. No ``subaccount`` param. + """ + self._require_auth() + params = _list_fcm_params( + subtrader_id=subtrader_id, + ticker=ticker, + status=status, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=cursor, + ) + data = self._get("/margin/fcm/orders", params=params, extra_headers=extra_headers) + return GetMarginOrdersResponse.model_validate(data) + + def list_all_fcm( + self, + *, + subtrader_id: str, + ticker: str | None = None, + status: str | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Iterator[MarginOrder]: + """Auto-paginate FCM subtrader orders (GET /margin/fcm/orders).""" + self._require_auth() + _validate_max_pages(max_pages) + params = _list_fcm_params( + subtrader_id=subtrader_id, + ticker=ticker, + status=status, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=None, + ) + return self._list_all( + "/margin/fcm/orders", + MarginOrder, + "orders", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) + + +class AsyncMarginOrdersResource(AsyncResource): + """Async perps orders API (create/get/list/cancel/decrease/amend + FCM orders).""" + + @overload + async def create( + self, *, request: CreateMarginOrderRequest, extra_headers: dict[str, str] | None = None + ) -> CreateMarginOrderResponse: ... + @overload + async def create( + self, + *, + ticker: str, + client_order_id: str, + side: BookSideLiteral, + count: int | float | str, + price: int | float | str, + time_in_force: TimeInForceLiteral, + self_trade_prevention_type: SelfTradePreventionTypeLiteral, + expiration_time: int | None = ..., + post_only: bool | None = ..., + cancel_order_on_pause: bool | None = ..., + reduce_only: bool | None = ..., + subaccount: int | None = ..., + order_group_id: str | None = ..., + extra_headers: dict[str, str] | None = None, + ) -> CreateMarginOrderResponse: ... + async def create( + self, + *, + request: CreateMarginOrderRequest | None = None, + ticker: str | None = None, + client_order_id: str | None = None, + side: BookSideLiteral | None = None, + count: int | float | str | None = None, + price: int | float | str | None = None, + time_in_force: TimeInForceLiteral | None = None, + self_trade_prevention_type: SelfTradePreventionTypeLiteral | None = None, + expiration_time: int | None = None, + post_only: bool | None = None, + cancel_order_on_pause: bool | None = None, + reduce_only: bool | None = None, + subaccount: int | None = None, + order_group_id: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> CreateMarginOrderResponse: + """Place a new margin order. See :meth:`MarginOrdersResource.create`.""" + self._require_auth() + body = _build_create_body( + request, + ticker=ticker, + client_order_id=client_order_id, + side=side, + count=count, + price=price, + time_in_force=time_in_force, + self_trade_prevention_type=self_trade_prevention_type, + expiration_time=expiration_time, + post_only=post_only, + cancel_order_on_pause=cancel_order_on_pause, + reduce_only=reduce_only, + subaccount=subaccount, + order_group_id=order_group_id, + ) + data = await self._post("/margin/orders", json=body, extra_headers=extra_headers) + return CreateMarginOrderResponse.model_validate(data) + + async def get( + self, order_id: str, *, extra_headers: dict[str, str] | None = None + ) -> MarginOrder: + """Retrieve a single margin order.""" + self._require_auth() + data = await self._get( + f"/margin/orders/{_seg(order_id, name='order_id')}", extra_headers=extra_headers + ) + return GetMarginOrderResponse.model_validate(data).order + + async def list( + self, + *, + ticker: str | None = None, + status: str | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + cursor: str | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetMarginOrdersResponse: + """List margin orders, single page. See :meth:`MarginOrdersResource.list`.""" + self._require_auth() + params = _list_orders_params( + ticker=ticker, + status=status, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=cursor, + subaccount=subaccount, + ) + data = await self._get("/margin/orders", params=params, extra_headers=extra_headers) + return GetMarginOrdersResponse.model_validate(data) + + def list_all( + self, + *, + ticker: str | None = None, + status: str | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + subaccount: int | None = None, + limit: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> AsyncIterator[MarginOrder]: + """Auto-paginate all margin orders. Returns an async iterator — use ``async for``.""" + self._require_auth() + _validate_max_pages(max_pages) + params = _list_orders_params( + ticker=ticker, + status=status, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=None, + subaccount=subaccount, + ) + return self._list_all( + "/margin/orders", + MarginOrder, + "orders", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) + + async def cancel( + self, + order_id: str, + *, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> CancelMarginOrderResponse: + """Cancel a margin order. Not retried.""" + self._require_auth() + params = _params(subaccount=subaccount) + data = await self._delete( + f"/margin/orders/{_seg(order_id, name='order_id')}", + params=params, + extra_headers=extra_headers, + ) + if data is None: + raise KalshiError("Expected CancelMarginOrderResponse body, got 204 No Content.") + return CancelMarginOrderResponse.model_validate(data) + + @overload + async def decrease( + self, + order_id: str, + *, + request: DecreaseMarginOrderRequest, + subaccount: int | None = ..., + extra_headers: dict[str, str] | None = None, + ) -> DecreaseMarginOrderResponse: ... + @overload + async def decrease( + self, + order_id: str, + *, + reduce_by: int | float | str | None = ..., + reduce_to: int | float | str | None = ..., + subaccount: int | None = ..., + extra_headers: dict[str, str] | None = None, + ) -> DecreaseMarginOrderResponse: ... + async def decrease( + self, + order_id: str, + *, + request: DecreaseMarginOrderRequest | None = None, + reduce_by: int | float | str | None = None, + reduce_to: int | float | str | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> DecreaseMarginOrderResponse: + """Decrease a margin order's size. See :meth:`MarginOrdersResource.decrease`.""" + self._require_auth() + body = _build_decrease_body(request, reduce_by=reduce_by, reduce_to=reduce_to) + params = _params(subaccount=subaccount) + data = await self._post( + f"/margin/orders/{_seg(order_id, name='order_id')}/decrease", + params=params, + json=body, + extra_headers=extra_headers, + ) + return DecreaseMarginOrderResponse.model_validate(data) + + @overload + async def amend( + self, + order_id: str, + *, + request: AmendMarginOrderRequest, + subaccount: int | None = ..., + extra_headers: dict[str, str] | None = None, + ) -> AmendMarginOrderResponse: ... + @overload + async def amend( + self, + order_id: str, + *, + ticker: str, + side: BookSideLiteral, + price: int | float | str, + count: int | float | str, + client_order_id: str | None = ..., + updated_client_order_id: str | None = ..., + subaccount: int | None = ..., + extra_headers: dict[str, str] | None = None, + ) -> AmendMarginOrderResponse: ... + async def amend( + self, + order_id: str, + *, + request: AmendMarginOrderRequest | None = None, + ticker: str | None = None, + side: BookSideLiteral | None = None, + price: int | float | str | None = None, + count: int | float | str | None = None, + client_order_id: str | None = None, + updated_client_order_id: str | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> AmendMarginOrderResponse: + """Amend a margin order's price/size. See :meth:`MarginOrdersResource.amend`.""" + self._require_auth() + body = _build_amend_body( + request, + ticker=ticker, + side=side, + price=price, + count=count, + client_order_id=client_order_id, + updated_client_order_id=updated_client_order_id, + ) + params = _params(subaccount=subaccount) + data = await self._post( + f"/margin/orders/{_seg(order_id, name='order_id')}/amend", + params=params, + json=body, + extra_headers=extra_headers, + ) + return AmendMarginOrderResponse.model_validate(data) + + async def list_fcm( + self, + *, + subtrader_id: str, + ticker: str | None = None, + status: str | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + cursor: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetMarginOrdersResponse: + """List FCM subtrader orders, single page. See :meth:`MarginOrdersResource.list_fcm`.""" + self._require_auth() + params = _list_fcm_params( + subtrader_id=subtrader_id, + ticker=ticker, + status=status, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=cursor, + ) + data = await self._get("/margin/fcm/orders", params=params, extra_headers=extra_headers) + return GetMarginOrdersResponse.model_validate(data) + + def list_all_fcm( + self, + *, + subtrader_id: str, + ticker: str | None = None, + status: str | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> AsyncIterator[MarginOrder]: + """Auto-paginate FCM subtrader orders. Returns an async iterator — use ``async for``.""" + self._require_auth() + _validate_max_pages(max_pages) + params = _list_fcm_params( + subtrader_id=subtrader_id, + ticker=ticker, + status=status, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=None, + ) + return self._list_all( + "/margin/fcm/orders", + MarginOrder, + "orders", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) diff --git a/kalshi/perps/resources/portfolio.py b/kalshi/perps/resources/portfolio.py new file mode 100644 index 0000000..bfdc2d9 --- /dev/null +++ b/kalshi/perps/resources/portfolio.py @@ -0,0 +1,306 @@ +"""Perps portfolio resource — positions, fills, trades (#393). + +Three margin read endpoints on the standalone perps client: + +- ``positions`` — ``GET /margin/positions``: auth required, single + non-paginated array (no ``_all()``). +- ``fills`` / ``fills_all`` — ``GET /margin/fills``: auth required, + cursor-paginated (the authenticated user's margin fills). +- ``trades`` / ``trades_all`` — ``GET /margin/trades``: **public** (no spec + ``security`` block, tag ``market``) — like ``MarketsResource.list_trades`` it + does **not** call ``_require_auth()``. ``ticker`` is required. Cursor-paginated. + +All three are GET, so they reuse the transport's retry/error machinery +unchanged (retry on 429/502/503/504). +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterator +from typing import Any + +from kalshi.models.common import Page +from kalshi.perps.models.portfolio import ( + GetMarginPositionsResponse, + MarginFill, + MarginTrade, +) +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _params, + _validate_limit, + _validate_max_pages, +) + + +def _margin_fills_params( + *, + subaccount: int | None, + min_ts: int | None, + max_ts: int | None, + limit: int | None, + cursor: str | None, +) -> dict[str, Any]: + """Build query params for ``GET /margin/fills`` (shared by sync/async).""" + limit = _validate_limit(limit, hi=1000) + return _params( + subaccount=subaccount, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=cursor, + ) + + +def _margin_trades_params( + *, + ticker: str, + min_ts: int | None, + max_ts: int | None, + limit: int | None, + cursor: str | None, +) -> dict[str, Any]: + """Build query params for ``GET /margin/trades`` (``ticker`` is required).""" + limit = _validate_limit(limit, hi=1000) + return _params( + ticker=ticker, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=cursor, + ) + + +class PerpsPortfolioResource(SyncResource): + """Sync perps portfolio API (``positions`` / ``fills`` / ``trades`` + paginators).""" + + def positions( + self, + *, + subaccount: int | None = None, + ticker: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetMarginPositionsResponse: + self._require_auth() + params = _params(subaccount=subaccount, ticker=ticker) + data = self._get("/margin/positions", params=params, extra_headers=extra_headers) + return GetMarginPositionsResponse.model_validate(data) + + def fills( + self, + *, + subaccount: int | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + cursor: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Page[MarginFill]: + self._require_auth() + params = _margin_fills_params( + subaccount=subaccount, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=cursor, + ) + return self._list( + "/margin/fills", MarginFill, "fills", params=params, extra_headers=extra_headers + ) + + def fills_all( + self, + *, + subaccount: int | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Iterator[MarginFill]: + """Auto-paginate the authenticated user's margin fills, yielding each ``MarginFill``.""" + self._require_auth() + _validate_max_pages(max_pages) + params = _margin_fills_params( + subaccount=subaccount, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=None, + ) + return self._list_all( + "/margin/fills", + MarginFill, + "fills", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) + + def trades( + self, + *, + ticker: str, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + cursor: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Page[MarginTrade]: + """Public margin trades for ``ticker`` (no auth required).""" + params = _margin_trades_params( + ticker=ticker, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=cursor, + ) + return self._list( + "/margin/trades", MarginTrade, "trades", params=params, extra_headers=extra_headers + ) + + def trades_all( + self, + *, + ticker: str, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Iterator[MarginTrade]: + """Auto-paginate public margin trades for ``ticker`` (no auth required).""" + _validate_max_pages(max_pages) + params = _margin_trades_params( + ticker=ticker, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=None, + ) + return self._list_all( + "/margin/trades", + MarginTrade, + "trades", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) + + +class AsyncPerpsPortfolioResource(AsyncResource): + """Async perps portfolio API (``positions`` / ``fills`` / ``trades`` + paginators).""" + + async def positions( + self, + *, + subaccount: int | None = None, + ticker: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetMarginPositionsResponse: + self._require_auth() + params = _params(subaccount=subaccount, ticker=ticker) + data = await self._get("/margin/positions", params=params, extra_headers=extra_headers) + return GetMarginPositionsResponse.model_validate(data) + + async def fills( + self, + *, + subaccount: int | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + cursor: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Page[MarginFill]: + self._require_auth() + params = _margin_fills_params( + subaccount=subaccount, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=cursor, + ) + return await self._list( + "/margin/fills", MarginFill, "fills", params=params, extra_headers=extra_headers + ) + + def fills_all( + self, + *, + subaccount: int | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> AsyncIterator[MarginFill]: + """Returns an async iterator over the user's margin fills — use ``async for``.""" + self._require_auth() + _validate_max_pages(max_pages) + params = _margin_fills_params( + subaccount=subaccount, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=None, + ) + return self._list_all( + "/margin/fills", + MarginFill, + "fills", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) + + async def trades( + self, + *, + ticker: str, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + cursor: str | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Page[MarginTrade]: + """Public margin trades for ``ticker`` (no auth required).""" + params = _margin_trades_params( + ticker=ticker, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=cursor, + ) + return await self._list( + "/margin/trades", MarginTrade, "trades", params=params, extra_headers=extra_headers + ) + + def trades_all( + self, + *, + ticker: str, + min_ts: int | None = None, + max_ts: int | None = None, + limit: int | None = None, + max_pages: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> AsyncIterator[MarginTrade]: + """Returns an async iterator over public margin trades — use ``async for`` (no auth).""" + _validate_max_pages(max_pages) + params = _margin_trades_params( + ticker=ticker, + min_ts=min_ts, + max_ts=max_ts, + limit=limit, + cursor=None, + ) + return self._list_all( + "/margin/trades", + MarginTrade, + "trades", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) diff --git a/kalshi/perps/resources/transfers.py b/kalshi/perps/resources/transfers.py new file mode 100644 index 0000000..6a2206c --- /dev/null +++ b/kalshi/perps/resources/transfers.py @@ -0,0 +1,331 @@ +"""Perps transfers & subaccounts resource (#396). + +Three authenticated POST endpoints on the perps ``/portfolio/*`` host: + +- ``transfer_instance`` — ``POST /portfolio/intra_exchange_instance_transfer`` + (move funds between the ``event_contract`` and ``margined`` instances). The + 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, + numbered sequentially from 1. +- ``transfer_subaccount`` — ``POST /portfolio/margin/subaccounts/transfer`` + (move funds between subaccounts ``0``-``32``; 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`` +instead of a server 401. POST is never retried. +""" + +from __future__ import annotations + +from typing import Any, overload +from uuid import UUID + +from kalshi.perps.models.transfers import ( + ApplySubaccountTransferRequest, + CreateSubaccountResponse, + ExchangeInstanceLiteral, + IntraExchangeInstanceTransferRequest, + IntraExchangeInstanceTransferResponse, +) +from kalshi.resources._base import ( + AsyncResource, + SyncResource, + _check_request_exclusive, +) + +# Shared module-level body builders (mirror the event-contract `_build_*_body`). + + +def _build_instance_transfer_body( + request: IntraExchangeInstanceTransferRequest | None, + *, + source: ExchangeInstanceLiteral | None, + destination: ExchangeInstanceLiteral | None, + amount: int | None, + source_exchange_shard: int | None, + destination_exchange_shard: int | None, +) -> dict[str, Any]: + # Shards are part of the kwargs path — included in the exclusivity check so + # `request=...` together with an explicit shard raises instead of silently + # dropping the shard. They default to 0 only when building from kwargs. + _check_request_exclusive( + request, + source=source, + destination=destination, + amount=amount, + source_exchange_shard=source_exchange_shard, + destination_exchange_shard=destination_exchange_shard, + ) + if request is None: + if source is None or destination is None or amount is None: + raise TypeError( + "transfer_instance() requires `source`, `destination`, and " + "`amount` (or pass `request=...`)" + ) + request = IntraExchangeInstanceTransferRequest( + source=source, + destination=destination, + amount=amount, + source_exchange_shard=source_exchange_shard or 0, + destination_exchange_shard=destination_exchange_shard or 0, + ) + return request.model_dump(exclude_none=True, by_alias=True, mode="json") + + +def _build_subaccount_transfer_body( + request: ApplySubaccountTransferRequest | None, + *, + client_transfer_id: UUID | str | None, + from_subaccount: int | None, + to_subaccount: int | None, + amount_cents: int | None, +) -> dict[str, Any]: + _check_request_exclusive( + request, + client_transfer_id=client_transfer_id, + from_subaccount=from_subaccount, + to_subaccount=to_subaccount, + amount_cents=amount_cents, + ) + if request is None: + if ( + client_transfer_id is None + or from_subaccount is None + or to_subaccount is None + or amount_cents is None + ): + raise TypeError( + "transfer_subaccount() requires `client_transfer_id`, " + "`from_subaccount`, `to_subaccount`, and `amount_cents` " + "(or pass `request=...`)" + ) + # Accept str for caller ergonomics; coerce once to surface a clean + # ValueError on malformed strings before the model validator sees them. + uid = ( + client_transfer_id if isinstance(client_transfer_id, UUID) else UUID(client_transfer_id) + ) + request = ApplySubaccountTransferRequest( + client_transfer_id=uid, + from_subaccount=from_subaccount, + to_subaccount=to_subaccount, + amount_cents=amount_cents, + ) + return request.model_dump(exclude_none=True, by_alias=True, mode="json") + + +class TransfersResource(SyncResource): + """Sync perps transfers & subaccounts API.""" + + @overload + def transfer_instance( + self, + *, + request: IntraExchangeInstanceTransferRequest, + extra_headers: dict[str, str] | None = None, + ) -> IntraExchangeInstanceTransferResponse: ... + @overload + def transfer_instance( + self, + *, + source: ExchangeInstanceLiteral, + destination: ExchangeInstanceLiteral, + amount: int, + source_exchange_shard: int = 0, + destination_exchange_shard: int = 0, + extra_headers: dict[str, str] | None = None, + ) -> IntraExchangeInstanceTransferResponse: ... + def transfer_instance( + self, + *, + request: IntraExchangeInstanceTransferRequest | None = None, + source: ExchangeInstanceLiteral | None = None, + destination: ExchangeInstanceLiteral | None = None, + amount: int | None = None, + source_exchange_shard: int | None = None, + destination_exchange_shard: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> IntraExchangeInstanceTransferResponse: + """Move funds between exchange instances. + + Spec marks this endpoint "currently not available" — it is implemented + forward-compatibly and may return an error until the server enables it. + """ + self._require_auth() + body = _build_instance_transfer_body( + request, + source=source, + destination=destination, + amount=amount, + source_exchange_shard=source_exchange_shard, + destination_exchange_shard=destination_exchange_shard, + ) + data = self._post( + "/portfolio/intra_exchange_instance_transfer", + json=body, + extra_headers=extra_headers, + ) + return IntraExchangeInstanceTransferResponse.model_validate(data) + + def create_subaccount( + self, *, extra_headers: dict[str, str] | None = None + ) -> CreateSubaccountResponse: + """Create a new margin subaccount (returns its sequential number).""" + self._require_auth() + # Spec defines no requestBody, but httpx omits Content-Type when no body + # is passed and demo rejects the POST with `invalid_content_type`. + # json={} forces Content-Type: application/json — same workaround as the + # event-contract SubaccountsResource.create. Response is HTTP 201. + data = self._post("/portfolio/margin/subaccounts", json={}, extra_headers=extra_headers) + return CreateSubaccountResponse.model_validate(data) + + @overload + def transfer_subaccount( + self, + *, + request: ApplySubaccountTransferRequest, + extra_headers: dict[str, str] | None = None, + ) -> None: ... + @overload + def transfer_subaccount( + self, + *, + client_transfer_id: UUID | str, + from_subaccount: int, + to_subaccount: int, + amount_cents: int, + extra_headers: dict[str, str] | None = None, + ) -> None: ... + def transfer_subaccount( + self, + *, + request: ApplySubaccountTransferRequest | None = None, + client_transfer_id: UUID | str | None = None, + from_subaccount: int | None = None, + to_subaccount: int | None = None, + amount_cents: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """Move funds between margin subaccounts (returns ``None``).""" + self._require_auth() + body = _build_subaccount_transfer_body( + request, + client_transfer_id=client_transfer_id, + from_subaccount=from_subaccount, + to_subaccount=to_subaccount, + amount_cents=amount_cents, + ) + self._post( + "/portfolio/margin/subaccounts/transfer", + json=body, + extra_headers=extra_headers, + ) + + +class AsyncTransfersResource(AsyncResource): + """Async perps transfers & subaccounts API.""" + + @overload + async def transfer_instance( + self, + *, + request: IntraExchangeInstanceTransferRequest, + extra_headers: dict[str, str] | None = None, + ) -> IntraExchangeInstanceTransferResponse: ... + @overload + async def transfer_instance( + self, + *, + source: ExchangeInstanceLiteral, + destination: ExchangeInstanceLiteral, + amount: int, + source_exchange_shard: int = 0, + destination_exchange_shard: int = 0, + extra_headers: dict[str, str] | None = None, + ) -> IntraExchangeInstanceTransferResponse: ... + async def transfer_instance( + self, + *, + request: IntraExchangeInstanceTransferRequest | None = None, + source: ExchangeInstanceLiteral | None = None, + destination: ExchangeInstanceLiteral | None = None, + amount: int | None = None, + source_exchange_shard: int | None = None, + destination_exchange_shard: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> IntraExchangeInstanceTransferResponse: + """Move funds between exchange instances. + + Spec marks this endpoint "currently not available" — it is implemented + forward-compatibly and may return an error until the server enables it. + """ + self._require_auth() + body = _build_instance_transfer_body( + request, + source=source, + destination=destination, + amount=amount, + source_exchange_shard=source_exchange_shard, + destination_exchange_shard=destination_exchange_shard, + ) + data = await self._post( + "/portfolio/intra_exchange_instance_transfer", + json=body, + extra_headers=extra_headers, + ) + return IntraExchangeInstanceTransferResponse.model_validate(data) + + async def create_subaccount( + self, *, extra_headers: dict[str, str] | None = None + ) -> CreateSubaccountResponse: + """Create a new margin subaccount (returns its sequential number).""" + self._require_auth() + # json={} forces Content-Type: application/json — demo rejects the + # bodyless POST with `invalid_content_type`. Response is HTTP 201. + data = await self._post( + "/portfolio/margin/subaccounts", json={}, extra_headers=extra_headers + ) + return CreateSubaccountResponse.model_validate(data) + + @overload + async def transfer_subaccount( + self, + *, + request: ApplySubaccountTransferRequest, + extra_headers: dict[str, str] | None = None, + ) -> None: ... + @overload + async def transfer_subaccount( + self, + *, + client_transfer_id: UUID | str, + from_subaccount: int, + to_subaccount: int, + amount_cents: int, + extra_headers: dict[str, str] | None = None, + ) -> None: ... + async def transfer_subaccount( + self, + *, + request: ApplySubaccountTransferRequest | None = None, + client_transfer_id: UUID | str | None = None, + from_subaccount: int | None = None, + to_subaccount: int | None = None, + amount_cents: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """Move funds between margin subaccounts (returns ``None``).""" + self._require_auth() + body = _build_subaccount_transfer_body( + request, + client_transfer_id=client_transfer_id, + from_subaccount=from_subaccount, + to_subaccount=to_subaccount, + amount_cents=amount_cents, + ) + await self._post( + "/portfolio/margin/subaccounts/transfer", + json=body, + extra_headers=extra_headers, + ) diff --git a/kalshi/perps/ws/__init__.py b/kalshi/perps/ws/__init__.py new file mode 100644 index 0000000..0a38f2c --- /dev/null +++ b/kalshi/perps/ws/__init__.py @@ -0,0 +1,19 @@ +"""Kalshi Perps (margin) WebSocket client. + +The transport foundation (#397): connection, subscription/command framing, +dispatch, sequence-gap and orderbook reuse, and the :class:`PerpsWebSocket` +facade. Per-channel payload models and typed ``subscribe_*`` helpers land in the +``perps: WS channels`` issue (#398). +""" + +from kalshi.perps.ws.backpressure import MessageQueue, OverflowStrategy +from kalshi.perps.ws.client import PerpsWebSocket +from kalshi.perps.ws.connection import ConnectionState, PerpsConnectionManager + +__all__ = [ + "ConnectionState", + "MessageQueue", + "OverflowStrategy", + "PerpsConnectionManager", + "PerpsWebSocket", +] diff --git a/kalshi/perps/ws/backpressure.py b/kalshi/perps/ws/backpressure.py new file mode 100644 index 0000000..4db25d9 --- /dev/null +++ b/kalshi/perps/ws/backpressure.py @@ -0,0 +1,14 @@ +"""Backpressure primitives for the perps WebSocket — re-exported verbatim. + +The perps WS has no perps-specific overflow behavior, so it reuses +:class:`kalshi.ws.backpressure.MessageQueue` and +:class:`~kalshi.ws.backpressure.OverflowStrategy` unchanged. This module is a +thin re-export so the perps WS package presents the same module layout as +``kalshi/ws/`` (mirror parity) without duplicating the bounded-queue logic. +""" + +from __future__ import annotations + +from kalshi.ws.backpressure import MessageQueue, OverflowStrategy + +__all__ = ["MessageQueue", "OverflowStrategy"] diff --git a/kalshi/perps/ws/channels.py b/kalshi/perps/ws/channels.py new file mode 100644 index 0000000..64e6e01 --- /dev/null +++ b/kalshi/perps/ws/channels.py @@ -0,0 +1,493 @@ +"""Perps subscription management with client-side durable IDs and sid remapping. + +Mirrors :class:`kalshi.ws.channels.SubscriptionManager` but with the **perps +margin command grammar**: ``subscribe`` (``params.channels``), ``unsubscribe`` +(``params.sids``), ``update_subscription`` in two shapes (array ``sids`` and +scalar ``sid``), and ``list_subscriptions`` (no params; array ``msg`` reply). + +Command frames are built from the ``extra="forbid"`` params/command models in +:mod:`kalshi.perps.ws.models.control` and serialized via +``model_dump(exclude_none=True, by_alias=True, mode="json")`` so a typo'd key +fails at build time rather than silently on the wire. + +The ``.list``-shadowing convention from CLAUDE.md applies: this surrounding API +has a ``list_subscriptions`` method, so list type annotations use +``builtins.list[T]`` throughout. +""" + +from __future__ import annotations + +import asyncio +import builtins +import collections +import json +import logging +from collections.abc import Callable +from typing import Any + +from websockets.exceptions import ConnectionClosed + +from kalshi.errors import KalshiConnectionError, KalshiSubscriptionError +from kalshi.perps.ws.backpressure import MessageQueue, OverflowStrategy +from kalshi.perps.ws.connection import PerpsConnectionManager +from kalshi.perps.ws.models.control import ( + ListSubscriptionsCommand, + SubscribeCommand, + SubscribeParams, + SubscriptionEntry, + UnsubscribeCommand, + UnsubscribeParams, + UpdateSubscriptionAction, + UpdateSubscriptionCommand, + UpdateSubscriptionParams, +) + +logger = logging.getLogger("kalshi.perps.ws") + +class PerpsSubscription: + """A single perps channel subscription with durable identity.""" + + def __init__( + self, + client_id: int, + channel: str, + params: dict[str, Any], + queue: MessageQueue[Any], + ) -> None: + self.client_id = client_id + self.channel = channel + self.params = params + self.queue = queue + self.server_sid: int | None = None + + def to_subscribe_params(self) -> dict[str, Any]: + """Build the validated ``params`` dict for the subscribe command. + + Forwarded keys are validated through :class:`SubscribeParams` + (``extra="forbid"``); an unknown key raises + :class:`~kalshi.errors.KalshiSubscriptionError` at submit time rather + than being silently dropped on the wire. + """ + # Validate the FULL user-supplied params (not just the known keys) so an + # unknown key trips SubscribeParams' extra="forbid" and fails fast at + # submit time rather than being silently dropped on the wire. + forward: dict[str, Any] = {"channels": [self.channel], **self.params} + try: + model = SubscribeParams.model_validate(forward) + except Exception as exc: + raise KalshiSubscriptionError( + f"Invalid subscribe params for channel {self.channel!r}: {exc}", + channel=self.channel, + op="subscribe", + ) from exc + return model.model_dump(exclude_none=True, by_alias=True, mode="json") + + +class PerpsSubscriptionManager: + """Manages perps WebSocket subscriptions with durable client-side IDs. + + Server-assigned sids change on reconnect. This manager maintains: + + - ``client_id -> PerpsSubscription`` (durable) + - ``server_sid -> client_id`` (rebuilt on reconnect) + """ + + def __init__( + self, + connection: PerpsConnectionManager, + *, + stash_maxlen: int = 1000, + json_loads: Callable[[bytes | str], Any] | None = None, + ) -> None: + self._connection = connection + self._json_loads: Callable[[bytes | str], Any] = ( + json_loads if json_loads is not None else json.loads + ) + self._subscriptions: dict[int, PerpsSubscription] = {} + self._sid_to_client: dict[int, int] = {} + self._next_client_id = 1 + self._next_msg_id = 1 + # Resubscribe-window stash (see SubscriptionManager #176): data frames + # arriving during ``resubscribe_all`` while ``_stashing`` is True are + # captured by sid for post-resubscribe replay. + self._stash: dict[int, collections.deque[str]] = {} + self._stashing: bool = False + self._stash_maxlen: int = stash_maxlen + self._stash_warned: set[int] = set() + + def _get_msg_id(self) -> int: + mid = self._next_msg_id + self._next_msg_id += 1 + return mid + + async def _wait_for_response( + self, + msg_id: int, + timeout: float = 5.0, + *, + channel: str | None = None, + client_id: int | None = None, + op: str | None = None, + ) -> dict[str, Any]: + """Read frames until we get the response matching our command id. + + Non-matching frames are stashed by sid during ``resubscribe_all`` + (``_stashing``) for replay, otherwise discarded. + + A matching frame whose ``type`` is ``error`` raises + :class:`~kalshi.errors.KalshiSubscriptionError` here so *every* command + (subscribe / unsubscribe / update / list) fails loudly on a server-side + error instead of mutating local state as if it had succeeded. + """ + deadline = asyncio.get_running_loop().time() + timeout + while True: + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise KalshiSubscriptionError( + f"Timed out waiting for response to command {msg_id}", + channel=channel, + client_id=client_id, + op=op, # type: ignore[arg-type] + ) + try: + raw = await asyncio.wait_for( + self._connection.recv(), timeout=remaining + ) + except ConnectionClosed as e: + raise KalshiConnectionError( + f"Connection closed while awaiting response to command {msg_id}" + ) from e + except TimeoutError: + raise KalshiSubscriptionError( + f"Timed out waiting for response to command {msg_id}", + channel=channel, + client_id=client_id, + op=op, # type: ignore[arg-type] + ) from None + data: dict[str, Any] = self._json_loads(raw) + if data.get("id") == msg_id: + if data.get("type") == "error": + raw_msg = data.get("msg", {}) + detail = raw_msg.get("msg") if isinstance(raw_msg, dict) else None + code = raw_msg.get("code") if isinstance(raw_msg, dict) else None + raise KalshiSubscriptionError( + str(detail or f"{op or 'command'} failed"), + error_code=code, + channel=channel, + client_id=client_id, + op=op, # type: ignore[arg-type] + ) + return data + if self._stashing: + self._maybe_stash(raw, data) + else: + logger.debug( + "Discarding non-matching frame during command: type=%s", + data.get("type"), + ) + + def _maybe_stash(self, raw: str, data: dict[str, Any]) -> None: + """Save a raw data frame to the per-sid stash, if it carries an int sid.""" + sid = data.get("sid") + if not isinstance(sid, int): + logger.debug( + "Stash mode: dropping non-matching frame with non-int sid: " + "type=%s sid=%r", + data.get("type"), sid, + ) + return + bucket = self._stash.get(sid) + if bucket is None: + bucket = collections.deque(maxlen=self._stash_maxlen) + self._stash[sid] = bucket + elif len(bucket) == self._stash_maxlen and sid not in self._stash_warned: + self._stash_warned.add(sid) + logger.warning( + "Stash for sid %d is full (%d frames); oldest frame will be " + "evicted. Resubscribe may be stalled or the channel is too " + "high-volume for the configured stash_maxlen.", + sid, self._stash_maxlen, + ) + bucket.append(raw) + + async def subscribe( + self, + channel: str, + params: dict[str, Any] | None = None, + queue: MessageQueue[Any] | None = None, + overflow: OverflowStrategy = OverflowStrategy.DROP_OLDEST, + maxsize: int = 1000, + ) -> PerpsSubscription: + """Subscribe to a channel. Returns a PerpsSubscription with a durable client_id.""" + client_id = self._next_client_id + self._next_client_id += 1 + if queue is None: + queue = MessageQueue( + maxsize=maxsize, + overflow=overflow, + channel=channel, + client_id=client_id, + ) + + sub_params = params or {} + sub = PerpsSubscription( + client_id=client_id, channel=channel, params=sub_params, queue=queue + ) + + msg_id = self._get_msg_id() + cmd = SubscribeCommand( + id=msg_id, + params=SubscribeParams.model_validate(sub.to_subscribe_params()), + ) + await self._connection.send( + cmd.model_dump(exclude_none=True, by_alias=True, mode="json") + ) + + # An error ack is raised inside _wait_for_response (centralized), so a + # returned frame here is always a success. + data = await self._wait_for_response( + msg_id, channel=channel, client_id=client_id, op="subscribe", + ) + server_sid = data.get("msg", {}).get("sid") + if server_sid is not None: + sub.server_sid = server_sid + self._sid_to_client[server_sid] = client_id + + self._subscriptions[client_id] = sub + logger.debug( + "Subscribed to %s: client_id=%d, server_sid=%s", + channel, client_id, server_sid, + ) + return sub + + async def unsubscribe(self, client_id: int) -> None: + """Unsubscribe by durable client_id. Sends ``params.sids``.""" + sub = self._subscriptions.get(client_id) + if not sub or sub.server_sid is None: + return + + msg_id = self._get_msg_id() + cmd = UnsubscribeCommand( + id=msg_id, params=UnsubscribeParams(sids=[sub.server_sid]) + ) + await self._connection.send( + cmd.model_dump(exclude_none=True, by_alias=True, mode="json") + ) + + await self._wait_for_response( + msg_id, channel=sub.channel, client_id=client_id, op="unsubscribe", + ) + await sub.queue.put_sentinel() + self._sid_to_client.pop(sub.server_sid, None) + del self._subscriptions[client_id] + logger.debug( + "Unsubscribed client_id=%d (server_sid=%d)", client_id, sub.server_sid + ) + + @staticmethod + def _apply_market_delta( + sub: PerpsSubscription, + action: str, + market_tickers: builtins.list[str] | None, + ) -> None: + """Persist an add/remove of markets onto ``sub.params``. + + Without this, a later :meth:`resubscribe_all` rebuilds the subscribe + command from the *original* ``params`` and silently reverts the update + after a reconnect (added markets vanish, removed markets return). + """ + if not market_tickers: + return + current: builtins.list[str] = list(sub.params.get("market_tickers") or []) + if action == UpdateSubscriptionAction.ADD_MARKETS: + current.extend(t for t in market_tickers if t not in current) + sub.params["market_tickers"] = current + elif action == UpdateSubscriptionAction.DELETE_MARKETS: + remove = set(market_tickers) + sub.params["market_tickers"] = [t for t in current if t not in remove] + + async def update_subscription( + self, + client_id: int, + action: str, + market_tickers: builtins.list[str] | None = None, + send_initial_snapshot: bool | None = None, + ) -> None: + """Add or remove markets from an existing subscription (array-``sids`` form). + + Builds ``updateSubscriptionCommandPayload`` with ``params.sids`` as a + single-element array (spec ``maxItems: 1``). + """ + sub = self._subscriptions.get(client_id) + if not sub or sub.server_sid is None: + raise KalshiSubscriptionError( + "Subscription not found or not active", + channel=sub.channel if sub else None, + client_id=client_id, + op="update_subscription", + ) + + msg_id = self._get_msg_id() + cmd = UpdateSubscriptionCommand( + id=msg_id, + params=UpdateSubscriptionParams( + action=UpdateSubscriptionAction(action), + sids=[sub.server_sid], + market_tickers=market_tickers, + send_initial_snapshot=send_initial_snapshot, + ), + ) + await self._connection.send( + cmd.model_dump(exclude_none=True, by_alias=True, mode="json") + ) + await self._wait_for_response( + msg_id, channel=sub.channel, client_id=client_id, + op="update_subscription", + ) + self._apply_market_delta(sub, action, market_tickers) + logger.debug( + "Updated subscription (sids) client_id=%d action=%s", client_id, action + ) + + async def update_subscription_single_sid( + self, + client_id: int, + action: str, + market_tickers: builtins.list[str] | None = None, + send_initial_snapshot: bool | None = None, + ) -> None: + """Add or remove markets using the scalar-``sid`` form. + + Builds ``updateSubscriptionCommandPayload`` with ``params.sid`` (scalar) + rather than the ``params.sids`` array — the + ``updateSubscriptionSingleSidCommand`` spec variant. + """ + sub = self._subscriptions.get(client_id) + if not sub or sub.server_sid is None: + raise KalshiSubscriptionError( + "Subscription not found or not active", + channel=sub.channel if sub else None, + client_id=client_id, + op="update_subscription", + ) + + msg_id = self._get_msg_id() + cmd = UpdateSubscriptionCommand( + id=msg_id, + params=UpdateSubscriptionParams( + action=UpdateSubscriptionAction(action), + sid=sub.server_sid, + market_tickers=market_tickers, + send_initial_snapshot=send_initial_snapshot, + ), + ) + await self._connection.send( + cmd.model_dump(exclude_none=True, by_alias=True, mode="json") + ) + await self._wait_for_response( + msg_id, channel=sub.channel, client_id=client_id, + op="update_subscription", + ) + self._apply_market_delta(sub, action, market_tickers) + logger.debug( + "Updated subscription (single sid) client_id=%d action=%s", + client_id, action, + ) + + async def list_subscriptions(self) -> builtins.list[SubscriptionEntry]: + """Send ``list_subscriptions`` and parse the array ``msg`` reply. + + The reply uses ``type: "ok"`` with ``msg`` as an array of + ``{channel, sid}`` entries (disambiguated from the scalar update ack by + ``msg`` being a list). Returns the parsed entries; an empty list when + no subscriptions are active. + """ + msg_id = self._get_msg_id() + cmd = ListSubscriptionsCommand(id=msg_id) + await self._connection.send( + cmd.model_dump(exclude_none=True, by_alias=True, mode="json") + ) + data = await self._wait_for_response(msg_id, op="list_subscriptions") + raw_entries = data.get("msg") + if not isinstance(raw_entries, list): + return [] + return [SubscriptionEntry.model_validate(entry) for entry in raw_entries] + + async def resubscribe_all(self) -> None: + """Re-subscribe all active subscriptions after reconnect. + + Gets new server sids and updates the mapping; iterators/callbacks keep + working because they reference client_ids. Each resubscribe is + independent — a failed one is removed (its iterator gets a sentinel) and + the rest continue. ``_stashing`` is enabled so non-matching data frames + are captured by sid for post-resubscribe replay by the client facade. + """ + old_subs = dict(self._subscriptions) + self._sid_to_client.clear() + self._stash.clear() + self._stash_warned.clear() + + self._stashing = True + try: + for client_id, sub in old_subs.items(): + sub.server_sid = None + try: + msg_id = self._get_msg_id() + params = sub.to_subscribe_params() + if sub.channel == "orderbook_delta": + params["send_initial_snapshot"] = True + cmd = SubscribeCommand( + id=msg_id, + params=SubscribeParams.model_validate(params), + ) + await self._connection.send( + cmd.model_dump( + exclude_none=True, by_alias=True, mode="json" + ) + ) + + # An error ack raises inside _wait_for_response and is caught + # by the per-sub ``except`` below (sentinel + drop). + data = await self._wait_for_response( + msg_id, channel=sub.channel, + client_id=client_id, op="subscribe", + ) + new_sid = data.get("msg", {}).get("sid") + if new_sid is not None: + sub.server_sid = new_sid + self._sid_to_client[new_sid] = client_id + logger.debug( + "Resubscribed %s: client_id=%d, new_sid=%s", + sub.channel, client_id, new_sid, + ) + except Exception: + logger.warning( + "Resubscribe failed for client_id=%d channel=%s", + client_id, sub.channel, exc_info=True, + ) + await sub.queue.put_sentinel() + self._subscriptions.pop(client_id, None) + finally: + self._stashing = False + + def take_stash(self) -> dict[int, collections.deque[str]]: + """Return and clear the resubscribe-window stash atomically.""" + stash, self._stash = self._stash, {} + self._stash_warned.clear() + return stash + + def get_subscription_by_sid(self, server_sid: int) -> PerpsSubscription | None: + """Look up a subscription by current server sid.""" + client_id = self._sid_to_client.get(server_sid) + if client_id is None: + return None + return self._subscriptions.get(client_id) + + def get_subscription(self, client_id: int) -> PerpsSubscription | None: + """Look up a subscription by durable client id.""" + return self._subscriptions.get(client_id) + + @property + def active_subscriptions(self) -> dict[int, PerpsSubscription]: + """All active subscriptions keyed by client_id.""" + return dict(self._subscriptions) diff --git a/kalshi/perps/ws/client.py b/kalshi/perps/ws/client.py new file mode 100644 index 0000000..fe4c6cf --- /dev/null +++ b/kalshi/perps/ws/client.py @@ -0,0 +1,833 @@ +"""User-facing perps (margin) WebSocket client. + +Mirrors :class:`kalshi.ws.client.KalshiWebSocket` for the perps margin host. +Same session lifecycle (``connect()`` async context manager, ``_start`` / +``_stop`` / recv loop, ``on_state_change`` / ``on_error`` hooks, cooperative- +pause recv loop, ``_PERMANENT_CLOSE_CODES`` fast-fail, reconnect + resubscribe ++ stash replay, seq-gap resync). + +Exposes both the **generic** command surface — ``subscribe(channel, params)``, +``unsubscribe``, ``update_subscription``, ``update_subscription_single_sid``, +``list_subscriptions`` — and the per-channel typed ``subscribe_*`` convenience +helpers, with the dispatcher's ``MESSAGE_MODELS`` and the orderbook-apply seam +fully wired. + +Timestamp convention: the recv path never coerces timestamps; the channel +payloads carry epoch-ms ``int`` ``*_ms`` fields that flow through as-is. +""" + +from __future__ import annotations + +import asyncio +import builtins +import contextlib +import json +import logging +from collections.abc import AsyncIterator, Awaitable, Callable +from types import TracebackType +from typing import Any + +from pydantic import BaseModel, ValidationError +from websockets.exceptions import ConnectionClosed + +from kalshi.auth import KalshiAuth +from kalshi.errors import ( + KalshiBackpressureError, + KalshiConnectionError, + KalshiSequenceGapError, + KalshiSubscriptionError, +) +from kalshi.perps.config import PerpsConfig +from kalshi.perps.ws.backpressure import OverflowStrategy +from kalshi.perps.ws.channels import PerpsSubscriptionManager +from kalshi.perps.ws.connection import ConnectionState, PerpsConnectionManager +from kalshi.perps.ws.dispatch import PerpsMessageDispatcher +from kalshi.perps.ws.models.control import PerpsErrorResponse, SubscriptionEntry +from kalshi.perps.ws.models.fill import MarginFillMessage +from kalshi.perps.ws.models.order_group import OrderGroupUpdatesMessage +from kalshi.perps.ws.models.orderbook import ( + MarginOrderbookDeltaMessage, + MarginOrderbookSnapshotMessage, +) +from kalshi.perps.ws.models.ticker import MarginTickerMessage +from kalshi.perps.ws.models.trade import MarginTradeMessage +from kalshi.perps.ws.models.user_orders import MarginUserOrderMessage +from kalshi.perps.ws.orderbook import PerpsOrderbookManager +from kalshi.perps.ws.sequence import PerpsSequenceTracker, SequenceGap + +logger = logging.getLogger("kalshi.perps.ws") + +_StateChangeCb = Callable[[ConnectionState, ConnectionState], Awaitable[None]] + +# Close codes that signal a permanent server-side rejection — fast-fail instead +# of burning the reconnect budget. App-specific 4xxx codes (4001 = auth) plus +# the RFC 6455 protocol-violation range. +_PERMANENT_CLOSE_CODES: frozenset[int] = frozenset( + {1002, 1003, 1007, 1008, 1009, 1010} +) | frozenset(range(4000, 5000)) + +# Cooperative-pause polling interval for the recv loop. +_RECV_POLL_S: float = 0.05 + +# Message types that ride the perps margin order book and carry ``seq``. This +# set lets the recv loop gate orderbook-apply + stale-sid checks by wire type. +_ORDERBOOK_TYPES = ("orderbook_snapshot", "orderbook_delta") + + +class PerpsWebSocket: + """WebSocket client for real-time Kalshi perps (margin) data. + + Usage:: + + ws = PerpsWebSocket(auth=auth, config=PerpsConfig.demo()) + async with ws.connect() as session: + stream = await session.subscribe("ticker", params={"market_tickers": ["BTC-PERP"]}) + async for frame in stream: + ... + """ + + def __init__( + self, + auth: KalshiAuth, + config: PerpsConfig, + heartbeat_timeout: float = 30.0, + on_state_change: _StateChangeCb | None = None, + on_error: Callable[[PerpsErrorResponse], Awaitable[None]] | None = None, + ) -> None: + self._auth = auth + self._config = config + self._heartbeat_timeout = heartbeat_timeout + self._on_state_change = on_state_change + self._on_error = on_error + + self._connection: PerpsConnectionManager | None = None + self._sub_mgr: PerpsSubscriptionManager | None = None + self._dispatcher: PerpsMessageDispatcher | None = None + self._seq_tracker: PerpsSequenceTracker | None = None + self._orderbook_mgr: PerpsOrderbookManager | None = None + self._recv_task: asyncio.Task[None] | None = None + self._running = False + self._subscribe_lock = asyncio.Lock() + # Cooperative recv-loop pause handshake. ``_pause_pending`` is set by a + # command path that needs exclusive ``recv()`` access; the recv loop + # observes it at the top of its loop, parks, and sets ``_pause_granted``. + # ``_resume_signal`` releases it; ``_resume_ack`` is set by the recv + # loop once it has actually left the parked state, so a back-to-back + # second command cannot slip a pause request through the resume window + # and end up with two concurrent ``recv()`` calls (websockets raises + # ConcurrencyError on overlapping recv). All command paths are + # serialized by ``_subscribe_lock`` anyway; the ack closes the + # intra-handshake gap. + self._pause_pending = False + self._pause_granted = asyncio.Event() + self._resume_signal = asyncio.Event() + self._resume_ack = asyncio.Event() + self._json_loads: Callable[[bytes | str], Any] = ( + config.ws_json_loads if config.ws_json_loads is not None else json.loads + ) + + def connect(self) -> _PerpsWebSocketSession: + """Return an async context manager for the WebSocket session.""" + return _PerpsWebSocketSession(self) + + async def _start(self) -> None: + """Connect and initialize managers. Does NOT start recv_loop yet.""" + if self._connection is not None or self._running: + raise RuntimeError( + "PerpsWebSocket session is already started. Each instance " + "supports one active session at a time — await the existing " + "`async with ws.connect()` to exit, or create a fresh " + "PerpsWebSocket() for a new session." + ) + try: + self._connection = PerpsConnectionManager( + auth=self._auth, + config=self._config, + heartbeat_timeout=self._heartbeat_timeout, + on_state_change=self._on_state_change, + ) + await self._connection.connect() + + self._sub_mgr = PerpsSubscriptionManager( + self._connection, json_loads=self._json_loads + ) + self._seq_tracker = PerpsSequenceTracker(on_gap=self._handle_seq_gap) + self._orderbook_mgr = PerpsOrderbookManager() + self._dispatcher = PerpsMessageDispatcher( + sub_mgr=self._sub_mgr, + on_error=self._on_error, + seq_tracker=self._seq_tracker, + orderbook_mgr=self._orderbook_mgr, + ) + self._running = True + except BaseException: + if self._connection is not None: + with contextlib.suppress(Exception): + await self._connection.close() + self._clear_session_state() + self._running = False + raise + + async def _stop(self) -> None: + """Stop the receive loop and close the connection (close-then-drain).""" + self._running = False + # Wake anything parked on the pause/resume handshake so teardown can't + # hang: a recv loop on ``_resume_signal.wait()`` exits via ``_running``, + # and a command awaiting ``_resume_ack`` is released. + self._resume_signal.set() + self._resume_ack.set() + self._pause_granted.set() + + try: + if self._connection: + await self._connection.close() + except Exception: + logger.warning("_connection.close() raised during _stop", exc_info=True) + finally: + if self._recv_task and not self._recv_task.done(): + with contextlib.suppress(asyncio.CancelledError, Exception): + await asyncio.wait_for(self._recv_task, timeout=2.0) + if not self._recv_task.done(): + self._recv_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self._recv_task + elif self._recv_task is not None: + # Task already finished (e.g. the recv loop raised on a permanent + # close code). Retrieve the stored exception so asyncio doesn't + # log "Task exception was never retrieved" when it is GC'd. + with contextlib.suppress(asyncio.CancelledError, Exception): + self._recv_task.exception() + await self._broadcast_sentinels() + + self._clear_session_state() + self._pause_granted.clear() + self._resume_signal.clear() + self._resume_ack.clear() + + async def _broadcast_sentinels(self) -> None: + """Put a shutdown sentinel on every active subscription queue.""" + if self._sub_mgr is not None: + for sub in self._sub_mgr.active_subscriptions.values(): + await sub.queue.put_sentinel() + + def _clear_session_state(self) -> None: + """Nil the per-session managers + recv task. + + Shared by ``_start`` (failure), ``_stop``, and the recv-loop fatal-error + teardown so they cannot diverge — the fatal path previously left + ``_recv_task`` dangling. Callers own ``_running`` and the pause/resume + event set/clear (which differs by path: ``_stop`` clears them, the + fatal path sets them to wake parked waiters). + """ + self._connection = None + self._sub_mgr = None + self._seq_tracker = None + self._orderbook_mgr = None + self._dispatcher = None + self._recv_task = None + self._pause_pending = False + + async def _ensure_recv_loop(self) -> None: + """Start the recv_loop background task or resume a parked one. + + On resume, awaits ``_resume_ack`` so the recv loop has actually left + the parked checkpoint (and is reading again) before returning. Without + the ack, a back-to-back second command could set ``_pause_pending`` + during the resume window and then call ``recv()`` while the recv loop + is also about to call ``recv()`` — websockets raises ConcurrencyError on + overlapping recv. The ack closes that gap. + """ + if self._pause_pending: + self._pause_pending = False + self._pause_granted.clear() + self._resume_ack.clear() + self._resume_signal.set() + # Wait for the recv loop to confirm it resumed (or that the session + # tore down, in which case the loop is gone and won't ack). + if self._recv_task is not None and not self._recv_task.done(): + await self._resume_ack.wait() + return + if self._recv_task is None or self._recv_task.done(): + self._recv_task = asyncio.create_task(self._recv_loop()) + + async def _pause_recv_loop(self) -> None: + """Cooperatively park the recv_loop so a command can call recv.""" + if self._recv_task is None or self._recv_task.done(): + return + self._pause_granted.clear() + self._resume_signal.clear() + self._pause_pending = True + await self._pause_granted.wait() + + async def _recv_loop(self) -> None: + """Background task: read frames, dispatch, handle reconnect.""" + assert self._connection is not None + assert self._dispatcher is not None + + while self._running: + if self._pause_pending: + self._pause_granted.set() + await self._resume_signal.wait() + self._resume_signal.clear() + # Acknowledge resume so a waiting ``_ensure_recv_loop`` knows the + # checkpoint is cleared before it returns to the command path. + self._resume_ack.set() + if not self._running: + break + continue + + try: + async with asyncio.timeout(_RECV_POLL_S): + raw = await self._connection.recv() + except TimeoutError: + continue + except asyncio.CancelledError: + break + except ConnectionClosed as e: + code = None + rcvd = getattr(e, "rcvd", None) + sent = getattr(e, "sent", None) + if rcvd is not None: + code = rcvd.code + if code is None and sent is not None: + code = sent.code + if code in _PERMANENT_CLOSE_CODES: + reason = (rcvd.reason if rcvd is not None else "") or "" + logger.error( + "Perps WS closed with permanent code %d (%s); not reconnecting", + code, reason or "", + ) + await self._broadcast_sentinels() + raise KalshiConnectionError( + f"WebSocket closed with permanent code {code}: {reason}" + ) from e + if not self._running: + break + await self._handle_reconnect() + if not self._running: + break + continue + + try: + await self._process_frame(raw) + except asyncio.CancelledError: + break + except (KalshiBackpressureError, KalshiSubscriptionError): + logger.error( + "Fatal perps WS error in recv loop; tearing down session", + exc_info=True, + ) + await self._broadcast_sentinels() + self._running = False + if self._connection is not None: + with contextlib.suppress(Exception): + await self._connection.close() + self._clear_session_state() + self._pause_granted.set() + self._resume_signal.set() + self._resume_ack.set() + break + except (json.JSONDecodeError, ValidationError, KeyError): + logger.warning("Skipping malformed perps WS frame", exc_info=True) + continue + except Exception: + logger.error( + "Unexpected error in perps recv loop; broadcasting sentinels", + exc_info=True, + ) + await self._broadcast_sentinels() + raise + + async def _process_frame(self, raw: str) -> None: + """Parse, track seq, (apply orderbook — #398), and dispatch a single frame. + + Seq tracking is provisional w.r.t. downstream dispatch: on a + ``KalshiBackpressureError`` the watermark is rolled back so the dropped + seq surfaces as a future gap rather than being treated as already-seen. + + The orderbook-apply seam validates ``orderbook_snapshot`` / + ``orderbook_delta`` into their concrete payload models and applies them + to the local book before dispatch; the stale-sid gate and seq tracking + below run for orderbook frames. + """ + assert self._dispatcher is not None + data = self._json_loads(raw) + sid = data.get("sid") + seq = data.get("seq") + msg_type = data.get("type", "") + + # Stale orderbook frames arriving after teardown must not be processed. + if ( + msg_type in _ORDERBOOK_TYPES + and isinstance(sid, int) + and self._sub_mgr is not None + and self._sub_mgr.get_subscription_by_sid(sid) is None + ): + logger.debug( + "Dropping stale %s for unmapped sid=%d (post-teardown race)", + msg_type, sid, + ) + return + + prev_seq: int | None = None + tracked = False + if sid is not None and seq is not None and self._seq_tracker: + channel = "" + sub = ( + self._sub_mgr.get_subscription_by_sid(sid) + if self._sub_mgr + else None + ) + if sub: + channel = sub.channel + prev_seq = self._seq_tracker.peek(sid) + ok, gap = self._seq_tracker.track_sync( + sid, seq, msg_type if msg_type else channel + ) + if gap is not None: + await self._seq_tracker.fire_gap(gap) + if not ok: + return + tracked = True + + # Pre-validate orderbook frames so the manager applies typed data, then + # hand the same instance to the dispatcher (no double Pydantic). Both + # apply + dispatch roll the seq watermark back on failure so a malformed + # frame on a sequenced channel doesn't silently advance the watermark + # and mask the next legitimate gap (mirrors the equities path, #241). + pre_validated: BaseModel | None = None + try: + if msg_type == "orderbook_snapshot" and self._orderbook_mgr: + snapshot = MarginOrderbookSnapshotMessage.model_validate(data) + self._orderbook_mgr._apply_snapshot_inplace( + snapshot, sid=snapshot.sid + ) + pre_validated = snapshot + elif msg_type == "orderbook_delta" and self._orderbook_mgr: + delta = MarginOrderbookDeltaMessage.model_validate(data) + self._orderbook_mgr._apply_delta_inplace(delta) + pre_validated = delta + + await self._dispatcher.dispatch(data, pre_validated=pre_validated) + except KalshiBackpressureError as exc: + if tracked and sid is not None and self._seq_tracker: + self._seq_tracker.rollback(sid, prev_seq) + if sid is not None and self._sub_mgr is not None: + affected = self._sub_mgr.get_subscription_by_sid(sid) + if affected is not None: + await self._broadcast_error(affected.client_id, exc) + raise + except Exception: + if tracked and sid is not None and self._seq_tracker: + self._seq_tracker.rollback(sid, prev_seq) + raise + + async def _broadcast_error(self, client_id: int, exc: BaseException) -> None: + """Surface ``exc`` to a subscription's iterator via an error sentinel.""" + if self._sub_mgr is None: + return + sub = self._sub_mgr.get_subscription(client_id) + if sub is None: + return + if ( + isinstance(exc, KalshiBackpressureError) + and exc.sid is None + and sub.server_sid is not None + ): + exc.sid = sub.server_sid + await sub.queue.put_error(exc) + + async def _handle_reconnect(self) -> None: + """Reconnect after ConnectionClosed and re-establish subscriptions.""" + assert self._connection is not None + logger.warning("Perps connection lost, attempting reconnect...") + async with self._subscribe_lock: + try: + await self._connection.reconnect() + if self._sub_mgr: + if self._seq_tracker: + self._seq_tracker.reset_all() + if self._orderbook_mgr: + self._orderbook_mgr.clear() + await self._sub_mgr.resubscribe_all() + await self._drain_resubscribe_stash() + await self._connection.mark_streaming() + except Exception as reconnect_err: + logger.error( + "Perps reconnect failed: %s", reconnect_err, exc_info=True + ) + await self._broadcast_sentinels() + self._running = False + + async def _drain_resubscribe_stash(self) -> None: + """Replay frames captured during a resubscribe through dispatch.""" + if self._sub_mgr is None: + return + stash = self._sub_mgr.take_stash() + for sid, raw_frames in stash.items(): + if self._sub_mgr.get_subscription_by_sid(sid) is None: + logger.debug( + "Dropping %d stashed frames for unmapped sid %d after resubscribe", + len(raw_frames), sid, + ) + continue + for raw in raw_frames: + try: + await self._process_frame(raw) + except Exception: + logger.warning( + "Failed to replay stashed perps frame for sid %d", + sid, exc_info=True, + ) + + async def _handle_seq_gap(self, gap: SequenceGap) -> None: + """Handle a sequence gap/reset by tearing down per-sid state. + + Clears the per-sid orderbook books and seq watermark and surfaces a + :class:`~kalshi.errors.KalshiSequenceGapError` to the affected + consumer. (The single-sub gap-recovery resubscribe lands with #398's + ``resubscribe_one`` once channel payloads exist; the foundation issue + guarantees the consumer is signalled rather than silently desynced.) + """ + logger.warning( + "Perps sequence %s on sid %d: expected %d, got %d. Triggering resync.", + gap.kind, gap.sid, gap.expected, gap.received, + ) + if self._sub_mgr is None: + return + sub = self._sub_mgr.get_subscription_by_sid(gap.sid) + if sub is None: + return + + if self._orderbook_mgr is not None: + cleared = self._orderbook_mgr.remove_by_sid(gap.sid) + if cleared: + logger.warning( + "Cleared %d margin orderbook entries for sid %d on %s", + len(cleared), gap.sid, gap.kind, + ) + if self._seq_tracker is not None: + self._seq_tracker.reset(gap.sid) + + await self._broadcast_error( + sub.client_id, + KalshiSequenceGapError( + f"Sequence {gap.kind} on {sub.channel}; local state cleared. " + "Resubscribe to resume.", + channel=sub.channel, + sid=gap.sid, + client_id=sub.client_id, + last_seq=gap.expected - 1, + next_seq=gap.received, + ), + ) + + # ------------------------------------------------------------------ + # Generic command surface (per-channel typed helpers are defined below) + # ------------------------------------------------------------------ + + async def _do_subscribe( + self, + channel: str, + params: dict[str, Any] | None = None, + overflow: OverflowStrategy = OverflowStrategy.DROP_OLDEST, + maxsize: int = 1000, + ) -> AsyncIterator[Any]: + """Pause recv_loop, subscribe, resume recv_loop, return the queue.""" + if not self._running or self._sub_mgr is None or self._connection is None: + raise RuntimeError( + "PerpsWebSocket session is not active. The recv loop tore down " + "after a fatal error (e.g. KalshiBackpressureError); exit the " + "current `async with ws.connect()` block and start a fresh " + "session before subscribing again." + ) + async with self._subscribe_lock: + await self._pause_recv_loop() + # Precondition check lives INSIDE the try so the finally always + # resumes the recv loop — an early raise here while the loop is + # parked would otherwise abandon it (permanent inbound deadlock). + try: + if ( + not self._running + or self._sub_mgr is None + or self._connection is None + ): + raise RuntimeError( + "PerpsWebSocket session is not active. The recv loop tore " + "down after a fatal error (e.g. KalshiBackpressureError); " + "exit the current `async with ws.connect()` block and start " + "a fresh session before subscribing again." + ) + sub = await self._sub_mgr.subscribe( + channel, params=params, overflow=overflow, maxsize=maxsize, + ) + finally: + await self._ensure_recv_loop() + return sub.queue + + async def subscribe( + self, + channel: str, + *, + params: dict[str, Any] | None = None, + overflow: OverflowStrategy = OverflowStrategy.DROP_OLDEST, + maxsize: int = 1000, + ) -> AsyncIterator[BaseModel]: + """Subscribe to a perps channel. Returns an async iterator of frames. + + Generic entry point: pass ``channel`` (one of :class:`PerpsChannel`) and + an optional ``params`` dict (``market_ticker`` / ``market_tickers`` / + ``send_initial_snapshot`` / ``skip_ticker_ack``). Prefer the per-channel + typed ``subscribe_*`` helpers below when one fits. + """ + return await self._do_subscribe( + channel, params=params, overflow=overflow, maxsize=maxsize, + ) + + # ------------------------------------------------------------------ + # Per-channel typed subscribe helpers (#398) + # + # The perps AsyncAPI defines exactly six data channels — all wired below. + # The prediction-API channels ``market_positions``, + # ``market_lifecycle_v2`` / ``event_fee_update``, ``multivariate``, + # ``multivariate_market_lifecycle``, and ``communications`` have NO perps + # counterpart and intentionally get NO ``subscribe_*`` helper here. + # + # ``builtins.list[str]`` is used for ``tickers`` params: the surrounding + # API has a ``list_subscriptions`` method, so the ``list`` builtin is + # shadowed by the CLAUDE.md convention applied across the perps WS layer. + # ------------------------------------------------------------------ + + async def subscribe_orderbook_delta( + self, + *, + tickers: builtins.list[str] | None = None, + maxsize: int = 1000, + ) -> AsyncIterator[ + MarginOrderbookSnapshotMessage | MarginOrderbookDeltaMessage + ]: + """Subscribe to ``orderbook_delta`` (snapshot first, then deltas). + + Forces ``send_initial_snapshot`` so the server emits an + ``orderbook_snapshot`` before incremental ``orderbook_delta`` frames. + Sequenced + gap-sensitive: overflow is ``ERROR`` (a drop here would + desync the local book rather than just lose a coalesced sample). + + Note: ``MarginOrderbookSnapshotMessage.msg.bid`` / ``.ask`` become live + dicts owned by the :class:`PerpsOrderbookManager` after dispatch — they + mutate on every subsequent delta. + """ + params: dict[str, Any] = {"send_initial_snapshot": True} + if tickers: + params["market_tickers"] = tickers + return await self._do_subscribe( + "orderbook_delta", params=params, + overflow=OverflowStrategy.ERROR, maxsize=maxsize, + ) + + async def subscribe_ticker( + self, + *, + tickers: builtins.list[str] | None = None, + send_initial_snapshot: bool = False, + maxsize: int = 1000, + ) -> AsyncIterator[MarginTickerMessage]: + """Subscribe to ``ticker`` (coalesced ≤1/market/sec). + + Carries the perps-unique ``funding_rate`` and the + reference/settlement/liquidation mark prices. Not sequence-gap + sensitive (coalesced sampling), so overflow is ``DROP_OLDEST``. + """ + params: dict[str, Any] = {} + if tickers: + params["market_tickers"] = tickers + if send_initial_snapshot: + params["send_initial_snapshot"] = True + return await self._do_subscribe( + "ticker", params=params, + overflow=OverflowStrategy.DROP_OLDEST, maxsize=maxsize, + ) + + async def subscribe_trade( + self, + *, + tickers: builtins.list[str] | None = None, + maxsize: int = 1000, + ) -> AsyncIterator[MarginTradeMessage]: + """Subscribe to ``trade`` (public executed margin trades).""" + params: dict[str, Any] = {} + if tickers: + params["market_tickers"] = tickers + return await self._do_subscribe( + "trade", params=params, + overflow=OverflowStrategy.DROP_OLDEST, maxsize=maxsize, + ) + + async def subscribe_fill( + self, + *, + tickers: builtins.list[str] | None = None, + maxsize: int = 1000, + ) -> AsyncIterator[MarginFillMessage]: + """Subscribe to ``fill`` (private authenticated-user fills).""" + params: dict[str, Any] = {} + if tickers: + params["market_tickers"] = tickers + return await self._do_subscribe( + "fill", params=params, + overflow=OverflowStrategy.DROP_OLDEST, maxsize=maxsize, + ) + + async def subscribe_user_orders( + self, + *, + tickers: builtins.list[str] | None = None, + maxsize: int = 1000, + ) -> AsyncIterator[MarginUserOrderMessage]: + """Subscribe to ``user_orders`` (private order create/update). + + The subscribe channel is ``user_orders`` but the message envelope + ``type`` is ``user_order`` (singular) — yields + :class:`MarginUserOrderMessage`. + """ + params: dict[str, Any] = {} + if tickers: + params["market_tickers"] = tickers + return await self._do_subscribe( + "user_orders", params=params, + overflow=OverflowStrategy.DROP_OLDEST, maxsize=maxsize, + ) + + async def subscribe_order_group( + self, + *, + maxsize: int = 1000, + ) -> AsyncIterator[OrderGroupUpdatesMessage]: + """Subscribe to ``order_group_updates`` (private group lifecycle/limits). + + Market spec is ignored for this channel. Sequenced + gap-sensitive, so + overflow is ``ERROR``. + """ + return await self._do_subscribe( + "order_group_updates", + overflow=OverflowStrategy.ERROR, maxsize=maxsize, + ) + + async def unsubscribe(self, client_id: int) -> None: + """Tear down a subscription and its associated local state. + + Pauses the recv loop for the duration so the ``unsubscribed`` ack is + read by the subscription manager rather than racing the recv loop's own + ``recv()`` (websockets forbids concurrent recv). + """ + if self._sub_mgr is None: + return + async with self._subscribe_lock: + await self._pause_recv_loop() + try: + if self._sub_mgr is None: + return + sub = self._sub_mgr.get_subscription(client_id) + if sub is None: + return + old_sid = sub.server_sid + if old_sid is not None: + if self._orderbook_mgr is not None: + cleared = self._orderbook_mgr.remove_by_sid(old_sid) + if cleared: + logger.debug( + "Cleared %d margin orderbook entries for sid %d " + "on unsubscribe", + len(cleared), old_sid, + ) + if self._seq_tracker is not None: + self._seq_tracker.reset(old_sid) + await self._sub_mgr.unsubscribe(client_id) + finally: + await self._ensure_recv_loop() + + async def update_subscription( + self, + client_id: int, + action: str, + *, + market_tickers: builtins.list[str] | None = None, + send_initial_snapshot: bool | None = None, + ) -> None: + """Add/remove markets on an existing subscription (array-``sids`` form). + + Pauses the recv loop so the ``ok`` ack is read by the subscription + manager rather than swallowed by the dispatcher. + """ + if not self._running or self._sub_mgr is None: + raise RuntimeError("PerpsWebSocket session is not active.") + async with self._subscribe_lock: + await self._pause_recv_loop() + try: + if not self._running or self._sub_mgr is None: + raise RuntimeError("PerpsWebSocket session is not active.") + await self._sub_mgr.update_subscription( + client_id, + action, + market_tickers=market_tickers, + send_initial_snapshot=send_initial_snapshot, + ) + finally: + await self._ensure_recv_loop() + + async def update_subscription_single_sid( + self, + client_id: int, + action: str, + *, + market_tickers: builtins.list[str] | None = None, + send_initial_snapshot: bool | None = None, + ) -> None: + """Add/remove markets using the scalar-``sid`` form.""" + if not self._running or self._sub_mgr is None: + raise RuntimeError("PerpsWebSocket session is not active.") + async with self._subscribe_lock: + await self._pause_recv_loop() + try: + if not self._running or self._sub_mgr is None: + raise RuntimeError("PerpsWebSocket session is not active.") + await self._sub_mgr.update_subscription_single_sid( + client_id, + action, + market_tickers=market_tickers, + send_initial_snapshot=send_initial_snapshot, + ) + finally: + await self._ensure_recv_loop() + + async def list_subscriptions(self) -> list[SubscriptionEntry]: + """List all active server-side subscriptions (parses the array ``msg``).""" + if not self._running or self._sub_mgr is None: + raise RuntimeError("PerpsWebSocket session is not active.") + async with self._subscribe_lock: + await self._pause_recv_loop() + try: + if not self._running or self._sub_mgr is None: + raise RuntimeError("PerpsWebSocket session is not active.") + return await self._sub_mgr.list_subscriptions() + finally: + await self._ensure_recv_loop() + + +class _PerpsWebSocketSession: + """Async context manager for a perps WebSocket session.""" + + def __init__(self, ws: PerpsWebSocket) -> None: + self._ws = ws + + async def __aenter__(self) -> PerpsWebSocket: + await self._ws._start() + return self._ws + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self._ws._stop() diff --git a/kalshi/perps/ws/connection.py b/kalshi/perps/ws/connection.py new file mode 100644 index 0000000..a533925 --- /dev/null +++ b/kalshi/perps/ws/connection.py @@ -0,0 +1,54 @@ +"""Perps (margin) WebSocket connection manager. + +Mirrors :class:`kalshi.ws.connection.ConnectionManager` for the margin WS host. +The base manager is already config-driven — it reads ``ws_base_url``, +``ws_ping_interval``, ``ws_close_timeout``, ``ws_max_retries`` and the +``retry_*`` knobs off the injected config, and signs the WS upgrade with +``GET`` + the ws path via the shared :class:`kalshi.auth.KalshiAuth`. Since +:class:`kalshi.perps.config.PerpsConfig` is a :class:`kalshi.config.KalshiConfig` +subclass carrying all of those fields (pointed at the margin host), the entire +state machine, AWS full-jitter reconnect, ``_open_socket`` / ``connect`` / +``reconnect`` / ``close`` / ``send`` / ``recv`` flow, and the path-only +(no-query-string) :class:`~kalshi.errors.KalshiConnectionError` message are +reused verbatim. + +This subclass exists only to: + +* pin the config parameter type to :class:`PerpsConfig` so callers get the + perps-specific host validation and demo/production helpers, and +* give the perps WS package the same module layout as ``kalshi/ws/`` (mirror + parity) and a clear seam if the margin handshake ever needs to diverge. + +:class:`~kalshi.ws.connection.ConnectionState` is reused as-is (re-exported here +for convenience); it is NOT redefined. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from kalshi.auth import KalshiAuth +from kalshi.perps.config import PerpsConfig +from kalshi.ws.connection import ConnectionManager, ConnectionState + +__all__ = ["ConnectionState", "PerpsConnectionManager"] + + +class PerpsConnectionManager(ConnectionManager): + """:class:`ConnectionManager` pinned to the perps margin WS host/config.""" + + def __init__( + self, + auth: KalshiAuth, + config: PerpsConfig, + heartbeat_timeout: float = 30.0, + on_state_change: ( + Callable[[ConnectionState, ConnectionState], Awaitable[None]] | None + ) = None, + ) -> None: + super().__init__( + auth=auth, + config=config, + heartbeat_timeout=heartbeat_timeout, + on_state_change=on_state_change, + ) diff --git a/kalshi/perps/ws/dispatch.py b/kalshi/perps/ws/dispatch.py new file mode 100644 index 0000000..c7187d9 --- /dev/null +++ b/kalshi/perps/ws/dispatch.py @@ -0,0 +1,285 @@ +"""Perps message dispatcher: parse raw margin frames, route to queues/callbacks. + +Mirrors :class:`kalshi.ws.dispatch.MessageDispatcher`. ``CONTROL_TYPES`` is +``{"subscribed", "unsubscribed", "ok", "error"}`` (same control surface as the +prediction API). ``MESSAGE_MODELS`` maps each data ``type`` (``ticker``, +``trade``, ``fill``, ``user_order``, ``orderbook_*``, ``order_group_updates``) +to its Pydantic payload model. The dispatcher also handles control-frame +routing (orphan-subscribed cleanup, server-initiated unsubscribe teardown) and +the "unknown message type" path. + +``type: "ok"`` collision: the perps ``list_subscriptions`` reply and the +``update_subscription`` ack BOTH use ``type: "ok"``. They are disambiguated by +the *shape of* ``msg`` — an **array** for list-subscriptions, an **object** +(or absent) for the update ack — NOT by ``type``. Both are command acks consumed +by :class:`~kalshi.perps.ws.channels.PerpsSubscriptionManager` (matched on +``id``), so they don't normally reach the dispatcher; the disambiguation helper +:meth:`classify_ok` is provided so the framing layer can branch on ``msg`` shape +wherever an ``ok`` frame must be classified. + +Timestamp note: control envelopes carry no timestamps. Channel payloads (#398) +carry epoch-ms ``int`` ``*_ms`` fields; this dispatcher routes the typed model +without touching them, so the int-typed convention is preserved. +""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from typing import Any, Literal + +from pydantic import BaseModel + +from kalshi.perps.ws.channels import PerpsSubscriptionManager +from kalshi.perps.ws.models.control import ( + PerpsErrorResponse, + UnsubscribeCommand, + UnsubscribeParams, +) +from kalshi.perps.ws.models.fill import MarginFillMessage +from kalshi.perps.ws.models.order_group import OrderGroupUpdatesMessage +from kalshi.perps.ws.models.orderbook import ( + MarginOrderbookDeltaMessage, + MarginOrderbookSnapshotMessage, +) +from kalshi.perps.ws.models.ticker import MarginTickerMessage +from kalshi.perps.ws.models.trade import MarginTradeMessage +from kalshi.perps.ws.models.user_orders import MarginUserOrderMessage +from kalshi.perps.ws.orderbook import PerpsOrderbookManager +from kalshi.perps.ws.sequence import PerpsSequenceTracker + +logger = logging.getLogger("kalshi.perps.ws") + +# Map envelope ``type`` string -> Pydantic model class (#398). Keyed by the +# wire ``type``, which differs from the subscribe channel name for the order +# book (channel ``orderbook_delta`` -> types ``orderbook_snapshot`` + +# ``orderbook_delta``) and user orders (channel ``user_orders`` -> type +# ``user_order``). +MESSAGE_MODELS: dict[str, type[BaseModel]] = { + "orderbook_snapshot": MarginOrderbookSnapshotMessage, + "orderbook_delta": MarginOrderbookDeltaMessage, + "ticker": MarginTickerMessage, + "trade": MarginTradeMessage, + "fill": MarginFillMessage, + "user_order": MarginUserOrderMessage, + "order_group_updates": OrderGroupUpdatesMessage, +} + +# Control message types (not routed to subscription queues). +CONTROL_TYPES = {"subscribed", "unsubscribed", "ok", "error"} + + +def classify_ok(data: dict[str, Any]) -> Literal["list_subscriptions", "update_ack"]: + """Disambiguate a ``type: "ok"`` frame by the shape of its ``msg``. + + The perps ``list_subscriptions`` response and the ``update_subscription`` + ack share ``type: "ok"``. The framing layer branches on ``msg`` being an + **array** (``list_subscriptions``) vs an object / absent (``update_ack``), + NOT on ``type`` — encoded here rather than excluded from drift checks. + """ + return "list_subscriptions" if isinstance(data.get("msg"), list) else "update_ack" + + +class PerpsMessageDispatcher: + """Routes parsed perps WebSocket messages to the correct subscription queue.""" + + def __init__( + self, + sub_mgr: PerpsSubscriptionManager, + on_error: Callable[[PerpsErrorResponse], Awaitable[None]] | None = None, + seq_tracker: PerpsSequenceTracker | None = None, + orderbook_mgr: PerpsOrderbookManager | None = None, + ) -> None: + self._sub_mgr = sub_mgr + self._on_error = on_error + self._seq_tracker = seq_tracker + self._orderbook_mgr = orderbook_mgr + self._callbacks: dict[str, Callable[[Any], Awaitable[None]]] = {} + # Sids the client never owned but proactively unsubscribed (orphan + # subscribe acks). When the server's ``unsubscribed`` ack for one of + # these arrives, ``_handle_server_unsubscribe`` short-circuits instead + # of running full teardown — otherwise a sid the server has already + # reused for a freshly-completed subscribe would have its seq watermark + # and orderbook state clobbered by the late orphan ack. + self._pending_orphan_unsub: set[int] = set() + + def register_callback( + self, channel: str, callback: Callable[[Any], Awaitable[None]] + ) -> None: + """Register a callback for a specific channel type. + + Messages on this channel are fanned out to BOTH the callback and any + active subscription queue for the same channel. + """ + if any( + sub.channel == channel + for sub in self._sub_mgr.active_subscriptions.values() + ): + logger.warning( + "register_callback(%r): an active subscription exists on this " + "channel; messages will be delivered to BOTH the callback and " + "the existing iterator queue.", + channel, + ) + self._callbacks[channel] = callback + + def unregister_callback(self, channel: str) -> None: + """Remove a callback for a channel type.""" + self._callbacks.pop(channel, None) + + async def _handle_server_unsubscribe(self, data: dict[str, Any]) -> None: + """Reap state for a server-initiated unsubscribe envelope. + + Client-initiated unsubscribes consume the ack inside + ``PerpsSubscriptionManager._wait_for_response``, so any unsubscribed + frame that reaches the dispatcher is server-initiated or a late ack + after teardown. Either way the sid mappings, seq tracker state, and + any held iterator must be cleaned up so they don't leak across + reconnects. + """ + sid = data.get("sid") + if sid is None: + msg_body = data.get("msg") + if isinstance(msg_body, dict): + sid = msg_body.get("sid") + if sid is None: + logger.debug("server unsubscribed envelope without sid: %s", data) + return + + # Correlate orphan-unsubscribe acks: short-circuit unconditionally so a + # reused sid's new owner isn't torn down by a late orphan ack. + if sid in self._pending_orphan_unsub: + self._pending_orphan_unsub.discard(sid) + logger.debug( + "server unsubscribed: orphan-correlation ack for sid %d", sid + ) + return + + client_id = self._sub_mgr._sid_to_client.pop(sid, None) + if self._seq_tracker is not None: + self._seq_tracker.reset(sid) + if self._orderbook_mgr is not None: + self._orderbook_mgr.remove_by_sid(sid) + + sub = ( + self._sub_mgr._subscriptions.pop(client_id, None) + if client_id is not None + else None + ) + if sub is not None: + await sub.queue.put_sentinel() + logger.info( + "Server-initiated unsubscribe: channel=%s sid=%d client_id=%d", + sub.channel, sid, client_id, + ) + elif client_id is None: + logger.debug( + "server unsubscribed for unknown sid %d (already reaped)", sid + ) + + async def _handle_orphan_subscribed(self, data: dict[str, Any]) -> None: + """Release a server-side sid whose subscribe ack has no client mapping. + + Normal subscribes consume the ``subscribed`` ack inside + ``PerpsSubscriptionManager._wait_for_response`` and install the + ``sid -> client_id`` mapping before the dispatcher sees it. If the + awaiting task is cancelled between send and ack, the ack lands here + with a sid that has no mapping; without intervention the server holds + an active sid the client can never address again. Best-effort: send an + ``unsubscribe`` for the orphan sid. + """ + sid = data.get("sid") + if sid is None: + msg_body = data.get("msg") + if isinstance(msg_body, dict): + sid = msg_body.get("sid") + if not isinstance(sid, int): + logger.debug( + "Orphan-subscribed handler: no int sid in envelope: %s", data + ) + return + if sid in self._sub_mgr._sid_to_client: + return + # Mark the sid as orphan-unsubscribed BEFORE sending so the eventual + # ``unsubscribed`` ack short-circuits in ``_handle_server_unsubscribe``. + self._pending_orphan_unsub.add(sid) + logger.warning( + "Orphan subscribed ack for sid=%d (no client mapping); " + "sending unsubscribe to release server-side state.", + sid, + ) + try: + msg_id = self._sub_mgr._get_msg_id() + # Build through the extra="forbid" model (like every other command + # path) so a key typo fails here instead of silently on the wire. + cmd = UnsubscribeCommand(id=msg_id, params=UnsubscribeParams(sids=[sid])) + await self._sub_mgr._connection.send( + cmd.model_dump(exclude_none=True, by_alias=True, mode="json") + ) + except Exception: + logger.warning( + "Failed to send orphan-unsubscribe for sid=%d", sid, exc_info=True + ) + + async def dispatch( + self, + data: dict[str, Any], + *, + pre_validated: BaseModel | None = None, + ) -> None: + """Route a pre-parsed frame to its subscriber. + + ``data`` is the already-decoded JSON envelope. ``pre_validated`` lets + the recv loop hand off a typed model it already validated (orderbook + snapshots/deltas applied to the local book), so the dispatcher routes + the same instance without re-running Pydantic. + """ + msg_type: str = data.get("type", "") + + # Skip control messages (handled by subscribe/unsubscribe flow). ``ok`` + # frames (update_ack / list_subscriptions) are command acks consumed by + # the subscription manager on ``id``; if one falls through here it has + # no further routing, so it is silently absorbed by this branch. + if msg_type in CONTROL_TYPES: + if msg_type == "error" and self._on_error is not None: + error = PerpsErrorResponse.model_validate(data) + await self._on_error(error) + elif msg_type == "unsubscribed": + await self._handle_server_unsubscribe(data) + elif msg_type == "subscribed": + await self._handle_orphan_subscribed(data) + return + + # Parse into the typed payload model registered for this wire ``type``. + model_cls = MESSAGE_MODELS.get(msg_type) + if model_cls is None: + logger.warning("Unknown message type: %s", msg_type) + return + + if pre_validated is not None and isinstance(pre_validated, model_cls): + parsed: BaseModel = pre_validated + else: + try: + parsed = model_cls.model_validate(data) + except Exception as exc: + logger.warning( + "Failed to parse %s message: %s", msg_type, type(exc).__name__ + ) + # Re-raise so the recv loop can roll back the seq watermark for + # sequenced channels (orderbook_*, order_group_updates). + raise + + sid = data.get("sid") + if sid is None: + logger.debug("Message without sid: type=%s", msg_type) + return + + sub = self._sub_mgr.get_subscription_by_sid(sid) + if sub is None: + logger.debug("Message for unknown sid %d (may be stale)", sid) + return + + # Fan out: deliver to the callback (if any) AND the subscription queue. + if sub.channel in self._callbacks: + await self._callbacks[sub.channel](parsed) + await sub.queue.put(parsed) diff --git a/kalshi/perps/ws/models/__init__.py b/kalshi/perps/ws/models/__init__.py new file mode 100644 index 0000000..ab95954 --- /dev/null +++ b/kalshi/perps/ws/models/__init__.py @@ -0,0 +1,98 @@ +"""Perps (margin) WebSocket message models. + +The command + response/control envelopes live in +:mod:`kalshi.perps.ws.models.control` (#397). The six per-channel data payload ++ envelope models (orderbook snapshot/delta, ticker, trade, fill, user_order, +order_group_updates) and the shared channel literals live in the per-channel +modules added by #398 and are re-exported here. +""" + +from kalshi.perps.ws.models._common import ( + OrderGroupEventType, + PerpsLastUpdateReason, + PerpsSelfTradePreventionType, +) +from kalshi.perps.ws.models.control import ( + ListSubscriptionsCommand, + ListSubscriptionsResponse, + OkMsg, + OkResponse, + PerpsBookSide, + PerpsChannel, + PerpsErrorMsg, + PerpsErrorResponse, + SubscribeCommand, + SubscribedMsg, + SubscribedResponse, + SubscribeParams, + SubscriptionEntry, + UnsubscribeCommand, + UnsubscribedResponse, + UnsubscribeParams, + UpdateSubscriptionAction, + UpdateSubscriptionCommand, + UpdateSubscriptionParams, +) +from kalshi.perps.ws.models.fill import MarginFillMessage, MarginFillPayload +from kalshi.perps.ws.models.order_group import ( + OrderGroupUpdatesMessage, + OrderGroupUpdatesPayload, +) +from kalshi.perps.ws.models.orderbook import ( + MarginOrderbookDeltaMessage, + MarginOrderbookDeltaPayload, + MarginOrderbookSnapshotMessage, + MarginOrderbookSnapshotPayload, +) +from kalshi.perps.ws.models.ticker import ( + FundingRate, + MarginTickerMessage, + MarginTickerPayload, + TickerPrice, +) +from kalshi.perps.ws.models.trade import MarginTradeMessage, MarginTradePayload +from kalshi.perps.ws.models.user_orders import ( + MarginUserOrderMessage, + MarginUserOrderPayload, +) + +__all__ = [ + "FundingRate", + "ListSubscriptionsCommand", + "ListSubscriptionsResponse", + "MarginFillMessage", + "MarginFillPayload", + "MarginOrderbookDeltaMessage", + "MarginOrderbookDeltaPayload", + "MarginOrderbookSnapshotMessage", + "MarginOrderbookSnapshotPayload", + "MarginTickerMessage", + "MarginTickerPayload", + "MarginTradeMessage", + "MarginTradePayload", + "MarginUserOrderMessage", + "MarginUserOrderPayload", + "OkMsg", + "OkResponse", + "OrderGroupEventType", + "OrderGroupUpdatesMessage", + "OrderGroupUpdatesPayload", + "PerpsBookSide", + "PerpsChannel", + "PerpsErrorMsg", + "PerpsErrorResponse", + "PerpsLastUpdateReason", + "PerpsSelfTradePreventionType", + "SubscribeCommand", + "SubscribeParams", + "SubscribedMsg", + "SubscribedResponse", + "SubscriptionEntry", + "TickerPrice", + "UnsubscribeCommand", + "UnsubscribeParams", + "UnsubscribedResponse", + "UpdateSubscriptionAction", + "UpdateSubscriptionCommand", + "UpdateSubscriptionParams", +] diff --git a/kalshi/perps/ws/models/_common.py b/kalshi/perps/ws/models/_common.py new file mode 100644 index 0000000..891ce1a --- /dev/null +++ b/kalshi/perps/ws/models/_common.py @@ -0,0 +1,53 @@ +"""Shared literal/enum types for perps (margin) WS channel payloads. + +These mirror the AsyncAPI ``components.schemas`` enums in +``specs/perps_asyncapi.yaml``. Defined once here so the per-channel payload +modules import a single source of truth. + +CRITICAL — perps differs from the prediction-API WS: + +* Book sides are ``bid``/``ask`` (:data:`PerpsBookSide`), NOT the prediction + API's ``yes``/``no``. Do NOT reuse :data:`kalshi.models.orders.BookSideLiteral`. +* All channel timestamps are Unix epoch **milliseconds** carried on ``int`` + ``*_ms``-suffixed fields — never coerced to RFC3339 ``datetime``. +""" + +from __future__ import annotations + +from typing import Literal + +# Spec schema ``bookSide`` — the side of an order or book level. +PerpsBookSide = Literal["bid", "ask"] + +# Spec schema ``selfTradePreventionType``. +PerpsSelfTradePreventionType = Literal["taker_at_cross", "maker"] + +# Spec schema ``lastUpdateReason`` — margin order update reason on a delta +# corresponding to the authenticated user's order. The empty string is a valid +# enum member per spec. +PerpsLastUpdateReason = Literal[ + "", + "Decrease", + "Amend", + "MarginCancel", + "SelfTradeCancel", + "ExpiryCancel", + "Trade", + "PostOnlyCrossCancel", +] + +# Spec ``orderGroupUpdatesPayload.msg.event_type`` enum. +OrderGroupEventType = Literal[ + "created", + "triggered", + "reset", + "deleted", + "limit_updated", +] + +__all__ = [ + "OrderGroupEventType", + "PerpsBookSide", + "PerpsLastUpdateReason", + "PerpsSelfTradePreventionType", +] diff --git a/kalshi/perps/ws/models/control.py b/kalshi/perps/ws/models/control.py new file mode 100644 index 0000000..c400e36 --- /dev/null +++ b/kalshi/perps/ws/models/control.py @@ -0,0 +1,272 @@ +"""Command and response/control envelope models for the Perps (margin) WebSocket API. + +This module mirrors :mod:`kalshi.ws.models.base` but for the **perps margin** +exchange, whose command grammar and host differ from the prediction-API WS. +Only the command + response/control envelopes live here. The per-channel data +payloads (``ticker``, ``trade``, ``fill``, ``user_order``, ``orderbook_*``, +``order_group_updates``) land in the separate ``perps: WS channels`` issue +(#398) and will populate ``PerpsMessageDispatcher.MESSAGE_MODELS``. + +Two model families: + +* **Command params + wrappers** are *request-side*. They use + ``model_config = {"extra": "forbid"}`` so a typo'd key fails at build time, + and are serialized via ``model_dump(exclude_none=True, by_alias=True, + mode="json")``. +* **Response/control envelopes** are *response-side* validating models. They + use ``model_config = {"extra": "allow", "populate_by_name": True}`` so a + future additive server field doesn't break parsing. + +Timestamp convention (CRITICAL — perps differs from the event-contract WS): +perps WS timestamps are **Unix epoch milliseconds** (``int``) carried on +``_ms``-suffixed field names. None of those fields appear on the control/command +envelopes in this module (only ``seq``/``sid``/``id``/``code`` are integers +here), but the channel payload models added by #398 will carry ``ts_ms``, +``created_ts_ms``, ``last_updated_ts_ms``, ``expiration_ts_ms`` and +``next_funding_time_ms`` as raw ``int`` epoch-ms — the parse path stays +int-typed and MUST NOT coerce them to RFC3339 ``datetime`` objects. + +Likewise, perps order/book sides are ``bid``/``ask`` (:class:`PerpsBookSide`), +**not** the prediction API's ``yes``/``no``. Channel payload prices ride as +:data:`~kalshi.types.DollarDecimal` dollar strings, counts as +:data:`~kalshi.types.FixedPointCount` ``_fp`` strings, and the funding rate as +:data:`~kalshi.types.MultiplierDecimal` (``number``/``double``). +""" + +from __future__ import annotations + +import builtins +from enum import StrEnum +from typing import Literal + +from pydantic import BaseModel + + +class PerpsChannel(StrEnum): + """Spec ``subscribeCommandPayload.params.channels.items.enum`` — subscribable channels.""" + + ORDERBOOK_DELTA = "orderbook_delta" + TICKER = "ticker" + TRADE = "trade" + FILL = "fill" + USER_ORDERS = "user_orders" + ORDER_GROUP_UPDATES = "order_group_updates" + + +class UpdateSubscriptionAction(StrEnum): + """Spec ``updateSubscriptionCommandPayload.params.action.enum`` — add or remove markets.""" + + ADD_MARKETS = "add_markets" + DELETE_MARKETS = "delete_markets" + + +class PerpsBookSide(StrEnum): + """Spec ``bookSide`` — the side of an order or book level. + + Perps uses ``bid``/``ask`` (NOT the prediction API's ``yes``/``no``). + """ + + BID = "bid" + ASK = "ask" + + +# ---------------------------------------------------------------------------- +# Command params (request-side, extra="forbid") +# ---------------------------------------------------------------------------- + + +class SubscribeParams(BaseModel): + """Params for the ``subscribe`` command (``subscribeCommandPayload.params``).""" + + model_config = {"extra": "forbid"} + + channels: builtins.list[PerpsChannel] + market_ticker: str | None = None + market_tickers: builtins.list[str] | None = None + send_initial_snapshot: bool | None = None + skip_ticker_ack: bool | None = None + + +class UnsubscribeParams(BaseModel): + """Params for the ``unsubscribe`` command (``unsubscribeCommandPayload.params``).""" + + model_config = {"extra": "forbid"} + + sids: builtins.list[int] + + +class UpdateSubscriptionParams(BaseModel): + """Params for the ``update_subscription`` command. + + The array variant sets ``sids`` (max 1 per spec); the single-sid variant + sets the scalar ``sid``. ``action`` selects add vs delete. + """ + + model_config = {"extra": "forbid"} + + action: UpdateSubscriptionAction + sid: int | None = None + sids: builtins.list[int] | None = None + market_ticker: str | None = None + market_tickers: builtins.list[str] | None = None + send_initial_snapshot: bool | None = None + + +# ---------------------------------------------------------------------------- +# Command wrappers (request-side, extra="forbid") +# ---------------------------------------------------------------------------- + + +class SubscribeCommand(BaseModel): + """Wire frame for ``subscribeCommandPayload`` (``cmd: "subscribe"``).""" + + model_config = {"extra": "forbid"} + + id: int + cmd: Literal["subscribe"] = "subscribe" + params: SubscribeParams + + +class UnsubscribeCommand(BaseModel): + """Wire frame for ``unsubscribeCommandPayload`` (``cmd: "unsubscribe"``).""" + + model_config = {"extra": "forbid"} + + id: int + cmd: Literal["unsubscribe"] = "unsubscribe" + params: UnsubscribeParams + + +class UpdateSubscriptionCommand(BaseModel): + """Wire frame for ``updateSubscriptionCommandPayload`` (``cmd: "update_subscription"``). + + Backs all three spec messages that share this payload — add markets, + delete markets, and the single-sid variant — distinguished by + ``params.action`` and whether ``params.sid`` or ``params.sids`` is set. + """ + + model_config = {"extra": "forbid"} + + id: int + cmd: Literal["update_subscription"] = "update_subscription" + params: UpdateSubscriptionParams + + +class ListSubscriptionsCommand(BaseModel): + """Wire frame for ``listSubscriptionsCommandPayload`` (``cmd: "list_subscriptions"``). + + Has no ``params`` per spec. + """ + + model_config = {"extra": "forbid"} + + id: int + cmd: Literal["list_subscriptions"] = "list_subscriptions" + + +# ---------------------------------------------------------------------------- +# Response / control envelopes (response-side, validating) +# ---------------------------------------------------------------------------- + + +class SubscribedMsg(BaseModel): + """``subscribedResponsePayload.msg`` — channel + assigned sid.""" + + model_config = {"extra": "allow", "populate_by_name": True} + + channel: str + sid: int + + +class SubscribedResponse(BaseModel): + """Spec ``subscribedResponsePayload`` (``type: "subscribed"``).""" + + model_config = {"extra": "allow", "populate_by_name": True} + + id: int | None = None + type: Literal["subscribed"] = "subscribed" + msg: SubscribedMsg + + +class UnsubscribedResponse(BaseModel): + """Spec ``unsubscribedResponsePayload`` (``type: "unsubscribed"``). + + ``seq`` is the per-sid sequence number; this is a control frame (the + sid/seq are top-level, not nested under ``msg``). + """ + + model_config = {"extra": "allow", "populate_by_name": True} + + id: int | None = None + sid: int + seq: int + type: Literal["unsubscribed"] = "unsubscribed" + + +class OkMsg(BaseModel): + """``okResponsePayload.msg`` — optional update-ack body. + + Object-shaped (carries ``market_tickers``). The ``list_subscriptions`` + reply also uses ``type: "ok"`` but its ``msg`` is an **array**; callers + disambiguate on ``msg`` shape (see :class:`ListSubscriptionsResponse`). + """ + + model_config = {"extra": "allow", "populate_by_name": True} + + market_tickers: builtins.list[str] | None = None + + +class OkResponse(BaseModel): + """Spec ``okResponsePayload`` (``type: "ok"``) — update-ack with object ``msg``.""" + + model_config = {"extra": "allow", "populate_by_name": True} + + id: int | None = None + sid: int | None = None + seq: int | None = None + type: Literal["ok"] = "ok" + msg: OkMsg | None = None + + +class SubscriptionEntry(BaseModel): + """One entry in a ``listSubscriptionsResponsePayload.msg`` array.""" + + model_config = {"extra": "allow", "populate_by_name": True} + + channel: str + sid: int + + +class ListSubscriptionsResponse(BaseModel): + """Spec ``listSubscriptionsResponsePayload`` (``type: "ok"`` with array ``msg``). + + Shares the ``type: "ok"`` const with :class:`OkResponse`; the framing + layer disambiguates by ``msg`` being an array (list-subscriptions) vs an + object (update ack). + """ + + model_config = {"extra": "allow", "populate_by_name": True} + + id: int + type: Literal["ok"] = "ok" + msg: builtins.list[SubscriptionEntry] + + +class PerpsErrorMsg(BaseModel): + """``errorResponsePayload.msg`` -- error code (1-18), message, optional ticker.""" + + model_config = {"extra": "allow", "populate_by_name": True} + + code: int + msg: str + market_ticker: str | None = None + + +class PerpsErrorResponse(BaseModel): + """Spec ``errorResponsePayload`` (``type: "error"``).""" + + model_config = {"extra": "allow", "populate_by_name": True} + + id: int | None = None + type: Literal["error"] = "error" + msg: PerpsErrorMsg diff --git a/kalshi/perps/ws/models/fill.py b/kalshi/perps/ws/models/fill.py new file mode 100644 index 0000000..22e6b88 --- /dev/null +++ b/kalshi/perps/ws/models/fill.py @@ -0,0 +1,49 @@ +"""Margin private fill channel message models (authenticated user fills). + +``side`` is ``bid``/``ask`` (:data:`PerpsBookSide`), NOT ``yes``/``no``; +``ts_ms`` is epoch milliseconds. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + +from kalshi.perps.ws.models._common import PerpsBookSide +from kalshi.types import DollarDecimal, FixedPointCount + + +class MarginFillPayload(BaseModel): + """Payload for ``fill`` messages (``marginFillPayload.msg``). + + Required per spec: ``trade_id``/``order_id`` (UUID strings), ``market_ticker``, + ``is_taker``, ``side`` (``bid``/``ask``), ``ts_ms`` (epoch ms), ``price`` + (dollar-decimal), ``count``/``post_position`` (fixed-point counts), and + ``fee_cost`` (dollar-decimal). ``client_order_id`` and ``subaccount`` are + optional. + """ + + trade_id: str + order_id: str + market_ticker: str + is_taker: bool + side: PerpsBookSide + ts_ms: int + price: DollarDecimal + count: FixedPointCount + fee_cost: DollarDecimal + post_position: FixedPointCount + client_order_id: str | None = None + subaccount: int | None = None + model_config = {"extra": "allow", "populate_by_name": True} + + +class MarginFillMessage(BaseModel): + """Private margin fill update. ``seq`` is NOT required on this channel.""" + + type: Literal["fill"] = "fill" + sid: int + seq: int | None = None + msg: MarginFillPayload + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/perps/ws/models/order_group.py b/kalshi/perps/ws/models/order_group.py new file mode 100644 index 0000000..de77769 --- /dev/null +++ b/kalshi/perps/ws/models/order_group.py @@ -0,0 +1,44 @@ +"""Margin order-group lifecycle/limit-update channel message models. + +Sequenced channel: ``seq`` is REQUIRED on the envelope (mirrors the +prediction-API ``OrderGroupMessage``). Market spec is ignored for this channel. +``ts_ms`` is the matching-engine epoch-millisecond timestamp. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import AliasChoices, BaseModel, Field + +from kalshi.perps.ws.models._common import OrderGroupEventType +from kalshi.types import FixedPointCount + + +class OrderGroupUpdatesPayload(BaseModel): + """Payload for ``order_group_updates`` messages (``orderGroupUpdatesPayload.msg``). + + Required per spec: ``event_type`` (one of ``created``/``triggered``/``reset``/ + ``deleted``/``limit_updated``), ``order_group_id``, and ``ts_ms`` (matching- + engine epoch ms). ``contracts_limit`` (wire ``contracts_limit_fp``, a 2-decimal + fixed-point count) is present only for ``created`` and ``limit_updated`` events. + """ + + event_type: OrderGroupEventType + order_group_id: str + ts_ms: int + contracts_limit: FixedPointCount | None = Field( + default=None, + validation_alias=AliasChoices("contracts_limit_fp", "contracts_limit"), + ) + model_config = {"extra": "allow", "populate_by_name": True} + + +class OrderGroupUpdatesMessage(BaseModel): + """Order-group lifecycle/limit update. Sequenced channel: ``seq`` REQUIRED.""" + + type: Literal["order_group_updates"] = "order_group_updates" + sid: int + seq: int + msg: OrderGroupUpdatesPayload + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/perps/ws/models/orderbook.py b/kalshi/perps/ws/models/orderbook.py new file mode 100644 index 0000000..126778b --- /dev/null +++ b/kalshi/perps/ws/models/orderbook.py @@ -0,0 +1,131 @@ +"""Margin orderbook snapshot + delta channel message models. + +Wire-distinct from the prediction-API orderbook (``kalshi/ws/models/ +orderbook_delta.py``): perps book sides are ``bid``/``ask`` (NOT ``yes``/ +``no``), snapshot levels are ``priceLevelDollarsCountFp`` = +``[price_in_dollars, contract_count_fp]`` string pairs under ``bid``/``ask`` +arrays, and the delta carries a single-sided ``price``/``delta``/``side`` plus +an epoch-ms ``ts_ms`` (NOT an RFC3339 ``ts``). +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, BeforeValidator, Field + +from kalshi.perps.ws.models._common import ( + PerpsBookSide, + PerpsLastUpdateReason, +) +from kalshi.types import DollarDecimal, FixedPointCount, _coerce_decimal + + +def _levels_to_dict(value: Any) -> Any: + """Collapse the snapshot wire payload into a ``dict[Decimal, Decimal]``. + + The wire format for ``bid`` / ``ask`` is a list of ``[price, count]`` + string pairs (``priceLevelDollarsCountFp``). Coercing each price/count to + ``Decimal`` inline and returning a fully typed ``dict[Decimal, Decimal]`` + means (a) pydantic-core just bounces the already-validated map back without + a second walk, and (b) :meth:`PerpsOrderbookManager._apply_snapshot_inplace` + adopts the dict directly with no rebuild. Mirrors the prediction-API + ``_levels_to_dict`` (#263). + + Accepts ``None`` (→ empty — perps allows a partial book, see the payload + docstring), an already-built dict (re-validation), or any iterable of + 2-element ``(price, count)`` pairs (wire shape). + """ + if value is None: + return {} + coerce = _coerce_decimal + if isinstance(value, dict): + if all(type(k) is Decimal and type(v) is Decimal for k, v in value.items()): + return value + return {coerce(k): coerce(v) for k, v in value.items()} + out: dict[Decimal, Decimal] = {} + try: + for row in value: + price, count = row + out[coerce(price)] = coerce(count) + except (TypeError, ValueError, ArithmeticError) as exc: + # Re-raise as ValueError so pydantic surfaces a ValidationError (a raw + # decimal.InvalidOperation / TypeError would escape model_validate). + raise ValueError(f"malformed orderbook level data: {exc}") from exc + return out + + +# Field type is ``dict[Decimal, Decimal]`` (not ``dict[DollarDecimal, +# FixedPointCount]``) so pydantic-core does not re-run the per-item coercion +# walk on values our ``BeforeValidator`` has already coerced. On-the-wire +# semantics: prices are dollar-decimals, counts are fixed-point counts — both +# land as ``Decimal``. Mirrors ``kalshi.ws.models.orderbook_delta.PriceCountMap``. +PriceCountMap = Annotated[dict[Decimal, Decimal], BeforeValidator(_levels_to_dict)] + + +class MarginOrderbookSnapshotPayload(BaseModel): + """Payload for ``orderbook_snapshot`` messages (``marginOrderbookSnapshotPayload.msg``). + + Wire format: ``bid`` and ``ask`` are arrays of ``[price_in_dollars, + contract_count_fp]`` string pairs (``priceLevelDollarsCountFp``). Each row + collapses into ``dict[Decimal, Decimal]`` (price -> count) in a single walk + so :class:`~kalshi.perps.ws.orderbook.PerpsOrderbookManager` adopts the map + with no second iteration. + + Partial-book note: unlike the prediction-API snapshot (#268, where BOTH + sides are required), the perps spec does NOT mark ``bid``/``ask`` as + ``required`` on ``msg``. Each side therefore defaults to an empty map when + omitted — a snapshot with one side absent is a legitimate one-sided book, + not schema drift. + + Aliasing: the wire names ``bid``/``ask`` already match the short names, so + there is no ``_dollars``/``_fp`` suffix mismatch to alias. + """ + + market_ticker: str + bid: PriceCountMap = Field(default_factory=dict) + ask: PriceCountMap = Field(default_factory=dict) + model_config = {"extra": "allow", "populate_by_name": True} + + +class MarginOrderbookDeltaPayload(BaseModel): + """Payload for ``orderbook_delta`` messages (``marginOrderbookDeltaPayload.msg``). + + Single-sided incremental update. ``price`` is a dollar-decimal string, + ``delta`` is a fixed-point count string (may be negative), ``side`` is + ``bid``/``ask``. ``ts_ms`` is epoch milliseconds (``int``), NOT RFC3339. + """ + + market_ticker: str + price: DollarDecimal + delta: FixedPointCount + side: PerpsBookSide + last_update_reason: PerpsLastUpdateReason | None = None + client_order_id: str | None = None + subaccount: int | None = None + ts_ms: int | None = None + model_config = {"extra": "allow", "populate_by_name": True} + + +class MarginOrderbookSnapshotMessage(BaseModel): + """Full margin orderbook snapshot, sent first on an ``orderbook_delta`` subscribe. + + Sequenced channel: ``seq`` is REQUIRED. + """ + + type: Literal["orderbook_snapshot"] = "orderbook_snapshot" + sid: int + seq: int + msg: MarginOrderbookSnapshotPayload + model_config = {"extra": "allow", "populate_by_name": True} + + +class MarginOrderbookDeltaMessage(BaseModel): + """Incremental margin orderbook update. Sequenced channel: ``seq`` REQUIRED.""" + + type: Literal["orderbook_delta"] = "orderbook_delta" + sid: int + seq: int + msg: MarginOrderbookDeltaPayload + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/perps/ws/models/ticker.py b/kalshi/perps/ws/models/ticker.py new file mode 100644 index 0000000..7ed90b5 --- /dev/null +++ b/kalshi/perps/ws/models/ticker.py @@ -0,0 +1,88 @@ +"""Margin ticker channel message models. + +Perps-unique: the ticker payload carries a ``funding_rate`` (perps funding) and +``reference_price`` / ``settlement_mark_price`` / ``liquidation_mark_price`` +mark-price objects — none of which exist on the prediction-API ticker. Prices +are single-sided (``price`` / ``bid`` / ``ask``), NOT ``yes_``/``no_``-prefixed. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import AliasChoices, BaseModel, Field + +from kalshi.types import DollarDecimal, FixedPointCount, MultiplierDecimal + + +class FundingRate(BaseModel): + """Perps funding-rate object (``fundingRate`` schema). + + ``rate`` is wire ``type: number, format: double``; typed + :data:`~kalshi.types.MultiplierDecimal` (not bare ``float``) to avoid + binary-float drift per the #225 invariant — the same handling + ``Series.fee_multiplier`` uses. ``next_funding_time_ms`` and ``ts_ms`` are + epoch milliseconds (``int``). + """ + + rate: MultiplierDecimal + next_funding_time_ms: int + ts_ms: int + model_config = {"extra": "allow", "populate_by_name": True} + + +class TickerPrice(BaseModel): + """A reference/mark price point (``tickerPrice`` schema). + + ``price`` is a USD fixed-point decimal string (4 decimals); ``ts_ms`` is + epoch milliseconds (``int``). + """ + + price: DollarDecimal + ts_ms: int + model_config = {"extra": "allow", "populate_by_name": True} + + +class MarginTickerPayload(BaseModel): + """Payload for ``ticker`` messages (``marginTickerPayload.msg``). + + Single-sided prices (``price``/``bid``/``ask``) — no ``yes_``/``no_`` + prefixes, unlike the prediction-API ticker. ``*_size_fp`` size fields are + aliased to short names; the ``volume``/``volume_24h``/``open_interest`` + fields are bare strings on the wire (no ``_fp`` suffix) but are fixed-point + counts, so they are typed :data:`~kalshi.types.FixedPointCount` with no + alias. The mark-price + funding-rate fields are optional. + """ + + market_ticker: str + price: DollarDecimal + bid: DollarDecimal + ask: DollarDecimal + bid_size: FixedPointCount = Field( + validation_alias=AliasChoices("bid_size_fp", "bid_size"), + ) + ask_size: FixedPointCount = Field( + validation_alias=AliasChoices("ask_size_fp", "ask_size"), + ) + last_trade_size: FixedPointCount = Field( + validation_alias=AliasChoices("last_trade_size_fp", "last_trade_size"), + ) + volume: FixedPointCount + volume_24h: FixedPointCount + open_interest: FixedPointCount + reference_price: TickerPrice | None = None + settlement_mark_price: TickerPrice | None = None + liquidation_mark_price: TickerPrice | None = None + funding_rate: FundingRate | None = None + ts_ms: int + model_config = {"extra": "allow", "populate_by_name": True} + + +class MarginTickerMessage(BaseModel): + """Margin ticker update. ``seq`` is NOT required on this channel.""" + + type: Literal["ticker"] = "ticker" + sid: int + seq: int | None = None + msg: MarginTickerPayload + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/perps/ws/models/trade.py b/kalshi/perps/ws/models/trade.py new file mode 100644 index 0000000..6525df3 --- /dev/null +++ b/kalshi/perps/ws/models/trade.py @@ -0,0 +1,41 @@ +"""Margin public trade channel message models. + +Wire-distinct from the prediction-API trade: ``taker_side`` is ``bid``/``ask`` +(:data:`PerpsBookSide`), NOT ``yes``/``no``, and ``ts_ms`` is epoch milliseconds. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + +from kalshi.perps.ws.models._common import PerpsBookSide +from kalshi.types import DollarDecimal, FixedPointCount + + +class MarginTradePayload(BaseModel): + """Payload for ``trade`` messages (``marginTradePayload.msg``). + + All fields required per spec. ``trade_id`` is a UUID string; ``price`` is a + dollar-decimal string; ``count`` is a fixed-point count string; + ``taker_side`` is ``bid``/``ask``; ``ts_ms`` is epoch milliseconds. + """ + + trade_id: str + market_ticker: str + price: DollarDecimal + count: FixedPointCount + taker_side: PerpsBookSide + ts_ms: int + model_config = {"extra": "allow", "populate_by_name": True} + + +class MarginTradeMessage(BaseModel): + """Public margin trade update. ``seq`` is NOT required on this channel.""" + + type: Literal["trade"] = "trade" + sid: int + seq: int | None = None + msg: MarginTradePayload + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/perps/ws/models/user_orders.py b/kalshi/perps/ws/models/user_orders.py new file mode 100644 index 0000000..66d3ca2 --- /dev/null +++ b/kalshi/perps/ws/models/user_orders.py @@ -0,0 +1,61 @@ +"""Margin private user-order channel message models (order create/update). + +The subscribe channel is ``user_orders`` but the message envelope ``type`` is +``user_order`` (singular) per spec — the dispatcher registers under +``user_order``. The order identifier field is ``ticker`` (NOT ``market_ticker``); +``side`` is ``bid``/``ask`` (:data:`PerpsBookSide`); all timestamps are +``*_ts_ms`` epoch milliseconds. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + +from kalshi.perps.ws.models._common import ( + PerpsBookSide, + PerpsSelfTradePreventionType, +) +from kalshi.types import DollarDecimal, FixedPointCount + + +class MarginUserOrderPayload(BaseModel): + """Payload for ``user_order`` messages (``marginUserOrderPayload.msg``). + + Required per spec: ``order_id``/``user_id`` (UUID strings), ``client_order_id``, + ``ticker`` (note: ``ticker``, NOT ``market_ticker``), ``side`` (``bid``/ + ``ask``), ``price`` (dollar-decimal), ``fill_count``/``remaining_count`` + (fixed-point counts), and ``created_ts_ms`` (epoch ms). The remaining fields + — STP type, order-group id, and the ``*_ts_ms`` timestamps — are optional. + """ + + order_id: str + user_id: str + client_order_id: str + ticker: str + side: PerpsBookSide + price: DollarDecimal + fill_count: FixedPointCount + remaining_count: FixedPointCount + created_ts_ms: int + self_trade_prevention_type: PerpsSelfTradePreventionType | None = None + order_group_id: str | None = None + expiration_ts_ms: int | None = None + last_updated_ts_ms: int | None = None + subaccount_number: int | None = None + model_config = {"extra": "allow", "populate_by_name": True} + + +class MarginUserOrderMessage(BaseModel): + """Private margin order create/update. ``seq`` is NOT required on this channel. + + Envelope ``type`` is ``user_order`` (singular) even though the subscribe + channel name is ``user_orders``. + """ + + type: Literal["user_order"] = "user_order" + sid: int + seq: int | None = None + msg: MarginUserOrderPayload + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/perps/ws/orderbook.py b/kalshi/perps/ws/orderbook.py new file mode 100644 index 0000000..109c3cd --- /dev/null +++ b/kalshi/perps/ws/orderbook.py @@ -0,0 +1,270 @@ +"""Local margin orderbook manager from perps WebSocket snapshots and deltas. + +This is a **fork** of :class:`kalshi.ws.orderbook.OrderbookManager`, not a reuse. +The prediction-API manager hardcodes ``yes`` / ``no`` sides and consumes +``OrderbookSnapshotMessage`` / ``OrderbookDeltaMessage`` whose ``side`` is +``yes``/``no``. Perps uses ``bid`` / ``ask`` (:class:`PerpsBookSide`) and the +margin snapshot carries ``bid`` / ``ask`` arrays of ``priceLevelDollarsCountFp`` +(``[price_in_dollars, contract_count_fp]``) levels. Forking (per the #397 +guidance) keeps the prediction-API manager and its tests untouched while the +Decimal price-indexed dict logic, lazy materialization, per-sid teardown +(:meth:`remove_by_sid`), and cache invalidation are mirrored verbatim. + +The public view is :class:`kalshi.perps.models.markets.MarginOrderbook` (the +same ``bids`` / ``asks`` of ``[price, quantity]`` shape the REST orderbook +endpoint returns), so consumers get one orderbook type across REST and WS. + +The apply paths consume the concrete ``MarginOrderbookSnapshotMessage`` / +``MarginOrderbookDeltaMessage`` channel models (#398): ``.sid``, +``.msg.market_ticker``, ``.msg.bid`` / ``.ask`` (price-indexed +``dict[Decimal, Decimal]`` maps) for snapshots, and ``.msg.price`` / +``.delta`` / ``.side`` for deltas. (#397 originally typed these via structural +Protocols to stay decoupled from the not-yet-written channel models; once #398 +landed they were swapped for the concrete types.) + +Timestamp note: per-level data carries no timestamps; delta envelopes may carry +``ts_ms`` (epoch-ms ``int``) on #398's payload model — this manager never reads +it, so the int-typed convention is preserved end to end. +""" + +from __future__ import annotations + +import builtins +import logging +from dataclasses import dataclass, field +from decimal import Decimal + +from kalshi.perps.models.markets import MarginOrderbook, MarginOrderbookLevel +from kalshi.perps.ws.models.orderbook import ( + MarginOrderbookDeltaMessage, + MarginOrderbookSnapshotMessage, +) + +logger = logging.getLogger("kalshi.perps.ws") + + +@dataclass +class _PerpsBookState: + """Internal mutable state for one margin ticker's orderbook. + + Levels are price-indexed (``dict[Decimal, Decimal]``) per side so delta + application is O(1) regardless of depth. The sorted public + :class:`MarginOrderbook` view is materialized lazily and memoized; the + cache is dropped by :meth:`invalidate` whenever a side mutates. + """ + + ticker: str + bid: dict[Decimal, Decimal] = field(default_factory=dict) + ask: dict[Decimal, Decimal] = field(default_factory=dict) + sid: int | None = None + _cached: MarginOrderbook | None = field(default=None, repr=False, compare=False) + + def to_orderbook(self) -> MarginOrderbook: + """Build (or return the cached) :class:`MarginOrderbook` from current state. + + Bids are emitted **best-first (price descending)** and asks **best-first + (price ascending)**, matching the spec's ``MarginOrderbookCount`` ordering + ("bids ordered from best bid downward, asks from best ask upward") so the + WS-produced book matches the REST ``GET /margin/markets/{ticker}/orderbook`` + ordering for the same :class:`MarginOrderbook` model. Materialized via + ``model_construct`` (the dicts are already SDK-canonical ``Decimal`` -> + ``Decimal``), so the per-level validators don't re-run on this hot path. + """ + if self._cached is not None: + return self._cached + bid_levels = [ + MarginOrderbookLevel.model_construct(price=price, quantity=qty) + for price, qty in sorted(self.bid.items(), reverse=True) + ] + ask_levels = [ + MarginOrderbookLevel.model_construct(price=price, quantity=qty) + for price, qty in sorted(self.ask.items()) + ] + ob = MarginOrderbook.model_construct( + ticker=self.ticker, bids=bid_levels, asks=ask_levels + ) + self._cached = ob + return ob + + def invalidate(self) -> None: + """Drop the cached :class:`MarginOrderbook`; next read re-materializes.""" + self._cached = None + + +class PerpsOrderbookManager: + """Maintains local margin orderbook state from the perps WebSocket stream. + + Prices and quantities are :class:`decimal.Decimal` throughout. Sides are + ``bid`` / ``ask`` (NOT ``yes`` / ``no``). Per-sid tracking lets all-markets + subscriptions be torn down without enumerating tickers. + + Usage:: + + mgr = PerpsOrderbookManager() + mgr.apply_snapshot(snapshot_msg) # initialize + mgr.apply_delta(delta_msg) # update + book = mgr.get("BTC-PERP") # read current state + """ + + def __init__(self) -> None: + self._books: dict[str, _PerpsBookState] = {} + self._sid_tickers: dict[int, set[str]] = {} + + def _install_snapshot( + self, + ticker: str, + sid_val: int | None, + bid: dict[Decimal, Decimal], + ask: dict[Decimal, Decimal], + ) -> _PerpsBookState: + """Replace the book for ``ticker`` with a freshly seeded state. + + The provided ``bid`` / ``ask`` dicts are adopted by identity; callers + defensive-copy when their input must not alias internal state. + """ + prior = self._books.get(ticker) + if prior is not None and prior.sid is not None and prior.sid != sid_val: + old_bucket = self._sid_tickers.get(prior.sid) + if old_bucket is not None: + old_bucket.discard(ticker) + if not old_bucket: + self._sid_tickers.pop(prior.sid, None) + + state = _PerpsBookState(ticker=ticker, bid=bid, ask=ask, sid=sid_val) + self._books[ticker] = state + if sid_val is not None: + bucket = self._sid_tickers.get(sid_val) + if bucket is None: + bucket = set() + self._sid_tickers[sid_val] = bucket + bucket.add(ticker) + logger.debug( + "Margin orderbook snapshot: %s (%d bid, %d ask levels, sid=%s)", + ticker, + len(bid), + len(ask), + sid_val, + ) + return state + + def _apply_snapshot_inplace( + self, msg: MarginOrderbookSnapshotMessage, sid: int | None = None + ) -> None: + """In-place state mutation for a snapshot. No materialization. + + Recv-loop hot-path entry point. ``msg.msg.bid`` / ``.ask`` are adopted + by identity into the new ``_PerpsBookState``; callers must treat them + as consumed. ``sid`` defaults to ``msg.sid``. + """ + sid_val = sid if sid is not None else msg.sid + self._install_snapshot( + msg.msg.market_ticker, sid_val, msg.msg.bid, msg.msg.ask + ) + + def _apply_delta_inplace(self, msg: MarginOrderbookDeltaMessage) -> bool: + """In-place state mutation for a delta. No materialization. + + Returns ``True`` if applied, ``False`` if no book exists yet for the + ticker (delta arrived before snapshot). + """ + ticker = msg.msg.market_ticker + state = self._books.get(ticker) + if state is None: + logger.warning( + "Margin delta for unknown ticker %s (no snapshot yet)", ticker + ) + return False + + price = msg.msg.price + delta = msg.msg.delta + side = str(msg.msg.side) + + # Route by exact side. An unknown side (new wire value, parse error) must + # NOT silently corrupt the ask book — drop the delta and warn instead. + if side == "bid": + levels = state.bid + elif side == "ask": + levels = state.ask + else: + logger.warning( + "Unknown perps orderbook side %r for %s; dropping delta", side, ticker + ) + return False + existing_qty = levels.get(price) + + if existing_qty is not None: + new_qty = existing_qty + delta + if new_qty <= 0: + del levels[price] + state.invalidate() + elif new_qty != existing_qty: + levels[price] = new_qty + state.invalidate() + elif delta > 0: + levels[price] = delta + state.invalidate() + + return True + + def apply_snapshot( + self, msg: MarginOrderbookSnapshotMessage, sid: int | None = None + ) -> MarginOrderbook: + """Initialize (or reset) a book from a full snapshot. + + Public wrapper: seeds the book with a defensive single-copy of the + caller's ``bid`` / ``ask`` dicts and materializes a fresh + :class:`MarginOrderbook`. ``msg`` is safe to reuse afterward. + """ + sid_val = sid if sid is not None else msg.sid + state = self._install_snapshot( + msg.msg.market_ticker, + sid_val, + dict(msg.msg.bid), + dict(msg.msg.ask), + ) + return state.to_orderbook() + + def apply_delta(self, msg: MarginOrderbookDeltaMessage) -> MarginOrderbook | None: + """Apply an incremental delta. Returns the book, or ``None`` if unseeded.""" + if not self._apply_delta_inplace(msg): + return None + state = self._books.get(msg.msg.market_ticker) + return state.to_orderbook() if state else None + + def get(self, ticker: str) -> MarginOrderbook | None: + """Get current book state (non-blocking). Returns a fresh view.""" + state = self._books.get(ticker) + if state is None: + return None + return state.to_orderbook() + + def remove(self, ticker: str) -> None: + """Remove a book (e.g., on unsubscribe).""" + state = self._books.pop(ticker, None) + if state is not None and state.sid is not None: + bucket = self._sid_tickers.get(state.sid) + if bucket is not None: + bucket.discard(ticker) + if not bucket: + self._sid_tickers.pop(state.sid, None) + + def tickers_for_sid(self, sid: int) -> set[str]: + """Return a fresh set of tickers currently tracked under ``sid``.""" + bucket = self._sid_tickers.get(sid) + return set(bucket) if bucket is not None else set() + + def remove_by_sid(self, sid: int) -> builtins.list[str]: + """Remove every book associated with ``sid``. Returns removed tickers.""" + bucket = self._sid_tickers.pop(sid, None) + if not bucket: + return [] + removed: builtins.list[str] = [] + for ticker in bucket: + if self._books.pop(ticker, None) is not None: + removed.append(ticker) + return removed + + def clear(self) -> None: + """Remove all books (e.g., on reconnect before resubscribe).""" + self._books.clear() + self._sid_tickers.clear() diff --git a/kalshi/perps/ws/sequence.py b/kalshi/perps/ws/sequence.py new file mode 100644 index 0000000..23771ce --- /dev/null +++ b/kalshi/perps/ws/sequence.py @@ -0,0 +1,41 @@ +"""Sequence tracking for perps WS channels that carry ``seq``. + +Reuses :class:`kalshi.ws.sequence.SequenceTracker` (and re-exports +:class:`~kalshi.ws.sequence.SequenceGap`) unchanged — only the *channel set* +differs. The prediction-API tracker keys off ``SEQUENCED_CHANNELS``; perps +carries ``seq`` on ``orderbook_delta`` / ``orderbook_snapshot`` (margin order +book) and ``order_group_updates``. (``unsubscribed`` also carries ``seq`` per +spec, but it is a control frame handled by the dispatcher, not a sequenced data +channel.) + +We subclass and override :meth:`should_track` against +:data:`PERPS_SEQUENCED_CHANNELS` rather than copying the watermark state machine +(gap / reset / duplicate handling, rollback, peek) which is identical. +""" + +from __future__ import annotations + +from kalshi.ws.sequence import SequenceGap, SequenceTracker + +__all__ = ["PERPS_SEQUENCED_CHANNELS", "PerpsSequenceTracker", "SequenceGap"] + +# Perps channels (and message types) that carry a per-sid ``seq``. Mirrors the +# prediction-API ``SEQUENCED_CHANNELS`` set; identical members today but kept +# perps-local so the two surfaces can diverge without cross-impact. +PERPS_SEQUENCED_CHANNELS = { + "orderbook_delta", + "orderbook_snapshot", + "order_group_updates", +} + + +class PerpsSequenceTracker(SequenceTracker): + """:class:`SequenceTracker` keyed to :data:`PERPS_SEQUENCED_CHANNELS`. + + Inherits the full watermark/gap/reset/duplicate state machine; only the + set of channels eligible for tracking changes. + """ + + def should_track(self, channel: str) -> bool: + """Whether this perps channel/message-type has sequence numbers.""" + return channel in PERPS_SEQUENCED_CHANNELS diff --git a/mkdocs.yml b/mkdocs.yml index 388ee7c..741b671 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -101,6 +101,7 @@ nav: - Incentive programs: resources/incentive-programs.md - Live data: resources/live-data.md - WebSocket: websockets.md + - Perps: perps.md - Testing: testing.md - Migration: - Overview: migration.md diff --git a/pyproject.toml b/pyproject.toml index 67db9a8..4cfb1f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "3.1.0" +version = "3.2.0" description = "A professional Python SDK for the Kalshi prediction markets API" readme = "README.md" license = { text = "MIT" } diff --git a/scripts/render_drift_body.py b/scripts/render_drift_body.py index 2179d36..67382c9 100644 --- a/scripts/render_drift_body.py +++ b/scripts/render_drift_body.py @@ -31,9 +31,22 @@ - `specs/openapi.yaml`: `${OPENAPI_SHA}` - `specs/asyncapi.yaml`: `${ASYNCAPI_SHA}` +- `specs/perps_openapi.yaml`: `${PERPS_OPENAPI_SHA}` +- `specs/perps_asyncapi.yaml`: `${PERPS_ASYNCAPI_SHA}` +- `specs/perps_scm_openapi.yaml`: `${PERPS_SCM_SHA}` Reproduce locally and verify the same hashes before generating models. +### Perps (margin) specs + +- perps OpenAPI changed: `${PERPS_OPENAPI_CHANGED}` +- perps AsyncAPI changed: `${PERPS_ASYNCAPI_CHANGED}` +- perps SCM/Klear changed: `${PERPS_SCM_CHANGED}` + +A perps-spec change reds the perps contract-drift suites in +`tests/test_contracts.py` (the `TestPerps*Drift` classes). Re-vendor with +`scripts/sync_spec.py` and reconcile the perps models/maps under `kalshi/perps/`. + ## OpenAPI - Version: `${OLD_VERSION}` → `${NEW_VERSION}` diff --git a/scripts/sync_spec.py b/scripts/sync_spec.py index 923c1f4..c735108 100644 --- a/scripts/sync_spec.py +++ b/scripts/sync_spec.py @@ -13,6 +13,9 @@ SPECS = { "openapi.yaml": "https://docs.kalshi.com/openapi.yaml", "asyncapi.yaml": "https://docs.kalshi.com/asyncapi.yaml", + "perps_openapi.yaml": "https://docs.kalshi.com/perps_openapi.yaml", + "perps_asyncapi.yaml": "https://docs.kalshi.com/perps_asyncapi.yaml", + "perps_scm_openapi.yaml": "https://docs.kalshi.com/perps_scm_openapi.yaml", } SPEC_DIR = Path(__file__).parent.parent / "specs" diff --git a/specs/perps_asyncapi.yaml b/specs/perps_asyncapi.yaml new file mode 100644 index 0000000..1aa197e --- /dev/null +++ b/specs/perps_asyncapi.yaml @@ -0,0 +1,1257 @@ +asyncapi: 3.0.0 +info: + title: Kalshi Perps WebSocket API + version: 2.0.0 + description: > + WebSocket API for receiving real-time market data notifications and updates + on Kalshi perps markets. + + + Supported channels: + + - `orderbook_delta` + + - `ticker` + + - `trade` + + - `fill` + + - `user_orders` + + - `order_group_updates` + contact: + name: Kalshi Support + url: https://kalshi.com + email: support@kalshi.com + license: + name: Proprietary + url: https://kalshi.com/terms + tags: + - name: control-frames + description: WebSocket control frames for connection management + - name: commands + description: Client-to-server command messages + - name: responses + description: Server responses to commands + - name: market-data + description: Real-time margin market data updates + - name: private + description: Private/authenticated margin data channels +servers: + production: + host: external-api-margin-ws.kalshi.com + pathname: /trade-api/ws/v2/margin + protocol: wss + description: Production margin WebSocket server (encrypted connection only) + security: + - $ref: '#/components/securitySchemes/apiKey' + demo: + host: external-api-margin-ws.demo.kalshi.co + pathname: /trade-api/ws/v2/margin + protocol: wss + description: Demo margin WebSocket server (encrypted connection only) + security: + - $ref: '#/components/securitySchemes/apiKey' +defaultContentType: application/json +channels: + root: + address: / + title: WebSocket Connection + description: | + Main WebSocket connection endpoint. + Authentication is required during the WebSocket handshake. + bindings: + ws: + method: GET + messages: + subscribeCommand: + $ref: '#/components/messages/subscribeCommand' + unsubscribeCommand: + $ref: '#/components/messages/unsubscribeCommand' + updateSubscriptionCommand: + $ref: '#/components/messages/updateSubscriptionCommand' + updateSubscriptionDeleteCommand: + $ref: '#/components/messages/updateSubscriptionDeleteCommand' + updateSubscriptionSingleSidCommand: + $ref: '#/components/messages/updateSubscriptionSingleSidCommand' + listSubscriptionsCommand: + $ref: '#/components/messages/listSubscriptionsCommand' + subscribedResponse: + $ref: '#/components/messages/subscribedResponse' + unsubscribedResponse: + $ref: '#/components/messages/unsubscribedResponse' + okResponse: + $ref: '#/components/messages/okResponse' + listSubscriptionsResponse: + $ref: '#/components/messages/listSubscriptionsResponse' + errorResponse: + $ref: '#/components/messages/errorResponse' + control_frames: + address: / + title: Connection Keep-Alive + description: | + Kalshi sends Ping frames every 10 seconds with body `heartbeat`. + Clients should respond with Pong frames. + messages: + incomingPing: + $ref: '#/components/messages/incomingPing' + incomingPong: + $ref: '#/components/messages/incomingPong' + outgoingPing: + $ref: '#/components/messages/outgoingPing' + outgoingPong: + $ref: '#/components/messages/outgoingPong' + orderbook_delta: + address: orderbook_delta + title: Orderbook Updates + description: > + Real-time margin orderbook price-level changes. + + + Requirements: + + - authenticated connection + + - market specification required via `market_ticker` or `market_tickers` + + - sends `orderbook_snapshot` first, then incremental `orderbook_delta` + updates + messages: + orderbookSnapshot: + $ref: '#/components/messages/orderbookSnapshot' + orderbookDelta: + $ref: '#/components/messages/orderbookDelta' + ticker: + address: ticker + title: Market Ticker + description: > + Margin market updates are delivered on a single channel. `ticker` messages + include + + price, top-of-book size, volume, open-interest, and optional + reference/mark prices. + + + Messages are coalesced to at most one per market per second (latest value + wins + + within the window). + + + Requirements: + + - no additional channel-level auth beyond the authenticated WebSocket + connection + + - market specification optional + + - supports `market_ticker`/`market_tickers` + messages: + ticker: + $ref: '#/components/messages/ticker' + trade: + address: trade + title: Public Trades + description: > + Public notifications for executed margin trades. + + + Requirements: + + - no additional channel-level auth beyond the authenticated WebSocket + connection + + - market specification optional via `market_ticker` or `market_tickers` + messages: + trade: + $ref: '#/components/messages/trade' + fill: + address: fill + title: User Fills + description: > + Private fill notifications for the authenticated user on the margin + exchange. + + + Requirements: + + - authenticated connection + + - market specification optional via `market_ticker` or `market_tickers` + + - supports `update_subscription` with `add_markets` and `delete_markets` + messages: + fill: + $ref: '#/components/messages/fill' + user_orders: + address: user_orders + title: User Orders + description: > + Private order created/updated notifications for the authenticated user on + the margin exchange. + + + Requirements: + + - authenticated connection + + - market specification optional via `market_tickers` + + - supports `update_subscription` with `add_markets` and `delete_markets` + messages: + userOrder: + $ref: '#/components/messages/userOrder' + order_group_updates: + address: order_group_updates + title: Order Group Updates + description: > + Real-time order group lifecycle and limit updates. Requires + authentication. + + + **Requirements:** + + - Authentication required + + - Market specification ignored + + - Updates sent when order groups are created, triggered, reset, deleted, + or have limits updated + + + **Use case:** Tracking order group lifecycle and limits + messages: + orderGroupUpdates: + $ref: '#/components/messages/orderGroupUpdates' +operations: + sendPing: + action: receive + title: Send Ping + channel: + $ref: '#/channels/control_frames' + messages: + - $ref: '#/channels/control_frames/messages/incomingPing' + tags: + - name: control-frames + sendPong: + action: receive + title: Send Pong + channel: + $ref: '#/channels/control_frames' + messages: + - $ref: '#/channels/control_frames/messages/incomingPong' + tags: + - name: control-frames + sendSubscribe: + action: receive + title: Subscribe to Channels + channel: + $ref: '#/channels/root' + messages: + - $ref: '#/channels/root/messages/subscribeCommand' + tags: + - name: commands + sendUnsubscribe: + action: receive + title: Unsubscribe from Channels + channel: + $ref: '#/channels/root' + messages: + - $ref: '#/channels/root/messages/unsubscribeCommand' + tags: + - name: commands + sendListSubscriptions: + action: receive + title: List Subscriptions + channel: + $ref: '#/channels/root' + messages: + - $ref: '#/channels/root/messages/listSubscriptionsCommand' + tags: + - name: commands + sendUpdateSubscription: + action: receive + title: Update Subscription - Add Markets + channel: + $ref: '#/channels/root' + messages: + - $ref: '#/channels/root/messages/updateSubscriptionCommand' + tags: + - name: commands + sendUpdateSubscriptionDelete: + action: receive + title: Update Subscription - Delete Markets + channel: + $ref: '#/channels/root' + messages: + - $ref: '#/channels/root/messages/updateSubscriptionDeleteCommand' + tags: + - name: commands + sendUpdateSubscriptionSingleSid: + action: receive + title: Update Subscription - Single SID + channel: + $ref: '#/channels/root' + messages: + - $ref: '#/channels/root/messages/updateSubscriptionSingleSidCommand' + tags: + - name: commands + receivePing: + action: send + title: Receive Ping + channel: + $ref: '#/channels/control_frames' + messages: + - $ref: '#/channels/control_frames/messages/outgoingPing' + tags: + - name: control-frames + receivePong: + action: send + title: Receive Pong + channel: + $ref: '#/channels/control_frames' + messages: + - $ref: '#/channels/control_frames/messages/outgoingPong' + tags: + - name: control-frames + receiveSubscribed: + action: send + title: Subscription Confirmed + channel: + $ref: '#/channels/root' + messages: + - $ref: '#/channels/root/messages/subscribedResponse' + tags: + - name: responses + receiveUnsubscribed: + action: send + title: Unsubscription Confirmed + channel: + $ref: '#/channels/root' + messages: + - $ref: '#/channels/root/messages/unsubscribedResponse' + tags: + - name: responses + receiveOk: + action: send + title: Update Confirmed + channel: + $ref: '#/channels/root' + messages: + - $ref: '#/channels/root/messages/okResponse' + tags: + - name: responses + receiveListSubscriptions: + action: send + title: List Subscriptions Response + channel: + $ref: '#/channels/root' + messages: + - $ref: '#/channels/root/messages/listSubscriptionsResponse' + tags: + - name: responses + receiveError: + action: send + title: Error Response + channel: + $ref: '#/channels/root' + messages: + - $ref: '#/channels/root/messages/errorResponse' + tags: + - name: responses + receiveOrderbookSnapshot: + action: send + title: Orderbook Snapshot + channel: + $ref: '#/channels/orderbook_delta' + messages: + - $ref: '#/channels/orderbook_delta/messages/orderbookSnapshot' + tags: + - name: market-data + receiveOrderbookDelta: + action: send + title: Orderbook Delta + channel: + $ref: '#/channels/orderbook_delta' + messages: + - $ref: '#/channels/orderbook_delta/messages/orderbookDelta' + tags: + - name: market-data + receiveTicker: + action: send + title: Ticker Update + channel: + $ref: '#/channels/ticker' + messages: + - $ref: '#/channels/ticker/messages/ticker' + tags: + - name: market-data + receiveTrade: + action: send + title: Trade Update + channel: + $ref: '#/channels/trade' + messages: + - $ref: '#/channels/trade/messages/trade' + tags: + - name: market-data + receiveFill: + action: send + title: Fill Notification + channel: + $ref: '#/channels/fill' + messages: + - $ref: '#/channels/fill/messages/fill' + tags: + - name: private + receiveUserOrder: + action: send + title: User Order Update + channel: + $ref: '#/channels/user_orders' + messages: + - $ref: '#/channels/user_orders/messages/userOrder' + tags: + - name: private + receiveOrderGroupUpdates: + action: send + title: Order Group Updates + summary: Receive order group lifecycle and limit updates + channel: + $ref: '#/channels/order_group_updates' + messages: + - $ref: '#/channels/order_group_updates/messages/orderGroupUpdates' + tags: + - name: private +components: + messages: + incomingPing: + name: ping + title: Ping + summary: Client sends Ping frame (0x9) to elicit Pong from Kalshi + contentType: application/octet-stream + payload: + type: string + const: '' + examples: + - name: emptyPing + summary: Client sends ping + payload: '' + incomingPong: + name: pong + title: Pong + summary: Client replies to Ping with Pong Frame (0xA) + contentType: application/octet-stream + payload: + type: string + const: '' + examples: + - name: emptyPong + summary: Client sends pong + payload: '' + outgoingPing: + name: ping + title: Ping + summary: Kalshi sends Ping (0x9) with body 'heartbeat' to elicit Pong from client + contentType: application/octet-stream + payload: + type: string + const: heartbeat + examples: + - name: heartbeatPing + summary: Kalshi sends heartbeat ping + payload: heartbeat + outgoingPong: + name: pong + title: Pong + summary: Kalshi responds to client Ping with Pong frame (0xA) + contentType: application/octet-stream + payload: + type: string + const: '' + examples: + - name: emptyPong + summary: Kalshi sends pong + payload: '' + subscribeCommand: + name: subscribe + title: Subscribe Command + summary: Subscribe to one or more margin channels + contentType: application/json + payload: + $ref: '#/components/schemas/subscribeCommandPayload' + unsubscribeCommand: + name: unsubscribe + title: Unsubscribe Command + summary: Cancel one or more subscriptions + contentType: application/json + payload: + $ref: '#/components/schemas/unsubscribeCommandPayload' + listSubscriptionsCommand: + name: list_subscriptions + title: List Subscriptions Command + summary: List all active subscriptions + contentType: application/json + payload: + $ref: '#/components/schemas/listSubscriptionsCommandPayload' + updateSubscriptionCommand: + name: update_subscription_add + title: Update Subscription - Add Markets + summary: Add markets to an existing subscription + contentType: application/json + payload: + $ref: '#/components/schemas/updateSubscriptionCommandPayload' + updateSubscriptionDeleteCommand: + name: update_subscription_delete + title: Update Subscription - Delete Markets + summary: Remove markets from an existing subscription + contentType: application/json + payload: + $ref: '#/components/schemas/updateSubscriptionCommandPayload' + updateSubscriptionSingleSidCommand: + name: update_subscription_single_sid + title: Update Subscription - Single SID + summary: Update a subscription using `sid` rather than `sids` + contentType: application/json + payload: + $ref: '#/components/schemas/updateSubscriptionCommandPayload' + subscribedResponse: + name: subscribed + title: Subscribed Response + summary: Confirmation that subscription was successful + contentType: application/json + payload: + $ref: '#/components/schemas/subscribedResponsePayload' + unsubscribedResponse: + name: unsubscribed + title: Unsubscribed Response + summary: Confirmation that unsubscription was successful + contentType: application/json + payload: + $ref: '#/components/schemas/unsubscribedResponsePayload' + okResponse: + name: ok + title: OK Response + summary: Successful update operation response + contentType: application/json + payload: + $ref: '#/components/schemas/okResponsePayload' + listSubscriptionsResponse: + name: list_subscriptions + title: List Subscriptions Response + summary: Response containing all active subscriptions + contentType: application/json + payload: + $ref: '#/components/schemas/listSubscriptionsResponsePayload' + errorResponse: + name: error + title: Error Response + summary: Error response for failed operations + contentType: application/json + payload: + $ref: '#/components/schemas/errorResponsePayload' + orderbookSnapshot: + name: orderbook_snapshot + title: Orderbook Snapshot + summary: Complete view of the margin order book's aggregated price levels + contentType: application/json + payload: + $ref: '#/components/schemas/marginOrderbookSnapshotPayload' + orderbookDelta: + name: orderbook_delta + title: Orderbook Delta + summary: Update to be applied to the current margin order book view + contentType: application/json + payload: + $ref: '#/components/schemas/marginOrderbookDeltaPayload' + ticker: + name: ticker + title: Ticker Update + summary: Margin market ticker information + contentType: application/json + payload: + $ref: '#/components/schemas/marginTickerPayload' + trade: + name: trade + title: Trade Update + summary: Public margin trade information + contentType: application/json + payload: + $ref: '#/components/schemas/marginTradePayload' + fill: + name: fill + title: Fill Update + summary: Private margin fill information for the authenticated user + contentType: application/json + payload: + $ref: '#/components/schemas/marginFillPayload' + userOrder: + name: user_order + title: User Order Update + summary: Private margin order create/update notifications + contentType: application/json + payload: + $ref: '#/components/schemas/marginUserOrderPayload' + orderGroupUpdates: + name: order_group_updates + title: Order Group Updates + summary: Order group lifecycle and limit updates for authenticated user + contentType: application/json + payload: + $ref: '#/components/schemas/orderGroupUpdatesPayload' + examples: + - name: orderGroupLimitUpdated + summary: Order group limit updated + payload: + type: order_group_updates + sid: 21 + seq: 7 + msg: + event_type: limit_updated + order_group_id: og_123 + contracts_limit_fp: '150.00' + schemas: + commandId: + type: integer + minimum: 0 + description: Unique ID of a command within a WebSocket session + subscriptionId: + type: integer + minimum: 1 + description: Server-generated subscription identifier + sequenceNumber: + type: integer + minimum: 1 + description: Sequence number used for snapshot/delta consistency + marketTicker: + type: string + description: Unique market identifier + bookSide: + type: string + enum: + - bid + - ask + selfTradePreventionType: + type: string + enum: + - taker_at_cross + - maker + description: Self-trade prevention type + lastUpdateReason: + type: string + enum: + - '' + - Decrease + - Amend + - MarginCancel + - SelfTradeCancel + - ExpiryCancel + - Trade + - PostOnlyCrossCancel + description: >- + Margin order update reason when the delta corresponds to the + authenticated user's order. + tickerPrice: + type: object + required: + - price + - ts_ms + properties: + price: + type: string + description: USD price as a fixed-point decimal string (4 decimals) + ts_ms: + type: integer + format: int64 + description: Unix timestamp in milliseconds. + fundingRate: + type: object + required: + - rate + - next_funding_time_ms + - ts_ms + properties: + rate: + type: number + format: double + description: Funding rate as a decimal value. + next_funding_time_ms: + type: integer + format: int64 + description: Unix timestamp in milliseconds for the next funding time. + ts_ms: + type: integer + format: int64 + description: Unix timestamp in milliseconds for the funding snapshot. + priceLevelDollarsCountFp: + type: array + items: + type: string + minItems: 2 + maxItems: 2 + description: '[price_in_dollars, contract_count_fp]' + subscribeCommandPayload: + type: object + required: + - id + - cmd + - params + properties: + id: + $ref: '#/components/schemas/commandId' + cmd: + type: string + const: subscribe + params: + type: object + required: + - channels + properties: + channels: + type: array + minItems: 1 + items: + type: string + enum: + - orderbook_delta + - ticker + - trade + - fill + - user_orders + - order_group_updates + market_ticker: + type: string + market_tickers: + type: array + items: + $ref: '#/components/schemas/marketTicker' + minItems: 1 + send_initial_snapshot: + type: boolean + description: Sends an initial snapshot for ticker subscriptions. + default: false + skip_ticker_ack: + type: boolean + default: false + unsubscribeCommandPayload: + type: object + required: + - id + - cmd + - params + properties: + id: + $ref: '#/components/schemas/commandId' + cmd: + type: string + const: unsubscribe + params: + type: object + required: + - sids + properties: + sids: + type: array + items: + $ref: '#/components/schemas/subscriptionId' + minItems: 1 + updateSubscriptionCommandPayload: + 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' + sids: + type: array + items: + $ref: '#/components/schemas/subscriptionId' + minItems: 1 + maxItems: 1 + market_ticker: + type: string + market_tickers: + type: array + items: + $ref: '#/components/schemas/marketTicker' + minItems: 1 + send_initial_snapshot: + type: boolean + description: Sends an initial snapshot for newly added ticker subscriptions. + default: false + action: + type: string + enum: + - add_markets + - delete_markets + listSubscriptionsCommandPayload: + type: object + required: + - id + - cmd + properties: + id: + $ref: '#/components/schemas/commandId' + cmd: + type: string + const: list_subscriptions + subscribedResponsePayload: + type: object + required: + - type + - msg + properties: + id: + $ref: '#/components/schemas/commandId' + type: + type: string + const: subscribed + msg: + type: object + required: + - channel + - sid + properties: + channel: + type: string + sid: + $ref: '#/components/schemas/subscriptionId' + unsubscribedResponsePayload: + type: object + required: + - sid + - seq + - type + properties: + id: + $ref: '#/components/schemas/commandId' + sid: + $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' + type: + type: string + const: unsubscribed + okResponsePayload: + type: object + required: + - type + properties: + id: + $ref: '#/components/schemas/commandId' + sid: + $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' + type: + type: string + const: ok + msg: + type: object + properties: + market_tickers: + type: array + items: + $ref: '#/components/schemas/marketTicker' + listSubscriptionsResponsePayload: + type: object + required: + - id + - type + - msg + properties: + id: + $ref: '#/components/schemas/commandId' + type: + type: string + const: ok + msg: + type: array + items: + type: object + required: + - channel + - sid + properties: + channel: + type: string + sid: + $ref: '#/components/schemas/subscriptionId' + errorResponsePayload: + type: object + required: + - type + - msg + properties: + id: + $ref: '#/components/schemas/commandId' + type: + type: string + const: error + msg: + type: object + required: + - code + - msg + properties: + code: + type: integer + minimum: 1 + maximum: 18 + msg: + type: string + market_ticker: + type: string + marginOrderbookSnapshotPayload: + type: object + required: + - type + - sid + - seq + - msg + properties: + type: + type: string + const: orderbook_snapshot + sid: + $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' + msg: + type: object + required: + - market_ticker + properties: + market_ticker: + $ref: '#/components/schemas/marketTicker' + bid: + type: array + items: + $ref: '#/components/schemas/priceLevelDollarsCountFp' + ask: + type: array + items: + $ref: '#/components/schemas/priceLevelDollarsCountFp' + marginOrderbookDeltaPayload: + type: object + required: + - type + - sid + - seq + - msg + properties: + type: + type: string + const: orderbook_delta + sid: + $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' + msg: + type: object + required: + - market_ticker + - price + - delta + - side + properties: + market_ticker: + $ref: '#/components/schemas/marketTicker' + price: + type: string + delta: + type: string + side: + $ref: '#/components/schemas/bookSide' + last_update_reason: + $ref: '#/components/schemas/lastUpdateReason' + client_order_id: + type: string + subaccount: + type: integer + ts_ms: + type: integer + format: int64 + description: Unix timestamp in milliseconds. + marginTickerPayload: + type: object + required: + - type + - sid + - msg + properties: + type: + type: string + const: ticker + sid: + $ref: '#/components/schemas/subscriptionId' + msg: + type: object + required: + - market_ticker + - price + - bid + - ask + - bid_size_fp + - ask_size_fp + - last_trade_size_fp + - volume + - volume_24h + - open_interest + - ts_ms + properties: + market_ticker: + $ref: '#/components/schemas/marketTicker' + price: + type: string + description: >- + Last traded price in USD as a fixed-point decimal string (4 + decimals). + bid: + type: string + description: USD price as a fixed-point decimal string (4 decimals). + ask: + type: string + description: USD price as a fixed-point decimal string (4 decimals). + bid_size_fp: + type: string + ask_size_fp: + type: string + last_trade_size_fp: + type: string + volume: + type: string + volume_24h: + type: string + open_interest: + type: string + reference_price: + description: Reference price of underlying asset, when available. + allOf: + - $ref: '#/components/schemas/tickerPrice' + settlement_mark_price: + $ref: '#/components/schemas/tickerPrice' + liquidation_mark_price: + $ref: '#/components/schemas/tickerPrice' + funding_rate: + $ref: '#/components/schemas/fundingRate' + ts_ms: + type: integer + format: int64 + description: Unix timestamp in milliseconds. + marginTradePayload: + type: object + required: + - type + - sid + - msg + properties: + type: + type: string + const: trade + sid: + $ref: '#/components/schemas/subscriptionId' + msg: + type: object + required: + - trade_id + - market_ticker + - price + - count + - taker_side + - ts_ms + properties: + trade_id: + type: string + format: uuid + market_ticker: + $ref: '#/components/schemas/marketTicker' + price: + type: string + count: + type: string + taker_side: + $ref: '#/components/schemas/bookSide' + ts_ms: + type: integer + format: int64 + description: Unix timestamp in milliseconds. + marginFillPayload: + type: object + required: + - type + - sid + - msg + properties: + type: + type: string + const: fill + sid: + $ref: '#/components/schemas/subscriptionId' + msg: + type: object + required: + - trade_id + - order_id + - market_ticker + - is_taker + - side + - ts_ms + - price + - count + - fee_cost + - post_position + properties: + trade_id: + type: string + format: uuid + order_id: + type: string + format: uuid + client_order_id: + type: string + market_ticker: + $ref: '#/components/schemas/marketTicker' + is_taker: + type: boolean + side: + $ref: '#/components/schemas/bookSide' + ts_ms: + type: integer + format: int64 + description: Unix timestamp in milliseconds. + price: + type: string + count: + type: string + fee_cost: + type: string + post_position: + type: string + subaccount: + type: integer + marginUserOrderPayload: + type: object + required: + - type + - sid + - msg + properties: + type: + type: string + const: user_order + sid: + $ref: '#/components/schemas/subscriptionId' + msg: + type: object + required: + - order_id + - user_id + - client_order_id + - ticker + - side + - price + - fill_count + - remaining_count + - created_ts_ms + properties: + order_id: + type: string + format: uuid + user_id: + type: string + format: uuid + client_order_id: + type: string + ticker: + $ref: '#/components/schemas/marketTicker' + side: + $ref: '#/components/schemas/bookSide' + price: + type: string + fill_count: + type: string + remaining_count: + type: string + self_trade_prevention_type: + $ref: '#/components/schemas/selfTradePreventionType' + order_group_id: + type: string + description: Order group identifier, if the order belongs to an order group + expiration_ts_ms: + type: integer + format: int64 + description: Unix timestamp in milliseconds. + created_ts_ms: + type: integer + format: int64 + description: Unix timestamp in milliseconds. + last_updated_ts_ms: + type: integer + format: int64 + description: Unix timestamp in milliseconds. + subaccount_number: + type: integer + orderGroupUpdatesPayload: + type: object + required: + - type + - sid + - seq + - msg + properties: + type: + type: string + const: order_group_updates + sid: + $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' + msg: + type: object + required: + - event_type + - order_group_id + - ts_ms + properties: + event_type: + type: string + description: Order group event type + enum: + - created + - triggered + - reset + - deleted + - limit_updated + order_group_id: + type: string + description: Order group identifier + contracts_limit_fp: + type: string + description: >- + Updated contracts limit in fixed-point (2 decimals). Present for + "created" and "limit_updated" events only. + ts_ms: + type: integer + format: int64 + description: >- + Matching engine timestamp at which the event was processed, as + Unix epoch milliseconds. + securitySchemes: + apiKey: + type: apiKey + in: user + description: API key authentication required for margin WebSocket connections. diff --git a/specs/perps_openapi.yaml b/specs/perps_openapi.yaml new file mode 100644 index 0000000..3e46142 --- /dev/null +++ b/specs/perps_openapi.yaml @@ -0,0 +1,2752 @@ +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 +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: + /margin/exchange/status: + get: + operationId: GetMarginExchangeStatus + summary: Get Exchange Status + description: Endpoint for getting the margin exchange status. + tags: + - exchange + responses: + '200': + description: Margin exchange status retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ExchangeStatus' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ExchangeStatus' + '503': + description: Service unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/ExchangeStatus' + '504': + description: Gateway timeout + content: + 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. + tags: + - risk + responses: + '200': + description: Margin risk parameters retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginRiskParametersResponse' + /margin/orders: + get: + operationId: GetMarginOrders + summary: Get Orders + description: Endpoint for listing margin orders with optional filtering. + tags: + - orders + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/TickerQuery' + - $ref: '#/components/parameters/MinTsQuery' + - $ref: '#/components/parameters/MaxTsQuery' + - $ref: '#/components/parameters/StatusQuery' + - $ref: '#/components/parameters/LimitQuery' + - $ref: '#/components/parameters/CursorQuery' + - $ref: '#/components/parameters/SubaccountQuery' + responses: + '200': + description: Orders retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginOrdersResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '500': + $ref: '#/components/responses/InternalServerError' + post: + operationId: CreateMarginOrder + summary: Create Order + description: Endpoint for submitting orders in a market. + tags: + - orders + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateMarginOrderRequest' + responses: + '201': + description: Order created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CreateMarginOrderResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '409': + $ref: '#/components/responses/ConflictError' + '429': + $ref: '#/components/responses/RateLimitError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/fcm/orders: + get: + operationId: GetMarginFCMOrders + summary: Get FCM Orders + 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. + tags: + - fcm + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - name: subtrader_id + in: query + required: true + 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/CursorQuery' + responses: + '200': + description: Margin orders retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginOrdersResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/orders/{order_id}: + get: + operationId: GetMarginOrder + summary: Get Order + description: Endpoint for retrieving a specific margin order. + tags: + - orders + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/OrderIdPath' + responses: + '200': + description: Order retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginOrderResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '404': + $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. + tags: + - orders + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/OrderIdPath' + - $ref: '#/components/parameters/SubaccountQueryDefaultPrimary' + responses: + '200': + description: Order cancelled successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CancelMarginOrderResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '404': + $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. + tags: + - orders + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/OrderIdPath' + - $ref: '#/components/parameters/SubaccountQueryDefaultPrimary' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DecreaseMarginOrderRequest' + responses: + '200': + description: Order decreased successfully + content: + application/json: + schema: + $ref: '#/components/schemas/DecreaseMarginOrderResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '404': + $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. + x-mint: + 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. + + + tags: + - orders + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/OrderIdPath' + - $ref: '#/components/parameters/SubaccountQueryDefaultPrimary' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AmendMarginOrderRequest' + responses: + '200': + description: Order amended successfully + content: + application/json: + schema: + $ref: '#/components/schemas/AmendMarginOrderResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '404': + $ref: '#/components/responses/NotFoundError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/markets: + get: + operationId: GetMarginMarkets + summary: Get Markets + description: Endpoint for listing available margin markets. + tags: + - market + parameters: + - in: query + name: status + required: false + schema: + $ref: '#/components/schemas/MarginMarketStatus' + x-go-type-skip-optional-pointer: true + description: Filter by market status (e.g. active, inactive, closed) + responses: + '200': + description: Markets retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginMarketsResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '429': + $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). + tags: + - market + parameters: + - in: path + name: ticker + required: true + schema: + type: string + description: Ticker of the market + responses: + '200': + description: Market retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/MarginMarketResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '404': + $ref: '#/components/responses/NotFoundError' + '429': + $ref: '#/components/responses/RateLimitError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/markets/{ticker}/orderbook: + get: + operationId: GetMarginMarketOrderbook + summary: Get Market Orderbook + description: Endpoint for getting the orderbook for a margin market. + tags: + - market + parameters: + - in: path + name: ticker + required: true + schema: + type: string + description: Ticker of the market + - name: depth + in: query + description: Depth of the orderbook to retrieve (0 means all levels) + required: false + schema: + type: integer + minimum: 0 + 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) + required: false + schema: + type: string + responses: + '200': + description: Orderbook retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/MarginOrderbookResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '404': + $ref: '#/components/responses/NotFoundError' + '429': + $ref: '#/components/responses/RateLimitError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/markets/{ticker}/candlesticks: + get: + operationId: GetMarginMarketCandlesticks + summary: Get Market Candlesticks + description: Endpoint for fetching candlestick data for a margin market. + tags: + - market + parameters: + - in: path + name: ticker + required: true + schema: + type: string + description: Ticker of the margin market + - name: start_ts + in: query + required: true + 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. + 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). + schema: + type: integer + enum: + - 1 + - 60 + - 1440 + x-oapi-codegen-extra-tags: + 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: + + 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 + schema: + type: boolean + default: false + responses: + '200': + description: Candlesticks retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginMarketCandlesticksResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '404': + $ref: '#/components/responses/NotFoundError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/fills: + get: + operationId: GetMarginFills + summary: Get Fills + description: Endpoint for retrieving the authenticated user's margin fills. + tags: + - portfolio + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - name: subaccount + in: query + required: false + description: Subaccount number (0 for primary, 1-32 for subaccounts) + schema: + type: integer + default: 0 + - name: limit + in: query + required: false + description: Number of results per page + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 100 + x-go-type-skip-optional-pointer: true + - name: cursor + in: query + required: false + description: Pagination cursor from a previous response + schema: + type: string + x-go-type-skip-optional-pointer: true + - name: min_ts + in: query + required: false + schema: + type: integer + format: int64 + description: Filter fills after this Unix timestamp + - name: max_ts + in: query + required: false + schema: + type: integer + format: int64 + description: Filter fills before this Unix timestamp + responses: + '200': + description: Fills retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginFillsResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $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. + tags: + - portfolio + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - name: subaccount + in: query + required: false + description: Subaccount number (0 for primary, 1-32 for subaccounts) + schema: + type: integer + - name: ticker + in: query + required: false + description: Filter positions by market ticker + schema: + type: string + x-go-type-skip-optional-pointer: true + responses: + '200': + description: Positions retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginPositionsResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $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. + tags: + - market + parameters: + - name: ticker + in: query + required: true + schema: + type: string + description: Market ticker to retrieve trades for + - name: limit + in: query + required: false + description: Number of results per page + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 100 + x-go-type-skip-optional-pointer: true + - name: cursor + in: query + required: false + description: Pagination cursor from a previous response + schema: + type: string + x-go-type-skip-optional-pointer: true + - name: min_ts + in: query + required: false + schema: + type: integer + format: int64 + description: Filter trades after this Unix timestamp + - name: max_ts + in: query + required: false + schema: + type: integer + format: int64 + description: Filter trades before this Unix timestamp + responses: + '200': + description: Trades retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginTradesResponse' + '400': + $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. + tags: + - exchange + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + responses: + '200': + description: Margin enabled status retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/MarginEnabledResponse' + '401': + $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. + tags: + - risk + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + responses: + '200': + description: Notional risk limit retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/NotionalRiskLimitResponse' + '401': + $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. + x-mint: + 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). + + + tags: + - portfolio + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - name: compute_available_balance + in: query + required: false + schema: + 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. + responses: + '200': + description: Margin balance retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginBalanceResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '429': + $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. + tags: + - risk + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + responses: + '200': + description: Margin risk data retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginRiskResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $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. + tags: + - fees + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + responses: + '200': + description: Fee tiers retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginFeeTiersResponse' + '401': + $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. + tags: + - funding + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - name: ticker + in: query + required: false + 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) + schema: + type: string + format: date + x-go-type-skip-optional-pointer: true + x-oapi-codegen-extra-tags: + validate: required + - name: end_date + in: query + required: true + description: Inclusive UTC end date for funding history range (YYYY-MM-DD format) + schema: + type: string + format: date + x-go-type-skip-optional-pointer: true + x-oapi-codegen-extra-tags: + validate: required + - $ref: '#/components/parameters/SubaccountQuery' + responses: + '200': + description: Funding history retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginFundingHistoryResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $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. + tags: + - funding + parameters: + - name: ticker + in: query + required: false + description: Market ticker. Leave empty to query across all markets. + schema: + type: string + x-go-type-skip-optional-pointer: true + - name: start_ts + in: query + required: false + 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. + schema: + type: integer + format: int64 + responses: + '200': + description: Historical funding rates retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginHistoricalFundingRatesResponse' + '400': + $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`. + tags: + - funding + parameters: + - name: ticker + in: query + required: true + description: Market ticker + schema: + type: string + x-go-type-skip-optional-pointer: true + x-oapi-codegen-extra-tags: + validate: required + responses: + '200': + description: Funding rate estimate retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginFundingRateEstimateResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '500': + $ref: '#/components/responses/InternalServerError' + /portfolio/intra_exchange_instance_transfer: + post: + operationId: IntraExchangeInstanceTransfer + summary: Intra Account Transfer + description: ' Endpoint for transferring funds within the same account. This endpoint is currently not available.' + tags: + - portfolio + security: + - kalshiAccessKey: [] + - kalshiAccessSignature: [] + - kalshiAccessTimestamp: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IntraExchangeInstanceTransferRequest' + responses: + '200': + description: Transfer request accepted. The transfer is processed asynchronously. + content: + application/json: + schema: + $ref: '#/components/schemas/IntraExchangeInstanceTransferResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $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. + tags: + - portfolio + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + responses: + '201': + description: Margin subaccount created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CreateSubaccountResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $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. + tags: + - portfolio + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ApplySubaccountTransferRequest' + responses: + '200': + description: Transfer completed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ApplySubaccountTransferResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $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. + tags: + - order-groups + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/SubaccountQuery' + responses: + '200': + description: Order groups retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetOrderGroupsResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $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. + tags: + - order-groups + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateOrderGroupRequest' + responses: + '201': + description: Order group created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CreateOrderGroupResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $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. + tags: + - order-groups + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/OrderGroupIdPath' + - $ref: '#/components/parameters/SubaccountQuery' + responses: + '200': + description: Order group retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetOrderGroupResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '404': + $ref: '#/components/responses/NotFoundError' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: DeleteMarginOrderGroup + summary: Delete Order Group + description: >- + Deletes an order group on the margin exchange and cancels all orders + within it. + tags: + - order-groups + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/OrderGroupIdPath' + - $ref: '#/components/parameters/SubaccountQueryDefaultPrimary' + responses: + '200': + description: Order group deleted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '404': + $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. + tags: + - order-groups + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/OrderGroupIdPath' + - $ref: '#/components/parameters/SubaccountQueryDefaultPrimary' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyResponse' + responses: + '200': + description: Order group reset successfully + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '404': + $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. + tags: + - order-groups + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/OrderGroupIdPath' + - $ref: '#/components/parameters/SubaccountQueryDefaultPrimary' + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyResponse' + responses: + '200': + description: Order group triggered successfully + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '404': + $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. + tags: + - order-groups + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/OrderGroupIdPath' + - $ref: '#/components/parameters/SubaccountQueryDefaultPrimary' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateOrderGroupLimitRequest' + responses: + '200': + description: Order group limit updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '404': + $ref: '#/components/responses/NotFoundError' + '500': + $ref: '#/components/responses/InternalServerError' +components: + securitySchemes: + kalshiAccessKey: + type: apiKey + in: header + name: KALSHI-ACCESS-KEY + description: Your API key ID + kalshiAccessSignature: + type: apiKey + in: header + name: KALSHI-ACCESS-SIGNATURE + description: RSA-PSS signature of the request + kalshiAccessTimestamp: + type: apiKey + in: header + name: KALSHI-ACCESS-TIMESTAMP + description: Request timestamp in milliseconds + responses: + BadRequestError: + description: Bad request - invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + ConflictError: + description: Conflict - resource already exists or cannot be modified + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + ForbiddenError: + description: Forbidden - insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + InternalServerError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + NotFoundError: + description: Resource not found + content: + application/json: + 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). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + UnauthorizedError: + description: Unauthorized - authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + schemas: + ApplySubaccountTransferRequest: + type: object + required: + - client_transfer_id + - from_subaccount + - to_subaccount + - amount_cents + properties: + client_transfer_id: + type: string + format: uuid + description: Unique client-provided transfer ID for idempotency. + x-oapi-codegen-extra-tags: + validate: required + from_subaccount: + type: integer + description: >- + Source subaccount number (0 for primary, 1-32 for numbered + subaccounts). + to_subaccount: + type: integer + description: >- + Destination subaccount number (0 for primary, 1-32 for numbered + subaccounts). + amount_cents: + type: integer + format: int64 + description: Amount to transfer in cents. + ApplySubaccountTransferResponse: + type: object + description: Empty response indicating successful transfer. + CreateOrderGroupRequest: + type: object + properties: + subaccount: + type: integer + minimum: 0 + description: >- + Optional subaccount number to use for this order group (0 for + primary, 1-32 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. + 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. + exchange_index: + allOf: + - $ref: '#/components/schemas/ExchangeIndex' + default: 0 + x-go-type-skip-optional-pointer: true + CreateOrderGroupResponse: + type: object + required: + - order_group_id + - subaccount + properties: + order_group_id: + type: string + description: The unique identifier for the created order group + subaccount: + type: integer + minimum: 0 + description: >- + Subaccount number that owns the created order group (0 for primary, + 1-32 for subaccounts). + x-go-type-skip-optional-pointer: true + exchange_index: + allOf: + - $ref: '#/components/schemas/ExchangeIndex' + x-go-type-skip-optional-pointer: true + CreateSubaccountResponse: + type: object + required: + - subaccount_number + properties: + subaccount_number: + type: integer + description: The sequential number assigned to this subaccount (1-32). + EmptyResponse: + type: object + description: An empty response body + ErrorResponse: + type: object + properties: + code: + type: string + description: Error code + message: + type: string + description: Human-readable error message + details: + type: string + description: Additional details about the error, if available + service: + type: string + 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. + example: 0 + ExchangeInstance: + type: string + enum: + - event_contract + - margined + description: The exchange instance type + 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' + 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' + GetOrderGroupResponse: + type: object + required: + - is_auto_cancel_enabled + - orders + properties: + is_auto_cancel_enabled: + type: boolean + 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. + x-go-type-skip-optional-pointer: true + orders: + type: array + items: + type: string + description: List of order IDs that belong to this order group + x-go-type-skip-optional-pointer: true + exchange_index: + allOf: + - $ref: '#/components/schemas/ExchangeIndex' + x-go-type-skip-optional-pointer: true + GetOrderGroupsResponse: + type: object + properties: + order_groups: + type: array + items: + $ref: '#/components/schemas/OrderGroup' + x-go-type-skip-optional-pointer: true + IntraExchangeInstanceTransferRequest: + type: object + required: + - source + - destination + - amount + properties: + source: + $ref: '#/components/schemas/ExchangeInstance' + description: The source exchange instance + destination: + $ref: '#/components/schemas/ExchangeInstance' + description: The destination exchange instance + amount: + type: integer + format: int64 + description: The amount to transfer in centicents + source_exchange_shard: + type: integer + default: 0 + x-go-type-skip-optional-pointer: true + description: Source exchange shard index (default 0) + destination_exchange_shard: + type: integer + default: 0 + x-go-type-skip-optional-pointer: true + description: Destination exchange shard index (default 0) + IntraExchangeInstanceTransferResponse: + type: object + required: + - transfer_id + properties: + transfer_id: + type: string + description: The ID of the transfer that was created + OrderGroup: + type: object + required: + - id + - is_auto_cancel_enabled + properties: + id: + type: string + description: Unique identifier for the order group + 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. + x-go-type-skip-optional-pointer: true + is_auto_cancel_enabled: + type: boolean + description: Whether auto-cancel is enabled for this order group + x-go-type-skip-optional-pointer: true + exchange_index: + allOf: + - $ref: '#/components/schemas/ExchangeIndex' + x-go-type-skip-optional-pointer: true + PriceLevelDollarsCountFp: + type: array + minItems: 2 + maxItems: 2 + 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). + 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. + UpdateOrderGroupLimitRequest: + type: object + properties: + contracts_limit: + 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. + 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. + ExchangeStatus: + type: object + required: + - exchange_active + - trading_active + properties: + exchange_active: + type: boolean + 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. + GetMarginRiskParametersResponse: + type: object + required: + - liquidation_margin_ratio_threshold + - queue_entry_margin_ratio_threshold + - initial_margin_multiplier + properties: + liquidation_margin_ratio_threshold: + type: number + format: double + description: Margin ratio at which a position is liquidated. + queue_entry_margin_ratio_threshold: + type: number + format: double + description: Margin ratio at which a position enters the liquidation queue. + initial_margin_multiplier: + type: object + 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. + CreateMarginOrderRequest: + type: object + required: + - ticker + - client_order_id + - side + - count + - price + - time_in_force + - self_trade_prevention_type + properties: + ticker: + type: string + x-oapi-codegen-extra-tags: + validate: required,min=1 + client_order_id: + type: string + x-go-type-skip-optional-pointer: true + side: + $ref: '#/components/schemas/BookSide' + x-oapi-codegen-extra-tags: + validate: required,oneof=bid ask + count: + $ref: '#/components/schemas/FixedPointCount' + description: String representation of the order quantity in contracts. + price: + $ref: '#/components/schemas/FixedPointDollars' + description: Price for the order in fixed-point dollars. + x-go-type-skip-optional-pointer: true + expiration_time: + type: integer + format: int64 + time_in_force: + type: string + 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 + post_only: + type: boolean + self_trade_prevention_type: + allOf: + - $ref: '#/components/schemas/SelfTradePreventionType' + x-oapi-codegen-extra-tags: + validate: required,oneof=taker_at_cross maker + 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. + reduce_only: + type: boolean + description: >- + Specifies whether the order place count should be capped by the + member's current position. + subaccount: + type: integer + minimum: 0 + default: 0 + 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: + - order_id + - fill_count + - remaining_count + properties: + order_id: + type: string + client_order_id: + type: string + fill_count: + $ref: '#/components/schemas/FixedPointCount' + 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. + average_fill_price: + $ref: '#/components/schemas/FixedPointDollars' + 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. + GetMarginOrderResponse: + type: object + required: + - order + properties: + order: + $ref: '#/components/schemas/MarginOrder' + GetMarginOrdersResponse: + type: object + required: + - orders + - cursor + properties: + orders: + type: array + items: + $ref: '#/components/schemas/MarginOrder' + cursor: + type: string + MarginOrder: + type: object + required: + - order_id + - user_id + - client_order_id + - ticker + - side + - price + - fill_count + - remaining_count + - last_update_reason + properties: + order_id: + type: string + user_id: + type: string + description: Unique identifier for users + client_order_id: + type: string + ticker: + type: string + side: + $ref: '#/components/schemas/BookSide' + last_update_reason: + $ref: '#/components/schemas/LastUpdateReason' + price: + $ref: '#/components/schemas/FixedPointDollars' + description: Price for the order in fixed-point dollars. + fill_count: + $ref: '#/components/schemas/FixedPointCount' + description: Fixed-point contract count for filled quantity + remaining_count: + $ref: '#/components/schemas/FixedPointCount' + description: Fixed-point contract count for remaining quantity + expiration_time: + type: string + format: date-time + nullable: true + created_time: + type: string + format: date-time + nullable: true + x-omitempty: false + last_update_time: + type: string + format: date-time + nullable: true + x-omitempty: true + description: The last update to an order (modify, cancel, fill) + self_trade_prevention_type: + $ref: '#/components/schemas/SelfTradePreventionType' + nullable: true + 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. + 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. + CancelMarginOrderResponse: + type: object + required: + - order_id + - reduced_by + properties: + order_id: + type: string + client_order_id: + type: string + reduced_by: + $ref: '#/components/schemas/FixedPointCount' + 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. + 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. + DecreaseMarginOrderResponse: + type: object + required: + - order_id + - remaining_count + properties: + order_id: + type: string + client_order_id: + type: string + remaining_count: + $ref: '#/components/schemas/FixedPointCount' + description: Number of contracts remaining after the decrease. + AmendMarginOrderRequest: + type: object + required: + - ticker + - side + - price + - count + properties: + ticker: + type: string + description: Market ticker + x-oapi-codegen-extra-tags: + validate: required,min=1 + side: + $ref: '#/components/schemas/BookSide' + description: Side of the order + x-oapi-codegen-extra-tags: + validate: required,oneof=bid ask + price: + $ref: '#/components/schemas/FixedPointDollars' + description: Updated price for the order in fixed-point dollars. + x-go-type-skip-optional-pointer: true + count: + $ref: '#/components/schemas/FixedPointCount' + description: String representation of the updated quantity for the order. + x-go-type-skip-optional-pointer: true + client_order_id: + type: string + description: The original client-specified order ID to be amended + x-go-type-skip-optional-pointer: true + updated_client_order_id: + type: string + description: The new client-specified order ID after amendment + x-go-type-skip-optional-pointer: true + AmendMarginOrderResponse: + type: object + required: + - order_id + properties: + order_id: + type: string + client_order_id: + type: string + remaining_count: + $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. + 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. + 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. + 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. + MarginOrderbookCount: + type: object + required: + - bids + - asks + properties: + bids: + type: array + 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]. + items: + $ref: '#/components/schemas/PriceLevelDollarsCountFp' + MarginOrderbookResponse: + type: object + required: + - orderbook + properties: + orderbook: + $ref: '#/components/schemas/MarginOrderbookCount' + MarginMarket: + type: object + required: + - ticker + - status + - title + - contract_size + - tick_size + - fractional_trading_enabled + properties: + ticker: + type: string + title: + type: string + contract_size: + type: string + description: Fixed-point number with 6 decimal places + tick_size: + $ref: '#/components/schemas/FixedPointDollars' + description: Minimum price increment in dollars. + status: + $ref: '#/components/schemas/MarginMarketStatus' + fractional_trading_enabled: + type: boolean + leverage_estimate: + 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. + Null when margin config or price data is unavailable. + price: + $ref: '#/components/schemas/FixedPointDollars' + description: Last trade price in dollars. + volume: + $ref: '#/components/schemas/FixedPointCount' + description: One sided total trade volume. + open_interest: + $ref: '#/components/schemas/FixedPointCount' + description: One sided open interest. + volume_24h: + $ref: '#/components/schemas/FixedPointCount' + description: One sided trade volume in the last 24 hours. + 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 + 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. + LastUpdateReason: + type: string + enum: + - '' + - Decrease + - Amend + - MarginCancel + - SelfTradeCancel + - ExpiryCancel + - Trade + - PostOnlyCrossCancel + MarginMarketResponse: + type: object + required: + - market + properties: + market: + $ref: '#/components/schemas/MarginMarket' + GetMarginMarketsResponse: + type: object + required: + - markets + properties: + markets: + type: array + items: + $ref: '#/components/schemas/MarginMarket' + GetMarginFillsResponse: + type: object + required: + - fills + - cursor + properties: + fills: + type: array + items: + $ref: '#/components/schemas/MarginFill' + cursor: + type: string + MarginFill: + type: object + required: + - fill_id + - order_id + - is_taker + - side + - count + - created_time + - ticker + - price + - entry_price + - fees + - realized_pnl + properties: + fill_id: + type: string + description: Unique identifier for the fill + order_id: + type: string + description: The order ID that generated this fill + is_taker: + type: boolean + description: Whether the user was the taker in this fill + side: + $ref: '#/components/schemas/BookSide' + count: + $ref: '#/components/schemas/FixedPointCount' + created_time: + type: string + format: date-time + description: When the fill was executed + ticker: + type: string + description: Market ticker symbol + price: + type: string + description: Fill price in fixed-point dollars + entry_price: + type: string + 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 + order_source: + $ref: '#/components/schemas/OrderSource' + GetMarginPositionsResponse: + type: object + required: + - positions + properties: + positions: + type: array + items: + $ref: '#/components/schemas/MarginPosition' + MarginPosition: + type: object + required: + - market_ticker + - position + - entry_price + - unrealized_pnl + - margin_used + - fees + properties: + market_ticker: + type: string + description: Market ticker symbol + position: + $ref: '#/components/schemas/FixedPointCount' + 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 + unrealized_pnl: + $ref: '#/components/schemas/FixedPointDollars' + description: Mark-to-market unrealized PnL for the open position + margin_used: + $ref: '#/components/schemas/FixedPointDollars' + 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 + 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 + GetMarginTradesResponse: + type: object + required: + - trades + - cursor + properties: + trades: + type: array + items: + $ref: '#/components/schemas/MarginTrade' + cursor: + type: string + MarginTrade: + type: object + required: + - trade_id + - ticker + - count + - price + - created_time + - taker_side + properties: + trade_id: + type: string + description: Unique identifier for the trade + ticker: + type: string + description: Market ticker symbol + count: + $ref: '#/components/schemas/FixedPointCount' + description: Number of contracts traded + price: + type: string + description: Trade price in dollars + created_time: + type: string + format: date-time + description: When the trade was executed + taker_side: + $ref: '#/components/schemas/BookSide' + description: Side of the taker in this trade + BookSide: + type: string + enum: + - bid + - ask + description: The side of an order or trade (bid or ask) + MarginEnabledResponse: + type: object + required: + - enabled + properties: + enabled: + type: boolean + description: Indicates whether margin trading is enabled for the user + NotionalRiskLimitResponse: + type: object + required: + - default_notional_value_risk_limit + - notional_value_risk_limits_by_market_ticker + 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' + 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. + example: + market-abc-123: '5000.0000' + MarginSubaccountBalance: + type: object + required: + - subaccount + - position_value + - account_equity + - maintenance_margin + - initial_margin + - resting_orders_margin + - available_balance + properties: + subaccount: + type: integer + description: The subaccount number (0 for primary, 1-32 for subaccounts) + position_value: + $ref: '#/components/schemas/FixedPointDollars' + 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. + maintenance_margin: + $ref: '#/components/schemas/FixedPointDollars' + 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. + 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. + 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. + GetMarginBalanceResponse: + type: object + required: + - subaccount_balances + - settled_funds + properties: + subaccount_balances: + type: array + items: + $ref: '#/components/schemas/MarginSubaccountBalance' + description: Per-subaccount balance breakdown + settled_funds: + $ref: '#/components/schemas/FixedPointDollars' + description: Total settled funds across all subaccounts in fixed-point dollars + MarginRiskPosition: + type: object + required: + - subaccount + - market_ticker + - position + - mark_price + - position_notional + properties: + subaccount: + type: integer + description: The subaccount number (0 for primary, 1-32 for subaccounts) + market_ticker: + type: string + description: Market ticker symbol + position: + $ref: '#/components/schemas/FixedPointCount' + description: Signed position quantity as a fixed-point count string + mark_price: + $ref: '#/components/schemas/FixedPointDollars' + 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 + maintenance_margin_required: + $ref: '#/components/schemas/FixedPointDollars' + 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. + 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. + nullable: true + GetMarginRiskResponse: + type: object + required: + - total_position_notional + - total_maintenance_margin + - positions + properties: + account_leverage: + type: number + format: double + 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 + total_maintenance_margin: + $ref: '#/components/schemas/FixedPointDollars' + 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: + - maker_fee_rates + - taker_fee_rates + properties: + maker_fee_rates: + type: object + 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. + 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. + MarginFundingHistoryEntry: + type: object + required: + - market_ticker + - funding_time + - funding_rate + - mark_price + - funding_amount + - quantity + - subaccount_number + properties: + market_ticker: + type: string + description: Ticker of the margin market + funding_time: + type: string + format: date-time + description: Timestamp when the funding payment was applied + funding_rate: + type: number + format: double + description: Funding rate for this period + mark_price: + $ref: '#/components/schemas/FixedPointDollars' + 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) + quantity: + $ref: '#/components/schemas/FixedPointCount' + description: Position size at time of funding as a fixed-point count string + subaccount_number: + type: integer + nullable: true + description: Subaccount number (0 for primary) + GetMarginFundingHistoryResponse: + type: object + required: + - funding_history + properties: + funding_history: + type: array + items: + $ref: '#/components/schemas/MarginFundingHistoryEntry' + description: Array of historical funding payment entries + MarginFundingRate: + type: object + required: + - market_ticker + - funding_time + - funding_rate + - mark_price + properties: + market_ticker: + type: string + description: Ticker of the margin market + funding_time: + type: string + format: date-time + description: Timestamp when the funding rate was applied + funding_rate: + type: number + format: double + description: Funding rate for this period + mark_price: + $ref: '#/components/schemas/FixedPointDollars' + description: Mark price at the time of funding + GetMarginHistoricalFundingRatesResponse: + type: object + required: + - funding_rates + properties: + funding_rates: + type: array + items: + $ref: '#/components/schemas/MarginFundingRate' + description: Array of historical funding rate entries + GetMarginFundingRateEstimateResponse: + type: object + required: + - next_funding_time + properties: + market_ticker: + type: string + description: Ticker of the margin market + computed_time: + type: string + format: date-time + description: Timestamp when this estimate was computed + funding_rate: + type: number + format: double + description: Estimated funding rate for the in-progress period + mark_price: + $ref: '#/components/schemas/FixedPointDollars' + description: Mark price at the time the estimate was computed + next_funding_time: + type: string + format: date-time + description: Timestamp of the next scheduled funding event + GetMarginMarketCandlesticksResponse: + type: object + required: + - ticker + - candlesticks + properties: + ticker: + type: string + description: Unique identifier for the margin market. + candlesticks: + type: array + description: Array of candlestick data points for the specified time range. + items: + $ref: '#/components/schemas/MarginMarketCandlestick' + MarginMarketCandlestick: + type: object + required: + - end_period_ts + - bid + - ask + - price + - volume + - open_interest + properties: + end_period_ts: + type: integer + format: int64 + 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. + ask: + $ref: '#/components/schemas/BidAskDistributionHistorical' + 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. + volume: + $ref: '#/components/schemas/FixedPointCount' + description: >- + Number 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). + BidAskDistributionHistorical: + type: object + required: + - open + - 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. + properties: + open: + $ref: '#/components/schemas/FixedPointDollars' + description: Quoted price at the start of the candlestick period (in dollars). + low: + $ref: '#/components/schemas/FixedPointDollars' + description: Lowest quoted price during the candlestick period (in dollars). + high: + $ref: '#/components/schemas/FixedPointDollars' + description: Highest quoted price during the candlestick period (in dollars). + close: + $ref: '#/components/schemas/FixedPointDollars' + description: Quoted price at the end of the candlestick period (in dollars). + PriceDistributionHistorical: + type: object + required: + - open + - low + - high + - close + - mean + - previous + properties: + open: + allOf: + - $ref: '#/components/schemas/FixedPointDollars' + nullable: true + 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. + high: + allOf: + - $ref: '#/components/schemas/FixedPointDollars' + nullable: true + 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. + mean: + allOf: + - $ref: '#/components/schemas/FixedPointDollars' + nullable: true + 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. + 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. + schema: + type: string + x-go-type-skip-optional-pointer: true + LimitQuery: + name: limit + in: query + description: Number of results per page. Defaults to 100. + schema: + type: integer + format: int64 + minimum: 1 + maximum: 1000 + default: 100 + x-oapi-codegen-extra-tags: + validate: omitempty,min=1,max=1000 + MaxTsQuery: + name: max_ts + in: query + description: Filter items before this Unix timestamp + schema: + type: integer + format: int64 + MinTsQuery: + name: min_ts + in: query + description: Filter items after this Unix timestamp + schema: + type: integer + format: int64 + OrderGroupIdPath: + name: order_group_id + in: path + required: true + description: Order group ID + schema: + type: string + OrderIdPath: + name: order_id + in: path + required: true + description: Order ID + schema: + type: string + StatusQuery: + name: status + in: query + description: Filter by status. Possible values depend on the endpoint. + schema: + type: string + TickerQuery: + name: ticker + in: query + description: Filter by market ticker + schema: + type: string + x-go-type-skip-optional-pointer: true + SubaccountQuery: + name: subaccount + in: query + required: false + description: >- + Subaccount number (0 for primary, 1-32 for subaccounts). If omitted, + defaults to all subaccounts. + schema: + type: integer + minimum: 0 + SubaccountQueryDefaultPrimary: + name: subaccount + in: query + required: false + description: Subaccount number (0 for primary, 1-32 for subaccounts). Defaults to 0. + schema: + type: integer + minimum: 0 + default: 0 +tags: + - name: exchange + description: Exchange status and information endpoints + - name: market + description: Market data endpoints + - name: orders + description: Order management endpoints + - name: order-groups + description: Order group management endpoints + - name: portfolio + description: Portfolio and balance information endpoints + - name: fcm + description: FCM member specific endpoints + - name: risk + description: Margin risk metrics, parameters, and limits + - name: funding + description: Funding rates and payment history + - name: fees + description: Margin fee schedule diff --git a/specs/perps_scm_openapi.yaml b/specs/perps_scm_openapi.yaml new file mode 100644 index 0000000..46426f2 --- /dev/null +++ b/specs/perps_scm_openapi.yaml @@ -0,0 +1,761 @@ +openapi: 3.0.0 +info: + title: Kalshi Self-Clearing Member API + version: 0.0.1 + 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. + + 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 + description: Production + - url: https://demo-api.kalshi.co/klear-api/v1 + description: Demo +security: + - kalshiSession: [] +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 + summary: Get Margin Reports + description: >- + Margin reports for the authenticated clearing member, filterable by date + range. + parameters: + - name: start_date + in: query + required: true + schema: + type: string + format: date + - name: end_date + in: query + required: true + schema: + type: string + format: date + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginReportsResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/active_obligation: + get: + operationId: GetActiveMarginObligation + summary: Get Active Margin Obligation + description: | + Returns the clearing member's outstanding settlement obligation for the + current cycle, if one exists. A negative amount indicates a net payable + to Kalshi Klear; a positive amount indicates a net receivable. Returns + null when no obligation is pending. + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/GetActiveMarginObligationResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/obligation_history: + get: + operationId: GetObligationHistory + summary: Get Obligation History + description: >- + Completed obligations from previous settlements, ordered by most recent + first. + parameters: + - name: limit + in: query + required: false + schema: + type: integer + default: 20 + maximum: 100 + description: Number of results per page. + - name: cursor + in: query + required: false + schema: + type: string + format: date-time + description: Cursor from a previous response to fetch the next page. + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/GetObligationHistoryResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/settlement_estimate: + get: + operationId: GetSettlementEstimate + summary: Get Settlement Estimate + description: >- + Estimated next settlement amounts for the authenticated clearing member, + including per-subtrader breakdowns. + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/GetSettlementEstimateResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/settlement_balance: + get: + operationId: GetSettlementBalance + summary: Get Settlement Balance + description: Settlement buffer balance for the authenticated clearing member. + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/GetSettlementBalanceResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/guaranty_fund_balance: + get: + operationId: GetGuarantyFundBalance + summary: Get Guaranty Fund Balance + description: >- + Guaranty fund contribution balance for the authenticated clearing + member. + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/GetGuarantyFundBalanceResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/settlement_balance_history: + get: + operationId: GetSettlementBalanceHistory + summary: Get Settlement Balance History + description: >- + Settlement balance changes for the authenticated clearing member, + ordered by most recent first. + parameters: + - name: limit + in: query + required: false + schema: + type: integer + default: 100 + maximum: 500 + description: Number of results per page. + - name: cursor + in: query + required: false + schema: + type: string + description: Cursor from a previous response to fetch the next page. + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/GetSettlementBalanceHistoryResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/withdraw_settlement_balance: + post: + operationId: WithdrawSettlementBalance + summary: Withdraw Settlement Balance + description: >- + Initiate a wire withdrawal from the clearing member's settlement buffer. + The withdrawal is processed asynchronously. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WithdrawSettlementBalanceRequest' + responses: + '200': + description: Withdrawal initiated + content: + application/json: + schema: + $ref: '#/components/schemas/WithdrawSettlementBalanceResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/settlement_balance_withdrawal: + get: + operationId: GetSettlementBalanceWithdrawal + summary: Get Settlement Balance Withdrawal + description: Get the status of a settlement balance withdrawal by ID. + parameters: + - name: id + in: query + required: true + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/GetSettlementBalanceWithdrawalResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '500': + $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. + responses: + BadRequestError: + description: Bad request - invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + UnauthorizedError: + description: Missing or invalid authentication + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + ForbiddenError: + description: Caller is not permitted to access this resource + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + InternalServerError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + schemas: + Error: + type: object + required: + - code + - message + properties: + code: + type: string + description: Machine-readable error code. + message: + type: string + description: Human-readable error message. + details: + type: string + service: + type: string + MarginReport: + type: object + required: + - report_type + - url + - date + - created_ts + - is_end_of_day + properties: + report_type: + type: string + enum: + - trade_audit + - position_snapshot + - market_price_snapshot + - funding_periods + - settlement_periods + url: + type: string + description: Presigned download URL (omitted from logs). + date: + type: string + format: date + created_ts: + type: string + format: date-time + is_end_of_day: + type: boolean + GetMarginReportsResponse: + type: object + required: + - reports + properties: + reports: + type: array + items: + $ref: '#/components/schemas/MarginReport' + ObligationInfo: + type: object + required: + - id + - user_id + - amount_centicents + - fees_centicents + - maintenance_margin_centicents + - pnl_centicents + - execution_time + - last_updated_ts + properties: + id: + type: string + user_id: + type: string + amount_centicents: + type: integer + format: int64 + description: | + Net settlement amount in USD as centicents (1 USD = 10,000 + centicents). All monetary fields on this API are returned in + centicents. Total the clearing member owes or is owed, inclusive + of PnL, fees, and maintenance margin changes. Negative means the + clearing member pays Kalshi Klear; positive means Kalshi Klear + pays the clearing member. + fees_centicents: + type: integer + format: int64 + description: Trading fees incurred during the settlement period. + maintenance_margin_centicents: + type: integer + format: int64 + description: Current maintenance margin requirement for this obligation. + pnl_centicents: + type: integer + format: int64 + description: Realized PnL for this settlement period. + execution_time: + type: string + format: date-time + description: Settlement cycle time this obligation belongs to. + last_updated_ts: + type: string + format: date-time + description: Timestamp of the last status change on this obligation. + ObligationReceiveInfo: + type: object + required: + - id + - type + - amount_centicents + - external_reference + - created_ts + properties: + id: + type: string + type: + type: string + amount_centicents: + type: integer + format: int64 + external_reference: + type: string + created_ts: + type: string + format: date-time + SettlementDetail: + type: object + required: + - id + - market_ticker + - subtrader_id + - pnl_centicents + - total_fees_centicents + - total_amount_centicents + properties: + id: + type: string + market_ticker: + type: string + subtrader_id: + type: string + pnl_centicents: + type: integer + format: int64 + description: PnL. + total_fees_centicents: + type: integer + format: int64 + description: Total fees. + total_amount_centicents: + type: integer + format: int64 + description: Total amount. + MaintenanceMarginDetail: + type: object + required: + - id + - subtrader_id + - maintenance_margin_centicents + - maintenance_margin_delta_centicents + properties: + id: + type: string + subtrader_id: + type: string + description: Empty when not populated. + maintenance_margin_centicents: + type: integer + format: int64 + description: Maintenance margin requirement after this settlement. + maintenance_margin_delta_centicents: + type: integer + format: int64 + description: >- + Change in maintenance margin between the previous settlement and + this one. + ObligationEntry: + allOf: + - $ref: '#/components/schemas/ObligationInfo' + - type: object + required: + - receives + - settlement_details + - maintenance_margin_details + properties: + receives: + type: array + items: + $ref: '#/components/schemas/ObligationReceiveInfo' + settlement_details: + type: array + items: + $ref: '#/components/schemas/SettlementDetail' + description: | + Per-market, per-subtrader breakdown of the settlement amount. + This structure may change in future versions as margin + methodology evolves (e.g. portfolio margin). + maintenance_margin_details: + type: array + items: + $ref: '#/components/schemas/MaintenanceMarginDetail' + description: | + Breakdown of the maintenance margin requirement and the delta + from the prior settlement. + GetActiveMarginObligationResponse: + type: object + properties: + obligation: + $ref: '#/components/schemas/ObligationEntry' + description: >- + The outstanding settlement obligation for the current cycle, or null + when no obligation is pending. + GetObligationHistoryResponse: + type: object + required: + - obligations + properties: + obligations: + type: array + items: + $ref: '#/components/schemas/ObligationEntry' + cursor: + type: string + format: date-time + description: >- + Pass as the `cursor` query param to fetch the next page. Absent when + there are no more results. + SettlementEstimate: + type: object + required: + - variation_margin_centicents + - total_fees_centicents + - maintenance_margin_delta_centicents + - maintenance_margin_required_centicents + - total_amount_centicents + properties: + variation_margin_centicents: + type: integer + format: int64 + description: Variation margin. + total_fees_centicents: + type: integer + format: int64 + description: Total fees. + maintenance_margin_delta_centicents: + type: integer + format: int64 + description: Change in maintenance margin requirement. + maintenance_margin_required_centicents: + type: integer + format: int64 + description: Maintenance margin requirement. + total_amount_centicents: + type: integer + format: int64 + description: Estimated total settlement amount. + GetSettlementEstimateResponse: + type: object + required: + - user_breakdown + - settlement_balance_centicents + properties: + user_breakdown: + $ref: '#/components/schemas/SettlementEstimate' + subtrader_breakdowns: + type: object + description: Map of subtrader ID to that subtrader's settlement estimate. + additionalProperties: + $ref: '#/components/schemas/SettlementEstimate' + settlement_balance_centicents: + 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: + - user_id + - balance_available_centicents + properties: + user_id: + type: string + balance_available_centicents: + type: integer + format: int64 + description: Settlement buffer balance. + locked_balance_centicents: + type: integer + format: int64 + description: Locked settlement balance (e.g. pending withdrawals). + GetGuarantyFundBalanceResponse: + type: object + required: + - user_id + - amount_centicents + - updated_ts + properties: + user_id: + type: string + amount_centicents: + type: integer + format: int64 + description: >- + Guaranty fund contribution balance. Zero when no contribution has + been made yet. + updated_ts: + type: string + format: date-time + description: Timestamp of the most recent contribution entry. + GetSettlementBalanceWithdrawalResponse: + type: object + required: + - id + - amount + - status + - created_ts + properties: + id: + type: string + amount: + type: string + description: Withdrawal amount in USD as a fixed-point string (e.g. "500.00"). + status: + type: string + enum: + - pending + - processing + - processed + - failed + created_ts: + type: string + format: date-time + WithdrawSettlementBalanceRequest: + type: object + required: + - amount + properties: + amount: + type: string + description: >- + Amount to withdraw in USD as a fixed-point string (e.g. "500.00"). + Must be positive. + WithdrawSettlementBalanceResponse: + type: object + required: + - id + properties: + id: + type: string + description: ID of the withdrawal. The withdrawal is processed asynchronously. + SettlementBalanceHistoryEntry: + type: object + required: + - balance_delta_centicents + - locked_balance_delta_centicents + - reason + - business_transaction_id + - created_ts + properties: + balance_delta_centicents: + type: integer + format: int64 + description: Change to the settlement buffer balance. + locked_balance_delta_centicents: + type: integer + format: int64 + description: >- + Change to the locked settlement balance. Funds are locked during + pending withdrawals. + reason: + type: string + description: Reason for the balance change. + business_transaction_id: + type: string + description: Identifier of the business transaction that caused this entry. + created_ts: + type: string + format: date-time + description: Timestamp when the entry was created. + GetSettlementBalanceHistoryResponse: + type: object + required: + - entries + properties: + entries: + type: array + items: + $ref: '#/components/schemas/SettlementBalanceHistoryEntry' + cursor: + type: string + description: >- + Pass as the `cursor` query param to fetch the next page. Absent when + there are no more results. diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 5f17007..571492d 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -1350,6 +1350,398 @@ class Exclusion: ) +# ════════════════════════════════════════════════════════════════════════════ +# Perps (margin) API contract maps +# +# The perps API uses its own spec files: ``specs/perps_openapi.yaml`` (REST) and +# ``specs/perps_scm_openapi.yaml`` (the Self-Clearing-Member "Klear" surface). +# These parallel maps are consumed by the ``TestPerps*Drift`` classes in +# ``test_contracts.py`` and resolved against those specs instead of the +# prediction-API ``specs/openapi.yaml``. Keying each map to its own spec file +# sidesteps the schema-ref name collisions across specs (e.g. perps and core +# both define ``ApplySubaccountTransferRequest``). +# +# The foundation issue (#388) ships these empty-but-defined; each per-resource +# perps issue appends its entries. +# ════════════════════════════════════════════════════════════════════════════ + +# Perps REST endpoints — validated against ``specs/perps_openapi.yaml``. +PERPS_METHOD_ENDPOINT_MAP: list[MethodEndpointEntry] = [ + # ── perps exchange (#389) ──────────────────────────────────────────── + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.exchange.PerpsExchangeResource.status", + http_method="GET", + path_template="/margin/exchange/status", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.exchange.PerpsExchangeResource.enabled", + http_method="GET", + path_template="/margin/enabled", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.exchange.PerpsExchangeResource.risk_parameters", + http_method="GET", + path_template="/margin/risk_parameters", + ), + # ── perps markets (#390) ─────────────────────────────────────────── + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.markets.PerpsMarketsResource.list", + http_method="GET", + path_template="/margin/markets", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.markets.PerpsMarketsResource.get", + http_method="GET", + path_template="/margin/markets/{ticker}", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.markets.PerpsMarketsResource.orderbook", + http_method="GET", + path_template="/margin/markets/{ticker}/orderbook", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.markets.PerpsMarketsResource.candlesticks", + http_method="GET", + path_template="/margin/markets/{ticker}/candlesticks", + ), + # ── perps orders (#391) ──────────────────────────────────────────── + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.create", + http_method="POST", + path_template="/margin/orders", + request_body_schema="#/components/schemas/CreateMarginOrderRequest", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.list", + http_method="GET", + path_template="/margin/orders", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.list_all", + http_method="GET", + path_template="/margin/orders", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.get", + http_method="GET", + path_template="/margin/orders/{order_id}", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.cancel", + http_method="DELETE", + path_template="/margin/orders/{order_id}", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.decrease", + http_method="POST", + path_template="/margin/orders/{order_id}/decrease", + request_body_schema="#/components/schemas/DecreaseMarginOrderRequest", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.amend", + http_method="POST", + path_template="/margin/orders/{order_id}/amend", + request_body_schema="#/components/schemas/AmendMarginOrderRequest", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.list_fcm", + http_method="GET", + path_template="/margin/fcm/orders", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.list_all_fcm", + http_method="GET", + path_template="/margin/fcm/orders", + ), + # ── perps order_groups (#392) ────────────────────────────────────── + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.order_groups.OrderGroupsResource.list", + http_method="GET", + path_template="/margin/order_groups", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.order_groups.OrderGroupsResource.create", + http_method="POST", + path_template="/margin/order_groups/create", + request_body_schema="#/components/schemas/CreateOrderGroupRequest", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.order_groups.OrderGroupsResource.get", + http_method="GET", + path_template="/margin/order_groups/{order_group_id}", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.order_groups.OrderGroupsResource.delete", + http_method="DELETE", + path_template="/margin/order_groups/{order_group_id}", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.order_groups.OrderGroupsResource.reset", + http_method="PUT", + path_template="/margin/order_groups/{order_group_id}/reset", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.order_groups.OrderGroupsResource.trigger", + http_method="PUT", + path_template="/margin/order_groups/{order_group_id}/trigger", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.order_groups.OrderGroupsResource.update_limit", + http_method="PUT", + path_template="/margin/order_groups/{order_group_id}/limit", + request_body_schema="#/components/schemas/UpdateOrderGroupLimitRequest", + ), + # ── perps portfolio (#393) ───────────────────────────────────────── + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.portfolio.PerpsPortfolioResource.positions", + http_method="GET", + path_template="/margin/positions", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.portfolio.PerpsPortfolioResource.fills", + http_method="GET", + path_template="/margin/fills", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.portfolio.PerpsPortfolioResource.fills_all", + http_method="GET", + path_template="/margin/fills", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.portfolio.PerpsPortfolioResource.trades", + http_method="GET", + path_template="/margin/trades", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.portfolio.PerpsPortfolioResource.trades_all", + http_method="GET", + path_template="/margin/trades", + ), + # ── perps margin_account (#394) ──────────────────────────────────── + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.margin_account.MarginAccountResource.balance", + http_method="GET", + path_template="/margin/balance", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.margin_account.MarginAccountResource.risk", + http_method="GET", + path_template="/margin/risk", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.margin_account.MarginAccountResource.notional_risk_limit", + http_method="GET", + path_template="/margin/notional_risk_limit", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.margin_account.MarginAccountResource.fee_tiers", + http_method="GET", + path_template="/margin/fee_tiers", + ), + # ── perps funding (#395) ─────────────────────────────────────────── + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.funding.FundingResource.rate_estimate", + http_method="GET", + path_template="/margin/funding_rates/estimate", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.funding.FundingResource.historical_rates", + http_method="GET", + path_template="/margin/funding_rates/historical", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.funding.FundingResource.history", + http_method="GET", + path_template="/margin/funding_history", + ), + # ── perps transfers (#396) ───────────────────────────────────────── + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.transfers.TransfersResource.transfer_instance", + http_method="POST", + path_template="/portfolio/intra_exchange_instance_transfer", + request_body_schema="#/components/schemas/IntraExchangeInstanceTransferRequest", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.transfers.TransfersResource.create_subaccount", + http_method="POST", + path_template="/portfolio/margin/subaccounts", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.transfers.TransfersResource.transfer_subaccount", + http_method="POST", + path_template="/portfolio/margin/subaccounts/transfer", + request_body_schema="#/components/schemas/ApplySubaccountTransferRequest", + ), +] + +# 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", + ), + # ── perps SCM/Klear margin endpoints (#400) ────────────────────────── + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.margin_reports", + http_method="GET", + path_template="/margin/reports", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.active_obligation", + http_method="GET", + path_template="/margin/active_obligation", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.obligation_history", + http_method="GET", + path_template="/margin/obligation_history", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.obligation_history_all", + http_method="GET", + path_template="/margin/obligation_history", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.settlement_estimate", + http_method="GET", + path_template="/margin/settlement_estimate", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.settlement_balance", + http_method="GET", + path_template="/margin/settlement_balance", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.guaranty_fund_balance", + http_method="GET", + path_template="/margin/guaranty_fund_balance", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.settlement_balance_history", + http_method="GET", + path_template="/margin/settlement_balance_history", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.settlement_balance_history_all", + http_method="GET", + path_template="/margin/settlement_balance_history", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.withdraw_settlement_balance", + http_method="POST", + path_template="/margin/withdraw_settlement_balance", + request_body_schema="#/components/schemas/WithdrawSettlementBalanceRequest", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.settlement_balance_withdrawal", + http_method="GET", + path_template="/margin/settlement_balance_withdrawal", + ), +] + +# Shared perps exclusion allowlist (same ``(sdk_fqn, field) → Exclusion`` shape +# as ``EXCLUSIONS``). Covers both the perps REST and SCM drift checks — FQNs are +# globally unique, so one map serves both. Dependent issues add their +# ``paginator_handled`` / ``client_only`` / ``wire_normalization`` entries here +# with a required ``reason``. +PERPS_EXCLUSIONS: dict[tuple[str, str], Exclusion] = { + # ── perps orders (#391) ── + ("kalshi.perps.resources.orders.MarginOrdersResource.list_all", "cursor"): Exclusion( + reason="paginator-handled; not a caller-facing kwarg on list_all", + kind="paginator_handled", + ), + ("kalshi.perps.resources.orders.MarginOrdersResource.list_all_fcm", "cursor"): Exclusion( + reason="paginator-handled; not a caller-facing kwarg on list_all_fcm", + kind="paginator_handled", + ), + ("kalshi.perps.resources.orders.MarginOrdersResource.list_all", "max_pages"): Exclusion( + reason="client-side paginator safety cap (#98); not a wire param", + kind="client_only", + ), + ("kalshi.perps.resources.orders.MarginOrdersResource.list_all_fcm", "max_pages"): Exclusion( + reason="client-side paginator safety cap (#98); not a wire param", + kind="client_only", + ), + ("kalshi.perps.models.orders.GetMarginOrdersResponse", "cursor"): Exclusion( + reason=( + "spec marks cursor required, but Kalshi omits the key on the final " + "page (rather than returning \"\") — kept optional so list_all() " + "doesn't crash on the last page. Mirrors the GetMarginFillsResponse/" + "GetMarginTradesResponse cursor handling." + ), + kind="server_omits_despite_required", + ), + # ── perps order_groups (#392) ── + ("kalshi.perps.models.order_groups.CreateOrderGroupRequest", "contracts_limit_fp"): Exclusion( + reason=( + "spec body offers a contracts_limit_fp FixedPointCount-string variant; " + "the SDK emits the integer contracts_limit only (Kalshi accepts either). " + "Mirrors the portfolio order-groups handling (#392)." + ), + kind="wire_normalization", + ), + ( + "kalshi.perps.models.order_groups.UpdateOrderGroupLimitRequest", + "contracts_limit_fp", + ): Exclusion( + reason=( + "contracts_limit_fp FixedPointCount-string variant on PUT /limit; " + "the SDK emits the integer contracts_limit only (#392)." + ), + kind="wire_normalization", + ), + # ── perps portfolio (#393) ── + ("kalshi.perps.resources.portfolio.PerpsPortfolioResource.fills_all", "cursor"): Exclusion( + reason="cursor consumed by _list_all paginator, not a caller kwarg", + kind="paginator_handled", + ), + ("kalshi.perps.resources.portfolio.PerpsPortfolioResource.fills_all", "max_pages"): Exclusion( + reason="client-side page cap, no wire counterpart", + kind="client_only", + ), + ("kalshi.perps.resources.portfolio.PerpsPortfolioResource.trades_all", "cursor"): Exclusion( + reason="cursor consumed by _list_all paginator, not a caller kwarg", + kind="paginator_handled", + ), + ("kalshi.perps.resources.portfolio.PerpsPortfolioResource.trades_all", "max_pages"): Exclusion( + reason="client-side page cap, no wire counterpart", + kind="client_only", + ), + # ── perps SCM/Klear paginators (#400) ── + ( + "kalshi.perps.klear.resources.margin.MarginResource.obligation_history_all", + "cursor", + ): Exclusion( + reason="cursor consumed by _list_all paginator, not a caller kwarg", + kind="paginator_handled", + ), + ( + "kalshi.perps.klear.resources.margin.MarginResource.obligation_history_all", + "max_pages", + ): Exclusion( + reason="client-side page cap, no wire counterpart", + kind="client_only", + ), + ( + "kalshi.perps.klear.resources.margin.MarginResource.settlement_balance_history_all", + "cursor", + ): Exclusion( + reason="cursor consumed by _list_all paginator, not a caller kwarg", + kind="paginator_handled", + ), + ( + "kalshi.perps.klear.resources.margin.MarginResource.settlement_balance_history_all", + "max_pages", + ): Exclusion( + reason="client-side page cap, no wire counterpart", + kind="client_only", + ), +} + + def _resolve_ref( spec: dict[str, Any], ref: str, diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 682a8e0..46cb5d6 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -28,6 +28,9 @@ from kalshi.async_client import AsyncKalshiClient from kalshi.client import KalshiClient from kalshi.models.markets import Market +from kalshi.perps.async_client import AsyncPerpsClient +from kalshi.perps.client import PerpsClient +from kalshi.perps.ws.client import PerpsWebSocket from kalshi.ws.client import KalshiWebSocket @@ -284,3 +287,158 @@ async def ws_session(sync_client: KalshiClient) -> AsyncIterator[KalshiWebSocket ws = KalshiWebSocket(auth=auth, config=config) async with ws.connect() as session: yield session + + +# =========================================================================== +# Perps (margin) integration fixtures +# =========================================================================== +# The perps API lives on its own host with a separate credential namespace +# (``KALSHI_PERPS_*``). These fixtures mirror the prediction-API fixtures above: +# auto-skip when perps credentials are absent, and hard-fail unless the resolved +# base/WS URL is the demo host. + +PERPS_DEMO_REST_HOST = "external-api.demo.kalshi.co" +PERPS_DEMO_WS_HOST = "external-api-margin-ws.demo.kalshi.co" + +# Session-scoped run ID for perps order isolation (mirrors TEST_RUN_ID). +PERPS_TEST_RUN_ID = f"perps-test-{uuid.uuid4().hex[:12]}" + + +@pytest.fixture(scope="session", autouse=True) +def _bridge_perps_env_vars( + _monkeypatch_session: pytest.MonkeyPatch, +) -> None: + """Map ``KALSHI_DEMO_*`` env vars to ``KALSHI_PERPS_*`` for from_env(). + + Perps ``from_env()`` reads a separate ``KALSHI_PERPS_*`` namespace. To let a + single demo credential set drive both suites, bridge the ``.env`` style + ``KALSHI_DEMO_*`` vars into the perps namespace when the perps vars are not + already set. Bridged through ``_monkeypatch_session`` so mutations roll back + at session end (no leak into unit tests reading ``os.environ``). + """ + demo_key_id = os.environ.get("KALSHI_DEMO_KEY_ID") + if demo_key_id and not os.environ.get("KALSHI_PERPS_KEY_ID"): + _monkeypatch_session.setenv("KALSHI_PERPS_KEY_ID", demo_key_id) + demo_path = os.environ.get("KALSHI_DEMO_PRIVATE_KEY_PATH") + if demo_path and not os.environ.get("KALSHI_PERPS_PRIVATE_KEY_PATH"): + _monkeypatch_session.setenv("KALSHI_PERPS_PRIVATE_KEY_PATH", demo_path) + if "KALSHI_PERPS_DEMO" not in os.environ: + _monkeypatch_session.setenv("KALSHI_PERPS_DEMO", "true") + + +def _perps_credentials_available() -> bool: + return bool(os.environ.get("KALSHI_PERPS_KEY_ID")) + + +def _assert_perps_demo_url(base_url: str, ws_base_url: str | None = None) -> None: + """Hard-fail if the perps client is not pointed at the demo environment.""" + if PERPS_DEMO_REST_HOST not in base_url: + pytest.fail( + f"SAFETY: Perps integration tests must run against the demo API. " + f"Resolved base_url is '{base_url}', expected '{PERPS_DEMO_REST_HOST}'. " + f"Check KALSHI_PERPS_API_BASE_URL and KALSHI_PERPS_DEMO env vars." + ) + if ws_base_url is not None and PERPS_DEMO_WS_HOST not in ws_base_url: + pytest.fail( + f"SAFETY: Perps WS integration tests must run against the demo API. " + f"Resolved ws_base_url is '{ws_base_url}', expected " + f"'{PERPS_DEMO_WS_HOST}'. " + f"Check KALSHI_PERPS_API_BASE_URL and KALSHI_PERPS_DEMO env vars." + ) + + +@pytest.fixture(scope="session") +def perps_test_run_id() -> str: + return PERPS_TEST_RUN_ID + + +@pytest.fixture(scope="session") +def perps_sync_client() -> Iterator[PerpsClient]: + if not _perps_credentials_available(): + pytest.skip("KALSHI_PERPS_KEY_ID not set — skipping perps integration tests") + os.environ.setdefault("KALSHI_PERPS_DEMO", "true") + client = PerpsClient.from_env() + _assert_perps_demo_url(client._config.base_url, client._config.ws_base_url) + yield client + client.close() + + +@pytest_asyncio.fixture +async def perps_async_client() -> AsyncIterator[AsyncPerpsClient]: + if not _perps_credentials_available(): + pytest.skip("KALSHI_PERPS_KEY_ID not set — skipping perps integration tests") + os.environ.setdefault("KALSHI_PERPS_DEMO", "true") + client = AsyncPerpsClient.from_env() + _assert_perps_demo_url(client._config.base_url, client._config.ws_base_url) + yield client + with contextlib.suppress(RuntimeError): + await client.close() # Event loop may be closing during teardown + + +def skip_if_not_margin_enabled(client: PerpsClient) -> None: + """Skip if the demo account is not margin-enabled (``GetMarginEnabled`` False).""" + if not client.is_authenticated: + pytest.skip("Perps client unauthenticated — cannot check margin access") + if not client.exchange.enabled().enabled: + pytest.skip("Demo account is not margin-enabled — skipping perps test") + + +def skip_if_no_margin_markets(client: PerpsClient) -> list[str]: + """Return tradable margin market tickers; skip if the demo has none.""" + markets = client.markets.list() + if not markets: + pytest.skip("No margin markets on demo server — skipping perps test") + return [m.ticker for m in markets] + + +@pytest.fixture(scope="session") +def perps_market_ticker(perps_sync_client: PerpsClient) -> str: + """Find a margin market on the demo server; skip if none exist.""" + tickers = skip_if_no_margin_markets(perps_sync_client) + return tickers[0] + + +@pytest.fixture(scope="session", autouse=True) +def cleanup_perps_orders(perps_sync_client: PerpsClient) -> Iterator[None]: + """After all tests, cancel any resting margin orders from this test run. + + Mirrors :func:`cleanup_orders` — matches on the ``PERPS_TEST_RUN_ID``-tagged + ``client_order_id`` so live perps order tests never leak. + """ + yield + if not perps_sync_client.is_authenticated: + return + try: + resp = perps_sync_client.orders.list(status="resting") + except Exception: + logger.warning("Perps cleanup: failed to list orders for cleanup sweep") + return + for order in resp.orders: + if order.client_order_id and order.client_order_id.startswith(PERPS_TEST_RUN_ID): + try: + perps_sync_client.orders.cancel(order.order_id) + logger.info("Perps cleanup: cancelled order %s", order.order_id) + except Exception: + logger.warning( + "Perps cleanup: failed to cancel order %s", order.order_id + ) + + +@pytest_asyncio.fixture +async def perps_ws_session( + perps_sync_client: PerpsClient, +) -> AsyncIterator[PerpsWebSocket]: + """Connect to the demo margin WS, yield an active session, clean up on exit. + + Mirrors :func:`ws_session`. The perps WS requires a (non-None) auth signer, + so this skips an unauthenticated perps client. + """ + config = perps_sync_client._config + _assert_perps_demo_url(config.base_url, config.ws_base_url) + + auth = perps_sync_client._auth + if auth is None: + pytest.skip("Perps client unauthenticated — cannot open margin WS") + ws = PerpsWebSocket(auth=auth, config=config) + async with ws.connect() as session: + yield session diff --git a/tests/integration/coverage_harness.py b/tests/integration/coverage_harness.py index 5c40677..8a82237 100644 --- a/tests/integration/coverage_harness.py +++ b/tests/integration/coverage_harness.py @@ -78,3 +78,60 @@ def discover_public_methods() -> dict[str, list[str]]: def register(resource_name: str, methods: list[str]) -> None: """Register methods as covered by integration tests.""" SCENARIO_REGISTRY[resource_name] = sorted(methods) + + +# --------------------------------------------------------------------------- +# Perps (margin) parallel registry +# --------------------------------------------------------------------------- +# Perps resources live under ``kalshi.perps.resources.*`` and subclass the same +# ``SyncResource`` base as the prediction-API resources. They are tracked in a +# **separate** registry so the existing ``discover_public_methods`` / +# ``SCENARIO_REGISTRY`` and its ``test_discovery_finds_all_resources`` exact-set +# assertion stay untouched. +PERPS_SCENARIO_REGISTRY: dict[str, list[str]] = {} + +PERPS_RESOURCE_MODULES = [ + "kalshi.perps.resources.exchange", + "kalshi.perps.resources.funding", + "kalshi.perps.resources.margin_account", + "kalshi.perps.resources.markets", + "kalshi.perps.resources.orders", + "kalshi.perps.resources.portfolio", +] + + +def discover_perps_public_methods() -> dict[str, list[str]]: + """Discover public methods on the perps sync resource classes. + + Mirrors :func:`discover_public_methods` but introspects + :data:`PERPS_RESOURCE_MODULES`. + """ + result: dict[str, list[str]] = {} + + for mod_name in PERPS_RESOURCE_MODULES: + mod = importlib.import_module(mod_name) + for name, cls in inspect.getmembers(mod, inspect.isclass): + if not issubclass(cls, SyncResource): + continue + if cls is SyncResource: + continue + if name.startswith("Async"): + continue + + methods: list[str] = [] + for method_name, method_obj in inspect.getmembers(cls, predicate=inspect.isfunction): + if method_name.startswith("_"): + continue + qualname = getattr(method_obj, "__qualname__", "") + if qualname.startswith(f"{name}."): + methods.append(method_name) + + if methods: + result[name] = sorted(methods) + + return result + + +def register_perps(resource_name: str, methods: list[str]) -> None: + """Register methods as covered by perps integration tests.""" + PERPS_SCENARIO_REGISTRY[resource_name] = sorted(methods) diff --git a/tests/integration/test_perps_balance_risk.py b/tests/integration/test_perps_balance_risk.py new file mode 100644 index 0000000..7591a3b --- /dev/null +++ b/tests/integration/test_perps_balance_risk.py @@ -0,0 +1,192 @@ +"""Integration tests for perps balance / risk / exchange access — live demo. + +Covers the ``MarginAccountResource`` (``balance`` / ``risk`` / +``notional_risk_limit`` / ``fee_tiers``) and the ``PerpsExchangeResource`` +(``status`` / ``enabled`` / ``risk_parameters``). + +``status`` and ``risk_parameters`` are public; ``enabled`` and the whole +margin-account surface are auth-gated and require a margin-enabled demo account +(guarded with ``skip_if_not_margin_enabled``). +""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from kalshi.models.common import Page +from kalshi.perps.async_client import AsyncPerpsClient +from kalshi.perps.client import PerpsClient +from kalshi.perps.models.exchange import ( + ExchangeStatus, + GetMarginRiskParametersResponse, + MarginEnabledResponse, +) +from kalshi.perps.models.margin_account import ( + GetMarginBalanceResponse, + GetMarginFeeTiersResponse, + GetMarginRiskResponse, + NotionalRiskLimitResponse, +) +from kalshi.perps.models.portfolio import ( + GetMarginPositionsResponse, + MarginFill, + MarginTrade, +) +from tests.integration.conftest import skip_if_not_margin_enabled +from tests.integration.coverage_harness import register_perps + +register_perps( + "PerpsExchangeResource", + ["enabled", "risk_parameters", "status"], +) +register_perps( + "MarginAccountResource", + ["balance", "fee_tiers", "notional_risk_limit", "risk"], +) +register_perps( + "PerpsPortfolioResource", + ["fills", "fills_all", "positions", "trades", "trades_all"], +) + + +@pytest.mark.integration +class TestPerpsExchangeSync: + def test_status(self, perps_sync_client: PerpsClient) -> None: + """Public — no auth required.""" + status = perps_sync_client.exchange.status() + assert isinstance(status, ExchangeStatus) + assert isinstance(status.exchange_active, bool) + assert isinstance(status.trading_active, bool) + + def test_risk_parameters(self, perps_sync_client: PerpsClient) -> None: + """Public — no auth required.""" + params = perps_sync_client.exchange.risk_parameters() + assert isinstance(params, GetMarginRiskParametersResponse) + assert isinstance(params.liquidation_margin_ratio_threshold, Decimal) + assert isinstance(params.initial_margin_multiplier, dict) + + def test_enabled(self, perps_sync_client: PerpsClient) -> None: + if not perps_sync_client.is_authenticated: + pytest.skip("Perps client unauthenticated — enabled() requires auth") + resp = perps_sync_client.exchange.enabled() + assert isinstance(resp, MarginEnabledResponse) + assert isinstance(resp.enabled, bool) + + +@pytest.mark.integration +class TestPerpsBalanceRiskSync: + def test_balance(self, perps_sync_client: PerpsClient) -> None: + skip_if_not_margin_enabled(perps_sync_client) + balance = perps_sync_client.margin.balance() + assert isinstance(balance, GetMarginBalanceResponse) + assert isinstance(balance.settled_funds, Decimal) + assert isinstance(balance.subaccount_balances, list) + for sub in balance.subaccount_balances: + assert isinstance(sub.account_equity, Decimal) + assert isinstance(sub.maintenance_margin, Decimal) + assert isinstance(sub.position_value, Decimal) + assert isinstance(sub.available_balance, Decimal) + + def test_risk(self, perps_sync_client: PerpsClient) -> None: + skip_if_not_margin_enabled(perps_sync_client) + risk = perps_sync_client.margin.risk() + assert isinstance(risk, GetMarginRiskResponse) + assert isinstance(risk.total_maintenance_margin, Decimal) + assert isinstance(risk.total_position_notional, Decimal) + assert isinstance(risk.positions, list) + for pos in risk.positions: + assert pos.market_ticker + assert isinstance(pos.mark_price, Decimal) + # liquidation price + leverage are optional (only set on open positions) + if pos.estimated_liquidation_price is not None: + assert isinstance(pos.estimated_liquidation_price, Decimal) + if pos.position_leverage is not None: + assert isinstance(pos.position_leverage, float) + + def test_notional_risk_limit(self, perps_sync_client: PerpsClient) -> None: + skip_if_not_margin_enabled(perps_sync_client) + resp = perps_sync_client.margin.notional_risk_limit() + assert isinstance(resp, NotionalRiskLimitResponse) + assert isinstance(resp.default_notional_value_risk_limit, Decimal) + + def test_fee_tiers(self, perps_sync_client: PerpsClient) -> None: + skip_if_not_margin_enabled(perps_sync_client) + resp = perps_sync_client.margin.fee_tiers() + assert isinstance(resp, GetMarginFeeTiersResponse) + assert isinstance(resp.maker_fee_rates, dict) + assert isinstance(resp.taker_fee_rates, dict) + + +@pytest.mark.integration +class TestPerpsPortfolioSync: + def test_positions(self, perps_sync_client: PerpsClient) -> None: + skip_if_not_margin_enabled(perps_sync_client) + resp = perps_sync_client.portfolio.positions() + assert isinstance(resp, GetMarginPositionsResponse) + assert isinstance(resp.positions, list) + for pos in resp.positions: + assert pos.market_ticker + assert isinstance(pos.entry_price, Decimal) + + def test_fills(self, perps_sync_client: PerpsClient) -> None: + skip_if_not_margin_enabled(perps_sync_client) + page = perps_sync_client.portfolio.fills(limit=5) + assert isinstance(page, Page) + for fill in page.items: + assert isinstance(fill, MarginFill) + assert fill.fill_id + + def test_fills_all(self, perps_sync_client: PerpsClient) -> None: + skip_if_not_margin_enabled(perps_sync_client) + for count, fill in enumerate(perps_sync_client.portfolio.fills_all(limit=5)): + assert isinstance(fill, MarginFill) + if count >= 2: + break + + def test_trades( + self, perps_sync_client: PerpsClient, perps_market_ticker: str + ) -> None: + """Public — no auth required; ``ticker`` is mandatory.""" + page = perps_sync_client.portfolio.trades(ticker=perps_market_ticker, limit=5) + assert isinstance(page, Page) + for trade in page.items: + assert isinstance(trade, MarginTrade) + assert trade.ticker == perps_market_ticker + assert isinstance(trade.price, Decimal) + + def test_trades_all( + self, perps_sync_client: PerpsClient, perps_market_ticker: str + ) -> None: + for count, trade in enumerate( + perps_sync_client.portfolio.trades_all(ticker=perps_market_ticker, limit=5) + ): + assert isinstance(trade, MarginTrade) + if count >= 2: + break + + +@pytest.mark.integration +class TestPerpsBalanceRiskAsync: + async def test_status(self, perps_async_client: AsyncPerpsClient) -> None: + status = await perps_async_client.exchange.status() + assert isinstance(status, ExchangeStatus) + + async def test_balance(self, perps_async_client: AsyncPerpsClient) -> None: + if not perps_async_client.is_authenticated: + pytest.skip("Perps client unauthenticated") + if not (await perps_async_client.exchange.enabled()).enabled: + pytest.skip("Demo account is not margin-enabled") + balance = await perps_async_client.margin.balance() + assert isinstance(balance, GetMarginBalanceResponse) + assert isinstance(balance.settled_funds, Decimal) + + async def test_risk(self, perps_async_client: AsyncPerpsClient) -> None: + if not perps_async_client.is_authenticated: + pytest.skip("Perps client unauthenticated") + if not (await perps_async_client.exchange.enabled()).enabled: + pytest.skip("Demo account is not margin-enabled") + risk = await perps_async_client.margin.risk() + assert isinstance(risk, GetMarginRiskResponse) + assert isinstance(risk.total_maintenance_margin, Decimal) diff --git a/tests/integration/test_perps_coverage.py b/tests/integration/test_perps_coverage.py new file mode 100644 index 0000000..d75a3d2 --- /dev/null +++ b/tests/integration/test_perps_coverage.py @@ -0,0 +1,85 @@ +"""Meta-test: every public perps resource method has a registered scenario. + +Parallels :mod:`tests.integration.test_coverage` but for the perps surface. It +imports the perps test files (so they populate ``PERPS_SCENARIO_REGISTRY`` at +import time), then compares the registered methods against what inspection +discovers on the actual perps resource classes via +``discover_perps_public_methods``. + +Kept separate from ``test_coverage`` so the prediction-API +``test_discovery_finds_all_resources`` exact-set assertion stays intact. +""" + +from __future__ import annotations + +import pytest + +# Force import of the perps test files so they register their methods. +import tests.integration.test_perps_balance_risk as _balance_risk # noqa: F401 +import tests.integration.test_perps_funding as _funding # noqa: F401 +import tests.integration.test_perps_markets as _markets # noqa: F401 +import tests.integration.test_perps_orders as _orders # noqa: F401 +import tests.integration.test_perps_ws as _ws # noqa: F401 +from tests.integration.coverage_harness import ( + PERPS_SCENARIO_REGISTRY, + discover_perps_public_methods, +) + + +@pytest.mark.integration +class TestPerpsCoverageHarness: + def test_all_methods_covered(self) -> None: + """Every public method on every perps sync resource class is registered.""" + discovered = discover_perps_public_methods() + missing: list[str] = [] + + for cls_name, methods in discovered.items(): + registered = PERPS_SCENARIO_REGISTRY.get(cls_name, []) + for method in methods: + if method not in registered: + missing.append(f"{cls_name}.{method}") + + if missing: + pytest.fail( + f"Perps integration coverage gap — {len(missing)} method(s) " + f"have no registered scenario:\n " + "\n ".join(missing) + ) + + def test_no_stale_registrations(self) -> None: + """No registered perps method should reference a non-existent method. + + The ``PerpsWebSocket`` registry entry is exempt — it tracks a WS channel + helper, not a REST resource class, so it never appears in the discovered + resource methods. + """ + discovered = discover_perps_public_methods() + stale: list[str] = [] + + for cls_name, methods in PERPS_SCENARIO_REGISTRY.items(): + if cls_name == "PerpsWebSocket": + continue + actual = discovered.get(cls_name, []) + for method in methods: + if method not in actual: + stale.append(f"{cls_name}.{method}") + + if stale: + pytest.fail( + f"Stale perps registrations — {len(stale)} method(s) registered " + f"but no longer exist:\n " + "\n ".join(stale) + ) + + def test_discovery_finds_perps_resources(self) -> None: + """Sanity check: discovery finds the expected perps resource classes.""" + discovered = discover_perps_public_methods() + expected = { + "PerpsExchangeResource", + "FundingResource", + "MarginAccountResource", + "PerpsMarketsResource", + "MarginOrdersResource", + "PerpsPortfolioResource", + } + assert set(discovered.keys()) == expected, ( + f"Expected perps resources: {expected}, discovered: {set(discovered.keys())}" + ) diff --git a/tests/integration/test_perps_funding.py b/tests/integration/test_perps_funding.py new file mode 100644 index 0000000..f3ae924 --- /dev/null +++ b/tests/integration/test_perps_funding.py @@ -0,0 +1,89 @@ +"""Integration tests for the perps (margin) funding resource — live demo. + +Covers ``rate_estimate`` / ``historical_rates`` / ``history``. ``rate_estimate`` +and ``historical_rates`` are public; ``history`` is auth-gated (per-user payment +history) and requires a margin-enabled demo account. + +Field-type note: ``funding_rate`` is a spec ``number/format: double`` and is a +plain ``float`` (NOT a price ``DollarDecimal``) on every funding model. +""" + +from __future__ import annotations + +import time +from datetime import datetime + +import pytest + +from kalshi.perps.async_client import AsyncPerpsClient +from kalshi.perps.client import PerpsClient +from kalshi.perps.models.funding import ( + MarginFundingHistoryEntry, + MarginFundingRate, + MarginFundingRateEstimate, +) +from tests.integration.conftest import skip_if_not_margin_enabled +from tests.integration.coverage_harness import register_perps + +register_perps( + "FundingResource", + ["history", "historical_rates", "rate_estimate"], +) + + +@pytest.mark.integration +class TestPerpsFundingSync: + def test_rate_estimate( + self, perps_sync_client: PerpsClient, perps_market_ticker: str + ) -> None: + est = perps_sync_client.funding.rate_estimate(perps_market_ticker) + assert isinstance(est, MarginFundingRateEstimate) + # funding_rate is a plain float when present (optional on the estimate). + if est.funding_rate is not None: + assert isinstance(est.funding_rate, float) + assert isinstance(est.next_funding_time, datetime) + + def test_historical_rates(self, perps_sync_client: PerpsClient) -> None: + rates = perps_sync_client.funding.historical_rates() + assert isinstance(rates, list) + for rate in rates: + assert isinstance(rate, MarginFundingRate) + assert isinstance(rate.funding_rate, float) + assert rate.market_ticker + assert isinstance(rate.funding_time, datetime) + + def test_history(self, perps_sync_client: PerpsClient) -> None: + skip_if_not_margin_enabled(perps_sync_client) + now = time.gmtime() + end_date = time.strftime("%Y-%m-%d", now) + start_date = time.strftime("%Y-%m-%d", time.gmtime(time.time() - 86400 * 7)) + history = perps_sync_client.funding.history( + start_date=start_date, end_date=end_date + ) + assert isinstance(history, list) + for entry in history: + assert isinstance(entry, MarginFundingHistoryEntry) + assert isinstance(entry.funding_rate, float) + assert entry.market_ticker + + +@pytest.mark.integration +class TestPerpsFundingAsync: + async def test_rate_estimate(self, perps_async_client: AsyncPerpsClient) -> None: + markets = await perps_async_client.markets.list() + if not markets: + pytest.skip("No margin markets on demo server") + est = await perps_async_client.funding.rate_estimate(markets[0].ticker) + assert isinstance(est, MarginFundingRateEstimate) + if est.funding_rate is not None: + assert isinstance(est.funding_rate, float) + assert isinstance(est.next_funding_time, datetime) + + async def test_historical_rates( + self, perps_async_client: AsyncPerpsClient + ) -> None: + rates = await perps_async_client.funding.historical_rates() + assert isinstance(rates, list) + for rate in rates: + assert isinstance(rate, MarginFundingRate) + assert isinstance(rate.funding_rate, float) diff --git a/tests/integration/test_perps_klear.py b/tests/integration/test_perps_klear.py new file mode 100644 index 0000000..d725df1 --- /dev/null +++ b/tests/integration/test_perps_klear.py @@ -0,0 +1,60 @@ +"""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 +``@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) +""" + +from __future__ import annotations + +import os + +import pytest + +from kalshi.perps.klear import KlearClient +from kalshi.perps.klear.config import DEMO_KLEAR_URL +from kalshi.perps.klear.models.auth import LogInResponse + + +@pytest.mark.integration +def test_klear_client_constructs_unauthenticated() -> None: + """A fresh demo Klear client is unauthenticated until ``login`` succeeds. + + No network call — this verifies the demo routing and the pre-login auth + state, which is safe to run on every integration session. + """ + with KlearClient(demo=True) as client: + assert client._config.base_url == DEMO_KLEAR_URL + assert client.is_authenticated is False + + +@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. + + 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. + """ + 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") + + 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) diff --git a/tests/integration/test_perps_markets.py b/tests/integration/test_perps_markets.py new file mode 100644 index 0000000..2c210ab --- /dev/null +++ b/tests/integration/test_perps_markets.py @@ -0,0 +1,108 @@ +"""Integration tests for the perps (margin) markets resource — live demo. + +Covers ``list`` / ``get`` / ``orderbook`` / ``candlesticks``. All four are public +read-only GETs; no margin-enabled account is required, only that the demo server +exposes at least one margin market (guarded by the ``perps_market_ticker`` +fixture, which skips when the demo has no margin markets). + +Margin market prices are NOT binary [0, 1] like the prediction API — they are +plain ``DollarDecimal`` and may exceed $1 — so these tests assert types/values +directly rather than using ``assert_model_fields`` (whose price-range check +targets binary markets). +""" + +from __future__ import annotations + +import time +from decimal import Decimal + +import pytest + +from kalshi.perps.async_client import AsyncPerpsClient +from kalshi.perps.client import PerpsClient +from kalshi.perps.models.markets import ( + MarginMarket, + MarginMarketCandlestick, + MarginOrderbook, + MarginOrderbookLevel, +) +from tests.integration.coverage_harness import register_perps + +register_perps( + "PerpsMarketsResource", + ["candlesticks", "get", "list", "orderbook"], +) + + +@pytest.mark.integration +class TestPerpsMarketsSync: + def test_list(self, perps_sync_client: PerpsClient) -> None: + markets = perps_sync_client.markets.list() + assert isinstance(markets, list) + if not markets: + pytest.skip("No margin markets on demo server") + market = markets[0] + assert isinstance(market, MarginMarket) + assert market.ticker + assert isinstance(market.contract_size, Decimal) + + def test_get(self, perps_sync_client: PerpsClient, perps_market_ticker: str) -> None: + market = perps_sync_client.markets.get(perps_market_ticker) + assert isinstance(market, MarginMarket) + assert market.ticker == perps_market_ticker + assert isinstance(market.tick_size, Decimal) + + def test_orderbook( + self, perps_sync_client: PerpsClient, perps_market_ticker: str + ) -> None: + ob = perps_sync_client.markets.orderbook(perps_market_ticker) + assert isinstance(ob, MarginOrderbook) + assert ob.ticker == perps_market_ticker + assert isinstance(ob.bids, list) + assert isinstance(ob.asks, list) + for level in (*ob.bids, *ob.asks): + assert isinstance(level, MarginOrderbookLevel) + assert isinstance(level.price, Decimal) + assert isinstance(level.quantity, Decimal) + + def test_candlesticks( + self, perps_sync_client: PerpsClient, perps_market_ticker: str + ) -> None: + now = int(time.time()) + result = perps_sync_client.markets.candlesticks( + perps_market_ticker, + start_ts=now - 86400 * 7, + end_ts=now, + period_interval=60, + ) + assert isinstance(result, list) + for candle in result: + assert isinstance(candle, MarginMarketCandlestick) + assert isinstance(candle.end_period_ts, int) + + +@pytest.mark.integration +class TestPerpsMarketsAsync: + async def test_list(self, perps_async_client: AsyncPerpsClient) -> None: + markets = await perps_async_client.markets.list() + assert isinstance(markets, list) + for market in markets: + assert isinstance(market, MarginMarket) + assert market.ticker + + async def test_get(self, perps_async_client: AsyncPerpsClient) -> None: + markets = await perps_async_client.markets.list() + if not markets: + pytest.skip("No margin markets on demo server") + ticker = markets[0].ticker + market = await perps_async_client.markets.get(ticker) + assert isinstance(market, MarginMarket) + assert market.ticker == ticker + + async def test_orderbook(self, perps_async_client: AsyncPerpsClient) -> None: + markets = await perps_async_client.markets.list() + if not markets: + pytest.skip("No margin markets on demo server") + ob = await perps_async_client.markets.orderbook(markets[0].ticker) + assert isinstance(ob, MarginOrderbook) + assert ob.ticker == markets[0].ticker diff --git a/tests/integration/test_perps_orders.py b/tests/integration/test_perps_orders.py new file mode 100644 index 0000000..e055b40 --- /dev/null +++ b/tests/integration/test_perps_orders.py @@ -0,0 +1,154 @@ +"""Integration tests for the perps (margin) orders resource — live demo. + +Covers ``create`` / ``get`` / ``list`` / ``cancel``. Order-mutating tests place a +RESTING (non-marketable, far-from-market) ``bid`` so it never fills, then cancel +it inline; the session-end ``cleanup_perps_orders`` sweep (conftest) is the +backstop, matching on the ``PERPS_TEST_RUN_ID``-tagged ``client_order_id``. + +All perps order endpoints are auth-gated and require a margin-enabled account, so +each test guards with ``skip_if_not_margin_enabled`` (the demo must also expose a +margin market, guarded by the ``perps_market_ticker`` fixture). +""" + +from __future__ import annotations + +import logging +from decimal import Decimal + +import pytest + +from kalshi.perps.async_client import AsyncPerpsClient +from kalshi.perps.client import PerpsClient +from kalshi.perps.models.orders import ( + CreateMarginOrderResponse, + GetMarginOrdersResponse, + MarginOrder, +) +from tests.integration.conftest import skip_if_not_margin_enabled +from tests.integration.coverage_harness import register_perps +from tests.integration.helpers import await_resource, wait_for_resource + +logger = logging.getLogger(__name__) + +register_perps( + "MarginOrdersResource", + [ + "amend", + "cancel", + "create", + "decrease", + "get", + "list", + "list_all", + "list_all_fcm", + "list_fcm", + ], +) + +# A resting bid far below any plausible market price. Perps prices are dollars +# (not the binary [0, 1]); $0.0001 is the minimum positive tick and is virtually +# guaranteed to rest rather than cross. +_RESTING_PRICE = "0.0001" + + +@pytest.mark.integration +class TestPerpsOrdersSync: + def test_list(self, perps_sync_client: PerpsClient) -> None: + skip_if_not_margin_enabled(perps_sync_client) + resp = perps_sync_client.orders.list(limit=5) + assert isinstance(resp, GetMarginOrdersResponse) + for order in resp.orders: + assert isinstance(order, MarginOrder) + assert order.order_id + + def test_create_get_cancel( + self, + perps_sync_client: PerpsClient, + perps_market_ticker: str, + perps_test_run_id: str, + ) -> None: + """Create a resting margin bid, retrieve it, then cancel it.""" + skip_if_not_margin_enabled(perps_sync_client) + client_order_id = f"{perps_test_run_id}-create" + + created = perps_sync_client.orders.create( + ticker=perps_market_ticker, + client_order_id=client_order_id, + side="bid", + count=1, + price=_RESTING_PRICE, + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + ) + assert isinstance(created, CreateMarginOrderResponse) + assert created.order_id + + try: + order = wait_for_resource( + lambda: perps_sync_client.orders.get(created.order_id), + ) + assert isinstance(order, MarginOrder) + assert order.order_id == created.order_id + assert isinstance(order.price, Decimal) + finally: + try: + perps_sync_client.orders.cancel(created.order_id) + except Exception: + logger.warning( + "Failed to cancel perps order %s in teardown", created.order_id + ) + + +@pytest.mark.integration +class TestPerpsOrdersAsync: + async def test_list(self, perps_async_client: AsyncPerpsClient) -> None: + if not perps_async_client.is_authenticated: + pytest.skip("Perps client unauthenticated") + if not (await perps_async_client.exchange.enabled()).enabled: + pytest.skip("Demo account is not margin-enabled") + resp = await perps_async_client.orders.list(limit=5) + assert isinstance(resp, GetMarginOrdersResponse) + for order in resp.orders: + assert isinstance(order, MarginOrder) + + async def test_create_get_cancel( + self, + perps_async_client: AsyncPerpsClient, + perps_test_run_id: str, + ) -> None: + """Async: create a resting margin bid, retrieve it, cancel it.""" + if not perps_async_client.is_authenticated: + pytest.skip("Perps client unauthenticated") + if not (await perps_async_client.exchange.enabled()).enabled: + pytest.skip("Demo account is not margin-enabled") + markets = await perps_async_client.markets.list() + if not markets: + pytest.skip("No margin markets on demo server") + ticker = markets[0].ticker + client_order_id = f"{perps_test_run_id}-async-create" + + created = await perps_async_client.orders.create( + ticker=ticker, + client_order_id=client_order_id, + side="bid", + count=1, + price=_RESTING_PRICE, + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + ) + assert isinstance(created, CreateMarginOrderResponse) + assert created.order_id + + try: + order = await await_resource( + lambda: perps_async_client.orders.get(created.order_id), + ) + assert isinstance(order, MarginOrder) + assert order.order_id == created.order_id + finally: + try: + await perps_async_client.orders.cancel(created.order_id) + except Exception: + logger.warning( + "Failed to cancel async perps order %s", created.order_id + ) diff --git a/tests/integration/test_perps_ws.py b/tests/integration/test_perps_ws.py new file mode 100644 index 0000000..dbfccac --- /dev/null +++ b/tests/integration/test_perps_ws.py @@ -0,0 +1,76 @@ +"""Integration tests for the perps (margin) WebSocket — live demo connection. + +Connects to the demo margin WS feed (``external-api-margin-ws.demo.kalshi.co``) +via the ``perps_ws_session`` fixture (conftest), subscribes to the ``ticker`` +channel, and asserts the perps-unique funding fields parse: ``funding_rate`` is a +``FundingRate`` object whose ``next_funding_time_ms`` is an int epoch-ms. + +Ticker frames are best-effort coalesced broadcasts; a quiet demo market within +the window is valid state, so a timeout SKIPs (does not fail). The margin WS also +requires auth, so the fixture skips an unauthenticated client. +""" + +from __future__ import annotations + +import asyncio +from decimal import Decimal + +import pytest + +from kalshi.perps.client import PerpsClient +from kalshi.perps.ws.client import PerpsWebSocket +from kalshi.perps.ws.models.ticker import FundingRate, MarginTickerMessage +from tests.integration.coverage_harness import register_perps +from tests.integration.helpers import retry_transient + +# The WS surface is not a REST resource, but registering it keeps the perps +# coverage check aware that the ticker channel is exercised live. +register_perps("PerpsWebSocket", ["subscribe_ticker"]) + + +@pytest.fixture(scope="session") +def perps_ws_ticker(perps_sync_client: PerpsClient) -> str: + """A margin market ticker to subscribe the WS ticker channel to.""" + markets = perps_sync_client.markets.list() + if not markets: + pytest.skip("No margin markets on demo server — cannot subscribe ticker") + return markets[0].ticker + + +@pytest.mark.integration +@pytest.mark.asyncio +class TestPerpsWebSocketLive: + @retry_transient(max_retries=2, delay=1.0) + async def test_ws_connect(self, perps_ws_session: PerpsWebSocket) -> None: + """Connect to demo margin WS and verify the session is live. + + The fixture establishes connect + auth; reaching here without a + KalshiConnectionError means the handshake succeeded. + """ + assert perps_ws_session._connection is not None + + @retry_transient(max_retries=2, delay=1.0) + async def test_ws_subscribe_ticker( + self, + perps_ws_session: PerpsWebSocket, + perps_ws_ticker: str, + ) -> None: + """Subscribe to ``ticker`` and parse funding fields from one update.""" + stream = await perps_ws_session.subscribe_ticker(tickers=[perps_ws_ticker]) + try: + msg = await asyncio.wait_for(stream.__anext__(), timeout=15.0) + except TimeoutError: + pytest.skip(f"No margin ticker for {perps_ws_ticker} within 15s") + + assert isinstance(msg, MarginTickerMessage) + assert msg.type == "ticker" + assert msg.msg.market_ticker == perps_ws_ticker + + # funding_rate is optional on the payload; when present it carries an + # int epoch-ms next-funding timestamp and a Decimal rate. + funding = msg.msg.funding_rate + if funding is not None: + assert isinstance(funding, FundingRate) + assert isinstance(funding.next_funding_time_ms, int) + assert isinstance(funding.rate, Decimal) + assert isinstance(funding.ts_ms, int) diff --git a/tests/perps/__init__.py b/tests/perps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/perps/conftest.py b/tests/perps/conftest.py new file mode 100644 index 0000000..9c40582 --- /dev/null +++ b/tests/perps/conftest.py @@ -0,0 +1,52 @@ +"""Shared fixtures for the perps (margin) test package. + +Reuses the RSA-key fixtures from ``tests/conftest.py`` (``rsa_private_key`` / +``pem_bytes`` / ``pem_string`` / ``test_auth``) and adds perps-specific config / +client fixtures pointed at the **demo** perps base URL (a known host, so no +escape hatch is needed for the happy paths). +""" + +from __future__ import annotations + +import os + +import pytest + +from kalshi.auth import KalshiAuth +from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig + +# Strip any perps credentials/overrides the developer's shell may export so the +# perps tests stay hermetic, mirroring the prediction-API conftest. Then enable +# the perps unknown-host escape hatch process-wide so respx-mocked sentinel +# hosts (e.g. https://perps-test.kalshi.com/trade-api/v2) work; the host-check +# tests in test_config.py explicitly ``monkeypatch.delenv`` it. +for _v in ( + "KALSHI_PERPS_KEY_ID", + "KALSHI_PERPS_PRIVATE_KEY", + "KALSHI_PERPS_PRIVATE_KEY_PATH", + "KALSHI_PERPS_PRIVATE_KEY_PASSPHRASE", + "KALSHI_PERPS_API_BASE_URL", + "KALSHI_PERPS_DEMO", + "KALSHI_PERPS_ALLOW_UNKNOWN_HOST", +): + os.environ.pop(_v, None) + +os.environ["KALSHI_PERPS_ALLOW_UNKNOWN_HOST"] = "1" + + +@pytest.fixture +def perps_config() -> PerpsConfig: + """A perps config pointed at the demo perps environment.""" + return PerpsConfig.demo(timeout=5.0, max_retries=2) + + +@pytest.fixture +def perps_client(test_auth: KalshiAuth, perps_config: PerpsConfig) -> PerpsClient: + """An authenticated sync perps client on the demo perps base URL.""" + return PerpsClient(auth=test_auth, config=perps_config) + + +@pytest.fixture +def async_perps_client(test_auth: KalshiAuth, perps_config: PerpsConfig) -> AsyncPerpsClient: + """An authenticated async perps client on the demo perps base URL.""" + return AsyncPerpsClient(auth=test_auth, config=perps_config) diff --git a/tests/perps/klear/__init__.py b/tests/perps/klear/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/perps/klear/conftest.py b/tests/perps/klear/conftest.py new file mode 100644 index 0000000..168b91a --- /dev/null +++ b/tests/perps/klear/conftest.py @@ -0,0 +1,30 @@ +"""Fixtures for the Klear (SCM) test package.""" + +from __future__ import annotations + +import os + +import pytest + +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"): + os.environ.pop(_v, None) + +KLEAR_BASE = "https://demo-api.kalshi.co/klear-api/v1" + + +@pytest.fixture +def klear_config() -> KlearConfig: + return KlearConfig.demo(timeout=5.0, max_retries=2) + + +@pytest.fixture +def klear_client(klear_config: KlearConfig) -> KlearClient: + return KlearClient(config=klear_config) + + +@pytest.fixture +def async_klear_client(klear_config: KlearConfig) -> AsyncKlearClient: + return AsyncKlearClient(config=klear_config) diff --git a/tests/perps/klear/test_auth.py b/tests/perps/klear/test_auth.py new file mode 100644 index 0000000..15155ed --- /dev/null +++ b/tests/perps/klear/test_auth.py @@ -0,0 +1,203 @@ +"""Tests for the Klear (SCM) auth foundation (#399).""" + +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, +) + +BASE = "https://demo-api.kalshi.co/klear-api/v1" + + +class TestLogInHappyPath: + @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=/"}, + ) + ) + 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.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"} + ) + ) + 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.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"}) + ) + 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 + 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 + ) -> None: + respx.post(f"{BASE}/log_in").mock( + return_value=httpx.Response( + 200, json={"token": "T"}, headers={"Set-Cookie": "session=S"} + ) + ) + with caplog.at_level(logging.DEBUG, logger="kalshi"): + klear_client.login(email="log-leak@x.com", password="log-secret") + 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) + + 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 TestReloginResetsSession: + @respx.mock + def test_relogin_mfa_challenge_clears_prior_account( + self, klear_client: KlearClient + ) -> 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() + + +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"} + ) + ) + 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.close() diff --git a/tests/perps/klear/test_config.py b/tests/perps/klear/test_config.py new file mode 100644 index 0000000..6727301 --- /dev/null +++ b/tests/perps/klear/test_config.py @@ -0,0 +1,52 @@ +"""Tests for KlearConfig (SCM host/path guards).""" + +from __future__ import annotations + +import pytest + +from kalshi.config import KalshiConfig +from kalshi.perps.klear.config import DEMO_KLEAR_URL, PRODUCTION_KLEAR_URL, KlearConfig + + +class TestKlearConfig: + def test_factories(self) -> None: + assert KlearConfig.production().base_url == PRODUCTION_KLEAR_URL + assert KlearConfig.demo().base_url == DEMO_KLEAR_URL + + def test_default_is_production_klear(self) -> None: + assert KlearConfig().base_url == PRODUCTION_KLEAR_URL + + def test_is_kalshi_config_subclass(self) -> None: + # Liskov: accepted where the reused transport expects KalshiConfig. + assert issubclass(KlearConfig, KalshiConfig) + assert isinstance(KlearConfig.demo(), KalshiConfig) + + def test_rejects_non_klear_path(self) -> None: + with pytest.raises(ValueError, match="/klear-api/v1"): + KlearConfig(base_url="https://api.klear.kalshi.com/trade-api/v2") + + def test_rejects_unknown_host(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("KALSHI_KLEAR_ALLOW_UNKNOWN_HOST", raising=False) + with pytest.raises(ValueError, match="not a known Kalshi Klear endpoint"): + KlearConfig(base_url="https://api.elections.kalshi.com/klear-api/v1") + + def test_allow_unknown_host_flag(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("KALSHI_KLEAR_ALLOW_UNKNOWN_HOST", raising=False) + c = KlearConfig( + base_url="https://klear-mock.example.com/klear-api/v1", allow_unknown_host=True + ) + assert c.base_url == "https://klear-mock.example.com/klear-api/v1" + + def test_plaintext_remote_rejected(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("KALSHI_KLEAR_ALLOW_UNKNOWN_HOST", "1") + with pytest.raises(ValueError, match="https://"): + KlearConfig(base_url="http://klear-mock.example.com/klear-api/v1") + + def test_localhost_allowed(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("KALSHI_KLEAR_ALLOW_UNKNOWN_HOST", raising=False) + c = KlearConfig(base_url="http://localhost/klear-api/v1") + assert c.base_url == "http://localhost/klear-api/v1" + + def test_holds_no_credentials_in_repr(self) -> None: + # KlearConfig carries no secrets; its repr is inherently safe. + assert "password" not in repr(KlearConfig.demo()).lower() diff --git a/tests/perps/klear/test_margin.py b/tests/perps/klear/test_margin.py new file mode 100644 index 0000000..4c65082 --- /dev/null +++ b/tests/perps/klear/test_margin.py @@ -0,0 +1,751 @@ +"""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. + +Money-typing invariants under test: ``_centicents`` fields stay plain ``int`` +(never ``Decimal``), only the withdraw/withdrawal ``amount`` fields are +``DollarDecimal`` dollar-strings, and the withdraw POST body wire shape is +exactly ``{"amount": "500.00"}``. POST is never retried. +""" + +from __future__ import annotations + +import json +from decimal import Decimal + +import httpx +import pytest +import respx +from pydantic import ValidationError + +from kalshi.errors import ( + AuthRequiredError, + KalshiAuthError, + KalshiError, + KalshiServerError, + KalshiValidationError, +) +from kalshi.perps.klear import AsyncKlearClient, KlearClient, KlearConfig +from kalshi.perps.klear.models.margin import ( + GetSettlementBalanceWithdrawalResponse, + MarginReport, + ObligationEntry, + SettlementBalanceHistoryEntry, + SettlementEstimate, + WithdrawSettlementBalanceRequest, +) + +BASE = "https://demo-api.kalshi.co/klear-api/v1" + +ALL_REPORT_TYPES = [ + "trade_audit", + "position_snapshot", + "market_price_snapshot", + "funding_periods", + "settlement_periods", +] + + +# --------------------------------------------------------------------------- # +# Fixtures / builders +# --------------------------------------------------------------------------- # + + +@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") + 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") + return async_klear_client + + +def _report(report_type: str = "trade_audit") -> dict[str, object]: + return { + "report_type": report_type, + "url": "https://example.com/presigned/report.csv", + "date": "2026-06-01", + "created_ts": "2026-06-01T12:00:00Z", + "is_end_of_day": True, + } + + +def _obligation(amount: int = -12345) -> dict[str, object]: + return { + "id": "ob1", + "user_id": "u1", + "amount_centicents": amount, + "fees_centicents": 100, + "maintenance_margin_centicents": 50, + "pnl_centicents": -200, + "execution_time": "2026-06-01T00:00:00Z", + "last_updated_ts": "2026-06-01T01:00:00Z", + "receives": [ + { + "id": "r1", + "type": "wire", + "amount_centicents": 5000, + "external_reference": "EXT-1", + "created_ts": "2026-06-01T00:30:00Z", + } + ], + "settlement_details": [ + { + "id": "sd1", + "market_ticker": "BTC-PERP", + "subtrader_id": "st1", + "pnl_centicents": -200, + "total_fees_centicents": 100, + "total_amount_centicents": -300, + } + ], + "maintenance_margin_details": [ + { + "id": "mm1", + "subtrader_id": "", + "maintenance_margin_centicents": 50, + "maintenance_margin_delta_centicents": 10, + } + ], + } + + +def _estimate() -> dict[str, int]: + return { + "variation_margin_centicents": 1000, + "total_fees_centicents": 200, + "maintenance_margin_delta_centicents": 30, + "maintenance_margin_required_centicents": 400, + "total_amount_centicents": 1630, + } + + +def _balance_history_entry() -> dict[str, object]: + return { + "balance_delta_centicents": -500, + "locked_balance_delta_centicents": 500, + "reason": "withdrawal_initiated", + "business_transaction_id": "btx-1", + "created_ts": "2026-06-01T00:00:00Z", + } + + +# --------------------------------------------------------------------------- # +# margin_reports +# --------------------------------------------------------------------------- # + + +class TestMarginReports: + @respx.mock + def test_happy_one_per_report_type(self, auth_klear_client: KlearClient) -> None: + route = respx.get(f"{BASE}/margin/reports").mock( + return_value=httpx.Response( + 200, json={"reports": [_report(rt) for rt in ALL_REPORT_TYPES]} + ) + ) + resp = auth_klear_client.margin.margin_reports( + start_date="2026-05-01", end_date="2026-06-01" + ) + assert [r.report_type for r in resp.reports] == ALL_REPORT_TYPES + first = resp.reports[0] + assert isinstance(first, MarginReport) + # date is datetime.date, created_ts is an aware datetime. + assert first.date.isoformat() == "2026-06-01" + assert first.created_ts.tzinfo is not None + params = route.calls.last.request.url.params + assert params["start_date"] == "2026-05-01" + assert params["end_date"] == "2026-06-01" + auth_klear_client.close() + + @respx.mock + def test_empty_reports_array(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/reports").mock( + return_value=httpx.Response(200, json={"reports": []}) + ) + resp = auth_klear_client.margin.margin_reports( + start_date="2026-05-01", end_date="2026-06-01" + ) + assert resp.reports == [] + auth_klear_client.close() + + @respx.mock + def test_null_reports_coerced_to_empty(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/reports").mock( + return_value=httpx.Response(200, json={"reports": None}) + ) + resp = auth_klear_client.margin.margin_reports( + start_date="2026-05-01", end_date="2026-06-01" + ) + assert resp.reports == [] + auth_klear_client.close() + + @respx.mock + def test_rejects_malformed_or_inverted_dates_client_side( + self, auth_klear_client: KlearClient + ) -> None: + # Date-range guard rejects bad/inverted ranges at the SDK boundary, + # before any HTTP — clearer than an opaque server 400. + route = respx.get(f"{BASE}/margin/reports").mock( + return_value=httpx.Response(200, json={"reports": []}) + ) + with pytest.raises(ValueError, match="YYYY-MM-DD"): + auth_klear_client.margin.margin_reports(start_date="nope", end_date="2026-06-01") + with pytest.raises(ValueError, match="on or after"): + auth_klear_client.margin.margin_reports(start_date="2026-06-02", end_date="2026-06-01") + 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( + return_value=httpx.Response(200, json={"reports": [_report()]}) + ) + resp = await auth_async_klear_client.margin.margin_reports( + start_date="2026-05-01", end_date="2026-06-01" + ) + assert resp.reports[0].report_type == "trade_audit" + await auth_async_klear_client.close() + + +# --------------------------------------------------------------------------- # +# active_obligation +# --------------------------------------------------------------------------- # + + +class TestActiveObligation: + @respx.mock + def test_happy_full_obligation(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/active_obligation").mock( + return_value=httpx.Response(200, json={"obligation": _obligation(amount=-99999)}) + ) + resp = auth_klear_client.margin.active_obligation() + ob = resp.obligation + assert isinstance(ob, ObligationEntry) + # Negative net amount stays a plain int (not Decimal). + assert ob.amount_centicents == -99999 + assert isinstance(ob.amount_centicents, int) and not isinstance(ob.amount_centicents, bool) + assert not isinstance(ob.amount_centicents, Decimal) + assert ob.receives[0].amount_centicents == 5000 + assert ob.settlement_details[0].market_ticker == "BTC-PERP" + assert ob.maintenance_margin_details[0].subtrader_id == "" + assert ob.execution_time.tzinfo is not None + auth_klear_client.close() + + @respx.mock + def test_obligation_null(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/active_obligation").mock( + return_value=httpx.Response(200, json={"obligation": None}) + ) + resp = auth_klear_client.margin.active_obligation() + assert resp.obligation is None + auth_klear_client.close() + + @respx.mock + def test_401_maps_to_auth_error(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/active_obligation").mock( + return_value=httpx.Response(401, json={"code": "unauthorized", "message": "no"}) + ) + with pytest.raises(KalshiAuthError): + auth_klear_client.margin.active_obligation() + auth_klear_client.close() + + @respx.mock + async def test_async_null(self, auth_async_klear_client: AsyncKlearClient) -> None: + respx.get(f"{BASE}/margin/active_obligation").mock( + return_value=httpx.Response(200, json={"obligation": None}) + ) + resp = await auth_async_klear_client.margin.active_obligation() + assert resp.obligation is None + await auth_async_klear_client.close() + + +# --------------------------------------------------------------------------- # +# obligation_history / obligation_history_all +# --------------------------------------------------------------------------- # + + +class TestObligationHistory: + @respx.mock + def test_happy_page_with_cursor(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/obligation_history").mock( + return_value=httpx.Response( + 200, json={"obligations": [_obligation()], "cursor": "2026-06-01T00:00:00Z"} + ) + ) + page = auth_klear_client.margin.obligation_history(limit=10) + assert len(page.items) == 1 + assert page.cursor == "2026-06-01T00:00:00Z" + assert page.has_next is True + auth_klear_client.close() + + @respx.mock + def test_all_paginates_two_pages(self, auth_klear_client: KlearClient) -> None: + responses = [ + httpx.Response( + 200, json={"obligations": [_obligation(), _obligation()], "cursor": "CUR2"} + ), + httpx.Response(200, json={"obligations": [_obligation()], "cursor": ""}), + ] + route = respx.get(f"{BASE}/margin/obligation_history").mock(side_effect=responses) + items = list(auth_klear_client.margin.obligation_history_all()) + assert len(items) == 3 + # The cursor from page 1 is forwarded as the page-2 query param. + assert route.calls[1].request.url.params["cursor"] == "CUR2" + auth_klear_client.close() + + def test_limit_over_max_raises_before_http(self, auth_klear_client: KlearClient) -> None: + with pytest.raises(ValueError): + auth_klear_client.margin.obligation_history(limit=101) + auth_klear_client.close() + + @respx.mock + def test_cursor_loop_guard(self, auth_klear_client: KlearClient) -> None: + # Server keeps returning the same cursor -> the paginator loop-guard fires. + respx.get(f"{BASE}/margin/obligation_history").mock( + return_value=httpx.Response( + 200, json={"obligations": [_obligation()], "cursor": "STUCK"} + ) + ) + with pytest.raises(KalshiError): + 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 + ) -> None: + responses = [ + httpx.Response(200, json={"obligations": [_obligation()], "cursor": "C"}), + httpx.Response(200, json={"obligations": [_obligation()], "cursor": ""}), + ] + respx.get(f"{BASE}/margin/obligation_history").mock(side_effect=responses) + items = [o async for o in auth_async_klear_client.margin.obligation_history_all()] + assert len(items) == 2 + await auth_async_klear_client.close() + + +# --------------------------------------------------------------------------- # +# settlement_estimate +# --------------------------------------------------------------------------- # + + +class TestSettlementEstimate: + @respx.mock + def test_happy_with_subtrader_breakdowns(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/settlement_estimate").mock( + return_value=httpx.Response( + 200, + json={ + "user_breakdown": _estimate(), + "subtrader_breakdowns": {"st1": _estimate(), "st2": _estimate()}, + "settlement_balance_centicents": 50000, + }, + ) + ) + resp = auth_klear_client.margin.settlement_estimate() + assert isinstance(resp.user_breakdown, SettlementEstimate) + assert resp.settlement_balance_centicents == 50000 + assert isinstance(resp.settlement_balance_centicents, int) + assert set(resp.subtrader_breakdowns or {}) == {"st1", "st2"} + assert isinstance(resp.subtrader_breakdowns["st1"], SettlementEstimate) # type: ignore[index] + auth_klear_client.close() + + @respx.mock + def test_subtrader_breakdowns_absent(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/settlement_estimate").mock( + return_value=httpx.Response( + 200, + json={"user_breakdown": _estimate(), "settlement_balance_centicents": 1}, + ) + ) + resp = auth_klear_client.margin.settlement_estimate() + assert resp.subtrader_breakdowns is None + auth_klear_client.close() + + @respx.mock + def test_500_propagates(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/settlement_estimate").mock( + return_value=httpx.Response(500, json={"code": "x", "message": "boom"}) + ) + with pytest.raises(KalshiServerError): + auth_klear_client.margin.settlement_estimate() + auth_klear_client.close() + + @respx.mock + async def test_async_happy(self, auth_async_klear_client: AsyncKlearClient) -> None: + respx.get(f"{BASE}/margin/settlement_estimate").mock( + return_value=httpx.Response( + 200, + json={"user_breakdown": _estimate(), "settlement_balance_centicents": 7}, + ) + ) + resp = await auth_async_klear_client.margin.settlement_estimate() + assert resp.settlement_balance_centicents == 7 + await auth_async_klear_client.close() + + +# --------------------------------------------------------------------------- # +# settlement_balance +# --------------------------------------------------------------------------- # + + +class TestSettlementBalance: + @respx.mock + def test_happy(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/settlement_balance").mock( + return_value=httpx.Response( + 200, + json={ + "user_id": "u1", + "balance_available_centicents": 1_000_000, + "locked_balance_centicents": 250_000, + }, + ) + ) + resp = auth_klear_client.margin.settlement_balance() + assert resp.balance_available_centicents == 1_000_000 + assert isinstance(resp.balance_available_centicents, int) + assert not isinstance(resp.balance_available_centicents, Decimal) + assert resp.locked_balance_centicents == 250_000 + auth_klear_client.close() + + @respx.mock + def test_locked_balance_absent(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/settlement_balance").mock( + return_value=httpx.Response( + 200, json={"user_id": "u1", "balance_available_centicents": 5} + ) + ) + resp = auth_klear_client.margin.settlement_balance() + assert resp.locked_balance_centicents is None + auth_klear_client.close() + + @respx.mock + def test_403_maps_to_auth_error(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/settlement_balance").mock( + return_value=httpx.Response(403, json={"code": "forbidden", "message": "no"}) + ) + with pytest.raises(KalshiAuthError): + auth_klear_client.margin.settlement_balance() + auth_klear_client.close() + + @respx.mock + async def test_async_happy(self, auth_async_klear_client: AsyncKlearClient) -> None: + respx.get(f"{BASE}/margin/settlement_balance").mock( + return_value=httpx.Response( + 200, json={"user_id": "u1", "balance_available_centicents": 9} + ) + ) + resp = await auth_async_klear_client.margin.settlement_balance() + assert resp.balance_available_centicents == 9 + await auth_async_klear_client.close() + + +# --------------------------------------------------------------------------- # +# guaranty_fund_balance +# --------------------------------------------------------------------------- # + + +class TestGuarantyFundBalance: + @respx.mock + def test_happy(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/guaranty_fund_balance").mock( + return_value=httpx.Response( + 200, + json={ + "user_id": "u1", + "amount_centicents": 123_456, + "updated_ts": "2026-06-01T00:00:00Z", + }, + ) + ) + resp = auth_klear_client.margin.guaranty_fund_balance() + assert resp.amount_centicents == 123_456 + assert isinstance(resp.amount_centicents, int) + assert resp.updated_ts.tzinfo is not None + auth_klear_client.close() + + @respx.mock + def test_zero_balance(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/guaranty_fund_balance").mock( + return_value=httpx.Response( + 200, + json={ + "user_id": "u1", + "amount_centicents": 0, + "updated_ts": "2026-06-01T00:00:00Z", + }, + ) + ) + resp = auth_klear_client.margin.guaranty_fund_balance() + 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( + return_value=httpx.Response( + 200, + json={ + "user_id": "u1", + "amount_centicents": 1, + "updated_ts": "2026-06-01T00:00:00Z", + }, + ) + ) + resp = await auth_async_klear_client.margin.guaranty_fund_balance() + assert resp.amount_centicents == 1 + await auth_async_klear_client.close() + + +# --------------------------------------------------------------------------- # +# settlement_balance_history / _all +# --------------------------------------------------------------------------- # + + +class TestSettlementBalanceHistory: + @respx.mock + def test_happy_page(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/settlement_balance_history").mock( + return_value=httpx.Response( + 200, json={"entries": [_balance_history_entry()], "cursor": "C1"} + ) + ) + page = auth_klear_client.margin.settlement_balance_history(limit=100) + assert len(page.items) == 1 + assert isinstance(page.items[0], SettlementBalanceHistoryEntry) + assert page.items[0].balance_delta_centicents == -500 + assert isinstance(page.items[0].balance_delta_centicents, int) + assert page.cursor == "C1" + auth_klear_client.close() + + @respx.mock + def test_all_paginates(self, auth_klear_client: KlearClient) -> None: + responses = [ + httpx.Response( + 200, json={"entries": [_balance_history_entry()], "cursor": "NEXT"} + ), + httpx.Response(200, json={"entries": [_balance_history_entry()], "cursor": ""}), + ] + route = respx.get(f"{BASE}/margin/settlement_balance_history").mock( + side_effect=responses + ) + items = list(auth_klear_client.margin.settlement_balance_history_all()) + assert len(items) == 2 + assert route.calls[1].request.url.params["cursor"] == "NEXT" + auth_klear_client.close() + + def test_limit_over_max_raises_before_http(self, auth_klear_client: KlearClient) -> None: + with pytest.raises(ValueError): + auth_klear_client.margin.settlement_balance_history(limit=501) + auth_klear_client.close() + + @respx.mock + def test_empty_entries(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/settlement_balance_history").mock( + return_value=httpx.Response(200, json={"entries": [], "cursor": ""}) + ) + page = auth_klear_client.margin.settlement_balance_history() + assert page.items == [] + assert page.has_next is False + auth_klear_client.close() + + @respx.mock + async def test_async_all_paginates( + self, auth_async_klear_client: AsyncKlearClient + ) -> None: + responses = [ + httpx.Response(200, json={"entries": [_balance_history_entry()], "cursor": "C"}), + httpx.Response(200, json={"entries": [_balance_history_entry()], "cursor": ""}), + ] + respx.get(f"{BASE}/margin/settlement_balance_history").mock(side_effect=responses) + items = [ + e async for e in auth_async_klear_client.margin.settlement_balance_history_all() + ] + assert len(items) == 2 + await auth_async_klear_client.close() + + +# --------------------------------------------------------------------------- # +# withdraw_settlement_balance +# --------------------------------------------------------------------------- # + + +class TestWithdrawSettlementBalance: + @respx.mock + def test_happy_wire_shape(self, auth_klear_client: KlearClient) -> None: + route = respx.post(f"{BASE}/margin/withdraw_settlement_balance").mock( + return_value=httpx.Response(200, json={"id": "wd-1"}) + ) + resp = auth_klear_client.margin.withdraw_settlement_balance(amount="500.00") + assert resp.id == "wd-1" + # Body is exactly {"amount": "500.00"} — DollarDecimal dollar-string, + # by_alias, NOT centicents. + body = json.loads(route.calls.last.request.content) + assert body == {"amount": "500.00"} + auth_klear_client.close() + + def test_forbid_extra_field(self) -> None: + with pytest.raises(ValidationError): + WithdrawSettlementBalanceRequest(amount="5", bogus=1) # type: ignore[call-arg] + + @respx.mock + def test_rejects_non_positive_amount_client_side(self, auth_klear_client: KlearClient) -> None: + # Security: a non-positive withdrawal must be rejected at construction, + # BEFORE any HTTP reaches the real-money endpoint (mirrors OrderPrice). + route = respx.post(f"{BASE}/margin/withdraw_settlement_balance").mock( + return_value=httpx.Response(200, json={"id": "should-not-happen"}) + ) + for bad in ("0.00", "-500.00"): + with pytest.raises(ValidationError): + auth_klear_client.margin.withdraw_settlement_balance(amount=bad) + assert not route.called # no request was ever issued + auth_klear_client.close() + + @respx.mock + def test_server_400_on_valid_amount_maps(self, auth_klear_client: KlearClient) -> None: + # A positive amount the server rejects (e.g. insufficient funds) maps cleanly. + respx.post(f"{BASE}/margin/withdraw_settlement_balance").mock( + return_value=httpx.Response(400, json={"code": "bad", "message": "insufficient funds"}) + ) + with pytest.raises(KalshiValidationError): + auth_klear_client.margin.withdraw_settlement_balance(amount="500.00") + auth_klear_client.close() + + @respx.mock + 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") + 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 + ) -> None: + route = respx.post(f"{BASE}/margin/withdraw_settlement_balance").mock( + return_value=httpx.Response(200, json={"id": "wd-2"}) + ) + resp = await auth_async_klear_client.margin.withdraw_settlement_balance(amount="12.34") + assert resp.id == "wd-2" + body = json.loads(route.calls.last.request.content) + assert body == {"amount": "12.34"} + await auth_async_klear_client.close() + + +# --------------------------------------------------------------------------- # +# settlement_balance_withdrawal +# --------------------------------------------------------------------------- # + + +class TestSettlementBalanceWithdrawal: + @pytest.mark.parametrize("status", ["pending", "processing", "processed", "failed"]) + @respx.mock + def test_happy_per_status(self, auth_klear_client: KlearClient, status: str) -> None: + route = respx.get(f"{BASE}/margin/settlement_balance_withdrawal").mock( + return_value=httpx.Response( + 200, + json={ + "id": "wd-1", + "amount": "500.00", + "status": status, + "created_ts": "2026-06-01T00:00:00Z", + }, + ) + ) + resp = auth_klear_client.margin.settlement_balance_withdrawal(id="wd-1") + assert isinstance(resp, GetSettlementBalanceWithdrawalResponse) + # amount is a DollarDecimal (dollar string -> Decimal), NOT centicents. + assert resp.amount == Decimal("500.00") + assert isinstance(resp.amount, Decimal) + assert resp.status == status + assert route.calls.last.request.url.params["id"] == "wd-1" + auth_klear_client.close() + + @respx.mock + def test_400_missing_id(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/settlement_balance_withdrawal").mock( + return_value=httpx.Response(400, json={"code": "bad", "message": "missing id"}) + ) + with pytest.raises(KalshiValidationError): + auth_klear_client.margin.settlement_balance_withdrawal(id="missing") + auth_klear_client.close() + + @respx.mock + async def test_async_failed_status( + self, auth_async_klear_client: AsyncKlearClient + ) -> None: + respx.get(f"{BASE}/margin/settlement_balance_withdrawal").mock( + return_value=httpx.Response( + 200, + json={ + "id": "wd-9", + "amount": "10.00", + "status": "failed", + "created_ts": "2026-06-01T00:00:00Z", + }, + ) + ) + resp = await auth_async_klear_client.margin.settlement_balance_withdrawal(id="wd-9") + assert resp.status == "failed" + await auth_async_klear_client.close() diff --git a/tests/perps/test_client.py b/tests/perps/test_client.py new file mode 100644 index 0000000..29791e1 --- /dev/null +++ b/tests/perps/test_client.py @@ -0,0 +1,144 @@ +"""Tests for PerpsClient / AsyncPerpsClient construction + lifecycle.""" + +from __future__ import annotations + +import pytest + +from kalshi.auth import KalshiAuth +from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig +from kalshi.perps.config import PERPS_DEMO_BASE_URL + +_RESOURCE_ATTRS = ( + "exchange", + "markets", + "orders", + "order_groups", + "portfolio", + "margin", + "funding", + "transfers", +) + + +class TestSyncConstruction: + def test_key_path_builds_owned_auth(self, tmp_path, pem_bytes: bytes) -> None: + key_file = tmp_path / "perps.pem" + key_file.write_bytes(pem_bytes) + client = PerpsClient( + key_id="perps-key", + private_key_path=str(key_file), + config=PerpsConfig.demo(), + ) + assert client.is_authenticated is True + assert client._auth_owned is True + client.close() + + def test_caller_owned_auth(self, test_auth: KalshiAuth) -> None: + client = PerpsClient(auth=test_auth, config=PerpsConfig.demo()) + assert client.is_authenticated is True + assert client._auth_owned is False + client.close() + + def test_unauthenticated(self) -> None: + client = PerpsClient(config=PerpsConfig.demo()) + assert client.is_authenticated is False + assert client._auth is None + client.close() + + def test_all_resource_stubs_present(self, test_auth: KalshiAuth) -> None: + client = PerpsClient(auth=test_auth, config=PerpsConfig.demo()) + for attr in _RESOURCE_ATTRS: + assert getattr(client, attr) is not None, attr + client.close() + + def test_both_key_sources_raises(self, pem_bytes: bytes) -> None: + with pytest.raises(ValueError, match="not both"): + PerpsClient( + key_id="k", + private_key_path="/tmp/x.pem", + private_key=pem_bytes, + config=PerpsConfig.demo(), + ) + + def test_empty_key_id_raises(self) -> None: + with pytest.raises(ValueError, match="must not be empty"): + PerpsClient(key_id=" ", config=PerpsConfig.demo()) + + def test_demo_with_conflicting_base_url_raises(self) -> None: + with pytest.raises(ValueError, match="Conflicting environment"): + PerpsClient(demo=True, base_url="https://external-api.kalshi.com/trade-api/v2") + + def test_demo_flag_resolves_demo_urls(self) -> None: + client = PerpsClient(demo=True) + assert client._config.base_url == PERPS_DEMO_BASE_URL + client.close() + + def test_context_manager(self, test_auth: KalshiAuth) -> None: + with PerpsClient(auth=test_auth, config=PerpsConfig.demo()) as client: + assert client.is_authenticated is True + + +class TestFromEnv: + def test_from_env_reads_perps_vars( + self, monkeypatch: pytest.MonkeyPatch, pem_string: str + ) -> None: + monkeypatch.setenv("KALSHI_PERPS_KEY_ID", "perps-env-key") + monkeypatch.setenv("KALSHI_PERPS_PRIVATE_KEY", pem_string) + monkeypatch.setenv("KALSHI_PERPS_DEMO", "true") + client = PerpsClient.from_env() + assert client.is_authenticated is True + # from_env built the auth → client owns it. + assert client._auth_owned is True + assert client._config.base_url == PERPS_DEMO_BASE_URL + client.close() + + def test_from_env_does_not_read_prediction_vars( + self, monkeypatch: pytest.MonkeyPatch, pem_string: str + ) -> None: + # Prediction-API creds must NOT authenticate a perps client. + monkeypatch.setenv("KALSHI_KEY_ID", "prediction-key") + monkeypatch.setenv("KALSHI_PRIVATE_KEY", pem_string) + monkeypatch.delenv("KALSHI_PERPS_KEY_ID", raising=False) + client = PerpsClient.from_env() + assert client.is_authenticated is False + client.close() + + def test_from_env_unauthenticated_when_no_creds( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("KALSHI_PERPS_KEY_ID", raising=False) + client = PerpsClient.from_env() + assert client.is_authenticated is False + client.close() + + def test_from_env_caller_auth_not_overwritten( + self, monkeypatch: pytest.MonkeyPatch, pem_string: str, test_auth: KalshiAuth + ) -> None: + monkeypatch.setenv("KALSHI_PERPS_KEY_ID", "perps-env-key") + monkeypatch.setenv("KALSHI_PERPS_PRIVATE_KEY", pem_string) + client = PerpsClient.from_env(auth=test_auth, config=PerpsConfig.demo()) + assert client._auth is test_auth + assert client._auth_owned is False # caller-supplied auth stays caller-owned + client.close() + + +class TestAsyncConstruction: + async def test_async_resource_stubs_present(self, test_auth: KalshiAuth) -> None: + client = AsyncPerpsClient(auth=test_auth, config=PerpsConfig.demo()) + for attr in _RESOURCE_ATTRS: + assert getattr(client, attr) is not None, attr + await client.close() + + async def test_async_context_manager(self, test_auth: KalshiAuth) -> None: + async with AsyncPerpsClient(auth=test_auth, config=PerpsConfig.demo()) as client: + assert client.is_authenticated is True + + async def test_async_from_env( + self, monkeypatch: pytest.MonkeyPatch, pem_string: str + ) -> None: + monkeypatch.setenv("KALSHI_PERPS_KEY_ID", "perps-env-key") + monkeypatch.setenv("KALSHI_PERPS_PRIVATE_KEY", pem_string) + client = AsyncPerpsClient.from_env(demo=True) + assert client.is_authenticated is True + assert client._auth_owned is True + await client.close() diff --git a/tests/perps/test_config.py b/tests/perps/test_config.py new file mode 100644 index 0000000..4ddf5f0 --- /dev/null +++ b/tests/perps/test_config.py @@ -0,0 +1,103 @@ +"""Tests for PerpsConfig (perps base URLs + security guards).""" + +from __future__ import annotations + +import pytest + +from kalshi.config import KalshiConfig +from kalshi.perps.config import ( + PERPS_DEMO_BASE_URL, + PERPS_DEMO_WS_URL, + PERPS_PRODUCTION_BASE_URL, + PERPS_PRODUCTION_WS_URL, + PerpsConfig, +) + + +class TestPerpsConfigFactories: + def test_production_urls(self) -> None: + c = PerpsConfig.production() + assert c.base_url == PERPS_PRODUCTION_BASE_URL + assert c.ws_base_url == PERPS_PRODUCTION_WS_URL + + def test_demo_urls(self) -> None: + c = PerpsConfig.demo() + assert c.base_url == PERPS_DEMO_BASE_URL + assert c.ws_base_url == PERPS_DEMO_WS_URL + + def test_default_is_production_perps(self) -> None: + # The @dataclass(frozen=True) re-decoration must flip the inherited + # default from the prediction-API URL to the perps prod URL. + c = PerpsConfig() + assert c.base_url == PERPS_PRODUCTION_BASE_URL + assert c.ws_base_url == PERPS_PRODUCTION_WS_URL + + def test_is_kalshi_config_subclass(self) -> None: + # Liskov: PerpsConfig must be accepted where the transport expects + # KalshiConfig (the transport is reused unchanged). + assert issubclass(PerpsConfig, KalshiConfig) + assert isinstance(PerpsConfig.demo(), KalshiConfig) + + def test_trailing_slash_stripped(self) -> None: + c = PerpsConfig( + base_url=PERPS_DEMO_BASE_URL + "/", + ws_base_url=PERPS_DEMO_WS_URL + "/", + ) + assert c.base_url == PERPS_DEMO_BASE_URL + assert c.ws_base_url == PERPS_DEMO_WS_URL + + +class TestPerpsConfigGuards: + def test_unknown_host_rejected(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("KALSHI_PERPS_ALLOW_UNKNOWN_HOST", raising=False) + with pytest.raises(ValueError, match="not a known Kalshi perps endpoint"): + PerpsConfig(base_url="https://api.elections.kalshi.com/trade-api/v2") + + def test_prediction_host_rejected(self, monkeypatch: pytest.MonkeyPatch) -> None: + # A prediction-API host must NOT be accepted by PerpsConfig. + monkeypatch.delenv("KALSHI_PERPS_ALLOW_UNKNOWN_HOST", raising=False) + with pytest.raises(ValueError): + PerpsConfig(base_url="https://demo-api.kalshi.co/trade-api/v2") + + def test_allow_unknown_host_flag(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("KALSHI_PERPS_ALLOW_UNKNOWN_HOST", raising=False) + c = PerpsConfig( + base_url="https://perps-test.kalshi.com/trade-api/v2", + ws_base_url=PERPS_PRODUCTION_WS_URL, + allow_unknown_host=True, + ) + assert c.base_url == "https://perps-test.kalshi.com/trade-api/v2" + + def test_allow_unknown_host_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("KALSHI_PERPS_ALLOW_UNKNOWN_HOST", "1") + c = PerpsConfig(base_url="https://perps-test.kalshi.com/trade-api/v2") + assert c.base_url == "https://perps-test.kalshi.com/trade-api/v2" + + def test_missing_v2_path_rejected(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("KALSHI_PERPS_ALLOW_UNKNOWN_HOST", "1") + with pytest.raises(ValueError, match="/trade-api/v2"): + PerpsConfig(base_url="https://external-api.kalshi.com/trade-api/v1") + + def test_plaintext_to_remote_rejected(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("KALSHI_PERPS_ALLOW_UNKNOWN_HOST", "1") + with pytest.raises(ValueError, match="https://"): + PerpsConfig(base_url="http://perps-test.kalshi.com/trade-api/v2") + + def test_access_header_in_extra_headers_rejected(self) -> None: + with pytest.raises(ValueError, match="KALSHI-ACCESS"): + PerpsConfig.demo(extra_headers={"KALSHI-ACCESS-KEY": "forged"}) + + def test_split_rest_ws_environment_rejected(self) -> None: + with pytest.raises(ValueError, match="split REST/WS"): + PerpsConfig( + base_url=PERPS_PRODUCTION_BASE_URL, + ws_base_url=PERPS_DEMO_WS_URL, + ) + + def test_localhost_allowed(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("KALSHI_PERPS_ALLOW_UNKNOWN_HOST", raising=False) + c = PerpsConfig( + base_url="http://localhost/trade-api/v2", + ws_base_url="ws://localhost/trade-api/ws/v2/margin", + ) + assert c.base_url == "http://localhost/trade-api/v2" diff --git a/tests/perps/test_contract_harness.py b/tests/perps/test_contract_harness.py new file mode 100644 index 0000000..d561cc0 --- /dev/null +++ b/tests/perps/test_contract_harness.py @@ -0,0 +1,61 @@ +"""Verify the perps contract-test harness wiring (foundation, #388). + +The maps are empty at the foundation stage; these tests confirm the loaders, +maps, and drift-class scaffolding exist and are well-formed so dependent +per-resource issues can append entries. +""" + +from __future__ import annotations + +from kalshi._contract_map import PERPS_CONTRACT_MAP, PERPS_SCM_CONTRACT_MAP +from tests._contract_support import ( + PERPS_EXCLUSIONS, + PERPS_METHOD_ENDPOINT_MAP, + PERPS_SCM_METHOD_ENDPOINT_MAP, +) +from tests.test_contracts import ( + PERPS_BODY_MODEL_MAP, + PERPS_SCM_BODY_MODEL_MAP, + PERPS_SCM_SPEC_FILE, + PERPS_SPEC_FILE, + _load_perps_scm_spec, + _load_perps_spec, +) + + +class TestPerpsSpecLoaders: + def test_perps_spec_file_committed(self) -> None: + assert PERPS_SPEC_FILE.exists(), PERPS_SPEC_FILE + assert PERPS_SCM_SPEC_FILE.exists(), PERPS_SCM_SPEC_FILE + + def test_load_perps_spec_parses(self) -> None: + spec = _load_perps_spec() + assert "paths" in spec + assert "components" in spec + # Sanity: a known perps path is present. + assert "/margin/exchange/status" in spec["paths"] + + def test_load_perps_scm_spec_parses(self) -> None: + spec = _load_perps_scm_spec() + assert "paths" in spec + assert "/log_in" in spec["paths"] + + +class TestPerpsMapsWellFormed: + def test_rest_maps_populated(self) -> None: + # The REST resources (#389-396) populate these. + assert len(PERPS_METHOD_ENDPOINT_MAP) > 0 + assert len(PERPS_BODY_MODEL_MAP) > 0 + assert len(PERPS_EXCLUSIONS) > 0 + assert len(PERPS_CONTRACT_MAP) > 0 + + def test_scm_maps_populated(self) -> None: + # The SCM/Klear endpoints (#399 auth, #400 margin) populate these. + assert len(PERPS_SCM_METHOD_ENDPOINT_MAP) > 0 + assert len(PERPS_SCM_BODY_MODEL_MAP) > 0 + assert len(PERPS_SCM_CONTRACT_MAP) > 0 + + def test_map_types(self) -> None: + assert isinstance(PERPS_METHOD_ENDPOINT_MAP, list) + assert isinstance(PERPS_EXCLUSIONS, dict) + assert isinstance(PERPS_BODY_MODEL_MAP, dict) diff --git a/tests/perps/test_exchange.py b/tests/perps/test_exchange.py new file mode 100644 index 0000000..34b8a26 --- /dev/null +++ b/tests/perps/test_exchange.py @@ -0,0 +1,160 @@ +"""Tests for the perps exchange resource (#389).""" + +from __future__ import annotations + +from decimal import Decimal + +import httpx +import pytest +import respx + +from kalshi.errors import AuthRequiredError, KalshiServerError +from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig +from kalshi.perps.models.common import ExchangeInstance + +BASE = "https://external-api.demo.kalshi.co/trade-api/v2" + + +# ── status ────────────────────────────────────────────────────────────────── + + +class TestStatus: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/exchange/status").mock( + return_value=httpx.Response( + 200, json={"exchange_active": True, "trading_active": False} + ) + ) + status = perps_client.exchange.status() + assert status.exchange_active is True + assert status.trading_active is False + + @respx.mock + def test_extra_field_tolerated(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/exchange/status").mock( + return_value=httpx.Response( + 200, + json={"exchange_active": True, "trading_active": True, "new_server_field": 1}, + ) + ) + status = perps_client.exchange.status() + assert status.exchange_active is True + + @respx.mock + async def test_async(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/exchange/status").mock( + return_value=httpx.Response(200, json={"exchange_active": True, "trading_active": True}) + ) + status = await async_perps_client.exchange.status() + assert status.trading_active is True + await async_perps_client.close() + + +# ── enabled ───────────────────────────────────────────────────────────────── + + +class TestEnabled: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/enabled").mock( + return_value=httpx.Response(200, json={"enabled": True}) + ) + assert perps_client.exchange.enabled().enabled is True + + @respx.mock + def test_unauthenticated_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/enabled").mock( + return_value=httpx.Response(200, json={"enabled": True}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + client.exchange.enabled() + assert not route.called # no HTTP issued + client.close() + + @respx.mock + def test_server_401_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/enabled").mock( + return_value=httpx.Response(401, json={"error": {"code": "unauthorized"}}) + ) + with pytest.raises(Exception): # noqa: B017 — mapped SDK auth error + perps_client.exchange.enabled() + + @respx.mock + async def test_async_unauth_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/enabled").mock( + return_value=httpx.Response(200, json={"enabled": True}) + ) + client = AsyncPerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + await client.exchange.enabled() + assert not route.called + await client.close() + + +# ── risk_parameters ───────────────────────────────────────────────────────── + + +class TestRiskParameters: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/risk_parameters").mock( + return_value=httpx.Response( + 200, + json={ + "liquidation_margin_ratio_threshold": 0.05, + "queue_entry_margin_ratio_threshold": 0.08, + "initial_margin_multiplier": {"BTC-PERP": 2.0, "ETH-PERP": 1.5}, + }, + ) + ) + rp = perps_client.exchange.risk_parameters() + assert rp.liquidation_margin_ratio_threshold == Decimal("0.05") + assert isinstance(rp.liquidation_margin_ratio_threshold, Decimal) + assert rp.initial_margin_multiplier["BTC-PERP"] == Decimal("2.0") + assert isinstance(rp.initial_margin_multiplier["ETH-PERP"], Decimal) + + @respx.mock + def test_empty_multiplier_map(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/risk_parameters").mock( + return_value=httpx.Response( + 200, + json={ + "liquidation_margin_ratio_threshold": 0.05, + "queue_entry_margin_ratio_threshold": 0.08, + "initial_margin_multiplier": {}, + }, + ) + ) + assert perps_client.exchange.risk_parameters().initial_margin_multiplier == {} + + @respx.mock + def test_high_precision_double_no_float_drift(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/risk_parameters").mock( + return_value=httpx.Response( + 200, + json={ + "liquidation_margin_ratio_threshold": 0.12345678, + "queue_entry_margin_ratio_threshold": 0.08, + "initial_margin_multiplier": {}, + }, + ) + ) + rp = perps_client.exchange.risk_parameters() + # MultiplierDecimal coerces via str() — no binary-float drift. + assert rp.liquidation_margin_ratio_threshold == Decimal("0.12345678") + + @respx.mock + def test_server_error_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/risk_parameters").mock(return_value=httpx.Response(500)) + with pytest.raises(KalshiServerError): + perps_client.exchange.risk_parameters() + + +class TestSharedPrimitives: + def test_exchange_instance(self) -> None: + assert ExchangeInstance("event_contract") is ExchangeInstance.EVENT_CONTRACT + assert ExchangeInstance("margined") is ExchangeInstance.MARGINED + with pytest.raises(ValueError): + ExchangeInstance("unknown") diff --git a/tests/perps/test_funding.py b/tests/perps/test_funding.py new file mode 100644 index 0000000..8768664 --- /dev/null +++ b/tests/perps/test_funding.py @@ -0,0 +1,344 @@ +"""Tests for the perps funding resource (#395). + +Covers ``rate_estimate``, ``historical_rates``, and ``history`` (sync + async): +happy path, edge cases, error mapping, and the auth gate on ``history``. +""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal + +import httpx +import pytest +import respx +from pydantic import ValidationError + +from kalshi.errors import ( + AuthRequiredError, + KalshiAuthError, + KalshiServerError, + KalshiValidationError, +) +from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig +from kalshi.perps.models.funding import ( + MarginFundingHistoryEntry, + MarginFundingRate, +) + +BASE = "https://external-api.demo.kalshi.co/trade-api/v2" + + +# ── rate_estimate ───────────────────────────────────────────────────────────── + + +class TestRateEstimate: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/funding_rates/estimate").mock( + return_value=httpx.Response( + 200, + json={ + "market_ticker": "BTC-PERP", + "computed_time": "2026-06-04T12:00:00Z", + "funding_rate": 0.000125, + "mark_price_dollars": "65000.50", + "next_funding_time": "2026-06-04T16:00:00Z", + }, + ) + ) + est = perps_client.funding.rate_estimate("BTC-PERP") + # mark_price -> Decimal, funding_rate -> float, next_funding_time aware. + assert isinstance(est.mark_price, Decimal) + assert est.mark_price == Decimal("65000.50") + assert isinstance(est.funding_rate, float) + assert est.funding_rate == 0.000125 + assert isinstance(est.next_funding_time, datetime) + assert est.next_funding_time.tzinfo is not None + assert est.market_ticker == "BTC-PERP" + # ticker propagated to the query string. + assert route.calls.last.request.url.params["ticker"] == "BTC-PERP" + + @respx.mock + def test_edge_only_required_field(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/funding_rates/estimate").mock( + return_value=httpx.Response( + 200, json={"next_funding_time": "2026-06-04T16:00:00Z"} + ) + ) + est = perps_client.funding.rate_estimate("BTC-PERP") + assert est.market_ticker is None + assert est.computed_time is None + assert est.funding_rate is None + assert est.mark_price is None + assert isinstance(est.next_funding_time, datetime) + + @respx.mock + def test_error_400_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/funding_rates/estimate").mock( + return_value=httpx.Response(400, json={"error": {"code": "bad_request"}}) + ) + with pytest.raises(KalshiValidationError): + perps_client.funding.rate_estimate("BTC-PERP") + + @respx.mock + def test_missing_required_field_raises(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/funding_rates/estimate").mock( + return_value=httpx.Response(200, json={"market_ticker": "BTC-PERP"}) + ) + with pytest.raises(ValidationError): + perps_client.funding.rate_estimate("BTC-PERP") + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/funding_rates/estimate").mock( + return_value=httpx.Response( + 200, + json={ + "funding_rate": 0.0002, + "mark_price_dollars": "100.0000", + "next_funding_time": "2026-06-04T16:00:00Z", + }, + ) + ) + est = await async_perps_client.funding.rate_estimate("ETH-PERP") + assert est.mark_price == Decimal("100.0000") + assert isinstance(est.next_funding_time, datetime) + await async_perps_client.close() + + +# ── historical_rates ────────────────────────────────────────────────────────── + + +class TestHistoricalRates: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/funding_rates/historical").mock( + return_value=httpx.Response( + 200, + json={ + "funding_rates": [ + { + "market_ticker": "BTC-PERP", + "funding_time": "2026-06-04T08:00:00Z", + "funding_rate": 0.0001, + "mark_price_dollars": "64000.00", + }, + { + "market_ticker": "BTC-PERP", + "funding_time": "2026-06-04T12:00:00Z", + "funding_rate": -0.0002, + "mark_price_dollars": "65000.00", + }, + ] + }, + ) + ) + rates = perps_client.funding.historical_rates( + ticker="BTC-PERP", start_ts=1000, end_ts=2000 + ) + assert len(rates) == 2 + assert all(isinstance(r, MarginFundingRate) for r in rates) + assert isinstance(rates[0].mark_price, Decimal) + assert rates[0].mark_price == Decimal("64000.00") + assert isinstance(rates[1].funding_rate, float) + assert rates[1].funding_rate == -0.0002 + params = route.calls.last.request.url.params + assert params["ticker"] == "BTC-PERP" + assert params["start_ts"] == "1000" + assert params["end_ts"] == "2000" + + @respx.mock + def test_optional_params_omitted_when_none(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/funding_rates/historical").mock( + return_value=httpx.Response(200, json={"funding_rates": []}) + ) + perps_client.funding.historical_rates() + params = route.calls.last.request.url.params + assert "ticker" not in params + assert "start_ts" not in params + assert "end_ts" not in params + + @respx.mock + def test_edge_empty_array(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/funding_rates/historical").mock( + return_value=httpx.Response(200, json={"funding_rates": []}) + ) + assert perps_client.funding.historical_rates() == [] + + @respx.mock + def test_edge_missing_key(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/funding_rates/historical").mock( + return_value=httpx.Response(200, json={}) + ) + assert perps_client.funding.historical_rates() == [] + + @respx.mock + def test_error_500_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/funding_rates/historical").mock( + return_value=httpx.Response(500) + ) + with pytest.raises(KalshiServerError): + perps_client.funding.historical_rates() + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/funding_rates/historical").mock( + return_value=httpx.Response( + 200, + json={ + "funding_rates": [ + { + "market_ticker": "ETH-PERP", + "funding_time": "2026-06-04T08:00:00Z", + "funding_rate": 0.0003, + "mark_price_dollars": "3000.00", + } + ] + }, + ) + ) + rates = await async_perps_client.funding.historical_rates(ticker="ETH-PERP") + assert len(rates) == 1 + assert rates[0].mark_price == Decimal("3000.00") + await async_perps_client.close() + + +# ── history ─────────────────────────────────────────────────────────────────── + + +def _entry(*, funding_amount: str, subaccount_number: int | None) -> dict[str, object]: + return { + "market_ticker": "BTC-PERP", + "funding_time": "2026-06-04T08:00:00Z", + "funding_rate": 0.0001, + "mark_price_dollars": "64000.00", + "funding_amount_dollars": funding_amount, + "quantity_fp": "10.00", + "subaccount_number": subaccount_number, + } + + +class TestHistory: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/funding_history").mock( + return_value=httpx.Response( + 200, + json={ + "funding_history": [ + _entry(funding_amount="12.50", subaccount_number=0), + _entry(funding_amount="-7.25", subaccount_number=None), + ] + }, + ) + ) + entries = perps_client.funding.history( + start_date="2026-06-01", + end_date="2026-06-04", + ticker="BTC-PERP", + subaccount=0, + ) + assert len(entries) == 2 + assert all(isinstance(e, MarginFundingHistoryEntry) for e in entries) + # Decimal typing on dollar/count fields; positive & negative amounts. + assert isinstance(entries[0].funding_amount, Decimal) + assert entries[0].funding_amount == Decimal("12.50") + assert entries[1].funding_amount == Decimal("-7.25") + assert isinstance(entries[0].mark_price, Decimal) + assert isinstance(entries[0].quantity, Decimal) + assert entries[0].quantity == Decimal("10.00") + # subaccount_number: 0 and explicit null both parse. + assert entries[0].subaccount_number == 0 + assert entries[1].subaccount_number is None + # Query string carries all four params. + params = route.calls.last.request.url.params + assert params["start_date"] == "2026-06-01" + assert params["end_date"] == "2026-06-04" + assert params["ticker"] == "BTC-PERP" + assert params["subaccount"] == "0" + + @respx.mock + def test_unauthenticated_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/funding_history").mock( + return_value=httpx.Response(200, json={"funding_history": []}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + client.funding.history(start_date="2026-06-01", end_date="2026-06-04") + assert not route.called # no HTTP issued + client.close() + + @respx.mock + def test_edge_empty_array(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/funding_history").mock( + return_value=httpx.Response(200, json={"funding_history": []}) + ) + out = perps_client.funding.history(start_date="2026-06-01", end_date="2026-06-04") + assert out == [] + + @respx.mock + def test_edge_missing_subaccount_key_raises(self, perps_client: PerpsClient) -> None: + bad = _entry(funding_amount="1.00", subaccount_number=0) + del bad["subaccount_number"] + respx.get(f"{BASE}/margin/funding_history").mock( + return_value=httpx.Response(200, json={"funding_history": [bad]}) + ) + with pytest.raises(ValidationError): + perps_client.funding.history(start_date="2026-06-01", end_date="2026-06-04") + + @respx.mock + def test_optional_params_omitted(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/funding_history").mock( + return_value=httpx.Response(200, json={"funding_history": []}) + ) + perps_client.funding.history(start_date="2026-06-01", end_date="2026-06-04") + params = route.calls.last.request.url.params + assert "ticker" not in params + assert "subaccount" not in params + assert params["start_date"] == "2026-06-01" + + @respx.mock + def test_error_401_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/funding_history").mock( + return_value=httpx.Response(401, json={"error": {"code": "unauthorized"}}) + ) + with pytest.raises(KalshiAuthError): + perps_client.funding.history(start_date="2026-06-01", end_date="2026-06-04") + + @respx.mock + def test_error_403_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/funding_history").mock( + return_value=httpx.Response(403, json={"error": {"code": "forbidden"}}) + ) + with pytest.raises(KalshiAuthError): + perps_client.funding.history(start_date="2026-06-01", end_date="2026-06-04") + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/funding_history").mock( + return_value=httpx.Response( + 200, + json={ + "funding_history": [_entry(funding_amount="5.00", subaccount_number=3)] + }, + ) + ) + entries = await async_perps_client.funding.history( + start_date="2026-06-01", end_date="2026-06-04" + ) + assert len(entries) == 1 + assert entries[0].funding_amount == Decimal("5.00") + assert entries[0].subaccount_number == 3 + await async_perps_client.close() + + @respx.mock + async def test_async_unauth_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/funding_history").mock( + return_value=httpx.Response(200, json={"funding_history": []}) + ) + client = AsyncPerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + await client.funding.history(start_date="2026-06-01", end_date="2026-06-04") + assert not route.called + await client.close() diff --git a/tests/perps/test_margin_account.py b/tests/perps/test_margin_account.py new file mode 100644 index 0000000..264150b --- /dev/null +++ b/tests/perps/test_margin_account.py @@ -0,0 +1,445 @@ +"""Tests for the perps margin-account resource (#394). + +Covers ``balance`` / ``risk`` / ``notional_risk_limit`` / ``fee_tiers`` sync and +async: happy path (asserting Decimal typing on money fields), error mapping, +edge cases (nullable fields, empty maps/arrays, signed positions), the +auth-required guard (raises before any HTTP), and the no-retry-not-applicable +GET-retry behaviour. Models/resources are imported from the concrete module +paths (package ``__init__`` exports are wired during integration). +""" + +from __future__ import annotations + +from decimal import Decimal + +import httpx +import pytest +import respx + +from kalshi.errors import AuthRequiredError, KalshiServerError +from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig + +BASE = "https://external-api.demo.kalshi.co/trade-api/v2" + + +# ── balance ─────────────────────────────────────────────────────────────────── + + +class TestBalance: + @respx.mock + def test_happy_multi_subaccount(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/balance").mock( + return_value=httpx.Response( + 200, + json={ + "subaccount_balances": [ + { + "subaccount": 0, + "position_value": "1500.0000", + "account_equity": "2000.5000", + "maintenance_margin": "300.0000", + "initial_margin": "600.0000", + "resting_orders_margin": "0.0000", + "available_balance": "0.0000", + }, + { + "subaccount": 1, + "position_value": "250.2500", + "account_equity": "0.0000", + "maintenance_margin": "50.0000", + "initial_margin": "0.0000", + "resting_orders_margin": "0.0000", + "available_balance": "0.0000", + }, + ], + "settled_funds": "3210.7500", + }, + ) + ) + resp = perps_client.margin.balance() + assert route.called + assert len(resp.subaccount_balances) == 2 + first = resp.subaccount_balances[0] + assert first.subaccount == 0 + assert first.position_value == Decimal("1500.0000") + assert isinstance(first.position_value, Decimal) + assert isinstance(first.account_equity, Decimal) + assert resp.settled_funds == Decimal("3210.7500") + assert isinstance(resp.settled_funds, Decimal) + + @respx.mock + def test_flag_omitted_by_default(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/balance").mock( + return_value=httpx.Response( + 200, json={"subaccount_balances": [], "settled_funds": "0.0000"} + ) + ) + perps_client.margin.balance() + # Param absent unless caller opts in. + assert "compute_available_balance" not in route.calls.last.request.url.params + + @respx.mock + def test_flag_true_sends_query_param(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/balance").mock( + return_value=httpx.Response( + 200, json={"subaccount_balances": [], "settled_funds": "0.0000"} + ) + ) + perps_client.margin.balance(compute_available_balance=True) + assert route.calls.last.request.url.params["compute_available_balance"] == "true" + + @respx.mock + def test_unauthenticated_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/balance").mock( + return_value=httpx.Response( + 200, json={"subaccount_balances": [], "settled_funds": "0.0000"} + ) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + client.margin.balance() + assert not route.called + client.close() + + @respx.mock + def test_server_401_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/balance").mock( + return_value=httpx.Response(401, json={"error": {"code": "unauthorized"}}) + ) + with pytest.raises(Exception): # noqa: B017 — mapped SDK auth error + perps_client.margin.balance() + + @respx.mock + def test_server_403_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/balance").mock( + return_value=httpx.Response(403, json={"error": {"code": "forbidden"}}) + ) + with pytest.raises(Exception): # noqa: B017 — mapped SDK forbidden error + perps_client.margin.balance() + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/balance").mock( + return_value=httpx.Response( + 200, + json={ + "subaccount_balances": [ + { + "subaccount": 0, + "position_value": "1.0000", + "account_equity": "2.0000", + "maintenance_margin": "0.0000", + "initial_margin": "0.0000", + "resting_orders_margin": "0.0000", + "available_balance": "0.0000", + } + ], + "settled_funds": "5.0000", + }, + ) + ) + resp = await async_perps_client.margin.balance() + assert resp.subaccount_balances[0].account_equity == Decimal("2.0000") + await async_perps_client.close() + + @respx.mock + async def test_async_unauth_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/balance").mock( + return_value=httpx.Response( + 200, json={"subaccount_balances": [], "settled_funds": "0.0000"} + ) + ) + client = AsyncPerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + await client.margin.balance() + assert not route.called + await client.close() + + +# ── risk ────────────────────────────────────────────────────────────────────── + + +class TestRisk: + @respx.mock + def test_happy_signed_position_and_nullables(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/risk").mock( + return_value=httpx.Response( + 200, + json={ + "account_leverage": 2.5, + "total_position_notional": "10000.0000", + "total_maintenance_margin": "4000.0000", + "positions": [ + { + "subaccount": 0, + "market_ticker": "BTC-PERP", + "position": "-150.00", + "mark_price": "0.5600", + "position_notional": "84.0000", + "maintenance_margin_required": "33.6000", + "position_leverage": 2.5, + "estimated_liquidation_price": "0.4200", + }, + { + "subaccount": 1, + "market_ticker": "ETH-PERP", + "position": "10.00", + "mark_price": "0.3000", + "position_notional": "3.0000", + "maintenance_margin_required": None, + "position_leverage": None, + "estimated_liquidation_price": None, + }, + ], + }, + ) + ) + resp = perps_client.margin.risk() + assert resp.account_leverage == 2.5 + assert resp.total_position_notional == Decimal("10000.0000") + assert isinstance(resp.total_maintenance_margin, Decimal) + first = resp.positions[0] + # Signed position round-trips as a negative Decimal. + assert first.position == Decimal("-150.00") + assert isinstance(first.position, Decimal) + assert first.mark_price == Decimal("0.5600") + assert first.estimated_liquidation_price == Decimal("0.4200") + second = resp.positions[1] + assert second.maintenance_margin_required is None + assert second.position_leverage is None + assert second.estimated_liquidation_price is None + + @respx.mock + def test_edge_null_leverage_empty_positions(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/risk").mock( + return_value=httpx.Response( + 200, + json={ + "account_leverage": None, + "total_position_notional": "0.0000", + "total_maintenance_margin": "0.0000", + "positions": [], + }, + ) + ) + resp = perps_client.margin.risk() + assert resp.account_leverage is None + assert resp.positions == [] + + @respx.mock + def test_server_error_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/risk").mock(return_value=httpx.Response(500)) + with pytest.raises(KalshiServerError): + perps_client.margin.risk() + + @respx.mock + def test_unauthenticated_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/risk").mock( + return_value=httpx.Response( + 200, + json={ + "total_position_notional": "0.0000", + "total_maintenance_margin": "0.0000", + "positions": [], + }, + ) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + client.margin.risk() + assert not route.called + client.close() + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/risk").mock( + return_value=httpx.Response( + 200, + json={ + "account_leverage": 1.0, + "total_position_notional": "1.0000", + "total_maintenance_margin": "1.0000", + "positions": [], + }, + ) + ) + resp = await async_perps_client.margin.risk() + assert resp.total_position_notional == Decimal("1.0000") + await async_perps_client.close() + + +# ── notional_risk_limit ─────────────────────────────────────────────────────── + + +class TestNotionalRiskLimit: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/notional_risk_limit").mock( + return_value=httpx.Response( + 200, + json={ + "default_notional_value_risk_limit": "5000.0000", + "notional_value_risk_limits_by_market_ticker": { + "market-abc-123": "7500.0000", + "market-xyz-789": "1000.0000", + }, + }, + ) + ) + resp = perps_client.margin.notional_risk_limit() + assert route.called + assert resp.default_notional_value_risk_limit == Decimal("5000.0000") + assert isinstance(resp.default_notional_value_risk_limit, Decimal) + overrides = resp.notional_value_risk_limits_by_market_ticker + assert overrides["market-abc-123"] == Decimal("7500.0000") + assert isinstance(overrides["market-xyz-789"], Decimal) + + @respx.mock + def test_edge_empty_override_map(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/notional_risk_limit").mock( + return_value=httpx.Response( + 200, + json={ + "default_notional_value_risk_limit": "5000.0000", + "notional_value_risk_limits_by_market_ticker": {}, + }, + ) + ) + resp = perps_client.margin.notional_risk_limit() + assert resp.notional_value_risk_limits_by_market_ticker == {} + + @respx.mock + def test_server_401_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/notional_risk_limit").mock( + return_value=httpx.Response(401, json={"error": {"code": "unauthorized"}}) + ) + with pytest.raises(Exception): # noqa: B017 — mapped SDK auth error + perps_client.margin.notional_risk_limit() + + @respx.mock + def test_unauthenticated_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/notional_risk_limit").mock( + return_value=httpx.Response( + 200, + json={ + "default_notional_value_risk_limit": "0.0000", + "notional_value_risk_limits_by_market_ticker": {}, + }, + ) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + client.margin.notional_risk_limit() + assert not route.called + client.close() + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/notional_risk_limit").mock( + return_value=httpx.Response( + 200, + json={ + "default_notional_value_risk_limit": "5000.0000", + "notional_value_risk_limits_by_market_ticker": {"m-1": "1.0000"}, + }, + ) + ) + resp = await async_perps_client.margin.notional_risk_limit() + assert resp.notional_value_risk_limits_by_market_ticker["m-1"] == Decimal("1.0000") + await async_perps_client.close() + + +# ── fee_tiers ───────────────────────────────────────────────────────────────── + + +class TestFeeTiers: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/fee_tiers").mock( + return_value=httpx.Response( + 200, + json={ + "maker_fee_rates": {"BTC-PERP": 0.0005, "ETH-PERP": 0.0007}, + "taker_fee_rates": {"BTC-PERP": 0.0012, "ETH-PERP": 0.0015}, + }, + ) + ) + resp = perps_client.margin.fee_tiers() + assert resp.maker_fee_rates["BTC-PERP"] == 0.0005 + assert isinstance(resp.maker_fee_rates["BTC-PERP"], float) + assert resp.taker_fee_rates["ETH-PERP"] == 0.0015 + assert isinstance(resp.taker_fee_rates["ETH-PERP"], float) + + @respx.mock + def test_edge_empty_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/fee_tiers").mock( + return_value=httpx.Response( + 200, json={"maker_fee_rates": {}, "taker_fee_rates": {}} + ) + ) + resp = perps_client.margin.fee_tiers() + assert resp.maker_fee_rates == {} + assert resp.taker_fee_rates == {} + + @respx.mock + def test_server_401_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/fee_tiers").mock( + return_value=httpx.Response(401, json={"error": {"code": "unauthorized"}}) + ) + with pytest.raises(Exception): # noqa: B017 — mapped SDK auth error + perps_client.margin.fee_tiers() + + @respx.mock + def test_unauthenticated_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/fee_tiers").mock( + return_value=httpx.Response(200, json={"maker_fee_rates": {}, "taker_fee_rates": {}}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + client.margin.fee_tiers() + assert not route.called + client.close() + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/fee_tiers").mock( + return_value=httpx.Response( + 200, + json={"maker_fee_rates": {"X": 0.001}, "taker_fee_rates": {"X": 0.002}}, + ) + ) + resp = await async_perps_client.margin.fee_tiers() + assert resp.taker_fee_rates["X"] == 0.002 + await async_perps_client.close() + + +# ── base URL / extra-field tolerance ────────────────────────────────────────── + + +class TestPerpsBaseUrl: + @respx.mock + def test_prod_base_url_matched(self, test_auth) -> None: # type: ignore[no-untyped-def] + prod = "https://external-api.kalshi.com/trade-api/v2" + route = respx.get(f"{prod}/margin/fee_tiers").mock( + return_value=httpx.Response(200, json={"maker_fee_rates": {}, "taker_fee_rates": {}}) + ) + client = PerpsClient(auth=test_auth, config=PerpsConfig.production()) + client.margin.fee_tiers() + assert route.called + assert str(route.calls.last.request.url) == f"{prod}/margin/fee_tiers" + client.close() + + @respx.mock + def test_additive_field_tolerated(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/fee_tiers").mock( + return_value=httpx.Response( + 200, + json={ + "maker_fee_rates": {}, + "taker_fee_rates": {}, + "new_server_field": 1, + }, + ) + ) + # extra="allow" — additive field doesn't break parsing. + assert perps_client.margin.fee_tiers().maker_fee_rates == {} diff --git a/tests/perps/test_markets.py b/tests/perps/test_markets.py new file mode 100644 index 0000000..a86f546 --- /dev/null +++ b/tests/perps/test_markets.py @@ -0,0 +1,413 @@ +"""Tests for the perps markets resource (#390). + +All four endpoints (``list`` / ``get`` / ``orderbook`` / ``candlesticks``) are +public margin market data — the spec marks no ``security`` block on any of them, +so an unauthenticated client must reach the wire (no ``AuthRequiredError``). +""" + +from __future__ import annotations + +from decimal import Decimal + +import httpx +import pytest +import respx + +from kalshi.errors import KalshiNotFoundError, KalshiServerError +from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig +from kalshi.perps.models.markets import ( + BidAskDistributionHistorical, + MarginMarket, + MarginMarketCandlestick, + MarginOrderbook, + PriceDistributionHistorical, +) + +BASE = "https://external-api.demo.kalshi.co/trade-api/v2" + + +def _market_dict(**overrides: object) -> dict[str, object]: + base: dict[str, object] = { + "ticker": "BTC-PERP", + "title": "Bitcoin Perpetual", + "status": "active", + "contract_size": "1.000000", + "tick_size": "0.0100", + "fractional_trading_enabled": True, + "leverage_estimate": 2.5, + "price": "0.5600", + "bid": "0.5500", + "ask": "0.5700", + "volume": "1000.00", + "volume_24h": "250.00", + "open_interest": "500.00", + } + base.update(overrides) + return base + + +def _candle_dict(**overrides: object) -> dict[str, object]: + base: dict[str, object] = { + "end_period_ts": 1_700_000_060, + "bid": {"open": "0.5500", "high": "0.5600", "low": "0.5400", "close": "0.5550"}, + "ask": {"open": "0.5700", "high": "0.5800", "low": "0.5600", "close": "0.5750"}, + "price": { + "open": "0.5600", + "high": "0.5700", + "low": "0.5500", + "close": "0.5650", + "mean": "0.5620", + "previous": "0.5580", + }, + "volume": "100.00", + "open_interest": "500.00", + } + base.update(overrides) + return base + + +# ── list ────────────────────────────────────────────────────────────────────── + + +class TestList: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets").mock( + return_value=httpx.Response(200, json={"markets": [_market_dict()]}) + ) + markets = perps_client.markets.list() + assert isinstance(markets, list) + assert len(markets) == 1 + m = markets[0] + assert isinstance(m, MarginMarket) + assert m.ticker == "BTC-PERP" + assert m.status == "active" + assert m.contract_size == Decimal("1.000000") + assert isinstance(m.contract_size, Decimal) + assert m.tick_size == Decimal("0.0100") + assert isinstance(m.tick_size, Decimal) + assert m.leverage_estimate == 2.5 + assert isinstance(m.leverage_estimate, float) + assert m.volume == Decimal("1000.00") + + @respx.mock + def test_status_filter(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/markets").mock( + return_value=httpx.Response(200, json={"markets": []}) + ) + perps_client.markets.list(status="active") + assert route.calls[0].request.url.params["status"] == "active" + + @respx.mock + def test_null_leverage_and_missing_optionals(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets").mock( + return_value=httpx.Response( + 200, + json={ + "markets": [ + { + "ticker": "ETH-PERP", + "title": "Ether Perpetual", + "status": "inactive", + "contract_size": "1.000000", + "tick_size": "0.0100", + "fractional_trading_enabled": False, + "leverage_estimate": None, + } + ] + }, + ) + ) + m = perps_client.markets.list()[0] + assert m.leverage_estimate is None + assert m.price is None + assert m.bid is None + assert m.ask is None + assert m.volume is None + assert m.volume_24h is None + assert m.open_interest is None + + @respx.mock + def test_empty_list(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets").mock( + return_value=httpx.Response(200, json={"markets": []}) + ) + assert perps_client.markets.list() == [] + + @respx.mock + def test_missing_markets_key(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets").mock(return_value=httpx.Response(200, json={})) + assert perps_client.markets.list() == [] + + @respx.mock + def test_server_error_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets").mock(return_value=httpx.Response(500)) + with pytest.raises(KalshiServerError): + perps_client.markets.list() + + @respx.mock + def test_public_no_auth_required(self) -> None: + route = respx.get(f"{BASE}/margin/markets").mock( + return_value=httpx.Response(200, json={"markets": [_market_dict()]}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + markets = client.markets.list() + assert len(markets) == 1 + assert route.called + client.close() + + @respx.mock + async def test_async(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/markets").mock( + return_value=httpx.Response(200, json={"markets": [_market_dict(ticker="SOL-PERP")]}) + ) + markets = await async_perps_client.markets.list() + assert markets[0].ticker == "SOL-PERP" + await async_perps_client.close() + + +# ── get ─────────────────────────────────────────────────────────────────────── + + +class TestGet: + @respx.mock + def test_happy_wrapped(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/BTC-PERP").mock( + return_value=httpx.Response(200, json={"market": _market_dict()}) + ) + m = perps_client.markets.get("BTC-PERP") + assert m.ticker == "BTC-PERP" + assert m.price == Decimal("0.5600") + assert isinstance(m.price, Decimal) + + @respx.mock + def test_happy_bare_fallback(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/BTC-PERP").mock( + return_value=httpx.Response(200, json=_market_dict()) + ) + m = perps_client.markets.get("BTC-PERP") + assert m.ticker == "BTC-PERP" + + @respx.mock + def test_not_found_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/NOPE").mock(return_value=httpx.Response(404)) + with pytest.raises(KalshiNotFoundError): + perps_client.markets.get("NOPE") + + @respx.mock + def test_not_found_single_call(self, perps_client: PerpsClient) -> None: + # 404 is non-retryable; GET only retries on 429/502/503/504. + route = respx.get(f"{BASE}/margin/markets/NOPE").mock( + return_value=httpx.Response(404) + ) + with pytest.raises(KalshiNotFoundError): + perps_client.markets.get("NOPE") + assert route.call_count == 1 + + @respx.mock + async def test_async(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/BTC-PERP").mock( + return_value=httpx.Response(200, json={"market": _market_dict()}) + ) + m = await async_perps_client.markets.get("BTC-PERP") + assert m.ticker == "BTC-PERP" + await async_perps_client.close() + + +# ── orderbook ───────────────────────────────────────────────────────────────── + + +class TestOrderbook: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/BTC-PERP/orderbook").mock( + return_value=httpx.Response( + 200, + json={ + "orderbook": { + "bids": [["0.1500", "100.00"], ["0.1400", "200.00"]], + "asks": [["0.1600", "50.00"]], + } + }, + ) + ) + ob = perps_client.markets.orderbook("BTC-PERP") + assert isinstance(ob, MarginOrderbook) + assert ob.ticker == "BTC-PERP" + assert ob.bids[0].price == Decimal("0.15") + assert isinstance(ob.bids[0].price, Decimal) + assert ob.bids[0].quantity == Decimal("100.00") + assert ob.asks[0].price == Decimal("0.16") + assert ob.asks[0].quantity == Decimal("50.00") + + @respx.mock + def test_params(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/markets/BTC-PERP/orderbook").mock( + return_value=httpx.Response(200, json={"orderbook": {"bids": [], "asks": []}}) + ) + perps_client.markets.orderbook("BTC-PERP", depth=10, aggregation_tick_size="0.10") + params = dict(route.calls[0].request.url.params) + assert params["depth"] == "10" + assert params["aggregation_tick_size"] == "0.10" + + @respx.mock + def test_null_bids_asks(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/BTC-PERP/orderbook").mock( + return_value=httpx.Response( + 200, json={"orderbook": {"bids": None, "asks": None}} + ) + ) + ob = perps_client.markets.orderbook("BTC-PERP") + assert ob.bids == [] + assert ob.asks == [] + + @respx.mock + def test_missing_bids_asks(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/BTC-PERP/orderbook").mock( + return_value=httpx.Response(200, json={"orderbook": {}}) + ) + ob = perps_client.markets.orderbook("BTC-PERP") + assert ob.bids == [] + assert ob.asks == [] + + @respx.mock + def test_not_found_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/NOPE/orderbook").mock( + return_value=httpx.Response(404) + ) + with pytest.raises(KalshiNotFoundError): + perps_client.markets.orderbook("NOPE") + + @respx.mock + async def test_async(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/BTC-PERP/orderbook").mock( + return_value=httpx.Response( + 200, json={"orderbook": {"bids": [["0.1500", "100.00"]], "asks": []}} + ) + ) + ob = await async_perps_client.markets.orderbook("BTC-PERP") + assert ob.bids[0].price == Decimal("0.15") + await async_perps_client.close() + + +# ── candlesticks ────────────────────────────────────────────────────────────── + + +class TestCandlesticks: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/BTC-PERP/candlesticks").mock( + return_value=httpx.Response( + 200, json={"ticker": "BTC-PERP", "candlesticks": [_candle_dict()]} + ) + ) + candles = perps_client.markets.candlesticks( + "BTC-PERP", start_ts=1_700_000_000, end_ts=1_700_003_600, period_interval=1 + ) + assert len(candles) == 1 + c = candles[0] + assert isinstance(c, MarginMarketCandlestick) + assert c.end_period_ts == 1_700_000_060 + assert isinstance(c.bid, BidAskDistributionHistorical) + assert c.bid.open == Decimal("0.5500") + assert c.bid.close == Decimal("0.5550") + assert c.ask.high == Decimal("0.5800") + assert isinstance(c.price, PriceDistributionHistorical) + assert c.price.open == Decimal("0.5600") + assert c.price.mean == Decimal("0.5620") + assert c.volume == Decimal("100.00") + assert isinstance(c.volume, Decimal) + assert c.open_interest == Decimal("500.00") + + @respx.mock + def test_all_null_trade_prices(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/BTC-PERP/candlesticks").mock( + return_value=httpx.Response( + 200, + json={ + "ticker": "BTC-PERP", + "candlesticks": [ + _candle_dict( + price={ + "open": None, + "high": None, + "low": None, + "close": None, + "mean": None, + "previous": None, + } + ) + ], + }, + ) + ) + c = perps_client.markets.candlesticks( + "BTC-PERP", start_ts=1, end_ts=2, period_interval=60 + )[0] + assert c.price.open is None + assert c.price.close is None + assert c.price.mean is None + assert c.price.previous is None + # bid/ask OHLC are still required + present. + assert c.bid.open == Decimal("0.5500") + + @respx.mock + def test_required_and_optional_params(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/markets/BTC-PERP/candlesticks").mock( + return_value=httpx.Response(200, json={"ticker": "BTC-PERP", "candlesticks": []}) + ) + perps_client.markets.candlesticks( + "BTC-PERP", + start_ts=1_700_000_000, + end_ts=1_700_003_600, + period_interval=1440, + include_latest_before_start=True, + ) + params = dict(route.calls[0].request.url.params) + assert params["start_ts"] == "1700000000" + assert params["end_ts"] == "1700003600" + assert params["period_interval"] == "1440" + assert params["include_latest_before_start"] == "true" + + @respx.mock + def test_include_latest_omitted_by_default(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/markets/BTC-PERP/candlesticks").mock( + return_value=httpx.Response(200, json={"ticker": "BTC-PERP", "candlesticks": []}) + ) + perps_client.markets.candlesticks( + "BTC-PERP", start_ts=1, end_ts=2, period_interval=1 + ) + assert "include_latest_before_start" not in dict(route.calls[0].request.url.params) + + @respx.mock + def test_not_found_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/NOPE/candlesticks").mock( + return_value=httpx.Response(404) + ) + with pytest.raises(KalshiNotFoundError): + perps_client.markets.candlesticks("NOPE", start_ts=1, end_ts=2, period_interval=1) + + @respx.mock + def test_invalid_period_interval_server_rejected(self, perps_client: PerpsClient) -> None: + # The client passes period_interval through; the server rejects bad + # values with a 400 which surfaces as a mapped SDK error. + respx.get(f"{BASE}/margin/markets/BTC-PERP/candlesticks").mock( + return_value=httpx.Response(400, json={"error": {"code": "invalid_parameter"}}) + ) + with pytest.raises(Exception): # noqa: B017 — mapped SDK validation error + perps_client.markets.candlesticks( + "BTC-PERP", start_ts=1, end_ts=2, period_interval=5 + ) + + @respx.mock + async def test_async(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/BTC-PERP/candlesticks").mock( + return_value=httpx.Response( + 200, json={"ticker": "BTC-PERP", "candlesticks": [_candle_dict()]} + ) + ) + candles = await async_perps_client.markets.candlesticks( + "BTC-PERP", start_ts=1, end_ts=2, period_interval=1 + ) + assert candles[0].price.mean == Decimal("0.5620") + await async_perps_client.close() diff --git a/tests/perps/test_models_common.py b/tests/perps/test_models_common.py new file mode 100644 index 0000000..fee344c --- /dev/null +++ b/tests/perps/test_models_common.py @@ -0,0 +1,108 @@ +"""Tests for the shared perps common models (enums + value-objects).""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest +from pydantic import BaseModel, TypeAdapter + +from kalshi.perps.models.common import ( + BookSide, + EmptyResponse, + ErrorResponse, + ExchangeIndex, + ExchangeInstance, + LastUpdateReason, + MarginMarketStatus, + OrderSource, + PriceLevelDollarsCountFp, + SelfTradePreventionType, +) +from kalshi.types import DollarDecimal, FixedPointCount + + +class TestEnums: + def test_book_side_values(self) -> None: + assert BookSide("bid") is BookSide.BID + assert BookSide("ask") is BookSide.ASK + assert BookSide.BID.value == "bid" + + def test_self_trade_prevention_values(self) -> None: + assert SelfTradePreventionType("taker_at_cross") is SelfTradePreventionType.TAKER_AT_CROSS + assert SelfTradePreventionType("maker") is SelfTradePreventionType.MAKER + + def test_last_update_reason_includes_empty_string_member(self) -> None: + # The empty-string member is a real wire value. + assert LastUpdateReason("") is LastUpdateReason.NONE + assert LastUpdateReason.NONE.value == "" + # PascalCase wire members (no snake_case rename). + assert LastUpdateReason("MarginCancel") is LastUpdateReason.MARGIN_CANCEL + assert LastUpdateReason("PostOnlyCrossCancel") is LastUpdateReason.POST_ONLY_CROSS_CANCEL + + def test_order_source_values(self) -> None: + assert OrderSource("user") is OrderSource.USER + assert OrderSource("system") is OrderSource.SYSTEM + + def test_margin_market_status_values(self) -> None: + assert {s.value for s in MarginMarketStatus} == {"inactive", "active", "closed"} + + def test_exchange_instance_values(self) -> None: + assert ExchangeInstance("event_contract") is ExchangeInstance.EVENT_CONTRACT + assert ExchangeInstance("margined") is ExchangeInstance.MARGINED + + def test_unknown_enum_value_raises(self) -> None: + with pytest.raises(ValueError): + BookSide("yes") # prediction-API value — not valid for perps + with pytest.raises(ValueError): + ExchangeInstance("nope") + + +class TestValueObjects: + def test_exchange_index_is_int_alias(self) -> None: + assert ExchangeIndex is int + + def test_price_level_tuple_parses(self) -> None: + adapter = TypeAdapter(PriceLevelDollarsCountFp) + price, qty = adapter.validate_python(["0.1500", "100.00"]) + assert price == Decimal("0.1500") + assert qty == Decimal("100.00") + assert isinstance(price, Decimal) + assert isinstance(qty, Decimal) + + def test_empty_response_parses_empty_object(self) -> None: + assert EmptyResponse.model_validate({}) is not None + # extra="allow": an additive field doesn't break parsing. + EmptyResponse.model_validate({"unexpected": 1}) + + def test_error_response_parses_full_body(self) -> None: + err = ErrorResponse.model_validate( + { + "code": "margin_disabled", + "message": "Margin not enabled", + "details": "contact support", + "service": "trade-api", + } + ) + assert err.code == "margin_disabled" + assert err.message == "Margin not enabled" + assert err.details == "contact support" + assert err.service == "trade-api" + + def test_error_response_all_optional(self) -> None: + err = ErrorResponse.model_validate({}) + assert err.code is None and err.message is None + + +class _RoundTrip(BaseModel): + price: DollarDecimal + count: FixedPointCount + + +class TestDecimalReuse: + def test_dollar_and_count_roundtrip(self) -> None: + m = _RoundTrip.model_validate({"price": "0.560000", "count": "10.00"}) + assert m.price == Decimal("0.560000") + assert m.count == Decimal("10.00") + dumped = m.model_dump(mode="json") + assert dumped == {"price": "0.560000", "count": "10.00"} diff --git a/tests/perps/test_order_groups.py b/tests/perps/test_order_groups.py new file mode 100644 index 0000000..dffed5e --- /dev/null +++ b/tests/perps/test_order_groups.py @@ -0,0 +1,455 @@ +"""Tests for the perps (margin) order groups resource (#392).""" + +from __future__ import annotations + +import json +from decimal import Decimal + +import httpx +import pytest +import respx +from pydantic import ValidationError + +from kalshi.errors import ( + AuthRequiredError, + KalshiAuthError, + KalshiNotFoundError, + KalshiValidationError, +) +from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig +from kalshi.perps.models.order_groups import ( + CreateOrderGroupRequest, + CreateOrderGroupResponse, + GetOrderGroupResponse, + OrderGroup, + UpdateOrderGroupLimitRequest, +) + +BASE = "https://external-api.demo.kalshi.co/trade-api/v2" + + +def _signed(request: httpx.Request) -> bool: + """True if the request carries the RSA-PSS auth headers.""" + return ( + "KALSHI-ACCESS-KEY" in request.headers + and "KALSHI-ACCESS-SIGNATURE" in request.headers + and "KALSHI-ACCESS-TIMESTAMP" in request.headers + ) + + +# ── list ────────────────────────────────────────────────────────────────────── + + +class TestList: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/order_groups").mock( + return_value=httpx.Response( + 200, + json={ + "order_groups": [ + { + "id": "og-1", + "contracts_limit_fp": "10.00", + "is_auto_cancel_enabled": True, + "exchange_index": 0, + }, + { + "id": "og-2", + "contracts_limit_fp": "25.50", + "is_auto_cancel_enabled": False, + }, + ] + }, + ) + ) + groups = perps_client.order_groups.list() + assert isinstance(groups, list) + assert [g.id for g in groups] == ["og-1", "og-2"] + assert isinstance(groups[0], OrderGroup) + assert groups[0].contracts_limit == Decimal("10.00") + assert isinstance(groups[0].contracts_limit, Decimal) + assert groups[1].contracts_limit == Decimal("25.50") + assert groups[1].is_auto_cancel_enabled is False + assert _signed(route.calls.last.request) + + @respx.mock + def test_empty_when_absent(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/order_groups").mock( + return_value=httpx.Response(200, json={}) + ) + assert perps_client.order_groups.list() == [] + + @respx.mock + def test_subaccount_query_param(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/order_groups").mock( + return_value=httpx.Response(200, json={"order_groups": []}) + ) + perps_client.order_groups.list(subaccount=3) + assert route.calls.last.request.url.params["subaccount"] == "3" + + @respx.mock + def test_unauthenticated_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/order_groups").mock( + return_value=httpx.Response(200, json={"order_groups": []}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + client.order_groups.list() + assert not route.called + client.close() + + @respx.mock + def test_server_401_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/order_groups").mock( + return_value=httpx.Response(401, json={"error": {"code": "unauthorized"}}) + ) + with pytest.raises(KalshiAuthError): + perps_client.order_groups.list() + + +# ── get ─────────────────────────────────────────────────────────────────────── + + +class TestGet: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/order_groups/og-9").mock( + return_value=httpx.Response( + 200, + json={ + "is_auto_cancel_enabled": True, + "orders": ["ord-1", "ord-2"], + "contracts_limit_fp": "5.00", + }, + ) + ) + resp = perps_client.order_groups.get("og-9") + assert isinstance(resp, GetOrderGroupResponse) + assert resp.is_auto_cancel_enabled is True + assert resp.orders == ["ord-1", "ord-2"] + assert resp.contracts_limit == Decimal("5.00") + assert _signed(route.calls.last.request) + + @respx.mock + def test_null_orders_coerced_to_empty(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/order_groups/og-1").mock( + return_value=httpx.Response( + 200, + json={"is_auto_cancel_enabled": False, "orders": None}, + ) + ) + resp = perps_client.order_groups.get("og-1") + assert resp.orders == [] + + @respx.mock + def test_subaccount_query_param(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/order_groups/og-1").mock( + return_value=httpx.Response( + 200, json={"is_auto_cancel_enabled": False, "orders": []} + ) + ) + perps_client.order_groups.get("og-1", subaccount=2) + assert route.calls.last.request.url.params["subaccount"] == "2" + + @respx.mock + def test_404_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/order_groups/missing").mock( + return_value=httpx.Response(404, json={"error": {"code": "not_found"}}) + ) + with pytest.raises(KalshiNotFoundError): + perps_client.order_groups.get("missing") + + +# ── create ──────────────────────────────────────────────────────────────────── + + +class TestCreate: + @respx.mock + def test_happy_kwargs(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/margin/order_groups/create").mock( + return_value=httpx.Response( + 201, json={"order_group_id": "og-new", "subaccount": 0} + ) + ) + resp = perps_client.order_groups.create(contracts_limit=10) + assert isinstance(resp, CreateOrderGroupResponse) + assert resp.order_group_id == "og-new" + assert resp.subaccount == 0 + + assert json.loads(route.calls.last.request.content) == {"contracts_limit": 10} + assert _signed(route.calls.last.request) + + @respx.mock + def test_happy_request_model(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/margin/order_groups/create").mock( + return_value=httpx.Response( + 201, json={"order_group_id": "og-2", "subaccount": 1, "exchange_index": 0} + ) + ) + resp = perps_client.order_groups.create( + request=CreateOrderGroupRequest(contracts_limit=5, subaccount=1) + ) + assert resp.subaccount == 1 + + assert json.loads(route.calls.last.request.content) == { + "contracts_limit": 5, + "subaccount": 1, + } + + @respx.mock + def test_subaccount_and_exchange_index_serialized(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/margin/order_groups/create").mock( + return_value=httpx.Response( + 201, json={"order_group_id": "og-3", "subaccount": 2} + ) + ) + perps_client.order_groups.create(contracts_limit=7, subaccount=2, exchange_index=0) + + assert json.loads(route.calls.last.request.content) == { + "contracts_limit": 7, + "subaccount": 2, + "exchange_index": 0, + } + + def test_both_request_and_kwargs_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(TypeError): + perps_client.order_groups.create( + request=CreateOrderGroupRequest(contracts_limit=1), contracts_limit=2 + ) + + def test_neither_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(TypeError): + perps_client.order_groups.create() + + def test_phantom_kwarg_rejected(self) -> None: + with pytest.raises(ValidationError): + CreateOrderGroupRequest(contracts_limit=1, bogus=True) # type: ignore[call-arg] + + def test_contracts_limit_below_min_rejected(self) -> None: + with pytest.raises(ValidationError): + CreateOrderGroupRequest(contracts_limit=0) + + @respx.mock + def test_400_maps(self, perps_client: PerpsClient) -> None: + respx.post(f"{BASE}/margin/order_groups/create").mock( + return_value=httpx.Response(400, json={"error": {"code": "bad_request"}}) + ) + with pytest.raises(KalshiValidationError): + perps_client.order_groups.create(contracts_limit=10) + + @respx.mock + def test_not_retried(self, perps_client: PerpsClient) -> None: + # perps_config sets max_retries=2, but POST is never retried. + route = respx.post(f"{BASE}/margin/order_groups/create").mock( + return_value=httpx.Response(503, json={"error": {"code": "unavailable"}}) + ) + with pytest.raises(Exception): # noqa: B017 — mapped 503 + perps_client.order_groups.create(contracts_limit=10) + assert route.call_count == 1 # POST is never retried + + +# ── delete ──────────────────────────────────────────────────────────────────── + + +class TestDelete: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + route = respx.delete(f"{BASE}/margin/order_groups/og-1").mock( + return_value=httpx.Response(200, json={}) + ) + assert perps_client.order_groups.delete("og-1") is None + assert _signed(route.calls.last.request) + + @respx.mock + def test_query_params(self, perps_client: PerpsClient) -> None: + route = respx.delete(f"{BASE}/margin/order_groups/og-1").mock( + return_value=httpx.Response(200, json={}) + ) + perps_client.order_groups.delete("og-1", subaccount=4) + params = route.calls.last.request.url.params + assert params["subaccount"] == "4" + + @respx.mock + def test_404_maps(self, perps_client: PerpsClient) -> None: + respx.delete(f"{BASE}/margin/order_groups/missing").mock( + return_value=httpx.Response(404, json={"error": {"code": "not_found"}}) + ) + with pytest.raises(KalshiNotFoundError): + perps_client.order_groups.delete("missing") + + @respx.mock + def test_not_retried(self, perps_client: PerpsClient) -> None: + # perps_config sets max_retries=2, but DELETE is never retried. + route = respx.delete(f"{BASE}/margin/order_groups/og-1").mock( + return_value=httpx.Response(503, json={"error": {"code": "unavailable"}}) + ) + with pytest.raises(Exception): # noqa: B017 — mapped 503 + perps_client.order_groups.delete("og-1") + assert route.call_count == 1 # DELETE is never retried + + +# ── reset ───────────────────────────────────────────────────────────────────── + + +class TestReset: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + route = respx.put(f"{BASE}/margin/order_groups/og-1/reset").mock( + return_value=httpx.Response(200, json={}) + ) + assert perps_client.order_groups.reset("og-1", subaccount=1) is None + req = route.calls.last.request + assert req.headers["content-type"] == "application/json" + assert req.content == b"{}" + assert req.url.params["subaccount"] == "1" + assert _signed(req) + + @respx.mock + def test_404_maps(self, perps_client: PerpsClient) -> None: + respx.put(f"{BASE}/margin/order_groups/missing/reset").mock( + return_value=httpx.Response(404, json={"error": {"code": "not_found"}}) + ) + with pytest.raises(KalshiNotFoundError): + perps_client.order_groups.reset("missing") + + +# ── trigger ─────────────────────────────────────────────────────────────────── + + +class TestTrigger: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + route = respx.put(f"{BASE}/margin/order_groups/og-1/trigger").mock( + return_value=httpx.Response(200, json={}) + ) + assert perps_client.order_groups.trigger("og-1", subaccount=2) is None + req = route.calls.last.request + assert req.headers["content-type"] == "application/json" + assert req.content == b"{}" + assert req.url.params["subaccount"] == "2" + assert _signed(req) + + @respx.mock + def test_404_maps(self, perps_client: PerpsClient) -> None: + respx.put(f"{BASE}/margin/order_groups/missing/trigger").mock( + return_value=httpx.Response(404, json={"error": {"code": "not_found"}}) + ) + with pytest.raises(KalshiNotFoundError): + perps_client.order_groups.trigger("missing") + + +# ── update_limit ────────────────────────────────────────────────────────────── + + +class TestUpdateLimit: + @respx.mock + def test_happy_kwargs(self, perps_client: PerpsClient) -> None: + route = respx.put(f"{BASE}/margin/order_groups/og-1/limit").mock( + return_value=httpx.Response(200, json={}) + ) + assert ( + perps_client.order_groups.update_limit("og-1", contracts_limit=25, subaccount=1) + is None + ) + req = route.calls.last.request + + assert json.loads(req.content) == {"contracts_limit": 25} + assert req.url.params["subaccount"] == "1" + assert _signed(req) + + @respx.mock + def test_happy_request_model(self, perps_client: PerpsClient) -> None: + route = respx.put(f"{BASE}/margin/order_groups/og-1/limit").mock( + return_value=httpx.Response(200, json={}) + ) + perps_client.order_groups.update_limit( + "og-1", request=UpdateOrderGroupLimitRequest(contracts_limit=50) + ) + + assert json.loads(route.calls.last.request.content) == {"contracts_limit": 50} + + def test_both_request_and_kwargs_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(TypeError): + perps_client.order_groups.update_limit( + "og-1", + request=UpdateOrderGroupLimitRequest(contracts_limit=1), + contracts_limit=2, + ) + + def test_neither_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(TypeError): + perps_client.order_groups.update_limit("og-1") + + def test_phantom_kwarg_rejected(self) -> None: + with pytest.raises(ValidationError): + UpdateOrderGroupLimitRequest(contracts_limit=1, bogus=True) # type: ignore[call-arg] + + @respx.mock + def test_400_maps(self, perps_client: PerpsClient) -> None: + respx.put(f"{BASE}/margin/order_groups/og-1/limit").mock( + return_value=httpx.Response(400, json={"error": {"code": "bad_request"}}) + ) + with pytest.raises(KalshiValidationError): + perps_client.order_groups.update_limit("og-1", contracts_limit=25) + + +# ── async coverage ──────────────────────────────────────────────────────────── + + +class TestAsync: + @respx.mock + async def test_list(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/order_groups").mock( + return_value=httpx.Response( + 200, + json={ + "order_groups": [ + {"id": "og-1", "is_auto_cancel_enabled": True, "contracts_limit_fp": "3.00"} + ] + }, + ) + ) + groups = await async_perps_client.order_groups.list() + assert groups[0].id == "og-1" + assert groups[0].contracts_limit == Decimal("3.00") + await async_perps_client.close() + + @respx.mock + async def test_create(self, async_perps_client: AsyncPerpsClient) -> None: + route = respx.post(f"{BASE}/margin/order_groups/create").mock( + return_value=httpx.Response( + 201, json={"order_group_id": "og-a", "subaccount": 0} + ) + ) + resp = await async_perps_client.order_groups.create(contracts_limit=8) + assert resp.order_group_id == "og-a" + assert resp.subaccount == 0 + + assert json.loads(route.calls.last.request.content) == {"contracts_limit": 8} + await async_perps_client.close() + + @respx.mock + async def test_update_limit(self, async_perps_client: AsyncPerpsClient) -> None: + route = respx.put(f"{BASE}/margin/order_groups/og-1/limit").mock( + return_value=httpx.Response(200, json={}) + ) + await async_perps_client.order_groups.update_limit( + "og-1", contracts_limit=12, subaccount=2 + ) + req = route.calls.last.request + + assert json.loads(req.content) == {"contracts_limit": 12} + assert req.url.params["subaccount"] == "2" + await async_perps_client.close() + + @respx.mock + async def test_unauthenticated_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/order_groups").mock( + return_value=httpx.Response(200, json={"order_groups": []}) + ) + client = AsyncPerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + await client.order_groups.list() + assert not route.called + await client.close() diff --git a/tests/perps/test_orders.py b/tests/perps/test_orders.py new file mode 100644 index 0000000..6d4ab28 --- /dev/null +++ b/tests/perps/test_orders.py @@ -0,0 +1,606 @@ +"""Tests for the perps (margin) orders resource (#391). + +Concrete-module imports only (``kalshi.perps.models.orders`` / +``kalshi.perps.resources.orders``) — the ``kalshi.perps`` package ``__init__`` +exports are wired during integration, after this resource lands. +""" + +from __future__ import annotations + +import json +from decimal import Decimal + +import httpx +import pytest +import respx +from pydantic import ValidationError + +from kalshi.errors import ( + AuthRequiredError, + KalshiConflictError, + KalshiNotFoundError, + KalshiServerError, + KalshiValidationError, +) +from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig +from kalshi.perps.models.orders import ( + AmendMarginOrderRequest, + CreateMarginOrderRequest, + DecreaseMarginOrderRequest, + GetMarginOrdersResponse, + MarginOrder, +) + +BASE = "https://external-api.demo.kalshi.co/trade-api/v2" + + +def _order_dict(**overrides: object) -> dict[str, object]: + """A minimal valid MarginOrder wire dict (required fields per spec).""" + base: dict[str, object] = { + "order_id": "ord-1", + "user_id": "usr-1", + "client_order_id": "cid-1", + "ticker": "BTC-PERP", + "side": "bid", + "last_update_reason": "", + "price": "0.5600", + "fill_count": "0.00", + "remaining_count": "100.00", + } + base.update(overrides) + return base + + +# ── create ─────────────────────────────────────────────────────────────────── + + +class TestCreate: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/margin/orders").mock( + return_value=httpx.Response( + 201, + json={ + "order_id": "ord-9", + "fill_count": "3.00", + "remaining_count": "97.00", + "client_order_id": "cid-9", + "average_fill_price": "0.5600", + "average_fee_paid": "0.0100", + }, + ) + ) + resp = perps_client.orders.create( + ticker="BTC-PERP", + client_order_id="cid-9", + side="bid", + count="100", + price="0.56", + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + ) + assert resp.order_id == "ord-9" + assert resp.fill_count == Decimal("3.00") + assert isinstance(resp.fill_count, Decimal) + assert resp.remaining_count == Decimal("97.00") + assert resp.average_fill_price == Decimal("0.5600") + + body = json.loads(route.calls[0].request.content) + assert body["client_order_id"] == "cid-9" + assert body["side"] == "bid" + assert body["price"] == "0.56" # OrderPrice serializes to a fixed-point string + assert body["count"] == "100" + # wire names are the short keys, not _dollars/_fp suffixed + assert "price_dollars" not in body + assert "count_fp" not in body + + @respx.mock + def test_conflict_maps(self, perps_client: PerpsClient) -> None: + respx.post(f"{BASE}/margin/orders").mock( + return_value=httpx.Response(409, json={"error": {"code": "duplicate"}}) + ) + with pytest.raises(KalshiConflictError): + perps_client.orders.create( + ticker="BTC-PERP", + client_order_id="cid-9", + side="bid", + count="100", + price="0.56", + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + ) + + def test_missing_required_field_raises_before_http(self, perps_client: PerpsClient) -> None: + # time_in_force / self_trade_prevention_type omitted -> TypeError, no HTTP. + with pytest.raises(TypeError): + perps_client.orders.create( + ticker="BTC-PERP", + client_order_id="cid-9", + side="bid", + count="100", + price="0.56", + ) + + def test_phantom_kwarg_rejected_by_forbid(self) -> None: + with pytest.raises(ValidationError): + CreateMarginOrderRequest( + ticker="BTC-PERP", + client_order_id="cid-9", + side="bid", + count="100", + price="0.56", + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + phantom="x", # type: ignore[call-arg] + ) + + def test_negative_price_rejected(self) -> None: + with pytest.raises(ValidationError): + CreateMarginOrderRequest( + ticker="BTC-PERP", + client_order_id="cid-9", + side="bid", + count="100", + price="-0.56", + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + ) + + @respx.mock + def test_unauthenticated_raises_before_http(self) -> None: + route = respx.post(f"{BASE}/margin/orders").mock( + return_value=httpx.Response(201, json={}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + client.orders.create( + ticker="BTC-PERP", + client_order_id="cid-9", + side="bid", + count="100", + price="0.56", + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + ) + assert not route.called + client.close() + + @respx.mock + def test_not_retried_on_503(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/margin/orders").mock( + return_value=httpx.Response(503, json={"error": {"code": "unavailable"}}) + ) + with pytest.raises(KalshiServerError): + perps_client.orders.create( + ticker="BTC-PERP", + client_order_id="cid-9", + side="bid", + count="100", + price="0.56", + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + ) + assert route.call_count == 1 # POST is never retried + + @respx.mock + def test_request_model_path(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/margin/orders").mock( + return_value=httpx.Response( + 201, + json={"order_id": "ord-9", "fill_count": "0.00", "remaining_count": "100.00"}, + ) + ) + perps_client.orders.create( + request=CreateMarginOrderRequest( + ticker="BTC-PERP", + client_order_id="cid-9", + side="ask", + count="100", + price="0.56", + time_in_force="immediate_or_cancel", + self_trade_prevention_type="maker", + subaccount=2, + ) + ) + body = json.loads(route.calls[0].request.content) + assert body["subaccount"] == 2 + assert body["side"] == "ask" + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + respx.post(f"{BASE}/margin/orders").mock( + return_value=httpx.Response( + 201, + json={"order_id": "ord-9", "fill_count": "0.00", "remaining_count": "100.00"}, + ) + ) + resp = await async_perps_client.orders.create( + ticker="BTC-PERP", + client_order_id="cid-9", + side="bid", + count="100", + price="0.56", + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + ) + assert resp.remaining_count == Decimal("100.00") + await async_perps_client.close() + + +# ── get ────────────────────────────────────────────────────────────────────── + + +class TestGet: + @respx.mock + def test_happy_unwraps_order(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/orders/ord-1").mock( + return_value=httpx.Response(200, json={"order": _order_dict()}) + ) + order = perps_client.orders.get("ord-1") + assert isinstance(order, MarginOrder) + assert order.order_id == "ord-1" + assert order.price == Decimal("0.5600") + assert isinstance(order.price, Decimal) + # empty-string last_update_reason is a real enum value + assert order.last_update_reason == "" + + @respx.mock + def test_not_found(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/orders/missing").mock( + return_value=httpx.Response(404, json={"error": {"code": "not_found"}}) + ) + with pytest.raises(KalshiNotFoundError): + perps_client.orders.get("missing") + + @respx.mock + def test_unauthenticated_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/orders/ord-1").mock( + return_value=httpx.Response(200, json={"order": _order_dict()}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + client.orders.get("ord-1") + assert not route.called + client.close() + + @respx.mock + async def test_async(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/orders/ord-1").mock( + return_value=httpx.Response(200, json={"order": _order_dict(side="ask")}) + ) + order = await async_perps_client.orders.get("ord-1") + assert order.side == "ask" + await async_perps_client.close() + + +# ── list / list_all ────────────────────────────────────────────────────────── + + +class TestList: + @respx.mock + def test_happy_envelope(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/orders").mock( + return_value=httpx.Response( + 200, json={"orders": [_order_dict(), _order_dict(order_id="ord-2")], "cursor": "c2"} + ) + ) + resp = perps_client.orders.list(ticker="BTC-PERP", status="resting", limit=50) + assert isinstance(resp, GetMarginOrdersResponse) + assert len(resp.orders) == 2 + assert resp.cursor == "c2" + q = dict(route.calls[0].request.url.params) + assert q["ticker"] == "BTC-PERP" + assert q["status"] == "resting" + assert q["limit"] == "50" + + @respx.mock + def test_subaccount_param_only_when_passed(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/orders").mock( + return_value=httpx.Response(200, json={"orders": [], "cursor": ""}) + ) + perps_client.orders.list() + assert "subaccount" not in dict(route.calls[0].request.url.params) + perps_client.orders.list(subaccount=0) + assert dict(route.calls[1].request.url.params)["subaccount"] == "0" + + def test_limit_out_of_range_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(ValueError): + perps_client.orders.list(limit=0) + with pytest.raises(ValueError): + perps_client.orders.list(limit=1001) + + @respx.mock + def test_list_all_walks_two_pages(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/orders").mock( + side_effect=[ + httpx.Response(200, json={"orders": [_order_dict()], "cursor": "c2"}), + httpx.Response( + 200, json={"orders": [_order_dict(order_id="ord-2")], "cursor": ""} + ), + ] + ) + orders = list(perps_client.orders.list_all(ticker="BTC-PERP")) + assert [o.order_id for o in orders] == ["ord-1", "ord-2"] + + @respx.mock + def test_list_all_max_pages_caps(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/orders").mock( + return_value=httpx.Response(200, json={"orders": [_order_dict()], "cursor": "next"}) + ) + orders = list(perps_client.orders.list_all(max_pages=1)) + assert len(orders) == 1 + assert route.call_count == 1 + + @respx.mock + async def test_async_list_all(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/orders").mock( + side_effect=[ + httpx.Response(200, json={"orders": [_order_dict()], "cursor": "c2"}), + httpx.Response( + 200, json={"orders": [_order_dict(order_id="ord-2")], "cursor": ""} + ), + ] + ) + seen = [o.order_id async for o in async_perps_client.orders.list_all()] + assert seen == ["ord-1", "ord-2"] + await async_perps_client.close() + + +# ── cancel ─────────────────────────────────────────────────────────────────── + + +class TestCancel: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + route = respx.delete(f"{BASE}/margin/orders/ord-1").mock( + return_value=httpx.Response( + 200, json={"order_id": "ord-1", "reduced_by": "100.00", "client_order_id": "cid-1"} + ) + ) + resp = perps_client.orders.cancel("ord-1") + assert resp.order_id == "ord-1" + assert resp.reduced_by == Decimal("100.00") + assert isinstance(resp.reduced_by, Decimal) + assert "subaccount" not in dict(route.calls[0].request.url.params) + + @respx.mock + def test_subaccount_param_emitted(self, perps_client: PerpsClient) -> None: + route = respx.delete(f"{BASE}/margin/orders/ord-1").mock( + return_value=httpx.Response(200, json={"order_id": "ord-1", "reduced_by": "0.00"}) + ) + perps_client.orders.cancel("ord-1", subaccount=3) + assert dict(route.calls[0].request.url.params)["subaccount"] == "3" + + @respx.mock + def test_not_found(self, perps_client: PerpsClient) -> None: + respx.delete(f"{BASE}/margin/orders/missing").mock( + return_value=httpx.Response(404, json={"error": {"code": "not_found"}}) + ) + with pytest.raises(KalshiNotFoundError): + perps_client.orders.cancel("missing") + + @respx.mock + def test_not_retried_on_503(self, perps_client: PerpsClient) -> None: + route = respx.delete(f"{BASE}/margin/orders/ord-1").mock( + return_value=httpx.Response(503, json={"error": {"code": "unavailable"}}) + ) + with pytest.raises(KalshiServerError): + perps_client.orders.cancel("ord-1") + assert route.call_count == 1 # DELETE is never retried + + @respx.mock + async def test_async(self, async_perps_client: AsyncPerpsClient) -> None: + respx.delete(f"{BASE}/margin/orders/ord-1").mock( + return_value=httpx.Response(200, json={"order_id": "ord-1", "reduced_by": "5.00"}) + ) + resp = await async_perps_client.orders.cancel("ord-1") + assert resp.reduced_by == Decimal("5.00") + await async_perps_client.close() + + +# ── decrease ───────────────────────────────────────────────────────────────── + + +class TestDecrease: + @respx.mock + def test_happy_reduce_by(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/margin/orders/ord-1/decrease").mock( + return_value=httpx.Response( + 200, json={"order_id": "ord-1", "remaining_count": "90.00"} + ) + ) + resp = perps_client.orders.decrease("ord-1", reduce_by="10") + assert resp.remaining_count == Decimal("90.00") + body = json.loads(route.calls[0].request.content) + assert body == {"reduce_by": "10"} + + @respx.mock + def test_happy_reduce_to(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/margin/orders/ord-1/decrease").mock( + return_value=httpx.Response( + 200, json={"order_id": "ord-1", "remaining_count": "50.00"} + ) + ) + perps_client.orders.decrease("ord-1", reduce_to="50", subaccount=1) + body = json.loads(route.calls[0].request.content) + assert body == {"reduce_to": "50"} + assert dict(route.calls[0].request.url.params)["subaccount"] == "1" + + def test_both_set_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(ValidationError): + perps_client.orders.decrease("ord-1", reduce_by="10", reduce_to="50") + + def test_neither_set_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(ValidationError): + perps_client.orders.decrease("ord-1") + + @respx.mock + def test_not_retried_on_503(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/margin/orders/ord-1/decrease").mock( + return_value=httpx.Response(503, json={"error": {"code": "unavailable"}}) + ) + with pytest.raises(KalshiServerError): + perps_client.orders.decrease("ord-1", reduce_by="10") + assert route.call_count == 1 + + @respx.mock + async def test_async(self, async_perps_client: AsyncPerpsClient) -> None: + respx.post(f"{BASE}/margin/orders/ord-1/decrease").mock( + return_value=httpx.Response( + 200, json={"order_id": "ord-1", "remaining_count": "0.00"} + ) + ) + resp = await async_perps_client.orders.decrease( + "ord-1", request=DecreaseMarginOrderRequest(reduce_to="0") + ) + assert resp.remaining_count == Decimal("0.00") + await async_perps_client.close() + + +# ── amend ──────────────────────────────────────────────────────────────────── + + +class TestAmend: + @respx.mock + def test_happy_nullable_fields_absent(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/margin/orders/ord-1/amend").mock( + return_value=httpx.Response(200, json={"order_id": "ord-1"}) + ) + resp = perps_client.orders.amend( + "ord-1", ticker="BTC-PERP", side="bid", price="0.57", count="80" + ) + assert resp.order_id == "ord-1" + assert resp.fill_count is None + assert resp.average_fill_price is None + body = json.loads(route.calls[0].request.content) + assert body["price"] == "0.57" + assert body["count"] == "80" + assert body["side"] == "bid" + + @respx.mock + def test_happy_with_fills(self, perps_client: PerpsClient) -> None: + respx.post(f"{BASE}/margin/orders/ord-1/amend").mock( + return_value=httpx.Response( + 200, + json={ + "order_id": "ord-1", + "remaining_count": "30.00", + "fill_count": "50.00", + "average_fill_price": "0.5700", + }, + ) + ) + resp = perps_client.orders.amend( + "ord-1", ticker="BTC-PERP", side="bid", price="0.57", count="80", subaccount=2 + ) + assert resp.fill_count == Decimal("50.00") + assert resp.average_fill_price == Decimal("0.5700") + + @respx.mock + def test_subaccount_query_param(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/margin/orders/ord-1/amend").mock( + return_value=httpx.Response(200, json={"order_id": "ord-1"}) + ) + perps_client.orders.amend( + "ord-1", ticker="BTC-PERP", side="ask", price="0.40", count="10", subaccount=4 + ) + assert dict(route.calls[0].request.url.params)["subaccount"] == "4" + + @respx.mock + def test_bad_request_maps(self, perps_client: PerpsClient) -> None: + respx.post(f"{BASE}/margin/orders/ord-1/amend").mock( + return_value=httpx.Response(400, json={"error": {"code": "bad"}}) + ) + with pytest.raises(KalshiValidationError): + perps_client.orders.amend( + "ord-1", ticker="BTC-PERP", side="bid", price="0.57", count="80" + ) + + def test_missing_required_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(TypeError): + perps_client.orders.amend("ord-1", ticker="BTC-PERP", side="bid") + + @respx.mock + def test_not_retried_on_503(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/margin/orders/ord-1/amend").mock( + return_value=httpx.Response(503, json={"error": {"code": "unavailable"}}) + ) + with pytest.raises(KalshiServerError): + perps_client.orders.amend( + "ord-1", ticker="BTC-PERP", side="bid", price="0.57", count="80" + ) + assert route.call_count == 1 + + @respx.mock + async def test_async(self, async_perps_client: AsyncPerpsClient) -> None: + respx.post(f"{BASE}/margin/orders/ord-1/amend").mock( + return_value=httpx.Response(200, json={"order_id": "ord-1"}) + ) + resp = await async_perps_client.orders.amend( + "ord-1", + request=AmendMarginOrderRequest( + ticker="BTC-PERP", side="bid", price="0.57", count="80" + ), + ) + assert resp.order_id == "ord-1" + await async_perps_client.close() + + +# ── list_fcm / list_all_fcm ────────────────────────────────────────────────── + + +class TestListFcm: + @respx.mock + def test_happy_subtrader_in_query(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/fcm/orders").mock( + return_value=httpx.Response(200, json={"orders": [_order_dict()], "cursor": ""}) + ) + resp = perps_client.orders.list_fcm(subtrader_id="sub-7", ticker="BTC-PERP") + assert isinstance(resp, GetMarginOrdersResponse) + q = dict(route.calls[0].request.url.params) + assert q["subtrader_id"] == "sub-7" + assert q["ticker"] == "BTC-PERP" + # FCM endpoint has no subaccount param + assert "subaccount" not in q + + def test_missing_subtrader_id_is_type_error(self, perps_client: PerpsClient) -> None: + with pytest.raises(TypeError): + perps_client.orders.list_fcm() # type: ignore[call-arg] + + @respx.mock + def test_list_all_fcm_walks_pages(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/fcm/orders").mock( + side_effect=[ + httpx.Response(200, json={"orders": [_order_dict()], "cursor": "c2"}), + httpx.Response( + 200, json={"orders": [_order_dict(order_id="ord-2")], "cursor": ""} + ), + ] + ) + orders = list(perps_client.orders.list_all_fcm(subtrader_id="sub-7")) + assert [o.order_id for o in orders] == ["ord-1", "ord-2"] + for call in route.calls: + assert "subaccount" not in dict(call.request.url.params) + + @respx.mock + def test_unauthenticated_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/fcm/orders").mock( + return_value=httpx.Response(200, json={"orders": [], "cursor": ""}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + client.orders.list_fcm(subtrader_id="sub-7") + assert not route.called + client.close() + + @respx.mock + async def test_async_list_all_fcm(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/fcm/orders").mock( + return_value=httpx.Response(200, json={"orders": [_order_dict()], "cursor": ""}) + ) + seen = [ + o.order_id + async for o in async_perps_client.orders.list_all_fcm(subtrader_id="sub-7") + ] + assert seen == ["ord-1"] + await async_perps_client.close() diff --git a/tests/perps/test_portfolio.py b/tests/perps/test_portfolio.py new file mode 100644 index 0000000..8d35124 --- /dev/null +++ b/tests/perps/test_portfolio.py @@ -0,0 +1,466 @@ +"""Tests for the perps portfolio resource — positions, fills, trades (#393).""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal + +import httpx +import pytest +import respx + +from kalshi.errors import ( + AuthRequiredError, + KalshiAuthError, + KalshiServerError, + KalshiValidationError, +) +from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig +from kalshi.perps.models.portfolio import MarginFill, MarginPosition, MarginTrade + +BASE = "https://external-api.demo.kalshi.co/trade-api/v2" + + +# ── sample payloads ────────────────────────────────────────────────────────── + + +def _long_position() -> dict[str, object]: + return { + "market_ticker": "BTC-PERP", + "position": "100.00", + "entry_price": "0.5600", + "unrealized_pnl": "12.3400", + "margin_used": "50.0000", + "fees": "1.2500", + "roe": 24.68, + } + + +def _short_position() -> dict[str, object]: + return { + "market_ticker": "ETH-PERP", + "position": "-40.00", + "entry_price": "0.3300", + "unrealized_pnl": "-5.5000", + "margin_used": "20.0000", + "fees": "0.8000", + "roe": -27.5, + } + + +def _fill(order_source: str | None = None) -> dict[str, object]: + payload: dict[str, object] = { + "fill_id": "f-1", + "order_id": "o-1", + "is_taker": True, + "side": "bid", + "count": "10.00", + "created_time": "2026-06-04T12:00:00Z", + "ticker": "BTC-PERP", + "price": "0.5600", + "entry_price": "0.5500", + "fees": "0.1000", + "realized_pnl": "-2.5000", + } + if order_source is not None: + payload["order_source"] = order_source + return payload + + +def _trade() -> dict[str, object]: + return { + "trade_id": "t-1", + "ticker": "BTC-PERP", + "count": "5.00", + "price": "0.5700", + "created_time": "2026-06-04T12:00:01Z", + "taker_side": "ask", + } + + +# ── positions ──────────────────────────────────────────────────────────────── + + +class TestPositions: + @respx.mock + def test_happy_long_and_short(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/positions").mock( + return_value=httpx.Response( + 200, json={"positions": [_long_position(), _short_position()]} + ) + ) + resp = perps_client.portfolio.positions() + assert len(resp.positions) == 2 + long, short = resp.positions + assert isinstance(long, MarginPosition) + assert isinstance(long.entry_price, Decimal) + assert isinstance(long.margin_used, Decimal) + assert isinstance(long.fees, Decimal) + assert long.position == Decimal("100.00") + assert short.position == Decimal("-40.00") + assert short.unrealized_pnl == Decimal("-5.5000") + # roe wire name surfaces as return_on_equity. + assert long.return_on_equity == 24.68 + assert short.return_on_equity == -27.5 + assert route.called + + @respx.mock + def test_roe_null(self, perps_client: PerpsClient) -> None: + payload = _long_position() + payload["roe"] = None + respx.get(f"{BASE}/margin/positions").mock( + return_value=httpx.Response(200, json={"positions": [payload]}) + ) + resp = perps_client.portfolio.positions() + assert resp.positions[0].return_on_equity is None + + @respx.mock + def test_empty_positions(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/positions").mock( + return_value=httpx.Response(200, json={"positions": []}) + ) + assert perps_client.portfolio.positions().positions == [] + + @respx.mock + def test_null_positions_coerced_to_empty(self, perps_client: PerpsClient) -> None: + # NullableList tolerates server null and coerces to []. + respx.get(f"{BASE}/margin/positions").mock( + return_value=httpx.Response(200, json={"positions": None}) + ) + assert perps_client.portfolio.positions().positions == [] + + @respx.mock + def test_query_params_dropped_when_none(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/positions").mock( + return_value=httpx.Response(200, json={"positions": []}) + ) + perps_client.portfolio.positions(subaccount=3, ticker="BTC-PERP") + request = route.calls.last.request + assert request.url.params["subaccount"] == "3" + assert request.url.params["ticker"] == "BTC-PERP" + + @respx.mock + def test_query_params_omitted(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/positions").mock( + return_value=httpx.Response(200, json={"positions": []}) + ) + perps_client.portfolio.positions() + request = route.calls.last.request + assert "subaccount" not in request.url.params + assert "ticker" not in request.url.params + + @respx.mock + def test_unauthenticated_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/positions").mock( + return_value=httpx.Response(200, json={"positions": []}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + client.portfolio.positions() + assert not route.called + client.close() + + @respx.mock + def test_server_401_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/positions").mock( + return_value=httpx.Response(401, json={"error": {"code": "unauthorized"}}) + ) + with pytest.raises(KalshiAuthError): + perps_client.portfolio.positions() + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/positions").mock( + return_value=httpx.Response(200, json={"positions": [_long_position()]}) + ) + resp = await async_perps_client.portfolio.positions() + assert resp.positions[0].position == Decimal("100.00") + await async_perps_client.close() + + @respx.mock + async def test_async_unauth_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/positions").mock( + return_value=httpx.Response(200, json={"positions": []}) + ) + client = AsyncPerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + await client.portfolio.positions() + assert not route.called + await client.close() + + +# ── fills / fills_all ──────────────────────────────────────────────────────── + + +class TestFills: + @respx.mock + def test_happy_single_page(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/fills").mock( + return_value=httpx.Response(200, json={"fills": [_fill()], "cursor": ""}) + ) + page = perps_client.portfolio.fills() + assert len(page.items) == 1 + fill = page.items[0] + assert isinstance(fill, MarginFill) + assert fill.side == "bid" + assert isinstance(fill.created_time, datetime) + assert fill.created_time.tzinfo is not None + assert fill.realized_pnl == Decimal("-2.5000") + assert page.has_next is False + assert route.called + + @respx.mock + def test_order_source_present_and_absent(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/fills").mock( + return_value=httpx.Response( + 200, + json={"fills": [_fill("system"), _fill()], "cursor": ""}, + ) + ) + fills = perps_client.portfolio.fills().items + assert fills[0].order_source == "system" + assert fills[1].order_source is None + + @respx.mock + def test_query_params(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/fills").mock( + return_value=httpx.Response(200, json={"fills": [], "cursor": ""}) + ) + perps_client.portfolio.fills( + subaccount=2, min_ts=1000, max_ts=2000, limit=50, cursor="c0" + ) + params = route.calls.last.request.url.params + assert params["subaccount"] == "2" + assert params["min_ts"] == "1000" + assert params["max_ts"] == "2000" + assert params["limit"] == "50" + assert params["cursor"] == "c0" + + @respx.mock + def test_params_dropped_when_none(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/fills").mock( + return_value=httpx.Response(200, json={"fills": [], "cursor": ""}) + ) + perps_client.portfolio.fills() + params = route.calls.last.request.url.params + for key in ("subaccount", "min_ts", "max_ts", "limit", "cursor"): + assert key not in params + + def test_limit_out_of_range_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(ValueError): + perps_client.portfolio.fills(limit=1001) + + @respx.mock + def test_fills_all_paginates(self, perps_client: PerpsClient) -> None: + responses = [ + httpx.Response(200, json={"fills": [_fill(), _fill()], "cursor": "abc"}), + httpx.Response(200, json={"fills": [_fill()], "cursor": ""}), + ] + respx.get(f"{BASE}/margin/fills").mock(side_effect=responses) + items = list(perps_client.portfolio.fills_all()) + assert len(items) == 3 + assert all(isinstance(f, MarginFill) for f in items) + + @respx.mock + def test_fills_all_max_pages_cap(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/fills").mock( + return_value=httpx.Response(200, json={"fills": [_fill()], "cursor": "more"}) + ) + items = list(perps_client.portfolio.fills_all(max_pages=1)) + assert len(items) == 1 + assert route.call_count == 1 + + def test_fills_all_invalid_max_pages_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(ValueError): + list(perps_client.portfolio.fills_all(max_pages=0)) + + @respx.mock + def test_server_500_propagates(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/fills").mock(return_value=httpx.Response(500)) + with pytest.raises(KalshiServerError): + perps_client.portfolio.fills() + + @respx.mock + def test_unauthenticated_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/fills").mock( + return_value=httpx.Response(200, json={"fills": [], "cursor": ""}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + client.portfolio.fills() + assert not route.called + client.close() + + @respx.mock + def test_fills_all_unauthenticated_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/fills").mock( + return_value=httpx.Response(200, json={"fills": [], "cursor": ""}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + list(client.portfolio.fills_all()) + assert not route.called + client.close() + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/fills").mock( + return_value=httpx.Response(200, json={"fills": [_fill()], "cursor": ""}) + ) + page = await async_perps_client.portfolio.fills() + assert page.items[0].side == "bid" + await async_perps_client.close() + + @respx.mock + async def test_async_fills_all_paginates( + self, async_perps_client: AsyncPerpsClient + ) -> None: + responses = [ + httpx.Response(200, json={"fills": [_fill(), _fill()], "cursor": "abc"}), + httpx.Response(200, json={"fills": [_fill()], "cursor": ""}), + ] + respx.get(f"{BASE}/margin/fills").mock(side_effect=responses) + items = [f async for f in async_perps_client.portfolio.fills_all()] + assert len(items) == 3 + await async_perps_client.close() + + @respx.mock + async def test_async_fills_all_unauth_raises_before_http(self) -> None: + route = respx.get(f"{BASE}/margin/fills").mock( + return_value=httpx.Response(200, json={"fills": [], "cursor": ""}) + ) + client = AsyncPerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + # fills_all is a plain def returning an AsyncIterator — the auth + # check fires before any HTTP, at call time (not on first iteration). + client.portfolio.fills_all() + assert not route.called + await client.close() + + +# ── trades / trades_all ────────────────────────────────────────────────────── + + +class TestTrades: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/trades").mock( + return_value=httpx.Response(200, json={"trades": [_trade()], "cursor": ""}) + ) + page = perps_client.portfolio.trades(ticker="BTC-PERP") + assert len(page.items) == 1 + trade = page.items[0] + assert isinstance(trade, MarginTrade) + assert trade.taker_side == "ask" + assert isinstance(trade.price, Decimal) + assert trade.count == Decimal("5.00") + assert isinstance(trade.created_time, datetime) + assert page.has_next is False + # ticker always present on /margin/trades. + assert route.calls.last.request.url.params["ticker"] == "BTC-PERP" + + @respx.mock + def test_public_unauthenticated_client_works(self) -> None: + # /margin/trades is public — no _require_auth(), so an unauth client works. + route = respx.get(f"{BASE}/margin/trades").mock( + return_value=httpx.Response(200, json={"trades": [_trade()], "cursor": ""}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + page = client.portfolio.trades(ticker="BTC-PERP") + assert len(page.items) == 1 + assert route.called + client.close() + + def test_ticker_required(self, perps_client: PerpsClient) -> None: + with pytest.raises(TypeError): + perps_client.portfolio.trades() # type: ignore[call-arg] + + def test_trades_all_ticker_required(self, perps_client: PerpsClient) -> None: + with pytest.raises(TypeError): + list(perps_client.portfolio.trades_all()) # type: ignore[call-arg] + + @respx.mock + def test_query_params(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/trades").mock( + return_value=httpx.Response(200, json={"trades": [], "cursor": ""}) + ) + perps_client.portfolio.trades( + ticker="ETH-PERP", min_ts=10, max_ts=20, limit=25, cursor="x" + ) + params = route.calls.last.request.url.params + assert params["ticker"] == "ETH-PERP" + assert params["min_ts"] == "10" + assert params["max_ts"] == "20" + assert params["limit"] == "25" + assert params["cursor"] == "x" + + @respx.mock + def test_optional_params_dropped(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/trades").mock( + return_value=httpx.Response(200, json={"trades": [], "cursor": ""}) + ) + perps_client.portfolio.trades(ticker="BTC-PERP") + params = route.calls.last.request.url.params + assert params["ticker"] == "BTC-PERP" + for key in ("min_ts", "max_ts", "limit", "cursor"): + assert key not in params + + def test_limit_out_of_range_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(ValueError): + perps_client.portfolio.trades(ticker="BTC-PERP", limit=0) + + @respx.mock + def test_trades_all_paginates(self, perps_client: PerpsClient) -> None: + responses = [ + httpx.Response(200, json={"trades": [_trade(), _trade()], "cursor": "p2"}), + httpx.Response(200, json={"trades": [_trade()], "cursor": ""}), + ] + respx.get(f"{BASE}/margin/trades").mock(side_effect=responses) + items = list(perps_client.portfolio.trades_all(ticker="BTC-PERP")) + assert len(items) == 3 + + @respx.mock + def test_trades_all_max_pages_cap(self, perps_client: PerpsClient) -> None: + # Distinct cursors per page so the cursor-loop guard doesn't trip; the + # cap stops iteration before the (never-empty) third page is fetched. + responses = [ + httpx.Response(200, json={"trades": [_trade()], "cursor": "p2"}), + httpx.Response(200, json={"trades": [_trade()], "cursor": "p3"}), + httpx.Response(200, json={"trades": [_trade()], "cursor": "p4"}), + ] + route = respx.get(f"{BASE}/margin/trades").mock(side_effect=responses) + items = list(perps_client.portfolio.trades_all(ticker="BTC-PERP", max_pages=2)) + assert len(items) == 2 + assert route.call_count == 2 + + @respx.mock + def test_bad_request_maps(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/trades").mock( + return_value=httpx.Response(400, json={"error": {"code": "bad_ticker"}}) + ) + with pytest.raises(KalshiValidationError): + perps_client.portfolio.trades(ticker="NOPE") + + @respx.mock + async def test_async_public_unauthenticated_works(self) -> None: + route = respx.get(f"{BASE}/margin/trades").mock( + return_value=httpx.Response(200, json={"trades": [_trade()], "cursor": ""}) + ) + client = AsyncPerpsClient(config=PerpsConfig.demo()) + page = await client.portfolio.trades(ticker="BTC-PERP") + assert page.items[0].taker_side == "ask" + assert route.called + await client.close() + + @respx.mock + async def test_async_trades_all_paginates( + self, async_perps_client: AsyncPerpsClient + ) -> None: + responses = [ + httpx.Response(200, json={"trades": [_trade()], "cursor": "p2"}), + httpx.Response(200, json={"trades": [_trade()], "cursor": ""}), + ] + respx.get(f"{BASE}/margin/trades").mock(side_effect=responses) + items = [t async for t in async_perps_client.portfolio.trades_all(ticker="BTC-PERP")] + assert len(items) == 2 + await async_perps_client.close() diff --git a/tests/perps/test_review_fixes.py b/tests/perps/test_review_fixes.py new file mode 100644 index 0000000..46d09ea --- /dev/null +++ b/tests/perps/test_review_fixes.py @@ -0,0 +1,144 @@ +"""Regression tests for the multi-LLM review fixes on the perps branch. + +Covers the confirmed-real findings fixed in one pass: + +- ``GetMarginOrdersResponse.cursor`` optional (final-page pagination crash). +- ``count`` / ``reduce_by`` positive constraints (``reduce_to`` allows 0). +- ``transfer_instance`` shard-kwarg exclusivity (silently-ignored routing arg). +- Partial-credential fail-fast on ``PerpsClient`` / ``AsyncPerpsClient``. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig +from kalshi.perps.models.orders import ( + AmendMarginOrderRequest, + CreateMarginOrderRequest, + DecreaseMarginOrderRequest, + GetMarginOrdersResponse, +) +from kalshi.perps.models.transfers import IntraExchangeInstanceTransferRequest +from kalshi.perps.resources.transfers import _build_instance_transfer_body + +# ── GetMarginOrdersResponse.cursor optional ────────────────────────────────── + + +class TestCursorOptional: + def test_parses_when_server_omits_cursor_on_final_page(self) -> None: + # Kalshi drops the cursor key on the last page; a bare ``str`` would crash. + resp = GetMarginOrdersResponse.model_validate({"orders": []}) + assert resp.cursor is None + + def test_parses_present_cursor(self) -> None: + resp = GetMarginOrdersResponse.model_validate({"orders": [], "cursor": "abc"}) + assert resp.cursor == "abc" + + +# ── positive-size constraints ──────────────────────────────────────────────── + + +class TestOrderSizeConstraints: + def test_create_rejects_zero_count(self) -> None: + with pytest.raises(ValidationError): + CreateMarginOrderRequest( + ticker="BTC-PERP", + client_order_id="c1", + side="bid", + count=0, + price="0.50", + time_in_force="good_till_canceled", + self_trade_prevention_type="maker", + ) + + def test_amend_rejects_zero_count(self) -> None: + with pytest.raises(ValidationError): + AmendMarginOrderRequest(ticker="BTC-PERP", side="bid", price="0.50", count=0) + + def test_decrease_rejects_zero_reduce_by(self) -> None: + with pytest.raises(ValidationError): + DecreaseMarginOrderRequest(reduce_by=0) + + def test_decrease_rejects_negative_reduce_by(self) -> None: + with pytest.raises(ValidationError): + DecreaseMarginOrderRequest(reduce_by=-1) + + def test_decrease_rejects_negative_reduce_to(self) -> None: + with pytest.raises(ValidationError): + DecreaseMarginOrderRequest(reduce_to=-1) + + def test_decrease_allows_zero_reduce_to(self) -> None: + # reduce_to is a target remaining count — 0 (decrease to nothing) is valid. + req = DecreaseMarginOrderRequest(reduce_to=0) + assert req.reduce_to == 0 + + +# ── transfer_instance shard-kwarg exclusivity ──────────────────────────────── + + +class TestTransferShardExclusivity: + def _req(self) -> IntraExchangeInstanceTransferRequest: + return IntraExchangeInstanceTransferRequest( + source="event_contract", destination="margined", amount=100 + ) + + def test_request_plus_source_shard_raises(self) -> None: + with pytest.raises(TypeError): + _build_instance_transfer_body( + self._req(), + source=None, + destination=None, + amount=None, + source_exchange_shard=5, + destination_exchange_shard=None, + ) + + def test_request_plus_destination_shard_raises(self) -> None: + with pytest.raises(TypeError): + _build_instance_transfer_body( + self._req(), + source=None, + destination=None, + amount=None, + source_exchange_shard=None, + destination_exchange_shard=2, + ) + + def test_kwargs_path_defaults_shards_to_zero(self) -> None: + body = _build_instance_transfer_body( + None, + source="event_contract", + destination="margined", + amount=100, + source_exchange_shard=None, + destination_exchange_shard=None, + ) + assert body["source_exchange_shard"] == 0 + assert body["destination_exchange_shard"] == 0 + + +# ── partial-credential fail-fast ───────────────────────────────────────────── + + +class TestPartialAuthFailFast: + def test_sync_key_id_without_key_material_raises(self) -> None: + with pytest.raises(ValueError, match="Incomplete credentials"): + PerpsClient(key_id="k", config=PerpsConfig.demo()) + + def test_sync_private_key_without_key_id_raises(self) -> None: + with pytest.raises(ValueError, match="Incomplete credentials"): + PerpsClient(private_key="pem", config=PerpsConfig.demo()) + + def test_sync_private_key_path_without_key_id_raises(self) -> None: + with pytest.raises(ValueError, match="Incomplete credentials"): + PerpsClient(private_key_path="/tmp/x.pem", config=PerpsConfig.demo()) + + def test_async_key_id_without_key_material_raises(self) -> None: + with pytest.raises(ValueError, match="Incomplete credentials"): + AsyncPerpsClient(key_id="k", config=PerpsConfig.demo()) + + def test_async_private_key_without_key_id_raises(self) -> None: + with pytest.raises(ValueError, match="Incomplete credentials"): + AsyncPerpsClient(private_key="pem", config=PerpsConfig.demo()) diff --git a/tests/perps/test_transfers.py b/tests/perps/test_transfers.py new file mode 100644 index 0000000..45dbce4 --- /dev/null +++ b/tests/perps/test_transfers.py @@ -0,0 +1,421 @@ +"""Tests for the perps transfers & subaccounts resource (#396).""" + +from __future__ import annotations + +import json +from uuid import UUID + +import httpx +import pytest +import respx +from pydantic import ValidationError + +from kalshi.auth import KalshiAuth +from kalshi.errors import AuthRequiredError, KalshiAuthError, KalshiValidationError +from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig +from kalshi.perps.models.transfers import ( + ApplySubaccountTransferRequest, + CreateSubaccountResponse, + IntraExchangeInstanceTransferRequest, + IntraExchangeInstanceTransferResponse, +) + +BASE = "https://external-api.demo.kalshi.co/trade-api/v2" +_XFER_ID = "550e8400-e29b-41d4-a716-446655440000" + + +# ── models ──────────────────────────────────────────────────────────────── + + +class TestModels: + def test_instance_request_serializes(self) -> None: + req = IntraExchangeInstanceTransferRequest( + source="event_contract", destination="margined", amount=100 + ) + body = req.model_dump(exclude_none=True, by_alias=True, mode="json") + assert body == { + "source": "event_contract", + "destination": "margined", + "amount": 100, + "source_exchange_shard": 0, + "destination_exchange_shard": 0, + } + + def test_instance_request_forbids_extra(self) -> None: + with pytest.raises(ValidationError): + IntraExchangeInstanceTransferRequest( # type: ignore[call-arg] + source="event_contract", destination="margined", amount=1, phantom=True + ) + + def test_instance_request_rejects_nonpositive_amount(self) -> None: + with pytest.raises(ValidationError): + IntraExchangeInstanceTransferRequest( + source="event_contract", destination="margined", amount=0 + ) + + def test_instance_request_rejects_negative_shard(self) -> None: + with pytest.raises(ValidationError): + IntraExchangeInstanceTransferRequest( + source="event_contract", + destination="margined", + amount=1, + source_exchange_shard=-1, + ) + + def test_instance_request_rejects_bad_instance(self) -> None: + with pytest.raises(ValidationError): + IntraExchangeInstanceTransferRequest( + source="nope", # type: ignore[arg-type] + destination="margined", + amount=1, + ) + + def test_subaccount_request_serializes(self) -> None: + req = ApplySubaccountTransferRequest( + client_transfer_id=_XFER_ID, from_subaccount=0, to_subaccount=3, amount_cents=500 + ) + body = req.model_dump(exclude_none=True, by_alias=True, mode="json") + assert body == { + "client_transfer_id": _XFER_ID, + "from_subaccount": 0, + "to_subaccount": 3, + "amount_cents": 500, + } + + def test_subaccount_request_forbids_extra(self) -> None: + with pytest.raises(ValidationError): + ApplySubaccountTransferRequest( # type: ignore[call-arg] + client_transfer_id=_XFER_ID, + from_subaccount=0, + to_subaccount=1, + amount_cents=1, + phantom=True, + ) + + def test_subaccount_request_rejects_negative_subaccount(self) -> None: + with pytest.raises(ValidationError): + ApplySubaccountTransferRequest( + client_transfer_id=_XFER_ID, from_subaccount=-1, to_subaccount=1, amount_cents=1 + ) + + def test_subaccount_request_rejects_zero_amount(self) -> None: + with pytest.raises(ValidationError): + ApplySubaccountTransferRequest( + client_transfer_id=_XFER_ID, from_subaccount=0, to_subaccount=1, amount_cents=0 + ) + + def test_subaccount_request_accepts_above_32(self) -> None: + req = ApplySubaccountTransferRequest( + client_transfer_id=_XFER_ID, from_subaccount=0, to_subaccount=41, amount_cents=1 + ) + assert req.to_subaccount == 41 + + def test_instance_response_parses(self) -> None: + resp = IntraExchangeInstanceTransferResponse.model_validate( + {"transfer_id": "abc", "extra_field": 1} + ) + assert resp.transfer_id == "abc" + + def test_create_subaccount_response_parses(self) -> None: + resp = CreateSubaccountResponse.model_validate({"subaccount_number": 5}) + assert resp.subaccount_number == 5 + + +# ── transfer_instance ─────────────────────────────────────────────────────── + + +class TestTransferInstance: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/portfolio/intra_exchange_instance_transfer").mock( + return_value=httpx.Response(200, json={"transfer_id": "abc"}) + ) + resp = perps_client.transfers.transfer_instance( + source="event_contract", destination="margined", amount=100 + ) + assert isinstance(resp, IntraExchangeInstanceTransferResponse) + assert resp.transfer_id == "abc" + body = json.loads(route.calls[0].request.content) + assert body == { + "source": "event_contract", + "destination": "margined", + "amount": 100, + "source_exchange_shard": 0, + "destination_exchange_shard": 0, + } + + @respx.mock + def test_request_object_overload_identical_body(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/portfolio/intra_exchange_instance_transfer").mock( + return_value=httpx.Response(200, json={"transfer_id": "abc"}) + ) + req = IntraExchangeInstanceTransferRequest( + source="event_contract", destination="margined", amount=100 + ) + perps_client.transfers.transfer_instance(request=req) + body = json.loads(route.calls[0].request.content) + assert body == { + "source": "event_contract", + "destination": "margined", + "amount": 100, + "source_exchange_shard": 0, + "destination_exchange_shard": 0, + } + + @respx.mock + def test_403_not_available_maps(self, perps_client: PerpsClient) -> None: + # Spec: endpoint "currently not available" — 403 maps to KalshiAuthError. + respx.post(f"{BASE}/portfolio/intra_exchange_instance_transfer").mock( + return_value=httpx.Response(403, json={"error": {"code": "forbidden"}}) + ) + with pytest.raises(KalshiAuthError): + perps_client.transfers.transfer_instance( + source="event_contract", destination="margined", amount=1 + ) + + @respx.mock + def test_400_maps(self, perps_client: PerpsClient) -> None: + respx.post(f"{BASE}/portfolio/intra_exchange_instance_transfer").mock( + return_value=httpx.Response(400, json={"message": "bad"}) + ) + with pytest.raises(KalshiValidationError): + perps_client.transfers.transfer_instance( + source="event_contract", destination="margined", amount=1 + ) + + @respx.mock + def test_not_retried(self, test_auth: KalshiAuth) -> None: + # POST is never retried even with a retryable 503 and max_retries set. + route = respx.post(f"{BASE}/portfolio/intra_exchange_instance_transfer").mock( + return_value=httpx.Response(503, json={"message": "down"}) + ) + client = PerpsClient(config=PerpsConfig.demo(max_retries=5), auth=test_auth) + with pytest.raises(Exception): # noqa: B017 — mapped 503 server error + client.transfers.transfer_instance( + source="event_contract", destination="margined", amount=1 + ) + assert route.call_count == 1 + client.close() + + @respx.mock + def test_edge_request_and_kwargs_mutually_exclusive(self, perps_client: PerpsClient) -> None: + req = IntraExchangeInstanceTransferRequest( + source="event_contract", destination="margined", amount=1 + ) + with pytest.raises(TypeError): + perps_client.transfers.transfer_instance(request=req, amount=2) # type: ignore[call-overload] + + def test_edge_missing_required_kwargs(self, perps_client: PerpsClient) -> None: + with pytest.raises(TypeError): + perps_client.transfers.transfer_instance() # type: ignore[call-overload] + + def test_edge_nonpositive_amount_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(ValidationError): + perps_client.transfers.transfer_instance( + source="event_contract", destination="margined", amount=0 + ) + + @respx.mock + def test_unauthenticated_raises_before_http(self) -> None: + route = respx.post(f"{BASE}/portfolio/intra_exchange_instance_transfer").mock( + return_value=httpx.Response(200, json={"transfer_id": "x"}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + client.transfers.transfer_instance( + source="event_contract", destination="margined", amount=1 + ) + assert not route.called + client.close() + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + route = respx.post(f"{BASE}/portfolio/intra_exchange_instance_transfer").mock( + return_value=httpx.Response(200, json={"transfer_id": "zzz"}) + ) + resp = await async_perps_client.transfers.transfer_instance( + source="margined", destination="event_contract", amount=42 + ) + assert resp.transfer_id == "zzz" + body = json.loads(route.calls[0].request.content) + assert body["amount"] == 42 + await async_perps_client.close() + + @respx.mock + async def test_async_unauth_raises_before_http(self) -> None: + route = respx.post(f"{BASE}/portfolio/intra_exchange_instance_transfer").mock( + return_value=httpx.Response(200, json={"transfer_id": "x"}) + ) + client = AsyncPerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + await client.transfers.transfer_instance( + source="event_contract", destination="margined", amount=1 + ) + assert not route.called + await client.close() + + +# ── create_subaccount ─────────────────────────────────────────────────────── + + +class TestCreateSubaccount: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/portfolio/margin/subaccounts").mock( + return_value=httpx.Response(201, json={"subaccount_number": 5}) + ) + resp = perps_client.transfers.create_subaccount() + assert isinstance(resp, CreateSubaccountResponse) + assert resp.subaccount_number == 5 + assert route.calls[0].request.content == b"{}" + assert route.calls[0].request.headers["content-type"] == "application/json" + + @respx.mock + def test_401_maps(self, perps_client: PerpsClient) -> None: + respx.post(f"{BASE}/portfolio/margin/subaccounts").mock( + return_value=httpx.Response(401, json={"error": {"code": "unauthorized"}}) + ) + with pytest.raises(KalshiAuthError): + perps_client.transfers.create_subaccount() + + @respx.mock + def test_unauthenticated_raises_before_http(self) -> None: + route = respx.post(f"{BASE}/portfolio/margin/subaccounts").mock( + return_value=httpx.Response(201, json={"subaccount_number": 1}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + client.transfers.create_subaccount() + assert not route.called + client.close() + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + respx.post(f"{BASE}/portfolio/margin/subaccounts").mock( + return_value=httpx.Response(201, json={"subaccount_number": 7}) + ) + resp = await async_perps_client.transfers.create_subaccount() + assert resp.subaccount_number == 7 + await async_perps_client.close() + + +# ── transfer_subaccount ────────────────────────────────────────────────────── + + +class TestTransferSubaccount: + @respx.mock + def test_happy_returns_none(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/portfolio/margin/subaccounts/transfer").mock( + return_value=httpx.Response(200, json={}) + ) + result = perps_client.transfers.transfer_subaccount( + client_transfer_id=_XFER_ID, from_subaccount=0, to_subaccount=3, amount_cents=500 + ) + assert result is None + body = json.loads(route.calls[0].request.content) + assert body == { + "client_transfer_id": _XFER_ID, + "from_subaccount": 0, + "to_subaccount": 3, + "amount_cents": 500, + } + + @respx.mock + def test_accepts_uuid_and_str(self, perps_client: PerpsClient) -> None: + route = respx.post(f"{BASE}/portfolio/margin/subaccounts/transfer").mock( + return_value=httpx.Response(200, json={}) + ) + perps_client.transfers.transfer_subaccount( + client_transfer_id=UUID(_XFER_ID), + from_subaccount=1, + to_subaccount=2, + amount_cents=10, + ) + body = json.loads(route.calls[0].request.content) + assert body["client_transfer_id"] == _XFER_ID + + def test_malformed_uuid_raises_before_http(self, perps_client: PerpsClient) -> None: + # Coercion happens before the HTTP call; malformed str → ValueError. + with pytest.raises(ValueError): + perps_client.transfers.transfer_subaccount( + client_transfer_id="not-a-uuid", + from_subaccount=0, + to_subaccount=1, + amount_cents=1, + ) + + @respx.mock + def test_400_maps(self, perps_client: PerpsClient) -> None: + respx.post(f"{BASE}/portfolio/margin/subaccounts/transfer").mock( + return_value=httpx.Response(400, json={"message": "insufficient"}) + ) + with pytest.raises(KalshiValidationError): + perps_client.transfers.transfer_subaccount( + client_transfer_id=_XFER_ID, + from_subaccount=0, + to_subaccount=1, + amount_cents=999_999_999, + ) + + def test_edge_zero_amount_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(ValidationError): + perps_client.transfers.transfer_subaccount( + client_transfer_id=_XFER_ID, from_subaccount=0, to_subaccount=1, amount_cents=0 + ) + + def test_edge_negative_subaccount_raises(self, perps_client: PerpsClient) -> None: + with pytest.raises(ValidationError): + perps_client.transfers.transfer_subaccount( + client_transfer_id=_XFER_ID, from_subaccount=-1, to_subaccount=1, amount_cents=1 + ) + + def test_edge_forbids_phantom_key(self) -> None: + with pytest.raises(ValidationError): + ApplySubaccountTransferRequest( + **{ # type: ignore[arg-type] + "client_transfer_id": _XFER_ID, + "from_subaccount": 0, + "to_subaccount": 1, + "amount_cents": 1, + "phantom": True, + } + ) + + @respx.mock + def test_unauthenticated_raises_before_http(self) -> None: + route = respx.post(f"{BASE}/portfolio/margin/subaccounts/transfer").mock( + return_value=httpx.Response(200, json={}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + with pytest.raises(AuthRequiredError): + client.transfers.transfer_subaccount( + client_transfer_id=_XFER_ID, from_subaccount=0, to_subaccount=1, amount_cents=1 + ) + assert not route.called + client.close() + + @respx.mock + async def test_async_happy(self, async_perps_client: AsyncPerpsClient) -> None: + route = respx.post(f"{BASE}/portfolio/margin/subaccounts/transfer").mock( + return_value=httpx.Response(200, json={}) + ) + result = await async_perps_client.transfers.transfer_subaccount( + client_transfer_id=_XFER_ID, from_subaccount=0, to_subaccount=1, amount_cents=42 + ) + assert result is None + body = json.loads(route.calls[0].request.content) + assert body["amount_cents"] == 42 + await async_perps_client.close() + + @respx.mock + async def test_async_request_object(self, async_perps_client: AsyncPerpsClient) -> None: + route = respx.post(f"{BASE}/portfolio/margin/subaccounts/transfer").mock( + return_value=httpx.Response(200, json={}) + ) + req = ApplySubaccountTransferRequest( + client_transfer_id=_XFER_ID, from_subaccount=2, to_subaccount=0, amount_cents=7 + ) + await async_perps_client.transfers.transfer_subaccount(request=req) + body = json.loads(route.calls[0].request.content) + assert body["from_subaccount"] == 2 + await async_perps_client.close() diff --git a/tests/perps/ws/__init__.py b/tests/perps/ws/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/perps/ws/conftest.py b/tests/perps/ws/conftest.py new file mode 100644 index 0000000..08ebd7e --- /dev/null +++ b/tests/perps/ws/conftest.py @@ -0,0 +1,166 @@ +"""Fake perps (margin) WebSocket server + fixtures for transport tests. + +Mirrors ``tests/ws/conftest.py`` but speaks the **perps command grammar**: +``subscribe`` (``params.channels``), ``unsubscribe`` (``params.sids``), +``update_subscription`` (object ``ok`` ack), and ``list_subscriptions`` +(array ``ok`` ack). Reuses the RSA-key fixtures from ``tests/conftest.py`` via +``test_auth``; the perps unknown-host escape hatch is enabled process-wide by +``tests/perps/conftest.py`` so a loopback ``ws://127.0.0.1:PORT/...`` URL passes +``PerpsConfig`` validation. +""" + +from __future__ import annotations + +import builtins +import json +from collections.abc import AsyncGenerator +from typing import Any + +import pytest +import websockets.datastructures +from websockets.asyncio.server import ServerConnection, serve +from websockets.http11 import Request, Response + +from kalshi.auth import KalshiAuth +from kalshi.perps.config import PerpsConfig + +_WS_PATH = "/trade-api/ws/v2/margin" + + +class FakePerpsWS: + """A fake Kalshi perps (margin) WebSocket server for testing. + + Configurable: + reject_auth: reject connections during HTTP handshake (401). + force_error: reply to ``subscribe`` with a ``type:"error"`` frame. + """ + + def __init__(self) -> None: + self.connections: builtins.list[ServerConnection] = [] + self.subscriptions: dict[int, dict[str, Any]] = {} + self._next_sid = 1 + self._server: Any = None + self.port: int = 0 + self.received_commands: builtins.list[dict[str, Any]] = [] + self.reject_auth: bool = False + self.force_error: bool = False + self.error_code: int = 6 + + def _process_request( + self, connection: ServerConnection, request: Request + ) -> Response | None: + if self.reject_auth: + return Response( + 401, "Unauthorized", websockets.datastructures.Headers() + ) + return None + + async def handler(self, ws: ServerConnection) -> None: + self.connections.append(ws) + try: + async for raw in ws: + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + msg: dict[str, Any] = json.loads(raw) + self.received_commands.append(msg) + await self._handle_command(ws, msg) + except websockets.ConnectionClosed: + pass + finally: + if ws in self.connections: + self.connections.remove(ws) + + async def _handle_command( + self, ws: ServerConnection, msg: dict[str, Any] + ) -> None: + cmd = msg.get("cmd") + msg_id = msg.get("id", 0) + if self.force_error: + # Error-frame ack for ANY command (subscribe/unsubscribe/update/ + # list) so tests can exercise the centralized error handling. + await ws.send(json.dumps({ + "id": msg_id, "type": "error", + "msg": {"code": self.error_code, "msg": "Forced error"}, + })) + return + if cmd == "subscribe": + channels: builtins.list[str] = msg.get("params", {}).get("channels", []) + for channel in channels: + sid = self._next_sid + self._next_sid += 1 + self.subscriptions[sid] = { + "channel": channel, "params": msg.get("params", {}), + } + await ws.send(json.dumps({ + "id": msg_id, "type": "subscribed", + "msg": {"channel": channel, "sid": sid}, + })) + elif cmd == "unsubscribe": + for sid in msg.get("params", {}).get("sids", []): + self.subscriptions.pop(sid, None) + await ws.send(json.dumps({ + "id": msg_id, "sid": sid, "seq": 1, "type": "unsubscribed", + })) + elif cmd == "update_subscription": + tickers = msg.get("params", {}).get("market_tickers", []) + await ws.send(json.dumps({ + "id": msg_id, "type": "ok", "msg": {"market_tickers": tickers}, + })) + elif cmd == "list_subscriptions": + subs = [ + {"channel": v["channel"], "sid": k} + for k, v in self.subscriptions.items() + ] + await ws.send(json.dumps({"id": msg_id, "type": "ok", "msg": subs})) + + async def send_to_all(self, msg: dict[str, Any]) -> None: + raw = json.dumps(msg) + for ws in self.connections: + await ws.send(raw) + + async def start(self) -> None: + self._server = await serve( + self.handler, "127.0.0.1", 0, process_request=self._process_request + ) + sockets = self._server.sockets + assert sockets is not None + self.port = sockets[0].getsockname()[1] + + async def stop(self) -> None: + if self._server is not None: + self._server.close() + await self._server.wait_closed() + + @property + def ws_url(self) -> str: + """Full perps margin WS URL for connecting to this fake server.""" + return f"ws://127.0.0.1:{self.port}{_WS_PATH}" + + +@pytest.fixture +async def fake_perps_ws() -> AsyncGenerator[FakePerpsWS]: + """Provide a running FakePerpsWS server, stopped after the test.""" + server = FakePerpsWS() + await server.start() + yield server + await server.stop() + + +@pytest.fixture +def perps_ws_config(fake_perps_ws: FakePerpsWS) -> PerpsConfig: + """A perps config whose ws_base_url points at the fake server (loopback). + + REST base_url stays on the demo host (a known perps host) so PerpsConfig's + split-environment guard is satisfied; only the WS URL is the loopback fake. + """ + return PerpsConfig( + base_url="https://external-api.demo.kalshi.co/trade-api/v2", + ws_base_url=fake_perps_ws.ws_url, + ws_max_retries=2, + ) + + +@pytest.fixture +def perps_auth(test_auth: KalshiAuth) -> KalshiAuth: + """Alias the shared RSA test auth for perps WS tests.""" + return test_auth diff --git a/tests/perps/ws/test_channels.py b/tests/perps/ws/test_channels.py new file mode 100644 index 0000000..ebc174e --- /dev/null +++ b/tests/perps/ws/test_channels.py @@ -0,0 +1,232 @@ +"""Tests for PerpsSubscriptionManager — the perps command grammar.""" + +from __future__ import annotations + +import pytest + +from kalshi.errors import KalshiSubscriptionError +from kalshi.perps.config import PerpsConfig +from kalshi.perps.ws.channels import PerpsSubscriptionManager +from kalshi.perps.ws.connection import PerpsConnectionManager +from kalshi.perps.ws.models.control import SubscriptionEntry + +from .conftest import FakePerpsWS + +pytestmark = pytest.mark.asyncio + + +async def _connected_mgr( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> tuple[PerpsConnectionManager, PerpsSubscriptionManager]: + conn = PerpsConnectionManager(auth=perps_auth, config=perps_ws_config) + await conn.connect() + return conn, PerpsSubscriptionManager(conn) + + +async def test_subscribe_happy_sends_channels_and_installs_sid( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) + sub = await mgr.subscribe("ticker", params={"market_tickers": ["BTC-PERP"]}) + assert sub.server_sid is not None + assert mgr.get_subscription_by_sid(sub.server_sid) is sub + cmd = fake_perps_ws.received_commands[-1] + assert cmd["cmd"] == "subscribe" + assert cmd["params"]["channels"] == ["ticker"] + assert cmd["params"]["market_tickers"] == ["BTC-PERP"] + await conn.close() + + +async def test_subscribe_error_raises_subscription_error( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + fake_perps_ws.force_error = True + fake_perps_ws.error_code = 6 + conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) + with pytest.raises(KalshiSubscriptionError) as ei: + await mgr.subscribe("ticker") + assert ei.value.error_code == 6 + await conn.close() + + +async def test_subscribe_rejects_unknown_param_key( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) + with pytest.raises(KalshiSubscriptionError): + await mgr.subscribe("ticker", params={"bogus_key": 1}) + await conn.close() + + +async def test_unsubscribe_sends_sids_and_tears_down( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) + sub = await mgr.subscribe("ticker") + sid = sub.server_sid + await mgr.unsubscribe(sub.client_id) + cmd = fake_perps_ws.received_commands[-1] + assert cmd["cmd"] == "unsubscribe" + assert cmd["params"]["sids"] == [sid] + assert mgr.get_subscription(sub.client_id) is None + await conn.close() + + +async def test_unsubscribe_unknown_client_id_is_noop( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) + await mgr.unsubscribe(999) # no raise + await conn.close() + + +async def test_update_subscription_array_sids_form( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) + sub = await mgr.subscribe("ticker") + await mgr.update_subscription( + sub.client_id, "add_markets", market_tickers=["ETH-PERP"] + ) + cmd = fake_perps_ws.received_commands[-1] + assert cmd["cmd"] == "update_subscription" + assert cmd["params"]["action"] == "add_markets" + assert cmd["params"]["sids"] == [sub.server_sid] + assert "sid" not in cmd["params"] + assert cmd["params"]["market_tickers"] == ["ETH-PERP"] + await conn.close() + + +async def test_update_subscription_single_sid_form( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) + sub = await mgr.subscribe("ticker") + await mgr.update_subscription_single_sid( + sub.client_id, "delete_markets", market_tickers=["ETH-PERP"] + ) + cmd = fake_perps_ws.received_commands[-1] + assert cmd["cmd"] == "update_subscription" + assert cmd["params"]["action"] == "delete_markets" + assert cmd["params"]["sid"] == sub.server_sid + assert "sids" not in cmd["params"] + await conn.close() + + +async def test_update_subscription_no_active_sub_raises( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) + with pytest.raises(KalshiSubscriptionError): + await mgr.update_subscription(123, "add_markets") + await conn.close() + + +# ── F8: error-frame acks raise on EVERY command (not just subscribe) ───────── + + +async def test_unsubscribe_raises_on_error_ack( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) + sub = await mgr.subscribe("ticker") + fake_perps_ws.force_error = True + fake_perps_ws.error_code = 8 + with pytest.raises(KalshiSubscriptionError) as ei: + await mgr.unsubscribe(sub.client_id) + assert ei.value.error_code == 8 + await conn.close() + + +async def test_update_raises_on_error_ack( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) + sub = await mgr.subscribe("ticker") + fake_perps_ws.force_error = True + with pytest.raises(KalshiSubscriptionError): + await mgr.update_subscription( + sub.client_id, "add_markets", market_tickers=["ETH-PERP"] + ) + await conn.close() + + +# ── F7: update_subscription persists the market change to sub.params ───────── + + +async def test_update_add_markets_persists_to_params( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) + sub = await mgr.subscribe("ticker", params={"market_tickers": ["A"]}) + await mgr.update_subscription(sub.client_id, "add_markets", market_tickers=["B"]) + persisted = mgr.get_subscription(sub.client_id) + assert persisted is not None + assert persisted.params["market_tickers"] == ["A", "B"] + await conn.close() + + +async def test_update_delete_markets_persists_to_params( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) + sub = await mgr.subscribe("ticker", params={"market_tickers": ["A", "B"]}) + await mgr.update_subscription_single_sid( + sub.client_id, "delete_markets", market_tickers=["A"] + ) + persisted = mgr.get_subscription(sub.client_id) + assert persisted is not None + assert persisted.params["market_tickers"] == ["B"] + await conn.close() + + +async def test_resubscribe_replays_updated_market_set( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + # The whole point of F7: a reconnect must replay the UPDATED markets. + conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) + sub = await mgr.subscribe("ticker", params={"market_tickers": ["A"]}) + await mgr.update_subscription(sub.client_id, "add_markets", market_tickers=["B"]) + await mgr.resubscribe_all() + cmd = fake_perps_ws.received_commands[-1] + assert cmd["cmd"] == "subscribe" + assert cmd["params"]["market_tickers"] == ["A", "B"] + await conn.close() + + +async def test_list_subscriptions_parses_array_msg( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) + await mgr.subscribe("ticker") + await mgr.subscribe("trade") + entries = await mgr.list_subscriptions() + assert all(isinstance(e, SubscriptionEntry) for e in entries) + channels = {e.channel for e in entries} + assert channels == {"ticker", "trade"} + cmd = fake_perps_ws.received_commands[-1] + assert cmd["cmd"] == "list_subscriptions" + assert "params" not in cmd + await conn.close() + + +async def test_list_subscriptions_empty( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) + entries = await mgr.list_subscriptions() + assert entries == [] + await conn.close() + + +async def test_resubscribe_all_reassigns_sids( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn, mgr = await _connected_mgr(fake_perps_ws, perps_ws_config, perps_auth) + sub = await mgr.subscribe("ticker") + old_sid = sub.server_sid + await mgr.resubscribe_all() + assert sub.server_sid is not None + assert sub.server_sid != old_sid + assert mgr.get_subscription_by_sid(sub.server_sid) is sub + await conn.close() diff --git a/tests/perps/ws/test_client.py b/tests/perps/ws/test_client.py new file mode 100644 index 0000000..4f99231 --- /dev/null +++ b/tests/perps/ws/test_client.py @@ -0,0 +1,153 @@ +"""Tests for the PerpsWebSocket facade — generic command surface + session lifecycle.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from kalshi.perps.config import PerpsConfig +from kalshi.perps.ws import PerpsWebSocket +from kalshi.perps.ws.models.control import SubscriptionEntry + +from .conftest import FakePerpsWS + +pytestmark = pytest.mark.asyncio + + +async def test_exported_from_package() -> None: + from kalshi.perps.ws import ( + ConnectionState, + MessageQueue, + OverflowStrategy, + PerpsConnectionManager, + PerpsWebSocket, + ) + + assert ConnectionState is not None + assert MessageQueue is not None + assert OverflowStrategy is not None + assert PerpsConnectionManager is not None + assert PerpsWebSocket is not None + + +async def test_session_connect_and_subscribe( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + ws = PerpsWebSocket(auth=perps_auth, config=perps_ws_config) + async with ws.connect() as session: + stream = await session.subscribe( + "ticker", params={"market_tickers": ["BTC-PERP"]} + ) + assert stream is not None + cmd = fake_perps_ws.received_commands[-1] + assert cmd["cmd"] == "subscribe" + assert cmd["params"]["channels"] == ["ticker"] + + +async def test_subscribe_then_receive_dispatched_frame( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + # Register a fake ticker model so the dispatcher routes the frame to the + # queue (foundation issue ships MESSAGE_MODELS empty; #398 fills it). + from pydantic import BaseModel + + from kalshi.perps.ws import dispatch as dmod + + class _Ticker(BaseModel): + type: str + sid: int + model_config = {"extra": "allow"} + + # Save/restore the real registration (#398 populates "ticker" -> the real + # MarginTickerMessage); popping it would pollute later MESSAGE_MODELS tests. + _orig_ticker = dmod.MESSAGE_MODELS.get("ticker") + dmod.MESSAGE_MODELS["ticker"] = _Ticker + try: + ws = PerpsWebSocket(auth=perps_auth, config=perps_ws_config) + async with ws.connect() as session: + stream = await session.subscribe("ticker") + # Find the assigned sid. + sid = next(iter(fake_perps_ws.subscriptions)) + await fake_perps_ws.send_to_all( + {"type": "ticker", "sid": sid, "msg": {"price": "1.00"}} + ) + frame = await asyncio.wait_for(stream.__anext__(), timeout=2.0) + assert frame.sid == sid + finally: + if _orig_ticker is not None: + dmod.MESSAGE_MODELS["ticker"] = _orig_ticker + else: + dmod.MESSAGE_MODELS.pop("ticker", None) + + +async def test_update_subscription_via_facade( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + ws = PerpsWebSocket(auth=perps_auth, config=perps_ws_config) + async with ws.connect() as session: + await session.subscribe("ticker") + client_id = next(iter(session._sub_mgr.active_subscriptions)) # type: ignore[union-attr] + await session.update_subscription( + client_id, "add_markets", market_tickers=["ETH-PERP"] + ) + cmd = fake_perps_ws.received_commands[-1] + assert cmd["cmd"] == "update_subscription" + assert cmd["params"]["action"] == "add_markets" + assert "sids" in cmd["params"] + + +async def test_update_subscription_single_sid_via_facade( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + ws = PerpsWebSocket(auth=perps_auth, config=perps_ws_config) + async with ws.connect() as session: + await session.subscribe("ticker") + client_id = next(iter(session._sub_mgr.active_subscriptions)) # type: ignore[union-attr] + await session.update_subscription_single_sid( + client_id, "delete_markets", market_tickers=["ETH-PERP"] + ) + cmd = fake_perps_ws.received_commands[-1] + assert cmd["cmd"] == "update_subscription" + assert "sid" in cmd["params"] + assert "sids" not in cmd["params"] + + +async def test_list_subscriptions_via_facade( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + ws = PerpsWebSocket(auth=perps_auth, config=perps_ws_config) + async with ws.connect() as session: + await session.subscribe("ticker") + await session.subscribe("trade") + entries = await session.list_subscriptions() + assert all(isinstance(e, SubscriptionEntry) for e in entries) + assert {e.channel for e in entries} == {"ticker", "trade"} + + +async def test_unsubscribe_via_facade_tears_down( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + ws = PerpsWebSocket(auth=perps_auth, config=perps_ws_config) + async with ws.connect() as session: + await session.subscribe("ticker") + client_id = next(iter(session._sub_mgr.active_subscriptions)) # type: ignore[union-attr] + await session.unsubscribe(client_id) + assert session._sub_mgr.get_subscription(client_id) is None # type: ignore[union-attr] + + +async def test_double_start_raises( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + ws = PerpsWebSocket(auth=perps_auth, config=perps_ws_config) + async with ws.connect(): + with pytest.raises(RuntimeError): + await ws._start() + + +async def test_subscribe_on_inactive_session_raises( + perps_ws_config: PerpsConfig, perps_auth +) -> None: + ws = PerpsWebSocket(auth=perps_auth, config=perps_ws_config) + with pytest.raises(RuntimeError): + await ws.subscribe("ticker") diff --git a/tests/perps/ws/test_connection.py b/tests/perps/ws/test_connection.py new file mode 100644 index 0000000..1489ff6 --- /dev/null +++ b/tests/perps/ws/test_connection.py @@ -0,0 +1,81 @@ +"""Tests for PerpsConnectionManager — handshake, state machine, reconnect.""" + +from __future__ import annotations + +import pytest + +from kalshi.errors import KalshiConnectionError +from kalshi.perps.config import PerpsConfig +from kalshi.perps.ws.connection import ConnectionState, PerpsConnectionManager + +from .conftest import FakePerpsWS + +pytestmark = pytest.mark.asyncio + + +async def test_connect_happy_path_builds_rsa_headers_and_transitions( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + states: list[tuple[ConnectionState, ConnectionState]] = [] + + async def on_state(old: ConnectionState, new: ConnectionState) -> None: + states.append((old, new)) + + mgr = PerpsConnectionManager( + auth=perps_auth, config=perps_ws_config, on_state_change=on_state + ) + assert mgr.state is ConnectionState.DISCONNECTED + await mgr.connect() + assert mgr.state is ConnectionState.CONNECTED + assert (ConnectionState.DISCONNECTED, ConnectionState.CONNECTING) in states + assert (ConnectionState.CONNECTING, ConnectionState.CONNECTED) in states + await mgr.close() + assert mgr.state is ConnectionState.CLOSED + + +async def test_connect_failure_goes_to_closed_with_path_no_query( + fake_perps_ws: FakePerpsWS, perps_auth +) -> None: + fake_perps_ws.reject_auth = True + config = PerpsConfig( + base_url="https://external-api.demo.kalshi.co/trade-api/v2", + ws_base_url=fake_perps_ws.ws_url, + ws_max_retries=1, + ) + mgr = PerpsConnectionManager(auth=perps_auth, config=config) + with pytest.raises(KalshiConnectionError) as ei: + await mgr.connect() + assert mgr.state is ConnectionState.CLOSED + # Path present, no query string / no leaked URL. + assert "/trade-api/ws/v2/margin" in str(ei.value) + assert "?" not in str(ei.value) + + +async def test_send_recv_roundtrip( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + mgr = PerpsConnectionManager(auth=perps_auth, config=perps_ws_config) + await mgr.connect() + await mgr.send({"id": 1, "cmd": "subscribe", "params": {"channels": ["ticker"]}}) + raw = await mgr.recv() + assert '"subscribed"' in raw + await mgr.close() + + +async def test_reconnect_fast_fails_on_permanent_close( + fake_perps_ws: FakePerpsWS, perps_auth +) -> None: + # reject_auth makes every reconnect attempt fail; with ws_max_retries=1 the + # AWS-full-jitter loop exhausts quickly and raises KalshiConnectionError. + fake_perps_ws.reject_auth = True + config = PerpsConfig( + base_url="https://external-api.demo.kalshi.co/trade-api/v2", + ws_base_url=fake_perps_ws.ws_url, + ws_max_retries=1, + retry_base_delay=0.001, + retry_max_delay=0.002, + ) + mgr = PerpsConnectionManager(auth=perps_auth, config=config) + with pytest.raises(KalshiConnectionError): + await mgr.reconnect() + assert mgr.state is ConnectionState.CLOSED diff --git a/tests/perps/ws/test_dispatch.py b/tests/perps/ws/test_dispatch.py new file mode 100644 index 0000000..ce254f1 --- /dev/null +++ b/tests/perps/ws/test_dispatch.py @@ -0,0 +1,137 @@ +"""Tests for PerpsMessageDispatcher — control routing, orphan/server unsubscribe.""" + +from __future__ import annotations + +import asyncio + +from kalshi.perps.config import PerpsConfig +from kalshi.perps.ws.channels import PerpsSubscriptionManager +from kalshi.perps.ws.connection import PerpsConnectionManager +from kalshi.perps.ws.dispatch import ( + CONTROL_TYPES, + MESSAGE_MODELS, + PerpsMessageDispatcher, + classify_ok, +) +from kalshi.perps.ws.models.control import PerpsErrorResponse +from kalshi.perps.ws.orderbook import PerpsOrderbookManager +from kalshi.perps.ws.sequence import PerpsSequenceTracker + +from .conftest import FakePerpsWS + +# asyncio_mode = "auto" (pyproject) auto-collects the async tests; an explicit +# module-level asyncio mark would also (wrongly) tag the sync tests below. + + +def test_control_types_and_message_models() -> None: + assert {"subscribed", "unsubscribed", "ok", "error"} == CONTROL_TYPES + # #398 populated the type -> model registry; the seven data channels route here. + assert set(MESSAGE_MODELS) >= { + "orderbook_snapshot", + "orderbook_delta", + "ticker", + "trade", + "fill", + "user_order", + "order_group_updates", + } + + +def test_classify_ok_branches_on_msg_shape() -> None: + assert classify_ok({"type": "ok", "msg": []}) == "list_subscriptions" + assert classify_ok({"type": "ok", "msg": {"market_tickers": []}}) == "update_ack" + assert classify_ok({"type": "ok"}) == "update_ack" + + +async def test_error_frame_routes_to_on_error( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn = PerpsConnectionManager(auth=perps_auth, config=perps_ws_config) + await conn.connect() + sub_mgr = PerpsSubscriptionManager(conn) + seen: list[PerpsErrorResponse] = [] + + async def on_error(e: PerpsErrorResponse) -> None: + seen.append(e) + + dispatcher = PerpsMessageDispatcher(sub_mgr=sub_mgr, on_error=on_error) + await dispatcher.dispatch( + {"type": "error", "id": 1, "msg": {"code": 7, "msg": "bad"}} + ) + assert len(seen) == 1 + assert seen[0].msg.code == 7 + assert seen[0].msg.msg == "bad" + await conn.close() + + +async def test_unknown_message_type_is_dropped( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn = PerpsConnectionManager(auth=perps_auth, config=perps_ws_config) + await conn.connect() + sub_mgr = PerpsSubscriptionManager(conn) + dispatcher = PerpsMessageDispatcher(sub_mgr=sub_mgr) + # No raise; an unregistered type just logs + returns. (Use a type that is + # neither a CONTROL_TYPE nor in MESSAGE_MODELS — "ticker" is now registered.) + await dispatcher.dispatch({"type": "not_a_real_channel", "sid": 1, "msg": {}}) + await conn.close() + + +async def test_server_unsubscribe_reaps_seq_and_orderbook( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn = PerpsConnectionManager(auth=perps_auth, config=perps_ws_config) + await conn.connect() + sub_mgr = PerpsSubscriptionManager(conn) + seq = PerpsSequenceTracker() + ob = PerpsOrderbookManager() + dispatcher = PerpsMessageDispatcher( + sub_mgr=sub_mgr, seq_tracker=seq, orderbook_mgr=ob + ) + + sub = await sub_mgr.subscribe("orderbook_delta", params={"market_tickers": ["X"]}) + sid = sub.server_sid + assert sid is not None + # Seed seq + orderbook state under the sid. + seq.track_sync(sid, 1, "orderbook_delta") + from kalshi.perps.ws.models.orderbook import MarginOrderbookSnapshotMessage + + snap = MarginOrderbookSnapshotMessage.model_validate( + { + "type": "orderbook_snapshot", + "sid": sid, + "seq": 1, + "msg": {"market_ticker": "X", "bid": [["0.10", "5"]], "ask": []}, + } + ) + ob._apply_snapshot_inplace(snap) + assert ob.get("X") is not None + + await dispatcher._handle_server_unsubscribe( + {"type": "unsubscribed", "sid": sid, "seq": 2} + ) + assert sub_mgr.get_subscription_by_sid(sid) is None + assert seq.peek(sid) is None + assert ob.get("X") is None + await conn.close() + + +async def test_orphan_subscribed_sends_unsubscribe( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + conn = PerpsConnectionManager(auth=perps_auth, config=perps_ws_config) + await conn.connect() + sub_mgr = PerpsSubscriptionManager(conn) + dispatcher = PerpsMessageDispatcher(sub_mgr=sub_mgr) + # Subscribed ack for a sid with no client mapping -> auto-unsubscribe. + await dispatcher._handle_orphan_subscribed( + {"type": "subscribed", "msg": {"channel": "ticker", "sid": 4242}} + ) + assert 4242 in dispatcher._pending_orphan_unsub + # The unsubscribe is best-effort fire-and-forget; yield so the fake server + # reads it off the socket before we assert. + await asyncio.sleep(0.1) + cmd = fake_perps_ws.received_commands[-1] + assert cmd["cmd"] == "unsubscribe" + assert cmd["params"]["sids"] == [4242] + await conn.close() diff --git a/tests/perps/ws/test_models.py b/tests/perps/ws/test_models.py new file mode 100644 index 0000000..805ee56 --- /dev/null +++ b/tests/perps/ws/test_models.py @@ -0,0 +1,102 @@ +"""Tests for perps WS control models — forbid on commands, envelope round-trips.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from kalshi.perps.ws.models.control import ( + ListSubscriptionsResponse, + OkResponse, + PerpsErrorResponse, + SubscribeCommand, + SubscribedResponse, + SubscribeParams, + SubscriptionEntry, + UnsubscribedResponse, + UnsubscribeParams, + UpdateSubscriptionAction, + UpdateSubscriptionParams, +) + + +class TestCommandForbid: + def test_subscribe_params_rejects_unknown_key(self) -> None: + with pytest.raises(ValidationError): + SubscribeParams(channels=["ticker"], bogus=1) # type: ignore[call-arg] + + def test_unsubscribe_params_rejects_unknown_key(self) -> None: + with pytest.raises(ValidationError): + UnsubscribeParams(sids=[1], extra=2) # type: ignore[call-arg] + + def test_update_params_rejects_unknown_key(self) -> None: + with pytest.raises(ValidationError): + UpdateSubscriptionParams( # type: ignore[call-arg] + action=UpdateSubscriptionAction.ADD_MARKETS, bogus=1 + ) + + def test_subscribe_command_serializes_with_aliases(self) -> None: + cmd = SubscribeCommand( + id=1, params=SubscribeParams(channels=["ticker"], market_tickers=["X"]) + ) + out = cmd.model_dump(exclude_none=True, by_alias=True, mode="json") + assert out == { + "id": 1, + "cmd": "subscribe", + "params": {"channels": ["ticker"], "market_tickers": ["X"]}, + } + + +class TestResponseRoundTrip: + def test_subscribed_response(self) -> None: + m = SubscribedResponse.model_validate( + {"id": 1, "type": "subscribed", "msg": {"channel": "ticker", "sid": 5}} + ) + assert m.msg.channel == "ticker" + assert m.msg.sid == 5 + + def test_unsubscribed_response_top_level_sid_seq(self) -> None: + m = UnsubscribedResponse.model_validate( + {"id": 1, "sid": 5, "seq": 9, "type": "unsubscribed"} + ) + assert m.sid == 5 and m.seq == 9 + + def test_ok_response_object_msg(self) -> None: + m = OkResponse.model_validate( + {"id": 1, "sid": 5, "type": "ok", "msg": {"market_tickers": ["X", "Y"]}} + ) + assert m.msg is not None + assert m.msg.market_tickers == ["X", "Y"] + + def test_ok_response_without_msg(self) -> None: + m = OkResponse.model_validate({"id": 1, "type": "ok"}) + assert m.msg is None + + def test_list_subscriptions_response_array_msg(self) -> None: + m = ListSubscriptionsResponse.model_validate( + { + "id": 1, + "type": "ok", + "msg": [ + {"channel": "ticker", "sid": 3}, + {"channel": "trade", "sid": 4}, + ], + } + ) + assert [e.sid for e in m.msg] == [3, 4] + assert all(isinstance(e, SubscriptionEntry) for e in m.msg) + + def test_error_response(self) -> None: + m = PerpsErrorResponse.model_validate( + {"id": 1, "type": "error", "msg": {"code": 6, "msg": "nope"}} + ) + assert m.msg.code == 6 + assert m.msg.msg == "nope" + + def test_ok_array_vs_object_disambiguation(self) -> None: + # Same type:"ok" frame validates against the right model based on msg shape. + array_frame = {"id": 1, "type": "ok", "msg": [{"channel": "t", "sid": 1}]} + object_frame = {"id": 1, "type": "ok", "msg": {"market_tickers": []}} + # Array msg parses as ListSubscriptionsResponse, object as OkResponse. + assert ListSubscriptionsResponse.model_validate(array_frame).msg[0].sid == 1 + assert OkResponse.model_validate(object_frame).msg is not None diff --git a/tests/perps/ws/test_orderbook.py b/tests/perps/ws/test_orderbook.py new file mode 100644 index 0000000..83de86b --- /dev/null +++ b/tests/perps/ws/test_orderbook.py @@ -0,0 +1,103 @@ +"""Tests for PerpsOrderbookManager — bid/ask side handling (no yes/no leak).""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from decimal import Decimal + +from kalshi.perps.ws.models.control import PerpsBookSide +from kalshi.perps.ws.orderbook import PerpsOrderbookManager + + +@dataclass +class _SnapMsg: + market_ticker: str + bid: dict = field(default_factory=dict) + ask: dict = field(default_factory=dict) + + +@dataclass +class _Snap: + sid: int + msg: _SnapMsg + + +@dataclass +class _DeltaMsg: + market_ticker: str + price: Decimal + delta: Decimal + side: object + + +@dataclass +class _Delta: + sid: int + msg: _DeltaMsg + + +def _snap(sid: int, ticker: str, bid: dict, ask: dict) -> _Snap: + return _Snap(sid=sid, msg=_SnapMsg(market_ticker=ticker, bid=dict(bid), ask=dict(ask))) + + +def test_apply_snapshot_builds_bid_ask_book() -> None: + mgr = PerpsOrderbookManager() + book = mgr.apply_snapshot( + _snap( + 1, "BTC-PERP", + {Decimal("0.40"): Decimal("10"), Decimal("0.41"): Decimal("5")}, + {Decimal("0.60"): Decimal("8")}, + ) + ) + assert book.ticker == "BTC-PERP" + # Best-first ordering (spec MarginOrderbookCount): bids descending, asks ascending. + assert [lvl.price for lvl in book.bids] == [Decimal("0.41"), Decimal("0.40")] + assert [lvl.quantity for lvl in book.bids] == [Decimal("5"), Decimal("10")] + assert [lvl.price for lvl in book.asks] == [Decimal("0.60")] + + +def test_delta_applies_to_bid_side_enum_and_string() -> None: + mgr = PerpsOrderbookManager() + mgr.apply_snapshot(_snap(1, "X", {Decimal("0.40"): Decimal("10")}, {})) + # Enum side + mgr.apply_delta(_Delta(1, _DeltaMsg("X", Decimal("0.40"), Decimal("5"), PerpsBookSide.BID))) + assert mgr.get("X").bids[0].quantity == Decimal("15") + # Raw wire string side + mgr.apply_delta(_Delta(1, _DeltaMsg("X", Decimal("0.40"), Decimal("-15"), "bid"))) + # Level removed when qty hits zero. + assert mgr.get("X").bids == [] + + +def test_delta_to_ask_side() -> None: + mgr = PerpsOrderbookManager() + mgr.apply_snapshot(_snap(1, "X", {}, {Decimal("0.60"): Decimal("3")})) + mgr.apply_delta(_Delta(1, _DeltaMsg("X", Decimal("0.61"), Decimal("7"), "ask"))) + asks = {lvl.price: lvl.quantity for lvl in mgr.get("X").asks} + assert asks == {Decimal("0.60"): Decimal("3"), Decimal("0.61"): Decimal("7")} + # Bid side untouched (guards against yes/no hardcode regression). + assert mgr.get("X").bids == [] + + +def test_delta_before_snapshot_is_noop() -> None: + mgr = PerpsOrderbookManager() + assert mgr.apply_delta(_Delta(1, _DeltaMsg("X", Decimal("0.4"), Decimal("1"), "bid"))) is None + + +def test_remove_by_sid_tears_down_all_markets() -> None: + mgr = PerpsOrderbookManager() + mgr.apply_snapshot(_snap(9, "A", {Decimal("0.1"): Decimal("1")}, {})) + mgr.apply_snapshot(_snap(9, "B", {Decimal("0.2"): Decimal("1")}, {})) + removed = mgr.remove_by_sid(9) + assert set(removed) == {"A", "B"} + assert mgr.get("A") is None and mgr.get("B") is None + + +def test_get_caches_until_mutation() -> None: + mgr = PerpsOrderbookManager() + mgr.apply_snapshot(_snap(1, "X", {Decimal("0.4"): Decimal("10")}, {})) + a = mgr.get("X") + b = mgr.get("X") + assert a is b # identity-stable cache + mgr.apply_delta(_Delta(1, _DeltaMsg("X", Decimal("0.4"), Decimal("1"), "bid"))) + c = mgr.get("X") + assert c is not a # invalidated on mutation diff --git a/tests/perps/ws/test_parity.py b/tests/perps/ws/test_parity.py new file mode 100644 index 0000000..737a95f --- /dev/null +++ b/tests/perps/ws/test_parity.py @@ -0,0 +1,186 @@ +"""AsyncAPI WS command/response parity test for the perps margin WebSocket. + +The WS analog of the REST drift test: loads ``specs/perps_asyncapi.yaml`` and +asserts every command/response message has a corresponding model in +``kalshi/perps/ws/models/control.py`` whose ``cmd`` (commands) / ``type`` +(responses) ``Literal`` matches the spec const. + +The ``listSubscriptionsResponsePayload`` / ``okResponsePayload`` collision +(both ``type: "ok"``) is NOT excluded — it is disambiguated by ``msg`` shape +(array vs object), which the test verifies directly via +``PerpsMessageDispatcher.classify_ok`` rather than skipping. +""" + +from __future__ import annotations + +import typing +from pathlib import Path +from typing import Literal + +import yaml + +from kalshi.perps.ws.dispatch import classify_ok +from kalshi.perps.ws.models.control import ( + ListSubscriptionsCommand, + ListSubscriptionsResponse, + OkResponse, + PerpsErrorResponse, + SubscribeCommand, + SubscribedResponse, + UnsubscribeCommand, + UnsubscribedResponse, + UpdateSubscriptionCommand, +) + +_SPEC_PATH = Path(__file__).resolve().parents[3] / "specs" / "perps_asyncapi.yaml" + + +def _load_spec() -> dict: + with _SPEC_PATH.open() as f: + return yaml.safe_load(f) + + +def _const_of(spec: dict, schema_name: str, field: str) -> str: + """Return the ``const`` declared on ``schema_name.properties[field]``.""" + schema = spec["components"]["schemas"][schema_name] + return schema["properties"][field]["const"] + + +def _literal_value(model: type, field: str) -> str: + """Return the single allowed value of a ``Literal`` field on a model.""" + hints = typing.get_type_hints(model) + ann = hints[field] + args = typing.get_args(ann) + assert args, f"{model.__name__}.{field} is not a Literal" + assert len(args) == 1, f"{model.__name__}.{field} Literal must be single-valued" + return args[0] + + +# Map spec payload schema -> (SDK model, const-field, command-or-response). +# updateSubscription{,Delete,SingleSid}Command all share updateSubscriptionCommandPayload. +_COMMAND_MAP = { + "subscribeCommandPayload": (SubscribeCommand, "cmd"), + "unsubscribeCommandPayload": (UnsubscribeCommand, "cmd"), + "updateSubscriptionCommandPayload": (UpdateSubscriptionCommand, "cmd"), + "listSubscriptionsCommandPayload": (ListSubscriptionsCommand, "cmd"), +} +_RESPONSE_MAP = { + "subscribedResponsePayload": (SubscribedResponse, "type"), + "unsubscribedResponsePayload": (UnsubscribedResponse, "type"), + "okResponsePayload": (OkResponse, "type"), + "listSubscriptionsResponsePayload": (ListSubscriptionsResponse, "type"), + "errorResponsePayload": (PerpsErrorResponse, "type"), +} + + +class TestCommandParity: + def test_every_command_schema_has_a_model(self) -> None: + spec = _load_spec() + schemas = spec["components"]["schemas"] + command_schemas = { + name for name in schemas if name.endswith("CommandPayload") + } + assert command_schemas == set(_COMMAND_MAP), ( + "Command payload schemas in the spec do not match the mapped models: " + f"spec={sorted(command_schemas)} mapped={sorted(_COMMAND_MAP)}" + ) + + def test_command_cmd_consts_match(self) -> None: + spec = _load_spec() + for schema_name, (model, field) in _COMMAND_MAP.items(): + spec_const = _const_of(spec, schema_name, field) + model_const = _literal_value(model, field) + assert model_const == spec_const, ( + f"{model.__name__}.{field}={model_const!r} != " + f"spec {schema_name}.{field}={spec_const!r}" + ) + + +class TestResponseParity: + def test_every_response_schema_has_a_model(self) -> None: + spec = _load_spec() + schemas = spec["components"]["schemas"] + response_schemas = { + name for name in schemas if name.endswith("ResponsePayload") + } + assert response_schemas == set(_RESPONSE_MAP), ( + "Response payload schemas in the spec do not match the mapped models: " + f"spec={sorted(response_schemas)} mapped={sorted(_RESPONSE_MAP)}" + ) + + def test_response_type_consts_match(self) -> None: + spec = _load_spec() + for schema_name, (model, field) in _RESPONSE_MAP.items(): + spec_const = _const_of(spec, schema_name, field) + model_const = _literal_value(model, field) + assert model_const == spec_const, ( + f"{model.__name__}.{field}={model_const!r} != " + f"spec {schema_name}.{field}={spec_const!r}" + ) + + def test_ok_collision_disambiguated_by_msg_shape(self) -> None: + """Both okResponsePayload and listSubscriptionsResponsePayload are type:"ok". + + The spec disambiguates by ``msg`` being an object (update ack) vs an + array (list_subscriptions). Assert both share the const AND that the + framing layer branches on ``msg`` shape. + """ + spec = _load_spec() + assert _const_of(spec, "okResponsePayload", "type") == "ok" + assert _const_of(spec, "listSubscriptionsResponsePayload", "type") == "ok" + # update ack -> object msg + assert classify_ok({"type": "ok", "id": 1, "msg": {"market_tickers": []}}) == "update_ack" + assert classify_ok({"type": "ok", "id": 1}) == "update_ack" + # list_subscriptions -> array msg + assert classify_ok({"type": "ok", "id": 1, "msg": []}) == "list_subscriptions" + assert classify_ok( + {"type": "ok", "id": 1, "msg": [{"channel": "ticker", "sid": 3}]} + ) == "list_subscriptions" + + +class TestEnumParity: + def test_channels_enum_matches_spec(self) -> None: + from kalshi.perps.ws.models.control import PerpsChannel + + spec = _load_spec() + spec_channels = set( + spec["components"]["schemas"]["subscribeCommandPayload"]["properties"][ + "params" + ]["properties"]["channels"]["items"]["enum"] + ) + assert {c.value for c in PerpsChannel} == spec_channels + + def test_action_enum_matches_spec(self) -> None: + from kalshi.perps.ws.models.control import UpdateSubscriptionAction + + spec = _load_spec() + spec_actions = set( + spec["components"]["schemas"]["updateSubscriptionCommandPayload"][ + "properties" + ]["params"]["properties"]["action"]["enum"] + ) + assert {a.value for a in UpdateSubscriptionAction} == spec_actions + + def test_book_side_enum_matches_spec(self) -> None: + from kalshi.perps.ws.models.control import PerpsBookSide + + spec = _load_spec() + spec_sides = set(spec["components"]["schemas"]["bookSide"]["enum"]) + assert {s.value for s in PerpsBookSide} == spec_sides + + +class TestSequencedChannels: + def test_perps_sequenced_channels(self) -> None: + from kalshi.perps.ws.sequence import PERPS_SEQUENCED_CHANNELS + + assert { + "orderbook_delta", + "orderbook_snapshot", + "order_group_updates", + } == PERPS_SEQUENCED_CHANNELS + + +def test_literal_imported_for_type_hints() -> None: + # Guard: get_type_hints needs Literal in scope. Importing it here ensures + # the parity helpers resolve forward refs even under `from __future__`. + assert Literal is not None diff --git a/tests/perps/ws/test_perps_ws_channels.py b/tests/perps/ws/test_perps_ws_channels.py new file mode 100644 index 0000000..6e8ea6f --- /dev/null +++ b/tests/perps/ws/test_perps_ws_channels.py @@ -0,0 +1,185 @@ +"""End-to-end channel tests: subscribe helper -> fake server frame -> typed model. + +Drives the six typed ``subscribe_*`` helpers through the FakePerpsWS server and +asserts the dispatched frame arrives as the correct ``Margin*Message`` model. +Also verifies the orderbook-apply seam in ``_process_frame`` feeds the +:class:`PerpsOrderbookManager`. +""" + +from __future__ import annotations + +import asyncio +from decimal import Decimal + +import pytest + +from kalshi.perps.config import PerpsConfig +from kalshi.perps.ws import PerpsWebSocket +from kalshi.perps.ws.models.fill import MarginFillMessage +from kalshi.perps.ws.models.order_group import OrderGroupUpdatesMessage +from kalshi.perps.ws.models.orderbook import ( + MarginOrderbookDeltaMessage, + MarginOrderbookSnapshotMessage, +) +from kalshi.perps.ws.models.ticker import MarginTickerMessage +from kalshi.perps.ws.models.trade import MarginTradeMessage +from kalshi.perps.ws.models.user_orders import MarginUserOrderMessage + +from .conftest import FakePerpsWS + +pytestmark = pytest.mark.asyncio + + +async def _sid(fake: FakePerpsWS) -> int: + return next(iter(fake.subscriptions)) + + +async def test_subscribe_ticker_yields_typed_message( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + ws = PerpsWebSocket(auth=perps_auth, config=perps_ws_config) + async with ws.connect() as session: + stream = await session.subscribe_ticker(tickers=["BTC-PERP"]) + sid = await _sid(fake_perps_ws) + await fake_perps_ws.send_to_all({ + "type": "ticker", "sid": sid, + "msg": { + "market_ticker": "BTC-PERP", "price": "100.0000", + "bid": "99.5000", "ask": "100.5000", + "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", + "funding_rate": {"rate": 0.0001, "next_funding_time_ms": 1, "ts_ms": 2}, + "ts_ms": 1700000000000, + }, + }) + frame = await asyncio.wait_for(stream.__anext__(), timeout=2.0) + assert isinstance(frame, MarginTickerMessage) + assert frame.msg.funding_rate is not None + assert frame.msg.funding_rate.rate == Decimal("0.0001") + + +async def test_subscribe_trade_yields_typed_message( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + ws = PerpsWebSocket(auth=perps_auth, config=perps_ws_config) + async with ws.connect() as session: + stream = await session.subscribe_trade(tickers=["BTC-PERP"]) + sid = await _sid(fake_perps_ws) + await fake_perps_ws.send_to_all({ + "type": "trade", "sid": sid, + "msg": { + "trade_id": "t1", "market_ticker": "BTC-PERP", + "price": "100.0000", "count": "5.00", + "taker_side": "ask", "ts_ms": 1700000000000, + }, + }) + frame = await asyncio.wait_for(stream.__anext__(), timeout=2.0) + assert isinstance(frame, MarginTradeMessage) + assert frame.msg.taker_side == "ask" + + +async def test_subscribe_fill_yields_typed_message( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + ws = PerpsWebSocket(auth=perps_auth, config=perps_ws_config) + async with ws.connect() as session: + stream = await session.subscribe_fill() + sid = await _sid(fake_perps_ws) + await fake_perps_ws.send_to_all({ + "type": "fill", "sid": sid, + "msg": { + "trade_id": "t1", "order_id": "o1", "market_ticker": "BTC-PERP", + "is_taker": True, "side": "bid", "ts_ms": 1700000000000, + "price": "100.0000", "count": "5.00", + "fee_cost": "0.0500", "post_position": "15.00", + }, + }) + frame = await asyncio.wait_for(stream.__anext__(), timeout=2.0) + assert isinstance(frame, MarginFillMessage) + assert frame.msg.post_position == Decimal("15.00") + + +async def test_subscribe_user_orders_yields_typed_message( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + ws = PerpsWebSocket(auth=perps_auth, config=perps_ws_config) + async with ws.connect() as session: + stream = await session.subscribe_user_orders() + sid = await _sid(fake_perps_ws) + # Subscribe channel is user_orders; envelope type is user_order. + await fake_perps_ws.send_to_all({ + "type": "user_order", "sid": sid, + "msg": { + "order_id": "o1", "user_id": "u1", "client_order_id": "c1", + "ticker": "BTC-PERP", "side": "ask", "price": "100.0000", + "fill_count": "2.00", "remaining_count": "8.00", + "created_ts_ms": 1700000000000, + }, + }) + frame = await asyncio.wait_for(stream.__anext__(), timeout=2.0) + assert isinstance(frame, MarginUserOrderMessage) + assert frame.msg.ticker == "BTC-PERP" + + +async def test_subscribe_order_group_yields_typed_message( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + ws = PerpsWebSocket(auth=perps_auth, config=perps_ws_config) + async with ws.connect() as session: + stream = await session.subscribe_order_group() + sid = await _sid(fake_perps_ws) + await fake_perps_ws.send_to_all({ + "type": "order_group_updates", "sid": sid, "seq": 1, + "msg": { + "event_type": "limit_updated", "order_group_id": "og_1", + "contracts_limit_fp": "150.00", "ts_ms": 1700000000000, + }, + }) + frame = await asyncio.wait_for(stream.__anext__(), timeout=2.0) + assert isinstance(frame, OrderGroupUpdatesMessage) + assert frame.msg.contracts_limit == Decimal("150.00") + + +async def test_subscribe_orderbook_delta_snapshot_then_delta( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + ws = PerpsWebSocket(auth=perps_auth, config=perps_ws_config) + async with ws.connect() as session: + stream = await session.subscribe_orderbook_delta(tickers=["BTC-PERP"]) + sid = await _sid(fake_perps_ws) + # Forces send_initial_snapshot=True on the wire. + cmd = fake_perps_ws.received_commands[-1] + assert cmd["params"]["send_initial_snapshot"] is True + + await fake_perps_ws.send_to_all({ + "type": "orderbook_snapshot", "sid": sid, "seq": 1, + "msg": { + "market_ticker": "BTC-PERP", + "bid": [["100.0000", "10.00"]], + "ask": [["101.0000", "5.00"]], + }, + }) + snap = await asyncio.wait_for(stream.__anext__(), timeout=2.0) + assert isinstance(snap, MarginOrderbookSnapshotMessage) + assert snap.msg.bid == {Decimal("100.0000"): Decimal("10.00")} + + # The orderbook-apply seam fed the manager. + book = session._orderbook_mgr.get("BTC-PERP") # type: ignore[union-attr] + assert book is not None + assert book.bids[0].price == Decimal("100.0000") + + await fake_perps_ws.send_to_all({ + "type": "orderbook_delta", "sid": sid, "seq": 2, + "msg": { + "market_ticker": "BTC-PERP", "price": "100.0000", + "delta": "-4.00", "side": "bid", "ts_ms": 1700000000000, + }, + }) + delta = await asyncio.wait_for(stream.__anext__(), timeout=2.0) + assert isinstance(delta, MarginOrderbookDeltaMessage) + assert delta.msg.delta == Decimal("-4.00") + + book2 = session._orderbook_mgr.get("BTC-PERP") # type: ignore[union-attr] + assert book2 is not None + assert book2.bids[0].quantity == Decimal("6.00") diff --git a/tests/perps/ws/test_perps_ws_models.py b/tests/perps/ws/test_perps_ws_models.py new file mode 100644 index 0000000..730a43e --- /dev/null +++ b/tests/perps/ws/test_perps_ws_models.py @@ -0,0 +1,615 @@ +"""Perps WS channel payload models: payload-parity, drift guard, behavior. + +Loads the committed ``specs/perps_asyncapi.yaml`` and asserts each +``Margin*Message`` / ``OrderGroupUpdatesMessage`` model parses a frame built +from the schema's ``required`` fields (plus the inline ``orderGroupUpdates`` +example), that decimals land as ``Decimal`` (not ``float``), and that a new +``required`` field on an owned payload schema the model lacks would fail +(drift guard). Per-channel behavior tests cover the happy/edge/error cases +from #398. + +This is the WS analog of the REST ``TestRequestParamDrift`` — but for inbound +read models, so it asserts *required-coverage* (every spec-required field is +accepted by the model) rather than no-extra-kwargs (these models are +``extra="allow"``). +""" + +from __future__ import annotations + +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest +from pydantic import BaseModel, ValidationError + +from kalshi.perps.ws.models.fill import MarginFillMessage, MarginFillPayload +from kalshi.perps.ws.models.order_group import ( + OrderGroupUpdatesMessage, + OrderGroupUpdatesPayload, +) +from kalshi.perps.ws.models.orderbook import ( + MarginOrderbookDeltaMessage, + MarginOrderbookDeltaPayload, + MarginOrderbookSnapshotMessage, + MarginOrderbookSnapshotPayload, +) +from kalshi.perps.ws.models.ticker import ( + FundingRate, + MarginTickerMessage, + MarginTickerPayload, + TickerPrice, +) +from kalshi.perps.ws.models.trade import MarginTradeMessage, MarginTradePayload +from kalshi.perps.ws.models.user_orders import ( + MarginUserOrderMessage, + MarginUserOrderPayload, +) + +_SPEC_PATH = Path(__file__).resolve().parents[3] / "specs" / "perps_asyncapi.yaml" + + +def _load_spec() -> dict[str, Any]: + import yaml + + with _SPEC_PATH.open() as f: + return yaml.safe_load(f) + + +# Owned envelope payload schema -> (envelope model, msg-payload model). +_PAYLOAD_MODELS: dict[str, tuple[type[BaseModel], type[BaseModel]]] = { + "marginOrderbookSnapshotPayload": ( + MarginOrderbookSnapshotMessage, + MarginOrderbookSnapshotPayload, + ), + "marginOrderbookDeltaPayload": ( + MarginOrderbookDeltaMessage, + MarginOrderbookDeltaPayload, + ), + "marginTickerPayload": (MarginTickerMessage, MarginTickerPayload), + "marginTradePayload": (MarginTradeMessage, MarginTradePayload), + "marginFillPayload": (MarginFillMessage, MarginFillPayload), + "marginUserOrderPayload": (MarginUserOrderMessage, MarginUserOrderPayload), + "orderGroupUpdatesPayload": ( + OrderGroupUpdatesMessage, + OrderGroupUpdatesPayload, + ), +} + + +def _accepted_names(model: type[BaseModel]) -> set[str]: + """Every wire name the model accepts: field names + their validation aliases.""" + names: set[str] = set() + for field_name, info in model.model_fields.items(): + names.add(field_name) + alias = info.validation_alias + if isinstance(alias, str): + names.add(alias) + elif alias is not None: + # AliasChoices.choices is a list of str | AliasPath. + for choice in getattr(alias, "choices", []): + if isinstance(choice, str): + names.add(choice) + return names + + +class TestPayloadDriftGuard: + """Fail if the AsyncAPI marks a field ``required`` the model can't accept.""" + + @pytest.mark.parametrize("schema_name", sorted(_PAYLOAD_MODELS)) + def test_required_msg_fields_covered(self, schema_name: str) -> None: + spec = _load_spec() + schema = spec["components"]["schemas"][schema_name] + msg_schema = schema["properties"]["msg"] + required = set(msg_schema.get("required", [])) + _envelope, payload_model = _PAYLOAD_MODELS[schema_name] + accepted = _accepted_names(payload_model) + missing = required - accepted + assert not missing, ( + f"{payload_model.__name__} is missing spec-required field(s) " + f"{sorted(missing)} on {schema_name}.msg" + ) + + @pytest.mark.parametrize("schema_name", sorted(_PAYLOAD_MODELS)) + def test_envelope_required_top_level_fields_covered( + self, schema_name: str + ) -> None: + spec = _load_spec() + schema = spec["components"]["schemas"][schema_name] + required = set(schema.get("required", [])) + envelope_model, _payload = _PAYLOAD_MODELS[schema_name] + accepted = _accepted_names(envelope_model) + missing = required - accepted + assert not missing, ( + f"{envelope_model.__name__} is missing spec-required envelope " + f"field(s) {sorted(missing)} on {schema_name}" + ) + + def test_sequenced_envelopes_require_seq(self) -> None: + # orderbook_* + order_group_updates mark seq required; ticker/trade/ + # fill/user_order do not. + spec = _load_spec() + for schema_name in ( + "marginOrderbookSnapshotPayload", + "marginOrderbookDeltaPayload", + "orderGroupUpdatesPayload", + ): + assert "seq" in spec["components"]["schemas"][schema_name]["required"] + for schema_name in ( + "marginTickerPayload", + "marginTradePayload", + "marginFillPayload", + "marginUserOrderPayload", + ): + assert "seq" not in spec["components"]["schemas"][schema_name][ + "required" + ] + + +class TestOrderGroupExampleParity: + """The spec ships an inline orderGroupLimitUpdated example — parse it. + + SPEC DISCREPANCY: the inline ``orderGroupLimitUpdated`` example omits + ``ts_ms`` from its ``msg`` even though ``orderGroupUpdatesPayload.msg`` + marks ``ts_ms`` as ``required``. The model (correctly) follows the schema's + required list, so this test back-fills the schema-required ``ts_ms`` the + example left out and asserts that detail explicitly, rather than relaxing + the model to match an incomplete example. + """ + + def test_inline_example_omits_required_ts_ms(self) -> None: + spec = _load_spec() + example = spec["components"]["messages"]["orderGroupUpdates"]["examples"][0][ + "payload" + ] + required = spec["components"]["schemas"]["orderGroupUpdatesPayload"][ + "properties" + ]["msg"]["required"] + # Documents the discrepancy: schema requires ts_ms, example omits it. + assert "ts_ms" in required + assert "ts_ms" not in example["msg"] + + def test_inline_example_validates_with_ts_ms_backfilled(self) -> None: + spec = _load_spec() + example = spec["components"]["messages"]["orderGroupUpdates"]["examples"][0][ + "payload" + ] + frame = {**example, "msg": {**example["msg"], "ts_ms": 1700000000000}} + msg = OrderGroupUpdatesMessage.model_validate(frame) + assert msg.type == "order_group_updates" + assert msg.sid == 21 + assert msg.seq == 7 + assert msg.msg.event_type == "limit_updated" + assert msg.msg.order_group_id == "og_123" + assert msg.msg.contracts_limit == Decimal("150.00") + assert isinstance(msg.msg.contracts_limit, Decimal) + + +# --------------------------------------------------------------------------- +# Per-channel behavior tests +# --------------------------------------------------------------------------- + + +class TestOrderbookDelta: + def test_snapshot_levels_collapse_to_decimal_dicts(self) -> None: + frame = { + "type": "orderbook_snapshot", + "sid": 1, + "seq": 1, + "msg": { + "market_ticker": "BTC-PERP", + "bid": [["100.5000", "10.00"], ["100.0000", "5.00"]], + "ask": [["101.0000", "7.00"]], + }, + } + msg = MarginOrderbookSnapshotMessage.model_validate(frame) + assert msg.msg.bid == {Decimal("100.5000"): Decimal("10.00"), + Decimal("100.0000"): Decimal("5.00")} + assert msg.msg.ask == {Decimal("101.0000"): Decimal("7.00")} + for k, v in msg.msg.bid.items(): + assert isinstance(k, Decimal) and isinstance(v, Decimal) + + def test_delta_bid_negative(self) -> None: + frame = { + "type": "orderbook_delta", + "sid": 1, + "seq": 2, + "msg": { + "market_ticker": "BTC-PERP", + "price": "100.5000", + "delta": "-3.00", + "side": "bid", + "ts_ms": 1700000000000, + }, + } + msg = MarginOrderbookDeltaMessage.model_validate(frame) + assert msg.msg.side == "bid" + assert msg.msg.delta == Decimal("-3.00") + assert msg.msg.price == Decimal("100.5000") + assert msg.msg.ts_ms == 1700000000000 + assert isinstance(msg.msg.ts_ms, int) + + def test_snapshot_omitted_sides_default_empty(self) -> None: + # Perps allows a partial book (unlike equities #268 both-required). + frame = { + "type": "orderbook_snapshot", + "sid": 1, + "seq": 1, + "msg": {"market_ticker": "BTC-PERP", "bid": [["100.0000", "5.00"]]}, + } + msg = MarginOrderbookSnapshotMessage.model_validate(frame) + assert msg.msg.bid == {Decimal("100.0000"): Decimal("5.00")} + assert msg.msg.ask == {} + + def test_snapshot_both_sides_omitted_default_empty(self) -> None: + frame = { + "type": "orderbook_snapshot", + "sid": 1, + "seq": 1, + "msg": {"market_ticker": "BTC-PERP"}, + } + msg = MarginOrderbookSnapshotMessage.model_validate(frame) + assert msg.msg.bid == {} and msg.msg.ask == {} + + def test_malformed_level_raises(self) -> None: + frame = { + "type": "orderbook_snapshot", + "sid": 1, + "seq": 1, + "msg": { + "market_ticker": "BTC-PERP", + "bid": [["not-a-number", "10.00"]], + }, + } + with pytest.raises(ValidationError): + MarginOrderbookSnapshotMessage.model_validate(frame) + + def test_snapshot_missing_seq_raises(self) -> None: + frame = { + "type": "orderbook_snapshot", + "sid": 1, + "msg": {"market_ticker": "BTC-PERP"}, + } + with pytest.raises(ValidationError): + MarginOrderbookSnapshotMessage.model_validate(frame) + + def test_delta_rejects_yes_side(self) -> None: + frame = { + "type": "orderbook_delta", + "sid": 1, + "seq": 2, + "msg": { + "market_ticker": "BTC-PERP", + "price": "100.5000", + "delta": "3.00", + "side": "yes", + }, + } + with pytest.raises(ValidationError): + MarginOrderbookDeltaMessage.model_validate(frame) + + def test_delta_last_update_reason_empty_string_ok(self) -> None: + frame = { + "type": "orderbook_delta", + "sid": 1, + "seq": 2, + "msg": { + "market_ticker": "BTC-PERP", + "price": "100.5000", + "delta": "3.00", + "side": "ask", + "last_update_reason": "", + }, + } + msg = MarginOrderbookDeltaMessage.model_validate(frame) + assert msg.msg.last_update_reason == "" + + +class TestTicker: + def _full_frame(self) -> dict[str, Any]: + return { + "type": "ticker", + "sid": 2, + "msg": { + "market_ticker": "BTC-PERP", + "price": "100.0000", + "bid": "99.5000", + "ask": "100.5000", + "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", + "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}, + "funding_rate": { + "rate": 0.0001, + "next_funding_time_ms": 1700003600000, + "ts_ms": 1700000000000, + }, + "ts_ms": 1700000000000, + }, + } + + def test_full_ticker_with_funding_and_marks(self) -> None: + msg = MarginTickerMessage.model_validate(self._full_frame()) + assert msg.seq is None + p = msg.msg + assert p.price == Decimal("100.0000") + 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 isinstance(p.reference_price, TickerPrice) + assert p.reference_price.price == Decimal("100.1000") + assert isinstance(p.settlement_mark_price, TickerPrice) + assert isinstance(p.liquidation_mark_price, TickerPrice) + assert isinstance(p.funding_rate, FundingRate) + # rate lands as Decimal, no binary-float drift. + assert p.funding_rate.rate == Decimal("0.0001") + assert isinstance(p.funding_rate.rate, Decimal) + assert p.funding_rate.next_funding_time_ms == 1700003600000 + assert p.ts_ms == 1700000000000 + + def test_ticker_marks_and_funding_absent_default_none(self) -> None: + frame = self._full_frame() + for key in ( + "reference_price", + "settlement_mark_price", + "liquidation_mark_price", + "funding_rate", + ): + frame["msg"].pop(key) + msg = MarginTickerMessage.model_validate(frame) + assert msg.msg.reference_price is None + assert msg.msg.settlement_mark_price is None + assert msg.msg.liquidation_mark_price is None + assert msg.msg.funding_rate is None + assert msg.seq is None + + def test_ticker_missing_ts_ms_raises(self) -> None: + frame = self._full_frame() + frame["msg"].pop("ts_ms") + with pytest.raises(ValidationError): + MarginTickerMessage.model_validate(frame) + + def test_funding_rate_no_float_drift(self) -> None: + # 0.65 as a JSON number must not commit to binary float at parse time. + fr = FundingRate.model_validate( + {"rate": 0.65, "next_funding_time_ms": 1, "ts_ms": 2} + ) + assert fr.rate == Decimal("0.65") + + +class TestTrade: + def test_happy(self) -> None: + frame = { + "type": "trade", + "sid": 3, + "msg": { + "trade_id": "11111111-1111-1111-1111-111111111111", + "market_ticker": "BTC-PERP", + "price": "100.0000", + "count": "5.00", + "taker_side": "ask", + "ts_ms": 1700000000000, + }, + } + msg = MarginTradeMessage.model_validate(frame) + assert msg.msg.taker_side == "ask" + assert msg.msg.count == Decimal("5.00") + assert isinstance(msg.msg.count, Decimal) + assert msg.msg.ts_ms == 1700000000000 + assert isinstance(msg.msg.ts_ms, int) + assert msg.seq is None + + def test_rejects_equities_yes_taker_side(self) -> None: + frame = { + "type": "trade", + "sid": 3, + "msg": { + "trade_id": "x", + "market_ticker": "BTC-PERP", + "price": "100.0000", + "count": "5.00", + "taker_side": "yes", + "ts_ms": 1700000000000, + }, + } + with pytest.raises(ValidationError): + MarginTradeMessage.model_validate(frame) + + +class TestFill: + def _frame(self) -> dict[str, Any]: + return { + "type": "fill", + "sid": 4, + "msg": { + "trade_id": "22222222-2222-2222-2222-222222222222", + "order_id": "33333333-3333-3333-3333-333333333333", + "market_ticker": "BTC-PERP", + "is_taker": True, + "side": "bid", + "ts_ms": 1700000000000, + "price": "100.0000", + "count": "5.00", + "fee_cost": "0.0500", + "post_position": "15.00", + }, + } + + def test_taker_fill_decimals(self) -> None: + msg = MarginFillMessage.model_validate(self._frame()) + assert msg.msg.is_taker is True + assert msg.msg.side == "bid" + assert msg.msg.fee_cost == Decimal("0.0500") + assert msg.msg.post_position == Decimal("15.00") + assert isinstance(msg.msg.fee_cost, Decimal) + assert msg.msg.client_order_id is None + assert msg.msg.subaccount is None + + def test_optional_fields_present(self) -> None: + frame = self._frame() + frame["msg"]["client_order_id"] = "coid-1" + frame["msg"]["subaccount"] = 7 + msg = MarginFillMessage.model_validate(frame) + assert msg.msg.client_order_id == "coid-1" + assert msg.msg.subaccount == 7 + + def test_missing_post_position_raises(self) -> None: + frame = self._frame() + frame["msg"].pop("post_position") + with pytest.raises(ValidationError): + MarginFillMessage.model_validate(frame) + + +class TestUserOrders: + def _frame(self) -> dict[str, Any]: + return { + "type": "user_order", + "sid": 5, + "msg": { + "order_id": "44444444-4444-4444-4444-444444444444", + "user_id": "55555555-5555-5555-5555-555555555555", + "client_order_id": "coid-2", + "ticker": "BTC-PERP", + "side": "ask", + "price": "100.0000", + "fill_count": "2.00", + "remaining_count": "8.00", + "created_ts_ms": 1700000000000, + }, + } + + def test_happy_with_group_and_stp(self) -> None: + frame = self._frame() + frame["msg"]["order_group_id"] = "og-1" + frame["msg"]["self_trade_prevention_type"] = "maker" + msg = MarginUserOrderMessage.model_validate(frame) + assert msg.msg.order_group_id == "og-1" + assert msg.msg.self_trade_prevention_type == "maker" + + def test_ticker_field_maps(self) -> None: + # Field is ``ticker`` (NOT ``market_ticker``). + msg = MarginUserOrderMessage.model_validate(self._frame()) + assert msg.msg.ticker == "BTC-PERP" + assert msg.msg.expiration_ts_ms is None + assert msg.msg.last_updated_ts_ms is None + + def test_missing_created_ts_ms_raises(self) -> None: + frame = self._frame() + frame["msg"].pop("created_ts_ms") + with pytest.raises(ValidationError): + MarginUserOrderMessage.model_validate(frame) + + def test_rejects_bad_stp(self) -> None: + frame = self._frame() + frame["msg"]["self_trade_prevention_type"] = "bogus" + with pytest.raises(ValidationError): + MarginUserOrderMessage.model_validate(frame) + + +class TestOrderGroup: + def test_limit_updated_with_contracts_limit(self) -> None: + frame = { + "type": "order_group_updates", + "sid": 6, + "seq": 9, + "msg": { + "event_type": "limit_updated", + "order_group_id": "og_123", + "contracts_limit_fp": "150.00", + "ts_ms": 1700000000000, + }, + } + msg = OrderGroupUpdatesMessage.model_validate(frame) + assert msg.msg.event_type == "limit_updated" + assert msg.msg.contracts_limit == Decimal("150.00") + assert isinstance(msg.msg.contracts_limit, Decimal) + + def test_deleted_without_contracts_limit(self) -> None: + frame = { + "type": "order_group_updates", + "sid": 6, + "seq": 10, + "msg": { + "event_type": "deleted", + "order_group_id": "og_123", + "ts_ms": 1700000000000, + }, + } + msg = OrderGroupUpdatesMessage.model_validate(frame) + assert msg.msg.contracts_limit is None + + def test_missing_seq_raises(self) -> None: + frame = { + "type": "order_group_updates", + "sid": 6, + "msg": { + "event_type": "created", + "order_group_id": "og_123", + "ts_ms": 1700000000000, + }, + } + with pytest.raises(ValidationError): + OrderGroupUpdatesMessage.model_validate(frame) + + def test_bad_event_type_raises(self) -> None: + frame = { + "type": "order_group_updates", + "sid": 6, + "seq": 9, + "msg": { + "event_type": "foo", + "order_group_id": "og_123", + "ts_ms": 1700000000000, + }, + } + with pytest.raises(ValidationError): + OrderGroupUpdatesMessage.model_validate(frame) + + +class TestDispatcherRegistration: + def test_message_models_registers_all_seven_types(self) -> None: + from kalshi.perps.ws.dispatch import MESSAGE_MODELS + + assert { + "orderbook_snapshot": MarginOrderbookSnapshotMessage, + "orderbook_delta": MarginOrderbookDeltaMessage, + "ticker": MarginTickerMessage, + "trade": MarginTradeMessage, + "fill": MarginFillMessage, + "user_order": MarginUserOrderMessage, + "order_group_updates": OrderGroupUpdatesMessage, + } == MESSAGE_MODELS + + +class TestSubscribeHelpers: + def test_six_typed_helpers_present(self) -> None: + from kalshi.perps.ws.client import PerpsWebSocket + + for name in ( + "subscribe_orderbook_delta", + "subscribe_ticker", + "subscribe_trade", + "subscribe_fill", + "subscribe_user_orders", + "subscribe_order_group", + ): + assert callable(getattr(PerpsWebSocket, name)) + + def test_out_of_scope_channels_have_no_helpers(self) -> None: + from kalshi.perps.ws.client import PerpsWebSocket + + for name in ( + "subscribe_market_positions", + "subscribe_multivariate", + "subscribe_multivariate_lifecycle", + "subscribe_communications", + "subscribe_market_lifecycle", + ): + assert not hasattr(PerpsWebSocket, name), ( + f"{name} has no perps counterpart and must not exist" + ) diff --git a/tests/perps/ws/test_sequence.py b/tests/perps/ws/test_sequence.py new file mode 100644 index 0000000..67887fc --- /dev/null +++ b/tests/perps/ws/test_sequence.py @@ -0,0 +1,59 @@ +"""Tests for PerpsSequenceTracker — perps channel set, reuse of the state machine.""" + +from __future__ import annotations + +from kalshi.perps.ws.sequence import ( + PERPS_SEQUENCED_CHANNELS, + PerpsSequenceTracker, + SequenceGap, +) + + +def test_sequenced_channels_set() -> None: + assert { + "orderbook_delta", + "orderbook_snapshot", + "order_group_updates", + } == PERPS_SEQUENCED_CHANNELS + + +def test_should_track_only_perps_sequenced_channels() -> None: + t = PerpsSequenceTracker() + assert t.should_track("orderbook_delta") + assert t.should_track("orderbook_snapshot") + assert t.should_track("order_group_updates") + # Non-sequenced perps channels pass through untracked. + for ch in ("ticker", "trade", "fill", "user_order", "user_orders"): + assert not t.should_track(ch) + + +def test_forward_gap_fires_for_order_group_updates() -> None: + gaps: list[SequenceGap] = [] + t = PerpsSequenceTracker() + # First seq seeds the watermark. + ok, gap = t.track_sync(1, 1, "order_group_updates") + assert ok and gap is None + # Jump from 1 -> 3 is a forward gap. + ok, gap = t.track_sync(1, 3, "order_group_updates") + assert not ok + assert gap is not None and gap.kind == "gap" + assert gap.expected == 2 and gap.received == 3 + gaps.append(gap) + assert gaps + + +def test_orderbook_delta_gap() -> None: + t = PerpsSequenceTracker() + t.track_sync(7, 5, "orderbook_delta") + ok, gap = t.track_sync(7, 9, "orderbook_delta") + assert not ok + assert gap is not None and gap.kind == "gap" + + +def test_non_sequenced_channel_never_gaps() -> None: + t = PerpsSequenceTracker() + # Even a wild seq jump on ticker is a pass-through (untracked). + ok, gap = t.track_sync(1, 1, "ticker") + assert ok and gap is None + ok, gap = t.track_sync(1, 99, "ticker") + assert ok and gap is None diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 46b1e28..ce79b50 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -35,10 +35,19 @@ from pydantic import NaiveDatetime as _PydNaive from pydantic.fields import FieldInfo -from kalshi._contract_map import CONTRACT_MAP, WS_CONTRACT_MAP, ContractEntry +from kalshi._contract_map import ( + CONTRACT_MAP, + PERPS_CONTRACT_MAP, + PERPS_SCM_CONTRACT_MAP, + WS_CONTRACT_MAP, + ContractEntry, +) from tests._contract_support import ( EXCLUSIONS, METHOD_ENDPOINT_MAP, + PERPS_EXCLUSIONS, + PERPS_METHOD_ENDPOINT_MAP, + PERPS_SCM_METHOD_ENDPOINT_MAP, Exclusion, MethodEndpointEntry, _resolve_path_params, @@ -260,6 +269,31 @@ def _load_spec() -> dict[str, Any]: return yaml.safe_load(f) +# Perps specs — the perps API ships its own REST + SCM/Klear OpenAPI documents. +PERPS_SPEC_FILE = Path(__file__).parent.parent / "specs" / "perps_openapi.yaml" +PERPS_SCM_SPEC_FILE = Path(__file__).parent.parent / "specs" / "perps_scm_openapi.yaml" + + +def _load_perps_spec() -> dict[str, Any]: + """Load the perps REST OpenAPI spec (``specs/perps_openapi.yaml``).""" + if yaml is None: + pytest.skip("pyyaml not installed. Run: uv sync --dev") + if not PERPS_SPEC_FILE.exists(): + pytest.skip("Perps OpenAPI spec not found. Run: uv run python scripts/sync_spec.py") + with open(PERPS_SPEC_FILE) as f: + return yaml.safe_load(f) + + +def _load_perps_scm_spec() -> dict[str, Any]: + """Load the perps SCM/Klear OpenAPI spec (``specs/perps_scm_openapi.yaml``).""" + if yaml is None: + pytest.skip("pyyaml not installed. Run: uv sync --dev") + if not PERPS_SCM_SPEC_FILE.exists(): + pytest.skip("Perps SCM OpenAPI spec not found. Run: uv run python scripts/sync_spec.py") + with open(PERPS_SCM_SPEC_FILE) as f: + return yaml.safe_load(f) + + def _resolve_ref(spec: dict[str, Any], ref: str) -> dict[str, Any]: """Resolve a $ref pointer in the OpenAPI spec.""" parts = ref.lstrip("#/").split("/") @@ -431,6 +465,8 @@ def _classify_drift( spec: dict[str, Any], spec_fields: dict[str, dict[str, Any]], model_class: type[PydanticBase], + *, + exclusions: dict[tuple[str, str], Exclusion] = EXCLUSIONS, ) -> tuple[list[str], list[str]]: """Compare spec fields against SDK model fields. @@ -439,8 +475,12 @@ def _classify_drift( Per-field skips are honored in this order: 1. ``entry.ignored_fields`` — per-contract-entry allowlist (legacy). - 2. ``EXCLUSIONS[(entry.sdk_model, field_name)]`` — typed, reasoned + 2. ``exclusions[(entry.sdk_model, field_name)]`` — typed, reasoned allowlist shared with the request-side drift checks (#157). + + ``exclusions`` defaults to the prediction-API ``EXCLUSIONS``; the perps + drift classes pass ``PERPS_EXCLUSIONS`` so perps deviations resolve against + their own allowlist. """ additive: list[str] = [] required_issues: list[str] = [] @@ -451,7 +491,7 @@ def _classify_drift( for spec_field_name in spec_fields: if spec_field_name in entry.ignored_fields: continue - if (entry.sdk_model, spec_field_name) in EXCLUSIONS: + if (entry.sdk_model, spec_field_name) in exclusions: continue if spec_field_name not in reverse_map: additive.append(f"Spec field '{spec_field_name}' has no SDK mapping") @@ -461,7 +501,7 @@ def _classify_drift( for req_field in required_fields: if req_field in entry.ignored_fields: continue - if (entry.sdk_model, req_field) in EXCLUSIONS: + if (entry.sdk_model, req_field) in exclusions: continue sdk_name = reverse_map.get(req_field) if sdk_name and sdk_name in model_class.model_fields: @@ -1198,6 +1238,49 @@ def _assert_params_match( } +# Perps request-body maps — keyed per spec so the shared ref strings (e.g. +# ``ApplySubaccountTransferRequest``) that collide across the perps and core +# specs resolve to the correct model. Foundation ships them empty; the +# per-resource perps issues append their ``spec-ref → perps-model-FQN`` entries. +PERPS_BODY_MODEL_MAP: dict[str, str] = { + # e.g. "#/components/schemas/CreateMarginOrderRequest": + # "kalshi.perps.models.orders.CreateMarginOrderRequest", + # ── perps orders (#391) ── + "#/components/schemas/CreateMarginOrderRequest": ( + "kalshi.perps.models.orders.CreateMarginOrderRequest" + ), + "#/components/schemas/DecreaseMarginOrderRequest": ( + "kalshi.perps.models.orders.DecreaseMarginOrderRequest" + ), + "#/components/schemas/AmendMarginOrderRequest": ( + "kalshi.perps.models.orders.AmendMarginOrderRequest" + ), + # ── perps order_groups (#392) ── + "#/components/schemas/CreateOrderGroupRequest": ( + "kalshi.perps.models.order_groups.CreateOrderGroupRequest" + ), + "#/components/schemas/UpdateOrderGroupLimitRequest": ( + "kalshi.perps.models.order_groups.UpdateOrderGroupLimitRequest" + ), + # ── perps transfers (#396) ── + "#/components/schemas/IntraExchangeInstanceTransferRequest": ( + "kalshi.perps.models.transfers.IntraExchangeInstanceTransferRequest" + ), + "#/components/schemas/ApplySubaccountTransferRequest": ( + "kalshi.perps.models.transfers.ApplySubaccountTransferRequest" + ), +} + +PERPS_SCM_BODY_MODEL_MAP: dict[str, str] = { + # ── perps SCM/Klear auth (#399) ── + "#/components/schemas/LogInRequest": "kalshi.perps.klear.models.auth.LogInRequest", + # ── perps SCM/Klear margin (#400) ── + "#/components/schemas/WithdrawSettlementBalanceRequest": ( + "kalshi.perps.klear.models.margin.WithdrawSettlementBalanceRequest" + ), +} + + def _get_model_class_from_fqn(fqn: str) -> type[PydanticBase]: module_name, _, cls_name = fqn.rpartition(".") module = importlib.import_module(module_name) @@ -1285,6 +1368,7 @@ def _check_model_drift( spec_schema: dict[str, Any], errors: list[str], context: str | None = None, + exclusions: dict[tuple[str, str], Exclusion] = EXCLUSIONS, ) -> None: """Compare SDK model wire names to spec schema properties, append errors. @@ -1292,16 +1376,19 @@ def _check_model_drift( top-level body model AND on any nested ``BaseModel`` field one level down (issue #52). ``context`` prefixes nested-pair errors so the failure message points at the parent field that owns the nested model. + + ``exclusions`` defaults to the prediction-API ``EXCLUSIONS``; the perps body + drift class passes ``PERPS_EXCLUSIONS``. """ spec_props = set(spec_schema.get("properties", {}).keys()) sdk_wire_names = _model_aliases(model_cls) # ADD drift: spec has property SDK model doesn't emit missing = spec_props - sdk_wire_names - missing_unallowed = {p for p in missing if (model_fqn, p) not in EXCLUSIONS} + missing_unallowed = {p for p in missing if (model_fqn, p) not in exclusions} # REMOVE drift: SDK emits wire name spec doesn't have extra = sdk_wire_names - spec_props - extra_unallowed = {p for p in extra if (model_fqn, p) not in EXCLUSIONS} + extra_unallowed = {p for p in extra if (model_fqn, p) not in exclusions} prefix = f"[nested {context} -> {model_fqn}] " if context else "" if missing_unallowed: @@ -1625,3 +1712,252 @@ def test_nested_body_drift_detects_phantom_field_on_nested_model() -> None: "the nested model. _check_model_drift / _iter_nested_body_models is " "no longer recursing into nested BaseModel fields. errors=" + repr(errors) ) + + +# ════════════════════════════════════════════════════════════════════════════ +# Perps (margin) API drift tests +# +# Structurally identical to the prediction-API drift classes above, but each +# perps entry resolves against its own spec (``specs/perps_openapi.yaml`` for the +# REST surface, ``specs/perps_scm_openapi.yaml`` for the SCM/Klear surface) and +# consults ``PERPS_EXCLUSIONS`` instead of ``EXCLUSIONS``. The shared spec-walking +# helpers (``_resolve_path_params``, ``_resolve_request_body_schema``, +# ``_get_schema_fields``, ``_classify_drift``, ``_check_model_drift``) are reused +# unchanged — they already take the spec dict (and now the ``exclusions`` map) as +# parameters, so there is no drift-logic duplication. +# +# Foundation (#388) ships the perps maps empty, so these classes collect a single +# skipped placeholder until the per-resource perps issues append entries. +# ════════════════════════════════════════════════════════════════════════════ + + +def _perps_param_ids(entries: list[MethodEndpointEntry]) -> tuple[list[Any], list[str]]: + """Return ``(argvalues, ids)`` for parametrize; a skip-placeholder when empty.""" + if entries: + return list(entries), [e.sdk_method.rsplit(".", 1)[1] for e in entries] + return [None], ["none_registered"] + + +def _perps_contract_ids(entries: list[ContractEntry]) -> tuple[list[Any], list[str]]: + """Return ``(argvalues, ids)`` for response-side parametrize; skip when empty.""" + if entries: + return list(entries), [e.sdk_model.rsplit(".", 1)[1] for e in entries] + return [None], ["none_registered"] + + +def _assert_perps_params_match( + entry: MethodEndpointEntry, + spec: dict[str, Any], + *, + async_: bool, +) -> None: + """Param-drift assertion for one perps GET/DELETE entry (mirrors the core check).""" + sdk_fqn = entry.sdk_method + if async_: + parts = sdk_fqn.split(".") + parts[-2] = f"Async{parts[-2]}" + sdk_fqn = ".".join(parts) + module_name = ".".join(parts[:-2]) + cls_name = parts[-2] + module = importlib.import_module(module_name) + assert hasattr(module, cls_name), f"Missing async sibling {cls_name} in {module_name}" + + sdk_params = _signature_params(sdk_fqn) + sdk_params.discard("extra_headers") + spec_params_list = _resolve_path_params(spec, entry.path_template, entry.http_method) + spec_params = {p["name"] for p in spec_params_list if p.get("in") in ("query", "path")} + spec_params |= _path_params_from_template(entry.path_template) + + lookup_fqn = entry.sdk_method + missing = spec_params - sdk_params + missing_unallowed = {p for p in missing if (lookup_fqn, p) not in PERPS_EXCLUSIONS} + extra = sdk_params - spec_params + extra_unallowed = {p for p in extra if (lookup_fqn, p) not in PERPS_EXCLUSIONS} + + errors: list[str] = [] + if missing_unallowed: + errors.append(f"[ADD drift] spec has params SDK missing: {sorted(missing_unallowed)}") + if extra_unallowed: + errors.append(f"[REMOVE drift] SDK has params spec doesn't: {sorted(extra_unallowed)}") + if errors: + pytest.fail( + f"{sdk_fqn} <-> {entry.http_method} {entry.path_template}\n" + + "\n".join(errors) + + f"\n(Allowlist via PERPS_EXCLUSIONS[({lookup_fqn!r}, '...')])" + ) + + +def _assert_perps_body_match( + entry: MethodEndpointEntry, + spec: dict[str, Any], + body_map: dict[str, str], +) -> None: + """Body-drift assertion for one perps body entry (mirrors the core check).""" + assert entry.request_body_schema is not None + model_fqn = body_map.get(entry.request_body_schema) + assert model_fqn is not None, ( + f"No request model registered in the perps body map for " + f"{entry.request_body_schema!r}. Add the mapping." + ) + model_cls = _get_model_class_from_fqn(model_fqn) + schema = _resolve_request_body_schema(spec, entry.path_template, entry.http_method) + assert schema is not None, ( + f"Spec has no body schema for {entry.http_method} {entry.path_template}" + ) + errors: list[str] = [] + _check_model_drift( + model_cls=model_cls, + model_fqn=model_fqn, + spec_schema=schema, + errors=errors, + exclusions=PERPS_EXCLUSIONS, + ) + for field_name, nested_cls, nested_schema in _iter_nested_body_models(model_cls, schema, spec): + nested_fqn = f"{nested_cls.__module__}.{nested_cls.__qualname__}" + _check_model_drift( + model_cls=nested_cls, + model_fqn=nested_fqn, + spec_schema=nested_schema, + errors=errors, + context=f"{model_fqn}.{field_name}", + exclusions=PERPS_EXCLUSIONS, + ) + if errors: + pytest.fail(f"{model_fqn} <-> {entry.request_body_schema}\n" + "\n".join(errors)) + + +def _assert_perps_response_drift(entry: ContractEntry, spec: dict[str, Any]) -> None: + """Response-side drift assertion for one perps contract entry.""" + spec_fields = _get_schema_fields(spec, entry.spec_schema) + model_class = _get_sdk_model_class(entry.sdk_model) + additive, required_issues = _classify_drift( + entry, spec, spec_fields, model_class, exclusions=PERPS_EXCLUSIONS + ) + problems = additive + required_issues + if problems: + pytest.fail( + f"Perps spec drift in {entry.sdk_model}:\n" + + "\n".join(f" - {p}" for p in problems) + ) + + +_PERPS_PARAM_VALS, _PERPS_PARAM_IDS = _perps_param_ids( + [e for e in PERPS_METHOD_ENDPOINT_MAP if e.http_method in ("GET", "DELETE")] +) +_PERPS_BODY_VALS, _PERPS_BODY_IDS = _perps_param_ids( + [e for e in PERPS_METHOD_ENDPOINT_MAP if e.request_body_schema is not None] +) +_PERPS_RESP_VALS, _PERPS_RESP_IDS = _perps_contract_ids(PERPS_CONTRACT_MAP) +_PERPS_SCM_PARAM_VALS, _PERPS_SCM_PARAM_IDS = _perps_param_ids( + [e for e in PERPS_SCM_METHOD_ENDPOINT_MAP if e.http_method in ("GET", "DELETE")] +) +_PERPS_SCM_BODY_VALS, _PERPS_SCM_BODY_IDS = _perps_param_ids( + [e for e in PERPS_SCM_METHOD_ENDPOINT_MAP if e.request_body_schema is not None] +) +_PERPS_SCM_RESP_VALS, _PERPS_SCM_RESP_IDS = _perps_contract_ids(PERPS_SCM_CONTRACT_MAP) + + +@pytest.mark.parametrize("entry", _PERPS_PARAM_VALS, ids=_PERPS_PARAM_IDS) +class TestPerpsRequestParamDrift: + """Verify perps REST resource signatures match ``specs/perps_openapi.yaml``.""" + + spec: dict[str, Any] + + @pytest.fixture(autouse=True) + def _load(self) -> None: + self.spec = _load_perps_spec() + + def test_sync_params_match_spec(self, entry: MethodEndpointEntry | None) -> None: + if entry is None: + pytest.skip("no perps REST GET/DELETE endpoints registered yet") + _assert_perps_params_match(entry, self.spec, async_=False) + + def test_async_params_match_spec(self, entry: MethodEndpointEntry | None) -> None: + if entry is None: + pytest.skip("no perps REST GET/DELETE endpoints registered yet") + _assert_perps_params_match(entry, self.spec, async_=True) + + +@pytest.mark.parametrize("entry", _PERPS_BODY_VALS, ids=_PERPS_BODY_IDS) +class TestPerpsRequestBodyDrift: + """Verify perps REST request-body models match ``specs/perps_openapi.yaml``.""" + + spec: dict[str, Any] + + @pytest.fixture(autouse=True) + def _load(self) -> None: + self.spec = _load_perps_spec() + + def test_body_properties_match_spec(self, entry: MethodEndpointEntry | None) -> None: + if entry is None: + pytest.skip("no perps REST body endpoints registered yet") + _assert_perps_body_match(entry, self.spec, PERPS_BODY_MODEL_MAP) + + +@pytest.mark.parametrize("entry", _PERPS_RESP_VALS, ids=_PERPS_RESP_IDS) +class TestPerpsSpecDrift: + """Verify perps REST response models match ``specs/perps_openapi.yaml``.""" + + spec: dict[str, Any] + + @pytest.fixture(autouse=True) + def _load(self) -> None: + self.spec = _load_perps_spec() + + def test_response_drift(self, entry: ContractEntry | None) -> None: + if entry is None: + pytest.skip("no perps REST response models registered yet") + _assert_perps_response_drift(entry, self.spec) + + +@pytest.mark.parametrize("entry", _PERPS_SCM_PARAM_VALS, ids=_PERPS_SCM_PARAM_IDS) +class TestPerpsScmRequestParamDrift: + """Verify perps SCM/Klear signatures match ``specs/perps_scm_openapi.yaml``.""" + + spec: dict[str, Any] + + @pytest.fixture(autouse=True) + def _load(self) -> None: + self.spec = _load_perps_scm_spec() + + def test_sync_params_match_spec(self, entry: MethodEndpointEntry | None) -> None: + if entry is None: + pytest.skip("no perps SCM GET/DELETE endpoints registered yet") + _assert_perps_params_match(entry, self.spec, async_=False) + + def test_async_params_match_spec(self, entry: MethodEndpointEntry | None) -> None: + if entry is None: + pytest.skip("no perps SCM GET/DELETE endpoints registered yet") + _assert_perps_params_match(entry, self.spec, async_=True) + + +@pytest.mark.parametrize("entry", _PERPS_SCM_BODY_VALS, ids=_PERPS_SCM_BODY_IDS) +class TestPerpsScmRequestBodyDrift: + """Verify perps SCM/Klear request-body models match ``specs/perps_scm_openapi.yaml``.""" + + spec: dict[str, Any] + + @pytest.fixture(autouse=True) + def _load(self) -> None: + self.spec = _load_perps_scm_spec() + + def test_body_properties_match_spec(self, entry: MethodEndpointEntry | None) -> None: + if entry is None: + pytest.skip("no perps SCM body endpoints registered yet") + _assert_perps_body_match(entry, self.spec, PERPS_SCM_BODY_MODEL_MAP) + + +@pytest.mark.parametrize("entry", _PERPS_SCM_RESP_VALS, ids=_PERPS_SCM_RESP_IDS) +class TestPerpsScmSpecDrift: + """Verify perps SCM/Klear response models match ``specs/perps_scm_openapi.yaml``.""" + + spec: dict[str, Any] + + @pytest.fixture(autouse=True) + def _load(self) -> None: + self.spec = _load_perps_scm_spec() + + def test_response_drift(self, entry: ContractEntry | None) -> None: + if entry is None: + pytest.skip("no perps SCM response models registered yet") + _assert_perps_response_drift(entry, self.spec) diff --git a/tests/test_examples_importable.py b/tests/test_examples_importable.py new file mode 100644 index 0000000..b902f69 --- /dev/null +++ b/tests/test_examples_importable.py @@ -0,0 +1,35 @@ +"""Import-check every ``examples/perps_*.py`` so a broken example reds CI. + +Imports each example module (which does NOT execute ``main()`` — that is guarded +by ``if __name__ == "__main__"``) to verify it parses, its imports resolve, and +the perps API surface it references still exists. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +_EXAMPLES_DIR = Path(__file__).parent.parent / "examples" +_PERPS_EXAMPLES = sorted(_EXAMPLES_DIR.glob("perps_*.py")) + + +@pytest.mark.parametrize("path", _PERPS_EXAMPLES, ids=[p.stem for p in _PERPS_EXAMPLES]) +def test_example_imports(path: Path) -> None: + spec = importlib.util.spec_from_file_location(path.stem, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) # importing must not call main() + assert hasattr(module, "main") + + +def test_perps_examples_present() -> None: + # Guards against the glob silently matching nothing (e.g. a directory move). + names = {p.name for p in _PERPS_EXAMPLES} + assert names == { + "perps_create_order.py", + "perps_stream_ticker.py", + "perps_balance_risk.py", + }