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,107 @@
1
+ """MessageFramer — splits a raw TCP byte stream into complete FIX messages.
2
+
3
+ FIX message structure on the wire::
4
+
5
+ 8=FIX.4.2\\x01 ← BeginString (variable length)
6
+ 9=<body_length>\\x01 ← BodyLength (variable length)
7
+ 35=A\\x01... ← body (exactly body_length bytes)
8
+ 10=NNN\\x01 ← CheckSum (always exactly 7 bytes: "10=" + 3 digits + SOH)
9
+
10
+ Framing algorithm:
11
+ 1. Scan for ``8=`` to sync to the start of a message.
12
+ 2. Parse ``9=<N>\\x01`` to read BodyLength.
13
+ 3. Wait until the buffer holds prefix + N + 7 bytes.
14
+ 4. Slice out the complete message and repeat.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ # CheckSum field is always "10=NNN\x01" — exactly 7 bytes.
20
+ _CHECKSUM_LEN = 7
21
+
22
+
23
+ class MessageFramer:
24
+ """Stateful byte-stream framer for FIX messages.
25
+
26
+ Feed raw bytes in any chunk size; complete messages are returned.
27
+
28
+ Example::
29
+
30
+ framer = MessageFramer()
31
+ for raw_msg in framer.feed(socket_data):
32
+ process(raw_msg)
33
+ """
34
+
35
+ def __init__(self) -> None:
36
+ self._buf: bytearray = bytearray()
37
+
38
+ def feed(self, data: bytes) -> list[bytes]:
39
+ """Append *data* to the internal buffer and return all complete messages."""
40
+ self._buf.extend(data)
41
+ messages: list[bytes] = []
42
+ while True:
43
+ msg = self._try_extract()
44
+ if msg is None:
45
+ break
46
+ messages.append(msg)
47
+ return messages
48
+
49
+ def reset(self) -> None:
50
+ """Discard all buffered bytes."""
51
+ self._buf.clear()
52
+
53
+ # ------------------------------------------------------------------
54
+ # Internal
55
+ # ------------------------------------------------------------------
56
+
57
+ def _try_extract(self) -> bytes | None:
58
+ buf = self._buf
59
+
60
+ # ── 1. Sync to BeginString ────────────────────────────────────
61
+ start = buf.find(b"8=")
62
+ if start == -1:
63
+ # Keep a trailing '8' — it may be the first byte of '8=' once more data arrives
64
+ if buf and buf[-1] == ord("8"):
65
+ del buf[:-1]
66
+ else:
67
+ buf.clear()
68
+ return None
69
+ if start > 0:
70
+ del buf[:start] # discard garbage before 8=
71
+
72
+ # ── 2. Find end of BeginString field ─────────────────────────
73
+ bs_soh = buf.find(b"\x01")
74
+ if bs_soh == -1:
75
+ return None # incomplete BeginString
76
+
77
+ # ── 3. Parse BodyLength field (must immediately follow) ───────
78
+ bl_start = bs_soh + 1
79
+ if len(buf) < bl_start + 3: # need at least "9=X"
80
+ return None
81
+
82
+ if buf[bl_start : bl_start + 2] != b"9=":
83
+ # Malformed — skip past this "8=" and retry
84
+ del buf[:2]
85
+ return None
86
+
87
+ bl_soh = buf.find(b"\x01", bl_start + 2)
88
+ if bl_soh == -1:
89
+ return None # incomplete BodyLength field
90
+
91
+ try:
92
+ body_length = int(buf[bl_start + 2 : bl_soh])
93
+ except ValueError:
94
+ # Corrupt BodyLength — drop up to and including this SOH
95
+ del buf[: bl_soh + 1]
96
+ return None
97
+
98
+ # ── 4. Wait for full message ──────────────────────────────────
99
+ body_start = bl_soh + 1
100
+ total = body_start + body_length + _CHECKSUM_LEN
101
+
102
+ if len(buf) < total:
103
+ return None
104
+
105
+ msg = bytes(buf[:total])
106
+ del buf[:total]
107
+ return msg
@@ -0,0 +1,146 @@
1
+ """SocketInitiator — manages outbound FIX connections with auto-reconnect."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from fixcore.application import Application
8
+ from fixcore.log.base import LogFactory
9
+ from fixcore.session.session import Session
10
+ from fixcore.session.session_id import SessionID
11
+ from fixcore.session.session_settings import SessionSettings
12
+ from fixcore.store.factory import StoreFactory
13
+ from fixcore.transport.framer import MessageFramer
14
+
15
+
16
+ class SocketInitiator:
17
+ """Dials outbound TCP connections for each configured initiator session.
18
+
19
+ Each session runs in its own :func:`asyncio.create_task` loop. On
20
+ disconnect the loop waits ``ReconnectInterval`` seconds (default 10) and
21
+ tries again until :meth:`stop` is called.
22
+
23
+ Usage::
24
+
25
+ initiator = SocketInitiator(settings, app, store_factory, log_factory)
26
+ async with initiator:
27
+ await asyncio.sleep(3600)
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ settings: SessionSettings,
33
+ application: Application,
34
+ store_factory: StoreFactory,
35
+ log_factory: LogFactory,
36
+ ) -> None:
37
+ self._settings = settings
38
+ self._app = application
39
+ self._store_factory = store_factory
40
+ self._log_factory = log_factory
41
+
42
+ self._sessions: dict[SessionID, Session] = {}
43
+ self._tasks: list[asyncio.Task[None]] = []
44
+ self._stop_event = asyncio.Event()
45
+
46
+ for sid in settings.session_ids:
47
+ conn_type = settings.get_or(sid, "ConnectionType", "initiator").lower()
48
+ if conn_type == "initiator":
49
+ self._sessions[sid] = Session(
50
+ sid, settings, application,
51
+ store_factory.create(sid),
52
+ log_factory.create(sid),
53
+ )
54
+
55
+ # ------------------------------------------------------------------
56
+ # Lifecycle
57
+ # ------------------------------------------------------------------
58
+
59
+ async def start(self) -> None:
60
+ """Launch a connect-loop task for each configured initiator session."""
61
+ self._stop_event.clear()
62
+ for sid, session in self._sessions.items():
63
+ task = asyncio.create_task(
64
+ self._connect_loop(sid, session),
65
+ name=f"initiator-{sid}",
66
+ )
67
+ self._tasks.append(task)
68
+
69
+ async def stop(self) -> None:
70
+ """Signal all connect loops to exit and wait for them to finish."""
71
+ self._stop_event.set()
72
+ if self._tasks:
73
+ await asyncio.gather(*self._tasks, return_exceptions=True)
74
+ self._tasks.clear()
75
+
76
+ async def __aenter__(self) -> "SocketInitiator":
77
+ await self.start()
78
+ return self
79
+
80
+ async def __aexit__(self, *_: object) -> None:
81
+ await self.stop()
82
+
83
+ @property
84
+ def sessions(self) -> dict[SessionID, Session]:
85
+ return dict(self._sessions)
86
+
87
+ # ------------------------------------------------------------------
88
+ # Per-session connect loop
89
+ # ------------------------------------------------------------------
90
+
91
+ async def _connect_loop(self, sid: SessionID, session: Session) -> None:
92
+ host = self._settings.get(sid, "SocketConnectHost")
93
+ port = int(self._settings.get(sid, "SocketConnectPort"))
94
+ reconnect = self._settings.get_int(sid, "ReconnectInterval", 10)
95
+
96
+ while not self._stop_event.is_set():
97
+ try:
98
+ reader, writer = await asyncio.open_connection(host, port)
99
+ except OSError:
100
+ # Connection refused or network error — wait and retry
101
+ await self._interruptible_sleep(reconnect)
102
+ continue
103
+
104
+ framer = MessageFramer()
105
+
106
+ async def send_fn(data: bytes, _w: asyncio.StreamWriter = writer) -> None:
107
+ _w.write(data)
108
+ await _w.drain()
109
+
110
+ await session.on_connect(send_fn)
111
+
112
+ try:
113
+ while not self._stop_event.is_set():
114
+ try:
115
+ data = await asyncio.wait_for(reader.read(4096), timeout=1.0)
116
+ except asyncio.TimeoutError:
117
+ continue
118
+ except (ConnectionResetError, asyncio.IncompleteReadError):
119
+ break
120
+ if not data:
121
+ break
122
+
123
+ for raw_msg in framer.feed(data):
124
+ await session.on_data(raw_msg)
125
+
126
+ finally:
127
+ await session.on_disconnect()
128
+ try:
129
+ writer.close()
130
+ await writer.wait_closed()
131
+ except Exception:
132
+ pass
133
+ framer.reset()
134
+
135
+ if not self._stop_event.is_set():
136
+ await self._interruptible_sleep(reconnect)
137
+
138
+ async def _interruptible_sleep(self, seconds: float) -> None:
139
+ """Sleep for *seconds* but wake immediately if stop is requested."""
140
+ try:
141
+ await asyncio.wait_for(
142
+ asyncio.shield(self._stop_event.wait()),
143
+ timeout=seconds,
144
+ )
145
+ except asyncio.TimeoutError:
146
+ pass
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: fixcore-engine
3
+ Version: 0.1.0
4
+ Summary: Pure Python FIX protocol engine
5
+ Author-email: Aidan Chisholm <aidan.chisholm@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Aidan Alexander Chisholm
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/aidan-chisholm/fixcore
29
+ Project-URL: Repository, https://github.com/aidan-chisholm/fixcore
30
+ Keywords: fix,financial,protocol,trading,quickfix
31
+ Classifier: Programming Language :: Python :: 3
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Operating System :: OS Independent
34
+ Classifier: Topic :: Office/Business :: Financial
35
+ Classifier: Intended Audience :: Developers
36
+ Requires-Python: >=3.11
37
+ Description-Content-Type: text/markdown
38
+ License-File: LICENSE
39
+ Provides-Extra: dev
40
+ Requires-Dist: pytest>=8.0; extra == "dev"
41
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
42
+ Requires-Dist: ruff>=0.4; extra == "dev"
43
+ Requires-Dist: mypy>=1.10; extra == "dev"
44
+ Provides-Extra: gui
45
+ Requires-Dist: aiohttp>=3.9; extra == "gui"
46
+ Dynamic: license-file
47
+
48
+ <img src="fixcore_logo.svg" alt="FIXcore" width="340" />
49
+
50
+ Pure Python FIX protocol engine — mirrors the QuickFIX architecture (QuickFIX/n, QuickFIX/J).
51
+
52
+ ## Features
53
+
54
+ - Full FIX 4.2 session layer (Logon, Logout, Heartbeat, ResendRequest, SequenceReset, Reject)
55
+ - Message encoding/decoding with DataDictionary validation
56
+ - Repeating groups
57
+ - Async transport: `SocketAcceptor` + `SocketInitiator` with auto-reconnect
58
+ - `FileStore` and `MemoryStore` persistence
59
+ - `MessageCracker` dispatch mixin
60
+ - Lightweight browser GUI via aiohttp (`tools/fix_gui.py`) — session management, message builder, live message log
61
+
62
+ ## Quick start
63
+
64
+ ```bash
65
+ pip install fixcore-engine
66
+ pip install "fixcore-engine[gui]" # includes aiohttp for the GUI
67
+ ```
68
+
69
+ ## Development
70
+
71
+ ```bash
72
+ pip install -e ".[dev,gui]"
73
+ pytest
74
+ python tools/fix_gui.py
75
+ ```
@@ -0,0 +1,32 @@
1
+ fixcore/__init__.py,sha256=kB3VWakZe8vfRllMdcPwFI7cIEnLKeVQKccgwvyjLuI,189
2
+ fixcore/application.py,sha256=NF3bZ9e61PVTkH6MecMJHPY85Q26blxkq7eJ0zBis0Q,1658
3
+ fixcore/log/__init__.py,sha256=LhtRdV8djjICrNRha-_hvWO44P8-SEJLGaBazfSTOnc,316
4
+ fixcore/log/base.py,sha256=Tuyre4h2Cngw_hloB0749jRCMRKhLxF-HHbv56sHCJQ,695
5
+ fixcore/log/factory.py,sha256=qYMSkel0w251ZWiBbtHiqpxejWmi1vfqXmw7CtSzM4A,384
6
+ fixcore/log/file_log.py,sha256=6jaL--3idj8mjahnOi_kz4cEc9GTt1fgKDaiNy5YRD8,2177
7
+ fixcore/log/screen.py,sha256=jQ6HsGpoYPNY1duASqosl6js5dQXX07Zm_f1bFTySMI,931
8
+ fixcore/message/__init__.py,sha256=ITFr0KySajGvyMfCxVx430MUQSbDaCSWEhmVAv7kfCY,708
9
+ fixcore/message/cracker.py,sha256=97xdfUeLJV2PmbDn4HJQVYbW5Zn4A5dGfwgI9jCsKs0,9160
10
+ fixcore/message/data_dictionary.py,sha256=at1Pc0Ku7j2LMw2FEp7GA428v3aYCr88WzbxDy456Xs,11270
11
+ fixcore/message/exceptions.py,sha256=SzjVpX0gs79lVeN5UdLhzSawBhph4T-GZx8S2jkkx_c,629
12
+ fixcore/message/field.py,sha256=M9oi9upU5douqaG5iCxZDQB3SmOzzaIEqEMpxbN0Bgc,5018
13
+ fixcore/message/message.py,sha256=pZNMSBbxKYON19CIq6gvlK4SzMVHcs7FPDZXVEfjqOE,13610
14
+ fixcore/session/__init__.py,sha256=cVkPLJ_DzB3A7Z8ssQb7-FAr09n8_mnwdCHM4C7iMxI,344
15
+ fixcore/session/session.py,sha256=LE2O7qOka5A8zUv8smoGgD0NEx5HsWta5kr42GpVSeY,20317
16
+ fixcore/session/session_id.py,sha256=ykKDV38P6RRnC1uMDdKGKoFNLwOCl8rfjRPo5x444mA,880
17
+ fixcore/session/session_settings.py,sha256=epx6aflPKrwSwL3CswnRmqqrLzWqdsxCYgH8z0EzzsU,5032
18
+ fixcore/session/state.py,sha256=gazczlRCpSibNHFVxY_yiQRCzIMxxFf_V9j2OPP_K4Y,1537
19
+ fixcore/store/__init__.py,sha256=co8EEl5aKMVI_9MHpsM8Gmhr_yIJA5xWu6cJ3-7TD68,431
20
+ fixcore/store/base.py,sha256=nrpDlrU9L_htCpwpnNMhKJAM04FPZ0yjkWdwGOkdcMA,1577
21
+ fixcore/store/factory.py,sha256=iZvAiITaZrwg2-SJjjc7r2_m7CNsdmc_8Ama_8CpVJg,990
22
+ fixcore/store/file_store.py,sha256=Ih_15nohYDq5dI6BtO7cs87Bt1VpwVzTwfP_zkVP8GI,5469
23
+ fixcore/store/memory.py,sha256=AkGsjXh1oTM-CXZEzsNpQWZXMRQ485GgXdRupPG-08U,1421
24
+ fixcore/transport/__init__.py,sha256=EuZg6qPwWOMuoFPcgvBjDX2bFYsi5QjhpEwNa87oIPk,296
25
+ fixcore/transport/acceptor.py,sha256=4fnxGaZdzHAgfS_L3Wku729xGhczRd3oizrz9pEsy1U,5933
26
+ fixcore/transport/framer.py,sha256=tReh1gLDtX8yv3wlNO4Y1CTMx91WpseuUPxMnkSU4_o,3643
27
+ fixcore/transport/initiator.py,sha256=mwRa4QRrCEDHkoCzpWrByIYAf4JjF9EbZK6HZnxnIf4,5169
28
+ fixcore_engine-0.1.0.dist-info/licenses/LICENSE,sha256=CZx7yTfXMKsRzuWtSQetjP_1vrKdPY2AJIVbRA7H9eM,1081
29
+ fixcore_engine-0.1.0.dist-info/METADATA,sha256=4upgAZxM_JdNPqSGI0OhRCMrRKM5SEO15oZekedkNRA,3028
30
+ fixcore_engine-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
31
+ fixcore_engine-0.1.0.dist-info/top_level.txt,sha256=0K_SIcbEjG9Y6KafA3APKFq5S4ZizMkDmycBA41Lchk,8
32
+ fixcore_engine-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aidan Alexander Chisholm
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ fixcore