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,146 @@
|
|
|
1
|
+
"""SessionSettings — parse QuickFIX-style INI configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import configparser
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fixcore.session.session_id import SessionID
|
|
10
|
+
|
|
11
|
+
_DEFAULT_SECTION = "DEFAULT"
|
|
12
|
+
_SESSION_SECTION_PREFIX = "SESSION"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SessionSettings:
|
|
16
|
+
"""Holds per-session and default configuration values.
|
|
17
|
+
|
|
18
|
+
Parses a QuickFIX-compatible INI file of the form::
|
|
19
|
+
|
|
20
|
+
[DEFAULT]
|
|
21
|
+
ConnectionType=initiator
|
|
22
|
+
HeartBtInt=30
|
|
23
|
+
ResetOnLogon=N
|
|
24
|
+
|
|
25
|
+
[SESSION]
|
|
26
|
+
BeginString=FIX.4.2
|
|
27
|
+
SenderCompID=CLIENT
|
|
28
|
+
TargetCompID=SERVER
|
|
29
|
+
SocketConnectHost=127.0.0.1
|
|
30
|
+
SocketConnectPort=9878
|
|
31
|
+
|
|
32
|
+
Multiple ``[SESSION]`` blocks are allowed; each must define
|
|
33
|
+
``BeginString``, ``SenderCompID``, and ``TargetCompID``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self._defaults: dict[str, str] = {}
|
|
38
|
+
# session_id → settings dict
|
|
39
|
+
self._sessions: dict[SessionID, dict[str, str]] = {}
|
|
40
|
+
|
|
41
|
+
# ------------------------------------------------------------------
|
|
42
|
+
# Parsing
|
|
43
|
+
# ------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_file(cls, path: str | Path) -> "SessionSettings":
|
|
47
|
+
settings = cls()
|
|
48
|
+
text = Path(path).read_text()
|
|
49
|
+
parser = configparser.RawConfigParser()
|
|
50
|
+
parser.optionxform = str # preserve case
|
|
51
|
+
import io
|
|
52
|
+
parser.read_file(io.StringIO(cls._preprocess(text)))
|
|
53
|
+
|
|
54
|
+
if parser.has_section(_DEFAULT_SECTION) or parser.defaults():
|
|
55
|
+
settings._defaults = dict(parser.defaults())
|
|
56
|
+
|
|
57
|
+
for section in parser.sections():
|
|
58
|
+
if section.upper().startswith(_SESSION_SECTION_PREFIX):
|
|
59
|
+
items = dict(parser.items(section))
|
|
60
|
+
session_id = SessionID(
|
|
61
|
+
begin_string=items["BeginString"],
|
|
62
|
+
sender_comp_id=items["SenderCompID"],
|
|
63
|
+
target_comp_id=items["TargetCompID"],
|
|
64
|
+
qualifier=items.get("SessionQualifier", ""),
|
|
65
|
+
)
|
|
66
|
+
settings._sessions[session_id] = items
|
|
67
|
+
|
|
68
|
+
return settings
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _preprocess(text: str) -> str:
|
|
72
|
+
"""Rename duplicate [SESSION] headers to [SESSION_0], [SESSION_1], ...
|
|
73
|
+
|
|
74
|
+
configparser rejects duplicate section names; QuickFIX configs routinely
|
|
75
|
+
have multiple [SESSION] blocks.
|
|
76
|
+
"""
|
|
77
|
+
lines = text.splitlines(keepends=True)
|
|
78
|
+
result: list[str] = []
|
|
79
|
+
session_count = 0
|
|
80
|
+
for line in lines:
|
|
81
|
+
if line.strip().upper() == "[SESSION]":
|
|
82
|
+
line = f"[SESSION_{session_count}]\n"
|
|
83
|
+
session_count += 1
|
|
84
|
+
result.append(line)
|
|
85
|
+
return "".join(result)
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_string(cls, text: str) -> "SessionSettings":
|
|
89
|
+
"""Parse from a configuration string (useful in tests)."""
|
|
90
|
+
import io
|
|
91
|
+
|
|
92
|
+
settings = cls()
|
|
93
|
+
parser = configparser.RawConfigParser()
|
|
94
|
+
parser.optionxform = str
|
|
95
|
+
parser.read_file(io.StringIO(cls._preprocess(text)))
|
|
96
|
+
|
|
97
|
+
settings._defaults = dict(parser.defaults())
|
|
98
|
+
|
|
99
|
+
for section in parser.sections():
|
|
100
|
+
if section.upper().startswith(_SESSION_SECTION_PREFIX):
|
|
101
|
+
items = dict(parser.items(section))
|
|
102
|
+
session_id = SessionID(
|
|
103
|
+
begin_string=items["BeginString"],
|
|
104
|
+
sender_comp_id=items["SenderCompID"],
|
|
105
|
+
target_comp_id=items["TargetCompID"],
|
|
106
|
+
qualifier=items.get("SessionQualifier", ""),
|
|
107
|
+
)
|
|
108
|
+
settings._sessions[session_id] = items
|
|
109
|
+
|
|
110
|
+
return settings
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
# Access
|
|
114
|
+
# ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def get(self, session_id: SessionID, key: str) -> str:
|
|
117
|
+
"""Return the value for *key* in *session_id*'s section.
|
|
118
|
+
|
|
119
|
+
Falls back to the DEFAULT section. Raises KeyError if not found.
|
|
120
|
+
"""
|
|
121
|
+
session = self._sessions.get(session_id, {})
|
|
122
|
+
if key in session:
|
|
123
|
+
return session[key]
|
|
124
|
+
if key in self._defaults:
|
|
125
|
+
return self._defaults[key]
|
|
126
|
+
raise KeyError(f"Setting {key!r} not found for {session_id}")
|
|
127
|
+
|
|
128
|
+
def get_or(self, session_id: SessionID, key: str, default: Any = None) -> Any:
|
|
129
|
+
try:
|
|
130
|
+
return self.get(session_id, key)
|
|
131
|
+
except KeyError:
|
|
132
|
+
return default
|
|
133
|
+
|
|
134
|
+
def get_bool(self, session_id: SessionID, key: str, default: bool = False) -> bool:
|
|
135
|
+
val = self.get_or(session_id, key)
|
|
136
|
+
if val is None:
|
|
137
|
+
return default
|
|
138
|
+
return val.strip().upper() in ("Y", "YES", "TRUE", "1")
|
|
139
|
+
|
|
140
|
+
def get_int(self, session_id: SessionID, key: str, default: int = 0) -> int:
|
|
141
|
+
val = self.get_or(session_id, key)
|
|
142
|
+
return int(val) if val is not None else default
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def session_ids(self) -> list[SessionID]:
|
|
146
|
+
return list(self._sessions.keys())
|
fixcore/session/state.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Session state enum and FIX session-layer constants."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum, auto
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SessionState(Enum):
|
|
7
|
+
DISCONNECTED = auto()
|
|
8
|
+
LOGON_TIMEOUT = auto() # connected, Logon sent (initiator) or awaited (acceptor)
|
|
9
|
+
LOGGED_ON = auto()
|
|
10
|
+
LOGOUT_TIMEOUT = auto() # Logout sent, waiting for counterparty Logout
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Tag constants used by the session layer
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
TAG_BEGIN_STRING = 8
|
|
18
|
+
TAG_BODY_LENGTH = 9
|
|
19
|
+
TAG_MSG_TYPE = 35
|
|
20
|
+
TAG_CHECKSUM = 10
|
|
21
|
+
|
|
22
|
+
TAG_MSG_SEQ_NUM = 34
|
|
23
|
+
TAG_SENDER_COMP_ID = 49
|
|
24
|
+
TAG_TARGET_COMP_ID = 56
|
|
25
|
+
TAG_SENDING_TIME = 52
|
|
26
|
+
TAG_POSS_DUP_FLAG = 43
|
|
27
|
+
TAG_ORIG_SENDING_TIME = 122
|
|
28
|
+
|
|
29
|
+
TAG_ENCRYPT_METHOD = 98
|
|
30
|
+
TAG_HEART_BT_INT = 108
|
|
31
|
+
TAG_RESET_SEQ_NUM_FLAG = 141
|
|
32
|
+
TAG_TEST_REQ_ID = 112
|
|
33
|
+
|
|
34
|
+
TAG_BEGIN_SEQ_NO = 7
|
|
35
|
+
TAG_END_SEQ_NO = 16
|
|
36
|
+
TAG_NEW_SEQ_NO = 36
|
|
37
|
+
TAG_GAP_FILL_FLAG = 123
|
|
38
|
+
|
|
39
|
+
TAG_REF_SEQ_NUM = 45
|
|
40
|
+
TAG_REF_TAG_ID = 371
|
|
41
|
+
TAG_REF_MSG_TYPE = 372
|
|
42
|
+
TAG_SESSION_REJECT_REASON = 373
|
|
43
|
+
TAG_TEXT = 58
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Message type constants
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
MSG_HEARTBEAT = "0"
|
|
50
|
+
MSG_TEST_REQUEST = "1"
|
|
51
|
+
MSG_RESEND_REQUEST = "2"
|
|
52
|
+
MSG_REJECT = "3"
|
|
53
|
+
MSG_SEQUENCE_RESET = "4"
|
|
54
|
+
MSG_LOGOUT = "5"
|
|
55
|
+
MSG_LOGON = "A"
|
|
56
|
+
|
|
57
|
+
ADMIN_MSG_TYPES: frozenset[str] = frozenset({
|
|
58
|
+
MSG_HEARTBEAT, MSG_TEST_REQUEST, MSG_RESEND_REQUEST,
|
|
59
|
+
MSG_REJECT, MSG_SEQUENCE_RESET, MSG_LOGOUT, MSG_LOGON,
|
|
60
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Message store — persistence of sent/received messages and sequence numbers."""
|
|
2
|
+
|
|
3
|
+
from fixcore.store.base import MessageStore
|
|
4
|
+
from fixcore.store.factory import FileStoreFactory, MemoryStoreFactory, StoreFactory
|
|
5
|
+
from fixcore.store.file_store import FileStore
|
|
6
|
+
from fixcore.store.memory import MemoryStore
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"MessageStore", "StoreFactory", "MemoryStoreFactory", "FileStoreFactory",
|
|
10
|
+
"FileStore", "MemoryStore",
|
|
11
|
+
]
|
fixcore/store/base.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""MessageStore abstract base class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MessageStore(ABC):
|
|
9
|
+
"""Persists sent messages and tracks sequence numbers for a single session."""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def get(self, begin_seq: int, end_seq: int) -> list[bytes]:
|
|
13
|
+
"""Return stored messages with sequence numbers in [begin_seq, end_seq]."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def set(self, seq_num: int, message: bytes) -> None:
|
|
17
|
+
"""Store *message* at *seq_num*."""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def next_sender_msg_seq_num(self) -> int:
|
|
21
|
+
"""Return the next outbound sequence number."""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def next_target_msg_seq_num(self) -> int:
|
|
25
|
+
"""Return the next expected inbound sequence number."""
|
|
26
|
+
|
|
27
|
+
@abstractmethod
|
|
28
|
+
def incr_next_sender_msg_seq_num(self) -> None:
|
|
29
|
+
"""Increment the outbound sequence number by 1."""
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def incr_next_target_msg_seq_num(self) -> None:
|
|
33
|
+
"""Increment the expected inbound sequence number by 1."""
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def set_next_sender_msg_seq_num(self, seq_num: int) -> None:
|
|
37
|
+
"""Override the outbound sequence number."""
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def set_next_target_msg_seq_num(self, seq_num: int) -> None:
|
|
41
|
+
"""Override the expected inbound sequence number."""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def reset(self) -> None:
|
|
45
|
+
"""Clear all stored messages and reset sequence numbers to 1."""
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def refresh(self) -> None:
|
|
49
|
+
"""Reload state from the underlying store (no-op for in-memory)."""
|
fixcore/store/factory.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""MessageStore factory — creates a store per session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fixcore.session.session_id import SessionID
|
|
9
|
+
from fixcore.store.base import MessageStore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StoreFactory(ABC):
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def create(self, session_id: SessionID) -> MessageStore: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MemoryStoreFactory(StoreFactory):
|
|
18
|
+
"""Creates a fresh in-memory store for each session (non-persistent)."""
|
|
19
|
+
|
|
20
|
+
def create(self, session_id: SessionID) -> MessageStore:
|
|
21
|
+
from fixcore.store.memory import MemoryStore
|
|
22
|
+
return MemoryStore()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FileStoreFactory(StoreFactory):
|
|
26
|
+
"""Creates a FileStore for each session under *store_dir*."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, store_dir: str | Path) -> None:
|
|
29
|
+
self._dir = Path(store_dir)
|
|
30
|
+
|
|
31
|
+
def create(self, session_id: SessionID) -> MessageStore:
|
|
32
|
+
from fixcore.store.file_store import FileStore
|
|
33
|
+
return FileStore(self._dir, session_id)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""File-backed MessageStore — persists messages and sequence numbers to disk.
|
|
2
|
+
|
|
3
|
+
Layout (one set of files per session, under *store_dir*)::
|
|
4
|
+
|
|
5
|
+
FIX.4.2-CLIENT-SERVER.body — raw FIX messages, newline-separated
|
|
6
|
+
FIX.4.2-CLIENT-SERVER.index — "seq_num=offset,length\\n" per stored message
|
|
7
|
+
FIX.4.2-CLIENT-SERVER.seqnums — "sender=N\\ntarget=M\\n"
|
|
8
|
+
|
|
9
|
+
The index is held in memory after init for O(1) random access. The body and
|
|
10
|
+
index files are append-only; ``reset()`` truncates them.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from fixcore.session.session_id import SessionID
|
|
19
|
+
from fixcore.store.base import MessageStore
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _session_prefix(session_id: SessionID) -> str:
|
|
23
|
+
"""Convert a SessionID to a safe filesystem name."""
|
|
24
|
+
return (
|
|
25
|
+
f"{session_id.begin_string}-"
|
|
26
|
+
f"{session_id.sender_comp_id}-"
|
|
27
|
+
f"{session_id.target_comp_id}"
|
|
28
|
+
+ (f"-{session_id.qualifier}" if session_id.qualifier else "")
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FileStore(MessageStore):
|
|
33
|
+
"""Durable MessageStore backed by three flat files per session.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
store_dir:
|
|
38
|
+
Directory where session files are written. Created if absent.
|
|
39
|
+
session_id:
|
|
40
|
+
Identifies the session; used to derive file names.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, store_dir: str | Path, session_id: SessionID) -> None:
|
|
44
|
+
self._dir = Path(store_dir)
|
|
45
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
prefix = self._dir / _session_prefix(session_id)
|
|
48
|
+
self._body_path = Path(f"{prefix}.body")
|
|
49
|
+
self._index_path = Path(f"{prefix}.index")
|
|
50
|
+
self._seqnums_path = Path(f"{prefix}.seqnums")
|
|
51
|
+
|
|
52
|
+
# In-memory index: seq_num → (byte_offset, length)
|
|
53
|
+
self._index: dict[int, tuple[int, int]] = {}
|
|
54
|
+
|
|
55
|
+
self._next_sender: int = 1
|
|
56
|
+
self._next_target: int = 1
|
|
57
|
+
|
|
58
|
+
self._init_files()
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
# Initialisation
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def _init_files(self) -> None:
|
|
65
|
+
"""Create files if absent, then load persisted state."""
|
|
66
|
+
for path in (self._body_path, self._index_path, self._seqnums_path):
|
|
67
|
+
if not path.exists():
|
|
68
|
+
path.touch()
|
|
69
|
+
|
|
70
|
+
self._load_seqnums()
|
|
71
|
+
self._load_index()
|
|
72
|
+
|
|
73
|
+
def _load_seqnums(self) -> None:
|
|
74
|
+
text = self._seqnums_path.read_text()
|
|
75
|
+
self._next_sender = 1
|
|
76
|
+
self._next_target = 1
|
|
77
|
+
for line in text.splitlines():
|
|
78
|
+
if line.startswith("sender="):
|
|
79
|
+
self._next_sender = int(line[7:])
|
|
80
|
+
elif line.startswith("target="):
|
|
81
|
+
self._next_target = int(line[7:])
|
|
82
|
+
|
|
83
|
+
def _load_index(self) -> None:
|
|
84
|
+
self._index.clear()
|
|
85
|
+
for line in self._index_path.read_text().splitlines():
|
|
86
|
+
if not line:
|
|
87
|
+
continue
|
|
88
|
+
# format: seq_num=offset,length
|
|
89
|
+
eq = line.index("=")
|
|
90
|
+
seq = int(line[:eq])
|
|
91
|
+
offset_str, length_str = line[eq + 1:].split(",")
|
|
92
|
+
self._index[seq] = (int(offset_str), int(length_str))
|
|
93
|
+
|
|
94
|
+
def _flush_seqnums(self) -> None:
|
|
95
|
+
self._seqnums_path.write_text(
|
|
96
|
+
f"sender={self._next_sender}\ntarget={self._next_target}\n"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
# MessageStore interface
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def get(self, begin_seq: int, end_seq: int) -> list[bytes]:
|
|
104
|
+
results: list[bytes] = []
|
|
105
|
+
with self._body_path.open("rb") as fh:
|
|
106
|
+
for seq in range(begin_seq, end_seq + 1):
|
|
107
|
+
entry = self._index.get(seq)
|
|
108
|
+
if entry is None:
|
|
109
|
+
continue
|
|
110
|
+
offset, length = entry
|
|
111
|
+
fh.seek(offset)
|
|
112
|
+
results.append(fh.read(length))
|
|
113
|
+
return results
|
|
114
|
+
|
|
115
|
+
def set(self, seq_num: int, message: bytes) -> None:
|
|
116
|
+
# Append message to body (newline-terminated for human readability)
|
|
117
|
+
with self._body_path.open("ab") as fh:
|
|
118
|
+
offset = fh.seek(0, os.SEEK_END)
|
|
119
|
+
fh.write(message)
|
|
120
|
+
fh.write(b"\n")
|
|
121
|
+
length = len(message)
|
|
122
|
+
|
|
123
|
+
# Append index entry
|
|
124
|
+
with self._index_path.open("a") as fh:
|
|
125
|
+
fh.write(f"{seq_num}={offset},{length}\n")
|
|
126
|
+
|
|
127
|
+
self._index[seq_num] = (offset, length)
|
|
128
|
+
|
|
129
|
+
def next_sender_msg_seq_num(self) -> int:
|
|
130
|
+
return self._next_sender
|
|
131
|
+
|
|
132
|
+
def next_target_msg_seq_num(self) -> int:
|
|
133
|
+
return self._next_target
|
|
134
|
+
|
|
135
|
+
def incr_next_sender_msg_seq_num(self) -> None:
|
|
136
|
+
self._next_sender += 1
|
|
137
|
+
self._flush_seqnums()
|
|
138
|
+
|
|
139
|
+
def incr_next_target_msg_seq_num(self) -> None:
|
|
140
|
+
self._next_target += 1
|
|
141
|
+
self._flush_seqnums()
|
|
142
|
+
|
|
143
|
+
def set_next_sender_msg_seq_num(self, seq_num: int) -> None:
|
|
144
|
+
self._next_sender = seq_num
|
|
145
|
+
self._flush_seqnums()
|
|
146
|
+
|
|
147
|
+
def set_next_target_msg_seq_num(self, seq_num: int) -> None:
|
|
148
|
+
self._next_target = seq_num
|
|
149
|
+
self._flush_seqnums()
|
|
150
|
+
|
|
151
|
+
def reset(self) -> None:
|
|
152
|
+
self._body_path.write_bytes(b"")
|
|
153
|
+
self._index_path.write_text("")
|
|
154
|
+
self._index.clear()
|
|
155
|
+
self._next_sender = 1
|
|
156
|
+
self._next_target = 1
|
|
157
|
+
self._flush_seqnums()
|
|
158
|
+
|
|
159
|
+
def refresh(self) -> None:
|
|
160
|
+
"""Reload all state from disk (e.g. after external modification)."""
|
|
161
|
+
self._load_seqnums()
|
|
162
|
+
self._load_index()
|
fixcore/store/memory.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""In-memory MessageStore implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fixcore.store.base import MessageStore
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MemoryStore(MessageStore):
|
|
9
|
+
"""Non-persistent store — useful for testing and ephemeral sessions."""
|
|
10
|
+
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._messages: dict[int, bytes] = {}
|
|
13
|
+
self._next_sender: int = 1
|
|
14
|
+
self._next_target: int = 1
|
|
15
|
+
|
|
16
|
+
def get(self, begin_seq: int, end_seq: int) -> list[bytes]:
|
|
17
|
+
return [
|
|
18
|
+
self._messages[seq]
|
|
19
|
+
for seq in range(begin_seq, end_seq + 1)
|
|
20
|
+
if seq in self._messages
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
def set(self, seq_num: int, message: bytes) -> None:
|
|
24
|
+
self._messages[seq_num] = message
|
|
25
|
+
|
|
26
|
+
def next_sender_msg_seq_num(self) -> int:
|
|
27
|
+
return self._next_sender
|
|
28
|
+
|
|
29
|
+
def next_target_msg_seq_num(self) -> int:
|
|
30
|
+
return self._next_target
|
|
31
|
+
|
|
32
|
+
def incr_next_sender_msg_seq_num(self) -> None:
|
|
33
|
+
self._next_sender += 1
|
|
34
|
+
|
|
35
|
+
def incr_next_target_msg_seq_num(self) -> None:
|
|
36
|
+
self._next_target += 1
|
|
37
|
+
|
|
38
|
+
def set_next_sender_msg_seq_num(self, seq_num: int) -> None:
|
|
39
|
+
self._next_sender = seq_num
|
|
40
|
+
|
|
41
|
+
def set_next_target_msg_seq_num(self, seq_num: int) -> None:
|
|
42
|
+
self._next_target = seq_num
|
|
43
|
+
|
|
44
|
+
def reset(self) -> None:
|
|
45
|
+
self._messages.clear()
|
|
46
|
+
self._next_sender = 1
|
|
47
|
+
self._next_target = 1
|
|
48
|
+
|
|
49
|
+
def refresh(self) -> None:
|
|
50
|
+
pass # nothing to reload
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Transport layer — asyncio-based TCP acceptor and initiator."""
|
|
2
|
+
|
|
3
|
+
from fixcore.transport.acceptor import SocketAcceptor
|
|
4
|
+
from fixcore.transport.framer import MessageFramer
|
|
5
|
+
from fixcore.transport.initiator import SocketInitiator
|
|
6
|
+
|
|
7
|
+
__all__ = ["MessageFramer", "SocketAcceptor", "SocketInitiator"]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""SocketAcceptor — listens for inbound FIX connections."""
|
|
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.message.message import Message
|
|
10
|
+
from fixcore.session.session import Session
|
|
11
|
+
from fixcore.session.session_id import SessionID
|
|
12
|
+
from fixcore.session.session_settings import SessionSettings
|
|
13
|
+
from fixcore.session.state import TAG_BEGIN_STRING, TAG_SENDER_COMP_ID, TAG_TARGET_COMP_ID
|
|
14
|
+
from fixcore.store.factory import StoreFactory
|
|
15
|
+
from fixcore.transport.framer import MessageFramer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SocketAcceptor:
|
|
19
|
+
"""Listens on a TCP port and routes incoming connections to the correct Session.
|
|
20
|
+
|
|
21
|
+
One :class:`~fixcore.session.Session` is pre-created per configured
|
|
22
|
+
acceptor session. When a connection arrives, the first FIX message
|
|
23
|
+
(expected to be a Logon) is used to look up the matching session by
|
|
24
|
+
BeginString/SenderCompID/TargetCompID.
|
|
25
|
+
|
|
26
|
+
Usage::
|
|
27
|
+
|
|
28
|
+
acceptor = SocketAcceptor(settings, app, store_factory, log_factory)
|
|
29
|
+
async with acceptor:
|
|
30
|
+
await asyncio.sleep(3600) # serve for an hour
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
settings: SessionSettings,
|
|
36
|
+
application: Application,
|
|
37
|
+
store_factory: StoreFactory,
|
|
38
|
+
log_factory: LogFactory,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._settings = settings
|
|
41
|
+
self._app = application
|
|
42
|
+
self._store_factory = store_factory
|
|
43
|
+
self._log_factory = log_factory
|
|
44
|
+
|
|
45
|
+
self._sessions: dict[SessionID, Session] = {}
|
|
46
|
+
self._server: asyncio.Server | None = None
|
|
47
|
+
self._connection_tasks: set[asyncio.Task[None]] = set()
|
|
48
|
+
|
|
49
|
+
for sid in settings.session_ids:
|
|
50
|
+
conn_type = settings.get_or(sid, "ConnectionType", "acceptor").lower()
|
|
51
|
+
if conn_type == "acceptor":
|
|
52
|
+
self._sessions[sid] = Session(
|
|
53
|
+
sid, settings, application,
|
|
54
|
+
store_factory.create(sid),
|
|
55
|
+
log_factory.create(sid),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
# Lifecycle
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
async def start(self) -> None:
|
|
63
|
+
"""Start the TCP server. Binds to SocketAcceptPort (default 9878)."""
|
|
64
|
+
# Use the first acceptor session to get host/port
|
|
65
|
+
sid = next(iter(self._sessions))
|
|
66
|
+
host = self._settings.get_or(sid, "SocketAcceptHost", "0.0.0.0")
|
|
67
|
+
port = int(self._settings.get_or(sid, "SocketAcceptPort", "9878"))
|
|
68
|
+
|
|
69
|
+
self._server = await asyncio.start_server(
|
|
70
|
+
self._handle_connection, host, port
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def stop(self) -> None:
|
|
74
|
+
"""Stop accepting new connections and close all active sessions."""
|
|
75
|
+
# Cancel all in-flight connection handlers (triggers their finally blocks)
|
|
76
|
+
for task in list(self._connection_tasks):
|
|
77
|
+
task.cancel()
|
|
78
|
+
if self._connection_tasks:
|
|
79
|
+
await asyncio.gather(*self._connection_tasks, return_exceptions=True)
|
|
80
|
+
self._connection_tasks.clear()
|
|
81
|
+
|
|
82
|
+
if self._server is not None:
|
|
83
|
+
self._server.close()
|
|
84
|
+
await self._server.wait_closed()
|
|
85
|
+
self._server = None
|
|
86
|
+
|
|
87
|
+
async def __aenter__(self) -> "SocketAcceptor":
|
|
88
|
+
await self.start()
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
async def __aexit__(self, *_: object) -> None:
|
|
92
|
+
await self.stop()
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def sessions(self) -> dict[SessionID, Session]:
|
|
96
|
+
return dict(self._sessions)
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# Connection handler
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
async def _handle_connection(
|
|
103
|
+
self,
|
|
104
|
+
reader: asyncio.StreamReader,
|
|
105
|
+
writer: asyncio.StreamWriter,
|
|
106
|
+
) -> None:
|
|
107
|
+
framer = MessageFramer()
|
|
108
|
+
session: Session | None = None
|
|
109
|
+
task = asyncio.current_task()
|
|
110
|
+
if task is not None:
|
|
111
|
+
self._connection_tasks.add(task)
|
|
112
|
+
|
|
113
|
+
async def send_fn(data: bytes) -> None:
|
|
114
|
+
writer.write(data)
|
|
115
|
+
await writer.drain()
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
while True:
|
|
119
|
+
try:
|
|
120
|
+
data = await reader.read(4096)
|
|
121
|
+
except (ConnectionResetError, asyncio.IncompleteReadError, asyncio.CancelledError):
|
|
122
|
+
break
|
|
123
|
+
if not data:
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
for raw_msg in framer.feed(data):
|
|
127
|
+
if session is None:
|
|
128
|
+
session = self._find_session(raw_msg)
|
|
129
|
+
if session is None:
|
|
130
|
+
# Unknown session — close connection
|
|
131
|
+
return
|
|
132
|
+
await session.on_connect(send_fn)
|
|
133
|
+
|
|
134
|
+
await session.on_data(raw_msg)
|
|
135
|
+
|
|
136
|
+
finally:
|
|
137
|
+
task = asyncio.current_task()
|
|
138
|
+
if task is not None:
|
|
139
|
+
self._connection_tasks.discard(task)
|
|
140
|
+
if session is not None:
|
|
141
|
+
await session.on_disconnect()
|
|
142
|
+
try:
|
|
143
|
+
writer.close()
|
|
144
|
+
await writer.wait_closed()
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
def _find_session(self, raw_msg: bytes) -> Session | None:
|
|
149
|
+
"""Match an incoming message to a pre-configured session."""
|
|
150
|
+
try:
|
|
151
|
+
msg = Message.decode(raw_msg)
|
|
152
|
+
except ValueError:
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
begin = msg.header.get_or(TAG_BEGIN_STRING)
|
|
156
|
+
sender = msg.header.get_or(TAG_SENDER_COMP_ID)
|
|
157
|
+
target = msg.header.get_or(TAG_TARGET_COMP_ID)
|
|
158
|
+
|
|
159
|
+
for sid, session in self._sessions.items():
|
|
160
|
+
if (
|
|
161
|
+
sid.begin_string == begin
|
|
162
|
+
and sid.sender_comp_id == target # our sender == their target
|
|
163
|
+
and sid.target_comp_id == sender # our target == their sender
|
|
164
|
+
):
|
|
165
|
+
return session
|
|
166
|
+
return None
|