diff --git a/kalshi/fix/__init__.py b/kalshi/fix/__init__.py index 1886de1..48cf601 100644 --- a/kalshi/fix/__init__.py +++ b/kalshi/fix/__init__.py @@ -42,6 +42,7 @@ ) from kalshi.fix.connection import FixConnection from kalshi.fix.enums import ( + AcceptQuoteResult, ApplVerID, EncryptMethod, EventResendRejectReason, @@ -54,6 +55,12 @@ OrderGroupAction, OrdStatus, OrdType, + QuoteCancelResult, + QuoteConfirmResult, + QuoteRequestRejectReason, + QuoteRequestType, + QuoteStatus, + RFQCancelResult, SecurityTradingStatus, SelfTradePreventionType, SessionRejectReason, @@ -72,6 +79,8 @@ ) from kalshi.fix.messages import ( APP_MESSAGE_MODELS, + AcceptQuote, + AcceptQuoteStatus, BusinessMessageReject, CollateralAmountChange, EventResendComplete, @@ -102,9 +111,20 @@ OrderMassCancelReport, OrderMassCancelRequest, Party, + Quote, + QuoteCancel, + QuoteCancelStatus, + QuoteConfirm, + QuoteConfirmStatus, + QuoteRequest, + QuoteRequestAck, + QuoteRequestReject, + QuoteStatusReport, Reject, RelatedSymbol, ResendRequest, + RFQCancel, + RFQCancelStatus, SecurityStatus, SecurityStatusRequest, SequenceReset, @@ -121,6 +141,9 @@ "APP_MESSAGE_MODELS", "BEGIN_STRING_FIXT11", "SOH", + "AcceptQuote", + "AcceptQuoteResult", + "AcceptQuoteStatus", "ApplVerID", "BookLevel", "BusinessMessageReject", @@ -183,6 +206,23 @@ "OrderMassCancelReport", "OrderMassCancelRequest", "Party", + "Quote", + "QuoteCancel", + "QuoteCancelResult", + "QuoteCancelStatus", + "QuoteConfirm", + "QuoteConfirmResult", + "QuoteConfirmStatus", + "QuoteRequest", + "QuoteRequestAck", + "QuoteRequestReject", + "QuoteRequestRejectReason", + "QuoteRequestType", + "QuoteStatus", + "QuoteStatusReport", + "RFQCancel", + "RFQCancelResult", + "RFQCancelStatus", "RawMessage", "Reject", "RelatedSymbol", diff --git a/kalshi/fix/enums.py b/kalshi/fix/enums.py index 5087041..f4c1010 100644 --- a/kalshi/fix/enums.py +++ b/kalshi/fix/enums.py @@ -267,6 +267,70 @@ class EventResendRejectReason(IntEnum): END_EXECID_TOO_LARGE = 4 +class QuoteRequestType(IntEnum): + """Tag 303 — QuoteRequestAck (35=b) request type.""" + + MANUAL = 1 + AUTOMATIC = 2 + + +class QuoteStatus(IntEnum): + """Tag 297 — QuoteStatusReport (35=AI) quote status.""" + + ACCEPTED = 0 + REJECTED = 5 + PENDING = 10 + CANCELLED = 17 + + +class QuoteRequestRejectReason(IntEnum): + """Tag 658 — QuoteRequestReject (35=AG) reason.""" + + UNKNOWN_SYMBOL = 1 + QUOTE_REQUEST_EXCEEDS_LIMIT = 3 + INSUFFICIENT_CREDIT = 11 + OTHER = 99 + + +# The next four enums use a ``Result`` suffix to avoid colliding with the +# same-named status *messages* (the Kalshi dictionary names the status field and +# its carrying message identically, e.g. tag 21010 and 35=U8 are both +# "QuoteConfirmStatus"); the message keeps the spec name, the value-enum is suffixed. + + +class QuoteConfirmResult(IntEnum): + """Tag 21010 — QuoteConfirmStatus (35=U8) outcome.""" + + ACCEPTED = 0 + REJECTED = 1 + + +class QuoteCancelResult(IntEnum): + """Tag 298 — QuoteCancelStatus (35=U9) outcome.""" + + CANCELLED = 0 + REJECTED = 1 + + +class RFQCancelResult(IntEnum): + """Tag 21013 — RFQCancelStatus (35=UB) outcome. + + Spelled ``CANCELLED`` to match :class:`QuoteCancelResult` / :class:`QuoteStatus` + even though the dictionary describes tag 21013 value 0 as "CANCELED" — the + member name is SDK-chosen and not wire-significant (the wire value is ``0``). + """ + + CANCELLED = 0 + REJECTED = 1 + + +class AcceptQuoteResult(IntEnum): + """Tag 21025 — AcceptQuoteStatus (35=UC) outcome.""" + + ACCEPTED = 0 + REJECTED = 1 + + class SubscriptionRequestType(StrEnum): """Tag 263 — market-data / security-status subscription action. diff --git a/kalshi/fix/messages/__init__.py b/kalshi/fix/messages/__init__.py index ed50571..f7dbb1e 100644 --- a/kalshi/fix/messages/__init__.py +++ b/kalshi/fix/messages/__init__.py @@ -51,6 +51,21 @@ OrderMassCancelRequest, ) from kalshi.fix.messages.order_groups import OrderGroupRequest, OrderGroupResponse +from kalshi.fix.messages.rfq import ( + AcceptQuote, + AcceptQuoteStatus, + Quote, + QuoteCancel, + QuoteCancelStatus, + QuoteConfirm, + QuoteConfirmStatus, + QuoteRequest, + QuoteRequestAck, + QuoteRequestReject, + QuoteStatusReport, + RFQCancel, + RFQCancelStatus, +) from kalshi.fix.messages.session import ( Heartbeat, Logon, @@ -63,6 +78,8 @@ __all__ = [ "APP_MESSAGE_MODELS", + "AcceptQuote", + "AcceptQuoteStatus", "BusinessMessageReject", "CollateralAmountChange", "EventResendComplete", @@ -94,6 +111,17 @@ "OrderMassCancelReport", "OrderMassCancelRequest", "Party", + "Quote", + "QuoteCancel", + "QuoteCancelStatus", + "QuoteConfirm", + "QuoteConfirmStatus", + "QuoteRequest", + "QuoteRequestAck", + "QuoteRequestReject", + "QuoteStatusReport", + "RFQCancel", + "RFQCancelStatus", "Reject", "RelatedSymbol", "ResendRequest", diff --git a/kalshi/fix/messages/dispatch.py b/kalshi/fix/messages/dispatch.py index c15eb2a..258787c 100644 --- a/kalshi/fix/messages/dispatch.py +++ b/kalshi/fix/messages/dispatch.py @@ -31,6 +31,17 @@ OrderMassCancelReport, ) from kalshi.fix.messages.order_groups import OrderGroupResponse +from kalshi.fix.messages.rfq import ( + AcceptQuoteStatus, + Quote, + QuoteCancelStatus, + QuoteConfirmStatus, + QuoteRequest, + QuoteRequestAck, + QuoteRequestReject, + QuoteStatusReport, + RFQCancelStatus, +) logger = logging.getLogger("kalshi.fix") @@ -48,6 +59,17 @@ MsgType.MARKET_DATA_REQUEST_REJECT.value: MarketDataRequestReject, MsgType.SECURITY_STATUS.value: SecurityStatus, MsgType.ORDER_GROUP_RESPONSE.value: OrderGroupResponse, + # RFQ / quoting. R and S are bidirectional — a client acting as market + # maker (or creator) receives them as notifications, so they decode too. + MsgType.QUOTE_REQUEST.value: QuoteRequest, + MsgType.QUOTE.value: Quote, + MsgType.QUOTE_REQUEST_ACK.value: QuoteRequestAck, + MsgType.QUOTE_STATUS_REPORT.value: QuoteStatusReport, + MsgType.QUOTE_REQUEST_REJECT.value: QuoteRequestReject, + MsgType.QUOTE_CONFIRM_STATUS.value: QuoteConfirmStatus, + MsgType.QUOTE_CANCEL_STATUS.value: QuoteCancelStatus, + MsgType.RFQ_CANCEL_STATUS.value: RFQCancelStatus, + MsgType.ACCEPT_QUOTE_STATUS.value: AcceptQuoteStatus, } ) diff --git a/kalshi/fix/messages/rfq.py b/kalshi/fix/messages/rfq.py new file mode 100644 index 0000000..74a9492 --- /dev/null +++ b/kalshi/fix/messages/rfq.py @@ -0,0 +1,353 @@ +"""RFQ / quoting FIX messages (GH #428) — prediction only. + +Request-for-quote spans two roles on different sessions: an **RFQ creator** +(KalshiRT) solicits and accepts quotes, and a **market maker** (KalshiRFQ) +answers them. ``QuoteRequest`` (35=R) and ``Quote`` (35=S) are bidirectional; +the rest are one-directional. + +Outbound (typed; build via the helpers, which enforce per-role required fields): +:class:`QuoteRequest` (R, creator create), :class:`Quote` (S, maker submit), +:class:`QuoteCancel` (Z), :class:`QuoteConfirm` (U7), :class:`AcceptQuote` (UA), +:class:`RFQCancel` (UE). Inbound (fields optional, codes raw — compare against +:mod:`kalshi.fix.enums`): :class:`QuoteRequestAck` (b), :class:`QuoteStatusReport` +(AI), :class:`QuoteRequestReject` (AG), :class:`QuoteConfirmStatus` (U8), +:class:`QuoteCancelStatus` (U9), :class:`RFQCancelStatus` (UB), +:class:`AcceptQuoteStatus` (UC). The bidirectional R/S also decode inbound. + +Prices (``BidPx``/``OfferPx``) ride the FIX ``Price`` field; units follow the +session like order entry — integer cents on prediction, fixed-point dollars under +``UseDollars`` — so :data:`~kalshi.types.DollarDecimal` parses either. +""" + +from __future__ import annotations + +from typing import Annotated + +from pydantic import model_validator + +from kalshi.fix.enums import MsgType, Side +from kalshi.fix.messages.base import ( + FixGroupMeta, + FixMessage, + FixType, + fixfield, + groupfield, +) +from kalshi.fix.messages.components import MultivariateSelectedLeg, Party +from kalshi.fix.tags import Tag +from kalshi.types import DollarDecimal, FixedPointCount + +# --------------------------------------------------------------------------- +# Bidirectional (created outbound via helpers; also decoded inbound) +# --------------------------------------------------------------------------- + + +class QuoteRequest(FixMessage): + """QuoteRequest (35=R) — create an RFQ (creator) / RFQ notification (maker). + + Fields are optional so the inbound notification parses robustly; use + :meth:`create` to build a well-formed outbound RFQ. ``NoRelatedSym`` is always + 1 (single market); ``Symbol`` xor the MVE collection+legs identify the market, + and ``OrderQty`` xor ``CashOrderQty`` size it. + """ + + MSG_TYPE = MsgType.QUOTE_REQUEST + + quote_req_id: str | None = fixfield(Tag.QUOTE_REQ_ID, FixType.STRING, default=None) + no_related_sym: int | None = fixfield(Tag.NO_RELATED_SYM, FixType.NUMINGROUP, default=None) + symbol: str | None = fixfield(Tag.SYMBOL, FixType.STRING, default=None) + order_qty: FixedPointCount | None = fixfield(Tag.ORDER_QTY, FixType.QTY, default=None) + cash_order_qty: DollarDecimal | None = fixfield(Tag.CASH_ORDER_QTY, FixType.AMT, default=None) + parties: Annotated[list[Party], FixGroupMeta(Tag.NO_PARTY_IDS, Party)] = groupfield() + rest_remainder: bool | None = fixfield(Tag.REST_REMAINDER, FixType.BOOLEAN, default=None) + replace_existing: bool | None = fixfield(Tag.REPLACE_EXISTING, FixType.BOOLEAN, default=None) + multivariate_collection_ticker: str | None = fixfield( + Tag.MULTIVARIATE_COLLECTION_TICKER, FixType.STRING, default=None + ) + multivariate_selected_legs: Annotated[ + list[MultivariateSelectedLeg], + FixGroupMeta(Tag.NO_MULTIVARIATE_SELECTED_LEGS, MultivariateSelectedLeg), + ] = groupfield() + alloc_account: int | None = fixfield(Tag.ALLOC_ACCOUNT, FixType.INT, default=None) + + @classmethod + def create( + cls, + quote_req_id: str, + symbol: str, + *, + order_qty: FixedPointCount | None = None, + cash_order_qty: DollarDecimal | None = None, + alloc_account: int | None = None, + rest_remainder: bool | None = None, + replace_existing: bool | None = None, + ) -> QuoteRequest: + """Create a single-market RFQ for ``symbol``. + + Requires at least one of ``order_qty`` / ``cash_order_qty`` to size it. + MVE/parlay RFQs (collection ticker + legs instead of a symbol) construct + :class:`QuoteRequest` directly. + """ + if order_qty is None and cash_order_qty is None: + raise ValueError("QuoteRequest.create requires order_qty or cash_order_qty") + return cls( + quote_req_id=quote_req_id, + no_related_sym=1, + symbol=symbol, + order_qty=order_qty, + cash_order_qty=cash_order_qty, + alloc_account=alloc_account, + rest_remainder=rest_remainder, + replace_existing=replace_existing, + ) + + +class Quote(FixMessage): + """Quote (35=S) — submit a quote (maker) / quote notification (creator). + + Either ``bid_px`` or ``offer_px`` may be zero, but not both (zero = no quote + for that side). Use :meth:`submit` for the maker path. + """ + + MSG_TYPE = MsgType.QUOTE + + quote_id: str | None = fixfield(Tag.QUOTE_ID, FixType.STRING, default=None) + quote_req_id: str | None = fixfield(Tag.QUOTE_REQ_ID, FixType.STRING, default=None) + symbol: str | None = fixfield(Tag.SYMBOL, FixType.STRING, default=None) + bid_px: DollarDecimal | None = fixfield(Tag.BID_PX, FixType.PRICE, default=None) + offer_px: DollarDecimal | None = fixfield(Tag.OFFER_PX, FixType.PRICE, default=None) + order_qty: FixedPointCount | None = fixfield(Tag.ORDER_QTY, FixType.QTY, default=None) + bid_size: FixedPointCount | None = fixfield(Tag.BID_SIZE, FixType.QTY, default=None) + offer_size: FixedPointCount | None = fixfield(Tag.OFFER_SIZE, FixType.QTY, default=None) + alloc_account: int | None = fixfield(Tag.ALLOC_ACCOUNT, FixType.INT, default=None) + rest_remainder: bool | None = fixfield(Tag.REST_REMAINDER, FixType.BOOLEAN, default=None) + + @classmethod + def submit( + cls, + quote_id: str, + quote_req_id: str, + symbol: str, + *, + bid_px: DollarDecimal | None = None, + offer_px: DollarDecimal | None = None, + alloc_account: int | None = None, + rest_remainder: bool | None = None, + ) -> Quote: + """Build a maker quote for ``quote_req_id`` on ``symbol``. + + At least one of ``bid_px`` / ``offer_px`` is required (a quote with neither + side is rejected by the exchange) — ``None`` means that side is absent, + distinct from ``Decimal(0)`` (an explicit zero = no quote for that side). + The maker does not send sizes — the + exchange derives size from the RFQ's ``OrderQty`` — so ``bid_size`` / + ``offer_size`` / ``order_qty`` are not parameters here; they appear on the + model only for the inbound (Exchange -> Creator) quote notification. + """ + if bid_px is None and offer_px is None: + raise ValueError("Quote.submit requires at least one of bid_px / offer_px") + return cls( + quote_id=quote_id, + quote_req_id=quote_req_id, + symbol=symbol, + bid_px=bid_px, + offer_px=offer_px, + alloc_account=alloc_account, + rest_remainder=rest_remainder, + ) + + +# --------------------------------------------------------------------------- +# Outbound requests +# --------------------------------------------------------------------------- + + +class QuoteCancel(FixMessage): + """QuoteCancel (35=Z) — maker cancels an active quote.""" + + MSG_TYPE = MsgType.QUOTE_CANCEL + + quote_id: str = fixfield(Tag.QUOTE_ID, FixType.STRING) + + +class QuoteConfirm(FixMessage): + """QuoteConfirm (35=U7) — maker confirms execution after a quote is accepted.""" + + MSG_TYPE = MsgType.QUOTE_CONFIRM + + quote_id: str = fixfield(Tag.QUOTE_ID, FixType.STRING) + + +class AcceptQuote(FixMessage): + """AcceptQuote (35=UA) — creator accepts a maker's quote. + + NB: ``side`` (tag 54) is *inverted* for AcceptQuote per the Kalshi docs — + ``Side.BUY_YES`` (1) accepts the maker's **NO** quote and ``Side.SELL_NO`` (2) + accepts the maker's **YES** quote (the field reuses the shared FIX Side, so the + enum member names read opposite to the accept semantics). + """ + + MSG_TYPE = MsgType.ACCEPT_QUOTE + + quote_id: str = fixfield(Tag.QUOTE_ID, FixType.STRING) + side: Side = fixfield(Tag.SIDE, FixType.CHAR) + order_qty: FixedPointCount | None = fixfield(Tag.ORDER_QTY, FixType.QTY, default=None) + cl_ord_id: str | None = fixfield(Tag.CL_ORD_ID, FixType.STRING, default=None) + parties: Annotated[list[Party], FixGroupMeta(Tag.NO_PARTY_IDS, Party)] = groupfield() + prefer_better_quote: bool | None = fixfield( + Tag.PREFER_BETTER_QUOTE, FixType.BOOLEAN, default=None + ) + + +class RFQCancel(FixMessage): + """RFQCancel (35=UE) — creator cancels an active RFQ. + + Identify the RFQ by either ``quote_req_id`` (client id) or ``rfq_id`` (server + id); use :meth:`for_req_id` / :meth:`for_rfq_id`. The helpers cover the common + case; an FCM on-behalf-of cancel sets ``parties`` via direct construction, + e.g. ``RFQCancel(quote_req_id=..., parties=[Party(...)])``. + """ + + MSG_TYPE = MsgType.RFQ_CANCEL + + quote_req_id: str | None = fixfield(Tag.QUOTE_REQ_ID, FixType.STRING, default=None) + rfq_id: str | None = fixfield(Tag.RFQ_ID, FixType.STRING, default=None) + parties: Annotated[list[Party], FixGroupMeta(Tag.NO_PARTY_IDS, Party)] = groupfield() + + @model_validator(mode="after") + def _require_identifier(self) -> RFQCancel: + # The validator enforces the outbound contract (at least one identifier). + # RFQCancel is outbound-only — do NOT add it to dispatch/APP_MESSAGE_MODELS + # without revisiting this, or it would reject inbound messages lacking both. + if self.quote_req_id is None and self.rfq_id is None: + raise ValueError("RFQCancel requires quote_req_id or rfq_id") + return self + + @classmethod + def for_req_id(cls, quote_req_id: str) -> RFQCancel: + """Cancel by the client-assigned QuoteReqID.""" + return cls(quote_req_id=quote_req_id) + + @classmethod + def for_rfq_id(cls, rfq_id: str) -> RFQCancel: + """Cancel by the server-assigned RfqID.""" + return cls(rfq_id=rfq_id) + + +# --------------------------------------------------------------------------- +# Inbound messages (fields optional; codes raw for robustness) +# --------------------------------------------------------------------------- + + +class QuoteRequestAck(FixMessage): + """QuoteRequestAck (35=b) — exchange ack of a creator's QuoteRequest. + + ``quote_request_type`` is a raw int (compare against + :class:`~kalshi.fix.enums.QuoteRequestType`); ``rfq_id`` is the server id. + """ + + MSG_TYPE = MsgType.QUOTE_REQUEST_ACK + + quote_req_id: str | None = fixfield(Tag.QUOTE_REQ_ID, FixType.STRING, default=None) + quote_request_type: int | None = fixfield(Tag.QUOTE_REQUEST_TYPE, FixType.INT, default=None) + rfq_id: str | None = fixfield(Tag.RFQ_ID, FixType.STRING, default=None) + symbol: str | None = fixfield(Tag.SYMBOL, FixType.STRING, default=None) + text: str | None = fixfield(Tag.TEXT, FixType.STRING, default=None) + + +class QuoteStatusReport(FixMessage): + """QuoteStatusReport (35=AI) — quote lifecycle update to the maker. + + ``quote_status`` is a raw int (compare against + :class:`~kalshi.fix.enums.QuoteStatus`); ``side`` (AcceptedSide) is a raw char + (FIX CHAR — not an int like the status codes), present only when ACCEPTED. + """ + + MSG_TYPE = MsgType.QUOTE_STATUS_REPORT + + quote_id: str | None = fixfield(Tag.QUOTE_ID, FixType.STRING, default=None) + quote_req_id: str | None = fixfield(Tag.QUOTE_REQ_ID, FixType.STRING, default=None) + alloc_account: int | None = fixfield(Tag.ALLOC_ACCOUNT, FixType.INT, default=None) + quote_status: int | None = fixfield(Tag.QUOTE_STATUS, FixType.INT, default=None) + order_qty: FixedPointCount | None = fixfield(Tag.ORDER_QTY, FixType.QTY, default=None) + bid_px: DollarDecimal | None = fixfield(Tag.BID_PX, FixType.PRICE, default=None) + offer_px: DollarDecimal | None = fixfield(Tag.OFFER_PX, FixType.PRICE, default=None) + bid_size: FixedPointCount | None = fixfield(Tag.BID_SIZE, FixType.QTY, default=None) + offer_size: FixedPointCount | None = fixfield(Tag.OFFER_SIZE, FixType.QTY, default=None) + side: str | None = fixfield(Tag.SIDE, FixType.CHAR, default=None) + text: str | None = fixfield(Tag.TEXT, FixType.STRING, default=None) + + +class QuoteRequestReject(FixMessage): + """QuoteRequestReject (35=AG) — exchange rejected/cancelled an RFQ request. + + ``quote_request_reject_reason`` is a raw int (compare against + :class:`~kalshi.fix.enums.QuoteRequestRejectReason`). + """ + + MSG_TYPE = MsgType.QUOTE_REQUEST_REJECT + + quote_req_id: str | None = fixfield(Tag.QUOTE_REQ_ID, FixType.STRING, default=None) + quote_request_reject_reason: int | None = fixfield( + Tag.QUOTE_REQUEST_REJECT_REASON, FixType.INT, default=None + ) + text: str | None = fixfield(Tag.TEXT, FixType.STRING, default=None) + + +class QuoteConfirmStatus(FixMessage): + """QuoteConfirmStatus (35=U8) — exchange response to a QuoteConfirm. + + ``quote_confirm_status`` is a raw int (compare against + :class:`~kalshi.fix.enums.QuoteConfirmResult`). + """ + + MSG_TYPE = MsgType.QUOTE_CONFIRM_STATUS + + quote_id: str | None = fixfield(Tag.QUOTE_ID, FixType.STRING, default=None) + quote_confirm_status: int | None = fixfield(Tag.QUOTE_CONFIRM_STATUS, FixType.INT, default=None) + text: str | None = fixfield(Tag.TEXT, FixType.STRING, default=None) + + +class QuoteCancelStatus(FixMessage): + """QuoteCancelStatus (35=U9) — exchange response to a QuoteCancel. + + ``quote_cancel_status`` is a raw int (compare against + :class:`~kalshi.fix.enums.QuoteCancelResult`). + """ + + MSG_TYPE = MsgType.QUOTE_CANCEL_STATUS + + quote_id: str | None = fixfield(Tag.QUOTE_ID, FixType.STRING, default=None) + quote_cancel_status: int | None = fixfield(Tag.QUOTE_CANCEL_STATUS, FixType.INT, default=None) + text: str | None = fixfield(Tag.TEXT, FixType.STRING, default=None) + + +class RFQCancelStatus(FixMessage): + """RFQCancelStatus (35=UB) — exchange response to an RFQCancel. + + ``rfq_cancel_status`` is a raw int (compare against + :class:`~kalshi.fix.enums.RFQCancelResult`). + """ + + MSG_TYPE = MsgType.RFQ_CANCEL_STATUS + + quote_req_id: str | None = fixfield(Tag.QUOTE_REQ_ID, FixType.STRING, default=None) + rfq_cancel_status: int | None = fixfield(Tag.RFQ_CANCEL_STATUS, FixType.INT, default=None) + text: str | None = fixfield(Tag.TEXT, FixType.STRING, default=None) + + +class AcceptQuoteStatus(FixMessage): + """AcceptQuoteStatus (35=UC) — exchange response to an AcceptQuote. + + ``accept_quote_status`` is a raw int (compare against + :class:`~kalshi.fix.enums.AcceptQuoteResult`); ``accepted_quote_id`` may differ + from the requested quote when ``PreferBetterQuote`` was set. + """ + + MSG_TYPE = MsgType.ACCEPT_QUOTE_STATUS + + quote_id: str | None = fixfield(Tag.QUOTE_ID, FixType.STRING, default=None) + accept_quote_status: int | None = fixfield(Tag.ACCEPT_QUOTE_STATUS, FixType.INT, default=None) + accepted_quote_id: str | None = fixfield(Tag.ACCEPTED_QUOTE_ID, FixType.STRING, default=None) + cl_ord_id: str | None = fixfield(Tag.CL_ORD_ID, FixType.STRING, default=None) + text: str | None = fixfield(Tag.TEXT, FixType.STRING, default=None) diff --git a/tests/fix/test_rfq.py b/tests/fix/test_rfq.py new file mode 100644 index 0000000..1e36eb1 --- /dev/null +++ b/tests/fix/test_rfq.py @@ -0,0 +1,435 @@ +"""Tests for the RFQ / quoting FIX flow (GH #428).""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from decimal import Decimal + +import pytest + +from kalshi.fix.auth import FixSigner +from kalshi.fix.codec import RawMessage, decode, encode +from kalshi.fix.config import FixConfig, FixSessionType +from kalshi.fix.enums import ( + AcceptQuoteResult, + MsgType, + QuoteCancelResult, + QuoteConfirmResult, + QuoteRequestType, + QuoteStatus, + RFQCancelResult, + Side, +) +from kalshi.fix.messages import ( + APP_MESSAGE_MODELS, + AcceptQuote, + AcceptQuoteStatus, + MultivariateSelectedLeg, + Quote, + QuoteCancel, + QuoteCancelStatus, + QuoteConfirm, + QuoteConfirmStatus, + QuoteRequest, + QuoteRequestAck, + QuoteRequestReject, + QuoteStatusReport, + RFQCancel, + RFQCancelStatus, + decode_app_message, +) +from kalshi.fix.messages.base import FixMessage +from kalshi.fix.session import FixSession +from kalshi.fix.tags import Tag + +from .conftest import MockAcceptor + +REQ = "client-req-123" +RFQ = "server-rfq-456" +QID = "quote-789" +SYM = "HIGHNY-23DEC31" + + +def _roundtrip(msg: FixMessage) -> FixMessage: + full = [ + (int(Tag.MSG_TYPE), msg.MSG_TYPE.value), + (int(Tag.MSG_SEQ_NUM), "1"), + (int(Tag.SENDING_TIME), "20250101-00:00:00.000"), + *msg.to_body_fields(), + ] + return type(msg).from_raw(decode(encode(full))) + + +def _wire(msg: FixMessage) -> str: + return "|".join(f"{t}={v}" for t, v in msg.to_body_fields()) + + +# --------------------------------------------------------------------------- +# Golden wire fixtures (pinned to docs.kalshi.com/fix/rfq-messages examples) +# --------------------------------------------------------------------------- + + +def test_golden_quote_request_create() -> None: + msg = QuoteRequest.create(REQ, symbol=SYM, order_qty=Decimal("100")) + assert _wire(msg) == f"131={REQ}|146=1|55={SYM}|38=100" + + +def test_golden_quote_request_mve_legs() -> None: + msg = QuoteRequest( + quote_req_id="client-req-456", + no_related_sym=1, + order_qty=Decimal("100"), + multivariate_collection_ticker="PARLAY-COLLECTION", + multivariate_selected_legs=[ + MultivariateSelectedLeg(event_ticker="EVENT1", market_ticker="MKT1", side="yes"), + MultivariateSelectedLeg(event_ticker="EVENT2", market_ticker="MKT2", side="no"), + ], + ) + assert _wire(msg) == ( + "131=client-req-456|146=1|38=100|20180=PARLAY-COLLECTION|" + "20181=2|20182=EVENT1|20183=MKT1|20184=yes|20182=EVENT2|20183=MKT2|20184=no" + ) + + +def test_golden_quote_request_ack() -> None: + msg = QuoteRequestAck(quote_req_id=REQ, quote_request_type=1, rfq_id=RFQ) + assert _wire(msg) == f"131={REQ}|303=1|21023={RFQ}" + + +def test_golden_quote_submit_maker() -> None: + # MM -> Exchange: cents on the wire (prediction default). + msg = Quote.submit(QID, RFQ, SYM, bid_px=Decimal("75"), offer_px=Decimal("25")) + assert _wire(msg) == f"117={QID}|131={RFQ}|55={SYM}|132=75|133=25" + + +def test_golden_quote_notification_creator() -> None: + # Exchange -> Creator: dollars on the wire (UseDollars). + msg = Quote( + quote_id=QID, + quote_req_id=RFQ, + symbol=SYM, + bid_px=Decimal("0.7500"), + offer_px=Decimal("0.2500"), + order_qty=Decimal("100"), + ) + assert _wire(msg) == f"117={QID}|131={RFQ}|55={SYM}|132=0.7500|133=0.2500|38=100" + + +def test_golden_accept_quote() -> None: + msg = AcceptQuote( + quote_id=QID, side=Side.BUY_YES, order_qty=Decimal("100"), cl_ord_id="client-accept-123" + ) + assert _wire(msg) == f"117={QID}|54=1|38=100|11=client-accept-123" + + +def test_golden_accept_quote_status() -> None: + msg = AcceptQuoteStatus( + quote_id=QID, accept_quote_status=0, accepted_quote_id=QID, cl_ord_id="client-accept-123" + ) + assert _wire(msg) == f"117={QID}|21025=0|21024={QID}|11=client-accept-123" + + +def test_golden_rfq_cancel_and_status() -> None: + assert _wire(RFQCancel.for_req_id(REQ)) == f"131={REQ}" + assert _wire(RFQCancel.for_rfq_id(RFQ)) == f"21023={RFQ}" + assert _wire(RFQCancelStatus(quote_req_id=REQ, rfq_cancel_status=0)) == f"131={REQ}|21013=0" + + +def test_golden_quote_status_report_pending() -> None: + msg = QuoteStatusReport( + quote_id=QID, + quote_req_id=RFQ, + quote_status=int(QuoteStatus.PENDING), + order_qty=Decimal("100"), + bid_px=Decimal("75"), + offer_px=Decimal("25"), + ) + assert _wire(msg) == f"117={QID}|131={RFQ}|297=10|38=100|132=75|133=25" + + +def test_golden_quote_cancel_flow() -> None: + assert _wire(QuoteCancel(quote_id=QID)) == f"117={QID}" + assert _wire(QuoteCancelStatus(quote_id=QID, quote_cancel_status=0)) == f"117={QID}|298=0" + + +def test_golden_quote_confirm_flow() -> None: + assert _wire(QuoteConfirm(quote_id=QID)) == f"117={QID}" + assert _wire(QuoteConfirmStatus(quote_id=QID, quote_confirm_status=0)) == f"117={QID}|21010=0" + + +def test_golden_quote_request_reject() -> None: + msg = QuoteRequestReject(quote_req_id=REQ, quote_request_reject_reason=1, text="unknown symbol") + assert _wire(msg) == f"131={REQ}|658=1|58=unknown symbol" + + +# --------------------------------------------------------------------------- +# Round-trips for the full message set +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "msg", + [ + QuoteRequest.create(REQ, symbol=SYM, order_qty=Decimal("100")), + Quote.submit(QID, RFQ, SYM, bid_px=Decimal("75"), offer_px=Decimal("25")), + QuoteCancel(quote_id=QID), + QuoteConfirm(quote_id=QID), + AcceptQuote(quote_id=QID, side=Side.SELL_NO), + RFQCancel.for_req_id(REQ), + QuoteRequestAck(quote_req_id=REQ, quote_request_type=1, rfq_id=RFQ), + QuoteStatusReport(quote_id=QID, quote_req_id=RFQ, quote_status=0, side="1"), + QuoteRequestReject(quote_req_id=REQ, quote_request_reject_reason=99, text="x"), + QuoteConfirmStatus(quote_id=QID, quote_confirm_status=1, text="no"), + QuoteCancelStatus(quote_id=QID, quote_cancel_status=0), + RFQCancelStatus(quote_req_id=REQ, rfq_cancel_status=1, text="no"), + AcceptQuoteStatus(quote_id=QID, accept_quote_status=0, accepted_quote_id=QID), + ], +) +def test_rfq_message_roundtrip(msg: FixMessage) -> None: + assert _roundtrip(msg) == msg + + +def test_quote_request_with_parties_roundtrip() -> None: + from kalshi.fix.messages import Party + + msg = QuoteRequest.create(REQ, symbol=SYM, order_qty=Decimal("100")) + msg = msg.model_copy(update={"parties": [Party(party_id="SUB-1", party_role=24)]}) + rt = _roundtrip(msg) + assert isinstance(rt, QuoteRequest) + assert rt.parties[0].party_id == "SUB-1" + assert rt == msg + + +def test_status_field_enum_comparisons() -> None: + # The raw-int status fields compare against their suffixed *Result enums. + assert QuoteCancelStatus(quote_id=QID, quote_cancel_status=1).quote_cancel_status == ( + QuoteCancelResult.REJECTED + ) + assert QuoteConfirmStatus(quote_id=QID, quote_confirm_status=0).quote_confirm_status == ( + QuoteConfirmResult.ACCEPTED + ) + assert RFQCancelStatus(quote_req_id=REQ, rfq_cancel_status=0).rfq_cancel_status == ( + RFQCancelResult.CANCELLED + ) + assert AcceptQuoteStatus(quote_id=QID, accept_quote_status=1).accept_quote_status == ( + AcceptQuoteResult.REJECTED + ) + + +def test_quote_submit_requires_a_price() -> None: + with pytest.raises(ValueError, match="bid_px / offer_px"): + Quote.submit(QID, RFQ, SYM) + + +def test_quote_request_create_requires_a_size() -> None: + with pytest.raises(ValueError, match="order_qty or cash_order_qty"): + QuoteRequest.create(REQ, SYM) + + +def test_rfq_cancel_requires_an_identifier() -> None: + # Direct construction with neither identifier is rejected (outbound-only guard). + with pytest.raises(ValueError, match="quote_req_id or rfq_id"): + RFQCancel() + + +def test_rfq_cancel_stays_outbound_only() -> None: + # RFQCancel carries a construction-time validator; it must NOT be registered + # for inbound dispatch (that would reject inbound messages lacking both ids). + # This tripwire fails if someone wires it into APP_MESSAGE_MODELS. + assert MsgType.RFQ_CANCEL.value not in APP_MESSAGE_MODELS + + +def test_quote_request_type_enum_comparison() -> None: + msg = QuoteRequestAck(quote_req_id=REQ, quote_request_type=1, rfq_id=RFQ) + assert msg.quote_request_type == QuoteRequestType.MANUAL + + +@pytest.mark.parametrize( + ("status", "wire"), + [ + (QuoteStatus.ACCEPTED, "0"), + (QuoteStatus.REJECTED, "5"), + (QuoteStatus.PENDING, "10"), + (QuoteStatus.CANCELLED, "17"), + ], +) +def test_quote_status_report_status_values(status: QuoteStatus, wire: str) -> None: + # Pin every QuoteStatus wire value (catches enum-value drift on 297). + msg = QuoteStatusReport(quote_id=QID, quote_req_id=RFQ, quote_status=int(status)) + assert _wire(msg) == f"117={QID}|131={RFQ}|297={wire}" + rt = _roundtrip(msg) + assert isinstance(rt, QuoteStatusReport) + assert rt.quote_status == status + + +def test_accept_quote_side_inverted_wire_values() -> None: + # AcceptQuote.side is inverted per spec: BUY_YES(1) accepts the maker's NO + # quote, SELL_NO(2) accepts the YES quote. Pin the wire values either way. + assert dict(AcceptQuote(quote_id=QID, side=Side.BUY_YES).to_body_fields())[int(Tag.SIDE)] == "1" + assert dict(AcceptQuote(quote_id=QID, side=Side.SELL_NO).to_body_fields())[int(Tag.SIDE)] == "2" + + +def test_fix_public_api_exports_are_importable() -> None: + # Every name in kalshi.fix.__all__ must actually be bound (guards the + # __all__-without-import class of bug, which mypy cannot catch). + import kalshi.fix as fix_pkg + + missing = [name for name in fix_pkg.__all__ if not hasattr(fix_pkg, name)] + assert missing == [] + + +@pytest.mark.parametrize( + "msg", + [ + QuoteRequestAck(quote_req_id=REQ, quote_request_type=1), + QuoteStatusReport(quote_id=QID, quote_req_id=RFQ, quote_status=10), + QuoteRequestReject(quote_req_id=REQ, quote_request_reject_reason=1, text="x"), + QuoteConfirmStatus(quote_id=QID, quote_confirm_status=0), + QuoteCancelStatus(quote_id=QID, quote_cancel_status=0), + RFQCancelStatus(quote_req_id=REQ, rfq_cancel_status=0), + AcceptQuoteStatus(quote_id=QID, accept_quote_status=0), + QuoteRequest.create(REQ, symbol=SYM, order_qty=Decimal("1")), + Quote.submit(QID, RFQ, SYM, bid_px=Decimal("50")), + ], +) +def test_decode_app_message_rfq_types(msg: FixMessage) -> None: + raw = decode( + encode( + [ + (int(Tag.MSG_TYPE), msg.MSG_TYPE.value), + (int(Tag.MSG_SEQ_NUM), "2"), + (int(Tag.SENDING_TIME), "20250101-00:00:00.000"), + *msg.to_body_fields(), + ] + ) + ) + decoded = decode_app_message(raw) + assert type(decoded) is type(msg) + assert decoded == msg + + +# --------------------------------------------------------------------------- +# Market-maker quote lifecycle against the mock acceptor +# --------------------------------------------------------------------------- + + +async def test_market_maker_quote_lifecycle( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + received: list[RawMessage] = [] + + async def on_message(raw: RawMessage) -> None: + received.append(raw) + + session = FixSession(fix_signer, fix_config, FixSessionType.RFQ, on_message=on_message) + await session.start() + try: + # Exchange notifies the maker of a new RFQ. + notif = QuoteRequest.create(RFQ, symbol=SYM, order_qty=Decimal("100")) + await acceptor.push("R", notif.to_body_fields(), seq=2) + await until(lambda: len(received) == 1) + decoded = decode_app_message(received[0]) + assert isinstance(decoded, QuoteRequest) + assert decoded.symbol == SYM + + # Maker submits a quote. + quote = Quote.submit(QID, RFQ, SYM, bid_px=Decimal("75"), offer_px=Decimal("25")) + await session.send(quote) + await until(lambda: acceptor.first("S") is not None) + raw_s = acceptor.first("S") + assert raw_s is not None + assert raw_s.get(Tag.QUOTE_ID) == QID + + # Exchange reports PENDING then ACCEPTED. + pending = QuoteStatusReport(quote_id=QID, quote_req_id=RFQ, quote_status=10) + accepted = QuoteStatusReport(quote_id=QID, quote_req_id=RFQ, quote_status=0, side="1") + await acceptor.push("AI", pending.to_body_fields(), seq=3) + await acceptor.push("AI", accepted.to_body_fields(), seq=4) + await until(lambda: len(received) == 3) + statuses = [decode_app_message(r) for r in received[1:]] + assert all(isinstance(s, QuoteStatusReport) for s in statuses) + accepted_report = statuses[1] + assert isinstance(accepted_report, QuoteStatusReport) + assert accepted_report.quote_status == QuoteStatus.ACCEPTED + assert accepted_report.side == "1" # AcceptedSide present only when ACCEPTED + + # Maker confirms; exchange acks. + await session.send(QuoteConfirm(quote_id=QID)) + await until(lambda: acceptor.first("U7") is not None) + await acceptor.push( + "U8", QuoteConfirmStatus(quote_id=QID, quote_confirm_status=0).to_body_fields(), seq=5 + ) + await until(lambda: len(received) == 4) + confirm = decode_app_message(received[3]) + assert isinstance(confirm, QuoteConfirmStatus) + assert confirm.quote_confirm_status == 0 + finally: + await session.close() + + +async def test_rfq_creator_lifecycle( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + """Creator flow on KalshiRT: R -> b -> S -> UA -> UC, then UE -> UB.""" + received: list[RawMessage] = [] + + async def on_message(raw: RawMessage) -> None: + received.append(raw) + + session = FixSession( + fix_signer, fix_config, FixSessionType.ORDER_ENTRY_RT, on_message=on_message + ) + await session.start() + try: + # Create the RFQ; exchange acks with the server RFQ id. + await session.send(QuoteRequest.create(REQ, symbol=SYM, order_qty=Decimal("100"))) + await until(lambda: acceptor.first("R") is not None) + r = acceptor.first("R") + assert r is not None + assert r.get(Tag.QUOTE_REQ_ID) == REQ + ack = QuoteRequestAck(quote_req_id=REQ, quote_request_type=1, rfq_id=RFQ) + await acceptor.push("b", ack.to_body_fields(), seq=2) + + # A maker's quote is forwarded to the creator (dollars on the wire). + notif = Quote( + quote_id=QID, quote_req_id=RFQ, symbol=SYM, + bid_px=Decimal("0.7500"), offer_px=Decimal("0.2500"), order_qty=Decimal("100"), + ) + await acceptor.push("S", notif.to_body_fields(), seq=3) + await until(lambda: len(received) == 2) + quote = decode_app_message(received[1]) + assert isinstance(quote, Quote) + assert quote.quote_id == QID + + # Accept it; exchange confirms. + await session.send(AcceptQuote(quote_id=QID, side=Side.SELL_NO, order_qty=Decimal("100"))) + await until(lambda: acceptor.first("UA") is not None) + await acceptor.push( + "UC", + AcceptQuoteStatus(quote_id=QID, accept_quote_status=0, accepted_quote_id=QID) + .to_body_fields(), + seq=4, + ) + await until(lambda: len(received) == 3) + accept_status = decode_app_message(received[2]) + assert isinstance(accept_status, AcceptQuoteStatus) + assert accept_status.accept_quote_status == AcceptQuoteResult.ACCEPTED + + # Cancel the RFQ; exchange acks the cancel. + await session.send(RFQCancel.for_req_id(REQ)) + await until(lambda: acceptor.first("UE") is not None) + await acceptor.push( + "UB", RFQCancelStatus(quote_req_id=REQ, rfq_cancel_status=0).to_body_fields(), seq=5 + ) + await until(lambda: len(received) == 4) + cancel_status = decode_app_message(received[3]) + assert isinstance(cancel_status, RFQCancelStatus) + assert cancel_status.rfq_cancel_status == RFQCancelResult.CANCELLED + finally: + await session.close()