cfquant 0.2.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.
Files changed (90) hide show
  1. LTtx/__init__.py +2 -0
  2. LTtx/tx/Config.txt +8 -0
  3. LTtx/tx/LTtx_server.py +1177 -0
  4. LTtx/tx/__init__.py +5 -0
  5. LTtx/tx/tx.py +1935 -0
  6. cfquant/__init__.py +26 -0
  7. cfquant/account_routing.py +119 -0
  8. cfquant/channels.py +76 -0
  9. cfquant/cli.py +266 -0
  10. cfquant/client.py +365 -0
  11. cfquant/config.py +86 -0
  12. cfquant/hub.py +304 -0
  13. cfquant/logging_i18n.py +348 -0
  14. cfquant/normal_bridge.py +676 -0
  15. cfquant/pipe_bridge.py +249 -0
  16. cfquant/pipe_client.py +223 -0
  17. cfquant/pipe_hub.py +602 -0
  18. cfquant/pipe_transport.py +493 -0
  19. cfquant/protocol.py +238 -0
  20. cfquant/qmt_bridge.py +1081 -0
  21. cfquant/runtime_report.py +334 -0
  22. cfquant/tx.py +1898 -0
  23. cfquant/tx_trade_bridge.py +1905 -0
  24. cfquant/version.py +5 -0
  25. cfquant/xtconstant.py +262 -0
  26. cfquant/xtdata.py +462 -0
  27. cfquant/xttrader.py +955 -0
  28. cfquant/xttype.py +586 -0
  29. cfquant-0.2.0.dist-info/LICENSE +21 -0
  30. cfquant-0.2.0.dist-info/METADATA +144 -0
  31. cfquant-0.2.0.dist-info/RECORD +90 -0
  32. cfquant-0.2.0.dist-info/WHEEL +5 -0
  33. cfquant-0.2.0.dist-info/entry_points.txt +4 -0
  34. cfquant-0.2.0.dist-info/top_level.txt +6 -0
  35. cfquant_pipe_hub.py +45 -0
  36. cfquant_web_server.py +12137 -0
  37. qmt_scripts/CFQUANT.py +507 -0
  38. qmt_scripts/CFQUANT_CTYPE_ALL_LOWLAT.py +830 -0
  39. qmt_scripts/CFQUANT_LITE.py +4928 -0
  40. qmt_scripts/CFQUANT_TRADE_LOWLAT.py +382 -0
  41. qmt_scripts/__init__.py +1 -0
  42. qmt_scripts//321/205/320/240/320/234/321/210/342/224/244/320/266/321/205/320/237/342/225/226/321/207/320/233/320/274/321/207/320/273/320/233/321/205/342/225/225/320/222/321/205/320/254/342/225/221/CFQUANT_CTYPE_ALL_LOWLAT_SH.py +866 -0
  43. qmt_scripts//321/205/320/240/320/234/321/210/342/224/244/320/266/321/205/320/237/342/225/226/321/207/320/233/320/274/321/207/320/273/320/233/321/205/342/225/225/320/222/321/205/320/254/342/225/221/CFQUANT_CTYPE_ALL_LOWLAT_SZ.py +866 -0
  44. qmt_scripts//321/205/320/240/320/234/321/210/342/224/244/320/266/321/205/320/237/342/225/226/321/207/320/233/320/274/321/207/320/273/320/233/321/205/342/225/225/320/222/321/205/320/254/342/225/221/CFQUANT_LITE_SH.py +5020 -0
  45. qmt_scripts//321/205/320/240/320/234/321/210/342/224/244/320/266/321/205/320/237/342/225/226/321/207/320/233/320/274/321/207/320/273/320/233/321/205/342/225/225/320/222/321/205/320/254/342/225/221/CFQUANT_LITE_SZ.py +5020 -0
  46. qmt_scripts//321/205/320/240/320/234/321/210/342/224/244/320/266/321/205/320/237/342/225/226/321/207/320/233/320/274/321/207/320/273/320/233/321/205/342/225/225/320/222/321/205/320/254/342/225/221/CFQUANT_TRADE_LOWLAT_SH.py +436 -0
  47. qmt_scripts//321/205/320/240/320/234/321/210/342/224/244/320/266/321/205/320/237/342/225/226/321/207/320/233/320/274/321/207/320/273/320/233/321/205/342/225/225/320/222/321/205/320/254/342/225/221/CFQUANT_TRADE_LOWLAT_SZ.py +436 -0
  48. qmt_scripts//321/205/320/240/320/234/321/210/342/224/244/320/266/321/205/320/237/342/225/226/321/207/320/233/320/274/321/207/320/273/320/233/321/205/342/225/225/320/222/321/205/320/254/342/225/221/readme.md +95 -0
  49. web_dashboard/__init__.py +1 -0
  50. web_dashboard/app.js +9847 -0
  51. web_dashboard/avatars/amber-pulse.svg +15 -0
  52. web_dashboard/avatars/copper-grid.svg +15 -0
  53. web_dashboard/avatars/market-blue.svg +15 -0
  54. web_dashboard/avatars/rose-circuit.svg +15 -0
  55. web_dashboard/avatars/signal-green.svg +15 -0
  56. web_dashboard/avatars/slate-wave.svg +15 -0
  57. web_dashboard/avatars/teal-orbit.svg +16 -0
  58. web_dashboard/avatars/violet-node.svg +15 -0
  59. web_dashboard/favicon.svg +5 -0
  60. web_dashboard/image/qmt_deploy/image-10.png +0 -0
  61. web_dashboard/image/qmt_deploy/image-11.png +0 -0
  62. web_dashboard/image/qmt_deploy/image-12.png +0 -0
  63. web_dashboard/image/qmt_deploy/image-13.png +0 -0
  64. web_dashboard/image/qmt_deploy/image-14.png +0 -0
  65. web_dashboard/image/qmt_deploy/image-15.png +0 -0
  66. web_dashboard/image/qmt_deploy/image-16.png +0 -0
  67. web_dashboard/image/qmt_deploy/image-17.png +0 -0
  68. web_dashboard/image/qmt_deploy/image-18.png +0 -0
  69. web_dashboard/image/qmt_deploy/image-19.png +0 -0
  70. web_dashboard/image/qmt_deploy/image-20.png +0 -0
  71. web_dashboard/image/qmt_deploy/image-21.png +0 -0
  72. web_dashboard/image/qmt_deploy/image-4.png +0 -0
  73. web_dashboard/image/qmt_deploy/image-5.png +0 -0
  74. web_dashboard/image/qmt_deploy/image-6.png +0 -0
  75. web_dashboard/image/qmt_deploy/image-7.png +0 -0
  76. web_dashboard/image/qmt_deploy/image-8.png +0 -0
  77. web_dashboard/image/qmt_deploy/image-9.png +0 -0
  78. web_dashboard/image//321/211/320/220/320/252/321/207/320/244/320/270/321/206/320/270/320/261/321/205/342/225/235/320/237/321/211/320/223/320/270/321/207/342/225/234/342/226/223/321/206/320/234/320/227/321/205/320/235/320/247/image-1.png +0 -0
  79. web_dashboard/image//321/211/320/220/320/252/321/207/320/244/320/270/321/206/320/270/320/261/321/205/342/225/235/320/237/321/211/320/223/320/270/321/207/342/225/234/342/226/223/321/206/320/234/320/227/321/205/320/235/320/247/image-2.png +0 -0
  80. web_dashboard/image//321/211/320/220/320/252/321/207/320/244/320/270/321/206/320/270/320/261/321/205/342/225/235/320/237/321/211/320/223/320/270/321/207/342/225/234/342/226/223/321/206/320/234/320/227/321/205/320/235/320/247/image-3.png +0 -0
  81. web_dashboard/image//321/211/320/220/320/252/321/207/320/244/320/270/321/206/320/270/320/261/321/205/342/225/235/320/237/321/211/320/223/320/270/321/207/342/225/234/342/226/223/321/206/320/234/320/227/321/205/320/235/320/247/image.png +0 -0
  82. web_dashboard/image//321/211/320/223/320/270/321/207/342/225/234/342/226/223/321/206/320/234/320/227/321/205/320/235/320/247/1783479325179.png +0 -0
  83. web_dashboard/image//321/211/320/223/320/270/321/207/342/225/234/342/226/223/321/206/320/234/320/227/321/205/320/235/320/247/1783479745262.png +0 -0
  84. web_dashboard/image//321/211/320/223/320/270/321/207/342/225/234/342/226/223/321/206/320/234/320/227/321/205/320/235/320/247/1783480178700.png +0 -0
  85. web_dashboard/image//321/211/320/223/320/270/321/207/342/225/234/342/226/223/321/206/320/234/320/227/321/205/320/235/320/247/1783480428759.png +0 -0
  86. web_dashboard/image//321/211/320/223/320/270/321/207/342/225/234/342/226/223/321/206/320/234/320/227/321/205/320/235/320/247/1783489807217.png +0 -0
  87. web_dashboard/image//321/211/320/223/320/270/321/207/342/225/234/342/226/223/321/206/320/234/320/227/321/205/320/235/320/247/1783490503491.png +0 -0
  88. web_dashboard/index.html +2317 -0
  89. web_dashboard/styles.css +7817 -0
  90. web_dashboard/vendor/mermaid.min.js +2029 -0
cfquant/pipe_hub.py ADDED
@@ -0,0 +1,602 @@
1
+ # -*- coding: utf-8 -*-
2
+ import json
3
+ import os
4
+ import threading
5
+ import time
6
+
7
+ from .pipe_transport import (
8
+ DEFAULT_PIPE_NAME,
9
+ create_pipe_instance,
10
+ dumps_pipe_message,
11
+ loads_pipe_message,
12
+ normalize_pipe_name,
13
+ wait_for_pipe_client,
14
+ )
15
+ from .protocol import loads_message, pack_event, pack_response
16
+ from .version import __version__ as CORE_VERSION
17
+
18
+
19
+ def default_status_file(filename):
20
+ runtime_dir = os.path.abspath(os.environ.get("CFQUANT_RUNTIME_DIR") or os.path.join(os.getcwd(), "runtime"))
21
+ return os.path.join(runtime_dir, "status", filename)
22
+
23
+
24
+ class CfquantPipeHub(object):
25
+ """
26
+ External named-pipe hub.
27
+
28
+ QMT-side pipe bridges register as role=qmt with a request_channel.
29
+ External API clients register as role=api and send cfquant request payloads.
30
+ The hub forwards requests to the matching QMT bridge and routes responses or
31
+ events back to the originating API client.
32
+ """
33
+
34
+ def __init__(self, pipe_name=None, show=True, default_request_channel="cfquant.normal.request"):
35
+ self.pipe_name = normalize_pipe_name(pipe_name or DEFAULT_PIPE_NAME)
36
+ self.show = show
37
+ self.default_request_channel = default_request_channel
38
+ self.running = False
39
+ self.listener = None
40
+ self.verbose_events = str(os.environ.get("CFQUANT_PIPE_HUB_VERBOSE_EVENTS") or "").strip().lower() in (
41
+ "1",
42
+ "true",
43
+ "yes",
44
+ "on",
45
+ )
46
+ self.qmt_rx_by_channel = {}
47
+ self.qmt_tx_by_channel = {}
48
+ self.qmt_channel_by_conn = {}
49
+ self.qmt_lock = threading.RLock()
50
+ self.pending = {}
51
+ self.client_by_id = {}
52
+ self.client_tx_by_id = {}
53
+ self.client_ids_by_conn = {}
54
+ self.client_conn_meta_by_conn = {}
55
+ self.client_generation_by_id = {}
56
+ self.state_lock = threading.RLock()
57
+ self.status_file = os.path.abspath(
58
+ os.environ.get("CFQUANT_PIPE_HUB_STATUS_FILE") or default_status_file("cfquant_pipe_hub_status.json")
59
+ )
60
+ self.pending_timeout_seconds = float(os.environ.get("CFQUANT_PIPE_HUB_PENDING_TIMEOUT", "60"))
61
+ self.maintenance_interval_seconds = float(os.environ.get("CFQUANT_PIPE_HUB_MAINTENANCE_INTERVAL", "2"))
62
+ self.maintenance_thread = None
63
+
64
+ def start(self):
65
+ if self.running:
66
+ return self
67
+ self.running = True
68
+ self._log("pipe hub started pipe=%s" % self.pipe_name)
69
+ self._start_maintenance()
70
+ self._write_status()
71
+ while self.running:
72
+ conn = None
73
+ try:
74
+ conn = create_pipe_instance(self.pipe_name)
75
+ self.listener = conn
76
+ wait_for_pipe_client(conn)
77
+ self._log("pipe client connected")
78
+ thread = threading.Thread(target=self._client_loop, args=(conn,))
79
+ thread.daemon = True
80
+ thread.start()
81
+ except Exception as e:
82
+ if conn is not None:
83
+ try:
84
+ conn.close()
85
+ except Exception:
86
+ pass
87
+ if self.running:
88
+ self._log("pipe accept failed: %s" % e)
89
+ time.sleep(0.2)
90
+ return self
91
+
92
+ def close(self):
93
+ self.running = False
94
+ try:
95
+ if self.listener is not None:
96
+ self.listener.close()
97
+ except Exception:
98
+ pass
99
+ with self.qmt_lock:
100
+ conns = list(self.qmt_rx_by_channel.values()) + list(self.qmt_tx_by_channel.values())
101
+ self.qmt_rx_by_channel.clear()
102
+ self.qmt_tx_by_channel.clear()
103
+ self.qmt_channel_by_conn.clear()
104
+ with self.state_lock:
105
+ conns.extend(self.client_ids_by_conn.keys())
106
+ self.pending.clear()
107
+ self.client_by_id.clear()
108
+ self.client_tx_by_id.clear()
109
+ self.client_ids_by_conn.clear()
110
+ self.client_conn_meta_by_conn.clear()
111
+ self.client_generation_by_id.clear()
112
+ for conn in conns:
113
+ try:
114
+ conn.close()
115
+ except Exception:
116
+ pass
117
+ self._write_status()
118
+
119
+ def _client_loop(self, conn):
120
+ role = "api"
121
+ try:
122
+ while self.running:
123
+ raw = conn.read_frame()
124
+ if raw is None:
125
+ break
126
+ envelope = loads_pipe_message(raw)
127
+ if envelope:
128
+ msg_type = envelope.get("type")
129
+ if msg_type == "hello":
130
+ role = self._handle_hello(conn, envelope, role)
131
+ if role in ("api_rx", "qmt_rx"):
132
+ self._passive_rx_loop(conn, role)
133
+ break
134
+ elif msg_type == "request":
135
+ role = "api"
136
+ self._handle_api_request(conn, envelope)
137
+ elif msg_type == "publish":
138
+ role = "qmt"
139
+ self._handle_qmt_publish(conn, envelope)
140
+ else:
141
+ self._log("pipe ignored envelope type=%s role=%s" % (msg_type, role))
142
+ else:
143
+ self._handle_api_request(conn, {
144
+ "payload": raw,
145
+ "request_channel": self.default_request_channel,
146
+ })
147
+ except Exception as e:
148
+ if self.running:
149
+ self._log("pipe client loop failed role=%s error=%s" % (role, e))
150
+ finally:
151
+ self._drop_conn(conn)
152
+ try:
153
+ conn.close()
154
+ except Exception:
155
+ pass
156
+ self._log("pipe client disconnected role=%s" % role)
157
+ self._write_status()
158
+
159
+ def _passive_rx_loop(self, conn, role):
160
+ while self.running:
161
+ if role == "qmt_rx":
162
+ with self.qmt_lock:
163
+ active = conn in self.qmt_channel_by_conn
164
+ elif role == "api_rx":
165
+ with self.state_lock:
166
+ active = conn in self.client_ids_by_conn
167
+ else:
168
+ active = False
169
+ if not active:
170
+ break
171
+ time.sleep(0.2)
172
+
173
+ def _handle_hello(self, conn, envelope, current_role):
174
+ role = envelope.get("role") or current_role
175
+ if role in ("qmt", "qmt_rx", "qmt_tx"):
176
+ channels = self._envelope_channels(envelope)
177
+ with self.qmt_lock:
178
+ target = self.qmt_rx_by_channel if role in ("qmt", "qmt_rx") else self.qmt_tx_by_channel
179
+ for channel in channels:
180
+ old = target.get(channel)
181
+ target[channel] = conn
182
+ self.qmt_channel_by_conn.setdefault(conn, set()).add(channel)
183
+ if old is not None and old is not conn:
184
+ try:
185
+ old.close()
186
+ except Exception:
187
+ pass
188
+ self._log(
189
+ "qmt pipe bridge registered role=%s channels=%s bridge_id=%s"
190
+ % (role, ",".join(channels), envelope.get("bridge_id") or "-")
191
+ )
192
+ elif role in ("api", "api_rx", "api_tx"):
193
+ client_id = envelope.get("client_id")
194
+ if client_id:
195
+ self._remember_client(conn, client_id, receive_conn=role in ("api", "api_rx"))
196
+ self._log("api pipe client registered role=%s client_id=%s" % (role, client_id or "-"))
197
+ self._write_status()
198
+ return role
199
+
200
+ def _envelope_channels(self, envelope):
201
+ raw = envelope.get("request_channels")
202
+ if raw is None:
203
+ raw = [envelope.get("request_channel") or self.default_request_channel]
204
+ result = []
205
+ for channel in raw:
206
+ channel = str(channel or "").strip()
207
+ if channel and channel not in result:
208
+ result.append(channel)
209
+ return result or [self.default_request_channel]
210
+
211
+ def _handle_api_request(self, conn, envelope):
212
+ api_received_at = time.perf_counter()
213
+ raw = envelope.get("payload")
214
+ msg = loads_message(raw)
215
+ if not msg or msg.get("type") != "request":
216
+ return
217
+ request_id = msg.get("id")
218
+ client_id = envelope.get("client_id") or msg.get("client_id") or msg.get("reply_channel")
219
+ request_channel = envelope.get("request_channel") or self.default_request_channel
220
+ if client_id:
221
+ self._remember_client(conn, client_id, receive_conn=False)
222
+ response_conn = self._client_rx_conn(client_id) or conn
223
+ with self.state_lock:
224
+ if request_id:
225
+ self.pending[request_id] = {
226
+ "conn": response_conn,
227
+ "qmt_conn": None,
228
+ "action": msg.get("action"),
229
+ "request_channel": request_channel,
230
+ "api_received_at": api_received_at,
231
+ "forward_done_at": None,
232
+ }
233
+ qmt = self._qmt_rx_conn(request_channel)
234
+ if qmt is None:
235
+ with self.state_lock:
236
+ self.pending.pop(request_id, None)
237
+ self._send_error(response_conn, request_id, "QMT pipe bridge not connected for channel=%s" % request_channel)
238
+ return
239
+ with self.state_lock:
240
+ pending = self.pending.get(request_id)
241
+ if pending:
242
+ pending["qmt_conn"] = qmt
243
+ forward_start = time.perf_counter()
244
+ try:
245
+ self._send_delivery(qmt, raw, request_channel=request_channel, client_id=client_id)
246
+ except Exception as e:
247
+ self._drop_conn(qmt)
248
+ with self.state_lock:
249
+ self.pending.pop(request_id, None)
250
+ self._send_error(
251
+ response_conn,
252
+ request_id,
253
+ "QMT pipe bridge send failed for channel=%s error=%s" % (request_channel, e),
254
+ )
255
+ return
256
+ forward_done = time.perf_counter()
257
+ with self.state_lock:
258
+ pending = self.pending.get(request_id)
259
+ if pending:
260
+ pending["forward_done_at"] = forward_done
261
+ self._log(
262
+ "pipe forwarded request action=%s id=%s channel=%s api_to_forward_ms=%.2f send_ms=%.2f len=%s"
263
+ % (
264
+ msg.get("action"),
265
+ request_id,
266
+ request_channel,
267
+ self._elapsed_ms(api_received_at, forward_done),
268
+ self._elapsed_ms(forward_start, forward_done),
269
+ len(raw or ""),
270
+ )
271
+ )
272
+ self._write_status()
273
+
274
+ def _handle_qmt_publish(self, conn, envelope):
275
+ qmt_received_at = time.perf_counter()
276
+ raw = envelope.get("payload")
277
+ msg = loads_message(raw)
278
+ target = None
279
+ status_changed = False
280
+ if not msg:
281
+ client_id = envelope.get("channel") or envelope.get("client_id")
282
+ if client_id:
283
+ with self.state_lock:
284
+ target = self.client_by_id.get(client_id)
285
+ if target is not None:
286
+ try:
287
+ callback_event = self._pack_callback_event(raw)
288
+ if callback_event is not None:
289
+ self._send_delivery(target, callback_event)
290
+ if self.verbose_events:
291
+ self._log(
292
+ "pipe got raw qmt callback channel=%s target=%s len=%s"
293
+ % (client_id, bool(target), len(raw or ""))
294
+ )
295
+ return
296
+ except Exception as e:
297
+ self._log("pipe raw qmt callback pack failed: %s" % e)
298
+ return
299
+ msg_type = msg.get("type")
300
+ if msg_type == "response":
301
+ request_id = msg.get("id")
302
+ with self.state_lock:
303
+ pending = self.pending.pop(request_id, None)
304
+ status_changed = pending is not None
305
+ if pending:
306
+ target = pending.get("conn")
307
+ api_received_at = pending.get("api_received_at") or qmt_received_at
308
+ forward_done_at = pending.get("forward_done_at") or api_received_at
309
+ self._log(
310
+ "pipe got qmt response action=%s id=%s channel=%s qmt_roundtrip_ms=%.2f total_to_hub_ms=%.2f len=%s"
311
+ % (
312
+ pending.get("action"),
313
+ request_id,
314
+ pending.get("request_channel"),
315
+ self._elapsed_ms(forward_done_at, qmt_received_at),
316
+ self._elapsed_ms(api_received_at, qmt_received_at),
317
+ len(raw or ""),
318
+ )
319
+ )
320
+ elif msg_type == "event":
321
+ client_id = msg.get("client_id") or envelope.get("channel")
322
+ with self.state_lock:
323
+ target = self.client_by_id.get(client_id)
324
+ if self.verbose_events:
325
+ self._log(
326
+ "pipe got qmt event event=%s client_id=%s target=%s len=%s"
327
+ % (msg.get("event"), client_id, bool(target), len(raw or ""))
328
+ )
329
+ if target is not None:
330
+ try:
331
+ self._send_delivery(target, raw)
332
+ except Exception as e:
333
+ self._log("pipe response/event delivery failed: %s" % e)
334
+ self._drop_conn(target)
335
+ if status_changed:
336
+ self._write_status()
337
+
338
+ def _pack_callback_event(self, raw):
339
+ try:
340
+ payload = json.loads(raw)
341
+ except Exception:
342
+ payload = {"raw": raw}
343
+ if not isinstance(payload, dict):
344
+ payload = {"raw": payload}
345
+ event_name = str(payload.get("event") or payload.get("event_name") or "callback")
346
+ client_id = str(payload.get("client_id") or payload.get("account_id") or payload.get("bridge_id") or "")
347
+ return pack_event(
348
+ event_name,
349
+ data=payload,
350
+ client_id=client_id or None,
351
+ subscription_id=payload.get("subscription_id") or payload.get("subscribe_id"),
352
+ )
353
+
354
+ def _send_error(self, conn, request_id, message):
355
+ try:
356
+ self._send_delivery(
357
+ conn,
358
+ pack_response(request_id, ok=False, error={"type": "ConnectionError", "message": message}),
359
+ )
360
+ except Exception as e:
361
+ self._log("pipe error delivery failed: %s" % e)
362
+ self._drop_conn(conn)
363
+
364
+ def _send_delivery(self, conn, payload, request_channel=None, client_id=None):
365
+ conn.write_frame(dumps_pipe_message({
366
+ "type": "delivery",
367
+ "payload": payload,
368
+ "request_channel": request_channel,
369
+ "client_id": client_id,
370
+ }))
371
+
372
+ def _qmt_rx_conn(self, request_channel):
373
+ with self.qmt_lock:
374
+ return self.qmt_rx_by_channel.get(request_channel)
375
+
376
+ def _client_rx_conn(self, client_id):
377
+ if not client_id:
378
+ return None
379
+ with self.state_lock:
380
+ return self.client_by_id.get(client_id)
381
+
382
+ def _remember_client(self, conn, client_id, receive_conn=True):
383
+ close_old = []
384
+ with self.state_lock:
385
+ if receive_conn:
386
+ generation = int(self.client_generation_by_id.get(client_id) or 0) + 1
387
+ self.client_generation_by_id[client_id] = generation
388
+ mapping = self.client_by_id
389
+ else:
390
+ generation = int(self.client_generation_by_id.get(client_id) or 0) or 1
391
+ self.client_generation_by_id[client_id] = generation
392
+ mapping = self.client_tx_by_id
393
+ old = mapping.get(client_id)
394
+ if old is not None and old is not conn:
395
+ self._detach_client_conn_locked(old, client_id)
396
+ close_old.append(old)
397
+ if receive_conn:
398
+ self.client_by_id[client_id] = conn
399
+ else:
400
+ self.client_tx_by_id[client_id] = conn
401
+ self.client_ids_by_conn.setdefault(conn, set()).add(client_id)
402
+ self.client_conn_meta_by_conn.setdefault(conn, {})[client_id] = {
403
+ "receive": bool(receive_conn),
404
+ "generation": generation,
405
+ }
406
+ for old in close_old:
407
+ try:
408
+ old.close()
409
+ except Exception:
410
+ pass
411
+
412
+ def _drop_conn(self, conn):
413
+ failed_pending = []
414
+ with self.qmt_lock:
415
+ channels = self.qmt_channel_by_conn.pop(conn, set())
416
+ for channel in channels:
417
+ if self.qmt_rx_by_channel.get(channel) is conn:
418
+ self.qmt_rx_by_channel.pop(channel, None)
419
+ if self.qmt_tx_by_channel.get(channel) is conn:
420
+ self.qmt_tx_by_channel.pop(channel, None)
421
+ close_peers = []
422
+ with self.state_lock:
423
+ meta_by_client = self.client_conn_meta_by_conn.pop(conn, {})
424
+ client_ids = set(self.client_ids_by_conn.pop(conn, set())) | set(meta_by_client.keys())
425
+ for client_id in client_ids:
426
+ meta = meta_by_client.get(client_id) or {}
427
+ generation = meta.get("generation")
428
+ receive_conn = meta.get("receive")
429
+ rx_conn = self.client_by_id.get(client_id)
430
+ tx_conn = self.client_tx_by_id.get(client_id)
431
+ rx_generation = self._client_conn_generation_locked(rx_conn, client_id)
432
+ tx_generation = self._client_conn_generation_locked(tx_conn, client_id)
433
+
434
+ if rx_conn is conn:
435
+ self.client_by_id.pop(client_id, None)
436
+ if tx_conn is conn:
437
+ self.client_tx_by_id.pop(client_id, None)
438
+
439
+ if receive_conn is True:
440
+ peer = tx_conn
441
+ peer_generation = tx_generation
442
+ peer_mapping = self.client_tx_by_id
443
+ elif receive_conn is False:
444
+ peer = rx_conn
445
+ peer_generation = rx_generation
446
+ peer_mapping = self.client_by_id
447
+ else:
448
+ peer = None
449
+ peer_generation = None
450
+ peer_mapping = None
451
+
452
+ if (
453
+ peer is not None
454
+ and peer is not conn
455
+ and generation is not None
456
+ and peer_generation == generation
457
+ and peer not in close_peers
458
+ ):
459
+ close_peers.append(peer)
460
+ if peer_mapping is not None and peer_mapping.get(client_id) is peer:
461
+ peer_mapping.pop(client_id, None)
462
+ self._detach_client_conn_locked(peer, client_id)
463
+
464
+ if self.client_by_id.get(client_id) is None and self.client_tx_by_id.get(client_id) is None:
465
+ self.client_generation_by_id.pop(client_id, None)
466
+ for request_id, pending in list(self.pending.items()):
467
+ pending_conn = pending.get("conn")
468
+ qmt_conn = pending.get("qmt_conn")
469
+ if pending_conn is conn or pending_conn in close_peers:
470
+ self.pending.pop(request_id, None)
471
+ elif qmt_conn is conn:
472
+ self.pending.pop(request_id, None)
473
+ failed_pending.append((
474
+ request_id,
475
+ pending,
476
+ "QMT pipe bridge disconnected for channel=%s" % pending.get("request_channel"),
477
+ ))
478
+ for peer in close_peers:
479
+ try:
480
+ peer.close()
481
+ except Exception:
482
+ pass
483
+ for request_id, pending, message in failed_pending:
484
+ self._send_error(pending.get("conn"), request_id, message)
485
+
486
+ def _detach_client_conn_locked(self, conn, client_id):
487
+ if conn is None:
488
+ return
489
+ client_ids = self.client_ids_by_conn.get(conn)
490
+ if client_ids is not None:
491
+ client_ids.discard(client_id)
492
+ if not client_ids:
493
+ self.client_ids_by_conn.pop(conn, None)
494
+ meta_by_client = self.client_conn_meta_by_conn.get(conn)
495
+ if meta_by_client is not None:
496
+ meta_by_client.pop(client_id, None)
497
+ if not meta_by_client:
498
+ self.client_conn_meta_by_conn.pop(conn, None)
499
+
500
+ def _client_conn_generation_locked(self, conn, client_id):
501
+ if conn is None:
502
+ return None
503
+ meta_by_client = self.client_conn_meta_by_conn.get(conn) or {}
504
+ meta = meta_by_client.get(client_id) or {}
505
+ return meta.get("generation")
506
+
507
+ def _start_maintenance(self):
508
+ if self.maintenance_thread is not None and self.maintenance_thread.is_alive():
509
+ return
510
+ self.maintenance_thread = threading.Thread(target=self._maintenance_loop)
511
+ self.maintenance_thread.daemon = True
512
+ self.maintenance_thread.start()
513
+
514
+ def _maintenance_loop(self):
515
+ while self.running:
516
+ try:
517
+ expired_count = self._cleanup_expired_pending()
518
+ if expired_count:
519
+ self._write_status()
520
+ except Exception as e:
521
+ self._log("pipe hub maintenance failed: %s" % e)
522
+ time.sleep(max(0.2, self.maintenance_interval_seconds))
523
+
524
+ def _cleanup_expired_pending(self):
525
+ timeout = max(0.0, self.pending_timeout_seconds)
526
+ if timeout <= 0:
527
+ return 0
528
+ now = time.perf_counter()
529
+ expired = []
530
+ with self.state_lock:
531
+ for request_id, pending in list(self.pending.items()):
532
+ started = pending.get("api_received_at") or now
533
+ if now - started < timeout:
534
+ continue
535
+ self.pending.pop(request_id, None)
536
+ expired.append((request_id, pending))
537
+ for request_id, pending in expired:
538
+ self._send_error(
539
+ pending.get("conn"),
540
+ request_id,
541
+ "QMT pipe bridge response timeout for action=%s channel=%s"
542
+ % (pending.get("action"), pending.get("request_channel")),
543
+ )
544
+ if expired:
545
+ self._log("pipe cleaned expired pending requests count=%s" % len(expired))
546
+ return len(expired)
547
+
548
+ def _log(self, msg):
549
+ if self.show:
550
+ print("%s %s" % (self._timestamp_ms(), msg), flush=True)
551
+
552
+ def _timestamp_ms(self):
553
+ now = time.time()
554
+ local = time.localtime(now)
555
+ return "%s.%03d" % (time.strftime("%Y-%m-%d %H:%M:%S", local), int((now - int(now)) * 1000))
556
+
557
+ def _elapsed_ms(self, start, end=None):
558
+ if end is None:
559
+ end = time.perf_counter()
560
+ return (end - start) * 1000.0
561
+
562
+ def _write_status(self):
563
+ try:
564
+ with self.qmt_lock:
565
+ qmt_rx_channels = sorted(set(self.qmt_rx_by_channel.keys()))
566
+ qmt_tx_channels = sorted(set(self.qmt_tx_by_channel.keys()))
567
+ qmt_channels = sorted(set(qmt_rx_channels) | set(qmt_tx_channels))
568
+ qmt_ready_channels = sorted(set(qmt_rx_channels) & set(qmt_tx_channels))
569
+ with self.state_lock:
570
+ pending_ids = list(self.pending.keys())[-20:]
571
+ client_count = len(set(self.client_by_id.values()))
572
+ data = {
573
+ "ts": time.strftime("%Y-%m-%d %H:%M:%S"),
574
+ "pipe_name": self.pipe_name,
575
+ "core_version": CORE_VERSION,
576
+ "hub_status_schema": 2,
577
+ "running": self.running,
578
+ "pid": os.getpid(),
579
+ "qmt_channels": qmt_channels,
580
+ "qmt_rx_channels": qmt_rx_channels,
581
+ "qmt_tx_channels": qmt_tx_channels,
582
+ "qmt_ready_channels": qmt_ready_channels,
583
+ "qmt_connected": bool(qmt_ready_channels or qmt_channels),
584
+ "pending_count": len(self.pending),
585
+ "pending_ids": pending_ids,
586
+ "api_client_count": client_count,
587
+ }
588
+ status_dir = os.path.dirname(self.status_file)
589
+ if status_dir:
590
+ os.makedirs(status_dir, exist_ok=True)
591
+ with open(self.status_file, "w", encoding="utf-8") as f:
592
+ json.dump(data, f, ensure_ascii=False, indent=2)
593
+ except Exception:
594
+ pass
595
+
596
+
597
+ def run_pipe_hub(pipe_name=None, show=True, default_request_channel="cfquant.normal.request"):
598
+ return CfquantPipeHub(
599
+ pipe_name=pipe_name,
600
+ show=show,
601
+ default_request_channel=default_request_channel,
602
+ ).start()