fixtureqa 0.4.21__tar.gz → 0.4.23__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.21/fixtureqa.egg-info → fixtureqa-0.4.23}/PKG-INFO +1 -1
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/messages.py +39 -14
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/message_store.py +57 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/models.py +5 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/perf_engine.py +33 -10
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/perf_models.py +4 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/perf_stats.py +24 -1
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/session.py +5 -1
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/session_manager.py +17 -0
- fixtureqa-0.4.23/fixture/static/assets/index-B3ccKxdv.js +102 -0
- fixtureqa-0.4.21/fixture/static/assets/index-o_l1KBYd.css → fixtureqa-0.4.23/fixture/static/assets/index-meqdijDf.css +1 -1
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/static/index.html +2 -2
- {fixtureqa-0.4.21 → fixtureqa-0.4.23/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixtureqa.egg-info/SOURCES.txt +3 -2
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/pyproject.toml +1 -1
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_message_store.py +56 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_perf_engine.py +38 -0
- fixtureqa-0.4.23/tests/test_session_auto_response.py +42 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_sessions.py +50 -0
- fixtureqa-0.4.21/fixture/static/assets/index-BzzfsRz2.js +0 -102
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/LICENSE +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/README.md +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/__init__.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/__main__.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/app.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/deps.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/perf.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/auth.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/events.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/latency_csv_writer.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/perf_charts.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/perf_payload.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/scenario_runner.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/server.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/setup.cfg +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_auth.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_config_store.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_health.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_inbound.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_perf_api.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_scenarios.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_templates.py +0 -0
- {fixtureqa-0.4.21 → fixtureqa-0.4.23}/tests/test_ws.py +0 -0
|
@@ -4,6 +4,7 @@ from typing import Annotated, Optional
|
|
|
4
4
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
5
5
|
from fastapi.responses import StreamingResponse
|
|
6
6
|
|
|
7
|
+
from ...core.message_store import MessageStore
|
|
7
8
|
from ...core.models import SessionConfig
|
|
8
9
|
from ...core.session_manager import SessionManager
|
|
9
10
|
from ...core.user_store import User
|
|
@@ -16,6 +17,20 @@ SM = Annotated[SessionManager, Depends(get_session_manager)]
|
|
|
16
17
|
OwnedSession = Annotated[SessionConfig, Depends(get_owned_session)]
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
def _to_response(e) -> MessageEntryResponse:
|
|
21
|
+
return MessageEntryResponse(
|
|
22
|
+
id=e.id,
|
|
23
|
+
direction=e.direction,
|
|
24
|
+
admin=e.admin,
|
|
25
|
+
seq_num=e.seq_num,
|
|
26
|
+
msg_type=e.msg_type,
|
|
27
|
+
msg_type_name=e.msg_type_name,
|
|
28
|
+
ts=e.timestamp.isoformat(),
|
|
29
|
+
fields={str(k): v for k, v in e.fields.items()},
|
|
30
|
+
raw=e.raw,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
19
34
|
@router.get("/{session_id}/messages", response_model=list[MessageEntryResponse])
|
|
20
35
|
def get_messages(
|
|
21
36
|
session_id: str,
|
|
@@ -44,20 +59,30 @@ def get_messages(
|
|
|
44
59
|
before_id=before_id,
|
|
45
60
|
)
|
|
46
61
|
|
|
47
|
-
return [
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
return [_to_response(e) for e in entries]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@router.get("/{session_id}/messages/search", response_model=list[MessageEntryResponse])
|
|
66
|
+
def search_messages(
|
|
67
|
+
session_id: str,
|
|
68
|
+
_cfg: OwnedSession,
|
|
69
|
+
sm: SM,
|
|
70
|
+
q: str = Query(..., min_length=1, description="substring or regex query over raw FIX"),
|
|
71
|
+
regex: bool = Query(default=False, description="treat q as a regular expression"),
|
|
72
|
+
limit: int = Query(default=1000, ge=1, le=5000),
|
|
73
|
+
):
|
|
74
|
+
"""Search the session's full messages.db history (not just the loaded page).
|
|
75
|
+
Matches against the raw FIX with SOH normalised to '|'. Returns the most
|
|
76
|
+
recent `limit` matches, oldest-first."""
|
|
77
|
+
writer = sm.message_writer
|
|
78
|
+
if writer is None:
|
|
79
|
+
return [] # message persistence disabled globally — nothing in messages.db
|
|
80
|
+
store = MessageStore(session_id, writer)
|
|
81
|
+
try:
|
|
82
|
+
entries = store.search_entries(q, regex=regex, limit=limit)
|
|
83
|
+
except ValueError as e:
|
|
84
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
85
|
+
return [_to_response(e) for e in entries]
|
|
61
86
|
|
|
62
87
|
|
|
63
88
|
@router.get("/{session_id}/messages/export")
|
|
@@ -12,6 +12,7 @@ inserts so the I/O thread is never blocked by SQLite.
|
|
|
12
12
|
import json
|
|
13
13
|
import logging
|
|
14
14
|
import queue
|
|
15
|
+
import re
|
|
15
16
|
import sqlite3
|
|
16
17
|
import threading
|
|
17
18
|
from concurrent.futures import Future
|
|
@@ -78,6 +79,12 @@ def _entry_to_row(session_id: str, entry: LogEntry) -> tuple:
|
|
|
78
79
|
)
|
|
79
80
|
|
|
80
81
|
|
|
82
|
+
def _like_escape(s: str) -> str:
|
|
83
|
+
"""Escape LIKE wildcards so a substring query matches literally (paired
|
|
84
|
+
with ESCAPE '\\' in the SQL)."""
|
|
85
|
+
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
86
|
+
|
|
87
|
+
|
|
81
88
|
def _row_to_entry(row) -> LogEntry:
|
|
82
89
|
row_id, _, ts, direction, admin, seq_num, mt, mt_name, raw, fields_json = row
|
|
83
90
|
return LogEntry(
|
|
@@ -364,6 +371,56 @@ class MessageStore:
|
|
|
364
371
|
|
|
365
372
|
return [_row_to_entry(r) for r in rows]
|
|
366
373
|
|
|
374
|
+
def search_entries(
|
|
375
|
+
self,
|
|
376
|
+
query: str,
|
|
377
|
+
*,
|
|
378
|
+
regex: bool = False,
|
|
379
|
+
limit: int = 1000,
|
|
380
|
+
) -> list[LogEntry]:
|
|
381
|
+
"""Search this session's persisted messages by raw content — the whole
|
|
382
|
+
messages.db history for the session, not just a loaded page.
|
|
383
|
+
|
|
384
|
+
The haystack is the raw FIX with SOH normalised to '|' (so a typed
|
|
385
|
+
'tag=value' matches and the pattern lines up with the on-screen view).
|
|
386
|
+
text mode: case-insensitive substring via LIKE. regex mode: Python re
|
|
387
|
+
(case-insensitive) via a per-connection REGEXP function. Returns up to
|
|
388
|
+
`limit` most-recent matches, oldest-first. Raises ValueError on an
|
|
389
|
+
invalid regex (the route maps it to a 400)."""
|
|
390
|
+
query = query.strip()
|
|
391
|
+
if not query:
|
|
392
|
+
return []
|
|
393
|
+
|
|
394
|
+
hay = "replace(raw, char(1), '|')"
|
|
395
|
+
select = (
|
|
396
|
+
"SELECT id, session_id, ts, direction, admin, seq_num, "
|
|
397
|
+
"msg_type, msg_type_name, raw, fields_json FROM messages "
|
|
398
|
+
"WHERE session_id = ?"
|
|
399
|
+
)
|
|
400
|
+
params: list = [self._session_id]
|
|
401
|
+
|
|
402
|
+
with closing(_connect(self._db_path)) as conn:
|
|
403
|
+
if regex:
|
|
404
|
+
try:
|
|
405
|
+
rx = re.compile(query, re.IGNORECASE)
|
|
406
|
+
except re.error as e:
|
|
407
|
+
raise ValueError(f"invalid regex: {e}") from e
|
|
408
|
+
conn.create_function(
|
|
409
|
+
"re_match", 1,
|
|
410
|
+
lambda v: 1 if v is not None and rx.search(v) else 0,
|
|
411
|
+
deterministic=True,
|
|
412
|
+
)
|
|
413
|
+
cond = f"re_match({hay})"
|
|
414
|
+
else:
|
|
415
|
+
cond = f"{hay} LIKE ? ESCAPE '\\'"
|
|
416
|
+
params.append(f"%{_like_escape(query)}%")
|
|
417
|
+
sql = (f"SELECT * FROM ({select} AND {cond} ORDER BY id DESC LIMIT ?) "
|
|
418
|
+
"ORDER BY id ASC")
|
|
419
|
+
params.append(limit)
|
|
420
|
+
rows = conn.execute(sql, params).fetchall()
|
|
421
|
+
|
|
422
|
+
return [_row_to_entry(r) for r in rows]
|
|
423
|
+
|
|
367
424
|
def __len__(self) -> int:
|
|
368
425
|
with closing(_connect(self._db_path)) as conn:
|
|
369
426
|
row = conn.execute(
|
|
@@ -108,3 +108,8 @@ class SessionState:
|
|
|
108
108
|
message_log: Optional[MessageLog] = field(default=None) # set by Session on init
|
|
109
109
|
status_changed_at: Optional[float] = None # time.time() of last status transition
|
|
110
110
|
last_heartbeat_at: Optional[float] = None # time.time() of last inbound Heartbeat
|
|
111
|
+
# >0 while one or more perf runs drive this session: the run generates its
|
|
112
|
+
# own acks/fills, so venue auto-responses must stay silent to avoid
|
|
113
|
+
# duplicate/conflicting ExecReports corrupting the measurement. Counter (not
|
|
114
|
+
# bool) so overlapping runs nest. Runtime-only — never persisted.
|
|
115
|
+
perf_active: int = 0
|
|
@@ -232,8 +232,10 @@ class PerfRun:
|
|
|
232
232
|
"inbound 35=F from the external client is still answered")
|
|
233
233
|
if self._has_client:
|
|
234
234
|
self._client_sub = self._sm.subscribe_session(self.config.client_session_id)
|
|
235
|
+
self._sm.mark_perf_active(self.config.client_session_id)
|
|
235
236
|
if self._has_venue:
|
|
236
237
|
self._venue_sub = self._sm.subscribe_session(self.config.venue_session_id)
|
|
238
|
+
self._sm.mark_perf_active(self.config.venue_session_id)
|
|
237
239
|
self._writer.write_run(self._run_record())
|
|
238
240
|
self._main = asyncio.create_task(self._run())
|
|
239
241
|
|
|
@@ -296,8 +298,10 @@ class PerfRun:
|
|
|
296
298
|
self._venue_sends.clear()
|
|
297
299
|
if self._client_sub:
|
|
298
300
|
self._client_sub.close()
|
|
301
|
+
self._sm.unmark_perf_active(self.config.client_session_id)
|
|
299
302
|
if self._venue_sub:
|
|
300
303
|
self._venue_sub.close()
|
|
304
|
+
self._sm.unmark_perf_active(self.config.venue_session_id)
|
|
301
305
|
if self._latency_csv is not None:
|
|
302
306
|
# Correlator is cancelled (no more enqueues); drain + gzip-finalize
|
|
303
307
|
# off-loop — close() joins the writer thread.
|
|
@@ -397,8 +401,19 @@ class PerfRun:
|
|
|
397
401
|
self._finalize(entry, "dropped")
|
|
398
402
|
return True
|
|
399
403
|
|
|
404
|
+
async def _send(self, session_id: str, msg: Message) -> bool:
|
|
405
|
+
"""Single send path — tallies every outbound app message per leg
|
|
406
|
+
(type-agnostic) for the messages-per-second chart, then delegates."""
|
|
407
|
+
ok = await self._sm.send_message(session_id, msg)
|
|
408
|
+
if ok:
|
|
409
|
+
if session_id == self.config.client_session_id:
|
|
410
|
+
self.stats.msgs_sent_client += 1
|
|
411
|
+
else:
|
|
412
|
+
self.stats.msgs_sent_venue += 1
|
|
413
|
+
return ok
|
|
414
|
+
|
|
400
415
|
async def _send_order(self, corr: str, msg: Message) -> bool:
|
|
401
|
-
ok = await self.
|
|
416
|
+
ok = await self._send(self.config.client_session_id, msg)
|
|
402
417
|
if ok:
|
|
403
418
|
self.stats.orders_sent += 1
|
|
404
419
|
else:
|
|
@@ -493,6 +508,8 @@ class PerfRun:
|
|
|
493
508
|
sub = self._venue_sub
|
|
494
509
|
while True:
|
|
495
510
|
inb = await sub.get()
|
|
511
|
+
if not inb.admin:
|
|
512
|
+
self.stats.msgs_recv_venue += 1
|
|
496
513
|
recv_ns = inb.recv_perf_ns or time.perf_counter_ns()
|
|
497
514
|
if inb.msg_type == "G":
|
|
498
515
|
await self._handle_amend(inb.fields, recv_ns)
|
|
@@ -517,7 +534,7 @@ class PerfRun:
|
|
|
517
534
|
last_qty=0, cum_qty=0, leaves_qty=order_qty, px="0")
|
|
518
535
|
if ack is not None:
|
|
519
536
|
send_ns = time.perf_counter_ns()
|
|
520
|
-
if await self.
|
|
537
|
+
if await self._send(cfg.venue_session_id, ack):
|
|
521
538
|
self.stats.acks_sent += 1
|
|
522
539
|
self._register_venue_send(ack, "ack", corr, of.get(T_CLORDID), send_ns)
|
|
523
540
|
|
|
@@ -573,7 +590,7 @@ class PerfRun:
|
|
|
573
590
|
cum_qty=cum_qty, leaves_qty=leaves_qty, px=entry["px"])
|
|
574
591
|
if er is not None:
|
|
575
592
|
send_ns = time.perf_counter_ns()
|
|
576
|
-
if await self.
|
|
593
|
+
if await self._send(cfg.venue_session_id, er):
|
|
577
594
|
self.stats.fills_sent += 1
|
|
578
595
|
self._register_venue_send(er, "fill", corr,
|
|
579
596
|
entry["of"].get(T_CLORDID), send_ns)
|
|
@@ -647,14 +664,14 @@ class PerfRun:
|
|
|
647
664
|
orig_clordid=orig)
|
|
648
665
|
if er is not None:
|
|
649
666
|
send_ns = time.perf_counter_ns()
|
|
650
|
-
if await self.
|
|
667
|
+
if await self._send(cfg.venue_session_id, er):
|
|
651
668
|
self._register_venue_send(er, "pending-replace", corr, clordid, send_ns)
|
|
652
669
|
er = self._build_exec(of, exec_type="5", ord_status="5", last_qty=0,
|
|
653
670
|
cum_qty=cum, leaves_qty=leaves, px=px,
|
|
654
671
|
orig_clordid=orig)
|
|
655
672
|
if er is not None:
|
|
656
673
|
send_ns = time.perf_counter_ns()
|
|
657
|
-
if await self.
|
|
674
|
+
if await self._send(cfg.venue_session_id, er):
|
|
658
675
|
self._register_venue_send(er, "replace", corr, clordid, send_ns)
|
|
659
676
|
|
|
660
677
|
async def _handle_cancel(self, of: dict, recv_ns: int = 0) -> None:
|
|
@@ -691,12 +708,12 @@ class PerfRun:
|
|
|
691
708
|
orig_clordid=of.get(T_ORIGCLORDID))
|
|
692
709
|
if er is not None:
|
|
693
710
|
send_ns = time.perf_counter_ns()
|
|
694
|
-
if await self.
|
|
711
|
+
if await self._send(cfg.venue_session_id, er):
|
|
695
712
|
self._register_venue_send(er, "canceled", corr, clordid, send_ns)
|
|
696
713
|
return
|
|
697
714
|
msg = self._build_cancel_reject(of, entry)
|
|
698
715
|
send_ns = time.perf_counter_ns()
|
|
699
|
-
if await self.
|
|
716
|
+
if await self._send(cfg.venue_session_id, msg):
|
|
700
717
|
self._register_venue_send(msg, "cancel-reject", corr, clordid, send_ns)
|
|
701
718
|
|
|
702
719
|
def _build_cancel_reject(self, of: dict, entry: Optional[dict]) -> Message:
|
|
@@ -811,6 +828,8 @@ class PerfRun:
|
|
|
811
828
|
sub = self._client_sub
|
|
812
829
|
while True:
|
|
813
830
|
inb = await sub.get()
|
|
831
|
+
if not inb.admin:
|
|
832
|
+
self.stats.msgs_recv_client += 1
|
|
814
833
|
if inb.msg_type == "9":
|
|
815
834
|
self._on_cancel_reject(inb.fields, inb.recv_perf_ns or time.perf_counter_ns())
|
|
816
835
|
continue
|
|
@@ -998,7 +1017,7 @@ class PerfRun:
|
|
|
998
1017
|
entry["cancel_clordid"] = new_clordid
|
|
999
1018
|
entry["cancel_sent_ns"] = time.perf_counter_ns()
|
|
1000
1019
|
entry["cancel_sent_at"] = time.time()
|
|
1001
|
-
if await self.
|
|
1020
|
+
if await self._send(self.config.client_session_id, msg):
|
|
1002
1021
|
self.stats.cancels_sent += 1
|
|
1003
1022
|
else:
|
|
1004
1023
|
entry["cancel_inflight"] = False
|
|
@@ -1129,7 +1148,7 @@ class PerfRun:
|
|
|
1129
1148
|
entry["amends_sent"] = n + 1
|
|
1130
1149
|
entry["amend_sent_ns"] = time.perf_counter_ns()
|
|
1131
1150
|
entry["amend_sent_at"] = time.time()
|
|
1132
|
-
if await self.
|
|
1151
|
+
if await self._send(self.config.client_session_id, msg):
|
|
1133
1152
|
self.stats.amends_sent += 1
|
|
1134
1153
|
else:
|
|
1135
1154
|
entry["amend_inflight"] = False
|
|
@@ -1219,6 +1238,8 @@ class PerfRun:
|
|
|
1219
1238
|
cps_live=round(r.cps, 1),
|
|
1220
1239
|
crps_live=round(r.crps, 1),
|
|
1221
1240
|
ccps_live=round(r.ccps, 1),
|
|
1241
|
+
msgs_out_live=round(r.mco, 1),
|
|
1242
|
+
msgs_in_live=round(r.mci, 1),
|
|
1222
1243
|
scenarios_dispatched=self.stats.scenarios_dispatched,
|
|
1223
1244
|
scenarios_per_sec=round(r.sps, 1),
|
|
1224
1245
|
scenarios_completed=self.stats.scenarios_completed,
|
|
@@ -1235,7 +1256,9 @@ class PerfRun:
|
|
|
1235
1256
|
amends_uncorrelated=self.stats.amends_uncorrelated,
|
|
1236
1257
|
amend_waits_expired=self.stats.amend_waits_expired,
|
|
1237
1258
|
cancels_received=self.stats.cancels_received,
|
|
1238
|
-
cancels_uncorrelated=self.stats.cancels_uncorrelated
|
|
1259
|
+
cancels_uncorrelated=self.stats.cancels_uncorrelated,
|
|
1260
|
+
msgs_out_live=round(r.mvo, 1),
|
|
1261
|
+
msgs_in_live=round(r.mvi, 1))
|
|
1239
1262
|
snap = LiveSnapshot(
|
|
1240
1263
|
run_id=self.run_id, status=self.status, mode=self.config.mode,
|
|
1241
1264
|
elapsed_s=round(self._elapsed_s(), 1), duration_s=self.config.test.duration,
|
|
@@ -252,6 +252,8 @@ class ClientLeg(BaseModel):
|
|
|
252
252
|
cps_live: float = 0.0
|
|
253
253
|
crps_live: float = 0.0
|
|
254
254
|
ccps_live: float = 0.0
|
|
255
|
+
msgs_out_live: float = 0.0 # all app messages this leg sent/s (MPS chart)
|
|
256
|
+
msgs_in_live: float = 0.0 # all app messages this leg received/s
|
|
255
257
|
scenarios_dispatched: int = 0
|
|
256
258
|
scenarios_per_sec: float = 0.0
|
|
257
259
|
scenarios_completed: int = 0
|
|
@@ -271,6 +273,8 @@ class VenueLeg(BaseModel):
|
|
|
271
273
|
amend_waits_expired: int = 0
|
|
272
274
|
cancels_received: int = 0
|
|
273
275
|
cancels_uncorrelated: int = 0
|
|
276
|
+
msgs_out_live: float = 0.0 # all app messages this leg sent/s (MPS chart)
|
|
277
|
+
msgs_in_live: float = 0.0 # all app messages this leg received/s
|
|
274
278
|
|
|
275
279
|
|
|
276
280
|
class SnapshotErrors(BaseModel):
|
|
@@ -25,6 +25,12 @@ class Rates(NamedTuple):
|
|
|
25
25
|
cps: float # cancels sent/s (35=F)
|
|
26
26
|
crps: float # cancel-rejects received/s (35=9)
|
|
27
27
|
ccps: float # canceled received/s (150=4)
|
|
28
|
+
# Type-agnostic message rates (every app message sent/received per leg) for
|
|
29
|
+
# the messages-per-second chart — counts all kinds, so it can't drift.
|
|
30
|
+
mco: float # client leg messages sent/s (orders + amends + cancels …)
|
|
31
|
+
mci: float # client leg messages received/s (acks + fills + replies …)
|
|
32
|
+
mvo: float # venue leg messages sent/s (acks + fills + replies …)
|
|
33
|
+
mvi: float # venue leg messages received/s (orders + amends + cancels …)
|
|
28
34
|
|
|
29
35
|
|
|
30
36
|
class TokenBucket:
|
|
@@ -147,6 +153,11 @@ class PerfStats:
|
|
|
147
153
|
self.cancels_received = 0 # venue saw an inbound 35=F
|
|
148
154
|
self.cancels_uncorrelated = 0 # inbound 35=F lacked the correlation tag (no reply)
|
|
149
155
|
self.venue_sends_unmatched = 0 # one-way send records evicted w/o a matching recv
|
|
156
|
+
# Type-agnostic per-leg message tallies (every app message), for the MPS chart.
|
|
157
|
+
self.msgs_sent_client = 0
|
|
158
|
+
self.msgs_recv_client = 0
|
|
159
|
+
self.msgs_sent_venue = 0
|
|
160
|
+
self.msgs_recv_venue = 0
|
|
150
161
|
self.lost_timeout = 0
|
|
151
162
|
self.dropped = 0
|
|
152
163
|
self.rejected = 0
|
|
@@ -170,6 +181,10 @@ class PerfStats:
|
|
|
170
181
|
self._last_cancels = 0
|
|
171
182
|
self._last_cancel_rejects = 0
|
|
172
183
|
self._last_canceled = 0
|
|
184
|
+
self._last_msc = 0
|
|
185
|
+
self._last_mrc = 0
|
|
186
|
+
self._last_msv = 0
|
|
187
|
+
self._last_mrv = 0
|
|
173
188
|
|
|
174
189
|
def rates(self) -> Rates:
|
|
175
190
|
"""Per-second deltas since the last call. Stateful — call exactly once
|
|
@@ -177,7 +192,7 @@ class PerfStats:
|
|
|
177
192
|
now = time.monotonic()
|
|
178
193
|
dt = now - self._last_t
|
|
179
194
|
if dt <= 0:
|
|
180
|
-
return Rates(0.0
|
|
195
|
+
return Rates(*([0.0] * 11))
|
|
181
196
|
r = Rates(
|
|
182
197
|
ops=(self.orders_sent - self._last_orders) / dt,
|
|
183
198
|
fps=(self.fills_sent - self._last_fills) / dt,
|
|
@@ -186,6 +201,10 @@ class PerfStats:
|
|
|
186
201
|
cps=(self.cancels_sent - self._last_cancels) / dt,
|
|
187
202
|
crps=(self.cancel_rejects - self._last_cancel_rejects) / dt,
|
|
188
203
|
ccps=(self.cancels_canceled - self._last_canceled) / dt,
|
|
204
|
+
mco=(self.msgs_sent_client - self._last_msc) / dt,
|
|
205
|
+
mci=(self.msgs_recv_client - self._last_mrc) / dt,
|
|
206
|
+
mvo=(self.msgs_sent_venue - self._last_msv) / dt,
|
|
207
|
+
mvi=(self.msgs_recv_venue - self._last_mrv) / dt,
|
|
189
208
|
)
|
|
190
209
|
self._last_t = now
|
|
191
210
|
self._last_orders = self.orders_sent
|
|
@@ -195,6 +214,10 @@ class PerfStats:
|
|
|
195
214
|
self._last_cancels = self.cancels_sent
|
|
196
215
|
self._last_cancel_rejects = self.cancel_rejects
|
|
197
216
|
self._last_canceled = self.cancels_canceled
|
|
217
|
+
self._last_msc = self.msgs_sent_client
|
|
218
|
+
self._last_mrc = self.msgs_recv_client
|
|
219
|
+
self._last_msv = self.msgs_sent_venue
|
|
220
|
+
self._last_mrv = self.msgs_recv_venue
|
|
198
221
|
return r
|
|
199
222
|
|
|
200
223
|
def fill_ratio(self) -> float:
|
|
@@ -286,8 +286,12 @@ class Session:
|
|
|
286
286
|
self.state.last_recv_seq_num = entry.seq_num
|
|
287
287
|
if entry.msg_type == "0": # Heartbeat
|
|
288
288
|
self.state.last_heartbeat_at = time.time()
|
|
289
|
+
# Stay silent while a perf run drives this session — the run
|
|
290
|
+
# generates its own acks/fills, so an auto-response here would
|
|
291
|
+
# double up and corrupt the measurement.
|
|
289
292
|
if (entry.msg_type == "D" and not entry.admin
|
|
290
|
-
and self.config.session_role.value == "venue"
|
|
293
|
+
and self.config.session_role.value == "venue"
|
|
294
|
+
and self.state.perf_active == 0):
|
|
291
295
|
self._schedule_venue_responses(entry.fields)
|
|
292
296
|
|
|
293
297
|
self._event_handler(event)
|
|
@@ -223,6 +223,23 @@ class SessionManager:
|
|
|
223
223
|
if not subs:
|
|
224
224
|
self._session_subs.pop(sub.session_id, None)
|
|
225
225
|
|
|
226
|
+
def mark_perf_active(self, session_id: str) -> None:
|
|
227
|
+
"""A perf run started driving this session — suppress its venue
|
|
228
|
+
auto-responses (the run owns the acks/fills) until unmarked. Counter,
|
|
229
|
+
so overlapping runs nest. Mirror with unmark_perf_active()."""
|
|
230
|
+
with self._lock:
|
|
231
|
+
session = self._sessions.get(session_id)
|
|
232
|
+
if session is not None:
|
|
233
|
+
session.state.perf_active += 1
|
|
234
|
+
|
|
235
|
+
def unmark_perf_active(self, session_id: str) -> None:
|
|
236
|
+
"""A perf run stopped driving this session. Tolerates a session removed
|
|
237
|
+
mid-run (lookup may miss) and never underflows below zero."""
|
|
238
|
+
with self._lock:
|
|
239
|
+
session = self._sessions.get(session_id)
|
|
240
|
+
if session is not None:
|
|
241
|
+
session.state.perf_active = max(0, session.state.perf_active - 1)
|
|
242
|
+
|
|
226
243
|
# ------------------------------------------------------------------
|
|
227
244
|
# Internal
|
|
228
245
|
# ------------------------------------------------------------------
|