fixtureqa 0.4.19__tar.gz → 0.4.21__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.
- {fixtureqa-0.4.19/fixtureqa.egg-info → fixtureqa-0.4.21}/PKG-INFO +1 -1
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/app.py +1 -1
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/routers/perf.py +27 -22
- fixtureqa-0.4.19/fixture/core/exec_csv_writer.py → fixtureqa-0.4.21/fixture/core/latency_csv_writer.py +5 -5
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/perf_charts.py +38 -11
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/perf_engine.py +25 -25
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/perf_models.py +9 -5
- fixtureqa-0.4.19/fixture/static/assets/index-BSuHxk6b.js → fixtureqa-0.4.21/fixture/static/assets/index-BzzfsRz2.js +2 -2
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/static/index.html +1 -1
- {fixtureqa-0.4.19 → fixtureqa-0.4.21/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixtureqa.egg-info/SOURCES.txt +2 -2
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/pyproject.toml +1 -1
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_perf_api.py +25 -15
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_perf_engine.py +44 -27
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_perf_models.py +10 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/LICENSE +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/README.md +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/__init__.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/__main__.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/deps.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/auth.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/events.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/models.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/perf_payload.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/perf_stats.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/scenario_runner.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/session.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/server.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/static/assets/index-o_l1KBYd.css +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/setup.cfg +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_auth.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_config_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_health.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_inbound.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_message_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_scenarios.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_sessions.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_templates.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.21}/tests/test_ws.py +0 -0
|
@@ -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,
|
|
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
|
|
184
|
-
# CSV-direct: the run streams per-
|
|
185
|
-
# gzip footer is only written when the run finishes, so a live file
|
|
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
|
|
190
|
-
path = reg.
|
|
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
|
|
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}
|
|
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,47 +200,52 @@ 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
|
-
|
|
204
|
-
if
|
|
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(
|
|
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|
|
|
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")
|
|
214
214
|
async def run_chart(run_id: str, reg: REG, _admin: Admin,
|
|
215
|
-
metric: str = Query("one_way"), agg: str = Query("both")
|
|
215
|
+
metric: str = Query("one_way"), agg: str = Query("both"),
|
|
216
|
+
scale: str = Query("log")):
|
|
216
217
|
"""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
|
|
218
|
-
once the run finishes. Needs
|
|
219
|
-
|
|
218
|
+
off-loop on first request and cached per (metric, agg, scale) — the CSV is
|
|
219
|
+
immutable once the run finishes. Needs record_latency; 404 otherwise, 409
|
|
220
|
+
while live."""
|
|
221
|
+
if (metric not in ("one_way", "round_trip", "both")
|
|
222
|
+
or agg not in ("scatter", "percentile", "both")
|
|
223
|
+
or scale not in ("log", "linear")):
|
|
220
224
|
raise HTTPException(status_code=422,
|
|
221
|
-
detail="metric must be one_way|round_trip|both;
|
|
225
|
+
detail="metric must be one_way|round_trip|both; "
|
|
226
|
+
"agg must be scatter|percentile|both; scale must be log|linear")
|
|
222
227
|
if not reg.exists(run_id):
|
|
223
228
|
raise HTTPException(status_code=404, detail="Run not found")
|
|
224
229
|
if _run_is_live(reg, run_id):
|
|
225
230
|
raise HTTPException(status_code=409,
|
|
226
231
|
detail="Run still active — the latency chart is rendered when the run finishes")
|
|
227
|
-
csv_path = reg.
|
|
232
|
+
csv_path = reg.latency_csv_path(run_id)
|
|
228
233
|
if csv_path is None or not os.path.exists(csv_path):
|
|
229
234
|
raise HTTPException(status_code=404,
|
|
230
|
-
detail="No per-message recording for this run (enable
|
|
235
|
+
detail="No per-message recording for this run (enable record_latency before starting)")
|
|
231
236
|
# Single-leg runs (client/venue) derive one-way from the wire send time;
|
|
232
237
|
# label it so the chart can't be misread as the single-clock measurement.
|
|
233
238
|
st = reg.get_status(run_id)
|
|
234
239
|
one_way_label = None
|
|
235
240
|
if st is not None and st.mode not in ("loopback", "manager"):
|
|
236
241
|
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"
|
|
242
|
+
out_path = f"{csv_path[:-len('.csv.gz')]}_{metric}_{agg}_{scale}.png"
|
|
238
243
|
if not os.path.exists(out_path):
|
|
239
244
|
try:
|
|
240
245
|
await asyncio.to_thread(
|
|
241
246
|
render_latency_chart, csv_path, out_path,
|
|
242
|
-
metric=metric, agg=agg,
|
|
243
|
-
one_way_label=one_way_label)
|
|
247
|
+
metric=metric, agg=agg, logy=(scale == "log"),
|
|
248
|
+
title=f"Per-message latency — {run_id}", one_way_label=one_way_label)
|
|
244
249
|
except ValueError as e:
|
|
245
250
|
# No chartable points (e.g. recording produced only un-latencied rows).
|
|
246
251
|
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.
|
|
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/
|
|
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,7 +26,7 @@ logger = logging.getLogger(__name__)
|
|
|
26
26
|
|
|
27
27
|
DEFAULT_MAX_QUEUE = 200_000
|
|
28
28
|
|
|
29
|
-
|
|
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
|
|
@@ -45,7 +45,7 @@ EXEC_COLS = (
|
|
|
45
45
|
)
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
class
|
|
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(
|
|
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.
|
|
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
|
|
|
@@ -53,10 +53,25 @@ def _pctl(sorted_vals: list[float], q: float) -> float:
|
|
|
53
53
|
return sorted_vals[i]
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
def _humanize_us(v, _pos=None) -> str:
|
|
57
|
+
"""Format a microsecond value as a self-describing tick label, picking the
|
|
58
|
+
unit per tick so a log axis spanning µs→s reads '500µs / 1ms / 10ms / 1s'
|
|
59
|
+
instead of 10^1 / 10^2. Handles negatives (tag-60 clock skew)."""
|
|
60
|
+
if v == 0:
|
|
61
|
+
return "0"
|
|
62
|
+
sign, a = ("-" if v < 0 else ""), abs(v)
|
|
63
|
+
if a < 1_000:
|
|
64
|
+
return f"{sign}{a:g}µs"
|
|
65
|
+
if a < 1_000_000:
|
|
66
|
+
return f"{sign}{a / 1_000:g}ms"
|
|
67
|
+
return f"{sign}{a / 1_000_000:g}s"
|
|
68
|
+
|
|
69
|
+
|
|
56
70
|
def _read_points(csv_gz_path: str, metric: str):
|
|
57
|
-
"""Group (x_seconds_since_start,
|
|
58
|
-
metric ('one_way' or 'round_trip'): y = that latency column
|
|
59
|
-
|
|
71
|
+
"""Group (x_seconds_since_start, y_microseconds) points by kind from the gz
|
|
72
|
+
CSV for one metric ('one_way' or 'round_trip'): y = that latency column (raw
|
|
73
|
+
µs — the axis humanizes per tick), x = its send column rebased to seconds
|
|
74
|
+
since the first send."""
|
|
60
75
|
y_col, x_col, _ = _METRIC[metric]
|
|
61
76
|
by_kind: dict[str, list[tuple[float, float]]] = defaultdict(list)
|
|
62
77
|
t0: Optional[float] = None
|
|
@@ -68,11 +83,11 @@ def _read_points(csv_gz_path: str, metric: str):
|
|
|
68
83
|
continue
|
|
69
84
|
try:
|
|
70
85
|
x = float(xraw)
|
|
71
|
-
|
|
86
|
+
y_us = float(yraw)
|
|
72
87
|
except ValueError:
|
|
73
88
|
continue
|
|
74
89
|
t0 = x if t0 is None else min(t0, x)
|
|
75
|
-
by_kind[r.get("kind") or "unknown"].append((x,
|
|
90
|
+
by_kind[r.get("kind") or "unknown"].append((x, y_us))
|
|
76
91
|
if t0 is not None:
|
|
77
92
|
for pts in by_kind.values():
|
|
78
93
|
pts[:] = [(x - t0, y) for (x, y) in pts]
|
|
@@ -98,15 +113,25 @@ def _draw(ax, by_kind, agg: str, bucket_s: float, logy: bool, ylabel: str, title
|
|
|
98
113
|
color=color, label=None if agg == "both" else kind,
|
|
99
114
|
edgecolors="none")
|
|
100
115
|
if agg in ("percentile", "both"):
|
|
101
|
-
bx, p50, p99 = _bucketed(pts, bucket_s)
|
|
116
|
+
bx, p50, p99, pmax = _bucketed(pts, bucket_s)
|
|
102
117
|
if bx:
|
|
103
118
|
ax.plot(bx, p50, color=color, lw=1.6, label=f"{kind} p50")
|
|
104
119
|
ax.plot(bx, p99, color=color, lw=1.0, ls="--", alpha=0.7,
|
|
105
120
|
label=f"{kind} p99")
|
|
121
|
+
# p100 / worst-case per bucket — the outlier envelope (faint dots)
|
|
122
|
+
ax.plot(bx, pmax, color=color, lw=0.8, ls=":", alpha=0.55,
|
|
123
|
+
label=f"{kind} max")
|
|
124
|
+
from matplotlib.ticker import FuncFormatter, LogLocator, NullFormatter
|
|
106
125
|
ax.set_xlabel("run time (s, from first send)")
|
|
107
126
|
ax.set_ylabel(ylabel + (" [log]" if logy else ""))
|
|
108
127
|
if logy:
|
|
109
128
|
ax.set_yscale("log")
|
|
129
|
+
# 1-2-5 per decade as labelled ticks (so narrow ranges still get several
|
|
130
|
+
# labels); finer minors as unlabelled gridlines.
|
|
131
|
+
ax.yaxis.set_major_locator(LogLocator(base=10, subs=(1.0, 2.0, 5.0)))
|
|
132
|
+
ax.yaxis.set_minor_locator(LogLocator(base=10, subs=tuple(x / 10 for x in range(10, 100))))
|
|
133
|
+
ax.yaxis.set_minor_formatter(NullFormatter())
|
|
134
|
+
ax.yaxis.set_major_formatter(FuncFormatter(_humanize_us))
|
|
110
135
|
ax.grid(True, which="both", ls=":", alpha=0.4)
|
|
111
136
|
ax.legend(loc="upper left", fontsize=8, ncol=2, framealpha=0.85)
|
|
112
137
|
ax.set_title(title)
|
|
@@ -147,7 +172,7 @@ def render_latency_chart(csv_gz_path: str, out_path: str, *, metric: str = "one_
|
|
|
147
172
|
base = title or "Per-message latency"
|
|
148
173
|
for ax, (m, by_kind) in zip(axes, panels):
|
|
149
174
|
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
|
|
175
|
+
_draw(ax, by_kind, agg, bucket_s, logy, f"latency — {label}",
|
|
151
176
|
base if len(panels) == 1 else f"{base} — {label}")
|
|
152
177
|
fig.tight_layout()
|
|
153
178
|
fig.savefig(out_path)
|
|
@@ -155,16 +180,18 @@ def render_latency_chart(csv_gz_path: str, out_path: str, *, metric: str = "one_
|
|
|
155
180
|
|
|
156
181
|
|
|
157
182
|
def _bucketed(sorted_pts: list[tuple[float, float]], bucket_s: float):
|
|
158
|
-
"""Per-bucket (center_x, p50, p99) over fixed-width time buckets
|
|
183
|
+
"""Per-bucket (center_x, p50, p99, max) over fixed-width time buckets
|
|
184
|
+
(max = p100, the worst-case latency in the bucket)."""
|
|
159
185
|
if bucket_s <= 0:
|
|
160
186
|
bucket_s = 1.0
|
|
161
187
|
buckets: dict[int, list[float]] = defaultdict(list)
|
|
162
188
|
for x, y in sorted_pts:
|
|
163
189
|
buckets[int(x // bucket_s)].append(y)
|
|
164
|
-
bx, p50, p99 = [], [], []
|
|
190
|
+
bx, p50, p99, pmax = [], [], [], []
|
|
165
191
|
for b in sorted(buckets):
|
|
166
192
|
ys = sorted(buckets[b])
|
|
167
193
|
bx.append((b + 0.5) * bucket_s)
|
|
168
194
|
p50.append(_pctl(ys, 0.50))
|
|
169
195
|
p99.append(_pctl(ys, 0.99))
|
|
170
|
-
|
|
196
|
+
pmax.append(ys[-1])
|
|
197
|
+
return bx, p50, p99, pmax
|
|
@@ -31,7 +31,7 @@ from typing import Optional
|
|
|
31
31
|
|
|
32
32
|
from fixcore.message.message import Message
|
|
33
33
|
|
|
34
|
-
from .
|
|
34
|
+
from .latency_csv_writer import LatencyCsvWriter
|
|
35
35
|
from .fix_time import utc_timestamp
|
|
36
36
|
from .perf_models import (
|
|
37
37
|
RunConfig, RunStatus, LiveSnapshot, ClientLeg, VenueLeg, SnapshotErrors, LatencyStats,
|
|
@@ -49,7 +49,7 @@ T_ORDERQTY, T_ORDTYPE, T_PRICE, T_TIF, T_TRANSACTTIME = 38, 40, 44, 59, 60
|
|
|
49
49
|
T_ORDERID, T_EXECID, T_EXECTRANSTYPE, T_EXECTYPE, T_ORDSTATUS = 37, 17, 20, 150, 39
|
|
50
50
|
T_LASTQTY, T_LASTPX, T_CUMQTY, T_LEAVESQTY, T_AVGPX = 32, 31, 14, 151, 6
|
|
51
51
|
|
|
52
|
-
# ExecType(150) → per-message latency-chart series label (see
|
|
52
|
+
# ExecType(150) → per-message latency-chart series label (see LatencyCsvWriter).
|
|
53
53
|
_EXEC_KIND = {
|
|
54
54
|
"0": "ack", "1": "fill", "2": "fill", "F": "fill",
|
|
55
55
|
"E": "pending-replace", "5": "replace",
|
|
@@ -124,13 +124,13 @@ class PerfRun:
|
|
|
124
124
|
def __init__(self, run_id: str, config: RunConfig, sm: SessionManager, writer: PerfWriter,
|
|
125
125
|
template_store=None, owner_uid: str = "",
|
|
126
126
|
sender_comp_id: str = "", target_comp_id: str = "",
|
|
127
|
-
|
|
127
|
+
latency_dir: Optional[str] = None):
|
|
128
128
|
self.run_id = run_id
|
|
129
129
|
self.config = config
|
|
130
130
|
self._sm = sm
|
|
131
131
|
self._writer = writer
|
|
132
|
-
self.
|
|
133
|
-
self.
|
|
132
|
+
self._latency_dir = latency_dir
|
|
133
|
+
self._latency_csv: Optional[LatencyCsvWriter] = None
|
|
134
134
|
|
|
135
135
|
self.status: str = "pending"
|
|
136
136
|
self.started_at: Optional[float] = None
|
|
@@ -209,14 +209,14 @@ class PerfRun:
|
|
|
209
209
|
# message's monotonic recv_perf_ns can be expressed as a UTC wall time
|
|
210
210
|
# (for the tag-60 latency path) without a second clock read per message.
|
|
211
211
|
self._start_wall = time.time()
|
|
212
|
-
if self.config.test.
|
|
212
|
+
if self.config.test.record_latency:
|
|
213
213
|
if not (self._has_client or self._has_venue):
|
|
214
|
-
self.warnings.append("
|
|
215
|
-
elif not self.
|
|
216
|
-
self.warnings.append("
|
|
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)")
|
|
217
217
|
else:
|
|
218
|
-
self.
|
|
219
|
-
os.path.join(self.
|
|
218
|
+
self._latency_csv = LatencyCsvWriter(
|
|
219
|
+
os.path.join(self._latency_dir, f"{self.run_id}.csv.gz"))
|
|
220
220
|
if not self._one_way:
|
|
221
221
|
self.warnings.append(
|
|
222
222
|
"single-leg run: per-message one-way latency uses the wire "
|
|
@@ -298,10 +298,10 @@ class PerfRun:
|
|
|
298
298
|
self._client_sub.close()
|
|
299
299
|
if self._venue_sub:
|
|
300
300
|
self._venue_sub.close()
|
|
301
|
-
if self.
|
|
301
|
+
if self._latency_csv is not None:
|
|
302
302
|
# Correlator is cancelled (no more enqueues); drain + gzip-finalize
|
|
303
303
|
# off-loop — close() joins the writer thread.
|
|
304
|
-
await asyncio.to_thread(self.
|
|
304
|
+
await asyncio.to_thread(self._latency_csv.close)
|
|
305
305
|
self.status = "stopped" if self._stopped_by_user else "completed"
|
|
306
306
|
self._build_snapshot()
|
|
307
307
|
self._publish()
|
|
@@ -822,10 +822,10 @@ class PerfRun:
|
|
|
822
822
|
send_at, recv_at, req_send_at, one_way_us, round_trip_us,
|
|
823
823
|
exec_type="", ord_status="", cum_qty=None, leaves_qty=None) -> None:
|
|
824
824
|
"""Append one message-crossing row to the per-message latency CSV
|
|
825
|
-
(opt-in via
|
|
826
|
-
if self.
|
|
825
|
+
(opt-in via record_latency). Column order must match LATENCY_COLS."""
|
|
826
|
+
if self._latency_csv is None:
|
|
827
827
|
return
|
|
828
|
-
self.
|
|
828
|
+
self._latency_csv.enqueue((
|
|
829
829
|
self.run_id, corr, clordid, exec_id, kind, direction,
|
|
830
830
|
send_at, recv_at, req_send_at, one_way_us, round_trip_us,
|
|
831
831
|
exec_type, ord_status, cum_qty, leaves_qty,
|
|
@@ -836,7 +836,7 @@ class PerfRun:
|
|
|
836
836
|
"""Stamp a venue→client message's send time keyed by its ExecID, so the
|
|
837
837
|
client correlator can compute one-way hub latency on receipt. No-op when
|
|
838
838
|
not recording. Bounded: drop-oldest past the cap (age-swept too)."""
|
|
839
|
-
if self.
|
|
839
|
+
if self._latency_csv is None:
|
|
840
840
|
return
|
|
841
841
|
eid = msg.get_field_or(self.config.exec_id_tag, "") or msg.get_field_or(T_EXECID, "")
|
|
842
842
|
if not eid:
|
|
@@ -858,7 +858,7 @@ class PerfRun:
|
|
|
858
858
|
inbound side. Manager/loopback: one-way = venue recv − the client's send
|
|
859
859
|
(from _pending, one clock). Venue-only: one-way = our recv − the
|
|
860
860
|
counterparty's wire send time (tag-60), needing a synced clock."""
|
|
861
|
-
if self.
|
|
861
|
+
if self._latency_csv is None:
|
|
862
862
|
return
|
|
863
863
|
if self._one_way:
|
|
864
864
|
entry = self._pending.get(corr)
|
|
@@ -888,7 +888,7 @@ class PerfRun:
|
|
|
888
888
|
from the venue send registry (manager/loopback) or the counterparty's
|
|
889
889
|
wire send time (tag-60, client-only — synced clock needed). round-trip:
|
|
890
890
|
from the originating request's send in _pending (always our own clock)."""
|
|
891
|
-
if self.
|
|
891
|
+
if self._latency_csv is None:
|
|
892
892
|
return
|
|
893
893
|
vs = self._venue_sends.pop(exec_id, None) if exec_id else None
|
|
894
894
|
if vs:
|
|
@@ -1307,9 +1307,9 @@ class RunRegistry:
|
|
|
1307
1307
|
TICKET_TTL_S = 30.0
|
|
1308
1308
|
_TERMINAL_STATES = ("completed", "stopped", "error")
|
|
1309
1309
|
|
|
1310
|
-
def __init__(self, writer: PerfWriter,
|
|
1310
|
+
def __init__(self, writer: PerfWriter, latency_dir: Optional[str] = None):
|
|
1311
1311
|
self._writer = writer
|
|
1312
|
-
self.
|
|
1312
|
+
self._latency_dir = latency_dir
|
|
1313
1313
|
self._runs: dict[str, PerfRun] = {}
|
|
1314
1314
|
self._tickets: dict[str, tuple[str, float]] = {}
|
|
1315
1315
|
|
|
@@ -1317,17 +1317,17 @@ class RunRegistry:
|
|
|
1317
1317
|
owner_uid: str = "", sender_comp_id: str = "", target_comp_id: str = "") -> PerfRun:
|
|
1318
1318
|
run_id = str(uuid.uuid4())
|
|
1319
1319
|
run = PerfRun(run_id, config, sm, self._writer, template_store, owner_uid,
|
|
1320
|
-
sender_comp_id, target_comp_id,
|
|
1320
|
+
sender_comp_id, target_comp_id, latency_dir=self._latency_dir)
|
|
1321
1321
|
self._runs[run_id] = run
|
|
1322
1322
|
run.start()
|
|
1323
1323
|
return run
|
|
1324
1324
|
|
|
1325
|
-
def
|
|
1325
|
+
def latency_csv_path(self, run_id: str) -> Optional[str]:
|
|
1326
1326
|
"""Path to the run's per-exec csv.gz, or None if it never recorded.
|
|
1327
1327
|
The file is complete (gzip footer written) only once the run is terminal."""
|
|
1328
|
-
if not self.
|
|
1328
|
+
if not self._latency_dir:
|
|
1329
1329
|
return None
|
|
1330
|
-
path = os.path.join(self.
|
|
1330
|
+
path = os.path.join(self._latency_dir, f"{run_id}.csv.gz")
|
|
1331
1331
|
return path if os.path.exists(path) else None
|
|
1332
1332
|
|
|
1333
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
|
|
55
|
-
# csv.gz
|
|
56
|
-
# the per-order row volume on
|
|
57
|
-
|
|
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):
|