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.
- fixcore/__init__.py +6 -0
- fixcore/application.py +47 -0
- fixcore/log/__init__.py +7 -0
- fixcore/log/base.py +27 -0
- fixcore/log/factory.py +10 -0
- fixcore/log/file_log.py +70 -0
- fixcore/log/screen.py +32 -0
- fixcore/message/__init__.py +17 -0
- fixcore/message/cracker.py +243 -0
- fixcore/message/data_dictionary.py +298 -0
- fixcore/message/exceptions.py +21 -0
- fixcore/message/field.py +147 -0
- fixcore/message/message.py +403 -0
- fixcore/session/__init__.py +8 -0
- fixcore/session/session.py +532 -0
- fixcore/session/session_id.py +32 -0
- fixcore/session/session_settings.py +146 -0
- fixcore/session/state.py +60 -0
- fixcore/store/__init__.py +11 -0
- fixcore/store/base.py +49 -0
- fixcore/store/factory.py +33 -0
- fixcore/store/file_store.py +162 -0
- fixcore/store/memory.py +50 -0
- fixcore/transport/__init__.py +7 -0
- fixcore/transport/acceptor.py +166 -0
- fixcore/transport/framer.py +107 -0
- fixcore/transport/initiator.py +146 -0
- fixcore_engine-0.1.0.dist-info/METADATA +75 -0
- fixcore_engine-0.1.0.dist-info/RECORD +32 -0
- fixcore_engine-0.1.0.dist-info/WHEEL +5 -0
- fixcore_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- fixcore_engine-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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"]
|