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