diff --git a/CHANGELOG.md b/CHANGELOG.md index 47fbfce..e9b8c7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ All notable changes to kalshi-sdk will be documented in this file. +## 22.5.0 — 2026-10-29 + +Reconciles nightly spec drift (#561) against core OpenAPI 3.35.0 and the +latest perps specs. Additive only. No breaking changes. + +### Added + +- **Exit triggers** (sync and async): `client.portfolio.exit_triggers(ticker)` + (`GET /portfolio/positions/{ticker}/exit_trigger`), + `set_exit_trigger` (`PUT`), `cancel_exit_trigger` (`DELETE`), and + `client.orders.create_with_exit_trigger` + (`POST /portfolio/events/orders/with_exit_trigger`). New models + `EventContractExitTrigger`, `SetEventContractExitTriggerRequest`, + `AttachedEventContractExitTrigger`, `EventContractExitSlippage`, + `CreateOrderWithExitTriggerRequest`. PUT/DELETE/POST are never retried. + (Closes #561). +- **Credit history**: `client.portfolio.credit_history()` and + `credit_history_all()` (`GET /portfolio/credit_history`), returning + `CreditHistory` with `amount` as `DollarDecimal`. (Closes #561). +- **Perps market schedules**: `perps.markets.schedules()` + (`GET /margin/markets/schedules`), returning + `MarginMarketScheduleDetails` / `MarginHolidayWindow`. (Closes #561). +- **Perps WebSocket `market_lifecycle_v2`** channel: + `PerpsWebSocket.subscribe_market_lifecycle`, yielding + `MarginMarketLifecycleV2Message`. +- Optional **`subaccount_type`** (`"cross"`, `"isolated"`, + `"auto_rebalance"` or `None`) on `MarginPosition`, + `MarginSubaccountBalance`, `MarginRiskPosition` and + `MarginFundingHistoryEntry`. The spec marks it required; the SDK keeps it + optional (contract exclusion `server_omits_despite_required`) so this + stays a minor release. (Closes #561). + +### Internal + +- Test config: set `asyncio_default_fixture_loop_scope = "function"` and + fixed fixture decorator order for pytest-asyncio 1.4.1 / pytest 9.2 + (#560, closes #559). + ## 22.4.0 — 2026-10-26 Reconciles nightly spec drift (#556, #557). Additive on the RFQ-batch diff --git a/CLAUDE.md b/CLAUDE.md index c2b3ab9..073fa20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,7 @@ tests/ ## API Reference -- OpenAPI spec: https://docs.kalshi.com/openapi.yaml (v3.34.0, 128 operations; 127 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.35.0, 133 operations; 132 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 (15 WebSocket channels; 12 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 1c2858c..07fa7ba 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ 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 (127 mapped of 128 operations across 19 resources, OpenAPI v3.34.0) and WebSocket API (12 typed `subscribe_*` channels + escape-hatch). -- **Perps (margin) API**: standalone `PerpsClient` / `AsyncPerpsClient` + `PerpsWebSocket` for the perpetual-futures exchange (50 REST operations, 6 WS channels), plus a `KlearClient` for the Self-Clearing-Member "Klear" settlement API (26 of 27 operations). See [Perps (margin) trading](#perps-margin-trading). +- **Full coverage** of the Kalshi REST API (132 mapped of 133 operations across 19 resources, OpenAPI v3.35.0) and WebSocket API (12 typed `subscribe_*` channels + escape-hatch). +- **Perps (margin) API**: standalone `PerpsClient` / `AsyncPerpsClient` + `PerpsWebSocket` for the perpetual-futures exchange (54 REST operations, 6 WS channels), plus a `KlearClient` for the Self-Clearing-Member "Klear" settlement API (26 of 27 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` / `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()`. @@ -220,7 +220,7 @@ Resource families on the perps client: `exchange`, `markets`, `orders`, Real-time streaming via `PerpsWebSocket` — `subscribe_ticker` (carries `funding_rate` + `next_funding_time_ms`), `subscribe_orderbook_delta`, `subscribe_trade`, `subscribe_fill`, `subscribe_user_orders`, -`subscribe_order_group`. +`subscribe_order_group`, `subscribe_market_lifecycle`. Prices are `DollarDecimal` (FixedPointDollars, up to 6 decimals); counts are `FixedPointCount` (2 decimals, `_fp` wire suffix). REST timestamps are RFC3339; diff --git a/docs/index.md b/docs/index.md index 4d5ee17..e5c7113 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,8 +3,8 @@ A professional, spec-first Python SDK for the [Kalshi](https://kalshi.com) prediction markets API. -- **Full REST coverage** — 127 mapped of 128 operations across 19 resources - (OpenAPI v3.34.0), every kwarg drift-tested against the spec. +- **Full REST coverage** — 132 mapped of 133 operations across 19 resources + (OpenAPI v3.35.0), every kwarg drift-tested against the spec. - **V2 event-market orders** — new `create_v2` / `amend_v2` / `decrease_v2` / `cancel_v2` / `cancel_all_v2` family on `/portfolio/events/orders/*`. Legacy `/portfolio/orders` keeps working; deprecation no earlier than May 6, 2026. @@ -16,7 +16,7 @@ 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 (50 REST operations, 6 WS + `PerpsWebSocket` for the perpetual-futures exchange (54 REST operations, 6 WS channels), and a `KlearClient` for the Self-Clearing-Member settlement API (26 of 27 operations, Bearer token auth). See [Perps](perps.md). - **FIX protocol** — a hand-rolled, async-first FIX engine (FIXT.1.1 / FIX50SP2) diff --git a/docs/perps.md b/docs/perps.md index b5c58f1..2343769 100644 --- a/docs/perps.md +++ b/docs/perps.md @@ -59,7 +59,7 @@ async with AsyncPerpsClient.from_env(demo=True) as perps: | Attribute | Endpoints | |---|---| | `exchange` | `status()`, `enabled()` (per-member access gate), `risk_parameters()` | -| `markets` | `list()`, `get()`, `orderbook()`, `bulk_orderbooks(*, tickers)`, `candlesticks()` | +| `markets` | `list()`, `get()`, `orderbook()`, `bulk_orderbooks(*, tickers)`, `candlesticks()`, `schedules(*, tickers)` | | `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()`, cross/isolated exit triggers | @@ -77,8 +77,19 @@ is not a margin market returns 404. books = perps.markets.bulk_orderbooks(tickers=["BTC-PERP", "ETH-PERP"]) for ob in books: print(ob.ticker, len(ob.bids), len(ob.asks)) + +schedules = perps.markets.schedules(tickers=["BTC-PERP", "ETH-PERP"]) +for ticker, details in schedules.items(): + print(ticker, len(details.kalshi_trading_holidays)) ``` +`markets.schedules(*, tickers)` returns Kalshi trading-holiday closures +(`GET /margin/markets/schedules`) as a ticker → `MarginMarketScheduleDetails` +map. `tickers` is required (1–100, each 1–200 characters) and serializes as a +comma-separated query string. Regular daily and weekend closures are not +included. Each holiday window's spec field `from` is `start` on +`MarginHolidayWindow` (`until` is the exclusive end). + The margin order side is `bid` / `ask` (not the prediction API's `yes` / `no`). Orders create/cancel/decrease/amend are POSTs/DELETEs and are **never retried**. `orders.amend(..., expiration_time=)` is int64 Unix seconds: omit it to keep @@ -212,7 +223,11 @@ perps.portfolio.cancel_cross_exit_trigger("BTC-PERP", trig.id) null while open. - **Subaccounts** (since the v3.21.0 spec sync) — `MarginPosition` (the `portfolio.positions()` rows) carries a **required** `subaccount` (`int`) - identifying which subaccount holds the position. + identifying which subaccount holds the position. `subaccount_type` + (`"cross"` / `"isolated"` / `"auto_rebalance"`, or `None` when the key is + absent) is optional on `MarginPosition`, `MarginSubaccountBalance`, + `MarginRiskPosition`, and `MarginFundingHistoryEntry`. The spec marks it + required; the SDK keeps it optional so older payloads still parse. - **Portfolio hedging** (since the v3.23.0 spec sync) — `MarginPosition` and `MarginRiskPosition` carry a **required** `is_portfolio` (`bool`), `True` when the position is hedged within a portfolio so its margin is computed at the @@ -287,16 +302,19 @@ async with ws.connect() as session: print(msg.msg.funding_rate.rate, msg.msg.funding_rate.next_funding_time_ms) ``` -Seven data channels — `orderbook_delta` (snapshot + delta, sequenced), `ticker`, -`trade`, `fill`, `user_orders`, `order_group_updates`, `corporate_actions`. The -connection, sequence-gap detection, reconnect, and backpressure machinery are -reused from the event-contract WS stack (see [WebSocket](websockets.md)); the -perps orderbook is `bid` / `ask`. The prediction-only channels -(`market_positions`, `multivariate*`, `communications`, `market_lifecycle_v2`, +Eight data channels — `orderbook_delta` (snapshot + delta, sequenced), `ticker`, +`trade`, `fill`, `user_orders`, `order_group_updates`, `corporate_actions`, +`market_lifecycle_v2`. The connection, sequence-gap detection, reconnect, and +backpressure machinery are reused from the event-contract WS stack (see +[WebSocket](websockets.md)); the perps orderbook is `bid` / `ask`. The +prediction-only channels (`market_positions`, `multivariate*`, `communications`, `cfbenchmarks_value`) have no perps counterpart. `corporate_actions` streams applied split / final-settlement / ticker-change notices (cash actions are REST-only); subscribe with `session.subscribe(channel="corporate_actions")`. Upcoming and recent notices are listed by `margin.corporate_actions()`. +`subscribe_market_lifecycle()` yields `MarginMarketLifecycleV2Message` for +activation and deactivation of every margin market (`event_type` is +`activated` or `deactivated`). Ticker filters are not supported on that channel. ## Self-Clearing-Member "Klear" API diff --git a/docs/reference.md b/docs/reference.md index cd8aa3e..be3a627 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -36,6 +36,14 @@ every exception class. ::: kalshi.models.orders.CreateOrderV2Request +::: kalshi.models.orders.CreateOrderWithExitTriggerRequest + +::: kalshi.models.portfolio.AttachedEventContractExitTrigger + +::: kalshi.models.portfolio.SetEventContractExitTriggerRequest + +::: kalshi.models.portfolio.EventContractExitSlippage + ::: kalshi.models.orders.AmendOrderV2Request ::: kalshi.models.orders.DecreaseOrderV2Request diff --git a/docs/request-models.md b/docs/request-models.md index f21ca58..2b526f0 100644 --- a/docs/request-models.md +++ b/docs/request-models.md @@ -52,6 +52,8 @@ exposed by each resource method stays in lockstep with the OpenAPI spec. | Resource | Request model | |---|---| | `client.orders.create_v2` | `CreateOrderV2Request` | +| `client.orders.create_with_exit_trigger` | `CreateOrderWithExitTriggerRequest` (adds `exit_trigger`: `AttachedEventContractExitTrigger`) | +| `client.portfolio.set_exit_trigger` | `SetEventContractExitTriggerRequest` (at least one of `stop_loss_price` / `take_profit_price`; nested `EventContractExitSlippage`) | | `client.orders.amend_v2` | `AmendOrderV2Request` | | `client.orders.decrease_v2` | `DecreaseOrderV2Request` (XOR `reduce_by` / `reduce_to`) | | `client.orders.batch_create_v2` | `BatchCreateOrdersV2Request` (wraps `list[CreateOrderV2Request]`) | diff --git a/docs/resources/orders.md b/docs/resources/orders.md index d75c634..276809e 100644 --- a/docs/resources/orders.md +++ b/docs/resources/orders.md @@ -13,6 +13,7 @@ Reads stay on `/portfolio/orders/*`. | Method | Endpoint | Retry | |---|---|---| | `create_v2(*, request)` | `POST /portfolio/events/orders` | **never** — see [Retries & idempotency](../retries.md) | +| `create_with_exit_trigger(*, request)` | `POST /portfolio/events/orders/with_exit_trigger` | never | | `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 | @@ -30,6 +31,13 @@ V2 uses `BookSideLiteral` (`"bid"` / `"ask"`), a single `price` field idempotency key. The surface is **model-only** — every write method takes a pre-built request model rather than kwargs. +`create_with_exit_trigger` places that same V2 order with an attached +take-profit / stop-loss bracket (`CreateOrderWithExitTriggerRequest`, which +subclasses `CreateOrderV2Request` and adds a required `exit_trigger`). The +route is hidden in the published spec (`x-excluded`) until event-contract +TP/SL launches. The POST is never retried. An empty `exit_trigger` object is +spec-valid; prices on the bracket are YES prices. + ## Place an order ```python diff --git a/docs/resources/portfolio.md b/docs/resources/portfolio.md index 7fedff7..21ee53b 100644 --- a/docs/resources/portfolio.md +++ b/docs/resources/portfolio.md @@ -13,6 +13,10 @@ Auth required throughout. | `fills(...)` / `fills_all(..., exchange_index=None)` | `GET /portfolio/fills` | | `total_resting_order_value()` | `GET /portfolio/summary/total_resting_order_value` (FCM only) | | `deposits(*, limit, cursor)` / `deposits_all(*, limit, max_pages)` | `GET /portfolio/deposits` | +| `credit_history(*, limit, cursor, min_ts, max_ts)` / `credit_history_all(...)` | `GET /portfolio/credit_history` | +| `exit_triggers(ticker, *, kind, subaccount)` | `GET /portfolio/positions/{ticker}/exit_trigger` | +| `set_exit_trigger(ticker, *, request, subaccount)` | `PUT /portfolio/positions/{ticker}/exit_trigger` (never retried) | +| `cancel_exit_trigger(ticker, *, kind, subaccount)` | `DELETE /portfolio/positions/{ticker}/exit_trigger` (never retried) | | `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}` | @@ -176,6 +180,44 @@ constructors (tests/mocks) must pass it; an empty list is valid. Non-FCM accounts get a `403` (mapped to `KalshiAuthError`). Demo mirrors production behavior here. +## Credit history + +`credit_history` returns `Page[CreditHistory]`, newest first. `min_ts` is +inclusive and `max_ts` is exclusive (credit creation time). `limit` is 1–1000. +`credit_type` is the spec's `type` field. `amount` is a `DollarDecimal` +(`amount_dollars` on the wire). + +```python +page = client.portfolio.credit_history(limit=50) +for credit in page: + print(credit.credit_id, credit.credit_type, credit.amount, credit.status) + +for credit in client.portfolio.credit_history_all(): + ... +``` + +## Exit triggers + +Take-profit / stop-loss brackets on an event-contract position. These routes +are hidden in the published OpenAPI (`x-excluded`) until the feature launches. +`PUT` and `DELETE` are never retried. Prices are YES prices (`DollarDecimal`). +`set_exit_trigger` replaces both legs — omit a leg to clear it, and pass at +least one of `stop_loss_price` or `take_profit_price`. + +```python +from decimal import Decimal +from kalshi import SetEventContractExitTriggerRequest + +trigger = client.portfolio.set_exit_trigger( + "HIGHNY-24JAN01-T60", + request=SetEventContractExitTriggerRequest(stop_loss_price=Decimal("0.5600")), +) +print(trigger.id, trigger.status, trigger.stop_loss_price) + +current = client.portfolio.exit_triggers("HIGHNY-24JAN01-T60", kind="bracket") +client.portfolio.cancel_exit_trigger("HIGHNY-24JAN01-T60", kind="bracket") +``` + ## Deposits and withdrawals New in v2.1.0. Standard `Page[T]` pagination — see [Pagination](../pagination.md). diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 4e58169..7f7f19d 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -43,6 +43,7 @@ ApiUsageLevelGrant, ApplySubaccountTransferRequest, AssociatedEvent, + AttachedEventContractExitTrigger, Balance, BatchCancelOrdersV2Request, BatchCancelOrdersV2RequestOrder, @@ -68,6 +69,7 @@ CreateOrderGroupResponse, CreateOrderV2Request, CreateOrderV2Response, + CreateOrderWithExitTriggerRequest, CreateQuoteRequest, CreateQuoteResponse, CreateRFQBatchChild, @@ -79,6 +81,8 @@ CreateRFQResponse, CreateSubaccountRequest, CreateSubaccountResponse, + CreditHistory, + CreditStatusLiteral, DailySchedule, DecreaseOrderV2Request, DecreaseOrderV2Response, @@ -86,6 +90,13 @@ EndpointTokenCost, Event, EventCandlesticks, + EventContractExitKindLiteral, + EventContractExitSideLiteral, + EventContractExitSlippage, + EventContractExitSlippageUnitLiteral, + EventContractExitStatusLiteral, + EventContractExitTrigger, + EventContractExitTriggeredLegLiteral, EventFeeChange, EventLiveData, EventMetadata, @@ -103,6 +114,7 @@ GetApiKeysResponse, GetBlockTradeProposalsResponse, GetCommunicationsIDResponse, + GetEventContractExitTriggersResponse, GetEventLiveDataResponse, GetFCMEventContractDailyCapResponse, GetFcmFillsResponse, @@ -186,6 +198,7 @@ SelfTradePreventionTypeLiteral, Series, SeriesFeeChange, + SetEventContractExitTriggerRequest, SetTargetBalanceAllocationRequest, Settlement, SettlementSource, @@ -265,6 +278,7 @@ "AsyncRFQBatchQuotesResource", "AsyncRFQBatchesResource", "AsyncRFQsResource", + "AttachedEventContractExitTrigger", "AuthRequiredError", "Balance", "BatchCancelOrdersV2Request", @@ -292,6 +306,7 @@ "CreateOrderGroupResponse", "CreateOrderV2Request", "CreateOrderV2Response", + "CreateOrderWithExitTriggerRequest", "CreateQuoteRequest", "CreateQuoteResponse", "CreateRFQBatchChild", @@ -303,6 +318,8 @@ "CreateRFQResponse", "CreateSubaccountRequest", "CreateSubaccountResponse", + "CreditHistory", + "CreditStatusLiteral", "DailySchedule", "DecreaseOrderV2Request", "DecreaseOrderV2Response", @@ -310,6 +327,13 @@ "EndpointTokenCost", "Event", "EventCandlesticks", + "EventContractExitKindLiteral", + "EventContractExitSideLiteral", + "EventContractExitSlippage", + "EventContractExitSlippageUnitLiteral", + "EventContractExitStatusLiteral", + "EventContractExitTrigger", + "EventContractExitTriggeredLegLiteral", "EventFeeChange", "EventLiveData", "EventMetadata", @@ -331,6 +355,7 @@ "GetApiKeysResponse", "GetBlockTradeProposalsResponse", "GetCommunicationsIDResponse", + "GetEventContractExitTriggersResponse", "GetEventLiveDataResponse", "GetFCMEventContractDailyCapResponse", "GetFCMSubtraderBlockedCategoriesResponse", @@ -445,6 +470,7 @@ "SelfTradePreventionTypeLiteral", "Series", "SeriesFeeChange", + "SetEventContractExitTriggerRequest", "SetTargetBalanceAllocationRequest", "Settlement", "SettlementSource", @@ -477,4 +503,4 @@ "Withdrawal", ] -__version__ = "22.4.0" +__version__ = "22.5.0" diff --git a/kalshi/_contract_map.py b/kalshi/_contract_map.py index 13914c7..ce5402e 100644 --- a/kalshi/_contract_map.py +++ b/kalshi/_contract_map.py @@ -492,6 +492,10 @@ class ContractEntry: sdk_model="kalshi.models.orders.CreateOrderV2Request", spec_schema="CreateOrderV2Request", ), + ContractEntry( + sdk_model="kalshi.models.orders.CreateOrderWithExitTriggerRequest", + spec_schema="CreateOrderWithExitTriggerRequest", + ), ContractEntry( sdk_model="kalshi.models.orders.CreateOrderV2Response", spec_schema="CreateOrderV2Response", @@ -593,6 +597,31 @@ class ContractEntry: sdk_model="kalshi.models.portfolio.Deposit", spec_schema="Deposit", ), + ContractEntry( + sdk_model="kalshi.models.portfolio.CreditHistory", + spec_schema="CreditHistory", + ), + ContractEntry( + sdk_model="kalshi.models.portfolio.EventContractExitSlippage", + spec_schema="EventContractExitSlippage", + ), + ContractEntry( + sdk_model="kalshi.models.portfolio.AttachedEventContractExitTrigger", + spec_schema="AttachedEventContractExitTrigger", + ), + ContractEntry( + sdk_model="kalshi.models.portfolio.SetEventContractExitTriggerRequest", + spec_schema="SetEventContractExitTriggerRequest", + ), + ContractEntry( + sdk_model="kalshi.models.portfolio.EventContractExitTrigger", + spec_schema="EventContractExitTrigger", + ), + ContractEntry( + sdk_model="kalshi.models.portfolio.GetEventContractExitTriggersResponse", + spec_schema="response|GET|/portfolio/positions/{ticker}/exit_trigger|200", + notes="Inline 200 schema on the GET; not a named component", + ), ContractEntry( sdk_model="kalshi.models.portfolio.Withdrawal", spec_schema="Withdrawal", @@ -790,6 +819,14 @@ class ContractEntry: sdk_model="kalshi.perps.models.markets.MarginMarketSchedule", spec_schema="MarginMarketSchedule", ), + ContractEntry( + sdk_model="kalshi.perps.models.markets.MarginHolidayWindow", + spec_schema="MarginHolidayWindow", + ), + ContractEntry( + sdk_model="kalshi.perps.models.markets.MarginMarketScheduleDetails", + spec_schema="MarginMarketScheduleDetails", + ), ContractEntry( sdk_model="kalshi.perps.models.markets.MarginMarketCandlestick", spec_schema="MarginMarketCandlestick", diff --git a/kalshi/models/__init__.py b/kalshi/models/__init__.py index 2827d48..b809dbd 100644 --- a/kalshi/models/__init__.py +++ b/kalshi/models/__init__.py @@ -163,6 +163,7 @@ CancelOrderV2Response, CreateOrderV2Request, CreateOrderV2Response, + CreateOrderWithExitTriggerRequest, DecreaseOrderV2Request, DecreaseOrderV2Response, Fill, @@ -174,10 +175,21 @@ TimeInForceLiteral, ) from kalshi.models.portfolio import ( + AttachedEventContractExitTrigger, Balance, + CreditHistory, + CreditStatusLiteral, Deposit, + EventContractExitKindLiteral, + EventContractExitSideLiteral, + EventContractExitSlippage, + EventContractExitSlippageUnitLiteral, + EventContractExitStatusLiteral, + EventContractExitTrigger, + EventContractExitTriggeredLegLiteral, EventPosition, ExchangeInstanceLiteral, + GetEventContractExitTriggersResponse, GetTargetBalanceAllocationResponse, IndexedBalance, IntraExchangeInstanceTransfer, @@ -187,6 +199,7 @@ PaymentTypeLiteral, PositionsResponse, RestingMarginReservationLiteral, + SetEventContractExitTriggerRequest, SetTargetBalanceAllocationRequest, Settlement, SettlementStatusLiteral, @@ -244,6 +257,7 @@ "ApiUsageLevelGrant", "ApplySubaccountTransferRequest", "AssociatedEvent", + "AttachedEventContractExitTrigger", "Balance", "BatchCancelOrdersV2Request", "BatchCancelOrdersV2RequestOrder", @@ -269,6 +283,7 @@ "CreateOrderGroupResponse", "CreateOrderV2Request", "CreateOrderV2Response", + "CreateOrderWithExitTriggerRequest", "CreateQuoteRequest", "CreateQuoteResponse", "CreateRFQBatchChild", @@ -280,6 +295,8 @@ "CreateRFQResponse", "CreateSubaccountRequest", "CreateSubaccountResponse", + "CreditHistory", + "CreditStatusLiteral", "DailySchedule", "DecreaseOrderV2Request", "DecreaseOrderV2Response", @@ -287,6 +304,13 @@ "EndpointTokenCost", "Event", "EventCandlesticks", + "EventContractExitKindLiteral", + "EventContractExitSideLiteral", + "EventContractExitSlippage", + "EventContractExitSlippageUnitLiteral", + "EventContractExitStatusLiteral", + "EventContractExitTrigger", + "EventContractExitTriggeredLegLiteral", "EventFeeChange", "EventLiveData", "EventMetadata", @@ -304,6 +328,7 @@ "GetApiKeysResponse", "GetBlockTradeProposalsResponse", "GetCommunicationsIDResponse", + "GetEventContractExitTriggersResponse", "GetEventLiveDataResponse", "GetFCMEventContractDailyCapResponse", "GetFCMSubtraderBlockedCategoriesResponse", @@ -387,6 +412,7 @@ "SelfTradePreventionTypeLiteral", "Series", "SeriesFeeChange", + "SetEventContractExitTriggerRequest", "SetTargetBalanceAllocationRequest", "Settlement", "SettlementSource", diff --git a/kalshi/models/orders.py b/kalshi/models/orders.py index 13cd950..0b0efa5 100644 --- a/kalshi/models/orders.py +++ b/kalshi/models/orders.py @@ -6,6 +6,7 @@ from pydantic import AliasChoices, AwareDatetime, BaseModel, Field, model_validator +from kalshi.models.portfolio import AttachedEventContractExitTrigger from kalshi.types import DollarDecimal, FixedPointCount, OrderPrice, StrictInt # Literal aliases for fixed-enum fields on the order models. @@ -190,6 +191,17 @@ class CreateOrderV2Request(BaseModel): model_config = {"extra": "forbid"} +class CreateOrderWithExitTriggerRequest(CreateOrderV2Request): + """Body for ``POST /portfolio/events/orders/with_exit_trigger``. + + Same wire shape as :class:`CreateOrderV2Request` plus a required + ``exit_trigger``. The route is hidden in the published spec + (``x-excluded``) until event-contract take-profit / stop-loss launches. + """ + + exit_trigger: AttachedEventContractExitTrigger + + class CreateOrderV2Response(BaseModel): """Response from POST /portfolio/events/orders.""" diff --git a/kalshi/models/portfolio.py b/kalshi/models/portfolio.py index 054a5e2..055b4e8 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, StrictInt +from pydantic import AliasChoices, AwareDatetime, BaseModel, Field, StrictInt, model_validator from kalshi.types import DollarDecimal, FixedPointCount, NullableList, UnixSecondsTimestamp @@ -287,3 +287,170 @@ class SetTargetBalanceAllocationRequest(BaseModel): resting_margin_reservation: RestingMarginReservationLiteral | None = None model_config = {"extra": "forbid"} + + +# --------------------------------------------------------------------------- +# Event-contract exit triggers and credit history (OpenAPI 3.35.0). +# Exit-trigger routes are ``x-excluded`` until take-profit / stop-loss launches. +# --------------------------------------------------------------------------- + +EventContractExitSideLiteral = Literal["yes", "no"] +"""Outcome side a position holds. Spec: ``EventContractExitTrigger.side``.""" + +EventContractExitKindLiteral = Literal["bracket"] +"""Exit-trigger family. Spec currently allows only ``bracket``.""" + +EventContractExitStatusLiteral = Literal[ + "pending_on_entry", + "active", + "filled", + "canceled", + "failed", + "unknown", +] +"""Lifecycle of an event-contract exit trigger.""" + +EventContractExitTriggeredLegLiteral = Literal["stop_loss", "take_profit"] +"""Which bracket leg fired.""" + +EventContractExitSlippageUnitLiteral = Literal["bps", "cents"] +"""Slippage unit. ``bps`` is 0-9999; ``cents`` is 0-99.""" + +CreditStatusLiteral = Literal["unknown", "pending", "failed", "applied"] +"""Credit processing status. Spec: ``CreditHistory.status``.""" + + +class EventContractExitSlippage(BaseModel): + """Execution allowance below the trigger price, shared by TP and SL. + + ``bps`` accepts 0-9999. ``cents`` accepts 0-99 (spec ``not`` rejects + ``unit=cents`` with ``value >= 100``). Zero allows no slippage. + """ + + unit: EventContractExitSlippageUnitLiteral + value: StrictInt = Field(ge=0, le=9999) + + model_config = {"extra": "forbid"} + + @model_validator(mode="after") + def _cents_under_100(self) -> EventContractExitSlippage: + if self.unit == "cents" and self.value >= 100: + raise ValueError("cents slippage must be 0-99 (got value >= 100)") + return self + + +class AttachedEventContractExitTrigger(BaseModel): + """Take-profit / stop-loss attached to ``POST .../orders/with_exit_trigger``. + + Prices are YES prices, like the order's ``price``. Every field is optional; + an empty object is spec-valid. Omit a leg to leave it unset. + """ + + slippage: EventContractExitSlippage | None = None + stop_loss_price: DollarDecimal | None = Field( + default=None, + validation_alias=AliasChoices("stop_loss_price_dollars", "stop_loss_price"), + serialization_alias="stop_loss_price", + ) + take_profit_price: DollarDecimal | None = Field( + default=None, + validation_alias=AliasChoices("take_profit_price_dollars", "take_profit_price"), + serialization_alias="take_profit_price", + ) + + model_config = {"extra": "forbid", "populate_by_name": True} + + +class SetEventContractExitTriggerRequest(BaseModel): + """Body for ``PUT /portfolio/positions/{ticker}/exit_trigger``. + + Replaces both legs: a leg you omit is cleared. At least one of + ``stop_loss_price`` or ``take_profit_price`` is required. Prices are YES + prices, like order prices. + """ + + slippage: EventContractExitSlippage | None = None + kind: EventContractExitKindLiteral | None = None + stop_loss_price: DollarDecimal | None = Field( + default=None, + validation_alias=AliasChoices("stop_loss_price_dollars", "stop_loss_price"), + serialization_alias="stop_loss_price", + ) + take_profit_price: DollarDecimal | None = Field( + default=None, + validation_alias=AliasChoices("take_profit_price_dollars", "take_profit_price"), + serialization_alias="take_profit_price", + ) + + model_config = {"extra": "forbid", "populate_by_name": True} + + @model_validator(mode="after") + def _at_least_one_leg(self) -> SetEventContractExitTriggerRequest: + if self.stop_loss_price is None and self.take_profit_price is None: + raise ValueError( + "at least one of stop_loss_price or take_profit_price is required" + ) + return self + + +class EventContractExitTrigger(BaseModel): + """A take-profit / stop-loss bracket on an event-contract position. + + ``side`` is the outcome the position holds. Prices are YES prices. + """ + + id: str + ticker: str + side: EventContractExitSideLiteral + subaccount_number: int + kind: EventContractExitKindLiteral + status: EventContractExitStatusLiteral + created_time: AwareDatetime + updated_time: AwareDatetime + slippage: EventContractExitSlippage | None = None + stop_loss_price: DollarDecimal | None = Field( + default=None, + validation_alias=AliasChoices("stop_loss_price_dollars", "stop_loss_price"), + ) + take_profit_price: DollarDecimal | None = Field( + default=None, + validation_alias=AliasChoices("take_profit_price_dollars", "take_profit_price"), + ) + status_reason: str | None = None + triggered_order_id: str | None = None + triggered_leg: EventContractExitTriggeredLegLiteral | None = None + + model_config = {"extra": "allow", "populate_by_name": True} + + +class GetEventContractExitTriggersResponse(BaseModel): + """Response from ``GET /portfolio/positions/{ticker}/exit_trigger``. + + ``exit_triggers`` lists live brackets. ``exit_trigger`` is the most recently + updated bracket, including a terminal one, and is null when none exists. + The key is required; the value may be null. + """ + + exit_trigger: EventContractExitTrigger | None + exit_triggers: list[EventContractExitTrigger] + + model_config = {"extra": "allow"} + + +class CreditHistory(BaseModel): + """One row from ``GET /portfolio/credit_history``. + + ``type`` is exposed as ``credit_type`` so it does not shadow the builtin. + ``amount`` accepts the spec's ``amount_dollars`` wire name. + """ + + credit_id: str + credit_type: str = Field(validation_alias=AliasChoices("type", "credit_type")) + status: CreditStatusLiteral + amount: DollarDecimal = Field( + validation_alias=AliasChoices("amount_dollars", "amount"), + ) + reason: str + created_time: AwareDatetime + + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/perps/__init__.py b/kalshi/perps/__init__.py index cd0327b..9396b3a 100644 --- a/kalshi/perps/__init__.py +++ b/kalshi/perps/__init__.py @@ -73,6 +73,7 @@ OrderSource, PriceLevelDollarsCountFp, SelfTradePreventionType, + SubaccountTypeLiteral, ) from kalshi.perps.models.exchange import ( ExchangeStatus, @@ -115,9 +116,11 @@ ) from kalshi.perps.models.markets import ( BidAskDistributionHistorical, + MarginHolidayWindow, MarginMarket, MarginMarketCandlestick, MarginMarketSchedule, + MarginMarketScheduleDetails, MarginMarketStatusLiteral, MarginOrderbook, MarginOrderbookLevel, @@ -214,6 +217,9 @@ FundingRate, MarginFillMessage, MarginFillPayload, + MarginMarketLifecycleEventType, + MarginMarketLifecycleV2Message, + MarginMarketLifecycleV2Payload, MarginOrderbookDeltaMessage, MarginOrderbookDeltaPayload, MarginOrderbookSnapshotMessage, @@ -337,9 +343,14 @@ "MarginFundingHistoryEntry", "MarginFundingRate", "MarginFundingRateEstimate", + "MarginHolidayWindow", "MarginMarket", "MarginMarketCandlestick", + "MarginMarketLifecycleEventType", + "MarginMarketLifecycleV2Message", + "MarginMarketLifecycleV2Payload", "MarginMarketSchedule", + "MarginMarketScheduleDetails", "MarginMarketStatus", "MarginMarketStatusLiteral", "MarginOrder", @@ -397,6 +408,7 @@ "SettlementBalanceHistoryEntry", "SettlementDetail", "SettlementEstimate", + "SubaccountTypeLiteral", "TickerPrice", "TimeInForceLiteral", "TransfersResource", diff --git a/kalshi/perps/models/__init__.py b/kalshi/perps/models/__init__.py index 01143d6..742c96f 100644 --- a/kalshi/perps/models/__init__.py +++ b/kalshi/perps/models/__init__.py @@ -19,6 +19,7 @@ OrderSource, PriceLevelDollarsCountFp, SelfTradePreventionType, + SubaccountTypeLiteral, ) from kalshi.perps.models.exchange import ( ExchangeStatus, @@ -61,9 +62,11 @@ ) from kalshi.perps.models.markets import ( BidAskDistributionHistorical, + MarginHolidayWindow, MarginMarket, MarginMarketCandlestick, MarginMarketSchedule, + MarginMarketScheduleDetails, MarginMarketStatusLiteral, MarginOrderbook, MarginOrderbookLevel, @@ -180,9 +183,11 @@ "MarginFundingHistoryEntry", "MarginFundingRate", "MarginFundingRateEstimate", + "MarginHolidayWindow", "MarginMarket", "MarginMarketCandlestick", "MarginMarketSchedule", + "MarginMarketScheduleDetails", "MarginMarketStatus", "MarginMarketStatusLiteral", "MarginOrder", @@ -203,6 +208,7 @@ "SelfTradePreventionTypeLiteral", "SetCrossExitTriggerRequest", "SetIsolatedExitTriggerRequest", + "SubaccountTypeLiteral", "TimeInForceLiteral", "UpdateExitTriggerRequest", "UpdateFCMNotionalRiskLimitRequest", diff --git a/kalshi/perps/models/common.py b/kalshi/perps/models/common.py index 808d7e5..994451e 100644 --- a/kalshi/perps/models/common.py +++ b/kalshi/perps/models/common.py @@ -17,6 +17,7 @@ from __future__ import annotations from enum import StrEnum +from typing import Literal from pydantic import BaseModel, ConfigDict @@ -102,6 +103,15 @@ class ExchangeInstance(StrEnum): MARGINED = "margined" +SubaccountTypeLiteral = Literal["cross", "isolated", "auto_rebalance"] +"""Spec ``SubaccountType`` — how a subaccount is margined. + +``cross`` for the primary account and numbered subaccounts, ``isolated`` for +a market's isolated subaccount, and ``auto_rebalance`` for an auto-rebalanced +isolated position (shares its market's isolated subaccount number). +""" + + class EmptyResponse(BaseModel): """Spec ``EmptyResponse`` — an empty object body. diff --git a/kalshi/perps/models/funding.py b/kalshi/perps/models/funding.py index 30883c7..822e39b 100644 --- a/kalshi/perps/models/funding.py +++ b/kalshi/perps/models/funding.py @@ -32,6 +32,7 @@ from pydantic import AliasChoices, AwareDatetime, BaseModel, Field +from kalshi.perps.models.common import SubaccountTypeLiteral from kalshi.types import ( DollarDecimal, FixedPointCount, @@ -60,7 +61,9 @@ class MarginFundingRate(BaseModel): class MarginFundingHistoryEntry(BaseModel): """Spec ``MarginFundingHistoryEntry`` — one realized funding payment. - All seven properties are spec-required. ``subaccount_number`` is marked + Dollar, count, and timestamp fields are spec-required. ``subaccount_type`` + (spec 3.35.0) is spec-required but kept optional — ``None`` when the key + is absent. ``subaccount_number`` is marked ``required`` **and** ``nullable: true`` in the spec, so it is typed ``int | None`` with **no default** — a missing key still raises ``ValidationError`` while an explicit ``null`` is tolerated (same pattern as @@ -85,6 +88,9 @@ class MarginFundingHistoryEntry(BaseModel): # Spec-required + nullable: no default so a missing key raises # ValidationError, while an explicit null is tolerated. subaccount_number: int | None + # Spec 3.35.0 marks this required. Optional so a history row that omits + # it still parses. See PERPS_EXCLUSIONS. + subaccount_type: SubaccountTypeLiteral | None = None class MarginFundingRateEstimate(BaseModel): diff --git a/kalshi/perps/models/margin_account.py b/kalshi/perps/models/margin_account.py index 1308ffb..f40fd43 100644 --- a/kalshi/perps/models/margin_account.py +++ b/kalshi/perps/models/margin_account.py @@ -23,6 +23,7 @@ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field +from kalshi.perps.models.common import SubaccountTypeLiteral from kalshi.types import ( DollarDecimal, FixedPointCount, @@ -37,6 +38,9 @@ class MarginSubaccountBalance(BaseModel): model_config = ConfigDict(extra="allow") subaccount: int + # Spec 3.35.0 marks subaccount_type required. Kept optional so a balance + # row that omits it still parses. See PERPS_EXCLUSIONS. + subaccount_type: SubaccountTypeLiteral | None = None position_value: DollarDecimal account_equity: DollarDecimal maintenance_margin: DollarDecimal @@ -60,6 +64,9 @@ class MarginRiskPosition(BaseModel): model_config = ConfigDict(extra="allow") subaccount: int + # Spec 3.35.0 marks subaccount_type required. Kept optional so a risk + # row that omits it still parses. See PERPS_EXCLUSIONS. + subaccount_type: SubaccountTypeLiteral | None = None market_ticker: str position: FixedPointCount mark_price: DollarDecimal diff --git a/kalshi/perps/models/markets.py b/kalshi/perps/models/markets.py index fd807fb..62edb55 100644 --- a/kalshi/perps/models/markets.py +++ b/kalshi/perps/models/markets.py @@ -18,7 +18,7 @@ from typing import Any, Literal -from pydantic import AliasChoices, BaseModel, Field +from pydantic import AliasChoices, AwareDatetime, BaseModel, Field from kalshi.types import DollarDecimal, FixedPointCount, MultiplierDecimal, NullableList @@ -316,3 +316,32 @@ class GetMarginMarketCandlesticksResponse(BaseModel): candlesticks: NullableList[MarginMarketCandlestick] model_config = {"extra": "allow"} + + +class MarginHolidayWindow(BaseModel): + """One Kalshi trading-holiday closure on ``GET /margin/markets/schedules``. + + Spec property ``from`` is a Python keyword, so the field is ``start``. + Both ``start`` and ``until`` are required; ``reason`` is optional. + """ + + start: AwareDatetime = Field( + validation_alias=AliasChoices("from", "start"), + serialization_alias="from", + ) + until: AwareDatetime + reason: str | None = None + + model_config = {"extra": "allow", "populate_by_name": True} + + +class MarginMarketScheduleDetails(BaseModel): + """Holiday closures for one margin market. + + Regular daily and weekend closures are not included. The schedules + endpoint returns a ticker → details map; this is one value. + """ + + kalshi_trading_holidays: list[MarginHolidayWindow] + + model_config = {"extra": "allow"} diff --git a/kalshi/perps/models/portfolio.py b/kalshi/perps/models/portfolio.py index 6813455..d4bcf6a 100644 --- a/kalshi/perps/models/portfolio.py +++ b/kalshi/perps/models/portfolio.py @@ -24,6 +24,7 @@ from pydantic import AliasChoices, AwareDatetime, BaseModel, Field +from kalshi.perps.models.common import SubaccountTypeLiteral from kalshi.types import ( DollarDecimal, FixedPointCount, @@ -52,6 +53,10 @@ class MarginPosition(BaseModel): """ subaccount: int + # Spec 3.35.0 marks subaccount_type required. Kept optional (None when + # the key is absent) so fixtures and payloads from before the field + # still validate. See PERPS_EXCLUSIONS. + subaccount_type: SubaccountTypeLiteral | None = None market_ticker: str position: FixedPointCount entry_price: DollarDecimal diff --git a/kalshi/perps/resources/markets.py b/kalshi/perps/resources/markets.py index 980f6fa..787b957 100644 --- a/kalshi/perps/resources/markets.py +++ b/kalshi/perps/resources/markets.py @@ -1,6 +1,6 @@ -"""Perps markets resource — list/get, orderbook, bulk orderbooks, candlesticks. +"""Perps markets resource — list/get, orderbook, bulk orderbooks, candlesticks, schedules. -Five read-only ``GET`` endpoints, all public market data (the spec marks no +Six read-only ``GET`` endpoints, all public market data (the spec marks no ``security`` block on any of them) — so none call ``_require_auth()``, unlike the public-markets ``orderbook`` which does. All retry on 429/502/503/504 (GET). @@ -24,6 +24,7 @@ GetMarginMarketsResponse, MarginMarket, MarginMarketCandlestick, + MarginMarketScheduleDetails, MarginMarketStatusLiteral, MarginOrderbook, MarginOrderbookLevel, @@ -38,6 +39,8 @@ ) _MAX_BULK = 100 +_MAX_SCHEDULE_TICKERS = 100 +_MAX_TICKER_LEN = 200 def _parse_orderbook(data: dict[str, Any], ticker: str) -> MarginOrderbook: @@ -93,8 +96,39 @@ def _bulk_orderbooks_params(*, tickers: builtins.list[str]) -> dict[str, Any]: return _params(tickers=_join_tickers(tickers)) +def _schedules_params(*, tickers: builtins.list[str]) -> dict[str, Any]: + """Validate + build params for GET /margin/markets/schedules. + + Spec requires 1..100 tickers, each 1..200 characters. Wire format is + ``?tickers=a,b`` (style: form, explode: false). + """ + if not tickers: + raise ValueError("tickers must be a non-empty list") + if len(tickers) > _MAX_SCHEDULE_TICKERS: + raise ValueError( + f"tickers accepts at most {_MAX_SCHEDULE_TICKERS} entries per spec " + f"(got {len(tickers)})" + ) + for i, ticker in enumerate(tickers): + if not ticker or not ticker.strip(): + raise ValueError(f"tickers[{i}] is empty") + if len(ticker) > _MAX_TICKER_LEN: + raise ValueError( + f"tickers[{i}] exceeds {_MAX_TICKER_LEN} characters (got {len(ticker)})" + ) + return _params(tickers=_join_tickers(tickers, max_items=_MAX_SCHEDULE_TICKERS)) + + +def _parse_schedules(data: dict[str, Any]) -> dict[str, MarginMarketScheduleDetails]: + """Parse the ticker → schedule-details map from GET /margin/markets/schedules.""" + return { + ticker: MarginMarketScheduleDetails.model_validate(details) + for ticker, details in data.items() + } + + class PerpsMarketsResource(SyncResource): - """Sync perps markets API (list / get / orderbook / bulk_orderbooks / candlesticks).""" + """Sync perps markets API (list / get / orderbook / bulk / candlesticks / schedules).""" def list( self, @@ -177,9 +211,28 @@ def candlesticks( ) return GetMarginMarketCandlesticksResponse.model_validate(data).candlesticks + def schedules( + self, + *, + tickers: builtins.list[str], + extra_headers: dict[str, str] | None = None, + ) -> dict[str, MarginMarketScheduleDetails]: + """``GET /margin/markets/schedules`` — holiday closures for up to 100 tickers. + + Public market data (no auth). ``tickers`` is required (1-100, each + 1-200 characters) and serializes as a comma-separated query string. + The body is a map keyed by market ticker. Regular daily and weekend + closures are not included. + """ + params = _schedules_params(tickers=tickers) + data = self._get( + "/margin/markets/schedules", params=params, extra_headers=extra_headers + ) + return _parse_schedules(data) + class AsyncPerpsMarketsResource(AsyncResource): - """Async perps markets API (list / get / orderbook / bulk_orderbooks / candlesticks).""" + """Async perps markets API (list / get / orderbook / bulk / candlesticks / schedules).""" async def list( self, @@ -263,3 +316,16 @@ async def candlesticks( extra_headers=extra_headers, ) return GetMarginMarketCandlesticksResponse.model_validate(data).candlesticks + + async def schedules( + self, + *, + tickers: builtins.list[str], + extra_headers: dict[str, str] | None = None, + ) -> dict[str, MarginMarketScheduleDetails]: + """Async :meth:`PerpsMarketsResource.schedules`.""" + params = _schedules_params(tickers=tickers) + data = await self._get( + "/margin/markets/schedules", params=params, extra_headers=extra_headers + ) + return _parse_schedules(data) diff --git a/kalshi/perps/ws/client.py b/kalshi/perps/ws/client.py index a69f619..65b87dc 100644 --- a/kalshi/perps/ws/client.py +++ b/kalshi/perps/ws/client.py @@ -44,6 +44,7 @@ from kalshi.perps.ws.dispatch import PerpsMessageDispatcher from kalshi.perps.ws.models.control import PerpsErrorResponse, SubscriptionEntry from kalshi.perps.ws.models.fill import MarginFillMessage +from kalshi.perps.ws.models.market_lifecycle import MarginMarketLifecycleV2Message from kalshi.perps.ws.models.order_group import OrderGroupUpdatesMessage from kalshi.perps.ws.models.orderbook import ( MarginOrderbookDeltaMessage, @@ -593,12 +594,13 @@ async def subscribe( # Per-channel typed subscribe helpers (#398) # # Typed ``subscribe_*`` helpers for the perps data channels. The - # prediction-API channels ``market_positions``, - # ``market_lifecycle_v2`` / ``event_fee_update``, ``multivariate``, + # prediction-API channels ``market_positions``, ``multivariate``, # ``multivariate_market_lifecycle``, and ``communications`` have NO perps # counterpart and intentionally get NO ``subscribe_*`` helper here. # ``corporate_actions`` is on :class:`PerpsChannel` and is subscribed - # via the generic :meth:`subscribe`. + # via the generic :meth:`subscribe`. ``market_lifecycle_v2`` is a perps + # channel (activation / deactivation of every margin market; ticker + # filters are not supported). # # ``builtins.list[str]`` is used for ``tickers`` params: the surrounding # API has a ``list_subscriptions`` method, so the ``list`` builtin is @@ -720,6 +722,21 @@ async def subscribe_order_group( overflow=OverflowStrategy.ERROR, maxsize=maxsize, ) + async def subscribe_market_lifecycle( + self, + *, + maxsize: int = 1000, + ) -> AsyncIterator[MarginMarketLifecycleV2Message]: + """Subscribe to ``market_lifecycle_v2`` (activation and deactivation). + + The server sends every margin market. ``market_ticker`` filters are + not supported. Not sequence-gap tracked, so overflow is ``DROP_OLDEST``. + """ + return await self._do_subscribe( + "market_lifecycle_v2", + overflow=OverflowStrategy.DROP_OLDEST, maxsize=maxsize, + ) + async def unsubscribe(self, client_id: int) -> None: """Tear down a subscription and its associated local state. diff --git a/kalshi/perps/ws/dispatch.py b/kalshi/perps/ws/dispatch.py index c7187d9..87d5a2d 100644 --- a/kalshi/perps/ws/dispatch.py +++ b/kalshi/perps/ws/dispatch.py @@ -37,6 +37,7 @@ UnsubscribeParams, ) from kalshi.perps.ws.models.fill import MarginFillMessage +from kalshi.perps.ws.models.market_lifecycle import MarginMarketLifecycleV2Message from kalshi.perps.ws.models.order_group import OrderGroupUpdatesMessage from kalshi.perps.ws.models.orderbook import ( MarginOrderbookDeltaMessage, @@ -63,6 +64,7 @@ "fill": MarginFillMessage, "user_order": MarginUserOrderMessage, "order_group_updates": OrderGroupUpdatesMessage, + "market_lifecycle_v2": MarginMarketLifecycleV2Message, } # Control message types (not routed to subscription queues). diff --git a/kalshi/perps/ws/models/__init__.py b/kalshi/perps/ws/models/__init__.py index ab95954..8c89e39 100644 --- a/kalshi/perps/ws/models/__init__.py +++ b/kalshi/perps/ws/models/__init__.py @@ -8,6 +8,7 @@ """ from kalshi.perps.ws.models._common import ( + MarginMarketLifecycleEventType, OrderGroupEventType, PerpsLastUpdateReason, PerpsSelfTradePreventionType, @@ -34,6 +35,10 @@ UpdateSubscriptionParams, ) from kalshi.perps.ws.models.fill import MarginFillMessage, MarginFillPayload +from kalshi.perps.ws.models.market_lifecycle import ( + MarginMarketLifecycleV2Message, + MarginMarketLifecycleV2Payload, +) from kalshi.perps.ws.models.order_group import ( OrderGroupUpdatesMessage, OrderGroupUpdatesPayload, @@ -62,6 +67,9 @@ "ListSubscriptionsResponse", "MarginFillMessage", "MarginFillPayload", + "MarginMarketLifecycleEventType", + "MarginMarketLifecycleV2Message", + "MarginMarketLifecycleV2Payload", "MarginOrderbookDeltaMessage", "MarginOrderbookDeltaPayload", "MarginOrderbookSnapshotMessage", diff --git a/kalshi/perps/ws/models/_common.py b/kalshi/perps/ws/models/_common.py index 3902197..b7645c2 100644 --- a/kalshi/perps/ws/models/_common.py +++ b/kalshi/perps/ws/models/_common.py @@ -49,7 +49,11 @@ "limit_updated", ] +# Spec ``marginMarketLifecycleV2Payload.msg.event_type`` enum. +MarginMarketLifecycleEventType = Literal["activated", "deactivated"] + __all__ = [ + "MarginMarketLifecycleEventType", "OrderGroupEventType", "PerpsBookSide", "PerpsLastUpdateReason", diff --git a/kalshi/perps/ws/models/control.py b/kalshi/perps/ws/models/control.py index 5c7df43..9897c0e 100644 --- a/kalshi/perps/ws/models/control.py +++ b/kalshi/perps/ws/models/control.py @@ -52,6 +52,7 @@ class PerpsChannel(StrEnum): USER_ORDERS = "user_orders" ORDER_GROUP_UPDATES = "order_group_updates" CORPORATE_ACTIONS = "corporate_actions" + MARKET_LIFECYCLE_V2 = "market_lifecycle_v2" class UpdateSubscriptionAction(StrEnum): diff --git a/kalshi/perps/ws/models/market_lifecycle.py b/kalshi/perps/ws/models/market_lifecycle.py new file mode 100644 index 0000000..2b28021 --- /dev/null +++ b/kalshi/perps/ws/models/market_lifecycle.py @@ -0,0 +1,38 @@ +"""Margin market lifecycle channel message models. + +``market_lifecycle_v2`` reports activation and deactivation of every margin +market. ``market_ticker`` filters are not supported. ``seq`` is required on +the envelope; ``sending_ts_ms`` is an optional epoch-millisecond send time. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel + +from kalshi.perps.ws.models._common import MarginMarketLifecycleEventType + + +class MarginMarketLifecycleV2Payload(BaseModel): + """Payload for ``market_lifecycle_v2`` (``marginMarketLifecycleV2Payload.msg``). + + All three fields are required: ``market_ticker``, ``event_type`` + (``activated`` / ``deactivated``), and ``is_deactivated``. + """ + + market_ticker: str + event_type: MarginMarketLifecycleEventType + is_deactivated: bool + model_config = {"extra": "allow", "populate_by_name": True} + + +class MarginMarketLifecycleV2Message(BaseModel): + """Market activation or deactivation. ``seq`` is required; not gap-tracked.""" + + type: Literal["market_lifecycle_v2"] = "market_lifecycle_v2" + sid: int + seq: int + msg: MarginMarketLifecycleV2Payload + sending_ts_ms: int | None = None + model_config = {"extra": "allow", "populate_by_name": True} diff --git a/kalshi/resources/orders.py b/kalshi/resources/orders.py index 6a85753..87524d5 100644 --- a/kalshi/resources/orders.py +++ b/kalshi/resources/orders.py @@ -28,6 +28,7 @@ CancelOrderV2Response, CreateOrderV2Request, CreateOrderV2Response, + CreateOrderWithExitTriggerRequest, DecreaseOrderV2Request, DecreaseOrderV2Response, Fill, @@ -296,6 +297,26 @@ def create_v2( data = self._post("/portfolio/events/orders", json=body, extra_headers=extra_headers) return CreateOrderV2Response.model_validate(data) + def create_with_exit_trigger( + self, + *, + request: CreateOrderWithExitTriggerRequest, + extra_headers: dict[str, str] | None = None, + ) -> CreateOrderV2Response: + """``POST /portfolio/events/orders/with_exit_trigger``. Not retried. + + Creates an entry order with an attached take-profit / stop-loss + bracket. Hidden (``x-excluded``) until that feature launches. + """ + self._require_auth() + body = request.model_dump(exclude_none=True, by_alias=True, mode="json") + data = self._post( + "/portfolio/events/orders/with_exit_trigger", + json=body, + extra_headers=extra_headers, + ) + return CreateOrderV2Response.model_validate(data) + def cancel_v2( self, order_id: str, @@ -611,6 +632,22 @@ async def create_v2( data = await self._post("/portfolio/events/orders", json=body, extra_headers=extra_headers) return CreateOrderV2Response.model_validate(data) + async def create_with_exit_trigger( + self, + *, + request: CreateOrderWithExitTriggerRequest, + extra_headers: dict[str, str] | None = None, + ) -> CreateOrderV2Response: + """Async :meth:`OrdersResource.create_with_exit_trigger`. Not retried.""" + self._require_auth() + body = request.model_dump(exclude_none=True, by_alias=True, mode="json") + data = await self._post( + "/portfolio/events/orders/with_exit_trigger", + json=body, + extra_headers=extra_headers, + ) + return CreateOrderV2Response.model_validate(data) + async def cancel_v2( self, order_id: str, diff --git a/kalshi/resources/portfolio.py b/kalshi/resources/portfolio.py index 344a716..5f0d6d4 100644 --- a/kalshi/resources/portfolio.py +++ b/kalshi/resources/portfolio.py @@ -5,16 +5,22 @@ from collections.abc import AsyncIterator, Iterator from typing import Any +from kalshi.errors import KalshiError from kalshi.models.common import Page from kalshi.models.orders import Fill from kalshi.models.portfolio import ( Balance, + CreditHistory, Deposit, + EventContractExitKindLiteral, + EventContractExitTrigger, + GetEventContractExitTriggersResponse, GetTargetBalanceAllocationResponse, IntraExchangeInstanceTransfer, MarketPosition, PositionsResponse, RestingMarginReservationLiteral, + SetEventContractExitTriggerRequest, SetTargetBalanceAllocationRequest, Settlement, SettlementStatusLiteral, @@ -495,6 +501,120 @@ def set_target_balance_allocation( extra_headers=extra_headers, ) + def exit_triggers( + self, + ticker: str, + *, + kind: EventContractExitKindLiteral | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetEventContractExitTriggersResponse: + """``GET /portfolio/positions/{ticker}/exit_trigger``. + + Hidden (``x-excluded``) until event-contract take-profit / stop-loss + launches. ``kind`` filters the bracket family. + """ + self._require_auth() + params = _params(kind=kind, subaccount=subaccount) + data = self._get( + f"/portfolio/positions/{_seg(ticker, name='ticker')}/exit_trigger", + params=params, + extra_headers=extra_headers, + ) + return GetEventContractExitTriggersResponse.model_validate(data) + + def set_exit_trigger( + self, + ticker: str, + *, + request: SetEventContractExitTriggerRequest, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> EventContractExitTrigger: + """``PUT /portfolio/positions/{ticker}/exit_trigger``. Not retried. + + Replaces both legs. A leg omitted from ``request`` is cleared. + """ + self._require_auth() + params = _params(subaccount=subaccount) + data = self._put( + f"/portfolio/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 EventContractExitTrigger body, got 204 No Content.") + return EventContractExitTrigger.model_validate(data) + + def cancel_exit_trigger( + self, + ticker: str, + *, + kind: EventContractExitKindLiteral | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """``DELETE /portfolio/positions/{ticker}/exit_trigger``. Not retried. + + Succeeds even when nothing is live. Returns 204. + """ + self._require_auth() + params = _params(kind=kind, subaccount=subaccount) + self._delete( + f"/portfolio/positions/{_seg(ticker, name='ticker')}/exit_trigger", + params=params, + extra_headers=extra_headers, + ) + + def credit_history( + self, + *, + limit: int | None = None, + cursor: str | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Page[CreditHistory]: + """``GET /portfolio/credit_history`` — credits, newest first. + + ``min_ts`` is inclusive and ``max_ts`` is exclusive, both applied to + credit creation time. ``limit`` is 1-1000. + """ + self._require_auth() + _validate_limit(limit, hi=1000) + params = _params(limit=limit, cursor=cursor, min_ts=min_ts, max_ts=max_ts) + return self._list( + "/portfolio/credit_history", + CreditHistory, + "credits", + params=params, + extra_headers=extra_headers, + ) + + def credit_history_all( + self, + *, + limit: int | None = None, + max_pages: int | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Iterator[CreditHistory]: + """Auto-paginate ``GET /portfolio/credit_history``.""" + self._require_auth() + _validate_max_pages(max_pages) + _validate_limit(limit, hi=1000) + params = _params(limit=limit, min_ts=min_ts, max_ts=max_ts) + return self._list_all( + "/portfolio/credit_history", + CreditHistory, + "credits", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) + class AsyncPortfolioResource(AsyncResource): """Async portfolio API.""" @@ -903,3 +1023,103 @@ async def set_target_balance_allocation( json=request.model_dump(exclude_none=True, by_alias=True, mode="json"), extra_headers=extra_headers, ) + + async def exit_triggers( + self, + ticker: str, + *, + kind: EventContractExitKindLiteral | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> GetEventContractExitTriggersResponse: + """Async :meth:`PortfolioResource.exit_triggers`.""" + self._require_auth() + params = _params(kind=kind, subaccount=subaccount) + data = await self._get( + f"/portfolio/positions/{_seg(ticker, name='ticker')}/exit_trigger", + params=params, + extra_headers=extra_headers, + ) + return GetEventContractExitTriggersResponse.model_validate(data) + + async def set_exit_trigger( + self, + ticker: str, + *, + request: SetEventContractExitTriggerRequest, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> EventContractExitTrigger: + """Async :meth:`PortfolioResource.set_exit_trigger`. Not retried.""" + self._require_auth() + params = _params(subaccount=subaccount) + data = await self._put( + f"/portfolio/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 EventContractExitTrigger body, got 204 No Content.") + return EventContractExitTrigger.model_validate(data) + + async def cancel_exit_trigger( + self, + ticker: str, + *, + kind: EventContractExitKindLiteral | None = None, + subaccount: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> None: + """Async :meth:`PortfolioResource.cancel_exit_trigger`. Not retried.""" + self._require_auth() + params = _params(kind=kind, subaccount=subaccount) + await self._delete( + f"/portfolio/positions/{_seg(ticker, name='ticker')}/exit_trigger", + params=params, + extra_headers=extra_headers, + ) + + async def credit_history( + self, + *, + limit: int | None = None, + cursor: str | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Page[CreditHistory]: + """Async :meth:`PortfolioResource.credit_history`.""" + self._require_auth() + _validate_limit(limit, hi=1000) + params = _params(limit=limit, cursor=cursor, min_ts=min_ts, max_ts=max_ts) + return await self._list( + "/portfolio/credit_history", + CreditHistory, + "credits", + params=params, + extra_headers=extra_headers, + ) + + def credit_history_all( + self, + *, + limit: int | None = None, + max_pages: int | None = None, + min_ts: int | None = None, + max_ts: int | None = None, + extra_headers: dict[str, str] | None = None, + ) -> AsyncIterator[CreditHistory]: + """Async auto-paginate ``GET /portfolio/credit_history``.""" + self._require_auth() + _validate_max_pages(max_pages) + _validate_limit(limit, hi=1000) + params = _params(limit=limit, min_ts=min_ts, max_ts=max_ts) + return self._list_all( + "/portfolio/credit_history", + CreditHistory, + "credits", + params=params, + max_pages=max_pages, + extra_headers=extra_headers, + ) diff --git a/pyproject.toml b/pyproject.toml index 3d065b9..3df7370 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "22.4.0" +version = "22.5.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 f82ca5f..198fbfb 100644 --- a/specs/asyncapi.yaml +++ b/specs/asyncapi.yaml @@ -26,7 +26,7 @@ info: | 8 | Unknown channel name | Invalid channel in subscribe | | 9 | Authentication required | Channel requires authenticated connection | | 10 | Channel error | Channel-specific error | - | 11 | Invalid parameter | Malformed parameter value | + | 11 | Invalid parameter | Malformed parameter value, or update_interval_ms differs from the existing orderbook subscription | | 12 | Exactly one subscription ID is required | For update_subscription | | 13 | Unsupported action | Invalid action for update_subscription | | 14 | Market Ticker required | Missing market specification (market_ticker or market_id) | @@ -41,6 +41,7 @@ info: | 26 | Subscription market limit exceeded | Adding markets would exceed the per-subscription market limit | | 27 | Too many requests | The subscription exceeded its command rate limit | | 28 | Underlying tickers required | Missing underlying_tickers for subscribe_underlyings/unsubscribe_underlyings on pyth_value | + | 29 | update_interval_ms must be 0, 100 or 1000 | Invalid update_interval_ms on orderbook_delta | ## Terminal Errors @@ -158,15 +159,31 @@ channels: - `market_id`/`market_ids` are not supported for this channel - Sends `orderbook_snapshot` first, then incremental `orderbook_delta` updates - Supports `update_subscription` with `add_markets` / `delete_markets` / `get_snapshot` actions - - `get_snapshot` returns an `orderbook_snapshot` for the requested `market_tickers` without modifying the subscription + - `get_snapshot` returns an `orderbook_snapshot` for the requested `market_tickers` without changing the subscribed markets. Apply every returned snapshot as a replacement for that market's local book + - Each connection has one `orderbook_delta` subscription. A repeated `subscribe` adds new markets to its existing `sid`; it does not resend snapshots for markets already subscribed + - A repeated `subscribe` must use the existing `update_interval_ms` (omitting it means `0`). A different valid interval returns error code 11 without adding markets. Use `update_subscription` with `add_markets` to add markets without respecifying the interval. To change the interval, `unsubscribe`, wait for `unsubscribed`, then `subscribe` again + + **Matching real-time updates across connections:** + These guarantees apply to subscriptions created in real-time mode (the initial `update_interval_ms` is omitted or `0`). Conflated subscriptions have the separate clock and recovery rules below. - **Matching updates across connections:** - `clock` is the exchange clock. It is the same on every connection, so it can match a market's updates across redundant connections; `seq` counts messages on one subscription only - One exchange action can make several deltas, so several deltas can share a `clock`. Every connection sends them in the same order, before any delta for that market with a higher `clock` - A snapshot includes every change with a `clock` at or below its own; the deltas that follow it on that connection have higher clocks - While you are subscribed to a market, its clocks never go down outside exchange maintenance, but they skip values. They are not comparable across markets. Keep checking `seq` on each connection for missed messages - A market's clock may decrease during exchange maintenance, so don't compare clocks from before a maintenance period with clocks after it + **Conflated updates (opt-in):** + - Set `update_interval_ms` to `100` or `1000` on the initial `subscribe` to receive conflated updates instead of one `orderbook_delta` per change. Omit it or set it to `0` for real-time updates, the default + - Each window produces one `orderbook_delta` per changed price level, carrying the net held change at that level. Levels whose held changes cancel out are skipped. Apply every delta in sequence to account for those changes exactly + - Each window's flush is scheduled one interval after that window's first held change; processing and transport latency are additional. `clock`, `ts` and `ts_ms` are those of the latest change included in each delta + - A conflated delta's `clock` describes that price level's latest included change, not the completeness of the whole book. Clocks can decrease between messages, including when an immediate own-order update overtakes a held public update. Do not discard a delta because its clock is lower than a previously received clock + - Updates to your own orders bypass the interval, with the usual `client_order_id` and `subaccount` annotations when available. For a subaccount-restricted key, this applies only to orders within that restriction. Changes to other participants' orders, including liquidity your trades consume, are held like other public changes + - Snapshots bypass the interval and replace any unsent held changes for their market. Always replace your local book with a received snapshot before applying subsequent deltas for that market + - Between flushes, public levels can lag your own orders, and the book can appear locked or crossed. A flush contains separate delta messages, not an atomic full-book snapshot; there is no end-of-flush message + - `seq` stays contiguous; the interval is fixed for the life of the subscription + - Apply every delta in `seq` order within its subscription. If you detect a gap, request `get_snapshot` for all subscribed markets, or `unsubscribe`, wait for `unsubscribed`, then `subscribe` again. A repeated `subscribe` alone does not resynchronize existing markets + - Conflation windows are independent across subscriptions, so the same clock can accompany different net deltas on different connections. Do not merge or deduplicate conflated streams by clock. When switching connections, rebuild from that connection's snapshot and follow its own `seq` + **Use case:** Building and maintaining a real-time orderbook messages: orderbookSnapshot: @@ -298,7 +315,8 @@ channels: - `shard_factor` (1-100) and `shard_key` (`0 <= key < shard_factor`) - RFQ events (RFQCreated, RFQDeleted) are sent for all users by default. Set `user_filter: "self"` to receive only RFQs you created. Omitted or empty `user_filter` keeps the default. - Quote events (QuoteCreated, QuoteAccepted, QuoteExecuted) are only sent if you created the quote OR you created the RFQ - - RFQ batch events (RFQBatchCreated, RFQBatchDeleted) follow the RFQ event rules: sent for all users by default, or with `user_filter: "self"` only for RFQ batches you created. A batch's child RFQs are announced only inside RFQBatchCreated; each child trade still sends QuoteExecuted, with `rfq_batch_id` set. With sharding, every batch event is partitioned by `rfq_batch_id` instead of the market ticker, so a child's QuoteExecuted can arrive on another shard than its batch's events. + - RFQ batch events (RFQBatchCreated, RFQBatchDeleted) follow the RFQ event rules: sent for all users by default, or with `user_filter: "self"` only for RFQ batches you created. Each child RFQ of a batch also arrives as an RFQCreated with `rfq_batch_id` set, right after its batch's RFQBatchCreated, and an RFQDeleted with `rfq_batch_id` set when it closes; filter on `rfq_batch_id` to skip them. Each child trade sends QuoteExecuted, with `rfq_batch_id` set. With sharding, every batch event is partitioned by `rfq_batch_id` instead of the market ticker, while a child's RFQ and quote events are partitioned by its market ticker, so they can arrive on another shard than its batch's events. + - A child RFQ of a batch can be quoted like a single RFQ. Such a quote competes with batch quotes for that child, with the differences listed on `rfq_batch_id` in RFQCreated. Its QuoteCreated and QuoteAccepted go only to the maker, never to the batch creator, with `rfq_creator_id` set to the shared placeholder. - Each child of an RFQ batch fills at its best quote across makers, and the children execute together as one fill-or-kill: nothing trades unless every child can fill in full. In the rare case that filling one child cancels a maker order another child needed, the batch closes partially executed. Every maker's batch quote prices every child, and each child goes to the maker bidding best on the accepted side, so one batch can have several winning makers. A batch quote counts only for 30 seconds after it was last submitted; resending an unchanged bid within that time keeps its time priority. - RFQBatchBestQuotes and RFQBatchExecuted are only sent to the RFQ batch creator. RFQBatchBestQuotes carries the best bids across makers without identifying them; the batch creator never receives a maker's individual batch quote. - Batch quote events (BatchQuoteCreated, BatchQuoteAccepted, BatchQuoteExecuted) are only sent to the maker that created the batch quote. Each winning maker confirms the children it won, and nothing executes until every winning maker has confirmed. @@ -1256,6 +1274,15 @@ components: params: channels: ["orderbook_delta"] market_ticker: "CPI-22DEC-TN0.1" + - name: subscribeToConflatedOrderbook + summary: Subscribe to orderbook_delta with 100 ms conflated updates + payload: + id: 16 + cmd: subscribe + params: + channels: ["orderbook_delta"] + market_tickers: ["FED-23DEC-T3.00", "CORIVER-2024-T1030"] + update_interval_ms: 100 - name: subscribeMultipleChannels summary: Subscribe to multiple channels for multiple markets payload: @@ -1646,7 +1673,7 @@ components: | 8 | Unknown channel name | Invalid channel in subscribe | | 9 | Authentication required | Channel requires authenticated connection | | 10 | Channel error | Channel-specific error | - | 11 | Invalid parameter | Malformed parameter value | + | 11 | Invalid parameter | Malformed parameter value, or update_interval_ms differs from the existing orderbook subscription | | 12 | Exactly one subscription ID is required | For update_subscription | | 13 | Unsupported action | Invalid action for update_subscription | | 14 | Market Ticker required | Missing market specification (market_ticker or market_id) | @@ -1664,6 +1691,7 @@ components: | 26 | Subscription market limit exceeded | Adding markets would exceed the per-subscription market limit | | 27 | Too many requests | The subscription exceeded its command rate limit | | 28 | Underlying tickers required | Missing underlying_tickers for subscribe_underlyings/unsubscribe_underlyings on pyth_value | + | 29 | update_interval_ms must be 0, 100 or 1000 | Invalid update_interval_ms on orderbook_delta | contentType: application/json payload: $ref: '#/components/schemas/errorResponsePayload' @@ -2446,7 +2474,7 @@ components: description: | An RFQ batch requests quotes on several combos together, for example every parlay of a round robin. Distinct legs are listed once in `legs`; each child RFQ lists the indexes of its legs in `leg_indices`. - Quote it with `POST /communications/rfq_batches/{rfq_batch_id}/quotes`, pricing every child. Each child is filled at the best bid across makers. Its child RFQs are not announced separately. + Quote it with `POST /communications/rfq_batches/{rfq_batch_id}/quotes`, pricing every child. Each child is filled at the best bid across makers. Each child RFQ is also announced as an `rfq_created` with `rfq_batch_id` set, and can be quoted like a single RFQ. contentType: application/json payload: $ref: '#/components/schemas/rfqBatchCreatedPayload' @@ -2805,6 +2833,23 @@ components: the legacy no-leg pricing. Integrations relying on the legacy behavior should migrate before the default flip; concrete dates will be announced before each step. default: false + update_interval_ms: + type: integer + enum: [0, 100, 1000] + default: 0 + description: | + Orderbook channel only. Opts in to conflated `orderbook_delta` updates: one net delta per + changed price level in each 100 or 1000 millisecond window. A window starts with its first + held change; processing and transport can delay delivery. Your own orders within any subaccount restriction and snapshots bypass the interval; + changes to other participants' orders, including liquidity you consume, remain held. + Omit or set to 0 for real-time updates, the default. Use JSON integer literals: other signed + 64-bit integer literals return error code 29. Values that fail integer decoding, including + floating-point notation and strings, return code 1 for the whole command. + A repeated subscribe adds markets to the existing sid and must specify the same interval + (omission means 0); a mismatch returns error code 11. Change intervals by unsubscribing first. + Follow seq within this subscription; conflated clocks can decrease and cannot match or + deduplicate updates across connections. Recover gaps with fresh snapshots for all subscribed + markets or unsubscribe then subscribe again; a repeated subscribe alone does not resynchronize. user_filter: type: string enum: ["", "self"] @@ -3121,7 +3166,7 @@ components: - 8: Unknown channel name - Invalid channel in subscribe - 9: Authentication required - Channel requires authenticated connection - 10: Channel error - Channel-specific error - - 11: Invalid parameter - Malformed parameter value + - 11: Invalid parameter - Malformed parameter value, or update_interval_ms differs from the existing orderbook subscription - 12: Exactly one subscription ID is required - For update_subscription - 13: Unsupported action - Invalid action for update_subscription - 14: Market Ticker required - Missing market specification (market_ticker or market_id) @@ -3137,10 +3182,11 @@ components: - 26: Subscription market limit exceeded - Adding markets would exceed the per-subscription market limit - 27: Too many requests - The subscription exceeded its command rate limit - 28: Underlying tickers required - Missing underlying_tickers for subscribe_underlyings/unsubscribe_underlyings on pyth_value + - 29: update_interval_ms must be 0, 100 or 1000 - Invalid update_interval_ms on orderbook_delta Codes 6, 16, and 17 are retired. The service no longer emits them and their numbers stay reserved. - enum: [1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28] + enum: [1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29] msg: type: string description: Human-readable error message @@ -3468,8 +3514,12 @@ components: format: int64 minimum: 1 description: | - Exchange clock of the exchange action that made this change. Every connection sends the same clock for the same change. - Several deltas can share a clock when one action makes several changes. + Exchange clock of the latest change included in this delta. + In real-time mode, every connection sends the same clock for the same change; + several deltas can share a clock. + In conflated mode, the latest exchange clock included in this price level's net delta. + It can decrease between messages and is not a whole-book watermark or a cross-connection + deduplication key. Apply deltas using this subscription's seq order. price_dollars: type: string description: Price level in dollars @@ -4368,6 +4418,18 @@ components: description: Yes settlement value in dollars for the selected leg. Omitted when unavailable. x-rust-type: common::types::Price x-rust-serialize-with: ws::dto::serde_money::serialize_option_price_dollars + rfq_batch_id: + type: string + description: >- + Identifier of the RFQ batch this RFQ is a child of. Omitted for an + RFQ outside a batch. A child can be quoted like a single RFQ, with + these differences: a quote counts only for 30 seconds after it was + sent, and resending an unchanged bid keeps its time priority; + nothing executes until every winning maker of the batch has + confirmed, so a confirmed quote can still close without a fill; + an accepted quote can't be cancelled, and not confirming it lapses + with the same penalty as cancelling; and `rfq_creator_id` on its + quote messages is the shared placeholder. sending_ts_ms: $ref: '#/components/schemas/sendingTimestampMs' @@ -4412,6 +4474,9 @@ components: type: string description: Timestamp when the RFQ was deleted format: date-time + rfq_batch_id: + type: string + description: Identifier of the RFQ batch this RFQ is a child of, which closed with it. Omitted for an RFQ outside a batch. sending_ts_ms: $ref: '#/components/schemas/sendingTimestampMs' @@ -4441,7 +4506,7 @@ components: description: Public communications ID of the quote creator (anonymized) rfq_creator_id: type: string - description: Public communications ID of the RFQ creator (pseudonymous). When obscure_creator_id is enabled, other users receive the shared placeholder "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" (SHA-256 of empty input). The creator sees their normal ID. + description: Public communications ID of the RFQ creator (pseudonymous). When obscure_creator_id is enabled, other users receive the shared placeholder "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" (SHA-256 of empty input). The creator sees their normal ID. On a quote on a child RFQ of an RFQ batch, always the placeholder. market_ticker: type: string description: Market ticker for the quote @@ -4511,7 +4576,7 @@ components: description: Public communications ID of the quote creator (anonymized) rfq_creator_id: type: string - description: Public communications ID of the RFQ creator (pseudonymous). When obscure_creator_id is enabled, other users receive the shared placeholder "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" (SHA-256 of empty input). The creator sees their normal ID. + description: Public communications ID of the RFQ creator (pseudonymous). When obscure_creator_id is enabled, other users receive the shared placeholder "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" (SHA-256 of empty input). The creator sees their normal ID. On a quote on a child RFQ of an RFQ batch, always the placeholder. market_ticker: type: string description: Market ticker for the quote @@ -4607,7 +4672,7 @@ components: Contains your own subaccount number; the counterparty's subaccount is never shared. rfq_batch_id: type: string - description: Identifier of the RFQ batch, when the quote was accepted as part of a batch quote. Omitted otherwise. + description: Identifier of the RFQ batch, when the quote is on a child RFQ of an RFQ batch. Omitted otherwise. sending_ts_ms: $ref: '#/components/schemas/sendingTimestampMs' @@ -5131,7 +5196,7 @@ x-error-codes: description: Channel-specific error - code: 11 name: Invalid parameter - description: Malformed parameter value + description: Malformed parameter value, or update_interval_ms differs from the existing orderbook subscription - code: 12 name: Exactly one subscription ID is required description: For update_subscription command @@ -5177,3 +5242,6 @@ x-error-codes: - code: 28 name: Underlying tickers required description: Missing underlying_tickers for subscribe_underlyings/unsubscribe_underlyings on pyth_value + - code: 29 + name: update_interval_ms must be 0, 100 or 1000 + description: Invalid update_interval_ms on orderbook_delta diff --git a/specs/openapi.yaml b/specs/openapi.yaml index 3b21ee9..c52d068 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.34.0 + version: 3.35.0 description: Manually defined OpenAPI spec for endpoints being migrated to spec-first approach servers: @@ -15,6 +15,141 @@ servers: description: Demo shared API server, also supported paths: + /portfolio/positions/{ticker}/exit_trigger: + parameters: + - name: ticker + in: path + required: true + schema: + type: string + - $ref: '#/components/parameters/SubaccountQueryDefaultPrimary' + put: + operationId: SetEventContractExitTrigger + # Hidden from the published docs until event-contract TP/SL launches. + x-excluded: true + summary: Set Position Exit Trigger + description: >- + Endpoint for setting a take-profit / stop-loss bracket on a position. Requires an open + position and fires a sell capped at the position. Both legs are set from the request, so + a leg you omit is cleared; use DELETE to cancel the bracket. + tags: [portfolio] + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SetEventContractExitTriggerRequest' + responses: + '200': + description: "" + content: + application/json: + schema: + $ref: '#/components/schemas/EventContractExitTrigger' + '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' + get: + operationId: GetEventContractExitTriggers + # Hidden from the published docs until event-contract TP/SL launches. + x-excluded: true + summary: Get Position Exit Triggers + description: >- + Endpoint for retrieving the take-profit / stop-loss bracket on a position. + `exit_triggers` lists live brackets, and `exit_trigger` is the most recently updated + bracket, including a terminal one. + tags: [portfolio] + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - name: kind + in: query + schema: + type: string + enum: [bracket] + x-enum-varnames: + - GetEventContractExitTriggersParamsKindBracket + responses: + '200': + description: "" + content: + application/json: + schema: + type: object + required: [exit_trigger, exit_triggers] + properties: + exit_trigger: + nullable: true + allOf: + - $ref: '#/components/schemas/EventContractExitTrigger' + exit_triggers: + type: array + items: + $ref: '#/components/schemas/EventContractExitTrigger' + '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: DeleteEventContractExitTrigger + # Hidden from the published docs until event-contract TP/SL launches. + x-excluded: true + summary: Cancel Position Exit Trigger + description: >- + Endpoint for cancelling the take-profit / stop-loss bracket on a position. Succeeds even + when nothing is live. + tags: [portfolio] + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - name: kind + in: query + schema: + type: string + enum: [bracket] + x-enum-varnames: + - DeleteEventContractExitTriggerParamsKindBracket + responses: + '204': + description: "" + '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' + /exchange/status: get: operationId: GetExchangeStatus @@ -1168,6 +1303,47 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /portfolio/events/orders/with_exit_trigger: + post: + operationId: CreateOrderWithExitTrigger + # Hidden from the published docs until event-contract TP/SL launches. + x-excluded: true + summary: Create Order With TP/SL + description: Creates an entry order with attached take-profit or stop-loss protection. + tags: + - orders + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateOrderWithExitTriggerRequest' + responses: + '201': + description: Order created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CreateOrderV2Response' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '409': + $ref: '#/components/responses/ConflictError' + '429': + $ref: '#/components/responses/RateLimitError' + '500': + $ref: '#/components/responses/InternalServerError' + '503': + $ref: '#/components/responses/ServiceUnavailableError' + /portfolio/events/orders/{order_id}: delete: operationId: CancelOrderV2 @@ -1884,6 +2060,40 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /portfolio/credit_history: + get: + operationId: GetCreditHistory + summary: Get Credit History + description: | + Returns the authenticated member's credit history, newest first. + Filters apply to credit creation time: `min_ts` is inclusive and `max_ts` is exclusive. + tags: + - portfolio + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - $ref: '#/components/parameters/LimitQuery' + - $ref: '#/components/parameters/CursorQuery' + - $ref: '#/components/parameters/MinTsQuery' + - $ref: '#/components/parameters/MaxTsQuery' + responses: + '200': + description: Credit history retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetCreditHistoryResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + $ref: '#/components/responses/ForbiddenError' + '500': + $ref: '#/components/responses/InternalServerError' + /portfolio/deposits: get: operationId: GetDeposits @@ -2496,6 +2706,9 @@ paths: child that has none; every child must be sized the same way, by contracts or by target cost. rest_remainder must be false, and FCM members cannot create RFQ batches yet (403 rfq_batch_unavailable). + Makers also see each child as an ordinary RFQ, with rfq_batch_id set, + and can quote it as a single RFQ; its best quote counts toward the + batch's. x-mint: content: | @@ -5369,6 +5582,101 @@ components: default: null schemas: + EventContractExitSlippage: + description: Execution allowance below the trigger price, shared by TP and SL. Use bps (0-9999) or whole cents (0-99). Zero allows no slippage. Omit on creation to save the backend default, or on edits to keep the existing setting. + type: object + additionalProperties: false + required: [unit, value] + not: + properties: + unit: + enum: [cents] + value: + minimum: 100 + required: [unit, value] + properties: + unit: + type: string + enum: [bps, cents] + x-enum-varnames: + - EventContractExitSlippageUnitBps + - EventContractExitSlippageUnitCents + value: + type: integer + format: int32 + x-go-type: "*int32" + minimum: 0 + maximum: 9999 + SetEventContractExitTriggerRequest: + description: Replaces both legs, so a leg you omit is cleared. At least one leg is required. Prices are YES prices, like order prices, so on a NO position the stop loss is above the YES price and the take profit is below it. + type: object + additionalProperties: false + properties: + slippage: + $ref: '#/components/schemas/EventContractExitSlippage' + kind: + type: string + enum: [bracket] + x-enum-varnames: + - SetEventContractExitTriggerRequestKindBracket + stop_loss_price: + $ref: '#/components/schemas/FixedPointDollars' + take_profit_price: + $ref: '#/components/schemas/FixedPointDollars' + EventContractExitTrigger: + description: "`side` is the outcome the position holds. Prices are YES prices, like order prices." + type: object + required: [id, ticker, side, subaccount_number, kind, status, created_time, updated_time] + properties: + slippage: + $ref: '#/components/schemas/EventContractExitSlippage' + id: + type: string + ticker: + type: string + side: + type: string + enum: [yes, no] + x-enum-varnames: + - EventContractExitTriggerSideYes + - EventContractExitTriggerSideNo + subaccount_number: + type: integer + kind: + type: string + enum: [bracket] + x-enum-varnames: + - EventContractExitTriggerKindBracket + stop_loss_price: + $ref: '#/components/schemas/FixedPointDollars' + take_profit_price: + $ref: '#/components/schemas/FixedPointDollars' + status: + type: string + enum: [pending_on_entry, active, filled, canceled, failed, unknown] + x-enum-varnames: + - EventContractExitTriggerStatusPendingOnEntry + - EventContractExitTriggerStatusActive + - EventContractExitTriggerStatusFilled + - EventContractExitTriggerStatusCanceled + - EventContractExitTriggerStatusFailed + - EventContractExitTriggerStatusUnknown + status_reason: + type: string + triggered_order_id: + type: string + triggered_leg: + type: string + enum: [stop_loss, take_profit] + x-enum-varnames: + - EventContractExitTriggerTriggeredLegStopLoss + - EventContractExitTriggerTriggeredLegTakeProfit + created_time: + type: string + format: date-time + updated_time: + type: string + format: date-time # Common schemas FixedPointDollars: type: string @@ -6308,6 +6616,211 @@ components: live_data: $ref: '#/components/schemas/EventLiveData' + GetMarketIndexesResponse: + type: object + required: + - indexes + properties: + indexes: + type: array + items: + $ref: '#/components/schemas/MarketIndex' + + MarketIndex: + type: object + required: + - id + - name + - family + - currency + - units + - session + - available + properties: + id: + type: string + description: Stable index ID, e.g. `SP500` or `SP500-45`. + name: + type: string + description: Index name, e.g. `S&P 500 Information Technology`. + family: + type: string + description: Index family, e.g. `S&P 500 GICS Sectors`. + gics_code: + type: string + description: GICS sector code, for sector indexes only. + sector: + type: string + description: GICS sector name, for sector indexes only. + currency: + type: string + description: ISO 4217 currency of the index, e.g. `USD`. + units: + type: string + description: Unit of index values; always `index_points`. + session: + $ref: '#/components/schemas/MarketIndexSession' + available: + type: boolean + description: >- + False while the index has no data source; such an index serves no + values. + latest: + $ref: '#/components/schemas/MarketIndexLatest' + + MarketIndexSession: + type: object + description: The index's regular publication session. + required: + - timezone + - open + - close + - days + properties: + timezone: + type: string + description: IANA time zone of `open` and `close`, e.g. `America/New_York`. + open: + type: string + description: Session open, `HH:MM` local time. + close: + type: string + description: Session close, `HH:MM` local time. + days: + type: array + description: Weekdays the session runs, e.g. `mon`. + items: + type: string + calendar: + type: string + description: >- + Holiday calendar applied on top of `days`: `us_equity` (US stock + market holidays and 1:00 PM early closes). + + MarketIndexLatest: + type: object + required: + - source + - status + properties: + source: + type: string + description: Data source of the value, e.g. `spdji` (S&P Dow Jones Indices) or `massive`. + status: + $ref: '#/components/schemas/MarketIndexStatus' + value: + type: number + format: double + description: Latest index value. Absent when no value is available. + last_tick_ms: + type: integer + format: int64 + description: Time of `value`, unix milliseconds. Absent with `value`. + + MarketIndexStatus: + type: string + enum: [open, closed, stale] + description: >- + `open`: in session with a current value. `closed`: outside the + session; the value is the latest from the last session. `stale`: in + session, but no source has updated in the last two minutes. + + MarketIndexTimeseriesPoint: + type: object + required: + - t + - v + properties: + t: + type: integer + format: int64 + description: Unix milliseconds. + v: + type: number + format: double + description: Index value. + + GetMarketIndexResponse: + type: object + required: + - index + - source + - status + - interval + - start_ms + - end_ms + - timeseries + properties: + index: + $ref: '#/components/schemas/MarketIndex' + source: + type: string + description: Data source of `timeseries`. + status: + $ref: '#/components/schemas/MarketIndexStatus' + value: + type: number + format: double + description: Latest index value. Absent when no value is available. + last_tick_ms: + type: integer + format: int64 + description: Time of `value`, unix milliseconds. + interval: + type: string + description: Point spacing of `timeseries` (`1m`). + start_ms: + type: integer + format: int64 + description: Window start, unix milliseconds. + end_ms: + type: integer + format: int64 + description: Window end, unix milliseconds. + timeseries: + type: array + items: + $ref: '#/components/schemas/MarketIndexTimeseriesPoint' + + GetMarketIndexHistoryResponse: + type: object + required: + - id + - source + - status + - interval + - start_ms + - end_ms + - timeseries + properties: + id: + type: string + description: Market index ID. + source: + type: string + description: Data source of `timeseries`. + status: + $ref: '#/components/schemas/MarketIndexStatus' + last_tick_ms: + type: integer + format: int64 + description: Time of the index's latest value, unix milliseconds. + interval: + type: string + description: Point spacing of `timeseries` (`1s` or `1m`). + start_ms: + type: integer + format: int64 + description: Window start, unix milliseconds. + end_ms: + type: integer + format: int64 + description: Window end, unix milliseconds. + timeseries: + type: array + items: + $ref: '#/components/schemas/MarketIndexTimeseriesPoint' + GetWeatherIndexResponse: type: object required: @@ -6759,6 +7272,51 @@ components: description: Exchange index of the subaccount. # Portfolio schemas (specific to portfolio endpoints, not shared with IB) + GetCreditHistoryResponse: + type: object + required: + - credits + - cursor + properties: + credits: + type: array + items: + $ref: '#/components/schemas/CreditHistory' + cursor: + type: string + description: Pagination cursor; empty when there are no more results. + + CreditHistory: + type: object + required: + - credit_id + - type + - status + - amount_dollars + - reason + - created_time + properties: + credit_id: + type: string + description: Unique credit identifier. + type: + type: string + description: Credit category. + status: + type: string + enum: [unknown, pending, failed, applied] + description: Credit processing status. + amount_dollars: + $ref: '#/components/schemas/FixedPointDollars' + description: Credit amount in dollars. + reason: + type: string + description: Credit description, which may include a market or event ticker. + created_time: + type: string + format: date-time + description: Time the credit was created. + GetSettlementsResponse: type: object required: @@ -8495,6 +9053,14 @@ components: type: string format: date-time description: Timestamp when the RFQ was last updated + rfq_batch_id: + type: string + description: >- + Identifier of the RFQ batch this RFQ is a child of. Omitted for an + RFQ outside a batch. A child can be quoted like a single RFQ, with + the differences described in + [Request for Quote](/getting_started/rfqs#rfq-batch-children). + x-go-type-skip-optional-pointer: true GetRFQsResponse: type: object @@ -9228,6 +9794,17 @@ components: order: $ref: '#/components/schemas/Order' + AttachedEventContractExitTrigger: + description: Prices are YES prices, like the order's `price`, so for an ask the stop loss is above the entry price and the take profit is below it. + type: object + properties: + slippage: + $ref: '#/components/schemas/EventContractExitSlippage' + stop_loss_price: + $ref: '#/components/schemas/FixedPointDollars' + take_profit_price: + $ref: '#/components/schemas/FixedPointDollars' + CreateOrderRequest: type: object required: @@ -9527,6 +10104,18 @@ components: $ref: '#/components/schemas/Order' description: The order after amendment + CreateOrderWithExitTriggerRequest: + allOf: + - $ref: '#/components/schemas/CreateOrderV2Request' + - type: object + required: [exit_trigger] + properties: + time_in_force: + type: string + x-go-type: CreateOrderV2RequestTimeInForce + exit_trigger: + $ref: '#/components/schemas/AttachedEventContractExitTrigger' + CreateOrderV2Request: type: object required: diff --git a/specs/perps_asyncapi.yaml b/specs/perps_asyncapi.yaml index dbd6969..c1995a2 100644 --- a/specs/perps_asyncapi.yaml +++ b/specs/perps_asyncapi.yaml @@ -13,6 +13,7 @@ info: - `user_orders` - `order_group_updates` - `corporate_actions` + - `market_lifecycle_v2` contact: name: Kalshi Support url: https://kalshi.com @@ -110,7 +111,22 @@ channels: - authenticated connection - market specification required via `market_ticker` or `market_tickers` - sends `orderbook_snapshot` first, then incremental `orderbook_delta` updates - - supports `get_snapshot` on `update_subscription` to request a fresh snapshot without changing the subscription + - supports `get_snapshot` on `update_subscription` to request a fresh snapshot without changing the subscribed markets. Apply every returned snapshot as a replacement for that market's local book + - each connection has one `orderbook_delta` subscription. A repeated `subscribe` adds new markets to its existing `sid` and sends no snapshot for markets already subscribed + - a repeated `subscribe` must use the existing `update_interval_ms` (omitting it means `0`); a different valid interval returns error code 11 without adding markets. Use `update_subscription` with `add_markets` to add markets without respecifying the interval. To change it, `unsubscribe`, wait for `unsubscribed`, then `subscribe` again + - optional conflated updates: set `update_interval_ms` to `100` or `1000` on the initial `subscribe` to receive one net + `orderbook_delta` per changed price level per window. A window starts with its first held change; processing and transport can delay delivery. Updates to your own orders + within any subaccount restriction and snapshots bypass the interval. A snapshot replaces any unsent held changes for its market, and `seq` stays contiguous. + Omit it or set it to `0` for real-time updates, the default + - levels whose held changes cancel out are skipped. `ts_ms` is the timestamp of the latest change included in each delta; + it can decrease between deltas and is not a completeness watermark. Follow the subscription's `seq` for delivery order + - changes to other participants' orders, including liquidity your trades consume, remain held. Between flushes, + public levels can lag your own orders and the book can appear locked or crossed. A flush has separate delta messages and no end-of-flush message + - apply every delta in `seq` order within its subscription. Recover a gap with `get_snapshot` for all subscribed markets, + or `unsubscribe`, wait for `unsubscribed`, then `subscribe` again. Replace each book with its new snapshot before applying later deltas; + a repeated `subscribe` alone does not resynchronize existing markets + - conflation windows are independent across subscriptions. When switching connections, rebuild from that + connection's snapshot and follow its own `seq`; do not combine deltas from different connections messages: orderbookSnapshot: $ref: '#/components/messages/orderbookSnapshot' @@ -137,6 +153,19 @@ channels: corporateActionsDelta: $ref: '#/components/messages/corporateActionsDelta' + market_lifecycle_v2: + address: market_lifecycle_v2 + title: Market Lifecycle + description: | + Market deactivation and activation notifications. + + Requirements: + - no additional channel-level auth beyond the authenticated WebSocket connection + - receives every margin market; `market_ticker` filters are not supported + messages: + marketLifecycleV2: + $ref: '#/components/messages/marketLifecycleV2' + ticker: address: ticker title: Market Ticker @@ -223,6 +252,17 @@ operations: tags: - name: market-data + receiveMarketLifecycleV2: + action: send + title: Market Lifecycle Event + summary: Receive market deactivation and activation notifications + channel: + $ref: '#/channels/market_lifecycle_v2' + messages: + - $ref: '#/channels/market_lifecycle_v2/messages/marketLifecycleV2' + tags: + - name: market-data + sendPing: action: receive title: Send Ping @@ -594,6 +634,36 @@ components: contentType: application/json payload: $ref: '#/components/schemas/marginCorporateActionsDeltaPayload' + marketLifecycleV2: + name: market_lifecycle_v2 + title: Market Lifecycle V2 + summary: Market deactivated or activated + contentType: application/json + payload: + $ref: '#/components/schemas/marginMarketLifecycleV2Payload' + examples: + - name: marketDeactivated + summary: Market deactivated + payload: + type: market_lifecycle_v2 + sending_ts_ms: 1792000000150 + sid: 13 + seq: 4 + msg: + market_ticker: "KXBTCPERP" + event_type: "deactivated" + is_deactivated: true + - name: marketActivated + summary: Market activated + payload: + type: market_lifecycle_v2 + sending_ts_ms: 1792000900150 + sid: 13 + seq: 5 + msg: + market_ticker: "KXBTCPERP" + event_type: "activated" + is_deactivated: false ticker: name: ticker title: Ticker Update @@ -748,7 +818,7 @@ components: minItems: 1 items: type: string - enum: ["orderbook_delta", "ticker", "trade", "fill", "user_orders", "order_group_updates", "corporate_actions"] + enum: ["orderbook_delta", "ticker", "trade", "fill", "user_orders", "order_group_updates", "corporate_actions", "market_lifecycle_v2"] market_ticker: type: string market_tickers: @@ -763,6 +833,22 @@ components: skip_ticker_ack: type: boolean default: false + update_interval_ms: + type: integer + enum: [0, 100, 1000] + default: 0 + description: | + Orderbook channel only. Opts in to conflated `orderbook_delta` updates: one net delta per + changed price level in each 100 or 1000 millisecond window. A window starts with its first + held change; processing and transport can delay delivery. Your own orders within any subaccount restriction and snapshots bypass the interval; + changes to other participants' orders, including liquidity you consume, remain held. + Omit or set to 0 for real-time updates, the default. Use JSON integer literals: other signed + 64-bit integer literals return error code 29. Values that fail integer decoding, including + floating-point notation and strings, return code 1 for the whole command. + A repeated subscribe adds markets to the existing sid and must specify the same interval + (omission means 0); a mismatch returns error code 11. Change intervals by unsubscribing first. + Follow seq within this subscription. Recover gaps with fresh snapshots for all subscribed markets + or unsubscribe then subscribe again. Independently conflated streams cannot be combined. unsubscribeCommandPayload: type: object @@ -948,7 +1034,7 @@ components: outside the public Margin surface. Codes 6, 16, and 17 are retired; the service no longer emits them and their numbers stay reserved. - enum: [1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 23, 24, 25, 26, 27, 28] + enum: [1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 18, 23, 24, 25, 26, 27, 28, 29] msg: type: string market_ticker: @@ -1028,7 +1114,10 @@ components: ts_ms: type: integer format: int64 - description: Unix timestamp in milliseconds. + description: | + Unix timestamp in milliseconds. + In conflated mode, this is the timestamp of the latest change included at this price level. + It can decrease between deltas; follow the subscription's seq for delivery order. subaccount: type: integer sending_ts_ms: @@ -1044,7 +1133,7 @@ components: properties: id: type: string - description: "Corporate action notice id, matching `id` in `GET /trade-api/v2/margin/corporate_actions`." + description: "Corporate action id, matching `id` in `GET /trade-api/v2/margin/corporate_actions`." market_id: type: string description: "Stable market UUID." @@ -1135,6 +1224,32 @@ components: sending_ts_ms: $ref: '#/components/schemas/sendingTimestampMs' + marginMarketLifecycleV2Payload: + type: object + required: ["type", "sid", "seq", "msg"] + properties: + type: + type: string + const: "market_lifecycle_v2" + sid: + $ref: '#/components/schemas/subscriptionId' + seq: + $ref: '#/components/schemas/sequenceNumber' + msg: + type: object + required: ["market_ticker", "event_type", "is_deactivated"] + properties: + market_ticker: + $ref: '#/components/schemas/marketTicker' + event_type: + type: string + enum: ["activated", "deactivated"] + is_deactivated: + type: boolean + description: Whether trading on the market is paused. + sending_ts_ms: + $ref: '#/components/schemas/sendingTimestampMs' + marginTickerPayload: type: object required: ["type", "sid", "msg"] @@ -1308,8 +1423,9 @@ components: type: string enum: ["user", "system"] description: | - `system` for liquidations and margin exit or trailing-stop - triggers. `user` for every other order. + `system` for liquidations, margin exit or trailing-stop + triggers, and auto-rebalance orders. `user` for every other + order. sending_ts_ms: $ref: '#/components/schemas/sendingTimestampMs' @@ -1376,8 +1492,9 @@ components: type: string enum: ["user", "system"] description: | - `system` for liquidations and margin exit or trailing-stop - triggers. `user` for every other order. + `system` for liquidations, margin exit or trailing-stop + triggers, and auto-rebalance orders. `user` for every other + order. sending_ts_ms: $ref: '#/components/schemas/sendingTimestampMs' diff --git a/specs/perps_openapi.yaml b/specs/perps_openapi.yaml index da55eb7..b6fb465 100644 --- a/specs/perps_openapi.yaml +++ b/specs/perps_openapi.yaml @@ -626,6 +626,46 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /margin/markets/schedules: + get: + operationId: GetMarginMarketSchedules + summary: Get Market Schedules + description: Endpoint for retrieving Kalshi trading holiday closures for multiple margin markets. Returns a map keyed by market ticker. Regular daily and weekend closures are not included. + tags: + - market + parameters: + - name: tickers + in: query + required: true + description: Comma-separated list of margin market tickers + schema: + type: array + items: + type: string + minLength: 1 + maxLength: 200 + minItems: 1 + maxItems: 100 + style: form + explode: false + x-oapi-codegen-extra-tags: + validate: required,min=1,max=100,dive,required,max=200 + responses: + '200': + description: Market schedules retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMarginMarketSchedulesResponse' + '400': + $ref: '#/components/responses/BadRequestError' + '404': + $ref: '#/components/responses/NotFoundError' + '429': + $ref: '#/components/responses/RateLimitError' + '500': + $ref: '#/components/responses/InternalServerError' + /margin/markets/orderbooks: get: operationId: GetMarginMarketOrderbooks @@ -2300,6 +2340,9 @@ components: enum: - bracket - trailing + x-enum-varnames: + - Bracket + - Trailing ExitTriggerStatus: type: string description: >- @@ -2321,6 +2364,9 @@ components: enum: - stop_loss - take_profit + x-enum-varnames: + - StopLoss + - TakeProfit ExitTriggerReason: type: string description: Why the trigger reached its terminal status. @@ -2330,6 +2376,7 @@ components: - entry_not_filled - order_rejected - position_flipped + - market_deactivated UpdateExitTriggerRequest: type: object description: >- @@ -3258,6 +3305,40 @@ components: How funding averages a bar with no premium (no orderbook sample or no index price anywhere in the bar, or a trading halt during it), configured at market creation. dampen counts the bar as a premium of 0; exclude leaves it out of the average. + GetMarginMarketSchedulesResponse: + type: object + additionalProperties: + $ref: '#/components/schemas/MarginMarketScheduleDetails' + + MarginMarketScheduleDetails: + type: object + required: + - kalshi_trading_holidays + properties: + kalshi_trading_holidays: + type: array + description: Kalshi trading holiday closures for the market. + items: + $ref: '#/components/schemas/MarginHolidayWindow' + + MarginHolidayWindow: + type: object + required: + - from + - until + properties: + from: + type: string + format: date-time + description: Inclusive start of the holiday closure in UTC. + until: + type: string + format: date-time + description: Exclusive end of the holiday closure in UTC. + reason: + type: string + description: Reason for the holiday closure. + MarginMarketSchedule: type: object nullable: true @@ -3307,8 +3388,14 @@ components: OrderReason: type: string - enum: ['liquidation', 'take_profit_stop_loss'] - description: The reason for a system-generated order. Present for liquidation and TP/SL orders. + enum: ['liquidation', 'take_profit_stop_loss', 'auto_rebalance'] + description: The reason for a system-generated order. Present for liquidation, TP/SL, and auto-rebalance orders. + + SubaccountType: + type: string + enum: ['cross', 'isolated', 'auto_rebalance'] + x-enum-varnames: [SubaccountTypeCross, SubaccountTypeIsolated, SubaccountTypeAutoRebalance] + description: How the subaccount is margined. `cross` for the primary account and numbered subaccounts, `isolated` for a market's isolated subaccount, and `auto_rebalance` for an auto-rebalanced isolated position, which shares its market's isolated subaccount number. LastUpdateReason: type: string @@ -3410,6 +3497,7 @@ components: type: object required: - subaccount + - subaccount_type - market_ticker - position - entry_price @@ -3420,6 +3508,8 @@ components: subaccount: type: integer description: The subaccount number that holds this position (0 for primary, 1-63 for subaccounts) + subaccount_type: + $ref: '#/components/schemas/SubaccountType' market_ticker: type: string description: Market ticker symbol @@ -3566,6 +3656,7 @@ components: type: object required: - subaccount + - subaccount_type - position_value - account_equity - maintenance_margin @@ -3576,6 +3667,8 @@ components: subaccount: type: integer description: The subaccount number (0 for primary, 1-63 for subaccounts) + subaccount_type: + $ref: '#/components/schemas/SubaccountType' position_value: $ref: '#/components/schemas/FixedPointDollars' description: Mark-to-market value of open positions for this subaccount in fixed-point dollars @@ -3614,6 +3707,7 @@ components: type: object required: - subaccount + - subaccount_type - market_ticker - position - mark_price @@ -3623,6 +3717,8 @@ components: subaccount: type: integer description: The subaccount number (0 for primary, 1-63 for subaccounts) + subaccount_type: + $ref: '#/components/schemas/SubaccountType' market_ticker: type: string description: Market ticker symbol @@ -3744,6 +3840,7 @@ components: - funding_amount - quantity - subaccount_number + - subaccount_type properties: market_ticker: type: string @@ -3769,6 +3866,8 @@ components: type: integer nullable: true description: Subaccount number (0 for primary) + subaccount_type: + $ref: '#/components/schemas/SubaccountType' GetMarginFundingHistoryResponse: type: object @@ -3801,7 +3900,7 @@ components: properties: id: type: string - description: "Stable notice id, equal to the Klear review id." + description: "Stable corporate action id." market_id: type: string description: "Stable market UUID." @@ -3825,7 +3924,7 @@ components: type: string format: date-time nullable: true - description: "Review execution time. Omitted for cancelled notices." + description: "Execution time. Omitted for cancelled notices." cash: $ref: '#/components/schemas/MarginCorporateActionCashTerms' split: diff --git a/tests/_contract_support.py b/tests/_contract_support.py index 58f5de6..d313612 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -422,6 +422,12 @@ class Exclusion: path_template="/portfolio/events/orders", request_body_schema="#/components/schemas/CreateOrderV2Request", ), + MethodEndpointEntry( + sdk_method="kalshi.resources.orders.OrdersResource.create_with_exit_trigger", + http_method="POST", + path_template="/portfolio/events/orders/with_exit_trigger", + request_body_schema="#/components/schemas/CreateOrderWithExitTriggerRequest", + ), MethodEndpointEntry( sdk_method="kalshi.resources.orders.OrdersResource.cancel_v2", http_method="DELETE", @@ -853,6 +859,32 @@ class Exclusion: http_method="GET", path_template="/portfolio/target_balance_allocation", ), + MethodEndpointEntry( + sdk_method="kalshi.resources.portfolio.PortfolioResource.exit_triggers", + http_method="GET", + path_template="/portfolio/positions/{ticker}/exit_trigger", + ), + MethodEndpointEntry( + sdk_method="kalshi.resources.portfolio.PortfolioResource.set_exit_trigger", + http_method="PUT", + path_template="/portfolio/positions/{ticker}/exit_trigger", + request_body_schema="#/components/schemas/SetEventContractExitTriggerRequest", + ), + MethodEndpointEntry( + sdk_method="kalshi.resources.portfolio.PortfolioResource.cancel_exit_trigger", + http_method="DELETE", + path_template="/portfolio/positions/{ticker}/exit_trigger", + ), + MethodEndpointEntry( + sdk_method="kalshi.resources.portfolio.PortfolioResource.credit_history", + http_method="GET", + path_template="/portfolio/credit_history", + ), + MethodEndpointEntry( + sdk_method="kalshi.resources.portfolio.PortfolioResource.credit_history_all", + http_method="GET", + path_template="/portfolio/credit_history", + ), MethodEndpointEntry( sdk_method="kalshi.resources.portfolio.PortfolioResource.set_target_balance_allocation", http_method="POST", @@ -1101,6 +1133,10 @@ class Exclusion: reason="paginator-handled; not a caller-facing kwarg on list_all", kind="paginator_handled", ), + ("kalshi.resources.portfolio.PortfolioResource.credit_history_all", "cursor"): Exclusion( + reason="paginator-handled; not a caller-facing kwarg on credit_history_all", + kind="paginator_handled", + ), ("kalshi.resources.portfolio.PortfolioResource.positions_all", "cursor"): Exclusion( reason="paginator-handled; not a caller-facing kwarg on list_all", kind="paginator_handled", @@ -1437,6 +1473,7 @@ class Exclusion: "kalshi.resources.portfolio.PortfolioResource.positions_all", "kalshi.resources.portfolio.PortfolioResource.fills_all", "kalshi.resources.portfolio.PortfolioResource.intra_exchange_transfers_all", + "kalshi.resources.portfolio.PortfolioResource.credit_history_all", "kalshi.resources.fcm.FcmResource.orders_all", "kalshi.resources.fcm.FcmResource.fills_all", "kalshi.resources.fcm.FcmResource.positions_all", @@ -1471,6 +1508,7 @@ class Exclusion: "kalshi.resources.portfolio.AsyncPortfolioResource.positions_all", "kalshi.resources.portfolio.AsyncPortfolioResource.fills_all", "kalshi.resources.portfolio.AsyncPortfolioResource.intra_exchange_transfers_all", + "kalshi.resources.portfolio.AsyncPortfolioResource.credit_history_all", "kalshi.resources.fcm.AsyncFcmResource.orders_all", "kalshi.resources.fcm.AsyncFcmResource.fills_all", "kalshi.resources.fcm.AsyncFcmResource.positions_all", @@ -1539,6 +1577,11 @@ class Exclusion: http_method="GET", path_template="/margin/markets/orderbooks", ), + MethodEndpointEntry( + sdk_method="kalshi.perps.resources.markets.PerpsMarketsResource.schedules", + http_method="GET", + path_template="/margin/markets/schedules", + ), MethodEndpointEntry( sdk_method="kalshi.perps.resources.markets.PerpsMarketsResource.candlesticks", http_method="GET", @@ -2037,6 +2080,47 @@ class Exclusion: ), kind="server_omits_despite_required", ), + ("kalshi.perps.models.portfolio.MarginPosition", "subaccount_type"): Exclusion( + reason=( + "spec 3.35.0 adds subaccount_type as required; the SDK keeps it " + "optional (None when absent) so payloads and fixtures predating the " + "field still validate and the change ships without a major version bump." + ), + kind="server_omits_despite_required", + ), + ( + "kalshi.perps.models.margin_account.MarginSubaccountBalance", + "subaccount_type", + ): Exclusion( + reason=( + "spec 3.35.0 adds subaccount_type as required; the SDK keeps it " + "optional (None when absent) so payloads and fixtures predating the " + "field still validate and the change ships without a major version bump." + ), + kind="server_omits_despite_required", + ), + ( + "kalshi.perps.models.margin_account.MarginRiskPosition", + "subaccount_type", + ): Exclusion( + reason=( + "spec 3.35.0 adds subaccount_type as required; the SDK keeps it " + "optional (None when absent) so payloads and fixtures predating the " + "field still validate and the change ships without a major version bump." + ), + kind="server_omits_despite_required", + ), + ( + "kalshi.perps.models.funding.MarginFundingHistoryEntry", + "subaccount_type", + ): Exclusion( + reason=( + "spec 3.35.0 adds subaccount_type as required; the SDK keeps it " + "optional (None when absent) so payloads and fixtures predating the " + "field still validate and the change ships without a major version bump." + ), + kind="server_omits_despite_required", + ), # ── perps orders (#391) ── ("kalshi.perps.resources.orders.MarginOrdersResource.list_all", "cursor"): Exclusion( reason="paginator-handled; not a caller-facing kwarg on list_all", @@ -2286,6 +2370,55 @@ def _collect(parameters_list: list[dict[str, Any]]) -> list[dict[str, Any]]: return list(merged.values()) +def _flatten_allof( + spec: dict[str, Any], + schema: dict[str, Any], + *, + max_ref_depth: int, +) -> dict[str, Any]: + """Merge an ``allOf`` object schema into one ``properties`` map. + + Earlier branches win on property-name collisions (``setdefault``), so a + later branch that restates ``time_in_force`` without its enum does not + wipe the enum from ``CreateOrderV2Request``. Does not mutate ``spec``. + Schemas without ``allOf`` are returned unchanged. + """ + if "allOf" not in schema: + return schema + + merged_props: dict[str, Any] = {} + merged_required: list[str] = [] + out: dict[str, Any] = { + key: value + for key, value in schema.items() + if key not in {"allOf", "properties", "required"} + } + branches = list(schema["allOf"]) + # A wrapper that also declares its own properties is applied first so + # allOf branches can fill gaps without overwriting them... no: earlier + # allOf branches win, and the wrapper's own properties fill only gaps. + for sub in branches: + if "$ref" in sub: + sub = _resolve_ref(spec, sub["$ref"], max_depth=max_ref_depth) + if "allOf" in sub: + sub = _flatten_allof(spec, sub, max_ref_depth=max_ref_depth) + for name, prop in sub.get("properties", {}).items(): + merged_props.setdefault(name, prop) + for req in sub.get("required", []): + if req not in merged_required: + merged_required.append(req) + for name, prop in schema.get("properties", {}).items(): + merged_props.setdefault(name, prop) + for req in schema.get("required", []): + if req not in merged_required: + merged_required.append(req) + out["type"] = out.get("type", "object") + out["properties"] = merged_props + if merged_required: + out["required"] = merged_required + return out + + def _resolve_request_body_schema( spec: dict[str, Any], path_template: str, @@ -2332,4 +2465,4 @@ def _resolve_request_body_schema( if "$ref" in schema: schema = _resolve_ref(spec, schema["$ref"], max_depth=max_ref_depth) - return schema # type: ignore[no-any-return] + return _flatten_allof(spec, schema, max_ref_depth=max_ref_depth) diff --git a/tests/_request_model_fixtures.py b/tests/_request_model_fixtures.py index 173f785..2c3e9b3 100644 --- a/tests/_request_model_fixtures.py +++ b/tests/_request_model_fixtures.py @@ -100,6 +100,8 @@ def _minimal_kwargs_no_override(model_cls: type[BaseModel]) -> dict[str, Any]: # one of ``reduce_by`` / ``reduce_to``. Both fields are optional so # the auto-builder skips them — populate one. "DecreaseOrderV2Request": {"reduce_by": Decimal("1")}, + # Both legs are optional, but a model_validator requires at least one. + "SetEventContractExitTriggerRequest": {"stop_loss_price": Decimal("0.5600")}, } diff --git a/tests/integration/test_orders.py b/tests/integration/test_orders.py index 846a974..06d1d28 100644 --- a/tests/integration/test_orders.py +++ b/tests/integration/test_orders.py @@ -19,6 +19,7 @@ "batch_create_v2", "cancel_v2", "create_v2", + "create_with_exit_trigger", "decrease_v2", "fills", "fills_all", diff --git a/tests/integration/test_perps_markets.py b/tests/integration/test_perps_markets.py index 02c0e84..e9abd65 100644 --- a/tests/integration/test_perps_markets.py +++ b/tests/integration/test_perps_markets.py @@ -31,7 +31,7 @@ register_perps( "PerpsMarketsResource", - ["bulk_orderbooks", "candlesticks", "get", "list", "orderbook"], + ["bulk_orderbooks", "candlesticks", "get", "list", "orderbook", "schedules"], ) diff --git a/tests/integration/test_portfolio.py b/tests/integration/test_portfolio.py index 27244b2..39c6fd1 100644 --- a/tests/integration/test_portfolio.py +++ b/tests/integration/test_portfolio.py @@ -24,14 +24,19 @@ "PortfolioResource", [ "balance", + "cancel_exit_trigger", + "credit_history", + "credit_history_all", "deposits", "deposits_all", + "exit_triggers", "fills", "fills_all", "positions", "positions_all", "settlements", "settlements_all", + "set_exit_trigger", "total_resting_order_value", "withdrawals", "withdrawals_all", diff --git a/tests/perps/test_funding.py b/tests/perps/test_funding.py index 449ece1..1a0c50d 100644 --- a/tests/perps/test_funding.py +++ b/tests/perps/test_funding.py @@ -485,3 +485,20 @@ async def test_async_unauth_raises_before_http(self) -> None: await client.funding.history(start_date="2026-06-01", end_date="2026-06-04") assert not route.called await client.close() + + +class TestSubaccountType: + def test_omitted_is_none_and_parses(self) -> None: + omitted = MarginFundingHistoryEntry.model_validate( + _entry(funding_amount="1.00", subaccount_number=0) + ) + assert omitted.subaccount_type is None + present = _entry(funding_amount="1.00", subaccount_number=0) + present["subaccount_type"] = "isolated" + assert MarginFundingHistoryEntry.model_validate(present).subaccount_type == "isolated" + + def test_invalid_raises(self) -> None: + payload = _entry(funding_amount="1.00", subaccount_number=0) + payload["subaccount_type"] = "nope" + with pytest.raises(ValidationError): + MarginFundingHistoryEntry.model_validate(payload) diff --git a/tests/perps/test_margin_account.py b/tests/perps/test_margin_account.py index 5391633..56fd4ee 100644 --- a/tests/perps/test_margin_account.py +++ b/tests/perps/test_margin_account.py @@ -20,6 +20,7 @@ from kalshi.errors import AuthRequiredError, KalshiServerError, KalshiValidationError from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig +from kalshi.perps.models.margin_account import MarginRiskPosition, MarginSubaccountBalance BASE = "https://external-api.demo.kalshi.co/trade-api/v2" @@ -892,3 +893,52 @@ def test_additive_field_tolerated(self, perps_client: PerpsClient) -> None: ) # extra="allow" — additive field doesn't break parsing. assert perps_client.margin.fee_tiers().maker_fee_rates == {} + + +def _balance_row(**overrides: object) -> dict[str, object]: + row: dict[str, object] = { + "subaccount": 0, + "position_value": "1.0000", + "account_equity": "2.0000", + "maintenance_margin": "0.1000", + "initial_margin": "0.2000", + "resting_orders_margin": "0.0000", + "available_balance": "1.8000", + } + row.update(overrides) + return row + + +def _risk_row(**overrides: object) -> dict[str, object]: + row: dict[str, object] = { + "subaccount": 0, + "market_ticker": "BTC-PERP", + "position": "1.00", + "mark_price": "0.5600", + "position_notional": "0.5600", + "is_portfolio": False, + } + row.update(overrides) + return row + + +class TestSubaccountType: + def test_balance_omitted_is_none_and_parses(self) -> None: + assert MarginSubaccountBalance.model_validate(_balance_row()).subaccount_type is None + parsed = MarginSubaccountBalance.model_validate( + _balance_row(subaccount_type="isolated") + ) + assert parsed.subaccount_type == "isolated" + + def test_balance_invalid_raises(self) -> None: + with pytest.raises(ValidationError): + MarginSubaccountBalance.model_validate(_balance_row(subaccount_type="nope")) + + def test_risk_omitted_is_none_and_parses(self) -> None: + assert MarginRiskPosition.model_validate(_risk_row()).subaccount_type is None + parsed = MarginRiskPosition.model_validate(_risk_row(subaccount_type="auto_rebalance")) + assert parsed.subaccount_type == "auto_rebalance" + + def test_risk_invalid_raises(self) -> None: + with pytest.raises(ValidationError): + MarginRiskPosition.model_validate(_risk_row(subaccount_type="cross-margin")) diff --git a/tests/perps/test_markets.py b/tests/perps/test_markets.py index 0e8e5ca..35ac744 100644 --- a/tests/perps/test_markets.py +++ b/tests/perps/test_markets.py @@ -19,9 +19,11 @@ from kalshi.perps import AsyncPerpsClient, PerpsClient, PerpsConfig from kalshi.perps.models.markets import ( BidAskDistributionHistorical, + MarginHolidayWindow, MarginMarket, MarginMarketCandlestick, MarginMarketSchedule, + MarginMarketScheduleDetails, MarginOrderbook, PriceDistributionHistorical, ) @@ -705,3 +707,76 @@ async def test_async(self, async_perps_client: AsyncPerpsClient) -> None: ) assert candles[0].price.mean == Decimal("0.5620") await async_perps_client.close() + + +class TestSchedules: + @respx.mock + def test_parses_holiday_map(self, perps_client: PerpsClient) -> None: + route = respx.get(f"{BASE}/margin/markets/schedules").mock( + return_value=httpx.Response( + 200, + json={ + "BTC-PERP": { + "kalshi_trading_holidays": [ + { + "from": "2026-12-25T00:00:00Z", + "until": "2026-12-26T00:00:00Z", + "reason": "Christmas", + } + ] + } + }, + ) + ) + details = perps_client.markets.schedules(tickers=["BTC-PERP", "ETH-PERP"]) + assert route.calls[0].request.url.params["tickers"] == "BTC-PERP,ETH-PERP" + window = details["BTC-PERP"].kalshi_trading_holidays[0] + assert isinstance(details["BTC-PERP"], MarginMarketScheduleDetails) + assert isinstance(window, MarginHolidayWindow) + assert window.reason == "Christmas" + assert window.start.year == 2026 + assert window.until.day == 26 + + def test_rejects_empty_blank_long_and_over_max(self, perps_client: PerpsClient) -> None: + with pytest.raises(ValueError, match="non-empty"): + perps_client.markets.schedules(tickers=[]) + with pytest.raises(ValueError, match="empty"): + perps_client.markets.schedules(tickers=["BTC-PERP", " "]) + with pytest.raises(ValueError, match="200"): + perps_client.markets.schedules(tickers=["X" * 201]) + with pytest.raises(ValueError, match="100"): + perps_client.markets.schedules(tickers=[f"T{i}" for i in range(101)]) + + @respx.mock + def test_not_found(self, perps_client: PerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/schedules").mock(return_value=httpx.Response(404)) + with pytest.raises(KalshiNotFoundError): + perps_client.markets.schedules(tickers=["NOPE"]) + + @respx.mock + def test_unauthenticated_reaches_wire(self) -> None: + route = respx.get(f"{BASE}/margin/markets/schedules").mock( + return_value=httpx.Response(200, json={}) + ) + client = PerpsClient(config=PerpsConfig.demo()) + assert client.markets.schedules(tickers=["BTC-PERP"]) == {} + assert route.called + client.close() + + @respx.mock + async def test_async(self, async_perps_client: AsyncPerpsClient) -> None: + respx.get(f"{BASE}/margin/markets/schedules").mock( + return_value=httpx.Response( + 200, + json={ + "BTC-PERP": { + "kalshi_trading_holidays": [ + {"from": "2026-01-01T00:00:00Z", "until": "2026-01-02T00:00:00Z"} + ] + } + }, + ) + ) + details = await async_perps_client.markets.schedules(tickers=["BTC-PERP"]) + assert details["BTC-PERP"].kalshi_trading_holidays[0].reason is None + await async_perps_client.close() diff --git a/tests/perps/test_portfolio.py b/tests/perps/test_portfolio.py index bbc5a42..6603e75 100644 --- a/tests/perps/test_portfolio.py +++ b/tests/perps/test_portfolio.py @@ -685,3 +685,20 @@ def test_bracket_price_none_and_positive_ok( ) assert ok.stop_loss_price == Decimal("50000.0000") assert ok.take_profit_price == Decimal("70000.0000") + + +class TestSubaccountType: + def test_omitted_is_none(self) -> None: + position = MarginPosition.model_validate(_long_position()) + assert position.subaccount_type is None + + def test_parses_when_present(self) -> None: + payload = _long_position() + payload["subaccount_type"] = "cross" + assert MarginPosition.model_validate(payload).subaccount_type == "cross" + + def test_invalid_raises(self) -> None: + payload = _long_position() + payload["subaccount_type"] = "hedged" + with pytest.raises(ValidationError): + MarginPosition.model_validate(payload) diff --git a/tests/perps/ws/test_dispatch.py b/tests/perps/ws/test_dispatch.py index ce254f1..3621ebc 100644 --- a/tests/perps/ws/test_dispatch.py +++ b/tests/perps/ws/test_dispatch.py @@ -25,7 +25,7 @@ def test_control_types_and_message_models() -> None: assert {"subscribed", "unsubscribed", "ok", "error"} == CONTROL_TYPES - # #398 populated the type -> model registry; the seven data channels route here. + # #398 populated the type -> model registry; data channels route here. assert set(MESSAGE_MODELS) >= { "orderbook_snapshot", "orderbook_delta", diff --git a/tests/perps/ws/test_perps_ws_channels.py b/tests/perps/ws/test_perps_ws_channels.py index 6047229..9932579 100644 --- a/tests/perps/ws/test_perps_ws_channels.py +++ b/tests/perps/ws/test_perps_ws_channels.py @@ -1,6 +1,6 @@ """End-to-end channel tests: subscribe helper -> fake server frame -> typed model. -Drives the six typed ``subscribe_*`` helpers through the FakePerpsWS server and +Drives the typed ``subscribe_*`` helpers through the FakePerpsWS server and asserts the dispatched frame arrives as the correct ``Margin*Message`` model. Also verifies the orderbook-apply seam in ``_process_frame`` feeds the :class:`PerpsOrderbookManager`. @@ -17,6 +17,7 @@ from kalshi.perps.config import PerpsConfig from kalshi.perps.ws import PerpsWebSocket from kalshi.perps.ws.models.fill import MarginFillMessage +from kalshi.perps.ws.models.market_lifecycle import MarginMarketLifecycleV2Message from kalshi.perps.ws.models.order_group import OrderGroupUpdatesMessage from kalshi.perps.ws.models.orderbook import ( MarginOrderbookDeltaMessage, @@ -63,6 +64,33 @@ async def test_subscribe_ticker_yields_typed_message( assert frame.msg.funding_rate.rate == Decimal("0.0001") +async def test_subscribe_market_lifecycle_yields_typed_message( + fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth +) -> None: + ws = PerpsWebSocket(auth=perps_auth, config=perps_ws_config) + async with ws.connect() as session: + stream = await session.subscribe_market_lifecycle() + sid = await _sid(fake_perps_ws) + cmd = fake_perps_ws.received_commands[-1] + assert cmd["params"]["channels"] == ["market_lifecycle_v2"] + assert "market_tickers" not in cmd["params"] + await fake_perps_ws.send_to_all({ + "type": "market_lifecycle_v2", + "sid": sid, + "seq": 4, + "sending_ts_ms": 1792000000150, + "msg": { + "market_ticker": "KXBTCPERP", + "event_type": "deactivated", + "is_deactivated": True, + }, + }) + frame = await asyncio.wait_for(stream.__anext__(), timeout=2.0) + assert isinstance(frame, MarginMarketLifecycleV2Message) + assert frame.msg.event_type == "deactivated" + assert frame.seq == 4 + + async def test_subscribe_trade_yields_typed_message( fake_perps_ws: FakePerpsWS, perps_ws_config: PerpsConfig, perps_auth ) -> None: diff --git a/tests/perps/ws/test_perps_ws_models.py b/tests/perps/ws/test_perps_ws_models.py index 7c0df02..4e54811 100644 --- a/tests/perps/ws/test_perps_ws_models.py +++ b/tests/perps/ws/test_perps_ws_models.py @@ -24,6 +24,10 @@ from pydantic import BaseModel, ValidationError from kalshi.perps.ws.models.fill import MarginFillMessage, MarginFillPayload +from kalshi.perps.ws.models.market_lifecycle import ( + MarginMarketLifecycleV2Message, + MarginMarketLifecycleV2Payload, +) from kalshi.perps.ws.models.order_group import ( OrderGroupUpdatesMessage, OrderGroupUpdatesPayload, @@ -69,6 +73,10 @@ def _load_spec() -> dict[str, Any]: "marginTickerPayload": (MarginTickerMessage, MarginTickerPayload), "marginTradePayload": (MarginTradeMessage, MarginTradePayload), "marginFillPayload": (MarginFillMessage, MarginFillPayload), + "marginMarketLifecycleV2Payload": ( + MarginMarketLifecycleV2Message, + MarginMarketLifecycleV2Payload, + ), "marginUserOrderPayload": (MarginUserOrderMessage, MarginUserOrderPayload), "orderGroupUpdatesPayload": ( OrderGroupUpdatesMessage, @@ -134,6 +142,7 @@ def test_sequenced_envelopes_require_seq(self) -> None: "marginOrderbookDeltaPayload", "orderGroupUpdatesPayload", "marginTradePayload", + "marginMarketLifecycleV2Payload", ): assert "seq" in spec["components"]["schemas"][schema_name]["required"] for schema_name in ( @@ -608,6 +617,60 @@ def test_bad_event_type_raises(self) -> None: OrderGroupUpdatesMessage.model_validate(frame) +class TestMarketLifecycle: + def test_parses_spec_deactivated_example(self) -> None: + spec = _load_spec() + example = spec["components"]["messages"]["marketLifecycleV2"]["examples"][0][ + "payload" + ] + msg = MarginMarketLifecycleV2Message.model_validate(example) + assert msg.type == "market_lifecycle_v2" + assert msg.sid == 13 + assert msg.seq == 4 + assert msg.sending_ts_ms == 1792000000150 + assert msg.msg.market_ticker == "KXBTCPERP" + assert msg.msg.event_type == "deactivated" + assert msg.msg.is_deactivated is True + + def test_parses_activated_and_omitted_sending_ts(self) -> None: + msg = MarginMarketLifecycleV2Message.model_validate( + { + "type": "market_lifecycle_v2", + "sid": 13, + "seq": 5, + "msg": { + "market_ticker": "KXBTCPERP", + "event_type": "activated", + "is_deactivated": False, + }, + } + ) + assert msg.msg.event_type == "activated" + assert msg.msg.is_deactivated is False + assert msg.sending_ts_ms is None + + def test_missing_seq_or_bad_event_type_raises(self) -> None: + base = { + "type": "market_lifecycle_v2", + "sid": 13, + "seq": 5, + "msg": { + "market_ticker": "KXBTCPERP", + "event_type": "activated", + "is_deactivated": False, + }, + } + missing_seq = {k: v for k, v in base.items() if k != "seq"} + with pytest.raises(ValidationError): + MarginMarketLifecycleV2Message.model_validate(missing_seq) + bad = { + **base, + "msg": {**base["msg"], "event_type": "halted"}, + } + with pytest.raises(ValidationError): + MarginMarketLifecycleV2Message.model_validate(bad) + + class TestDispatcherRegistration: def test_message_models_registers_all_seven_types(self) -> None: from kalshi.perps.ws.dispatch import MESSAGE_MODELS @@ -620,11 +683,12 @@ def test_message_models_registers_all_seven_types(self) -> None: "fill": MarginFillMessage, "user_order": MarginUserOrderMessage, "order_group_updates": OrderGroupUpdatesMessage, + "market_lifecycle_v2": MarginMarketLifecycleV2Message, } == MESSAGE_MODELS class TestSubscribeHelpers: - def test_six_typed_helpers_present(self) -> None: + def test_typed_helpers_present(self) -> None: from kalshi.perps.ws.client import PerpsWebSocket for name in ( @@ -634,6 +698,7 @@ def test_six_typed_helpers_present(self) -> None: "subscribe_fill", "subscribe_user_orders", "subscribe_order_group", + "subscribe_market_lifecycle", ): assert callable(getattr(PerpsWebSocket, name)) @@ -642,9 +707,8 @@ def test_out_of_scope_channels_have_no_helpers(self) -> None: for name in ( "subscribe_market_positions", - "subscribe_multivariate_lifecycle", + "subscribe_multivariate_lifecycle", "subscribe_communications", - "subscribe_market_lifecycle", ): assert not hasattr(PerpsWebSocket, name), ( f"{name} has no perps counterpart and must not exist" diff --git a/tests/test_async_orders.py b/tests/test_async_orders.py index dfd7a1a..89083f6 100644 --- a/tests/test_async_orders.py +++ b/tests/test_async_orders.py @@ -24,8 +24,10 @@ BatchCancelOrdersV2RequestOrder, BatchCreateOrdersV2Request, CreateOrderV2Request, + CreateOrderWithExitTriggerRequest, DecreaseOrderV2Request, ) +from kalshi.models.portfolio import AttachedEventContractExitTrigger from kalshi.resources.orders import AsyncOrdersResource from tests._model_fixtures import fill_dict, order_dict @@ -958,3 +960,77 @@ async def test_issue_351_orders_fills_all_emits_deprecation_warning( ids = [f.trade_id async for f in orders.fills_all()] assert ids == ["a"] assert len([w for w in record if issubclass(w.category, DeprecationWarning)]) == 1 + + +class TestAsyncCreateOrderWithExitTrigger: + @respx.mock + @pytest.mark.asyncio + async def test_returns_response(self, orders: AsyncOrdersResource) -> None: + route = respx.post( + "https://test.kalshi.com/trade-api/v2/portfolio/events/orders/with_exit_trigger", + ).mock( + return_value=httpx.Response( + 201, + json={ + "order_id": "ord-v2-1", + "fill_count": "0", + "remaining_count": "10", + "ts_ms": 1, + }, + ) + ) + result = await orders.create_with_exit_trigger( + request=CreateOrderWithExitTriggerRequest( + ticker="MKT-A", + client_order_id="cli-1", + side="bid", + count=Decimal("10"), + price=Decimal("0.50"), + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + exit_trigger=AttachedEventContractExitTrigger( + take_profit_price=Decimal("0.8000"), + ), + ), + ) + assert result.order_id == "ord-v2-1" + body = json.loads(route.calls[0].request.content) + assert body["exit_trigger"]["take_profit_price"] == "0.8000" + + @respx.mock + @pytest.mark.asyncio + async def test_server_400(self, orders: AsyncOrdersResource) -> None: + from kalshi.errors import KalshiValidationError + + respx.post( + "https://test.kalshi.com/trade-api/v2/portfolio/events/orders/with_exit_trigger", + ).mock(return_value=httpx.Response(400, json={"error": "bad"})) + with pytest.raises(KalshiValidationError): + await orders.create_with_exit_trigger( + request=CreateOrderWithExitTriggerRequest( + ticker="MKT-A", + client_order_id="cli-1", + side="bid", + count=Decimal("1"), + price=Decimal("0.50"), + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + exit_trigger=AttachedEventContractExitTrigger(), + ), + ) + + @pytest.mark.asyncio + async def test_requires_auth(self, unauth_orders_async: AsyncOrdersResource) -> None: + with pytest.raises(AuthRequiredError): + await unauth_orders_async.create_with_exit_trigger( + request=CreateOrderWithExitTriggerRequest( + ticker="MKT-A", + client_order_id="cli-1", + side="bid", + count=Decimal("1"), + price=Decimal("0.50"), + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + exit_trigger=AttachedEventContractExitTrigger(), + ), + ) diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 45aca3b..c3c162e 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -329,6 +329,28 @@ def _resolve_schema(spec: dict[str, Any], schema_name: str) -> dict[str, Any]: name at the top level — e.g., the per-entry shapes inside ``Batch*Orders*`` response wrappers. """ + if schema_name.startswith("response|"): + # Inline response schema: ``response|GET|/path/{id}|200``. + # Paths contain no ``|``, so the split is unambiguous. + try: + _, method, path, status = schema_name.split("|", 3) + except ValueError: + pytest.fail(f"Bad response schema locator {schema_name!r}") + path_item = spec.get("paths", {}).get(path) + if not isinstance(path_item, dict) or method.lower() not in path_item: + pytest.fail(f"Schema locator {schema_name!r} does not match a spec operation") + response = path_item[method.lower()].get("responses", {}).get(status) + if not isinstance(response, dict): + pytest.fail(f"Schema locator {schema_name!r} has no {status} response") + if "$ref" in response: + response = _resolve_ref(spec, response["$ref"]) + node = response.get("content", {}).get("application/json", {}).get("schema") + if not isinstance(node, dict): + pytest.fail(f"Schema locator {schema_name!r} has no JSON schema") + if "$ref" in node: + node = _resolve_ref(spec, node["$ref"]) + return node + schemas = spec.get("components", {}).get("schemas", {}) head, *rest = schema_name.split(".") node = schemas.get(head) @@ -1422,6 +1444,9 @@ def _assert_params_match( # DecreaseOrderRequest/BatchCreate*/BatchCancel*) were removed in spec v3.22.0 # along with their endpoints. Only the V2 family remains. "#/components/schemas/CreateOrderV2Request": ("kalshi.models.orders.CreateOrderV2Request"), + "#/components/schemas/CreateOrderWithExitTriggerRequest": ( + "kalshi.models.orders.CreateOrderWithExitTriggerRequest" + ), "#/components/schemas/AmendOrderV2Request": ("kalshi.models.orders.AmendOrderV2Request"), "#/components/schemas/DecreaseOrderV2Request": ("kalshi.models.orders.DecreaseOrderV2Request"), "#/components/schemas/BatchCreateOrdersV2Request": ( @@ -1471,6 +1496,9 @@ def _assert_params_match( "#/components/schemas/SetTargetBalanceAllocationRequest": ( "kalshi.models.portfolio.SetTargetBalanceAllocationRequest" ), + "#/components/schemas/SetEventContractExitTriggerRequest": ( + "kalshi.models.portfolio.SetEventContractExitTriggerRequest" + ), "#/components/schemas/CreateFCMPositionTransferRequest": ( "kalshi.models.fcm.CreateFCMPositionTransferRequest" ), diff --git a/tests/test_model_extra_policy.py b/tests/test_model_extra_policy.py index bcdef58..2c188d5 100644 --- a/tests/test_model_extra_policy.py +++ b/tests/test_model_extra_policy.py @@ -25,6 +25,8 @@ "CreateRFQBatchChild", "RFQBatchQuoteEntry", "RFQBatchAcceptLimit", + "AttachedEventContractExitTrigger", + "EventContractExitSlippage", } ) diff --git a/tests/test_orders.py b/tests/test_orders.py index 7c6b3db..f5f3c37 100644 --- a/tests/test_orders.py +++ b/tests/test_orders.py @@ -25,8 +25,10 @@ BatchCancelOrdersV2RequestOrder, BatchCreateOrdersV2Request, CreateOrderV2Request, + CreateOrderWithExitTriggerRequest, DecreaseOrderV2Request, ) +from kalshi.models.portfolio import AttachedEventContractExitTrigger from kalshi.resources.orders import OrdersResource from tests._model_fixtures import fill_dict, order_dict @@ -1158,3 +1160,93 @@ def test_issue_351_orders_fills_all_emits_deprecation_warning( ids = [f.trade_id for f in orders.fills_all()] assert ids == ["a"] assert len([w for w in record if issubclass(w.category, DeprecationWarning)]) == 1 + + +def _exit_order(**trigger: object) -> CreateOrderWithExitTriggerRequest: + return CreateOrderWithExitTriggerRequest( + ticker="MKT-A", + client_order_id="cli-1", + side="bid", + count=Decimal("10"), + price=Decimal("0.50"), + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + exit_trigger=AttachedEventContractExitTrigger(**trigger), + ) + + +class TestCreateOrderWithExitTrigger: + @respx.mock + def test_returns_response(self, orders: OrdersResource) -> None: + route = respx.post( + "https://test.kalshi.com/trade-api/v2/portfolio/events/orders/with_exit_trigger", + ).mock( + return_value=httpx.Response( + 201, + json={ + "order_id": "ord-v2-1", + "client_order_id": "cli-1", + "fill_count": "0", + "remaining_count": "10", + "ts_ms": 1700000000000, + }, + ) + ) + result = orders.create_with_exit_trigger( + request=_exit_order(stop_loss_price=Decimal("0.5600")), + ) + assert result.order_id == "ord-v2-1" + assert result.fill_count == Decimal("0") + body = json.loads(route.calls[0].request.content) + assert body["exit_trigger"] == {"stop_loss_price": "0.5600"} + assert isinstance(body["exit_trigger"]["stop_loss_price"], str) + assert body["price"] == "0.50" + assert "stop_loss_price_dollars" not in body["exit_trigger"] + + @respx.mock + def test_server_400_and_409(self, orders: OrdersResource) -> None: + from kalshi.errors import KalshiConflictError, KalshiValidationError + + route = respx.post( + "https://test.kalshi.com/trade-api/v2/portfolio/events/orders/with_exit_trigger", + ) + route.mock(return_value=httpx.Response(400, json={"error": "bad"})) + with pytest.raises(KalshiValidationError): + orders.create_with_exit_trigger(request=_exit_order()) + route.mock(return_value=httpx.Response(409, json={"error": "conflict"})) + with pytest.raises(KalshiConflictError): + orders.create_with_exit_trigger(request=_exit_order()) + + def test_requires_exit_trigger_and_rejects_phantom(self) -> None: + with pytest.raises(ValidationError): + CreateOrderWithExitTriggerRequest( + ticker="MKT-A", + client_order_id="cli-1", + side="bid", + count=Decimal("10"), + price=Decimal("0.50"), + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + ) + with pytest.raises(ValidationError): + CreateOrderWithExitTriggerRequest( + ticker="MKT-A", + client_order_id="cli-1", + side="bid", + count=Decimal("10"), + price=Decimal("0.50"), + time_in_force="good_till_canceled", + self_trade_prevention_type="taker_at_cross", + exit_trigger=AttachedEventContractExitTrigger(), + ghost_field="x", # type: ignore[call-arg] + ) + + def test_empty_attached_trigger_is_valid(self) -> None: + request = _exit_order() + assert request.exit_trigger.stop_loss_price is None + dumped = request.model_dump(exclude_none=True, by_alias=True, mode="json") + assert dumped["exit_trigger"] == {} + + def test_requires_auth(self, unauth_orders: OrdersResource) -> None: + with pytest.raises(AuthRequiredError): + unauth_orders.create_with_exit_trigger(request=_exit_order()) diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index f219e8c..81f12c3 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -2,16 +2,29 @@ from __future__ import annotations +import json from decimal import Decimal import httpx import pytest import respx +from pydantic import ValidationError from kalshi._base_client import AsyncTransport, SyncTransport from kalshi.auth import KalshiAuth from kalshi.config import KalshiConfig -from kalshi.errors import AuthRequiredError, KalshiAuthError +from kalshi.errors import ( + AuthRequiredError, + KalshiAuthError, + KalshiNotFoundError, + KalshiValidationError, +) +from kalshi.models.portfolio import ( + AttachedEventContractExitTrigger, + CreditHistory, + EventContractExitSlippage, + SetEventContractExitTriggerRequest, +) from kalshi.resources.portfolio import AsyncPortfolioResource, PortfolioResource from tests._model_fixtures import ( event_position_dict, @@ -1574,3 +1587,227 @@ async def test_async_roundtrip(self, async_portfolio: AsyncPortfolioResource) -> await async_portfolio.set_target_balance_allocation( allocations=[TargetBalanceAllocationInput(exchange_index=0, percent=100)] ) + + +_BASE = "https://test.kalshi.com/trade-api/v2" +_TRIGGER = { + "id": "trig-1", + "ticker": "MKT-A", + "side": "yes", + "subaccount_number": 0, + "kind": "bracket", + "status": "active", + "created_time": "2026-06-04T12:00:00Z", + "updated_time": "2026-06-04T12:00:01Z", + "stop_loss_price": "0.4000", + "take_profit_price": "0.7000", +} +_CREDIT = { + "credit_id": "cr-1", + "type": "promo", + "status": "applied", + "amount_dollars": "1.5000", + "reason": "welcome", + "created_time": "2026-06-04T12:00:00Z", +} + + +class TestExitTriggers: + @respx.mock + def test_get_parses_bracket(self, portfolio: PortfolioResource) -> None: + route = respx.get(f"{_BASE}/portfolio/positions/MKT-A/exit_trigger").mock( + return_value=httpx.Response( + 200, + json={"exit_trigger": _TRIGGER, "exit_triggers": [_TRIGGER]}, + ) + ) + resp = portfolio.exit_triggers("MKT-A", kind="bracket", subaccount=2) + assert resp.exit_trigger is not None + assert resp.exit_trigger.id == "trig-1" + assert resp.exit_trigger.stop_loss_price == Decimal("0.4000") + assert len(resp.exit_triggers) == 1 + assert dict(route.calls[0].request.url.params) == {"kind": "bracket", "subaccount": "2"} + + @respx.mock + def test_get_null_trigger_and_omitted_kind(self, portfolio: PortfolioResource) -> None: + route = respx.get(f"{_BASE}/portfolio/positions/MKT-A/exit_trigger").mock( + return_value=httpx.Response( + 200, json={"exit_trigger": None, "exit_triggers": []} + ) + ) + resp = portfolio.exit_triggers("MKT-A") + assert resp.exit_trigger is None + assert resp.exit_triggers == [] + assert "kind" not in route.calls[0].request.url.params + + @respx.mock + def test_get_404(self, portfolio: PortfolioResource) -> None: + respx.get(f"{_BASE}/portfolio/positions/NOPE/exit_trigger").mock( + return_value=httpx.Response(404, json={"error": "missing"}) + ) + with pytest.raises(KalshiNotFoundError): + portfolio.exit_triggers("NOPE") + + def test_get_requires_auth(self, unauth_portfolio: PortfolioResource) -> None: + with pytest.raises(AuthRequiredError): + unauth_portfolio.exit_triggers("MKT-A") + + @respx.mock + def test_put_serializes_one_leg(self, portfolio: PortfolioResource) -> None: + route = respx.put(f"{_BASE}/portfolio/positions/MKT-A/exit_trigger").mock( + return_value=httpx.Response(200, json=_TRIGGER) + ) + trigger = portfolio.set_exit_trigger( + "MKT-A", + request=SetEventContractExitTriggerRequest(stop_loss_price=Decimal("0.5600")), + subaccount=1, + ) + assert trigger.id == "trig-1" + body = json.loads(route.calls[0].request.content) + assert body == {"stop_loss_price": "0.5600"} + assert isinstance(body["stop_loss_price"], str) + assert dict(route.calls[0].request.url.params) == {"subaccount": "1"} + + def test_put_rejects_neither_leg_and_phantom(self) -> None: + with pytest.raises(ValidationError, match="at least one"): + SetEventContractExitTriggerRequest() + with pytest.raises(ValidationError): + SetEventContractExitTriggerRequest(stop_loss_price=Decimal("0.5600"), ghost="x") # type: ignore[call-arg] + + def test_slippage_cents_cap(self) -> None: + with pytest.raises(ValidationError, match="cents slippage"): + EventContractExitSlippage(unit="cents", value=100) + ok = EventContractExitSlippage(unit="bps", value=100) + assert ok.value == 100 + attached = AttachedEventContractExitTrigger( + stop_loss_price_dollars=Decimal("0.5600"), # type: ignore[call-arg] + ) + assert attached.stop_loss_price == Decimal("0.5600") + + @respx.mock + def test_put_400(self, portfolio: PortfolioResource) -> None: + respx.put(f"{_BASE}/portfolio/positions/MKT-A/exit_trigger").mock( + return_value=httpx.Response(400, json={"error": "bad"}) + ) + with pytest.raises(KalshiValidationError): + portfolio.set_exit_trigger( + "MKT-A", + request=SetEventContractExitTriggerRequest(take_profit_price=Decimal("0.8000")), + ) + + @respx.mock + def test_delete_204(self, portfolio: PortfolioResource) -> None: + route = respx.delete(f"{_BASE}/portfolio/positions/MKT-A/exit_trigger").mock( + return_value=httpx.Response(204) + ) + assert portfolio.cancel_exit_trigger("MKT-A", kind="bracket") is None + assert route.calls[0].request.url.params["kind"] == "bracket" + + @respx.mock + def test_delete_401(self, portfolio: PortfolioResource) -> None: + respx.delete(f"{_BASE}/portfolio/positions/MKT-A/exit_trigger").mock( + return_value=httpx.Response(401, json={"error": "unauthorized"}) + ) + with pytest.raises(KalshiAuthError): + portfolio.cancel_exit_trigger("MKT-A") + + def test_delete_requires_auth(self, unauth_portfolio: PortfolioResource) -> None: + with pytest.raises(AuthRequiredError): + unauth_portfolio.cancel_exit_trigger("MKT-A") + + @respx.mock + @pytest.mark.asyncio + async def test_async_roundtrip(self, async_portfolio: AsyncPortfolioResource) -> None: + respx.get(f"{_BASE}/portfolio/positions/MKT-A/exit_trigger").mock( + return_value=httpx.Response( + 200, json={"exit_trigger": None, "exit_triggers": [_TRIGGER]} + ) + ) + respx.put(f"{_BASE}/portfolio/positions/MKT-A/exit_trigger").mock( + return_value=httpx.Response(200, json=_TRIGGER) + ) + respx.delete(f"{_BASE}/portfolio/positions/MKT-A/exit_trigger").mock( + return_value=httpx.Response(204) + ) + got = await async_portfolio.exit_triggers("MKT-A") + assert got.exit_trigger is None + assert got.exit_triggers[0].side == "yes" + updated = await async_portfolio.set_exit_trigger( + "MKT-A", + request=SetEventContractExitTriggerRequest(stop_loss_price=Decimal("0.4000")), + ) + assert updated.status == "active" + assert await async_portfolio.cancel_exit_trigger("MKT-A") is None + + +class TestCreditHistory: + @respx.mock + def test_returns_page(self, portfolio: PortfolioResource) -> None: + route = respx.get(f"{_BASE}/portfolio/credit_history").mock( + return_value=httpx.Response(200, json={"credits": [_CREDIT], "cursor": "abc"}) + ) + page = portfolio.credit_history(limit=10, min_ts=1, max_ts=2) + assert len(page.items) == 1 + assert isinstance(page.items[0], CreditHistory) + assert page.items[0].credit_type == "promo" + assert page.items[0].amount == Decimal("1.5000") + assert page.cursor == "abc" + params = dict(route.calls[0].request.url.params) + assert params == {"limit": "10", "min_ts": "1", "max_ts": "2"} + + @respx.mock + def test_empty_page(self, portfolio: PortfolioResource) -> None: + respx.get(f"{_BASE}/portfolio/credit_history").mock( + return_value=httpx.Response(200, json={"credits": [], "cursor": ""}) + ) + page = portfolio.credit_history() + assert page.items == [] + assert page.cursor is None + + @respx.mock + def test_all_stops_on_empty_cursor(self, portfolio: PortfolioResource) -> None: + respx.get(f"{_BASE}/portfolio/credit_history").mock( + side_effect=[ + httpx.Response(200, json={"credits": [_CREDIT], "cursor": "p2"}), + httpx.Response( + 200, + json={"credits": [{**_CREDIT, "credit_id": "cr-2"}], "cursor": ""}, + ), + ] + ) + ids = [c.credit_id for c in portfolio.credit_history_all(limit=1)] + assert ids == ["cr-1", "cr-2"] + + def test_limit_out_of_range(self, portfolio: PortfolioResource) -> None: + with pytest.raises(ValueError, match=r"limit must be in"): + portfolio.credit_history(limit=1001) + with pytest.raises(ValueError, match="max_pages"): + list(portfolio.credit_history_all(max_pages=0)) + + @respx.mock + def test_auth_failure(self, portfolio: PortfolioResource) -> None: + respx.get(f"{_BASE}/portfolio/credit_history").mock( + return_value=httpx.Response(401, json={"error": "unauthorized"}) + ) + with pytest.raises(KalshiAuthError): + portfolio.credit_history() + + def test_requires_auth(self, unauth_portfolio: PortfolioResource) -> None: + with pytest.raises(AuthRequiredError): + unauth_portfolio.credit_history() + with pytest.raises(AuthRequiredError): + unauth_portfolio.credit_history_all() + + @respx.mock + @pytest.mark.asyncio + async def test_async_page_and_all(self, async_portfolio: AsyncPortfolioResource) -> None: + respx.get(f"{_BASE}/portfolio/credit_history").mock( + side_effect=[ + httpx.Response(200, json={"credits": [_CREDIT], "cursor": "p2"}), + httpx.Response(200, json={"credits": [_CREDIT], "cursor": ""}), + ] + ) + page = await async_portfolio.credit_history() + assert page.items[0].credit_id == "cr-1" + items = [c async for c in async_portfolio.credit_history_all()] + assert len(items) == 1