diff --git a/CHANGELOG.md b/CHANGELOG.md index 3edbb33..37674d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to kalshi-sdk will be documented in this file. +## 22.9.0 — 2026-11-05 + +Reconciles nightly spec drift (#571) against OpenAPI 3.36.0 and the latest +perps spec. Additive only. No breaking changes. + +### Added + +- **`block_trade_proposals.cancel(block_trade_proposal_id)`** (sync and + async) — `DELETE /communications/block-trade-proposals/{id}`, 204, no + body. Only the proposer can cancel. POST/DELETE are never retried. +- **`block_trade_proposals.reject(block_trade_proposal_id)`** (sync and + async) — `POST /communications/block-trade-proposals/{id}/reject`, 204, + no request body. Either counterparty can reject; the proposer cancels + instead. (Closes #571). +- Optional **`MarginMarket.minimum_quantity`** (`FixedPointCount | None`). + Spec marks it required; the SDK keeps it optional (contract exclusion + `server_omits_despite_required`) so payloads omitting the key still + parse. Accepts `minimum_quantity_fp` and `minimum_quantity` aliases. + ## 22.8.0 — 2026-11-04 Reconciles nightly spec drift (#569) against OpenAPI 3.36.0 and the latest diff --git a/docs/resources/communications.md b/docs/resources/communications.md index 4f50403..71ed3d7 100644 --- a/docs/resources/communications.md +++ b/docs/resources/communications.md @@ -32,6 +32,8 @@ Auth required throughout. | `block_trade_proposals.list(...)` / `block_trade_proposals.list_all(...)` | `GET /communications/block-trade-proposals` | | `block_trade_proposals.create(...)` | `POST /communications/block-trade-proposals` | | `block_trade_proposals.accept(block_trade_proposal_id, *, subtrader_id=None, subaccount=None)` | `POST /communications/block-trade-proposals/{block_trade_proposal_id}/accept` | +| `block_trade_proposals.cancel(block_trade_proposal_id)` | `DELETE /communications/block-trade-proposals/{block_trade_proposal_id}` | +| `block_trade_proposals.reject(block_trade_proposal_id)` | `POST /communications/block-trade-proposals/{block_trade_proposal_id}/reject` | | `rfq_batches.list(...)` / `rfq_batches.list_all(...)` | `GET /communications/rfq_batches` | | `rfq_batches.get(rfq_batch_id)` | `GET /communications/rfq_batches/{rfq_batch_id}` | | `rfq_batches.create(...)` | `POST /communications/rfq_batches` | @@ -211,6 +213,12 @@ for proposal in client.communications.block_trade_proposals.list_all( # Accept (the counterparty side). The body is optional — pass subtrader_id # or subaccount only if you trade through a sub-trader / subaccount. client.communications.block_trade_proposals.accept(proposal_id) + +# Cancel (proposer only). 204, no body. +client.communications.block_trade_proposals.cancel(proposal_id) + +# Reject (either counterparty; the proposer cancels instead). 204, no body. +client.communications.block_trade_proposals.reject(proposal_id) ``` !!! info "Prices are centi-cents, counts are centicounts — plain integers" diff --git a/kalshi/__init__.py b/kalshi/__init__.py index 7e2c0a8..79b4341 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -503,4 +503,4 @@ "Withdrawal", ] -__version__ = "22.8.0" +__version__ = "22.9.0" diff --git a/kalshi/perps/models/markets.py b/kalshi/perps/models/markets.py index 8b51068..7d49f6f 100644 --- a/kalshi/perps/models/markets.py +++ b/kalshi/perps/models/markets.py @@ -85,6 +85,13 @@ class MarginMarket(BaseModel): underlying_multiplier: str tick_size: DollarDecimal fractional_trading_enabled: bool + # Spec marks it required; the SDK keeps it optional so older payloads + # and existing constructors still validate (minor release). Minimum + # order quantity in contracts (FixedPointCount). + minimum_quantity: FixedPointCount | None = Field( + default=None, + validation_alias=AliasChoices("minimum_quantity_fp", "minimum_quantity"), + ) # Spec requires the key; value is null for markets that trade 24/7 # (``MarginMarketSchedule`` is ``nullable: true``). No default — missing # key hard-fails so required-drift stays aligned. diff --git a/kalshi/resources/communications.py b/kalshi/resources/communications.py index 4ee40cf..f5c3c0d 100644 --- a/kalshi/resources/communications.py +++ b/kalshi/resources/communications.py @@ -1045,6 +1045,37 @@ def accept( extra_headers=extra_headers, ) + def cancel( + self, block_trade_proposal_id: str, *, extra_headers: dict[str, str] | None = None + ) -> None: + """Cancel a pending block-trade proposal. + + ``DELETE /communications/block-trade-proposals/{block_trade_proposal_id}`` + — only the proposer can cancel. Returns 204 with no body. + """ + self._require_auth() + self._delete( + f"/communications/block-trade-proposals/" + f"{_seg(block_trade_proposal_id, name='block_trade_proposal_id')}", + extra_headers=extra_headers, + ) + + def reject( + self, block_trade_proposal_id: str, *, extra_headers: dict[str, str] | None = None + ) -> None: + """Reject a pending block-trade proposal. + + ``POST /communications/block-trade-proposals/{block_trade_proposal_id}/reject`` + — either counterparty can reject; the proposer cancels instead. + Returns 204 with no request body. + """ + self._require_auth() + self._post_void( + f"/communications/block-trade-proposals/" + f"{_seg(block_trade_proposal_id, name='block_trade_proposal_id')}/reject", + extra_headers=extra_headers, + ) + class RFQBatchQuotesResource(SyncResource): """Sync batch-quote sub-resource — ``client.communications.rfq_batches.quotes``. @@ -2161,6 +2192,37 @@ async def accept( extra_headers=extra_headers, ) + async def cancel( + self, block_trade_proposal_id: str, *, extra_headers: dict[str, str] | None = None + ) -> None: + """Cancel a pending block-trade proposal. + + ``DELETE /communications/block-trade-proposals/{block_trade_proposal_id}`` + — only the proposer can cancel. Returns 204 with no body. + """ + self._require_auth() + await self._delete( + f"/communications/block-trade-proposals/" + f"{_seg(block_trade_proposal_id, name='block_trade_proposal_id')}", + extra_headers=extra_headers, + ) + + async def reject( + self, block_trade_proposal_id: str, *, extra_headers: dict[str, str] | None = None + ) -> None: + """Reject a pending block-trade proposal. + + ``POST /communications/block-trade-proposals/{block_trade_proposal_id}/reject`` + — either counterparty can reject; the proposer cancels instead. + Returns 204 with no request body. + """ + self._require_auth() + await self._post_void( + f"/communications/block-trade-proposals/" + f"{_seg(block_trade_proposal_id, name='block_trade_proposal_id')}/reject", + extra_headers=extra_headers, + ) + class AsyncRFQBatchQuotesResource(AsyncResource): """Async batch-quote sub-resource — ``client.communications.rfq_batches.quotes``.""" diff --git a/pyproject.toml b/pyproject.toml index dd9111c..860e7ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "22.8.0" +version = "22.9.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/openapi.yaml b/specs/openapi.yaml index 6a77632..abf97f8 100644 --- a/specs/openapi.yaml +++ b/specs/openapi.yaml @@ -2313,6 +2313,36 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /communications/block-trade-proposals/{block_trade_proposal_id}: + delete: + operationId: CancelBlockTradeProposal + summary: Cancel Block Trade Proposal + description: ' Endpoint for cancelling a pending block trade proposal. Only the proposer can cancel it.' + tags: + - communications + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - name: block_trade_proposal_id + in: path + required: true + description: Block trade proposal ID + schema: + type: string + responses: + '204': + description: Block trade proposal cancelled successfully + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '404': + $ref: '#/components/responses/NotFoundError' + '500': + $ref: '#/components/responses/InternalServerError' + /communications/block-trade-proposals/{block_trade_proposal_id}/accept: post: operationId: AcceptBlockTradeProposal @@ -2349,6 +2379,38 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /communications/block-trade-proposals/{block_trade_proposal_id}/reject: + post: + operationId: RejectBlockTradeProposal + summary: Reject Block Trade Proposal + description: ' Endpoint for rejecting a pending block trade proposal. Either counterparty can reject it, but not the proposer, which cancels it instead.' + tags: + - communications + security: + - kalshiAccessKey: [] + kalshiAccessSignature: [] + kalshiAccessTimestamp: [] + parameters: + - name: block_trade_proposal_id + in: path + required: true + description: Block trade proposal ID + schema: + type: string + responses: + '204': + description: Block trade proposal rejected successfully + '400': + $ref: '#/components/responses/BadRequestError' + '401': + $ref: '#/components/responses/UnauthorizedError' + '404': + $ref: '#/components/responses/NotFoundError' + '409': + $ref: '#/components/responses/ConflictError' + '500': + $ref: '#/components/responses/InternalServerError' + /communications/rfqs: get: operationId: GetRFQs @@ -7362,7 +7424,7 @@ components: description: The event ticker symbol of the market that was settled. market_result: type: string - enum: ['yes', 'no', 'scalar'] + enum: ['yes', 'no', 'scalar', ''] description: The outcome of the market settlement. 'yes' = market resolved to YES, 'no' = market resolved to NO, 'scalar' = scalar market settled at a specific value. yes_count_fp: $ref: '#/components/schemas/FixedPointCount' @@ -8850,7 +8912,7 @@ components: description: Expiration time of the proposal status: type: string - description: Current status of the proposal + description: 'Current status of the proposal: pending, executed, cancelled (by the proposer, or on expiry) or rejected (by a counterparty)' created_ts: type: string format: date-time @@ -11394,6 +11456,7 @@ components: market_type: type: string enum: [binary, scalar] + x-enumNames: [MarketMarketTypeBinary, MarketMarketTypeScalar] description: Identifies the type of market title: type: string diff --git a/specs/perps_openapi.yaml b/specs/perps_openapi.yaml index 37f7c35..b221679 100644 --- a/specs/perps_openapi.yaml +++ b/specs/perps_openapi.yaml @@ -3148,6 +3148,7 @@ components: - underlying_multiplier - tick_size - fractional_trading_enabled + - minimum_quantity - schedule - exchange_index - market_version @@ -3186,6 +3187,10 @@ components: fractional_trading_enabled: type: boolean description: Whether fractional trading is enabled for this market. + minimum_quantity: + $ref: '#/components/schemas/FixedPointCount' + description: Minimum order quantity in contracts. + example: "1.00" leverage_estimate: type: number format: double diff --git a/tests/_contract_support.py b/tests/_contract_support.py index f527040..808aba4 100644 --- a/tests/_contract_support.py +++ b/tests/_contract_support.py @@ -680,6 +680,16 @@ class Exclusion: path_template="/communications/block-trade-proposals/{block_trade_proposal_id}/accept", request_body_schema="#/components/schemas/AcceptBlockTradeProposalRequest", ), + MethodEndpointEntry( + sdk_method="kalshi.resources.communications.BlockTradeProposalsResource.cancel", + http_method="DELETE", + path_template="/communications/block-trade-proposals/{block_trade_proposal_id}", + ), + MethodEndpointEntry( + sdk_method="kalshi.resources.communications.BlockTradeProposalsResource.reject", + http_method="POST", + path_template="/communications/block-trade-proposals/{block_trade_proposal_id}/reject", + ), # RFQ batches MethodEndpointEntry( sdk_method="kalshi.resources.communications.RFQBatchesResource.list", @@ -2104,6 +2114,14 @@ class Exclusion: ), kind="server_omits_despite_required", ), + ("kalshi.perps.models.markets.MarginMarket", "minimum_quantity"): Exclusion( + reason=( + "spec adds minimum_quantity as required; the SDK keeps it optional " + "(None when absent) so payloads/fixtures predating the field still " + "validate and the change ships as a minor release (22.9.0)." + ), + 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 " diff --git a/tests/perps/test_markets.py b/tests/perps/test_markets.py index 35ac744..4209cc0 100644 --- a/tests/perps/test_markets.py +++ b/tests/perps/test_markets.py @@ -214,6 +214,21 @@ def test_halt_sources_optional(self) -> None: del payload["halt_sources"] assert MarginMarket.model_validate(payload).halt_sources is None + def test_minimum_quantity_optional(self) -> None: + payload = _market_dict() + payload.pop("minimum_quantity", None) + payload.pop("minimum_quantity_fp", None) + assert MarginMarket.model_validate(payload).minimum_quantity is None + + def test_minimum_quantity_parses(self) -> None: + m = MarginMarket.model_validate(_market_dict(minimum_quantity="1.00")) + assert m.minimum_quantity == Decimal("1.00") + assert isinstance(m.minimum_quantity, Decimal) + + def test_minimum_quantity_fp_alias(self) -> None: + m = MarginMarket.model_validate(_market_dict(minimum_quantity_fp="2.50")) + assert m.minimum_quantity == Decimal("2.50") + def test_halt_sources_parses(self) -> None: m = MarginMarket.model_validate(_market_dict(halt_sources=["corporate_action"])) assert m.halt_sources == ["corporate_action"] diff --git a/tests/test_communications.py b/tests/test_communications.py index 893cd49..084177e 100644 --- a/tests/test_communications.py +++ b/tests/test_communications.py @@ -17,6 +17,7 @@ from kalshi.config import DEMO_BASE_URL, DEMO_WS_URL, KalshiConfig from kalshi.errors import ( AuthRequiredError, + KalshiConflictError, KalshiNotFoundError, KalshiServerError, KalshiValidationError, @@ -100,6 +101,31 @@ def unauth_comms(config: KalshiConfig) -> CommunicationsResource: return CommunicationsResource(SyncTransport(None, config)) +@pytest.fixture +def retrying_config() -> KalshiConfig: + return KalshiConfig( + base_url="https://test.kalshi.com/trade-api/v2", + timeout=5.0, + max_retries=2, + retry_base_delay=0.01, + retry_max_delay=0.05, + ) + + +@pytest.fixture +def retrying_comms( + test_auth: KalshiAuth, retrying_config: KalshiConfig +) -> CommunicationsResource: + return CommunicationsResource(SyncTransport(test_auth, retrying_config)) + + +@pytest.fixture +def retrying_async_comms( + test_auth: KalshiAuth, retrying_config: KalshiConfig +) -> AsyncCommunicationsResource: + return AsyncCommunicationsResource(AsyncTransport(test_auth, retrying_config)) + + _MINIMAL_RFQ = { "id": "rfq-1", "creator_id": "comm-42", @@ -1701,6 +1727,106 @@ def test_404(self, comms: CommunicationsResource) -> None: comms.block_trade_proposals.accept("gone") +class TestCancelBlockTradeProposal: + @respx.mock + def test_sends_delete(self, comms: CommunicationsResource) -> None: + route = respx.delete(f"{_BTP_URL}/btp-1").mock(return_value=httpx.Response(204)) + result = comms.block_trade_proposals.cancel("btp-1") + assert result is None + assert route.called + assert route.call_count == 1 + req = route.calls[0].request + assert req.method == "DELETE" + assert req.url.path.endswith("/communications/block-trade-proposals/btp-1") + + @respx.mock + def test_not_retried_on_503(self, retrying_comms: CommunicationsResource) -> None: + route = respx.delete(f"{_BTP_URL}/btp-1").mock( + return_value=httpx.Response(503, json={"message": "unavailable"}) + ) + with pytest.raises(KalshiServerError): + retrying_comms.block_trade_proposals.cancel("btp-1") + assert route.call_count == 1 + + @respx.mock + def test_404(self, comms: CommunicationsResource) -> None: + respx.delete(f"{_BTP_URL}/gone").mock( + return_value=httpx.Response(404, json={"message": "missing"}) + ) + with pytest.raises(KalshiNotFoundError): + comms.block_trade_proposals.cancel("gone") + + def test_empty_id_rejected(self, comms: CommunicationsResource) -> None: + with pytest.raises(ValueError, match="block_trade_proposal_id"): + comms.block_trade_proposals.cancel("") + + def test_dotdot_rejected(self, comms: CommunicationsResource) -> None: + with pytest.raises(ValueError, match="block_trade_proposal_id"): + comms.block_trade_proposals.cancel("..") + + @respx.mock + def test_slash_is_encoded(self, comms: CommunicationsResource) -> None: + route = respx.delete(f"{_BTP_URL}/%2F..%2Fadmin").mock(return_value=httpx.Response(204)) + comms.block_trade_proposals.cancel("/../admin") + assert route.called + assert "%2F..%2Fadmin" in str(route.calls[0].request.url) + + +class TestRejectBlockTradeProposal: + @respx.mock + def test_sends_post(self, comms: CommunicationsResource) -> None: + route = respx.post(f"{_BTP_URL}/btp-1/reject").mock(return_value=httpx.Response(204)) + result = comms.block_trade_proposals.reject("btp-1") + assert result is None + assert route.called + assert route.call_count == 1 + req = route.calls[0].request + assert req.method == "POST" + assert req.url.path.endswith("/communications/block-trade-proposals/btp-1/reject") + + @respx.mock + def test_not_retried_on_503(self, retrying_comms: CommunicationsResource) -> None: + route = respx.post(f"{_BTP_URL}/btp-1/reject").mock( + return_value=httpx.Response(503, json={"message": "unavailable"}) + ) + with pytest.raises(KalshiServerError): + retrying_comms.block_trade_proposals.reject("btp-1") + assert route.call_count == 1 + + @respx.mock + def test_404(self, comms: CommunicationsResource) -> None: + respx.post(f"{_BTP_URL}/gone/reject").mock( + return_value=httpx.Response(404, json={"message": "missing"}) + ) + with pytest.raises(KalshiNotFoundError): + comms.block_trade_proposals.reject("gone") + + @respx.mock + def test_409(self, comms: CommunicationsResource) -> None: + respx.post(f"{_BTP_URL}/btp-1/reject").mock( + return_value=httpx.Response(409, json={"message": "conflict"}) + ) + with pytest.raises(KalshiConflictError): + comms.block_trade_proposals.reject("btp-1") + + def test_empty_id_rejected(self, comms: CommunicationsResource) -> None: + with pytest.raises(ValueError, match="block_trade_proposal_id"): + comms.block_trade_proposals.reject("") + + def test_dotdot_rejected(self, comms: CommunicationsResource) -> None: + with pytest.raises(ValueError, match="block_trade_proposal_id"): + comms.block_trade_proposals.reject("..") + + @respx.mock + def test_slash_is_encoded(self, comms: CommunicationsResource) -> None: + route = respx.post(f"{_BTP_URL}/%2F..%2Fadmin/reject").mock( + return_value=httpx.Response(204) + ) + comms.block_trade_proposals.reject("/../admin") + assert route.called + assert "%2F..%2Fadmin/reject" in str(route.calls[0].request.url) + + class TestAsyncBlockTradeProposals: async def test_list( self, @@ -1762,6 +1888,114 @@ async def test_accept( body = json.loads(route.calls[0].request.content) assert body == {"subtrader_id": "st-1"} + async def test_cancel( + self, + async_comms: AsyncCommunicationsResource, + respx_mock: respx.MockRouter, + ) -> None: + route = respx_mock.delete(f"{_BTP_URL}/btp-1").mock(return_value=httpx.Response(204)) + result = await async_comms.block_trade_proposals.cancel("btp-1") + assert result is None + assert route.called + assert route.call_count == 1 + req = route.calls[0].request + assert req.method == "DELETE" + assert req.url.path.endswith("/communications/block-trade-proposals/btp-1") + + async def test_cancel_not_retried_on_503( + self, + retrying_async_comms: AsyncCommunicationsResource, + respx_mock: respx.MockRouter, + ) -> None: + route = respx_mock.delete(f"{_BTP_URL}/btp-1").mock( + return_value=httpx.Response(503, json={"message": "unavailable"}) + ) + with pytest.raises(KalshiServerError): + await retrying_async_comms.block_trade_proposals.cancel("btp-1") + assert route.call_count == 1 + + async def test_cancel_404( + self, + async_comms: AsyncCommunicationsResource, + respx_mock: respx.MockRouter, + ) -> None: + respx_mock.delete(f"{_BTP_URL}/gone").mock( + return_value=httpx.Response(404, json={"message": "missing"}) + ) + with pytest.raises(KalshiNotFoundError): + await async_comms.block_trade_proposals.cancel("gone") + + async def test_cancel_empty_id_rejected( + self, async_comms: AsyncCommunicationsResource + ) -> None: + with pytest.raises(ValueError, match="block_trade_proposal_id"): + await async_comms.block_trade_proposals.cancel("") + + async def test_cancel_slash_is_encoded( + self, + async_comms: AsyncCommunicationsResource, + respx_mock: respx.MockRouter, + ) -> None: + route = respx_mock.delete(f"{_BTP_URL}/%2F..%2Fadmin").mock( + return_value=httpx.Response(204) + ) + await async_comms.block_trade_proposals.cancel("/../admin") + assert route.called + + async def test_reject( + self, + async_comms: AsyncCommunicationsResource, + respx_mock: respx.MockRouter, + ) -> None: + route = respx_mock.post(f"{_BTP_URL}/btp-1/reject").mock(return_value=httpx.Response(204)) + result = await async_comms.block_trade_proposals.reject("btp-1") + assert result is None + assert route.called + assert route.call_count == 1 + req = route.calls[0].request + assert req.method == "POST" + assert req.url.path.endswith("/communications/block-trade-proposals/btp-1/reject") + + async def test_reject_not_retried_on_503( + self, + retrying_async_comms: AsyncCommunicationsResource, + respx_mock: respx.MockRouter, + ) -> None: + route = respx_mock.post(f"{_BTP_URL}/btp-1/reject").mock( + return_value=httpx.Response(503, json={"message": "unavailable"}) + ) + with pytest.raises(KalshiServerError): + await retrying_async_comms.block_trade_proposals.reject("btp-1") + assert route.call_count == 1 + + async def test_reject_409( + self, + async_comms: AsyncCommunicationsResource, + respx_mock: respx.MockRouter, + ) -> None: + respx_mock.post(f"{_BTP_URL}/btp-1/reject").mock( + return_value=httpx.Response(409, json={"message": "conflict"}) + ) + with pytest.raises(KalshiConflictError): + await async_comms.block_trade_proposals.reject("btp-1") + + async def test_reject_empty_id_rejected( + self, async_comms: AsyncCommunicationsResource + ) -> None: + with pytest.raises(ValueError, match="block_trade_proposal_id"): + await async_comms.block_trade_proposals.reject("") + + async def test_reject_slash_is_encoded( + self, + async_comms: AsyncCommunicationsResource, + respx_mock: respx.MockRouter, + ) -> None: + route = respx_mock.post(f"{_BTP_URL}/%2F..%2Fadmin/reject").mock( + return_value=httpx.Response(204) + ) + await async_comms.block_trade_proposals.reject("/../admin") + assert route.called + class TestBlockTradeProposalsAuthGuard: def test_list_requires_auth(self, unauth_comms: CommunicationsResource) -> None: @@ -1788,6 +2022,14 @@ def test_accept_requires_auth(self, unauth_comms: CommunicationsResource) -> Non with pytest.raises(AuthRequiredError): unauth_comms.block_trade_proposals.accept("btp-1") + def test_cancel_requires_auth(self, unauth_comms: CommunicationsResource) -> None: + with pytest.raises(AuthRequiredError): + unauth_comms.block_trade_proposals.cancel("btp-1") + + def test_reject_requires_auth(self, unauth_comms: CommunicationsResource) -> None: + with pytest.raises(AuthRequiredError): + unauth_comms.block_trade_proposals.reject("btp-1") + # ── RFQ batches ─────────────────────────────────────────────────────────────