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,403 @@
1
+ """FIX Message — Header / Body / Trailer with encode/decode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from fixcore.message.data_dictionary import DataDictionary, GroupDef
9
+ from fixcore.message.field import Group
10
+
11
+ SOH = b"\x01"
12
+ SOH_INT = 0x01
13
+
14
+ # Standard header/trailer tags
15
+ TAG_BEGIN_STRING = 8
16
+ TAG_BODY_LENGTH = 9
17
+ TAG_MSG_TYPE = 35
18
+ TAG_CHECKSUM = 10
19
+
20
+ # Standard FIX header tags — routed to Header during decode
21
+ HEADER_TAGS: frozenset[int] = frozenset({
22
+ 8, # BeginString
23
+ 9, # BodyLength
24
+ 35, # MsgType
25
+ 49, # SenderCompID
26
+ 56, # TargetCompID
27
+ 34, # MsgSeqNum
28
+ 52, # SendingTime
29
+ 43, # PossDupFlag
30
+ 97, # PossResend
31
+ 122, # OrigSendingTime
32
+ 115, # OnBehalfOfCompID
33
+ 128, # DeliverToCompID
34
+ 129, # DeliverToSubID
35
+ 142, # SenderLocationID
36
+ 143, # TargetLocationID
37
+ 144, # OnBehalfOfLocationID
38
+ 145, # DeliverToLocationID
39
+ 50, # SenderSubID
40
+ 57, # TargetSubID
41
+ 116, # OnBehalfOfSubID
42
+ })
43
+
44
+
45
+ class Header:
46
+ """Thin wrapper — tag ordering for the standard header is enforced on encode."""
47
+
48
+ # Tags that must appear first (in order) per FIX spec
49
+ LEADING_TAGS = (TAG_BEGIN_STRING, TAG_BODY_LENGTH, TAG_MSG_TYPE)
50
+
51
+ def __init__(self) -> None:
52
+ self._fields: dict[int, str] = {}
53
+ self._order: list[int] = []
54
+
55
+ def set(self, tag: int, value: str) -> None:
56
+ if tag not in self._fields:
57
+ self._order.append(tag)
58
+ self._fields[tag] = value
59
+
60
+ def get(self, tag: int) -> str:
61
+ return self._fields[tag]
62
+
63
+ def get_or(self, tag: int, default: str = "") -> str:
64
+ return self._fields.get(tag, default)
65
+
66
+ def has(self, tag: int) -> bool:
67
+ return tag in self._fields
68
+
69
+ def remove_field(self, tag: int) -> None:
70
+ self._fields.pop(tag, None)
71
+ if tag in self._order:
72
+ self._order.remove(tag)
73
+
74
+ def items(self) -> list[tuple[int, str]]:
75
+ """Return fields in FIX-spec order: leading tags first, then the rest."""
76
+ leading = [(t, self._fields[t]) for t in self.LEADING_TAGS if t in self._fields]
77
+ rest = [
78
+ (t, self._fields[t])
79
+ for t in self._order
80
+ if t not in self.LEADING_TAGS
81
+ ]
82
+ return leading + rest
83
+
84
+
85
+ class Trailer:
86
+ def __init__(self) -> None:
87
+ self._fields: dict[int, str] = {}
88
+
89
+ def set(self, tag: int, value: str) -> None:
90
+ self._fields[tag] = value
91
+
92
+ def get(self, tag: int) -> str:
93
+ return self._fields[tag]
94
+
95
+ def has(self, tag: int) -> bool:
96
+ return tag in self._fields
97
+
98
+ def items(self) -> list[tuple[int, str]]:
99
+ # Checksum must always be last
100
+ result = [(t, v) for t, v in self._fields.items() if t != TAG_CHECKSUM]
101
+ if TAG_CHECKSUM in self._fields:
102
+ result.append((TAG_CHECKSUM, self._fields[TAG_CHECKSUM]))
103
+ return result
104
+
105
+
106
+ class Message:
107
+ """A complete FIX message.
108
+
109
+ Encoding
110
+ --------
111
+ Call ``encode()`` to produce the wire bytes. BodyLength (tag 9) and
112
+ CheckSum (tag 10) are computed automatically — any values set by the
113
+ caller are overwritten.
114
+
115
+ Decoding
116
+ --------
117
+ Call ``Message.decode(raw)`` to parse raw bytes (SOH-delimited) into a
118
+ Message instance. BodyLength and CheckSum are validated.
119
+ """
120
+
121
+ def __init__(self) -> None:
122
+ self.header = Header()
123
+ self.body: dict[int, str] = {}
124
+ self._body_order: list[int] = []
125
+ self._body_groups: dict[int, list[Group]] = {}
126
+ self.trailer = Trailer()
127
+
128
+ # ------------------------------------------------------------------
129
+ # Body helpers
130
+ # ------------------------------------------------------------------
131
+
132
+ def set_field(self, tag: int, value: str) -> None:
133
+ if tag not in self.body:
134
+ self._body_order.append(tag)
135
+ self.body[tag] = value
136
+
137
+ def get_field(self, tag: int) -> str:
138
+ return self.body[tag]
139
+
140
+ def get_field_or(self, tag: int, default: str = "") -> str:
141
+ return self.body.get(tag, default)
142
+
143
+ def has_field(self, tag: int) -> bool:
144
+ return tag in self.body
145
+
146
+ # ------------------------------------------------------------------
147
+ # Repeating group API
148
+ # ------------------------------------------------------------------
149
+
150
+ def add_group(self, count_tag: int, instance: Group) -> None:
151
+ """Append a repeating group instance and keep the count field in sync."""
152
+ self._body_groups.setdefault(count_tag, []).append(instance)
153
+ self.set_field(count_tag, str(len(self._body_groups[count_tag])))
154
+
155
+ def get_groups(self, count_tag: int) -> list[Group]:
156
+ """Return all instances for *count_tag* (empty list if none)."""
157
+ return self._body_groups.get(count_tag, [])
158
+
159
+ def group_count(self, count_tag: int) -> int:
160
+ return len(self._body_groups.get(count_tag, []))
161
+
162
+ # ------------------------------------------------------------------
163
+ # Encode
164
+ # ------------------------------------------------------------------
165
+
166
+ def encode(self) -> bytes:
167
+ """Serialise to wire format, computing BodyLength and CheckSum."""
168
+ body_parts: list[bytes] = []
169
+
170
+ # tag 35 first (part of header but counted in body length)
171
+ if self.header.has(TAG_MSG_TYPE):
172
+ body_parts.append(_field_bytes(TAG_MSG_TYPE, self.header.get(TAG_MSG_TYPE)))
173
+
174
+ # remaining header fields (excluding 8, 9, 35)
175
+ for tag, value in self.header.items():
176
+ if tag not in (TAG_BEGIN_STRING, TAG_BODY_LENGTH, TAG_MSG_TYPE):
177
+ body_parts.append(_field_bytes(tag, value))
178
+
179
+ # body — group-aware
180
+ for tag in self._body_order:
181
+ if tag in self._body_groups:
182
+ # Emit count then each instance's fields (recursive for nesting)
183
+ body_parts.append(_field_bytes(tag, str(len(self._body_groups[tag]))))
184
+ for instance in self._body_groups[tag]:
185
+ body_parts.extend(_encode_group_instance(instance))
186
+ else:
187
+ body_parts.append(_field_bytes(tag, self.body[tag]))
188
+
189
+ # trailer (excluding checksum)
190
+ for tag, value in self.trailer.items():
191
+ if tag != TAG_CHECKSUM:
192
+ body_parts.append(_field_bytes(tag, value))
193
+
194
+ body_bytes = b"".join(body_parts)
195
+ body_length = len(body_bytes)
196
+
197
+ prefix = (
198
+ _field_bytes(TAG_BEGIN_STRING, self.header.get(TAG_BEGIN_STRING))
199
+ + _field_bytes(TAG_BODY_LENGTH, str(body_length))
200
+ )
201
+
202
+ checksum = _checksum(prefix + body_bytes)
203
+ suffix = _field_bytes(TAG_CHECKSUM, f"{checksum:03d}")
204
+
205
+ return prefix + body_bytes + suffix
206
+
207
+ # ------------------------------------------------------------------
208
+ # Decode
209
+ # ------------------------------------------------------------------
210
+
211
+ @classmethod
212
+ def decode(
213
+ cls,
214
+ raw: bytes,
215
+ data_dictionary: DataDictionary | None = None,
216
+ ) -> "Message":
217
+ """Parse *raw* SOH-delimited bytes into a Message.
218
+
219
+ Raises ValueError on malformed input or checksum/body-length mismatch.
220
+
221
+ If *data_dictionary* is supplied, repeating groups are extracted and
222
+ stored via :meth:`add_group` / :meth:`get_groups`. Without it the
223
+ body is populated as flat fields (backward-compatible behaviour).
224
+ """
225
+ fields = _parse_fields(raw)
226
+ if not fields:
227
+ raise ValueError("Empty message")
228
+
229
+ msg = cls()
230
+
231
+ # Separate header/trailer from body fields
232
+ body_fields: list[tuple[int, str]] = []
233
+ for tag, value in fields:
234
+ if tag in HEADER_TAGS:
235
+ msg.header.set(tag, value)
236
+ elif tag == TAG_CHECKSUM:
237
+ msg.trailer.set(tag, value)
238
+ else:
239
+ body_fields.append((tag, value))
240
+
241
+ if data_dictionary is not None:
242
+ # Group-aware body parse
243
+ msg_type = msg.header.get_or(TAG_MSG_TYPE)
244
+ try:
245
+ msg_def = data_dictionary.message_def(msg_type)
246
+ group_defs: dict[int, GroupDef] = msg_def.groups
247
+ except Exception:
248
+ group_defs = {}
249
+ _populate_body(msg, body_fields, group_defs)
250
+ else:
251
+ # Flat parse — backward compatible
252
+ for tag, value in body_fields:
253
+ msg.set_field(tag, value)
254
+
255
+ _validate(raw, msg)
256
+ return msg
257
+
258
+ # ------------------------------------------------------------------
259
+ # Convenience
260
+ # ------------------------------------------------------------------
261
+
262
+ @property
263
+ def msg_type(self) -> str:
264
+ return self.header.get_or(TAG_MSG_TYPE)
265
+
266
+ def __repr__(self) -> str:
267
+ raw = self.encode()
268
+ return raw.replace(SOH, b"|").decode("latin-1")
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # Internal helpers
273
+ # ---------------------------------------------------------------------------
274
+
275
+ def _encode_group_instance(group: Group) -> list[bytes]:
276
+ """Recursively serialise one repeating group instance to a list of bytes."""
277
+ parts: list[bytes] = []
278
+ for tag in group._order:
279
+ if tag in group._groups:
280
+ parts.append(_field_bytes(tag, str(len(group._groups[tag]))))
281
+ for nested in group._groups[tag]:
282
+ parts.extend(_encode_group_instance(nested))
283
+ else:
284
+ parts.append(_field_bytes(tag, group._fields[tag]))
285
+ return parts
286
+
287
+
288
+ def _populate_body(
289
+ msg: Message,
290
+ body_fields: list[tuple[int, str]],
291
+ group_defs: dict[int, GroupDef],
292
+ ) -> None:
293
+ """Populate msg body from *body_fields*, extracting groups where defined."""
294
+ pos = 0
295
+ while pos < len(body_fields):
296
+ tag, value = body_fields[pos]
297
+ if tag in group_defs:
298
+ count = int(value) if value.isdigit() else 0
299
+ pos += 1
300
+ instances, pos = _extract_groups(body_fields, pos, group_defs[tag], count)
301
+ # Store count so has_field / get_field still work
302
+ msg.set_field(tag, str(count))
303
+ for inst in instances:
304
+ msg._body_groups.setdefault(tag, []).append(inst)
305
+ else:
306
+ msg.set_field(tag, value)
307
+ pos += 1
308
+
309
+
310
+ def _extract_groups(
311
+ flat: list[tuple[int, str]],
312
+ pos: int,
313
+ group_def: GroupDef,
314
+ count: int,
315
+ ) -> tuple[list[Group], int]:
316
+ """Extract *count* group instances from *flat* starting at *pos*.
317
+
318
+ Returns ``(instances, new_pos)``.
319
+ """
320
+ from fixcore.message.field import Group # local import avoids circular
321
+
322
+ instances: list[Group] = []
323
+ for _ in range(count):
324
+ if pos >= len(flat):
325
+ break
326
+ tag, value = flat[pos]
327
+ # Each instance must start with the delimiter tag
328
+ if tag != group_def.delimiter:
329
+ break
330
+ instance = Group()
331
+ instance.set_field(tag, value)
332
+ pos += 1
333
+
334
+ # Consume member tags until the delimiter appears again (next instance)
335
+ # or we hit a tag that doesn't belong to this group
336
+ all_members = set(group_def.members) | set(group_def.nested_groups)
337
+ while pos < len(flat):
338
+ tag, value = flat[pos]
339
+ if tag == group_def.delimiter:
340
+ break # start of next instance
341
+ if tag not in all_members:
342
+ break # tag belongs to parent scope
343
+ if tag in group_def.nested_groups:
344
+ nested_count = int(value) if value.isdigit() else 0
345
+ pos += 1
346
+ nested_insts, pos = _extract_groups(
347
+ flat, pos, group_def.nested_groups[tag], nested_count
348
+ )
349
+ instance._fields[tag] = str(nested_count)
350
+ instance._order.append(tag)
351
+ instance._groups[tag] = nested_insts
352
+ else:
353
+ instance.set_field(tag, value)
354
+ pos += 1
355
+
356
+ instances.append(instance)
357
+
358
+ return instances, pos
359
+
360
+
361
+ def _field_bytes(tag: int, value: str) -> bytes:
362
+ return f"{tag}={value}".encode("latin-1") + SOH
363
+
364
+
365
+ def _checksum(data: bytes) -> int:
366
+ return sum(data) % 256
367
+
368
+
369
+ def _parse_fields(raw: bytes) -> list[tuple[int, str]]:
370
+ fields: list[tuple[int, str]] = []
371
+ for part in raw.split(SOH):
372
+ if not part:
373
+ continue
374
+ sep = part.find(b"=")
375
+ if sep == -1:
376
+ raise ValueError(f"Malformed field (no '='): {part!r}")
377
+ tag = int(part[:sep])
378
+ value = part[sep + 1 :].decode("latin-1")
379
+ fields.append((tag, value))
380
+ return fields
381
+
382
+
383
+ def _validate(raw: bytes, msg: Message) -> None:
384
+ # Validate checksum
385
+ checksum_idx = raw.rfind(b"\x0110=")
386
+ if checksum_idx == -1:
387
+ raise ValueError("Missing CheckSum field (tag 10)")
388
+ computed = _checksum(raw[: checksum_idx + 1]) # include the leading SOH
389
+ declared = int(msg.trailer.get(TAG_CHECKSUM))
390
+ if computed != declared:
391
+ raise ValueError(f"CheckSum mismatch: computed {computed:03d}, got {declared:03d}")
392
+
393
+ # Validate body length
394
+ if msg.header.has(TAG_BODY_LENGTH):
395
+ declared_len = int(msg.header.get(TAG_BODY_LENGTH))
396
+ # body length = bytes from tag 35 up to (not including) tag 10 field
397
+ start = raw.find(b"35=")
398
+ end = raw.rfind(b"\x0110=") + 1 # include the SOH before tag 10
399
+ actual_len = end - start
400
+ if actual_len != declared_len:
401
+ raise ValueError(
402
+ f"BodyLength mismatch: declared {declared_len}, actual {actual_len}"
403
+ )
@@ -0,0 +1,8 @@
1
+ """FIX session layer — state machine, sequence numbers, settings."""
2
+
3
+ from fixcore.session.session import Session
4
+ from fixcore.session.session_id import SessionID
5
+ from fixcore.session.session_settings import SessionSettings
6
+ from fixcore.session.state import SessionState
7
+
8
+ __all__ = ["Session", "SessionID", "SessionSettings", "SessionState"]