fixcore-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,298 @@
1
+ """DataDictionary — loads a QuickFIX XML spec and validates FIX messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import xml.etree.ElementTree as ET
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ from fixcore.message.exceptions import InvalidMessage
10
+
11
+ # Imported here to avoid circular import at module level; also used as constants
12
+ TAG_BODY_LENGTH = 9
13
+ TAG_CHECKSUM = 10
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Data model
18
+ # ---------------------------------------------------------------------------
19
+
20
+ @dataclass(frozen=True)
21
+ class FieldDef:
22
+ """Definition of a single FIX field."""
23
+ number: int
24
+ name: str
25
+ type: str # STRING, INT, PRICE, QTY, CHAR, …
26
+ values: dict[str, str] = field(default_factory=dict) # enum → description
27
+
28
+
29
+ @dataclass
30
+ class GroupDef:
31
+ """Definition of a repeating group."""
32
+ number_tag: int # NoXxx counter tag (e.g. 78 NoAllocs)
33
+ delimiter: int # first expected tag inside each instance
34
+ members: list[int] # ordered member tags
35
+ nested_groups: dict[int, "GroupDef"] = field(default_factory=dict)
36
+
37
+
38
+ @dataclass
39
+ class MessageDef:
40
+ """Definition of a FIX message type."""
41
+ name: str
42
+ msg_type: str # e.g. "D", "0", "A"
43
+ msg_cat: str # "admin" | "app"
44
+ required: set[int] = field(default_factory=set)
45
+ optional: set[int] = field(default_factory=set)
46
+ groups: dict[int, GroupDef] = field(default_factory=dict)
47
+
48
+ @property
49
+ def all_fields(self) -> set[int]:
50
+ return self.required | self.optional
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # DataDictionary
55
+ # ---------------------------------------------------------------------------
56
+
57
+ class DataDictionary:
58
+ """Parses a QuickFIX-format XML spec and validates Message objects.
59
+
60
+ Usage::
61
+
62
+ dd = DataDictionary.from_xml("specs/FIX42.xml")
63
+ dd.validate(message) # raises InvalidMessage on failure
64
+ """
65
+
66
+ def __init__(self) -> None:
67
+ self._version: str = ""
68
+ self._fields_by_number: dict[int, FieldDef] = {}
69
+ self._fields_by_name: dict[str, int] = {} # name → tag number
70
+ self._messages: dict[str, MessageDef] = {} # msg_type → MessageDef
71
+ self._header: dict[int, bool] = {} # tag → required
72
+ self._trailer: dict[int, bool] = {}
73
+
74
+ # ------------------------------------------------------------------
75
+ # Construction
76
+ # ------------------------------------------------------------------
77
+
78
+ @classmethod
79
+ def from_xml(cls, path: str | Path) -> "DataDictionary":
80
+ """Load a DataDictionary from a QuickFIX XML spec file."""
81
+ tree = ET.parse(str(path))
82
+ root = tree.getroot()
83
+ dd = cls()
84
+ dd._version = _build_version(root)
85
+ dd._load_fields(root)
86
+ dd._load_header(root)
87
+ dd._load_trailer(root)
88
+ dd._load_messages(root)
89
+ return dd
90
+
91
+ @classmethod
92
+ def from_string(cls, xml_text: str) -> "DataDictionary":
93
+ """Load a DataDictionary from an XML string (useful in tests)."""
94
+ root = ET.fromstring(xml_text)
95
+ dd = cls()
96
+ dd._version = _build_version(root)
97
+ dd._load_fields(root)
98
+ dd._load_header(root)
99
+ dd._load_trailer(root)
100
+ dd._load_messages(root)
101
+ return dd
102
+
103
+ # ------------------------------------------------------------------
104
+ # Internal loaders
105
+ # ------------------------------------------------------------------
106
+
107
+ def _load_fields(self, root: ET.Element) -> None:
108
+ fields_el = root.find("fields")
109
+ if fields_el is None:
110
+ return
111
+ for f in fields_el.findall("field"):
112
+ number = int(f.get("number", 0))
113
+ name = f.get("name", "")
114
+ ftype = f.get("type", "STRING").upper()
115
+ values = {
116
+ v.get("enum", ""): v.get("description", "")
117
+ for v in f.findall("value")
118
+ }
119
+ fd = FieldDef(number=number, name=name, type=ftype, values=values)
120
+ self._fields_by_number[number] = fd
121
+ self._fields_by_name[name] = number
122
+
123
+ def _load_header(self, root: ET.Element) -> None:
124
+ header_el = root.find("header")
125
+ if header_el is None:
126
+ return
127
+ for f in header_el.findall("field"):
128
+ tag = self._resolve_tag(f.get("name", ""))
129
+ if tag is not None:
130
+ self._header[tag] = f.get("required", "N").upper() == "Y"
131
+
132
+ def _load_trailer(self, root: ET.Element) -> None:
133
+ trailer_el = root.find("trailer")
134
+ if trailer_el is None:
135
+ return
136
+ for f in trailer_el.findall("field"):
137
+ tag = self._resolve_tag(f.get("name", ""))
138
+ if tag is not None:
139
+ self._trailer[tag] = f.get("required", "N").upper() == "Y"
140
+
141
+ def _load_messages(self, root: ET.Element) -> None:
142
+ messages_el = root.find("messages")
143
+ if messages_el is None:
144
+ return
145
+ for msg_el in messages_el.findall("message"):
146
+ msg_type = msg_el.get("msgtype", "")
147
+ name = msg_el.get("name", "")
148
+ cat = msg_el.get("msgcat", "app").lower()
149
+ msg_def = MessageDef(name=name, msg_type=msg_type, msg_cat=cat)
150
+ self._parse_fields_and_groups(msg_el, msg_def.required, msg_def.optional, msg_def.groups)
151
+ self._messages[msg_type] = msg_def
152
+
153
+ def _parse_fields_and_groups(
154
+ self,
155
+ parent: ET.Element,
156
+ required: set[int],
157
+ optional: set[int],
158
+ groups: dict[int, GroupDef],
159
+ ) -> None:
160
+ for child in parent:
161
+ if child.tag == "field":
162
+ tag = self._resolve_tag(child.get("name", ""))
163
+ if tag is None:
164
+ continue
165
+ if child.get("required", "N").upper() == "Y":
166
+ required.add(tag)
167
+ else:
168
+ optional.add(tag)
169
+ elif child.tag == "group":
170
+ tag = self._resolve_tag(child.get("name", ""))
171
+ if tag is None:
172
+ continue
173
+ req = child.get("required", "N").upper() == "Y"
174
+ if req:
175
+ required.add(tag)
176
+ else:
177
+ optional.add(tag)
178
+ group_def = self._parse_group(tag, child)
179
+ groups[tag] = group_def
180
+
181
+ def _parse_group(self, number_tag: int, group_el: ET.Element) -> GroupDef:
182
+ members: list[int] = []
183
+ nested: dict[int, GroupDef] = {}
184
+ delimiter: int = 0
185
+ for child in group_el:
186
+ if child.tag == "field":
187
+ tag = self._resolve_tag(child.get("name", ""))
188
+ if tag is not None:
189
+ if delimiter == 0:
190
+ delimiter = tag
191
+ members.append(tag)
192
+ elif child.tag == "group":
193
+ tag = self._resolve_tag(child.get("name", ""))
194
+ if tag is not None:
195
+ if delimiter == 0:
196
+ delimiter = tag
197
+ members.append(tag)
198
+ nested[tag] = self._parse_group(tag, child)
199
+ return GroupDef(number_tag=number_tag, delimiter=delimiter, members=members, nested_groups=nested)
200
+
201
+ def _resolve_tag(self, name: str) -> int | None:
202
+ return self._fields_by_name.get(name)
203
+
204
+ # ------------------------------------------------------------------
205
+ # Public API
206
+ # ------------------------------------------------------------------
207
+
208
+ @property
209
+ def version(self) -> str:
210
+ return self._version
211
+
212
+ def field_def(self, tag: int) -> FieldDef:
213
+ """Return FieldDef for *tag*; raises KeyError if unknown."""
214
+ return self._fields_by_number[tag]
215
+
216
+ def field_tag(self, name: str) -> int:
217
+ """Return the tag number for field *name*; raises KeyError if unknown."""
218
+ return self._fields_by_name[name]
219
+
220
+ def message_def(self, msg_type: str) -> MessageDef:
221
+ """Return MessageDef for *msg_type*; raises KeyError if unknown."""
222
+ return self._messages[msg_type]
223
+
224
+ def is_header_field(self, tag: int) -> bool:
225
+ return tag in self._header
226
+
227
+ def is_trailer_field(self, tag: int) -> bool:
228
+ return tag in self._trailer
229
+
230
+ def validate(self, message: "Message") -> None: # type: ignore[name-defined]
231
+ """Validate *message* against this DataDictionary.
232
+
233
+ Checks:
234
+ - MsgType is known
235
+ - All required header fields are present
236
+ - All required body fields are present
237
+ - All required trailer fields are present
238
+
239
+ Raises :exc:`InvalidMessage` on the first violation found.
240
+ """
241
+ from fixcore.message.message import Message # local to avoid circular
242
+
243
+ msg_type = message.msg_type
244
+ if not msg_type:
245
+ raise InvalidMessage("Missing MsgType (tag 35)")
246
+
247
+ msg_def = self._messages.get(msg_type)
248
+ if msg_def is None:
249
+ raise InvalidMessage(f"Unknown MsgType: {msg_type!r}")
250
+
251
+ # BodyLength and CheckSum are computed by the engine on encode — skip them
252
+ COMPUTED_TAGS = {TAG_BODY_LENGTH, TAG_CHECKSUM}
253
+
254
+ # Required header fields
255
+ for tag, required in self._header.items():
256
+ if tag in COMPUTED_TAGS:
257
+ continue
258
+ if required and not message.header.has(tag):
259
+ name = self._fields_by_number.get(tag, FieldDef(tag, str(tag), "STRING")).name
260
+ raise InvalidMessage(f"Missing required header field: {name} ({tag})")
261
+
262
+ # Required body fields
263
+ for tag in msg_def.required:
264
+ if not message.has_field(tag):
265
+ name = self._fields_by_number.get(tag, FieldDef(tag, str(tag), "STRING")).name
266
+ raise InvalidMessage(
267
+ f"Missing required field: {name} ({tag}) in {msg_def.name}"
268
+ )
269
+
270
+ # Required trailer fields
271
+ for tag, required in self._trailer.items():
272
+ if tag in COMPUTED_TAGS:
273
+ continue
274
+ if required and not message.trailer.has(tag):
275
+ name = self._fields_by_number.get(tag, FieldDef(tag, str(tag), "STRING")).name
276
+ raise InvalidMessage(f"Missing required trailer field: {name} ({tag})")
277
+
278
+ def validate_field_value(self, tag: int, value: str) -> bool:
279
+ """Return True if *value* is a valid enum for *tag*, or if *tag* has no enum."""
280
+ fd = self._fields_by_number.get(tag)
281
+ if fd is None or not fd.values:
282
+ return True
283
+ return value in fd.values
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Helpers
288
+ # ---------------------------------------------------------------------------
289
+
290
+ def _build_version(root: ET.Element) -> str:
291
+ fix_type = root.get("type", "FIX")
292
+ major = root.get("major", "4")
293
+ minor = root.get("minor", "2")
294
+ sp = root.get("servicepack", "")
295
+ version = f"{fix_type}.{major}.{minor}"
296
+ if sp and sp != "0":
297
+ version += f"SP{sp}"
298
+ return version
@@ -0,0 +1,21 @@
1
+ """FIX message exceptions."""
2
+
3
+
4
+ class InvalidMessage(Exception):
5
+ """Raised when a FIX message fails DataDictionary validation."""
6
+
7
+
8
+ class UnsupportedVersion(Exception):
9
+ """Raised when a message's BeginString has no matching DataDictionary."""
10
+
11
+
12
+ class FieldNotFound(KeyError):
13
+ """Raised when a required field is absent from a message."""
14
+
15
+
16
+ class UnsupportedMessageType(Exception):
17
+ """Raised by MessageCracker.on_message when no handler is registered for a MsgType."""
18
+
19
+ def __init__(self, msg_type: str) -> None:
20
+ super().__init__(f"No handler for MsgType {msg_type!r}")
21
+ self.msg_type = msg_type
@@ -0,0 +1,147 @@
1
+ """FIX field and field-map primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import OrderedDict
6
+ from typing import Iterator
7
+
8
+
9
+ class Field:
10
+ """A single tag=value pair."""
11
+
12
+ __slots__ = ("tag", "value")
13
+
14
+ def __init__(self, tag: int, value: str) -> None:
15
+ self.tag = tag
16
+ self.value = value
17
+
18
+ def __repr__(self) -> str:
19
+ return f"Field({self.tag}={self.value!r})"
20
+
21
+ def __eq__(self, other: object) -> bool:
22
+ if not isinstance(other, Field):
23
+ return NotImplemented
24
+ return self.tag == other.tag and self.value == other.value
25
+
26
+
27
+ class FieldMap:
28
+ """Ordered collection of FIX fields (tag → value).
29
+
30
+ Preserves insertion order. Repeated tags are allowed (e.g. repeating
31
+ groups) and stored in order of insertion.
32
+ """
33
+
34
+ def __init__(self) -> None:
35
+ # tag → list[str] to support repeating tags
36
+ self._fields: OrderedDict[int, list[str]] = OrderedDict()
37
+
38
+ # ------------------------------------------------------------------
39
+ # Mutation
40
+ # ------------------------------------------------------------------
41
+
42
+ def set_field(self, tag: int, value: str) -> None:
43
+ """Set *tag* to *value*, replacing any existing value(s)."""
44
+ self._fields[tag] = [value]
45
+
46
+ def append_field(self, tag: int, value: str) -> None:
47
+ """Append *value* for *tag* (used for repeating groups)."""
48
+ if tag in self._fields:
49
+ self._fields[tag].append(value)
50
+ else:
51
+ self._fields[tag] = [value]
52
+
53
+ def remove_field(self, tag: int) -> None:
54
+ self._fields.pop(tag, None)
55
+
56
+ # ------------------------------------------------------------------
57
+ # Access
58
+ # ------------------------------------------------------------------
59
+
60
+ def get_field(self, tag: int) -> str:
61
+ """Return the first value for *tag*; raises KeyError if absent."""
62
+ return self._fields[tag][0]
63
+
64
+ def get_field_or(self, tag: int, default: str = "") -> str:
65
+ vals = self._fields.get(tag)
66
+ return vals[0] if vals else default
67
+
68
+ def has_field(self, tag: int) -> bool:
69
+ return tag in self._fields
70
+
71
+ # ------------------------------------------------------------------
72
+ # Iteration
73
+ # ------------------------------------------------------------------
74
+
75
+ def __iter__(self) -> Iterator[Field]:
76
+ """Yield all fields in insertion order, expanding repeated tags."""
77
+ for tag, values in self._fields.items():
78
+ for value in values:
79
+ yield Field(tag, value)
80
+
81
+ def __len__(self) -> int:
82
+ return sum(len(v) for v in self._fields.values())
83
+
84
+ def __repr__(self) -> str:
85
+ pairs = ", ".join(f"{t}={v!r}" for t, vs in self._fields.items() for v in vs)
86
+ return f"{self.__class__.__name__}({pairs})"
87
+
88
+
89
+ class Group:
90
+ """One repeating group instance.
91
+
92
+ Holds the member fields for a single row within a FIX repeating group
93
+ (e.g. one entry in ``NoMsgTypes`` or one leg in ``NoLegs``).
94
+
95
+ Usage::
96
+
97
+ g = Group()
98
+ g.set_field(372, "D") # RefMsgType
99
+ g.set_field(385, "S") # MsgDirection
100
+ msg.add_group(384, g) # NoMsgTypes count tag
101
+
102
+ Nested groups are supported via :meth:`add_group` / :meth:`get_groups`.
103
+ """
104
+
105
+ def __init__(self) -> None:
106
+ self._fields: dict[int, str] = {}
107
+ # Insertion order for both simple fields and nested-group count tags
108
+ self._order: list[int] = []
109
+ self._groups: dict[int, list["Group"]] = {}
110
+
111
+ # ------------------------------------------------------------------
112
+ # Simple field access (mirrors Message body API)
113
+ # ------------------------------------------------------------------
114
+
115
+ def set_field(self, tag: int, value: str) -> None:
116
+ if tag not in self._fields:
117
+ self._order.append(tag)
118
+ self._fields[tag] = value
119
+
120
+ def get_field(self, tag: int) -> str:
121
+ """Return the value for *tag*; raises KeyError if absent."""
122
+ return self._fields[tag]
123
+
124
+ def get_field_or(self, tag: int, default: str = "") -> str:
125
+ return self._fields.get(tag, default)
126
+
127
+ def has_field(self, tag: int) -> bool:
128
+ return tag in self._fields
129
+
130
+ # ------------------------------------------------------------------
131
+ # Nested groups
132
+ # ------------------------------------------------------------------
133
+
134
+ def add_group(self, count_tag: int, instance: "Group") -> None:
135
+ """Append a nested group instance and update the count field."""
136
+ if count_tag not in self._groups:
137
+ self._order.append(count_tag)
138
+ self._groups[count_tag] = []
139
+ self._groups[count_tag].append(instance)
140
+ self._fields[count_tag] = str(len(self._groups[count_tag]))
141
+
142
+ def get_groups(self, count_tag: int) -> "list[Group]":
143
+ return self._groups.get(count_tag, [])
144
+
145
+ def __repr__(self) -> str:
146
+ pairs = ", ".join(f"{t}={v!r}" for t, v in self._fields.items())
147
+ return f"Group({pairs})"