fixcore-engine 0.1.0__tar.gz → 0.1.2__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.
- {fixcore_engine-0.1.0/fixcore_engine.egg-info → fixcore_engine-0.1.2}/PKG-INFO +1 -1
- fixcore_engine-0.1.2/fixcore/gui.py +517 -0
- fixcore_engine-0.1.2/fixcore/gui_ui/app.js +659 -0
- fixcore_engine-0.1.2/fixcore/gui_ui/fixcore_logo.svg +27 -0
- fixcore_engine-0.1.2/fixcore/gui_ui/index.html +180 -0
- fixcore_engine-0.1.2/fixcore/gui_ui/style.css +472 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/transport/initiator.py +14 -1
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2/fixcore_engine.egg-info}/PKG-INFO +1 -1
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore_engine.egg-info/SOURCES.txt +6 -0
- fixcore_engine-0.1.2/fixcore_engine.egg-info/entry_points.txt +2 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/pyproject.toml +7 -1
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/LICENSE +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/README.md +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/__init__.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/application.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/log/__init__.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/log/base.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/log/factory.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/log/file_log.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/log/screen.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/message/__init__.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/message/cracker.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/message/data_dictionary.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/message/exceptions.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/message/field.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/message/message.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/session/__init__.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/session/session.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/session/session_id.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/session/session_settings.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/session/state.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/store/__init__.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/store/base.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/store/factory.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/store/file_store.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/store/memory.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/transport/__init__.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/transport/acceptor.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore/transport/framer.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore_engine.egg-info/dependency_links.txt +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore_engine.egg-info/requires.txt +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/fixcore_engine.egg-info/top_level.txt +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/setup.cfg +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_cracker.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_data_dictionary.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_file_log.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_file_store.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_framer.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_integration.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_message.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_session.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_session_id.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_store.py +0 -0
- {fixcore_engine-0.1.0 → fixcore_engine-0.1.2}/tests/test_transport.py +0 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""FIX Engine GUI — aiohttp backend + browser frontend.
|
|
2
|
+
|
|
3
|
+
Starts a local HTTP server, opens the default browser, and drives the
|
|
4
|
+
FIX engine from the same asyncio event loop (no threading needed).
|
|
5
|
+
|
|
6
|
+
Run:
|
|
7
|
+
fixcore-gui
|
|
8
|
+
fixcore-gui --port 8765
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import webbrowser
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from aiohttp import web
|
|
23
|
+
except ImportError:
|
|
24
|
+
print("aiohttp is required: pip install \"fixcore-engine[gui]\"")
|
|
25
|
+
sys.exit(1)
|
|
26
|
+
|
|
27
|
+
from fixcore.application import Application
|
|
28
|
+
from fixcore.log.base import Log, LogFactory
|
|
29
|
+
from fixcore.message.message import Message
|
|
30
|
+
from fixcore.session.session_id import SessionID
|
|
31
|
+
from fixcore.session.session_settings import SessionSettings
|
|
32
|
+
from fixcore.store.factory import FileStoreFactory, MemoryStoreFactory
|
|
33
|
+
from fixcore.transport.acceptor import SocketAcceptor
|
|
34
|
+
from fixcore.transport.initiator import SocketInitiator
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# WebSocket connections — used to push engine events to the browser
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
_ws_connections: set[web.WebSocketResponse] = set()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _push_event(event_type: str, **kwargs: Any) -> None:
|
|
44
|
+
"""Push a JSON event to all connected browser tabs."""
|
|
45
|
+
if not _ws_connections:
|
|
46
|
+
return
|
|
47
|
+
payload = json.dumps({"type": event_type, **kwargs})
|
|
48
|
+
try:
|
|
49
|
+
loop = asyncio.get_running_loop()
|
|
50
|
+
except RuntimeError:
|
|
51
|
+
return
|
|
52
|
+
for ws in list(_ws_connections):
|
|
53
|
+
loop.create_task(ws.send_str(payload))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Bridge: Application + Log callbacks → browser via WebSocket
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def _sid_key(sid: SessionID) -> str:
|
|
61
|
+
return f"{sid.sender_comp_id}/{sid.target_comp_id}/{sid.begin_string}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GUIApplication(Application):
|
|
65
|
+
def on_create(self, sid: SessionID) -> None:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
def on_logon(self, sid: SessionID) -> None:
|
|
69
|
+
_push_event("logon", sid=_sid_key(sid))
|
|
70
|
+
|
|
71
|
+
def on_logout(self, sid: SessionID) -> None:
|
|
72
|
+
_push_event("logout", sid=_sid_key(sid))
|
|
73
|
+
|
|
74
|
+
def to_admin(self, msg: Message, sid: SessionID) -> None:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
def to_app(self, msg: Message, sid: SessionID) -> None:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
def from_admin(self, msg: Message, sid: SessionID) -> None:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
def from_app(self, msg: Message, sid: SessionID) -> None:
|
|
84
|
+
_push_event("msg_in", sid=_sid_key(sid), msg_type=msg.msg_type)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class GUILog(Log):
|
|
88
|
+
def __init__(self, sid: SessionID) -> None:
|
|
89
|
+
self._sid = _sid_key(sid)
|
|
90
|
+
|
|
91
|
+
def on_incoming(self, raw: str) -> None:
|
|
92
|
+
_push_event("raw_in", sid=self._sid, raw=raw.replace("\x01", "|"))
|
|
93
|
+
|
|
94
|
+
def on_outgoing(self, raw: str) -> None:
|
|
95
|
+
_push_event("raw_out", sid=self._sid, raw=raw.replace("\x01", "|"))
|
|
96
|
+
|
|
97
|
+
def on_event(self, text: str) -> None:
|
|
98
|
+
_push_event("session_event", sid=self._sid, text=text)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class GUILogFactory(LogFactory):
|
|
102
|
+
def create(self, sid: SessionID) -> Log:
|
|
103
|
+
return GUILog(sid)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Session registry
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
_sessions: dict[str, dict[str, Any]] = {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _build_cfg(config: dict[str, Any]) -> str:
|
|
114
|
+
conn = config.get("connection_type", "initiator")
|
|
115
|
+
lines = [
|
|
116
|
+
"[DEFAULT]",
|
|
117
|
+
f"ConnectionType={conn}",
|
|
118
|
+
f"HeartBtInt={config.get('heartbt_int', 30)}",
|
|
119
|
+
"ReconnectInterval=5",
|
|
120
|
+
"",
|
|
121
|
+
"[SESSION]",
|
|
122
|
+
f"BeginString={config.get('begin_string', 'FIX.4.2')}",
|
|
123
|
+
f"SenderCompID={config['sender_comp_id']}",
|
|
124
|
+
f"TargetCompID={config['target_comp_id']}",
|
|
125
|
+
]
|
|
126
|
+
if conn == "initiator":
|
|
127
|
+
lines += [
|
|
128
|
+
f"SocketConnectHost={config.get('host', '127.0.0.1')}",
|
|
129
|
+
f"SocketConnectPort={config.get('port', 9878)}",
|
|
130
|
+
]
|
|
131
|
+
else:
|
|
132
|
+
lines += [
|
|
133
|
+
f"SocketAcceptHost={config.get('host', '0.0.0.0')}",
|
|
134
|
+
f"SocketAcceptPort={config.get('port', 9878)}",
|
|
135
|
+
]
|
|
136
|
+
return "\n".join(lines)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# HTTP / WebSocket handlers
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
GUI_DIR = Path(__file__).parent / "gui_ui"
|
|
144
|
+
SESSIONS_FILE = Path("fix_sessions.json")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _open_browser(url: str) -> None:
|
|
148
|
+
"""Open *url* in the default browser, handling WSL2 gracefully."""
|
|
149
|
+
try:
|
|
150
|
+
wsl = "microsoft" in Path("/proc/version").read_text().lower()
|
|
151
|
+
except OSError:
|
|
152
|
+
wsl = False
|
|
153
|
+
if wsl:
|
|
154
|
+
subprocess.Popen(["cmd.exe", "/c", "start", url],
|
|
155
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
156
|
+
else:
|
|
157
|
+
webbrowser.open(url)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def handle_index(request: web.Request) -> web.FileResponse:
|
|
161
|
+
return web.FileResponse(GUI_DIR / "index.html")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def handle_static(request: web.Request) -> web.FileResponse:
|
|
165
|
+
name = request.match_info["name"]
|
|
166
|
+
filepath = (GUI_DIR / name).resolve()
|
|
167
|
+
try:
|
|
168
|
+
filepath.relative_to(GUI_DIR.resolve())
|
|
169
|
+
except ValueError:
|
|
170
|
+
raise web.HTTPForbidden()
|
|
171
|
+
if not filepath.exists():
|
|
172
|
+
raise web.HTTPNotFound()
|
|
173
|
+
return web.FileResponse(filepath)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
async def handle_ws(request: web.Request) -> web.WebSocketResponse:
|
|
177
|
+
ws = web.WebSocketResponse()
|
|
178
|
+
await ws.prepare(request)
|
|
179
|
+
_ws_connections.add(ws)
|
|
180
|
+
try:
|
|
181
|
+
async for _ in ws:
|
|
182
|
+
pass # we only push; ignore inbound frames
|
|
183
|
+
finally:
|
|
184
|
+
_ws_connections.discard(ws)
|
|
185
|
+
return ws
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def handle_api(request: web.Request) -> web.Response:
|
|
189
|
+
method = request.match_info["method"]
|
|
190
|
+
body: dict[str, Any] = {}
|
|
191
|
+
if request.content_length:
|
|
192
|
+
try:
|
|
193
|
+
body = await request.json()
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
result = await _dispatch(method, body)
|
|
197
|
+
return web.json_response(result)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# API dispatch
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
async def _dispatch(method: str, body: dict[str, Any]) -> Any:
|
|
205
|
+
if method == "get_version":
|
|
206
|
+
return "fixcore 0.1"
|
|
207
|
+
if method == "get_sessions":
|
|
208
|
+
return _get_sessions()
|
|
209
|
+
if method == "create_session":
|
|
210
|
+
return await _create_session(body)
|
|
211
|
+
if method == "remove_session":
|
|
212
|
+
return await _remove_session(body.get("sid_key", ""))
|
|
213
|
+
if method == "logon":
|
|
214
|
+
return await _logon(body.get("sid_key", ""))
|
|
215
|
+
if method == "logout":
|
|
216
|
+
return await _logout(body.get("sid_key", ""))
|
|
217
|
+
if method == "reset_seq_nums":
|
|
218
|
+
return _reset_seq_nums(body.get("sid_key", ""))
|
|
219
|
+
if method == "send_message":
|
|
220
|
+
return await _send_message(
|
|
221
|
+
body.get("sid_key", ""),
|
|
222
|
+
body.get("msg_type", ""),
|
|
223
|
+
body.get("fields", []),
|
|
224
|
+
)
|
|
225
|
+
if method == "update_session":
|
|
226
|
+
return await _update_session(body.get("old_key", ""), body)
|
|
227
|
+
if method == "stop":
|
|
228
|
+
return await _stop(body.get("sid_key", ""))
|
|
229
|
+
return {"ok": False, "error": f"Unknown method: {method!r}"}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# Business logic
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def _save_sessions_config() -> None:
|
|
237
|
+
"""Persist all current session configs to SESSIONS_FILE."""
|
|
238
|
+
configs = [entry["config"] for entry in _sessions.values()]
|
|
239
|
+
SESSIONS_FILE.write_text(json.dumps(configs, indent=2))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def _restore_sessions() -> None:
|
|
243
|
+
"""Load and recreate sessions from SESSIONS_FILE on startup."""
|
|
244
|
+
if not SESSIONS_FILE.exists():
|
|
245
|
+
return
|
|
246
|
+
try:
|
|
247
|
+
configs = json.loads(SESSIONS_FILE.read_text())
|
|
248
|
+
except Exception as exc:
|
|
249
|
+
print(f"[warn] Could not read {SESSIONS_FILE}: {exc}")
|
|
250
|
+
return
|
|
251
|
+
for config in configs:
|
|
252
|
+
result = await _create_session(config)
|
|
253
|
+
if not result.get("ok"):
|
|
254
|
+
print(f"[warn] Could not restore session: {result.get('error')}")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _get_sessions() -> list[dict[str, Any]]:
|
|
258
|
+
result = []
|
|
259
|
+
for key, entry in _sessions.items():
|
|
260
|
+
transport = entry.get("transport")
|
|
261
|
+
session = None
|
|
262
|
+
if transport is not None:
|
|
263
|
+
d = transport.sessions
|
|
264
|
+
if d:
|
|
265
|
+
session = next(iter(d.values()))
|
|
266
|
+
state = "DISCONNECTED"
|
|
267
|
+
sender_seq = 1
|
|
268
|
+
target_seq = 1
|
|
269
|
+
if session is not None:
|
|
270
|
+
state = session.state.name
|
|
271
|
+
try:
|
|
272
|
+
sender_seq = session._store.next_sender_msg_seq_num()
|
|
273
|
+
target_seq = session._store.next_target_msg_seq_num()
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
cfg = entry["config"]
|
|
277
|
+
result.append({
|
|
278
|
+
"key": key,
|
|
279
|
+
"label": f"{cfg['sender_comp_id']} \u2192 {cfg['target_comp_id']}",
|
|
280
|
+
"begin_string": cfg.get("begin_string", "FIX.4.2"),
|
|
281
|
+
"connection_type": cfg.get("connection_type", "initiator"),
|
|
282
|
+
"sender_comp_id": cfg.get("sender_comp_id", ""),
|
|
283
|
+
"target_comp_id": cfg.get("target_comp_id", ""),
|
|
284
|
+
"host": cfg.get("host", ""),
|
|
285
|
+
"port": cfg.get("port", 0),
|
|
286
|
+
"heartbt_int": cfg.get("heartbt_int", 30),
|
|
287
|
+
"store": cfg.get("store", "memory"),
|
|
288
|
+
"state": state,
|
|
289
|
+
"sender_seq": sender_seq,
|
|
290
|
+
"target_seq": target_seq,
|
|
291
|
+
"started": entry.get("started", False),
|
|
292
|
+
})
|
|
293
|
+
return result
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
async def _create_session(config: dict[str, Any]) -> dict[str, Any]:
|
|
297
|
+
sender = config.get("sender_comp_id", "").strip()
|
|
298
|
+
target = config.get("target_comp_id", "").strip()
|
|
299
|
+
begin = config.get("begin_string", "FIX.4.2")
|
|
300
|
+
if not sender or not target:
|
|
301
|
+
return {"ok": False, "error": "SenderCompID and TargetCompID are required"}
|
|
302
|
+
key = f"{sender}/{target}/{begin}"
|
|
303
|
+
if key in _sessions:
|
|
304
|
+
return {"ok": False, "error": f"Session {key!r} already exists"}
|
|
305
|
+
try:
|
|
306
|
+
settings = SessionSettings.from_string(_build_cfg(config))
|
|
307
|
+
except Exception as exc:
|
|
308
|
+
return {"ok": False, "error": str(exc)}
|
|
309
|
+
store_type = config.get("store", "memory")
|
|
310
|
+
if store_type == "file":
|
|
311
|
+
store_dir = Path("fix_store")
|
|
312
|
+
store_dir.mkdir(exist_ok=True)
|
|
313
|
+
store_factory: Any = FileStoreFactory(store_dir)
|
|
314
|
+
else:
|
|
315
|
+
store_factory = MemoryStoreFactory()
|
|
316
|
+
app = GUIApplication()
|
|
317
|
+
log_factory = GUILogFactory()
|
|
318
|
+
conn = config.get("connection_type", "initiator")
|
|
319
|
+
transport: SocketInitiator | SocketAcceptor
|
|
320
|
+
if conn == "initiator":
|
|
321
|
+
transport = SocketInitiator(settings, app, store_factory, log_factory)
|
|
322
|
+
else:
|
|
323
|
+
transport = SocketAcceptor(settings, app, store_factory, log_factory)
|
|
324
|
+
_sessions[key] = {"config": config, "transport": transport, "started": False}
|
|
325
|
+
try:
|
|
326
|
+
await transport.start()
|
|
327
|
+
_sessions[key]["started"] = True
|
|
328
|
+
except Exception as exc:
|
|
329
|
+
del _sessions[key]
|
|
330
|
+
return {"ok": False, "error": f"Failed to start: {exc}"}
|
|
331
|
+
_save_sessions_config()
|
|
332
|
+
return {"ok": True, "key": key}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def _remove_session(sid_key: str) -> dict[str, Any]:
|
|
336
|
+
entry = _sessions.get(sid_key)
|
|
337
|
+
if entry is None:
|
|
338
|
+
return {"ok": False, "error": "Unknown session"}
|
|
339
|
+
transport = entry.get("transport")
|
|
340
|
+
if transport and entry.get("started"):
|
|
341
|
+
try:
|
|
342
|
+
await transport.stop()
|
|
343
|
+
except Exception:
|
|
344
|
+
pass
|
|
345
|
+
del _sessions[sid_key]
|
|
346
|
+
_save_sessions_config()
|
|
347
|
+
return {"ok": True}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
async def _update_session(old_key: str, config: dict[str, Any]) -> dict[str, Any]:
|
|
351
|
+
entry = _sessions.get(old_key)
|
|
352
|
+
if entry is None:
|
|
353
|
+
return {"ok": False, "error": "Unknown session"}
|
|
354
|
+
transport = entry.get("transport")
|
|
355
|
+
if transport and entry.get("started"):
|
|
356
|
+
try:
|
|
357
|
+
await transport.stop()
|
|
358
|
+
except Exception:
|
|
359
|
+
pass
|
|
360
|
+
del _sessions[old_key]
|
|
361
|
+
return await _create_session(config)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def _logon(sid_key: str) -> dict[str, Any]:
|
|
365
|
+
entry = _sessions.get(sid_key)
|
|
366
|
+
if entry is None:
|
|
367
|
+
return {"ok": False, "error": "Unknown session"}
|
|
368
|
+
transport = entry.get("transport")
|
|
369
|
+
if transport is None:
|
|
370
|
+
return {"ok": False, "error": "No transport"}
|
|
371
|
+
try:
|
|
372
|
+
if entry.get("started"):
|
|
373
|
+
await transport.stop()
|
|
374
|
+
await transport.start()
|
|
375
|
+
entry["started"] = True
|
|
376
|
+
except Exception as exc:
|
|
377
|
+
return {"ok": False, "error": str(exc)}
|
|
378
|
+
return {"ok": True}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
async def _logout(sid_key: str) -> dict[str, Any]:
|
|
382
|
+
"""Graceful FIX logout — sends Logout message and disables auto-reconnect."""
|
|
383
|
+
entry = _sessions.get(sid_key)
|
|
384
|
+
if entry is None:
|
|
385
|
+
return {"ok": False, "error": "Unknown session"}
|
|
386
|
+
transport = entry.get("transport")
|
|
387
|
+
if transport is None:
|
|
388
|
+
return {"ok": False, "error": "No transport"}
|
|
389
|
+
try:
|
|
390
|
+
if isinstance(transport, SocketInitiator):
|
|
391
|
+
await transport.logout()
|
|
392
|
+
else:
|
|
393
|
+
session = next(iter(transport.sessions.values()), None)
|
|
394
|
+
if session and session.is_logged_on:
|
|
395
|
+
await session.send_logout()
|
|
396
|
+
entry["started"] = False
|
|
397
|
+
except Exception as exc:
|
|
398
|
+
return {"ok": False, "error": str(exc)}
|
|
399
|
+
return {"ok": True}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
async def _stop(sid_key: str) -> dict[str, Any]:
|
|
403
|
+
"""Hard stop — kills the transport immediately without sending FIX Logout."""
|
|
404
|
+
entry = _sessions.get(sid_key)
|
|
405
|
+
if entry is None:
|
|
406
|
+
return {"ok": False, "error": "Unknown session"}
|
|
407
|
+
transport = entry.get("transport")
|
|
408
|
+
if transport is None:
|
|
409
|
+
return {"ok": False, "error": "No transport"}
|
|
410
|
+
try:
|
|
411
|
+
await transport.stop()
|
|
412
|
+
entry["started"] = False
|
|
413
|
+
except Exception as exc:
|
|
414
|
+
return {"ok": False, "error": str(exc)}
|
|
415
|
+
return {"ok": True}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _reset_seq_nums(sid_key: str) -> dict[str, Any]:
|
|
419
|
+
entry = _sessions.get(sid_key)
|
|
420
|
+
if entry is None:
|
|
421
|
+
return {"ok": False, "error": "Unknown session"}
|
|
422
|
+
transport = entry.get("transport")
|
|
423
|
+
if transport is None:
|
|
424
|
+
return {"ok": False, "error": "No transport"}
|
|
425
|
+
session = next(iter(transport.sessions.values()), None)
|
|
426
|
+
if session is None:
|
|
427
|
+
return {"ok": False, "error": "No session"}
|
|
428
|
+
if session.is_logged_on:
|
|
429
|
+
return {"ok": False, "error": "Disconnect before resetting sequence numbers"}
|
|
430
|
+
try:
|
|
431
|
+
session._store.reset()
|
|
432
|
+
except Exception as exc:
|
|
433
|
+
return {"ok": False, "error": str(exc)}
|
|
434
|
+
return {"ok": True}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
async def _send_message(
|
|
438
|
+
sid_key: str, msg_type: str, fields: list[dict[str, str]]
|
|
439
|
+
) -> dict[str, Any]:
|
|
440
|
+
entry = _sessions.get(sid_key)
|
|
441
|
+
if entry is None:
|
|
442
|
+
return {"ok": False, "error": "Unknown session"}
|
|
443
|
+
transport = entry.get("transport")
|
|
444
|
+
if transport is None:
|
|
445
|
+
return {"ok": False, "error": "No transport"}
|
|
446
|
+
session = next(iter(transport.sessions.values()), None)
|
|
447
|
+
if session is None or not session.is_logged_on:
|
|
448
|
+
return {"ok": False, "error": "Not logged on"}
|
|
449
|
+
msg = Message()
|
|
450
|
+
msg.header.set(35, msg_type)
|
|
451
|
+
for f in fields:
|
|
452
|
+
try:
|
|
453
|
+
tag = int(f["tag"])
|
|
454
|
+
val = str(f["value"])
|
|
455
|
+
msg.set_field(tag, val)
|
|
456
|
+
except (KeyError, ValueError) as exc:
|
|
457
|
+
return {"ok": False, "error": f"Bad field {f!r}: {exc}"}
|
|
458
|
+
try:
|
|
459
|
+
await session.send_app(msg)
|
|
460
|
+
except Exception as exc:
|
|
461
|
+
return {"ok": False, "error": str(exc)}
|
|
462
|
+
return {"ok": True}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# ---------------------------------------------------------------------------
|
|
466
|
+
# Server startup
|
|
467
|
+
# ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
async def main(port: int) -> None:
|
|
470
|
+
app = web.Application()
|
|
471
|
+
app.router.add_get("/", handle_index)
|
|
472
|
+
app.router.add_get("/ws", handle_ws)
|
|
473
|
+
app.router.add_post("/api/{method}", handle_api)
|
|
474
|
+
app.router.add_get("/{name:.+}", handle_static)
|
|
475
|
+
|
|
476
|
+
runner = web.AppRunner(app)
|
|
477
|
+
await runner.setup()
|
|
478
|
+
site = web.TCPSite(runner, "127.0.0.1", port)
|
|
479
|
+
await site.start()
|
|
480
|
+
|
|
481
|
+
await _restore_sessions()
|
|
482
|
+
|
|
483
|
+
url = f"http://127.0.0.1:{port}"
|
|
484
|
+
print(f"FIXcore GUI running at {url} (Ctrl+C to stop)")
|
|
485
|
+
_open_browser(url)
|
|
486
|
+
|
|
487
|
+
try:
|
|
488
|
+
await asyncio.Event().wait()
|
|
489
|
+
except asyncio.CancelledError:
|
|
490
|
+
pass
|
|
491
|
+
finally:
|
|
492
|
+
for entry in list(_sessions.values()):
|
|
493
|
+
t = entry.get("transport")
|
|
494
|
+
if t and entry.get("started"):
|
|
495
|
+
try:
|
|
496
|
+
await t.stop()
|
|
497
|
+
except Exception:
|
|
498
|
+
pass
|
|
499
|
+
await runner.cleanup()
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def run() -> None:
|
|
503
|
+
"""Console script entry point — invoked by `fixcore-gui`."""
|
|
504
|
+
port = 8765
|
|
505
|
+
for i, arg in enumerate(sys.argv[1:], 1):
|
|
506
|
+
if arg == "--port" and i < len(sys.argv):
|
|
507
|
+
port = int(sys.argv[i + 1])
|
|
508
|
+
elif arg.startswith("--port="):
|
|
509
|
+
port = int(arg.split("=", 1)[1])
|
|
510
|
+
try:
|
|
511
|
+
asyncio.run(main(port))
|
|
512
|
+
except KeyboardInterrupt:
|
|
513
|
+
print("\nShutting down.")
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
if __name__ == "__main__":
|
|
517
|
+
run()
|