fixtureqa 0.4.17__tar.gz → 0.4.19__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.19}/PKG-INFO +2 -1
  2. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/perf.py +41 -0
  3. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/exec_csv_writer.py +18 -10
  4. fixtureqa-0.4.19/fixture/core/perf_charts.py +170 -0
  5. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/perf_engine.py +222 -35
  6. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/perf_stats.py +1 -0
  7. fixtureqa-0.4.19/fixture/static/assets/index-BSuHxk6b.js +102 -0
  8. fixtureqa-0.4.17/fixture/static/assets/index-BwQf-cei.css → fixtureqa-0.4.19/fixture/static/assets/index-o_l1KBYd.css +1 -1
  9. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/static/index.html +2 -2
  10. {fixtureqa-0.4.17 → fixtureqa-0.4.19/fixtureqa.egg-info}/PKG-INFO +2 -1
  11. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixtureqa.egg-info/SOURCES.txt +3 -2
  12. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixtureqa.egg-info/requires.txt +1 -0
  13. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/pyproject.toml +2 -1
  14. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_perf_api.py +37 -0
  15. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_perf_engine.py +160 -12
  16. fixtureqa-0.4.17/fixture/static/assets/index-BM8-U5CW.js +0 -102
  17. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/LICENSE +0 -0
  18. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/README.md +0 -0
  19. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/__init__.py +0 -0
  20. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/__main__.py +0 -0
  21. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/__init__.py +0 -0
  22. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/app.py +0 -0
  23. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/connection_manager.py +0 -0
  24. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/deps.py +0 -0
  25. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/__init__.py +0 -0
  26. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/admin.py +0 -0
  27. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/auth.py +0 -0
  28. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/branding.py +0 -0
  29. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/custom_tags.py +0 -0
  30. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/fix_spec.py +0 -0
  31. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/messages.py +0 -0
  32. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/scenarios.py +0 -0
  33. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/sessions.py +0 -0
  34. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/setup.py +0 -0
  35. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/spec_overlay.py +0 -0
  36. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/templates.py +0 -0
  37. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/routers/ws.py +0 -0
  38. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/api/schemas.py +0 -0
  39. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/config/__init__.py +0 -0
  40. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/__init__.py +0 -0
  41. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/atomic_io.py +0 -0
  42. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/auth.py +0 -0
  43. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/config_store.py +0 -0
  44. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/custom_tag_store.py +0 -0
  45. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/db_migrations.py +0 -0
  46. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/events.py +0 -0
  47. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/fix_application.py +0 -0
  48. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/fix_builder.py +0 -0
  49. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/fix_parser.py +0 -0
  50. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/fix_spec_parser.py +0 -0
  51. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/fix_tags.py +0 -0
  52. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/fix_time.py +0 -0
  53. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/housekeeping.py +0 -0
  54. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/inbound.py +0 -0
  55. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/json_store.py +0 -0
  56. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/message_log.py +0 -0
  57. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/message_store.py +0 -0
  58. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/models.py +0 -0
  59. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/perf_models.py +0 -0
  60. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/perf_payload.py +0 -0
  61. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/perf_store.py +0 -0
  62. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/perf_writer.py +0 -0
  63. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/scenario_runner.py +0 -0
  64. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/scenario_store.py +0 -0
  65. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/session.py +0 -0
  66. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/session_manager.py +0 -0
  67. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/spec_overlay_store.py +0 -0
  68. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/template_store.py +0 -0
  69. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/user_store.py +0 -0
  70. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/core/venue_responses.py +0 -0
  71. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/fix_specs/FIX42.xml +0 -0
  72. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/fix_specs/FIX44.xml +0 -0
  73. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/server.py +0 -0
  74. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  75. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  76. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  77. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/static/favicon.svg +0 -0
  78. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixture/ui/__init__.py +0 -0
  79. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixtureqa.egg-info/dependency_links.txt +0 -0
  80. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixtureqa.egg-info/entry_points.txt +0 -0
  81. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/fixtureqa.egg-info/top_level.txt +0 -0
  82. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/setup.cfg +0 -0
  83. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_atomic_io.py +0 -0
  84. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_auth.py +0 -0
  85. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_config_store.py +0 -0
  86. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_connection_manager.py +0 -0
  87. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_db_migrations.py +0 -0
  88. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_fix_builder.py +0 -0
  89. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_health.py +0 -0
  90. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_inbound.py +0 -0
  91. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_inbound_validation.py +0 -0
  92. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_message_store.py +0 -0
  93. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_perf_models.py +0 -0
  94. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_perf_payload.py +0 -0
  95. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_perf_rehydrate.py +0 -0
  96. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_scenarios.py +0 -0
  97. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_session_lifecycle.py +0 -0
  98. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_session_manager_concurrency.py +0 -0
  99. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_sessions.py +0 -0
  100. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/tests/test_templates.py +0 -0
  101. {fixtureqa-0.4.17 → fixtureqa-0.4.19}/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.19
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,44 @@ 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
+ # Single-leg runs (client/venue) derive one-way from the wire send time;
232
+ # label it so the chart can't be misread as the single-clock measurement.
233
+ st = reg.get_status(run_id)
234
+ one_way_label = None
235
+ if st is not None and st.mode not in ("loopback", "manager"):
236
+ one_way_label = "one-way (tag-60 wire send → recv; needs synced clocks)"
237
+ out_path = f"{csv_path[:-len('.csv.gz')]}_{metric}_{agg}.png"
238
+ if not os.path.exists(out_path):
239
+ try:
240
+ await asyncio.to_thread(
241
+ render_latency_chart, csv_path, out_path,
242
+ metric=metric, agg=agg, title=f"Per-message latency — {run_id}",
243
+ one_way_label=one_way_label)
244
+ except ValueError as e:
245
+ # No chartable points (e.g. recording produced only un-latencied rows).
246
+ raise HTTPException(status_code=404, detail=str(e))
247
+ return FileResponse(out_path, media_type="image/png",
248
+ filename=f"{run_id}_latency.png")
249
+
250
+
210
251
  # --- saved configs --------------------------------------------------------
211
252
 
212
253
  @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)
36
+ "send_at", # wall clock of THIS message's send on the near leg (one-way x-axis)
37
+ "recv_at", # wall clock of THIS message's receive on the far leg
38
+ "req_send_at", # wall clock of the originating request's send (order/amend/cancel
39
+ # send; == send_at for c2v rows) — the round-trip x-axis
40
+ "one_way_us", # this message's one-way transit. manager/loopback: recv − send on
41
+ # one monotonic clock. client/venue-only: recv − the wire send time
42
+ # (TransactTime 60 / SendingTime 52), needs a synced clock.
43
+ "round_trip_us", # v2c only: recv − the originating request's send (our clock); 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, one_way_label: 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 = one_way_label if (m == "one_way" and one_way_label) else _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