fixtureqa 0.4.3__tar.gz → 0.4.5__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 (99) hide show
  1. {fixtureqa-0.4.3/fixtureqa.egg-info → fixtureqa-0.4.5}/PKG-INFO +1 -1
  2. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/app.py +2 -1
  3. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/perf.py +25 -2
  4. fixtureqa-0.4.5/fixture/core/exec_csv_writer.py +81 -0
  5. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/perf_engine.py +45 -3
  6. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/perf_models.py +4 -0
  7. fixtureqa-0.4.3/fixture/static/assets/index-Dd6aSjfO.js → fixtureqa-0.4.5/fixture/static/assets/index-BKIC30ah.js +5 -5
  8. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/static/index.html +1 -1
  9. {fixtureqa-0.4.3 → fixtureqa-0.4.5/fixtureqa.egg-info}/PKG-INFO +1 -1
  10. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixtureqa.egg-info/SOURCES.txt +2 -1
  11. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/pyproject.toml +1 -1
  12. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_perf_api.py +43 -0
  13. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_perf_engine.py +51 -0
  14. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/LICENSE +0 -0
  15. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/README.md +0 -0
  16. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/__init__.py +0 -0
  17. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/__main__.py +0 -0
  18. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/__init__.py +0 -0
  19. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/connection_manager.py +0 -0
  20. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/deps.py +0 -0
  21. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/__init__.py +0 -0
  22. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/admin.py +0 -0
  23. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/auth.py +0 -0
  24. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/branding.py +0 -0
  25. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/custom_tags.py +0 -0
  26. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/fix_spec.py +0 -0
  27. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/messages.py +0 -0
  28. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/scenarios.py +0 -0
  29. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/sessions.py +0 -0
  30. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/setup.py +0 -0
  31. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/spec_overlay.py +0 -0
  32. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/templates.py +0 -0
  33. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/routers/ws.py +0 -0
  34. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/api/schemas.py +0 -0
  35. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/config/__init__.py +0 -0
  36. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/__init__.py +0 -0
  37. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/atomic_io.py +0 -0
  38. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/auth.py +0 -0
  39. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/config_store.py +0 -0
  40. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/custom_tag_store.py +0 -0
  41. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/db_migrations.py +0 -0
  42. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/events.py +0 -0
  43. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/fix_application.py +0 -0
  44. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/fix_builder.py +0 -0
  45. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/fix_parser.py +0 -0
  46. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/fix_spec_parser.py +0 -0
  47. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/fix_tags.py +0 -0
  48. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/fix_time.py +0 -0
  49. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/housekeeping.py +0 -0
  50. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/inbound.py +0 -0
  51. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/json_store.py +0 -0
  52. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/message_log.py +0 -0
  53. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/message_store.py +0 -0
  54. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/models.py +0 -0
  55. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/perf_payload.py +0 -0
  56. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/perf_stats.py +0 -0
  57. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/perf_store.py +0 -0
  58. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/perf_writer.py +0 -0
  59. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/scenario_runner.py +0 -0
  60. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/scenario_store.py +0 -0
  61. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/session.py +0 -0
  62. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/session_manager.py +0 -0
  63. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/spec_overlay_store.py +0 -0
  64. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/template_store.py +0 -0
  65. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/user_store.py +0 -0
  66. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/core/venue_responses.py +0 -0
  67. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/fix_specs/FIX42.xml +0 -0
  68. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/fix_specs/FIX44.xml +0 -0
  69. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/server.py +0 -0
  70. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  71. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/static/assets/index-BwQf-cei.css +0 -0
  72. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  73. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  74. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/static/favicon.svg +0 -0
  75. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixture/ui/__init__.py +0 -0
  76. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixtureqa.egg-info/dependency_links.txt +0 -0
  77. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixtureqa.egg-info/entry_points.txt +0 -0
  78. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixtureqa.egg-info/requires.txt +0 -0
  79. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/fixtureqa.egg-info/top_level.txt +0 -0
  80. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/setup.cfg +0 -0
  81. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_atomic_io.py +0 -0
  82. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_auth.py +0 -0
  83. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_config_store.py +0 -0
  84. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_connection_manager.py +0 -0
  85. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_db_migrations.py +0 -0
  86. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_fix_builder.py +0 -0
  87. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_health.py +0 -0
  88. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_inbound.py +0 -0
  89. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_inbound_validation.py +0 -0
  90. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_message_store.py +0 -0
  91. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_perf_models.py +0 -0
  92. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_perf_payload.py +0 -0
  93. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_perf_rehydrate.py +0 -0
  94. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_scenarios.py +0 -0
  95. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_session_lifecycle.py +0 -0
  96. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_session_manager_concurrency.py +0 -0
  97. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_sessions.py +0 -0
  98. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_templates.py +0 -0
  99. {fixtureqa-0.4.3 → fixtureqa-0.4.5}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.4.3
3
+ Version: 0.4.5
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -90,7 +90,8 @@ def create_app(
90
90
  app.state.spec_overlay_store = SpecOverlayStore(data_dir)
91
91
  perf_writer = PerfWriter(os.path.join(data_dir, "perf.db"))
92
92
  app.state.perf_writer = perf_writer
93
- app.state.perf_registry = RunRegistry(perf_writer)
93
+ app.state.perf_registry = RunRegistry(
94
+ perf_writer, execs_dir=os.path.join(data_dir, "perf_execs"))
94
95
  # Runs left non-terminal in perf.db were interrupted by a prior restart —
95
96
  # mark them errored so History doesn't show zombie 'running' rows.
96
97
  app.state.perf_registry.reconcile_interrupted()
@@ -12,7 +12,7 @@ import zipfile
12
12
  from typing import Annotated, Optional
13
13
 
14
14
  from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
15
- from fastapi.responses import Response, StreamingResponse
15
+ from fastapi.responses import FileResponse, Response, StreamingResponse
16
16
 
17
17
  from ...core.models import SessionStatus
18
18
  from ...core.perf_models import RunConfig, RunStatus
@@ -136,6 +136,11 @@ def run_scenarios(run_id: str, reg: REG, pw: PW, _admin: Admin,
136
136
  return pw.read_scenarios(run_id, limit, offset)
137
137
 
138
138
 
139
+ def _run_is_live(reg: RunRegistry, run_id: str) -> bool:
140
+ run = reg.get(run_id)
141
+ return run is not None and run.status not in _TERMINAL
142
+
143
+
139
144
  def _csv_stream(pw: PerfWriter, table: str, cols: tuple, run_id: str):
140
145
  buf = io.StringIO()
141
146
  w = csv.writer(buf)
@@ -172,6 +177,19 @@ def run_export(run_id: str, reg: REG, pw: PW, _admin: Admin,
172
177
  _csv_stream(pw, "perf_scenarios", pw.scenario_columns(), run_id),
173
178
  media_type="text/csv",
174
179
  headers={"Content-Disposition": f'attachment; filename="{run_id}_scenarios.csv"'})
180
+ if kind == "execs":
181
+ # CSV-direct: the run streams per-exec rows straight into this csv.gz; the
182
+ # gzip footer is only written when the run finishes, so a live file would
183
+ # download truncated.
184
+ if _run_is_live(reg, run_id):
185
+ raise HTTPException(status_code=409,
186
+ detail="Run still active — the exec CSV is finalized when the run finishes")
187
+ path = reg.exec_csv_path(run_id)
188
+ if path is None:
189
+ raise HTTPException(status_code=404,
190
+ detail="No exec recording for this run (enable record_execs before starting)")
191
+ return FileResponse(path, media_type="application/gzip",
192
+ filename=f"{run_id}_execs.csv.gz")
175
193
  if kind == "both":
176
194
  buf = io.BytesIO()
177
195
  with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
@@ -179,9 +197,14 @@ def run_export(run_id: str, reg: REG, pw: PW, _admin: Admin,
179
197
  _csv_text(pw, "perf_messages", pw.message_columns(), run_id))
180
198
  z.writestr(f"{run_id}_scenarios.csv",
181
199
  _csv_text(pw, "perf_scenarios", pw.scenario_columns(), run_id))
200
+ execs_path = reg.exec_csv_path(run_id)
201
+ if execs_path is not None and not _run_is_live(reg, run_id):
202
+ # Already gzipped — store as-is rather than re-deflating.
203
+ z.write(execs_path, arcname=f"{run_id}_execs.csv.gz",
204
+ compress_type=zipfile.ZIP_STORED)
182
205
  return Response(buf.getvalue(), media_type="application/zip",
183
206
  headers={"Content-Disposition": f'attachment; filename="{run_id}.zip"'})
184
- raise HTTPException(status_code=422, detail="kind must be messages|scenarios|both")
207
+ raise HTTPException(status_code=422, detail="kind must be messages|scenarios|execs|both")
185
208
 
186
209
 
187
210
  # --- saved configs --------------------------------------------------------
@@ -0,0 +1,81 @@
1
+ """
2
+ Per-run gzipped CSV recorder for individual ExecutionReports (opt-in via
3
+ test.record_execs).
4
+
5
+ CSV-direct: there is no database staging — rows stream straight to
6
+ data/perf_execs/<run_id>.csv.gz and export serves the finished file as-is.
7
+ Same non-blocking producer contract as PerfWriter: the event loop only does a
8
+ put_nowait (drop+count when the bounded queue is full); all formatting and
9
+ gzip/disk work happens on one daemon writer thread, so recording cannot add
10
+ latency to the measured inbound path.
11
+
12
+ One instance per recording run; it owns its file exclusively for its lifetime.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import csv
17
+ import gzip
18
+ import logging
19
+ import os
20
+ import queue
21
+ import threading
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ DEFAULT_MAX_QUEUE = 200_000
26
+
27
+ EXEC_COLS = (
28
+ "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
37
+ )
38
+
39
+
40
+ class ExecCsvWriter:
41
+ def __init__(self, path: str, max_queue: int = DEFAULT_MAX_QUEUE):
42
+ self.path = path
43
+ os.makedirs(os.path.dirname(path), exist_ok=True)
44
+ self._queue: queue.Queue = queue.Queue(maxsize=max_queue)
45
+ self.dropped = 0
46
+ self._thread = threading.Thread(target=self._run, name="perf-exec-csv", daemon=True)
47
+ self._thread.start()
48
+
49
+ # -- producer side (event loop; never blocks) -----------------------
50
+
51
+ def enqueue(self, row: tuple) -> None:
52
+ try:
53
+ self._queue.put_nowait(row)
54
+ except queue.Full:
55
+ self.dropped += 1
56
+ if self.dropped % 1000 == 1:
57
+ logger.warning("exec csv queue full — dropped %d row(s) so far", self.dropped)
58
+
59
+ def close(self) -> None:
60
+ """Drain, finalize the gzip stream and stop. Blocks on the thread join —
61
+ call off-loop (asyncio.to_thread)."""
62
+ try:
63
+ self._queue.put(None, timeout=5)
64
+ except queue.Full:
65
+ logger.error("exec csv queue full on close — file %s may be incomplete", self.path)
66
+ self._thread.join(timeout=30.0)
67
+
68
+ # -- writer thread ---------------------------------------------------
69
+
70
+ def _run(self) -> None:
71
+ try:
72
+ with gzip.open(self.path, "wt", newline="") as fh:
73
+ w = csv.writer(fh)
74
+ w.writerow(EXEC_COLS)
75
+ while True:
76
+ item = self._queue.get()
77
+ if item is None:
78
+ return # with-block flushes + writes the gzip footer
79
+ w.writerow(item)
80
+ except Exception:
81
+ logger.exception("exec csv writer failed (%s)", self.path)
@@ -21,6 +21,7 @@ from __future__ import annotations
21
21
  import asyncio
22
22
  import json
23
23
  import logging
24
+ import os
24
25
  import random
25
26
  import time
26
27
  import uuid
@@ -28,6 +29,7 @@ from typing import Optional
28
29
 
29
30
  from fixcore.message.message import Message
30
31
 
32
+ from .exec_csv_writer import ExecCsvWriter
31
33
  from .fix_time import utc_timestamp
32
34
  from .perf_models import (
33
35
  RunConfig, RunStatus, LiveSnapshot, ClientLeg, VenueLeg, SnapshotErrors, LatencyStats,
@@ -80,11 +82,14 @@ def _to_float(v) -> Optional[float]:
80
82
  class PerfRun:
81
83
  def __init__(self, run_id: str, config: RunConfig, sm: SessionManager, writer: PerfWriter,
82
84
  template_store=None, owner_uid: str = "",
83
- sender_comp_id: str = "", target_comp_id: str = ""):
85
+ sender_comp_id: str = "", target_comp_id: str = "",
86
+ execs_dir: Optional[str] = None):
84
87
  self.run_id = run_id
85
88
  self.config = config
86
89
  self._sm = sm
87
90
  self._writer = writer
91
+ self._execs_dir = execs_dir
92
+ self._exec_csv: Optional[ExecCsvWriter] = None
88
93
 
89
94
  self.status: str = "pending"
90
95
  self.started_at: Optional[float] = None
@@ -131,6 +136,16 @@ class PerfRun:
131
136
  self.status = "running"
132
137
  self.started_at = time.time()
133
138
  self._start_ns = time.perf_counter_ns()
139
+ if self.config.test.record_execs:
140
+ # Per-exec capture happens in the client-leg correlator; venue-only
141
+ # runs have nothing to record.
142
+ if not self._has_client:
143
+ self.warnings.append("record_execs has no effect in venue mode (no client leg)")
144
+ elif not self._execs_dir:
145
+ self.warnings.append("record_execs unavailable (no exec CSV directory configured)")
146
+ else:
147
+ self._exec_csv = ExecCsvWriter(
148
+ os.path.join(self._execs_dir, f"{self.run_id}.csv.gz"))
134
149
  if self._has_client:
135
150
  self._client_sub = self._sm.subscribe_session(self.config.client_session_id)
136
151
  if self._has_venue:
@@ -192,6 +207,10 @@ class PerfRun:
192
207
  self._client_sub.close()
193
208
  if self._venue_sub:
194
209
  self._venue_sub.close()
210
+ if self._exec_csv is not None:
211
+ # Correlator is cancelled (no more enqueues); drain + gzip-finalize
212
+ # off-loop — close() joins the writer thread.
213
+ await asyncio.to_thread(self._exec_csv.close)
195
214
  self.status = "stopped" if self._stopped_by_user else "completed"
196
215
  self._build_snapshot()
197
216
  self._publish()
@@ -482,6 +501,20 @@ class PerfRun:
482
501
  return
483
502
  entry["seen_exec"].add(exec_id)
484
503
 
504
+ if self._exec_csv is not None:
505
+ seq = entry["exec_seq"] = entry.get("exec_seq", 0) + 1
506
+ prev_ns = entry.get("last_recv_ns")
507
+ entry["last_recv_ns"] = now_ns
508
+ self._exec_csv.enqueue((
509
+ self.run_id, corr, entry.get("clordid"), exec_id,
510
+ f.get(T_EXECTYPE), f.get(T_ORDSTATUS),
511
+ _to_float(f.get(T_LASTQTY)), _to_float(f.get(T_LASTPX)),
512
+ _to_float(f.get(T_CUMQTY)), _to_float(f.get(T_LEAVESQTY)),
513
+ seq, entry.get("sent_at"), time.time(),
514
+ (now_ns - entry["sent_ns"]) / 1000.0,
515
+ ((now_ns - prev_ns) / 1000.0) if prev_ns is not None else None,
516
+ ))
517
+
485
518
  if not entry["responded"]:
486
519
  entry["responded"] = True
487
520
  entry["first_resp_at"] = time.time()
@@ -640,8 +673,9 @@ class RunRegistry:
640
673
  TICKET_TTL_S = 30.0
641
674
  _TERMINAL_STATES = ("completed", "stopped", "error")
642
675
 
643
- def __init__(self, writer: PerfWriter):
676
+ def __init__(self, writer: PerfWriter, execs_dir: Optional[str] = None):
644
677
  self._writer = writer
678
+ self._execs_dir = execs_dir
645
679
  self._runs: dict[str, PerfRun] = {}
646
680
  self._tickets: dict[str, tuple[str, float]] = {}
647
681
 
@@ -649,11 +683,19 @@ class RunRegistry:
649
683
  owner_uid: str = "", sender_comp_id: str = "", target_comp_id: str = "") -> PerfRun:
650
684
  run_id = str(uuid.uuid4())
651
685
  run = PerfRun(run_id, config, sm, self._writer, template_store, owner_uid,
652
- sender_comp_id, target_comp_id)
686
+ sender_comp_id, target_comp_id, execs_dir=self._execs_dir)
653
687
  self._runs[run_id] = run
654
688
  run.start()
655
689
  return run
656
690
 
691
+ def exec_csv_path(self, run_id: str) -> Optional[str]:
692
+ """Path to the run's per-exec csv.gz, or None if it never recorded.
693
+ The file is complete (gzip footer written) only once the run is terminal."""
694
+ if not self._execs_dir:
695
+ return None
696
+ path = os.path.join(self._execs_dir, f"{run_id}.csv.gz")
697
+ return path if os.path.exists(path) else None
698
+
657
699
  def get(self, run_id: str) -> Optional[PerfRun]:
658
700
  """The live PerfRun, or None if it's not in this process (e.g. historical).
659
701
  Used for actions that need the live object: stop, WS watcher, ticket."""
@@ -43,6 +43,10 @@ class TestConfig(BaseModel):
43
43
  max_pending: int = Field(default=100_000, gt=0)
44
44
  on_saturation: SaturationPolicy = "pause"
45
45
  output: str = "results"
46
+ # Record every inbound ExecutionReport (ack + each fill) to a per-run
47
+ # csv.gz for detailed latency analysis. Off by default: ~(1+fills_per_order)×
48
+ # the per-order row volume on disk.
49
+ record_execs: bool = False
46
50
 
47
51
 
48
52
  class OrderPayloadConfig(BaseModel):