fixtureqa 0.16.0__tar.gz → 0.16.1__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.16.0/fixtureqa.egg-info → fixtureqa-0.16.1}/PKG-INFO +2 -2
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/README.md +10 -7
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/app.py +2 -2
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/routers/atdl.py +2 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/routers/sessions.py +19 -26
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/atdl_parser.py +15 -3
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/atdl_store.py +36 -10
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/fix_application.py +29 -14
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/perf_engine.py +8 -2
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/scenario_runner.py +7 -4
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/session.py +16 -4
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/session_manager.py +14 -5
- fixtureqa-0.16.0/fixture/static/assets/index-DCRhkrrW.js → fixtureqa-0.16.1/fixture/static/assets/index-Cn1j6RQg.js +18 -18
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/static/index.html +1 -1
- {fixtureqa-0.16.0 → fixtureqa-0.16.1/fixtureqa.egg-info}/PKG-INFO +2 -2
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixtureqa.egg-info/SOURCES.txt +3 -1
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixtureqa.egg-info/requires.txt +1 -1
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/pyproject.toml +2 -2
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_atdl.py +62 -0
- fixtureqa-0.16.1/tests/test_fix_application.py +62 -0
- fixtureqa-0.16.1/tests/test_send_errors.py +105 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_sessions.py +23 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/LICENSE +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/__init__.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/__main__.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/deps.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/routers/perf.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/routers/specs.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/auth.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/events.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/latency_csv_writer.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/models.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/perf_charts.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/perf_models.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/perf_payload.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/perf_stats.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/spec_store.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/value_gen.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/server.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/static/assets/index-D0a-mw9U.css +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/setup.cfg +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_auth.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_config_store.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_fix_spec_tree.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_health.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_inbound.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_message_store.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_msg_user_attribution.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_non_stop_session.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_perf_api.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_perf_engine.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_scenarios.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_session_auto_response.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_session_visibility.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_specs.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_templates.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_timezone_data.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_value_gen.py +0 -0
- {fixtureqa-0.16.0 → fixtureqa-0.16.1}/tests/test_ws.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fixtureqa
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.1
|
|
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.
|
|
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.
|
|
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
|

|
|
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.
|
|
12
|
-
- **
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
235
|
-
|
|
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
|
-
|
|
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*
|
|
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=
|
|
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(
|
|
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":
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
75
|
-
await self._get(session_id).stop(
|
|
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
|
-
|
|
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(
|
|
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
|
# ------------------------------------------------------------------
|