fixtureqa 0.4.6__tar.gz → 0.4.8__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.6/fixtureqa.egg-info → fixtureqa-0.4.8}/PKG-INFO +1 -1
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/perf_engine.py +146 -15
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/perf_models.py +33 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/perf_payload.py +23 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/perf_stats.py +45 -4
- fixtureqa-0.4.8/fixture/static/assets/index-DrmyYeG0.js +102 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/static/index.html +1 -1
- {fixtureqa-0.4.6 → fixtureqa-0.4.8/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixtureqa.egg-info/SOURCES.txt +1 -1
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/pyproject.toml +1 -1
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_perf_engine.py +292 -1
- fixtureqa-0.4.6/fixture/static/assets/index-FGgej6RF.js +0 -102
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/LICENSE +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/README.md +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/__init__.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/__main__.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/app.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/deps.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/perf.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/auth.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/events.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/exec_csv_writer.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/models.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/scenario_runner.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/session.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/server.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/static/assets/index-BwQf-cei.css +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/setup.cfg +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_auth.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_config_store.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_health.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_inbound.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_message_store.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_perf_api.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_scenarios.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_sessions.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_templates.py +0 -0
- {fixtureqa-0.4.6 → fixtureqa-0.4.8}/tests/test_ws.py +0 -0
|
@@ -35,14 +35,14 @@ from .perf_models import (
|
|
|
35
35
|
RunConfig, RunStatus, LiveSnapshot, ClientLeg, VenueLeg, SnapshotErrors, LatencyStats,
|
|
36
36
|
)
|
|
37
37
|
from .perf_payload import PayloadFactory, ScenarioSelector
|
|
38
|
-
from .perf_stats import PerfStats, TokenBucket
|
|
38
|
+
from .perf_stats import BurstGate, PerfStats, TokenBucket
|
|
39
39
|
from .perf_writer import PerfWriter
|
|
40
40
|
from .session_manager import SessionManager
|
|
41
41
|
|
|
42
42
|
logger = logging.getLogger(__name__)
|
|
43
43
|
|
|
44
44
|
# FIX tags
|
|
45
|
-
T_MSGTYPE, T_CLORDID, T_SYMBOL, T_SIDE = 35, 11, 55, 54
|
|
45
|
+
T_MSGTYPE, T_CLORDID, T_ORIGCLORDID, T_SYMBOL, T_SIDE = 35, 11, 41, 55, 54
|
|
46
46
|
T_ORDERQTY, T_ORDTYPE, T_PRICE, T_TIF, T_TRANSACTTIME = 38, 40, 44, 59, 60
|
|
47
47
|
T_ORDERID, T_EXECID, T_EXECTRANSTYPE, T_EXECTYPE, T_ORDSTATUS = 37, 17, 20, 150, 39
|
|
48
48
|
T_LASTQTY, T_LASTPX, T_CUMQTY, T_LEAVESQTY, T_AVGPX = 32, 31, 14, 151, 6
|
|
@@ -107,6 +107,10 @@ class PerfRun:
|
|
|
107
107
|
self._pending: dict[str, dict] = {}
|
|
108
108
|
self._order_seq = 0
|
|
109
109
|
|
|
110
|
+
# Amend injection is fill-triggered on the client leg (35=D orders only).
|
|
111
|
+
self._amend_on = config.amend.enabled and self._has_client
|
|
112
|
+
self._amend_tasks: set[asyncio.Task] = set()
|
|
113
|
+
|
|
110
114
|
# PayloadFactory validates templates/tokens now — raises PerfConfigError
|
|
111
115
|
# (surfaced as 422) before any task starts.
|
|
112
116
|
self._factory = PayloadFactory(config, template_store, owner_uid,
|
|
@@ -115,10 +119,15 @@ class PerfRun:
|
|
|
115
119
|
if config.payload.scenarios else None)
|
|
116
120
|
|
|
117
121
|
r = config.rate
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
|
|
123
|
+
def _pacer(per_window: int, window_ms: int, mode: str):
|
|
124
|
+
if mode == "burst":
|
|
125
|
+
return BurstGate(per_window, window_ms)
|
|
126
|
+
return TokenBucket(per_window, window_ms,
|
|
127
|
+
r.allow_burst, r.max_burst_multiplier)
|
|
128
|
+
|
|
129
|
+
self._order_bucket = _pacer(r.orders_per_window, r.order_window_ms, r.dispatch)
|
|
130
|
+
self._fill_bucket = _pacer(r.fills_per_window, r.fill_window_ms, r.fill_dispatch)
|
|
122
131
|
|
|
123
132
|
self._client_sub = None
|
|
124
133
|
self._venue_sub = None
|
|
@@ -146,6 +155,10 @@ class PerfRun:
|
|
|
146
155
|
else:
|
|
147
156
|
self._exec_csv = ExecCsvWriter(
|
|
148
157
|
os.path.join(self._execs_dir, f"{self.run_id}.csv.gz"))
|
|
158
|
+
if self.config.amend.enabled and not self._has_client:
|
|
159
|
+
self.warnings.append(
|
|
160
|
+
"amend injection has no effect in venue mode (no client leg); "
|
|
161
|
+
"inbound 35=G from the external client is still answered")
|
|
149
162
|
if self._has_client:
|
|
150
163
|
self._client_sub = self._sm.subscribe_session(self.config.client_session_id)
|
|
151
164
|
if self._has_venue:
|
|
@@ -197,11 +210,13 @@ class PerfRun:
|
|
|
197
210
|
await asyncio.sleep(0.02)
|
|
198
211
|
else:
|
|
199
212
|
await asyncio.sleep(_GRACE_DRAIN_S)
|
|
200
|
-
for t in self._tasks:
|
|
213
|
+
for t in list(self._tasks) + list(self._amend_tasks):
|
|
201
214
|
t.cancel()
|
|
202
|
-
await asyncio.gather(*self._tasks, return_exceptions=True)
|
|
215
|
+
await asyncio.gather(*self._tasks, *self._amend_tasks, return_exceptions=True)
|
|
203
216
|
for entry in list(self._pending.values()):
|
|
204
|
-
|
|
217
|
+
if entry.get("amend_inflight"):
|
|
218
|
+
self.stats.amends_lost += 1
|
|
219
|
+
self._finalize(entry, "filled" if entry.get("completed") else "lost")
|
|
205
220
|
self._pending.clear()
|
|
206
221
|
if self._client_sub:
|
|
207
222
|
self._client_sub.close()
|
|
@@ -311,6 +326,12 @@ class PerfRun:
|
|
|
311
326
|
meta["sent_at"] = time.time()
|
|
312
327
|
meta["responded"] = False
|
|
313
328
|
meta["seen_exec"] = set()
|
|
329
|
+
if self._amend_on and meta.get("msg_type") == "D":
|
|
330
|
+
meta["amend_eligible"] = random.random() < self.config.amend.amend_ratio
|
|
331
|
+
meta["fills_seen"] = 0
|
|
332
|
+
meta["amends_sent"] = 0
|
|
333
|
+
meta["amend_inflight"] = False
|
|
334
|
+
meta["completed"] = False
|
|
314
335
|
if resp_event is not None:
|
|
315
336
|
meta["resp_event"] = resp_event
|
|
316
337
|
self._pending[meta["corr_id"]] = meta
|
|
@@ -378,6 +399,9 @@ class PerfRun:
|
|
|
378
399
|
sub = self._venue_sub
|
|
379
400
|
while True:
|
|
380
401
|
inb = await sub.get()
|
|
402
|
+
if inb.msg_type == "G":
|
|
403
|
+
await self._handle_amend(inb.fields)
|
|
404
|
+
continue
|
|
381
405
|
if inb.msg_type != "D":
|
|
382
406
|
continue
|
|
383
407
|
self.stats.orders_received += 1
|
|
@@ -404,6 +428,31 @@ class PerfRun:
|
|
|
404
428
|
if er is not None and await self._sm.send_message(cfg.venue_session_id, er):
|
|
405
429
|
self.stats.fills_sent += 1
|
|
406
430
|
|
|
431
|
+
async def _handle_amend(self, of: dict) -> None:
|
|
432
|
+
"""Venue side of 35=G: reply Replaced (150=5/39=5), optionally preceded
|
|
433
|
+
by Pending Replace (150=E/39=E). Stateless — built from the request's
|
|
434
|
+
own fields with no order book, so CumQty reads 0 even when the order is
|
|
435
|
+
partially filled (the OMS under test reads 150/39 + the ClOrdID chain).
|
|
436
|
+
Like acks, replies are immediate, not fill-rate-limited."""
|
|
437
|
+
cfg = self.config
|
|
438
|
+
self.stats.amends_received += 1
|
|
439
|
+
if of.get(cfg.correlation_tag) is None:
|
|
440
|
+
return
|
|
441
|
+
order_qty = _to_int(of.get(T_ORDERQTY), 0)
|
|
442
|
+
px = of.get(T_PRICE) or "0"
|
|
443
|
+
orig = of.get(T_ORIGCLORDID)
|
|
444
|
+
if cfg.amend.pending_replace:
|
|
445
|
+
er = self._build_exec(of, exec_type="E", ord_status="E", last_qty=0,
|
|
446
|
+
cum_qty=0, leaves_qty=order_qty, px=px,
|
|
447
|
+
orig_clordid=orig)
|
|
448
|
+
if er is not None:
|
|
449
|
+
await self._sm.send_message(cfg.venue_session_id, er)
|
|
450
|
+
er = self._build_exec(of, exec_type="5", ord_status="5", last_qty=0,
|
|
451
|
+
cum_qty=0, leaves_qty=order_qty, px=px,
|
|
452
|
+
orig_clordid=orig)
|
|
453
|
+
if er is not None:
|
|
454
|
+
await self._sm.send_message(cfg.venue_session_id, er)
|
|
455
|
+
|
|
407
456
|
def _exec(self, of: dict, **kw) -> Optional[Message]:
|
|
408
457
|
"""Build one ExecReport via the exec template when configured, else the
|
|
409
458
|
built-in builder (Phase B). Both enforce the standard correlation tags."""
|
|
@@ -445,8 +494,10 @@ class PerfRun:
|
|
|
445
494
|
return schedule
|
|
446
495
|
|
|
447
496
|
def _build_exec(self, of: dict, *, exec_type: str, ord_status: str,
|
|
448
|
-
last_qty: int, cum_qty: int, leaves_qty: int, px: str
|
|
449
|
-
|
|
497
|
+
last_qty: int, cum_qty: int, leaves_qty: int, px: str,
|
|
498
|
+
orig_clordid: Optional[str] = None) -> Optional[Message]:
|
|
499
|
+
"""Build one ExecutionReport (ack, fill or replace ack) from an inbound
|
|
500
|
+
message's fields."""
|
|
450
501
|
cfg = self.config
|
|
451
502
|
corr = of.get(cfg.correlation_tag)
|
|
452
503
|
if corr is None:
|
|
@@ -457,6 +508,8 @@ class PerfRun:
|
|
|
457
508
|
er.header.set(T_MSGTYPE, "8")
|
|
458
509
|
if of.get(T_CLORDID):
|
|
459
510
|
er.set_field(T_CLORDID, of[T_CLORDID])
|
|
511
|
+
if orig_clordid:
|
|
512
|
+
er.set_field(T_ORIGCLORDID, orig_clordid)
|
|
460
513
|
er.set_field(cfg.correlation_tag, corr)
|
|
461
514
|
er.set_field(cfg.exec_id_tag, _uuid())
|
|
462
515
|
er.set_field(T_ORDERID, "O" + _guid()[:8].upper())
|
|
@@ -515,6 +568,14 @@ class PerfRun:
|
|
|
515
568
|
((now_ns - prev_ns) / 1000.0) if prev_ns is not None else None,
|
|
516
569
|
))
|
|
517
570
|
|
|
571
|
+
# Amend responses (Pending Replace / Replaced) are not order responses:
|
|
572
|
+
# they must not count as first response, a fill, or a completion.
|
|
573
|
+
exec_type = f.get(T_EXECTYPE)
|
|
574
|
+
if exec_type in ("E", "5"):
|
|
575
|
+
if exec_type == "5":
|
|
576
|
+
self._on_replaced(corr, entry, now_ns)
|
|
577
|
+
return
|
|
578
|
+
|
|
518
579
|
if not entry["responded"]:
|
|
519
580
|
entry["responded"] = True
|
|
520
581
|
entry["first_resp_at"] = time.time()
|
|
@@ -527,7 +588,24 @@ class PerfRun:
|
|
|
527
588
|
ev.set()
|
|
528
589
|
|
|
529
590
|
cum = _to_int(f.get(T_CUMQTY), 0)
|
|
530
|
-
|
|
591
|
+
|
|
592
|
+
# Amend trigger: every nth fill of a still-working order. Counting cum
|
|
593
|
+
# increases (not ExecType values) keeps it venue-agnostic (4.2's 150=1/2
|
|
594
|
+
# and 4.4's 150=F alike). Never fires on the final fill.
|
|
595
|
+
if self._amend_on and entry.get("amend_eligible") and cum > entry.get("cum_seen", 0):
|
|
596
|
+
entry["cum_seen"] = cum
|
|
597
|
+
entry["fills_seen"] += 1
|
|
598
|
+
a = self.config.amend
|
|
599
|
+
if (self._injecting and not entry["amend_inflight"]
|
|
600
|
+
and cum < entry["order_qty"]
|
|
601
|
+
and entry["fills_seen"] % a.every_n_fills == 0
|
|
602
|
+
and (a.max_per_order == 0 or entry["amends_sent"] < a.max_per_order)):
|
|
603
|
+
entry["amend_inflight"] = True
|
|
604
|
+
t = asyncio.create_task(self._send_amend(entry))
|
|
605
|
+
self._amend_tasks.add(t)
|
|
606
|
+
t.add_done_callback(self._amend_tasks.discard)
|
|
607
|
+
|
|
608
|
+
if cum >= entry["order_qty"] and not entry.get("completed"):
|
|
531
609
|
lat_us = (now_ns - entry["sent_ns"]) / 1000.0
|
|
532
610
|
entry["fill_latency_us"] = lat_us
|
|
533
611
|
entry["filled_at"] = time.time()
|
|
@@ -535,9 +613,48 @@ class PerfRun:
|
|
|
535
613
|
entry["fill_price"] = f.get(T_LASTPX) or f.get(T_AVGPX)
|
|
536
614
|
self.stats.completions += 1
|
|
537
615
|
self.stats.fill_completion_latency.add(lat_us)
|
|
616
|
+
if entry.get("amend_inflight"):
|
|
617
|
+
# The venue dispatches an order's fills before it reads the 35=G,
|
|
618
|
+
# so the Replaced ack usually lands after the final fill. Keep
|
|
619
|
+
# the entry so the ack still correlates; _on_replaced (or the
|
|
620
|
+
# sweeper / shutdown) finalizes it.
|
|
621
|
+
entry["completed"] = True
|
|
622
|
+
else:
|
|
623
|
+
entry["completed"] = True
|
|
624
|
+
self._pending.pop(corr, None)
|
|
625
|
+
self._finalize(entry, "filled")
|
|
626
|
+
|
|
627
|
+
def _on_replaced(self, corr: str, entry: dict, now_ns: int) -> None:
|
|
628
|
+
"""Client got a Replaced ack (150=5): close the in-flight amend."""
|
|
629
|
+
self.stats.replaces_received += 1
|
|
630
|
+
sent_ns = entry.get("amend_sent_ns")
|
|
631
|
+
if entry.get("amend_inflight") and sent_ns:
|
|
632
|
+
self.stats.amend_latency.add((now_ns - sent_ns) / 1000.0)
|
|
633
|
+
if entry.get("pending_clordid"):
|
|
634
|
+
entry["clordid"] = entry["pending_clordid"]
|
|
635
|
+
entry["pending_clordid"] = None
|
|
636
|
+
entry["amend_inflight"] = False
|
|
637
|
+
if entry.get("completed"):
|
|
538
638
|
self._pending.pop(corr, None)
|
|
539
639
|
self._finalize(entry, "filled")
|
|
540
640
|
|
|
641
|
+
async def _send_amend(self, entry: dict) -> None:
|
|
642
|
+
"""Build and send one price amend (alternating ± offset). Demand-driven
|
|
643
|
+
by fills (rate ≈ fills/s ÷ every_n_fills) — no pacer of its own."""
|
|
644
|
+
a = self.config.amend
|
|
645
|
+
n = entry["amends_sent"]
|
|
646
|
+
base = _to_float(entry.get("price")) or 100.0
|
|
647
|
+
new_price = round(base + a.price_offset * (1 if n % 2 == 0 else -1), 4)
|
|
648
|
+
msg, new_clordid = self._factory.build_amend(entry, new_price)
|
|
649
|
+
entry["pending_clordid"] = new_clordid
|
|
650
|
+
entry["amends_sent"] = n + 1
|
|
651
|
+
entry["amend_sent_ns"] = time.perf_counter_ns()
|
|
652
|
+
if await self._sm.send_message(self.config.client_session_id, msg):
|
|
653
|
+
self.stats.amends_sent += 1
|
|
654
|
+
else:
|
|
655
|
+
entry["amend_inflight"] = False
|
|
656
|
+
entry["pending_clordid"] = None
|
|
657
|
+
|
|
541
658
|
# ------------------------------------------------------------------
|
|
542
659
|
# Timeout sweeper
|
|
543
660
|
# ------------------------------------------------------------------
|
|
@@ -554,7 +671,15 @@ class PerfRun:
|
|
|
554
671
|
expired = [c for c, e in self._pending.items() if now - e["sent_ns"] > timeout_ns]
|
|
555
672
|
for c in expired:
|
|
556
673
|
entry = self._pending.pop(c, None)
|
|
557
|
-
if entry is
|
|
674
|
+
if entry is None:
|
|
675
|
+
continue
|
|
676
|
+
if entry.get("amend_inflight"):
|
|
677
|
+
self.stats.amends_lost += 1
|
|
678
|
+
if entry.get("completed"):
|
|
679
|
+
# Order filled; only the amend's Replaced ack never came —
|
|
680
|
+
# don't double-count a completed order as lost.
|
|
681
|
+
self._finalize(entry, "filled")
|
|
682
|
+
else:
|
|
558
683
|
self.stats.lost_timeout += 1
|
|
559
684
|
self._finalize(entry, "lost")
|
|
560
685
|
|
|
@@ -586,12 +711,16 @@ class PerfRun:
|
|
|
586
711
|
return (time.perf_counter_ns() - self._start_ns) / 1e9 if self._start_ns else 0.0
|
|
587
712
|
|
|
588
713
|
def _build_snapshot(self) -> None:
|
|
589
|
-
ops, fps, sps = self.stats.rates()
|
|
714
|
+
ops, fps, sps, aps = self.stats.rates()
|
|
590
715
|
client = None
|
|
591
716
|
if self._has_client:
|
|
592
717
|
client = ClientLeg(
|
|
593
718
|
orders_sent=self.stats.orders_sent, ops_live=round(ops, 1),
|
|
594
719
|
pending=len(self._pending), dropped=self.stats.dropped,
|
|
720
|
+
amends_sent=self.stats.amends_sent,
|
|
721
|
+
replaces_received=self.stats.replaces_received,
|
|
722
|
+
amends_lost=self.stats.amends_lost,
|
|
723
|
+
aps_live=round(aps, 1),
|
|
595
724
|
scenarios_dispatched=self.stats.scenarios_dispatched,
|
|
596
725
|
scenarios_per_sec=round(sps, 1),
|
|
597
726
|
scenarios_completed=self.stats.scenarios_completed,
|
|
@@ -603,7 +732,8 @@ class PerfRun:
|
|
|
603
732
|
acks_sent=self.stats.acks_sent,
|
|
604
733
|
fills_sent=self.stats.fills_sent, fps_live=round(fps, 1),
|
|
605
734
|
fill_ratio=round(self.stats.fill_ratio(), 3),
|
|
606
|
-
unfilled=max(0, self.stats.orders_sent - self.stats.completions)
|
|
735
|
+
unfilled=max(0, self.stats.orders_sent - self.stats.completions),
|
|
736
|
+
amends_received=self.stats.amends_received)
|
|
607
737
|
snap = LiveSnapshot(
|
|
608
738
|
run_id=self.run_id, status=self.status, mode=self.config.mode,
|
|
609
739
|
elapsed_s=round(self._elapsed_s(), 1), duration_s=self.config.test.duration,
|
|
@@ -611,6 +741,7 @@ class PerfRun:
|
|
|
611
741
|
client=client, venue=venue,
|
|
612
742
|
response_latency=LatencyStats(**self.stats.response_latency.stats()),
|
|
613
743
|
fill_completion_latency=LatencyStats(**self.stats.fill_completion_latency.stats()),
|
|
744
|
+
amend_latency=LatencyStats(**self.stats.amend_latency.stats()),
|
|
614
745
|
scenario_latency=LatencyStats(**self.stats.scenario_latency.stats()),
|
|
615
746
|
errors=SnapshotErrors(lost_timeout=self.stats.lost_timeout,
|
|
616
747
|
rejected=self.stats.rejected,
|
|
@@ -24,9 +24,17 @@ RunState = Literal["pending", "running", "completed", "stopped", "error", "satur
|
|
|
24
24
|
Sequence = Literal["parallel", "sequential", "sequential_timed"]
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
DispatchMode = Literal["smooth", "burst"]
|
|
28
|
+
|
|
29
|
+
|
|
27
30
|
class RateConfig(BaseModel):
|
|
28
31
|
orders_per_window: int = Field(gt=0)
|
|
29
32
|
order_window_ms: int = Field(gt=0)
|
|
33
|
+
# smooth: continuous-refill token bucket — sends evenly spaced at the
|
|
34
|
+
# average rate. burst: up to per_window sends back-to-back per window
|
|
35
|
+
# boundary, then silence until the next window (sawtooth throughput).
|
|
36
|
+
dispatch: DispatchMode = "smooth" # order leg
|
|
37
|
+
fill_dispatch: DispatchMode = "smooth" # venue exec leg
|
|
30
38
|
fills_per_window: int = Field(gt=0)
|
|
31
39
|
fill_window_ms: int = Field(gt=0)
|
|
32
40
|
fill_ratio: float = Field(default=1.0, ge=0.0, le=1.0)
|
|
@@ -49,6 +57,24 @@ class TestConfig(BaseModel):
|
|
|
49
57
|
record_execs: bool = False
|
|
50
58
|
|
|
51
59
|
|
|
60
|
+
class AmendConfig(BaseModel):
|
|
61
|
+
"""Client-leg amend traffic (35=G OrderCancelReplaceRequest), price-only.
|
|
62
|
+
|
|
63
|
+
Triggered every `every_n_fills` fills of a working order (so amend rate ≈
|
|
64
|
+
fills/s ÷ n); `amend_ratio` picks the eligible fraction of orders and
|
|
65
|
+
`max_per_order` caps the chain length (0 = unlimited). Each amend nudges
|
|
66
|
+
the price by ± `price_offset` (alternating). `pending_replace` makes the
|
|
67
|
+
in-process venue reply 150=E (Pending Replace) before 150=5 (Replaced),
|
|
68
|
+
matching common real-venue behaviour. Amends never fire on the final fill
|
|
69
|
+
— use partial fills or fills_per_order > 1 to leave the order working."""
|
|
70
|
+
enabled: bool = False
|
|
71
|
+
every_n_fills: int = Field(default=1, ge=1)
|
|
72
|
+
amend_ratio: float = Field(default=1.0, ge=0.0, le=1.0)
|
|
73
|
+
max_per_order: int = Field(default=1, ge=0)
|
|
74
|
+
price_offset: float = Field(default=0.01, gt=0)
|
|
75
|
+
pending_replace: bool = False
|
|
76
|
+
|
|
77
|
+
|
|
52
78
|
class OrderPayloadConfig(BaseModel):
|
|
53
79
|
symbols: list[str] = Field(min_length=1)
|
|
54
80
|
side: Literal["fixed_buy", "fixed_sell", "alternate", "random"] = "alternate"
|
|
@@ -138,6 +164,7 @@ class RunConfig(BaseModel):
|
|
|
138
164
|
exec_id_tag: int = Field(default=25116, gt=0)
|
|
139
165
|
rate: RateConfig
|
|
140
166
|
test: TestConfig = Field(default_factory=TestConfig)
|
|
167
|
+
amend: AmendConfig = Field(default_factory=AmendConfig)
|
|
141
168
|
payload: PayloadConfig
|
|
142
169
|
|
|
143
170
|
@model_validator(mode="after")
|
|
@@ -169,6 +196,10 @@ class ClientLeg(BaseModel):
|
|
|
169
196
|
ops_live: float = 0.0
|
|
170
197
|
pending: int = 0
|
|
171
198
|
dropped: int = 0
|
|
199
|
+
amends_sent: int = 0
|
|
200
|
+
replaces_received: int = 0
|
|
201
|
+
amends_lost: int = 0
|
|
202
|
+
aps_live: float = 0.0
|
|
172
203
|
scenarios_dispatched: int = 0
|
|
173
204
|
scenarios_per_sec: float = 0.0
|
|
174
205
|
scenarios_completed: int = 0
|
|
@@ -183,6 +214,7 @@ class VenueLeg(BaseModel):
|
|
|
183
214
|
fill_ratio: float = 0.0
|
|
184
215
|
unfilled: int = 0
|
|
185
216
|
venue_proc_us_p50: float = 0.0
|
|
217
|
+
amends_received: int = 0
|
|
186
218
|
|
|
187
219
|
|
|
188
220
|
class SnapshotErrors(BaseModel):
|
|
@@ -202,6 +234,7 @@ class LiveSnapshot(BaseModel):
|
|
|
202
234
|
venue: Optional[VenueLeg] = None
|
|
203
235
|
response_latency: LatencyStats = Field(default_factory=LatencyStats)
|
|
204
236
|
fill_completion_latency: LatencyStats = Field(default_factory=LatencyStats)
|
|
237
|
+
amend_latency: LatencyStats = Field(default_factory=LatencyStats)
|
|
205
238
|
scenario_latency: LatencyStats = Field(default_factory=LatencyStats)
|
|
206
239
|
errors: SnapshotErrors = Field(default_factory=SnapshotErrors)
|
|
207
240
|
|
|
@@ -385,6 +385,29 @@ class PayloadFactory:
|
|
|
385
385
|
def _clordid(self) -> str:
|
|
386
386
|
return "PCL-" + _guid()[:12].upper()
|
|
387
387
|
|
|
388
|
+
# -- amend (35=G OrderCancelReplaceRequest, price-only) -------------
|
|
389
|
+
|
|
390
|
+
def build_amend(self, meta: dict, new_price: float) -> tuple[Message, str]:
|
|
391
|
+
"""Re-price a working order. Carries the order's correlation id so the
|
|
392
|
+
replace acks route to the same pending entry; OrigClOrdID(41) is the
|
|
393
|
+
current chain tip, ClOrdID(11) is fresh — returned so the engine can
|
|
394
|
+
promote the chain once the venue confirms (150=5)."""
|
|
395
|
+
p = self.config.payload.order
|
|
396
|
+
new_clordid = self._clordid()
|
|
397
|
+
msg = Message()
|
|
398
|
+
msg.header.set(35, "G")
|
|
399
|
+
msg.set_field(11, new_clordid)
|
|
400
|
+
msg.set_field(41, meta["clordid"])
|
|
401
|
+
msg.set_field(self.config.correlation_tag, meta["corr_id"])
|
|
402
|
+
msg.set_field(55, meta["symbol"])
|
|
403
|
+
msg.set_field(54, meta["side"])
|
|
404
|
+
msg.set_field(38, str(meta["order_qty"]))
|
|
405
|
+
msg.set_field(40, "2") # re-pricing → limit, whatever the original was
|
|
406
|
+
msg.set_field(44, f"{new_price:.2f}")
|
|
407
|
+
msg.set_field(59, p.time_in_force)
|
|
408
|
+
msg.set_field(60, _utc_ms())
|
|
409
|
+
return msg, new_clordid
|
|
410
|
+
|
|
388
411
|
def _apply_expiry(self, msg: Message, fields: dict) -> None:
|
|
389
412
|
"""Rewrite ExpireTime(126)/ExpireDate(432) to a shared future instant —
|
|
390
413
|
only for tags the message already carries (never inserts), exactly like
|
|
@@ -40,6 +40,34 @@ class TokenBucket:
|
|
|
40
40
|
await asyncio.sleep((n - self._tokens) / self._rate_per_s)
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
class BurstGate:
|
|
44
|
+
"""Window-boundary pacer (same `acquire()` contract as TokenBucket).
|
|
45
|
+
|
|
46
|
+
Allows `per_window` acquires back-to-back within each window, then blocks
|
|
47
|
+
everyone until the next boundary — a sawtooth instead of the bucket's even
|
|
48
|
+
spacing. Boundaries stay aligned to the original cadence (no drift).
|
|
49
|
+
Note: it shapes a ceiling — bursts only show when demand inside a window
|
|
50
|
+
exceeds it (e.g. injector loop, or a venue fill backlog)."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, per_window: int, window_ms: int):
|
|
53
|
+
self._per_window = per_window
|
|
54
|
+
self._window_s = window_ms / 1000.0
|
|
55
|
+
self._window_end = time.monotonic() + self._window_s
|
|
56
|
+
self._used = 0
|
|
57
|
+
|
|
58
|
+
async def acquire(self, n: int = 1) -> None:
|
|
59
|
+
while True:
|
|
60
|
+
now = time.monotonic()
|
|
61
|
+
if now >= self._window_end:
|
|
62
|
+
missed = int((now - self._window_end) / self._window_s) + 1
|
|
63
|
+
self._window_end += missed * self._window_s
|
|
64
|
+
self._used = 0
|
|
65
|
+
if self._used + n <= self._per_window:
|
|
66
|
+
self._used += n
|
|
67
|
+
return
|
|
68
|
+
await asyncio.sleep(max(0.0, self._window_end - now))
|
|
69
|
+
|
|
70
|
+
|
|
43
71
|
class _Reservoir:
|
|
44
72
|
"""Reservoir sampler over latency values (microseconds). Exact count/mean/max;
|
|
45
73
|
approximate percentiles from a fixed-size representative sample."""
|
|
@@ -91,6 +119,10 @@ class PerfStats:
|
|
|
91
119
|
self.fills_sent = 0 # venue sent a fill ExecutionReport
|
|
92
120
|
self.responses = 0 # client got first response for an order
|
|
93
121
|
self.completions = 0 # client saw a full fill
|
|
122
|
+
self.amends_sent = 0 # client sent a 35=G
|
|
123
|
+
self.replaces_received = 0 # client got a Replaced ack (150=5)
|
|
124
|
+
self.amends_lost = 0 # amend in flight when its order was reaped
|
|
125
|
+
self.amends_received = 0 # venue saw an inbound 35=G
|
|
94
126
|
self.lost_timeout = 0
|
|
95
127
|
self.dropped = 0
|
|
96
128
|
self.rejected = 0
|
|
@@ -102,27 +134,31 @@ class PerfStats:
|
|
|
102
134
|
|
|
103
135
|
self.response_latency = _Reservoir(reservoir_size)
|
|
104
136
|
self.fill_completion_latency = _Reservoir(reservoir_size)
|
|
137
|
+
self.amend_latency = _Reservoir(reservoir_size)
|
|
105
138
|
self.scenario_latency = _Reservoir(reservoir_size)
|
|
106
139
|
|
|
107
140
|
self._last_t = time.monotonic()
|
|
108
141
|
self._last_orders = 0
|
|
109
142
|
self._last_fills = 0
|
|
110
143
|
self._last_scen = 0
|
|
144
|
+
self._last_amends = 0
|
|
111
145
|
|
|
112
|
-
def rates(self) -> tuple[float, float, float]:
|
|
113
|
-
"""(orders/s, fills/s, scenarios/s) since the last call (delta-based)."""
|
|
146
|
+
def rates(self) -> tuple[float, float, float, float]:
|
|
147
|
+
"""(orders/s, fills/s, scenarios/s, amends/s) since the last call (delta-based)."""
|
|
114
148
|
now = time.monotonic()
|
|
115
149
|
dt = now - self._last_t
|
|
116
150
|
if dt <= 0:
|
|
117
|
-
return 0.0, 0.0, 0.0
|
|
151
|
+
return 0.0, 0.0, 0.0, 0.0
|
|
118
152
|
ops = (self.orders_sent - self._last_orders) / dt
|
|
119
153
|
fps = (self.fills_sent - self._last_fills) / dt
|
|
120
154
|
sps = (self.scenarios_dispatched - self._last_scen) / dt
|
|
155
|
+
aps = (self.amends_sent - self._last_amends) / dt
|
|
121
156
|
self._last_t = now
|
|
122
157
|
self._last_orders = self.orders_sent
|
|
123
158
|
self._last_fills = self.fills_sent
|
|
124
159
|
self._last_scen = self.scenarios_dispatched
|
|
125
|
-
|
|
160
|
+
self._last_amends = self.amends_sent
|
|
161
|
+
return ops, fps, sps, aps
|
|
126
162
|
|
|
127
163
|
def fill_ratio(self) -> float:
|
|
128
164
|
# Fraction of orders fully filled (cumQty >= orderQty). Uses completions,
|
|
@@ -140,6 +176,10 @@ class PerfStats:
|
|
|
140
176
|
"fills_sent": self.fills_sent,
|
|
141
177
|
"responses": self.responses,
|
|
142
178
|
"completions": self.completions,
|
|
179
|
+
"amends_sent": self.amends_sent,
|
|
180
|
+
"replaces_received": self.replaces_received,
|
|
181
|
+
"amends_lost": self.amends_lost,
|
|
182
|
+
"amends_received": self.amends_received,
|
|
143
183
|
"lost_timeout": self.lost_timeout,
|
|
144
184
|
"dropped": self.dropped,
|
|
145
185
|
"rejected": self.rejected,
|
|
@@ -149,5 +189,6 @@ class PerfStats:
|
|
|
149
189
|
"fill_ratio": self.fill_ratio(),
|
|
150
190
|
"response_latency": self.response_latency.stats(),
|
|
151
191
|
"fill_completion_latency": self.fill_completion_latency.stats(),
|
|
192
|
+
"amend_latency": self.amend_latency.stats(),
|
|
152
193
|
"scenario_latency": self.scenario_latency.stats(),
|
|
153
194
|
}
|