fixtureqa 0.4.21__tar.gz → 0.4.23__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.21/fixtureqa.egg-info → fixtureqa-0.4.23}/PKG-INFO +1 -1
  2. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/messages.py +39 -14
  3. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/message_store.py +57 -0
  4. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/models.py +5 -0
  5. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/perf_engine.py +33 -10
  6. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/perf_models.py +4 -0
  7. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/perf_stats.py +24 -1
  8. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/session.py +5 -1
  9. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/session_manager.py +17 -0
  10. fixtureqa-0.4.23/fixture/static/assets/index-B3ccKxdv.js +102 -0
  11. fixtureqa-0.4.21/fixture/static/assets/index-o_l1KBYd.css → fixtureqa-0.4.23/fixture/static/assets/index-meqdijDf.css +1 -1
  12. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/static/index.html +2 -2
  13. {fixtureqa-0.4.21 → fixtureqa-0.4.23/fixtureqa.egg-info}/PKG-INFO +1 -1
  14. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixtureqa.egg-info/SOURCES.txt +3 -2
  15. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/pyproject.toml +1 -1
  16. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_message_store.py +56 -0
  17. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_perf_engine.py +38 -0
  18. fixtureqa-0.4.23/tests/test_session_auto_response.py +42 -0
  19. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_sessions.py +50 -0
  20. fixtureqa-0.4.21/fixture/static/assets/index-BzzfsRz2.js +0 -102
  21. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/LICENSE +0 -0
  22. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/README.md +0 -0
  23. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/__init__.py +0 -0
  24. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/__main__.py +0 -0
  25. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/__init__.py +0 -0
  26. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/app.py +0 -0
  27. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/connection_manager.py +0 -0
  28. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/deps.py +0 -0
  29. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/__init__.py +0 -0
  30. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/admin.py +0 -0
  31. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/auth.py +0 -0
  32. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/branding.py +0 -0
  33. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/custom_tags.py +0 -0
  34. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/fix_spec.py +0 -0
  35. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/perf.py +0 -0
  36. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/scenarios.py +0 -0
  37. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/sessions.py +0 -0
  38. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/setup.py +0 -0
  39. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/spec_overlay.py +0 -0
  40. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/templates.py +0 -0
  41. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/ws.py +0 -0
  42. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/schemas.py +0 -0
  43. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/config/__init__.py +0 -0
  44. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/__init__.py +0 -0
  45. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/atomic_io.py +0 -0
  46. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/auth.py +0 -0
  47. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/config_store.py +0 -0
  48. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/custom_tag_store.py +0 -0
  49. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/db_migrations.py +0 -0
  50. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/events.py +0 -0
  51. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/fix_application.py +0 -0
  52. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/fix_builder.py +0 -0
  53. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/fix_parser.py +0 -0
  54. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/fix_spec_parser.py +0 -0
  55. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/fix_tags.py +0 -0
  56. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/fix_time.py +0 -0
  57. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/housekeeping.py +0 -0
  58. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/inbound.py +0 -0
  59. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/json_store.py +0 -0
  60. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/latency_csv_writer.py +0 -0
  61. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/message_log.py +0 -0
  62. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/perf_charts.py +0 -0
  63. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/perf_payload.py +0 -0
  64. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/perf_store.py +0 -0
  65. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/perf_writer.py +0 -0
  66. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/scenario_runner.py +0 -0
  67. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/scenario_store.py +0 -0
  68. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/spec_overlay_store.py +0 -0
  69. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/template_store.py +0 -0
  70. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/user_store.py +0 -0
  71. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/venue_responses.py +0 -0
  72. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/fix_specs/FIX42.xml +0 -0
  73. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/fix_specs/FIX44.xml +0 -0
  74. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/server.py +0 -0
  75. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  76. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  77. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  78. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/static/favicon.svg +0 -0
  79. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/ui/__init__.py +0 -0
  80. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixtureqa.egg-info/dependency_links.txt +0 -0
  81. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixtureqa.egg-info/entry_points.txt +0 -0
  82. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixtureqa.egg-info/requires.txt +0 -0
  83. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixtureqa.egg-info/top_level.txt +0 -0
  84. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/setup.cfg +0 -0
  85. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_atomic_io.py +0 -0
  86. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_auth.py +0 -0
  87. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_config_store.py +0 -0
  88. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_connection_manager.py +0 -0
  89. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_db_migrations.py +0 -0
  90. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_fix_builder.py +0 -0
  91. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_health.py +0 -0
  92. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_inbound.py +0 -0
  93. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_inbound_validation.py +0 -0
  94. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_perf_api.py +0 -0
  95. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_perf_models.py +0 -0
  96. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_perf_payload.py +0 -0
  97. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_perf_rehydrate.py +0 -0
  98. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_scenarios.py +0 -0
  99. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_session_lifecycle.py +0 -0
  100. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_session_manager_concurrency.py +0 -0
  101. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_templates.py +0 -0
  102. {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.4.21
3
+ Version: 0.4.23
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -4,6 +4,7 @@ from typing import Annotated, Optional
4
4
  from fastapi import APIRouter, Depends, HTTPException, Query
5
5
  from fastapi.responses import StreamingResponse
6
6
 
7
+ from ...core.message_store import MessageStore
7
8
  from ...core.models import SessionConfig
8
9
  from ...core.session_manager import SessionManager
9
10
  from ...core.user_store import User
@@ -16,6 +17,20 @@ SM = Annotated[SessionManager, Depends(get_session_manager)]
16
17
  OwnedSession = Annotated[SessionConfig, Depends(get_owned_session)]
17
18
 
18
19
 
20
+ def _to_response(e) -> MessageEntryResponse:
21
+ return MessageEntryResponse(
22
+ id=e.id,
23
+ direction=e.direction,
24
+ admin=e.admin,
25
+ seq_num=e.seq_num,
26
+ msg_type=e.msg_type,
27
+ msg_type_name=e.msg_type_name,
28
+ ts=e.timestamp.isoformat(),
29
+ fields={str(k): v for k, v in e.fields.items()},
30
+ raw=e.raw,
31
+ )
32
+
33
+
19
34
  @router.get("/{session_id}/messages", response_model=list[MessageEntryResponse])
20
35
  def get_messages(
21
36
  session_id: str,
@@ -44,20 +59,30 @@ def get_messages(
44
59
  before_id=before_id,
45
60
  )
46
61
 
47
- return [
48
- MessageEntryResponse(
49
- id=e.id,
50
- direction=e.direction,
51
- admin=e.admin,
52
- seq_num=e.seq_num,
53
- msg_type=e.msg_type,
54
- msg_type_name=e.msg_type_name,
55
- ts=e.timestamp.isoformat(),
56
- fields={str(k): v for k, v in e.fields.items()},
57
- raw=e.raw,
58
- )
59
- for e in entries
60
- ]
62
+ return [_to_response(e) for e in entries]
63
+
64
+
65
+ @router.get("/{session_id}/messages/search", response_model=list[MessageEntryResponse])
66
+ def search_messages(
67
+ session_id: str,
68
+ _cfg: OwnedSession,
69
+ sm: SM,
70
+ q: str = Query(..., min_length=1, description="substring or regex query over raw FIX"),
71
+ regex: bool = Query(default=False, description="treat q as a regular expression"),
72
+ limit: int = Query(default=1000, ge=1, le=5000),
73
+ ):
74
+ """Search the session's full messages.db history (not just the loaded page).
75
+ Matches against the raw FIX with SOH normalised to '|'. Returns the most
76
+ recent `limit` matches, oldest-first."""
77
+ writer = sm.message_writer
78
+ if writer is None:
79
+ return [] # message persistence disabled globally — nothing in messages.db
80
+ store = MessageStore(session_id, writer)
81
+ try:
82
+ entries = store.search_entries(q, regex=regex, limit=limit)
83
+ except ValueError as e:
84
+ raise HTTPException(status_code=400, detail=str(e))
85
+ return [_to_response(e) for e in entries]
61
86
 
62
87
 
63
88
  @router.get("/{session_id}/messages/export")
@@ -12,6 +12,7 @@ inserts so the I/O thread is never blocked by SQLite.
12
12
  import json
13
13
  import logging
14
14
  import queue
15
+ import re
15
16
  import sqlite3
16
17
  import threading
17
18
  from concurrent.futures import Future
@@ -78,6 +79,12 @@ def _entry_to_row(session_id: str, entry: LogEntry) -> tuple:
78
79
  )
79
80
 
80
81
 
82
+ def _like_escape(s: str) -> str:
83
+ """Escape LIKE wildcards so a substring query matches literally (paired
84
+ with ESCAPE '\\' in the SQL)."""
85
+ return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
86
+
87
+
81
88
  def _row_to_entry(row) -> LogEntry:
82
89
  row_id, _, ts, direction, admin, seq_num, mt, mt_name, raw, fields_json = row
83
90
  return LogEntry(
@@ -364,6 +371,56 @@ class MessageStore:
364
371
 
365
372
  return [_row_to_entry(r) for r in rows]
366
373
 
374
+ def search_entries(
375
+ self,
376
+ query: str,
377
+ *,
378
+ regex: bool = False,
379
+ limit: int = 1000,
380
+ ) -> list[LogEntry]:
381
+ """Search this session's persisted messages by raw content — the whole
382
+ messages.db history for the session, not just a loaded page.
383
+
384
+ The haystack is the raw FIX with SOH normalised to '|' (so a typed
385
+ 'tag=value' matches and the pattern lines up with the on-screen view).
386
+ text mode: case-insensitive substring via LIKE. regex mode: Python re
387
+ (case-insensitive) via a per-connection REGEXP function. Returns up to
388
+ `limit` most-recent matches, oldest-first. Raises ValueError on an
389
+ invalid regex (the route maps it to a 400)."""
390
+ query = query.strip()
391
+ if not query:
392
+ return []
393
+
394
+ hay = "replace(raw, char(1), '|')"
395
+ select = (
396
+ "SELECT id, session_id, ts, direction, admin, seq_num, "
397
+ "msg_type, msg_type_name, raw, fields_json FROM messages "
398
+ "WHERE session_id = ?"
399
+ )
400
+ params: list = [self._session_id]
401
+
402
+ with closing(_connect(self._db_path)) as conn:
403
+ if regex:
404
+ try:
405
+ rx = re.compile(query, re.IGNORECASE)
406
+ except re.error as e:
407
+ raise ValueError(f"invalid regex: {e}") from e
408
+ conn.create_function(
409
+ "re_match", 1,
410
+ lambda v: 1 if v is not None and rx.search(v) else 0,
411
+ deterministic=True,
412
+ )
413
+ cond = f"re_match({hay})"
414
+ else:
415
+ cond = f"{hay} LIKE ? ESCAPE '\\'"
416
+ params.append(f"%{_like_escape(query)}%")
417
+ sql = (f"SELECT * FROM ({select} AND {cond} ORDER BY id DESC LIMIT ?) "
418
+ "ORDER BY id ASC")
419
+ params.append(limit)
420
+ rows = conn.execute(sql, params).fetchall()
421
+
422
+ return [_row_to_entry(r) for r in rows]
423
+
367
424
  def __len__(self) -> int:
368
425
  with closing(_connect(self._db_path)) as conn:
369
426
  row = conn.execute(
@@ -108,3 +108,8 @@ class SessionState:
108
108
  message_log: Optional[MessageLog] = field(default=None) # set by Session on init
109
109
  status_changed_at: Optional[float] = None # time.time() of last status transition
110
110
  last_heartbeat_at: Optional[float] = None # time.time() of last inbound Heartbeat
111
+ # >0 while one or more perf runs drive this session: the run generates its
112
+ # own acks/fills, so venue auto-responses must stay silent to avoid
113
+ # duplicate/conflicting ExecReports corrupting the measurement. Counter (not
114
+ # bool) so overlapping runs nest. Runtime-only — never persisted.
115
+ perf_active: int = 0
@@ -232,8 +232,10 @@ class PerfRun:
232
232
  "inbound 35=F from the external client is still answered")
233
233
  if self._has_client:
234
234
  self._client_sub = self._sm.subscribe_session(self.config.client_session_id)
235
+ self._sm.mark_perf_active(self.config.client_session_id)
235
236
  if self._has_venue:
236
237
  self._venue_sub = self._sm.subscribe_session(self.config.venue_session_id)
238
+ self._sm.mark_perf_active(self.config.venue_session_id)
237
239
  self._writer.write_run(self._run_record())
238
240
  self._main = asyncio.create_task(self._run())
239
241
 
@@ -296,8 +298,10 @@ class PerfRun:
296
298
  self._venue_sends.clear()
297
299
  if self._client_sub:
298
300
  self._client_sub.close()
301
+ self._sm.unmark_perf_active(self.config.client_session_id)
299
302
  if self._venue_sub:
300
303
  self._venue_sub.close()
304
+ self._sm.unmark_perf_active(self.config.venue_session_id)
301
305
  if self._latency_csv is not None:
302
306
  # Correlator is cancelled (no more enqueues); drain + gzip-finalize
303
307
  # off-loop — close() joins the writer thread.
@@ -397,8 +401,19 @@ class PerfRun:
397
401
  self._finalize(entry, "dropped")
398
402
  return True
399
403
 
404
+ async def _send(self, session_id: str, msg: Message) -> bool:
405
+ """Single send path — tallies every outbound app message per leg
406
+ (type-agnostic) for the messages-per-second chart, then delegates."""
407
+ ok = await self._sm.send_message(session_id, msg)
408
+ if ok:
409
+ if session_id == self.config.client_session_id:
410
+ self.stats.msgs_sent_client += 1
411
+ else:
412
+ self.stats.msgs_sent_venue += 1
413
+ return ok
414
+
400
415
  async def _send_order(self, corr: str, msg: Message) -> bool:
401
- ok = await self._sm.send_message(self.config.client_session_id, msg)
416
+ ok = await self._send(self.config.client_session_id, msg)
402
417
  if ok:
403
418
  self.stats.orders_sent += 1
404
419
  else:
@@ -493,6 +508,8 @@ class PerfRun:
493
508
  sub = self._venue_sub
494
509
  while True:
495
510
  inb = await sub.get()
511
+ if not inb.admin:
512
+ self.stats.msgs_recv_venue += 1
496
513
  recv_ns = inb.recv_perf_ns or time.perf_counter_ns()
497
514
  if inb.msg_type == "G":
498
515
  await self._handle_amend(inb.fields, recv_ns)
@@ -517,7 +534,7 @@ class PerfRun:
517
534
  last_qty=0, cum_qty=0, leaves_qty=order_qty, px="0")
518
535
  if ack is not None:
519
536
  send_ns = time.perf_counter_ns()
520
- if await self._sm.send_message(cfg.venue_session_id, ack):
537
+ if await self._send(cfg.venue_session_id, ack):
521
538
  self.stats.acks_sent += 1
522
539
  self._register_venue_send(ack, "ack", corr, of.get(T_CLORDID), send_ns)
523
540
 
@@ -573,7 +590,7 @@ class PerfRun:
573
590
  cum_qty=cum_qty, leaves_qty=leaves_qty, px=entry["px"])
574
591
  if er is not None:
575
592
  send_ns = time.perf_counter_ns()
576
- if await self._sm.send_message(cfg.venue_session_id, er):
593
+ if await self._send(cfg.venue_session_id, er):
577
594
  self.stats.fills_sent += 1
578
595
  self._register_venue_send(er, "fill", corr,
579
596
  entry["of"].get(T_CLORDID), send_ns)
@@ -647,14 +664,14 @@ class PerfRun:
647
664
  orig_clordid=orig)
648
665
  if er is not None:
649
666
  send_ns = time.perf_counter_ns()
650
- if await self._sm.send_message(cfg.venue_session_id, er):
667
+ if await self._send(cfg.venue_session_id, er):
651
668
  self._register_venue_send(er, "pending-replace", corr, clordid, send_ns)
652
669
  er = self._build_exec(of, exec_type="5", ord_status="5", last_qty=0,
653
670
  cum_qty=cum, leaves_qty=leaves, px=px,
654
671
  orig_clordid=orig)
655
672
  if er is not None:
656
673
  send_ns = time.perf_counter_ns()
657
- if await self._sm.send_message(cfg.venue_session_id, er):
674
+ if await self._send(cfg.venue_session_id, er):
658
675
  self._register_venue_send(er, "replace", corr, clordid, send_ns)
659
676
 
660
677
  async def _handle_cancel(self, of: dict, recv_ns: int = 0) -> None:
@@ -691,12 +708,12 @@ class PerfRun:
691
708
  orig_clordid=of.get(T_ORIGCLORDID))
692
709
  if er is not None:
693
710
  send_ns = time.perf_counter_ns()
694
- if await self._sm.send_message(cfg.venue_session_id, er):
711
+ if await self._send(cfg.venue_session_id, er):
695
712
  self._register_venue_send(er, "canceled", corr, clordid, send_ns)
696
713
  return
697
714
  msg = self._build_cancel_reject(of, entry)
698
715
  send_ns = time.perf_counter_ns()
699
- if await self._sm.send_message(cfg.venue_session_id, msg):
716
+ if await self._send(cfg.venue_session_id, msg):
700
717
  self._register_venue_send(msg, "cancel-reject", corr, clordid, send_ns)
701
718
 
702
719
  def _build_cancel_reject(self, of: dict, entry: Optional[dict]) -> Message:
@@ -811,6 +828,8 @@ class PerfRun:
811
828
  sub = self._client_sub
812
829
  while True:
813
830
  inb = await sub.get()
831
+ if not inb.admin:
832
+ self.stats.msgs_recv_client += 1
814
833
  if inb.msg_type == "9":
815
834
  self._on_cancel_reject(inb.fields, inb.recv_perf_ns or time.perf_counter_ns())
816
835
  continue
@@ -998,7 +1017,7 @@ class PerfRun:
998
1017
  entry["cancel_clordid"] = new_clordid
999
1018
  entry["cancel_sent_ns"] = time.perf_counter_ns()
1000
1019
  entry["cancel_sent_at"] = time.time()
1001
- if await self._sm.send_message(self.config.client_session_id, msg):
1020
+ if await self._send(self.config.client_session_id, msg):
1002
1021
  self.stats.cancels_sent += 1
1003
1022
  else:
1004
1023
  entry["cancel_inflight"] = False
@@ -1129,7 +1148,7 @@ class PerfRun:
1129
1148
  entry["amends_sent"] = n + 1
1130
1149
  entry["amend_sent_ns"] = time.perf_counter_ns()
1131
1150
  entry["amend_sent_at"] = time.time()
1132
- if await self._sm.send_message(self.config.client_session_id, msg):
1151
+ if await self._send(self.config.client_session_id, msg):
1133
1152
  self.stats.amends_sent += 1
1134
1153
  else:
1135
1154
  entry["amend_inflight"] = False
@@ -1219,6 +1238,8 @@ class PerfRun:
1219
1238
  cps_live=round(r.cps, 1),
1220
1239
  crps_live=round(r.crps, 1),
1221
1240
  ccps_live=round(r.ccps, 1),
1241
+ msgs_out_live=round(r.mco, 1),
1242
+ msgs_in_live=round(r.mci, 1),
1222
1243
  scenarios_dispatched=self.stats.scenarios_dispatched,
1223
1244
  scenarios_per_sec=round(r.sps, 1),
1224
1245
  scenarios_completed=self.stats.scenarios_completed,
@@ -1235,7 +1256,9 @@ class PerfRun:
1235
1256
  amends_uncorrelated=self.stats.amends_uncorrelated,
1236
1257
  amend_waits_expired=self.stats.amend_waits_expired,
1237
1258
  cancels_received=self.stats.cancels_received,
1238
- cancels_uncorrelated=self.stats.cancels_uncorrelated)
1259
+ cancels_uncorrelated=self.stats.cancels_uncorrelated,
1260
+ msgs_out_live=round(r.mvo, 1),
1261
+ msgs_in_live=round(r.mvi, 1))
1239
1262
  snap = LiveSnapshot(
1240
1263
  run_id=self.run_id, status=self.status, mode=self.config.mode,
1241
1264
  elapsed_s=round(self._elapsed_s(), 1), duration_s=self.config.test.duration,
@@ -252,6 +252,8 @@ class ClientLeg(BaseModel):
252
252
  cps_live: float = 0.0
253
253
  crps_live: float = 0.0
254
254
  ccps_live: float = 0.0
255
+ msgs_out_live: float = 0.0 # all app messages this leg sent/s (MPS chart)
256
+ msgs_in_live: float = 0.0 # all app messages this leg received/s
255
257
  scenarios_dispatched: int = 0
256
258
  scenarios_per_sec: float = 0.0
257
259
  scenarios_completed: int = 0
@@ -271,6 +273,8 @@ class VenueLeg(BaseModel):
271
273
  amend_waits_expired: int = 0
272
274
  cancels_received: int = 0
273
275
  cancels_uncorrelated: int = 0
276
+ msgs_out_live: float = 0.0 # all app messages this leg sent/s (MPS chart)
277
+ msgs_in_live: float = 0.0 # all app messages this leg received/s
274
278
 
275
279
 
276
280
  class SnapshotErrors(BaseModel):
@@ -25,6 +25,12 @@ class Rates(NamedTuple):
25
25
  cps: float # cancels sent/s (35=F)
26
26
  crps: float # cancel-rejects received/s (35=9)
27
27
  ccps: float # canceled received/s (150=4)
28
+ # Type-agnostic message rates (every app message sent/received per leg) for
29
+ # the messages-per-second chart — counts all kinds, so it can't drift.
30
+ mco: float # client leg messages sent/s (orders + amends + cancels …)
31
+ mci: float # client leg messages received/s (acks + fills + replies …)
32
+ mvo: float # venue leg messages sent/s (acks + fills + replies …)
33
+ mvi: float # venue leg messages received/s (orders + amends + cancels …)
28
34
 
29
35
 
30
36
  class TokenBucket:
@@ -147,6 +153,11 @@ class PerfStats:
147
153
  self.cancels_received = 0 # venue saw an inbound 35=F
148
154
  self.cancels_uncorrelated = 0 # inbound 35=F lacked the correlation tag (no reply)
149
155
  self.venue_sends_unmatched = 0 # one-way send records evicted w/o a matching recv
156
+ # Type-agnostic per-leg message tallies (every app message), for the MPS chart.
157
+ self.msgs_sent_client = 0
158
+ self.msgs_recv_client = 0
159
+ self.msgs_sent_venue = 0
160
+ self.msgs_recv_venue = 0
150
161
  self.lost_timeout = 0
151
162
  self.dropped = 0
152
163
  self.rejected = 0
@@ -170,6 +181,10 @@ class PerfStats:
170
181
  self._last_cancels = 0
171
182
  self._last_cancel_rejects = 0
172
183
  self._last_canceled = 0
184
+ self._last_msc = 0
185
+ self._last_mrc = 0
186
+ self._last_msv = 0
187
+ self._last_mrv = 0
173
188
 
174
189
  def rates(self) -> Rates:
175
190
  """Per-second deltas since the last call. Stateful — call exactly once
@@ -177,7 +192,7 @@ class PerfStats:
177
192
  now = time.monotonic()
178
193
  dt = now - self._last_t
179
194
  if dt <= 0:
180
- return Rates(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
195
+ return Rates(*([0.0] * 11))
181
196
  r = Rates(
182
197
  ops=(self.orders_sent - self._last_orders) / dt,
183
198
  fps=(self.fills_sent - self._last_fills) / dt,
@@ -186,6 +201,10 @@ class PerfStats:
186
201
  cps=(self.cancels_sent - self._last_cancels) / dt,
187
202
  crps=(self.cancel_rejects - self._last_cancel_rejects) / dt,
188
203
  ccps=(self.cancels_canceled - self._last_canceled) / dt,
204
+ mco=(self.msgs_sent_client - self._last_msc) / dt,
205
+ mci=(self.msgs_recv_client - self._last_mrc) / dt,
206
+ mvo=(self.msgs_sent_venue - self._last_msv) / dt,
207
+ mvi=(self.msgs_recv_venue - self._last_mrv) / dt,
189
208
  )
190
209
  self._last_t = now
191
210
  self._last_orders = self.orders_sent
@@ -195,6 +214,10 @@ class PerfStats:
195
214
  self._last_cancels = self.cancels_sent
196
215
  self._last_cancel_rejects = self.cancel_rejects
197
216
  self._last_canceled = self.cancels_canceled
217
+ self._last_msc = self.msgs_sent_client
218
+ self._last_mrc = self.msgs_recv_client
219
+ self._last_msv = self.msgs_sent_venue
220
+ self._last_mrv = self.msgs_recv_venue
198
221
  return r
199
222
 
200
223
  def fill_ratio(self) -> float:
@@ -286,8 +286,12 @@ class Session:
286
286
  self.state.last_recv_seq_num = entry.seq_num
287
287
  if entry.msg_type == "0": # Heartbeat
288
288
  self.state.last_heartbeat_at = time.time()
289
+ # Stay silent while a perf run drives this session — the run
290
+ # generates its own acks/fills, so an auto-response here would
291
+ # double up and corrupt the measurement.
289
292
  if (entry.msg_type == "D" and not entry.admin
290
- and self.config.session_role.value == "venue"):
293
+ and self.config.session_role.value == "venue"
294
+ and self.state.perf_active == 0):
291
295
  self._schedule_venue_responses(entry.fields)
292
296
 
293
297
  self._event_handler(event)
@@ -223,6 +223,23 @@ class SessionManager:
223
223
  if not subs:
224
224
  self._session_subs.pop(sub.session_id, None)
225
225
 
226
+ def mark_perf_active(self, session_id: str) -> None:
227
+ """A perf run started driving this session — suppress its venue
228
+ auto-responses (the run owns the acks/fills) until unmarked. Counter,
229
+ so overlapping runs nest. Mirror with unmark_perf_active()."""
230
+ with self._lock:
231
+ session = self._sessions.get(session_id)
232
+ if session is not None:
233
+ session.state.perf_active += 1
234
+
235
+ def unmark_perf_active(self, session_id: str) -> None:
236
+ """A perf run stopped driving this session. Tolerates a session removed
237
+ mid-run (lookup may miss) and never underflows below zero."""
238
+ with self._lock:
239
+ session = self._sessions.get(session_id)
240
+ if session is not None:
241
+ session.state.perf_active = max(0, session.state.perf_active - 1)
242
+
226
243
  # ------------------------------------------------------------------
227
244
  # Internal
228
245
  # ------------------------------------------------------------------