fixtureqa 0.4.19__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.19/fixtureqa.egg-info → fixtureqa-0.4.20}/PKG-INFO +1 -1
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/app.py +1 -1
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/routers/perf.py +15 -15
- fixtureqa-0.4.19/fixture/core/exec_csv_writer.py → fixtureqa-0.4.20/fixture/core/latency_csv_writer.py +5 -5
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/perf_charts.py +1 -1
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/perf_engine.py +25 -25
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/perf_models.py +9 -5
- fixtureqa-0.4.19/fixture/static/assets/index-BSuHxk6b.js → fixtureqa-0.4.20/fixture/static/assets/index-BhxUj8V-.js +2 -2
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/static/index.html +1 -1
- {fixtureqa-0.4.19 → fixtureqa-0.4.20/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixtureqa.egg-info/SOURCES.txt +2 -2
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/pyproject.toml +1 -1
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_perf_api.py +22 -14
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_perf_engine.py +27 -27
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_perf_models.py +10 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/LICENSE +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/README.md +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/__init__.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/__main__.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/deps.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/auth.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/events.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/models.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/perf_payload.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/perf_stats.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/scenario_runner.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/session.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/server.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/static/assets/index-o_l1KBYd.css +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/setup.cfg +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_auth.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_config_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_health.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_inbound.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_message_store.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_scenarios.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_sessions.py +0 -0
- {fixtureqa-0.4.19 → fixtureqa-0.4.20}/tests/test_templates.py +0 -0
- {fixtureqa-0.4.19 → 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,10 +224,10 @@ 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
231
|
# Single-leg runs (client/venue) derive one-way from the wire send time;
|
|
232
232
|
# label it so the chart can't be misread as the single-clock measurement.
|
|
233
233
|
st = reg.get_status(run_id)
|
|
@@ -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
|
|
|
@@ -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):
|