fixtureqa 0.6.0__tar.gz → 0.8.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.
- {fixtureqa-0.6.0/fixtureqa.egg-info → fixtureqa-0.8.0}/PKG-INFO +2 -2
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/README.md +1 -1
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/routers/sessions.py +18 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/schemas.py +54 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/models.py +59 -1
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/scenario_runner.py +37 -11
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/session.py +47 -6
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/session_manager.py +10 -0
- fixtureqa-0.6.0/fixture/static/assets/index-DKAYJxvL.css → fixtureqa-0.8.0/fixture/static/assets/index-B-hpSZWZ.css +1 -1
- fixtureqa-0.8.0/fixture/static/assets/index-Cu3H3T9B.js +102 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/static/index.html +2 -2
- {fixtureqa-0.6.0 → fixtureqa-0.8.0/fixtureqa.egg-info}/PKG-INFO +2 -2
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixtureqa.egg-info/SOURCES.txt +3 -2
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixtureqa.egg-info/requires.txt +1 -1
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/pyproject.toml +2 -2
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_config_store.py +20 -0
- fixtureqa-0.8.0/tests/test_non_stop_session.py +177 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_scenarios.py +54 -0
- fixtureqa-0.6.0/fixture/static/assets/index-C46Em7NG.js +0 -102
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/LICENSE +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/__init__.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/__main__.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/app.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/deps.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/routers/perf.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/auth.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/events.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/latency_csv_writer.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/perf_charts.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/perf_engine.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/perf_models.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/perf_payload.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/perf_stats.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/value_gen.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/fix_specs/FIX42.xml +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/fix_specs/FIX44.xml +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/server.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/setup.cfg +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_auth.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_health.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_inbound.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_message_store.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_perf_api.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_perf_engine.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_session_auto_response.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_sessions.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_templates.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_value_gen.py +0 -0
- {fixtureqa-0.6.0 → fixtureqa-0.8.0}/tests/test_ws.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fixtureqa
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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.
|
|
7
|
+
Requires-Dist: fixcore-engine==0.7.0
|
|
8
8
|
Requires-Dist: fastapi>=0.111.0
|
|
9
9
|
Requires-Dist: uvicorn[standard]>=0.29.0
|
|
10
10
|
Requires-Dist: websockets>=12.0
|
|
@@ -15,7 +15,7 @@ A Python FIX protocol testing tool. Supports FIX 4.2 and 4.4, dynamic session cr
|
|
|
15
15
|
- **Message log** — real-time IN/OUT stream with field breakdown, filtering, cursor pagination
|
|
16
16
|
- **Compose & send** — paste raw FIX or use message templates; auto-inject tag 60 and primary ID
|
|
17
17
|
- **Templates** — create/edit reusable message templates with FIX spec field definitions
|
|
18
|
-
- **Scenarios** — automated send/expect/delay test scripts with live pass/fail results
|
|
18
|
+
- **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
19
|
- **Venue simulation** — auto-ack and auto-fill responses for inbound NewOrderSingles
|
|
20
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))
|
|
21
21
|
- **Log analysis** — Overview stats, Throughput chart, Reject detail, Latency measurement (p50/p95/p99)
|
|
@@ -54,19 +54,37 @@ def _session_response(sm: SessionManager, session_id: str, us: Optional[UserStor
|
|
|
54
54
|
begin_string=cfg.begin_string,
|
|
55
55
|
sender_comp_id=cfg.sender_comp_id,
|
|
56
56
|
target_comp_id=cfg.target_comp_id,
|
|
57
|
+
session_qualifier=cfg.session_qualifier,
|
|
57
58
|
connection_type=cfg.connection_type.value,
|
|
58
59
|
host=cfg.host,
|
|
59
60
|
port=cfg.port,
|
|
61
|
+
socket_accept_host=cfg.socket_accept_host,
|
|
60
62
|
heartbeat_interval=cfg.heartbeat_interval,
|
|
61
63
|
reconnect_interval=cfg.reconnect_interval,
|
|
64
|
+
logon_timeout=cfg.logon_timeout,
|
|
65
|
+
logout_timeout=cfg.logout_timeout,
|
|
66
|
+
check_latency=cfg.check_latency,
|
|
67
|
+
max_latency=cfg.max_latency,
|
|
68
|
+
requires_orig_sending_time=cfg.requires_orig_sending_time,
|
|
69
|
+
resend_request_chunk_size=cfg.resend_request_chunk_size,
|
|
70
|
+
send_redundant_resend_requests=cfg.send_redundant_resend_requests,
|
|
62
71
|
use_file_log=cfg.use_file_log,
|
|
63
72
|
start_time=cfg.start_time,
|
|
64
73
|
end_time=cfg.end_time,
|
|
74
|
+
start_day=cfg.start_day,
|
|
75
|
+
end_day=cfg.end_day,
|
|
76
|
+
weekdays=cfg.weekdays,
|
|
77
|
+
time_zone=cfg.time_zone,
|
|
78
|
+
non_stop_session=cfg.non_stop_session,
|
|
65
79
|
reset_on_logon=cfg.reset_on_logon,
|
|
66
80
|
reset_on_logout=cfg.reset_on_logout,
|
|
81
|
+
reset_on_disconnect=cfg.reset_on_disconnect,
|
|
67
82
|
durable_seqnums=cfg.durable_seqnums,
|
|
68
83
|
log_messages=cfg.log_messages,
|
|
69
84
|
validate_inbound=cfg.validate_inbound,
|
|
85
|
+
validate_fields_have_values=cfg.validate_fields_have_values,
|
|
86
|
+
validate_user_defined_fields=cfg.validate_user_defined_fields,
|
|
87
|
+
validate_fields_out_of_range=cfg.validate_fields_out_of_range,
|
|
70
88
|
session_role=cfg.session_role.value,
|
|
71
89
|
auto_ack=cfg.auto_ack,
|
|
72
90
|
auto_ack_delay_ms=cfg.auto_ack_delay_ms,
|
|
@@ -7,20 +7,38 @@ class SessionConfigRequest(BaseModel):
|
|
|
7
7
|
begin_string: str = "FIX.4.2"
|
|
8
8
|
sender_comp_id: str
|
|
9
9
|
target_comp_id: str
|
|
10
|
+
session_qualifier: str = "" # SessionQualifier (disambiguate same triple)
|
|
10
11
|
connection_type: str = "initiator" # "initiator" or "acceptor"
|
|
11
12
|
host: str = "localhost"
|
|
12
13
|
port: int = Field(default=9876, ge=1, le=65535)
|
|
14
|
+
socket_accept_host: str = "" # SocketAcceptHost bind addr ("" = 0.0.0.0)
|
|
13
15
|
heartbeat_interval: int = Field(default=30, ge=1)
|
|
14
16
|
reconnect_interval: int = Field(default=10, ge=1)
|
|
17
|
+
logon_timeout: int = Field(default=10, ge=1)
|
|
18
|
+
logout_timeout: int = Field(default=10, ge=1)
|
|
19
|
+
check_latency: bool = False # CheckLatency: reject stale SendingTime
|
|
20
|
+
max_latency: int = Field(default=120, ge=1) # MaxLatency (s) when check_latency
|
|
21
|
+
requires_orig_sending_time: bool = True # RequiresOrigSendingTime
|
|
22
|
+
resend_request_chunk_size: int = Field(default=0, ge=0) # 0 = unlimited
|
|
23
|
+
send_redundant_resend_requests: bool = False
|
|
15
24
|
data_dictionary_path: Optional[str] = None
|
|
16
25
|
use_file_log: bool = True
|
|
17
26
|
start_time: str = "00:00:00"
|
|
18
27
|
end_time: str = "00:00:00"
|
|
28
|
+
start_day: str = "" # StartDay/EndDay together = weekly window
|
|
29
|
+
end_day: str = ""
|
|
30
|
+
weekdays: str = "" # e.g. "Mon,Tue,Wed,Thu,Fri" (excl. start/end day)
|
|
31
|
+
time_zone: str = "" # IANA name; "" = UTC
|
|
32
|
+
non_stop_session: bool = False # True = NonStopSession=Y (no daily seqnum reset)
|
|
19
33
|
reset_on_logon: bool = False
|
|
20
34
|
reset_on_logout: bool = False
|
|
35
|
+
reset_on_disconnect: bool = False
|
|
21
36
|
durable_seqnums: bool = True # False = in-memory store (no per-msg fsync; high-rate)
|
|
22
37
|
log_messages: bool = True # False = in-memory log only (no per-msg SQLite; high-rate)
|
|
23
38
|
validate_inbound: bool = False # True = reject non-conforming inbound (conformant-venue mode)
|
|
39
|
+
validate_fields_have_values: bool = True # reject "55=" (empty value)
|
|
40
|
+
validate_user_defined_fields: bool = False # reject unknown tag>=5000
|
|
41
|
+
validate_fields_out_of_range: bool = False # reject bad enums (e.g. Side=Z)
|
|
24
42
|
session_role: str = "generic" # "client", "venue", or "generic"
|
|
25
43
|
auto_ack: bool = False
|
|
26
44
|
auto_ack_delay_ms: int = Field(default=100, ge=0)
|
|
@@ -35,19 +53,37 @@ class SessionResponse(BaseModel):
|
|
|
35
53
|
begin_string: str
|
|
36
54
|
sender_comp_id: str
|
|
37
55
|
target_comp_id: str
|
|
56
|
+
session_qualifier: str
|
|
38
57
|
connection_type: str
|
|
39
58
|
host: str
|
|
40
59
|
port: int
|
|
60
|
+
socket_accept_host: str
|
|
41
61
|
heartbeat_interval: int
|
|
42
62
|
reconnect_interval: int
|
|
63
|
+
logon_timeout: int
|
|
64
|
+
logout_timeout: int
|
|
65
|
+
check_latency: bool
|
|
66
|
+
max_latency: int
|
|
67
|
+
requires_orig_sending_time: bool
|
|
68
|
+
resend_request_chunk_size: int
|
|
69
|
+
send_redundant_resend_requests: bool
|
|
43
70
|
use_file_log: bool
|
|
44
71
|
start_time: str
|
|
45
72
|
end_time: str
|
|
73
|
+
start_day: str
|
|
74
|
+
end_day: str
|
|
75
|
+
weekdays: str
|
|
76
|
+
time_zone: str
|
|
77
|
+
non_stop_session: bool
|
|
46
78
|
reset_on_logon: bool
|
|
47
79
|
reset_on_logout: bool
|
|
80
|
+
reset_on_disconnect: bool
|
|
48
81
|
durable_seqnums: bool
|
|
49
82
|
log_messages: bool
|
|
50
83
|
validate_inbound: bool
|
|
84
|
+
validate_fields_have_values: bool
|
|
85
|
+
validate_user_defined_fields: bool
|
|
86
|
+
validate_fields_out_of_range: bool
|
|
51
87
|
session_role: str
|
|
52
88
|
auto_ack: bool
|
|
53
89
|
auto_ack_delay_ms: int
|
|
@@ -68,15 +104,29 @@ class UpdateSessionRequest(BaseModel):
|
|
|
68
104
|
begin_string: Optional[str] = None
|
|
69
105
|
sender_comp_id: Optional[str] = None
|
|
70
106
|
target_comp_id: Optional[str] = None
|
|
107
|
+
session_qualifier: Optional[str] = None
|
|
71
108
|
connection_type: Optional[str] = None
|
|
72
109
|
host: Optional[str] = None
|
|
73
110
|
port: Optional[int] = Field(default=None, ge=1, le=65535)
|
|
111
|
+
socket_accept_host: Optional[str] = None
|
|
74
112
|
heartbeat_interval: Optional[int] = Field(default=None, ge=1)
|
|
75
113
|
reconnect_interval: Optional[int] = Field(default=None, ge=1)
|
|
114
|
+
logon_timeout: Optional[int] = Field(default=None, ge=1)
|
|
115
|
+
logout_timeout: Optional[int] = Field(default=None, ge=1)
|
|
116
|
+
check_latency: Optional[bool] = None
|
|
117
|
+
max_latency: Optional[int] = Field(default=None, ge=1)
|
|
118
|
+
requires_orig_sending_time: Optional[bool] = None
|
|
119
|
+
resend_request_chunk_size: Optional[int] = Field(default=None, ge=0)
|
|
120
|
+
send_redundant_resend_requests: Optional[bool] = None
|
|
76
121
|
data_dictionary_path: Optional[str] = None
|
|
77
122
|
use_file_log: Optional[bool] = None
|
|
78
123
|
start_time: Optional[str] = None
|
|
79
124
|
end_time: Optional[str] = None
|
|
125
|
+
start_day: Optional[str] = None
|
|
126
|
+
end_day: Optional[str] = None
|
|
127
|
+
weekdays: Optional[str] = None
|
|
128
|
+
time_zone: Optional[str] = None
|
|
129
|
+
non_stop_session: Optional[bool] = None
|
|
80
130
|
session_role: Optional[str] = None
|
|
81
131
|
auto_ack: Optional[bool] = None
|
|
82
132
|
auto_ack_delay_ms: Optional[int] = Field(default=None, ge=0)
|
|
@@ -84,9 +134,13 @@ class UpdateSessionRequest(BaseModel):
|
|
|
84
134
|
auto_fill_delay_ms: Optional[int] = Field(default=None, ge=0)
|
|
85
135
|
reset_on_logon: Optional[bool] = None
|
|
86
136
|
reset_on_logout: Optional[bool] = None
|
|
137
|
+
reset_on_disconnect: Optional[bool] = None
|
|
87
138
|
durable_seqnums: Optional[bool] = None
|
|
88
139
|
log_messages: Optional[bool] = None
|
|
89
140
|
validate_inbound: Optional[bool] = None
|
|
141
|
+
validate_fields_have_values: Optional[bool] = None
|
|
142
|
+
validate_user_defined_fields: Optional[bool] = None
|
|
143
|
+
validate_fields_out_of_range: Optional[bool] = None
|
|
90
144
|
|
|
91
145
|
|
|
92
146
|
class MessageEntryResponse(BaseModel):
|
|
@@ -39,12 +39,28 @@ class SessionConfig:
|
|
|
39
39
|
sender_comp_id: str = ""
|
|
40
40
|
target_comp_id: str = ""
|
|
41
41
|
|
|
42
|
+
# Disambiguates two sessions sharing the same BeginString/Sender/Target
|
|
43
|
+
# triple (fixcore SessionQualifier). "" = none.
|
|
44
|
+
session_qualifier: str = ""
|
|
45
|
+
|
|
42
46
|
# Connection
|
|
43
47
|
connection_type: ConnectionType = ConnectionType.INITIATOR
|
|
44
48
|
host: str = "localhost" # ignored for acceptor
|
|
45
49
|
port: int = 9876
|
|
46
50
|
heartbeat_interval: int = 30
|
|
47
51
|
reconnect_interval: int = 10 # initiator only
|
|
52
|
+
# Acceptor bind address (fixcore SocketAcceptHost). "" = 0.0.0.0 (all
|
|
53
|
+
# interfaces). Ignored for an initiator.
|
|
54
|
+
socket_accept_host: str = ""
|
|
55
|
+
|
|
56
|
+
# Liveness timers
|
|
57
|
+
logon_timeout: int = 10 # LogonTimeout (s): wait for Logon to complete
|
|
58
|
+
logout_timeout: int = 10 # LogoutTimeout (s): wait for Logout confirmation
|
|
59
|
+
# Reject inbound messages whose SendingTime(52) skews more than max_latency
|
|
60
|
+
# seconds from the local clock (fixcore CheckLatency). Off by default
|
|
61
|
+
# (fixcore's default; QuickFIX defaults this on). PossDup resends are exempt.
|
|
62
|
+
check_latency: bool = False
|
|
63
|
+
max_latency: int = 120 # MaxLatency (s): allowed SendingTime skew when check_latency
|
|
48
64
|
|
|
49
65
|
# Data dictionary (None = use bundled spec)
|
|
50
66
|
data_dictionary_path: Optional[str] = None
|
|
@@ -54,13 +70,43 @@ class SessionConfig:
|
|
|
54
70
|
file_log_path: str = "./log"
|
|
55
71
|
use_file_log: bool = True
|
|
56
72
|
|
|
57
|
-
# Session schedule ("00:00:00" for both = always active
|
|
73
|
+
# Session schedule ("00:00:00" for both = always active that day, with a
|
|
74
|
+
# daily sequence-number reset at midnight — fixcore's default daily window).
|
|
58
75
|
start_time: str = "00:00:00"
|
|
59
76
|
end_time: str = "00:00:00"
|
|
60
77
|
|
|
78
|
+
# Disable the daily session window entirely: when True, the engine runs as a
|
|
79
|
+
# single never-ending session (fixcore NonStopSession=Y) and never resets
|
|
80
|
+
# sequence numbers on a day boundary. False (default) keeps the daily window
|
|
81
|
+
# above, which performs the standard midnight seqnum reset. NonStopSession=Y
|
|
82
|
+
# is mutually exclusive with StartTime/EndTime, so those keys are omitted
|
|
83
|
+
# when this is set (see Session._build_settings).
|
|
84
|
+
non_stop_session: bool = False
|
|
85
|
+
|
|
86
|
+
# Extended schedule (fixcore SessionSchedule). All optional; combine with
|
|
87
|
+
# start_time/end_time. start_day+end_day => a *weekly* window (both required
|
|
88
|
+
# together). weekdays (e.g. "Mon,Tue,Wed,Thu,Fri") restricts the daily
|
|
89
|
+
# window to those days and is mutually exclusive with start_day/end_day.
|
|
90
|
+
# time_zone is an IANA name ("" = UTC). All ignored when non_stop_session.
|
|
91
|
+
start_day: str = ""
|
|
92
|
+
end_day: str = ""
|
|
93
|
+
weekdays: str = ""
|
|
94
|
+
time_zone: str = ""
|
|
95
|
+
|
|
61
96
|
# Sequence number reset behaviour
|
|
62
97
|
reset_on_logon: bool = False
|
|
63
98
|
reset_on_logout: bool = False
|
|
99
|
+
reset_on_disconnect: bool = False # ResetOnDisconnect: reset to 1 on every drop
|
|
100
|
+
|
|
101
|
+
# Resend / duplicate handling (fixcore)
|
|
102
|
+
# Reject a resent app message (PossDupFlag=Y) missing OrigSendingTime(122).
|
|
103
|
+
requires_orig_sending_time: bool = True
|
|
104
|
+
# Cap messages requested per outbound ResendRequest (0 = unlimited / one
|
|
105
|
+
# open-ended request); a positive value chunks recovery of a large gap.
|
|
106
|
+
resend_request_chunk_size: int = 0
|
|
107
|
+
# Re-send a ResendRequest for every out-of-order message while one is already
|
|
108
|
+
# outstanding (default N = re-request only when the gap advances).
|
|
109
|
+
send_redundant_resend_requests: bool = False
|
|
64
110
|
|
|
65
111
|
# Seqnum/message store durability. True (default) = fixcore FileStore, which
|
|
66
112
|
# fsyncs per message (durable resend/recovery, but caps throughput at disk
|
|
@@ -85,6 +131,18 @@ class SessionConfig:
|
|
|
85
131
|
# on regardless of this flag.
|
|
86
132
|
validate_inbound: bool = False
|
|
87
133
|
|
|
134
|
+
# Finer-grained validation, effective only while validate_inbound is on
|
|
135
|
+
# (mapped to the matching fixcore keys):
|
|
136
|
+
# validate_fields_have_values -> ValidateFieldsHaveValues (reject "55=")
|
|
137
|
+
# validate_user_defined_fields -> ValidateUserDefinedFields (reject unknown tag>=5000)
|
|
138
|
+
# validate_fields_out_of_range -> ValidateFieldsOutOfRange (reject bad enums,
|
|
139
|
+
# e.g. Side=Z). This is the fixcore-native key; it subsumes QuickFIX/n's
|
|
140
|
+
# inverse AllowUnknownEnumValues (=N), so that alias is not exposed
|
|
141
|
+
# separately — leaving this off is equivalent to AllowUnknownEnumValues=Y.
|
|
142
|
+
validate_fields_have_values: bool = True
|
|
143
|
+
validate_user_defined_fields: bool = False
|
|
144
|
+
validate_fields_out_of_range: bool = False
|
|
145
|
+
|
|
88
146
|
# Role / intent
|
|
89
147
|
session_role: SessionRole = SessionRole.GENERIC
|
|
90
148
|
|
|
@@ -9,6 +9,7 @@ for timeout and abort signalling.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import asyncio
|
|
12
|
+
import re
|
|
12
13
|
import threading
|
|
13
14
|
import time
|
|
14
15
|
import uuid
|
|
@@ -25,6 +26,11 @@ from . import value_gen
|
|
|
25
26
|
|
|
26
27
|
SKIP_TAGS = {8, 9, 10, 34, 35, 49, 52, 56}
|
|
27
28
|
|
|
29
|
+
# {recv:<tag>} in a send value echoes that tag from the message the run's most
|
|
30
|
+
# recent expect step matched (e.g. a venue ack carries the order's ClOrdID via
|
|
31
|
+
# 11={recv:11}). No parens, so it never collides with value_gen's {gen(...)}.
|
|
32
|
+
_RECV_RE = re.compile(r"\{recv:(\d+)\}")
|
|
33
|
+
|
|
28
34
|
|
|
29
35
|
def _now_ms() -> float:
|
|
30
36
|
return time.monotonic() * 1000
|
|
@@ -362,6 +368,9 @@ class ScenarioRunner:
|
|
|
362
368
|
# repeated step and across scenario-level repeats (uuid()/random are
|
|
363
369
|
# always fresh regardless).
|
|
364
370
|
gen_counters: dict = {}
|
|
371
|
+
# Fields of the message the most recent expect matched, so a following
|
|
372
|
+
# send can echo them via {recv:<tag>} (FIFO: expect k matched order k).
|
|
373
|
+
last_recv: dict = {}
|
|
365
374
|
idx = 0
|
|
366
375
|
for run_iter in range(scenario_repeat):
|
|
367
376
|
for step in steps:
|
|
@@ -392,9 +401,11 @@ class ScenarioRunner:
|
|
|
392
401
|
for iteration in range(step_repeat):
|
|
393
402
|
try:
|
|
394
403
|
if step_type == "send":
|
|
395
|
-
error = self._run_send(step, session_id, uid, gen_counters)
|
|
404
|
+
error = self._run_send(step, session_id, uid, gen_counters, last_recv)
|
|
396
405
|
elif step_type == "expect":
|
|
397
|
-
error = self._run_expect(step, inbox, abort_flag)
|
|
406
|
+
error, matched = self._run_expect(step, inbox, abort_flag)
|
|
407
|
+
if matched is not None:
|
|
408
|
+
last_recv = matched
|
|
398
409
|
elif step_type == "delay":
|
|
399
410
|
self._run_delay(step, abort_flag)
|
|
400
411
|
if abort_flag.is_set():
|
|
@@ -432,8 +443,18 @@ class ScenarioRunner:
|
|
|
432
443
|
|
|
433
444
|
return overall_status
|
|
434
445
|
|
|
446
|
+
def _resolve_value(self, value: str, counters: dict, recv_fields: dict,
|
|
447
|
+
key=0) -> str:
|
|
448
|
+
"""Resolve {recv:<tag>} (echo a tag from the last matched message) then
|
|
449
|
+
the value_gen generators ({gen(...)} / whole-value). recv runs first so
|
|
450
|
+
it composes with generators (e.g. 17=EX-{recv:11}); an absent tag → ""."""
|
|
451
|
+
if isinstance(value, str) and recv_fields and "{recv:" in value:
|
|
452
|
+
value = _RECV_RE.sub(
|
|
453
|
+
lambda m: (recv_fields.get(m.group(1)) or [""])[0], value)
|
|
454
|
+
return value_gen.eval_field_value(value, counters, key)
|
|
455
|
+
|
|
435
456
|
def _run_send(self, step: dict, session_id: str, uid: str,
|
|
436
|
-
counters: dict) -> Optional[str]:
|
|
457
|
+
counters: dict, recv_fields: dict) -> Optional[str]:
|
|
437
458
|
"""Build and send a FIX message. Returns error string or None.
|
|
438
459
|
|
|
439
460
|
A 'raw' field is built group-aware via the session's DataDictionary, so
|
|
@@ -445,11 +466,12 @@ class ScenarioRunner:
|
|
|
445
466
|
{gen(...)} — evaluated fresh per send. `counters` is the run-scoped seq()
|
|
446
467
|
state (passed in so seq() keeps incrementing across a repeated step / a
|
|
447
468
|
repeated scenario, e.g. 11=ORD-{seq(4)} → ORD-0001, ORD-0002, …).
|
|
469
|
+
`recv_fields` is the last expect's matched message, echoed via {recv:tag}.
|
|
448
470
|
"""
|
|
449
471
|
raw_field = step.get("raw", "").strip()
|
|
450
472
|
if raw_field:
|
|
451
473
|
from .fix_builder import build_message_from_raw
|
|
452
|
-
raw_field =
|
|
474
|
+
raw_field = self._resolve_value(raw_field, counters, recv_fields)
|
|
453
475
|
try:
|
|
454
476
|
msg = build_message_from_raw(
|
|
455
477
|
raw_field, self._sm.get_data_dictionary(session_id), transact_time=_utc_ts())
|
|
@@ -490,7 +512,7 @@ class ScenarioRunner:
|
|
|
490
512
|
if tag not in SKIP_TAGS:
|
|
491
513
|
try:
|
|
492
514
|
msg.set_field(int(tag),
|
|
493
|
-
|
|
515
|
+
self._resolve_value(str(value), counters, recv_fields, tag))
|
|
494
516
|
except Exception:
|
|
495
517
|
pass
|
|
496
518
|
|
|
@@ -506,9 +528,13 @@ class ScenarioRunner:
|
|
|
506
528
|
|
|
507
529
|
def _run_expect(
|
|
508
530
|
self, step: dict, inbox: _RunInbox, abort_flag: threading.Event
|
|
509
|
-
) -> Optional[str]:
|
|
531
|
+
) -> tuple[Optional[str], Optional[dict]]:
|
|
510
532
|
"""Consume the next inbound message matching msg_type from the run inbox
|
|
511
|
-
and evaluate the step's assertions against it. Returns error
|
|
533
|
+
and evaluate the step's assertions against it. Returns (error, fields):
|
|
534
|
+
error is None on success; fields is the matched message's multi-value
|
|
535
|
+
field dict (None on timeout/abort) so a following send can echo it via
|
|
536
|
+
{recv:<tag>}. The matched fields are returned even when an assertion
|
|
537
|
+
fails (the consumed message is still the one we looked at)."""
|
|
512
538
|
expected_msg_type = step.get("msg_type", "")
|
|
513
539
|
timeout_ms = step.get("timeout_ms", 5000)
|
|
514
540
|
assertions: list[dict] = step.get("assertions", [])
|
|
@@ -516,10 +542,10 @@ class ScenarioRunner:
|
|
|
516
542
|
item = inbox.next_matching(expected_msg_type, timeout_ms / 1000.0, abort_flag)
|
|
517
543
|
|
|
518
544
|
if abort_flag.is_set():
|
|
519
|
-
return "Aborted"
|
|
545
|
+
return "Aborted", None
|
|
520
546
|
if item is None:
|
|
521
547
|
desc = expected_msg_type or "any"
|
|
522
|
-
return f"Timeout waiting for message type {desc!r} after {timeout_ms}ms"
|
|
548
|
+
return f"Timeout waiting for message type {desc!r} after {timeout_ms}ms", None
|
|
523
549
|
|
|
524
550
|
fields = item["fields"]
|
|
525
551
|
for assertion in assertions:
|
|
@@ -529,8 +555,8 @@ class ScenarioRunner:
|
|
|
529
555
|
value = assertion.get("value", "")
|
|
530
556
|
actual_list = fields.get(str(tag))
|
|
531
557
|
actual_repr = repr(actual_list[0]) if actual_list and len(actual_list) == 1 else repr(actual_list) if actual_list else "<absent>"
|
|
532
|
-
return f"Assertion failed: tag {tag} {op} {value!r} (actual: {actual_repr})"
|
|
533
|
-
return None
|
|
558
|
+
return f"Assertion failed: tag {tag} {op} {value!r} (actual: {actual_repr})", fields
|
|
559
|
+
return None, fields
|
|
534
560
|
|
|
535
561
|
def _run_delay(self, step: dict, abort_flag: threading.Event) -> None:
|
|
536
562
|
delay_ms = step.get("delay_ms", 0)
|
|
@@ -340,15 +340,49 @@ class Session:
|
|
|
340
340
|
os.path.join(self.config.file_log_path, self.config.session_id)
|
|
341
341
|
)
|
|
342
342
|
|
|
343
|
+
yn = lambda b: "Y" if b else "N" # noqa: E731 — local boolean->FIX cfg
|
|
344
|
+
|
|
345
|
+
# Session schedule. NonStopSession=Y is mutually exclusive with *any*
|
|
346
|
+
# schedule key in fixcore (it raises on the combination), so emit either
|
|
347
|
+
# NonStopSession or the StartTime/EndTime window (+ optional refinements):
|
|
348
|
+
# - TimeZone scopes the times/days;
|
|
349
|
+
# - Weekdays restricts the daily window to listed days and is itself
|
|
350
|
+
# exclusive with StartDay/EndDay, so prefer it when both are set;
|
|
351
|
+
# - StartDay+EndDay (only together) make it a weekly window.
|
|
352
|
+
if self.config.non_stop_session:
|
|
353
|
+
schedule_lines = ["NonStopSession=Y"]
|
|
354
|
+
else:
|
|
355
|
+
schedule_lines = [
|
|
356
|
+
f"StartTime={self.config.start_time}",
|
|
357
|
+
f"EndTime={self.config.end_time}",
|
|
358
|
+
]
|
|
359
|
+
if self.config.time_zone:
|
|
360
|
+
schedule_lines.append(f"TimeZone={self.config.time_zone}")
|
|
361
|
+
if self.config.weekdays:
|
|
362
|
+
schedule_lines.append(f"Weekdays={self.config.weekdays}")
|
|
363
|
+
elif self.config.start_day and self.config.end_day:
|
|
364
|
+
schedule_lines += [
|
|
365
|
+
f"StartDay={self.config.start_day}",
|
|
366
|
+
f"EndDay={self.config.end_day}",
|
|
367
|
+
]
|
|
368
|
+
|
|
343
369
|
lines = [
|
|
344
370
|
"[DEFAULT]",
|
|
345
371
|
f"ConnectionType={ct}",
|
|
346
372
|
f"BeginString={self.config.begin_string}",
|
|
347
373
|
f"SenderCompID={self.config.sender_comp_id}",
|
|
348
374
|
f"TargetCompID={self.config.target_comp_id}",
|
|
375
|
+
*([f"SessionQualifier={self.config.session_qualifier}"]
|
|
376
|
+
if self.config.session_qualifier else []),
|
|
349
377
|
f"HeartBtInt={self.config.heartbeat_interval}",
|
|
350
|
-
f"
|
|
351
|
-
f"
|
|
378
|
+
f"LogonTimeout={self.config.logon_timeout}",
|
|
379
|
+
f"LogoutTimeout={self.config.logout_timeout}",
|
|
380
|
+
f"CheckLatency={yn(self.config.check_latency)}",
|
|
381
|
+
f"MaxLatency={self.config.max_latency}",
|
|
382
|
+
f"RequiresOrigSendingTime={yn(self.config.requires_orig_sending_time)}",
|
|
383
|
+
f"ResendRequestChunkSize={self.config.resend_request_chunk_size}",
|
|
384
|
+
f"SendRedundantResendRequests={yn(self.config.send_redundant_resend_requests)}",
|
|
385
|
+
*schedule_lines,
|
|
352
386
|
f"DataDictionary={dd_path}",
|
|
353
387
|
# Always load the dictionary so inbound repeating groups decode into
|
|
354
388
|
# proper instances (otherwise duplicate group-member tags collapse to
|
|
@@ -357,12 +391,17 @@ class Session:
|
|
|
357
391
|
"UseDataDictionary=Y",
|
|
358
392
|
# Permissive by default: decode group-aware but don't auto-reject
|
|
359
393
|
# non-conforming inbound. Flip on per session to behave like a
|
|
360
|
-
# conformant counterparty. Requires fixcore >= 0.5.0.
|
|
361
|
-
|
|
394
|
+
# conformant counterparty. Requires fixcore >= 0.5.0. The finer
|
|
395
|
+
# toggles below only bite while ValidateIncomingMessage=Y.
|
|
396
|
+
f"ValidateIncomingMessage={yn(self.config.validate_inbound)}",
|
|
397
|
+
f"ValidateFieldsHaveValues={yn(self.config.validate_fields_have_values)}",
|
|
398
|
+
f"ValidateUserDefinedFields={yn(self.config.validate_user_defined_fields)}",
|
|
399
|
+
f"ValidateFieldsOutOfRange={yn(self.config.validate_fields_out_of_range)}",
|
|
362
400
|
f"FileStorePath={store_path}",
|
|
363
401
|
f"FileLogPath={log_path}",
|
|
364
|
-
f"ResetOnLogon={
|
|
365
|
-
f"ResetOnLogout={
|
|
402
|
+
f"ResetOnLogon={yn(self.config.reset_on_logon)}",
|
|
403
|
+
f"ResetOnLogout={yn(self.config.reset_on_logout)}",
|
|
404
|
+
f"ResetOnDisconnect={yn(self.config.reset_on_disconnect)}",
|
|
366
405
|
"",
|
|
367
406
|
"[SESSION]",
|
|
368
407
|
]
|
|
@@ -375,6 +414,8 @@ class Session:
|
|
|
375
414
|
]
|
|
376
415
|
else:
|
|
377
416
|
lines.append(f"SocketAcceptPort={self.config.port}")
|
|
417
|
+
if self.config.socket_accept_host:
|
|
418
|
+
lines.append(f"SocketAcceptHost={self.config.socket_accept_host}")
|
|
378
419
|
|
|
379
420
|
return SessionSettings.from_string("\n".join(lines))
|
|
380
421
|
|
|
@@ -264,6 +264,16 @@ class SessionManager:
|
|
|
264
264
|
raise ValueError("target_comp_id is required")
|
|
265
265
|
if config.begin_string not in ("FIX.4.2", "FIX.4.4"):
|
|
266
266
|
raise ValueError(f"Unsupported FIX version: {config.begin_string!r}")
|
|
267
|
+
# Schedule-combo rules (mirror fixcore so a bad combo fails on save with a
|
|
268
|
+
# clear message instead of at engine start). Only meaningful when a
|
|
269
|
+
# windowed schedule is in play, i.e. not non-stop.
|
|
270
|
+
if not config.non_stop_session:
|
|
271
|
+
if config.weekdays and (config.start_day or config.end_day):
|
|
272
|
+
raise ValueError("Weekdays cannot be combined with StartDay/EndDay")
|
|
273
|
+
if bool(config.start_day) != bool(config.end_day):
|
|
274
|
+
raise ValueError("StartDay and EndDay must both be set (weekly window)")
|
|
275
|
+
if config.check_latency and config.max_latency < 1:
|
|
276
|
+
raise ValueError("max_latency must be >= 1 when check_latency is on")
|
|
267
277
|
from .models import ConnectionType
|
|
268
278
|
if config.connection_type == ConnectionType.ACCEPTOR:
|
|
269
279
|
for sid, s in self._sessions.items():
|