Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions kalshi/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
123 changes: 123 additions & 0 deletions kalshi/fix/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""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

# Sorted (ruff RUF022); grouping is by the imports above, not by ``__all__`` order.
__all__ = [
"BEGIN_STRING_FIXT11",
"SOH",
"ApplVerID",
"EncryptMethod",
"ExecInst",
"ExecType",
"FixClient",
"FixCodecError",
"FixConfig",
"FixConnection",
"FixConnectionError",
"FixEnvironment",
"FixLogonError",
"FixMessage",
"FixParser",
"FixProduct",
"FixRejectError",
"FixSequenceError",
"FixSession",
"FixSessionError",
"FixSessionState",
"FixSessionType",
"FixSigner",
"Heartbeat",
"KalshiFixError",
"Logon",
"Logout",
"MsgType",
"OrdStatus",
"OrdType",
"RawMessage",
"Reject",
"ResendRequest",
"SelfTradePreventionType",
"SequenceReset",
"SessionRejectReason",
"Side",
"Tag",
"TestRequest",
"TimeInForce",
"decode",
"encode",
]
26 changes: 26 additions & 0 deletions kalshi/fix/_timefmt.py
Original file line number Diff line number Diff line change
@@ -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)
131 changes: 131 additions & 0 deletions kalshi/fix/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""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.
# ``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),
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")
Loading