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
8 changes: 8 additions & 0 deletions kalshi/fix/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,12 @@
from kalshi.fix.enums import (
AcceptQuoteResult,
ApplVerID,
CollateralAmountType,
EncryptMethod,
EventResendRejectReason,
ExecInst,
ExecType,
MarketResult,
MDEntryType,
MDReqRejReason,
MDUpdateAction,
Expand Down Expand Up @@ -98,6 +100,7 @@
MarketDataRequestReject,
MarketDataSnapshotFullRefresh,
MarketSettlementParty,
MarketSettlementReport,
MDIncrementalEntry,
MDSnapshotEntry,
MiscFee,
Expand Down Expand Up @@ -134,6 +137,7 @@
)
from kalshi.fix.orderbook import BookLevel, FixOrderBook, MarketDataBook
from kalshi.fix.session import FixSession, FixSessionState
from kalshi.fix.settlement import SettlementReassembler
from kalshi.fix.tags import Tag

# Sorted (ruff RUF022); grouping is by the imports above, not by ``__all__`` order.
Expand All @@ -148,6 +152,7 @@
"BookLevel",
"BusinessMessageReject",
"CollateralAmountChange",
"CollateralAmountType",
"EncryptMethod",
"EventResendComplete",
"EventResendReject",
Expand Down Expand Up @@ -190,7 +195,9 @@
"MarketDataRequest",
"MarketDataRequestReject",
"MarketDataSnapshotFullRefresh",
"MarketResult",
"MarketSettlementParty",
"MarketSettlementReport",
"MiscFee",
"MsgType",
"MultivariateSelectedLeg",
Expand Down Expand Up @@ -233,6 +240,7 @@
"SelfTradePreventionType",
"SequenceReset",
"SessionRejectReason",
"SettlementReassembler",
"Side",
"SubscriptionRequestType",
"Tag",
Expand Down
8 changes: 8 additions & 0 deletions kalshi/fix/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ class FixConfig:
# an order-entry capability. Valid on NR/RT and requires skip_pending_exec_reports.
listener_session: bool = False
skip_pending_exec_reports: bool = False
# Settlement-report stream (tag 20127), applies to RT/PT logon. ``None`` omits
# the flag — the gateway then applies its default (on for KalshiPT, off for
# KalshiRT). Set ``True`` to opt KalshiRT in, ``False`` to opt KalshiPT out.
receive_settlement_reports: bool | None = None
# ``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
Expand Down Expand Up @@ -207,6 +211,7 @@ def prediction(
cancel_orders_on_disconnect: bool = False,
listener_session: bool = False,
skip_pending_exec_reports: bool = False,
receive_settlement_reports: bool | None = None,
use_dollars: bool | None = None,
allow_unknown_host: bool = False,
) -> FixConfig:
Expand All @@ -225,6 +230,7 @@ def prediction(
cancel_orders_on_disconnect=cancel_orders_on_disconnect,
listener_session=listener_session,
skip_pending_exec_reports=skip_pending_exec_reports,
receive_settlement_reports=receive_settlement_reports,
use_dollars=use_dollars,
allow_unknown_host=allow_unknown_host,
)
Expand All @@ -245,6 +251,7 @@ def margin(
cancel_orders_on_disconnect: bool = False,
listener_session: bool = False,
skip_pending_exec_reports: bool = False,
receive_settlement_reports: bool | None = None,
use_dollars: bool = True,
allow_unknown_host: bool = False,
) -> FixConfig:
Expand All @@ -263,6 +270,7 @@ def margin(
cancel_orders_on_disconnect=cancel_orders_on_disconnect,
listener_session=listener_session,
skip_pending_exec_reports=skip_pending_exec_reports,
receive_settlement_reports=receive_settlement_reports,
use_dollars=use_dollars,
allow_unknown_host=allow_unknown_host,
)
Expand Down
15 changes: 15 additions & 0 deletions kalshi/fix/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,21 @@ class OrderGroupAction(IntEnum):
UPDATE = 5


class MarketResult(StrEnum):
"""Tag 20107 — market outcome on a MarketSettlementReport (35=UMS)."""

YES = "yes"
NO = "no"
SCALAR = "scalar"


class CollateralAmountType(StrEnum):
"""Tag 1705 — kind of collateral change in a settlement collateral entry."""

BALANCE = "BALANCE"
PAYOUT = "PAYOUT"


class EventResendRejectReason(IntEnum):
"""Tag 21004 — why a drop-copy EventResendRequest (35=U3) was rejected."""

Expand Down
2 changes: 2 additions & 0 deletions kalshi/fix/messages/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
SequenceReset,
TestRequest,
)
from kalshi.fix.messages.settlement import MarketSettlementReport

__all__ = [
"APP_MESSAGE_MODELS",
Expand All @@ -100,6 +101,7 @@
"MarketDataRequestReject",
"MarketDataSnapshotFullRefresh",
"MarketSettlementParty",
"MarketSettlementReport",
"MiscFee",
"MultivariateSelectedLeg",
"NewOrderSingle",
Expand Down
3 changes: 3 additions & 0 deletions kalshi/fix/messages/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
QuoteStatusReport,
RFQCancelStatus,
)
from kalshi.fix.messages.settlement import MarketSettlementReport

logger = logging.getLogger("kalshi.fix")

Expand Down Expand Up @@ -70,6 +71,8 @@
MsgType.QUOTE_CANCEL_STATUS.value: QuoteCancelStatus,
MsgType.RFQ_CANCEL_STATUS.value: RFQCancelStatus,
MsgType.ACCEPT_QUOTE_STATUS.value: AcceptQuoteStatus,
# Market settlement (post trade)
MsgType.MARKET_SETTLEMENT_REPORT.value: MarketSettlementReport,
}
)

Expand Down
66 changes: 66 additions & 0 deletions kalshi/fix/messages/settlement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Market-settlement / post-trade FIX messages (GH #429) — prediction only.

A :class:`MarketSettlementReport` (35=UMS) is a read-only settlement notification
streamed on the ``KalshiPT`` session (and on ``KalshiRT`` when
``ReceiveSettlementReports`` is opted in). Per-party collateral changes and fees
are nested inside the ``NoMarketSettlementPartyIDs`` group (the shared
:class:`~kalshi.fix.messages.components.MarketSettlementParty` entry, which itself
nests ``NoCollateralAmountChanges`` and ``NoMiscFees``).

Fields are optional and code fields raw (compare against :mod:`kalshi.fix.enums`)
per the inbound convention. Large settlement batches paginate across several
fragments — correlate them by ``Symbol`` (the report id differs per page) and
reassemble with :class:`~kalshi.fix.settlement.SettlementReassembler`.
"""

from __future__ import annotations

from typing import Annotated

from kalshi.fix.enums import MsgType
from kalshi.fix.messages.base import (
FixGroupMeta,
FixMessage,
FixType,
fixfield,
groupfield,
)
from kalshi.fix.messages.components import MarketSettlementParty
from kalshi.fix.tags import Tag
from kalshi.types import DollarDecimal


class MarketSettlementReport(FixMessage):
"""MarketSettlementReport (35=UMS) — a market's settlement detail.

``market_result`` is a raw str (compare against
:class:`~kalshi.fix.enums.MarketResult`). ``settlement_price`` rides the FIX
``Price`` field as a no-float ``Decimal`` (``DollarDecimal`` is just the
coercion type); the value is in *cents*, 2 dp — ``Decimal("100.00")`` is 100c
(full YES payout) and ``Decimal("30.60")`` a scalar market — not dollars.
``clearing_business_date`` is the raw ``YYYYMMDD`` string. ``last_fragment``
is ``False`` on non-final pages of a paginated batch, ``True`` (or absent) on
the last.
"""

MSG_TYPE = MsgType.MARKET_SETTLEMENT_REPORT

market_settlement_report_id: str | None = fixfield(
Tag.MARKET_SETTLEMENT_REPORT_ID, FixType.STRING, default=None
)
symbol: str | None = fixfield(Tag.SYMBOL, FixType.STRING, default=None)
clearing_business_date: str | None = fixfield(
Tag.CLEARING_BUSINESS_DATE, FixType.LOCALMKTDATE, default=None
)
tot_num_market_settlement_reports: int | None = fixfield(
Tag.TOT_NUM_MARKET_SETTLEMENT_REPORTS, FixType.INT, default=None
)
market_result: str | None = fixfield(Tag.MARKET_RESULT, FixType.STRING, default=None)
settlement_price: DollarDecimal | None = fixfield(
Tag.SETTLEMENT_PRICE, FixType.PRICE, default=None
)
last_fragment: bool | None = fixfield(Tag.LAST_FRAGMENT, FixType.BOOLEAN, default=None)
parties: Annotated[
list[MarketSettlementParty],
FixGroupMeta(Tag.NO_MARKET_SETTLEMENT_PARTY_IDS, MarketSettlementParty),
] = groupfield()
3 changes: 3 additions & 0 deletions kalshi/fix/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,9 @@ async def _open_and_logon(self) -> None:
cancel_orders_on_disconnect=True if self._config.cancel_orders_on_disconnect else None,
listener_session=True if self._config.listener_session else None,
skip_pending_exec_reports=True if self._config.skip_pending_exec_reports else None,
# Pass through directly: None omits the tag (gateway default), True/False
# explicitly opt in (RT) / out (PT).
receive_settlement_reports=self._config.receive_settlement_reports,
)
await self._send(logon)

Expand Down
99 changes: 99 additions & 0 deletions kalshi/fix/settlement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Reassembly of paginated market-settlement reports (GH #429).

A large settlement batch is delivered as several :class:`MarketSettlementReport`
(35=UMS) fragments: each non-final fragment carries ``LastFragment=N`` and a
subset of the parties, the final one ``LastFragment=Y`` (or omitted). Fragments
of one batch are correlated by ``Symbol`` — the ``MarketSettlementReportID``
differs per page, so it cannot be used as the key.

:class:`SettlementReassembler` accumulates each symbol's party entries across
fragments and, on the terminal fragment, returns a single
:class:`MarketSettlementReport` carrying every party.
"""

from __future__ import annotations

import logging

from kalshi.fix.messages.components import MarketSettlementParty
from kalshi.fix.messages.settlement import MarketSettlementReport

logger = logging.getLogger("kalshi.fix")


class SettlementReassembler:
"""Reassemble paginated UMS fragments into one report per settlement batch.

Usage::

reasm = SettlementReassembler()
complete = reasm.add(decode_app_message(raw)) # MarketSettlementReport | None
if complete is not None:
... # all parties for the batch are in complete.parties

Assumptions / limitations. ``Symbol`` is the only correlation field (the report
id differs per page), so this keys solely on it and assumes a batch's fragments
arrive as one contiguous run terminated by ``LastFragment=Y`` (or a standalone
report). It does **not** reconcile against ``TotNumMarketSettlementReports``
(20106). Consequences:

* Two batches for the *same* symbol must not interleave — a second batch's
terminal arriving while the first is still buffered would coalesce both into
one report. A well-formed gateway sends a symbol's fragments contiguously
(each batch ends with ``LastFragment=Y`` before the next begins), so this
does not occur in practice; sequential same-symbol batches reassemble
independently because the buffer clears on each terminal.
* If a terminal fragment never arrives (mid-batch disconnect), the partial
parties stay buffered — see :meth:`pending_symbols` — until :meth:`clear`
(call it on reconnect).
* A re-delivered terminal fragment re-emits its report, so consumers should
handle emitted reports idempotently.
"""

def __init__(self) -> None:
self._pending: dict[str, list[MarketSettlementParty]] = {}

def add(self, report: MarketSettlementReport) -> MarketSettlementReport | None:
"""Feed one fragment; return the assembled report on the terminal fragment.

Returns ``None`` while more fragments are expected (``LastFragment=N``).
A terminal fragment (``LastFragment`` ``True`` or absent) returns the
report with every accumulated party; a standalone report is returned
unchanged. The assembled report carries the *terminal* fragment's
``MarketSettlementReportID`` (each page has its own — there is no canonical
batch id), so key any dedup off ``Symbol`` + clearing date, not that id.
"""
if report.last_fragment is False:
# Non-final page: must have a Symbol to correlate the batch.
if report.symbol is None:
logger.warning(
"settlement fragment without Symbol; dropping %d parties",
len(report.parties),
)
return None
self._pending.setdefault(report.symbol, []).extend(report.parties)
return None

# Terminal fragment (True or None).
if report.symbol is not None and report.symbol in self._pending:
parties = self._pending.pop(report.symbol)
parties.extend(report.parties)
return report.model_copy(update={"parties": parties})
return report

def pending_symbols(self) -> set[str]:
"""Symbols with buffered fragments still awaiting their terminal page."""
return set(self._pending)

def clear(self) -> None:
"""Drop all buffered fragments (e.g. on reconnect).

Warns when discarding non-empty buffers so silently-dropped partial
settlement data is observable.
"""
if self._pending:
logger.warning(
"SettlementReassembler.clear() dropping buffered settlement fragments for %s",
sorted(self._pending),
)
self._pending.clear()
Loading