fixtureqa 0.4.26__tar.gz → 0.4.27__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.
- {fixtureqa-0.4.26/fixtureqa.egg-info → fixtureqa-0.4.27}/PKG-INFO +1 -1
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/routers/perf.py +10 -4
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/perf_charts.py +6 -2
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/perf_engine.py +89 -8
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/perf_models.py +40 -3
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/perf_stats.py +18 -1
- fixtureqa-0.4.27/fixture/static/assets/index-DgRTHspW.js +102 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/static/index.html +1 -1
- {fixtureqa-0.4.26 → fixtureqa-0.4.27/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixtureqa.egg-info/SOURCES.txt +1 -1
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/pyproject.toml +1 -1
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_perf_api.py +51 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_perf_engine.py +35 -3
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_perf_models.py +18 -0
- fixtureqa-0.4.26/fixture/static/assets/index-C7Iff2Mt.js +0 -102
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/LICENSE +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/README.md +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/__init__.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/__main__.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/app.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/deps.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/auth.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/events.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/latency_csv_writer.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/models.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/perf_payload.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/scenario_runner.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/session.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/server.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/static/assets/index-C9bcm_lN.css +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/setup.cfg +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_auth.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_config_store.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_health.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_inbound.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_message_store.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_scenarios.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_session_auto_response.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_sessions.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_templates.py +0 -0
- {fixtureqa-0.4.26 → fixtureqa-0.4.27}/tests/test_ws.py +0 -0
|
@@ -80,19 +80,25 @@ def _require_started(sm: SessionManager, sid: str, label: str):
|
|
|
80
80
|
async def create_run(body: RunConfig, sm: SM, reg: REG, ts: TS, admin: Admin):
|
|
81
81
|
# async so PerfRun.start()'s asyncio.create_task runs on the event loop
|
|
82
82
|
# (a sync def route runs in a threadpool with no running loop).
|
|
83
|
-
need_client = body.mode in ("loopback", "manager", "client")
|
|
84
|
-
need_venue = body.mode in ("loopback", "manager", "venue")
|
|
83
|
+
need_client = body.mode in ("loopback", "manager", "client", "middle_manager")
|
|
84
|
+
need_venue = body.mode in ("loopback", "manager", "venue", "middle_manager")
|
|
85
85
|
participating: list[tuple[str, object]] = [] # (label, config) for fixture legs
|
|
86
86
|
if need_client:
|
|
87
87
|
participating.append(("client", _require_started(sm, body.client_session_id, "client").config))
|
|
88
88
|
if need_venue:
|
|
89
89
|
participating.append(("venue", _require_started(sm, body.venue_session_id, "venue").config))
|
|
90
|
+
if body.mode == "middle_manager":
|
|
91
|
+
# The relay's two sessions are driven too — require them started and let the
|
|
92
|
+
# durable guard / ceiling participant set cover them like any other leg.
|
|
93
|
+
participating.append(("relay_in", _require_started(sm, body.relay_in_session_id, "relay_in").config))
|
|
94
|
+
participating.append(("relay_out", _require_started(sm, body.relay_out_session_id, "relay_out").config))
|
|
90
95
|
|
|
91
96
|
# Optional per-session msgs/s ceiling: validate the named session actually
|
|
92
97
|
# participates, then fold it into the effective order rate (the injector is
|
|
93
98
|
# throttled to min(configured, ceiling) — see PerfRun). mpo every message
|
|
94
99
|
# crosses the link, so each leg touches ~mpo messages per order.
|
|
95
|
-
participant_sids = {sid for sid in (body.client_session_id, body.venue_session_id
|
|
100
|
+
participant_sids = {sid for sid in (body.client_session_id, body.venue_session_id,
|
|
101
|
+
body.relay_in_session_id, body.relay_out_session_id) if sid}
|
|
96
102
|
csid = body.rate.ceiling_session_id
|
|
97
103
|
if body.rate.max_msgs_per_sec is not None and csid and csid not in participant_sids:
|
|
98
104
|
raise HTTPException(status_code=422, detail=(
|
|
@@ -291,7 +297,7 @@ async def run_chart(run_id: str, reg: REG, _admin: Admin,
|
|
|
291
297
|
# label it so the chart can't be misread as the single-clock measurement.
|
|
292
298
|
st = reg.get_status(run_id)
|
|
293
299
|
one_way_label = None
|
|
294
|
-
if st is not None and st.mode not in ("loopback", "manager"):
|
|
300
|
+
if st is not None and st.mode not in ("loopback", "manager", "middle_manager"):
|
|
295
301
|
one_way_label = "one-way (tag-60 wire send → recv; needs synced clocks)"
|
|
296
302
|
out_path = f"{csv_path[:-len('.csv.gz')]}_{metric}_{agg}_{scale}.png"
|
|
297
303
|
if not os.path.exists(out_path):
|
|
@@ -33,10 +33,14 @@ _KIND_COLOR = {
|
|
|
33
33
|
"canceled": "#2dd4bf",
|
|
34
34
|
"cancel-reject": "#f43f5e",
|
|
35
35
|
"amend-reject": "#ef4444",
|
|
36
|
+
"relay-down": "#34d399", # middle_manager relay pass-through, client→venue
|
|
37
|
+
"relay-up": "#10b981", # middle_manager relay pass-through, venue→client
|
|
36
38
|
}
|
|
37
|
-
# Drawing order: order lifecycle, then amend, then cancel (request before reply)
|
|
39
|
+
# Drawing order: order lifecycle, then amend, then cancel (request before reply),
|
|
40
|
+
# then the middle_manager relay pass-through pair.
|
|
38
41
|
_KIND_ORDER = ["order", "ack", "fill", "amend", "pending-replace", "replace",
|
|
39
|
-
"cancel", "pending-cancel", "canceled", "cancel-reject", "amend-reject"
|
|
42
|
+
"cancel", "pending-cancel", "canceled", "cancel-reject", "amend-reject",
|
|
43
|
+
"relay-down", "relay-up"]
|
|
40
44
|
|
|
41
45
|
# Per metric: (latency column, x-axis column, axis label).
|
|
42
46
|
_METRIC = {
|
|
@@ -32,10 +32,11 @@ from typing import Optional
|
|
|
32
32
|
from fixcore.message.message import Message
|
|
33
33
|
|
|
34
34
|
from .latency_csv_writer import LatencyCsvWriter
|
|
35
|
+
from .fix_builder import build_message_from_raw
|
|
35
36
|
from .fix_time import utc_timestamp
|
|
36
37
|
from .perf_models import (
|
|
37
|
-
RunConfig, RunStatus, LiveSnapshot, ClientLeg, VenueLeg,
|
|
38
|
-
est_msgs_per_order,
|
|
38
|
+
RunConfig, RunStatus, LiveSnapshot, ClientLeg, VenueLeg, RelayLeg, SnapshotErrors,
|
|
39
|
+
LatencyStats, est_msgs_per_order,
|
|
39
40
|
)
|
|
40
41
|
from .perf_payload import PayloadFactory, ScenarioSelector
|
|
41
42
|
from .perf_stats import BurstGate, PerfStats, TokenBucket
|
|
@@ -142,11 +143,15 @@ class PerfRun:
|
|
|
142
143
|
self._draining_full = False # pause timeout sweeper during a full drain
|
|
143
144
|
self._injecting = True
|
|
144
145
|
|
|
145
|
-
self._has_client = config.mode in ("loopback", "manager", "client")
|
|
146
|
-
self._has_venue = config.mode in ("loopback", "manager", "venue")
|
|
146
|
+
self._has_client = config.mode in ("loopback", "manager", "client", "middle_manager")
|
|
147
|
+
self._has_venue = config.mode in ("loopback", "manager", "venue", "middle_manager")
|
|
148
|
+
# middle_manager: a 2-session relay (relay_in acceptor + relay_out initiator)
|
|
149
|
+
# spliced between the gen-client and the venue. The relay forwards verbatim
|
|
150
|
+
# and times its own pass-through; the injector/correlator/venue are unchanged.
|
|
151
|
+
self._has_relay = config.mode == "middle_manager"
|
|
147
152
|
# One-way per-message latency (recv at far leg − send at near leg) needs
|
|
148
|
-
# both legs in this process on one clock — i.e. loopback/manager.
|
|
149
|
-
# client/venue mode we only see one leg, so the recorder falls back to
|
|
153
|
+
# both legs in this process on one clock — i.e. loopback/manager/middle_manager.
|
|
154
|
+
# In client/venue mode we only see one leg, so the recorder falls back to
|
|
150
155
|
# round-trip (see _emit_crossing / _record_rt).
|
|
151
156
|
self._one_way = self._has_client and self._has_venue
|
|
152
157
|
|
|
@@ -203,6 +208,9 @@ class PerfRun:
|
|
|
203
208
|
|
|
204
209
|
self._client_sub = None
|
|
205
210
|
self._venue_sub = None
|
|
211
|
+
self._relay_in_sub = None
|
|
212
|
+
self._relay_out_sub = None
|
|
213
|
+
self._relay_dd = None # DataDictionary for group-safe relay re-emit
|
|
206
214
|
self._tasks: list[asyncio.Task] = []
|
|
207
215
|
self._main: Optional[asyncio.Task] = None
|
|
208
216
|
self._stop = asyncio.Event()
|
|
@@ -257,6 +265,15 @@ class PerfRun:
|
|
|
257
265
|
if self._has_venue:
|
|
258
266
|
self._venue_sub = self._sm.subscribe_session(self.config.venue_session_id)
|
|
259
267
|
self._sm.mark_perf_active(self.config.venue_session_id)
|
|
268
|
+
if self._has_relay:
|
|
269
|
+
self._relay_in_sub = self._sm.subscribe_session(self.config.relay_in_session_id)
|
|
270
|
+
self._sm.mark_perf_active(self.config.relay_in_session_id)
|
|
271
|
+
self._relay_out_sub = self._sm.subscribe_session(self.config.relay_out_session_id)
|
|
272
|
+
self._sm.mark_perf_active(self.config.relay_out_session_id)
|
|
273
|
+
try:
|
|
274
|
+
self._relay_dd = self._sm.get_data_dictionary(self.config.relay_out_session_id)
|
|
275
|
+
except Exception:
|
|
276
|
+
self._relay_dd = None # flat re-emit still works for non-group messages
|
|
260
277
|
self._writer.write_run(self._run_record())
|
|
261
278
|
self._main = asyncio.create_task(self._run())
|
|
262
279
|
|
|
@@ -275,6 +292,8 @@ class PerfRun:
|
|
|
275
292
|
tasks += [self._injector(), self._correlator(), self._timeout_sweeper()]
|
|
276
293
|
if self._has_venue:
|
|
277
294
|
tasks += [self._fill_dispatcher(), self._fill_sender()]
|
|
295
|
+
if self._has_relay:
|
|
296
|
+
tasks += [self._relay_forward("down"), self._relay_forward("up")]
|
|
278
297
|
self._tasks = [asyncio.create_task(t) for t in tasks]
|
|
279
298
|
dur = self.config.test.duration
|
|
280
299
|
try:
|
|
@@ -323,6 +342,12 @@ class PerfRun:
|
|
|
323
342
|
if self._venue_sub:
|
|
324
343
|
self._venue_sub.close()
|
|
325
344
|
self._sm.unmark_perf_active(self.config.venue_session_id)
|
|
345
|
+
if self._relay_in_sub:
|
|
346
|
+
self._relay_in_sub.close()
|
|
347
|
+
self._sm.unmark_perf_active(self.config.relay_in_session_id)
|
|
348
|
+
if self._relay_out_sub:
|
|
349
|
+
self._relay_out_sub.close()
|
|
350
|
+
self._sm.unmark_perf_active(self.config.relay_out_session_id)
|
|
326
351
|
if self._latency_csv is not None:
|
|
327
352
|
# Correlator is cancelled (no more enqueues); drain + gzip-finalize
|
|
328
353
|
# off-loop — close() joins the writer thread.
|
|
@@ -424,6 +449,50 @@ class PerfRun:
|
|
|
424
449
|
self._finalize(entry, "dropped")
|
|
425
450
|
return True
|
|
426
451
|
|
|
452
|
+
# ------------------------------------------------------------------
|
|
453
|
+
# Relay forwarder (middle_manager)
|
|
454
|
+
# ------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
async def _relay_forward(self, direction: str) -> None:
|
|
457
|
+
"""Verbatim FIX relay for middle_manager. "down" reads relay_in (from the
|
|
458
|
+
gen-client) and re-emits on relay_out (to the venue); "up" is the reverse.
|
|
459
|
+
The message body forwards as-is (correlation + ClOrdID preserved) while
|
|
460
|
+
the destination session stamps its own comp-ids/seqnum on send. Times the
|
|
461
|
+
per-hop pass-through and optionally injects an ordered delay. A single
|
|
462
|
+
coroutine per direction keeps forwarding FIFO (no reordering)."""
|
|
463
|
+
down = direction == "down"
|
|
464
|
+
sub = self._relay_in_sub if down else self._relay_out_sub
|
|
465
|
+
dst = self.config.relay_out_session_id if down else self.config.relay_in_session_id
|
|
466
|
+
reservoir = self.stats.relay_down_latency if down else self.stats.relay_up_latency
|
|
467
|
+
kind = "relay-down" if down else "relay-up"
|
|
468
|
+
rcfg = self.config.relay
|
|
469
|
+
while True:
|
|
470
|
+
inb = await sub.get()
|
|
471
|
+
if inb.admin:
|
|
472
|
+
continue # session-level (Logon/Heartbeat/…) is per-session, not relayed
|
|
473
|
+
recv_ns = inb.recv_perf_ns or time.perf_counter_ns()
|
|
474
|
+
if rcfg.inject_delay_ms or rcfg.jitter_ms:
|
|
475
|
+
delay = rcfg.inject_delay_ms + (random.random() * rcfg.jitter_ms)
|
|
476
|
+
if delay > 0:
|
|
477
|
+
await asyncio.sleep(delay / 1000.0)
|
|
478
|
+
try:
|
|
479
|
+
msg = build_message_from_raw(inb.raw, self._relay_dd)
|
|
480
|
+
except Exception:
|
|
481
|
+
continue # unparseable (shouldn't happen) — drop, don't wedge the loop
|
|
482
|
+
pass_us = (time.perf_counter_ns() - recv_ns) / 1000.0
|
|
483
|
+
if await self._send(dst, msg):
|
|
484
|
+
reservoir.add(pass_us)
|
|
485
|
+
if down:
|
|
486
|
+
self.stats.relay_fwd_down += 1
|
|
487
|
+
else:
|
|
488
|
+
self.stats.relay_fwd_up += 1
|
|
489
|
+
self._emit_crossing(
|
|
490
|
+
corr=inb.fields.get(self.config.correlation_tag),
|
|
491
|
+
clordid=inb.fields.get(T_CLORDID), exec_id=None, kind=kind,
|
|
492
|
+
direction="c2v" if down else "v2c",
|
|
493
|
+
send_at=time.time(), recv_at=time.time(), req_send_at=None,
|
|
494
|
+
one_way_us=pass_us, round_trip_us=None)
|
|
495
|
+
|
|
427
496
|
async def _send(self, session_id: str, msg: Message) -> bool:
|
|
428
497
|
"""Single send path — tallies every outbound app message per leg
|
|
429
498
|
(type-agnostic) for the messages-per-second chart, then delegates."""
|
|
@@ -431,8 +500,9 @@ class PerfRun:
|
|
|
431
500
|
if ok:
|
|
432
501
|
if session_id == self.config.client_session_id:
|
|
433
502
|
self.stats.msgs_sent_client += 1
|
|
434
|
-
|
|
503
|
+
elif session_id == self.config.venue_session_id:
|
|
435
504
|
self.stats.msgs_sent_venue += 1
|
|
505
|
+
# relay (middle_manager) sends are tallied by _relay_forward, not here
|
|
436
506
|
return ok
|
|
437
507
|
|
|
438
508
|
async def _send_order(self, corr: str, msg: Message) -> bool:
|
|
@@ -1284,11 +1354,22 @@ class PerfRun:
|
|
|
1284
1354
|
cancels_uncorrelated=self.stats.cancels_uncorrelated,
|
|
1285
1355
|
msgs_out_live=round(r.mvo, 1),
|
|
1286
1356
|
msgs_in_live=round(r.mvi, 1))
|
|
1357
|
+
relay = None
|
|
1358
|
+
if self._has_relay:
|
|
1359
|
+
relay = RelayLeg(
|
|
1360
|
+
msgs_down=self.stats.relay_fwd_down,
|
|
1361
|
+
msgs_up=self.stats.relay_fwd_up,
|
|
1362
|
+
dropped=((self._relay_in_sub.dropped if self._relay_in_sub else 0)
|
|
1363
|
+
+ (self._relay_out_sub.dropped if self._relay_out_sub else 0)),
|
|
1364
|
+
mdps_live=round(r.mrd, 1),
|
|
1365
|
+
mups_live=round(r.mru, 1),
|
|
1366
|
+
down_latency=LatencyStats(**self.stats.relay_down_latency.stats()),
|
|
1367
|
+
up_latency=LatencyStats(**self.stats.relay_up_latency.stats()))
|
|
1287
1368
|
snap = LiveSnapshot(
|
|
1288
1369
|
run_id=self.run_id, status=self.status, mode=self.config.mode,
|
|
1289
1370
|
elapsed_s=round(self._elapsed_s(), 1), duration_s=self.config.test.duration,
|
|
1290
1371
|
rate_config=self.config.rate.model_dump(),
|
|
1291
|
-
client=client, venue=venue,
|
|
1372
|
+
client=client, venue=venue, relay=relay,
|
|
1292
1373
|
response_latency=LatencyStats(**self.stats.response_latency.stats()),
|
|
1293
1374
|
fill_completion_latency=LatencyStats(**self.stats.fill_completion_latency.stats()),
|
|
1294
1375
|
amend_latency=LatencyStats(**self.stats.amend_latency.stats()),
|
|
@@ -18,7 +18,7 @@ from typing import Literal, Optional
|
|
|
18
18
|
|
|
19
19
|
from pydantic import AliasChoices, BaseModel, Field, model_validator
|
|
20
20
|
|
|
21
|
-
RunMode = Literal["loopback", "manager", "client", "venue"]
|
|
21
|
+
RunMode = Literal["loopback", "manager", "client", "venue", "middle_manager"]
|
|
22
22
|
SaturationPolicy = Literal["pause", "drop_oldest"]
|
|
23
23
|
RunState = Literal["pending", "running", "completed", "stopped", "error", "saturated"]
|
|
24
24
|
Sequence = Literal["parallel", "sequential", "sequential_timed"]
|
|
@@ -195,11 +195,26 @@ class PayloadConfig(BaseModel):
|
|
|
195
195
|
return self
|
|
196
196
|
|
|
197
197
|
|
|
198
|
+
class RelayConfig(BaseModel):
|
|
199
|
+
"""middle_manager relay behaviour. Verbatim forward by default; an optional
|
|
200
|
+
ordered delay (with jitter) injected before each forward simulates a slow
|
|
201
|
+
hub hop. inject_delay_ms applies to both directions; jitter_ms adds a random
|
|
202
|
+
0..jitter_ms on top per message."""
|
|
203
|
+
inject_delay_ms: int = Field(default=0, ge=0)
|
|
204
|
+
jitter_ms: int = Field(default=0, ge=0)
|
|
205
|
+
|
|
206
|
+
|
|
198
207
|
class RunConfig(BaseModel):
|
|
199
208
|
name: str = Field(min_length=1)
|
|
200
209
|
mode: RunMode = "loopback"
|
|
201
210
|
client_session_id: Optional[str] = None
|
|
202
211
|
venue_session_id: Optional[str] = None
|
|
212
|
+
# middle_manager only: the relay's two sessions. relay_in is the acceptor the
|
|
213
|
+
# gen-client connects to; relay_out is the initiator that connects to the venue.
|
|
214
|
+
# The run forwards client→relay_in→relay_out→venue and the replies back.
|
|
215
|
+
relay_in_session_id: Optional[str] = None
|
|
216
|
+
relay_out_session_id: Optional[str] = None
|
|
217
|
+
relay: "RelayConfig" = Field(default_factory=lambda: RelayConfig())
|
|
203
218
|
# Correlation: the tag carrying the order-identity GUID echoed end-to-end by
|
|
204
219
|
# the stack (default 376 ComplianceID); exec_id_tag (25116) identifies distinct
|
|
205
220
|
# ExecutionReports for dedupe.
|
|
@@ -213,12 +228,21 @@ class RunConfig(BaseModel):
|
|
|
213
228
|
|
|
214
229
|
@model_validator(mode="after")
|
|
215
230
|
def _sessions(self) -> "RunConfig":
|
|
216
|
-
need_client = self.mode in ("loopback", "manager", "client")
|
|
217
|
-
need_venue = self.mode in ("loopback", "manager", "venue")
|
|
231
|
+
need_client = self.mode in ("loopback", "manager", "client", "middle_manager")
|
|
232
|
+
need_venue = self.mode in ("loopback", "manager", "venue", "middle_manager")
|
|
218
233
|
if need_client and not self.client_session_id:
|
|
219
234
|
raise ValueError(f"mode {self.mode!r} requires client_session_id")
|
|
220
235
|
if need_venue and not self.venue_session_id:
|
|
221
236
|
raise ValueError(f"mode {self.mode!r} requires venue_session_id")
|
|
237
|
+
if self.mode == "middle_manager":
|
|
238
|
+
if not (self.relay_in_session_id and self.relay_out_session_id):
|
|
239
|
+
raise ValueError("mode 'middle_manager' requires relay_in_session_id "
|
|
240
|
+
"and relay_out_session_id")
|
|
241
|
+
ids = [self.client_session_id, self.relay_in_session_id,
|
|
242
|
+
self.relay_out_session_id, self.venue_session_id]
|
|
243
|
+
if len(set(ids)) != 4:
|
|
244
|
+
raise ValueError("middle_manager needs four distinct sessions "
|
|
245
|
+
"(client, relay_in, relay_out, venue)")
|
|
222
246
|
if self.payload.fill.fills_per_order == 0:
|
|
223
247
|
# Ack-only orders never complete via fills: without a cancel reply
|
|
224
248
|
# to finalize them, every order just times out as lost.
|
|
@@ -300,6 +324,18 @@ class VenueLeg(BaseModel):
|
|
|
300
324
|
msgs_in_live: float = 0.0 # all app messages this leg received/s
|
|
301
325
|
|
|
302
326
|
|
|
327
|
+
class RelayLeg(BaseModel):
|
|
328
|
+
# middle_manager: the in-process relay hop. "down" = client→venue direction
|
|
329
|
+
# (orders/amends/cancels), "up" = venue→client (execs/rejects).
|
|
330
|
+
msgs_down: int = 0
|
|
331
|
+
msgs_up: int = 0
|
|
332
|
+
dropped: int = 0
|
|
333
|
+
mdps_live: float = 0.0 # relay msgs forwarded downstream/s
|
|
334
|
+
mups_live: float = 0.0 # relay msgs forwarded upstream/s
|
|
335
|
+
down_latency: LatencyStats = Field(default_factory=LatencyStats)
|
|
336
|
+
up_latency: LatencyStats = Field(default_factory=LatencyStats)
|
|
337
|
+
|
|
338
|
+
|
|
303
339
|
class SnapshotErrors(BaseModel):
|
|
304
340
|
lost_timeout: int = 0
|
|
305
341
|
rejected: int = 0
|
|
@@ -315,6 +351,7 @@ class LiveSnapshot(BaseModel):
|
|
|
315
351
|
rate_config: dict = Field(default_factory=dict)
|
|
316
352
|
client: Optional[ClientLeg] = None
|
|
317
353
|
venue: Optional[VenueLeg] = None
|
|
354
|
+
relay: Optional[RelayLeg] = None
|
|
318
355
|
response_latency: LatencyStats = Field(default_factory=LatencyStats)
|
|
319
356
|
fill_completion_latency: LatencyStats = Field(default_factory=LatencyStats)
|
|
320
357
|
amend_latency: LatencyStats = Field(default_factory=LatencyStats)
|
|
@@ -31,6 +31,8 @@ class Rates(NamedTuple):
|
|
|
31
31
|
mci: float # client leg messages received/s (acks + fills + replies …)
|
|
32
32
|
mvo: float # venue leg messages sent/s (acks + fills + replies …)
|
|
33
33
|
mvi: float # venue leg messages received/s (orders + amends + cancels …)
|
|
34
|
+
mrd: float # middle_manager relay messages forwarded downstream/s
|
|
35
|
+
mru: float # middle_manager relay messages forwarded upstream/s
|
|
34
36
|
|
|
35
37
|
|
|
36
38
|
class TokenBucket:
|
|
@@ -158,6 +160,9 @@ class PerfStats:
|
|
|
158
160
|
self.msgs_recv_client = 0
|
|
159
161
|
self.msgs_sent_venue = 0
|
|
160
162
|
self.msgs_recv_venue = 0
|
|
163
|
+
# middle_manager relay: messages forwarded each direction
|
|
164
|
+
self.relay_fwd_down = 0 # client→venue direction (orders/amends/cancels)
|
|
165
|
+
self.relay_fwd_up = 0 # venue→client direction (execs/rejects)
|
|
161
166
|
self.lost_timeout = 0
|
|
162
167
|
self.dropped = 0
|
|
163
168
|
self.rejected = 0
|
|
@@ -172,6 +177,8 @@ class PerfStats:
|
|
|
172
177
|
self.amend_latency = _Reservoir(reservoir_size)
|
|
173
178
|
self.cancel_latency = _Reservoir(reservoir_size)
|
|
174
179
|
self.scenario_latency = _Reservoir(reservoir_size)
|
|
180
|
+
self.relay_down_latency = _Reservoir(reservoir_size)
|
|
181
|
+
self.relay_up_latency = _Reservoir(reservoir_size)
|
|
175
182
|
|
|
176
183
|
self._last_t = time.monotonic()
|
|
177
184
|
self._last_orders = 0
|
|
@@ -185,6 +192,8 @@ class PerfStats:
|
|
|
185
192
|
self._last_mrc = 0
|
|
186
193
|
self._last_msv = 0
|
|
187
194
|
self._last_mrv = 0
|
|
195
|
+
self._last_rfd = 0
|
|
196
|
+
self._last_rfu = 0
|
|
188
197
|
|
|
189
198
|
def rates(self) -> Rates:
|
|
190
199
|
"""Per-second deltas since the last call. Stateful — call exactly once
|
|
@@ -192,7 +201,7 @@ class PerfStats:
|
|
|
192
201
|
now = time.monotonic()
|
|
193
202
|
dt = now - self._last_t
|
|
194
203
|
if dt <= 0:
|
|
195
|
-
return Rates(*([0.0] *
|
|
204
|
+
return Rates(*([0.0] * len(Rates._fields)))
|
|
196
205
|
r = Rates(
|
|
197
206
|
ops=(self.orders_sent - self._last_orders) / dt,
|
|
198
207
|
fps=(self.fills_sent - self._last_fills) / dt,
|
|
@@ -205,6 +214,8 @@ class PerfStats:
|
|
|
205
214
|
mci=(self.msgs_recv_client - self._last_mrc) / dt,
|
|
206
215
|
mvo=(self.msgs_sent_venue - self._last_msv) / dt,
|
|
207
216
|
mvi=(self.msgs_recv_venue - self._last_mrv) / dt,
|
|
217
|
+
mrd=(self.relay_fwd_down - self._last_rfd) / dt,
|
|
218
|
+
mru=(self.relay_fwd_up - self._last_rfu) / dt,
|
|
208
219
|
)
|
|
209
220
|
self._last_t = now
|
|
210
221
|
self._last_orders = self.orders_sent
|
|
@@ -218,6 +229,8 @@ class PerfStats:
|
|
|
218
229
|
self._last_mrc = self.msgs_recv_client
|
|
219
230
|
self._last_msv = self.msgs_sent_venue
|
|
220
231
|
self._last_mrv = self.msgs_recv_venue
|
|
232
|
+
self._last_rfd = self.relay_fwd_down
|
|
233
|
+
self._last_rfu = self.relay_fwd_up
|
|
221
234
|
return r
|
|
222
235
|
|
|
223
236
|
def fill_ratio(self) -> float:
|
|
@@ -261,4 +274,8 @@ class PerfStats:
|
|
|
261
274
|
"amend_latency": self.amend_latency.stats(),
|
|
262
275
|
"cancel_latency": self.cancel_latency.stats(),
|
|
263
276
|
"scenario_latency": self.scenario_latency.stats(),
|
|
277
|
+
"relay_fwd_down": self.relay_fwd_down,
|
|
278
|
+
"relay_fwd_up": self.relay_fwd_up,
|
|
279
|
+
"relay_down_latency": self.relay_down_latency.stats(),
|
|
280
|
+
"relay_up_latency": self.relay_up_latency.stats(),
|
|
264
281
|
}
|