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.
Files changed (99) hide show
  1. {fixtureqa-0.4.1/fixtureqa.egg-info → fixtureqa-0.4.3}/PKG-INFO +1 -1
  2. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/fix_time.py +7 -3
  3. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/perf_models.py +8 -0
  4. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/perf_payload.py +176 -19
  5. fixtureqa-0.4.1/fixture/static/assets/index-D9vW5wFo.css → fixtureqa-0.4.3/fixture/static/assets/index-BwQf-cei.css +1 -1
  6. fixtureqa-0.4.3/fixture/static/assets/index-CyNOPa0n.js +2 -0
  7. fixtureqa-0.4.3/fixture/static/assets/index-Dd6aSjfO.js +102 -0
  8. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/static/index.html +2 -2
  9. {fixtureqa-0.4.1 → fixtureqa-0.4.3/fixtureqa.egg-info}/PKG-INFO +1 -1
  10. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixtureqa.egg-info/SOURCES.txt +3 -2
  11. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/pyproject.toml +1 -1
  12. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_perf_payload.py +195 -0
  13. fixtureqa-0.4.1/fixture/static/assets/index-erFDS7Xr.js +0 -102
  14. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/LICENSE +0 -0
  15. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/README.md +0 -0
  16. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/__init__.py +0 -0
  17. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/__main__.py +0 -0
  18. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/__init__.py +0 -0
  19. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/app.py +0 -0
  20. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/connection_manager.py +0 -0
  21. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/deps.py +0 -0
  22. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/__init__.py +0 -0
  23. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/admin.py +0 -0
  24. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/auth.py +0 -0
  25. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/branding.py +0 -0
  26. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/custom_tags.py +0 -0
  27. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/fix_spec.py +0 -0
  28. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/messages.py +0 -0
  29. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/perf.py +0 -0
  30. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/scenarios.py +0 -0
  31. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/sessions.py +0 -0
  32. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/setup.py +0 -0
  33. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/spec_overlay.py +0 -0
  34. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/templates.py +0 -0
  35. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/routers/ws.py +0 -0
  36. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/api/schemas.py +0 -0
  37. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/config/__init__.py +0 -0
  38. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/__init__.py +0 -0
  39. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/atomic_io.py +0 -0
  40. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/auth.py +0 -0
  41. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/config_store.py +0 -0
  42. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/custom_tag_store.py +0 -0
  43. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/db_migrations.py +0 -0
  44. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/events.py +0 -0
  45. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/fix_application.py +0 -0
  46. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/fix_builder.py +0 -0
  47. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/fix_parser.py +0 -0
  48. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/fix_spec_parser.py +0 -0
  49. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/fix_tags.py +0 -0
  50. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/housekeeping.py +0 -0
  51. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/inbound.py +0 -0
  52. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/json_store.py +0 -0
  53. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/message_log.py +0 -0
  54. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/message_store.py +0 -0
  55. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/models.py +0 -0
  56. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/perf_engine.py +0 -0
  57. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/perf_stats.py +0 -0
  58. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/perf_store.py +0 -0
  59. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/perf_writer.py +0 -0
  60. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/scenario_runner.py +0 -0
  61. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/scenario_store.py +0 -0
  62. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/session.py +0 -0
  63. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/session_manager.py +0 -0
  64. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/spec_overlay_store.py +0 -0
  65. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/template_store.py +0 -0
  66. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/user_store.py +0 -0
  67. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/core/venue_responses.py +0 -0
  68. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/fix_specs/FIX42.xml +0 -0
  69. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/fix_specs/FIX44.xml +0 -0
  70. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/server.py +0 -0
  71. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  72. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  73. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/static/favicon.svg +0 -0
  74. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixture/ui/__init__.py +0 -0
  75. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixtureqa.egg-info/dependency_links.txt +0 -0
  76. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixtureqa.egg-info/entry_points.txt +0 -0
  77. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixtureqa.egg-info/requires.txt +0 -0
  78. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/fixtureqa.egg-info/top_level.txt +0 -0
  79. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/setup.cfg +0 -0
  80. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_atomic_io.py +0 -0
  81. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_auth.py +0 -0
  82. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_config_store.py +0 -0
  83. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_connection_manager.py +0 -0
  84. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_db_migrations.py +0 -0
  85. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_fix_builder.py +0 -0
  86. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_health.py +0 -0
  87. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_inbound.py +0 -0
  88. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_inbound_validation.py +0 -0
  89. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_message_store.py +0 -0
  90. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_perf_api.py +0 -0
  91. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_perf_engine.py +0 -0
  92. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_perf_models.py +0 -0
  93. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_perf_rehydrate.py +0 -0
  94. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_scenarios.py +0 -0
  95. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_session_lifecycle.py +0 -0
  96. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_session_manager_concurrency.py +0 -0
  97. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_sessions.py +0 -0
  98. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_templates.py +0 -0
  99. {fixtureqa-0.4.1 → fixtureqa-0.4.3}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -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 _load_standalone(self, tpl_id: Optional[str], allowed: set,
111
- label: str) -> Optional[dict[int, str]]:
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
- if tok.startswith("ref:"):
122
- raise PerfConfigError(
123
- f"{{{tok}}} not allowed in {label} template (no scenario sequence)")
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
- if 11 not in fields:
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) -> str:
223
- if not isinstance(value, str) or "{" not in value:
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
- if 11 not in fields:
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) or "{" not in value:
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: