diff --git a/CHANGELOG.md b/CHANGELOG.md index d1e04cc..8bb4e60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,62 @@ All notable changes to kalshi-sdk will be documented in this file. +## 13.0.0 — 2026-08-28 + +Reconciles upstream OpenAPI **3.28.0 → 3.29.0**, plus matching perps, Klear, +and AsyncAPI updates, after nightly contract failures (Closes #507, Closes #508). +**Breaking** for constructors of `Fill`, `MarketPosition`, `Settlement`, +and the WS `FillPayload` / `UserOrdersPayload` that omit the new required +`exchange_index`. + +### Changed (breaking) + +- **`Fill.exchange_index`**, **`MarketPosition.exchange_index`**, and + **`Settlement.exchange_index`** (`int`, required) — which exchange shard + produced the row. Live list callers are unaffected; tests/mocks that + construct these models must pass the shard (typically `0`). + +### Added + +- **`live_data.weather(city, *, from_ts, to, last_sec, detailed)`** — + `GET /live_data/weather/{city}`. `from_ts` is the spec `from` query + (unix milliseconds); named to avoid the Python keyword. +- **`portfolio.target_balance_allocation()`** / + **`portfolio.set_target_balance_allocation(...)`** — + get/replace per-shard sweepable-balance targets. +- **`orders.cancel_all_v2(*, subaccount)`** — + `DELETE /portfolio/events/orders` (up to 10,000 resting orders; 204). +- Optional **`GetApiKeysResponse.api_key_region_expiration_ts`**. +- Optional **`IncentiveProgram.max_reward_per_account`**. +- **Perps** exit triggers on a position (sync + async): + - `portfolio.cross_exit_triggers` / `set_cross_exit_trigger` / + `cancel_cross_exit_triggers` + - `portfolio.update_cross_exit_trigger` / `cancel_cross_exit_trigger` + - `portfolio.isolated_exit_triggers` / `set_isolated_exit_trigger` / + `cancel_isolated_exit_triggers` +- **Perps** `orders.cancel_all(*, subaccount)` — + `DELETE /margin/orders` (up to 10,000 resting margin orders; 204). +- **Perps FCM** optional `asset_class` on `risk_controls` / + `update_risk_controls` / `delete_risk_controls` (mutually exclusive + with `market_ticker`). +- **Klear** `margin.settlement_prices(asset_class, settlement_time)` and + `margin.estimate_maintenance_margin(...)`. +- **Klear** `MarginReport.snapshot_ts` (optional) and report types + `maintenance_margin` / `maintenance_margin_aggregate`. +- **WS** required `exchange_index` on `FillPayload` and `UserOrdersPayload`. + +### Spec notes + +- Core OpenAPI `info.version` **3.29.0** (paths 95; 108 operations; 107 + mapped). Still unimplemented on the core client: + `POST /portfolio/intra_exchange_instance_transfer` (use + `PerpsClient.transfers.transfer_instance()`). +- AsyncAPI still 14 channels; fill/user-order payloads gained required + `exchange_index`. +- Perps OpenAPI: 38 → 47 operations (exit triggers + cancel-all). +- Perps SCM OpenAPI: 17 → 19 operations. Still unimplemented: + `GET /margin/large_trader_positions` (surveillance). + ## 12.0.0 — 2026-08-16 Reconciles upstream OpenAPI **3.27.0 → 3.28.0**, plus additive perps FCM diff --git a/CLAUDE.md b/CLAUDE.md index c3d007c..f358916 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,13 +116,13 @@ tests/ ## Testing -- pytest + pytest-asyncio + respx (httpx mock); ~1920 tests across unit + contract drift suites. +- pytest + pytest-asyncio + respx (httpx mock); ~4330 tests across unit + contract drift suites. - Use `respx.mock` for HTTP mocking. Generate test RSA keys via conftest.py fixtures. - New function → write a test. Bug fix → write a regression test. New error path → write a test that triggers it. ## API Reference -- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.28.0, 104 operations; 103 mapped in the core SDK — `POST /portfolio/intra_exchange_instance_transfer` is implemented on `PerpsClient.transfers.transfer_instance` and left unimplemented on the core client) +- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.29.0, 108 operations; 107 mapped in the core SDK — `POST /portfolio/intra_exchange_instance_transfer` is implemented on `PerpsClient.transfers.transfer_instance` and left unimplemented on the core client) - AsyncAPI spec: https://docs.kalshi.com/asyncapi.yaml (14 WebSocket channels; 11 typed `subscribe_*` + escape-hatch) - Base URL: https://api.elections.kalshi.com/trade-api/v2 - Demo URL: https://demo-api.kalshi.co/trade-api/v2 diff --git a/README.md b/README.md index 536a2d0..e52920b 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) predi [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Type checked: mypy strict](https://img.shields.io/badge/mypy-strict-blue.svg)](https://mypy.readthedocs.io/) -- **Full coverage** of the Kalshi REST API (103 mapped of 104 operations across 19 resources, OpenAPI v3.28.0) and WebSocket API (11 typed `subscribe_*` channels + 2 escape-hatch). -- **Perps (margin) API**: standalone `PerpsClient` / `AsyncPerpsClient` + `PerpsWebSocket` for the perpetual-futures exchange (38 REST operations, 6 WS channels), plus a `KlearClient` for the Self-Clearing-Member "Klear" settlement API (16 operations). See [Perps (margin) trading](#perps-margin-trading). +- **Full coverage** of the Kalshi REST API (107 mapped of 108 operations across 19 resources, OpenAPI v3.29.0) and WebSocket API (11 typed `subscribe_*` channels + 2 escape-hatch). +- **Perps (margin) API**: standalone `PerpsClient` / `AsyncPerpsClient` + `PerpsWebSocket` for the perpetual-futures exchange (47 REST operations, 6 WS channels), plus a `KlearClient` for the Self-Clearing-Member "Klear" settlement API (18 operations). See [Perps (margin) trading](#perps-margin-trading). - **FIX protocol**: an async-first FIX engine (FIXT.1.1 / FIX50SP2) for both products — order-entry, drop-copy, market-data, post-trade (prediction), and RFQ (prediction) sessions (plus order-group management over the order-entry session) with typed message models, sequence recovery, and order-book / settlement reassembly. `from kalshi import FixClient` / `MarginFixClient`. See [FIX protocol](#fix-protocol-low-latency-trading). -- **V2 event-market orders**: `create_v2` / `amend_v2` / `decrease_v2` / `cancel_v2` plus batched variants on `/portfolio/events/orders/*` — the only order-write surface. +- **V2 event-market orders**: `create_v2` / `amend_v2` / `decrease_v2` / `cancel_v2` / `cancel_all_v2` plus batched variants on `/portfolio/events/orders/*` — the only order-write surface. - **Funding & cost introspection**: `portfolio.deposits()`, `portfolio.withdrawals()`, `account.endpoint_costs()`. - **Sync and async** clients sharing one transport — no thread-pool wrapping. - **Typed end-to-end**: Pydantic v2 models, `mypy --strict` clean, ships `py.typed`. `Literal` types on fixed-enum kwargs. diff --git a/ROADMAP.md b/ROADMAP.md index 541ace1..7729b6b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,12 @@ ## Shipped +- **v13.0.0 (2026-08-28)** — Spec-drift reconcile (#507 / #508). OpenAPI + 3.28.0 → 3.29.0. **Breaking:** `Fill` / `MarketPosition` / `Settlement` + require `exchange_index`. Additive: weather index, target-balance + allocation, cancel-all (event + margin), perps exit triggers, FCM + `asset_class` IM caps, Klear settlement prices + maintenance-margin + estimate. - **v12.0.0 (2026-08-16)** — Spec-drift reconcile (#503). OpenAPI 3.27.0 → 3.28.0. **Breaking:** `TotalRestingOrderValue` requires `resting_order_value_breakdown`. Additive: `exchange_index` query on diff --git a/docs/index.md b/docs/index.md index 654d05d..967adc6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,10 +3,10 @@ A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) prediction markets API. -- **Full REST coverage** — 103 mapped of 104 operations across 19 resources - (OpenAPI v3.28.0), every kwarg drift-tested against the spec. +- **Full REST coverage** — 107 mapped of 108 operations across 19 resources + (OpenAPI v3.29.0), every kwarg drift-tested against the spec. - **V2 event-market orders** — new `create_v2` / `amend_v2` / `decrease_v2` / - `cancel_v2` family on `/portfolio/events/orders/*`. Legacy `/portfolio/orders` + `cancel_v2` / `cancel_all_v2` family on `/portfolio/events/orders/*`. Legacy `/portfolio/orders` keeps working; deprecation no earlier than May 6, 2026. - **Funding + cost introspection** — `portfolio.deposits()`, `portfolio.withdrawals()`, `portfolio.intra_exchange_transfers()`, @@ -16,9 +16,9 @@ markets API. 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 (38 REST operations, 6 WS) + `PerpsWebSocket` for the perpetual-futures exchange (47 REST operations, 6 WS channels), and a `KlearClient` for the Self-Clearing-Member settlement API - (16 operations, Bearer token auth). See [Perps](perps.md). + (18 operations, Bearer token auth). See [Perps](perps.md). - **FIX protocol** — a hand-rolled, async-first FIX engine (FIXT.1.1 / FIX50SP2) for both products: order-entry, drop-copy, market-data, post-trade (prediction), and RFQ (prediction) sessions — plus order-group management over the order-entry diff --git a/docs/migration.md b/docs/migration.md index 8df737e..c92bc9a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,5 +1,43 @@ # Migration +## v12.0 → v13.0.0 + +Reconciles upstream OpenAPI **3.28.0 → 3.29.0**, plus matching perps, Klear, +and AsyncAPI updates (Closes #507, Closes #508). **Breaking** only for code +that constructs `Fill`, `MarketPosition`, or `Settlement` without +`exchange_index`. + +### Response model field changes + +- **`Fill.exchange_index`**, **`MarketPosition.exchange_index`**, + **`Settlement.exchange_index`** — required `int`. Live + `orders.fills()` / `portfolio.positions()` / `portfolio.settlements()` + callers are unaffected. +- **WS** `FillPayload.exchange_index` and `UserOrdersPayload.exchange_index` + — required `int` (same constructor impact on fixtures). + +```python +# Before (constructors / test fixtures): +# Fill(..., outcome_side="yes", book_side="bid") + +# After: +Fill(..., outcome_side="yes", book_side="bid", exchange_index=0) +``` + +### Added (non-breaking) + +- `live_data.weather(city, *, from_ts, to, last_sec, detailed)` +- `portfolio.target_balance_allocation()` / + `set_target_balance_allocation(...)` +- `orders.cancel_all_v2(*, subaccount)` +- Perps exit triggers on `perps.portfolio.*` +- Perps `orders.cancel_all(*, subaccount)` +- Perps FCM `asset_class=` on IM-cap methods +- Klear `settlement_prices()` / `estimate_maintenance_margin()` + +See the [changelog](https://github.com/TexasCoding/kalshi-python-sdk/blob/main/CHANGELOG.md) +for the full list. + ## v11.0 → v12.0.0 Reconciles upstream OpenAPI **3.27.0 → 3.28.0**, plus additive perps FCM diff --git a/docs/perps.md b/docs/perps.md index c659e7b..eb10e40 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -60,9 +60,9 @@ async with AsyncPerpsClient.from_env(demo=True) as perps: |---|---| | `exchange` | `status()`, `enabled()` (per-member access gate), `risk_parameters()` | | `markets` | `list()`, `get()`, `orderbook()`, `candlesticks()` | -| `orders` | `create()`, `get()`, `list()` / `list_all()`, `cancel()`, `decrease()`, `amend()` | +| `orders` | `create()`, `get()`, `list()` / `list_all()`, `cancel()`, `cancel_all()`, `decrease()`, `amend()` | | `order_groups` | `list()`, `get()`, `create()`, `delete()`, `reset()`, `trigger()`, `update_limit()` | -| `portfolio` | `positions()`, `fills()` / `fills_all()`, `trades()` / `trades_all()` | +| `portfolio` | `positions()`, `fills()` / `fills_all()`, `trades()` / `trades_all()`, cross/isolated exit triggers | | `margin` | `balance()`, `risk()`, `notional_risk_limit()`, `fee_tiers()`, `api_limits()` | | `funding` | `rate_estimate()`, `historical_rates()`, `history()` | | `transfers` | `transfer_instance()`, `create_subaccount()`, `transfer_subaccount()` | @@ -92,6 +92,26 @@ perps.fcm.update_risk_controls( market_ticker="BTC-PERP", ) perps.fcm.delete_risk_controls(subtrader_id="user_desk1", market_ticker="BTC-PERP") +# asset_class is mutually exclusive with market_ticker +perps.fcm.risk_controls(subtrader_id="user_desk1", asset_class="Crypto") +``` + +Exit triggers (stop-loss / take-profit / trailing) sit on a position slot: + +```python +from decimal import Decimal +from kalshi.perps.models.portfolio import SetCrossExitTriggerRequest + +listed = perps.portfolio.cross_exit_triggers("BTC-PERP") +trig = perps.portfolio.set_cross_exit_trigger( + "BTC-PERP", + request=SetCrossExitTriggerRequest( + kind="bracket", + stop_loss_price=Decimal("50000.0000"), + take_profit_price=Decimal("70000.0000"), + ), +) +perps.portfolio.cancel_cross_exit_trigger("BTC-PERP", trig.id) ``` ## Value types & timestamps @@ -218,6 +238,11 @@ next-settlement estimates keyed by asset class. (The singular `active_obligation()` / `settlement_estimate()` endpoints were removed upstream in the v11.0.0 reconcile.) +`klear.margin.settlement_prices(asset_class="Crypto", settlement_time=...)` +returns a ticker → centicents map at a settlement cycle. +`klear.margin.estimate_maintenance_margin(asset_class="Crypto", positions=[...])` +margins a hypothetical portfolio. + When an `ObligationEntry` inline detail array is capped at 1000 rows, the matching `*_truncated` flag is set; page the full set via: diff --git a/docs/resources/live-data.md b/docs/resources/live-data.md index 2e14038..371d4e3 100644 --- a/docs/resources/live-data.md +++ b/docs/resources/live-data.md @@ -14,6 +14,7 @@ Public — no auth required. | `get_event(event_ticker, *, range=None)` | `GET /live_data/events/{event_ticker}` | | `batch(milestone_ids, *, include_player_stats=None)` | `GET /live_data/batch` | | `game_stats(milestone_id)` | `GET /live_data/milestone/{milestone_id}/game_stats` | +| `weather(city, *, from_ts, to, last_sec, detailed)` | `GET /live_data/weather/{city}` | | `get_typed(milestone_type, milestone_id)` | `GET /live_data/{type}/milestone/{milestone_id}` (legacy) | ## Get one milestone's live data @@ -67,6 +68,20 @@ else: Other milestone types return `pbp=None`. Each period's `events` is a list of loose dicts (no fixed play schema upstream). +## Weather index + +```python +idx = client.live_data.weather("miami", last_sec=3600, detailed=True) +print(idx.city, idx.units, idx.config_version) +for point in idx.timeseries: + print(point.t, point.status, point.v) +``` + +`from_ts` is the spec `from` query (unix milliseconds, inclusive). Named +`from_ts` to avoid the Python keyword; the wire key is still `from`. +`last_sec` is mutually exclusive with `from_ts`/`to` per spec. `detailed=True` +attaches per-station audit readings on every point. + ## Legacy `get_typed` ```python diff --git a/docs/resources/orders.md b/docs/resources/orders.md index 3064574..4f73dd9 100644 --- a/docs/resources/orders.md +++ b/docs/resources/orders.md @@ -15,6 +15,7 @@ Reads stay on `/portfolio/orders/*`. | `create_v2(*, request)` | `POST /portfolio/events/orders` | **never** — see [Retries & idempotency](../retries.md) | | `batch_create_v2(*, request)` | `POST /portfolio/events/orders/batched` | never | | `cancel_v2(order_id, *, subaccount, exchange_index, market_ticker)` | `DELETE /portfolio/events/orders/{order_id}` | never | +| `cancel_all_v2(*, subaccount)` | `DELETE /portfolio/events/orders` | never | | `batch_cancel_v2(*, request)` | `DELETE /portfolio/events/orders/batched` | never | | `amend_v2(order_id, *, request, subaccount)` | `POST /portfolio/events/orders/{order_id}/amend` | never | | `decrease_v2(order_id, *, request, subaccount)` | `POST /portfolio/events/orders/{order_id}/decrease` | never | diff --git a/docs/resources/portfolio.md b/docs/resources/portfolio.md index d7f8e92..0f1fd2c 100644 --- a/docs/resources/portfolio.md +++ b/docs/resources/portfolio.md @@ -16,6 +16,8 @@ Auth required throughout. | `withdrawals(*, limit, cursor)` / `withdrawals_all(*, limit, max_pages)` | `GET /portfolio/withdrawals` | | `intra_exchange_transfers(...)` / `intra_exchange_transfers_all(...)` | `GET /portfolio/intra_exchange_instance_transfers` | | `get_intra_exchange_transfer(transfer_id)` | `GET /portfolio/intra_exchange_instance_transfers/{transfer_id}` | +| `target_balance_allocation()` | `GET /portfolio/target_balance_allocation` | +| `set_target_balance_allocation(*, allocations)` | `POST /portfolio/target_balance_allocation` | `balance()`, `positions()` / `positions_all()`, `settlements()` / `settlements_all()`, and `fills()` / `fills_all()` all take an optional @@ -220,6 +222,25 @@ print(t.status, t.created_ts) `IntraExchangeInstanceTransfer.amount` is a fixed-point dollar `DollarDecimal` (not the integer centicents used on the POST create body). +## Target balance allocation + +Per-shard sweepable-balance targets. POST is never retried. + +```python +from kalshi import TargetBalanceAllocationInput + +current = client.portfolio.target_balance_allocation() +client.portfolio.set_target_balance_allocation( + allocations=[TargetBalanceAllocationInput(exchange_index=0, percent=100)] +) +``` + +Percents are integers 0–100. At most 101 allocation rows. + +`Fill`, `MarketPosition`, and `Settlement` each carry a required +`exchange_index` as of OpenAPI 3.29.0 (SDK v13.0.0). Live list callers +are unaffected; constructors/fixtures must pass it. + ## Position fields `MarketPosition` and `EventPosition` use the standard `_dollars` / `_fp` diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 4c9cfbc..3a2b9bd 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -107,6 +107,8 @@ GetSubaccountBalancesResponse, GetSubaccountNettingResponse, GetTagsForSeriesCategoriesResponse, + GetTargetBalanceAllocationResponse, + GetWeatherIndexResponse, HistoricalCutoff, IncentiveProgram, IncentiveProgramStatusLiteral, @@ -152,6 +154,7 @@ SelfTradePreventionTypeLiteral, Series, SeriesFeeChange, + SetTargetBalanceAllocationRequest, Settlement, SettlementSource, SettlementStatusLiteral, @@ -161,6 +164,8 @@ SubaccountBalance, SubaccountNettingConfig, SubaccountTransfer, + TargetBalanceAllocation, + TargetBalanceAllocationInput, TickerPair, TimeInForceLiteral, TotalRestingOrderValue, @@ -169,6 +174,8 @@ UpdateSubaccountNettingRequest, UserDataTimestamp, UserFilterLiteral, + WeatherIndexPoint, + WeatherIndexStationReading, WeeklySchedule, Withdrawal, ) @@ -285,6 +292,8 @@ "GetSubaccountBalancesResponse", "GetSubaccountNettingResponse", "GetTagsForSeriesCategoriesResponse", + "GetTargetBalanceAllocationResponse", + "GetWeatherIndexResponse", "HistoricalCutoff", "IncentiveProgram", "IncentiveProgramStatusLiteral", @@ -359,6 +368,7 @@ "SelfTradePreventionTypeLiteral", "Series", "SeriesFeeChange", + "SetTargetBalanceAllocationRequest", "Settlement", "SettlementSource", "SettlementStatusLiteral", @@ -369,6 +379,8 @@ "SubaccountBalance", "SubaccountNettingConfig", "SubaccountTransfer", + "TargetBalanceAllocation", + "TargetBalanceAllocationInput", "TickerPair", "TimeInForceLiteral", "TotalRestingOrderValue", @@ -377,8 +389,10 @@ "UpdateSubaccountNettingRequest", "UserDataTimestamp", "UserFilterLiteral", + "WeatherIndexPoint", + "WeatherIndexStationReading", "WeeklySchedule", "Withdrawal", ] -__version__ = "12.0.0" +__version__ = "13.0.0" diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index 6269b34..69c9331 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -72,6 +72,22 @@ class ContractEntry: sdk_model="kalshi.models.portfolio.Settlement", spec_schema="Settlement", ), + ContractEntry( + sdk_model="kalshi.models.portfolio.TargetBalanceAllocation", + spec_schema="TargetBalanceAllocation", + ), + ContractEntry( + sdk_model="kalshi.models.portfolio.TargetBalanceAllocationInput", + spec_schema="TargetBalanceAllocationInput", + ), + ContractEntry( + sdk_model="kalshi.models.portfolio.GetTargetBalanceAllocationResponse", + spec_schema="GetTargetBalanceAllocationResponse", + ), + ContractEntry( + sdk_model="kalshi.models.portfolio.SetTargetBalanceAllocationRequest", + spec_schema="SetTargetBalanceAllocationRequest", + ), ContractEntry( sdk_model="kalshi.models.historical.Trade", spec_schema="Trade", @@ -217,6 +233,18 @@ class ContractEntry: spec_schema="EventLiveData", notes="event-keyed live data; details is dict[str, Any]", ), + ContractEntry( + sdk_model="kalshi.models.live_data.WeatherIndexStationReading", + spec_schema="WeatherIndexStationReading", + ), + ContractEntry( + sdk_model="kalshi.models.live_data.WeatherIndexPoint", + spec_schema="WeatherIndexPoint", + ), + ContractEntry( + sdk_model="kalshi.models.live_data.GetWeatherIndexResponse", + spec_schema="GetWeatherIndexResponse", + ), ContractEntry( sdk_model="kalshi.models.markets.MarketCandlesticks", spec_schema="MarketCandlesticksResponse", @@ -804,6 +832,26 @@ class ContractEntry: sdk_model="kalshi.perps.models.fcm.UpdateFCMSubtraderRiskControlsRequest", spec_schema="UpdateFCMSubtraderRiskControlsRequest", ), + ContractEntry( + sdk_model="kalshi.perps.models.portfolio.ExitTrigger", + spec_schema="ExitTrigger", + ), + ContractEntry( + sdk_model="kalshi.perps.models.portfolio.GetExitTriggersResponse", + spec_schema="GetExitTriggersResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.models.portfolio.SetCrossExitTriggerRequest", + spec_schema="SetCrossExitTriggerRequest", + ), + ContractEntry( + sdk_model="kalshi.perps.models.portfolio.SetIsolatedExitTriggerRequest", + spec_schema="SetIsolatedExitTriggerRequest", + ), + ContractEntry( + sdk_model="kalshi.perps.models.portfolio.UpdateExitTriggerRequest", + spec_schema="UpdateExitTriggerRequest", + ), ] PERPS_SCM_CONTRACT_MAP: list[ContractEntry] = [ @@ -910,4 +958,20 @@ class ContractEntry: sdk_model="kalshi.perps.klear.models.margin.UpdateMarginSubtraderGroupRequest", spec_schema="UpdateMarginSubtraderGroupRequest", ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.GetSettlementPricesResponse", + spec_schema="GetSettlementPricesResponse", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.EstimatePortfolioMaintenanceMarginPosition", + spec_schema="EstimatePortfolioMaintenanceMarginPosition", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.EstimatePortfolioMaintenanceMarginRequest", + spec_schema="EstimatePortfolioMaintenanceMarginRequest", + ), + ContractEntry( + sdk_model="kalshi.perps.klear.models.margin.EstimatePortfolioMaintenanceMarginResponse", + spec_schema="EstimatePortfolioMaintenanceMarginResponse", + ), ] diff --git a/kalshi/models/__init__.py b/kalshi/models/__init__.py index aa23b65..b0bec77 100644 --- a/kalshi/models/__init__.py +++ b/kalshi/models/__init__.py @@ -73,9 +73,12 @@ GetGameStatsResponse, GetLiveDataResponse, GetLiveDatasResponse, + GetWeatherIndexResponse, LiveData, PlayByPlay, PlayByPlayPeriod, + WeatherIndexPoint, + WeatherIndexStationReading, ) from kalshi.models.markets import ( BidAskDistribution, @@ -137,6 +140,7 @@ Deposit, EventPosition, ExchangeInstanceLiteral, + GetTargetBalanceAllocationResponse, IndexedBalance, IntraExchangeInstanceTransfer, IntraExchangeInstanceTransferStatusLiteral, @@ -144,8 +148,11 @@ PaymentStatusLiteral, PaymentTypeLiteral, PositionsResponse, + SetTargetBalanceAllocationRequest, Settlement, SettlementStatusLiteral, + TargetBalanceAllocation, + TargetBalanceAllocationInput, TotalRestingOrderValue, Withdrawal, ) @@ -262,6 +269,8 @@ "GetSubaccountBalancesResponse", "GetSubaccountNettingResponse", "GetTagsForSeriesCategoriesResponse", + "GetTargetBalanceAllocationResponse", + "GetWeatherIndexResponse", "HistoricalCutoff", "IncentiveProgram", "IncentiveProgramStatusLiteral", @@ -307,6 +316,7 @@ "SelfTradePreventionTypeLiteral", "Series", "SeriesFeeChange", + "SetTargetBalanceAllocationRequest", "Settlement", "SettlementSource", "SettlementStatusLiteral", @@ -316,6 +326,8 @@ "SubaccountBalance", "SubaccountNettingConfig", "SubaccountTransfer", + "TargetBalanceAllocation", + "TargetBalanceAllocationInput", "TickerPair", "TimeInForceLiteral", "TotalRestingOrderValue", @@ -324,6 +336,8 @@ "UpdateSubaccountNettingRequest", "UserDataTimestamp", "UserFilterLiteral", + "WeatherIndexPoint", + "WeatherIndexStationReading", "WeeklySchedule", "Withdrawal", ] diff --git a/kalshi/models/api_keys.py b/kalshi/models/api_keys.py index ad74d5d..6922676 100644 --- a/kalshi/models/api_keys.py +++ b/kalshi/models/api_keys.py @@ -43,6 +43,7 @@ class GetApiKeysResponse(BaseModel): """ api_keys: NullableList[ApiKey] = [] + api_key_region_expiration_ts: int | None = None model_config = {"extra": "allow"} diff --git a/kalshi/models/incentive_programs.py b/kalshi/models/incentive_programs.py index 9336ecb..7820ba2 100644 --- a/kalshi/models/incentive_programs.py +++ b/kalshi/models/incentive_programs.py @@ -38,6 +38,7 @@ class IncentiveProgram(BaseModel): # v3.18.0 backfill (#160). incentive_description: str + max_reward_per_account: int | None = None model_config = {"extra": "allow"} diff --git a/kalshi/models/live_data.py b/kalshi/models/live_data.py index 7e8e296..46477d2 100644 --- a/kalshi/models/live_data.py +++ b/kalshi/models/live_data.py @@ -90,6 +90,43 @@ class PlayByPlay(BaseModel): model_config = {"extra": "allow"} +class WeatherIndexStationReading(BaseModel): + """Per-station audit reading on a weather-index point (``detailed=true``).""" + + station_id: str + code: str + source: str | None = None + temp_f: float | None = None + obs_time_ms: int | None = None + received_at_ms: int | None = None + primary_code: str | None = None + + model_config = {"extra": "allow"} + + +class WeatherIndexPoint(BaseModel): + """One minute of a published weather index.""" + + t: int + status: str + v: float | None = None + contributors: int | None = None + stations: list[WeatherIndexStationReading] | None = None + + model_config = {"extra": "allow"} + + +class GetWeatherIndexResponse(BaseModel): + """Response from GET /live_data/weather/{city}.""" + + city: str + units: str + timeseries: list[WeatherIndexPoint] + config_version: str | None = None + + model_config = {"extra": "allow"} + + class GetGameStatsResponse(BaseModel): """Response from GET /live_data/milestone/{milestone_id}/game_stats. diff --git a/kalshi/models/orders.py b/kalshi/models/orders.py index 5de97fa..6ec65e8 100644 --- a/kalshi/models/orders.py +++ b/kalshi/models/orders.py @@ -133,6 +133,7 @@ class Fill(BaseModel): book_side: BookSideLiteral subaccount_number: int | None = None ts: int | None = None + exchange_index: int model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/models/portfolio.py b/kalshi/models/portfolio.py index 3bc0b72..525818e 100644 --- a/kalshi/models/portfolio.py +++ b/kalshi/models/portfolio.py @@ -4,7 +4,7 @@ from typing import Literal -from pydantic import AliasChoices, AwareDatetime, BaseModel, Field +from pydantic import AliasChoices, AwareDatetime, BaseModel, Field, StrictInt from kalshi.types import DollarDecimal, FixedPointCount, NullableList, UnixSecondsTimestamp @@ -80,6 +80,7 @@ class MarketPosition(BaseModel): """ ticker: str + exchange_index: int total_traded: DollarDecimal | None = Field( validation_alias=AliasChoices("total_traded_dollars", "total_traded"), ) @@ -183,6 +184,7 @@ class Settlement(BaseModel): """A settled market position.""" ticker: str + exchange_index: int event_ticker: str market_result: str yes_count: FixedPointCount | None = Field( @@ -237,3 +239,37 @@ class IntraExchangeInstanceTransfer(BaseModel): model_config = {"extra": "allow"} + +class TargetBalanceAllocation(BaseModel): + """One shard's target share of sweepable balance.""" + + exchange_index: StrictInt = Field(ge=0) + percent: StrictInt = Field(ge=0, le=100) + + model_config = {"extra": "allow"} + + +class TargetBalanceAllocationInput(BaseModel): + """Write-side counterpart of :class:`TargetBalanceAllocation`.""" + + exchange_index: StrictInt = Field(ge=0) + percent: StrictInt = Field(ge=0, le=100) + + model_config = {"extra": "forbid"} + + +class GetTargetBalanceAllocationResponse(BaseModel): + """Response from GET /portfolio/target_balance_allocation.""" + + allocations: list[TargetBalanceAllocation] + + model_config = {"extra": "allow"} + + +class SetTargetBalanceAllocationRequest(BaseModel): + """Body for POST /portfolio/target_balance_allocation.""" + + allocations: list[TargetBalanceAllocationInput] = Field(max_length=101) + + model_config = {"extra": "forbid"} + diff --git a/kalshi/perps/__init__.py b/kalshi/perps/__init__.py index 5da1be2..fe5e3ec 100644 --- a/kalshi/perps/__init__.py +++ b/kalshi/perps/__init__.py @@ -78,6 +78,7 @@ MarginEnabledResponse, ) from kalshi.perps.models.fcm import ( + FCMAssetClassLiteral, FCMSubtraderRiskControls, GetFCMSubtraderRiskControlsResponse, UpdateFCMSubtraderRiskControlsRequest, @@ -130,12 +131,21 @@ TimeInForceLiteral, ) from kalshi.perps.models.portfolio import ( + ExitTrigger, + ExitTriggerKindLiteral, + ExitTriggerLegLiteral, + ExitTriggerReasonLiteral, + ExitTriggerStatusLiteral, + GetExitTriggersResponse, GetMarginFillsResponse, GetMarginPositionsResponse, GetMarginTradesResponse, MarginFill, MarginPosition, MarginTrade, + SetCrossExitTriggerRequest, + SetIsolatedExitTriggerRequest, + UpdateExitTriggerRequest, ) from kalshi.perps.models.transfers import ( ApplySubaccountTransferRequest, @@ -246,11 +256,18 @@ "ExchangeInstance", "ExchangeInstanceLiteral", "ExchangeStatus", + "ExitTrigger", + "ExitTriggerKindLiteral", + "ExitTriggerLegLiteral", + "ExitTriggerReasonLiteral", + "ExitTriggerStatusLiteral", + "FCMAssetClassLiteral", "FCMSubtraderRiskControls", "FundingPaymentDetail", "FundingRate", "FundingResource", "GetActiveMarginObligationsResponse", + "GetExitTriggersResponse", "GetFCMSubtraderRiskControlsResponse", "GetGuarantyFundBalanceResponse", "GetMarginBalanceResponse", @@ -342,12 +359,15 @@ "PriceLevelDollarsCountFp", "SelfTradePreventionType", "SelfTradePreventionTypeLiteral", + "SetCrossExitTriggerRequest", + "SetIsolatedExitTriggerRequest", "SettlementBalanceHistoryEntry", "SettlementDetail", "SettlementEstimate", "TickerPrice", "TimeInForceLiteral", "TransfersResource", + "UpdateExitTriggerRequest", "UpdateFCMSubtraderRiskControlsRequest", "UpdateOrderGroupLimitRequest", "UpdateSubscriptionAction", diff --git a/kalshi/perps/klear/models/__init__.py b/kalshi/perps/klear/models/__init__.py index e9be371..c2e91b7 100644 --- a/kalshi/perps/klear/models/__init__.py +++ b/kalshi/perps/klear/models/__init__.py @@ -8,6 +8,9 @@ AssetClassSettlementEstimate, CreateMarginSubtraderGroupRequest, CreateMarginSubtraderGroupResponse, + EstimatePortfolioMaintenanceMarginPosition, + EstimatePortfolioMaintenanceMarginRequest, + EstimatePortfolioMaintenanceMarginResponse, FundingPaymentDetail, GetActiveMarginObligationsResponse, GetGuarantyFundBalanceResponse, @@ -21,6 +24,7 @@ GetSettlementBalanceResponse, GetSettlementBalanceWithdrawalResponse, GetSettlementEstimateByAssetClassResponse, + GetSettlementPricesResponse, MaintenanceMarginDetail, MarginReport, MarginReportTypeLiteral, @@ -43,6 +47,9 @@ "CreateMarginSubtraderGroupRequest", "CreateMarginSubtraderGroupResponse", "Error", + "EstimatePortfolioMaintenanceMarginPosition", + "EstimatePortfolioMaintenanceMarginRequest", + "EstimatePortfolioMaintenanceMarginResponse", "FundingPaymentDetail", "GetActiveMarginObligationsResponse", "GetGuarantyFundBalanceResponse", @@ -56,6 +63,7 @@ "GetSettlementBalanceResponse", "GetSettlementBalanceWithdrawalResponse", "GetSettlementEstimateByAssetClassResponse", + "GetSettlementPricesResponse", "MaintenanceMarginDetail", "MarginReport", "MarginReportTypeLiteral", diff --git a/kalshi/perps/klear/models/margin.py b/kalshi/perps/klear/models/margin.py index 8a72afe..6e03bae 100644 --- a/kalshi/perps/klear/models/margin.py +++ b/kalshi/perps/klear/models/margin.py @@ -71,6 +71,8 @@ def _require_positive_withdrawal(value: Decimal) -> Decimal: "market_price_snapshot", "funding_periods", "settlement_periods", + "maintenance_margin", + "maintenance_margin_aggregate", ] """Spec ``MarginReport.report_type`` — the kind of margin report.""" @@ -96,6 +98,7 @@ class MarginReport(BaseModel): date: datetime.date created_ts: AwareDatetime is_end_of_day: bool + snapshot_ts: AwareDatetime | None = None model_config = {"extra": "allow"} @@ -494,3 +497,55 @@ class UpdateMarginSubtraderGroupRequest(BaseModel): subtrader_ids: list[str] = Field(min_length=1) model_config = {"extra": "forbid"} + + +class GetSettlementPricesResponse(BaseModel): + """Response from GET /margin/settlement_prices. + + Values are settlement (mark) prices in **centicents** (1 USD = 10,000). + """ + + settlement_prices: dict[str, int] + + model_config = {"extra": "allow"} + + +def _require_nonzero_quantity(value: int) -> int: + if value == 0: + raise ValueError("quantity must be non-zero") + return value + + +def _require_positive_price(value: Decimal) -> Decimal: + if value <= 0: + raise ValueError(f"price must be positive (got {value})") + return value + + +class EstimatePortfolioMaintenanceMarginPosition(BaseModel): + """One hypothetical position in an estimate-maintenance-margin request.""" + + market_ticker: str + quantity: Annotated[int, AfterValidator(_require_nonzero_quantity)] + price: Annotated[DollarDecimal, AfterValidator(_require_positive_price)] + + model_config = {"extra": "forbid"} + + +class EstimatePortfolioMaintenanceMarginRequest(BaseModel): + """Body for POST /margin/estimate_maintenance_margin.""" + + asset_class: AssetClassLiteral + positions: list[EstimatePortfolioMaintenanceMarginPosition] = Field( + min_length=1, max_length=500 + ) + + model_config = {"extra": "forbid"} + + +class EstimatePortfolioMaintenanceMarginResponse(BaseModel): + """Response from POST /margin/estimate_maintenance_margin.""" + + maintenance_margin_fp: DollarDecimal | None = None + + model_config = {"extra": "allow"} diff --git a/kalshi/perps/klear/resources/margin.py b/kalshi/perps/klear/resources/margin.py index c4c82b2..b47984e 100644 --- a/kalshi/perps/klear/resources/margin.py +++ b/kalshi/perps/klear/resources/margin.py @@ -39,8 +39,12 @@ from kalshi.models.common import Page from kalshi.perps.klear.models.margin import ( + AssetClassLiteral, CreateMarginSubtraderGroupRequest, CreateMarginSubtraderGroupResponse, + EstimatePortfolioMaintenanceMarginPosition, + EstimatePortfolioMaintenanceMarginRequest, + EstimatePortfolioMaintenanceMarginResponse, FundingPaymentDetail, GetActiveMarginObligationsResponse, GetGuarantyFundBalanceResponse, @@ -49,6 +53,7 @@ GetSettlementBalanceResponse, GetSettlementBalanceWithdrawalResponse, GetSettlementEstimateByAssetClassResponse, + GetSettlementPricesResponse, MaintenanceMarginDetail, ObligationEntry, SettlementBalanceHistoryEntry, @@ -391,6 +396,47 @@ def settlement_balance_withdrawal( ) return GetSettlementBalanceWithdrawalResponse.model_validate(data) + def settlement_prices( + self, + *, + asset_class: AssetClassLiteral, + settlement_time: str, + extra_headers: dict[str, str] | None = None, + ) -> GetSettlementPricesResponse: + """``GET /margin/settlement_prices`` — mark prices at a settlement cycle. + + ``settlement_time`` is RFC3339. Values are centicents. + """ + params = _params(asset_class=asset_class, settlement_time=settlement_time) + data = self._get("/margin/settlement_prices", params=params, extra_headers=extra_headers) + return GetSettlementPricesResponse.model_validate(data) + + def estimate_maintenance_margin( + self, + *, + request: EstimatePortfolioMaintenanceMarginRequest | None = None, + asset_class: AssetClassLiteral | None = None, + positions: list[EstimatePortfolioMaintenanceMarginPosition] | None = None, + extra_headers: dict[str, str] | None = None, + ) -> EstimatePortfolioMaintenanceMarginResponse: + """``POST /margin/estimate_maintenance_margin``. Not retried.""" + _check_request_exclusive(request, asset_class=asset_class, positions=positions) + if request is None: + if asset_class is None or positions is None: + raise TypeError( + "estimate_maintenance_margin() requires `asset_class` and " + "`positions` (or pass `request=...`)" + ) + request = EstimatePortfolioMaintenanceMarginRequest( + asset_class=asset_class, positions=positions + ) + data = self._post( + "/margin/estimate_maintenance_margin", + json=request.model_dump(exclude_none=True, by_alias=True, mode="json"), + extra_headers=extra_headers, + ) + return EstimatePortfolioMaintenanceMarginResponse.model_validate(data) + def list_subtrader_groups( self, *, extra_headers: dict[str, str] | None = None ) -> GetMarginSubtraderGroupsResponse: @@ -729,6 +775,46 @@ async def settlement_balance_withdrawal( ) return GetSettlementBalanceWithdrawalResponse.model_validate(data) + async def settlement_prices( + self, + *, + asset_class: AssetClassLiteral, + settlement_time: str, + extra_headers: dict[str, str] | None = None, + ) -> GetSettlementPricesResponse: + """Async :meth:`MarginResource.settlement_prices`.""" + params = _params(asset_class=asset_class, settlement_time=settlement_time) + data = await self._get( + "/margin/settlement_prices", params=params, extra_headers=extra_headers + ) + return GetSettlementPricesResponse.model_validate(data) + + async def estimate_maintenance_margin( + self, + *, + request: EstimatePortfolioMaintenanceMarginRequest | None = None, + asset_class: AssetClassLiteral | None = None, + positions: list[EstimatePortfolioMaintenanceMarginPosition] | None = None, + extra_headers: dict[str, str] | None = None, + ) -> EstimatePortfolioMaintenanceMarginResponse: + """Async :meth:`MarginResource.estimate_maintenance_margin`.""" + _check_request_exclusive(request, asset_class=asset_class, positions=positions) + if request is None: + if asset_class is None or positions is None: + raise TypeError( + "estimate_maintenance_margin() requires `asset_class` and " + "`positions` (or pass `request=...`)" + ) + request = EstimatePortfolioMaintenanceMarginRequest( + asset_class=asset_class, positions=positions + ) + data = await self._post( + "/margin/estimate_maintenance_margin", + json=request.model_dump(exclude_none=True, by_alias=True, mode="json"), + extra_headers=extra_headers, + ) + return EstimatePortfolioMaintenanceMarginResponse.model_validate(data) + async def list_subtrader_groups( self, *, extra_headers: dict[str, str] | None = None ) -> GetMarginSubtraderGroupsResponse: diff --git a/kalshi/perps/models/__init__.py b/kalshi/perps/models/__init__.py index de60f44..110dcbb 100644 --- a/kalshi/perps/models/__init__.py +++ b/kalshi/perps/models/__init__.py @@ -26,6 +26,7 @@ MarginEnabledResponse, ) from kalshi.perps.models.fcm import ( + FCMAssetClassLiteral, FCMSubtraderRiskControls, GetFCMSubtraderRiskControlsResponse, UpdateFCMSubtraderRiskControlsRequest, @@ -78,12 +79,21 @@ TimeInForceLiteral, ) from kalshi.perps.models.portfolio import ( + ExitTrigger, + ExitTriggerKindLiteral, + ExitTriggerLegLiteral, + ExitTriggerReasonLiteral, + ExitTriggerStatusLiteral, + GetExitTriggersResponse, GetMarginFillsResponse, GetMarginPositionsResponse, GetMarginTradesResponse, MarginFill, MarginPosition, MarginTrade, + SetCrossExitTriggerRequest, + SetIsolatedExitTriggerRequest, + UpdateExitTriggerRequest, ) from kalshi.perps.models.transfers import ( ApplySubaccountTransferRequest, @@ -114,7 +124,14 @@ "ExchangeInstance", "ExchangeInstanceLiteral", "ExchangeStatus", + "ExitTrigger", + "ExitTriggerKindLiteral", + "ExitTriggerLegLiteral", + "ExitTriggerReasonLiteral", + "ExitTriggerStatusLiteral", + "FCMAssetClassLiteral", "FCMSubtraderRiskControls", + "GetExitTriggersResponse", "GetFCMSubtraderRiskControlsResponse", "GetMarginBalanceResponse", "GetMarginFeeTiersResponse", @@ -155,7 +172,10 @@ "PriceLevelDollarsCountFp", "SelfTradePreventionType", "SelfTradePreventionTypeLiteral", + "SetCrossExitTriggerRequest", + "SetIsolatedExitTriggerRequest", "TimeInForceLiteral", + "UpdateExitTriggerRequest", "UpdateFCMSubtraderRiskControlsRequest", "UpdateOrderGroupLimitRequest", ] diff --git a/kalshi/perps/models/fcm.py b/kalshi/perps/models/fcm.py index 1a0bc7a..5a8f0f9 100644 --- a/kalshi/perps/models/fcm.py +++ b/kalshi/perps/models/fcm.py @@ -2,10 +2,25 @@ from __future__ import annotations +from typing import Literal + from pydantic import BaseModel, Field from kalshi.types import DollarDecimal, OrderPrice +FCMAssetClassLiteral = Literal[ + "Crypto", + "Equities", + "Metals", + "FX", + "Energy", + "Indices", + "Rates", + "Compute", + "GPU", +] +"""Asset-class scope for an FCM initial-margin cap.""" + class CreateMarginFCMSubtraderRequest(BaseModel): """Body for POST /margin/fcm/subtraders. @@ -37,6 +52,7 @@ class FCMSubtraderRiskControls(BaseModel): subtrader_id: str im_cap: DollarDecimal market_ticker: str | None = None + asset_class: FCMAssetClassLiteral | None = None model_config = {"extra": "allow"} @@ -58,5 +74,6 @@ class UpdateFCMSubtraderRiskControlsRequest(BaseModel): subtrader_id: str im_cap: OrderPrice market_ticker: str | None = None + asset_class: FCMAssetClassLiteral | None = None model_config = {"extra": "forbid"} diff --git a/kalshi/perps/models/portfolio.py b/kalshi/perps/models/portfolio.py index 7dfca78..8dd9795 100644 --- a/kalshi/perps/models/portfolio.py +++ b/kalshi/perps/models/portfolio.py @@ -138,3 +138,99 @@ def has_next(self) -> bool: return bool(self.cursor) model_config = {"extra": "allow"} + + +ExitTriggerKindLiteral = Literal["bracket", "trailing"] +"""Trigger family: stop-loss/take-profit pair, or a trailing stop.""" + +ExitTriggerStatusLiteral = Literal[ + "pending_on_entry", + "active", + "filled", + "failed", + "canceled", + "unknown", +] +"""Lifecycle of a live exit trigger.""" + +ExitTriggerReasonLiteral = Literal[ + "user_canceled", + "position_closed", + "entry_not_filled", + "order_rejected", + "position_flipped", +] +"""Why a trigger reached a terminal status.""" + +ExitTriggerLegLiteral = Literal["stop_loss", "take_profit"] +"""Which bracket leg fired.""" + + +class ExitTrigger(BaseModel): + """A live stop-loss / take-profit / trailing-stop on a margin position.""" + + id: str + ticker: str + kind: ExitTriggerKindLiteral + status: ExitTriggerStatusLiteral + count: FixedPointCount + filled_count: FixedPointCount + created_time: AwareDatetime + updated_time: AwareDatetime + status_reason: ExitTriggerReasonLiteral | None = None + triggered_leg: ExitTriggerLegLiteral | None = None + triggered_order_id: str | None = None + anchor_order_id: str | None = None + client_trigger_id: str | None = None + stop_loss_price: DollarDecimal | None = None + take_profit_price: DollarDecimal | None = None + trail_amount: DollarDecimal | None = None + trail_bps: int | None = None + watermark_price: DollarDecimal | None = None + effective_stop_price: DollarDecimal | None = None + + model_config = {"extra": "allow"} + + +class GetExitTriggersResponse(BaseModel): + """Response from GET .../positions/{ticker}/exit_trigger.""" + + exit_triggers: list[ExitTrigger] + + model_config = {"extra": "allow"} + + +class SetCrossExitTriggerRequest(BaseModel): + """Body for PUT /margin/cross/positions/{ticker}/exit_trigger.""" + + count: FixedPointCount | None = None + anchor_order_id: str | None = None + client_trigger_id: str | None = None + kind: ExitTriggerKindLiteral | None = None + stop_loss_price: DollarDecimal | None = None + take_profit_price: DollarDecimal | None = None + trail_amount: DollarDecimal | None = None + trail_bps: int | None = Field(default=None, ge=1, le=9999) + + model_config = {"extra": "forbid"} + + +class SetIsolatedExitTriggerRequest(BaseModel): + """Body for PUT /margin/isolated/positions/{ticker}/exit_trigger.""" + + kind: ExitTriggerKindLiteral | None = None + stop_loss_price: DollarDecimal | None = None + take_profit_price: DollarDecimal | None = None + trail_amount: DollarDecimal | None = None + trail_bps: int | None = Field(default=None, ge=1, le=9999) + + model_config = {"extra": "forbid"} + + +class UpdateExitTriggerRequest(BaseModel): + """Body for PUT /margin/cross/positions/{ticker}/exit_trigger/{trigger_id}.""" + + stop_loss_price: DollarDecimal | None = None + take_profit_price: DollarDecimal | None = None + + model_config = {"extra": "forbid"} diff --git a/kalshi/perps/resources/fcm.py b/kalshi/perps/resources/fcm.py index 02c57f1..7a21865 100644 --- a/kalshi/perps/resources/fcm.py +++ b/kalshi/perps/resources/fcm.py @@ -12,6 +12,7 @@ from kalshi.perps.models.fcm import ( CreateMarginFCMSubtraderRequest, CreateMarginFCMSubtraderResponse, + FCMAssetClassLiteral, GetFCMSubtraderRiskControlsResponse, UpdateFCMSubtraderRiskControlsRequest, ) @@ -46,9 +47,14 @@ def _build_update_risk_controls_body( subtrader_id: str | None, im_cap: Decimal | None, market_ticker: str | None, + asset_class: FCMAssetClassLiteral | None, ) -> dict[str, object]: _check_request_exclusive( - request, subtrader_id=subtrader_id, im_cap=im_cap, market_ticker=market_ticker + request, + subtrader_id=subtrader_id, + im_cap=im_cap, + market_ticker=market_ticker, + asset_class=asset_class, ) if request is None: if subtrader_id is None or im_cap is None: @@ -60,6 +66,7 @@ def _build_update_risk_controls_body( subtrader_id=subtrader_id, im_cap=im_cap, market_ticker=market_ticker, + asset_class=asset_class, ) return request.model_dump(exclude_none=True, by_alias=True, mode="json") @@ -103,11 +110,16 @@ def risk_controls( *, subtrader_id: str, market_ticker: str | None = None, + asset_class: FCMAssetClassLiteral | None = None, extra_headers: dict[str, str] | None = None, ) -> GetFCMSubtraderRiskControlsResponse: """``GET /margin/fcm/subtraders/risk_controls`` — list IM caps.""" self._require_auth() - params = _params(subtrader_id=subtrader_id, market_ticker=market_ticker) + params = _params( + subtrader_id=subtrader_id, + market_ticker=market_ticker, + asset_class=asset_class, + ) data = self._get(_RISK_CONTROLS_PATH, params=params, extra_headers=extra_headers) return GetFCMSubtraderRiskControlsResponse.model_validate(data) @@ -125,6 +137,7 @@ def update_risk_controls( subtrader_id: str, im_cap: Decimal, market_ticker: str | None = None, + asset_class: FCMAssetClassLiteral | None = None, extra_headers: dict[str, str] | None = None, ) -> None: ... def update_risk_controls( @@ -134,6 +147,7 @@ def update_risk_controls( subtrader_id: str | None = None, im_cap: Decimal | None = None, market_ticker: str | None = None, + asset_class: FCMAssetClassLiteral | None = None, extra_headers: dict[str, str] | None = None, ) -> None: """``PUT /margin/fcm/subtraders/risk_controls`` — set an IM cap.""" @@ -143,6 +157,7 @@ def update_risk_controls( subtrader_id=subtrader_id, im_cap=im_cap, market_ticker=market_ticker, + asset_class=asset_class, ) self._put(_RISK_CONTROLS_PATH, json=body, extra_headers=extra_headers) @@ -151,11 +166,16 @@ def delete_risk_controls( *, subtrader_id: str, market_ticker: str | None = None, + asset_class: FCMAssetClassLiteral | None = None, extra_headers: dict[str, str] | None = None, ) -> None: """``DELETE /margin/fcm/subtraders/risk_controls`` — remove an IM cap.""" self._require_auth() - params = _params(subtrader_id=subtrader_id, market_ticker=market_ticker) + params = _params( + subtrader_id=subtrader_id, + market_ticker=market_ticker, + asset_class=asset_class, + ) self._delete(_RISK_CONTROLS_PATH, params=params, extra_headers=extra_headers) @@ -194,11 +214,16 @@ async def risk_controls( *, subtrader_id: str, market_ticker: str | None = None, + asset_class: FCMAssetClassLiteral | None = None, extra_headers: dict[str, str] | None = None, ) -> GetFCMSubtraderRiskControlsResponse: """Async :meth:`FcmResource.risk_controls`.""" self._require_auth() - params = _params(subtrader_id=subtrader_id, market_ticker=market_ticker) + params = _params( + subtrader_id=subtrader_id, + market_ticker=market_ticker, + asset_class=asset_class, + ) data = await self._get(_RISK_CONTROLS_PATH, params=params, extra_headers=extra_headers) return GetFCMSubtraderRiskControlsResponse.model_validate(data) @@ -216,6 +241,7 @@ async def update_risk_controls( subtrader_id: str, im_cap: Decimal, market_ticker: str | None = None, + asset_class: FCMAssetClassLiteral | None = None, extra_headers: dict[str, str] | None = None, ) -> None: ... async def update_risk_controls( @@ -225,6 +251,7 @@ async def update_risk_controls( subtrader_id: str | None = None, im_cap: Decimal | None = None, market_ticker: str | None = None, + asset_class: FCMAssetClassLiteral | None = None, extra_headers: dict[str, str] | None = None, ) -> None: """Async :meth:`FcmResource.update_risk_controls`.""" @@ -234,6 +261,7 @@ async def update_risk_controls( subtrader_id=subtrader_id, im_cap=im_cap, market_ticker=market_ticker, + asset_class=asset_class, ) await self._put(_RISK_CONTROLS_PATH, json=body, extra_headers=extra_headers) @@ -242,9 +270,14 @@ async def delete_risk_controls( *, subtrader_id: str, market_ticker: str | None = None, + asset_class: FCMAssetClassLiteral | None = None, extra_headers: dict[str, str] | None = None, ) -> None: """Async :meth:`FcmResource.delete_risk_controls`.""" self._require_auth() - params = _params(subtrader_id=subtrader_id, market_ticker=market_ticker) + params = _params( + subtrader_id=subtrader_id, + market_ticker=market_ticker, + asset_class=asset_class, + ) await self._delete(_RISK_CONTROLS_PATH, params=params, extra_headers=extra_headers) diff --git a/kalshi/perps/resources/orders.py b/kalshi/perps/resources/orders.py index da9e39e..b5dbb2d 100644 --- a/kalshi/perps/resources/orders.py +++ b/kalshi/perps/resources/orders.py @@ -393,6 +393,20 @@ def cancel( raise KalshiError("Expected CancelMarginOrderResponse body, got 204 No Content.") return CancelMarginOrderResponse.model_validate(data) + def cancel_all( + self, + *, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """``DELETE /margin/orders`` — cancel up to 10,000 resting margin orders. + + Not retried (DELETE). Returns 204. + """ + self._require_auth() + params = _params(subaccount=subaccount) + self._delete("/margin/orders", params=params, extra_headers=extra_headers) + @overload def decrease( self, @@ -727,6 +741,17 @@ async def cancel( raise KalshiError("Expected CancelMarginOrderResponse body, got 204 No Content.") return CancelMarginOrderResponse.model_validate(data) + async def cancel_all( + self, + *, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """Async :meth:`MarginOrdersResource.cancel_all`.""" + self._require_auth() + params = _params(subaccount=subaccount) + await self._delete("/margin/orders", params=params, extra_headers=extra_headers) + @overload async def decrease( self, diff --git a/kalshi/perps/resources/portfolio.py b/kalshi/perps/resources/portfolio.py index bfdc2d9..087d365 100644 --- a/kalshi/perps/resources/portfolio.py +++ b/kalshi/perps/resources/portfolio.py @@ -19,16 +19,24 @@ from collections.abc import AsyncIterator, Iterator from typing import Any +from kalshi.errors import KalshiError from kalshi.models.common import Page from kalshi.perps.models.portfolio import ( + ExitTrigger, + ExitTriggerKindLiteral, + GetExitTriggersResponse, GetMarginPositionsResponse, MarginFill, MarginTrade, + SetCrossExitTriggerRequest, + SetIsolatedExitTriggerRequest, + UpdateExitTriggerRequest, ) from kalshi.resources._base import ( AsyncResource, SyncResource, _params, + _seg, _validate_limit, _validate_max_pages, ) @@ -188,6 +196,158 @@ def trades_all( extra_headers=extra_headers, ) + def cross_exit_triggers( + self, + ticker: str, + *, + kind: ExitTriggerKindLiteral | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetExitTriggersResponse: + """``GET /margin/cross/positions/{ticker}/exit_trigger``.""" + self._require_auth() + params = _params(kind=kind, subaccount=subaccount) + data = self._get( + f"/margin/cross/positions/{_seg(ticker, name='ticker')}/exit_trigger", + params=params, + extra_headers=extra_headers, + ) + return GetExitTriggersResponse.model_validate(data) + + def set_cross_exit_trigger( + self, + ticker: str, + *, + request: SetCrossExitTriggerRequest, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> ExitTrigger: + """``PUT /margin/cross/positions/{ticker}/exit_trigger``. Not retried.""" + self._require_auth() + params = _params(subaccount=subaccount) + data = self._put( + f"/margin/cross/positions/{_seg(ticker, name='ticker')}/exit_trigger", + params=params, + json=request.model_dump(exclude_none=True, by_alias=True, mode="json"), + extra_headers=extra_headers, + ) + if data is None: + raise KalshiError("Expected ExitTrigger body, got 204 No Content.") + return ExitTrigger.model_validate(data) + + def cancel_cross_exit_triggers( + self, + ticker: str, + *, + kind: ExitTriggerKindLiteral | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """``DELETE /margin/cross/positions/{ticker}/exit_trigger``. Not retried.""" + self._require_auth() + params = _params(kind=kind, subaccount=subaccount) + self._delete( + f"/margin/cross/positions/{_seg(ticker, name='ticker')}/exit_trigger", + params=params, + extra_headers=extra_headers, + ) + + def update_cross_exit_trigger( + self, + ticker: str, + trigger_id: str, + *, + request: UpdateExitTriggerRequest, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> ExitTrigger: + """``PUT /margin/cross/positions/{ticker}/exit_trigger/{trigger_id}``.""" + self._require_auth() + params = _params(subaccount=subaccount) + data = self._put( + ( + f"/margin/cross/positions/{_seg(ticker, name='ticker')}" + f"/exit_trigger/{_seg(trigger_id, name='trigger_id')}" + ), + params=params, + json=request.model_dump(exclude_none=True, by_alias=True, mode="json"), + extra_headers=extra_headers, + ) + if data is None: + raise KalshiError("Expected ExitTrigger body, got 204 No Content.") + return ExitTrigger.model_validate(data) + + def cancel_cross_exit_trigger( + self, + ticker: str, + trigger_id: str, + *, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """``DELETE /margin/cross/positions/{ticker}/exit_trigger/{trigger_id}``.""" + self._require_auth() + params = _params(subaccount=subaccount) + self._delete( + ( + f"/margin/cross/positions/{_seg(ticker, name='ticker')}" + f"/exit_trigger/{_seg(trigger_id, name='trigger_id')}" + ), + params=params, + extra_headers=extra_headers, + ) + + def isolated_exit_triggers( + self, + ticker: str, + *, + kind: ExitTriggerKindLiteral | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetExitTriggersResponse: + """``GET /margin/isolated/positions/{ticker}/exit_trigger``.""" + self._require_auth() + params = _params(kind=kind) + data = self._get( + f"/margin/isolated/positions/{_seg(ticker, name='ticker')}/exit_trigger", + params=params, + extra_headers=extra_headers, + ) + return GetExitTriggersResponse.model_validate(data) + + def set_isolated_exit_trigger( + self, + ticker: str, + *, + request: SetIsolatedExitTriggerRequest, + extra_headers: dict[str, str] | None = None, + ) -> ExitTrigger: + """``PUT /margin/isolated/positions/{ticker}/exit_trigger``. Not retried.""" + self._require_auth() + data = self._put( + f"/margin/isolated/positions/{_seg(ticker, name='ticker')}/exit_trigger", + json=request.model_dump(exclude_none=True, by_alias=True, mode="json"), + extra_headers=extra_headers, + ) + if data is None: + raise KalshiError("Expected ExitTrigger body, got 204 No Content.") + return ExitTrigger.model_validate(data) + + def cancel_isolated_exit_triggers( + self, + ticker: str, + *, + kind: ExitTriggerKindLiteral | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """``DELETE /margin/isolated/positions/{ticker}/exit_trigger``. Not retried.""" + self._require_auth() + params = _params(kind=kind) + self._delete( + f"/margin/isolated/positions/{_seg(ticker, name='ticker')}/exit_trigger", + params=params, + extra_headers=extra_headers, + ) + class AsyncPerpsPortfolioResource(AsyncResource): """Async perps portfolio API (``positions`` / ``fills`` / ``trades`` + paginators).""" @@ -304,3 +464,155 @@ def trades_all( max_pages=max_pages, extra_headers=extra_headers, ) + + async def cross_exit_triggers( + self, + ticker: str, + *, + kind: ExitTriggerKindLiteral | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetExitTriggersResponse: + """Async :meth:`PerpsPortfolioResource.cross_exit_triggers`.""" + self._require_auth() + params = _params(kind=kind, subaccount=subaccount) + data = await self._get( + f"/margin/cross/positions/{_seg(ticker, name='ticker')}/exit_trigger", + params=params, + extra_headers=extra_headers, + ) + return GetExitTriggersResponse.model_validate(data) + + async def set_cross_exit_trigger( + self, + ticker: str, + *, + request: SetCrossExitTriggerRequest, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> ExitTrigger: + """Async :meth:`PerpsPortfolioResource.set_cross_exit_trigger`.""" + self._require_auth() + params = _params(subaccount=subaccount) + data = await self._put( + f"/margin/cross/positions/{_seg(ticker, name='ticker')}/exit_trigger", + params=params, + json=request.model_dump(exclude_none=True, by_alias=True, mode="json"), + extra_headers=extra_headers, + ) + if data is None: + raise KalshiError("Expected ExitTrigger body, got 204 No Content.") + return ExitTrigger.model_validate(data) + + async def cancel_cross_exit_triggers( + self, + ticker: str, + *, + kind: ExitTriggerKindLiteral | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """Async :meth:`PerpsPortfolioResource.cancel_cross_exit_triggers`.""" + self._require_auth() + params = _params(kind=kind, subaccount=subaccount) + await self._delete( + f"/margin/cross/positions/{_seg(ticker, name='ticker')}/exit_trigger", + params=params, + extra_headers=extra_headers, + ) + + async def update_cross_exit_trigger( + self, + ticker: str, + trigger_id: str, + *, + request: UpdateExitTriggerRequest, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> ExitTrigger: + """Async :meth:`PerpsPortfolioResource.update_cross_exit_trigger`.""" + self._require_auth() + params = _params(subaccount=subaccount) + data = await self._put( + ( + f"/margin/cross/positions/{_seg(ticker, name='ticker')}" + f"/exit_trigger/{_seg(trigger_id, name='trigger_id')}" + ), + params=params, + json=request.model_dump(exclude_none=True, by_alias=True, mode="json"), + extra_headers=extra_headers, + ) + if data is None: + raise KalshiError("Expected ExitTrigger body, got 204 No Content.") + return ExitTrigger.model_validate(data) + + async def cancel_cross_exit_trigger( + self, + ticker: str, + trigger_id: str, + *, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """Async :meth:`PerpsPortfolioResource.cancel_cross_exit_trigger`.""" + self._require_auth() + params = _params(subaccount=subaccount) + await self._delete( + ( + f"/margin/cross/positions/{_seg(ticker, name='ticker')}" + f"/exit_trigger/{_seg(trigger_id, name='trigger_id')}" + ), + params=params, + extra_headers=extra_headers, + ) + + async def isolated_exit_triggers( + self, + ticker: str, + *, + kind: ExitTriggerKindLiteral | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetExitTriggersResponse: + """Async :meth:`PerpsPortfolioResource.isolated_exit_triggers`.""" + self._require_auth() + params = _params(kind=kind) + data = await self._get( + f"/margin/isolated/positions/{_seg(ticker, name='ticker')}/exit_trigger", + params=params, + extra_headers=extra_headers, + ) + return GetExitTriggersResponse.model_validate(data) + + async def set_isolated_exit_trigger( + self, + ticker: str, + *, + request: SetIsolatedExitTriggerRequest, + extra_headers: dict[str, str] | None = None, + ) -> ExitTrigger: + """Async :meth:`PerpsPortfolioResource.set_isolated_exit_trigger`.""" + self._require_auth() + data = await self._put( + f"/margin/isolated/positions/{_seg(ticker, name='ticker')}/exit_trigger", + json=request.model_dump(exclude_none=True, by_alias=True, mode="json"), + extra_headers=extra_headers, + ) + if data is None: + raise KalshiError("Expected ExitTrigger body, got 204 No Content.") + return ExitTrigger.model_validate(data) + + async def cancel_isolated_exit_triggers( + self, + ticker: str, + *, + kind: ExitTriggerKindLiteral | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """Async :meth:`PerpsPortfolioResource.cancel_isolated_exit_triggers`.""" + self._require_auth() + params = _params(kind=kind) + await self._delete( + f"/margin/isolated/positions/{_seg(ticker, name='ticker')}/exit_trigger", + params=params, + extra_headers=extra_headers, + ) diff --git a/kalshi/resources/live_data.py b/kalshi/resources/live_data.py index cb7335a..825b00a 100644 --- a/kalshi/resources/live_data.py +++ b/kalshi/resources/live_data.py @@ -15,6 +15,7 @@ GetGameStatsResponse, GetLiveDataResponse, GetLiveDatasResponse, + GetWeatherIndexResponse, LiveData, ) from kalshi.resources._base import AsyncResource, SyncResource, _bool_param, _params, _seg @@ -136,6 +137,36 @@ def game_stats( ) return GetGameStatsResponse.model_validate(data) + def weather( + self, + city: str, + *, + from_ts: int | None = None, + to: int | None = None, + last_sec: int | None = None, + detailed: bool | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetWeatherIndexResponse: + """``GET /live_data/weather/{city}`` — published weather index timeseries. + + ``from_ts`` is the spec ``from`` query (unix milliseconds). Named + ``from_ts`` to avoid the Python keyword; the wire key is still + ``from``. Mutually exclusive with ``last_sec`` per spec. + """ + params = _params( + to=to, + last_sec=last_sec, + detailed=_bool_param(detailed), + ) + if from_ts is not None: + params["from"] = from_ts + data = self._get( + f"/live_data/weather/{_seg(city, name='city')}", + params=params, + extra_headers=extra_headers, + ) + return GetWeatherIndexResponse.model_validate(data) + class AsyncLiveDataResource(AsyncResource): """Async live-data API.""" @@ -248,3 +279,28 @@ async def game_stats( extra_headers=extra_headers, ) return GetGameStatsResponse.model_validate(data) + + async def weather( + self, + city: str, + *, + from_ts: int | None = None, + to: int | None = None, + last_sec: int | None = None, + detailed: bool | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetWeatherIndexResponse: + """Async :meth:`LiveDataResource.weather`.""" + params = _params( + to=to, + last_sec=last_sec, + detailed=_bool_param(detailed), + ) + if from_ts is not None: + params["from"] = from_ts + data = await self._get( + f"/live_data/weather/{_seg(city, name='city')}", + params=params, + extra_headers=extra_headers, + ) + return GetWeatherIndexResponse.model_validate(data) diff --git a/kalshi/resources/orders.py b/kalshi/resources/orders.py index 2753fbf..5b0cd99 100644 --- a/kalshi/resources/orders.py +++ b/kalshi/resources/orders.py @@ -317,6 +317,24 @@ def cancel_v2( raise KalshiError("Expected CancelOrderV2Response body, got 204 No Content.") return CancelOrderV2Response.model_validate(data) + def cancel_all_v2( + self, + *, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """``DELETE /portfolio/events/orders`` — cancel up to 10,000 resting orders. + + Not retried (DELETE). Returns 204. + """ + self._require_auth() + params = _params(subaccount=subaccount) + self._delete( + "/portfolio/events/orders", + params=params, + extra_headers=extra_headers, + ) + def amend_v2( self, order_id: str, @@ -608,6 +626,21 @@ async def cancel_v2( raise KalshiError("Expected CancelOrderV2Response body, got 204 No Content.") return CancelOrderV2Response.model_validate(data) + async def cancel_all_v2( + self, + *, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """Async :meth:`OrdersResource.cancel_all_v2`.""" + self._require_auth() + params = _params(subaccount=subaccount) + await self._delete( + "/portfolio/events/orders", + params=params, + extra_headers=extra_headers, + ) + async def amend_v2( self, order_id: str, diff --git a/kalshi/resources/portfolio.py b/kalshi/resources/portfolio.py index 4f8d951..91eb5e2 100644 --- a/kalshi/resources/portfolio.py +++ b/kalshi/resources/portfolio.py @@ -10,16 +10,20 @@ from kalshi.models.portfolio import ( Balance, Deposit, + GetTargetBalanceAllocationResponse, IntraExchangeInstanceTransfer, MarketPosition, PositionsResponse, + SetTargetBalanceAllocationRequest, Settlement, + TargetBalanceAllocationInput, TotalRestingOrderValue, Withdrawal, ) from kalshi.resources._base import ( AsyncResource, SyncResource, + _check_request_exclusive, _fills_params, _params, _seg, @@ -433,6 +437,42 @@ def get_intra_exchange_transfer( ) return IntraExchangeInstanceTransfer.model_validate(data.get("transfer", data)) + def target_balance_allocation( + self, *, extra_headers: dict[str, str] | None = None + ) -> GetTargetBalanceAllocationResponse: + """``GET /portfolio/target_balance_allocation`` — per-shard sweep targets.""" + self._require_auth() + data = self._get( + "/portfolio/target_balance_allocation", extra_headers=extra_headers + ) + return GetTargetBalanceAllocationResponse.model_validate(data) + + def set_target_balance_allocation( + self, + *, + request: SetTargetBalanceAllocationRequest | None = None, + allocations: list[TargetBalanceAllocationInput] | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """``POST /portfolio/target_balance_allocation`` — replace sweep targets. + + Not retried (POST). + """ + self._require_auth() + _check_request_exclusive(request, allocations=allocations) + if request is None: + if allocations is None: + raise TypeError( + "set_target_balance_allocation() requires `allocations` " + "(or pass `request=...`)" + ) + request = SetTargetBalanceAllocationRequest(allocations=allocations) + self._post_void( + "/portfolio/target_balance_allocation", + json=request.model_dump(exclude_none=True, by_alias=True, mode="json"), + extra_headers=extra_headers, + ) + class AsyncPortfolioResource(AsyncResource): """Async portfolio API.""" @@ -792,3 +832,36 @@ async def get_intra_exchange_transfer( extra_headers=extra_headers, ) return IntraExchangeInstanceTransfer.model_validate(data.get("transfer", data)) + + async def target_balance_allocation( + self, *, extra_headers: dict[str, str] | None = None + ) -> GetTargetBalanceAllocationResponse: + """Async :meth:`PortfolioResource.target_balance_allocation`.""" + self._require_auth() + data = await self._get( + "/portfolio/target_balance_allocation", extra_headers=extra_headers + ) + return GetTargetBalanceAllocationResponse.model_validate(data) + + async def set_target_balance_allocation( + self, + *, + request: SetTargetBalanceAllocationRequest | None = None, + allocations: list[TargetBalanceAllocationInput] | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """Async :meth:`PortfolioResource.set_target_balance_allocation`.""" + self._require_auth() + _check_request_exclusive(request, allocations=allocations) + if request is None: + if allocations is None: + raise TypeError( + "set_target_balance_allocation() requires `allocations` " + "(or pass `request=...`)" + ) + request = SetTargetBalanceAllocationRequest(allocations=allocations) + await self._post_void( + "/portfolio/target_balance_allocation", + json=request.model_dump(exclude_none=True, by_alias=True, mode="json"), + extra_headers=extra_headers, + ) diff --git a/kalshi/ws/models/fill.py b/kalshi/ws/models/fill.py index 7f7e013..d2b40b5 100644 --- a/kalshi/ws/models/fill.py +++ b/kalshi/ws/models/fill.py @@ -43,6 +43,7 @@ class FillPayload(BaseModel): outcome_side: SideLiteral book_side: BookSideLiteral ts_ms: int + exchange_index: int model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/ws/models/user_orders.py b/kalshi/ws/models/user_orders.py index 7972d57..de33635 100644 --- a/kalshi/ws/models/user_orders.py +++ b/kalshi/ws/models/user_orders.py @@ -69,6 +69,7 @@ class UserOrdersPayload(BaseModel): created_ts_ms: int last_updated_ts_ms: int | None = None expiration_ts_ms: int | None = None + exchange_index: int model_config = {"extra": "allow", "populate_by_name": True} diff --git a/pyproject.toml b/pyproject.toml index fc9041e..fc22d3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "12.0.0" +version = "13.0.0" description = "A professional Python SDK for the Kalshi prediction markets and Perps (margin) APIs" readme = "README.md" license = { text = "MIT" } diff --git a/specs/asyncapi.yaml b/specs/asyncapi.yaml index 94268bb..f1b9e9b 100644 --- a/specs/asyncapi.yaml +++ b/specs/asyncapi.yaml @@ -373,6 +373,7 @@ channels: - If you subscribe without any `index_ids`, no value events flow until you add indices or switch to `["all"]` - `sid` identifies the subscription stream; use it for `update_subscription` and `unsubscribe` - Missing `index_ids` for `subscribe_indices`/`unsubscribe_indices` returns an `error` with `code: 24` ("Index IDs required"); unsupported actions return a standard websocket `error` + - This channel is real-time only. Historical index values — including intra-second granularity on some indices — are available over REST via the [CF Benchmarks REST Passthrough](/cfbenchmarks/rest-passthrough) messages: cfbenchmarksValue: $ref: '#/components/messages/cfbenchmarksValue' @@ -1524,6 +1525,7 @@ components: trade_id: "d91bc706-ee49-470d-82d8-11418bda6fed" order_id: "ee587a1c-8b87-4dcf-b721-9f6f790619fa" market_ticker: "HIGHNY-22DEC23-B53.5" + exchange_index: 2 is_taker: true side: "yes" yes_price_dollars: "0.750" @@ -1739,6 +1741,7 @@ components: order_id: "ee587a1c-8b87-4dcf-b721-9f6f790619fa" user_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" ticker: "FED-23DEC-T3.00" + exchange_index: 2 status: "resting" side: "yes" is_yes: true @@ -2666,7 +2669,7 @@ components: $ref: '#/components/schemas/subscriptionId' msg: type: object - required: ["trade_id", "order_id", "market_ticker", "is_taker", "side", "yes_price_dollars", "count_fp", "fee_cost", "action", "outcome_side", "book_side", "ts", "ts_ms", "post_position_fp", "purchased_side"] + required: ["trade_id", "order_id", "market_ticker", "exchange_index", "is_taker", "side", "yes_price_dollars", "count_fp", "fee_cost", "action", "outcome_side", "book_side", "ts", "ts_ms", "post_position_fp", "purchased_side"] properties: trade_id: type: string @@ -2679,6 +2682,9 @@ components: market_ticker: $ref: '#/components/schemas/marketTicker' description: Unique identifier for markets. This is what you use to differentiate fills for different markets + exchange_index: + type: integer + description: Identifier for the exchange shard where the fill occurred is_taker: type: boolean description: If you were a taker on this fill @@ -2934,7 +2940,7 @@ components: fee_type_override: type: string nullable: true - enum: [quadratic, quadratic_with_maker_fees, flat, null] + enum: [quadratic, quadratic_with_maker_fees, quadratic_with_combo_maker_fees, flat, null] description: Event fee type override. `null` when the override has been cleared. fee_multiplier_override: type: number @@ -3023,7 +3029,7 @@ components: $ref: '#/components/schemas/subscriptionId' msg: type: object - required: ["order_id", "user_id", "ticker", "status", "side", "is_yes", "outcome_side", "book_side", "yes_price_dollars", "fill_count_fp", "remaining_count_fp", "initial_count_fp", "taker_fill_cost_dollars", "maker_fill_cost_dollars", "taker_fees_dollars", "maker_fees_dollars", "client_order_id", "created_time", "created_ts_ms"] + required: ["order_id", "user_id", "ticker", "exchange_index", "status", "side", "is_yes", "outcome_side", "book_side", "yes_price_dollars", "fill_count_fp", "remaining_count_fp", "initial_count_fp", "taker_fill_cost_dollars", "maker_fill_cost_dollars", "taker_fees_dollars", "maker_fees_dollars", "client_order_id", "created_time", "created_ts_ms"] properties: order_id: type: string @@ -3036,6 +3042,9 @@ components: ticker: $ref: '#/components/schemas/marketTicker' description: Market ticker for the order + exchange_index: + type: integer + description: Identifier for the exchange shard where the order resides status: type: string description: Current order status diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 1e29dc7..f28cb28 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.0 info: title: Kalshi Trade API Manual Endpoints - version: 3.28.0 + version: 3.29.0 description: Manually defined OpenAPI spec for endpoints being migrated to spec-first approach servers: @@ -1055,6 +1055,33 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + delete: + operationId: CancelAllOrders + summary: Cancel Up to 10,000 Orders + description: Cancels up to 10,000 resting event-market orders for the authenticated Direct member across every exchange shard. If `subaccount` is omitted, matching orders may come from any subaccount. If it is provided, only orders for that subaccount are eligible. When more than 10,000 orders match, the orders selected for cancellation are arbitrary and no ordering guarantees should be relied upon. + x-mint: + content: | + + **Rate limit:** A request consumes the same number of write tokens as a batch cancel containing the maximum number of orders allowed for the caller's API tier. + + tags: + - orders + parameters: + - $ref: '#/components/parameters/SubaccountQuery' + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + responses: + '204': + description: Up to 10,000 matching resting orders were cancelled + '401': + $ref: '#/components/responses/UnauthorizedError' + '429': + $ref: '#/components/responses/RateLimitError' + '500': + $ref: '#/components/responses/InternalServerError' + /portfolio/events/orders/batched: post: operationId: BatchCreateOrdersV2 @@ -1149,10 +1176,14 @@ paths: parameters: - $ref: '#/components/parameters/OrderIdPath' - $ref: '#/components/parameters/SubaccountQueryDefaultPrimary' - - $ref: '#/components/parameters/ExchangeIndexQuery' + - name: exchange_index + in: query + description: Exchange shard index. If omitted, auto-routes when market_ticker is provided; otherwise defaults to 0. Use -1 to require auto-routing by market ticker. + schema: + $ref: '#/components/schemas/ExchangeIndex' - name: market_ticker in: query - description: Market ticker. Required when exchange_index is -1 (auto). + description: Market ticker used for auto-routing when exchange_index is omitted or -1. schema: type: string x-go-type-skip-optional-pointer: true @@ -1472,7 +1503,7 @@ paths: get: operationId: GetBalance summary: Get Balance - description: "Endpoint for getting the balance and portfolio value of a member. `portfolio_value` is always scoped to the requested `exchange_index` (defaulting to 0). When `subaccount` is omitted, `balance` is the primary account's aggregate available balance; pass `subaccount` explicitly (0 for primary, 1-63 for subaccounts) to read that subaccount's balance on the requested exchange index instead. This endpoint also accepts API keys with the 'read::portfolio_balance' scope." + description: "Returns the balance and portfolio value for a member. Both values include all exchange indexes unless `exchange_index` is provided. Pass `subaccount` to use a subaccount instead of the primary account. This endpoint also accepts API keys with the 'read::portfolio_balance' scope." tags: - portfolio security: @@ -1485,8 +1516,7 @@ paths: in: query schema: $ref: '#/components/schemas/ExchangeIndex' - x-go-type-skip-optional-pointer: true - description: 'Exchange index to scope the returned portfolio value to, and the balance when `subaccount` is provided. Defaults to 0.' + description: 'Exchange index used to scope the balance and portfolio value. If omitted, both include all exchange indexes.' responses: '200': description: Balance retrieved successfully @@ -1503,7 +1533,12 @@ paths: post: operationId: IntraExchangeInstanceTransfer summary: Intra Account Transfer - description: 'Endpoint for transferring funds within the same account.' + description: | + Transfers funds within the same account. + + When `source_exchange_shard` and `destination_exchange_shard` are the same, Kalshi treats the request as a subaccount transfer. The returned transfer ID appears in the subaccount transfer history. + + Cross-exchange-index subaccount transfers run in up to three non-atomic steps. If a later step fails, completed steps are not undone, so funds may remain in the primary account on the source or destination exchange index. tags: - portfolio security: @@ -3035,6 +3070,73 @@ paths: description: Internal server error + /live_data/weather/{city}: + get: + operationId: GetWeatherIndex + summary: Get Weather Index + description: >- + Get the Kalshi-computed city temperature index: the canonical + minute-resolution series behind hourly temperature markets. City-keyed + and independent of any event — the series exists whenever the city's + index is configured. Values are Fahrenheit rounded to 0.01. Minutes + where the index quorum failed carry no value and are never returned as + points, so gaps in the series are real gaps. With `detailed=true` each + point additionally carries every member station's reported reading and + quality-control disposition — the pre-incorporation breakdown. + tags: + - live-data + parameters: + - name: city + in: path + required: true + description: Index city ID (e.g. `miami`) + schema: + type: string + - name: from + in: query + required: false + description: >- + Window start, unix milliseconds (inclusive). Defaults to `to` + minus 24 hours. Must be paired with `to` unless `last_sec` is + used. + schema: + type: integer + format: int64 + - name: to + in: query + required: false + description: Window end, unix milliseconds (inclusive). Defaults to now. + schema: + type: integer + format: int64 + - name: last_sec + in: query + required: false + description: >- + Trailing window in seconds; equivalent to `from=now-last_sec`, + `to=now`. Mutually exclusive with `from`/`to`. + schema: + type: integer + format: int64 + - name: detailed + in: query + required: false + description: Include per-station audit readings on every point. + schema: + type: boolean + responses: + '200': + description: Weather index retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetWeatherIndexResponse' + '400': + description: Unknown city or invalid window parameters + '500': + description: Internal server error + + /structured_targets: get: operationId: GetStructuredTargets @@ -3063,7 +3165,7 @@ paths: example: basketball_player - name: competition in: query - description: 'Filter by competition. Matches against the league, conference, division, or tour in the structured target details.' + description: 'Filter by competition. Matches against the league, conference, division, or tour in the structured target details, or any entry in the details leagues array.' required: false schema: type: string @@ -3371,10 +3473,10 @@ paths: - name: type in: query required: false - description: 'Type filter. Can be "all", "liquidity", or "volume". Default is "all".' + description: 'Type filter. Can be "all", "liquidity", "volume", or "margin_maker_volume". Default is "all".' schema: type: string - enum: [all, liquidity, volume] + enum: [all, liquidity, volume, margin_maker_volume] - name: incentive_description in: query required: false @@ -3479,6 +3581,65 @@ paths: '500': description: Internal server error + /portfolio/target_balance_allocation: + get: + operationId: GetTargetBalanceAllocation + summary: Get Target Balance Allocation + description: | + Retrieves the caller's target balance allocation across exchange indexes. + tags: + - portfolio + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + responses: + '200': + description: Target balance allocation retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetTargetBalanceAllocationResponse' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '500': + $ref: '#/components/responses/InternalServerError' + post: + operationId: SetTargetBalanceAllocation + summary: Set Target Balance Allocation + description: | + Replaces the caller's target balance allocation across exchange indexes. + Percentages must total 100. Passing an empty allocations array disables automatic rebalancing. + tags: + - portfolio + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SetTargetBalanceAllocationRequest' + responses: + '200': + description: Target balance allocation updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '500': + $ref: '#/components/responses/InternalServerError' + /fcm/positions: get: operationId: GetFCMPositions @@ -4054,6 +4215,7 @@ components: ExchangeIndexQuery: name: exchange_index in: query + description: Identifier for an exchange shard. Defaults to 0. schema: $ref: '#/components/schemas/ExchangeIndex' x-go-type-skip-optional-pointer: true @@ -4203,15 +4365,16 @@ components: ExchangeIndex: type: integer - description: "Identifier for an exchange shard. Defaults to 0 if unspecified." + description: "Identifier for an exchange shard." example: 0 FeeType: type: string - enum: [quadratic, quadratic_with_maker_fees, flat] + enum: [quadratic, quadratic_with_maker_fees, quadratic_with_combo_maker_fees, flat] x-enum-varnames: - FeeTypeQuadratic - FeeTypeQuadraticWithMakerFees + - FeeTypeQuadraticWithComboMakerFees - FeeTypeFlat description: Fee type for a series or scheduled fee override. @@ -4403,6 +4566,11 @@ components: description: List of all API keys associated with the user items: $ref: '#/components/schemas/ApiKey' + api_key_region_expiration_ts: + type: integer + format: int64 + nullable: true + description: Unix timestamp (seconds) when the account's location attestation for API key requests expires; a past value means the attestation has lapsed. Absent when the account has never attested. CreateApiKeyRequest: type: object @@ -5095,6 +5263,116 @@ components: live_data: $ref: '#/components/schemas/EventLiveData' + GetWeatherIndexResponse: + type: object + required: + - city + - units + - timeseries + properties: + city: + type: string + description: Index city ID. + config_version: + type: string + description: >- + Index configuration version of the newest returned point (e.g. + `miami-temperature-v1.0`). Empty when no points matched the + window. + units: + type: string + description: Always `fahrenheit`. + timeseries: + type: array + items: + $ref: '#/components/schemas/WeatherIndexPoint' + + WeatherIndexPoint: + type: object + required: + - t + - status + properties: + t: + type: integer + format: int64 + description: Event minute, unix milliseconds UTC. + v: + type: number + format: double + description: >- + Published index value, Fahrenheit rounded to 0.01. Absent on + `incomplete` points, which have no canonical value yet. + status: + type: string + description: >- + `normal` (every member contributed its exact-minute primary + observation) or `degraded` (a member was absent, fallback-fed, or + substituted by quality control; the value is equally + settlement-eligible). With `detailed=true`, trailing minutes still + inside their receipt deadline are additionally served as + `incomplete`: no value, and stations carrying the raw readings + recorded so far (code `pending`, not yet quality-controlled — more + readings may still arrive, and the canonical value may differ). + contributors: + type: integer + description: >- + Number of accepted member stations backing the point. Absent on + `incomplete` points. + stations: + type: array + description: >- + Per-station audit readings (only with `detailed=true`), sorted by + station ID — every configured member's reported value and QC + disposition before incorporation into the index. + items: + $ref: '#/components/schemas/WeatherIndexStationReading' + + WeatherIndexStationReading: + type: object + required: + - station_id + - code + properties: + station_id: + type: string + description: Member station (e.g. `KMIA1M`) or its official fallback ID. + code: + type: string + description: >- + Disposition: `ok` (accepted), `missing` (no eligible + observation), `late` (received after the deadline; diagnostic + only), a QC rejection (`range`, `rate_spatial`, `extreme`), or + `pending` (raw reading on an `incomplete` minute, not yet + quality-controlled). + source: + type: string + description: >- + `hf_asos` (exact-minute primary) or `metar` (carried-forward + official observation). Absent when no reading was available. + temp_f: + type: number + format: double + description: >- + Raw reported temperature in Fahrenheit (unrounded — only the + published index value carries output rounding). Absent for + `missing` members. + obs_time_ms: + type: integer + format: int64 + description: >- + Observation time for carried-forward fallbacks (differs from the + event minute). Absent for exact-minute primaries. + received_at_ms: + type: integer + format: int64 + description: Local wire-receipt time backing the eligibility deadline. + primary_code: + type: string + description: >- + Why the primary observation was passed over when a fallback was + selected instead. + GetGameStatsResponse: type: object properties: @@ -5138,14 +5416,14 @@ components: balance: type: integer format: int64 - description: Member's available balance in cents. This represents the amount available for trading. + description: Member's available balance in cents for the requested account and exchange index. It includes all exchange indexes when `exchange_index` is omitted. balance_dollars: $ref: '#/components/schemas/FixedPointDollars' - description: Member's available balance as a fixed-point dollar string. This represents the amount available for trading. + description: Member's available balance as a fixed-point dollar string for the requested account and exchange index. It includes all exchange indexes when `exchange_index` is omitted. portfolio_value: type: integer format: int64 - description: Member's portfolio value in cents. This is the current value of the positions held by the requested subaccount on the requested exchange index. + description: Member's portfolio value in cents for the requested account and exchange index. It includes all exchange indexes when `exchange_index` is omitted. updated_ts: type: integer format: int64 @@ -5345,6 +5623,7 @@ components: type: object required: - ticker + - exchange_index - event_ticker - market_result - yes_count_fp @@ -5358,6 +5637,8 @@ components: ticker: type: string description: The ticker symbol of the market that was settled. + exchange_index: + $ref: '#/components/schemas/ExchangeIndex' event_ticker: type: string description: The event ticker symbol of the market that was settled. @@ -5812,6 +6093,7 @@ components: type: object required: - ticker + - exchange_index - total_traded_dollars - position_fp - market_exposure_dollars @@ -5823,6 +6105,8 @@ components: type: string description: Unique identifier for the market x-go-type-skip-optional-pointer: true + exchange_index: + $ref: '#/components/schemas/ExchangeIndex' total_traded_dollars: $ref: '#/components/schemas/FixedPointDollars' description: Total spent on this market in dollars @@ -5989,7 +6273,7 @@ components: description: The ticker symbol of the market associated with this incentive program incentive_type: type: string - enum: ['liquidity', 'volume'] + enum: ['liquidity', 'volume', 'margin_maker_volume'] description: Type of incentive program incentive_description: type: string @@ -6018,6 +6302,11 @@ components: $ref: '#/components/schemas/FixedPointCount' nullable: true description: String representation of the target size for the incentive program (optional) + max_reward_per_account: + type: integer + format: int64 + nullable: true + description: Maximum reward per account in centi-cents (optional) GetTradesResponse: type: object @@ -6036,6 +6325,7 @@ components: type: object required: - fill_id + - exchange_index - trade_id - order_id - ticker @@ -6051,6 +6341,8 @@ components: fill_id: type: string description: Unique identifier for this fill + exchange_index: + $ref: '#/components/schemas/ExchangeIndex' trade_id: type: string description: Unique identifier for this fill (legacy field name, same as fill_id) @@ -6185,6 +6477,67 @@ components: type: object description: An empty response body + TargetBalanceAllocation: + type: object + required: + - exchange_index + - percent + properties: + exchange_index: + type: integer + minimum: 0 + description: Exchange index that receives this percentage of sweepable balance + percent: + type: integer + minimum: 0 + maximum: 100 + description: Target percentage of sweepable balance for the exchange index + + TargetBalanceAllocationInput: + type: object + required: + - exchange_index + - percent + properties: + exchange_index: + type: integer + minimum: 0 + description: Exchange index that receives this percentage of sweepable balance + x-go-type: '*int' + x-oapi-codegen-extra-tags: + validate: required,gte=0 + percent: + type: integer + minimum: 0 + maximum: 100 + description: Target percentage of sweepable balance for the exchange index + x-go-type: '*int' + x-oapi-codegen-extra-tags: + validate: required,gte=0,lte=100 + + GetTargetBalanceAllocationResponse: + type: object + required: + - allocations + properties: + allocations: + type: array + items: + $ref: '#/components/schemas/TargetBalanceAllocation' + + SetTargetBalanceAllocationRequest: + type: object + required: + - allocations + properties: + allocations: + type: array + maxItems: 101 + x-oapi-codegen-extra-tags: + validate: max=101,dive + items: + $ref: '#/components/schemas/TargetBalanceAllocationInput' + IntraExchangeInstanceTransferRequest: type: object required: @@ -6220,6 +6573,20 @@ components: description: Destination exchange shard index (default 0) x-oapi-codegen-extra-tags: validate: "gte=0,lte=100" + source_subaccount: + type: integer + default: 0 + x-go-type-skip-optional-pointer: true + description: Source subaccount number (default 0 for the primary account). Only supported for event contract to event contract transfers. + x-oapi-codegen-extra-tags: + validate: "gte=0" + destination_subaccount: + type: integer + default: 0 + x-go-type-skip-optional-pointer: true + description: Destination subaccount number (default 0 for the primary account). Only supported for event contract to event contract transfers. + x-oapi-codegen-extra-tags: + validate: "gte=0" IntraExchangeInstanceTransferResponse: type: object @@ -6375,6 +6742,7 @@ components: allOf: - $ref: '#/components/schemas/ExchangeIndex' default: 0 + description: Identifier for an exchange shard. Defaults to 0. x-go-type-skip-optional-pointer: true UpdateOrderGroupLimitRequest: @@ -6758,7 +7126,6 @@ components: subaccount: type: integer description: The subaccount number to create the RFQ for (direct members only; 0 for primary, 1-63 for subaccounts) - x-go-type-skip-optional-pointer: true CreateRFQResponse: type: object @@ -7350,9 +7717,7 @@ components: exchange_index: allOf: - $ref: '#/components/schemas/ExchangeIndex' - default: 0 - description: "Exchange shard index. Defaults to 0. Use -1 to auto-route by market ticker." - x-go-type-skip-optional-pointer: true + description: "Exchange shard index. If omitted, auto-routes when ticker is provided; otherwise defaults to 0. Use -1 to require auto-routing by ticker." CreateOrderV2Response: type: object @@ -7430,11 +7795,10 @@ components: exchange_index: allOf: - $ref: '#/components/schemas/ExchangeIndex' - default: 0 - x-go-type-skip-optional-pointer: true + description: "Exchange shard index. If omitted, auto-routes when market_ticker is provided; otherwise defaults to 0. Use -1 to require auto-routing by market ticker." market_ticker: type: string - description: Market ticker. Required when exchange_index is -1 (auto). + description: Market ticker used for auto-routing when exchange_index is omitted or -1. x-go-type-skip-optional-pointer: true DecreaseOrderV2Response: @@ -7506,9 +7870,7 @@ components: exchange_index: allOf: - $ref: '#/components/schemas/ExchangeIndex' - default: 0 - description: "Exchange shard index. Defaults to 0. Use -1 to auto-route by market ticker." - x-go-type-skip-optional-pointer: true + description: "Exchange shard index. If omitted, auto-routes when ticker is provided; otherwise defaults to 0. Use -1 to require auto-routing by ticker." AmendOrderV2Response: type: object @@ -7674,12 +8036,10 @@ components: exchange_index: allOf: - $ref: '#/components/schemas/ExchangeIndex' - default: 0 - description: "Exchange shard index. Defaults to 0. Use -1 to auto-route by market ticker." - x-go-type-skip-optional-pointer: true + description: "Exchange shard index. If omitted, auto-routes when market_ticker is provided; otherwise defaults to 0. Use -1 to require auto-routing by market ticker." market_ticker: type: string - description: Market ticker. Required when exchange_index is -1 (auto). + description: Market ticker used for auto-routing when exchange_index is omitted or -1. x-go-type-skip-optional-pointer: true BatchCancelOrdersV2Response: @@ -8226,7 +8586,8 @@ components: x-go-type-skip-optional-pointer: true available_on_brokers: type: boolean - description: Whether this event is available to trade on brokers. + description: Deprecated. No longer populated and always returns false; it will be removed in a future release. + deprecated: true product_metadata: type: object nullable: true @@ -8313,7 +8674,7 @@ components: fee_type: allOf: - $ref: '#/components/schemas/FeeType' - description: "FeeType is a string representing the series' fee structure. Fee structures can be found at https://kalshi.com/docs/kalshi-fee-schedule.pdf. 'quadratic' is described by the General Trading Fees Table, 'quadratic_with_maker_fees' is described by the General Trading Fees Table with maker fees described in the Maker Fees section, 'flat' is described by the Specific Trading Fees Table." + description: "FeeType is a string representing the series' fee structure. Fee structures can be found at https://kalshi.com/docs/kalshi-fee-schedule.pdf. 'quadratic' is described by the General Trading Fees Table, 'quadratic_with_maker_fees' is described by the General Trading Fees Table with maker fees described in the Maker Fees section, 'quadratic_with_combo_maker_fees' is the same maker-fee structure with a 0.5 maker multiplier instead of 0.25, 'flat' is described by the Specific Trading Fees Table." fee_multiplier: type: number format: double diff --git a/specs/perps_openapi.yaml b/specs/perps_openapi.yaml index 5cb16c2..7dda126 100644 --- a/specs/perps_openapi.yaml +++ b/specs/perps_openapi.yaml @@ -52,8 +52,9 @@ paths: summary: Get FCM Subtrader Risk Controls description: | Returns the initial margin caps configured for an FCM member's subtrader on the margined - exchange. A cap with no market_ticker applies across all markets; the remaining caps are - scoped to a single market each. Markets without a cap are omitted. + exchange. A cap with neither market_ticker nor asset_class applies across all markets; the + remaining caps are scoped to a single market or a single asset class each. Every cap in + scope for an order is enforced independently. Markets without a cap are omitted. tags: - fcm security: @@ -74,6 +75,14 @@ paths: schema: type: string x-go-type-skip-optional-pointer: true + - name: asset_class + in: query + required: false + description: Restricts the response to the cap scoped to this asset class when supplied. Mutually exclusive with market_ticker. + schema: + type: string + x-go-type-skip-optional-pointer: true + enum: [Crypto, Equities, Metals, FX, Energy, Indices, Rates, Compute, GPU] responses: '200': description: Risk controls retrieved successfully @@ -148,6 +157,14 @@ paths: schema: type: string x-go-type-skip-optional-pointer: true + - name: asset_class + in: query + required: false + description: Scopes the initial margin cap removal to this asset class when supplied. Mutually exclusive with market_ticker. + schema: + type: string + x-go-type-skip-optional-pointer: true + enum: [Crypto, Equities, Metals, FX, Energy, Indices, Rates, Compute, GPU] responses: '200': description: Risk controls deleted successfully @@ -303,6 +320,33 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + delete: + operationId: CancelAllMarginOrders + summary: Cancel Up to 10,000 Orders + description: Cancels up to 10,000 resting margin orders for the authenticated Direct member. If `subaccount` is omitted, matching orders may come from any subaccount. If it is provided, only orders for that subaccount are eligible. When more than 10,000 orders match, the orders selected for cancellation are arbitrary and no ordering guarantees should be relied upon. + x-mint: + content: | + + **Rate limit:** A request consumes the same number of write tokens as a batch cancel containing the maximum number of orders allowed for the caller's margin API tier. + + tags: + - orders + parameters: + - $ref: '#/components/parameters/SubaccountQuery' + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + responses: + '204': + description: Up to 10,000 matching resting margin orders were cancelled + '401': + $ref: '#/components/responses/UnauthorizedError' + '429': + $ref: '#/components/responses/RateLimitError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/orders/{order_id}: get: operationId: GetMarginOrder @@ -1292,6 +1336,293 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /margin/isolated/positions/{ticker}/exit_trigger: + put: + operationId: SetIsolatedMarginExitTrigger + summary: Set Isolated Exit Trigger + description: >- + Endpoint for setting a stop-loss / take-profit bracket or a trailing stop on the isolated + margin position for a market. Requires an open position, closes it in full, and fires + reduce-only orders. + tags: + - exit-triggers + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/ExitTriggerTickerPath' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SetIsolatedExitTriggerRequest' + responses: + '200': + description: Exit trigger set successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ExitTrigger' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/NotFoundError' + '409': + $ref: '#/components/responses/ConflictError' + '429': + $ref: '#/components/responses/RateLimitError' + '500': + $ref: '#/components/responses/InternalServerError' + get: + operationId: GetIsolatedMarginExitTriggers + summary: Get Isolated Exit Triggers + description: >- + Endpoint for retrieving the live exit triggers on the isolated margin position for a + market. + tags: + - exit-triggers + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/ExitTriggerTickerPath' + - $ref: '#/components/parameters/ExitTriggerKindQuery' + responses: + '200': + description: Exit triggers retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetExitTriggersResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '429': + $ref: '#/components/responses/RateLimitError' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: DeleteIsolatedMarginExitTrigger + summary: Cancel Isolated Exit Trigger + description: >- + Endpoint for cancelling exit triggers on the isolated margin position for a market. + Succeeds even when nothing is live. The position itself is unaffected. + tags: + - exit-triggers + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/ExitTriggerTickerPath' + - $ref: '#/components/parameters/ExitTriggerKindQuery' + responses: + '204': + description: Exit trigger canceled + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '429': + $ref: '#/components/responses/RateLimitError' + '500': + $ref: '#/components/responses/InternalServerError' + + /margin/cross/positions/{ticker}/exit_trigger: + put: + operationId: SetCrossMarginExitTrigger + summary: Set Cross Exit Trigger + description: >- + Endpoint for setting a stop-loss / take-profit bracket or a trailing stop on a + non-isolated margin position. A position holds one trailing stop and up to 20 brackets, + so long as their counts fit the position. Requires an open position unless + `anchor_order_id` is supplied, and fires reduce-only orders. + tags: + - exit-triggers + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/ExitTriggerTickerPath' + - $ref: '#/components/parameters/SubaccountQueryDefaultPrimary' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SetCrossExitTriggerRequest' + responses: + '200': + description: Exit trigger set successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ExitTrigger' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/NotFoundError' + '409': + $ref: '#/components/responses/ConflictError' + '429': + $ref: '#/components/responses/RateLimitError' + '500': + $ref: '#/components/responses/InternalServerError' + get: + operationId: GetCrossMarginExitTriggers + summary: Get Cross Exit Triggers + description: >- + Endpoint for retrieving the live exit triggers on a non-isolated margin position. + tags: + - exit-triggers + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/ExitTriggerTickerPath' + - $ref: '#/components/parameters/SubaccountQueryDefaultPrimary' + - $ref: '#/components/parameters/ExitTriggerKindQuery' + responses: + '200': + description: Exit triggers retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetExitTriggersResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '429': + $ref: '#/components/responses/RateLimitError' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: DeleteCrossMarginExitTrigger + summary: Cancel Cross Exit Triggers + description: >- + Endpoint for cancelling exit triggers on a non-isolated margin position, including + partial and order-anchored brackets. Succeeds even when nothing is live; use the + `{trigger_id}` route to cancel a single bracket. + tags: + - exit-triggers + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/ExitTriggerTickerPath' + - $ref: '#/components/parameters/SubaccountQueryDefaultPrimary' + - $ref: '#/components/parameters/ExitTriggerKindQuery' + responses: + '204': + description: Exit triggers canceled + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '429': + $ref: '#/components/responses/RateLimitError' + '500': + $ref: '#/components/responses/InternalServerError' + + /margin/cross/positions/{ticker}/exit_trigger/{trigger_id}: + put: + operationId: UpdateCrossMarginExitTrigger + summary: Update Cross Exit Trigger + description: >- + Endpoint for updating one bracket's leg prices, leaving any others on the position + untouched. At least one leg is required; cancel with `DELETE`. Trailing stops are not + addressed by id — use the collection route with `kind=trailing`. + tags: + - exit-triggers + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/ExitTriggerTickerPath' + - $ref: '#/components/parameters/ExitTriggerIdPath' + - $ref: '#/components/parameters/SubaccountQueryDefaultPrimary' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateExitTriggerRequest' + responses: + '200': + description: Exit trigger set successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ExitTrigger' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/NotFoundError' + '429': + $ref: '#/components/responses/RateLimitError' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: DeleteCrossMarginExitTriggerById + summary: Cancel Cross Exit Trigger by ID + description: >- + Endpoint for cancelling one bracket, leaving any others on the position live. Trailing + stops are not addressed by id — use the collection route with `kind=trailing`. + tags: + - exit-triggers + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/ExitTriggerTickerPath' + - $ref: '#/components/parameters/ExitTriggerIdPath' + - $ref: '#/components/parameters/SubaccountQueryDefaultPrimary' + responses: + '204': + description: Exit trigger canceled + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '404': + $ref: '#/components/responses/NotFoundError' + '429': + $ref: '#/components/responses/RateLimitError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/order_groups/{order_group_id}/limit: put: operationId: UpdateMarginOrderGroupLimit @@ -1499,6 +1830,11 @@ components: type: string description: Scopes the initial margin cap to this market when supplied. x-go-type-skip-optional-pointer: true + asset_class: + type: string + description: Scopes the initial margin cap to this asset class when supplied. Mutually exclusive with market_ticker. + x-go-type-skip-optional-pointer: true + enum: [Crypto, Equities, Metals, FX, Energy, Indices, Rates, Compute, GPU] im_cap: allOf: - $ref: '#/components/schemas/FixedPointDollars' @@ -1527,8 +1863,13 @@ components: description: The subtrader the initial margin cap applies to. market_ticker: type: string - description: The market the cap is scoped to. Absent when the cap applies across all markets. + description: Present only on a market-scoped cap. + x-go-type-skip-optional-pointer: true + asset_class: + type: string + description: Present only on an asset-class-scoped cap. A cap with neither market_ticker nor asset_class applies across all markets. x-go-type-skip-optional-pointer: true + enum: [Crypto, Equities, Metals, FX, Energy, Indices, Rates, Compute, GPU] im_cap: allOf: - $ref: '#/components/schemas/FixedPointDollars' @@ -1625,6 +1966,223 @@ components: 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; the minimum granularity is 0.01 contracts. example: "10.00" + ExitTriggerKind: + type: string + description: >- + Trigger family. `bracket` is a stop-loss / take-profit pair; `trailing` is a trailing stop + whose stop level ratchets with the liquidation mark. + enum: + - bracket + - trailing + ExitTriggerStatus: + type: string + description: >- + `pending_on_entry` awaits an initial or further fill from the anchor order before + arming — an order-anchored trigger returns here after closing its current quantity, so + this status can follow `active` and carries a non-zero `filled_count` when it does. + `active` is live and evaluated against the mark each tick. `filled`, `failed`, and + `canceled` are terminal. + enum: + - pending_on_entry + - active + - filled + - failed + - canceled + - unknown + ExitTriggerLeg: + type: string + description: Which leg of a bracket fired. Present only once a leg has fired. + enum: + - stop_loss + - take_profit + ExitTriggerReason: + type: string + description: Why the trigger reached its terminal status. + enum: + - user_canceled + - position_closed + - entry_not_filled + - order_rejected + - position_flipped + UpdateExitTriggerRequest: + type: object + description: >- + At least one leg is required and a leg you omit is cleared; cancel with `DELETE`. + properties: + stop_loss_price: + $ref: '#/components/schemas/FixedPointDollars' + description: Mark price at which the stop-loss leg fires. + take_profit_price: + $ref: '#/components/schemas/FixedPointDollars' + description: Mark price at which the take-profit leg fires. + SetCrossExitTriggerRequest: + type: object + description: >- + For `bracket`, supply `stop_loss_price` and/or `take_profit_price`. For `trailing`, + supply exactly one of `trail_amount` or `trail_bps`. Fields belonging to the other kind + are rejected, as is a body that sets neither — cancel with `DELETE`. A leg you omit is cleared. + properties: + count: + $ref: '#/components/schemas/FixedPointCount' + description: >- + Contracts this trigger closes when it fires. Omit it to cover the whole position, + which then requires that no partial or anchored trigger already covers part of it. + If supplied, it must be a whole number of contracts. + anchor_order_id: + type: string + description: >- + Ties the trigger to an order: its size follows that order's cumulative fill, and it + stays pending until the order first fills. Bracket only; mutually exclusive with + `count`. If the order cannot currently be validated, the request returns `409` with + error code `anchor_order_unavailable` before writing anything, so retrying is safe. + client_trigger_id: + type: string + maxLength: 64 + x-oapi-codegen-extra-tags: + validate: "omitempty,max=64" + description: >- + Your own identifier, required on a `count` or `anchor_order_id` create because those + append a trigger rather than replace one; rejected on every other write. Replaying a + key returns the trigger the first request made, in whatever state it has since + reached. Keys are single-use per account and belong to the position they created on: + reusing one returns the original even once it is `canceled`, and presenting it for + another subaccount or ticker returns `409` with error code + `idempotency_key_already_used`. + kind: + allOf: + - $ref: '#/components/schemas/ExitTriggerKind' + default: bracket + description: Which trigger family this write targets. Defaults to `bracket`. + stop_loss_price: + $ref: '#/components/schemas/FixedPointDollars' + description: Mark price at which the stop-loss leg fires. Bracket only. + take_profit_price: + $ref: '#/components/schemas/FixedPointDollars' + description: Mark price at which the take-profit leg fires. Bracket only. + trail_amount: + $ref: '#/components/schemas/FixedPointDollars' + description: Absolute trailing distance in dollars. Trailing only; mutually exclusive with `trail_bps`. + trail_bps: + type: integer + minimum: 1 + maximum: 9999 + description: >- + Trailing distance in basis points of the tracked watermark (not the entry price). + Trailing only; mutually exclusive with `trail_amount`. + SetIsolatedExitTriggerRequest: + type: object + description: >- + For `bracket`, supply `stop_loss_price` and/or `take_profit_price`. For `trailing`, + supply exactly one of `trail_amount` or `trail_bps`. Fields belonging to the other kind + are rejected, as is a body that sets neither — cancel with `DELETE`. A leg you omit is cleared. + properties: + kind: + allOf: + - $ref: '#/components/schemas/ExitTriggerKind' + default: bracket + description: Which trigger family this write targets. Defaults to `bracket`. + stop_loss_price: + $ref: '#/components/schemas/FixedPointDollars' + description: Mark price at which the stop-loss leg fires. Bracket only. + take_profit_price: + $ref: '#/components/schemas/FixedPointDollars' + description: Mark price at which the take-profit leg fires. Bracket only. + trail_amount: + $ref: '#/components/schemas/FixedPointDollars' + description: Absolute trailing distance in dollars. Trailing only; mutually exclusive with `trail_bps`. + trail_bps: + type: integer + minimum: 1 + maximum: 9999 + description: >- + Trailing distance in basis points of the tracked watermark (not the entry price). + Trailing only; mutually exclusive with `trail_amount`. + ExitTrigger: + type: object + required: + - id + - ticker + - kind + - status + - count + - filled_count + - created_time + - updated_time + properties: + id: + type: string + description: Unique identifier for this trigger. + ticker: + type: string + description: Market the protected position is on. + kind: + $ref: '#/components/schemas/ExitTriggerKind' + status: + $ref: '#/components/schemas/ExitTriggerStatus' + status_reason: + $ref: '#/components/schemas/ExitTriggerReason' + triggered_leg: + $ref: '#/components/schemas/ExitTriggerLeg' + description: Leg that fired the last time the trigger fired. + triggered_order_id: + type: string + description: >- + Order placed the last time the trigger fired. An order-anchored trigger keeps it + after returning to `pending_on_entry`, so its presence does not mean the trigger is + done. + anchor_order_id: + type: string + description: >- + Order this trigger follows, for order-anchored triggers. Cleared once that order + reaches a terminal state, so use `client_trigger_id` to correlate a trigger with the + request that made it. + client_trigger_id: + type: string + description: The idempotency key the create supplied, for appending creates. + stop_loss_price: + $ref: '#/components/schemas/FixedPointDollars' + description: Stop-loss leg price. Bracket triggers only. + take_profit_price: + $ref: '#/components/schemas/FixedPointDollars' + description: Take-profit leg price. Bracket triggers only. + trail_amount: + $ref: '#/components/schemas/FixedPointDollars' + description: Absolute trailing distance. Trailing triggers only. + trail_bps: + type: integer + description: Trailing distance in basis points of the watermark. Trailing triggers only. + watermark_price: + $ref: '#/components/schemas/FixedPointDollars' + description: >- + Most favorable liquidation mark seen since creation — the peak for long positions, the + trough for short. Trailing triggers only. + effective_stop_price: + $ref: '#/components/schemas/FixedPointDollars' + description: >- + Level whose breach fires the stop: watermark minus the trail for longs, plus for + shorts. Trailing triggers only. + count: + $ref: '#/components/schemas/FixedPointCount' + description: Contracts this trigger closes when it fires. `0` means the whole position. + filled_count: + $ref: '#/components/schemas/FixedPointCount' + description: Contracts closed so far across fire attempts. + created_time: + type: string + format: date-time + updated_time: + type: string + format: date-time + GetExitTriggersResponse: + type: object + required: + - exit_triggers + properties: + exit_triggers: + type: array + description: Live triggers on this position slot. Empty when none are set. + items: + $ref: '#/components/schemas/ExitTrigger' # Common schemas FixedPointDollars: type: string @@ -2902,6 +3460,27 @@ components: type: integer minimum: 0 default: 0 + ExitTriggerTickerPath: + name: ticker + in: path + required: true + description: Ticker of the market whose position the trigger protects. + schema: + type: string + ExitTriggerIdPath: + name: trigger_id + in: path + required: true + description: Identifier of a specific exit trigger, as returned by the list endpoint. + schema: + type: string + ExitTriggerKindQuery: + name: kind + in: query + required: false + description: Restricts the request to one trigger family. Omitted covers every kind. + schema: + $ref: '#/components/schemas/ExitTriggerKind' tags: - name: account description: Account information endpoints @@ -2923,3 +3502,5 @@ tags: description: Funding rates and payment history - name: fees description: Margin fee schedule + - name: exit-triggers + description: Stop-loss, take-profit, and trailing-stop triggers on margin positions diff --git a/specs/perps_scm_openapi.yaml b/specs/perps_scm_openapi.yaml index c0219d2..42c597d 100644 --- a/specs/perps_scm_openapi.yaml +++ b/specs/perps_scm_openapi.yaml @@ -1,10 +1,10 @@ openapi: 3.0.0 info: - title: Kalshi Self-Clearing Member API + title: Kalshi 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 + Klear API endpoints available to clearing members — + institutional users who clear margin activity directly with Kalshi. ## Authentication @@ -16,7 +16,7 @@ info: To generate an access token and obtain your admin user id, sign in at https://klearing.kalshi.com, and navigate to the "Security" page. - If you are in the process of onboarding to be a self-clearing member, + If you are in the process of onboarding to be a clearing member, please contact your contact at Kalshi for credentials. Otherwise, please contact institutional@kalshi.com. @@ -196,6 +196,33 @@ paths: '403': { $ref: '#/components/responses/ForbiddenError' } '500': { $ref: '#/components/responses/InternalServerError' } + /margin/estimate_maintenance_margin: + post: + operationId: EstimatePortfolioMaintenanceMargin + summary: Estimate Portfolio Maintenance Margin + description: >- + Maintenance margin requirement in dollars for a hypothetical portfolio, + margined under the rules that apply to the authenticated clearing + member. Positions and prices are supplied by the caller and need not + reflect positions held. Every market in `positions` must belong to + `asset_class`. + + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/EstimatePortfolioMaintenanceMarginRequest' } + responses: + '200': + description: Successful response + content: + application/json: + schema: { $ref: '#/components/schemas/EstimatePortfolioMaintenanceMarginResponse' } + '400': { $ref: '#/components/responses/BadRequestError' } + '401': { $ref: '#/components/responses/UnauthorizedError' } + '403': { $ref: '#/components/responses/ForbiddenError' } + '500': { $ref: '#/components/responses/InternalServerError' } + /margin/settlement_estimate_by_asset_class: get: operationId: GetSettlementEstimateByAssetClass @@ -221,6 +248,29 @@ paths: '403': { $ref: '#/components/responses/ForbiddenError' } '500': { $ref: '#/components/responses/InternalServerError' } + /margin/settlement_prices: + get: + operationId: GetSettlementPrices + summary: Get Settlement Prices + description: >- + Settlement (mark) prices for the markets in an asset class at a given + settlement time. Empty when no settlement has run for that asset + class and settlement time. + + parameters: + - { name: asset_class, in: query, required: true, schema: { $ref: '#/components/schemas/AssetClass' } } + - { name: settlement_time, in: query, required: true, schema: { type: string, format: date-time }, description: Settlement cycle time, RFC3339. } + responses: + '200': + description: Successful response + content: + application/json: + schema: { $ref: '#/components/schemas/GetSettlementPricesResponse' } + '400': { $ref: '#/components/responses/BadRequestError' } + '401': { $ref: '#/components/responses/UnauthorizedError' } + '403': { $ref: '#/components/responses/ForbiddenError' } + '500': { $ref: '#/components/responses/InternalServerError' } + /margin/settlement_balance: get: operationId: GetSettlementBalance @@ -461,9 +511,20 @@ components: - maintenance_margin - maintenance_margin_aggregate url: { type: string, description: Presigned download URL (omitted from logs). } - date: { type: string, format: date } + date: + type: string + format: date + description: >- + The ET report date the file belongs to. Intraday snapshots after + 4pm ET belong to the next report date. created_ts: { type: string, format: date-time } is_end_of_day: { type: boolean } + snapshot_ts: + type: string + format: date-time + description: >- + Snapshot instant for intraday files (noon and hourly cycles); + omitted for end-of-day files. GetMarginReportsResponse: type: object @@ -633,6 +694,31 @@ components: market_ticker: { type: string } position: { type: integer, description: Signed contract count; positive long, negative short. Can be below the reportable threshold when exit_day is true. } exit_day: { type: boolean, description: True when this row is included because the same market was reportable on the prior settlement day but is now below the reportable threshold. } + EstimatePortfolioMaintenanceMarginPosition: + type: object + required: [market_ticker, quantity, price] + properties: + market_ticker: { type: string, description: Market to include in the hypothetical portfolio. Must belong to the request's asset class. } + quantity: { type: integer, format: int64, description: Signed whole contract count; positive long, negative short. Must be non-zero. } + price: { type: string, description: 'Price of one contract in USD, to at most four decimal places (e.g. "6.8000"). Must be positive.' } + + EstimatePortfolioMaintenanceMarginRequest: + type: object + required: [asset_class, positions] + properties: + asset_class: { $ref: '#/components/schemas/AssetClass' } + positions: + type: array + minItems: 1 + maxItems: 500 + description: Hypothetical portfolio to margin. Market tickers must be unique and must name markets that exist in the requested asset class. + items: { $ref: '#/components/schemas/EstimatePortfolioMaintenanceMarginPosition' } + + EstimatePortfolioMaintenanceMarginResponse: + type: object + properties: + maintenance_margin_fp: { type: string, description: 'Maintenance margin requirement for the submitted portfolio, in USD (e.g. "1234.5600").' } + GetObligationHistoryResponse: type: object required: [obligations] @@ -741,6 +827,15 @@ components: additionalProperties: { $ref: '#/components/schemas/AssetClassSettlementEstimate' } settlement_balance_centicents: { type: integer, format: int64, description: Current settlement buffer balance. } + GetSettlementPricesResponse: + type: object + required: [settlement_prices] + properties: + settlement_prices: + type: object + description: Map of market ticker to that market's settlement (mark) price at the requested settlement time, in centicents. Empty when no settlement ran for that asset class and settlement time. + additionalProperties: { type: integer, format: int64 } + GetSettlementBalanceResponse: type: object required: [user_id, balance_available_centicents] diff --git a/tests/_contract_support.py b/tests/_contract_support.py index afc1f5a..2cca8a8 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -239,6 +239,11 @@ class Exclusion: http_method="GET", path_template="/live_data/milestone/{milestone_id}/game_stats", ), + MethodEndpointEntry( + sdk_method="kalshi.resources.live_data.LiveDataResource.weather", + http_method="GET", + path_template="/live_data/weather/{city}", + ), # ── events ────────────────────────────────────────────────────────────── MethodEndpointEntry( sdk_method="kalshi.resources.events.EventsResource.list", @@ -417,6 +422,11 @@ class Exclusion: http_method="DELETE", path_template="/portfolio/events/orders/{order_id}", ), + MethodEndpointEntry( + sdk_method="kalshi.resources.orders.OrdersResource.cancel_all_v2", + http_method="DELETE", + path_template="/portfolio/events/orders", + ), MethodEndpointEntry( sdk_method="kalshi.resources.orders.OrdersResource.amend_v2", http_method="POST", @@ -776,6 +786,17 @@ class Exclusion: http_method="GET", path_template="/portfolio/intra_exchange_instance_transfers/{transfer_id}", ), + MethodEndpointEntry( + sdk_method="kalshi.resources.portfolio.PortfolioResource.target_balance_allocation", + http_method="GET", + path_template="/portfolio/target_balance_allocation", + ), + MethodEndpointEntry( + sdk_method="kalshi.resources.portfolio.PortfolioResource.set_target_balance_allocation", + http_method="POST", + path_template="/portfolio/target_balance_allocation", + request_body_schema="#/components/schemas/SetTargetBalanceAllocationRequest", + ), # ── series ────────────────────────────────────────────────────────────── MethodEndpointEntry( sdk_method="kalshi.resources.series.SeriesResource.list", @@ -1091,6 +1112,17 @@ class Exclusion: ), kind="kwarg_rename", ), + ("kalshi.resources.live_data.LiveDataResource.weather", "from"): Exclusion( + reason="SDK kwarg named from_ts (not from) to avoid the Python keyword", + kind="kwarg_rename", + ), + ("kalshi.resources.live_data.LiveDataResource.weather", "from_ts"): Exclusion( + reason=( + "SDK renamed from spec's `from` query param to avoid the Python " + "keyword; wire still sends `from`" + ), + kind="kwarg_rename", + ), # --- milestones.list/list_all: `type` query param renamed to `milestone_type` --- # Same rationale as get_typed above. Wire still sends `?type=...`. ("kalshi.resources.milestones.MilestonesResource.list", "type"): Exclusion( @@ -1403,6 +1435,11 @@ class Exclusion: http_method="DELETE", path_template="/margin/orders/{order_id}", ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.cancel_all", + http_method="DELETE", + path_template="/margin/orders", + ), MethodEndpointEntry( sdk_method="kalshi.perps.resources.orders.MarginOrdersResource.decrease", http_method="POST", @@ -1566,6 +1603,62 @@ class Exclusion: http_method="DELETE", path_template="/margin/fcm/subtraders/risk_controls", ), + # ── perps exit triggers ── + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.portfolio.PerpsPortfolioResource.cross_exit_triggers", + http_method="GET", + path_template="/margin/cross/positions/{ticker}/exit_trigger", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.portfolio.PerpsPortfolioResource.set_cross_exit_trigger", + http_method="PUT", + path_template="/margin/cross/positions/{ticker}/exit_trigger", + request_body_schema="#/components/schemas/SetCrossExitTriggerRequest", + ), + MethodEndpointEntry( + sdk_method=( + "kalshi.perps.resources.portfolio.PerpsPortfolioResource.cancel_cross_exit_triggers" + ), + http_method="DELETE", + path_template="/margin/cross/positions/{ticker}/exit_trigger", + ), + MethodEndpointEntry( + sdk_method=( + "kalshi.perps.resources.portfolio.PerpsPortfolioResource.update_cross_exit_trigger" + ), + http_method="PUT", + path_template="/margin/cross/positions/{ticker}/exit_trigger/{trigger_id}", + request_body_schema="#/components/schemas/UpdateExitTriggerRequest", + ), + MethodEndpointEntry( + sdk_method=( + "kalshi.perps.resources.portfolio.PerpsPortfolioResource.cancel_cross_exit_trigger" + ), + http_method="DELETE", + path_template="/margin/cross/positions/{ticker}/exit_trigger/{trigger_id}", + ), + MethodEndpointEntry( + sdk_method=( + "kalshi.perps.resources.portfolio.PerpsPortfolioResource.isolated_exit_triggers" + ), + http_method="GET", + path_template="/margin/isolated/positions/{ticker}/exit_trigger", + ), + MethodEndpointEntry( + sdk_method=( + "kalshi.perps.resources.portfolio.PerpsPortfolioResource.set_isolated_exit_trigger" + ), + http_method="PUT", + path_template="/margin/isolated/positions/{ticker}/exit_trigger", + request_body_schema="#/components/schemas/SetIsolatedExitTriggerRequest", + ), + MethodEndpointEntry( + sdk_method=( + "kalshi.perps.resources.portfolio.PerpsPortfolioResource.cancel_isolated_exit_triggers" + ), + http_method="DELETE", + path_template="/margin/isolated/positions/{ticker}/exit_trigger", + ), ] # SCM/Klear endpoints — validated against ``specs/perps_scm_openapi.yaml``. @@ -1690,6 +1783,17 @@ class Exclusion: http_method="DELETE", path_template="/fcm/margin/subtrader_groups/{group_id}", ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.settlement_prices", + http_method="GET", + path_template="/margin/settlement_prices", + ), + MethodEndpointEntry( + sdk_method="kalshi.perps.klear.resources.margin.MarginResource.estimate_maintenance_margin", + http_method="POST", + path_template="/margin/estimate_maintenance_margin", + request_body_schema="#/components/schemas/EstimatePortfolioMaintenanceMarginRequest", + ), ] # Shared perps exclusion allowlist (same ``(sdk_fqn, field) → Exclusion`` shape diff --git a/tests/_model_fixtures.py b/tests/_model_fixtures.py index 7f58f42..0c0e41f 100644 --- a/tests/_model_fixtures.py +++ b/tests/_model_fixtures.py @@ -113,6 +113,7 @@ def fill_dict(**overrides: Any) -> dict[str, Any]: "fee_cost_dollars": "0.0050", "outcome_side": "yes", "book_side": "bid", + "exchange_index": 0, } base.update(overrides) return base @@ -210,6 +211,7 @@ def settlement_dict(**overrides: Any) -> dict[str, Any]: "settled_time": "2026-01-01T00:00:00Z", "fee_cost_dollars": "0.0000", "market_result": "no", + "exchange_index": 0, } base.update(overrides) return base @@ -219,6 +221,7 @@ def market_position_dict(**overrides: Any) -> dict[str, Any]: """Spec-shaped MarketPosition response dict.""" base: dict[str, Any] = { "ticker": "MKT-A", + "exchange_index": 0, "total_traded_dollars": "0.0000", "position_fp": "0.00", "market_exposure_dollars": "0.0000", @@ -379,6 +382,7 @@ def user_orders_payload_dict(**overrides: Any) -> dict[str, Any]: "outcome_side": "yes", "book_side": "bid", "created_ts_ms": 1735689600000, + "exchange_index": 0, } base.update(overrides) return base @@ -402,6 +406,7 @@ def fill_payload_dict(**overrides: Any) -> dict[str, Any]: "outcome_side": "yes", "book_side": "bid", "purchased_side": "yes", + "exchange_index": 0, } base.update(overrides) return base diff --git a/tests/perps/klear/test_margin.py b/tests/perps/klear/test_margin.py index 83a8608..1f16e41 100644 --- a/tests/perps/klear/test_margin.py +++ b/tests/perps/klear/test_margin.py @@ -46,6 +46,8 @@ "market_price_snapshot", "funding_periods", "settlement_periods", + "maintenance_margin", + "maintenance_margin_aggregate", ] @@ -955,3 +957,138 @@ def test_create_request_rejects_empty_list(self) -> None: with pytest.raises(ValidationError): CreateMarginSubtraderGroupRequest(subtrader_ids=[]) + + +class TestSettlementPrices: + @respx.mock + def test_happy(self, auth_klear_client: KlearClient) -> None: + route = respx.get(f"{BASE}/margin/settlement_prices").mock( + return_value=httpx.Response( + 200, json={"settlement_prices": {"BTC-PERP": 650000000}} + ) + ) + resp = auth_klear_client.margin.settlement_prices( + asset_class="Crypto", + settlement_time="2026-08-28T16:00:00Z", + ) + assert resp.settlement_prices["BTC-PERP"] == 650000000 + q = dict(route.calls[0].request.url.params) + assert q["asset_class"] == "Crypto" + assert q["settlement_time"] == "2026-08-28T16:00:00Z" + auth_klear_client.close() + + @respx.mock + def test_empty_map(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/settlement_prices").mock( + return_value=httpx.Response(200, json={"settlement_prices": {}}) + ) + resp = auth_klear_client.margin.settlement_prices( + asset_class="Crypto", settlement_time="2026-08-28T16:00:00Z" + ) + assert resp.settlement_prices == {} + auth_klear_client.close() + + @respx.mock + def test_400_maps(self, auth_klear_client: KlearClient) -> None: + respx.get(f"{BASE}/margin/settlement_prices").mock( + return_value=httpx.Response(400, json={"error": {"code": "bad_time"}}) + ) + with pytest.raises(KalshiValidationError): + auth_klear_client.margin.settlement_prices( + asset_class="Crypto", settlement_time="nope" + ) + auth_klear_client.close() + + +class TestEstimateMaintenanceMargin: + @respx.mock + def test_kwargs(self, auth_klear_client: KlearClient) -> None: + from kalshi.perps.klear.models.margin import ( + EstimatePortfolioMaintenanceMarginPosition, + ) + + route = respx.post(f"{BASE}/margin/estimate_maintenance_margin").mock( + return_value=httpx.Response( + 200, json={"maintenance_margin_fp": "1234.5600"} + ) + ) + pos = EstimatePortfolioMaintenanceMarginPosition( + market_ticker="BTC-PERP", + quantity=2, + price=Decimal("6.8000"), + ) + resp = auth_klear_client.margin.estimate_maintenance_margin( + asset_class="Crypto", positions=[pos] + ) + assert resp.maintenance_margin_fp == Decimal("1234.5600") + body = json.loads(route.calls[0].request.content) + assert body["asset_class"] == "Crypto" + assert body["positions"][0]["quantity"] == 2 + assert body["positions"][0]["price"] == "6.8000" + auth_klear_client.close() + + def test_rejects_zero_quantity(self) -> None: + from kalshi.perps.klear.models.margin import ( + EstimatePortfolioMaintenanceMarginPosition, + ) + + with pytest.raises(ValidationError): + EstimatePortfolioMaintenanceMarginPosition( + market_ticker="BTC-PERP", quantity=0, price=Decimal("1.00") + ) + + def test_rejects_nonpositive_price(self) -> None: + from kalshi.perps.klear.models.margin import ( + EstimatePortfolioMaintenanceMarginPosition, + ) + + with pytest.raises(ValidationError): + EstimatePortfolioMaintenanceMarginPosition( + market_ticker="BTC-PERP", quantity=1, price=Decimal("0") + ) + + def test_requires_args(self, auth_klear_client: KlearClient) -> None: + with pytest.raises(TypeError, match="estimate_maintenance_margin"): + auth_klear_client.margin.estimate_maintenance_margin() + auth_klear_client.close() + + @respx.mock + @pytest.mark.asyncio + async def test_async( + self, auth_async_klear_client: AsyncKlearClient + ) -> None: + from kalshi.perps.klear.models.margin import ( + EstimatePortfolioMaintenanceMarginPosition, + ) + + respx.post(f"{BASE}/margin/estimate_maintenance_margin").mock( + return_value=httpx.Response(200, json={}) + ) + resp = await auth_async_klear_client.margin.estimate_maintenance_margin( + asset_class="Crypto", + positions=[ + EstimatePortfolioMaintenanceMarginPosition( + market_ticker="BTC-PERP", + quantity=-1, + price=Decimal("6.8000"), + ) + ], + ) + assert resp.maintenance_margin_fp is None + await auth_async_klear_client.close() + + +class TestMarginReportSnapshotTs: + def test_parses_optional_snapshot_ts(self) -> None: + report = MarginReport.model_validate( + { + **_report(), + "snapshot_ts": "2026-06-01T12:00:00Z", + } + ) + assert report.snapshot_ts is not None + assert report.is_end_of_day is True + + def test_omitted_snapshot_ts(self) -> None: + report = MarginReport.model_validate(_report()) + assert report.snapshot_ts is None diff --git a/tests/perps/test_fcm.py b/tests/perps/test_fcm.py index 7d11010..00d4302 100644 --- a/tests/perps/test_fcm.py +++ b/tests/perps/test_fcm.py @@ -140,6 +140,17 @@ def test_get_risk_controls_filters_market(self, perps_client: PerpsClient) -> No "market_ticker": "ETH-PERP", } + @respx.mock + def test_get_risk_controls_filters_asset_class(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/fcm/subtraders/risk_controls").mock( + return_value=httpx.Response(200, json={"risk_controls": []}) + ) + perps_client.fcm.risk_controls(subtrader_id="user_desk1", asset_class="Crypto") + assert dict(route.calls[0].request.url.params) == { + "subtrader_id": "user_desk1", + "asset_class": "Crypto", + } + @respx.mock def test_update_risk_controls_kwargs(self, perps_client: PerpsClient) -> None: route = respx.put(f"{BASE}/margin/fcm/subtraders/risk_controls").mock( @@ -156,6 +167,22 @@ def test_update_risk_controls_kwargs(self, perps_client: PerpsClient) -> None: "market_ticker": "BTC-PERP", } + @respx.mock + def test_update_risk_controls_asset_class(self, perps_client: PerpsClient) -> None: + route = respx.put(f"{BASE}/margin/fcm/subtraders/risk_controls").mock( + return_value=httpx.Response(200, json={}) + ) + perps_client.fcm.update_risk_controls( + subtrader_id="user_desk1", + im_cap=Decimal("50.0000"), + asset_class="Equities", + ) + assert json.loads(route.calls[0].request.content) == { + "subtrader_id": "user_desk1", + "im_cap": "50.0000", + "asset_class": "Equities", + } + @respx.mock def test_update_risk_controls_request_model(self, perps_client: PerpsClient) -> None: route = respx.put(f"{BASE}/margin/fcm/subtraders/risk_controls").mock( @@ -190,6 +217,20 @@ def test_delete_risk_controls(self, perps_client: PerpsClient) -> None: "market_ticker": "BTC-PERP", } + @respx.mock + def test_delete_risk_controls_asset_class(self, perps_client: PerpsClient) -> None: + route = respx.delete(f"{BASE}/margin/fcm/subtraders/risk_controls").mock( + return_value=httpx.Response(200, json={}) + ) + perps_client.fcm.delete_risk_controls( + subtrader_id="user_desk1", + asset_class="Crypto", + ) + assert dict(route.calls[0].request.url.params) == { + "subtrader_id": "user_desk1", + "asset_class": "Crypto", + } + def test_unauthenticated_raises(self) -> None: client = PerpsClient(config=PerpsConfig.demo(max_retries=0)) with pytest.raises(AuthRequiredError): diff --git a/tests/perps/test_orders.py b/tests/perps/test_orders.py index 7e0eac3..a029dcd 100644 --- a/tests/perps/test_orders.py +++ b/tests/perps/test_orders.py @@ -398,6 +398,39 @@ async def test_async(self, async_perps_client: AsyncPerpsClient) -> None: await async_perps_client.close() +class TestCancelAll: + @respx.mock + def test_happy(self, perps_client: PerpsClient) -> None: + route = respx.delete(f"{BASE}/margin/orders").mock( + return_value=httpx.Response(204) + ) + assert perps_client.orders.cancel_all() is None + assert route.called + assert "subaccount" not in dict(route.calls[0].request.url.params) + + @respx.mock + def test_subaccount_param(self, perps_client: PerpsClient) -> None: + route = respx.delete(f"{BASE}/margin/orders").mock( + return_value=httpx.Response(204) + ) + perps_client.orders.cancel_all(subaccount=4) + assert dict(route.calls[0].request.url.params)["subaccount"] == "4" + + @respx.mock + def test_not_retried_on_503(self, perps_client: PerpsClient) -> None: + route = respx.delete(f"{BASE}/margin/orders").mock( + return_value=httpx.Response(503, json={"error": {"code": "unavailable"}}) + ) + with pytest.raises(KalshiServerError): + perps_client.orders.cancel_all() + assert route.call_count == 1 + + def test_unauthenticated_raises(self) -> None: + client = PerpsClient(config=PerpsConfig.demo(max_retries=0)) + with pytest.raises(AuthRequiredError): + client.orders.cancel_all() + + # ── decrease ───────────────────────────────────────────────────────────────── diff --git a/tests/perps/test_portfolio.py b/tests/perps/test_portfolio.py index 619ab8a..bf0365e 100644 --- a/tests/perps/test_portfolio.py +++ b/tests/perps/test_portfolio.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from datetime import datetime from decimal import Decimal @@ -478,3 +479,128 @@ async def test_async_trades_all_paginates( items = [t async for t in async_perps_client.portfolio.trades_all(ticker="BTC-PERP")] assert len(items) == 2 await async_perps_client.close() + + +def _exit_trigger(**overrides: object) -> dict[str, object]: + base: dict[str, object] = { + "id": "trg-1", + "ticker": "BTC-PERP", + "kind": "bracket", + "status": "active", + "count": "10.00", + "filled_count": "0.00", + "created_time": "2026-01-01T00:00:00Z", + "updated_time": "2026-01-01T00:00:00Z", + "stop_loss_price": "50000.0000", + "take_profit_price": "70000.0000", + } + base.update(overrides) + return base + + +class TestExitTriggers: + @respx.mock + def test_cross_list(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/cross/positions/BTC-PERP/exit_trigger").mock( + return_value=httpx.Response(200, json={"exit_triggers": [_exit_trigger()]}) + ) + resp = perps_client.portfolio.cross_exit_triggers("BTC-PERP", kind="bracket") + assert len(resp.exit_triggers) == 1 + assert resp.exit_triggers[0].id == "trg-1" + assert dict(route.calls[0].request.url.params)["kind"] == "bracket" + + @respx.mock + def test_set_cross(self, perps_client: PerpsClient) -> None: + from kalshi.perps.models.portfolio import SetCrossExitTriggerRequest + + route = respx.put(f"{BASE}/margin/cross/positions/BTC-PERP/exit_trigger").mock( + return_value=httpx.Response(200, json=_exit_trigger()) + ) + req = SetCrossExitTriggerRequest( + kind="bracket", + stop_loss_price=Decimal("50000.0000"), + ) + trig = perps_client.portfolio.set_cross_exit_trigger( + "BTC-PERP", request=req, subaccount=1 + ) + assert trig.status == "active" + assert json.loads(route.calls[0].request.content)["kind"] == "bracket" + assert dict(route.calls[0].request.url.params)["subaccount"] == "1" + + @respx.mock + def test_cancel_cross_collection(self, perps_client: PerpsClient) -> None: + route = respx.delete(f"{BASE}/margin/cross/positions/BTC-PERP/exit_trigger").mock( + return_value=httpx.Response(204) + ) + perps_client.portfolio.cancel_cross_exit_triggers("BTC-PERP") + assert route.called + + @respx.mock + def test_update_cross_by_id(self, perps_client: PerpsClient) -> None: + from kalshi.perps.models.portfolio import UpdateExitTriggerRequest + + route = respx.put( + f"{BASE}/margin/cross/positions/BTC-PERP/exit_trigger/trg-1" + ).mock(return_value=httpx.Response(200, json=_exit_trigger())) + req = UpdateExitTriggerRequest(stop_loss_price=Decimal("49000.0000")) + trig = perps_client.portfolio.update_cross_exit_trigger( + "BTC-PERP", "trg-1", request=req + ) + assert trig.id == "trg-1" + assert json.loads(route.calls[0].request.content)["stop_loss_price"] == "49000.0000" + + @respx.mock + def test_cancel_cross_by_id(self, perps_client: PerpsClient) -> None: + route = respx.delete( + f"{BASE}/margin/cross/positions/BTC-PERP/exit_trigger/trg-1" + ).mock(return_value=httpx.Response(204)) + perps_client.portfolio.cancel_cross_exit_trigger("BTC-PERP", "trg-1") + assert route.called + + @respx.mock + def test_isolated_roundtrip(self, perps_client: PerpsClient) -> None: + from kalshi.perps.models.portfolio import SetIsolatedExitTriggerRequest + + respx.get(f"{BASE}/margin/isolated/positions/ETH-PERP/exit_trigger").mock( + return_value=httpx.Response(200, json={"exit_triggers": []}) + ) + route = respx.put(f"{BASE}/margin/isolated/positions/ETH-PERP/exit_trigger").mock( + return_value=httpx.Response(200, json=_exit_trigger(ticker="ETH-PERP")) + ) + respx.delete(f"{BASE}/margin/isolated/positions/ETH-PERP/exit_trigger").mock( + return_value=httpx.Response(204) + ) + listed = perps_client.portfolio.isolated_exit_triggers("ETH-PERP") + assert listed.exit_triggers == [] + req = SetIsolatedExitTriggerRequest(kind="trailing", trail_bps=100) + trig = perps_client.portfolio.set_isolated_exit_trigger("ETH-PERP", request=req) + assert trig.ticker == "ETH-PERP" + assert json.loads(route.calls[0].request.content)["trail_bps"] == 100 + perps_client.portfolio.cancel_isolated_exit_triggers("ETH-PERP", kind="trailing") + + @respx.mock + def test_set_cross_404(self, perps_client: PerpsClient) -> None: + from kalshi.errors import KalshiNotFoundError + from kalshi.perps.models.portfolio import SetCrossExitTriggerRequest + + respx.put(f"{BASE}/margin/cross/positions/NOPE/exit_trigger").mock( + return_value=httpx.Response(404, json={"error": {"code": "not_found"}}) + ) + with pytest.raises(KalshiNotFoundError): + perps_client.portfolio.set_cross_exit_trigger( + "NOPE", request=SetCrossExitTriggerRequest(kind="bracket") + ) + + def test_unauthenticated_raises(self) -> None: + client = PerpsClient(config=PerpsConfig.demo(max_retries=0)) + with pytest.raises(AuthRequiredError): + client.portfolio.cross_exit_triggers("BTC-PERP") + + @respx.mock + async def test_async_cross_list(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/cross/positions/BTC-PERP/exit_trigger").mock( + return_value=httpx.Response(200, json={"exit_triggers": [_exit_trigger()]}) + ) + resp = await async_perps_client.portfolio.cross_exit_triggers("BTC-PERP") + assert resp.exit_triggers[0].kind == "bracket" + await async_perps_client.close() diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 178c0bf..53d2abf 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -1448,6 +1448,9 @@ def _assert_params_match( ), "#/components/schemas/CreateApiKeyRequest": ("kalshi.models.api_keys.CreateApiKeyRequest"), "#/components/schemas/GenerateApiKeyRequest": ("kalshi.models.api_keys.GenerateApiKeyRequest"), + "#/components/schemas/SetTargetBalanceAllocationRequest": ( + "kalshi.models.portfolio.SetTargetBalanceAllocationRequest" + ), } @@ -1489,6 +1492,15 @@ def _assert_params_match( "#/components/schemas/UpdateFCMSubtraderRiskControlsRequest": ( "kalshi.perps.models.fcm.UpdateFCMSubtraderRiskControlsRequest" ), + "#/components/schemas/SetCrossExitTriggerRequest": ( + "kalshi.perps.models.portfolio.SetCrossExitTriggerRequest" + ), + "#/components/schemas/SetIsolatedExitTriggerRequest": ( + "kalshi.perps.models.portfolio.SetIsolatedExitTriggerRequest" + ), + "#/components/schemas/UpdateExitTriggerRequest": ( + "kalshi.perps.models.portfolio.UpdateExitTriggerRequest" + ), } PERPS_SCM_BODY_MODEL_MAP: dict[str, str] = { @@ -1504,6 +1516,9 @@ def _assert_params_match( "#/components/schemas/UpdateMarginSubtraderGroupRequest": ( "kalshi.perps.klear.models.margin.UpdateMarginSubtraderGroupRequest" ), + "#/components/schemas/EstimatePortfolioMaintenanceMarginRequest": ( + "kalshi.perps.klear.models.margin.EstimatePortfolioMaintenanceMarginRequest" + ), } diff --git a/tests/test_live_data.py b/tests/test_live_data.py index cf4a86e..4a5363b 100644 --- a/tests/test_live_data.py +++ b/tests/test_live_data.py @@ -13,6 +13,7 @@ from kalshi.models.live_data import ( EventLiveData, GetGameStatsResponse, + GetWeatherIndexResponse, LiveData, ) from kalshi.resources.live_data import AsyncLiveDataResource, LiveDataResource @@ -290,3 +291,88 @@ async def test_game_stats( ).mock(return_value=httpx.Response(200, json={"pbp": None})) resp = await async_live_data.game_stats("ms-1") assert resp.pbp is None + + +_WEATHER_JSON = { + "city": "miami", + "units": "fahrenheit", + "config_version": "miami-temperature-v1.0", + "timeseries": [ + { + "t": 1700000000000, + "v": 78.12, + "status": "normal", + "contributors": 3, + }, + { + "t": 1700000060000, + "status": "incomplete", + "stations": [ + { + "station_id": "KMIA1M", + "code": "pending", + "source": "hf_asos", + "temp_f": 78.05, + } + ], + }, + ], +} + + +class TestLiveDataWeather: + @respx.mock + def test_weather_happy(self, live_data: LiveDataResource) -> None: + respx.get( + "https://test.kalshi.com/trade-api/v2/live_data/weather/miami", + ).mock(return_value=httpx.Response(200, json=_WEATHER_JSON)) + resp = live_data.weather("miami") + assert isinstance(resp, GetWeatherIndexResponse) + assert resp.city == "miami" + assert resp.units == "fahrenheit" + assert resp.timeseries[0].v == 78.12 + assert resp.timeseries[1].v is None + assert resp.timeseries[1].stations is not None + assert resp.timeseries[1].stations[0].station_id == "KMIA1M" + + @respx.mock + def test_weather_sends_from_as_from( + self, live_data: LiveDataResource, + ) -> None: + route = respx.get( + "https://test.kalshi.com/trade-api/v2/live_data/weather/miami", + ).mock(return_value=httpx.Response(200, json=_WEATHER_JSON)) + live_data.weather( + "miami", from_ts=1, to=2, last_sec=None, detailed=True, + ) + q = dict(route.calls[0].request.url.params) + assert q["from"] == "1" + assert q["to"] == "2" + assert q["detailed"] == "true" + assert "last_sec" not in q + assert "from_ts" not in q + + @respx.mock + def test_weather_400_maps(self, live_data: LiveDataResource) -> None: + from kalshi.errors import KalshiValidationError + + respx.get( + "https://test.kalshi.com/trade-api/v2/live_data/weather/nope", + ).mock(return_value=httpx.Response(400, json={"message": "unknown city"})) + with pytest.raises(KalshiValidationError): + live_data.weather("nope") + + def test_weather_rejects_empty_city(self, live_data: LiveDataResource) -> None: + with pytest.raises(ValueError, match="city"): + live_data.weather("") + + @respx.mock + @pytest.mark.asyncio + async def test_async_weather( + self, async_live_data: AsyncLiveDataResource, + ) -> None: + respx.get( + "https://test.kalshi.com/trade-api/v2/live_data/weather/miami", + ).mock(return_value=httpx.Response(200, json=_WEATHER_JSON)) + resp = await async_live_data.weather("miami", last_sec=3600) + assert resp.city == "miami" diff --git a/tests/test_model_extra_policy.py b/tests/test_model_extra_policy.py index e519ebd..b0a72d4 100644 --- a/tests/test_model_extra_policy.py +++ b/tests/test_model_extra_policy.py @@ -17,7 +17,8 @@ # the CLAUDE.md "Adding a new resource" convention. Both `Request` and the # longer `RequestOrder` are needed: `BatchCancelOrdersV2RequestOrder` is a # request-body sub-model whose name doesn't end in plain `Request`. -_REQUEST_BODY_SUFFIXES = ("Request", "RequestOrder") +# `Input` covers nested write models such as `TargetBalanceAllocationInput`. +_REQUEST_BODY_SUFFIXES = ("Request", "RequestOrder", "Input") def _exported_model_classes() -> list[tuple[str, type[BaseModel]]]: diff --git a/tests/test_orders.py b/tests/test_orders.py index 31b9beb..a0065de 100644 --- a/tests/test_orders.py +++ b/tests/test_orders.py @@ -542,6 +542,39 @@ def test_passes_query_params(self, orders: OrdersResource) -> None: assert params["exchange_index"] == "0" +class TestCancelAllOrdersV2: + @respx.mock + def test_204_ok(self, orders: OrdersResource) -> None: + route = respx.delete( + "https://test.kalshi.com/trade-api/v2/portfolio/events/orders", + ).mock(return_value=httpx.Response(204)) + assert orders.cancel_all_v2() is None + assert route.called + assert "subaccount" not in dict(route.calls[0].request.url.params) + + @respx.mock + def test_subaccount_query(self, orders: OrdersResource) -> None: + route = respx.delete( + "https://test.kalshi.com/trade-api/v2/portfolio/events/orders", + ).mock(return_value=httpx.Response(204)) + orders.cancel_all_v2(subaccount=2) + assert dict(route.calls[0].request.url.params)["subaccount"] == "2" + + @respx.mock + def test_429_maps(self, orders: OrdersResource) -> None: + from kalshi.errors import KalshiRateLimitError + + respx.delete( + "https://test.kalshi.com/trade-api/v2/portfolio/events/orders", + ).mock(return_value=httpx.Response(429, json={"message": "rate limited"})) + with pytest.raises(KalshiRateLimitError): + orders.cancel_all_v2() + + def test_unauthenticated_raises(self, unauth_orders: OrdersResource) -> None: + with pytest.raises(AuthRequiredError): + unauth_orders.cancel_all_v2() + + class TestAmendOrderV2: @respx.mock def test_returns_response(self, orders: OrdersResource) -> None: diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index 4b2831d..e0fe49c 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -351,6 +351,7 @@ def test_returns_settlements(self, portfolio: PortfolioResource) -> None: "settlements": [ { "ticker": "MKT-A", + "exchange_index": 0, "event_ticker": "EVT-1", "market_result": "yes", "yes_count_fp": "10.00", @@ -1435,3 +1436,89 @@ async def test_requires_auth( ) -> None: with pytest.raises(AuthRequiredError): await unauth_async_portfolio.intra_exchange_transfers() + + +class TestTargetBalanceAllocation: + @respx.mock + def test_get(self, portfolio: PortfolioResource) -> None: + respx.get( + "https://test.kalshi.com/trade-api/v2/portfolio/target_balance_allocation" + ).mock( + return_value=httpx.Response( + 200, + json={"allocations": [{"exchange_index": 0, "percent": 100}]}, + ) + ) + resp = portfolio.target_balance_allocation() + assert len(resp.allocations) == 1 + assert resp.allocations[0].exchange_index == 0 + assert resp.allocations[0].percent == 100 + + @respx.mock + def test_set_kwargs(self, portfolio: PortfolioResource) -> None: + import json + + from kalshi.models.portfolio import TargetBalanceAllocationInput + + route = respx.post( + "https://test.kalshi.com/trade-api/v2/portfolio/target_balance_allocation" + ).mock(return_value=httpx.Response(200, json={})) + portfolio.set_target_balance_allocation( + allocations=[TargetBalanceAllocationInput(exchange_index=0, percent=100)] + ) + assert json.loads(route.calls[0].request.content) == { + "allocations": [{"exchange_index": 0, "percent": 100}] + } + + @respx.mock + def test_set_request_model(self, portfolio: PortfolioResource) -> None: + import json + + from kalshi.models.portfolio import ( + SetTargetBalanceAllocationRequest, + TargetBalanceAllocationInput, + ) + + route = respx.post( + "https://test.kalshi.com/trade-api/v2/portfolio/target_balance_allocation" + ).mock(return_value=httpx.Response(200, json={})) + req = SetTargetBalanceAllocationRequest( + allocations=[TargetBalanceAllocationInput(exchange_index=1, percent=40)] + ) + portfolio.set_target_balance_allocation(request=req) + assert json.loads(route.calls[0].request.content)["allocations"][0]["percent"] == 40 + + def test_set_requires_args(self, portfolio: PortfolioResource) -> None: + with pytest.raises(TypeError, match="set_target_balance_allocation"): + portfolio.set_target_balance_allocation() + + def test_percent_bounds(self) -> None: + from pydantic import ValidationError + + from kalshi.models.portfolio import TargetBalanceAllocationInput + + with pytest.raises(ValidationError): + TargetBalanceAllocationInput(exchange_index=0, percent=101) + + def test_requires_auth(self, unauth_portfolio: PortfolioResource) -> None: + with pytest.raises(AuthRequiredError): + unauth_portfolio.target_balance_allocation() + + @respx.mock + @pytest.mark.asyncio + async def test_async_roundtrip( + self, async_portfolio: AsyncPortfolioResource + ) -> None: + from kalshi.models.portfolio import TargetBalanceAllocationInput + + respx.get( + "https://test.kalshi.com/trade-api/v2/portfolio/target_balance_allocation" + ).mock(return_value=httpx.Response(200, json={"allocations": []})) + respx.post( + "https://test.kalshi.com/trade-api/v2/portfolio/target_balance_allocation" + ).mock(return_value=httpx.Response(200, json={})) + resp = await async_portfolio.target_balance_allocation() + assert resp.allocations == [] + await async_portfolio.set_target_balance_allocation( + allocations=[TargetBalanceAllocationInput(exchange_index=0, percent=100)] + ) diff --git a/tests/ws/test_models.py b/tests/ws/test_models.py index c292f38..492b0bb 100644 --- a/tests/ws/test_models.py +++ b/tests/ws/test_models.py @@ -1390,6 +1390,7 @@ def test_orderbook_delta_payload_side_rejects_trailing_whitespace(self) -> None: "outcome_side": "yes", "book_side": "bid", "ts_ms": 1_700_000_000_000, + "exchange_index": 0, }, ), ( @@ -1430,6 +1431,7 @@ def test_orderbook_delta_payload_side_rejects_trailing_whitespace(self) -> None: "outcome_side": "yes", "book_side": "bid", "created_ts_ms": 1_700_000_000_000, + "exchange_index": 0, }, ), (