Skip to content

Commit 0ff1009

Browse files
TexasCodingclaude
andauthored
feat(fix): repeating-group support in the message framework (closes #423) (#430)
- FixGroupMeta + groupfield(): declare a repeating group as Annotated[list[Entry], FixGroupMeta(NumInGroupTag, Entry)] - extract a shared _FixFieldModel base (FixMessage + FixGroup); layout-driven to_body_fields emits scalars and groups in declaration order (NumInGroup + each entry's fields, with nesting), and positional from_raw parses entries delimited by the entry's first-field tag, recursing for nested groups - components.py: shared entry models Party, MiscFee, CollateralAmountChange, MultivariateSelectedLeg, and the nested MarketSettlementParty (NoMarketSettlementPartyIDs -> NoCollateralAmountChanges + NoMiscFees). Amounts use DollarDecimal, quantities FixedPointCount (no float drift). - 7 round-trip tests: multi-entry, empty-group omission, trailing-scalar boundary, single entry, decimal coercion, nested round-trip, nested wire structure. ruff + mypy --strict clean; 104 FIX tests pass. Closes #423. Part of #402. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a632aa6 commit 0ff1009

4 files changed

Lines changed: 489 additions & 68 deletions

File tree

kalshi/fix/messages/__init__.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,28 @@
11
"""Typed FIX message models (FIX Dictionary v1.03).
22
3-
Foundation phase exposes the session-layer (admin) messages plus the base
4-
framework. Application messages (order entry, drop copy, market data,
5-
RFQ/settlement) are added in later phases — see GH #402.
3+
Exposes the base framework (scalar + repeating-group fields), the session-layer
4+
(admin) messages, and the shared repeating-group entry components. Application
5+
messages (order entry, drop copy, market data, RFQ/settlement) are added in later
6+
phases — see GH #402.
67
"""
78

89
from __future__ import annotations
910

10-
from kalshi.fix.messages.base import FixMessage, FixType, fixfield
11+
from kalshi.fix.messages.base import (
12+
FixGroup,
13+
FixGroupMeta,
14+
FixMessage,
15+
FixType,
16+
fixfield,
17+
groupfield,
18+
)
19+
from kalshi.fix.messages.components import (
20+
CollateralAmountChange,
21+
MarketSettlementParty,
22+
MiscFee,
23+
MultivariateSelectedLeg,
24+
Party,
25+
)
1126
from kalshi.fix.messages.session import (
1227
Heartbeat,
1328
Logon,
@@ -19,14 +34,22 @@
1934
)
2035

2136
__all__ = [
37+
"CollateralAmountChange",
38+
"FixGroup",
39+
"FixGroupMeta",
2240
"FixMessage",
2341
"FixType",
2442
"Heartbeat",
2543
"Logon",
2644
"Logout",
45+
"MarketSettlementParty",
46+
"MiscFee",
47+
"MultivariateSelectedLeg",
48+
"Party",
2749
"Reject",
2850
"ResendRequest",
2951
"SequenceReset",
3052
"TestRequest",
3153
"fixfield",
54+
"groupfield",
3255
]

kalshi/fix/messages/base.py

Lines changed: 205 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,31 @@
11
"""Typed FIX message framework: Pydantic models that round-trip to wire fields.
22
3-
Each message subclasses :class:`FixMessage` and declares its body fields with
4-
:func:`fixfield`, attaching the FIX tag number and wire type to the Pydantic
5-
field via ``json_schema_extra``. The base then drives:
6-
7-
* :meth:`FixMessage.to_body_fields` — ordered ``(tag, value)`` pairs for the
8-
message body (everything after the standard header), with length-prefixed
9-
data fields (``RawData``/``Signature``) auto-emitting their length field.
10-
* :meth:`FixMessage.from_raw` — build a typed model from a decoded
11-
:class:`~kalshi.fix.codec.RawMessage`, reading only the tags it declares
12-
(unknown/header tags are ignored, so inbound messages with extra fields
13-
parse cleanly).
3+
Each message subclasses :class:`FixMessage` and declares its fields with
4+
:func:`fixfield` (scalars) and :func:`groupfield` (repeating groups). Scalar
5+
metadata (tag + wire type) rides on ``json_schema_extra``; group metadata (the
6+
``NumInGroup`` tag + entry model) rides on an :class:`Annotated`
7+
:class:`FixGroupMeta`. The shared base (:class:`_FixFieldModel`) drives:
8+
9+
* :meth:`to_body_fields` — ordered ``(tag, value)`` pairs in declaration order,
10+
with length-prefixed data fields auto-emitting their length field and repeating
11+
groups expanding to ``NumInGroup`` + each entry's fields (nesting supported).
12+
* :meth:`from_raw` — build a typed model from a decoded
13+
:class:`~kalshi.fix.codec.RawMessage`. Scalars are read by tag; groups are
14+
parsed *positionally* (each entry delimited by its first field's tag), which
15+
recurses for nested groups.
1416
1517
The standard header (``SenderCompID``/``TargetCompID``/``MsgSeqNum``/
1618
``SendingTime``) and ``MsgType`` are owned by the session/connection layer, not
1719
the message body — see :mod:`kalshi.fix.session`.
1820
19-
Scope note: scalar fields only. Repeating groups (``NoPartyIDs``, ``NoMiscFees``,
20-
…) land with the order-entry / settlement message phases; see GH #402.
21+
Positional group decoding assumes group-member tags are distinct from top-level
22+
scalar tags (true for the Kalshi FIX dictionary v1.03), so a scalar is found by
23+
first-occurrence without confusion with a same-tagged field inside a group.
2124
"""
2225

2326
from __future__ import annotations
2427

28+
from dataclasses import dataclass
2529
from decimal import Decimal
2630
from enum import StrEnum
2731
from typing import Any, ClassVar, Self
@@ -63,18 +67,55 @@ class FixType(StrEnum):
6367
_DECIMAL_TYPES = frozenset({FixType.PRICE, FixType.QTY, FixType.AMT, FixType.DECIMAL})
6468

6569

70+
@dataclass(frozen=True)
71+
class FixGroupMeta:
72+
"""``Annotated`` metadata marking a repeating-group field.
73+
74+
Declare a group as ``Annotated[list[EntryModel], FixGroupMeta(count_tag,
75+
EntryModel)]`` where ``count_tag`` is the ``NumInGroup`` tag and
76+
``EntryModel`` is the :class:`FixGroup` subclass describing one entry.
77+
"""
78+
79+
num_in_group_tag: int
80+
entry_model: type[_FixFieldModel]
81+
82+
83+
@dataclass(frozen=True)
84+
class _ScalarSpec:
85+
name: str
86+
tag: int
87+
fix_type: FixType
88+
89+
90+
@dataclass(frozen=True)
91+
class _GroupSpec:
92+
name: str
93+
count_tag: int
94+
entry_model: type[_FixFieldModel]
95+
96+
97+
_FieldSpec = _ScalarSpec | _GroupSpec
98+
99+
66100
def fixfield(tag: int, fix_type: FixType, *, default: Any = ..., **kwargs: Any) -> Any:
67-
"""Declare a FIX-mapped Pydantic field.
101+
"""Declare a scalar FIX field.
68102
69103
``tag`` is the FIX tag number, ``fix_type`` its wire type. Omit ``default``
70-
for a required field (Pydantic treats ``...`` as required). The metadata
71-
rides on ``json_schema_extra`` so the base can introspect tag + type in
72-
declaration order.
104+
for a required field (Pydantic treats ``...`` as required).
73105
"""
74106
extra: dict[str, Any] = {"fix_tag": int(tag), "fix_type": fix_type.value}
75107
return Field(default=default, json_schema_extra=extra, **kwargs)
76108

77109

110+
def groupfield() -> Any:
111+
"""Declare a repeating-group field (defaults to an empty list).
112+
113+
The ``NumInGroup`` tag and entry model ride on the field's
114+
:class:`Annotated` :class:`FixGroupMeta`; this only supplies the default.
115+
"""
116+
return Field(default_factory=list)
117+
118+
78119
def _to_wire(value: Any, fix_type: FixType) -> str:
79120
"""Convert a Python field value to its FIX wire string."""
80121
if fix_type is FixType.BOOLEAN:
@@ -112,77 +153,177 @@ def _from_wire(raw: str, fix_type: FixType) -> Any:
112153
return raw
113154

114155

115-
# Per-class cache of the introspected FIX field map. The map is deterministic
116-
# per message class and, once application messages land, is read on every
117-
# encode/decode of every order and execution report — so caching it avoids
118-
# re-walking ``model_fields`` on the hot path. Classes live for the process
119-
# lifetime, so a plain dict keyed by class is fine.
120-
_FIX_FIELDS_CACHE: dict[type[FixMessage], list[tuple[str, int, FixType]]] = {}
156+
def _first_value(pairs: list[tuple[int, str]], tag: int) -> str | None:
157+
"""First value for ``tag`` in ``pairs``, or ``None``."""
158+
for t, v in pairs:
159+
if t == tag:
160+
return v
161+
return None
121162

122163

123-
class FixMessage(BaseModel):
124-
"""Base for all typed FIX messages.
164+
def _index_of(pairs: list[tuple[int, str]], tag: int) -> int | None:
165+
"""Index of the first pair with ``tag``, or ``None``."""
166+
for i, (t, _) in enumerate(pairs):
167+
if t == tag:
168+
return i
169+
return None
125170

126-
Subclasses set the ``MSG_TYPE`` class var and declare body fields with
127-
:func:`fixfield`. ``extra="forbid"`` makes a typo in an outbound message
128-
fail at construction; inbound messages are built via :meth:`from_raw`, which
129-
only passes declared tags, so server-added fields never reach the
130-
constructor.
131-
"""
132171

133-
MSG_TYPE: ClassVar[MsgType]
172+
def _parse_group(
173+
pairs: list[tuple[int, str]],
174+
start: int,
175+
count: int,
176+
entry_model: type[_FixFieldModel],
177+
) -> tuple[list[_FixFieldModel], int]:
178+
"""Parse up to ``count`` group entries from ``pairs`` beginning at ``start``.
179+
180+
Each entry begins at the entry model's delimiter (first) tag and runs until
181+
the next delimiter or a tag the entry does not own — which naturally captures
182+
nested groups (their tags are part of the entry's member set). Returns the
183+
parsed entries and the index just past the group.
184+
"""
185+
delim = entry_model._first_tag()
186+
member_tags = entry_model._member_tags()
187+
entries: list[_FixFieldModel] = []
188+
i = start
189+
n = len(pairs)
190+
while len(entries) < count and i < n and pairs[i][0] == delim:
191+
entry_pairs = [pairs[i]]
192+
i += 1
193+
while i < n and pairs[i][0] != delim and pairs[i][0] in member_tags:
194+
entry_pairs.append(pairs[i])
195+
i += 1
196+
entries.append(entry_model._from_pairs(entry_pairs))
197+
return entries, i
198+
199+
200+
# Per-class caches of the introspected layout / member-tag set. Both are
201+
# deterministic per class and read on every encode/decode, so caching avoids
202+
# re-walking ``model_fields``. Classes live for the process lifetime, so plain
203+
# dicts keyed by class are fine.
204+
_LAYOUT_CACHE: dict[type[_FixFieldModel], list[_FieldSpec]] = {}
205+
_MEMBER_TAGS_CACHE: dict[type[_FixFieldModel], frozenset[int]] = {}
206+
207+
208+
class _FixFieldModel(BaseModel):
209+
"""Shared machinery for FIX messages and repeating-group entries.
210+
211+
``extra="forbid"`` makes a typo in an outbound model fail at construction;
212+
inbound models are built via :meth:`from_raw` / :meth:`_from_pairs`, which
213+
pass only declared tags, so server-added fields never reach the constructor.
214+
"""
134215

135216
model_config = ConfigDict(extra="forbid")
136217

137218
@classmethod
138-
def _fix_fields(cls) -> list[tuple[str, int, FixType]]:
139-
"""``(attr_name, tag, fix_type)`` for each FIX-mapped field, in declaration order.
140-
141-
Cached per class (see :data:`_FIX_FIELDS_CACHE`). Callers iterate the
142-
result and must not mutate it.
143-
"""
144-
cached = _FIX_FIELDS_CACHE.get(cls)
219+
def _layout(cls) -> list[_FieldSpec]:
220+
"""Ordered scalar/group field specs in declaration order (cached)."""
221+
cached = _LAYOUT_CACHE.get(cls)
145222
if cached is not None:
146223
return cached
147-
out: list[tuple[str, int, FixType]] = []
224+
specs: list[_FieldSpec] = []
148225
for name, field in cls.model_fields.items():
226+
group_meta = next(
227+
(m for m in field.metadata if isinstance(m, FixGroupMeta)), None
228+
)
229+
if group_meta is not None:
230+
specs.append(_GroupSpec(name, group_meta.num_in_group_tag, group_meta.entry_model))
231+
continue
149232
extra = field.json_schema_extra
150233
if isinstance(extra, dict) and "fix_tag" in extra:
151234
tag = int(extra["fix_tag"]) # type: ignore[arg-type]
152-
fix_type = FixType(str(extra["fix_type"]))
153-
out.append((name, tag, fix_type))
154-
_FIX_FIELDS_CACHE[cls] = out
155-
return out
235+
specs.append(_ScalarSpec(name, tag, FixType(str(extra["fix_type"]))))
236+
_LAYOUT_CACHE[cls] = specs
237+
return specs
238+
239+
@classmethod
240+
def _member_tags(cls) -> frozenset[int]:
241+
"""Every tag this model can contain, transitively through nested groups."""
242+
cached = _MEMBER_TAGS_CACHE.get(cls)
243+
if cached is not None:
244+
return cached
245+
tags: set[int] = set()
246+
for spec in cls._layout():
247+
if isinstance(spec, _GroupSpec):
248+
tags.add(spec.count_tag)
249+
tags |= spec.entry_model._member_tags()
250+
else:
251+
tags.add(spec.tag)
252+
length_tag = _DATA_TO_LENGTH.get(spec.tag)
253+
if length_tag is not None:
254+
tags.add(length_tag)
255+
frozen = frozenset(tags)
256+
_MEMBER_TAGS_CACHE[cls] = frozen
257+
return frozen
258+
259+
@classmethod
260+
def _first_tag(cls) -> int:
261+
"""The delimiter tag — the first declared field's tag (group entry start)."""
262+
for spec in cls._layout():
263+
return spec.tag if isinstance(spec, _ScalarSpec) else spec.count_tag
264+
raise ValueError(f"{cls.__name__} declares no FIX fields")
156265

157266
def to_body_fields(self) -> list[tuple[int, str]]:
158-
"""Ordered ``(tag, value)`` body fields (excludes the standard header).
267+
"""Ordered ``(tag, value)`` fields in declaration order.
159268
160-
``None`` values are omitted. A data field auto-emits its length field
161-
immediately before it.
269+
``None`` scalars and empty groups are omitted; a data field auto-emits
270+
its length field; a group emits ``NumInGroup`` then each entry's fields.
162271
"""
163272
out: list[tuple[int, str]] = []
164-
for name, tag, fix_type in self._fix_fields():
165-
value = getattr(self, name)
166-
if value is None:
167-
continue
168-
wire = _to_wire(value, fix_type)
169-
length_tag = _DATA_TO_LENGTH.get(tag)
170-
if length_tag is not None:
171-
out.append((length_tag, str(len(wire.encode("latin-1")))))
172-
out.append((tag, wire))
273+
for spec in self._layout():
274+
value = getattr(self, spec.name)
275+
if isinstance(spec, _GroupSpec):
276+
if not value:
277+
continue
278+
out.append((spec.count_tag, str(len(value))))
279+
for entry in value:
280+
out.extend(entry.to_body_fields())
281+
else:
282+
if value is None:
283+
continue
284+
wire = _to_wire(value, spec.fix_type)
285+
length_tag = _DATA_TO_LENGTH.get(spec.tag)
286+
if length_tag is not None:
287+
out.append((length_tag, str(len(wire.encode("latin-1")))))
288+
out.append((spec.tag, wire))
173289
return out
174290

175291
@classmethod
176292
def from_raw(cls, raw: RawMessage) -> Self:
177-
"""Build a typed message from a decoded :class:`RawMessage`.
293+
"""Build a typed model from a decoded :class:`RawMessage`."""
294+
return cls._from_pairs(raw.pairs)
295+
296+
@classmethod
297+
def _from_pairs(cls, pairs: list[tuple[int, str]]) -> Self:
298+
"""Build a typed model from an ordered ``(tag, value)`` slice.
178299
179-
Reads only the tags this model declares; all other fields (header,
180-
trailer, server extensions) are ignored.
300+
Scalars are read by tag; groups are parsed positionally from the
301+
``NumInGroup`` field onward (recursing for nested groups).
181302
"""
182303
kwargs: dict[str, Any] = {}
183-
for name, tag, fix_type in cls._fix_fields():
184-
value = raw.get(tag)
185-
if value is None:
186-
continue
187-
kwargs[name] = _from_wire(value, fix_type)
304+
for spec in cls._layout():
305+
if isinstance(spec, _GroupSpec):
306+
idx = _index_of(pairs, spec.count_tag)
307+
if idx is None:
308+
continue
309+
try:
310+
count = int(pairs[idx][1])
311+
except ValueError:
312+
continue
313+
entries, _ = _parse_group(pairs, idx + 1, count, spec.entry_model)
314+
kwargs[spec.name] = entries
315+
else:
316+
value = _first_value(pairs, spec.tag)
317+
if value is not None:
318+
kwargs[spec.name] = _from_wire(value, spec.fix_type)
188319
return cls(**kwargs)
320+
321+
322+
class FixMessage(_FixFieldModel):
323+
"""Base for all typed FIX messages. Subclasses set the ``MSG_TYPE`` class var."""
324+
325+
MSG_TYPE: ClassVar[MsgType]
326+
327+
328+
class FixGroup(_FixFieldModel):
329+
"""Base for one entry of a repeating group (no ``MsgType`` of its own)."""

0 commit comments

Comments
 (0)