|
1 | 1 | """Typed FIX message framework: Pydantic models that round-trip to wire fields. |
2 | 2 |
|
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. |
14 | 16 |
|
15 | 17 | The standard header (``SenderCompID``/``TargetCompID``/``MsgSeqNum``/ |
16 | 18 | ``SendingTime``) and ``MsgType`` are owned by the session/connection layer, not |
17 | 19 | the message body — see :mod:`kalshi.fix.session`. |
18 | 20 |
|
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. |
21 | 24 | """ |
22 | 25 |
|
23 | 26 | from __future__ import annotations |
24 | 27 |
|
| 28 | +from dataclasses import dataclass |
25 | 29 | from decimal import Decimal |
26 | 30 | from enum import StrEnum |
27 | 31 | from typing import Any, ClassVar, Self |
@@ -63,18 +67,55 @@ class FixType(StrEnum): |
63 | 67 | _DECIMAL_TYPES = frozenset({FixType.PRICE, FixType.QTY, FixType.AMT, FixType.DECIMAL}) |
64 | 68 |
|
65 | 69 |
|
| 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 | + |
66 | 100 | 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. |
68 | 102 |
|
69 | 103 | ``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). |
73 | 105 | """ |
74 | 106 | extra: dict[str, Any] = {"fix_tag": int(tag), "fix_type": fix_type.value} |
75 | 107 | return Field(default=default, json_schema_extra=extra, **kwargs) |
76 | 108 |
|
77 | 109 |
|
| 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 | + |
78 | 119 | def _to_wire(value: Any, fix_type: FixType) -> str: |
79 | 120 | """Convert a Python field value to its FIX wire string.""" |
80 | 121 | if fix_type is FixType.BOOLEAN: |
@@ -112,77 +153,177 @@ def _from_wire(raw: str, fix_type: FixType) -> Any: |
112 | 153 | return raw |
113 | 154 |
|
114 | 155 |
|
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 |
121 | 162 |
|
122 | 163 |
|
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 |
125 | 170 |
|
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 | | - """ |
132 | 171 |
|
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 | + """ |
134 | 215 |
|
135 | 216 | model_config = ConfigDict(extra="forbid") |
136 | 217 |
|
137 | 218 | @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) |
145 | 222 | if cached is not None: |
146 | 223 | return cached |
147 | | - out: list[tuple[str, int, FixType]] = [] |
| 224 | + specs: list[_FieldSpec] = [] |
148 | 225 | 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 |
149 | 232 | extra = field.json_schema_extra |
150 | 233 | if isinstance(extra, dict) and "fix_tag" in extra: |
151 | 234 | 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") |
156 | 265 |
|
157 | 266 | 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. |
159 | 268 |
|
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. |
162 | 271 | """ |
163 | 272 | 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)) |
173 | 289 | return out |
174 | 290 |
|
175 | 291 | @classmethod |
176 | 292 | 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. |
178 | 299 |
|
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). |
181 | 302 | """ |
182 | 303 | 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) |
188 | 319 | 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