fixtureqa 0.4.1__tar.gz → 0.4.3__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.1/fixtureqa.egg-info → fixtureqa-0.4.3}/PKG-INFO +1 -1
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/fix_time.py +7 -3
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/perf_models.py +8 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/perf_payload.py +176 -19
- fixtureqa-0.4.1/fixture/static/assets/index-D9vW5wFo.css → fixtureqa-0.4.3/fixture/static/assets/index-BwQf-cei.css +1 -1
- fixtureqa-0.4.3/fixture/static/assets/index-CyNOPa0n.js +2 -0
- fixtureqa-0.4.3/fixture/static/assets/index-Dd6aSjfO.js +102 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/static/index.html +2 -2
- {fixtureqa-0.4.1 → fixtureqa-0.4.3/fixtureqa.egg-info}/PKG-INFO +1 -1
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixtureqa.egg-info/SOURCES.txt +3 -2
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/pyproject.toml +1 -1
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_perf_payload.py +195 -0
- fixtureqa-0.4.1/fixture/static/assets/index-erFDS7Xr.js +0 -102
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/LICENSE +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/README.md +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/__init__.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/__main__.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/app.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/deps.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/perf.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/auth.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/events.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/models.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/perf_engine.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/perf_stats.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/scenario_runner.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/session.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/server.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixtureqa.egg-info/requires.txt +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/setup.cfg +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_auth.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_config_store.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_health.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_inbound.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_message_store.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_perf_api.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_perf_engine.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_scenarios.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_sessions.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_templates.py +0 -0
- {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_ws.py +0 -0
|
@@ -7,12 +7,16 @@ lands here once.
|
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
from datetime import datetime, timezone
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def utc_timestamp(*, ms: bool = False) -> str:
|
|
14
|
-
"""FIX UTCTimestamp (60/52-style): YYYYMMDD-HH:MM:SS[.mmm].
|
|
13
|
+
def utc_timestamp(*, ms: bool = False, offset_s: float = 0.0) -> str:
|
|
14
|
+
"""FIX UTCTimestamp (60/52-style): YYYYMMDD-HH:MM:SS[.mmm].
|
|
15
|
+
|
|
16
|
+
offset_s shifts the instant into the future (expiry tags 126/432)."""
|
|
15
17
|
now = datetime.now(timezone.utc)
|
|
18
|
+
if offset_s:
|
|
19
|
+
now += timedelta(seconds=offset_s)
|
|
16
20
|
if ms:
|
|
17
21
|
return now.strftime("%Y%m%d-%H:%M:%S.%f")[:-3]
|
|
18
22
|
return now.strftime("%Y%m%d-%H:%M:%S")
|
|
@@ -106,6 +106,14 @@ class PayloadConfig(BaseModel):
|
|
|
106
106
|
# exec_template_id shapes the venue's ack/fill ExecReports. Omitted → built-in builders.
|
|
107
107
|
order_template_id: Optional[str] = None
|
|
108
108
|
exec_template_id: Optional[str] = None
|
|
109
|
+
# Send-options parity with the standard send UI (client-injected messages only):
|
|
110
|
+
# auto_expiry rewrites ExpireTime(126)/ExpireDate(432) to a future instant when
|
|
111
|
+
# the message already carries them (never inserts); gen_rules set tag → generator
|
|
112
|
+
# expression / literal (insert-or-replace) on every order/scenario message.
|
|
113
|
+
auto_expiry: bool = True
|
|
114
|
+
expiry_offset: int = Field(default=1, ge=0)
|
|
115
|
+
expiry_unit: Literal["minutes", "hours", "days"] = "days"
|
|
116
|
+
gen_rules: dict[str, str] = Field(default_factory=dict)
|
|
109
117
|
|
|
110
118
|
@model_validator(mode="after")
|
|
111
119
|
def _weights(self) -> "PayloadConfig":
|
|
@@ -17,11 +17,30 @@ Tokens (in template field values):
|
|
|
17
17
|
{ref:<tpl>.clordid} the ClOrdID generated earlier in this scenario instance
|
|
18
18
|
for template <tpl> (sequential scenarios only, earlier msg)
|
|
19
19
|
{<var>} any key from payload.variables
|
|
20
|
+
|
|
21
|
+
Value generators (same set the standard-send UI evaluates client-side; here
|
|
22
|
+
they are evaluated server-side, fresh per message): uuid() · random_str() /
|
|
23
|
+
random_str(n) · random_int(a,b) · seq() / seq(width) / seq(width,start) ·
|
|
24
|
+
timestamp() · date(). A field value that is exactly one bare call is replaced;
|
|
25
|
+
generators also embed in static text as {gen(...)} — e.g. "ORD-{seq(8)}" →
|
|
26
|
+
"ORD-00000001". seq keeps one counter per tag per run. A bare unknown
|
|
27
|
+
name(...) is sent as-is; an unknown {name(...)} is rejected at run start.
|
|
28
|
+
|
|
29
|
+
Send-options parity (client-injected messages only):
|
|
30
|
+
payload.gen_rules tag → expression, insert-or-replace on every order /
|
|
31
|
+
scenario message (correlation tag is engine-protected)
|
|
32
|
+
payload.auto_expiry rewrite ExpireTime(126)/ExpireDate(432) to now +
|
|
33
|
+
expiry_offset expiry_unit — only when already present
|
|
34
|
+
|
|
35
|
+
ClOrdID(11) is guaranteed unique per order: a template/rule tag-11 value with
|
|
36
|
+
no token/generator would repeat verbatim on every order of the run, so it is
|
|
37
|
+
replaced with a fresh engine ClOrdID.
|
|
20
38
|
"""
|
|
21
39
|
from __future__ import annotations
|
|
22
40
|
|
|
23
41
|
import random
|
|
24
42
|
import re
|
|
43
|
+
import string
|
|
25
44
|
import uuid
|
|
26
45
|
from typing import Optional
|
|
27
46
|
|
|
@@ -70,6 +89,59 @@ def _tokens(value: str) -> list[str]:
|
|
|
70
89
|
return _TOKEN_RE.findall(value) if isinstance(value, str) else []
|
|
71
90
|
|
|
72
91
|
|
|
92
|
+
# -- value generators (mirror frontend SendOptions.evalGenerator) ----------
|
|
93
|
+
|
|
94
|
+
_GEN_RE = re.compile(r"^(\w+)\(([^)]*)\)$")
|
|
95
|
+
_ALNUM = string.ascii_letters + string.digits
|
|
96
|
+
_GENERATOR_NAMES = {"uuid", "random_str", "random_int", "seq", "timestamp", "date"}
|
|
97
|
+
_EXPIRY_UNIT_S = {"minutes": 60, "hours": 3600, "days": 86400}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _gen_call(name: str, raw_args: str, tag: int, counters: dict[int, int]) -> Optional[str]:
|
|
101
|
+
"""Evaluate one generator call; None = unknown name / invalid args."""
|
|
102
|
+
args = [a.strip() for a in raw_args.split(",")] if raw_args.strip() else []
|
|
103
|
+
if name == "uuid":
|
|
104
|
+
return _uuid()
|
|
105
|
+
if name == "random_str":
|
|
106
|
+
n = _to_int(args[0], 8) if args else 8
|
|
107
|
+
return "".join(random.choices(_ALNUM, k=min(n, 256) if n > 0 else 8))
|
|
108
|
+
if name == "random_int":
|
|
109
|
+
try:
|
|
110
|
+
a, b = int(float(args[0])), int(float(args[1]))
|
|
111
|
+
except (IndexError, TypeError, ValueError):
|
|
112
|
+
return None
|
|
113
|
+
return str(random.randint(min(a, b), max(a, b)))
|
|
114
|
+
if name == "seq":
|
|
115
|
+
# seq() / seq(width) / seq(width, start) — incrementing counter,
|
|
116
|
+
# zero-padded to width; one counter per tag per run.
|
|
117
|
+
width = _to_int(args[0], 0) if args else 0
|
|
118
|
+
start = _to_int(args[1], 1) if len(args) > 1 else 1
|
|
119
|
+
n = counters.get(tag, start)
|
|
120
|
+
counters[tag] = n + 1
|
|
121
|
+
return str(n).zfill(width) if width > 0 else str(n)
|
|
122
|
+
if name == "timestamp":
|
|
123
|
+
return utc_timestamp()
|
|
124
|
+
if name == "date":
|
|
125
|
+
return utc_timestamp()[:8]
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _eval_generator(value: str, tag: int, counters: dict[int, int]) -> str:
|
|
130
|
+
"""Evaluate a whole-value generator expression; pass literals through."""
|
|
131
|
+
m = _GEN_RE.match(value.strip())
|
|
132
|
+
if not m:
|
|
133
|
+
return value
|
|
134
|
+
out = _gen_call(m.group(1), m.group(2), tag, counters)
|
|
135
|
+
return value if out is None else out
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _is_dynamic(value: str) -> bool:
|
|
139
|
+
"""True when a template value renders differently per message
|
|
140
|
+
(contains a {token} or is a whole-value generator call)."""
|
|
141
|
+
return isinstance(value, str) and (
|
|
142
|
+
"{" in value or _GEN_RE.match(value.strip()) is not None)
|
|
143
|
+
|
|
144
|
+
|
|
73
145
|
class PayloadFactory:
|
|
74
146
|
def __init__(self, config: RunConfig, template_store, owner_uid: str,
|
|
75
147
|
sender_comp_id: str = "", target_comp_id: str = ""):
|
|
@@ -81,6 +153,8 @@ class PayloadFactory:
|
|
|
81
153
|
self._templates: dict[str, dict[int, str]] = {}
|
|
82
154
|
self._order_tpl: Optional[dict[int, str]] = None # Phase B single-order template
|
|
83
155
|
self._exec_tpl: Optional[dict[int, str]] = None # Phase B ExecReport template
|
|
156
|
+
self._gen_rules: dict[int, str] = {} # tag → expression, every client msg
|
|
157
|
+
self._counters: dict[int, int] = {} # seq() state, per tag per run
|
|
84
158
|
self._symbol_i = 0
|
|
85
159
|
self._side_i = 0
|
|
86
160
|
self._load_and_validate()
|
|
@@ -89,6 +163,7 @@ class PayloadFactory:
|
|
|
89
163
|
|
|
90
164
|
def _load_and_validate(self) -> None:
|
|
91
165
|
vars_ = self.config.payload.variables
|
|
166
|
+
self._gen_rules = self._parse_gen_rules(vars_)
|
|
92
167
|
for scn in self.config.payload.scenarios:
|
|
93
168
|
seen: list[str] = []
|
|
94
169
|
for tpl_id in scn.messages:
|
|
@@ -101,14 +176,32 @@ class PayloadFactory:
|
|
|
101
176
|
for tok in _tokens(val):
|
|
102
177
|
self._validate_token(tok, scn, tpl_id, seen, vars_)
|
|
103
178
|
seen.append(tpl_id)
|
|
179
|
+
# gen rules apply to every client-injected message (insert-or-replace)
|
|
180
|
+
fields.update(self._gen_rules)
|
|
104
181
|
|
|
105
182
|
# Phase B standalone templates (no scenario sequence → no {ref:}).
|
|
106
183
|
p = self.config.payload
|
|
107
|
-
self._order_tpl = self._load_standalone(p.order_template_id, _SIMPLE, "order"
|
|
184
|
+
self._order_tpl = self._load_standalone(p.order_template_id, _SIMPLE, "order",
|
|
185
|
+
merge_rules=True)
|
|
108
186
|
self._exec_tpl = self._load_standalone(p.exec_template_id, _SIMPLE | _EXEC_TOKENS, "exec")
|
|
109
187
|
|
|
110
|
-
def
|
|
111
|
-
|
|
188
|
+
def _parse_gen_rules(self, vars_: dict) -> dict[int, str]:
|
|
189
|
+
rules: dict[int, str] = {}
|
|
190
|
+
for k, v in self.config.payload.gen_rules.items():
|
|
191
|
+
try:
|
|
192
|
+
tag = int(k)
|
|
193
|
+
except (TypeError, ValueError):
|
|
194
|
+
raise PerfConfigError(f"gen_rules: non-integer tag {k!r}")
|
|
195
|
+
if tag in CONTROL_TAGS:
|
|
196
|
+
continue # transport-owned, the session stamps these
|
|
197
|
+
val = str(v)
|
|
198
|
+
for tok in _tokens(val):
|
|
199
|
+
self._validate_simple_token(tok, f"gen_rules[{tag}]", _SIMPLE, vars_)
|
|
200
|
+
rules[tag] = val
|
|
201
|
+
return rules
|
|
202
|
+
|
|
203
|
+
def _load_standalone(self, tpl_id: Optional[str], allowed: set, label: str,
|
|
204
|
+
merge_rules: bool = False) -> Optional[dict[int, str]]:
|
|
112
205
|
if not tpl_id:
|
|
113
206
|
return None
|
|
114
207
|
tpl = self._ts.get_template(self._uid, tpl_id) if self._ts else None
|
|
@@ -118,13 +211,23 @@ class PayloadFactory:
|
|
|
118
211
|
vars_ = self.config.payload.variables
|
|
119
212
|
for val in fields.values():
|
|
120
213
|
for tok in _tokens(val):
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if tok not in allowed and tok not in vars_:
|
|
125
|
-
raise PerfConfigError(f"unknown token {{{tok}}} in {label} template {tpl_id!r}")
|
|
214
|
+
self._validate_simple_token(tok, f"{label} template {tpl_id!r}", allowed, vars_)
|
|
215
|
+
if merge_rules:
|
|
216
|
+
fields.update(self._gen_rules)
|
|
126
217
|
return fields
|
|
127
218
|
|
|
219
|
+
def _validate_simple_token(self, tok: str, where: str, allowed: set, vars_: dict) -> None:
|
|
220
|
+
"""Token check for contexts with no scenario sequence (no {ref:})."""
|
|
221
|
+
if tok.startswith("ref:"):
|
|
222
|
+
raise PerfConfigError(f"{{{tok}}} not allowed in {where} (no scenario sequence)")
|
|
223
|
+
gm = _GEN_RE.match(tok)
|
|
224
|
+
if gm:
|
|
225
|
+
if gm.group(1) not in _GENERATOR_NAMES:
|
|
226
|
+
raise PerfConfigError(f"unknown generator {{{tok}}} in {where}")
|
|
227
|
+
return
|
|
228
|
+
if tok not in allowed and tok not in vars_:
|
|
229
|
+
raise PerfConfigError(f"unknown token {{{tok}}} in {where}")
|
|
230
|
+
|
|
128
231
|
def _parse_fields(self, tpl: dict, tpl_id: str, label: str = "") -> dict[int, str]:
|
|
129
232
|
try:
|
|
130
233
|
fields = {int(k): str(v) for k, v in tpl.get("fields", {}).items()}
|
|
@@ -140,6 +243,11 @@ class PayloadFactory:
|
|
|
140
243
|
seen: list[str], vars_: dict) -> None:
|
|
141
244
|
if tok in _SIMPLE or tok in vars_:
|
|
142
245
|
return
|
|
246
|
+
gm = _GEN_RE.match(tok)
|
|
247
|
+
if gm:
|
|
248
|
+
if gm.group(1) not in _GENERATOR_NAMES:
|
|
249
|
+
raise PerfConfigError(f"unknown generator {{{tok}}} in template {tpl_id!r}")
|
|
250
|
+
return
|
|
143
251
|
if tok.startswith("ref:"):
|
|
144
252
|
ref = tok[4:]
|
|
145
253
|
if "." not in ref:
|
|
@@ -194,6 +302,16 @@ class PayloadFactory:
|
|
|
194
302
|
msg.set_field(44, f"{ctx['price']:.2f}")
|
|
195
303
|
msg.set_field(59, p.time_in_force)
|
|
196
304
|
msg.set_field(60, _utc_ms())
|
|
305
|
+
for rtag, raw in self._gen_rules.items():
|
|
306
|
+
if rtag == self.config.correlation_tag:
|
|
307
|
+
continue # engine-authoritative — correlation must survive
|
|
308
|
+
if rtag == 11 and not _is_dynamic(raw):
|
|
309
|
+
continue # a static ClOrdID would repeat across the run
|
|
310
|
+
val = self._resolve(raw, ctx, corr, clordid, {}, rtag)
|
|
311
|
+
msg.set_field(rtag, val)
|
|
312
|
+
if rtag == 11:
|
|
313
|
+
clordid = val
|
|
314
|
+
self._apply_expiry(msg, self._gen_rules)
|
|
197
315
|
return msg, {"corr_id": corr, "clordid": clordid, "symbol": ctx["symbol"],
|
|
198
316
|
"side": ctx["side"], "qty": ctx["qty"], "price": ctx["price"],
|
|
199
317
|
"order_qty": ctx["qty"], "msg_type": "D"}
|
|
@@ -209,22 +327,34 @@ class PayloadFactory:
|
|
|
209
327
|
for tag, raw in fields.items():
|
|
210
328
|
if tag == 35:
|
|
211
329
|
continue
|
|
212
|
-
msg.set_field(tag, self._resolve(raw, ctx, corr, clordid, refs))
|
|
330
|
+
msg.set_field(tag, self._resolve(raw, ctx, corr, clordid, refs, tag))
|
|
213
331
|
# ensure correlation + clordid are present even if the template omits them
|
|
214
332
|
msg.set_field(self.config.correlation_tag, corr)
|
|
215
|
-
|
|
333
|
+
# ClOrdID must be unique per message: keep a dynamic template value
|
|
334
|
+
# (renders fresh each time), replace a static one that would repeat.
|
|
335
|
+
if 11 in fields and _is_dynamic(fields[11]):
|
|
336
|
+
clordid = msg.get_field_or(11, clordid)
|
|
337
|
+
else:
|
|
216
338
|
msg.set_field(11, clordid)
|
|
339
|
+
self._apply_expiry(msg, fields)
|
|
217
340
|
refs[tpl_id] = clordid
|
|
218
341
|
return msg, {"corr_id": corr, "clordid": clordid, "symbol": ctx["symbol"],
|
|
219
342
|
"side": ctx["side"], "qty": ctx["qty"], "price": ctx["price"],
|
|
220
343
|
"order_qty": ctx["qty"], "msg_type": mtype}
|
|
221
344
|
|
|
222
|
-
def _resolve(self, value: str, ctx: dict, corr: str, clordid: str, refs: dict
|
|
223
|
-
|
|
345
|
+
def _resolve(self, value: str, ctx: dict, corr: str, clordid: str, refs: dict,
|
|
346
|
+
tag: int = 0) -> str:
|
|
347
|
+
if not isinstance(value, str):
|
|
224
348
|
return value
|
|
349
|
+
if "{" not in value:
|
|
350
|
+
return _eval_generator(value, tag, self._counters)
|
|
225
351
|
|
|
226
352
|
def sub(m: re.Match) -> str:
|
|
227
353
|
tok = m.group(1)
|
|
354
|
+
gm = _GEN_RE.match(tok)
|
|
355
|
+
if gm: # embedded generator, e.g. ORD-{seq(8)}
|
|
356
|
+
out = _gen_call(gm.group(1), gm.group(2), tag, self._counters)
|
|
357
|
+
return m.group(0) if out is None else out
|
|
228
358
|
if tok == "clordid":
|
|
229
359
|
return clordid
|
|
230
360
|
if tok in ("compliance_id", "guid"):
|
|
@@ -250,11 +380,27 @@ class PayloadFactory:
|
|
|
250
380
|
return refs.get(ref_tpl, "")
|
|
251
381
|
return self.config.payload.variables.get(tok, "")
|
|
252
382
|
|
|
253
|
-
return _TOKEN_RE.sub(sub, value)
|
|
383
|
+
return _eval_generator(_TOKEN_RE.sub(sub, value), tag, self._counters)
|
|
254
384
|
|
|
255
385
|
def _clordid(self) -> str:
|
|
256
386
|
return "PCL-" + _guid()[:12].upper()
|
|
257
387
|
|
|
388
|
+
def _apply_expiry(self, msg: Message, fields: dict) -> None:
|
|
389
|
+
"""Rewrite ExpireTime(126)/ExpireDate(432) to a shared future instant —
|
|
390
|
+
only for tags the message already carries (never inserts), exactly like
|
|
391
|
+
the standard-send auto-expiry option."""
|
|
392
|
+
p = self.config.payload
|
|
393
|
+
if not p.auto_expiry:
|
|
394
|
+
return
|
|
395
|
+
has126, has432 = 126 in fields, 432 in fields
|
|
396
|
+
if not (has126 or has432):
|
|
397
|
+
return
|
|
398
|
+
ts = utc_timestamp(offset_s=p.expiry_offset * _EXPIRY_UNIT_S[p.expiry_unit])
|
|
399
|
+
if has126:
|
|
400
|
+
msg.set_field(126, ts)
|
|
401
|
+
if has432:
|
|
402
|
+
msg.set_field(432, ts[:8])
|
|
403
|
+
|
|
258
404
|
# -- Phase B: templated single order ------------------------------------
|
|
259
405
|
|
|
260
406
|
def _build_order_from_template(self, ctx: dict, corr: str, clordid: str) -> tuple[Message, dict]:
|
|
@@ -269,12 +415,16 @@ class PayloadFactory:
|
|
|
269
415
|
for tag, raw in fields.items():
|
|
270
416
|
if tag == 35:
|
|
271
417
|
continue
|
|
272
|
-
val = self._resolve(raw, ctx, corr, clordid, {})
|
|
418
|
+
val = self._resolve(raw, ctx, corr, clordid, {}, tag)
|
|
273
419
|
msg.set_field(tag, val)
|
|
274
420
|
rendered[tag] = val
|
|
275
421
|
# Standard tags the engine depends on (correlation, OrderQty for filling).
|
|
276
422
|
msg.set_field(self.config.correlation_tag, corr)
|
|
277
|
-
|
|
423
|
+
# ClOrdID must be unique per order: keep a dynamic template value
|
|
424
|
+
# (renders fresh each time), replace a static one that would repeat.
|
|
425
|
+
if 11 in fields and _is_dynamic(fields[11]):
|
|
426
|
+
clordid = rendered[11]
|
|
427
|
+
else:
|
|
278
428
|
msg.set_field(11, clordid)
|
|
279
429
|
if 55 not in fields:
|
|
280
430
|
msg.set_field(55, ctx["symbol"])
|
|
@@ -287,6 +437,7 @@ class PayloadFactory:
|
|
|
287
437
|
else:
|
|
288
438
|
order_qty = ctx["qty"]
|
|
289
439
|
msg.set_field(38, str(order_qty))
|
|
440
|
+
self._apply_expiry(msg, fields)
|
|
290
441
|
return msg, {"corr_id": corr, "clordid": clordid, "symbol": ctx["symbol"],
|
|
291
442
|
"side": ctx["side"], "qty": ctx["qty"], "price": ctx["price"],
|
|
292
443
|
"order_qty": order_qty, "msg_type": mtype}
|
|
@@ -313,7 +464,7 @@ class PayloadFactory:
|
|
|
313
464
|
for tag, raw in self._exec_tpl.items():
|
|
314
465
|
if tag == 35:
|
|
315
466
|
continue
|
|
316
|
-
msg.set_field(tag, self._resolve_exec(raw, of, exec_ctx))
|
|
467
|
+
msg.set_field(tag, self._resolve_exec(raw, of, exec_ctx, tag))
|
|
317
468
|
# Engine-authoritative standard tags (override any template value).
|
|
318
469
|
msg.set_field(cfg.correlation_tag, corr)
|
|
319
470
|
msg.set_field(cfg.exec_id_tag, _uuid())
|
|
@@ -337,13 +488,19 @@ class PayloadFactory:
|
|
|
337
488
|
msg.set_field(60, _utc_ms())
|
|
338
489
|
return msg
|
|
339
490
|
|
|
340
|
-
def _resolve_exec(self, value: str, of: dict, exec_ctx: dict) -> str:
|
|
341
|
-
if not isinstance(value, str)
|
|
491
|
+
def _resolve_exec(self, value: str, of: dict, exec_ctx: dict, tag: int = 0) -> str:
|
|
492
|
+
if not isinstance(value, str):
|
|
342
493
|
return value
|
|
494
|
+
if "{" not in value:
|
|
495
|
+
return _eval_generator(value, tag, self._counters)
|
|
343
496
|
cfg = self.config
|
|
344
497
|
|
|
345
498
|
def sub(m: re.Match) -> str:
|
|
346
499
|
tok = m.group(1)
|
|
500
|
+
gm = _GEN_RE.match(tok)
|
|
501
|
+
if gm: # embedded generator, e.g. ORD-{seq(8)}
|
|
502
|
+
out = _gen_call(gm.group(1), gm.group(2), tag, self._counters)
|
|
503
|
+
return m.group(0) if out is None else out
|
|
347
504
|
if tok in exec_ctx:
|
|
348
505
|
return str(exec_ctx[tok])
|
|
349
506
|
if tok == "clordid":
|
|
@@ -368,7 +525,7 @@ class PayloadFactory:
|
|
|
368
525
|
return "E" + _guid()[:8].upper()
|
|
369
526
|
return cfg.payload.variables.get(tok, "")
|
|
370
527
|
|
|
371
|
-
return _TOKEN_RE.sub(sub, value)
|
|
528
|
+
return _eval_generator(_TOKEN_RE.sub(sub, value), tag, self._counters)
|
|
372
529
|
|
|
373
530
|
|
|
374
531
|
class ScenarioSelector:
|