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.
- {fixtureqa-0.4.18/fixtureqa.egg-info → fixtureqa-0.4.20}/PKG-INFO +1 -1
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/app.py +1 -1
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/perf.py +23 -16
- fixtureqa-0.4.18/fixture/core/exec_csv_writer.py → fixtureqa-0.4.20/fixture/core/latency_csv_writer.py +10 -10
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/perf_charts.py +3 -3
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/perf_engine.py +99 -49
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/perf_models.py +9 -5
- fixtureqa-0.4.18/fixture/static/assets/index-BSuHxk6b.js → fixtureqa-0.4.20/fixture/static/assets/index-BhxUj8V-.js +2 -2
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/static/index.html +1 -1
- {fixtureqa-0.4.18 → fixtureqa-0.4.20/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixtureqa.egg-info/SOURCES.txt +2 -2
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/pyproject.toml +1 -1
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_perf_api.py +22 -14
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_perf_engine.py +68 -30
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_perf_models.py +10 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/LICENSE +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/README.md +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/__init__.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/__main__.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/deps.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/auth.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/events.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/models.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/perf_payload.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/perf_stats.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/scenario_runner.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/session.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/server.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/static/assets/index-o_l1KBYd.css +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/setup.cfg +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_auth.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_config_store.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_health.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_inbound.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_message_store.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_scenarios.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_sessions.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/tests/test_templates.py +0 -0
- {fixtureqa-0.4.18 → fixtureqa-0.4.20}/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,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
|
-
|
|
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")
|
|
@@ -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
|
|
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.
|
|
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
|
|
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.
|
|
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,26 +26,26 @@ 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
|
|
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)
|
|
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", #
|
|
42
|
-
# monotonic clock
|
|
43
|
-
|
|
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
|
|
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
|
|
|
@@ -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 .
|
|
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
|
|
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
|
-
|
|
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.
|
|
109
|
-
self.
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
self.warnings.append("
|
|
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.
|
|
193
|
-
os.path.join(self.
|
|
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.
|
|
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.
|
|
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
|
|
795
|
-
if self.
|
|
825
|
+
(opt-in via record_latency). Column order must match LATENCY_COLS."""
|
|
826
|
+
if self._latency_csv is None:
|
|
796
827
|
return
|
|
797
|
-
self.
|
|
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.
|
|
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
|
|
823
|
-
|
|
824
|
-
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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=
|
|
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
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
if self.
|
|
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
|
-
|
|
851
|
-
|
|
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,
|
|
1310
|
+
def __init__(self, writer: PerfWriter, latency_dir: Optional[str] = None):
|
|
1261
1311
|
self._writer = writer
|
|
1262
|
-
self.
|
|
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,
|
|
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
|
|
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.
|
|
1328
|
+
if not self._latency_dir:
|
|
1279
1329
|
return None
|
|
1280
|
-
path = os.path.join(self.
|
|
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
|
|
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):
|