From cf2bd190c28599f344b21b6e8860d7f38a201da7 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 5 Jun 2026 18:38:17 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(fix):=20FIX=20protocol=20foundation=20?= =?UTF-8?q?(codec=20+=20session=20engine=20+=20auth)=20=E2=80=94=20part=20?= =?UTF-8?q?of=20#402?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-rolled, async-first, dependency-free FIX engine (FIXT.1.1 transport / FIX50SP2 application) shared by the prediction and perps (margin) products. Foundation layer only — order entry, drop copy, market data, and RFQ/settlement message flows land in later phases (sub-issues of #402). - codec: tag=value framing (BodyLength / 3-digit CheckSum / SOH), length- prefixed data fields read by byte count, incremental FixParser with false-"8=" resync and bounded buffering - typed Pydantic message framework (tag-metadata driven) + session/admin messages (Logon/Logout/Heartbeat/TestRequest/ResendRequest/SequenceReset/Reject) - FixSigner: RSA-PSS logon RawData signature reusing the existing cryptography key (adds an additive KalshiAuth.private_key property) - FixConfig: prod/demo x prediction/margin host+port tables, TLS + host guards, retransmission + UseDollars policy - async FixSession state machine: signed logon, heartbeat/test-request liveness, inbound sequence tracking with buffered gap-fill/resend, reset-on-logon policy, AWS full-jitter reconnect, graceful logout - FixClient (prediction, 6 sessions) + MarginFixClient (margin, KALSHI_PERPS_* creds, UseDollars on) over a shared core - 90 tests: codec round-trip/framing, message round-trip, logon signature, config resolution/guards, and the async session state machine driven by an in-memory mock acceptor Hardened via an internal multi-agent adversarial review and the multi-LLM review (codex/gemini/grok): codec resync + data-field adjacency + SOH-injection rejection + corrupt-frame resync; session ValidationError recovery, SequenceReset gap-validation/no-rewind, outbound-seq reserve-before-send, close() timeout + task ordering, logon lower-seq fatal, inbound-ResendRequest gating, strict Y/N booleans; config per-product heartbeat floor + TLS-to- gateway enforcement. Part of #402. Co-Authored-By: Claude Opus 4.8 (1M context) --- kalshi/auth.py | 11 + kalshi/fix/__init__.py | 129 ++++++ kalshi/fix/auth.py | 128 ++++++ kalshi/fix/client.py | 183 +++++++++ kalshi/fix/codec.py | 378 ++++++++++++++++++ kalshi/fix/config.py | 293 ++++++++++++++ kalshi/fix/connection.py | 115 ++++++ kalshi/fix/enums.py | 248 ++++++++++++ kalshi/fix/errors.py | 121 ++++++ kalshi/fix/messages/__init__.py | 32 ++ kalshi/fix/messages/base.py | 183 +++++++++ kalshi/fix/messages/session.py | 110 +++++ kalshi/fix/session.py | 683 ++++++++++++++++++++++++++++++++ kalshi/fix/tags.py | 200 ++++++++++ kalshi/perps/fix/__init__.py | 13 + kalshi/perps/fix/client.py | 59 +++ tests/fix/__init__.py | 0 tests/fix/conftest.py | 212 ++++++++++ tests/fix/test_auth.py | 84 ++++ tests/fix/test_codec.py | 265 +++++++++++++ tests/fix/test_config.py | 123 ++++++ tests/fix/test_messages.py | 167 ++++++++ tests/fix/test_session.py | 519 ++++++++++++++++++++++++ 23 files changed, 4256 insertions(+) create mode 100644 kalshi/fix/__init__.py create mode 100644 kalshi/fix/auth.py create mode 100644 kalshi/fix/client.py create mode 100644 kalshi/fix/codec.py create mode 100644 kalshi/fix/config.py create mode 100644 kalshi/fix/connection.py create mode 100644 kalshi/fix/enums.py create mode 100644 kalshi/fix/errors.py create mode 100644 kalshi/fix/messages/__init__.py create mode 100644 kalshi/fix/messages/base.py create mode 100644 kalshi/fix/messages/session.py create mode 100644 kalshi/fix/session.py create mode 100644 kalshi/fix/tags.py create mode 100644 kalshi/perps/fix/__init__.py create mode 100644 kalshi/perps/fix/client.py create mode 100644 tests/fix/__init__.py create mode 100644 tests/fix/conftest.py create mode 100644 tests/fix/test_auth.py create mode 100644 tests/fix/test_codec.py create mode 100644 tests/fix/test_config.py create mode 100644 tests/fix/test_messages.py create mode 100644 tests/fix/test_session.py diff --git a/kalshi/auth.py b/kalshi/auth.py index 0f56e59f..b6d5f69a 100644 --- a/kalshi/auth.py +++ b/kalshi/auth.py @@ -264,6 +264,17 @@ def try_from_env( def key_id(self) -> str: return self._key_id + @property + def private_key(self) -> rsa.RSAPrivateKey: + """The loaded RSA private key. + + Exposed (read-only) so other signing surfaces in the SDK can reuse the + same key without re-loading the PEM — notably the FIX logon signer + (:class:`kalshi.fix.auth.FixSigner`), which signs a different payload + (the FIX Logon pre-hash string) with the identical RSA-PSS scheme. + """ + return self._private_key + def sign_request( self, method: str, path: str, timestamp_ms: int | None = None ) -> dict[str, str]: diff --git a/kalshi/fix/__init__.py b/kalshi/fix/__init__.py new file mode 100644 index 00000000..09be8e43 --- /dev/null +++ b/kalshi/fix/__init__.py @@ -0,0 +1,129 @@ +"""Kalshi FIX protocol support (FIXT.1.1 transport / FIX50SP2 application layer). + +A hand-rolled, async-first, dependency-free FIX engine shared by the prediction +and perps (margin) products. One Kalshi FIX dictionary (v1.03) covers both; the +margin product is a subset (no RFQ / settlement / post-trade) that always quotes +fixed-point dollars. See GH issue #402 for the epic and the locked design +decisions (hand-rolled codec + typed Pydantic message models + async session +engine; shared core in ``kalshi.fix`` with a thin margin facade in +``kalshi.perps.fix``). + +This package is the *foundation* layer — codec, typed message models, the +session/recovery state machine, the RSA-PSS logon signer, and connectivity +config. Order-entry, drop-copy, market-data, and RFQ/settlement message flows +land in later phases. + +Usage:: + + from kalshi.fix import FixClient, FixEnvironment, FixSessionType + + client = FixClient.from_env(environment=FixEnvironment.DEMO) + async with client.order_entry(on_message=handle) as session: + ... # send order-entry messages, receive execution reports +""" + +from __future__ import annotations + +from kalshi.fix.auth import FixSigner +from kalshi.fix.client import FixClient +from kalshi.fix.codec import ( + BEGIN_STRING_FIXT11, + SOH, + FixParser, + RawMessage, + decode, + encode, +) +from kalshi.fix.config import ( + FixConfig, + FixEnvironment, + FixProduct, + FixSessionType, +) +from kalshi.fix.connection import FixConnection +from kalshi.fix.enums import ( + ApplVerID, + EncryptMethod, + ExecInst, + ExecType, + MsgType, + OrdStatus, + OrdType, + SelfTradePreventionType, + SessionRejectReason, + Side, + TimeInForce, +) +from kalshi.fix.errors import ( + FixCodecError, + FixConnectionError, + FixLogonError, + FixRejectError, + FixSequenceError, + FixSessionError, + KalshiFixError, +) +from kalshi.fix.messages import ( + FixMessage, + Heartbeat, + Logon, + Logout, + Reject, + ResendRequest, + SequenceReset, + TestRequest, +) +from kalshi.fix.session import FixSession, FixSessionState +from kalshi.fix.tags import Tag + +__all__ = [ + "BEGIN_STRING_FIXT11", + "SOH", + "ApplVerID", + "EncryptMethod", + "ExecInst", + "ExecType", + # Clients / sessions + "FixClient", + "FixCodecError", + # Config + "FixConfig", + "FixConnection", + "FixConnectionError", + "FixEnvironment", + "FixLogonError", + # Messages + "FixMessage", + "FixParser", + "FixProduct", + "FixRejectError", + "FixSequenceError", + "FixSession", + "FixSessionError", + "FixSessionState", + "FixSessionType", + # Auth + "FixSigner", + "Heartbeat", + # Errors + "KalshiFixError", + "Logon", + "Logout", + # Enums + "MsgType", + "OrdStatus", + "OrdType", + "RawMessage", + "Reject", + "ResendRequest", + "SelfTradePreventionType", + "SequenceReset", + "SessionRejectReason", + "Side", + "Tag", + "TestRequest", + "TimeInForce", + "decode", + # Codec + "encode", +] diff --git a/kalshi/fix/auth.py b/kalshi/fix/auth.py new file mode 100644 index 00000000..a5318f32 --- /dev/null +++ b/kalshi/fix/auth.py @@ -0,0 +1,128 @@ +"""RSA-PSS logon signer for the Kalshi FIX gateway. + +FIX reuses the **same RSA key pair as the REST API**. The Logon (35=A) message +authenticates by placing a base64 RSA-PSS signature in ``RawData`` (tag 96) over +a pre-hash string built from session fields:: + + PreHashString = SendingTime + SOH + MsgType + SOH + MsgSeqNum + + SOH + SenderCompID + SOH + TargetCompID + +The signature scheme is identical to the REST signer in :mod:`kalshi.auth` +(RSA-PSS, SHA-256, MGF1(SHA-256), salt length = digest length); only the signed +payload differs. ``SenderCompID`` is the API key UUID; ``TargetCompID`` is the +session (e.g. ``KalshiNR``). ``SendingTime`` must be byte-identical to tag 52 in +the Logon and within 30 s of the server clock. +""" + +from __future__ import annotations + +import base64 +from collections.abc import Callable +from pathlib import Path + +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding, rsa + +from kalshi.auth import KalshiAuth +from kalshi.fix.codec import SOH +from kalshi.fix.enums import MsgType + +# These MUST match the REST signer in kalshi.auth (RSA-PSS / SHA-256 / +# MGF1(SHA-256) / salt_length = digest length). Kept as a local copy rather than +# importing kalshi.auth's private module constants so the scheme is explicit at +# the FIX call site; if kalshi.auth ever changes its padding, update both. +_FIX_SHA256 = hashes.SHA256() +_FIX_PSS_PADDING = padding.PSS( + mgf=padding.MGF1(_FIX_SHA256), + salt_length=padding.PSS.DIGEST_LENGTH, +) + +# The FIX pre-hash uses the SOH delimiter as a string separator. +_SOH_STR = SOH.decode("ascii") + + +class FixSigner: + """Signs FIX Logon messages with the account's RSA private key. + + Construct from an existing :class:`~kalshi.auth.KalshiAuth` (the common + case — the FIX client takes the same auth object as the REST/WS clients) via + :meth:`from_auth`, or load a key directly with :meth:`from_pem` / + :meth:`from_key_path` / :meth:`from_env` (which delegate to ``KalshiAuth``'s + loaders, so PEM handling, OpenSSH detection, and passphrase support are + shared). + """ + + def __init__(self, sender_comp_id: str, private_key: rsa.RSAPrivateKey) -> None: + self._sender_comp_id = sender_comp_id + self._private_key = private_key + + @classmethod + def from_auth(cls, auth: KalshiAuth) -> FixSigner: + """Build a signer from an existing :class:`KalshiAuth` (reuses its key).""" + return cls(auth.key_id, auth.private_key) + + @classmethod + def from_pem( + cls, + key_id: str, + pem_data: str | bytes, + *, + password: bytes | str | Callable[[], bytes | str] | None = None, + ) -> FixSigner: + """Load a signer from PEM-encoded private-key content.""" + return cls.from_auth(KalshiAuth.from_pem(key_id, pem_data, password=password)) + + @classmethod + def from_key_path( + cls, + key_id: str, + key_path: str | Path, + *, + password: bytes | str | Callable[[], bytes | str] | None = None, + ) -> FixSigner: + """Load a signer from a PEM private-key file (``~`` is expanded).""" + return cls.from_auth(KalshiAuth.from_key_path(key_id, key_path, password=password)) + + @classmethod + def from_env( + cls, + *, + password: bytes | str | Callable[[], bytes | str] | None = None, + ) -> FixSigner: + """Load a signer from the ``KALSHI_*`` environment variables. + + Same variables as the REST signer (``KALSHI_KEY_ID`` + + ``KALSHI_PRIVATE_KEY`` / ``KALSHI_PRIVATE_KEY_PATH``), since FIX shares + the REST key. For the margin product (separate ``KALSHI_PERPS_*`` key), + construct via :meth:`from_auth` from the perps auth instead. + """ + return cls.from_auth(KalshiAuth.from_env(password=password)) + + @property + def sender_comp_id(self) -> str: + """The API key UUID used as ``SenderCompID`` (tag 49) and in the pre-hash.""" + return self._sender_comp_id + + def build_pre_hash(self, *, sending_time: str, msg_seq_num: int, target_comp_id: str) -> bytes: + """Build the SOH-joined Logon pre-hash string as bytes (exposed for tests).""" + parts = [ + sending_time, + MsgType.LOGON.value, + str(msg_seq_num), + self._sender_comp_id, + target_comp_id, + ] + return _SOH_STR.join(parts).encode("utf-8") + + def sign_logon(self, *, sending_time: str, msg_seq_num: int, target_comp_id: str) -> str: + """Return the base64 RSA-PSS signature for a Logon's ``RawData`` (tag 96). + + ``sending_time`` must equal the Logon's ``SendingTime`` (tag 52) exactly. + """ + pre_hash = self.build_pre_hash( + sending_time=sending_time, + msg_seq_num=msg_seq_num, + target_comp_id=target_comp_id, + ) + signature = self._private_key.sign(pre_hash, _FIX_PSS_PADDING, _FIX_SHA256) + return base64.b64encode(signature).decode("ascii") diff --git a/kalshi/fix/client.py b/kalshi/fix/client.py new file mode 100644 index 00000000..a113a842 --- /dev/null +++ b/kalshi/fix/client.py @@ -0,0 +1,183 @@ +"""High-level FIX client facades. + +Thin convenience over :class:`kalshi.fix.session.FixSession`: hold the signer + +config and mint a session per :class:`~kalshi.fix.config.FixSessionType`. The +prediction client is :class:`FixClient`; the margin client lives in +:mod:`kalshi.perps.fix` and reuses the shared core via :class:`_BaseFixClient`. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import ClassVar, Self + +from kalshi.auth import KalshiAuth +from kalshi.fix.auth import FixSigner +from kalshi.fix.config import FixConfig, FixEnvironment, FixProduct, FixSessionType +from kalshi.fix.session import FixSession, MessageHandler, StateChangeHandler + + +class _BaseFixClient: + """Shared FIX client logic: holds signer + config, constructs sessions. + + Subclasses set ``_PRODUCT`` to bind the client to a product line. Concrete + session-type helpers live on the product-specific subclasses so only the + sessions a product actually supports are exposed. + """ + + _PRODUCT: ClassVar[FixProduct] + + def __init__( + self, + signer: FixSigner, + *, + environment: FixEnvironment = FixEnvironment.PRODUCTION, + config: FixConfig | None = None, + ssl_context: object | None = None, + ) -> None: + self._signer = signer + self._ssl_context = ssl_context + if config is None: + config = FixConfig(product=self._PRODUCT, environment=environment) + elif config.product is not self._PRODUCT: + raise ValueError( + f"config.product {config.product.value!r} does not match this " + f"client's product {self._PRODUCT.value!r}" + ) + self._config = config + + @classmethod + def from_auth( + cls, + auth: KalshiAuth, + *, + environment: FixEnvironment = FixEnvironment.PRODUCTION, + config: FixConfig | None = None, + ssl_context: object | None = None, + ) -> Self: + """Build a client reusing an existing :class:`KalshiAuth`'s key.""" + return cls( + FixSigner.from_auth(auth), + environment=environment, + config=config, + ssl_context=ssl_context, + ) + + @property + def signer(self) -> FixSigner: + """The logon signer.""" + return self._signer + + @property + def config(self) -> FixConfig: + """The resolved connectivity config.""" + return self._config + + def session( + self, + session_type: FixSessionType, + *, + on_message: MessageHandler | None = None, + on_state_change: StateChangeHandler | None = None, + ) -> FixSession: + """Construct (but do not start) a :class:`FixSession` for ``session_type``.""" + return FixSession( + self._signer, + self._config, + session_type, + on_message=on_message, + on_state_change=on_state_change, + ssl_context=self._ssl_context, + ) + + def order_entry( + self, + *, + retransmission: bool = False, + on_message: MessageHandler | None = None, + on_state_change: StateChangeHandler | None = None, + ) -> FixSession: + """An order-entry session (KalshiNR, or KalshiRT when ``retransmission``).""" + session_type = ( + FixSessionType.ORDER_ENTRY_RT if retransmission else FixSessionType.ORDER_ENTRY_NR + ) + return self.session( + session_type, on_message=on_message, on_state_change=on_state_change + ) + + def drop_copy( + self, + *, + on_message: MessageHandler | None = None, + on_state_change: StateChangeHandler | None = None, + ) -> FixSession: + """A drop-copy (KalshiDC) session for historical execution-report queries.""" + return self.session( + FixSessionType.DROP_COPY, on_message=on_message, on_state_change=on_state_change + ) + + def market_data( + self, + *, + on_message: MessageHandler | None = None, + on_state_change: StateChangeHandler | None = None, + ) -> FixSession: + """A market-data (KalshiMD) session for order-book snapshots and updates.""" + return self.session( + FixSessionType.MARKET_DATA, on_message=on_message, on_state_change=on_state_change + ) + + +class FixClient(_BaseFixClient): + """FIX client for the prediction (event-contract) gateway. + + Supports all six session types: order entry (NR/RT), drop copy, market data, + post trade, and RFQ. Construct from a :class:`FixSigner`, or from an existing + :class:`KalshiAuth` via :meth:`from_auth` / the ``KALSHI_*`` env via + :meth:`from_env`. + """ + + _PRODUCT = FixProduct.PREDICTION + + @classmethod + def from_env( + cls, + *, + environment: FixEnvironment = FixEnvironment.PRODUCTION, + config: FixConfig | None = None, + ssl_context: object | None = None, + password: bytes | str | Callable[[], bytes | str] | None = None, + ) -> FixClient: + """Build a prediction FIX client from the ``KALSHI_*`` environment vars. + + ``password`` (or ``KALSHI_PRIVATE_KEY_PASSPHRASE``) decrypts a + passphrase-protected private key — parity with the REST client. + """ + return cls( + FixSigner.from_env(password=password), + environment=environment, + config=config, + ssl_context=ssl_context, + ) + + def post_trade( + self, + *, + on_message: MessageHandler | None = None, + on_state_change: StateChangeHandler | None = None, + ) -> FixSession: + """A post-trade (KalshiPT) session for market-settlement reports.""" + return self.session( + FixSessionType.POST_TRADE, on_message=on_message, on_state_change=on_state_change + ) + + def rfq( + self, + *, + on_message: MessageHandler | None = None, + on_state_change: StateChangeHandler | None = None, + ) -> FixSession: + """An RFQ (KalshiRFQ) market-maker session.""" + return self.session( + FixSessionType.RFQ, on_message=on_message, on_state_change=on_state_change + ) diff --git a/kalshi/fix/codec.py b/kalshi/fix/codec.py new file mode 100644 index 00000000..712c185f --- /dev/null +++ b/kalshi/fix/codec.py @@ -0,0 +1,378 @@ +"""Hand-rolled FIX tag=value codec (SOH framing, BodyLength, CheckSum). + +No third-party FIX library: this is a pure-Python encoder/decoder plus an +incremental stream parser. Three responsibilities: + +* :func:`encode` — assemble an ordered ``(tag, value)`` field list into wire + bytes, computing ``BodyLength`` (tag 9) and the 3-digit ``CheckSum`` (tag 10) + and prepending ``BeginString`` (tag 8). +* :func:`decode` — parse one *complete* message's bytes into a + :class:`RawMessage`, validating the checksum and body length. Length-prefixed + data fields (``RawData``/``Signature``) are read by byte count so an embedded + SOH in the value cannot corrupt framing. +* :class:`FixParser` — buffer raw TCP reads and hand back complete messages. + Framing is *deterministic*: ``BodyLength`` tells us exactly how many body + bytes follow, so we never scan for SOH to find message boundaries. + +Values are carried as ``str`` (decoded latin-1, a reversible 1:1 byte mapping — +Kalshi FIX values are ASCII, and latin-1 round-trips any byte without error so +framing stays byte-accurate even on unexpected input). +""" + +from __future__ import annotations + +import logging + +from kalshi.fix.errors import FixCodecError +from kalshi.fix.tags import DATA_LENGTH_FIELDS, Tag + +logger = logging.getLogger("kalshi.fix") + +SOH = b"\x01" +"""The FIX field delimiter (Start Of Header, ASCII 0x01).""" + +BEGIN_STRING_FIXT11 = "FIXT.1.1" +"""Kalshi runs the FIXT.1.1 transport (application layer is FIX50SP2).""" + +# A correct message ends with the fixed-width trailer ``10=NNN\x01`` (CheckSum is +# always modulo 256, zero-padded to three digits) — exactly 7 bytes. +_CHECKSUM_FIELD_LEN = 7 + +# Guard against an unbounded buffer when a malformed/garbage BodyLength is read +# off the wire. Kalshi FIX application messages are well under this; the value is +# a denial-of-service backstop, not a real protocol limit. +MAX_BODY_LENGTH = 1_000_000 + +# Max digits a plausible BodyLength field may have (digit width of +# MAX_BODY_LENGTH plus slack). Bounds the pre-frame buffer so an un-terminated +# BodyLength field cannot force unbounded buffering before the MAX_BODY_LENGTH +# check is reached. +_MAX_BODYLEN_DIGITS = len(str(MAX_BODY_LENGTH)) + 2 + +# Values redacted in :meth:`RawMessage.__repr__` so the logon signature and any +# credential never reach logs / tracebacks / exception sinks. +_REDACTED_TAGS = frozenset({Tag.RAW_DATA, Tag.SIGNATURE, Tag.PASSWORD}) + +# Length-prefixed DATA fields whose value may legally contain the SOH delimiter +# (they are framed by their preceding length field, not by SOH-scanning). +_DATA_TAGS = frozenset(DATA_LENGTH_FIELDS.values()) + + +def encode(fields: list[tuple[int, str]], *, begin_string: str = BEGIN_STRING_FIXT11) -> bytes: + """Encode an ordered field list into a complete FIX wire message. + + ``fields`` must be the ordered ``(tag, value)`` pairs starting with + ``(35, MsgType)`` followed by the remaining header fields, the body, and any + trailer fields *other than* CheckSum. ``BeginString`` (8), ``BodyLength`` + (9), and ``CheckSum`` (10) are computed here and must not appear in + ``fields``. + + Per FIXT.1.1, ``BodyLength`` counts every byte from the start of the + MsgType field (the byte after the SOH that terminates BodyLength) up to and + including the SOH that immediately precedes the CheckSum field. + """ + if not fields: + raise FixCodecError("cannot encode an empty field list") + for tag, value in fields: + if tag in (Tag.BEGIN_STRING, Tag.BODY_LENGTH, Tag.CHECK_SUM): + raise FixCodecError( + f"tag {int(tag)} (BeginString/BodyLength/CheckSum) is computed by " + "encode() and must not be supplied in the field list" + ) + # Reject SOH in non-DATA fields: a caller-controlled value containing the + # delimiter would otherwise smuggle extra tags into a checksum-valid + # message. DATA fields (RawData/Signature) are length-prefixed and may + # legally contain SOH. + if tag not in _DATA_TAGS and "\x01" in value: + raise FixCodecError(f"SOH (0x01) is not allowed in non-data field {int(tag)}") + if fields[0][0] != Tag.MSG_TYPE: + raise FixCodecError( + f"first encoded field must be MsgType (35), got tag {int(fields[0][0])}" + ) + + body = b"".join( + f"{int(tag)}=".encode("ascii") + value.encode("latin-1") + SOH for tag, value in fields + ) + header = ( + b"8=" + begin_string.encode("ascii") + SOH + b"9=" + str(len(body)).encode("ascii") + SOH + ) + without_checksum = header + body + checksum = sum(without_checksum) % 256 + return without_checksum + b"10=" + f"{checksum:03d}".encode("ascii") + SOH + + +def _parse_fields(data: bytes) -> list[tuple[int, str]]: + """Parse ``tag=value\\x01`` pairs from ``data`` (no trailing CheckSum field). + + Honors :data:`kalshi.fix.tags.DATA_LENGTH_FIELDS`: when a length field is + seen, the immediately following data field's value is read by exact byte + count rather than by scanning for the next SOH. + """ + pairs: list[tuple[int, str]] = [] + i = 0 + n = len(data) + expected_data_tag: int | None = None + expected_data_len = 0 + while i < n: + eq = data.find(b"=", i) + if eq == -1: + raise FixCodecError("field without '=' separator", raw=data[i : i + 64]) + try: + tag = int(data[i:eq]) + except ValueError: + raise FixCodecError(f"non-integer tag {data[i:eq]!r}", raw=data[i : i + 64]) from None + vstart = eq + 1 + if expected_data_tag is not None: + # A length field MUST be immediately followed by its data field; a + # mismatch would let arbitrary tag=value pairs be smuggled inside the + # declared byte length while keeping BodyLength/CheckSum valid. + if tag != expected_data_tag: + raise FixCodecError( + f"data field tag {expected_data_tag} must immediately follow its length " + f"field, got tag {tag}", + raw=data[i : i + 64], + ) + vend = vstart + expected_data_len + if vend > n or data[vend : vend + 1] != SOH: + raise FixCodecError( + f"data field {tag} length {expected_data_len} overruns message", + raw=data[i : i + 64], + ) + value = data[vstart:vend] + soh = vend + expected_data_tag = None + else: + soh = data.find(SOH, vstart) + if soh == -1: + raise FixCodecError(f"field {tag} not SOH-terminated", raw=data[i : i + 64]) + value = data[vstart:soh] + pairs.append((tag, value.decode("latin-1"))) + if tag in DATA_LENGTH_FIELDS: + try: + expected_data_len = int(value) + except ValueError: + raise FixCodecError( + f"length field {tag} has non-integer value {value!r}", + raw=data[i : i + 64], + ) from None + expected_data_tag = DATA_LENGTH_FIELDS[tag] + i = soh + 1 + return pairs + + +def decode(data: bytes) -> RawMessage: + """Decode one complete FIX message's bytes into a :class:`RawMessage`. + + ``data`` must be exactly one frame: ``8=...`` through the SOH that + terminates the CheckSum field. Validates that the message begins with + ``BeginString``, that ``BodyLength`` matches the bytes on the wire, and that + the ``CheckSum`` is correct. + """ + if len(data) < _CHECKSUM_FIELD_LEN or not data.startswith(b"8="): + raise FixCodecError("message does not begin with BeginString (tag 8)", raw=data[:64]) + if not data.endswith(SOH): + raise FixCodecError("message not SOH-terminated", raw=data[-64:]) + + checksum_start = len(data) - _CHECKSUM_FIELD_LEN + if data[checksum_start : checksum_start + 3] != b"10=": + raise FixCodecError("message does not end with CheckSum (tag 10)", raw=data[-64:]) + try: + declared_checksum = int(data[checksum_start + 3 : checksum_start + 6]) + except ValueError: + raise FixCodecError("CheckSum value is not a 3-digit integer", raw=data[-64:]) from None + computed = sum(data[:checksum_start]) % 256 + if computed != declared_checksum: + raise FixCodecError( + f"CheckSum mismatch: declared {declared_checksum:03d}, computed {computed:03d}", + raw=data[:64], + ) + + # Validate BodyLength against the actual framed body (tags 8 and 9 are always + # the first two fields, so the body starts after the second SOH). + soh1 = data.find(SOH) + soh2 = data.find(SOH, soh1 + 1) + if soh1 == -1 or soh2 == -1 or data[soh1 + 1 : soh1 + 3] != b"9=": + raise FixCodecError("BodyLength (tag 9) must immediately follow BeginString", raw=data[:64]) + try: + declared_body_len = int(data[soh1 + 3 : soh2]) + except ValueError: + raise FixCodecError("BodyLength value is not an integer", raw=data[:64]) from None + actual_body_len = checksum_start - (soh2 + 1) + if declared_body_len != actual_body_len: + raise FixCodecError( + f"BodyLength mismatch: declared {declared_body_len}, actual {actual_body_len}", + raw=data[:64], + ) + + pairs = _parse_fields(data[:checksum_start]) + pairs.append((int(Tag.CHECK_SUM), f"{declared_checksum:03d}")) + return RawMessage(pairs) + + +class RawMessage: + """An ordered, decoded FIX message as ``(tag, value)`` string pairs. + + The low-level wire representation used by the codec and the session state + machine. Typed Pydantic models (:mod:`kalshi.fix.messages`) are built from / + rendered to this. Tag lookups are linear scans — FIX messages are small. + """ + + __slots__ = ("pairs",) + + def __init__(self, pairs: list[tuple[int, str]]) -> None: + self.pairs = pairs + + def get(self, tag: int) -> str | None: + """First value for ``tag``, or ``None``.""" + for t, v in self.pairs: + if t == tag: + return v + return None + + def get_all(self, tag: int) -> list[str]: + """All values for ``tag`` in order (repeating-group fields).""" + return [v for t, v in self.pairs if t == tag] + + def get_int(self, tag: int) -> int | None: + """First value for ``tag`` parsed as ``int``, or ``None`` if absent. + + Raises :class:`FixCodecError` if present but not an integer. + """ + v = self.get(tag) + if v is None: + return None + try: + return int(v) + except ValueError: + raise FixCodecError(f"tag {tag} value {v!r} is not an integer") from None + + @property + def msg_type(self) -> str | None: + """The MsgType (tag 35) value.""" + return self.get(Tag.MSG_TYPE) + + @property + def seq_num(self) -> int | None: + """The MsgSeqNum (tag 34) value.""" + return self.get_int(Tag.MSG_SEQ_NUM) + + def __repr__(self) -> str: + rendered = " ".join( + f"{t}={'' if t in _REDACTED_TAGS else v}" for t, v in self.pairs + ) + return f"RawMessage({rendered})" + + +class FixParser: + """Incremental FIX frame extractor over a byte stream. + + Feed raw socket reads with :meth:`append`; pull complete messages with + :meth:`get_message` (one at a time) or :meth:`messages` (all currently + available). Partial frames stay buffered until the rest arrives. Framing is + by ``BodyLength`` byte count, so a value containing SOH never splits a frame. + """ + + __slots__ = ("_buf", "_expected_header") + + def __init__(self, begin_string: str = BEGIN_STRING_FIXT11) -> None: + self._buf = bytearray() + # The exact bytes a real frame must start with, e.g. b"8=FIXT.1.1\x01". + # Used both to resync past a false "8=" inside junk and to know where + # the BodyLength field begins. + self._expected_header = b"8=" + begin_string.encode("ascii") + SOH + + def append(self, data: bytes) -> None: + """Add bytes read from the socket to the parse buffer.""" + self._buf.extend(data) + + def get_message(self) -> RawMessage | None: + """Extract and return the next complete message, or ``None`` if incomplete. + + Resynchronizes across junk: a ``8=`` that is not a genuine BeginString + (its value is not the expected ``BeginString``) is skipped rather than + treated as a frame, so a stray ``8=`` in inter-frame noise cannot abort + the session or consume a valid frame that follows. Every code path that + raises first advances the buffer past the offending marker, so a caught + :class:`FixCodecError` cannot make the read loop spin on the same bytes. + """ + buf = self._buf + expected = self._expected_header + hlen = len(expected) + while True: + start = buf.find(b"8=") + if start == -1: + # No BeginString marker yet. Keep a trailing byte in case "8=" + # is split across two reads. + if len(buf) > 1: + del buf[:-1] + return None + if start > 0: + del buf[: start] + + n = len(buf) + if n < hlen: + # Incomplete: wait only if what we have is a prefix of the real + # header; otherwise this "8=" is a false marker — skip it. + if expected.startswith(bytes(buf)): + return None + del buf[:2] + continue + if not buf.startswith(expected): + # A "8=" whose BeginString value isn't ours — junk; resync. + del buf[:2] + continue + + # Genuine BeginString. BodyLength ("9=\x01") follows it. + if n < hlen + 2: + return None # wait for the "9=" marker + if bytes(buf[hlen : hlen + 2]) != b"9=": + del buf[:2] # advance before raising (no busy-loop) + raise FixCodecError( + "BodyLength (tag 9) must immediately follow BeginString", + raw=bytes(buf[:64]), + ) + soh2 = buf.find(SOH, hlen + 2) + if soh2 == -1: + if n - (hlen + 2) > _MAX_BODYLEN_DIGITS: + del buf[:2] + raise FixCodecError( + "BodyLength field too long / not SOH-terminated", raw=bytes(buf[:64]) + ) + return None # wait for the SOH terminating BodyLength + try: + body_len = int(buf[hlen + 2 : soh2]) + except ValueError: + del buf[:2] + raise FixCodecError( + "BodyLength value is not an integer", raw=bytes(buf[:64]) + ) from None + if body_len < 0 or body_len > MAX_BODY_LENGTH: + del buf[:2] + raise FixCodecError(f"implausible BodyLength {body_len}", raw=bytes(buf[:64])) + + end = soh2 + 1 + body_len + _CHECKSUM_FIELD_LEN + if n < end: + return None # frame not fully arrived yet + + msg_bytes = bytes(buf[:end]) + try: + msg = decode(msg_bytes) + except FixCodecError: + # Corrupt frame (CheckSum / BodyLength inconsistency). A corrupted + # BodyLength could have made `end` over-reach into the next frame, + # so do NOT consume the whole span — skip past this BeginString + # marker and resync, preserving any valid frame that follows. + logger.warning("discarding corrupt FIX frame; resyncing") + del buf[:2] + continue + del buf[:end] + return msg + + def messages(self) -> list[RawMessage]: + """Drain and return all currently-complete messages.""" + out: list[RawMessage] = [] + while True: + msg = self.get_message() + if msg is None: + return out + out.append(msg) diff --git a/kalshi/fix/config.py b/kalshi/fix/config.py new file mode 100644 index 00000000..8c9bb508 --- /dev/null +++ b/kalshi/fix/config.py @@ -0,0 +1,293 @@ +"""Connectivity configuration for the Kalshi FIX gateway. + +Resolves the TCP host + port and session parameters for a given product +(prediction vs margin), environment (production vs demo), and session type +(NR/RT/DC/PT/RFQ/MD). Mirrors the security posture of +:class:`kalshi.config.KalshiConfig`: TLS is required to non-loopback hosts, and +a host override outside the known Kalshi FIX endpoints must be opted into. + +Endpoint tables come from docs.kalshi.com/fix/connectivity and +docs.kalshi.com/fix-margin/connectivity (see GH #402 / the spec-facts memo). +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from enum import StrEnum + +# Production prediction market-data is served from the order-entry host in the +# published table; demo splits it onto a dedicated marketdata host. Margin +# production likewise co-locates MD with order entry, while demo splits it. + + +class FixEnvironment(StrEnum): + """Kalshi FIX environment.""" + + PRODUCTION = "production" + DEMO = "demo" + + +class FixProduct(StrEnum): + """Which Kalshi product line the FIX session trades.""" + + PREDICTION = "prediction" + MARGIN = "margin" + + +class FixSessionType(StrEnum): + """FIX session type. The value is the wire ``TargetCompID`` (tag 56).""" + + ORDER_ENTRY_NR = "KalshiNR" + ORDER_ENTRY_RT = "KalshiRT" + DROP_COPY = "KalshiDC" + POST_TRADE = "KalshiPT" + RFQ = "KalshiRFQ" + MARKET_DATA = "KalshiMD" + + +# Port is identical across products and environments. +_SESSION_PORTS: dict[FixSessionType, int] = { + FixSessionType.ORDER_ENTRY_NR: 8228, + FixSessionType.ORDER_ENTRY_RT: 8230, + FixSessionType.DROP_COPY: 8229, + FixSessionType.POST_TRADE: 8231, + FixSessionType.RFQ: 8232, + FixSessionType.MARKET_DATA: 8233, +} + +# Sessions that support message retransmission (ResendRequest / SequenceReset). +# Everything else must logon with ResetSeqNumFlag=Y. PT is prediction-only. +_RETRANSMISSION_SESSIONS: frozenset[FixSessionType] = frozenset( + {FixSessionType.ORDER_ENTRY_RT, FixSessionType.POST_TRADE} +) + +# Allowed session types per product. Margin has no Post-Trade or RFQ sessions. +_PRODUCT_SESSIONS: dict[FixProduct, frozenset[FixSessionType]] = { + FixProduct.PREDICTION: frozenset(FixSessionType), + FixProduct.MARGIN: frozenset( + { + FixSessionType.ORDER_ENTRY_NR, + FixSessionType.ORDER_ENTRY_RT, + FixSessionType.DROP_COPY, + FixSessionType.MARKET_DATA, + } + ), +} + +# (product, environment) -> (order-entry host, market-data host). +_HOSTS: dict[tuple[FixProduct, FixEnvironment], tuple[str, str]] = { + (FixProduct.PREDICTION, FixEnvironment.PRODUCTION): ( + "mm.fix.elections.kalshi.com", + "mm.fix.elections.kalshi.com", + ), + (FixProduct.PREDICTION, FixEnvironment.DEMO): ( + "fix.demo.kalshi.co", + "marketdata.fix.demo.kalshi.co", + ), + (FixProduct.MARGIN, FixEnvironment.PRODUCTION): ( + "margin-fix-api.fix.elections.kalshi.com", + "margin-fix-api.fix.elections.kalshi.com", + ), + (FixProduct.MARGIN, FixEnvironment.DEMO): ( + "margin-fix.demo.kalshi.co", + "margin-marketdata.fix.demo.kalshi.co", + ), +} + +# Every known Kalshi FIX host, for validating a host override. +_KNOWN_FIX_HOSTS: frozenset[str] = frozenset( + host for pair in _HOSTS.values() for host in pair +) +_LOCAL_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1"}) + +# Default heartbeat interval (seconds). Server requires > 3. +DEFAULT_HEARTBEAT_INTERVAL = 30 +DEFAULT_CONNECT_TIMEOUT = 10.0 +DEFAULT_MAX_RETRIES = 10 + + +@dataclass(frozen=True) +class FixConfig: + """FIX connectivity + session configuration. + + A single config describes a product/environment plus tuning; the concrete + host/port are resolved per :class:`FixSessionType` (one TCP connection per + session, one connection per API key). ``host`` / ``port`` overrides target a + mock server or a local TLS proxy (stunnel) and, when set, apply to every + session. + + Use :meth:`prediction` / :meth:`margin` for the canonical environments. + """ + + product: FixProduct = FixProduct.PREDICTION + environment: FixEnvironment = FixEnvironment.PRODUCTION + host: str | None = None + port: int | None = None + use_tls: bool = True + heartbeat_interval: int = DEFAULT_HEARTBEAT_INTERVAL + connect_timeout: float = DEFAULT_CONNECT_TIMEOUT + max_retries: int = DEFAULT_MAX_RETRIES + retry_base_delay: float = 0.5 + retry_max_delay: float = 30.0 + cancel_orders_on_disconnect: bool = False + # ``None`` derives from the product: margin always uses fixed-point dollars, + # prediction defaults to integer cents (opt into dollars by setting True). + use_dollars: bool | None = None + allow_unknown_host: bool = False + + def __post_init__(self) -> None: + # Prediction requires HeartBtInt > 3; margin requires >= 3. + min_hb = 4 if self.product is FixProduct.PREDICTION else 3 + if self.heartbeat_interval < min_hb: + raise ValueError( + f"FixConfig.heartbeat_interval must be >= {min_hb} for the " + f"{self.product.value} FIX gateway (server requirement), " + f"got {self.heartbeat_interval}" + ) + if self.port is not None and not (0 < self.port < 65536): + raise ValueError(f"FixConfig.port must be 1..65535, got {self.port}") + + allow_unknown = self.allow_unknown_host or ( + os.environ.get("KALSHI_FIX_ALLOW_UNKNOWN_HOST", "").strip() == "1" + ) + if self.host is not None: + host = self.host.lower() + is_local = host in _LOCAL_HOSTS + if not self.use_tls and not is_local: + raise ValueError( + f"FixConfig.use_tls=False is only allowed for loopback hosts " + f"{sorted(_LOCAL_HOSTS)}; plaintext FIX to a remote host " + f"({self.host!r}) would expose the logon signature." + ) + if host not in _KNOWN_FIX_HOSTS and not is_local and not allow_unknown: + raise ValueError( + f"FixConfig.host {self.host!r} is not a known Kalshi FIX endpoint. " + f"Known hosts: {sorted(_KNOWN_FIX_HOSTS)}. If this is an intentional " + "mock server or TLS proxy, opt in with FixConfig(allow_unknown_host=True) " + "or set KALSHI_FIX_ALLOW_UNKNOWN_HOST=1." + ) + elif not self.use_tls: + # No host override: every session resolves to a real Kalshi FIX + # gateway, which mandates TLS 1.2+. Plaintext there would expose the + # logon signature. use_tls=False is only for a loopback host override. + raise ValueError( + "FixConfig.use_tls=False is only allowed with a loopback host override " + "(e.g. host='127.0.0.1' for a mock server or local TLS proxy); the Kalshi " + "FIX gateways require TLS 1.2+ (plaintext would expose the logon signature)." + ) + + @classmethod + def prediction( + cls, + environment: FixEnvironment = FixEnvironment.PRODUCTION, + *, + host: str | None = None, + port: int | None = None, + use_tls: bool = True, + heartbeat_interval: int = DEFAULT_HEARTBEAT_INTERVAL, + connect_timeout: float = DEFAULT_CONNECT_TIMEOUT, + max_retries: int = DEFAULT_MAX_RETRIES, + retry_base_delay: float = 0.5, + retry_max_delay: float = 30.0, + cancel_orders_on_disconnect: bool = False, + use_dollars: bool | None = None, + allow_unknown_host: bool = False, + ) -> FixConfig: + """Config for the prediction (event-contract) FIX gateway.""" + return cls( + product=FixProduct.PREDICTION, + environment=environment, + host=host, + port=port, + use_tls=use_tls, + heartbeat_interval=heartbeat_interval, + connect_timeout=connect_timeout, + max_retries=max_retries, + retry_base_delay=retry_base_delay, + retry_max_delay=retry_max_delay, + cancel_orders_on_disconnect=cancel_orders_on_disconnect, + use_dollars=use_dollars, + allow_unknown_host=allow_unknown_host, + ) + + @classmethod + def margin( + cls, + environment: FixEnvironment = FixEnvironment.PRODUCTION, + *, + host: str | None = None, + port: int | None = None, + use_tls: bool = True, + heartbeat_interval: int = DEFAULT_HEARTBEAT_INTERVAL, + connect_timeout: float = DEFAULT_CONNECT_TIMEOUT, + max_retries: int = DEFAULT_MAX_RETRIES, + retry_base_delay: float = 0.5, + retry_max_delay: float = 30.0, + cancel_orders_on_disconnect: bool = False, + use_dollars: bool = True, + allow_unknown_host: bool = False, + ) -> FixConfig: + """Config for the margin (perps) FIX gateway (fixed-point dollars enforced).""" + return cls( + product=FixProduct.MARGIN, + environment=environment, + host=host, + port=port, + use_tls=use_tls, + heartbeat_interval=heartbeat_interval, + connect_timeout=connect_timeout, + max_retries=max_retries, + retry_base_delay=retry_base_delay, + retry_max_delay=retry_max_delay, + cancel_orders_on_disconnect=cancel_orders_on_disconnect, + use_dollars=use_dollars, + allow_unknown_host=allow_unknown_host, + ) + + @property + def allowed_sessions(self) -> frozenset[FixSessionType]: + """Session types valid for this product.""" + return _PRODUCT_SESSIONS[self.product] + + @property + def effective_use_dollars(self) -> bool: + """Resolved ``UseDollars`` flag: margin is always on; prediction opt-in.""" + if self.product is FixProduct.MARGIN: + return True + return bool(self.use_dollars) + + def _check_session(self, session: FixSessionType) -> None: + if session not in self.allowed_sessions: + raise ValueError( + f"session {session.value!r} is not available for product " + f"{self.product.value!r}. Allowed: " + f"{sorted(s.value for s in self.allowed_sessions)}" + ) + + def host_for(self, session: FixSessionType) -> str: + """Resolve the TCP host for ``session`` (honoring a ``host`` override).""" + self._check_session(session) + if self.host is not None: + return self.host + oe_host, md_host = _HOSTS[(self.product, self.environment)] + return md_host if session is FixSessionType.MARKET_DATA else oe_host + + def port_for(self, session: FixSessionType) -> int: + """Resolve the TCP port for ``session`` (honoring a ``port`` override).""" + self._check_session(session) + if self.port is not None: + return self.port + return _SESSION_PORTS[session] + + def target_comp_id(self, session: FixSessionType) -> str: + """The wire ``TargetCompID`` (tag 56) for ``session``.""" + return session.value + + def supports_retransmission(self, session: FixSessionType) -> bool: + """Whether ``session`` supports ResendRequest/SequenceReset recovery. + + When ``False`` the session must logon with ``ResetSeqNumFlag=Y`` and a + forward sequence gap is unrecoverable. + """ + return session in _RETRANSMISSION_SESSIONS diff --git a/kalshi/fix/connection.py b/kalshi/fix/connection.py new file mode 100644 index 00000000..9e4f4425 --- /dev/null +++ b/kalshi/fix/connection.py @@ -0,0 +1,115 @@ +"""Low-level async TCP/TLS transport for FIX, with frame reassembly. + +Wraps :func:`asyncio.open_connection` and a :class:`~kalshi.fix.codec.FixParser` +so callers read whole :class:`~kalshi.fix.codec.RawMessage` frames and write raw +bytes. TLS is on by default (Kalshi requires TLS 1.2+); ``use_tls=False`` is for +loopback mock servers in tests. The session state machine +(:mod:`kalshi.fix.session`) layers logon/heartbeat/sequencing on top. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import ssl + +from kalshi.fix.codec import FixParser, RawMessage +from kalshi.fix.errors import FixConnectionError + +logger = logging.getLogger("kalshi.fix") + +_READ_CHUNK = 65536 + + +class FixConnection: + """A single FIX TCP/TLS connection with incremental frame reassembly.""" + + def __init__( + self, + host: str, + port: int, + *, + use_tls: bool = True, + connect_timeout: float = 10.0, + ssl_context: ssl.SSLContext | None = None, + ) -> None: + self._host = host + self._port = port + self._use_tls = use_tls + self._connect_timeout = connect_timeout + self._ssl_context = ssl_context + self._reader: asyncio.StreamReader | None = None + self._writer: asyncio.StreamWriter | None = None + self._parser = FixParser() + + @property + def is_open(self) -> bool: + """True when the writer exists and is not closing.""" + return self._writer is not None and not self._writer.is_closing() + + async def open(self) -> None: + """Establish the TCP (+TLS) connection. + + Raises: + FixConnectionError: on connect timeout, refusal, DNS, or TLS failure. + """ + ssl_arg: ssl.SSLContext | None + if self._use_tls: + ssl_arg = ( + self._ssl_context if self._ssl_context is not None else ssl.create_default_context() + ) + else: + ssl_arg = None + try: + self._reader, self._writer = await asyncio.wait_for( + asyncio.open_connection(self._host, self._port, ssl=ssl_arg), + timeout=self._connect_timeout, + ) + except (OSError, ssl.SSLError, TimeoutError) as e: + # Don't interpolate the raw exception (it can carry the full address); + # the cause is preserved via __cause__. + raise FixConnectionError( + f"FIX connect failed to {self._host}:{self._port}" + ) from e + + async def send_bytes(self, data: bytes) -> None: + """Write a complete encoded message to the socket and flush it.""" + if self._writer is None or self._writer.is_closing(): + raise FixConnectionError("cannot send on a closed FIX connection") + try: + self._writer.write(data) + await self._writer.drain() + except (OSError, ssl.SSLError) as e: + raise FixConnectionError("FIX send failed") from e + + async def read_message(self) -> RawMessage: + """Return the next complete message, reading from the socket as needed. + + Raises: + FixConnectionError: on EOF (peer closed) or a transport error. + FixCodecError: if the bytes received are malformed framing. + """ + if self._reader is None: + raise FixConnectionError("cannot read on an unopened FIX connection") + while True: + msg = self._parser.get_message() + if msg is not None: + return msg + try: + chunk = await self._reader.read(_READ_CHUNK) + except (OSError, ssl.SSLError) as e: + raise FixConnectionError("FIX read failed") from e + if not chunk: + raise FixConnectionError("FIX connection closed by peer (EOF)") + self._parser.append(chunk) + + async def close(self) -> None: + """Close the connection. Idempotent and never raises.""" + writer = self._writer + self._writer = None + self._reader = None + if writer is not None and not writer.is_closing(): + writer.close() + with contextlib.suppress(Exception): + await asyncio.wait_for(writer.wait_closed(), timeout=2.0) diff --git a/kalshi/fix/enums.py b/kalshi/fix/enums.py new file mode 100644 index 00000000..3f12498d --- /dev/null +++ b/kalshi/fix/enums.py @@ -0,0 +1,248 @@ +"""FIX enumerations for the Kalshi dialect (FIXT.1.1 / FIX50SP2). + +Values are the exact on-the-wire tokens from the Kalshi FIX Dictionary v1.03. +String/char enums subclass :class:`enum.StrEnum` so ``member.value`` *is* the +wire string; integer enums subclass :class:`enum.IntEnum` and serialize via +``str(int(member))``. The codec / message layer converts in both directions. + +Defining the full enum surface up front (including order-entry and RFQ values +not exercised by the foundation layer) keeps a single spec-aligned source of +truth for the later message-flow phases. +""" + +from __future__ import annotations + +from enum import IntEnum, StrEnum + + +class MsgType(StrEnum): + """Tag 35 — message type. Admin (session) + application message identifiers.""" + + # Session / admin + HEARTBEAT = "0" + TEST_REQUEST = "1" + RESEND_REQUEST = "2" + REJECT = "3" + SEQUENCE_RESET = "4" + LOGOUT = "5" + LOGON = "A" + # Order entry + NEW_ORDER_SINGLE = "D" + ORDER_CANCEL_REQUEST = "F" + ORDER_CANCEL_REPLACE_REQUEST = "G" + EXECUTION_REPORT = "8" + ORDER_CANCEL_REJECT = "9" + ORDER_MASS_CANCEL_REQUEST = "q" + ORDER_MASS_CANCEL_REPORT = "r" + BUSINESS_MESSAGE_REJECT = "j" + # Order groups + ORDER_GROUP_REQUEST = "UOG" + ORDER_GROUP_RESPONSE = "UOH" + # Drop copy (event resend) + EVENT_RESEND_REQUEST = "U1" + EVENT_RESEND_COMPLETE = "U2" + EVENT_RESEND_REJECT = "U3" + # Market data + MARKET_DATA_REQUEST = "V" + MARKET_DATA_SNAPSHOT_FULL_REFRESH = "W" + MARKET_DATA_INCREMENTAL_REFRESH = "X" + MARKET_DATA_REQUEST_REJECT = "Y" + SECURITY_STATUS_REQUEST = "e" + SECURITY_STATUS = "f" + # Post trade + MARKET_SETTLEMENT_REPORT = "UMS" + # RFQ / quoting + QUOTE_REQUEST = "R" + QUOTE_REQUEST_ACK = "b" + QUOTE = "S" + QUOTE_CANCEL = "Z" + QUOTE_STATUS_REPORT = "AI" + QUOTE_REQUEST_REJECT = "AG" + QUOTE_CONFIRM = "U7" + QUOTE_CONFIRM_STATUS = "U8" + QUOTE_CANCEL_STATUS = "U9" + ACCEPT_QUOTE = "UA" + RFQ_CANCEL_STATUS = "UB" + ACCEPT_QUOTE_STATUS = "UC" + RFQ_CANCEL = "UE" + + +# Admin message types are handled by the session state machine itself; everything +# else is an application message routed to consumers. +ADMIN_MSG_TYPES: frozenset[MsgType] = frozenset( + { + MsgType.HEARTBEAT, + MsgType.TEST_REQUEST, + MsgType.RESEND_REQUEST, + MsgType.REJECT, + MsgType.SEQUENCE_RESET, + MsgType.LOGOUT, + MsgType.LOGON, + } +) + + +class EncryptMethod(IntEnum): + """Tag 98 — Kalshi FIX uses transport TLS, so message-level encryption is None.""" + + NONE = 0 + + +class ApplVerID(StrEnum): + """Tags 1128 / 1137 — application version. Kalshi is FIX50SP2.""" + + FIX50SP2 = "9" + + +class Side(StrEnum): + """Tag 54 — order side in Kalshi's yes/no contract vocabulary.""" + + BUY_YES = "1" + SELL_NO = "2" + + +class OrdType(StrEnum): + """Tag 40 — Kalshi supports limit orders only.""" + + LIMIT = "2" + + +class TimeInForce(StrEnum): + """Tag 59 — order time in force.""" + + DAY = "0" + GTC = "1" + IOC = "3" + FOK = "4" + GTD = "6" + + +class OrdStatus(StrEnum): + """Tag 39 — order status.""" + + NEW = "0" + PARTIALLY_FILLED = "1" + FILLED = "2" + CANCELED = "4" + PENDING_CANCEL = "6" + REJECTED = "8" + PENDING_NEW = "A" + EXPIRED = "C" + PENDING_REPLACE = "E" + + +class ExecType(StrEnum): + """Tag 150 — execution report type.""" + + NEW = "0" + CANCELED = "4" + REPLACED = "5" + PENDING_CANCEL = "6" + REJECTED = "8" + PENDING_NEW = "A" + EXPIRED = "C" + PENDING_REPLACE = "E" + TRADE = "F" + + +class ExecInst(StrEnum): + """Tag 18 — execution instructions (multi-value string; Kalshi uses POST_ONLY).""" + + POST_ONLY = "6" + + +class SelfTradePreventionType(IntEnum): + """Tag 2964 — self-trade prevention behaviour.""" + + UNKNOWN = 0 + TAKER_AT_CROSS = 1 + MAKER = 2 + + +class SessionRejectReason(IntEnum): + """Tag 373 — reason a session-level Reject (35=3) was issued.""" + + INVALID_TAG_NUMBER = 0 + REQUIRED_TAG_MISSING = 1 + TAG_NOT_DEFINED_FOR_MESSAGE = 2 + UNDEFINED_TAG = 3 + TAG_SPECIFIED_WITHOUT_VALUE = 4 + VALUE_INCORRECT = 5 + INCORRECT_DATA_FORMAT = 6 + DECRYPTION_PROBLEM = 7 + SIGNATURE_PROBLEM = 8 + COMPID_PROBLEM = 9 + SENDINGTIME_ACCURACY_PROBLEM = 10 + INVALID_MSGTYPE = 11 + XML_VALIDATION_ERROR = 12 + TAG_APPEARS_MORE_THAN_ONCE = 13 + TAG_SPECIFIED_OUT_OF_REQUIRED_ORDER = 14 + REPEATING_GROUP_FIELDS_OUT_OF_ORDER = 15 + INCORRECT_NUMINGROUP_COUNT_FOR_REPEATING_GROUP = 16 + NON_DATA_VALUE_INCLUDES_FIELD_DELIMITER = 17 + INVALID_UNSUPPORTED_APPLICATION_VERSION = 18 + OTHER = 99 + + +class BusinessRejectReason(IntEnum): + """Tag 380 — reason a BusinessMessageReject (35=j) was issued.""" + + OTHER = 0 + UNKNOWN_ID = 1 + UNKNOWN_SECURITY = 2 + UNSUPPORTED_MESSAGE_TYPE = 3 + APPLICATION_NOT_AVAILABLE = 4 + CONDITIONALLY_REQUIRED_FIELD_MISSING = 5 + NOT_AUTHORIZED = 6 + RATE_LIMIT_EXCEEDED = 8 + + +class CxlRejReason(IntEnum): + """Tag 102 — order cancel/replace rejection reason.""" + + TOO_LATE_TO_CANCEL = 0 + UNKNOWN_ORDER = 1 + BROKER = 2 + INVALID_PRICE_INCREMENT = 18 + OTHER = 99 + + +class CxlRejResponseTo(StrEnum): + """Tag 434 — which request an OrderCancelReject (35=9) responds to.""" + + ORDER_CANCEL_REQUEST = "1" + ORDER_CANCEL_REPLACE_REQUEST = "2" + + +class OrdRejReason(IntEnum): + """Tag 103 — order rejection reason.""" + + UNKNOWN_SYMBOL = 1 + EXCHANGE_CLOSED = 2 + ORDER_EXCEEDS_LIMIT = 3 + TOO_LATE_TO_ENTER = 4 + DUPLICATE_ORDER = 6 + STALE_ORDER = 8 + UNSUPPORTED_ORDER_CHARACTERISTIC = 11 + INCORRECT_QUANTITY = 13 + UNKNOWN_ACCOUNT = 15 + OTHER = 99 + + +class MassCancelRequestType(StrEnum): + """Tag 530 — scope of an OrderMassCancelRequest (35=q).""" + + CANCEL_FOR_SESSION = "6" + + +class MassCancelResponse(StrEnum): + """Tag 531 — result of an OrderMassCancelRequest.""" + + REJECTED = "0" + ALL_ORDERS_CANCELLED = "6" + + +class PartyRole(IntEnum): + """Tag 452 — role of a party in the NoPartyIDs group.""" + + CUSTOMER_ACCOUNT = 24 diff --git a/kalshi/fix/errors.py b/kalshi/fix/errors.py new file mode 100644 index 00000000..b7f40eb9 --- /dev/null +++ b/kalshi/fix/errors.py @@ -0,0 +1,121 @@ +"""Exception hierarchy for the Kalshi FIX subsystem. + +Mirrors the WebSocket error pattern in :mod:`kalshi.errors`: a single +``KalshiFixError`` base (extending the SDK-wide :class:`kalshi.errors.KalshiError` +so a caller can ``except KalshiError`` across REST / WS / FIX) with subclasses +that carry structured context fields rather than forcing callers to parse +message strings. +""" + +from __future__ import annotations + +from kalshi.errors import KalshiError + + +class KalshiFixError(KalshiError): + """Base exception for all Kalshi FIX errors. + + FIX is a TCP/TLS protocol with no HTTP status, so ``status_code`` is always + ``None`` (the field exists on :class:`kalshi.errors.KalshiError` for the + REST transport and is kept for cross-surface ``except KalshiError`` use). + """ + + def __init__(self, message: str) -> None: + super().__init__(message, status_code=None) + + +class FixConnectionError(KalshiFixError): + """TCP/TLS connection failed, was refused, or max reconnect attempts exceeded. + + The original transport exception (``OSError`` / ``ssl.SSLError`` / + ``asyncio.TimeoutError``) is chained via ``__cause__``. + """ + + +class FixCodecError(KalshiFixError): + """A FIX frame could not be encoded or decoded. + + Raised on malformed framing — missing ``BeginString`` / ``BodyLength`` / + ``CheckSum``, a ``BodyLength`` that disagrees with the bytes on the wire, a + ``CheckSum`` mismatch, or a field that is not ``tag=value``. ``raw`` carries + the offending bytes (truncated) for debugging when available. + """ + + def __init__(self, message: str, *, raw: bytes | None = None) -> None: + self.raw = raw + super().__init__(message) + + +class FixLogonError(KalshiFixError): + """Logon (35=A) was rejected by the acceptor. + + The acceptor answers a failed Logon with a Logout (35=5) carrying a + human-readable ``Text`` (58); that text is surfaced in ``reason`` when + present. Common causes: bad RawData signature, SendingTime outside the 30s + skew window (``SessionRejectReason=10``), unknown CompID, or a missing + ``ResetSeqNumFlag=Y`` on a non-retransmission session. + """ + + def __init__(self, message: str, *, reason: str | None = None) -> None: + self.reason = reason + super().__init__(message) + + +class FixSessionError(KalshiFixError): + """A session-level protocol violation or unexpected lifecycle event. + + Covers an unexpected Logout, a liveness failure (no Heartbeat/TestRequest + response within the interval), or an inbound message that breaks the session + state machine. + """ + + +class FixSequenceError(KalshiFixError): + """An unrecoverable sequence-number condition. + + Two cases, both fatal to the session: + + * The acceptor's ``MsgSeqNum`` is *lower* than expected (per spec the + connection is terminated — a lower number means the peer's view of the + session is corrupt). + * A forward gap was detected on a session that does not support + retransmission (``KalshiNR`` / ``KalshiDC``), so it cannot be recovered + via ``ResendRequest``. + """ + + def __init__( + self, + message: str, + *, + expected: int | None = None, + received: int | None = None, + ) -> None: + self.expected = expected + self.received = received + super().__init__(message) + + +class FixRejectError(KalshiFixError): + """The acceptor rejected a message we sent. + + Raised for an inbound session-level Reject (35=3) or BusinessMessageReject + (35=j). Carries the structured reject fields so callers can route on them + without parsing ``Text``. + """ + + def __init__( + self, + message: str, + *, + ref_seq_num: int | None = None, + ref_tag_id: int | None = None, + ref_msg_type: str | None = None, + reject_reason: int | None = None, + text: str | None = None, + ) -> None: + self.ref_seq_num = ref_seq_num + self.ref_tag_id = ref_tag_id + self.ref_msg_type = ref_msg_type + self.reject_reason = reject_reason + self.text = text + super().__init__(message) diff --git a/kalshi/fix/messages/__init__.py b/kalshi/fix/messages/__init__.py new file mode 100644 index 00000000..ef73f76b --- /dev/null +++ b/kalshi/fix/messages/__init__.py @@ -0,0 +1,32 @@ +"""Typed FIX message models (FIX Dictionary v1.03). + +Foundation phase exposes the session-layer (admin) messages plus the base +framework. Application messages (order entry, drop copy, market data, +RFQ/settlement) are added in later phases — see GH #402. +""" + +from __future__ import annotations + +from kalshi.fix.messages.base import FixMessage, FixType, fixfield +from kalshi.fix.messages.session import ( + Heartbeat, + Logon, + Logout, + Reject, + ResendRequest, + SequenceReset, + TestRequest, +) + +__all__ = [ + "FixMessage", + "FixType", + "Heartbeat", + "Logon", + "Logout", + "Reject", + "ResendRequest", + "SequenceReset", + "TestRequest", + "fixfield", +] diff --git a/kalshi/fix/messages/base.py b/kalshi/fix/messages/base.py new file mode 100644 index 00000000..5880d829 --- /dev/null +++ b/kalshi/fix/messages/base.py @@ -0,0 +1,183 @@ +"""Typed FIX message framework: Pydantic models that round-trip to wire fields. + +Each message subclasses :class:`FixMessage` and declares its body fields with +:func:`fixfield`, attaching the FIX tag number and wire type to the Pydantic +field via ``json_schema_extra``. The base then drives: + +* :meth:`FixMessage.to_body_fields` — ordered ``(tag, value)`` pairs for the + message body (everything after the standard header), with length-prefixed + data fields (``RawData``/``Signature``) auto-emitting their length field. +* :meth:`FixMessage.from_raw` — build a typed model from a decoded + :class:`~kalshi.fix.codec.RawMessage`, reading only the tags it declares + (unknown/header tags are ignored, so inbound messages with extra fields + parse cleanly). + +The standard header (``SenderCompID``/``TargetCompID``/``MsgSeqNum``/ +``SendingTime``) and ``MsgType`` are owned by the session/connection layer, not +the message body — see :mod:`kalshi.fix.session`. + +Scope note: scalar fields only. Repeating groups (``NoPartyIDs``, ``NoMiscFees``, +…) land with the order-entry / settlement message phases; see GH #402. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal +from enum import StrEnum +from typing import Any, ClassVar, Self + +from pydantic import BaseModel, ConfigDict, Field + +from kalshi.fix.codec import RawMessage +from kalshi.fix.enums import MsgType +from kalshi.fix.tags import DATA_LENGTH_FIELDS + +# Reverse of DATA_LENGTH_FIELDS: data_tag -> length_tag. Used by to_body_fields +# to auto-emit the length field immediately before a data field. +_DATA_TO_LENGTH: dict[int, int] = {data: length for length, data in DATA_LENGTH_FIELDS.items()} + + +class FixType(StrEnum): + """Wire type of a FIX field, governing string<->Python conversion.""" + + STRING = "STRING" + CHAR = "CHAR" + MULTIPLEVALUESTRING = "MULTIPLEVALUESTRING" + INT = "INT" + SEQNUM = "SEQNUM" + LENGTH = "LENGTH" + NUMINGROUP = "NUMINGROUP" + BOOLEAN = "BOOLEAN" + PRICE = "PRICE" + QTY = "QTY" + AMT = "AMT" + DECIMAL = "DECIMAL" + CURRENCY = "CURRENCY" + UTCTIMESTAMP = "UTCTIMESTAMP" + LOCALMKTDATE = "LOCALMKTDATE" + DATA = "DATA" + + +_INT_TYPES = frozenset({FixType.INT, FixType.SEQNUM, FixType.LENGTH, FixType.NUMINGROUP}) +_DECIMAL_TYPES = frozenset({FixType.PRICE, FixType.QTY, FixType.AMT, FixType.DECIMAL}) + + +def fixfield(tag: int, fix_type: FixType, *, default: Any = ..., **kwargs: Any) -> Any: + """Declare a FIX-mapped Pydantic field. + + ``tag`` is the FIX tag number, ``fix_type`` its wire type. Omit ``default`` + for a required field (Pydantic treats ``...`` as required). The metadata + rides on ``json_schema_extra`` so the base can introspect tag + type in + declaration order. + """ + extra: dict[str, Any] = {"fix_tag": int(tag), "fix_type": fix_type.value} + return Field(default=default, json_schema_extra=extra, **kwargs) + + +def _to_wire(value: Any, fix_type: FixType) -> str: + """Convert a Python field value to its FIX wire string.""" + if fix_type is FixType.BOOLEAN: + return "Y" if value else "N" + if fix_type in _DECIMAL_TYPES: + # value is a Decimal; ``f"{d:f}"`` avoids scientific notation and float drift. + return f"{value:f}" + if fix_type is FixType.UTCTIMESTAMP: + return _format_utc_timestamp(value) + if fix_type is FixType.LOCALMKTDATE: + return value if isinstance(value, str) else value.strftime("%Y%m%d") + # STRING / CHAR / MULTIPLEVALUESTRING / INT-likes / enums. ``str()`` of a + # StrEnum is its value; of an IntEnum (Py3.11+) its integer — both correct. + return str(value) + + +def _from_wire(raw: str, fix_type: FixType) -> Any: + """Convert a FIX wire string to a Python value Pydantic can validate. + + Enum-typed fields are returned as the underlying ``int``/``str`` and coerced + to the enum by Pydantic at construction. + """ + if fix_type is FixType.BOOLEAN: + if raw == "Y": + return True + if raw == "N": + return False + raise ValueError(f"invalid FIX boolean {raw!r} (expected 'Y' or 'N')") + if fix_type in _INT_TYPES: + return int(raw) + if fix_type in _DECIMAL_TYPES: + return Decimal(raw) + if fix_type is FixType.UTCTIMESTAMP: + return _parse_utc_timestamp(raw) + return raw + + +def _format_utc_timestamp(dt: datetime) -> str: + """FIX UTCTimestamp with millisecond precision: ``YYYYMMDD-HH:MM:SS.sss``.""" + return dt.strftime("%Y%m%d-%H:%M:%S.") + f"{dt.microsecond // 1000:03d}" + + +def _parse_utc_timestamp(value: str) -> datetime: + """Parse a FIX UTCTimestamp (with or without fractional seconds) as UTC-aware.""" + fmt = "%Y%m%d-%H:%M:%S.%f" if "." in value else "%Y%m%d-%H:%M:%S" + return datetime.strptime(value, fmt).replace(tzinfo=UTC) + + +class FixMessage(BaseModel): + """Base for all typed FIX messages. + + Subclasses set the ``MSG_TYPE`` class var and declare body fields with + :func:`fixfield`. ``extra="forbid"`` makes a typo in an outbound message + fail at construction; inbound messages are built via :meth:`from_raw`, which + only passes declared tags, so server-added fields never reach the + constructor. + """ + + MSG_TYPE: ClassVar[MsgType] + + model_config = ConfigDict(extra="forbid") + + @classmethod + def _fix_fields(cls) -> list[tuple[str, int, FixType]]: + """``(attr_name, tag, fix_type)`` for each FIX-mapped field, in declaration order.""" + out: list[tuple[str, int, FixType]] = [] + for name, field in cls.model_fields.items(): + extra = field.json_schema_extra + if isinstance(extra, dict) and "fix_tag" in extra: + tag = int(extra["fix_tag"]) # type: ignore[arg-type] + fix_type = FixType(str(extra["fix_type"])) + out.append((name, tag, fix_type)) + return out + + def to_body_fields(self) -> list[tuple[int, str]]: + """Ordered ``(tag, value)`` body fields (excludes the standard header). + + ``None`` values are omitted. A data field auto-emits its length field + immediately before it. + """ + out: list[tuple[int, str]] = [] + for name, tag, fix_type in self._fix_fields(): + value = getattr(self, name) + if value is None: + continue + wire = _to_wire(value, fix_type) + length_tag = _DATA_TO_LENGTH.get(tag) + if length_tag is not None: + out.append((length_tag, str(len(wire.encode("latin-1"))))) + out.append((tag, wire)) + return out + + @classmethod + def from_raw(cls, raw: RawMessage) -> Self: + """Build a typed message from a decoded :class:`RawMessage`. + + Reads only the tags this model declares; all other fields (header, + trailer, server extensions) are ignored. + """ + kwargs: dict[str, Any] = {} + for name, tag, fix_type in cls._fix_fields(): + value = raw.get(tag) + if value is None: + continue + kwargs[name] = _from_wire(value, fix_type) + return cls(**kwargs) diff --git a/kalshi/fix/messages/session.py b/kalshi/fix/messages/session.py new file mode 100644 index 00000000..351bd658 --- /dev/null +++ b/kalshi/fix/messages/session.py @@ -0,0 +1,110 @@ +"""Typed session-layer (admin) FIX messages. + +These are the messages the :class:`kalshi.fix.session.FixSession` state machine +constructs and consumes directly: Logon, Logout, Heartbeat, TestRequest, +ResendRequest, SequenceReset, and Reject. Application messages (order entry, +market data, …) land in later phases. + +Reason/reject *codes* on inbound messages (e.g. ``session_reject_reason``) are +typed as plain ``int`` rather than enums so an unknown code from the server +parses cleanly instead of raising; compare against :mod:`kalshi.fix.enums` +(e.g. ``SessionRejectReason.SENDINGTIME_ACCURACY_PROBLEM``) at the call site. +""" + +from __future__ import annotations + +from kalshi.fix.enums import ApplVerID, EncryptMethod, MsgType +from kalshi.fix.messages.base import FixMessage, FixType, fixfield + + +class Logon(FixMessage): + """Logon (35=A). + + The ``raw_data`` field carries the base64 RSA-PSS logon signature; the codec + auto-emits its ``RawDataLength`` (95) field. ``heartbeat_interval`` must be + > 3 (Kalshi default 30). ``reset_seq_num_flag`` must be ``True`` on + non-retransmission sessions (KalshiNR / KalshiDC). + """ + + MSG_TYPE = MsgType.LOGON + + encrypt_method: EncryptMethod = fixfield(98, FixType.INT, default=EncryptMethod.NONE) + heartbeat_interval: int = fixfield(108, FixType.INT) + raw_data: str | None = fixfield(96, FixType.DATA, default=None) + reset_seq_num_flag: bool | None = fixfield(141, FixType.BOOLEAN, default=None) + next_expected_msg_seq_num: int | None = fixfield(789, FixType.SEQNUM, default=None) + max_message_size: int | None = fixfield(383, FixType.LENGTH, default=None) + test_message_indicator: bool | None = fixfield(464, FixType.BOOLEAN, default=None) + username: str | None = fixfield(553, FixType.STRING, default=None) + password: str | None = fixfield(554, FixType.STRING, default=None) + default_appl_ver_id: ApplVerID = fixfield(1137, FixType.STRING, default=ApplVerID.FIX50SP2) + # Kalshi custom logon options. + cancel_orders_on_disconnect: bool | None = fixfield(8013, FixType.BOOLEAN, default=None) + skip_pending_exec_reports: bool | None = fixfield(21011, FixType.BOOLEAN, default=None) + use_dollars: bool | None = fixfield(21005, FixType.BOOLEAN, default=None) + cancel_order_on_pause: bool | None = fixfield(21006, FixType.BOOLEAN, default=None) + enable_ioc_cancel_report: bool | None = fixfield(21007, FixType.BOOLEAN, default=None) + listener_session: bool | None = fixfield(20126, FixType.BOOLEAN, default=None) + receive_settlement_reports: bool | None = fixfield(20127, FixType.BOOLEAN, default=None) + message_retention_period: int | None = fixfield(20200, FixType.INT, default=None) + preserve_original_order_qty: bool | None = fixfield(21008, FixType.BOOLEAN, default=None) + use_expired_ord_status: bool | None = fixfield(21012, FixType.BOOLEAN, default=None) + always_emit_new_before_trade: bool | None = fixfield(21026, FixType.BOOLEAN, default=None) + + +class Logout(FixMessage): + """Logout (35=5). ``text`` carries a human-readable reason when present.""" + + MSG_TYPE = MsgType.LOGOUT + + text: str | None = fixfield(58, FixType.STRING, default=None) + + +class Heartbeat(FixMessage): + """Heartbeat (35=0). ``test_req_id`` echoes a TestRequest when responding to one.""" + + MSG_TYPE = MsgType.HEARTBEAT + + test_req_id: str | None = fixfield(112, FixType.STRING, default=None) + + +class TestRequest(FixMessage): + """TestRequest (35=1). The peer must echo ``test_req_id`` in its Heartbeat.""" + + # Stop pytest from collecting this FIX message as a test class when it is + # imported into a test module (its name matches the default ``Test*`` glob). + __test__ = False + + MSG_TYPE = MsgType.TEST_REQUEST + + test_req_id: str = fixfield(112, FixType.STRING) + + +class ResendRequest(FixMessage): + """ResendRequest (35=2). Inclusive range; ``end_seq_no=0`` means "through latest".""" + + MSG_TYPE = MsgType.RESEND_REQUEST + + begin_seq_no: int = fixfield(7, FixType.SEQNUM) + end_seq_no: int = fixfield(16, FixType.SEQNUM) + + +class SequenceReset(FixMessage): + """SequenceReset (35=4). ``gap_fill_flag`` distinguishes gap-fill from reset mode.""" + + MSG_TYPE = MsgType.SEQUENCE_RESET + + gap_fill_flag: bool | None = fixfield(123, FixType.BOOLEAN, default=None) + new_seq_no: int = fixfield(36, FixType.SEQNUM) + + +class Reject(FixMessage): + """Reject (35=3) — session-level rejection of a message we sent.""" + + MSG_TYPE = MsgType.REJECT + + ref_seq_num: int = fixfield(45, FixType.SEQNUM) + ref_tag_id: int | None = fixfield(371, FixType.INT, default=None) + ref_msg_type: str | None = fixfield(372, FixType.STRING, default=None) + session_reject_reason: int | None = fixfield(373, FixType.INT, default=None) + text: str | None = fixfield(58, FixType.STRING, default=None) diff --git a/kalshi/fix/session.py b/kalshi/fix/session.py new file mode 100644 index 00000000..851d939b --- /dev/null +++ b/kalshi/fix/session.py @@ -0,0 +1,683 @@ +"""Async FIX session state machine: logon, liveness, sequencing, recovery. + +:class:`FixSession` owns one FIX session (one TCP connection, one ``TargetCompID``) +and implements the session layer on top of :class:`kalshi.fix.connection.FixConnection`: + +* **Logon** (35=A) with an RSA-PSS ``RawData`` signature; ``ResetSeqNumFlag=Y`` on + non-retransmission sessions (NR/DC/RFQ/MD), continuity on RT/PT reconnect. +* **Liveness** — periodic Heartbeat (35=0), TestRequest (35=1) on inbound silence, + and reconnect when the peer goes quiet. +* **Sequencing** — track expected inbound ``MsgSeqNum``; on a forward gap buffer the + out-of-order messages and send a ResendRequest (retransmission sessions) or fail + (non-retransmission). Honor inbound SequenceReset/gap-fill and drop duplicates. +* **Recovery** — AWS full-jitter reconnect backoff (matching the WS/REST transports). + +Application messages are delivered to an ``on_message`` callback as decoded +:class:`~kalshi.fix.codec.RawMessage`; the order-entry / market-data phases build +typed streams on top. Admin messages are handled internally. + +Foundation scope note: responses to an inbound ResendRequest are administrative +gap-fills (no outbound message store yet); a persistent store for true +retransmission is a later phase. See GH #402. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import random +import time +from collections.abc import Awaitable, Callable +from datetime import UTC, datetime +from enum import Enum +from types import TracebackType +from typing import Any + +from pydantic import ValidationError + +from kalshi.fix.auth import FixSigner +from kalshi.fix.codec import RawMessage, encode +from kalshi.fix.config import FixConfig, FixSessionType +from kalshi.fix.connection import FixConnection +from kalshi.fix.enums import MsgType +from kalshi.fix.errors import FixConnectionError, FixLogonError, FixSequenceError, FixSessionError +from kalshi.fix.messages.base import FixMessage, _format_utc_timestamp +from kalshi.fix.messages.session import ( + Heartbeat, + Logon, + Logout, + Reject, + ResendRequest, + SequenceReset, + TestRequest, +) +from kalshi.fix.tags import Tag + +logger = logging.getLogger("kalshi.fix") + +MessageHandler = Callable[[RawMessage], Awaitable[None]] +StateChangeHandler = Callable[["FixSessionState", "FixSessionState"], Awaitable[None]] + + +def _heartbeat_due(now: float, last_send: float, interval: float) -> bool: + """True when an idle Heartbeat is due (no outbound message for >= interval).""" + return now - last_send >= interval + + +def _liveness(now: float, last_recv: float, interval: float) -> str: + """Classify peer liveness from inbound silence. + + Returns ``"ok"``, ``"test_request"`` (silent >= one interval — probe with a + TestRequest), or ``"dead"`` (silent >= two intervals — force a reconnect). + """ + silence = now - last_recv + if silence >= interval * 2: + return "dead" + if silence >= interval: + return "test_request" + return "ok" + + +class FixSessionState(Enum): + """FIX session lifecycle states.""" + + DISCONNECTED = "disconnected" + CONNECTING = "connecting" + LOGGING_ON = "logging_on" + ACTIVE = "active" + RECONNECTING = "reconnecting" + CLOSED = "closed" + + +class FixSession: + """A single async FIX session. + + Usage:: + + signer = FixSigner.from_env() + config = FixConfig.prediction(environment=FixEnvironment.DEMO) + session = FixSession(signer, config, FixSessionType.ORDER_ENTRY_NR, + on_message=handle) + async with session: + ... # send order-entry messages, receive execution reports + """ + + def __init__( + self, + signer: FixSigner, + config: FixConfig, + session_type: FixSessionType, + *, + on_message: MessageHandler | None = None, + on_state_change: StateChangeHandler | None = None, + ssl_context: Any = None, + ) -> None: + config._check_session(session_type) + self._signer = signer + self._config = config + self._session_type = session_type + self._target = config.target_comp_id(session_type) + self._supports_retransmission = config.supports_retransmission(session_type) + self._on_message = on_message + self._on_state_change = on_state_change + self._ssl_context = ssl_context + + self._hb = config.heartbeat_interval + self._connection: FixConnection | None = None + self._state = FixSessionState.DISCONNECTED + self._running = False + + # Sequence numbers: next to send / next expected inbound. + self._out_seq = 1 + self._in_seq = 1 + self._logged_on_once = False + # Out-of-order inbound buffer (seq -> message) and resend bookkeeping. + self._pending: dict[int, RawMessage] = {} + self._resend_requested = False + + # Liveness bookkeeping (monotonic seconds). + self._last_send = 0.0 + self._last_recv = 0.0 + self._test_request_outstanding = False + self._test_req_counter = 0 + + self._send_lock = asyncio.Lock() + self._recv_task: asyncio.Task[None] | None = None + self._hb_task: asyncio.Task[None] | None = None + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def state(self) -> FixSessionState: + """Current session state.""" + return self._state + + @property + def session_type(self) -> FixSessionType: + """The session type (TargetCompID) this session connects as.""" + return self._session_type + + @property + def outbound_seq_num(self) -> int: + """The MsgSeqNum that will be used for the next outbound message.""" + return self._out_seq + + @property + def inbound_seq_num(self) -> int: + """The MsgSeqNum expected on the next inbound message.""" + return self._in_seq + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def __aenter__(self) -> FixSession: + await self.start() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + await self.close() + + async def start(self) -> None: + """Connect, log on, and start the background recv + heartbeat tasks.""" + if self._running: + raise RuntimeError("FixSession is already started") + self._running = True + try: + await self._open_and_logon() + except BaseException: + self._running = False + await self._close_connection() + await self._set_state(FixSessionState.CLOSED) + raise + self._recv_task = asyncio.create_task(self._recv_loop()) + self._hb_task = asyncio.create_task(self._heartbeat_loop()) + + async def close(self) -> None: + """Stop background tasks, send a best-effort (bounded) Logout, close the socket. + + The heartbeat loop is cancelled first so it cannot race the Logout send, + and the Logout is wrapped in a timeout so a dead/half-open connection + (where ``writer.drain()`` never returns) cannot hang ``close()``. + """ + self._running = False + await self._cancel(self._hb_task) + self._hb_task = None + if self._state is FixSessionState.ACTIVE: + with contextlib.suppress(Exception): + await asyncio.wait_for(self._send(Logout()), timeout=2.0) + await self._cancel(self._recv_task) + self._recv_task = None + await self._close_connection() + await self._set_state(FixSessionState.CLOSED) + + @staticmethod + async def _cancel(task: asyncio.Task[None] | None) -> None: + """Cancel a background task and await its teardown. Never raises.""" + if task is not None and not task.done(): + task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + + # ------------------------------------------------------------------ + # Sending + # ------------------------------------------------------------------ + + async def send(self, message: FixMessage) -> int: + """Send an application message; returns the assigned MsgSeqNum. + + Admin messages are managed by the session itself — use the typed + order-entry / market-data helpers (later phases) rather than sending + session-layer messages directly. + """ + return await self._send(message) + + async def _send(self, message: FixMessage) -> int: + async with self._send_lock: + if self._connection is None or not self._connection.is_open: + raise FixSessionError("cannot send: FIX connection is not open") + seq = self._out_seq + # Reserve the sequence number BEFORE handing bytes to the transport. + # If the write fails after the bytes reached the peer, the number is + # consumed (a recoverable gap) rather than silently reused for a + # different message on the next reconnect. + self._out_seq += 1 + sending_time = self._now_sending_time() + # Logon's RawData signature is bound to this exact seq + sending_time, + # so (re)sign here rather than trusting any preset value. + if isinstance(message, Logon): + message.raw_data = self._signer.sign_logon( + sending_time=sending_time, + msg_seq_num=seq, + target_comp_id=self._target, + ) + header: list[tuple[int, str]] = [ + (int(Tag.MSG_TYPE), type(message).MSG_TYPE.value), + (int(Tag.SENDER_COMP_ID), self._signer.sender_comp_id), + (int(Tag.TARGET_COMP_ID), self._target), + (int(Tag.MSG_SEQ_NUM), str(seq)), + (int(Tag.SENDING_TIME), sending_time), + ] + wire = encode(header + message.to_body_fields()) + await self._connection.send_bytes(wire) + self._last_send = time.monotonic() + return seq + + async def _send_gap_fill(self, begin_seq_no: int) -> None: + """Reply to an inbound ResendRequest with an administrative gap-fill. + + We do not yet persist outbound application messages, so the only correct + response is a SequenceReset/gap-fill covering ``[begin, NewSeqNo)``: + MsgSeqNum=begin, PossDupFlag=Y. ``NewSeqNo`` is clamped to be strictly + greater than ``begin`` (FIX requires NewSeqNo > MsgSeqNum) even when the + peer asks to resend from a sequence at or beyond our next outbound — a + real message store for true retransmission is a later phase. + """ + async with self._send_lock: + if self._connection is None or not self._connection.is_open: + return + sending_time = self._now_sending_time() + new_seq_no = max(self._out_seq, begin_seq_no + 1) + sr = SequenceReset(gap_fill_flag=True, new_seq_no=new_seq_no) + header: list[tuple[int, str]] = [ + (int(Tag.MSG_TYPE), MsgType.SEQUENCE_RESET.value), + (int(Tag.SENDER_COMP_ID), self._signer.sender_comp_id), + (int(Tag.TARGET_COMP_ID), self._target), + (int(Tag.MSG_SEQ_NUM), str(begin_seq_no)), + (int(Tag.POSS_DUP_FLAG), "Y"), + (int(Tag.SENDING_TIME), sending_time), + (int(Tag.ORIG_SENDING_TIME), sending_time), + ] + wire = encode(header + sr.to_body_fields()) + await self._connection.send_bytes(wire) + self._last_send = time.monotonic() + + def _now_sending_time(self) -> str: + return _format_utc_timestamp(datetime.now(UTC)) + + # ------------------------------------------------------------------ + # Connect + logon + # ------------------------------------------------------------------ + + async def _open_and_logon(self) -> None: + await self._set_state(FixSessionState.CONNECTING) + host = self._config.host_for(self._session_type) + port = self._config.port_for(self._session_type) + self._connection = FixConnection( + host, + port, + use_tls=self._config.use_tls, + connect_timeout=self._config.connect_timeout, + ssl_context=self._ssl_context, + ) + await self._connection.open() + + # Reset policy: non-retransmission sessions always reset; retransmission + # sessions reset only on their first-ever logon (we have no on-disk store + # to resume across process restarts) and resume on reconnect. + reset = (not self._supports_retransmission) or (not self._logged_on_once) + if reset: + self._out_seq = 1 + self._in_seq = 1 + self._pending.clear() + self._resend_requested = False + + await self._set_state(FixSessionState.LOGGING_ON) + logon = Logon( + heartbeat_interval=self._hb, + reset_seq_num_flag=True if reset else None, + use_dollars=True if self._config.effective_use_dollars else None, + cancel_orders_on_disconnect=True if self._config.cancel_orders_on_disconnect else None, + ) + await self._send(logon) + + try: + raw = await asyncio.wait_for( + self._connection.read_message(), timeout=self._config.connect_timeout + ) + except TimeoutError as e: + raise FixLogonError("timed out waiting for Logon response") from e + self._last_recv = time.monotonic() + + mt = raw.msg_type + if mt == MsgType.LOGOUT: + raise FixLogonError("FIX logon rejected", reason=Logout.from_raw(raw).text) + if mt != MsgType.LOGON: + raise FixSessionError(f"expected Logon response, got MsgType={mt!r}") + + resp_seq = raw.seq_num if raw.seq_num is not None else self._in_seq + need_resend = False + if resp_seq == self._in_seq: + self._in_seq = resp_seq + 1 + elif resp_seq > self._in_seq: + if self._supports_retransmission: + # Gap immediately after logon (retransmission session resuming): + # buffer the logon and request the missing range. + self._pending[resp_seq] = raw + need_resend = True + else: + # NR/DC/RFQ/MD cannot recover a gap; per spec a too-high seq + # terminates the session. Fail the logon. + raise FixLogonError( + f"Logon response seq {resp_seq} ahead of expected {self._in_seq} on " + f"non-retransmission session {self._session_type.value}" + ) + else: + # Lower-than-expected Logon sequence: never rewind the inbound + # watermark. A backwards seq signals a corrupt/duplicated server + # session and is fatal per spec (do not silently re-process old + # messages on reconnect). + raise FixLogonError( + f"Logon response seq {resp_seq} below expected {self._in_seq}; refusing to rewind" + ) + + self._logged_on_once = True + self._test_request_outstanding = False + await self._set_state(FixSessionState.ACTIVE) + + if need_resend: + await self._request_resend() + + # ------------------------------------------------------------------ + # Receive loop + inbound handling + # ------------------------------------------------------------------ + + async def _recv_loop(self) -> None: + assert self._connection is not None + while self._running: + try: + raw = await self._connection.read_message() + except asyncio.CancelledError: + break + except FixConnectionError: + if not self._running: + break + if not await self._reconnect(): + self._running = False + break + continue + except Exception: + # Malformed framing (FixCodecError) or unexpected error: skip the + # frame and keep the session alive. + logger.warning("skipping malformed FIX frame", exc_info=True) + continue + + self._last_recv = time.monotonic() + self._test_request_outstanding = False + try: + await self._handle_inbound(raw) + except FixSequenceError: + logger.error("unrecoverable FIX sequence error; closing session", exc_info=True) + self._running = False + await self._close_connection() + await self._set_state(FixSessionState.CLOSED) + break + except (FixConnectionError, FixSessionError): + # An admin reply (Heartbeat / ResendRequest gap-fill) failed + # because the socket dropped between the read and the reply. + # Recover via the same reconnect path used for read failures + # rather than letting the recv loop die with the session wedged. + if not self._running: + break + logger.warning( + "FIX send failed while handling inbound message; reconnecting", + exc_info=True, + ) + if not await self._reconnect(): + self._running = False + break + except Exception: + # Backstop: no unexpected error may silently kill the recv loop + # and leave the session wedged in ACTIVE. Log and keep reading; + # genuine sequence/connection failures are handled above. + logger.exception("unexpected error handling inbound FIX message; continuing") + + async def _handle_inbound(self, raw: RawMessage) -> None: + mt = raw.msg_type + + if mt == MsgType.SEQUENCE_RESET: + await self._handle_sequence_reset(raw) + return + + seq = raw.seq_num + if seq is None: + logger.warning("inbound FIX message %r without MsgSeqNum; ignoring", mt) + return + + if seq == self._in_seq: + await self._safe_process(raw, mt, seq) + self._in_seq += 1 + await self._drain_pending() + elif seq < self._in_seq: + if raw.get(Tag.POSS_DUP_FLAG) == "Y": + # Legitimate retransmission of an already-processed message. + logger.debug("ignoring PossDup inbound seq %d (expected %d)", seq, self._in_seq) + else: + # Per spec a lower-than-expected MsgSeqNum without PossDupFlag is + # a fatal session error — terminate rather than silently skip. + raise FixSequenceError( + f"inbound MsgSeqNum {seq} below expected {self._in_seq} without PossDupFlag", + expected=self._in_seq, + received=seq, + ) + else: + # Forward gap. + self._pending[seq] = raw + if self._supports_retransmission: + await self._request_resend() + else: + raise FixSequenceError( + f"forward sequence gap on non-retransmission session " + f"{self._session_type.value}", + expected=self._in_seq, + received=seq, + ) + + async def _handle_sequence_reset(self, raw: RawMessage) -> None: + """Apply an inbound SequenceReset, honoring sequencing rules. + + GapFill mode participates in sequencing via its own MsgSeqNum: a gap-fill + arriving ahead of the expected inbound seq is itself a gap (recover on + RT/PT, fatal otherwise). Both gap-fill and reset mode only ever move the + watermark FORWARD — a stale/duplicate reset never rewinds (which would + re-process already-delivered messages). + """ + try: + sr = SequenceReset.from_raw(raw) + except (ValidationError, ValueError): + logger.warning("malformed inbound SequenceReset; ignoring", exc_info=True) + return + + seq = raw.seq_num + if bool(sr.gap_fill_flag) and seq is not None and seq > self._in_seq: + if self._supports_retransmission: + await self._request_resend() + return + raise FixSequenceError( + f"SequenceReset gap-fill at seq {seq} ahead of expected {self._in_seq} on " + f"non-retransmission session {self._session_type.value}", + expected=self._in_seq, + received=seq, + ) + + if sr.new_seq_no > self._in_seq: + self._in_seq = sr.new_seq_no + # Drop any buffered messages now behind the watermark. + for s in [s for s in self._pending if s < self._in_seq]: + del self._pending[s] + if not self._pending: + self._resend_requested = False + await self._drain_pending() + + async def _drain_pending(self) -> None: + """Process buffered out-of-order messages now made contiguous.""" + while self._in_seq in self._pending: + raw = self._pending.pop(self._in_seq) + await self._safe_process(raw, raw.msg_type, self._in_seq) + self._in_seq += 1 + if not self._pending: + self._resend_requested = False + + async def _safe_process(self, raw: RawMessage, mt: str | None, seq: int) -> None: + """Process one in-order message; a malformed admin payload becomes a Reject. + + A schema/parse failure on an admin message must not crash the recv loop — + treat it as consumed (the caller still advances ``_in_seq``) and answer + with a session-level Reject (35=3) rather than wedging the session. + """ + try: + await self._process(raw, mt) + except (ValidationError, ValueError): + logger.warning("malformed inbound %s at seq %d; sending Reject", mt, seq, exc_info=True) + with contextlib.suppress(FixConnectionError, FixSessionError): + await self._send(Reject(ref_seq_num=seq)) + + async def _process(self, raw: RawMessage, mt: str | None) -> None: + """Dispatch one in-order message (admin handled here, app to callback).""" + if mt == MsgType.HEARTBEAT: + return + if mt == MsgType.TEST_REQUEST: + await self._send(Heartbeat(test_req_id=TestRequest.from_raw(raw).test_req_id)) + return + if mt == MsgType.RESEND_REQUEST: + if self._supports_retransmission: + await self._send_gap_fill(ResendRequest.from_raw(raw).begin_seq_no) + else: + # NR/DC/RFQ/MD do not support retransmission; an inbound + # ResendRequest is anomalous — log and ignore rather than emit an + # invalid recovery message. + logger.warning( + "ignoring inbound ResendRequest on non-retransmission session %s", + self._session_type.value, + ) + return + if mt == MsgType.LOGOUT: + logger.info("received Logout from peer: %s", Logout.from_raw(raw).text) + self._running = False + await self._close_connection() + await self._set_state(FixSessionState.CLOSED) + return + if mt == MsgType.LOGON: + # Mid-session Logon is unexpected once ACTIVE; ignore. + return + if mt == MsgType.REJECT: + rj = Reject.from_raw(raw) + logger.warning( + "FIX session Reject: refSeqNum=%s reason=%s text=%r", + rj.ref_seq_num, + rj.session_reject_reason, + rj.text, + ) + return + # Application message. Isolate consumer callback failures so a buggy + # handler cannot tear down the session (the message's seq is already + # accounted for by the caller). + if self._on_message is not None: + try: + await self._on_message(raw) + except Exception: + logger.exception("FIX on_message callback raised; continuing session") + + async def _request_resend(self) -> None: + if self._resend_requested: + return + self._resend_requested = True + # end=0 means "through the latest message". + await self._send(ResendRequest(begin_seq_no=self._in_seq, end_seq_no=0)) + + # ------------------------------------------------------------------ + # Heartbeat / liveness + # ------------------------------------------------------------------ + + async def _heartbeat_loop(self) -> None: + interval = self._hb + while self._running: + await asyncio.sleep(interval / 2) + if not self._running: + break + if self._state is not FixSessionState.ACTIVE: + continue + now = time.monotonic() + try: + if _heartbeat_due(now, self._last_send, interval): + await self._send(Heartbeat()) + liveness = _liveness(now, self._last_recv, interval) + if liveness == "dead": + logger.warning( + "FIX peer silent for %.1fs (interval %ds); forcing reconnect", + now - self._last_recv, + interval, + ) + # Drop the socket; the recv loop observes EOF and reconnects. + await self._close_connection() + elif liveness == "test_request" and not self._test_request_outstanding: + self._test_request_outstanding = True + self._test_req_counter += 1 + await self._send(TestRequest(test_req_id=f"TR{self._test_req_counter}")) + except (FixConnectionError, FixSessionError): + # Transition in flight (closing / reconnecting); let the recv loop drive. + continue + + # ------------------------------------------------------------------ + # Reconnect + # ------------------------------------------------------------------ + + async def _reconnect(self) -> bool: + """Reconnect with AWS full-jitter backoff. Returns True on success.""" + await self._close_connection() + await self._set_state(FixSessionState.RECONNECTING) + last_exc: BaseException | None = None + for attempt in range(self._config.max_retries): + delay = random.uniform( + 0, + min( + self._config.retry_max_delay, + self._config.retry_base_delay * (2**attempt), + ), + ) + logger.warning( + "FIX reconnect in %.1fs (attempt %d/%d)", + delay, + attempt + 1, + self._config.max_retries, + ) + await asyncio.sleep(delay) + if not self._running: + return False + try: + await self._open_and_logon() + return True + except Exception as exc: + last_exc = exc + logger.debug("FIX reconnect attempt %d failed", attempt + 1, exc_info=True) + continue + await self._set_state(FixSessionState.CLOSED) + logger.error( + "FIX reconnect exhausted %d attempts", self._config.max_retries, exc_info=last_exc + ) + return False + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + async def _close_connection(self) -> None: + if self._connection is not None: + await self._connection.close() + + async def _set_state(self, new_state: FixSessionState) -> None: + old = self._state + if old is new_state: + return + self._state = new_state + logger.debug("FIX session state: %s -> %s", old.value, new_state.value) + if self._on_state_change is not None: + await self._on_state_change(old, new_state) diff --git a/kalshi/fix/tags.py b/kalshi/fix/tags.py new file mode 100644 index 00000000..5435ffe1 --- /dev/null +++ b/kalshi/fix/tags.py @@ -0,0 +1,200 @@ +"""FIX tag numbers for the Kalshi dialect (FIX Dictionary v1.03). + +A single :class:`Tag` ``IntEnum` is the canonical name<->number mapping used by +the codec and the typed message models. Because :class:`Tag` subclasses ``int``, +members are usable anywhere a raw tag number is expected. + +Also exposes :data:`DATA_LENGTH_FIELDS` — the length-prefixed binary/data fields +whose value may legally contain the SOH delimiter and must therefore be read by +byte count (using the immediately-preceding length field) rather than by +scanning for the next SOH. +""" + +from __future__ import annotations + +from enum import IntEnum + + +class Tag(IntEnum): + """FIX field tag numbers (Kalshi dictionary v1.03).""" + + # --- Standard header / trailer --- + BEGIN_STRING = 8 + BODY_LENGTH = 9 + MSG_TYPE = 35 + SENDER_COMP_ID = 49 + TARGET_COMP_ID = 56 + MSG_SEQ_NUM = 34 + SENDING_TIME = 52 + SENDER_SUB_ID = 50 + SENDER_LOCATION_ID = 142 + TARGET_SUB_ID = 57 + TARGET_LOCATION_ID = 143 + ON_BEHALF_OF_COMP_ID = 115 + DELIVER_TO_COMP_ID = 128 + POSS_DUP_FLAG = 43 + POSS_RESEND = 97 + ORIG_SENDING_TIME = 122 + APPL_VER_ID = 1128 + CSTM_APPL_VER_ID = 1129 + SIGNATURE_LENGTH = 93 + SIGNATURE = 89 + CHECK_SUM = 10 + + # --- Session / admin --- + TEST_REQ_ID = 112 + BEGIN_SEQ_NO = 7 + END_SEQ_NO = 16 + REF_SEQ_NUM = 45 + REF_TAG_ID = 371 + REF_MSG_TYPE = 372 + SESSION_REJECT_REASON = 373 + GAP_FILL_FLAG = 123 + NEW_SEQ_NO = 36 + ENCRYPT_METHOD = 98 + HEART_BT_INT = 108 + RAW_DATA_LENGTH = 95 + RAW_DATA = 96 + RESET_SEQ_NUM_FLAG = 141 + NEXT_EXPECTED_MSG_SEQ_NUM = 789 + MAX_MESSAGE_SIZE = 383 + TEST_MESSAGE_INDICATOR = 464 + USERNAME = 553 + PASSWORD = 554 + DEFAULT_APPL_VER_ID = 1137 + TEXT = 58 + + # --- Logon options (Kalshi custom) --- + CANCEL_ORDERS_ON_DISCONNECT = 8013 + SKIP_PENDING_EXEC_REPORTS = 21011 + USE_DOLLARS = 21005 + CANCEL_ORDER_ON_PAUSE = 21006 + ENABLE_IOC_CANCEL_REPORT = 21007 + LISTENER_SESSION = 20126 + RECEIVE_SETTLEMENT_REPORTS = 20127 + MESSAGE_RETENTION_PERIOD = 20200 + PRESERVE_ORIGINAL_ORDER_QTY = 21008 + USE_EXPIRED_ORD_STATUS = 21012 + ALWAYS_EMIT_NEW_BEFORE_TRADE = 21026 + + # --- Order entry --- + CL_ORD_ID = 11 + ORIG_CL_ORD_ID = 41 + SECONDARY_CL_ORD_ID = 526 + ORDER_ID = 37 + EXEC_INST = 18 + ORDER_QTY = 38 + ORD_TYPE = 40 + PRICE = 44 + SIDE = 54 + SYMBOL = 55 + SECURITY_ID = 48 + TIME_IN_FORCE = 59 + EXPIRE_TIME = 126 + SELF_TRADE_PREVENTION_TYPE = 2964 + ORDER_GROUP_ID = 20130 + MAX_EXECUTION_COST = 21009 + ALLOC_ACCOUNT = 79 + TRANSACT_TIME = 60 + + # --- Execution report --- + EXEC_ID = 17 + EXEC_TYPE = 150 + ORD_STATUS = 39 + LEAVES_QTY = 151 + CUM_QTY = 14 + AVG_PX = 6 + LAST_PX = 31 + LAST_QTY = 32 + ORD_REJ_REASON = 103 + EXEC_RESTATEMENT_REASON = 378 + TRD_MATCH_ID = 880 + AGGRESSOR_INDICATOR = 1057 + LONG_QTY = 704 + SHORT_QTY = 705 + + # --- Cancel reject / mass cancel / business reject --- + CXL_REJ_REASON = 102 + CXL_REJ_RESPONSE_TO = 434 + MASS_CANCEL_REQUEST_TYPE = 530 + MASS_CANCEL_RESPONSE = 531 + MASS_CANCEL_REJECT_REASON = 532 + BUSINESS_REJECT_REF_ID = 379 + BUSINESS_REJECT_REASON = 380 + + # --- Parties group --- + NO_PARTY_IDS = 453 + PARTY_ID = 448 + PARTY_ROLE = 452 + + # --- Misc fees group --- + NO_MISC_FEES = 136 + MISC_FEE_AMT = 137 + MISC_FEE_CURR = 138 + MISC_FEE_TYPE = 139 + MISC_FEE_BASIS = 891 + + # --- Collateral changes group --- + NO_COLLATERAL_AMOUNT_CHANGES = 1703 + COLLATERAL_AMOUNT_CHANGE = 1704 + COLLATERAL_AMOUNT_TYPE = 1705 + + # --- RFQ / quoting --- + QUOTE_ID = 117 + QUOTE_REQ_ID = 131 + BID_PX = 132 + OFFER_PX = 133 + BID_SIZE = 134 + OFFER_SIZE = 135 + NO_RELATED_SYM = 146 + CASH_ORDER_QTY = 152 + QUOTE_STATUS = 297 + QUOTE_CANCEL_STATUS = 298 + QUOTE_REQUEST_TYPE = 303 + QUOTE_REQUEST_REJECT_REASON = 658 + QUOTE_CONFIRM_STATUS = 21010 + RFQ_CANCEL_STATUS = 21013 + REST_REMAINDER = 21015 + REPLACE_EXISTING = 21016 + PREFER_BETTER_QUOTE = 21022 + RFQ_ID = 21023 + ACCEPTED_QUOTE_ID = 21024 + ACCEPT_QUOTE_STATUS = 21025 + + # --- Multivariate legs group --- + MULTIVARIATE_COLLECTION_TICKER = 20180 + NO_MULTIVARIATE_SELECTED_LEGS = 20181 + MULTIVARIATE_SELECTED_EVENT_TICKER = 20182 + MULTIVARIATE_SELECTED_MARKET_TICKER = 20183 + MULTIVARIATE_SELECTED_SIDE = 20184 + + # --- Market settlement (post trade) --- + MARKET_SETTLEMENT_REPORT_ID = 20105 + TOT_NUM_MARKET_SETTLEMENT_REPORTS = 20106 + MARKET_RESULT = 20107 + NO_MARKET_SETTLEMENT_PARTY_IDS = 20108 + MARKET_SETTLEMENT_PARTY_ID = 20109 + MARKET_SETTLEMENT_PARTY_ROLE = 20110 + CLEARING_BUSINESS_DATE = 715 + SETTLEMENT_PRICE = 730 + LAST_FRAGMENT = 893 + + # --- Order groups --- + ORDER_GROUP_ACTION = 20131 + ORDER_GROUP_CONTRACTS_LIMIT = 20132 + + # --- Drop copy (event resend) --- + BEGIN_EXEC_ID = 21001 + END_EXEC_ID = 21002 + RESEND_EVENT_COUNT = 21003 + EVENT_RESEND_REJECT_REASON = 21004 + + +# Length-prefixed data fields: ``length_tag -> data_tag``. The data field's value +# may legally contain the SOH (\x01) delimiter, so the decoder reads exactly +# ```` bytes for it instead of scanning to the next SOH. Per FIXT.1.1 the +# length field always immediately precedes its data field. +DATA_LENGTH_FIELDS: dict[int, int] = { + Tag.RAW_DATA_LENGTH: Tag.RAW_DATA, + Tag.SIGNATURE_LENGTH: Tag.SIGNATURE, +} diff --git a/kalshi/perps/fix/__init__.py b/kalshi/perps/fix/__init__.py new file mode 100644 index 00000000..947f360e --- /dev/null +++ b/kalshi/perps/fix/__init__.py @@ -0,0 +1,13 @@ +"""Margin (perps) FIX client surface. + +A thin facade over the shared FIX core in :mod:`kalshi.fix` — see +:class:`MarginFixClient`. The codec, session engine, and message models are +shared; this package only specializes product, endpoints, dollar pricing, and +credential source. See GH #402. +""" + +from __future__ import annotations + +from kalshi.perps.fix.client import MarginFixClient + +__all__ = ["MarginFixClient"] diff --git a/kalshi/perps/fix/client.py b/kalshi/perps/fix/client.py new file mode 100644 index 00000000..607ab2df --- /dev/null +++ b/kalshi/perps/fix/client.py @@ -0,0 +1,59 @@ +"""Margin (perps) FIX client facade. + +A thin product facade over the shared FIX core in :mod:`kalshi.fix`. The Kalshi +FIX dictionary is identical across products, so the margin client reuses the +same codec / session engine / message models; it differs only in: + +* product = margin (host/port tables resolve to the ``margin-*`` endpoints), +* ``UseDollars`` is always on (fixed-point dollar pricing — enforced by + :class:`~kalshi.fix.config.FixConfig` for the margin product), +* available sessions are NR / RT / DC / MD (no post-trade or RFQ), +* credentials come from the separate ``KALSHI_PERPS_*`` env vars, matching the + perps REST/WS clients. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from kalshi.errors import KalshiAuthError +from kalshi.fix.auth import FixSigner +from kalshi.fix.client import _BaseFixClient +from kalshi.fix.config import FixConfig, FixEnvironment, FixProduct +from kalshi.perps._env import try_perps_auth_from_env + + +class MarginFixClient(_BaseFixClient): + """FIX client for the margin (perps) gateway. + + Supports order entry (NR/RT), drop copy, and market data. Construct from a + :class:`~kalshi.fix.auth.FixSigner`, from an existing + :class:`~kalshi.auth.KalshiAuth` via :meth:`from_auth`, or from the + ``KALSHI_PERPS_*`` environment via :meth:`from_env`. + """ + + _PRODUCT = FixProduct.MARGIN + + @classmethod + def from_env( + cls, + *, + environment: FixEnvironment = FixEnvironment.PRODUCTION, + config: FixConfig | None = None, + ssl_context: object | None = None, + password: bytes | str | Callable[[], bytes | str] | None = None, + ) -> MarginFixClient: + """Build a margin FIX client from the ``KALSHI_PERPS_*`` environment vars.""" + auth = try_perps_auth_from_env(password=password) + if auth is None: + raise KalshiAuthError( + "Margin FIX requires KALSHI_PERPS_KEY_ID and a private key " + "(KALSHI_PERPS_PRIVATE_KEY or KALSHI_PERPS_PRIVATE_KEY_PATH). " + "Kalshi recommends a separate API key for the perps exchange." + ) + return cls( + FixSigner.from_auth(auth), + environment=environment, + config=config, + ssl_context=ssl_context, + ) diff --git a/tests/fix/__init__.py b/tests/fix/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/fix/conftest.py b/tests/fix/conftest.py new file mode 100644 index 00000000..f3bf1763 --- /dev/null +++ b/tests/fix/conftest.py @@ -0,0 +1,212 @@ +"""Fixtures for FIX tests: an in-memory mock acceptor + signer/config helpers. + +The :class:`MockAcceptor` is a plain-TCP asyncio server (loopback, no TLS) that +speaks just enough FIX to drive :class:`kalshi.fix.session.FixSession` through +its state machine: it answers Logon with Logon (or Logout to simulate +rejection), echoes TestRequest with Heartbeat, records everything it receives, +and lets a test ``push`` arbitrary server->client messages with chosen +sequence numbers. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import Awaitable, Callable + +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa + +from kalshi.fix.auth import FixSigner +from kalshi.fix.codec import FixParser, RawMessage, encode +from kalshi.fix.config import FixConfig, FixEnvironment +from kalshi.fix.tags import Tag + +_FIXED_SENDING_TIME = "20250101-00:00:00.000" + + +class MockAcceptor: + """A minimal scriptable FIX acceptor for driving FixSession in tests.""" + + def __init__(self) -> None: + self.received: list[RawMessage] = [] + self.client_comp_id: str = "" + self.reject_logon: bool = False + self.reject_text: str = "logon rejected by mock" + # When set, the Logon response carries this MsgSeqNum instead of the + # auto-incremented one (used to drive logon-time sequence-gap paths). + self.logon_resp_seq: int | None = None + self._server: asyncio.AbstractServer | None = None + self._writer: asyncio.StreamWriter | None = None + self._out_seq = 1 + self.port = 0 + self.connection_count = 0 + self._connected = asyncio.Event() + + async def start(self) -> None: + self._server = await asyncio.start_server(self._handle, "127.0.0.1", 0) + self.port = self._server.sockets[0].getsockname()[1] + + async def stop(self) -> None: + if self._server is None: + return + self._server.close() + # Abort any live client connection so the handler task can exit, then + # bound wait_closed() — it can otherwise block indefinitely waiting on a + # connection handler that is parked in reader.read(). + if self._writer is not None and not self._writer.is_closing(): + self._writer.close() + with contextlib.suppress(Exception): + await asyncio.wait_for(self._server.wait_closed(), timeout=1.0) + + async def wait_connected(self) -> None: + await asyncio.wait_for(self._connected.wait(), timeout=2.0) + + def drop_connection(self) -> None: + """Forcibly close the current client connection (simulate a disconnect).""" + if self._writer is not None and not self._writer.is_closing(): + self._writer.close() + + async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + self.connection_count += 1 + # NB: _out_seq persists across connections. The acceptor only resets it + # when a Logon carries ResetSeqNumFlag=Y (see _respond) — mirroring a real + # server so RT reconnect/resume (no reset flag) continues the sequence. + self._writer = writer + self._connected.set() + parser = FixParser() + try: + while True: + data = await reader.read(65536) + if not data: + break + parser.append(data) + for msg in parser.messages(): + self.received.append(msg) + await self._respond(msg, writer) + except (ConnectionResetError, asyncio.CancelledError, OSError): + pass + + async def _respond(self, msg: RawMessage, writer: asyncio.StreamWriter) -> None: + mt = msg.msg_type + if mt == "A": # Logon + self.client_comp_id = msg.get(Tag.SENDER_COMP_ID) or "" + # A real acceptor resets its own sequence only when the client asks + # (ResetSeqNumFlag=Y on NR/DC/first logon); RT resume keeps continuity. + if msg.get(Tag.RESET_SEQ_NUM_FLAG) == "Y": + self._out_seq = 1 + if self.reject_logon: + await self._send(writer, "5", [(int(Tag.TEXT), self.reject_text)]) + else: + await self._send( + writer, + "A", + [ + (int(Tag.ENCRYPT_METHOD), "0"), + (int(Tag.HEART_BT_INT), "30"), + (int(Tag.DEFAULT_APPL_VER_ID), "9"), + (int(Tag.RESET_SEQ_NUM_FLAG), "Y"), + ], + seq=self.logon_resp_seq, + ) + elif mt == "1": # TestRequest -> Heartbeat echo + trid = msg.get(Tag.TEST_REQ_ID) + body = [(int(Tag.TEST_REQ_ID), trid)] if trid else [] + await self._send(writer, "0", body) + # everything else is recorded only + + async def _send( + self, + writer: asyncio.StreamWriter, + msg_type: str, + body: list[tuple[int, str]], + *, + seq: int | None = None, + poss_dup: bool = False, + ) -> None: + s = seq if seq is not None else self._out_seq + if seq is None: + self._out_seq += 1 + header: list[tuple[int, str]] = [ + (int(Tag.MSG_TYPE), msg_type), + (int(Tag.SENDER_COMP_ID), "Kalshi"), + (int(Tag.TARGET_COMP_ID), self.client_comp_id or "client"), + (int(Tag.MSG_SEQ_NUM), str(s)), + ] + if poss_dup: + header.append((int(Tag.POSS_DUP_FLAG), "Y")) + header.append((int(Tag.SENDING_TIME), _FIXED_SENDING_TIME)) + if poss_dup: + header.append((int(Tag.ORIG_SENDING_TIME), _FIXED_SENDING_TIME)) + writer.write(encode(header + body)) + await writer.drain() + + async def push( + self, + msg_type: str, + body: list[tuple[int, str]] | None = None, + *, + seq: int | None = None, + poss_dup: bool = False, + ) -> None: + """Send a server->client message on the current connection.""" + assert self._writer is not None, "no client connected" + await self._send(self._writer, msg_type, body or [], seq=seq, poss_dup=poss_dup) + + def received_types(self) -> list[str | None]: + return [m.msg_type for m in self.received] + + def first(self, msg_type: str) -> RawMessage | None: + for m in self.received: + if m.msg_type == msg_type: + return m + return None + + +@pytest.fixture +async def acceptor() -> object: + a = MockAcceptor() + await a.start() + try: + yield a + finally: + await a.stop() + + +@pytest.fixture +def fix_signer(rsa_private_key: rsa.RSAPrivateKey) -> FixSigner: + return FixSigner("test-api-key-uuid", rsa_private_key) + + +@pytest.fixture +def fix_config(acceptor: MockAcceptor) -> FixConfig: + """Prediction config pointed at the loopback mock acceptor (plain TCP).""" + return FixConfig.prediction( + environment=FixEnvironment.DEMO, + host="127.0.0.1", + port=acceptor.port, + use_tls=False, + heartbeat_interval=4, + connect_timeout=2.0, + retry_base_delay=0.01, + retry_max_delay=0.05, + max_retries=5, + ) + + +@pytest.fixture +def until() -> Callable[..., Awaitable[None]]: + """Return an async poller that waits until ``predicate()`` is truthy.""" + + async def _until( + predicate: Callable[[], bool], *, timeout: float = 2.0, interval: float = 0.01 + ) -> None: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while loop.time() < deadline: + if predicate(): + return + await asyncio.sleep(interval) + raise AssertionError("condition not met within timeout") + + return _until diff --git a/tests/fix/test_auth.py b/tests/fix/test_auth.py new file mode 100644 index 00000000..4dc8ebf4 --- /dev/null +++ b/tests/fix/test_auth.py @@ -0,0 +1,84 @@ +"""Tests for the FIX logon signer.""" + +from __future__ import annotations + +import base64 + +import pytest +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding, rsa + +from kalshi.auth import KalshiAuth +from kalshi.fix.auth import FixSigner + + +def _verify(pub: rsa.RSAPublicKey, signature_b64: str, pre_hash: bytes) -> None: + pub.verify( + base64.b64decode(signature_b64), + pre_hash, + padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.DIGEST_LENGTH), + hashes.SHA256(), + ) + + +def test_pre_hash_format(fix_signer: FixSigner) -> None: + pre = fix_signer.build_pre_hash( + sending_time="20230809-05:28:18.035", msg_seq_num=1, target_comp_id="KalshiNR" + ) + assert pre == b"\x01".join( + [b"20230809-05:28:18.035", b"A", b"1", b"test-api-key-uuid", b"KalshiNR"] + ) + + +def test_sign_logon_verifies_with_public_key( + fix_signer: FixSigner, rsa_private_key: rsa.RSAPrivateKey +) -> None: + sig = fix_signer.sign_logon( + sending_time="20230809-05:28:18.035", msg_seq_num=1, target_comp_id="KalshiNR" + ) + pre = fix_signer.build_pre_hash( + sending_time="20230809-05:28:18.035", msg_seq_num=1, target_comp_id="KalshiNR" + ) + _verify(rsa_private_key.public_key(), sig, pre) + + +def test_signature_is_base64(fix_signer: FixSigner) -> None: + sig = fix_signer.sign_logon( + sending_time="20230809-05:28:18.035", msg_seq_num=1, target_comp_id="KalshiNR" + ) + # Decodes cleanly and is the expected length for a 2048-bit key (256 bytes). + assert len(base64.b64decode(sig)) == 256 + + +def test_tampered_prehash_fails_verification( + fix_signer: FixSigner, rsa_private_key: rsa.RSAPrivateKey +) -> None: + sig = fix_signer.sign_logon( + sending_time="20230809-05:28:18.035", msg_seq_num=1, target_comp_id="KalshiNR" + ) + wrong = fix_signer.build_pre_hash( + sending_time="20230809-05:28:18.035", msg_seq_num=2, target_comp_id="KalshiNR" + ) + with pytest.raises(InvalidSignature): + _verify(rsa_private_key.public_key(), sig, wrong) + + +def test_from_auth_reuses_key(rsa_private_key: rsa.RSAPrivateKey) -> None: + auth = KalshiAuth(key_id="k123", private_key=rsa_private_key) + signer = FixSigner.from_auth(auth) + assert signer.sender_comp_id == "k123" + # Same key object -> a signature over the same pre-hash verifies. + kw = {"sending_time": "20230809-05:28:18.035", "msg_seq_num": 1, "target_comp_id": "KalshiNR"} + sig = signer.sign_logon(**kw) + pre = signer.build_pre_hash(**kw) + _verify(rsa_private_key.public_key(), sig, pre) + + +def test_from_pem_loads_key(pem_string: str) -> None: + signer = FixSigner.from_pem("k456", pem_string) + assert signer.sender_comp_id == "k456" + sig = signer.sign_logon( + sending_time="20230809-05:28:18.035", msg_seq_num=1, target_comp_id="KalshiNR" + ) + assert len(base64.b64decode(sig)) == 256 diff --git a/tests/fix/test_codec.py b/tests/fix/test_codec.py new file mode 100644 index 00000000..7f8604a2 --- /dev/null +++ b/tests/fix/test_codec.py @@ -0,0 +1,265 @@ +"""Tests for the hand-rolled FIX codec.""" + +from __future__ import annotations + +import pytest + +from kalshi.fix.codec import ( + BEGIN_STRING_FIXT11, + SOH, + FixParser, + RawMessage, + decode, + encode, +) +from kalshi.fix.errors import FixCodecError +from kalshi.fix.tags import Tag + + +def _logon_fields() -> list[tuple[int, str]]: + return [ + (int(Tag.MSG_TYPE), "A"), + (int(Tag.SENDER_COMP_ID), "uuid"), + (int(Tag.TARGET_COMP_ID), "KalshiNR"), + (int(Tag.MSG_SEQ_NUM), "1"), + (int(Tag.SENDING_TIME), "20250926-21:54:07.001"), + (int(Tag.ENCRYPT_METHOD), "0"), + (int(Tag.HEART_BT_INT), "30"), + ] + + +def test_encode_prepends_beginstring_bodylength_checksum() -> None: + wire = encode(_logon_fields()) + assert wire.startswith(b"8=" + BEGIN_STRING_FIXT11.encode() + SOH + b"9=") + assert wire.endswith(SOH) + # CheckSum is the final field, 3 zero-padded digits. + assert wire[-7:-4] == b"10=" + + +def test_encode_bodylength_and_checksum_are_correct() -> None: + wire = encode(_logon_fields()) + cs_start = len(wire) - 7 + # CheckSum = sum of all preceding bytes mod 256. + assert int(wire[cs_start + 3 : cs_start + 6]) == sum(wire[:cs_start]) % 256 + # BodyLength = bytes from MsgType through the SOH before CheckSum. + soh1 = wire.index(SOH) + soh2 = wire.index(SOH, soh1 + 1) + declared = int(wire[soh1 + 3 : soh2]) + assert declared == cs_start - (soh2 + 1) + + +def test_encode_rejects_framing_tags() -> None: + for tag in (Tag.BEGIN_STRING, Tag.BODY_LENGTH, Tag.CHECK_SUM): + with pytest.raises(FixCodecError): + encode([(int(Tag.MSG_TYPE), "A"), (int(tag), "x")]) + + +def test_encode_requires_msgtype_first() -> None: + with pytest.raises(FixCodecError): + encode([(int(Tag.SENDER_COMP_ID), "uuid"), (int(Tag.MSG_TYPE), "A")]) + + +def test_roundtrip_decode() -> None: + wire = encode(_logon_fields()) + rm = decode(wire) + assert rm.msg_type == "A" + assert rm.seq_num == 1 + assert rm.get(Tag.TARGET_COMP_ID) == "KalshiNR" + assert rm.get_int(Tag.HEART_BT_INT) == 30 + + +def test_data_field_with_embedded_equals_and_soh_safe() -> None: + # RawData carries a length prefix; its value may contain '=' (and even SOH). + raw_value = "ab=cd" + fields = [ + (int(Tag.MSG_TYPE), "A"), + (int(Tag.MSG_SEQ_NUM), "1"), + (int(Tag.RAW_DATA_LENGTH), str(len(raw_value))), + (int(Tag.RAW_DATA), raw_value), + ] + rm = decode(encode(fields)) + assert rm.get(Tag.RAW_DATA) == raw_value + + +def test_checksum_mismatch_raises() -> None: + wire = bytearray(encode(_logon_fields())) + # Corrupt a byte in the body. + wire[10] = wire[10] ^ 0x01 + with pytest.raises(FixCodecError, match="CheckSum"): + decode(bytes(wire)) + + +def test_bodylength_mismatch_raises() -> None: + wire = encode(_logon_fields()) + soh1 = wire.index(SOH) + soh2 = wire.index(SOH, soh1 + 1) + # Rewrite BodyLength to a wrong value of the same digit width. + declared = int(wire[soh1 + 3 : soh2]) + bad = wire[: soh1 + 3] + str(declared + 1).encode().rjust(soh2 - (soh1 + 3), b"0") + wire[soh2:] + with pytest.raises(FixCodecError): + decode(bad) + + +def test_decode_requires_beginstring() -> None: + with pytest.raises(FixCodecError, match="BeginString"): + decode(b"35=A\x0110=000\x01") + + +def test_parser_handles_split_and_multiple_frames() -> None: + a = encode(_logon_fields()) + b = encode( + [ + (int(Tag.MSG_TYPE), "0"), + (int(Tag.MSG_SEQ_NUM), "2"), + (int(Tag.SENDING_TIME), "20250926-21:54:08.001"), + ] + ) + stream = a + b + parser = FixParser() + seen: list[tuple[str | None, int | None]] = [] + for byte in stream: # feed one byte at a time + parser.append(bytes([byte])) + msg = parser.get_message() + if msg is not None: + seen.append((msg.msg_type, msg.seq_num)) + assert seen == [("A", 1), ("0", 2)] + + +def test_parser_discards_junk_before_beginstring() -> None: + parser = FixParser() + parser.append(b"garbage\x01noise" + encode(_logon_fields())) + msgs = parser.messages() + assert [m.msg_type for m in msgs] == ["A"] + + +def test_parser_rejects_implausible_bodylength() -> None: + parser = FixParser() + parser.append(b"8=FIXT.1.1\x019=99999999\x01") + with pytest.raises(FixCodecError, match="implausible"): + parser.get_message() + + +def test_rawmessage_repr_redacts_sensitive_fields() -> None: + rm = RawMessage([(int(Tag.MSG_TYPE), "A"), (int(Tag.RAW_DATA), "secretsig")]) + text = repr(rm) + assert "secretsig" not in text + assert "" in text + + +def test_get_int_raises_on_non_integer() -> None: + rm = RawMessage([(int(Tag.HEART_BT_INT), "abc")]) + with pytest.raises(FixCodecError): + rm.get_int(Tag.HEART_BT_INT) + + +def test_parser_resyncs_past_false_8equals_then_9() -> None: + # Junk that contains "8=" followed by a SOH and "9=" but whose BeginString + # value is not FIXT.1.1 must be skipped, not framed. + parser = FixParser() + parser.append(b"8=XXX\x019=5\x01junk\x01" + encode(_logon_fields())) + assert [m.msg_type for m in parser.messages()] == ["A"] + + +def test_parser_resyncs_past_stray_8equals_without_9() -> None: + parser = FixParser() + parser.append(b"8=junk\x01ZZ\x01" + encode(_logon_fields())) + assert [m.msg_type for m in parser.messages()] == ["A"] + + +def test_parser_bounds_unterminated_bodylength() -> None: + # Valid BeginString then a BodyLength digit run with no SOH must be rejected + # (bounded), not buffered forever. + parser = FixParser() + parser.append(b"8=FIXT.1.1\x019=" + b"1" * 100) + with pytest.raises(FixCodecError): + parser.get_message() + + +def test_parser_bounds_garbage_after_8equals() -> None: + # A "8=" followed by a long non-FIXT.1.1 run must not grow the buffer. + parser = FixParser() + parser.append(b"8=" + b"X" * 100_000) + assert parser.get_message() is None + assert len(parser._buf) <= 1 # resynced down to a possible split "8=" + + +def test_parser_frames_data_field_with_embedded_soh() -> None: + raw_value = "ab\x01cd" # an actual SOH inside the value + fields = [ + (int(Tag.MSG_TYPE), "A"), + (int(Tag.MSG_SEQ_NUM), "1"), + (int(Tag.SENDING_TIME), "20250926-21:54:07.001"), + (int(Tag.RAW_DATA_LENGTH), str(len(raw_value.encode("latin-1")))), + (int(Tag.RAW_DATA), raw_value), + ] + parser = FixParser() + parser.append(encode(fields)) + msgs = parser.messages() + assert len(msgs) == 1 + assert msgs[0].get(Tag.RAW_DATA) == raw_value + + +def test_encode_rejects_empty_field_list() -> None: + with pytest.raises(FixCodecError): + encode([]) + + +def test_rawmessage_get_all_returns_repeating_values() -> None: + rm = RawMessage( + [(int(Tag.NO_PARTY_IDS), "2"), (int(Tag.PARTY_ID), "a"), (int(Tag.PARTY_ID), "b")] + ) + assert rm.get_all(Tag.PARTY_ID) == ["a", "b"] + + +def test_encode_rejects_soh_in_non_data_field() -> None: + # A SOH in a normal field would smuggle extra tags into a checksum-valid frame. + with pytest.raises(FixCodecError, match="SOH"): + encode([(int(Tag.MSG_TYPE), "A"), (int(Tag.TEXT), "a\x01b")]) + + +def test_encode_allows_soh_in_data_field() -> None: + # DATA fields are length-prefixed, so an embedded SOH is legal. + wire = encode( + [ + (int(Tag.MSG_TYPE), "A"), + (int(Tag.MSG_SEQ_NUM), "1"), + (int(Tag.RAW_DATA_LENGTH), "3"), + (int(Tag.RAW_DATA), "a\x01b"), + ] + ) + assert decode(wire).get(Tag.RAW_DATA) == "a\x01b" + + +def test_decode_rejects_non_adjacent_data_field() -> None: + # A length field not immediately followed by its data field is malformed. + wire = encode( + [ + (int(Tag.MSG_TYPE), "A"), + (int(Tag.MSG_SEQ_NUM), "1"), + (int(Tag.RAW_DATA_LENGTH), "5"), + (int(Tag.PRICE), "0.5"), # not RawData (96) + ] + ) + with pytest.raises(FixCodecError, match="must immediately follow"): + decode(wire) + + +def test_parser_resyncs_after_corrupt_frame() -> None: + # A frame with a bad CheckSum must be skipped, and a following valid frame + # must still be delivered (not lost to over-consumption). + corrupt = bytearray( + encode( + [ + (int(Tag.MSG_TYPE), "0"), + (int(Tag.MSG_SEQ_NUM), "9"), + (int(Tag.SENDING_TIME), "20250926-21:54:08.001"), + ] + ) + ) + # Corrupt the declared CheckSum (the "NNN" digits in the trailing "10=NNN\x01"). + idx = len(corrupt) - 5 + corrupt[idx] = ord("0") if corrupt[idx] != ord("0") else ord("1") + parser = FixParser() + parser.append(bytes(corrupt) + encode(_logon_fields())) + types = [m.msg_type for m in parser.messages()] + assert types == ["A"] # corrupt heartbeat skipped, valid logon returned diff --git a/tests/fix/test_config.py b/tests/fix/test_config.py new file mode 100644 index 00000000..dd21daa5 --- /dev/null +++ b/tests/fix/test_config.py @@ -0,0 +1,123 @@ +"""Tests for FIX connectivity config.""" + +from __future__ import annotations + +import pytest + +from kalshi.fix.config import ( + FixConfig, + FixEnvironment, + FixProduct, + FixSessionType, +) + + +def test_prediction_production_hosts_and_ports() -> None: + c = FixConfig.prediction(environment=FixEnvironment.PRODUCTION) + assert c.host_for(FixSessionType.ORDER_ENTRY_NR) == "mm.fix.elections.kalshi.com" + assert c.port_for(FixSessionType.ORDER_ENTRY_NR) == 8228 + assert c.port_for(FixSessionType.ORDER_ENTRY_RT) == 8230 + assert c.port_for(FixSessionType.DROP_COPY) == 8229 + assert c.port_for(FixSessionType.POST_TRADE) == 8231 + assert c.port_for(FixSessionType.RFQ) == 8232 + assert c.port_for(FixSessionType.MARKET_DATA) == 8233 + + +def test_prediction_demo_splits_market_data_host() -> None: + c = FixConfig.prediction(environment=FixEnvironment.DEMO) + assert c.host_for(FixSessionType.ORDER_ENTRY_NR) == "fix.demo.kalshi.co" + assert c.host_for(FixSessionType.MARKET_DATA) == "marketdata.fix.demo.kalshi.co" + + +def test_margin_hosts() -> None: + prod = FixConfig.margin(environment=FixEnvironment.PRODUCTION) + assert prod.host_for(FixSessionType.ORDER_ENTRY_NR) == "margin-fix-api.fix.elections.kalshi.com" + demo = FixConfig.margin(environment=FixEnvironment.DEMO) + assert demo.host_for(FixSessionType.ORDER_ENTRY_NR) == "margin-fix.demo.kalshi.co" + assert demo.host_for(FixSessionType.MARKET_DATA) == "margin-marketdata.fix.demo.kalshi.co" + + +def test_target_comp_id_is_session_value() -> None: + c = FixConfig.prediction() + assert c.target_comp_id(FixSessionType.ORDER_ENTRY_NR) == "KalshiNR" + assert c.target_comp_id(FixSessionType.MARKET_DATA) == "KalshiMD" + + +def test_retransmission_only_on_rt_and_pt() -> None: + c = FixConfig.prediction() + assert c.supports_retransmission(FixSessionType.ORDER_ENTRY_RT) + assert c.supports_retransmission(FixSessionType.POST_TRADE) + assert not c.supports_retransmission(FixSessionType.ORDER_ENTRY_NR) + assert not c.supports_retransmission(FixSessionType.DROP_COPY) + assert not c.supports_retransmission(FixSessionType.MARKET_DATA) + + +def test_margin_disallows_post_trade_and_rfq() -> None: + c = FixConfig.margin() + assert FixSessionType.POST_TRADE not in c.allowed_sessions + assert FixSessionType.RFQ not in c.allowed_sessions + with pytest.raises(ValueError, match="not available for product"): + c.host_for(FixSessionType.RFQ) + + +def test_margin_forces_use_dollars() -> None: + assert FixConfig.margin().effective_use_dollars is True + # Even constructing margin directly without the factory enforces dollars. + assert FixConfig(product=FixProduct.MARGIN).effective_use_dollars is True + + +def test_prediction_defaults_to_cents() -> None: + assert FixConfig.prediction().effective_use_dollars is False + assert FixConfig.prediction(use_dollars=True).effective_use_dollars is True + + +def test_host_port_override() -> None: + c = FixConfig.prediction(host="127.0.0.1", port=9999, use_tls=False) + assert c.host_for(FixSessionType.ORDER_ENTRY_NR) == "127.0.0.1" + assert c.port_for(FixSessionType.MARKET_DATA) == 9999 + + +def test_rejects_low_heartbeat() -> None: + with pytest.raises(ValueError, match="heartbeat_interval"): + FixConfig.prediction(heartbeat_interval=2) + + +def test_per_product_heartbeat_floor() -> None: + # Prediction requires > 3; margin requires >= 3. + with pytest.raises(ValueError, match="heartbeat_interval"): + FixConfig.prediction(heartbeat_interval=3) + assert FixConfig.prediction(heartbeat_interval=4).heartbeat_interval == 4 + assert FixConfig.margin(heartbeat_interval=3).heartbeat_interval == 3 + + +def test_rejects_bad_port() -> None: + with pytest.raises(ValueError, match="port"): + FixConfig.prediction(port=70000) + + +def test_rejects_plaintext_to_remote_host() -> None: + with pytest.raises(ValueError, match="use_tls"): + FixConfig.prediction(host="evil.example.com", use_tls=False, allow_unknown_host=True) + + +def test_rejects_unknown_host_without_optin() -> None: + with pytest.raises(ValueError, match="not a known Kalshi FIX endpoint"): + FixConfig.prediction(host="evil.example.com") + + +def test_allows_unknown_host_with_optin() -> None: + c = FixConfig.prediction(host="proxy.internal", allow_unknown_host=True) + assert c.host_for(FixSessionType.ORDER_ENTRY_NR) == "proxy.internal" + + +def test_loopback_plaintext_allowed() -> None: + c = FixConfig.prediction(host="127.0.0.1", port=8228, use_tls=False) + assert c.host_for(FixSessionType.ORDER_ENTRY_NR) == "127.0.0.1" + + +def test_rejects_plaintext_without_host_override() -> None: + # No host override resolves to the real gateway, which mandates TLS. + with pytest.raises(ValueError, match="use_tls"): + FixConfig.prediction(use_tls=False) + with pytest.raises(ValueError, match="use_tls"): + FixConfig.margin(use_tls=False) diff --git a/tests/fix/test_messages.py b/tests/fix/test_messages.py new file mode 100644 index 00000000..5779393d --- /dev/null +++ b/tests/fix/test_messages.py @@ -0,0 +1,167 @@ +"""Tests for the typed FIX message framework and session messages.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from pydantic import ValidationError + +from kalshi.fix.codec import decode, encode +from kalshi.fix.enums import ApplVerID, EncryptMethod, MsgType +from kalshi.fix.messages import ( + Heartbeat, + Logon, + Logout, + Reject, + ResendRequest, + SequenceReset, + TestRequest, +) +from kalshi.fix.messages.base import ( + FixType, + _format_utc_timestamp, + _from_wire, + _parse_utc_timestamp, +) +from kalshi.fix.tags import Tag + + +def _wire(msg_type: str, body: list[tuple[int, str]], seq: int = 1) -> bytes: + header = [ + (int(Tag.MSG_TYPE), msg_type), + (int(Tag.MSG_SEQ_NUM), str(seq)), + (int(Tag.SENDING_TIME), "20250101-00:00:00.000"), + ] + return encode(header + body) + + +def test_logon_body_includes_defaults_and_auto_rawdata_length() -> None: + logon = Logon(heartbeat_interval=30, raw_data="QhA8659M", reset_seq_num_flag=True) + body = dict(logon.to_body_fields()) + assert body[int(Tag.ENCRYPT_METHOD)] == "0" # EncryptMethod.NONE default + assert body[int(Tag.HEART_BT_INT)] == "30" + assert body[int(Tag.DEFAULT_APPL_VER_ID)] == "9" # ApplVerID.FIX50SP2 default + assert body[int(Tag.RESET_SEQ_NUM_FLAG)] == "Y" + # RawData auto-emits its length field with the byte length of the value. + assert body[int(Tag.RAW_DATA_LENGTH)] == str(len("QhA8659M")) + assert body[int(Tag.RAW_DATA)] == "QhA8659M" + + +def test_logon_omits_none_optionals() -> None: + logon = Logon(heartbeat_interval=30) + tags = {t for t, _ in logon.to_body_fields()} + assert int(Tag.USE_DOLLARS) not in tags + assert int(Tag.RAW_DATA) not in tags + + +def test_logon_use_dollars_serializes_boolean() -> None: + logon = Logon(heartbeat_interval=30, use_dollars=True) + assert (int(Tag.USE_DOLLARS), "Y") in logon.to_body_fields() + + +def test_logon_enums_are_typed() -> None: + logon = Logon(heartbeat_interval=30) + assert logon.encrypt_method is EncryptMethod.NONE + assert logon.default_appl_ver_id is ApplVerID.FIX50SP2 + + +def test_logon_from_raw_roundtrip() -> None: + logon = Logon(heartbeat_interval=45, use_dollars=True, reset_seq_num_flag=True) + rm = decode(_wire("A", logon.to_body_fields())) + parsed = Logon.from_raw(rm) + assert parsed.heartbeat_interval == 45 + assert parsed.use_dollars is True + assert parsed.reset_seq_num_flag is True + + +def test_msg_type_class_var() -> None: + assert Logon.MSG_TYPE is MsgType.LOGON + assert Heartbeat.MSG_TYPE is MsgType.HEARTBEAT + assert SequenceReset.MSG_TYPE is MsgType.SEQUENCE_RESET + + +def test_extra_field_forbidden() -> None: + with pytest.raises(ValidationError): + Logon(heartbeat_interval=30, bogus_field=1) # type: ignore[call-arg] + + +def test_test_request_requires_id() -> None: + with pytest.raises(ValidationError): + TestRequest() # type: ignore[call-arg] + tr = TestRequest(test_req_id="ping") + assert (int(Tag.TEST_REQ_ID), "ping") in tr.to_body_fields() + + +def test_resend_request_seqnums() -> None: + rr = ResendRequest(begin_seq_no=5, end_seq_no=0) + body = dict(rr.to_body_fields()) + assert body[int(Tag.BEGIN_SEQ_NO)] == "5" + assert body[int(Tag.END_SEQ_NO)] == "0" + + +def test_sequence_reset_gap_fill() -> None: + sr = SequenceReset(gap_fill_flag=True, new_seq_no=10) + body = dict(sr.to_body_fields()) + assert body[int(Tag.GAP_FILL_FLAG)] == "Y" + assert body[int(Tag.NEW_SEQ_NO)] == "10" + + +def test_reject_parses_unknown_reason_as_int() -> None: + # Inbound reject codes are plain ints so an unknown code never raises. + rm = decode( + _wire( + "3", + [ + (int(Tag.REF_SEQ_NUM), "4"), + (int(Tag.SESSION_REJECT_REASON), "10"), + (int(Tag.TEXT), "SendingTime accuracy problem"), + ], + seq=5, + ) + ) + rj = Reject.from_raw(rm) + assert rj.ref_seq_num == 4 + assert rj.session_reject_reason == 10 + assert rj.text == "SendingTime accuracy problem" + + +def test_logout_text_optional() -> None: + assert Logout().to_body_fields() == [] + assert Logout(text="bye").to_body_fields() == [(int(Tag.TEXT), "bye")] + + +def test_heartbeat_optional_test_req_id() -> None: + assert Heartbeat().to_body_fields() == [] + assert Heartbeat(test_req_id="x").to_body_fields() == [(int(Tag.TEST_REQ_ID), "x")] + + +def test_utc_timestamp_format_and_parse() -> None: + dt = datetime(2025, 9, 26, 21, 54, 7, 1000, tzinfo=UTC) + assert _format_utc_timestamp(dt) == "20250926-21:54:07.001" + assert _parse_utc_timestamp("20250926-21:54:07.001") == dt + assert _parse_utc_timestamp("20250926-21:54:07") == datetime( + 2025, 9, 26, 21, 54, 7, tzinfo=UTC + ) + + +def test_utc_timestamp_truncates_sub_millisecond() -> None: + # Millisecond precision via floor truncation (not rounding) — the SendingTime + # in the logon pre-hash must be byte-identical to tag 52. + assert ( + _format_utc_timestamp(datetime(2025, 9, 26, 21, 54, 7, 123456, tzinfo=UTC)) + == "20250926-21:54:07.123" + ) + assert ( + _format_utc_timestamp(datetime(2025, 9, 26, 21, 54, 7, 999999, tzinfo=UTC)) + == "20250926-21:54:07.999" + ) + assert _parse_utc_timestamp("20250926-21:54:07.123456").microsecond == 123456 + + +def test_boolean_parsing_is_strict() -> None: + assert _from_wire("Y", FixType.BOOLEAN) is True + assert _from_wire("N", FixType.BOOLEAN) is False + # Anything else must raise rather than silently become False. + with pytest.raises(ValueError): + _from_wire("X", FixType.BOOLEAN) diff --git a/tests/fix/test_session.py b/tests/fix/test_session.py new file mode 100644 index 00000000..e49cb3fb --- /dev/null +++ b/tests/fix/test_session.py @@ -0,0 +1,519 @@ +"""State-machine tests for FixSession against the in-memory mock acceptor.""" + +from __future__ import annotations + +import base64 +from collections.abc import Awaitable, Callable + +import pytest +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding, rsa + +from kalshi.fix.auth import FixSigner +from kalshi.fix.codec import RawMessage +from kalshi.fix.config import FixConfig, FixSessionType +from kalshi.fix.errors import FixLogonError +from kalshi.fix.session import FixSession, FixSessionState, _heartbeat_due, _liveness +from kalshi.fix.tags import Tag + +from .conftest import MockAcceptor + +# asyncio_mode = "auto" (pyproject) runs async tests without an explicit marker, +# so the sync helper tests below stay unmarked (no spurious asyncio warning). + + +def _verify_logon_signature(logon: RawMessage, public_key: rsa.RSAPublicKey) -> None: + """Reconstruct the logon pre-hash from the wire fields and verify RawData.""" + pre_hash = b"\x01".join( + s.encode() + for s in [ + logon.get(Tag.SENDING_TIME) or "", + "A", + str(logon.seq_num), + logon.get(Tag.SENDER_COMP_ID) or "", + logon.get(Tag.TARGET_COMP_ID) or "", + ] + ) + public_key.verify( + base64.b64decode(logon.get(Tag.RAW_DATA) or ""), + pre_hash, + padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.DIGEST_LENGTH), + hashes.SHA256(), + ) + + +async def test_logon_handshake_sends_signed_reset_logon( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + rsa_private_key: rsa.RSAPrivateKey, +) -> None: + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR) + await session.start() + try: + assert session.state is FixSessionState.ACTIVE + # Logon consumed outbound seq 1; next outbound is 2. Logon response was + # seq 1, so next expected inbound is 2. + assert session.outbound_seq_num == 2 + assert session.inbound_seq_num == 2 + + logon = acceptor.first("A") + assert logon is not None + assert logon.get(Tag.SENDER_COMP_ID) == "test-api-key-uuid" + assert logon.get(Tag.TARGET_COMP_ID) == "KalshiNR" + assert logon.get(Tag.RESET_SEQ_NUM_FLAG) == "Y" + assert logon.get(Tag.ENCRYPT_METHOD) == "0" + assert logon.get(Tag.DEFAULT_APPL_VER_ID) == "9" + assert logon.get_int(Tag.HEART_BT_INT) == 4 + # The RawData signature verifies against the account public key. + _verify_logon_signature(logon, rsa_private_key.public_key()) + finally: + await session.close() + + +async def test_logon_rejection_raises( + fix_signer: FixSigner, fix_config: FixConfig, acceptor: MockAcceptor +) -> None: + acceptor.reject_logon = True + acceptor.reject_text = "bad signature" + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR) + with pytest.raises(FixLogonError) as exc: + await session.start() + assert exc.value.reason == "bad signature" + assert session.state is FixSessionState.CLOSED + + +async def test_test_request_answered_with_heartbeat( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR) + await session.start() + try: + await acceptor.push("1", [(int(Tag.TEST_REQ_ID), "ping")], seq=2) + await until(lambda: acceptor.first("0") is not None) + hb = acceptor.first("0") + assert hb is not None + assert hb.get(Tag.TEST_REQ_ID) == "ping" + assert session.inbound_seq_num == 3 + finally: + await session.close() + + +async def test_application_message_delivered_to_callback( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + received: list[RawMessage] = [] + + async def on_message(msg: RawMessage) -> None: + received.append(msg) + + session = FixSession( + fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR, on_message=on_message + ) + await session.start() + try: + await acceptor.push("8", [(int(Tag.CL_ORD_ID), "abc")], seq=2) + await until(lambda: len(received) == 1) + assert received[0].msg_type == "8" + assert received[0].get(Tag.CL_ORD_ID) == "abc" + assert session.inbound_seq_num == 3 + finally: + await session.close() + + +async def test_inbound_gap_fatal_on_non_retransmission_session( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR) + await session.start() + try: + # Expected seq is 2; a jump to 5 is an unrecoverable gap on NR. + await acceptor.push("8", seq=5) + await until(lambda: session.state is FixSessionState.CLOSED) + finally: + await session.close() + + +async def test_inbound_gap_requests_resend_on_rt( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + received: list[RawMessage] = [] + + async def on_message(msg: RawMessage) -> None: + received.append(msg) + + session = FixSession( + fix_signer, fix_config, FixSessionType.ORDER_ENTRY_RT, on_message=on_message + ) + await session.start() + try: + # Gap: expected 2, server sends an app message at 5. + await acceptor.push("8", [(int(Tag.CL_ORD_ID), "late")], seq=5) + # Client should request a resend rather than fail. + await until(lambda: acceptor.first("2") is not None) + resend = acceptor.first("2") + assert resend is not None + assert resend.get_int(Tag.BEGIN_SEQ_NO) == 2 + + # Server resends the missing 2,3,4 (as PossDup heartbeats); 5 then drains. + await acceptor.push("0", seq=2, poss_dup=True) + await acceptor.push("0", seq=3, poss_dup=True) + await acceptor.push("0", seq=4, poss_dup=True) + await until(lambda: session.inbound_seq_num == 6) + # The buffered app message at seq 5 reached the callback after the gap filled. + assert any(m.get(Tag.CL_ORD_ID) == "late" for m in received) + finally: + await session.close() + + +async def test_sequence_reset_gapfill_advances_expected( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR) + await session.start() + try: + # GapFill SequenceReset advances the expected inbound counter to NewSeqNo. + await acceptor.push( + "4", [(int(Tag.GAP_FILL_FLAG), "Y"), (int(Tag.NEW_SEQ_NO), "5")], seq=2 + ) + await until(lambda: session.inbound_seq_num == 5) + finally: + await session.close() + + +async def test_duplicate_inbound_ignored( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + received: list[RawMessage] = [] + + async def on_message(msg: RawMessage) -> None: + received.append(msg) + + session = FixSession( + fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR, on_message=on_message + ) + await session.start() + try: + await acceptor.push("8", [(int(Tag.CL_ORD_ID), "first")], seq=2) + await until(lambda: session.inbound_seq_num == 3) + # Re-deliver seq 2 as a PossDup — must be ignored, not re-dispatched. + await acceptor.push("8", [(int(Tag.CL_ORD_ID), "first")], seq=2, poss_dup=True) + await acceptor.push("8", [(int(Tag.CL_ORD_ID), "second")], seq=3) + await until(lambda: session.inbound_seq_num == 4) + assert [m.get(Tag.CL_ORD_ID) for m in received] == ["first", "second"] + finally: + await session.close() + + +async def test_graceful_close_sends_logout( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR) + await session.start() + await session.close() + await until(lambda: acceptor.first("5") is not None) + assert session.state is FixSessionState.CLOSED + + +async def test_reconnect_after_disconnect( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR) + await session.start() + try: + assert acceptor.connection_count == 1 + # Server drops the connection; the client should reconnect and re-logon. + acceptor.drop_connection() + await until(lambda: acceptor.connection_count == 2, timeout=3.0) + await until(lambda: session.state is FixSessionState.ACTIVE, timeout=3.0) + finally: + await session.close() + + +async def test_context_manager( + fix_signer: FixSigner, fix_config: FixConfig, acceptor: MockAcceptor +) -> None: + async with FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR) as session: + assert session.state is FixSessionState.ACTIVE + assert session.state is FixSessionState.CLOSED + + +async def test_inbound_resend_request_replies_with_gap_fill( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_RT) + await session.start() + try: + out_before = session.outbound_seq_num + # Server asks us to resend from seq 1; we have no message store, so the + # correct reply is a SequenceReset/gap-fill (admin, PossDup, no seq bump). + await acceptor.push( + "2", [(int(Tag.BEGIN_SEQ_NO), "1"), (int(Tag.END_SEQ_NO), "0")], seq=2 + ) + await until(lambda: acceptor.first("4") is not None) + gap_fill = acceptor.first("4") + assert gap_fill is not None + assert gap_fill.seq_num == 1 # MsgSeqNum == BeginSeqNo, not out_seq + assert gap_fill.get(Tag.GAP_FILL_FLAG) == "Y" + assert gap_fill.get(Tag.POSS_DUP_FLAG) == "Y" + assert gap_fill.get(Tag.ORIG_SENDING_TIME) is not None + assert gap_fill.get_int(Tag.NEW_SEQ_NO) == out_before + assert session.outbound_seq_num == out_before # gap-fill must not consume a seq + finally: + await session.close() + + +async def test_too_low_seq_without_possdup_terminates( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR) + await session.start() + try: + # Expected inbound is 2; a non-PossDup message at seq 1 is a fatal error. + await acceptor.push("8", seq=1) + await until(lambda: session.state is FixSessionState.CLOSED) + finally: + await session.close() + + +async def test_logon_gap_requests_resend_on_rt( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + acceptor.logon_resp_seq = 3 # Logon reply arrives ahead of expected (gap) + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_RT) + await session.start() + try: + assert session.state is FixSessionState.ACTIVE + await until(lambda: acceptor.first("2") is not None) + resend = acceptor.first("2") + assert resend is not None + assert resend.get_int(Tag.BEGIN_SEQ_NO) == 1 + finally: + await session.close() + + +async def test_logon_gap_fails_on_non_retransmission_session( + fix_signer: FixSigner, fix_config: FixConfig, acceptor: MockAcceptor +) -> None: + acceptor.logon_resp_seq = 3 + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR) + with pytest.raises(FixLogonError): + await session.start() + assert session.state is FixSessionState.CLOSED + + +async def test_margin_logon_emits_use_dollars( + fix_signer: FixSigner, acceptor: MockAcceptor +) -> None: + config = FixConfig.margin(host="127.0.0.1", port=acceptor.port, use_tls=False) + session = FixSession(fix_signer, config, FixSessionType.ORDER_ENTRY_NR) + await session.start() + try: + logon = acceptor.first("A") + assert logon is not None + assert logon.get(Tag.USE_DOLLARS) == "Y" # margin is always fixed-point dollars + finally: + await session.close() + + +async def test_prediction_logon_omits_use_dollars_by_default( + fix_signer: FixSigner, fix_config: FixConfig, acceptor: MockAcceptor +) -> None: + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR) + await session.start() + try: + logon = acceptor.first("A") + assert logon is not None + assert logon.get(Tag.USE_DOLLARS) is None + assert logon.get(Tag.CANCEL_ORDERS_ON_DISCONNECT) is None + finally: + await session.close() + + +async def test_cancel_orders_on_disconnect_emitted( + fix_signer: FixSigner, acceptor: MockAcceptor +) -> None: + config = FixConfig.prediction( + host="127.0.0.1", port=acceptor.port, use_tls=False, cancel_orders_on_disconnect=True + ) + session = FixSession(fix_signer, config, FixSessionType.ORDER_ENTRY_NR) + await session.start() + try: + logon = acceptor.first("A") + assert logon is not None + assert logon.get(Tag.CANCEL_ORDERS_ON_DISCONNECT) == "Y" + finally: + await session.close() + + +async def test_peer_initiated_logout_tears_down( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR) + await session.start() + try: + await acceptor.push("5", [(int(Tag.TEXT), "bye")], seq=2) + await until(lambda: session.state is FixSessionState.CLOSED) + assert acceptor.connection_count == 1 # no reconnect on peer logout + finally: + await session.close() + + +async def test_rt_reconnect_resumes_without_reset( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_RT) + await session.start() + try: + acceptor.drop_connection() + await until(lambda: acceptor.connection_count == 2, timeout=3.0) + await until(lambda: session.state is FixSessionState.ACTIVE, timeout=3.0) + logons = [m for m in acceptor.received if m.msg_type == "A"] + assert len(logons) >= 2 + # First logon resets; the RT reconnect resumes continuity (no reset flag). + assert logons[0].get(Tag.RESET_SEQ_NUM_FLAG) == "Y" + assert logons[-1].get(Tag.RESET_SEQ_NUM_FLAG) is None + finally: + await session.close() + + +async def test_sequence_reset_never_rewinds( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR) + await session.start() + try: + await acceptor.push("8", seq=2) + await acceptor.push("8", seq=3) + await until(lambda: session.inbound_seq_num == 4) + # A reset-mode SequenceReset below the watermark must NOT rewind. + await acceptor.push("4", [(int(Tag.NEW_SEQ_NO), "2")], seq=4) + await acceptor.push("8", seq=4) # force the recv loop to make a pass + await until(lambda: session.inbound_seq_num == 5) + # A gap-fill SequenceReset below the watermark must also not rewind. + await acceptor.push( + "4", [(int(Tag.GAP_FILL_FLAG), "Y"), (int(Tag.NEW_SEQ_NO), "2")], seq=5 + ) + await acceptor.push("8", seq=5) + await until(lambda: session.inbound_seq_num == 6) + finally: + await session.close() + + +async def test_on_message_exception_does_not_kill_session( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + calls = 0 + + async def boom(_msg: RawMessage) -> None: + nonlocal calls + calls += 1 + raise RuntimeError("consumer bug") + + session = FixSession( + fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR, on_message=boom + ) + await session.start() + try: + await acceptor.push("8", seq=2) + await acceptor.push("8", seq=3) + await until(lambda: session.inbound_seq_num == 4) + assert calls == 2 # both delivered despite raising + assert session.state is FixSessionState.ACTIVE # session survived + finally: + await session.close() + + +async def test_malformed_admin_message_does_not_wedge( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR) + await session.start() + try: + # A TestRequest missing its required TestReqID (112) fails schema + # validation in from_raw — the session must Reject it, not die. + await acceptor.push("1", seq=2) + await until(lambda: acceptor.first("3") is not None) # Reject (35=3) sent + assert session.state is FixSessionState.ACTIVE + assert session.inbound_seq_num == 3 # malformed message consumed, not stuck + finally: + await session.close() + + +async def test_inbound_resend_request_ignored_on_nr( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR) + await session.start() + try: + await acceptor.push( + "2", [(int(Tag.BEGIN_SEQ_NO), "1"), (int(Tag.END_SEQ_NO), "0")], seq=2 + ) + await until(lambda: session.inbound_seq_num == 3) + assert acceptor.first("4") is None # no gap-fill emitted on NR + assert session.state is FixSessionState.ACTIVE + finally: + await session.close() + + +def test_heartbeat_due_threshold() -> None: + assert _heartbeat_due(now=10.0, last_send=5.0, interval=5.0) is True + assert _heartbeat_due(now=9.9, last_send=5.0, interval=5.0) is False + + +def test_liveness_thresholds() -> None: + assert _liveness(now=10.0, last_recv=9.0, interval=5.0) == "ok" # silence 1 + assert _liveness(now=10.0, last_recv=5.0, interval=5.0) == "test_request" # silence 5 + assert _liveness(now=10.0, last_recv=4.9, interval=5.0) == "test_request" # silence 5.1 + assert _liveness(now=10.0, last_recv=0.0, interval=5.0) == "dead" # silence 10 >= 2*interval From a648c08364910f70c1b522cc6b9824cf830f8511 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 5 Jun 2026 18:49:14 -0500 Subject: [PATCH 2/3] =?UTF-8?q?fix(fix):=20address=20PR=20#422=20review=20?= =?UTF-8?q?=E2=80=94=20Logon=20copy,=20field=20caching,=20ssl=20typing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the automated code-review findings on PR #422: - _send signs onto a Logon COPY (model_copy) instead of mutating the caller's object — avoids stale signature bytes on reuse across reconnects - cache the per-class FIX field map (_fix_fields) — deterministic per class and on the hot path once order-entry/market-data application messages land - annotate ssl_context as ssl.SSLContext | None on FixSession and the client facades (was Any / object) - move the UTCTimestamp helpers to kalshi/fix/_timefmt.py so session.py no longer imports a module-private name from messages.base - FixConfig.__post_init__ rejects margin with use_dollars=False (previously silently ignored) - log a warning on an unexpected mid-session inbound Logon - type the acceptor test fixture (AsyncIterator[MockAcceptor]); add an on_state_change transition-coverage test - note SHA256 algorithm-descriptor reuse safety in fix/auth.py ruff + mypy --strict clean; 91 FIX tests pass. Part of #402. Co-Authored-By: Claude Opus 4.8 (1M context) --- kalshi/fix/_timefmt.py | 26 ++++++++++++++++++++++++++ kalshi/fix/auth.py | 3 +++ kalshi/fix/client.py | 7 ++++--- kalshi/fix/messages/base.py | 31 ++++++++++++++++++------------- kalshi/fix/session.py | 30 +++++++++++++++++++----------- kalshi/perps/fix/client.py | 3 ++- tests/fix/conftest.py | 4 ++-- tests/fix/test_messages.py | 20 ++++++++------------ tests/fix/test_session.py | 26 ++++++++++++++++++++++++++ 9 files changed, 108 insertions(+), 42 deletions(-) create mode 100644 kalshi/fix/_timefmt.py diff --git a/kalshi/fix/_timefmt.py b/kalshi/fix/_timefmt.py new file mode 100644 index 00000000..44a05fc1 --- /dev/null +++ b/kalshi/fix/_timefmt.py @@ -0,0 +1,26 @@ +"""FIX UTCTimestamp formatting/parsing (millisecond precision). + +Shared by the message codec (UTCTIMESTAMP fields, :mod:`kalshi.fix.messages.base`) +and the session layer (``SendingTime``, :mod:`kalshi.fix.session`). Lives in its +own small internal module so neither importer reaches across into the other's +private helpers. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + + +def format_utc_timestamp(dt: datetime) -> str: + """FIX UTCTimestamp with millisecond precision: ``YYYYMMDD-HH:MM:SS.sss``. + + Milliseconds are floor-truncated from microseconds (not rounded) so the + value is byte-stable — the logon pre-hash must match tag 52 exactly. + """ + return dt.strftime("%Y%m%d-%H:%M:%S.") + f"{dt.microsecond // 1000:03d}" + + +def parse_utc_timestamp(value: str) -> datetime: + """Parse a FIX UTCTimestamp (with or without fractional seconds) as UTC-aware.""" + fmt = "%Y%m%d-%H:%M:%S.%f" if "." in value else "%Y%m%d-%H:%M:%S" + return datetime.strptime(value, fmt).replace(tzinfo=UTC) diff --git a/kalshi/fix/auth.py b/kalshi/fix/auth.py index a5318f32..e5225c3d 100644 --- a/kalshi/fix/auth.py +++ b/kalshi/fix/auth.py @@ -31,6 +31,9 @@ # MGF1(SHA-256) / salt_length = digest length). Kept as a local copy rather than # importing kalshi.auth's private module constants so the scheme is explicit at # the FIX call site; if kalshi.auth ever changes its padding, update both. +# ``hashes.SHA256()`` is a stateless algorithm *descriptor* (not a live hashing +# context), so a single module-level instance is safe to reuse for both MGF1 and +# the outer sign() call — the same pattern kalshi.auth uses. _FIX_SHA256 = hashes.SHA256() _FIX_PSS_PADDING = padding.PSS( mgf=padding.MGF1(_FIX_SHA256), diff --git a/kalshi/fix/client.py b/kalshi/fix/client.py index a113a842..401bfcc1 100644 --- a/kalshi/fix/client.py +++ b/kalshi/fix/client.py @@ -8,6 +8,7 @@ from __future__ import annotations +import ssl from collections.abc import Callable from typing import ClassVar, Self @@ -33,7 +34,7 @@ def __init__( *, environment: FixEnvironment = FixEnvironment.PRODUCTION, config: FixConfig | None = None, - ssl_context: object | None = None, + ssl_context: ssl.SSLContext | None = None, ) -> None: self._signer = signer self._ssl_context = ssl_context @@ -53,7 +54,7 @@ def from_auth( *, environment: FixEnvironment = FixEnvironment.PRODUCTION, config: FixConfig | None = None, - ssl_context: object | None = None, + ssl_context: ssl.SSLContext | None = None, ) -> Self: """Build a client reusing an existing :class:`KalshiAuth`'s key.""" return cls( @@ -145,7 +146,7 @@ def from_env( *, environment: FixEnvironment = FixEnvironment.PRODUCTION, config: FixConfig | None = None, - ssl_context: object | None = None, + ssl_context: ssl.SSLContext | None = None, password: bytes | str | Callable[[], bytes | str] | None = None, ) -> FixClient: """Build a prediction FIX client from the ``KALSHI_*`` environment vars. diff --git a/kalshi/fix/messages/base.py b/kalshi/fix/messages/base.py index 5880d829..2ee5fbf8 100644 --- a/kalshi/fix/messages/base.py +++ b/kalshi/fix/messages/base.py @@ -22,13 +22,13 @@ from __future__ import annotations -from datetime import UTC, datetime from decimal import Decimal from enum import StrEnum from typing import Any, ClassVar, Self from pydantic import BaseModel, ConfigDict, Field +from kalshi.fix._timefmt import format_utc_timestamp, parse_utc_timestamp from kalshi.fix.codec import RawMessage from kalshi.fix.enums import MsgType from kalshi.fix.tags import DATA_LENGTH_FIELDS @@ -83,7 +83,7 @@ def _to_wire(value: Any, fix_type: FixType) -> str: # value is a Decimal; ``f"{d:f}"`` avoids scientific notation and float drift. return f"{value:f}" if fix_type is FixType.UTCTIMESTAMP: - return _format_utc_timestamp(value) + return format_utc_timestamp(value) if fix_type is FixType.LOCALMKTDATE: return value if isinstance(value, str) else value.strftime("%Y%m%d") # STRING / CHAR / MULTIPLEVALUESTRING / INT-likes / enums. ``str()`` of a @@ -108,19 +108,16 @@ def _from_wire(raw: str, fix_type: FixType) -> Any: if fix_type in _DECIMAL_TYPES: return Decimal(raw) if fix_type is FixType.UTCTIMESTAMP: - return _parse_utc_timestamp(raw) + return parse_utc_timestamp(raw) return raw -def _format_utc_timestamp(dt: datetime) -> str: - """FIX UTCTimestamp with millisecond precision: ``YYYYMMDD-HH:MM:SS.sss``.""" - return dt.strftime("%Y%m%d-%H:%M:%S.") + f"{dt.microsecond // 1000:03d}" - - -def _parse_utc_timestamp(value: str) -> datetime: - """Parse a FIX UTCTimestamp (with or without fractional seconds) as UTC-aware.""" - fmt = "%Y%m%d-%H:%M:%S.%f" if "." in value else "%Y%m%d-%H:%M:%S" - return datetime.strptime(value, fmt).replace(tzinfo=UTC) +# Per-class cache of the introspected FIX field map. The map is deterministic +# per message class and, once application messages land, is read on every +# encode/decode of every order and execution report — so caching it avoids +# re-walking ``model_fields`` on the hot path. Classes live for the process +# lifetime, so a plain dict keyed by class is fine. +_FIX_FIELDS_CACHE: dict[type[FixMessage], list[tuple[str, int, FixType]]] = {} class FixMessage(BaseModel): @@ -139,7 +136,14 @@ class FixMessage(BaseModel): @classmethod def _fix_fields(cls) -> list[tuple[str, int, FixType]]: - """``(attr_name, tag, fix_type)`` for each FIX-mapped field, in declaration order.""" + """``(attr_name, tag, fix_type)`` for each FIX-mapped field, in declaration order. + + Cached per class (see :data:`_FIX_FIELDS_CACHE`). Callers iterate the + result and must not mutate it. + """ + cached = _FIX_FIELDS_CACHE.get(cls) + if cached is not None: + return cached out: list[tuple[str, int, FixType]] = [] for name, field in cls.model_fields.items(): extra = field.json_schema_extra @@ -147,6 +151,7 @@ def _fix_fields(cls) -> list[tuple[str, int, FixType]]: tag = int(extra["fix_tag"]) # type: ignore[arg-type] fix_type = FixType(str(extra["fix_type"])) out.append((name, tag, fix_type)) + _FIX_FIELDS_CACHE[cls] = out return out def to_body_fields(self) -> list[tuple[int, str]]: diff --git a/kalshi/fix/session.py b/kalshi/fix/session.py index 851d939b..5f0787fd 100644 --- a/kalshi/fix/session.py +++ b/kalshi/fix/session.py @@ -27,22 +27,23 @@ import contextlib import logging import random +import ssl import time from collections.abc import Awaitable, Callable from datetime import UTC, datetime from enum import Enum from types import TracebackType -from typing import Any from pydantic import ValidationError +from kalshi.fix._timefmt import format_utc_timestamp from kalshi.fix.auth import FixSigner from kalshi.fix.codec import RawMessage, encode from kalshi.fix.config import FixConfig, FixSessionType from kalshi.fix.connection import FixConnection from kalshi.fix.enums import MsgType from kalshi.fix.errors import FixConnectionError, FixLogonError, FixSequenceError, FixSessionError -from kalshi.fix.messages.base import FixMessage, _format_utc_timestamp +from kalshi.fix.messages.base import FixMessage from kalshi.fix.messages.session import ( Heartbeat, Logon, @@ -111,7 +112,7 @@ def __init__( *, on_message: MessageHandler | None = None, on_state_change: StateChangeHandler | None = None, - ssl_context: Any = None, + ssl_context: ssl.SSLContext | None = None, ) -> None: config._check_session(session_type) self._signer = signer @@ -251,13 +252,18 @@ async def _send(self, message: FixMessage) -> int: # different message on the next reconnect. self._out_seq += 1 sending_time = self._now_sending_time() - # Logon's RawData signature is bound to this exact seq + sending_time, - # so (re)sign here rather than trusting any preset value. + # Logon's RawData signature is bound to this exact seq + sending_time. + # Sign onto a COPY rather than mutating the caller's object (which a + # caller might reuse across reconnects or inspect after send()). if isinstance(message, Logon): - message.raw_data = self._signer.sign_logon( - sending_time=sending_time, - msg_seq_num=seq, - target_comp_id=self._target, + message = message.model_copy( + update={ + "raw_data": self._signer.sign_logon( + sending_time=sending_time, + msg_seq_num=seq, + target_comp_id=self._target, + ) + } ) header: list[tuple[int, str]] = [ (int(Tag.MSG_TYPE), type(message).MSG_TYPE.value), @@ -301,7 +307,7 @@ async def _send_gap_fill(self, begin_seq_no: int) -> None: self._last_send = time.monotonic() def _now_sending_time(self) -> str: - return _format_utc_timestamp(datetime.now(UTC)) + return format_utc_timestamp(datetime.now(UTC)) # ------------------------------------------------------------------ # Connect + logon @@ -566,7 +572,9 @@ async def _process(self, raw: RawMessage, mt: str | None) -> None: await self._set_state(FixSessionState.CLOSED) return if mt == MsgType.LOGON: - # Mid-session Logon is unexpected once ACTIVE; ignore. + # A mid-session Logon is a protocol anomaly once ACTIVE; log and + # ignore (Kalshi's gateway drives session lifecycle via Logout). + logger.warning("unexpected mid-session Logon from peer; ignoring") return if mt == MsgType.REJECT: rj = Reject.from_raw(raw) diff --git a/kalshi/perps/fix/client.py b/kalshi/perps/fix/client.py index 607ab2df..e256b6e1 100644 --- a/kalshi/perps/fix/client.py +++ b/kalshi/perps/fix/client.py @@ -14,6 +14,7 @@ from __future__ import annotations +import ssl from collections.abc import Callable from kalshi.errors import KalshiAuthError @@ -40,7 +41,7 @@ def from_env( *, environment: FixEnvironment = FixEnvironment.PRODUCTION, config: FixConfig | None = None, - ssl_context: object | None = None, + ssl_context: ssl.SSLContext | None = None, password: bytes | str | Callable[[], bytes | str] | None = None, ) -> MarginFixClient: """Build a margin FIX client from the ``KALSHI_PERPS_*`` environment vars.""" diff --git a/tests/fix/conftest.py b/tests/fix/conftest.py index f3bf1763..a30b66b0 100644 --- a/tests/fix/conftest.py +++ b/tests/fix/conftest.py @@ -12,7 +12,7 @@ import asyncio import contextlib -from collections.abc import Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable import pytest from cryptography.hazmat.primitives.asymmetric import rsa @@ -164,7 +164,7 @@ def first(self, msg_type: str) -> RawMessage | None: @pytest.fixture -async def acceptor() -> object: +async def acceptor() -> AsyncIterator[MockAcceptor]: a = MockAcceptor() await a.start() try: diff --git a/tests/fix/test_messages.py b/tests/fix/test_messages.py index 5779393d..72a68090 100644 --- a/tests/fix/test_messages.py +++ b/tests/fix/test_messages.py @@ -7,6 +7,7 @@ import pytest from pydantic import ValidationError +from kalshi.fix._timefmt import format_utc_timestamp, parse_utc_timestamp from kalshi.fix.codec import decode, encode from kalshi.fix.enums import ApplVerID, EncryptMethod, MsgType from kalshi.fix.messages import ( @@ -18,12 +19,7 @@ SequenceReset, TestRequest, ) -from kalshi.fix.messages.base import ( - FixType, - _format_utc_timestamp, - _from_wire, - _parse_utc_timestamp, -) +from kalshi.fix.messages.base import FixType, _from_wire from kalshi.fix.tags import Tag @@ -138,9 +134,9 @@ def test_heartbeat_optional_test_req_id() -> None: def test_utc_timestamp_format_and_parse() -> None: dt = datetime(2025, 9, 26, 21, 54, 7, 1000, tzinfo=UTC) - assert _format_utc_timestamp(dt) == "20250926-21:54:07.001" - assert _parse_utc_timestamp("20250926-21:54:07.001") == dt - assert _parse_utc_timestamp("20250926-21:54:07") == datetime( + assert format_utc_timestamp(dt) == "20250926-21:54:07.001" + assert parse_utc_timestamp("20250926-21:54:07.001") == dt + assert parse_utc_timestamp("20250926-21:54:07") == datetime( 2025, 9, 26, 21, 54, 7, tzinfo=UTC ) @@ -149,14 +145,14 @@ def test_utc_timestamp_truncates_sub_millisecond() -> None: # Millisecond precision via floor truncation (not rounding) — the SendingTime # in the logon pre-hash must be byte-identical to tag 52. assert ( - _format_utc_timestamp(datetime(2025, 9, 26, 21, 54, 7, 123456, tzinfo=UTC)) + format_utc_timestamp(datetime(2025, 9, 26, 21, 54, 7, 123456, tzinfo=UTC)) == "20250926-21:54:07.123" ) assert ( - _format_utc_timestamp(datetime(2025, 9, 26, 21, 54, 7, 999999, tzinfo=UTC)) + format_utc_timestamp(datetime(2025, 9, 26, 21, 54, 7, 999999, tzinfo=UTC)) == "20250926-21:54:07.999" ) - assert _parse_utc_timestamp("20250926-21:54:07.123456").microsecond == 123456 + assert parse_utc_timestamp("20250926-21:54:07.123456").microsecond == 123456 def test_boolean_parsing_is_strict() -> None: diff --git a/tests/fix/test_session.py b/tests/fix/test_session.py index e49cb3fb..c3ca2668 100644 --- a/tests/fix/test_session.py +++ b/tests/fix/test_session.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 +import itertools from collections.abc import Awaitable, Callable import pytest @@ -262,6 +263,31 @@ async def test_context_manager( assert session.state is FixSessionState.CLOSED +async def test_on_state_change_callback_fires( + fix_signer: FixSigner, fix_config: FixConfig, acceptor: MockAcceptor +) -> None: + transitions: list[tuple[FixSessionState, FixSessionState]] = [] + + async def on_change(old: FixSessionState, new: FixSessionState) -> None: + transitions.append((old, new)) + + session = FixSession( + fix_signer, fix_config, FixSessionType.ORDER_ENTRY_NR, on_state_change=on_change + ) + await session.start() + await session.close() + + news = [new for _, new in transitions] + # Logon drives CONNECTING -> LOGGING_ON -> ACTIVE; close ends at CLOSED. + assert FixSessionState.CONNECTING in news + assert FixSessionState.LOGGING_ON in news + assert FixSessionState.ACTIVE in news + assert news[-1] is FixSessionState.CLOSED + # Each transition's "old" chains from the previous "new". + for prev, cur in itertools.pairwise(transitions): + assert cur[0] is prev[1] + + async def test_inbound_resend_request_replies_with_gap_fill( fix_signer: FixSigner, fix_config: FixConfig, From 01ec0ad03afe582f9623d8b4bc92c88702479f50 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Fri, 5 Jun 2026 18:57:47 -0500 Subject: [PATCH 3/3] =?UTF-8?q?fix(fix):=20address=20PR=20#422=20second=20?= =?UTF-8?q?review=20=E2=80=94=20gap-fill=20buffering,=20Self=20returns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _handle_sequence_reset now BUFFERS an out-of-order gap-fill SequenceReset (seq ahead of expected, RT/PT) and _drain_pending applies its NewSeqNo jump once the preceding gap is recovered (extracted _apply_sequence_reset_forward). Previously the reset was dropped and _in_seq never advanced. - FixClient.from_env / MarginFixClient.from_env now return Self (subclass-correct, matching from_auth) - _send_gap_fill: comment explains WHY it bypasses _send (begin_seq_no as MsgSeqNum; must not consume an outbound seq) - document the KALSHI_FIX_ALLOW_UNKNOWN_HOST escape hatch in FixConfig's docstring - MockAcceptor clears _connected on disconnect so wait_connected() is valid after a reconnect - drop the misleading interleaved category comments in kalshi.fix.__all__ tests: - out-of-order gap-fill SequenceReset buffering on RT (pins the bug-1 fix) - new tests/fix/test_client.py: from_auth/from_env return the concrete subclass, prediction exposes all six sessions, margin lacks prediction-only helpers and rejects an RFQ session, product/config mismatch raises ruff + mypy --strict clean; 97 FIX tests pass. Part of #402. Co-Authored-By: Claude Opus 4.8 (1M context) --- kalshi/fix/__init__.py | 8 +---- kalshi/fix/client.py | 2 +- kalshi/fix/config.py | 5 +++ kalshi/fix/session.py | 34 +++++++++++++++++--- kalshi/perps/fix/client.py | 3 +- tests/fix/conftest.py | 4 +++ tests/fix/test_client.py | 66 ++++++++++++++++++++++++++++++++++++++ tests/fix/test_session.py | 24 ++++++++++++++ 8 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 tests/fix/test_client.py diff --git a/kalshi/fix/__init__.py b/kalshi/fix/__init__.py index 09be8e43..d20a5061 100644 --- a/kalshi/fix/__init__.py +++ b/kalshi/fix/__init__.py @@ -76,6 +76,7 @@ from kalshi.fix.session import FixSession, FixSessionState from kalshi.fix.tags import Tag +# Sorted (ruff RUF022); grouping is by the imports above, not by ``__all__`` order. __all__ = [ "BEGIN_STRING_FIXT11", "SOH", @@ -83,16 +84,13 @@ "EncryptMethod", "ExecInst", "ExecType", - # Clients / sessions "FixClient", "FixCodecError", - # Config "FixConfig", "FixConnection", "FixConnectionError", "FixEnvironment", "FixLogonError", - # Messages "FixMessage", "FixParser", "FixProduct", @@ -102,14 +100,11 @@ "FixSessionError", "FixSessionState", "FixSessionType", - # Auth "FixSigner", "Heartbeat", - # Errors "KalshiFixError", "Logon", "Logout", - # Enums "MsgType", "OrdStatus", "OrdType", @@ -124,6 +119,5 @@ "TestRequest", "TimeInForce", "decode", - # Codec "encode", ] diff --git a/kalshi/fix/client.py b/kalshi/fix/client.py index 401bfcc1..e9be68ac 100644 --- a/kalshi/fix/client.py +++ b/kalshi/fix/client.py @@ -148,7 +148,7 @@ def from_env( config: FixConfig | None = None, ssl_context: ssl.SSLContext | None = None, password: bytes | str | Callable[[], bytes | str] | None = None, - ) -> FixClient: + ) -> Self: """Build a prediction FIX client from the ``KALSHI_*`` environment vars. ``password`` (or ``KALSHI_PRIVATE_KEY_PASSPHRASE``) decrypts a diff --git a/kalshi/fix/config.py b/kalshi/fix/config.py index 8c9bb508..ce8328e2 100644 --- a/kalshi/fix/config.py +++ b/kalshi/fix/config.py @@ -117,6 +117,11 @@ class FixConfig: mock server or a local TLS proxy (stunnel) and, when set, apply to every session. + A non-loopback ``host`` override outside the known Kalshi FIX endpoints is + rejected unless ``allow_unknown_host=True`` or the process-wide + ``KALSHI_FIX_ALLOW_UNKNOWN_HOST=1`` environment variable is set (an escape + hatch for CI / staging proxies). + Use :meth:`prediction` / :meth:`margin` for the canonical environments. """ diff --git a/kalshi/fix/session.py b/kalshi/fix/session.py index 5f0787fd..b2f984d5 100644 --- a/kalshi/fix/session.py +++ b/kalshi/fix/session.py @@ -290,6 +290,9 @@ async def _send_gap_fill(self, begin_seq_no: int) -> None: async with self._send_lock: if self._connection is None or not self._connection.is_open: return + # Deliberately NOT via _send(): a gap-fill must carry begin_seq_no as + # its MsgSeqNum (not the next outbound slot) and must not consume an + # outbound sequence number — _send() always assigns + increments _out_seq. sending_time = self._now_sending_time() new_seq_no = max(self._out_seq, begin_seq_no + 1) sr = SequenceReset(gap_fill_flag=True, new_seq_no=new_seq_no) @@ -504,7 +507,11 @@ async def _handle_sequence_reset(self, raw: RawMessage) -> None: seq = raw.seq_num if bool(sr.gap_fill_flag) and seq is not None and seq > self._in_seq: + # Out-of-order gap-fill: it is itself ahead of the expected seq. + # Buffer it so _drain_pending applies its NewSeqNo jump once the + # preceding gap is recovered (RT/PT); otherwise it is unrecoverable. if self._supports_retransmission: + self._pending[seq] = raw await self._request_resend() return raise FixSequenceError( @@ -514,21 +521,40 @@ async def _handle_sequence_reset(self, raw: RawMessage) -> None: received=seq, ) + self._apply_sequence_reset_forward(sr) + await self._drain_pending() + + def _apply_sequence_reset_forward(self, sr: SequenceReset) -> None: + """Advance the expected inbound counter to ``NewSeqNo`` (forward only). + + Never rewinds (a stale/duplicate reset must not re-process delivered + messages); drops any buffered messages now behind the watermark. + """ if sr.new_seq_no > self._in_seq: self._in_seq = sr.new_seq_no - # Drop any buffered messages now behind the watermark. for s in [s for s in self._pending if s < self._in_seq]: del self._pending[s] if not self._pending: self._resend_requested = False - await self._drain_pending() async def _drain_pending(self) -> None: """Process buffered out-of-order messages now made contiguous.""" while self._in_seq in self._pending: raw = self._pending.pop(self._in_seq) - await self._safe_process(raw, raw.msg_type, self._in_seq) - self._in_seq += 1 + if raw.msg_type == MsgType.SEQUENCE_RESET: + # A buffered gap-fill SequenceReset applies its own NewSeqNo jump + # (it does not consume a single slot), so route it through the + # reset logic rather than _safe_process. + try: + sr = SequenceReset.from_raw(raw) + except (ValidationError, ValueError): + logger.warning("malformed buffered SequenceReset; skipping", exc_info=True) + self._in_seq += 1 + continue + self._apply_sequence_reset_forward(sr) + else: + await self._safe_process(raw, raw.msg_type, self._in_seq) + self._in_seq += 1 if not self._pending: self._resend_requested = False diff --git a/kalshi/perps/fix/client.py b/kalshi/perps/fix/client.py index e256b6e1..40b8f17d 100644 --- a/kalshi/perps/fix/client.py +++ b/kalshi/perps/fix/client.py @@ -16,6 +16,7 @@ import ssl from collections.abc import Callable +from typing import Self from kalshi.errors import KalshiAuthError from kalshi.fix.auth import FixSigner @@ -43,7 +44,7 @@ def from_env( config: FixConfig | None = None, ssl_context: ssl.SSLContext | None = None, password: bytes | str | Callable[[], bytes | str] | None = None, - ) -> MarginFixClient: + ) -> Self: """Build a margin FIX client from the ``KALSHI_PERPS_*`` environment vars.""" auth = try_perps_auth_from_env(password=password) if auth is None: diff --git a/tests/fix/conftest.py b/tests/fix/conftest.py index a30b66b0..73c79309 100644 --- a/tests/fix/conftest.py +++ b/tests/fix/conftest.py @@ -86,6 +86,10 @@ async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWrit await self._respond(msg, writer) except (ConnectionResetError, asyncio.CancelledError, OSError): pass + finally: + # Clear so wait_connected() blocks until the *next* connection after a + # drop/reconnect, rather than returning on the stale prior connect. + self._connected.clear() async def _respond(self, msg: RawMessage, writer: asyncio.StreamWriter) -> None: mt = msg.msg_type diff --git a/tests/fix/test_client.py b/tests/fix/test_client.py new file mode 100644 index 00000000..71590f7d --- /dev/null +++ b/tests/fix/test_client.py @@ -0,0 +1,66 @@ +"""Tests for the FIX client facades (FixClient / MarginFixClient).""" + +from __future__ import annotations + +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa + +from kalshi.auth import KalshiAuth +from kalshi.fix import FixClient, FixConfig, FixEnvironment, FixSessionType +from kalshi.perps.fix import MarginFixClient + + +def test_from_auth_returns_concrete_subclass(rsa_private_key: rsa.RSAPrivateKey) -> None: + auth = KalshiAuth(key_id="k", private_key=rsa_private_key) + + class Sub(FixClient): + pass + + assert type(Sub.from_auth(auth)) is Sub # -> Self, not _BaseFixClient + assert type(FixClient.from_auth(auth)) is FixClient + + +def test_from_env_returns_concrete_subclass( + monkeypatch: pytest.MonkeyPatch, pem_string: str +) -> None: + monkeypatch.setenv("KALSHI_KEY_ID", "k") + monkeypatch.setenv("KALSHI_PRIVATE_KEY", pem_string) + monkeypatch.delenv("KALSHI_PRIVATE_KEY_PATH", raising=False) + + class Sub(FixClient): + pass + + assert type(Sub.from_env(environment=FixEnvironment.DEMO)) is Sub + assert type(FixClient.from_env(environment=FixEnvironment.DEMO)) is FixClient + + +def test_prediction_client_exposes_all_sessions(rsa_private_key: rsa.RSAPrivateKey) -> None: + client = FixClient.from_auth( + KalshiAuth(key_id="k", private_key=rsa_private_key), environment=FixEnvironment.DEMO + ) + assert client.order_entry().session_type is FixSessionType.ORDER_ENTRY_NR + assert client.order_entry(retransmission=True).session_type is FixSessionType.ORDER_ENTRY_RT + assert client.drop_copy().session_type is FixSessionType.DROP_COPY + assert client.market_data().session_type is FixSessionType.MARKET_DATA + assert client.post_trade().session_type is FixSessionType.POST_TRADE + assert client.rfq().session_type is FixSessionType.RFQ + + +def test_margin_client_has_no_prediction_only_helpers( + rsa_private_key: rsa.RSAPrivateKey, +) -> None: + client = MarginFixClient.from_auth( + KalshiAuth(key_id="k", private_key=rsa_private_key), environment=FixEnvironment.DEMO + ) + assert client.order_entry().session_type is FixSessionType.ORDER_ENTRY_NR + assert not hasattr(client, "rfq") + assert not hasattr(client, "post_trade") + # The shared session() path still rejects a prediction-only session for margin. + with pytest.raises(ValueError, match="not available for product"): + client.session(FixSessionType.RFQ) + + +def test_product_config_mismatch_rejected(rsa_private_key: rsa.RSAPrivateKey) -> None: + auth = KalshiAuth(key_id="k", private_key=rsa_private_key) + with pytest.raises(ValueError, match="does not match this client's product"): + FixClient.from_auth(auth, config=FixConfig.margin()) diff --git a/tests/fix/test_session.py b/tests/fix/test_session.py index c3ca2668..441972f2 100644 --- a/tests/fix/test_session.py +++ b/tests/fix/test_session.py @@ -468,6 +468,30 @@ async def test_sequence_reset_never_rewinds( await session.close() +async def test_out_of_order_gapfill_sequence_reset_is_buffered_on_rt( + fix_signer: FixSigner, + fix_config: FixConfig, + acceptor: MockAcceptor, + until: Callable[..., Awaitable[None]], +) -> None: + session = FixSession(fix_signer, fix_config, FixSessionType.ORDER_ENTRY_RT) + await session.start() + try: + # A gap-fill SequenceReset arrives AHEAD of expected (seq 5, expected 2): + # it must be buffered and applied only once the preceding gap is filled. + await acceptor.push( + "4", [(int(Tag.GAP_FILL_FLAG), "Y"), (int(Tag.NEW_SEQ_NO), "7")], seq=5 + ) + await until(lambda: acceptor.first("2") is not None) # resend requested + # Fill 2,3,4; then the buffered reset at 5 applies NewSeqNo -> in_seq == 7. + await acceptor.push("0", seq=2, poss_dup=True) + await acceptor.push("0", seq=3, poss_dup=True) + await acceptor.push("0", seq=4, poss_dup=True) + await until(lambda: session.inbound_seq_num == 7) + finally: + await session.close() + + async def test_on_message_exception_does_not_kill_session( fix_signer: FixSigner, fix_config: FixConfig,