fixtureqa 0.16.0__tar.gz → 0.17.0__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 (116) hide show
  1. {fixtureqa-0.16.0/fixtureqa.egg-info → fixtureqa-0.17.0}/PKG-INFO +2 -2
  2. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/README.md +10 -7
  3. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/app.py +2 -2
  4. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/routers/atdl.py +2 -0
  5. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/routers/sessions.py +19 -26
  6. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/atdl_parser.py +15 -3
  7. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/atdl_store.py +36 -10
  8. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/fix_application.py +29 -14
  9. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/perf_engine.py +8 -2
  10. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/scenario_runner.py +7 -4
  11. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/session.py +16 -4
  12. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/session_manager.py +14 -5
  13. fixtureqa-0.16.0/fixture/static/assets/index-D0a-mw9U.css → fixtureqa-0.17.0/fixture/static/assets/index-B_lSUQwR.css +1 -1
  14. fixtureqa-0.17.0/fixture/static/assets/index-GOqXScPd.js +103 -0
  15. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/static/index.html +2 -2
  16. {fixtureqa-0.16.0 → fixtureqa-0.17.0/fixtureqa.egg-info}/PKG-INFO +2 -2
  17. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixtureqa.egg-info/SOURCES.txt +4 -2
  18. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixtureqa.egg-info/requires.txt +1 -1
  19. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/pyproject.toml +2 -2
  20. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_atdl.py +62 -0
  21. fixtureqa-0.17.0/tests/test_fix_application.py +62 -0
  22. fixtureqa-0.17.0/tests/test_send_errors.py +105 -0
  23. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_sessions.py +23 -0
  24. fixtureqa-0.16.0/fixture/static/assets/index-DCRhkrrW.js +0 -103
  25. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/LICENSE +0 -0
  26. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/__init__.py +0 -0
  27. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/__main__.py +0 -0
  28. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/__init__.py +0 -0
  29. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/connection_manager.py +0 -0
  30. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/deps.py +0 -0
  31. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/routers/__init__.py +0 -0
  32. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/routers/admin.py +0 -0
  33. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/routers/auth.py +0 -0
  34. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/routers/branding.py +0 -0
  35. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/routers/custom_tags.py +0 -0
  36. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/routers/fix_spec.py +0 -0
  37. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/routers/messages.py +0 -0
  38. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/routers/perf.py +0 -0
  39. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/routers/scenarios.py +0 -0
  40. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/routers/setup.py +0 -0
  41. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/routers/spec_overlay.py +0 -0
  42. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/routers/specs.py +0 -0
  43. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/routers/templates.py +0 -0
  44. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/routers/ws.py +0 -0
  45. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/api/schemas.py +0 -0
  46. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/config/__init__.py +0 -0
  47. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/__init__.py +0 -0
  48. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/atomic_io.py +0 -0
  49. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/auth.py +0 -0
  50. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/config_store.py +0 -0
  51. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/custom_tag_store.py +0 -0
  52. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/db_migrations.py +0 -0
  53. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/events.py +0 -0
  54. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/fix_builder.py +0 -0
  55. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/fix_parser.py +0 -0
  56. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/fix_spec_parser.py +0 -0
  57. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/fix_tags.py +0 -0
  58. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/fix_time.py +0 -0
  59. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/housekeeping.py +0 -0
  60. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/inbound.py +0 -0
  61. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/json_store.py +0 -0
  62. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/latency_csv_writer.py +0 -0
  63. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/message_log.py +0 -0
  64. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/message_store.py +0 -0
  65. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/models.py +0 -0
  66. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/perf_charts.py +0 -0
  67. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/perf_models.py +0 -0
  68. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/perf_payload.py +0 -0
  69. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/perf_stats.py +0 -0
  70. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/perf_store.py +0 -0
  71. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/perf_writer.py +0 -0
  72. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/scenario_store.py +0 -0
  73. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/spec_overlay_store.py +0 -0
  74. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/spec_store.py +0 -0
  75. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/template_store.py +0 -0
  76. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/user_store.py +0 -0
  77. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/value_gen.py +0 -0
  78. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/core/venue_responses.py +0 -0
  79. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/server.py +0 -0
  80. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  81. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  82. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  83. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/static/favicon.svg +0 -0
  84. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixture/ui/__init__.py +0 -0
  85. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
  86. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixtureqa.egg-info/entry_points.txt +0 -0
  87. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/fixtureqa.egg-info/top_level.txt +0 -0
  88. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/setup.cfg +0 -0
  89. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_atomic_io.py +0 -0
  90. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_auth.py +0 -0
  91. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_config_store.py +0 -0
  92. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_connection_manager.py +0 -0
  93. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_db_migrations.py +0 -0
  94. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_fix_builder.py +0 -0
  95. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_fix_spec_tree.py +0 -0
  96. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_health.py +0 -0
  97. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_inbound.py +0 -0
  98. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_inbound_validation.py +0 -0
  99. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_message_store.py +0 -0
  100. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_msg_user_attribution.py +0 -0
  101. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_non_stop_session.py +0 -0
  102. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_perf_api.py +0 -0
  103. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_perf_engine.py +0 -0
  104. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_perf_models.py +0 -0
  105. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_perf_payload.py +0 -0
  106. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_perf_rehydrate.py +0 -0
  107. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_scenarios.py +0 -0
  108. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_session_auto_response.py +0 -0
  109. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_session_lifecycle.py +0 -0
  110. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_session_manager_concurrency.py +0 -0
  111. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_session_visibility.py +0 -0
  112. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_specs.py +0 -0
  113. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_templates.py +0 -0
  114. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_timezone_data.py +0 -0
  115. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_value_gen.py +0 -0
  116. {fixtureqa-0.16.0 → fixtureqa-0.17.0}/tests/test_ws.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.16.0
3
+ Version: 0.17.0
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
7
- Requires-Dist: fixcore-engine==0.11.0
7
+ Requires-Dist: fixcore-engine==0.11.1
8
8
  Requires-Dist: fastapi>=0.111.0
9
9
  Requires-Dist: uvicorn[standard]>=0.29.0
10
10
  Requires-Dist: websockets>=12.0
@@ -1,6 +1,6 @@
1
1
  # FIXture
2
2
 
3
- A Python FIX protocol testing tool. Supports FIX 4.2 and 4.4, dynamic session creation at runtime (no restart required), and both initiator and acceptor roles per session.
3
+ A Python FIX protocol testing tool. Supports FIX 4.0–4.4 and FIX 5.0 (over FIXT.1.1), dynamic session creation at runtime (no restart required), and both initiator and acceptor roles per session.
4
4
 
5
5
  ![FIXture](frontend/src/assets/logo-dark.svg)
6
6
 
@@ -8,16 +8,19 @@ A Python FIX protocol testing tool. Supports FIX 4.2 and 4.4, dynamic session cr
8
8
 
9
9
  ## Features
10
10
 
11
- - **FIX 4.2 and 4.4** support with bundled data dictionaries
12
- - **Initiator and acceptor** roles, configurable per session
11
+ - **FIX 4.0–4.4 and FIX 5.0** every classic version plus FIX 5.0/SP1/SP2 over FIXT.1.1 (transport + application dictionary, ApplVerID on the Logon), with bundled data dictionaries
12
+ - **Custom data dictionaries** upload per-user spec XMLs and pick them per session; built-in spec browser for every bundled/uploaded dictionary (messages, fields, enums, repeating groups)
13
+ - **Initiator and acceptor** roles, configurable per session, with the full engine config surface (schedules, timers, resend/validation behaviour) in the session form
13
14
  - **Dynamic sessions** — add or remove sessions without restarting
14
- - **Multi-user auth** — JWT login, per-user session isolation, admin panel
15
- - **Message log** — real-time IN/OUT stream with field breakdown, filtering, cursor pagination
16
- - **Compose & send** — paste raw FIX or use message templates; auto-inject tag 60 and primary ID
15
+ - **Multi-user auth** — JWT login and admin panel; sessions are visible and sendable to every user, control (start/stop/edit/delete) stays with the owner or an admin, and each sent message is attributed to the user who sent it
16
+ - **Message log** — real-time IN/OUT stream with field breakdown, filtering, cursor pagination, and full-history search (substring or regex)
17
+ - **Compose & send** — paste raw FIX or use message templates; one message per line sends a batch in order; auto-inject tag 60 and primary ID; value generators (`uuid()`, `ORD-{seq(8)}`, …)
18
+ - **Algo orders (FIXatdl)** — upload a broker's ATDL file and build orders from a generated form: typed parameters, conditional enable/hide and cross-field validation (StateRules/StrategyEdits), the strategy identifier stamped automatically; validate-without-saving for authoring your own files
17
19
  - **Templates** — create/edit reusable message templates with FIX spec field definitions
20
+ - **Custom tags** — per-user tag definitions (name, type, enum labels) that decode in the message log; ATDL parameters can register themselves automatically
18
21
  - **Scenarios** — automated send/expect/delay test scripts with live pass/fail results; repeatable steps/scenarios, value generators (`uuid()`, `ORD-{seq(8)}`, …) in send values, `{recv:<tag>}` to echo an order's tags into the ack/fill that answers it, and cross-session **suites** that coordinate through real FIX traffic (e.g. a client scenario and a venue scenario run together)
19
22
  - **Venue simulation** — auto-ack and auto-fill responses for inbound NewOrderSingles
20
- - **Performance testing** — rate-controlled order injection with latency correlation (p50/p95/p99), live dashboard, run history and CSV export; order/exec shapes templatable per counterparty; amend (35=G) and cancel (35=F) flavours with their own reply latencies (see [PERF.md](PERF.md))
23
+ - **Performance testing** — rate-controlled order injection with latency correlation (p50/p95/p99), live dashboard, run history and CSV export; order/exec shapes templatable per counterparty; amend (35=G) and cancel (35=F) flavours with their own reply latencies; a 4-session relay topology (`client → relay → venue`) that measures per-hop pass-through latency (see [PERF.md](PERF.md))
21
24
  - **Log analysis** — Overview stats, Throughput chart, Reject detail, Latency measurement (p50/p95/p99)
22
25
  - **Export** — download session messages as CSV or raw FIX
23
26
  - **Sequence numbers** — view and reset TX/RX seqnums per session
@@ -54,10 +54,10 @@ async def lifespan(app: FastAPI):
54
54
  await app.state.perf_registry.shutdown()
55
55
  except Exception:
56
56
  pass
57
- # Force-stop all running sessions so their asyncio tasks can be cancelled cleanly
57
+ # Stop all running sessions so their asyncio tasks can be cancelled cleanly
58
58
  for sid in list(sm._sessions.keys()):
59
59
  try:
60
- await sm.stop_session(sid, force=True)
60
+ await sm.stop_session(sid)
61
61
  except Exception:
62
62
  pass
63
63
  sm.close() # flush SQLite writer
@@ -55,6 +55,8 @@ def _strategy_dict(s) -> dict:
55
55
  "type": p.type,
56
56
  "kind": p.kind,
57
57
  "required": p.required,
58
+ "true_wire_value": p.true_wire_value,
59
+ "false_wire_value": p.false_wire_value,
58
60
  "enums": [{"enum_id": e.enum_id, "wire_value": e.wire_value}
59
61
  for e in p.enums],
60
62
  }
@@ -1,5 +1,5 @@
1
1
  from typing import Annotated, Optional
2
- from fastapi import APIRouter, Depends, HTTPException, Query
2
+ from fastapi import APIRouter, Depends, HTTPException
3
3
 
4
4
  from ...core.message_store import MessageStore
5
5
  from ...core.session_manager import SessionManager
@@ -56,6 +56,8 @@ def _config_from_request(body: SessionConfigRequest, owner_uid: str) -> SessionC
56
56
 
57
57
  def _session_response(sm: SessionManager, session_id: str, us: Optional[UserStore] = None,
58
58
  counts: Optional[dict] = None) -> SessionResponse:
59
+ """Sessions are visible to all users, so every response carries
60
+ owner_username (resolved via `us`) for the UI owner badge / "Mine" filter."""
59
61
  cfg = sm.get_config(session_id)
60
62
  if cfg is None:
61
63
  raise HTTPException(status_code=404, detail="Session not found")
@@ -126,19 +128,11 @@ def _session_response(sm: SessionManager, session_id: str, us: Optional[UserStor
126
128
  )
127
129
 
128
130
 
129
- def _owner_store(us: UserStore, user: User) -> Optional[UserStore]:
130
- """Every response carries owner_username: sessions are visible to all users,
131
- so the UI owner badge / "Mine" filter needs the owner on every session,
132
- not just for admins."""
133
- return us
134
-
135
-
136
131
  @router.get("/", response_model=list[SessionResponse])
137
132
  def list_sessions(sm: SM, us: US, user: CurrentUser):
138
133
  configs = sm.list_sessions_for_user(user.uid, is_admin=user.is_admin)
139
- user_store = _owner_store(us, user)
140
134
  counts = sm.message_counts()
141
- return [_session_response(sm, cfg.session_id, user_store, counts) for cfg in configs]
135
+ return [_session_response(sm, cfg.session_id, us, counts) for cfg in configs]
142
136
 
143
137
 
144
138
  @router.post("/", response_model=SessionResponse, status_code=201)
@@ -168,7 +162,7 @@ async def create_session(body: SessionConfigRequest, sm: SM, us: US, ss: SS, use
168
162
  pass # best-effort rollback; surface the original start failure
169
163
  raise HTTPException(status_code=500, detail=f"Failed to start session: {e}")
170
164
 
171
- return _session_response(sm, session_id, _owner_store(us, user))
165
+ return _session_response(sm, session_id, us)
172
166
 
173
167
 
174
168
  @router.post("/import", response_model=list[SessionResponse], status_code=201)
@@ -188,13 +182,13 @@ def import_sessions(body: list[SessionConfigRequest], sm: SM, us: US, ss: SS, us
188
182
  session_id = sm.add_session(cfg)
189
183
  except (HTTPException, ValueError, KeyError):
190
184
  continue # invalid / conflicting entry — skip just this one
191
- created.append(_session_response(sm, session_id, _owner_store(us, user)))
185
+ created.append(_session_response(sm, session_id, us))
192
186
  return created
193
187
 
194
188
 
195
189
  @router.get("/{session_id}", response_model=SessionResponse)
196
190
  def get_session(session_id: str, _cfg: ViewableSession, sm: SM, us: US, user: CurrentUser):
197
- return _session_response(sm, session_id, _owner_store(us, user))
191
+ return _session_response(sm, session_id, us)
198
192
 
199
193
 
200
194
  @router.patch("/{session_id}", response_model=SessionResponse)
@@ -209,7 +203,7 @@ def update_session(session_id: str, body: UpdateSessionRequest, _cfg: OwnedSessi
209
203
  raise HTTPException(status_code=409, detail=str(e))
210
204
  except (ValueError, KeyError) as e:
211
205
  raise HTTPException(status_code=422, detail=str(e))
212
- return _session_response(sm, session_id, _owner_store(us, user))
206
+ return _session_response(sm, session_id, us)
213
207
 
214
208
 
215
209
  @router.delete("/{session_id}", status_code=204)
@@ -226,20 +220,13 @@ async def start_session(session_id: str, _cfg: OwnedSession, sm: SM, us: US, use
226
220
  await sm.start_session(session_id)
227
221
  except RuntimeError as e:
228
222
  raise HTTPException(status_code=409, detail=str(e))
229
- return _session_response(sm, session_id, _owner_store(us, user))
223
+ return _session_response(sm, session_id, us)
230
224
 
231
225
 
232
226
  @router.post("/{session_id}/stop", response_model=SessionResponse)
233
- async def stop_session(
234
- session_id: str,
235
- _cfg: OwnedSession,
236
- sm: SM,
237
- us: US,
238
- user: CurrentUser,
239
- force: bool = Query(default=False),
240
- ):
241
- await sm.stop_session(session_id, force=force)
242
- return _session_response(sm, session_id, _owner_store(us, user))
227
+ async def stop_session(session_id: str, _cfg: OwnedSession, sm: SM, us: US, user: CurrentUser):
228
+ await sm.stop_session(session_id)
229
+ return _session_response(sm, session_id, us)
243
230
 
244
231
 
245
232
  @router.post("/{session_id}/send")
@@ -258,7 +245,13 @@ async def send_message(session_id: str, body: SendMessageRequest, _cfg: Viewable
258
245
  except ValueError as e:
259
246
  raise HTTPException(status_code=422, detail=str(e))
260
247
 
261
- ok = await sm.send_message(session_id, msg, user_id=user.uid)
248
+ from ...core.session_manager import SendError
249
+ try:
250
+ ok = await sm.send_message(session_id, msg, user_id=user.uid)
251
+ except SendError as e:
252
+ # A real send failure on a logged-on session — report the engine's
253
+ # error instead of the misleading "not logged on".
254
+ raise HTTPException(status_code=500, detail=f"Send failed: {e}")
262
255
  if not ok:
263
256
  raise HTTPException(status_code=409, detail="Session not logged on")
264
257
  return {"ok": True}
@@ -16,7 +16,10 @@ itself is walked only to map a ``<Control parameterRef=...>`` to the parameter a
16
16
  Edit/StrategyEdit/StateRule semantics (FIXatdl 1.1 spec):
17
17
  * ``Edit`` is a boolean expression: either an ``operator`` (EX/NX/EQ/NE/LE/LT/
18
18
  GE/GT over ``field`` vs ``value`` or ``field2``) **or** a ``logicOperator``
19
- (AND/OR/XOR/NOT over child Edits) — never both.
19
+ (AND/OR/XOR/NOT over child Edits) — never both. ``field``/``field2`` name a
20
+ strategy parameter, or a *standard FIX field* with the ``FIX_`` prefix
21
+ (e.g. ``FIX_OrderQty``) — the latter resolve against the order ticket, not
22
+ the parameter list.
20
23
  * ``StrategyEdit`` is an *assertion*: the order is valid when its Edit is TRUE,
21
24
  and ``errorMessage`` is shown when it is FALSE.
22
25
  * ``StateRule`` action is *in-effect when its Edit is TRUE*: ``enabled="false"``
@@ -83,6 +86,10 @@ class Parameter:
83
86
  type: str # raw ATDL type, e.g. "Price_t"
84
87
  kind: str # form kind: int|float|bool|string|…
85
88
  required: bool
89
+ # Boolean_t wire encoding (ATDL trueWireValue/falseWireValue, default Y/N);
90
+ # Edits/StateRules still speak "true"/"false". "" for non-boolean params.
91
+ true_wire_value: str = ""
92
+ false_wire_value: str = ""
86
93
  enums: list[EnumPair] = _dcfield(default_factory=list)
87
94
 
88
95
 
@@ -95,7 +102,7 @@ class Edit:
95
102
  (``logic`` + ``children``), or an *EditRef* leaf (``ref`` = a named Edit id).
96
103
  """
97
104
  operator: str = "" # EX/NX/EQ/NE/LE/LT/GE/GT ('' unless a comparison)
98
- field: str = "" # parameter *name* being tested
105
+ field: str = "" # parameter *name* (or FIX_-prefixed standard field)
99
106
  field2: str = "" # compare against another field's value
100
107
  value: str = "" # …or against this literal
101
108
  has_value: bool = False # value="" (present) vs value absent
@@ -334,6 +341,8 @@ def atdl_warnings(atdl: AtdlFile) -> list[str]:
334
341
  w.append(f"EditRef '{ref}' in '{s.name}' does not resolve to a "
335
342
  "named <Edit id=...>.")
336
343
  for fld in sorted(fields):
344
+ if fld.startswith("FIX_"):
345
+ continue # standard FIX field ref, resolved against the order ticket
337
346
  if fld not in names:
338
347
  w.append(f"An Edit in '{s.name}' references unknown field '{fld}'.")
339
348
  for sr in s.state_rules:
@@ -345,13 +354,16 @@ def atdl_warnings(atdl: AtdlFile) -> list[str]:
345
354
 
346
355
  def _parse_parameter(el) -> Parameter:
347
356
  atdl_type = _xsi_type(el)
357
+ kind = _TYPE_KIND.get(atdl_type, "string")
348
358
  return Parameter(
349
359
  name=el.get("name", ""),
350
360
  fix_tag=_int_attr(el, "fixTag", 0),
351
361
  type=atdl_type,
352
- kind=_TYPE_KIND.get(atdl_type, "string"),
362
+ kind=kind,
353
363
  # ATDL `use` is "optional"/"required"; absent → optional.
354
364
  required=(el.get("use", "optional").lower() == "required"),
365
+ true_wire_value=(el.get("trueWireValue", "Y") if kind == "bool" else ""),
366
+ false_wire_value=(el.get("falseWireValue", "N") if kind == "bool" else ""),
355
367
  enums=[
356
368
  EnumPair(enum_id=ep.get("enumID", ""), wire_value=ep.get("wireValue", ""))
357
369
  for ep in _find_local(el, "EnumPair")
@@ -32,6 +32,10 @@ class AtdlStore:
32
32
  def __init__(self, data_dir: str):
33
33
  self._data_dir = data_dir
34
34
  self._lock = threading.RLock()
35
+ # path -> (mtime, strategy_count): list() used to re-parse every file
36
+ # on every call just to display its count; parse once per on-disk
37
+ # version instead (mtime change = new upload replaced the file).
38
+ self._counts: dict[str, tuple[float, int]] = {}
35
39
 
36
40
  # --- paths ---
37
41
  def _dir(self, uid: str) -> str:
@@ -62,10 +66,36 @@ class AtdlStore:
62
66
  parsed = parse_atdl(xml_text)
63
67
  except AtdlParseError as e:
64
68
  raise AtdlValidationError(str(e)) from e
69
+ path = self._path(uid, name)
65
70
  with self._lock:
66
- atomic_write_text(self._path(uid, name), xml_text)
71
+ atomic_write_text(path, xml_text)
72
+ try:
73
+ self._counts[path] = (os.path.getmtime(path), len(parsed.strategies))
74
+ except OSError:
75
+ pass
67
76
  return {"name": name, "strategy_count": len(parsed.strategies),
68
- "value": self._path(uid, name)}
77
+ "value": path}
78
+
79
+ def _strategy_count(self, path: str) -> int:
80
+ """Strategy count for a stored file, parsed at most once per on-disk
81
+ version. 0 for a file that vanished or no longer parses."""
82
+ try:
83
+ mtime = os.path.getmtime(path)
84
+ except OSError:
85
+ return 0
86
+ with self._lock:
87
+ hit = self._counts.get(path)
88
+ if hit is not None and hit[0] == mtime:
89
+ return hit[1]
90
+ count = 0
91
+ try:
92
+ with open(path) as f:
93
+ count = len(parse_atdl(f.read()).strategies)
94
+ except (OSError, AtdlParseError):
95
+ pass
96
+ with self._lock:
97
+ self._counts[path] = (mtime, count)
98
+ return count
69
99
 
70
100
  def list(self, uid: str) -> list[dict]:
71
101
  """This user's uploaded ATDL files (name + strategy count)."""
@@ -75,14 +105,9 @@ class AtdlStore:
75
105
  for fn in sorted(os.listdir(d)):
76
106
  if not fn.lower().endswith(".xml"):
77
107
  continue
78
- count = 0
79
- try:
80
- with open(self._path(uid, fn)) as f:
81
- count = len(parse_atdl(f.read()).strategies)
82
- except (OSError, AtdlParseError):
83
- pass
84
- out.append({"name": fn, "strategy_count": count,
85
- "value": self._path(uid, fn)})
108
+ path = self._path(uid, fn)
109
+ out.append({"name": fn, "strategy_count": self._strategy_count(path),
110
+ "value": path})
86
111
  return out
87
112
 
88
113
  def get_parsed(self, uid: str, name: str) -> AtdlFile:
@@ -98,6 +123,7 @@ class AtdlStore:
98
123
  name = self._sanitize(name)
99
124
  with self._lock:
100
125
  path = self._path(uid, name)
126
+ self._counts.pop(path, None)
101
127
  if not os.path.isfile(path):
102
128
  return False
103
129
  os.remove(path)
@@ -1,4 +1,4 @@
1
- import asyncio
1
+ import logging
2
2
  import time
3
3
 
4
4
  from fixcore.application import Application
@@ -7,6 +7,8 @@ from fixcore.session.session_id import SessionID
7
7
 
8
8
  from .events import EventHandler, EventType, SessionEvent
9
9
 
10
+ logger = logging.getLogger(__name__)
11
+
10
12
 
11
13
  def _flatten_groups(groups: dict, into: dict) -> None:
12
14
  """Merge repeating-group member fields into a flat {tag: value} dict.
@@ -73,24 +75,18 @@ class FixApplication(Application):
73
75
  self._emit(EventType.SESSION_STATUS_CHANGED, {"status": "LOGGED_OUT"})
74
76
 
75
77
  def to_admin(self, message: Message, session_id: SessionID) -> None:
76
- self._emit(EventType.MESSAGE_SENT, self._payload(message, admin=True))
78
+ self._emit_message(EventType.MESSAGE_SENT, message, admin=True)
77
79
 
78
80
  def to_app(self, message: Message, session_id: SessionID) -> None:
79
- try:
80
- self._emit(EventType.MESSAGE_SENT, self._payload(message, admin=False))
81
- except Exception:
82
- pass
81
+ self._emit_message(EventType.MESSAGE_SENT, message, admin=False)
83
82
 
84
83
  def from_admin(self, message: Message, session_id: SessionID) -> None:
85
- recv_perf_ns = time.perf_counter_ns()
86
- self._emit(EventType.MESSAGE_RECEIVED, self._payload(message, admin=True, recv_perf_ns=recv_perf_ns))
84
+ self._emit_message(EventType.MESSAGE_RECEIVED, message, admin=True,
85
+ recv_perf_ns=time.perf_counter_ns())
87
86
 
88
87
  def from_app(self, message: Message, session_id: SessionID) -> None:
89
- recv_perf_ns = time.perf_counter_ns()
90
- try:
91
- self._emit(EventType.MESSAGE_RECEIVED, self._payload(message, admin=False, recv_perf_ns=recv_perf_ns))
92
- except Exception:
93
- pass
88
+ self._emit_message(EventType.MESSAGE_RECEIVED, message, admin=False,
89
+ recv_perf_ns=time.perf_counter_ns())
94
90
 
95
91
  # ------------------------------------------------------------------
96
92
  # Internal
@@ -108,6 +104,22 @@ class FixApplication(Application):
108
104
  payload["recv_perf_ns"] = recv_perf_ns
109
105
  return payload
110
106
 
107
+ def _emit_message(self, event_type: EventType, message: Message, *,
108
+ admin: bool, recv_perf_ns: int | None = None) -> None:
109
+ """Build the payload and emit. An Application callback must never raise
110
+ into the engine (it would tear down the fixcore session task), so
111
+ payload building — which walks Message internals — is guarded here for
112
+ all four to_*/from_* callbacks. Previously only the app-level pair was
113
+ wrapped; an admin message whose payload failed to build raised into
114
+ fixcore."""
115
+ try:
116
+ payload = self._payload(message, admin=admin, recv_perf_ns=recv_perf_ns)
117
+ except Exception:
118
+ logger.exception("[%s] dropped a message event: payload build failed",
119
+ self._session_id)
120
+ return
121
+ self._emit(event_type, payload)
122
+
111
123
  def _emit(self, event_type: EventType, payload: dict) -> None:
112
124
  try:
113
125
  self._event_handler(SessionEvent(
@@ -116,4 +128,7 @@ class FixApplication(Application):
116
128
  payload=payload,
117
129
  ))
118
130
  except Exception:
119
- pass
131
+ # Swallow (never raise into the engine) but don't hide it — a
132
+ # failing handler chain means events are being lost.
133
+ logger.exception("[%s] event handler failed for %s",
134
+ self._session_id, event_type.name)
@@ -41,7 +41,7 @@ from .perf_models import (
41
41
  from .perf_payload import PayloadFactory, ScenarioSelector
42
42
  from .perf_stats import BurstGate, PerfStats, TokenBucket
43
43
  from .perf_writer import PerfWriter
44
- from .session_manager import SessionManager
44
+ from .session_manager import SendError, SessionManager
45
45
 
46
46
  logger = logging.getLogger(__name__)
47
47
 
@@ -525,7 +525,13 @@ class PerfRun:
525
525
  async def _send(self, session_id: str, msg: Message) -> bool:
526
526
  """Single send path — tallies every outbound app message per leg
527
527
  (type-agnostic) for the messages-per-second chart, then delegates."""
528
- ok = await self._sm.send_message(session_id, msg)
528
+ try:
529
+ ok = await self._sm.send_message(session_id, msg)
530
+ except SendError:
531
+ # Counted like a not-logged-on refusal (rejected/failed) rather
532
+ # than crashing the injector/venue task mid-run; the underlying
533
+ # error is already logged by the session.
534
+ ok = False
529
535
  if ok:
530
536
  if session_id == self.config.client_session_id:
531
537
  self.stats.msgs_sent_client += 1
@@ -20,7 +20,7 @@ from fixcore.message.message import Message
20
20
  from .events import SessionEvent, EventType
21
21
  from .fix_builder import _ordered_pairs
22
22
  from .fix_time import utc_timestamp
23
- from .session_manager import SessionManager
23
+ from .session_manager import SendError, SessionManager
24
24
  from .template_store import TemplateStore
25
25
  from . import value_gen
26
26
 
@@ -519,9 +519,12 @@ class ScenarioRunner:
519
519
  loop = self._loop
520
520
  if loop is None:
521
521
  return "Scenario runner not bound to an event loop"
522
- ok = asyncio.run_coroutine_threadsafe(
523
- self._sm.send_message(session_id, msg), loop
524
- ).result(timeout=5)
522
+ try:
523
+ ok = asyncio.run_coroutine_threadsafe(
524
+ self._sm.send_message(session_id, msg), loop
525
+ ).result(timeout=5)
526
+ except SendError as e:
527
+ return f"Send failed: {e}"
525
528
  if not ok:
526
529
  return "Session not logged on"
527
530
  return None
@@ -32,6 +32,13 @@ _sending_user: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
32
32
  logger = logging.getLogger(__name__)
33
33
 
34
34
 
35
+ class SendError(RuntimeError):
36
+ """An application-level send failed for a reason other than "not logged
37
+ on" (encode error, transport blow-up). Distinct so callers can report the
38
+ real cause — these used to be swallowed into a False return, which the API
39
+ then misreported as "Session not logged on". Import via session_manager."""
40
+
41
+
35
42
  class _NullLog:
36
43
  """fixcore Log that drops everything — for perf sessions (no wire logging)."""
37
44
 
@@ -177,7 +184,7 @@ class Session:
177
184
  self._app = None
178
185
  self._fixcore_session_id = None
179
186
 
180
- async def stop(self, force: bool = False) -> None:
187
+ async def stop(self) -> None:
181
188
  for task in list(self._venue_tasks):
182
189
  task.cancel()
183
190
  if self._transport is not None:
@@ -203,7 +210,11 @@ class Session:
203
210
  ))
204
211
 
205
212
  async def send_message(self, message: Message, user_id: Optional[str] = None) -> bool:
206
- """Send an application-level FIX message. Returns False if not logged on.
213
+ """Send an application-level FIX message.
214
+
215
+ Returns False when the session is not logged on (the normal, expected
216
+ refusal). Any other failure raises SendError carrying the engine's
217
+ error, after logging it.
207
218
 
208
219
  `user_id` attributes the send to a human user; it's stashed in a
209
220
  ContextVar that the to_app/to_admin log path reads. None (engine/auto
@@ -218,8 +229,9 @@ class Session:
218
229
  try:
219
230
  await transport_session.send_app(message)
220
231
  return True
221
- except Exception:
222
- return False
232
+ except Exception as e:
233
+ logger.exception("[%s] send_app failed", self.config.session_id)
234
+ raise SendError(str(e)) from e
223
235
  finally:
224
236
  _sending_user.reset(token)
225
237
 
@@ -5,7 +5,9 @@ import shutil
5
5
  import threading
6
6
  from typing import Dict, List, Optional, Set
7
7
  from .models import SessionConfig, SessionState, SessionStatus
8
- from .session import Session
8
+ # SendError is re-exported here: external code (routes, perf, scenarios) only
9
+ # imports from SessionManager, never from Session directly.
10
+ from .session import SendError, Session # noqa: F401 (SendError re-export)
9
11
  from .events import EventHandler, EventType, SessionEvent
10
12
  from .config_store import ConfigStore
11
13
  from .inbound import InboundMessage, SessionSubscription, DEFAULT_MAXSIZE
@@ -71,8 +73,8 @@ class SessionManager:
71
73
  async def start_session(self, session_id: str) -> None:
72
74
  await self._get(session_id).start()
73
75
 
74
- async def stop_session(self, session_id: str, force: bool = False) -> None:
75
- await self._get(session_id).stop(force=force)
76
+ async def stop_session(self, session_id: str) -> None:
77
+ await self._get(session_id).stop()
76
78
 
77
79
  def update_session(self, session_id: str, req) -> SessionConfig:
78
80
  """Update config fields on a stopped session.
@@ -90,7 +92,13 @@ class SessionManager:
90
92
  if session.state.status != SessionStatus.STOPPED:
91
93
  raise RuntimeError("Session must be stopped before editing")
92
94
  cfg = session.config
93
- updates = {k: v for k, v in req.model_dump().items() if v is not None}
95
+ # Only fields the caller actually sent are applied, so an explicit
96
+ # null can clear a nullable field (previously indistinguishable
97
+ # from "not provided" and silently dropped). None is still skipped
98
+ # for the non-nullable fields, where it is never a valid value.
99
+ nullable = {"data_dictionary_path", "app_data_dictionary"}
100
+ updates = {k: v for k, v in req.model_dump(exclude_unset=True).items()
101
+ if v is not None or k in nullable}
94
102
  if "connection_type" in updates:
95
103
  updates["connection_type"] = ConnectionType(updates["connection_type"])
96
104
  if "session_role" in updates:
@@ -125,7 +133,7 @@ class SessionManager:
125
133
  session = self._sessions.pop(session_id, None)
126
134
  if session is None:
127
135
  raise KeyError(f"Unknown session: {session_id!r}")
128
- await session.stop(force=True) # await — outside the lock
136
+ await session.stop() # await — outside the lock
129
137
  self._persist() # disk I/O — outside the lock
130
138
 
131
139
  def get_state(self, session_id: str) -> SessionState:
@@ -172,6 +180,7 @@ class SessionManager:
172
180
  return self._get(session_id).set_seqnums(sender, target)
173
181
 
174
182
  async def send_message(self, session_id: str, message, user_id: Optional[str] = None) -> bool:
183
+ """False = not logged on; raises SendError on an actual send failure."""
175
184
  return await self._get(session_id).send_message(message, user_id=user_id)
176
185
 
177
186
  # ------------------------------------------------------------------