fixcore-engine 0.1.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.
Files changed (48) hide show
  1. fixcore_engine-0.1.0/LICENSE +21 -0
  2. fixcore_engine-0.1.0/PKG-INFO +75 -0
  3. fixcore_engine-0.1.0/README.md +28 -0
  4. fixcore_engine-0.1.0/fixcore/__init__.py +6 -0
  5. fixcore_engine-0.1.0/fixcore/application.py +47 -0
  6. fixcore_engine-0.1.0/fixcore/log/__init__.py +7 -0
  7. fixcore_engine-0.1.0/fixcore/log/base.py +27 -0
  8. fixcore_engine-0.1.0/fixcore/log/factory.py +10 -0
  9. fixcore_engine-0.1.0/fixcore/log/file_log.py +70 -0
  10. fixcore_engine-0.1.0/fixcore/log/screen.py +32 -0
  11. fixcore_engine-0.1.0/fixcore/message/__init__.py +17 -0
  12. fixcore_engine-0.1.0/fixcore/message/cracker.py +243 -0
  13. fixcore_engine-0.1.0/fixcore/message/data_dictionary.py +298 -0
  14. fixcore_engine-0.1.0/fixcore/message/exceptions.py +21 -0
  15. fixcore_engine-0.1.0/fixcore/message/field.py +147 -0
  16. fixcore_engine-0.1.0/fixcore/message/message.py +403 -0
  17. fixcore_engine-0.1.0/fixcore/session/__init__.py +8 -0
  18. fixcore_engine-0.1.0/fixcore/session/session.py +532 -0
  19. fixcore_engine-0.1.0/fixcore/session/session_id.py +32 -0
  20. fixcore_engine-0.1.0/fixcore/session/session_settings.py +146 -0
  21. fixcore_engine-0.1.0/fixcore/session/state.py +60 -0
  22. fixcore_engine-0.1.0/fixcore/store/__init__.py +11 -0
  23. fixcore_engine-0.1.0/fixcore/store/base.py +49 -0
  24. fixcore_engine-0.1.0/fixcore/store/factory.py +33 -0
  25. fixcore_engine-0.1.0/fixcore/store/file_store.py +162 -0
  26. fixcore_engine-0.1.0/fixcore/store/memory.py +50 -0
  27. fixcore_engine-0.1.0/fixcore/transport/__init__.py +7 -0
  28. fixcore_engine-0.1.0/fixcore/transport/acceptor.py +166 -0
  29. fixcore_engine-0.1.0/fixcore/transport/framer.py +107 -0
  30. fixcore_engine-0.1.0/fixcore/transport/initiator.py +146 -0
  31. fixcore_engine-0.1.0/fixcore_engine.egg-info/PKG-INFO +75 -0
  32. fixcore_engine-0.1.0/fixcore_engine.egg-info/SOURCES.txt +46 -0
  33. fixcore_engine-0.1.0/fixcore_engine.egg-info/dependency_links.txt +1 -0
  34. fixcore_engine-0.1.0/fixcore_engine.egg-info/requires.txt +9 -0
  35. fixcore_engine-0.1.0/fixcore_engine.egg-info/top_level.txt +1 -0
  36. fixcore_engine-0.1.0/pyproject.toml +55 -0
  37. fixcore_engine-0.1.0/setup.cfg +4 -0
  38. fixcore_engine-0.1.0/tests/test_cracker.py +229 -0
  39. fixcore_engine-0.1.0/tests/test_data_dictionary.py +262 -0
  40. fixcore_engine-0.1.0/tests/test_file_log.py +114 -0
  41. fixcore_engine-0.1.0/tests/test_file_store.py +178 -0
  42. fixcore_engine-0.1.0/tests/test_framer.py +157 -0
  43. fixcore_engine-0.1.0/tests/test_integration.py +713 -0
  44. fixcore_engine-0.1.0/tests/test_message.py +219 -0
  45. fixcore_engine-0.1.0/tests/test_session.py +522 -0
  46. fixcore_engine-0.1.0/tests/test_session_id.py +74 -0
  47. fixcore_engine-0.1.0/tests/test_store.py +36 -0
  48. fixcore_engine-0.1.0/tests/test_transport.py +262 -0
@@ -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,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,28 @@
1
+ <img src="fixcore_logo.svg" alt="FIXcore" width="340" />
2
+
3
+ Pure Python FIX protocol engine — mirrors the QuickFIX architecture (QuickFIX/n, QuickFIX/J).
4
+
5
+ ## Features
6
+
7
+ - Full FIX 4.2 session layer (Logon, Logout, Heartbeat, ResendRequest, SequenceReset, Reject)
8
+ - Message encoding/decoding with DataDictionary validation
9
+ - Repeating groups
10
+ - Async transport: `SocketAcceptor` + `SocketInitiator` with auto-reconnect
11
+ - `FileStore` and `MemoryStore` persistence
12
+ - `MessageCracker` dispatch mixin
13
+ - Lightweight browser GUI via aiohttp (`tools/fix_gui.py`) — session management, message builder, live message log
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ pip install fixcore-engine
19
+ pip install "fixcore-engine[gui]" # includes aiohttp for the GUI
20
+ ```
21
+
22
+ ## Development
23
+
24
+ ```bash
25
+ pip install -e ".[dev,gui]"
26
+ pytest
27
+ python tools/fix_gui.py
28
+ ```
@@ -0,0 +1,6 @@
1
+ """quickfix-py — pure Python FIX protocol engine."""
2
+
3
+ from fixcore.application import Application
4
+ from fixcore.session.session_id import SessionID
5
+
6
+ __all__ = ["Application", "SessionID"]
@@ -0,0 +1,47 @@
1
+ """Application interface — users subclass this to implement their trading logic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from fixcore.message.message import Message
10
+ from fixcore.session.session_id import SessionID
11
+
12
+
13
+ class Application(ABC):
14
+ """Abstract base for FIX application callbacks.
15
+
16
+ All methods are called from within the asyncio event loop. Blocking
17
+ implementations must delegate to ``asyncio.get_event_loop().run_in_executor``
18
+ to avoid stalling other sessions.
19
+ """
20
+
21
+ @abstractmethod
22
+ def on_create(self, session_id: SessionID) -> None:
23
+ """Called when a session is created."""
24
+
25
+ @abstractmethod
26
+ def on_logon(self, session_id: SessionID) -> None:
27
+ """Called after a successful Logon exchange."""
28
+
29
+ @abstractmethod
30
+ def on_logout(self, session_id: SessionID) -> None:
31
+ """Called after a session logs out or disconnects."""
32
+
33
+ @abstractmethod
34
+ def to_admin(self, message: Message, session_id: SessionID) -> None:
35
+ """Called before an admin message is sent; may mutate *message* in place."""
36
+
37
+ @abstractmethod
38
+ def to_app(self, message: Message, session_id: SessionID) -> None:
39
+ """Called before an application message is sent; may mutate *message* in place."""
40
+
41
+ @abstractmethod
42
+ def from_admin(self, message: Message, session_id: SessionID) -> None:
43
+ """Called when an admin message is received."""
44
+
45
+ @abstractmethod
46
+ def from_app(self, message: Message, session_id: SessionID) -> None:
47
+ """Called when an application message is received."""
@@ -0,0 +1,7 @@
1
+ """Logging layer — session and message event logging."""
2
+
3
+ from fixcore.log.base import Log, LogFactory
4
+ from fixcore.log.file_log import FileLog, FileLogFactory
5
+ from fixcore.log.screen import ScreenLog, ScreenLogFactory
6
+
7
+ __all__ = ["Log", "LogFactory", "FileLog", "FileLogFactory", "ScreenLog", "ScreenLogFactory"]
@@ -0,0 +1,27 @@
1
+ """Log / LogFactory abstract base classes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from fixcore.session.session_id import SessionID
8
+
9
+
10
+ class Log(ABC):
11
+ @abstractmethod
12
+ def on_incoming(self, message: str) -> None:
13
+ """Log a raw incoming message."""
14
+
15
+ @abstractmethod
16
+ def on_outgoing(self, message: str) -> None:
17
+ """Log a raw outgoing message."""
18
+
19
+ @abstractmethod
20
+ def on_event(self, text: str) -> None:
21
+ """Log a session event (connect, disconnect, error, etc.)."""
22
+
23
+
24
+ class LogFactory(ABC):
25
+ @abstractmethod
26
+ def create(self, session_id: SessionID) -> Log:
27
+ """Return a Log instance for *session_id*."""
@@ -0,0 +1,10 @@
1
+ """LogFactory helpers — screen and file implementations already in their own modules.
2
+
3
+ Re-exports everything under one roof so transport code only needs one import.
4
+ """
5
+
6
+ from fixcore.log.base import Log, LogFactory
7
+ from fixcore.log.file_log import FileLogFactory
8
+ from fixcore.log.screen import ScreenLogFactory
9
+
10
+ __all__ = ["Log", "LogFactory", "FileLogFactory", "ScreenLogFactory"]
@@ -0,0 +1,70 @@
1
+ """File-backed Log implementation — appends timestamped entries to a log file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ from fixcore.log.base import Log, LogFactory
9
+ from fixcore.session.session_id import SessionID
10
+
11
+
12
+ def _session_filename(session_id: SessionID) -> str:
13
+ return (
14
+ f"{session_id.begin_string}-"
15
+ f"{session_id.sender_comp_id}-"
16
+ f"{session_id.target_comp_id}"
17
+ + (f"-{session_id.qualifier}" if session_id.qualifier else "")
18
+ + ".log"
19
+ )
20
+
21
+
22
+ def _now() -> str:
23
+ return datetime.now(tz=timezone.utc).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]
24
+
25
+
26
+ class FileLog(Log):
27
+ """Writes incoming messages, outgoing messages, and session events to a
28
+ single append-only log file.
29
+
30
+ Log line format::
31
+
32
+ 20240101-12:00:00.000 : <incoming> 8=FIX.4.2|9=...|
33
+ 20240101-12:00:00.001 : <outgoing> 8=FIX.4.2|9=...|
34
+ 20240101-12:00:00.002 : --event-- Logon complete
35
+ """
36
+
37
+ def __init__(self, log_dir: str | Path, session_id: SessionID) -> None:
38
+ log_path = Path(log_dir) / _session_filename(session_id)
39
+ log_path.parent.mkdir(parents=True, exist_ok=True)
40
+ self._fh = log_path.open("a", encoding="utf-8", buffering=1) # line-buffered
41
+
42
+ def on_incoming(self, message: str) -> None:
43
+ self._fh.write(f"{_now()} : <incoming> {message}\n")
44
+
45
+ def on_outgoing(self, message: str) -> None:
46
+ self._fh.write(f"{_now()} : <outgoing> {message}\n")
47
+
48
+ def on_event(self, text: str) -> None:
49
+ self._fh.write(f"{_now()} : --event-- {text}\n")
50
+
51
+ def close(self) -> None:
52
+ """Flush and close the underlying file handle."""
53
+ self._fh.flush()
54
+ self._fh.close()
55
+
56
+ def __del__(self) -> None:
57
+ try:
58
+ self._fh.close()
59
+ except Exception:
60
+ pass
61
+
62
+
63
+ class FileLogFactory(LogFactory):
64
+ """Creates a :class:`FileLog` for each session under *log_dir*."""
65
+
66
+ def __init__(self, log_dir: str | Path) -> None:
67
+ self._log_dir = Path(log_dir)
68
+
69
+ def create(self, session_id: SessionID) -> FileLog:
70
+ return FileLog(self._log_dir, session_id)
@@ -0,0 +1,32 @@
1
+ """Screen (stdout) log implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from datetime import datetime, timezone
7
+
8
+ from fixcore.log.base import Log, LogFactory
9
+ from fixcore.session.session_id import SessionID
10
+
11
+
12
+ def _now() -> str:
13
+ return datetime.now(tz=timezone.utc).strftime("%Y%m%d-%H:%M:%S.%f")[:-3]
14
+
15
+
16
+ class ScreenLog(Log):
17
+ def __init__(self, session_id: SessionID) -> None:
18
+ self._prefix = str(session_id)
19
+
20
+ def on_incoming(self, message: str) -> None:
21
+ print(f"{_now()} {self._prefix} < {message}", file=sys.stdout)
22
+
23
+ def on_outgoing(self, message: str) -> None:
24
+ print(f"{_now()} {self._prefix} > {message}", file=sys.stdout)
25
+
26
+ def on_event(self, text: str) -> None:
27
+ print(f"{_now()} {self._prefix} -- {text}", file=sys.stdout)
28
+
29
+
30
+ class ScreenLogFactory(LogFactory):
31
+ def create(self, session_id: SessionID) -> ScreenLog:
32
+ return ScreenLog(session_id)
@@ -0,0 +1,17 @@
1
+ """FIX message layer — encoding, decoding, and field definitions."""
2
+
3
+ from fixcore.message.cracker import MessageCracker
4
+ from fixcore.message.data_dictionary import DataDictionary, FieldDef, GroupDef, MessageDef
5
+ from fixcore.message.exceptions import (
6
+ FieldNotFound, InvalidMessage, UnsupportedMessageType, UnsupportedVersion,
7
+ )
8
+ from fixcore.message.field import Field, FieldMap, Group
9
+ from fixcore.message.message import Header, Message, Trailer
10
+
11
+ __all__ = [
12
+ "MessageCracker",
13
+ "DataDictionary", "FieldDef", "GroupDef", "MessageDef",
14
+ "FieldNotFound", "InvalidMessage", "UnsupportedMessageType", "UnsupportedVersion",
15
+ "Field", "FieldMap", "Group",
16
+ "Header", "Message", "Trailer",
17
+ ]
@@ -0,0 +1,243 @@
1
+ """MessageCracker — mixin that dispatches fromApp messages to typed handler methods.
2
+
3
+ Usage::
4
+
5
+ class MyApp(Application, MessageCracker):
6
+ def from_app(self, message: Message, session_id: SessionID) -> None:
7
+ self.crack(message, session_id)
8
+
9
+ def on_new_order_single(self, message: Message, session_id: SessionID) -> None:
10
+ clord_id = message.get_field(11)
11
+ ...
12
+
13
+ def on_execution_report(self, message: Message, session_id: SessionID) -> None:
14
+ ...
15
+
16
+ Handler method names follow the convention ``on_<snake_case_message_name>``. If
17
+ no matching method is found, :meth:`on_message` is called, which raises
18
+ :exc:`~fixcore.message.exceptions.UnsupportedMessageType` by default.
19
+
20
+ Custom / venue-specific message types can be registered at class or instance
21
+ level::
22
+
23
+ MessageCracker.register("U1", "on_custom_order") # class-level
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from fixcore.message.exceptions import UnsupportedMessageType
29
+ from fixcore.message.message import Message
30
+ from fixcore.session.session_id import SessionID
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # MsgType → handler method name registry
35
+ # ---------------------------------------------------------------------------
36
+
37
+ # FIX 4.2 / 4.4 application messages
38
+ _REGISTRY: dict[str, str] = {
39
+ # Orders
40
+ "D": "on_new_order_single",
41
+ "F": "on_order_cancel_request",
42
+ "G": "on_order_cancel_replace_request",
43
+ "H": "on_order_status_request",
44
+ "Q": "on_dont_know_trade",
45
+ # Executions / responses
46
+ "8": "on_execution_report",
47
+ "9": "on_order_cancel_reject",
48
+ # Lists
49
+ "E": "on_new_order_list",
50
+ "K": "on_list_cancel_request",
51
+ "L": "on_list_execute",
52
+ "M": "on_list_status_request",
53
+ "N": "on_list_status",
54
+ # Market data
55
+ "V": "on_market_data_request",
56
+ "W": "on_market_data_snapshot_full_refresh",
57
+ "X": "on_market_data_incremental_refresh",
58
+ "Y": "on_market_data_request_reject",
59
+ # Quotes
60
+ "R": "on_quote_request",
61
+ "S": "on_quote",
62
+ "Z": "on_quote_cancel",
63
+ "a": "on_quote_status_request",
64
+ "b": "on_mass_quote_acknowledgement",
65
+ "i": "on_mass_quote",
66
+ # Security / reference data
67
+ "c": "on_security_definition_request",
68
+ "d": "on_security_definition",
69
+ "e": "on_security_status_request",
70
+ "f": "on_security_status",
71
+ "g": "on_trading_session_status_request",
72
+ "h": "on_trading_session_status",
73
+ # Allocation / settlement
74
+ "J": "on_allocation",
75
+ "P": "on_allocation_ack",
76
+ "T": "on_settlement_instructions",
77
+ # News / email
78
+ "B": "on_news",
79
+ "C": "on_email",
80
+ # Business / application reject
81
+ "j": "on_business_message_reject",
82
+ # FIX 4.4 / 5.0 additions
83
+ "AB": "on_new_order_multileg",
84
+ "AC": "on_multileg_order_cancel_replace",
85
+ "AD": "on_trade_capture_report_request",
86
+ "AE": "on_trade_capture_report",
87
+ "AF": "on_order_mass_status_request",
88
+ "AG": "on_quote_request_reject",
89
+ "AH": "on_rfq_request",
90
+ "AI": "on_quote_status_report",
91
+ "AJ": "on_quote_response",
92
+ "AK": "on_confirmation",
93
+ "AL": "on_position_maintenance_request",
94
+ "AM": "on_position_maintenance_report",
95
+ "AN": "on_request_for_positions",
96
+ "AO": "on_request_for_positions_ack",
97
+ "AP": "on_position_report",
98
+ "AQ": "on_trade_capture_report_request_ack",
99
+ "AR": "on_trade_capture_report_ack",
100
+ "AS": "on_allocation_report",
101
+ "AT": "on_allocation_report_ack",
102
+ "AU": "on_confirmation_ack",
103
+ "AV": "on_settlement_instruction_request",
104
+ "AW": "on_assignment_report",
105
+ "AX": "on_collateral_request",
106
+ "AY": "on_collateral_assignment",
107
+ "AZ": "on_collateral_response",
108
+ }
109
+
110
+
111
+ class MessageCracker:
112
+ """Mixin providing MsgType-based dispatch for :meth:`Application.from_app`.
113
+
114
+ Combine with :class:`~fixcore.application.Application`::
115
+
116
+ class MyApp(Application, MessageCracker):
117
+ def from_app(self, message, session_id):
118
+ self.crack(message, session_id)
119
+
120
+ def on_new_order_single(self, message, session_id):
121
+ ...
122
+ """
123
+
124
+ # Instance-level overrides (populated by register_instance or subclass __init__)
125
+ _instance_registry: dict[str, str]
126
+
127
+ # ------------------------------------------------------------------
128
+ # Dispatch
129
+ # ------------------------------------------------------------------
130
+
131
+ def crack(self, message: Message, session_id: SessionID) -> None:
132
+ """Dispatch *message* to the appropriate typed handler.
133
+
134
+ Resolution order:
135
+ 1. Instance registry (set via :meth:`register_instance`)
136
+ 2. Class-level registry (set via :meth:`register`)
137
+ 3. :meth:`on_message` fallback
138
+ """
139
+ msg_type = message.msg_type
140
+
141
+ # Instance overrides take precedence
142
+ instance_reg = getattr(self, "_instance_registry", {})
143
+ handler_name = instance_reg.get(msg_type) or _REGISTRY.get(msg_type)
144
+
145
+ if handler_name:
146
+ handler = getattr(self, handler_name, None)
147
+ if handler is not None:
148
+ handler(message, session_id)
149
+ return
150
+
151
+ self.on_message(message, session_id)
152
+
153
+ def on_message(self, message: Message, session_id: SessionID) -> None:
154
+ """Fallback called when no specific handler is found.
155
+
156
+ Raises :exc:`~fixcore.message.exceptions.UnsupportedMessageType`
157
+ by default. Override to handle all unknown message types generically.
158
+ """
159
+ raise UnsupportedMessageType(message.msg_type)
160
+
161
+ # ------------------------------------------------------------------
162
+ # Registration helpers
163
+ # ------------------------------------------------------------------
164
+
165
+ @classmethod
166
+ def register(cls, msg_type: str, handler_name: str) -> None:
167
+ """Register a class-wide handler for *msg_type*.
168
+
169
+ Example::
170
+
171
+ MessageCracker.register("U1", "on_custom_order")
172
+ """
173
+ _REGISTRY[msg_type] = handler_name
174
+
175
+ def register_instance(self, msg_type: str, handler_name: str) -> None:
176
+ """Register a handler for *msg_type* on this instance only."""
177
+ if not hasattr(self, "_instance_registry"):
178
+ self._instance_registry = {}
179
+ self._instance_registry[msg_type] = handler_name
180
+
181
+ # ------------------------------------------------------------------
182
+ # Default typed handlers (all call on_message — override to handle)
183
+ # ------------------------------------------------------------------
184
+
185
+ def on_new_order_single(self, message: Message, session_id: SessionID) -> None:
186
+ self.on_message(message, session_id)
187
+
188
+ def on_execution_report(self, message: Message, session_id: SessionID) -> None:
189
+ self.on_message(message, session_id)
190
+
191
+ def on_order_cancel_request(self, message: Message, session_id: SessionID) -> None:
192
+ self.on_message(message, session_id)
193
+
194
+ def on_order_cancel_reject(self, message: Message, session_id: SessionID) -> None:
195
+ self.on_message(message, session_id)
196
+
197
+ def on_order_cancel_replace_request(self, message: Message, session_id: SessionID) -> None:
198
+ self.on_message(message, session_id)
199
+
200
+ def on_order_status_request(self, message: Message, session_id: SessionID) -> None:
201
+ self.on_message(message, session_id)
202
+
203
+ def on_market_data_request(self, message: Message, session_id: SessionID) -> None:
204
+ self.on_message(message, session_id)
205
+
206
+ def on_market_data_snapshot_full_refresh(self, message: Message, session_id: SessionID) -> None:
207
+ self.on_message(message, session_id)
208
+
209
+ def on_market_data_incremental_refresh(self, message: Message, session_id: SessionID) -> None:
210
+ self.on_message(message, session_id)
211
+
212
+ def on_market_data_request_reject(self, message: Message, session_id: SessionID) -> None:
213
+ self.on_message(message, session_id)
214
+
215
+ def on_quote_request(self, message: Message, session_id: SessionID) -> None:
216
+ self.on_message(message, session_id)
217
+
218
+ def on_quote(self, message: Message, session_id: SessionID) -> None:
219
+ self.on_message(message, session_id)
220
+
221
+ def on_news(self, message: Message, session_id: SessionID) -> None:
222
+ self.on_message(message, session_id)
223
+
224
+ def on_business_message_reject(self, message: Message, session_id: SessionID) -> None:
225
+ self.on_message(message, session_id)
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Auto-generate default handlers for any registered handler name that doesn't
230
+ # already have an explicit implementation on the class.
231
+ # ---------------------------------------------------------------------------
232
+
233
+ def _make_default_handler(name: str): # noqa: ANN202
234
+ def handler(self: MessageCracker, message: Message, session_id: SessionID) -> None:
235
+ self.on_message(message, session_id)
236
+ handler.__name__ = name
237
+ handler.__qualname__ = f"MessageCracker.{name}"
238
+ return handler
239
+
240
+
241
+ for _handler_name in set(_REGISTRY.values()):
242
+ if not hasattr(MessageCracker, _handler_name):
243
+ setattr(MessageCracker, _handler_name, _make_default_handler(_handler_name))