fixtureqa 0.4.17__tar.gz → 0.4.18__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 (101) hide show
  1. {fixtureqa-0.4.17/fixtureqa.egg-info → fixtureqa-0.4.18}/PKG-INFO +2 -1
  2. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/routers/perf.py +34 -0
  3. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/exec_csv_writer.py +18 -10
  4. fixtureqa-0.4.18/fixture/core/perf_charts.py +170 -0
  5. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/perf_engine.py +168 -31
  6. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/perf_stats.py +1 -0
  7. fixtureqa-0.4.18/fixture/static/assets/index-BSuHxk6b.js +102 -0
  8. fixtureqa-0.4.17/fixture/static/assets/index-BwQf-cei.css → fixtureqa-0.4.18/fixture/static/assets/index-o_l1KBYd.css +1 -1
  9. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/static/index.html +2 -2
  10. {fixtureqa-0.4.17 → fixtureqa-0.4.18/fixtureqa.egg-info}/PKG-INFO +2 -1
  11. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixtureqa.egg-info/SOURCES.txt +3 -2
  12. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixtureqa.egg-info/requires.txt +1 -0
  13. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/pyproject.toml +2 -1
  14. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_perf_api.py +37 -0
  15. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_perf_engine.py +122 -12
  16. fixtureqa-0.4.17/fixture/static/assets/index-BM8-U5CW.js +0 -102
  17. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/LICENSE +0 -0
  18. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/README.md +0 -0
  19. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/__init__.py +0 -0
  20. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/__main__.py +0 -0
  21. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/__init__.py +0 -0
  22. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/app.py +0 -0
  23. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/connection_manager.py +0 -0
  24. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/deps.py +0 -0
  25. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/routers/__init__.py +0 -0
  26. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/routers/admin.py +0 -0
  27. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/routers/auth.py +0 -0
  28. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/routers/branding.py +0 -0
  29. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/routers/custom_tags.py +0 -0
  30. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/routers/fix_spec.py +0 -0
  31. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/routers/messages.py +0 -0
  32. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/routers/scenarios.py +0 -0
  33. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/routers/sessions.py +0 -0
  34. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/routers/setup.py +0 -0
  35. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/routers/spec_overlay.py +0 -0
  36. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/routers/templates.py +0 -0
  37. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/routers/ws.py +0 -0
  38. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/api/schemas.py +0 -0
  39. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/config/__init__.py +0 -0
  40. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/__init__.py +0 -0
  41. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/atomic_io.py +0 -0
  42. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/auth.py +0 -0
  43. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/config_store.py +0 -0
  44. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/custom_tag_store.py +0 -0
  45. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/db_migrations.py +0 -0
  46. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/events.py +0 -0
  47. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/fix_application.py +0 -0
  48. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/fix_builder.py +0 -0
  49. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/fix_parser.py +0 -0
  50. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/fix_spec_parser.py +0 -0
  51. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/fix_tags.py +0 -0
  52. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/fix_time.py +0 -0
  53. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/housekeeping.py +0 -0
  54. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/inbound.py +0 -0
  55. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/json_store.py +0 -0
  56. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/message_log.py +0 -0
  57. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/message_store.py +0 -0
  58. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/models.py +0 -0
  59. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/perf_models.py +0 -0
  60. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/perf_payload.py +0 -0
  61. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/perf_store.py +0 -0
  62. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/perf_writer.py +0 -0
  63. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/scenario_runner.py +0 -0
  64. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/scenario_store.py +0 -0
  65. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/session.py +0 -0
  66. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/session_manager.py +0 -0
  67. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/spec_overlay_store.py +0 -0
  68. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/template_store.py +0 -0
  69. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/user_store.py +0 -0
  70. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/core/venue_responses.py +0 -0
  71. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/fix_specs/FIX42.xml +0 -0
  72. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/fix_specs/FIX44.xml +0 -0
  73. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/server.py +0 -0
  74. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  75. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  76. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  77. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/static/favicon.svg +0 -0
  78. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixture/ui/__init__.py +0 -0
  79. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixtureqa.egg-info/dependency_links.txt +0 -0
  80. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixtureqa.egg-info/entry_points.txt +0 -0
  81. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/fixtureqa.egg-info/top_level.txt +0 -0
  82. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/setup.cfg +0 -0
  83. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_atomic_io.py +0 -0
  84. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_auth.py +0 -0
  85. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_config_store.py +0 -0
  86. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_connection_manager.py +0 -0
  87. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_db_migrations.py +0 -0
  88. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_fix_builder.py +0 -0
  89. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_health.py +0 -0
  90. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_inbound.py +0 -0
  91. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_inbound_validation.py +0 -0
  92. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_message_store.py +0 -0
  93. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_perf_models.py +0 -0
  94. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_perf_payload.py +0 -0
  95. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_perf_rehydrate.py +0 -0
  96. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_scenarios.py +0 -0
  97. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_session_lifecycle.py +0 -0
  98. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_session_manager_concurrency.py +0 -0
  99. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_sessions.py +0 -0
  100. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_templates.py +0 -0
  101. {fixtureqa-0.4.17 → fixtureqa-0.4.18}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.4.17
3
+ Version: 0.4.18
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -10,6 +10,7 @@ Requires-Dist: uvicorn[standard]>=0.29.0
10
10
  Requires-Dist: websockets>=12.0
11
11
  Requires-Dist: python-jose[cryptography]>=3.3.0
12
12
  Requires-Dist: passlib[bcrypt]>=1.7.4
13
+ Requires-Dist: matplotlib>=3.7
13
14
  Provides-Extra: dev
14
15
  Requires-Dist: pytest>=8.0; extra == "dev"
15
16
  Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
@@ -5,9 +5,11 @@ Stage 1a: run lifecycle (start/list/get/stop) + live snapshot WS. The WS uses a
5
5
  short-TTL single-use ticket (JWT never in the URL); the rest of the contract
6
6
  (messages/scenarios/export/configs) lands in later stages.
7
7
  """
8
+ import asyncio
8
9
  import csv
9
10
  import io
10
11
  import logging
12
+ import os
11
13
  import zipfile
12
14
  from typing import Annotated, Optional
13
15
 
@@ -16,6 +18,7 @@ from fastapi.responses import FileResponse, Response, StreamingResponse
16
18
 
17
19
  from ...core.models import SessionStatus
18
20
  from ...core.perf_models import RunConfig, RunStatus
21
+ from ...core.perf_charts import render_latency_chart
19
22
  from ...core.perf_engine import RunRegistry
20
23
  from ...core.perf_payload import PerfConfigError
21
24
  from ...core.perf_store import PerfStore
@@ -207,6 +210,37 @@ def run_export(run_id: str, reg: REG, pw: PW, _admin: Admin,
207
210
  raise HTTPException(status_code=422, detail="kind must be messages|scenarios|execs|both")
208
211
 
209
212
 
213
+ @router.get("/runs/{run_id}/chart")
214
+ async def run_chart(run_id: str, reg: REG, _admin: Admin,
215
+ metric: str = Query("one_way"), agg: str = Query("both")):
216
+ """Per-message latency chart (PNG) from the run's recorded csv.gz. Rendered
217
+ off-loop on first request and cached per (metric, agg) — the CSV is immutable
218
+ once the run finishes. Needs record_execs; 404 otherwise, 409 while live."""
219
+ if metric not in ("one_way", "round_trip", "both") or agg not in ("scatter", "percentile", "both"):
220
+ raise HTTPException(status_code=422,
221
+ detail="metric must be one_way|round_trip|both; agg must be scatter|percentile|both")
222
+ if not reg.exists(run_id):
223
+ raise HTTPException(status_code=404, detail="Run not found")
224
+ if _run_is_live(reg, run_id):
225
+ raise HTTPException(status_code=409,
226
+ detail="Run still active — the latency chart is rendered when the run finishes")
227
+ csv_path = reg.exec_csv_path(run_id)
228
+ if csv_path is None or not os.path.exists(csv_path):
229
+ raise HTTPException(status_code=404,
230
+ detail="No per-message recording for this run (enable record_execs before starting)")
231
+ out_path = f"{csv_path[:-len('.csv.gz')]}_{metric}_{agg}.png"
232
+ if not os.path.exists(out_path):
233
+ try:
234
+ await asyncio.to_thread(
235
+ render_latency_chart, csv_path, out_path,
236
+ metric=metric, agg=agg, title=f"Per-message latency — {run_id}")
237
+ except ValueError as e:
238
+ # No chartable points (e.g. recording produced only un-latencied rows).
239
+ raise HTTPException(status_code=404, detail=str(e))
240
+ return FileResponse(out_path, media_type="image/png",
241
+ filename=f"{run_id}_latency.png")
242
+
243
+
210
244
  # --- saved configs --------------------------------------------------------
211
245
 
212
246
  @router.get("/configs")
@@ -1,6 +1,7 @@
1
1
  """
2
- Per-run gzipped CSV recorder for individual ExecutionReports (opt-in via
3
- test.record_execs).
2
+ Per-run gzipped CSV recorder for individual inbound correlated messages —
3
+ every ExecutionReport (ack, fill, amend/cancel reply) plus OrderCancelReject
4
+ (35=9), opt-in via test.record_execs.
4
5
 
5
6
  CSV-direct: there is no database staging — rows stream straight to
6
7
  data/perf_execs/<run_id>.csv.gz and export serves the finished file as-is.
@@ -10,6 +11,7 @@ gzip/disk work happens on one daemon writer thread, so recording cannot add
10
11
  latency to the measured inbound path.
11
12
 
12
13
  One instance per recording run; it owns its file exclusively for its lifetime.
14
+ Drives scripts/perf_latency_chart.py (per-`kind` latency plots).
13
15
  """
14
16
  from __future__ import annotations
15
17
 
@@ -26,14 +28,20 @@ DEFAULT_MAX_QUEUE = 200_000
26
28
 
27
29
  EXEC_COLS = (
28
30
  "run_id", "corr_id", "clordid", "exec_id",
29
- "exec_type", "ord_status", # tags 150 / 39
30
- "last_qty", "last_px", "cum_qty", "leaves_qty", # tags 32 / 31 / 14 / 151
31
- "exec_seq", # 1-based report index within the order
32
- "sent_at", # wall clock of the order send (same for all the order's rows)
33
- "recv_at", # wall clock of this report's receive
34
- "latency_us", # client receive order send (one monotonic clock — authoritative;
35
- # recv_at sent_at is wall clock, for cross-referencing only)
36
- "gap_us", # since the previous report for this order; empty on the first
31
+ "kind", # message kind / chart series key (engine-assigned):
32
+ # client→venue: order / amend / cancel
33
+ # venue→client: ack / fill / pending-replace / replace /
34
+ # pending-cancel / canceled / cancel-reject / amend-reject
35
+ "direction", # c2v (client→hub→venue) | v2c (venue→hub→client) | rt
36
+ # (rt = round-trip-only fallback, client/venue-only modes)
37
+ "send_at", # wall clock of THIS message's send on the near leg (one-way x-axis)
38
+ "recv_at", # wall clock of THIS message's receive on the far leg
39
+ "req_send_at", # wall clock of the originating request's send (order/amend/cancel
40
+ # send; == send_at for c2v rows) — the round-trip x-axis
41
+ "one_way_us", # recv_ns − send_ns: this message's transit through the hub, one
42
+ # monotonic clock (empty in client/venue-only modes — no far leg)
43
+ "round_trip_us", # v2c only: recv_ns − the originating request's send_ns; empty for c2v
44
+ "exec_type", "ord_status", "cum_qty", "leaves_qty", # tags 150 / 39 / 14 / 151 (forensics)
37
45
  )
38
46
 
39
47
 
@@ -0,0 +1,170 @@
1
+ """
2
+ Post-run per-message latency charts from a recorded run's csv.gz
3
+ (test.record_execs). One plot line/series per message `kind` (ack / fill /
4
+ replace / canceled / cancel-reject / …): x = order send time (run progression),
5
+ y = per-message latency.
6
+
7
+ Single implementation, two callers: scripts/perf_latency_chart.py (offline CLI)
8
+ and the FastAPI /perf/runs/{id}/chart endpoint (rendered off-loop and cached).
9
+
10
+ matplotlib is imported lazily inside render_latency_chart so importing this
11
+ module (and starting the server) never pays the matplotlib import cost; the
12
+ render uses the Agg backend via the object-oriented Figure API, which is
13
+ safe to run on a worker thread (no pyplot global state, no GUI backend).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import csv
18
+ import gzip
19
+ from collections import defaultdict
20
+ from typing import Optional
21
+
22
+ # Stable colour per kind so charts are comparable across runs (mirrors the live
23
+ # dashboard hues). Unknown kinds fall back to the mpl cycle.
24
+ _KIND_COLOR = {
25
+ "order": "#38bdf8", # client→venue
26
+ "ack": "#00b4c8",
27
+ "fill": "#a78bfa",
28
+ "amend": "#f59e0b", # client→venue
29
+ "pending-replace": "#c084fc",
30
+ "replace": "#7c3aed",
31
+ "cancel": "#fb7185", # client→venue
32
+ "pending-cancel": "#fb923c",
33
+ "canceled": "#2dd4bf",
34
+ "cancel-reject": "#f43f5e",
35
+ "amend-reject": "#ef4444",
36
+ }
37
+ # Drawing order: order lifecycle, then amend, then cancel (request before reply).
38
+ _KIND_ORDER = ["order", "ack", "fill", "amend", "pending-replace", "replace",
39
+ "cancel", "pending-cancel", "canceled", "cancel-reject", "amend-reject"]
40
+
41
+ # Per metric: (latency column, x-axis column, axis label).
42
+ _METRIC = {
43
+ "one_way": ("one_way_us", "send_at", "one-way hub transit"),
44
+ "round_trip": ("round_trip_us", "req_send_at", "round-trip"),
45
+ }
46
+
47
+
48
+ def _pctl(sorted_vals: list[float], q: float) -> float:
49
+ """Nearest-rank percentile of an already-sorted list (q in 0..1)."""
50
+ if not sorted_vals:
51
+ return 0.0
52
+ i = min(len(sorted_vals) - 1, max(0, int(round(q * (len(sorted_vals) - 1)))))
53
+ return sorted_vals[i]
54
+
55
+
56
+ def _read_points(csv_gz_path: str, metric: str):
57
+ """Group (x_seconds_since_start, y_ms) points by kind from the gz CSV for one
58
+ metric ('one_way' or 'round_trip'): y = that latency column, x = its send
59
+ column rebased to seconds since the first send."""
60
+ y_col, x_col, _ = _METRIC[metric]
61
+ by_kind: dict[str, list[tuple[float, float]]] = defaultdict(list)
62
+ t0: Optional[float] = None
63
+ with gzip.open(csv_gz_path, "rt", newline="") as fh:
64
+ for r in csv.DictReader(fh):
65
+ yraw = r.get(y_col) or ""
66
+ xraw = r.get(x_col) or ""
67
+ if yraw == "" or xraw == "":
68
+ continue
69
+ try:
70
+ x = float(xraw)
71
+ y_ms = float(yraw) / 1000.0
72
+ except ValueError:
73
+ continue
74
+ t0 = x if t0 is None else min(t0, x)
75
+ by_kind[r.get("kind") or "unknown"].append((x, y_ms))
76
+ if t0 is not None:
77
+ for pts in by_kind.values():
78
+ pts[:] = [(x - t0, y) for (x, y) in pts]
79
+ return by_kind
80
+
81
+
82
+ def _ordered_kinds(by_kind) -> list[str]:
83
+ known = [k for k in _KIND_ORDER if k in by_kind]
84
+ extra = sorted(k for k in by_kind if k not in _KIND_COLOR)
85
+ return known + extra
86
+
87
+
88
+ def _draw(ax, by_kind, agg: str, bucket_s: float, logy: bool, ylabel: str, title: str):
89
+ for kind in _ordered_kinds(by_kind):
90
+ pts = sorted(by_kind[kind])
91
+ if not pts:
92
+ continue
93
+ color = _KIND_COLOR.get(kind)
94
+ xs = [p[0] for p in pts]
95
+ ys = [p[1] for p in pts]
96
+ if agg in ("scatter", "both"):
97
+ ax.scatter(xs, ys, s=6, alpha=0.25 if agg == "both" else 0.5,
98
+ color=color, label=None if agg == "both" else kind,
99
+ edgecolors="none")
100
+ if agg in ("percentile", "both"):
101
+ bx, p50, p99 = _bucketed(pts, bucket_s)
102
+ if bx:
103
+ ax.plot(bx, p50, color=color, lw=1.6, label=f"{kind} p50")
104
+ ax.plot(bx, p99, color=color, lw=1.0, ls="--", alpha=0.7,
105
+ label=f"{kind} p99")
106
+ ax.set_xlabel("run time (s, from first send)")
107
+ ax.set_ylabel(ylabel + (" [log]" if logy else ""))
108
+ if logy:
109
+ ax.set_yscale("log")
110
+ ax.grid(True, which="both", ls=":", alpha=0.4)
111
+ ax.legend(loc="upper left", fontsize=8, ncol=2, framealpha=0.85)
112
+ ax.set_title(title)
113
+
114
+
115
+ def render_latency_chart(csv_gz_path: str, out_path: str, *, metric: str = "one_way",
116
+ agg: str = "both", bucket_s: float = 2.0,
117
+ logy: bool = True, kinds: Optional[list[str]] = None,
118
+ title: Optional[str] = None) -> str:
119
+ """Render a per-kind latency chart from a recorded run CSV to out_path (PNG).
120
+
121
+ metric: 'one_way' (recv − send, this message's hub transit; x = its own send
122
+ time) | 'round_trip' (recv − the originating request's send; x = request send)
123
+ | 'both' (two stacked panels, one-way above round-trip).
124
+ agg: 'scatter' = one point per message; 'percentile' = per-kind p50 & p99 over
125
+ `bucket_s`-second buckets (exposes backlog); 'both' overlays them.
126
+
127
+ Blocking + CPU-bound (matplotlib) — call off the event loop. Returns out_path.
128
+ Raises ValueError if the CSV has no usable points for the metric.
129
+ """
130
+ import matplotlib
131
+ matplotlib.use("Agg")
132
+ from matplotlib.figure import Figure
133
+
134
+ metrics = ["one_way", "round_trip"] if metric == "both" else [metric]
135
+ panels = []
136
+ for m in metrics:
137
+ by_kind = _read_points(csv_gz_path, m)
138
+ if kinds:
139
+ by_kind = {k: v for k, v in by_kind.items() if k in set(kinds)}
140
+ if any(by_kind.values()):
141
+ panels.append((m, by_kind))
142
+ if not panels:
143
+ raise ValueError(f"no chartable {metric!r} latency points in {csv_gz_path}")
144
+
145
+ fig = Figure(figsize=(12, 6.5 * len(panels)), dpi=120)
146
+ axes = fig.subplots(len(panels), 1, squeeze=False)[:, 0]
147
+ base = title or "Per-message latency"
148
+ for ax, (m, by_kind) in zip(axes, panels):
149
+ label = _METRIC[m][2]
150
+ _draw(ax, by_kind, agg, bucket_s, logy, f"latency (ms) — {label}",
151
+ base if len(panels) == 1 else f"{base} — {label}")
152
+ fig.tight_layout()
153
+ fig.savefig(out_path)
154
+ return out_path
155
+
156
+
157
+ def _bucketed(sorted_pts: list[tuple[float, float]], bucket_s: float):
158
+ """Per-bucket (center_x, p50, p99) over fixed-width time buckets."""
159
+ if bucket_s <= 0:
160
+ bucket_s = 1.0
161
+ buckets: dict[int, list[float]] = defaultdict(list)
162
+ for x, y in sorted_pts:
163
+ buckets[int(x // bucket_s)].append(y)
164
+ bx, p50, p99 = [], [], []
165
+ for b in sorted(buckets):
166
+ ys = sorted(buckets[b])
167
+ bx.append((b + 0.5) * bucket_s)
168
+ p50.append(_pctl(ys, 0.50))
169
+ p99.append(_pctl(ys, 0.99))
170
+ return bx, p50, p99
@@ -48,8 +48,24 @@ T_ORDERQTY, T_ORDTYPE, T_PRICE, T_TIF, T_TRANSACTTIME = 38, 40, 44, 59, 60
48
48
  T_ORDERID, T_EXECID, T_EXECTRANSTYPE, T_EXECTYPE, T_ORDSTATUS = 37, 17, 20, 150, 39
49
49
  T_LASTQTY, T_LASTPX, T_CUMQTY, T_LEAVESQTY, T_AVGPX = 32, 31, 14, 151, 6
50
50
 
51
+ # ExecType(150) → per-message latency-chart series label (see ExecCsvWriter).
52
+ _EXEC_KIND = {
53
+ "0": "ack", "1": "fill", "2": "fill", "F": "fill",
54
+ "E": "pending-replace", "5": "replace",
55
+ "6": "pending-cancel", "4": "canceled",
56
+ }
57
+
58
+ # venue→client kind → the originating client request whose send time gives the
59
+ # round-trip baseline (the wall-clock key is the same with "_ns"→"_at").
60
+ _KIND_REQ_NS = {
61
+ "ack": "sent_ns", "fill": "sent_ns",
62
+ "pending-replace": "amend_sent_ns", "replace": "amend_sent_ns", "amend-reject": "amend_sent_ns",
63
+ "pending-cancel": "cancel_sent_ns", "canceled": "cancel_sent_ns", "cancel-reject": "cancel_sent_ns",
64
+ }
65
+
51
66
  _GRACE_DRAIN_S = 0.5
52
67
  _SNAPSHOT_INTERVAL_S = 0.5
68
+ _VENUE_SENDS_CAP = 500_000 # hard cap on the one-way send registry (drop-oldest)
53
69
 
54
70
 
55
71
  def _utc_ms() -> str:
@@ -103,10 +119,19 @@ class PerfRun:
103
119
 
104
120
  self._has_client = config.mode in ("loopback", "manager", "client")
105
121
  self._has_venue = config.mode in ("loopback", "manager", "venue")
122
+ # One-way per-message latency (recv at far leg − send at near leg) needs
123
+ # both legs in this process on one clock — i.e. loopback/manager. In
124
+ # client/venue mode we only see one leg, so the recorder falls back to
125
+ # round-trip (see _emit_crossing / _record_rt).
126
+ self._one_way = self._has_client and self._has_venue
106
127
 
107
128
  self.stats = PerfStats()
108
129
  self._pending: dict[str, dict] = {}
109
130
  self._order_seq = 0
131
+ # venue→client send-time registry for one-way latency: exec_id → send
132
+ # record, populated by the venue send sites and popped by the client
133
+ # correlator. Bounded by pop-on-match + age/cap sweep in _timeout_sweeper.
134
+ self._venue_sends: dict[str, dict] = {}
110
135
 
111
136
  # Amend injection is fill-triggered on the client leg (35=D orders only);
112
137
  # cancel injection is ack- or fill-triggered. Both park their send tasks
@@ -237,6 +262,7 @@ class PerfRun:
237
262
  self._pending.clear()
238
263
  self._venue_orders.clear()
239
264
  self._fill_runnable.clear()
265
+ self._venue_sends.clear()
240
266
  if self._client_sub:
241
267
  self._client_sub.close()
242
268
  if self._venue_sub:
@@ -436,11 +462,12 @@ class PerfRun:
436
462
  sub = self._venue_sub
437
463
  while True:
438
464
  inb = await sub.get()
465
+ recv_ns = inb.recv_perf_ns or time.perf_counter_ns()
439
466
  if inb.msg_type == "G":
440
- await self._handle_amend(inb.fields)
467
+ await self._handle_amend(inb.fields, recv_ns)
441
468
  continue
442
469
  if inb.msg_type == "F":
443
- await self._handle_cancel(inb.fields)
470
+ await self._handle_cancel(inb.fields, recv_ns)
444
471
  continue
445
472
  if inb.msg_type != "D":
446
473
  continue
@@ -450,13 +477,18 @@ class PerfRun:
450
477
  corr = of.get(cfg.correlation_tag)
451
478
  if corr is None or order_qty <= 0:
452
479
  continue
480
+ # Order crossing client→hub→venue: one-way latency vs the client send.
481
+ self._record_c2v(of, corr, recv_ns, "order")
453
482
 
454
483
  # 1. Ack the order first (New) — immediate, not rate-limited.
455
484
  if f.send_ack:
456
485
  ack = self._exec(of, exec_type="0", ord_status="0",
457
486
  last_qty=0, cum_qty=0, leaves_qty=order_qty, px="0")
458
- if ack is not None and await self._sm.send_message(cfg.venue_session_id, ack):
459
- self.stats.acks_sent += 1
487
+ if ack is not None:
488
+ send_ns = time.perf_counter_ns()
489
+ if await self._sm.send_message(cfg.venue_session_id, ack):
490
+ self.stats.acks_sent += 1
491
+ self._register_venue_send(ack, "ack", corr, of.get(T_CLORDID), send_ns)
460
492
 
461
493
  # 2. Book the fills — gated by fill_ratio (a skipped order stays
462
494
  # acked/open and is never booked).
@@ -508,8 +540,12 @@ class PerfRun:
508
540
  et, ost, last_qty, cum_qty, leaves_qty = entry["schedule"][entry["idx"]]
509
541
  er = self._exec(entry["of"], exec_type=et, ord_status=ost, last_qty=last_qty,
510
542
  cum_qty=cum_qty, leaves_qty=leaves_qty, px=entry["px"])
511
- if er is not None and await self._sm.send_message(cfg.venue_session_id, er):
512
- self.stats.fills_sent += 1
543
+ if er is not None:
544
+ send_ns = time.perf_counter_ns()
545
+ if await self._sm.send_message(cfg.venue_session_id, er):
546
+ self.stats.fills_sent += 1
547
+ self._register_venue_send(er, "fill", corr,
548
+ entry["of"].get(T_CLORDID), send_ns)
513
549
  entry["idx"] += 1
514
550
  entry["cum"] = cum_qty
515
551
  if entry["idx"] >= len(entry["schedule"]):
@@ -532,7 +568,7 @@ class PerfRun:
532
568
  self.stats.amend_waits_expired += 1
533
569
  self._fill_runnable.append(corr)
534
570
 
535
- async def _handle_amend(self, of: dict) -> None:
571
+ async def _handle_amend(self, of: dict, recv_ns: int = 0) -> None:
536
572
  """Venue side of 35=G: reply Replaced (150=5/39=5), optionally preceded
537
573
  by Pending Replace (150=E/39=E). With the order in the book the reply
538
574
  carries the REAL CumQty/LeavesQty, the order's price is updated so the
@@ -554,8 +590,11 @@ class PerfRun:
554
590
  f"{cfg.correlation_tag} — no replace ack sent; check the "
555
591
  f"counterparty passes the tag through on amends")
556
592
  return
593
+ # Amend crossing client→hub→venue: one-way latency vs the client amend send.
594
+ self._record_c2v(of, corr, recv_ns, "amend")
557
595
  order_qty = _to_int(of.get(T_ORDERQTY), 0)
558
596
  orig = of.get(T_ORIGCLORDID)
597
+ clordid = of.get(T_CLORDID)
559
598
  entry = self._venue_orders.get(corr)
560
599
  if entry is not None:
561
600
  new_px = of.get(T_PRICE)
@@ -576,14 +615,18 @@ class PerfRun:
576
615
  cum_qty=cum, leaves_qty=leaves, px=px,
577
616
  orig_clordid=orig)
578
617
  if er is not None:
579
- await self._sm.send_message(cfg.venue_session_id, er)
618
+ send_ns = time.perf_counter_ns()
619
+ if await self._sm.send_message(cfg.venue_session_id, er):
620
+ self._register_venue_send(er, "pending-replace", corr, clordid, send_ns)
580
621
  er = self._build_exec(of, exec_type="5", ord_status="5", last_qty=0,
581
622
  cum_qty=cum, leaves_qty=leaves, px=px,
582
623
  orig_clordid=orig)
583
624
  if er is not None:
584
- await self._sm.send_message(cfg.venue_session_id, er)
625
+ send_ns = time.perf_counter_ns()
626
+ if await self._sm.send_message(cfg.venue_session_id, er):
627
+ self._register_venue_send(er, "replace", corr, clordid, send_ns)
585
628
 
586
- async def _handle_cancel(self, of: dict) -> None:
629
+ async def _handle_cancel(self, of: dict, recv_ns: int = 0) -> None:
587
630
  """Venue side of 35=F, per config.cancel.venue_response: "reject"
588
631
  answers 35=9 OrderCancelReject (CxlRejReason from config, correlation
589
632
  tag echoed) and the order keeps working — the cancel-reject flavour;
@@ -602,6 +645,9 @@ class PerfRun:
602
645
  f"{cfg.correlation_tag} — no reply sent; check the "
603
646
  f"counterparty passes the tag through on cancels")
604
647
  return
648
+ # Cancel crossing client→hub→venue: one-way latency vs the client cancel send.
649
+ self._record_c2v(of, corr, recv_ns, "cancel")
650
+ clordid = of.get(T_CLORDID)
605
651
  entry = self._venue_orders.get(corr)
606
652
  if cfg.cancel.venue_response == "cancel":
607
653
  if entry is not None:
@@ -613,10 +659,14 @@ class PerfRun:
613
659
  cum_qty=cum, leaves_qty=0, px=px,
614
660
  orig_clordid=of.get(T_ORIGCLORDID))
615
661
  if er is not None:
616
- await self._sm.send_message(cfg.venue_session_id, er)
662
+ send_ns = time.perf_counter_ns()
663
+ if await self._sm.send_message(cfg.venue_session_id, er):
664
+ self._register_venue_send(er, "canceled", corr, clordid, send_ns)
617
665
  return
618
666
  msg = self._build_cancel_reject(of, entry)
619
- await self._sm.send_message(cfg.venue_session_id, msg)
667
+ send_ns = time.perf_counter_ns()
668
+ if await self._sm.send_message(cfg.venue_session_id, msg):
669
+ self._register_venue_send(msg, "cancel-reject", corr, clordid, send_ns)
620
670
 
621
671
  def _build_cancel_reject(self, of: dict, entry: Optional[dict]) -> Message:
622
672
  """35=9 OrderCancelReject answering a 35=F: CxlRejResponseTo(434)=1,
@@ -634,6 +684,10 @@ class PerfRun:
634
684
  msg.set_field(T_ORDSTATUS, "1" if cum > 0 else "0")
635
685
  msg.set_field(102, str(cfg.cancel.cxl_rej_reason))
636
686
  msg.set_field(434, "1")
687
+ # Unique id so the client can match this reply for one-way latency
688
+ # (35=9 is not an ExecReport, but we reuse the exec id tags as the key).
689
+ msg.set_field(cfg.exec_id_tag, _uuid())
690
+ msg.set_field(T_EXECID, "R" + _guid()[:8].upper())
637
691
  corr = of.get(cfg.correlation_tag)
638
692
  if corr:
639
693
  msg.set_field(cfg.correlation_tag, corr)
@@ -733,17 +787,98 @@ class PerfRun:
733
787
  continue
734
788
  self._on_exec_report(inb.fields, inb.recv_perf_ns or time.perf_counter_ns())
735
789
 
790
+ def _emit_crossing(self, *, corr, clordid, exec_id, kind, direction,
791
+ send_at, recv_at, req_send_at, one_way_us, round_trip_us,
792
+ exec_type="", ord_status="", cum_qty=None, leaves_qty=None) -> None:
793
+ """Append one message-crossing row to the per-message latency CSV
794
+ (opt-in via record_execs). Column order must match EXEC_COLS."""
795
+ if self._exec_csv is None:
796
+ return
797
+ self._exec_csv.enqueue((
798
+ self.run_id, corr, clordid, exec_id, kind, direction,
799
+ send_at, recv_at, req_send_at, one_way_us, round_trip_us,
800
+ exec_type, ord_status, cum_qty, leaves_qty,
801
+ ))
802
+
803
+ def _register_venue_send(self, msg: Message, kind: str, corr, clordid,
804
+ send_ns: int) -> None:
805
+ """Stamp a venue→client message's send time keyed by its ExecID, so the
806
+ client correlator can compute one-way hub latency on receipt. No-op when
807
+ not recording. Bounded: drop-oldest past the cap (age-swept too)."""
808
+ if self._exec_csv is None:
809
+ return
810
+ eid = msg.get_field_or(self.config.exec_id_tag, "") or msg.get_field_or(T_EXECID, "")
811
+ if not eid:
812
+ return
813
+ sends = self._venue_sends
814
+ if len(sends) >= _VENUE_SENDS_CAP:
815
+ sends.pop(next(iter(sends)), None)
816
+ self.stats.venue_sends_unmatched += 1
817
+ sends[eid] = {"kind": kind, "send_ns": send_ns, "send_wall": time.time(),
818
+ "corr": corr, "clordid": clordid}
819
+
820
+ def _record_c2v(self, of: dict, corr: str, recv_ns: int, kind: str) -> None:
821
+ """Record a client→venue crossing (order/amend/cancel) at the venue
822
+ inbound side: one-way latency = venue recv − the client's send (looked up
823
+ in _pending). Only when we drive both legs on one clock (manager/loopback)."""
824
+ if self._exec_csv is None or not self._one_way:
825
+ return
826
+ entry = self._pending.get(corr)
827
+ if entry is None:
828
+ return
829
+ ns_key = {"order": "sent_ns", "amend": "amend_sent_ns",
830
+ "cancel": "cancel_sent_ns"}[kind]
831
+ send_ns = entry.get(ns_key)
832
+ if not send_ns:
833
+ return
834
+ send_at = entry.get(ns_key[:-2] + "at") # *_sent_ns → *_sent_at
835
+ self._emit_crossing(
836
+ corr=corr, clordid=of.get(T_CLORDID), exec_id=None, kind=kind,
837
+ direction="c2v", send_at=send_at, recv_at=time.time(),
838
+ req_send_at=send_at, one_way_us=(recv_ns - send_ns) / 1000.0,
839
+ round_trip_us=None)
840
+
841
+ def _record_v2c(self, f: dict, corr, exec_id, now_ns: int, kind: str,
842
+ entry: Optional[dict]) -> None:
843
+ """Record a venue→client crossing at the client inbound side: one-way
844
+ latency from the venue send registry (manager/loopback), and round-trip
845
+ from the originating request's send time in _pending. Client-only mode
846
+ has no registry hit → round-trip only, direction 'rt'."""
847
+ if self._exec_csv is None:
848
+ return
849
+ vs = self._venue_sends.pop(exec_id, None) if exec_id else None
850
+ one_way_us = ((now_ns - vs["send_ns"]) / 1000.0) if vs else None
851
+ send_at = vs["send_wall"] if vs else None
852
+ rt_us = req_send_at = None
853
+ ns_key = _KIND_REQ_NS.get(kind)
854
+ if entry is not None and ns_key:
855
+ base_ns = entry.get(ns_key)
856
+ if base_ns:
857
+ rt_us = (now_ns - base_ns) / 1000.0
858
+ req_send_at = entry.get(ns_key[:-2] + "at") # *_sent_ns → *_sent_at
859
+ clordid = (entry.get("clordid") if entry else None) or (vs["clordid"] if vs else None)
860
+ self._emit_crossing(
861
+ corr=corr, clordid=clordid, exec_id=exec_id, kind=kind,
862
+ direction="v2c" if self._one_way else "rt",
863
+ send_at=send_at, recv_at=time.time(), req_send_at=req_send_at,
864
+ one_way_us=one_way_us, round_trip_us=rt_us,
865
+ exec_type=f.get(T_EXECTYPE, ""), ord_status=f.get(T_ORDSTATUS, ""),
866
+ cum_qty=_to_float(f.get(T_CUMQTY)), leaves_qty=_to_float(f.get(T_LEAVESQTY)))
867
+
736
868
  def _on_cancel_reject(self, f: dict, now_ns: int) -> None:
737
869
  """OrderCancelReject (35=9): answers either a 35=F (CxlRejResponseTo
738
870
  434=1 — the cancel-reject flavour's expected reply) or a 35=G (434=2 —
739
871
  an amend the counterparty refused). Dispatch on 434; when it's absent,
740
872
  infer from which request is in flight on the correlated entry."""
741
- corr = f.get(self.config.correlation_tag)
873
+ cfg = self.config
874
+ corr = f.get(cfg.correlation_tag)
742
875
  entry = self._pending.get(corr) if corr is not None else None
876
+ exec_id = f.get(cfg.exec_id_tag) or f.get(T_EXECID)
743
877
  resp_to = f.get(434)
744
878
  if resp_to == "1" or (resp_to is None and entry is not None
745
879
  and entry.get("cancel_inflight")
746
880
  and not entry.get("amend_inflight")):
881
+ self._record_v2c(f, corr, exec_id, now_ns, "cancel-reject", entry)
747
882
  self._on_cancel_rejected(corr, entry, now_ns)
748
883
  return
749
884
  self.stats.amend_rejects += 1
@@ -751,6 +886,7 @@ class PerfRun:
751
886
  self.warnings.append(
752
887
  "counterparty rejected an amend (35=9 OrderCancelReject) — "
753
888
  "it may not match OrigClOrdID(41) against its own order chain")
889
+ self._record_v2c(f, corr, exec_id, now_ns, "amend-reject", entry)
754
890
  if entry is None or not entry.get("amend_inflight"):
755
891
  return
756
892
  entry["amend_inflight"] = False
@@ -811,6 +947,7 @@ class PerfRun:
811
947
  msg, new_clordid = self._factory.build_cancel(entry)
812
948
  entry["cancel_clordid"] = new_clordid
813
949
  entry["cancel_sent_ns"] = time.perf_counter_ns()
950
+ entry["cancel_sent_at"] = time.time()
814
951
  if await self._sm.send_message(self.config.client_session_id, msg):
815
952
  self.stats.cancels_sent += 1
816
953
  else:
@@ -820,32 +957,23 @@ class PerfRun:
820
957
  cfg = self.config
821
958
  corr = f.get(cfg.correlation_tag)
822
959
  entry = self._pending.get(corr) if corr is not None else None
823
- if entry is None:
824
- return
825
960
  exec_id = f.get(cfg.exec_id_tag) or f.get(T_EXECID)
826
- if exec_id is not None:
961
+ exec_type = f.get(T_EXECTYPE)
962
+ # Dedupe only when we have the order entry; record the crossing before the
963
+ # entry-None return so a late reply for a finalized order still gets its
964
+ # one-way latency (round-trip needs the entry and is skipped if gone).
965
+ if entry is not None and exec_id is not None:
827
966
  if exec_id in entry["seen_exec"]:
828
967
  return
829
968
  entry["seen_exec"].add(exec_id)
830
-
831
- if self._exec_csv is not None:
832
- seq = entry["exec_seq"] = entry.get("exec_seq", 0) + 1
833
- prev_ns = entry.get("last_recv_ns")
834
- entry["last_recv_ns"] = now_ns
835
- self._exec_csv.enqueue((
836
- self.run_id, corr, entry.get("clordid"), exec_id,
837
- f.get(T_EXECTYPE), f.get(T_ORDSTATUS),
838
- _to_float(f.get(T_LASTQTY)), _to_float(f.get(T_LASTPX)),
839
- _to_float(f.get(T_CUMQTY)), _to_float(f.get(T_LEAVESQTY)),
840
- seq, entry.get("sent_at"), time.time(),
841
- (now_ns - entry["sent_ns"]) / 1000.0,
842
- ((now_ns - prev_ns) / 1000.0) if prev_ns is not None else None,
843
- ))
969
+ self._record_v2c(f, corr, exec_id, now_ns,
970
+ _EXEC_KIND.get(exec_type, f"exec:{exec_type}"), entry)
971
+ if entry is None:
972
+ return
844
973
 
845
974
  # Amend/cancel responses (Pending Replace / Replaced / Pending Cancel /
846
975
  # Canceled) are not order responses: they must not count as first
847
976
  # response, a fill, or a completion.
848
- exec_type = f.get(T_EXECTYPE)
849
977
  if exec_type in ("E", "5", "6", "4"):
850
978
  if exec_type == "5":
851
979
  self._on_replaced(corr, entry, now_ns)
@@ -950,6 +1078,7 @@ class PerfRun:
950
1078
  entry["pending_clordid"] = new_clordid
951
1079
  entry["amends_sent"] = n + 1
952
1080
  entry["amend_sent_ns"] = time.perf_counter_ns()
1081
+ entry["amend_sent_at"] = time.time()
953
1082
  if await self._sm.send_message(self.config.client_session_id, msg):
954
1083
  self.stats.amends_sent += 1
955
1084
  else:
@@ -985,6 +1114,14 @@ class PerfRun:
985
1114
  else:
986
1115
  self.stats.lost_timeout += 1
987
1116
  self._finalize(entry, "lost")
1117
+ # Evict one-way send records whose reply never came back (dropped at
1118
+ # the hub) so the registry can't grow unbounded across a long run.
1119
+ if self._venue_sends:
1120
+ stale = [eid for eid, v in self._venue_sends.items()
1121
+ if now - v["send_ns"] > timeout_ns]
1122
+ for eid in stale:
1123
+ if self._venue_sends.pop(eid, None) is not None:
1124
+ self.stats.venue_sends_unmatched += 1
988
1125
 
989
1126
  # ------------------------------------------------------------------
990
1127
  # Persistence + snapshots
@@ -146,6 +146,7 @@ class PerfStats:
146
146
  self.cancels_lost = 0 # cancel in flight when its order was reaped
147
147
  self.cancels_received = 0 # venue saw an inbound 35=F
148
148
  self.cancels_uncorrelated = 0 # inbound 35=F lacked the correlation tag (no reply)
149
+ self.venue_sends_unmatched = 0 # one-way send records evicted w/o a matching recv
149
150
  self.lost_timeout = 0
150
151
  self.dropped = 0
151
152
  self.rejected = 0