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,532 @@
|
|
|
1
|
+
"""FIX Session — state machine, sequence number management, admin message handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Awaitable, Callable
|
|
9
|
+
|
|
10
|
+
from fixcore.application import Application
|
|
11
|
+
from fixcore.log.base import Log
|
|
12
|
+
from fixcore.message.message import Message
|
|
13
|
+
from fixcore.session.session_id import SessionID
|
|
14
|
+
from fixcore.session.session_settings import SessionSettings
|
|
15
|
+
from fixcore.session.state import (
|
|
16
|
+
ADMIN_MSG_TYPES,
|
|
17
|
+
MSG_HEARTBEAT,
|
|
18
|
+
MSG_LOGON,
|
|
19
|
+
MSG_LOGOUT,
|
|
20
|
+
MSG_REJECT,
|
|
21
|
+
MSG_RESEND_REQUEST,
|
|
22
|
+
MSG_SEQUENCE_RESET,
|
|
23
|
+
MSG_TEST_REQUEST,
|
|
24
|
+
TAG_BEGIN_SEQ_NO,
|
|
25
|
+
TAG_BEGIN_STRING,
|
|
26
|
+
TAG_ENCRYPT_METHOD,
|
|
27
|
+
TAG_END_SEQ_NO,
|
|
28
|
+
TAG_GAP_FILL_FLAG,
|
|
29
|
+
TAG_HEART_BT_INT,
|
|
30
|
+
TAG_MSG_SEQ_NUM,
|
|
31
|
+
TAG_MSG_TYPE,
|
|
32
|
+
TAG_NEW_SEQ_NO,
|
|
33
|
+
TAG_ORIG_SENDING_TIME,
|
|
34
|
+
TAG_POSS_DUP_FLAG,
|
|
35
|
+
TAG_REF_MSG_TYPE,
|
|
36
|
+
TAG_REF_SEQ_NUM,
|
|
37
|
+
TAG_REF_TAG_ID,
|
|
38
|
+
TAG_RESET_SEQ_NUM_FLAG,
|
|
39
|
+
TAG_SENDER_COMP_ID,
|
|
40
|
+
TAG_SENDING_TIME,
|
|
41
|
+
TAG_SESSION_REJECT_REASON,
|
|
42
|
+
TAG_TARGET_COMP_ID,
|
|
43
|
+
TAG_TEST_REQ_ID,
|
|
44
|
+
TAG_TEXT,
|
|
45
|
+
SessionState,
|
|
46
|
+
)
|
|
47
|
+
from fixcore.store.base import MessageStore
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _utc_timestamp() -> str:
|
|
51
|
+
return datetime.now(tz=timezone.utc).strftime("%Y%m%d-%H:%M:%S")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Type alias for the transport-provided send callback
|
|
55
|
+
SendFn = Callable[[bytes], Awaitable[None]]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Session:
|
|
59
|
+
"""Manages a single FIX session's full lifecycle.
|
|
60
|
+
|
|
61
|
+
The transport layer calls:
|
|
62
|
+
- ``on_connect(send_fn)`` — when TCP connection is established
|
|
63
|
+
- ``on_disconnect()`` — when connection is lost
|
|
64
|
+
- ``on_data(raw)`` — with each complete, framed FIX message
|
|
65
|
+
|
|
66
|
+
User code calls:
|
|
67
|
+
- ``send_app(message)`` — to send an application message
|
|
68
|
+
- ``send_logout(text)`` — to initiate a graceful logout
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
session_id: SessionID,
|
|
74
|
+
settings: SessionSettings,
|
|
75
|
+
application: Application,
|
|
76
|
+
store: MessageStore,
|
|
77
|
+
log: Log,
|
|
78
|
+
) -> None:
|
|
79
|
+
self._id = session_id
|
|
80
|
+
self._settings = settings
|
|
81
|
+
self._app = application
|
|
82
|
+
self._store = store
|
|
83
|
+
self._log = log
|
|
84
|
+
|
|
85
|
+
# Config (with defaults)
|
|
86
|
+
self._heartbt_int: int = settings.get_int(session_id, "HeartBtInt", 30)
|
|
87
|
+
self._logon_timeout: int = settings.get_int(session_id, "LogonTimeout", 10)
|
|
88
|
+
self._logout_timeout: int = settings.get_int(session_id, "LogoutTimeout", 10)
|
|
89
|
+
self._is_initiator: bool = (
|
|
90
|
+
settings.get_or(session_id, "ConnectionType", "initiator").lower() == "initiator"
|
|
91
|
+
)
|
|
92
|
+
self._reset_on_logon: bool = settings.get_bool(session_id, "ResetOnLogon", False)
|
|
93
|
+
self._reset_on_logout: bool = settings.get_bool(session_id, "ResetOnLogout", False)
|
|
94
|
+
self._reset_on_disconnect: bool = settings.get_bool(session_id, "ResetOnDisconnect", False)
|
|
95
|
+
|
|
96
|
+
# Runtime state
|
|
97
|
+
self._state: SessionState = SessionState.DISCONNECTED
|
|
98
|
+
self._send_fn: SendFn | None = None
|
|
99
|
+
self._heartbeat_task: asyncio.Task[None] | None = None
|
|
100
|
+
self._last_send_time: float = 0.0
|
|
101
|
+
self._last_recv_time: float = 0.0
|
|
102
|
+
self._test_req_id: str | None = None
|
|
103
|
+
self._test_req_sent_at: float = 0.0
|
|
104
|
+
self._logon_sent_at: float = 0.0
|
|
105
|
+
|
|
106
|
+
self._app.on_create(session_id)
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
# Transport interface
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
async def on_connect(self, send_fn: SendFn) -> None:
|
|
113
|
+
"""Called by transport when connection is established."""
|
|
114
|
+
self._send_fn = send_fn
|
|
115
|
+
self._last_recv_time = time.monotonic()
|
|
116
|
+
|
|
117
|
+
if self._reset_on_logon:
|
|
118
|
+
self._store.reset()
|
|
119
|
+
|
|
120
|
+
self._state = SessionState.LOGON_TIMEOUT
|
|
121
|
+
self._heartbeat_task = asyncio.create_task(self._timer_loop())
|
|
122
|
+
|
|
123
|
+
if self._is_initiator:
|
|
124
|
+
await self._send_logon()
|
|
125
|
+
|
|
126
|
+
async def on_disconnect(self) -> None:
|
|
127
|
+
"""Called by transport when connection is lost."""
|
|
128
|
+
was_logged_on = self._state == SessionState.LOGGED_ON
|
|
129
|
+
self._state = SessionState.DISCONNECTED
|
|
130
|
+
self._send_fn = None
|
|
131
|
+
|
|
132
|
+
if self._heartbeat_task is not None:
|
|
133
|
+
self._heartbeat_task.cancel()
|
|
134
|
+
try:
|
|
135
|
+
await self._heartbeat_task
|
|
136
|
+
except asyncio.CancelledError:
|
|
137
|
+
pass
|
|
138
|
+
self._heartbeat_task = None
|
|
139
|
+
|
|
140
|
+
self._test_req_id = None
|
|
141
|
+
|
|
142
|
+
if self._reset_on_disconnect:
|
|
143
|
+
self._store.reset()
|
|
144
|
+
|
|
145
|
+
if was_logged_on:
|
|
146
|
+
self._app.on_logout(self._id)
|
|
147
|
+
|
|
148
|
+
self._log.on_event("Disconnected")
|
|
149
|
+
|
|
150
|
+
async def on_data(self, raw: bytes) -> None:
|
|
151
|
+
"""Called by transport with a single complete framed FIX message."""
|
|
152
|
+
try:
|
|
153
|
+
msg = Message.decode(raw)
|
|
154
|
+
except ValueError as exc:
|
|
155
|
+
self._log.on_event(f"Decode error: {exc}")
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
self._log.on_incoming(raw.replace(b"\x01", b"|").decode("latin-1"))
|
|
159
|
+
self._last_recv_time = time.monotonic()
|
|
160
|
+
self._test_req_id = None # any incoming message clears a pending TestRequest
|
|
161
|
+
|
|
162
|
+
msg_type = msg.msg_type
|
|
163
|
+
if not msg_type:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
# Header validation
|
|
167
|
+
if not self._validate_comp_ids(msg):
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
if not await self._check_seq_num(msg):
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
# Dispatch
|
|
174
|
+
if msg_type == MSG_LOGON:
|
|
175
|
+
await self._on_logon(msg)
|
|
176
|
+
elif msg_type == MSG_LOGOUT:
|
|
177
|
+
await self._on_logout(msg)
|
|
178
|
+
elif msg_type == MSG_HEARTBEAT:
|
|
179
|
+
await self._on_heartbeat(msg)
|
|
180
|
+
elif msg_type == MSG_TEST_REQUEST:
|
|
181
|
+
await self._on_test_request(msg)
|
|
182
|
+
elif msg_type == MSG_RESEND_REQUEST:
|
|
183
|
+
await self._on_resend_request(msg)
|
|
184
|
+
elif msg_type == MSG_SEQUENCE_RESET:
|
|
185
|
+
await self._on_sequence_reset(msg)
|
|
186
|
+
elif msg_type == MSG_REJECT:
|
|
187
|
+
await self._on_reject(msg)
|
|
188
|
+
else:
|
|
189
|
+
if self._state != SessionState.LOGGED_ON:
|
|
190
|
+
self._log.on_event(f"Received app message {msg_type!r} while not logged on — ignoring")
|
|
191
|
+
return
|
|
192
|
+
self._app.from_app(msg, self._id)
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
# User-facing send API
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
async def send_app(self, message: Message) -> None:
|
|
199
|
+
"""Send an application message. Raises RuntimeError if not logged on."""
|
|
200
|
+
if self._state != SessionState.LOGGED_ON:
|
|
201
|
+
raise RuntimeError(f"Session {self._id} is not logged on")
|
|
202
|
+
await self._send_message(message, is_admin=False)
|
|
203
|
+
|
|
204
|
+
async def send_logout(self, text: str = "") -> None:
|
|
205
|
+
"""Initiate a graceful logout."""
|
|
206
|
+
await self._send_logout(text)
|
|
207
|
+
|
|
208
|
+
# ------------------------------------------------------------------
|
|
209
|
+
# Properties
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def session_id(self) -> SessionID:
|
|
214
|
+
return self._id
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def state(self) -> SessionState:
|
|
218
|
+
return self._state
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def is_logged_on(self) -> bool:
|
|
222
|
+
return self._state == SessionState.LOGGED_ON
|
|
223
|
+
|
|
224
|
+
# ------------------------------------------------------------------
|
|
225
|
+
# Incoming admin message handlers
|
|
226
|
+
# ------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
async def _on_logon(self, msg: Message) -> None:
|
|
229
|
+
if self._state == SessionState.LOGGED_ON:
|
|
230
|
+
self._log.on_event("Received second Logon while already logged on — ignoring")
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
reset_flag = msg.header.get_or(TAG_RESET_SEQ_NUM_FLAG, "N").upper() == "Y"
|
|
234
|
+
if reset_flag:
|
|
235
|
+
self._store.reset()
|
|
236
|
+
# seq num was already consumed by _check_seq_num; set next target to 2
|
|
237
|
+
self._store.set_next_target_msg_seq_num(2)
|
|
238
|
+
|
|
239
|
+
if not self._is_initiator:
|
|
240
|
+
# Acceptor: respond with Logon
|
|
241
|
+
await self._send_logon()
|
|
242
|
+
|
|
243
|
+
self._state = SessionState.LOGGED_ON
|
|
244
|
+
self._app.on_logon(self._id)
|
|
245
|
+
self._log.on_event("Logon complete")
|
|
246
|
+
|
|
247
|
+
async def _on_logout(self, msg: Message) -> None:
|
|
248
|
+
text = msg.get_field_or(TAG_TEXT, "")
|
|
249
|
+
self._log.on_event(f"Received Logout{': ' + text if text else ''}")
|
|
250
|
+
|
|
251
|
+
if self._state == SessionState.LOGOUT_TIMEOUT:
|
|
252
|
+
# We sent Logout first; counterparty confirmed — clean disconnect
|
|
253
|
+
self._state = SessionState.DISCONNECTED
|
|
254
|
+
else:
|
|
255
|
+
# Counterparty-initiated logout — echo back and clean up
|
|
256
|
+
await self._send_logout()
|
|
257
|
+
|
|
258
|
+
self._app.on_logout(self._id)
|
|
259
|
+
self._log.on_event("Logout complete")
|
|
260
|
+
|
|
261
|
+
async def _on_heartbeat(self, msg: Message) -> None:
|
|
262
|
+
# If this is a response to our TestRequest, clear the pending state
|
|
263
|
+
test_req_id = msg.get_field_or(TAG_TEST_REQ_ID)
|
|
264
|
+
if test_req_id and test_req_id == self._test_req_id:
|
|
265
|
+
self._test_req_id = None
|
|
266
|
+
self._app.from_admin(msg, self._id)
|
|
267
|
+
|
|
268
|
+
async def _on_test_request(self, msg: Message) -> None:
|
|
269
|
+
test_req_id = msg.get_field_or(TAG_TEST_REQ_ID, "")
|
|
270
|
+
await self._send_heartbeat(test_req_id=test_req_id)
|
|
271
|
+
self._app.from_admin(msg, self._id)
|
|
272
|
+
|
|
273
|
+
async def _on_resend_request(self, msg: Message) -> None:
|
|
274
|
+
begin = int(msg.get_field_or(TAG_BEGIN_SEQ_NO, "1"))
|
|
275
|
+
end = int(msg.get_field_or(TAG_END_SEQ_NO, "0")) # 0 = infinity
|
|
276
|
+
if end == 0:
|
|
277
|
+
end = self._store.next_sender_msg_seq_num() - 1
|
|
278
|
+
|
|
279
|
+
self._log.on_event(f"ResendRequest [{begin},{end}]")
|
|
280
|
+
await self._resend_range(begin, end)
|
|
281
|
+
self._app.from_admin(msg, self._id)
|
|
282
|
+
|
|
283
|
+
async def _on_sequence_reset(self, msg: Message) -> None:
|
|
284
|
+
new_seq_no = int(msg.get_field_or(TAG_NEW_SEQ_NO, "1"))
|
|
285
|
+
gap_fill = msg.get_field_or(TAG_GAP_FILL_FLAG, "N").upper() == "Y"
|
|
286
|
+
|
|
287
|
+
if gap_fill:
|
|
288
|
+
self._log.on_event(f"GapFill: next expected seq → {new_seq_no}")
|
|
289
|
+
else:
|
|
290
|
+
self._log.on_event(f"SequenceReset: next expected seq → {new_seq_no}")
|
|
291
|
+
|
|
292
|
+
self._store.set_next_target_msg_seq_num(new_seq_no)
|
|
293
|
+
self._app.from_admin(msg, self._id)
|
|
294
|
+
|
|
295
|
+
async def _on_reject(self, msg: Message) -> None:
|
|
296
|
+
ref_seq = msg.get_field_or(TAG_REF_SEQ_NUM, "?")
|
|
297
|
+
reason = msg.get_field_or(TAG_SESSION_REJECT_REASON, "")
|
|
298
|
+
text = msg.get_field_or(TAG_TEXT, "")
|
|
299
|
+
self._log.on_event(f"Session Reject for seq {ref_seq}: {reason} {text}".strip())
|
|
300
|
+
self._app.from_admin(msg, self._id)
|
|
301
|
+
|
|
302
|
+
# ------------------------------------------------------------------
|
|
303
|
+
# Outgoing admin message builders
|
|
304
|
+
# ------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
async def _send_logon(self) -> None:
|
|
307
|
+
msg = Message()
|
|
308
|
+
msg.header.set(TAG_MSG_TYPE, MSG_LOGON)
|
|
309
|
+
msg.set_field(TAG_ENCRYPT_METHOD, "0")
|
|
310
|
+
msg.set_field(TAG_HEART_BT_INT, str(self._heartbt_int))
|
|
311
|
+
self._app.to_admin(msg, self._id)
|
|
312
|
+
await self._send_message(msg, is_admin=True)
|
|
313
|
+
self._logon_sent_at = time.monotonic()
|
|
314
|
+
|
|
315
|
+
async def _send_logout(self, text: str = "") -> None:
|
|
316
|
+
msg = Message()
|
|
317
|
+
msg.header.set(TAG_MSG_TYPE, MSG_LOGOUT)
|
|
318
|
+
if text:
|
|
319
|
+
msg.set_field(TAG_TEXT, text)
|
|
320
|
+
self._app.to_admin(msg, self._id)
|
|
321
|
+
await self._send_message(msg, is_admin=True)
|
|
322
|
+
self._state = SessionState.LOGOUT_TIMEOUT
|
|
323
|
+
|
|
324
|
+
async def _send_heartbeat(self, test_req_id: str = "") -> None:
|
|
325
|
+
msg = Message()
|
|
326
|
+
msg.header.set(TAG_MSG_TYPE, MSG_HEARTBEAT)
|
|
327
|
+
if test_req_id:
|
|
328
|
+
msg.set_field(TAG_TEST_REQ_ID, test_req_id)
|
|
329
|
+
self._app.to_admin(msg, self._id)
|
|
330
|
+
await self._send_message(msg, is_admin=True)
|
|
331
|
+
|
|
332
|
+
async def _send_test_request(self, test_req_id: str) -> None:
|
|
333
|
+
msg = Message()
|
|
334
|
+
msg.header.set(TAG_MSG_TYPE, MSG_TEST_REQUEST)
|
|
335
|
+
msg.set_field(TAG_TEST_REQ_ID, test_req_id)
|
|
336
|
+
self._app.to_admin(msg, self._id)
|
|
337
|
+
await self._send_message(msg, is_admin=True)
|
|
338
|
+
self._test_req_id = test_req_id
|
|
339
|
+
self._test_req_sent_at = time.monotonic()
|
|
340
|
+
|
|
341
|
+
async def _send_resend_request(self, begin_seq: int, end_seq: int) -> None:
|
|
342
|
+
msg = Message()
|
|
343
|
+
msg.header.set(TAG_MSG_TYPE, MSG_RESEND_REQUEST)
|
|
344
|
+
msg.set_field(TAG_BEGIN_SEQ_NO, str(begin_seq))
|
|
345
|
+
msg.set_field(TAG_END_SEQ_NO, str(end_seq))
|
|
346
|
+
self._app.to_admin(msg, self._id)
|
|
347
|
+
await self._send_message(msg, is_admin=True)
|
|
348
|
+
|
|
349
|
+
async def _send_sequence_reset(self, new_seq_no: int, gap_fill: bool = False) -> None:
|
|
350
|
+
msg = Message()
|
|
351
|
+
msg.header.set(TAG_MSG_TYPE, MSG_SEQUENCE_RESET)
|
|
352
|
+
msg.set_field(TAG_GAP_FILL_FLAG, "Y" if gap_fill else "N")
|
|
353
|
+
msg.set_field(TAG_NEW_SEQ_NO, str(new_seq_no))
|
|
354
|
+
# For gap-fill: override the seq num to be the beginning of the gap, not current
|
|
355
|
+
await self._send_message(msg, is_admin=True)
|
|
356
|
+
|
|
357
|
+
async def _send_reject(
|
|
358
|
+
self,
|
|
359
|
+
ref_seq_num: int,
|
|
360
|
+
ref_tag: int | None = None,
|
|
361
|
+
ref_msg_type: str | None = None,
|
|
362
|
+
reason: int | None = None,
|
|
363
|
+
text: str = "",
|
|
364
|
+
) -> None:
|
|
365
|
+
msg = Message()
|
|
366
|
+
msg.header.set(TAG_MSG_TYPE, MSG_REJECT)
|
|
367
|
+
msg.set_field(TAG_REF_SEQ_NUM, str(ref_seq_num))
|
|
368
|
+
if ref_tag is not None:
|
|
369
|
+
msg.set_field(TAG_REF_TAG_ID, str(ref_tag))
|
|
370
|
+
if ref_msg_type is not None:
|
|
371
|
+
msg.set_field(TAG_REF_MSG_TYPE, ref_msg_type)
|
|
372
|
+
if reason is not None:
|
|
373
|
+
msg.set_field(TAG_SESSION_REJECT_REASON, str(reason))
|
|
374
|
+
if text:
|
|
375
|
+
msg.set_field(TAG_TEXT, text)
|
|
376
|
+
self._app.to_admin(msg, self._id)
|
|
377
|
+
await self._send_message(msg, is_admin=True)
|
|
378
|
+
|
|
379
|
+
# ------------------------------------------------------------------
|
|
380
|
+
# Resend logic
|
|
381
|
+
# ------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
async def _resend_range(self, begin: int, end: int) -> None:
|
|
384
|
+
"""Replay stored messages [begin, end], gap-filling admin messages."""
|
|
385
|
+
stored = self._store.get(begin, end)
|
|
386
|
+
seq = begin
|
|
387
|
+
gap_start: int | None = None
|
|
388
|
+
|
|
389
|
+
for raw in stored:
|
|
390
|
+
try:
|
|
391
|
+
msg = Message.decode(raw)
|
|
392
|
+
except ValueError:
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
msg_seq = int(msg.header.get_or(TAG_MSG_SEQ_NUM, "0"))
|
|
396
|
+
msg_type = msg.msg_type
|
|
397
|
+
|
|
398
|
+
if msg_type in ADMIN_MSG_TYPES:
|
|
399
|
+
# Admin messages are gap-filled, not re-sent
|
|
400
|
+
if gap_start is None:
|
|
401
|
+
gap_start = msg_seq
|
|
402
|
+
else:
|
|
403
|
+
# Flush any pending gap-fill before this app message
|
|
404
|
+
if gap_start is not None:
|
|
405
|
+
await self._send_sequence_reset(msg_seq, gap_fill=True)
|
|
406
|
+
gap_start = None
|
|
407
|
+
|
|
408
|
+
# Re-send with PossDupFlag
|
|
409
|
+
msg.header.set(TAG_POSS_DUP_FLAG, "Y")
|
|
410
|
+
msg.header.set(TAG_ORIG_SENDING_TIME, msg.header.get_or(TAG_SENDING_TIME))
|
|
411
|
+
msg.header.set(TAG_SENDING_TIME, _utc_timestamp())
|
|
412
|
+
self._app.to_app(msg, self._id)
|
|
413
|
+
if self._send_fn is not None:
|
|
414
|
+
raw_out = msg.encode()
|
|
415
|
+
self._log.on_outgoing(raw_out.replace(b"\x01", b"|").decode("latin-1"))
|
|
416
|
+
await self._send_fn(raw_out)
|
|
417
|
+
|
|
418
|
+
seq = msg_seq + 1
|
|
419
|
+
|
|
420
|
+
# If messages at the tail were all admin, close the gap
|
|
421
|
+
if gap_start is not None:
|
|
422
|
+
await self._send_sequence_reset(end + 1, gap_fill=True)
|
|
423
|
+
|
|
424
|
+
# ------------------------------------------------------------------
|
|
425
|
+
# Core send path
|
|
426
|
+
# ------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
async def _send_message(self, msg: Message, *, is_admin: bool) -> None:
|
|
429
|
+
"""Stamp header, store, log, and transmit *msg*."""
|
|
430
|
+
seq_num = self._store.next_sender_msg_seq_num()
|
|
431
|
+
msg.header.set(TAG_BEGIN_STRING, self._id.begin_string)
|
|
432
|
+
msg.header.set(TAG_SENDER_COMP_ID, self._id.sender_comp_id)
|
|
433
|
+
msg.header.set(TAG_TARGET_COMP_ID, self._id.target_comp_id)
|
|
434
|
+
msg.header.set(TAG_MSG_SEQ_NUM, str(seq_num))
|
|
435
|
+
msg.header.set(TAG_SENDING_TIME, _utc_timestamp())
|
|
436
|
+
|
|
437
|
+
raw = msg.encode()
|
|
438
|
+
self._store.set(seq_num, raw)
|
|
439
|
+
self._store.incr_next_sender_msg_seq_num()
|
|
440
|
+
self._last_send_time = time.monotonic()
|
|
441
|
+
|
|
442
|
+
self._log.on_outgoing(raw.replace(b"\x01", b"|").decode("latin-1"))
|
|
443
|
+
|
|
444
|
+
if self._send_fn is not None:
|
|
445
|
+
await self._send_fn(raw)
|
|
446
|
+
|
|
447
|
+
# ------------------------------------------------------------------
|
|
448
|
+
# Sequence number validation
|
|
449
|
+
# ------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
def _validate_comp_ids(self, msg: Message) -> bool:
|
|
452
|
+
sender = msg.header.get_or(TAG_SENDER_COMP_ID)
|
|
453
|
+
target = msg.header.get_or(TAG_TARGET_COMP_ID)
|
|
454
|
+
if sender != self._id.target_comp_id or target != self._id.sender_comp_id:
|
|
455
|
+
self._log.on_event(
|
|
456
|
+
f"CompID mismatch: got {sender}->{target}, "
|
|
457
|
+
f"expected {self._id.target_comp_id}->{self._id.sender_comp_id}"
|
|
458
|
+
)
|
|
459
|
+
return False
|
|
460
|
+
return True
|
|
461
|
+
|
|
462
|
+
async def _check_seq_num(self, msg: Message) -> bool:
|
|
463
|
+
"""Validate incoming MsgSeqNum.
|
|
464
|
+
|
|
465
|
+
Returns True if processing should continue, False to drop the message.
|
|
466
|
+
"""
|
|
467
|
+
seq_str = msg.header.get_or(TAG_MSG_SEQ_NUM, "0")
|
|
468
|
+
try:
|
|
469
|
+
seq_num = int(seq_str)
|
|
470
|
+
except ValueError:
|
|
471
|
+
await self._send_reject(0, ref_tag=TAG_MSG_SEQ_NUM, reason=6)
|
|
472
|
+
return False
|
|
473
|
+
|
|
474
|
+
expected = self._store.next_target_msg_seq_num()
|
|
475
|
+
|
|
476
|
+
if seq_num == expected:
|
|
477
|
+
self._store.incr_next_target_msg_seq_num()
|
|
478
|
+
return True
|
|
479
|
+
|
|
480
|
+
if seq_num > expected:
|
|
481
|
+
self._log.on_event(f"MsgSeqNum gap: expected {expected}, got {seq_num} — sending ResendRequest")
|
|
482
|
+
await self._send_resend_request(expected, 0)
|
|
483
|
+
return False
|
|
484
|
+
|
|
485
|
+
# seq_num < expected
|
|
486
|
+
poss_dup = msg.header.get_or(TAG_POSS_DUP_FLAG, "N").upper() == "Y"
|
|
487
|
+
if poss_dup:
|
|
488
|
+
# Silently discard duplicate
|
|
489
|
+
return False
|
|
490
|
+
|
|
491
|
+
# Sequence number too low and not a dup — serious error
|
|
492
|
+
self._log.on_event(f"MsgSeqNum too low: expected {expected}, got {seq_num} — logging out")
|
|
493
|
+
await self._send_logout(f"MsgSeqNum too low, expected {expected} got {seq_num}")
|
|
494
|
+
return False
|
|
495
|
+
|
|
496
|
+
# ------------------------------------------------------------------
|
|
497
|
+
# Timer loop (runs as asyncio Task)
|
|
498
|
+
# ------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
async def _timer_loop(self) -> None:
|
|
501
|
+
"""Background task: drives heartbeat and test-request logic."""
|
|
502
|
+
while self._state != SessionState.DISCONNECTED:
|
|
503
|
+
await asyncio.sleep(1)
|
|
504
|
+
now = time.monotonic()
|
|
505
|
+
|
|
506
|
+
if self._state == SessionState.LOGON_TIMEOUT:
|
|
507
|
+
# Check logon timeout (initiator)
|
|
508
|
+
if (
|
|
509
|
+
self._is_initiator
|
|
510
|
+
and self._logon_sent_at > 0
|
|
511
|
+
and now - self._logon_sent_at > self._logon_timeout
|
|
512
|
+
):
|
|
513
|
+
self._log.on_event("Logon timeout — disconnecting")
|
|
514
|
+
self._state = SessionState.DISCONNECTED
|
|
515
|
+
continue
|
|
516
|
+
|
|
517
|
+
if self._state != SessionState.LOGGED_ON:
|
|
518
|
+
continue
|
|
519
|
+
|
|
520
|
+
# Heartbeat: send if we've been idle for HeartBtInt seconds
|
|
521
|
+
if now - self._last_send_time >= self._heartbt_int:
|
|
522
|
+
await self._send_heartbeat()
|
|
523
|
+
|
|
524
|
+
# Test request: send if we haven't received anything
|
|
525
|
+
recv_age = now - self._last_recv_time
|
|
526
|
+
if recv_age >= self._heartbt_int + 1:
|
|
527
|
+
if self._test_req_id is None:
|
|
528
|
+
test_req_id = str(int(now))
|
|
529
|
+
await self._send_test_request(test_req_id)
|
|
530
|
+
elif now - self._test_req_sent_at >= self._heartbt_int:
|
|
531
|
+
self._log.on_event("TestRequest timed out — disconnecting")
|
|
532
|
+
self._state = SessionState.DISCONNECTED
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""SessionID — immutable identifier for a FIX session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class SessionID:
|
|
10
|
+
"""Uniquely identifies a FIX session.
|
|
11
|
+
|
|
12
|
+
Attributes
|
|
13
|
+
----------
|
|
14
|
+
begin_string:
|
|
15
|
+
FIX version, e.g. ``"FIX.4.2"``, ``"FIX.4.4"``, ``"FIXT.1.1"``.
|
|
16
|
+
sender_comp_id:
|
|
17
|
+
SenderCompID of the local party.
|
|
18
|
+
target_comp_id:
|
|
19
|
+
TargetCompID of the remote party.
|
|
20
|
+
qualifier:
|
|
21
|
+
Optional disambiguator when two sessions share the same
|
|
22
|
+
BeginString/SenderCompID/TargetCompID triple.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
begin_string: str
|
|
26
|
+
sender_comp_id: str
|
|
27
|
+
target_comp_id: str
|
|
28
|
+
qualifier: str = ""
|
|
29
|
+
|
|
30
|
+
def __str__(self) -> str:
|
|
31
|
+
base = f"{self.begin_string}:{self.sender_comp_id}->{self.target_comp_id}"
|
|
32
|
+
return f"{base}:{self.qualifier}" if self.qualifier else base
|