fixtureqa 0.4.18__tar.gz → 0.4.20__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 (100) hide show
  1. {fixtureqa-0.4.18/fixtureqa.egg-info → fixtureqa-0.4.20}/PKG-INFO +1 -1
  2. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/app.py +1 -1
  3. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/perf.py +23 -16
  4. fixtureqa-0.4.18/fixture/core/exec_csv_writer.py → fixtureqa-0.4.20/fixture/core/latency_csv_writer.py +10 -10
  5. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/perf_charts.py +3 -3
  6. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/perf_engine.py +99 -49
  7. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/perf_models.py +9 -5
  8. fixtureqa-0.4.18/fixture/static/assets/index-BSuHxk6b.js → fixtureqa-0.4.20/fixture/static/assets/index-BhxUj8V-.js +2 -2
  9. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/static/index.html +1 -1
  10. {fixtureqa-0.4.18 → fixtureqa-0.4.20/fixtureqa.egg-info}/PKG-INFO +1 -1
  11. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixtureqa.egg-info/SOURCES.txt +2 -2
  12. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/pyproject.toml +1 -1
  13. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_perf_api.py +22 -14
  14. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_perf_engine.py +68 -30
  15. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_perf_models.py +10 -0
  16. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/LICENSE +0 -0
  17. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/README.md +0 -0
  18. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/__init__.py +0 -0
  19. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/__main__.py +0 -0
  20. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/__init__.py +0 -0
  21. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/connection_manager.py +0 -0
  22. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/deps.py +0 -0
  23. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/__init__.py +0 -0
  24. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/admin.py +0 -0
  25. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/auth.py +0 -0
  26. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/branding.py +0 -0
  27. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/custom_tags.py +0 -0
  28. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/fix_spec.py +0 -0
  29. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/messages.py +0 -0
  30. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/scenarios.py +0 -0
  31. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/sessions.py +0 -0
  32. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/setup.py +0 -0
  33. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/spec_overlay.py +0 -0
  34. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/templates.py +0 -0
  35. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/ws.py +0 -0
  36. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/schemas.py +0 -0
  37. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/config/__init__.py +0 -0
  38. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/__init__.py +0 -0
  39. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/atomic_io.py +0 -0
  40. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/auth.py +0 -0
  41. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/config_store.py +0 -0
  42. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/custom_tag_store.py +0 -0
  43. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/db_migrations.py +0 -0
  44. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/events.py +0 -0
  45. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/fix_application.py +0 -0
  46. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/fix_builder.py +0 -0
  47. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/fix_parser.py +0 -0
  48. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/fix_spec_parser.py +0 -0
  49. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/fix_tags.py +0 -0
  50. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/fix_time.py +0 -0
  51. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/housekeeping.py +0 -0
  52. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/inbound.py +0 -0
  53. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/json_store.py +0 -0
  54. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/message_log.py +0 -0
  55. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/message_store.py +0 -0
  56. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/models.py +0 -0
  57. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/perf_payload.py +0 -0
  58. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/perf_stats.py +0 -0
  59. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/perf_store.py +0 -0
  60. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/perf_writer.py +0 -0
  61. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/scenario_runner.py +0 -0
  62. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/scenario_store.py +0 -0
  63. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/session.py +0 -0
  64. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/session_manager.py +0 -0
  65. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/spec_overlay_store.py +0 -0
  66. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/template_store.py +0 -0
  67. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/user_store.py +0 -0
  68. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/venue_responses.py +0 -0
  69. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/fix_specs/FIX42.xml +0 -0
  70. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/fix_specs/FIX44.xml +0 -0
  71. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/server.py +0 -0
  72. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  73. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  74. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/static/assets/index-o_l1KBYd.css +0 -0
  75. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  76. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/static/favicon.svg +0 -0
  77. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/ui/__init__.py +0 -0
  78. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixtureqa.egg-info/dependency_links.txt +0 -0
  79. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixtureqa.egg-info/entry_points.txt +0 -0
  80. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixtureqa.egg-info/requires.txt +0 -0
  81. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixtureqa.egg-info/top_level.txt +0 -0
  82. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/setup.cfg +0 -0
  83. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_atomic_io.py +0 -0
  84. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_auth.py +0 -0
  85. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_config_store.py +0 -0
  86. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_connection_manager.py +0 -0
  87. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_db_migrations.py +0 -0
  88. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_fix_builder.py +0 -0
  89. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_health.py +0 -0
  90. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_inbound.py +0 -0
  91. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_inbound_validation.py +0 -0
  92. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_message_store.py +0 -0
  93. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_perf_payload.py +0 -0
  94. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_perf_rehydrate.py +0 -0
  95. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_scenarios.py +0 -0
  96. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_session_lifecycle.py +0 -0
  97. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_session_manager_concurrency.py +0 -0
  98. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_sessions.py +0 -0
  99. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_templates.py +0 -0
  100. {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.4.18
3
+ Version: 0.4.20
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -91,7 +91,7 @@ def create_app(
91
91
  perf_writer = PerfWriter(os.path.join(data_dir, "perf.db"))
92
92
  app.state.perf_writer = perf_writer
93
93
  app.state.perf_registry = RunRegistry(
94
- perf_writer, execs_dir=os.path.join(data_dir, "perf_execs"))
94
+ perf_writer, latency_dir=os.path.join(data_dir, "perf_latency"))
95
95
  # Runs left non-terminal in perf.db were interrupted by a prior restart —
96
96
  # mark them errored so History doesn't show zombie 'running' rows.
97
97
  app.state.perf_registry.reconcile_interrupted()
@@ -180,19 +180,19 @@ def run_export(run_id: str, reg: REG, pw: PW, _admin: Admin,
180
180
  _csv_stream(pw, "perf_scenarios", pw.scenario_columns(), run_id),
181
181
  media_type="text/csv",
182
182
  headers={"Content-Disposition": f'attachment; filename="{run_id}_scenarios.csv"'})
183
- if kind == "execs":
184
- # CSV-direct: the run streams per-exec rows straight into this csv.gz; the
185
- # gzip footer is only written when the run finishes, so a live file would
186
- # download truncated.
183
+ if kind in ("latency", "execs"): # "execs" kept for back-compat
184
+ # CSV-direct: the run streams per-message rows straight into this csv.gz;
185
+ # the gzip footer is only written when the run finishes, so a live file
186
+ # would download truncated.
187
187
  if _run_is_live(reg, run_id):
188
188
  raise HTTPException(status_code=409,
189
- detail="Run still active — the exec CSV is finalized when the run finishes")
190
- path = reg.exec_csv_path(run_id)
189
+ detail="Run still active — the latency CSV is finalized when the run finishes")
190
+ path = reg.latency_csv_path(run_id)
191
191
  if path is None:
192
192
  raise HTTPException(status_code=404,
193
- detail="No exec recording for this run (enable record_execs before starting)")
193
+ detail="No per-message recording for this run (enable record_latency before starting)")
194
194
  return FileResponse(path, media_type="application/gzip",
195
- filename=f"{run_id}_execs.csv.gz")
195
+ filename=f"{run_id}_latency.csv.gz")
196
196
  if kind == "both":
197
197
  buf = io.BytesIO()
198
198
  with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
@@ -200,14 +200,14 @@ def run_export(run_id: str, reg: REG, pw: PW, _admin: Admin,
200
200
  _csv_text(pw, "perf_messages", pw.message_columns(), run_id))
201
201
  z.writestr(f"{run_id}_scenarios.csv",
202
202
  _csv_text(pw, "perf_scenarios", pw.scenario_columns(), run_id))
203
- execs_path = reg.exec_csv_path(run_id)
204
- if execs_path is not None and not _run_is_live(reg, run_id):
203
+ latency_path = reg.latency_csv_path(run_id)
204
+ if latency_path is not None and not _run_is_live(reg, run_id):
205
205
  # Already gzipped — store as-is rather than re-deflating.
206
- z.write(execs_path, arcname=f"{run_id}_execs.csv.gz",
206
+ z.write(latency_path, arcname=f"{run_id}_latency.csv.gz",
207
207
  compress_type=zipfile.ZIP_STORED)
208
208
  return Response(buf.getvalue(), media_type="application/zip",
209
209
  headers={"Content-Disposition": f'attachment; filename="{run_id}.zip"'})
210
- raise HTTPException(status_code=422, detail="kind must be messages|scenarios|execs|both")
210
+ raise HTTPException(status_code=422, detail="kind must be messages|scenarios|latency|both")
211
211
 
212
212
 
213
213
  @router.get("/runs/{run_id}/chart")
@@ -215,7 +215,7 @@ async def run_chart(run_id: str, reg: REG, _admin: Admin,
215
215
  metric: str = Query("one_way"), agg: str = Query("both")):
216
216
  """Per-message latency chart (PNG) from the run's recorded csv.gz. Rendered
217
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."""
218
+ once the run finishes. Needs record_latency; 404 otherwise, 409 while live."""
219
219
  if metric not in ("one_way", "round_trip", "both") or agg not in ("scatter", "percentile", "both"):
220
220
  raise HTTPException(status_code=422,
221
221
  detail="metric must be one_way|round_trip|both; agg must be scatter|percentile|both")
@@ -224,16 +224,23 @@ async def run_chart(run_id: str, reg: REG, _admin: Admin,
224
224
  if _run_is_live(reg, run_id):
225
225
  raise HTTPException(status_code=409,
226
226
  detail="Run still active — the latency chart is rendered when the run finishes")
227
- csv_path = reg.exec_csv_path(run_id)
227
+ csv_path = reg.latency_csv_path(run_id)
228
228
  if csv_path is None or not os.path.exists(csv_path):
229
229
  raise HTTPException(status_code=404,
230
- detail="No per-message recording for this run (enable record_execs before starting)")
230
+ detail="No per-message recording for this run (enable record_latency 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)"
231
237
  out_path = f"{csv_path[:-len('.csv.gz')]}_{metric}_{agg}.png"
232
238
  if not os.path.exists(out_path):
233
239
  try:
234
240
  await asyncio.to_thread(
235
241
  render_latency_chart, csv_path, out_path,
236
- metric=metric, agg=agg, title=f"Per-message latency — {run_id}")
242
+ metric=metric, agg=agg, title=f"Per-message latency — {run_id}",
243
+ one_way_label=one_way_label)
237
244
  except ValueError as e:
238
245
  # No chartable points (e.g. recording produced only un-latencied rows).
239
246
  raise HTTPException(status_code=404, detail=str(e))
@@ -1,10 +1,10 @@
1
1
  """
2
2
  Per-run gzipped CSV recorder for individual inbound correlated messages —
3
3
  every ExecutionReport (ack, fill, amend/cancel reply) plus OrderCancelReject
4
- (35=9), opt-in via test.record_execs.
4
+ (35=9), opt-in via test.record_latency.
5
5
 
6
6
  CSV-direct: there is no database staging — rows stream straight to
7
- data/perf_execs/<run_id>.csv.gz and export serves the finished file as-is.
7
+ data/perf_latency/<run_id>.csv.gz and export serves the finished file as-is.
8
8
  Same non-blocking producer contract as PerfWriter: the event loop only does a
9
9
  put_nowait (drop+count when the bounded queue is full); all formatting and
10
10
  gzip/disk work happens on one daemon writer thread, so recording cannot add
@@ -26,26 +26,26 @@ logger = logging.getLogger(__name__)
26
26
 
27
27
  DEFAULT_MAX_QUEUE = 200_000
28
28
 
29
- EXEC_COLS = (
29
+ LATENCY_COLS = (
30
30
  "run_id", "corr_id", "clordid", "exec_id",
31
31
  "kind", # message kind / chart series key (engine-assigned):
32
32
  # client→venue: order / amend / cancel
33
33
  # venue→client: ack / fill / pending-replace / replace /
34
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)
35
+ "direction", # c2v (client→hub→venue) | v2c (venue→hub→client)
37
36
  "send_at", # wall clock of THIS message's send on the near leg (one-way x-axis)
38
37
  "recv_at", # wall clock of THIS message's receive on the far leg
39
38
  "req_send_at", # wall clock of the originating request's send (order/amend/cancel
40
39
  # 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
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
44
  "exec_type", "ord_status", "cum_qty", "leaves_qty", # tags 150 / 39 / 14 / 151 (forensics)
45
45
  )
46
46
 
47
47
 
48
- class ExecCsvWriter:
48
+ class LatencyCsvWriter:
49
49
  def __init__(self, path: str, max_queue: int = DEFAULT_MAX_QUEUE):
50
50
  self.path = path
51
51
  os.makedirs(os.path.dirname(path), exist_ok=True)
@@ -79,7 +79,7 @@ class ExecCsvWriter:
79
79
  try:
80
80
  with gzip.open(self.path, "wt", newline="") as fh:
81
81
  w = csv.writer(fh)
82
- w.writerow(EXEC_COLS)
82
+ w.writerow(LATENCY_COLS)
83
83
  while True:
84
84
  item = self._queue.get()
85
85
  if item is None:
@@ -1,6 +1,6 @@
1
1
  """
2
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 /
3
+ (test.record_latency). One plot line/series per message `kind` (ack / fill /
4
4
  replace / canceled / cancel-reject / …): x = order send time (run progression),
5
5
  y = per-message latency.
6
6
 
@@ -115,7 +115,7 @@ def _draw(ax, by_kind, agg: str, bucket_s: float, logy: bool, ylabel: str, title
115
115
  def render_latency_chart(csv_gz_path: str, out_path: str, *, metric: str = "one_way",
116
116
  agg: str = "both", bucket_s: float = 2.0,
117
117
  logy: bool = True, kinds: Optional[list[str]] = None,
118
- title: Optional[str] = None) -> str:
118
+ title: Optional[str] = None, one_way_label: Optional[str] = None) -> str:
119
119
  """Render a per-kind latency chart from a recorded run CSV to out_path (PNG).
120
120
 
121
121
  metric: 'one_way' (recv − send, this message's hub transit; x = its own send
@@ -146,7 +146,7 @@ def render_latency_chart(csv_gz_path: str, out_path: str, *, metric: str = "one_
146
146
  axes = fig.subplots(len(panels), 1, squeeze=False)[:, 0]
147
147
  base = title or "Per-message latency"
148
148
  for ax, (m, by_kind) in zip(axes, panels):
149
- label = _METRIC[m][2]
149
+ label = one_way_label if (m == "one_way" and one_way_label) else _METRIC[m][2]
150
150
  _draw(ax, by_kind, agg, bucket_s, logy, f"latency (ms) — {label}",
151
151
  base if len(panels) == 1 else f"{base} — {label}")
152
152
  fig.tight_layout()
@@ -26,11 +26,12 @@ import random
26
26
  import time
27
27
  import uuid
28
28
  from collections import deque
29
+ from datetime import datetime, timezone
29
30
  from typing import Optional
30
31
 
31
32
  from fixcore.message.message import Message
32
33
 
33
- from .exec_csv_writer import ExecCsvWriter
34
+ from .latency_csv_writer import LatencyCsvWriter
34
35
  from .fix_time import utc_timestamp
35
36
  from .perf_models import (
36
37
  RunConfig, RunStatus, LiveSnapshot, ClientLeg, VenueLeg, SnapshotErrors, LatencyStats,
@@ -48,7 +49,7 @@ T_ORDERQTY, T_ORDTYPE, T_PRICE, T_TIF, T_TRANSACTTIME = 38, 40, 44, 59, 60
48
49
  T_ORDERID, T_EXECID, T_EXECTRANSTYPE, T_EXECTYPE, T_ORDSTATUS = 37, 17, 20, 150, 39
49
50
  T_LASTQTY, T_LASTPX, T_CUMQTY, T_LEAVESQTY, T_AVGPX = 32, 31, 14, 151, 6
50
51
 
51
- # ExecType(150) → per-message latency-chart series label (see ExecCsvWriter).
52
+ # ExecType(150) → per-message latency-chart series label (see LatencyCsvWriter).
52
53
  _EXEC_KIND = {
53
54
  "0": "ack", "1": "fill", "2": "fill", "F": "fill",
54
55
  "E": "pending-replace", "5": "replace",
@@ -96,17 +97,40 @@ def _to_float(v) -> Optional[float]:
96
97
  return None
97
98
 
98
99
 
100
+ # Wire send-time tags for the tag-60 latency path (client/venue-only modes):
101
+ # TransactTime(60), falling back to the session SendingTime(52) which every
102
+ # message carries (e.g. 35=9 has no TransactTime).
103
+ _LAT_TS_TAGS = (60, 52)
104
+
105
+
106
+ def _parse_fix_ts(fields: dict) -> Optional[float]:
107
+ """The counterparty's wall-clock send time (epoch seconds, UTC) from
108
+ TransactTime(60) → SendingTime(52). Returns None if absent/unparseable.
109
+ Used only when FIXture sees one leg, so the send timestamp must come off the
110
+ wire — accuracy then depends on the two hosts' clocks being NTP/PTP-synced."""
111
+ for tag in _LAT_TS_TAGS:
112
+ val = fields.get(tag)
113
+ if not val:
114
+ continue
115
+ for fmt in ("%Y%m%d-%H:%M:%S.%f", "%Y%m%d-%H:%M:%S"):
116
+ try:
117
+ return datetime.strptime(val, fmt).replace(tzinfo=timezone.utc).timestamp()
118
+ except ValueError:
119
+ continue
120
+ return None
121
+
122
+
99
123
  class PerfRun:
100
124
  def __init__(self, run_id: str, config: RunConfig, sm: SessionManager, writer: PerfWriter,
101
125
  template_store=None, owner_uid: str = "",
102
126
  sender_comp_id: str = "", target_comp_id: str = "",
103
- execs_dir: Optional[str] = None):
127
+ latency_dir: Optional[str] = None):
104
128
  self.run_id = run_id
105
129
  self.config = config
106
130
  self._sm = sm
107
131
  self._writer = writer
108
- self._execs_dir = execs_dir
109
- self._exec_csv: Optional[ExecCsvWriter] = None
132
+ self._latency_dir = latency_dir
133
+ self._latency_csv: Optional[LatencyCsvWriter] = None
110
134
 
111
135
  self.status: str = "pending"
112
136
  self.started_at: Optional[float] = None
@@ -181,16 +205,23 @@ class PerfRun:
181
205
  self.status = "running"
182
206
  self.started_at = time.time()
183
207
  self._start_ns = time.perf_counter_ns()
184
- if self.config.test.record_execs:
185
- # Per-exec capture happens in the client-leg correlator; venue-only
186
- # runs have nothing to record.
187
- if not self._has_client:
188
- self.warnings.append("record_execs has no effect in venue mode (no client leg)")
189
- elif not self._execs_dir:
190
- self.warnings.append("record_execs unavailable (no exec CSV directory configured)")
208
+ # Wall-clock anchored to the same instant as _start_ns, so an inbound
209
+ # message's monotonic recv_perf_ns can be expressed as a UTC wall time
210
+ # (for the tag-60 latency path) without a second clock read per message.
211
+ self._start_wall = time.time()
212
+ if self.config.test.record_latency:
213
+ if not (self._has_client or self._has_venue):
214
+ self.warnings.append("record_latency has no effect with no client or venue leg")
215
+ elif not self._latency_dir:
216
+ self.warnings.append("record_latency unavailable (no latency CSV directory configured)")
191
217
  else:
192
- self._exec_csv = ExecCsvWriter(
193
- os.path.join(self._execs_dir, f"{self.run_id}.csv.gz"))
218
+ self._latency_csv = LatencyCsvWriter(
219
+ os.path.join(self._latency_dir, f"{self.run_id}.csv.gz"))
220
+ if not self._one_way:
221
+ self.warnings.append(
222
+ "single-leg run: per-message one-way latency uses the wire "
223
+ "send time (TransactTime 60 / SendingTime 52) — accurate only "
224
+ "if this host and the counterparty share a synced clock")
194
225
  if self.config.amend.enabled and not self._has_client:
195
226
  self.warnings.append(
196
227
  "amend injection has no effect in venue mode (no client leg); "
@@ -267,10 +298,10 @@ class PerfRun:
267
298
  self._client_sub.close()
268
299
  if self._venue_sub:
269
300
  self._venue_sub.close()
270
- if self._exec_csv is not None:
301
+ if self._latency_csv is not None:
271
302
  # Correlator is cancelled (no more enqueues); drain + gzip-finalize
272
303
  # off-loop — close() joins the writer thread.
273
- await asyncio.to_thread(self._exec_csv.close)
304
+ await asyncio.to_thread(self._latency_csv.close)
274
305
  self.status = "stopped" if self._stopped_by_user else "completed"
275
306
  self._build_snapshot()
276
307
  self._publish()
@@ -791,10 +822,10 @@ class PerfRun:
791
822
  send_at, recv_at, req_send_at, one_way_us, round_trip_us,
792
823
  exec_type="", ord_status="", cum_qty=None, leaves_qty=None) -> None:
793
824
  """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:
825
+ (opt-in via record_latency). Column order must match LATENCY_COLS."""
826
+ if self._latency_csv is None:
796
827
  return
797
- self._exec_csv.enqueue((
828
+ self._latency_csv.enqueue((
798
829
  self.run_id, corr, clordid, exec_id, kind, direction,
799
830
  send_at, recv_at, req_send_at, one_way_us, round_trip_us,
800
831
  exec_type, ord_status, cum_qty, leaves_qty,
@@ -805,7 +836,7 @@ class PerfRun:
805
836
  """Stamp a venue→client message's send time keyed by its ExecID, so the
806
837
  client correlator can compute one-way hub latency on receipt. No-op when
807
838
  not recording. Bounded: drop-oldest past the cap (age-swept too)."""
808
- if self._exec_csv is None:
839
+ if self._latency_csv is None:
809
840
  return
810
841
  eid = msg.get_field_or(self.config.exec_id_tag, "") or msg.get_field_or(T_EXECID, "")
811
842
  if not eid:
@@ -817,38 +848,58 @@ class PerfRun:
817
848
  sends[eid] = {"kind": kind, "send_ns": send_ns, "send_wall": time.time(),
818
849
  "corr": corr, "clordid": clordid}
819
850
 
851
+ def _recv_wall(self, recv_ns: int) -> float:
852
+ """Wall-clock (epoch secs) of an inbound message, anchored to the same
853
+ instant as its monotonic recv_perf_ns — for the tag-60 latency path."""
854
+ return self._start_wall + (recv_ns - self._start_ns) / 1e9
855
+
820
856
  def _record_c2v(self, of: dict, corr: str, recv_ns: int, kind: str) -> None:
821
857
  """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:
858
+ inbound side. Manager/loopback: one-way = venue recv − the client's send
859
+ (from _pending, one clock). Venue-only: one-way = our recv the
860
+ counterparty's wire send time (tag-60), needing a synced clock."""
861
+ if self._latency_csv is None:
825
862
  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
863
+ if self._one_way:
864
+ entry = self._pending.get(corr)
865
+ if entry is None:
866
+ return
867
+ ns_key = {"order": "sent_ns", "amend": "amend_sent_ns",
868
+ "cancel": "cancel_sent_ns"}[kind]
869
+ send_ns = entry.get(ns_key)
870
+ if not send_ns:
871
+ return
872
+ send_at = entry.get(ns_key[:-2] + "at") # *_sent_ns → *_sent_at
873
+ one_way_us = (recv_ns - send_ns) / 1000.0
874
+ else:
875
+ ts = _parse_fix_ts(of)
876
+ if ts is None:
877
+ return
878
+ send_at = ts
879
+ one_way_us = (self._recv_wall(recv_ns) - ts) * 1e6
835
880
  self._emit_crossing(
836
881
  corr=corr, clordid=of.get(T_CLORDID), exec_id=None, kind=kind,
837
882
  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)
883
+ req_send_at=send_at, one_way_us=one_way_us, round_trip_us=None)
840
884
 
841
885
  def _record_v2c(self, f: dict, corr, exec_id, now_ns: int, kind: str,
842
886
  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:
887
+ """Record a venue→client crossing at the client inbound side. one-way:
888
+ from the venue send registry (manager/loopback) or the counterparty's
889
+ wire send time (tag-60, client-only synced clock needed). round-trip:
890
+ from the originating request's send in _pending (always our own clock)."""
891
+ if self._latency_csv is None:
848
892
  return
849
893
  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
894
+ if vs:
895
+ one_way_us = (now_ns - vs["send_ns"]) / 1000.0
896
+ send_at = vs["send_wall"]
897
+ elif not self._one_way:
898
+ ts = _parse_fix_ts(f)
899
+ one_way_us = ((self._recv_wall(now_ns) - ts) * 1e6) if ts is not None else None
900
+ send_at = ts
901
+ else:
902
+ one_way_us = send_at = None
852
903
  rt_us = req_send_at = None
853
904
  ns_key = _KIND_REQ_NS.get(kind)
854
905
  if entry is not None and ns_key:
@@ -858,8 +909,7 @@ class PerfRun:
858
909
  req_send_at = entry.get(ns_key[:-2] + "at") # *_sent_ns → *_sent_at
859
910
  clordid = (entry.get("clordid") if entry else None) or (vs["clordid"] if vs else None)
860
911
  self._emit_crossing(
861
- corr=corr, clordid=clordid, exec_id=exec_id, kind=kind,
862
- direction="v2c" if self._one_way else "rt",
912
+ corr=corr, clordid=clordid, exec_id=exec_id, kind=kind, direction="v2c",
863
913
  send_at=send_at, recv_at=time.time(), req_send_at=req_send_at,
864
914
  one_way_us=one_way_us, round_trip_us=rt_us,
865
915
  exec_type=f.get(T_EXECTYPE, ""), ord_status=f.get(T_ORDSTATUS, ""),
@@ -1257,9 +1307,9 @@ class RunRegistry:
1257
1307
  TICKET_TTL_S = 30.0
1258
1308
  _TERMINAL_STATES = ("completed", "stopped", "error")
1259
1309
 
1260
- def __init__(self, writer: PerfWriter, execs_dir: Optional[str] = None):
1310
+ def __init__(self, writer: PerfWriter, latency_dir: Optional[str] = None):
1261
1311
  self._writer = writer
1262
- self._execs_dir = execs_dir
1312
+ self._latency_dir = latency_dir
1263
1313
  self._runs: dict[str, PerfRun] = {}
1264
1314
  self._tickets: dict[str, tuple[str, float]] = {}
1265
1315
 
@@ -1267,17 +1317,17 @@ class RunRegistry:
1267
1317
  owner_uid: str = "", sender_comp_id: str = "", target_comp_id: str = "") -> PerfRun:
1268
1318
  run_id = str(uuid.uuid4())
1269
1319
  run = PerfRun(run_id, config, sm, self._writer, template_store, owner_uid,
1270
- sender_comp_id, target_comp_id, execs_dir=self._execs_dir)
1320
+ sender_comp_id, target_comp_id, latency_dir=self._latency_dir)
1271
1321
  self._runs[run_id] = run
1272
1322
  run.start()
1273
1323
  return run
1274
1324
 
1275
- def exec_csv_path(self, run_id: str) -> Optional[str]:
1325
+ def latency_csv_path(self, run_id: str) -> Optional[str]:
1276
1326
  """Path to the run's per-exec csv.gz, or None if it never recorded.
1277
1327
  The file is complete (gzip footer written) only once the run is terminal."""
1278
- if not self._execs_dir:
1328
+ if not self._latency_dir:
1279
1329
  return None
1280
- path = os.path.join(self._execs_dir, f"{run_id}.csv.gz")
1330
+ path = os.path.join(self._latency_dir, f"{run_id}.csv.gz")
1281
1331
  return path if os.path.exists(path) else None
1282
1332
 
1283
1333
  def get(self, run_id: str) -> Optional[PerfRun]:
@@ -16,7 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  from typing import Literal, Optional
18
18
 
19
- from pydantic import BaseModel, Field, model_validator
19
+ from pydantic import AliasChoices, BaseModel, Field, model_validator
20
20
 
21
21
  RunMode = Literal["loopback", "manager", "client", "venue"]
22
22
  SaturationPolicy = Literal["pause", "drop_oldest"]
@@ -51,10 +51,14 @@ class TestConfig(BaseModel):
51
51
  max_pending: int = Field(default=100_000, gt=0)
52
52
  on_saturation: SaturationPolicy = "pause"
53
53
  output: str = "results"
54
- # Record every inbound ExecutionReport (ack + each fill) to a per-run
55
- # csv.gz for detailed latency analysis. Off by default: ~(1+fills_per_order)×
56
- # the per-order row volume on disk.
57
- record_execs: bool = False
54
+ # Record every message crossing (orders, amends, cancels + acks, fills,
55
+ # replies) to a per-run csv.gz the source for the per-message latency
56
+ # charts. On by default; ~(2+fills_per_order)× the per-order row volume on
57
+ # disk. Turn off for pure throughput runs that don't need the latency CSV.
58
+ # Accepts the legacy key `record_execs` so saved configs / run history from
59
+ # before the rename still load.
60
+ record_latency: bool = Field(
61
+ default=True, validation_alias=AliasChoices("record_latency", "record_execs"))
58
62
 
59
63
 
60
64
  class AmendConfig(BaseModel):