fixcore-engine 0.2.1__tar.gz → 0.4.0__tar.gz
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.
- {fixcore_engine-0.2.1/fixcore_engine.egg-info → fixcore_engine-0.4.0}/PKG-INFO +1 -1
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/log/file_log.py +2 -2
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/log/screen.py +2 -2
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/message/__init__.py +12 -3
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/message/cracker.py +5 -2
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/message/data_dictionary.py +84 -12
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/message/exceptions.py +17 -1
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/message/field.py +4 -4
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/message/message.py +46 -9
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/session/session.py +311 -107
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/session/session_settings.py +24 -6
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/session/state.py +3 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/store/file_store.py +17 -5
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/transport/acceptor.py +1 -1
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/transport/initiator.py +4 -4
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0/fixcore_engine.egg-info}/PKG-INFO +1 -1
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/pyproject.toml +1 -1
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_cracker.py +10 -3
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_data_dictionary.py +25 -1
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_file_log.py +1 -2
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_file_store.py +30 -2
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_framer.py +7 -2
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_integration.py +5 -6
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_message.py +78 -1
- fixcore_engine-0.4.0/tests/test_session.py +990 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_transport.py +2 -5
- fixcore_engine-0.2.1/tests/test_session.py +0 -522
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/LICENSE +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/README.md +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/__init__.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/application.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/gui.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/gui_ui/app.js +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/gui_ui/fixcore_logo.svg +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/gui_ui/index.html +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/gui_ui/style.css +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/log/__init__.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/log/base.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/log/factory.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/session/__init__.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/session/session_id.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/store/__init__.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/store/base.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/store/factory.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/store/memory.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/transport/__init__.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore/transport/framer.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore_engine.egg-info/SOURCES.txt +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore_engine.egg-info/dependency_links.txt +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore_engine.egg-info/entry_points.txt +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore_engine.egg-info/requires.txt +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/fixcore_engine.egg-info/top_level.txt +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/setup.cfg +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_session_id.py +0 -0
- {fixcore_engine-0.2.1 → fixcore_engine-0.4.0}/tests/test_store.py +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from datetime import
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
from fixcore.log.base import Log, LogFactory
|
|
@@ -20,7 +20,7 @@ def _session_filename(session_id: SessionID) -> str:
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def _now() -> str:
|
|
23
|
-
return datetime.now(tz=
|
|
23
|
+
return datetime.now(tz=UTC).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
class FileLog(Log):
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import sys
|
|
6
|
-
from datetime import
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
7
|
|
|
8
8
|
from fixcore.log.base import Log, LogFactory
|
|
9
9
|
from fixcore.session.session_id import SessionID
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def _now() -> str:
|
|
13
|
-
return datetime.now(tz=
|
|
13
|
+
return datetime.now(tz=UTC).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
class ScreenLog(Log):
|
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
"""FIX message layer — encoding, decoding, and field definitions."""
|
|
2
2
|
|
|
3
3
|
from fixcore.message.cracker import MessageCracker
|
|
4
|
-
from fixcore.message.data_dictionary import
|
|
4
|
+
from fixcore.message.data_dictionary import (
|
|
5
|
+
DataDictionary,
|
|
6
|
+
FieldDef,
|
|
7
|
+
GroupDef,
|
|
8
|
+
MessageDef,
|
|
9
|
+
load_data_dictionary,
|
|
10
|
+
)
|
|
5
11
|
from fixcore.message.exceptions import (
|
|
6
|
-
FieldNotFound,
|
|
12
|
+
FieldNotFound,
|
|
13
|
+
InvalidMessage,
|
|
14
|
+
UnsupportedMessageType,
|
|
15
|
+
UnsupportedVersion,
|
|
7
16
|
)
|
|
8
17
|
from fixcore.message.field import Field, FieldMap, Group
|
|
9
18
|
from fixcore.message.message import Header, Message, Trailer
|
|
10
19
|
|
|
11
20
|
__all__ = [
|
|
12
21
|
"MessageCracker",
|
|
13
|
-
"DataDictionary", "FieldDef", "GroupDef", "MessageDef",
|
|
22
|
+
"DataDictionary", "FieldDef", "GroupDef", "MessageDef", "load_data_dictionary",
|
|
14
23
|
"FieldNotFound", "InvalidMessage", "UnsupportedMessageType", "UnsupportedVersion",
|
|
15
24
|
"Field", "FieldMap", "Group",
|
|
16
25
|
"Header", "Message", "Trailer",
|
|
@@ -25,11 +25,12 @@ level::
|
|
|
25
25
|
|
|
26
26
|
from __future__ import annotations
|
|
27
27
|
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
|
|
28
30
|
from fixcore.message.exceptions import UnsupportedMessageType
|
|
29
31
|
from fixcore.message.message import Message
|
|
30
32
|
from fixcore.session.session_id import SessionID
|
|
31
33
|
|
|
32
|
-
|
|
33
34
|
# ---------------------------------------------------------------------------
|
|
34
35
|
# MsgType → handler method name registry
|
|
35
36
|
# ---------------------------------------------------------------------------
|
|
@@ -230,7 +231,9 @@ class MessageCracker:
|
|
|
230
231
|
# already have an explicit implementation on the class.
|
|
231
232
|
# ---------------------------------------------------------------------------
|
|
232
233
|
|
|
233
|
-
def _make_default_handler(
|
|
234
|
+
def _make_default_handler(
|
|
235
|
+
name: str,
|
|
236
|
+
) -> Callable[[MessageCracker, Message, SessionID], None]:
|
|
234
237
|
def handler(self: MessageCracker, message: Message, session_id: SessionID) -> None:
|
|
235
238
|
self.on_message(message, session_id)
|
|
236
239
|
handler.__name__ = name
|
|
@@ -4,11 +4,22 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import xml.etree.ElementTree as ET
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
|
+
from functools import cache
|
|
7
8
|
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
8
10
|
|
|
9
11
|
from fixcore.message.exceptions import InvalidMessage
|
|
10
12
|
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from fixcore.message.message import Message
|
|
15
|
+
|
|
16
|
+
# SessionRejectReason codes (FIX session-level)
|
|
17
|
+
REJECT_REQUIRED_TAG_MISSING = 1
|
|
18
|
+
REJECT_VALUE_OUT_OF_RANGE = 5
|
|
19
|
+
REJECT_INVALID_MSGTYPE = 11
|
|
20
|
+
|
|
11
21
|
# Imported here to avoid circular import at module level; also used as constants
|
|
22
|
+
TAG_MSG_TYPE = 35
|
|
12
23
|
TAG_BODY_LENGTH = 9
|
|
13
24
|
TAG_CHECKSUM = 10
|
|
14
25
|
|
|
@@ -32,7 +43,7 @@ class GroupDef:
|
|
|
32
43
|
number_tag: int # NoXxx counter tag (e.g. 78 NoAllocs)
|
|
33
44
|
delimiter: int # first expected tag inside each instance
|
|
34
45
|
members: list[int] # ordered member tags
|
|
35
|
-
nested_groups: dict[int,
|
|
46
|
+
nested_groups: dict[int, GroupDef] = field(default_factory=dict)
|
|
36
47
|
|
|
37
48
|
|
|
38
49
|
@dataclass
|
|
@@ -76,7 +87,7 @@ class DataDictionary:
|
|
|
76
87
|
# ------------------------------------------------------------------
|
|
77
88
|
|
|
78
89
|
@classmethod
|
|
79
|
-
def from_xml(cls, path: str | Path) ->
|
|
90
|
+
def from_xml(cls, path: str | Path) -> DataDictionary:
|
|
80
91
|
"""Load a DataDictionary from a QuickFIX XML spec file."""
|
|
81
92
|
tree = ET.parse(str(path))
|
|
82
93
|
root = tree.getroot()
|
|
@@ -89,7 +100,7 @@ class DataDictionary:
|
|
|
89
100
|
return dd
|
|
90
101
|
|
|
91
102
|
@classmethod
|
|
92
|
-
def from_string(cls, xml_text: str) ->
|
|
103
|
+
def from_string(cls, xml_text: str) -> DataDictionary:
|
|
93
104
|
"""Load a DataDictionary from an XML string (useful in tests)."""
|
|
94
105
|
root = ET.fromstring(xml_text)
|
|
95
106
|
dd = cls()
|
|
@@ -147,7 +158,9 @@ class DataDictionary:
|
|
|
147
158
|
name = msg_el.get("name", "")
|
|
148
159
|
cat = msg_el.get("msgcat", "app").lower()
|
|
149
160
|
msg_def = MessageDef(name=name, msg_type=msg_type, msg_cat=cat)
|
|
150
|
-
self._parse_fields_and_groups(
|
|
161
|
+
self._parse_fields_and_groups(
|
|
162
|
+
msg_el, msg_def.required, msg_def.optional, msg_def.groups
|
|
163
|
+
)
|
|
151
164
|
self._messages[msg_type] = msg_def
|
|
152
165
|
|
|
153
166
|
def _parse_fields_and_groups(
|
|
@@ -196,7 +209,9 @@ class DataDictionary:
|
|
|
196
209
|
delimiter = tag
|
|
197
210
|
members.append(tag)
|
|
198
211
|
nested[tag] = self._parse_group(tag, child)
|
|
199
|
-
return GroupDef(
|
|
212
|
+
return GroupDef(
|
|
213
|
+
number_tag=number_tag, delimiter=delimiter, members=members, nested_groups=nested
|
|
214
|
+
)
|
|
200
215
|
|
|
201
216
|
def _resolve_tag(self, name: str) -> int | None:
|
|
202
217
|
return self._fields_by_name.get(name)
|
|
@@ -227,7 +242,7 @@ class DataDictionary:
|
|
|
227
242
|
def is_trailer_field(self, tag: int) -> bool:
|
|
228
243
|
return tag in self._trailer
|
|
229
244
|
|
|
230
|
-
def validate(self, message:
|
|
245
|
+
def validate(self, message: Message, *, check_field_values: bool = False) -> None:
|
|
231
246
|
"""Validate *message* against this DataDictionary.
|
|
232
247
|
|
|
233
248
|
Checks:
|
|
@@ -235,18 +250,27 @@ class DataDictionary:
|
|
|
235
250
|
- All required header fields are present
|
|
236
251
|
- All required body fields are present
|
|
237
252
|
- All required trailer fields are present
|
|
253
|
+
- If *check_field_values* is True, every enum-bearing header/body field
|
|
254
|
+
carries a value defined in the dictionary (out-of-range → Reject)
|
|
238
255
|
|
|
239
256
|
Raises :exc:`InvalidMessage` on the first violation found.
|
|
240
257
|
"""
|
|
241
|
-
from fixcore.message.message import Message # local to avoid circular
|
|
242
258
|
|
|
243
259
|
msg_type = message.msg_type
|
|
244
260
|
if not msg_type:
|
|
245
|
-
raise InvalidMessage(
|
|
261
|
+
raise InvalidMessage(
|
|
262
|
+
"Missing MsgType (tag 35)",
|
|
263
|
+
reason=REJECT_REQUIRED_TAG_MISSING,
|
|
264
|
+
ref_tag=35,
|
|
265
|
+
)
|
|
246
266
|
|
|
247
267
|
msg_def = self._messages.get(msg_type)
|
|
248
268
|
if msg_def is None:
|
|
249
|
-
raise InvalidMessage(
|
|
269
|
+
raise InvalidMessage(
|
|
270
|
+
f"Unknown MsgType: {msg_type!r}",
|
|
271
|
+
reason=REJECT_INVALID_MSGTYPE,
|
|
272
|
+
ref_tag=35,
|
|
273
|
+
)
|
|
250
274
|
|
|
251
275
|
# BodyLength and CheckSum are computed by the engine on encode — skip them
|
|
252
276
|
COMPUTED_TAGS = {TAG_BODY_LENGTH, TAG_CHECKSUM}
|
|
@@ -257,14 +281,20 @@ class DataDictionary:
|
|
|
257
281
|
continue
|
|
258
282
|
if required and not message.header.has(tag):
|
|
259
283
|
name = self._fields_by_number.get(tag, FieldDef(tag, str(tag), "STRING")).name
|
|
260
|
-
raise InvalidMessage(
|
|
284
|
+
raise InvalidMessage(
|
|
285
|
+
f"Missing required header field: {name} ({tag})",
|
|
286
|
+
reason=REJECT_REQUIRED_TAG_MISSING,
|
|
287
|
+
ref_tag=tag,
|
|
288
|
+
)
|
|
261
289
|
|
|
262
290
|
# Required body fields
|
|
263
291
|
for tag in msg_def.required:
|
|
264
292
|
if not message.has_field(tag):
|
|
265
293
|
name = self._fields_by_number.get(tag, FieldDef(tag, str(tag), "STRING")).name
|
|
266
294
|
raise InvalidMessage(
|
|
267
|
-
f"Missing required field: {name} ({tag}) in {msg_def.name}"
|
|
295
|
+
f"Missing required field: {name} ({tag}) in {msg_def.name}",
|
|
296
|
+
reason=REJECT_REQUIRED_TAG_MISSING,
|
|
297
|
+
ref_tag=tag,
|
|
268
298
|
)
|
|
269
299
|
|
|
270
300
|
# Required trailer fields
|
|
@@ -273,7 +303,27 @@ class DataDictionary:
|
|
|
273
303
|
continue
|
|
274
304
|
if required and not message.trailer.has(tag):
|
|
275
305
|
name = self._fields_by_number.get(tag, FieldDef(tag, str(tag), "STRING")).name
|
|
276
|
-
raise InvalidMessage(
|
|
306
|
+
raise InvalidMessage(
|
|
307
|
+
f"Missing required trailer field: {name} ({tag})",
|
|
308
|
+
reason=REJECT_REQUIRED_TAG_MISSING,
|
|
309
|
+
ref_tag=tag,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# Enum (out-of-range) field-value checks — header + body flat fields.
|
|
313
|
+
# MsgType is skipped (already validated against the message set above);
|
|
314
|
+
# tags without a defined enum no-op in validate_field_value.
|
|
315
|
+
if check_field_values:
|
|
316
|
+
fields = list(message.header.items()) + list(message.body.items())
|
|
317
|
+
for tag, value in fields:
|
|
318
|
+
if tag in COMPUTED_TAGS or tag == TAG_MSG_TYPE:
|
|
319
|
+
continue
|
|
320
|
+
if not self.validate_field_value(tag, value):
|
|
321
|
+
name = self._fields_by_number.get(tag, FieldDef(tag, str(tag), "STRING")).name
|
|
322
|
+
raise InvalidMessage(
|
|
323
|
+
f"Value {value!r} out of range for {name} ({tag})",
|
|
324
|
+
reason=REJECT_VALUE_OUT_OF_RANGE,
|
|
325
|
+
ref_tag=tag,
|
|
326
|
+
)
|
|
277
327
|
|
|
278
328
|
def validate_field_value(self, tag: int, value: str) -> bool:
|
|
279
329
|
"""Return True if *value* is a valid enum for *tag*, or if *tag* has no enum."""
|
|
@@ -283,6 +333,28 @@ class DataDictionary:
|
|
|
283
333
|
return value in fd.values
|
|
284
334
|
|
|
285
335
|
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
# Cached loading
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
@cache
|
|
341
|
+
def _load_cached(resolved_path: str) -> DataDictionary:
|
|
342
|
+
return DataDictionary.from_xml(resolved_path)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def load_data_dictionary(path: str | Path) -> DataDictionary:
|
|
346
|
+
"""Load (and cache) a DataDictionary from *path*.
|
|
347
|
+
|
|
348
|
+
Multiple sessions sharing the same spec file reuse a single parsed
|
|
349
|
+
instance. Raises :exc:`FileNotFoundError` with a clear message if the file
|
|
350
|
+
does not exist.
|
|
351
|
+
"""
|
|
352
|
+
resolved = Path(path).expanduser()
|
|
353
|
+
if not resolved.is_file():
|
|
354
|
+
raise FileNotFoundError(f"DataDictionary spec not found: {path}")
|
|
355
|
+
return _load_cached(str(resolved.resolve()))
|
|
356
|
+
|
|
357
|
+
|
|
286
358
|
# ---------------------------------------------------------------------------
|
|
287
359
|
# Helpers
|
|
288
360
|
# ---------------------------------------------------------------------------
|
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
"""FIX message exceptions."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
3
5
|
|
|
4
6
|
class InvalidMessage(Exception):
|
|
5
|
-
"""Raised when a FIX message fails DataDictionary validation.
|
|
7
|
+
"""Raised when a FIX message fails DataDictionary validation.
|
|
8
|
+
|
|
9
|
+
Optionally carries the FIX SessionRejectReason (*reason*) and the offending
|
|
10
|
+
tag (*ref_tag*) so the session layer can emit a spec-compliant Reject.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
message: str,
|
|
16
|
+
reason: int | None = None,
|
|
17
|
+
ref_tag: int | None = None,
|
|
18
|
+
) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.reason = reason
|
|
21
|
+
self.ref_tag = ref_tag
|
|
6
22
|
|
|
7
23
|
|
|
8
24
|
class UnsupportedVersion(Exception):
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from collections import OrderedDict
|
|
6
|
-
from
|
|
6
|
+
from collections.abc import Iterator
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class Field:
|
|
@@ -106,7 +106,7 @@ class Group:
|
|
|
106
106
|
self._fields: dict[int, str] = {}
|
|
107
107
|
# Insertion order for both simple fields and nested-group count tags
|
|
108
108
|
self._order: list[int] = []
|
|
109
|
-
self._groups: dict[int, list[
|
|
109
|
+
self._groups: dict[int, list[Group]] = {}
|
|
110
110
|
|
|
111
111
|
# ------------------------------------------------------------------
|
|
112
112
|
# Simple field access (mirrors Message body API)
|
|
@@ -131,7 +131,7 @@ class Group:
|
|
|
131
131
|
# Nested groups
|
|
132
132
|
# ------------------------------------------------------------------
|
|
133
133
|
|
|
134
|
-
def add_group(self, count_tag: int, instance:
|
|
134
|
+
def add_group(self, count_tag: int, instance: Group) -> None:
|
|
135
135
|
"""Append a nested group instance and update the count field."""
|
|
136
136
|
if count_tag not in self._groups:
|
|
137
137
|
self._order.append(count_tag)
|
|
@@ -139,7 +139,7 @@ class Group:
|
|
|
139
139
|
self._groups[count_tag].append(instance)
|
|
140
140
|
self._fields[count_tag] = str(len(self._groups[count_tag]))
|
|
141
141
|
|
|
142
|
-
def get_groups(self, count_tag: int) ->
|
|
142
|
+
def get_groups(self, count_tag: int) -> list[Group]:
|
|
143
143
|
return self._groups.get(count_tag, [])
|
|
144
144
|
|
|
145
145
|
def __repr__(self) -> str:
|
|
@@ -213,7 +213,7 @@ class Message:
|
|
|
213
213
|
cls,
|
|
214
214
|
raw: bytes,
|
|
215
215
|
data_dictionary: DataDictionary | None = None,
|
|
216
|
-
) ->
|
|
216
|
+
) -> Message:
|
|
217
217
|
"""Parse *raw* SOH-delimited bytes into a Message.
|
|
218
218
|
|
|
219
219
|
Raises ValueError on malformed input or checksum/body-length mismatch.
|
|
@@ -225,6 +225,7 @@ class Message:
|
|
|
225
225
|
fields = _parse_fields(raw)
|
|
226
226
|
if not fields:
|
|
227
227
|
raise ValueError("Empty message")
|
|
228
|
+
_validate_field_order(fields)
|
|
228
229
|
|
|
229
230
|
msg = cls()
|
|
230
231
|
|
|
@@ -264,8 +265,20 @@ class Message:
|
|
|
264
265
|
return self.header.get_or(TAG_MSG_TYPE)
|
|
265
266
|
|
|
266
267
|
def __repr__(self) -> str:
|
|
267
|
-
|
|
268
|
-
|
|
268
|
+
try:
|
|
269
|
+
raw = self.encode()
|
|
270
|
+
return raw.replace(SOH, b"|").decode("latin-1")
|
|
271
|
+
except Exception:
|
|
272
|
+
# Partially-built message (e.g. no BeginString): show the fields
|
|
273
|
+
# we have without the computed BodyLength / CheckSum.
|
|
274
|
+
parts = [f"{t}={v}" for t, v in self.header.items()]
|
|
275
|
+
for tag in self._body_order:
|
|
276
|
+
if tag in self._body_groups:
|
|
277
|
+
parts.append(f"{tag}={len(self._body_groups[tag])}")
|
|
278
|
+
else:
|
|
279
|
+
parts.append(f"{tag}={self.body[tag]}")
|
|
280
|
+
parts += [f"{t}={v}" for t, v in self.trailer.items()]
|
|
281
|
+
return f"Message({'|'.join(parts)})"
|
|
269
282
|
|
|
270
283
|
|
|
271
284
|
# ---------------------------------------------------------------------------
|
|
@@ -366,6 +379,22 @@ def _checksum(data: bytes) -> int:
|
|
|
366
379
|
return sum(data) % 256
|
|
367
380
|
|
|
368
381
|
|
|
382
|
+
def _validate_field_order(fields: list[tuple[int, str]]) -> None:
|
|
383
|
+
"""Enforce the FIX positional invariants for the standard header/trailer.
|
|
384
|
+
|
|
385
|
+
Tag 8 (BeginString) must be first, 9 (BodyLength) second, 35 (MsgType)
|
|
386
|
+
third, and 10 (CheckSum) last.
|
|
387
|
+
"""
|
|
388
|
+
if fields[0][0] != TAG_BEGIN_STRING:
|
|
389
|
+
raise ValueError("First field must be BeginString (tag 8)")
|
|
390
|
+
if len(fields) < 2 or fields[1][0] != TAG_BODY_LENGTH:
|
|
391
|
+
raise ValueError("Second field must be BodyLength (tag 9)")
|
|
392
|
+
if len(fields) < 3 or fields[2][0] != TAG_MSG_TYPE:
|
|
393
|
+
raise ValueError("Third field must be MsgType (tag 35)")
|
|
394
|
+
if fields[-1][0] != TAG_CHECKSUM:
|
|
395
|
+
raise ValueError("Last field must be CheckSum (tag 10)")
|
|
396
|
+
|
|
397
|
+
|
|
369
398
|
def _parse_fields(raw: bytes) -> list[tuple[int, str]]:
|
|
370
399
|
fields: list[tuple[int, str]] = []
|
|
371
400
|
for part in raw.split(SOH):
|
|
@@ -381,22 +410,30 @@ def _parse_fields(raw: bytes) -> list[tuple[int, str]]:
|
|
|
381
410
|
|
|
382
411
|
|
|
383
412
|
def _validate(raw: bytes, msg: Message) -> None:
|
|
384
|
-
#
|
|
413
|
+
# Locate the CheckSum field by its trailing SOH. ``checksum_idx`` points at
|
|
414
|
+
# the SOH immediately before "10="; everything up to and including it is
|
|
415
|
+
# covered by the checksum.
|
|
385
416
|
checksum_idx = raw.rfind(b"\x0110=")
|
|
386
417
|
if checksum_idx == -1:
|
|
387
418
|
raise ValueError("Missing CheckSum field (tag 10)")
|
|
419
|
+
|
|
420
|
+
# Validate checksum
|
|
388
421
|
computed = _checksum(raw[: checksum_idx + 1]) # include the leading SOH
|
|
389
422
|
declared = int(msg.trailer.get(TAG_CHECKSUM))
|
|
390
423
|
if computed != declared:
|
|
391
424
|
raise ValueError(f"CheckSum mismatch: computed {computed:03d}, got {declared:03d}")
|
|
392
425
|
|
|
393
|
-
# Validate body length
|
|
426
|
+
# Validate body length. BodyLength counts the bytes from the start of tag 35
|
|
427
|
+
# up to (and including) the SOH before tag 10. The start is anchored on the
|
|
428
|
+
# SOH positions of the first two fields (8=… then 9=…) rather than a literal
|
|
429
|
+
# search for "35=", which could otherwise match inside a field value.
|
|
394
430
|
if msg.header.has(TAG_BODY_LENGTH):
|
|
395
431
|
declared_len = int(msg.header.get(TAG_BODY_LENGTH))
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
432
|
+
begin_string_soh = raw.find(b"\x01")
|
|
433
|
+
body_length_soh = raw.find(b"\x01", begin_string_soh + 1)
|
|
434
|
+
body_start = body_length_soh + 1 # first byte after "9=<len>\x01" (tag 35)
|
|
435
|
+
end = checksum_idx + 1 # include the SOH before tag 10
|
|
436
|
+
actual_len = end - body_start
|
|
400
437
|
if actual_len != declared_len:
|
|
401
438
|
raise ValueError(
|
|
402
439
|
f"BodyLength mismatch: declared {declared_len}, actual {actual_len}"
|