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 ADDED
@@ -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"]
fixcore/application.py ADDED
@@ -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"]
fixcore/log/base.py ADDED
@@ -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*."""
fixcore/log/factory.py ADDED
@@ -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)
fixcore/log/screen.py ADDED
@@ -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))