fixtureqa 0.4.26__tar.gz → 0.4.28__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.
Files changed (102) hide show
  1. {fixtureqa-0.4.26/fixtureqa.egg-info → fixtureqa-0.4.28}/PKG-INFO +1 -1
  2. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/routers/perf.py +10 -4
  3. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/perf_charts.py +6 -2
  4. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/perf_engine.py +89 -8
  5. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/perf_models.py +40 -3
  6. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/perf_stats.py +18 -1
  7. fixtureqa-0.4.28/fixture/static/assets/index-COvq8e1-.js +102 -0
  8. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/static/index.html +1 -1
  9. {fixtureqa-0.4.26 → fixtureqa-0.4.28/fixtureqa.egg-info}/PKG-INFO +1 -1
  10. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixtureqa.egg-info/SOURCES.txt +1 -1
  11. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/pyproject.toml +1 -1
  12. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_perf_api.py +51 -0
  13. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_perf_engine.py +35 -3
  14. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_perf_models.py +18 -0
  15. fixtureqa-0.4.26/fixture/static/assets/index-C7Iff2Mt.js +0 -102
  16. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/LICENSE +0 -0
  17. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/README.md +0 -0
  18. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/__init__.py +0 -0
  19. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/__main__.py +0 -0
  20. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/__init__.py +0 -0
  21. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/app.py +0 -0
  22. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/connection_manager.py +0 -0
  23. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/deps.py +0 -0
  24. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/routers/__init__.py +0 -0
  25. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/routers/admin.py +0 -0
  26. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/routers/auth.py +0 -0
  27. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/routers/branding.py +0 -0
  28. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/routers/custom_tags.py +0 -0
  29. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/routers/fix_spec.py +0 -0
  30. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/routers/messages.py +0 -0
  31. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/routers/scenarios.py +0 -0
  32. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/routers/sessions.py +0 -0
  33. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/routers/setup.py +0 -0
  34. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/routers/spec_overlay.py +0 -0
  35. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/routers/templates.py +0 -0
  36. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/routers/ws.py +0 -0
  37. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/api/schemas.py +0 -0
  38. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/config/__init__.py +0 -0
  39. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/__init__.py +0 -0
  40. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/atomic_io.py +0 -0
  41. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/auth.py +0 -0
  42. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/config_store.py +0 -0
  43. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/custom_tag_store.py +0 -0
  44. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/db_migrations.py +0 -0
  45. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/events.py +0 -0
  46. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/fix_application.py +0 -0
  47. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/fix_builder.py +0 -0
  48. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/fix_parser.py +0 -0
  49. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/fix_spec_parser.py +0 -0
  50. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/fix_tags.py +0 -0
  51. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/fix_time.py +0 -0
  52. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/housekeeping.py +0 -0
  53. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/inbound.py +0 -0
  54. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/json_store.py +0 -0
  55. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/latency_csv_writer.py +0 -0
  56. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/message_log.py +0 -0
  57. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/message_store.py +0 -0
  58. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/models.py +0 -0
  59. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/perf_payload.py +0 -0
  60. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/perf_store.py +0 -0
  61. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/perf_writer.py +0 -0
  62. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/scenario_runner.py +0 -0
  63. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/scenario_store.py +0 -0
  64. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/session.py +0 -0
  65. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/session_manager.py +0 -0
  66. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/spec_overlay_store.py +0 -0
  67. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/template_store.py +0 -0
  68. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/user_store.py +0 -0
  69. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/core/venue_responses.py +0 -0
  70. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/fix_specs/FIX42.xml +0 -0
  71. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/fix_specs/FIX44.xml +0 -0
  72. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/server.py +0 -0
  73. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  74. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/static/assets/index-C9bcm_lN.css +0 -0
  75. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  76. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  77. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/static/favicon.svg +0 -0
  78. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixture/ui/__init__.py +0 -0
  79. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixtureqa.egg-info/dependency_links.txt +0 -0
  80. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixtureqa.egg-info/entry_points.txt +0 -0
  81. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixtureqa.egg-info/requires.txt +0 -0
  82. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/fixtureqa.egg-info/top_level.txt +0 -0
  83. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/setup.cfg +0 -0
  84. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_atomic_io.py +0 -0
  85. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_auth.py +0 -0
  86. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_config_store.py +0 -0
  87. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_connection_manager.py +0 -0
  88. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_db_migrations.py +0 -0
  89. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_fix_builder.py +0 -0
  90. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_health.py +0 -0
  91. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_inbound.py +0 -0
  92. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_inbound_validation.py +0 -0
  93. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_message_store.py +0 -0
  94. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_perf_payload.py +0 -0
  95. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_perf_rehydrate.py +0 -0
  96. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_scenarios.py +0 -0
  97. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_session_auto_response.py +0 -0
  98. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_session_lifecycle.py +0 -0
  99. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_session_manager_concurrency.py +0 -0
  100. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_sessions.py +0 -0
  101. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_templates.py +0 -0
  102. {fixtureqa-0.4.26 → fixtureqa-0.4.28}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.4.26
3
+ Version: 0.4.28
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -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) if sid}
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, SnapshotErrors, LatencyStats,
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. In
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
- else:
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] * 11))
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
  }