fixtureqa 0.15.0__tar.gz → 0.16.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 (114) hide show
  1. {fixtureqa-0.15.0/fixtureqa.egg-info → fixtureqa-0.16.0}/PKG-INFO +2 -1
  2. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/app.py +4 -1
  3. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/deps.py +5 -0
  4. fixtureqa-0.16.0/fixture/api/routers/atdl.py +148 -0
  5. fixtureqa-0.16.0/fixture/core/atdl_parser.py +359 -0
  6. fixtureqa-0.16.0/fixture/core/atdl_store.py +119 -0
  7. fixtureqa-0.16.0/fixture/static/assets/index-DCRhkrrW.js +103 -0
  8. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/static/index.html +1 -1
  9. {fixtureqa-0.15.0 → fixtureqa-0.16.0/fixtureqa.egg-info}/PKG-INFO +2 -1
  10. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixtureqa.egg-info/SOURCES.txt +5 -1
  11. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixtureqa.egg-info/requires.txt +1 -0
  12. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/pyproject.toml +2 -1
  13. fixtureqa-0.16.0/tests/test_atdl.py +350 -0
  14. fixtureqa-0.15.0/fixture/static/assets/index-DQvZ5mvq.js +0 -102
  15. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/LICENSE +0 -0
  16. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/README.md +0 -0
  17. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/__init__.py +0 -0
  18. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/__main__.py +0 -0
  19. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/__init__.py +0 -0
  20. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/connection_manager.py +0 -0
  21. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/__init__.py +0 -0
  22. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/admin.py +0 -0
  23. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/auth.py +0 -0
  24. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/branding.py +0 -0
  25. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/custom_tags.py +0 -0
  26. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/fix_spec.py +0 -0
  27. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/messages.py +0 -0
  28. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/perf.py +0 -0
  29. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/scenarios.py +0 -0
  30. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/sessions.py +0 -0
  31. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/setup.py +0 -0
  32. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/spec_overlay.py +0 -0
  33. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/specs.py +0 -0
  34. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/templates.py +0 -0
  35. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/ws.py +0 -0
  36. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/schemas.py +0 -0
  37. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/config/__init__.py +0 -0
  38. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/__init__.py +0 -0
  39. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/atomic_io.py +0 -0
  40. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/auth.py +0 -0
  41. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/config_store.py +0 -0
  42. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/custom_tag_store.py +0 -0
  43. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/db_migrations.py +0 -0
  44. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/events.py +0 -0
  45. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/fix_application.py +0 -0
  46. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/fix_builder.py +0 -0
  47. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/fix_parser.py +0 -0
  48. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/fix_spec_parser.py +0 -0
  49. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/fix_tags.py +0 -0
  50. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/fix_time.py +0 -0
  51. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/housekeeping.py +0 -0
  52. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/inbound.py +0 -0
  53. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/json_store.py +0 -0
  54. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/latency_csv_writer.py +0 -0
  55. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/message_log.py +0 -0
  56. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/message_store.py +0 -0
  57. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/models.py +0 -0
  58. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/perf_charts.py +0 -0
  59. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/perf_engine.py +0 -0
  60. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/perf_models.py +0 -0
  61. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/perf_payload.py +0 -0
  62. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/perf_stats.py +0 -0
  63. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/perf_store.py +0 -0
  64. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/perf_writer.py +0 -0
  65. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/scenario_runner.py +0 -0
  66. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/scenario_store.py +0 -0
  67. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/session.py +0 -0
  68. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/session_manager.py +0 -0
  69. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/spec_overlay_store.py +0 -0
  70. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/spec_store.py +0 -0
  71. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/template_store.py +0 -0
  72. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/user_store.py +0 -0
  73. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/value_gen.py +0 -0
  74. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/venue_responses.py +0 -0
  75. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/server.py +0 -0
  76. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
  77. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/static/assets/index-CyNOPa0n.js +0 -0
  78. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/static/assets/index-D0a-mw9U.css +0 -0
  79. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
  80. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/static/favicon.svg +0 -0
  81. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/ui/__init__.py +0 -0
  82. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
  83. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixtureqa.egg-info/entry_points.txt +0 -0
  84. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixtureqa.egg-info/top_level.txt +0 -0
  85. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/setup.cfg +0 -0
  86. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_atomic_io.py +0 -0
  87. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_auth.py +0 -0
  88. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_config_store.py +0 -0
  89. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_connection_manager.py +0 -0
  90. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_db_migrations.py +0 -0
  91. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_fix_builder.py +0 -0
  92. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_fix_spec_tree.py +0 -0
  93. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_health.py +0 -0
  94. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_inbound.py +0 -0
  95. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_inbound_validation.py +0 -0
  96. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_message_store.py +0 -0
  97. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_msg_user_attribution.py +0 -0
  98. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_non_stop_session.py +0 -0
  99. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_perf_api.py +0 -0
  100. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_perf_engine.py +0 -0
  101. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_perf_models.py +0 -0
  102. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_perf_payload.py +0 -0
  103. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_perf_rehydrate.py +0 -0
  104. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_scenarios.py +0 -0
  105. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_session_auto_response.py +0 -0
  106. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_session_lifecycle.py +0 -0
  107. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_session_manager_concurrency.py +0 -0
  108. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_session_visibility.py +0 -0
  109. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_sessions.py +0 -0
  110. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_specs.py +0 -0
  111. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_templates.py +0 -0
  112. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_timezone_data.py +0 -0
  113. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_value_gen.py +0 -0
  114. {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixtureqa
3
- Version: 0.15.0
3
+ Version: 0.16.0
4
4
  Summary: FIXture — FIX Protocol Testing Tool
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -11,6 +11,7 @@ Requires-Dist: websockets>=12.0
11
11
  Requires-Dist: python-jose[cryptography]>=3.3.0
12
12
  Requires-Dist: passlib[bcrypt]>=1.7.4
13
13
  Requires-Dist: python-multipart>=0.0.9
14
+ Requires-Dist: defusedxml>=0.7.1
14
15
  Requires-Dist: matplotlib>=3.7
15
16
  Provides-Extra: dev
16
17
  Requires-Dist: pytest>=8.0; extra == "dev"
@@ -13,13 +13,14 @@ from ..core.scenario_store import ScenarioStore, ScenarioGroupStore
13
13
  from ..core.scenario_runner import ScenarioRunner
14
14
  from ..core.spec_overlay_store import SpecOverlayStore
15
15
  from ..core.spec_store import SpecStore
16
+ from ..core.atdl_store import AtdlStore
16
17
  from ..core.template_store import TemplateStore
17
18
  from ..core.user_store import UserStore
18
19
  from .connection_manager import ConnectionManager
19
20
  from ..core.perf_engine import RunRegistry
20
21
  from ..core.perf_writer import PerfWriter
21
22
  from ..core.perf_store import PerfStore
22
- from .routers import sessions, messages, ws, auth, setup, admin, fix_spec, templates, scenarios, branding, custom_tags, spec_overlay, specs, perf
23
+ from .routers import sessions, messages, ws, auth, setup, admin, fix_spec, templates, scenarios, branding, custom_tags, spec_overlay, specs, atdl, perf
23
24
 
24
25
 
25
26
  @asynccontextmanager
@@ -96,6 +97,7 @@ def create_app(
96
97
  app.state.custom_tag_store = CustomTagStore(data_dir)
97
98
  app.state.spec_overlay_store = SpecOverlayStore(data_dir)
98
99
  app.state.spec_store = SpecStore(data_dir)
100
+ app.state.atdl_store = AtdlStore(data_dir)
99
101
  perf_writer = PerfWriter(os.path.join(data_dir, "perf.db"))
100
102
  app.state.perf_writer = perf_writer
101
103
  app.state.perf_registry = RunRegistry(
@@ -122,6 +124,7 @@ def create_app(
122
124
  app.include_router(custom_tags.router, prefix="/api/custom-tags")
123
125
  app.include_router(spec_overlay.router, prefix="/api/spec-overlay")
124
126
  app.include_router(specs.router, prefix="/api/specs")
127
+ app.include_router(atdl.router, prefix="/api/atdl")
125
128
  app.include_router(perf.router, prefix="/api/perf")
126
129
  app.include_router(ws.router)
127
130
 
@@ -13,6 +13,7 @@ from ..core.scenario_runner import ScenarioRunner
13
13
  from ..core.custom_tag_store import CustomTagStore
14
14
  from ..core.spec_overlay_store import SpecOverlayStore
15
15
  from ..core.spec_store import SpecStore
16
+ from ..core.atdl_store import AtdlStore
16
17
  from .connection_manager import ConnectionManager
17
18
 
18
19
  oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
@@ -127,6 +128,10 @@ def get_spec_store(request: HTTPConnection) -> SpecStore:
127
128
  return request.app.state.spec_store
128
129
 
129
130
 
131
+ def get_atdl_store(request: HTTPConnection) -> AtdlStore:
132
+ return request.app.state.atdl_store
133
+
134
+
130
135
  def get_perf_registry(request: HTTPConnection):
131
136
  return request.app.state.perf_registry
132
137
 
@@ -0,0 +1,148 @@
1
+ """ATDL (Algorithmic Trading Definition Language) upload/list/delete + browse.
2
+
3
+ A user uploads a broker's FIXatdl XML; this router persists it per-user and
4
+ exposes the parsed strategies/parameters so the compose UI can render an algo
5
+ order-entry form. Mirrors the custom-spec router (:mod:`.specs`).
6
+ """
7
+
8
+ from typing import Annotated
9
+
10
+ from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile, status
11
+
12
+ from ...core.atdl_parser import AtdlParseError, atdl_warnings, parse_atdl
13
+ from ...core.atdl_store import AtdlStore, AtdlValidationError
14
+ from ...core.user_store import User
15
+ from ..deps import get_atdl_store, get_current_user
16
+
17
+ router = APIRouter(tags=["atdl"])
18
+
19
+ AS = Annotated[AtdlStore, Depends(get_atdl_store)]
20
+ CurrentUser = Annotated[User, Depends(get_current_user)]
21
+
22
+ # ATDL files are XML text; a large broker suite is well under 2 MB.
23
+ _MAX_ATDL_BYTES = 2 * 1024 * 1024
24
+
25
+
26
+ def _edit_dict(e) -> dict:
27
+ """Serialise an Edit expression tree (see core.atdl_parser.Edit)."""
28
+ return {
29
+ "operator": e.operator,
30
+ "field": e.field,
31
+ "field2": e.field2,
32
+ "value": e.value,
33
+ "has_value": e.has_value,
34
+ "logic": e.logic,
35
+ "ref": e.ref,
36
+ "id": e.id,
37
+ "children": [_edit_dict(c) for c in e.children],
38
+ }
39
+
40
+
41
+ def _strategy_dict(s) -> dict:
42
+ return {
43
+ "name": s.name,
44
+ "ui_rep": s.ui_rep,
45
+ "wire_value": s.wire_value,
46
+ "fix_msg_type": s.fix_msg_type,
47
+ "has_layout": s.has_layout,
48
+ # Retained for the UI's "advanced features" hint; now derived from the
49
+ # rules we actually parse (Phase 3) rather than mere presence.
50
+ "has_edits": bool(s.edits or s.state_rules),
51
+ "parameters": [
52
+ {
53
+ "name": p.name,
54
+ "fix_tag": p.fix_tag,
55
+ "type": p.type,
56
+ "kind": p.kind,
57
+ "required": p.required,
58
+ "enums": [{"enum_id": e.enum_id, "wire_value": e.wire_value}
59
+ for e in p.enums],
60
+ }
61
+ for p in s.parameters
62
+ ],
63
+ "edits": [{"error_message": se.error_message, "edit": _edit_dict(se.edit)}
64
+ for se in s.edits],
65
+ "state_rules": [
66
+ {"param": sr.param, "enabled": sr.enabled, "visible": sr.visible,
67
+ "value": sr.value, "edit": _edit_dict(sr.edit)}
68
+ for sr in s.state_rules
69
+ ],
70
+ "named_edits": {k: _edit_dict(v) for k, v in s.named_edits.items()},
71
+ }
72
+
73
+
74
+ @router.get("")
75
+ def list_atdl(store: AS, user: CurrentUser) -> list[dict]:
76
+ """This user's uploaded ATDL files (name + strategy count)."""
77
+ return store.list(user.uid)
78
+
79
+
80
+ @router.post("", status_code=status.HTTP_201_CREATED)
81
+ async def upload_atdl(file: UploadFile, store: AS, user: CurrentUser) -> dict:
82
+ raw = await file.read()
83
+ if len(raw) > _MAX_ATDL_BYTES:
84
+ raise HTTPException(status_code=413, detail="ATDL file too large")
85
+ try:
86
+ text = raw.decode("utf-8")
87
+ except UnicodeDecodeError:
88
+ raise HTTPException(status_code=400, detail="ATDL must be UTF-8 XML")
89
+ try:
90
+ return store.save(user.uid, file.filename or "algos.xml", text)
91
+ except AtdlValidationError as e:
92
+ raise HTTPException(status_code=400, detail=str(e))
93
+
94
+
95
+ @router.post("/validate", status_code=status.HTTP_200_OK)
96
+ async def validate_atdl(file: UploadFile, user: CurrentUser) -> dict:
97
+ """Parse + lint an ATDL file **without saving it** (library/authoring check).
98
+
99
+ Returns ``valid: true`` with the parsed strategies and any non-fatal
100
+ ``warnings`` on success, or ``valid: false`` with the parse ``error`` (HTTP
101
+ 200 either way — a parse failure is a validation result, not a request error).
102
+ """
103
+ raw = await file.read()
104
+ if len(raw) > _MAX_ATDL_BYTES:
105
+ raise HTTPException(status_code=413, detail="ATDL file too large")
106
+ try:
107
+ text = raw.decode("utf-8")
108
+ except UnicodeDecodeError:
109
+ return {"valid": False, "error": "ATDL must be UTF-8 XML"}
110
+ try:
111
+ parsed = parse_atdl(text)
112
+ except AtdlParseError as e:
113
+ return {"valid": False, "error": str(e)}
114
+ return {
115
+ "valid": True,
116
+ "name": file.filename or "",
117
+ "strategy_wire_tag": parsed.strategy_wire_tag,
118
+ "strategy_count": len(parsed.strategies),
119
+ "warnings": atdl_warnings(parsed),
120
+ "strategies": [_strategy_dict(s) for s in parsed.strategies],
121
+ }
122
+
123
+
124
+ @router.get("/{name}")
125
+ def get_atdl(name: str, store: AS, user: CurrentUser) -> dict:
126
+ """Parsed strategies/parameters (+ lint warnings) for one uploaded ATDL file."""
127
+ try:
128
+ parsed = store.get_parsed(user.uid, name)
129
+ except AtdlValidationError as e:
130
+ # Missing file or a file that no longer parses.
131
+ raise HTTPException(status_code=404, detail=str(e))
132
+ return {
133
+ "name": name,
134
+ "strategy_wire_tag": parsed.strategy_wire_tag,
135
+ "warnings": atdl_warnings(parsed),
136
+ "strategies": [_strategy_dict(s) for s in parsed.strategies],
137
+ }
138
+
139
+
140
+ @router.delete("/{name}", status_code=status.HTTP_204_NO_CONTENT)
141
+ def delete_atdl(name: str, store: AS, user: CurrentUser) -> Response:
142
+ try:
143
+ deleted = store.delete(user.uid, name)
144
+ except AtdlValidationError as e:
145
+ raise HTTPException(status_code=400, detail=str(e))
146
+ if not deleted:
147
+ raise HTTPException(status_code=404, detail="ATDL file not found")
148
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -0,0 +1,359 @@
1
+ """
2
+ ATDL (Algorithmic Trading Definition Language) parser — Phase 1 sketch.
3
+
4
+ Parses a FIXatdl 1.1 file into a flat, UI-friendly model: the strategies it
5
+ offers and, per strategy, the parameters (FIX tag + type + optionality + enum
6
+ pairs) needed to build a ``NewOrderSingle``.
7
+
8
+ Scope: strategies + parameters (Phase 1) plus the **validation / flow-control**
9
+ rules (Phase 3): ``<StrategyEdit>`` (cross-field validation assertions) and
10
+ ``<StateRule>`` (conditional enable/hide of a control). Both hang off ``<Edit>``
11
+ boolean-expression trees; named ``<Edit id=...>`` reused via ``<EditRef>`` are
12
+ collected so the frontend evaluator can resolve them. ``<StrategyLayout>``
13
+ itself is walked only to map a ``<Control parameterRef=...>`` to the parameter a
14
+ ``<StateRule>`` governs — the control widgets are not otherwise interpreted.
15
+
16
+ Edit/StrategyEdit/StateRule semantics (FIXatdl 1.1 spec):
17
+ * ``Edit`` is a boolean expression: either an ``operator`` (EX/NX/EQ/NE/LE/LT/
18
+ GE/GT over ``field`` vs ``value`` or ``field2``) **or** a ``logicOperator``
19
+ (AND/OR/XOR/NOT over child Edits) — never both.
20
+ * ``StrategyEdit`` is an *assertion*: the order is valid when its Edit is TRUE,
21
+ and ``errorMessage`` is shown when it is FALSE.
22
+ * ``StateRule`` action is *in-effect when its Edit is TRUE*: ``enabled="false"``
23
+ disables the control, ``visible="false"`` hides it (etc.).
24
+ The boolean evaluation itself lives in the frontend (``lib/atdlEval.ts``) since
25
+ it runs live against form input; this module only produces the rule tree.
26
+
27
+ Security: ATDL files are untrusted uploads, so parsing goes through
28
+ ``defusedxml`` (XXE / billion-laughs neutralised). This module has zero UI
29
+ dependencies (core layer rule).
30
+
31
+ Namespaces (FIXatdl 1.1)::
32
+
33
+ Core http://www.fixprotocol.org/FIXatdl-1-1/Core
34
+ Layout http://www.fixprotocol.org/FIXatdl-1-1/Layout
35
+ Validation http://www.fixprotocol.org/FIXatdl-1-1/Validation
36
+ xsi http://www.w3.org/2001/XMLSchema-instance
37
+
38
+ The parameter type is carried in the ``xsi:type`` attribute as a QName
39
+ (``"t:Int_t"``). Phase 1 takes the *local part* only (``"Int_t"``) — every ATDL
40
+ parameter type lives in the Core namespace, so prefix resolution buys nothing
41
+ here; revisit if Layout parsing (multiple namespaces) is added.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ # aliased: the Edit dataclass has a `field` attribute (ATDL's field name) which
47
+ # would otherwise shadow dataclasses.field inside the class body.
48
+ from dataclasses import dataclass, field as _dcfield
49
+
50
+ # defusedxml exposes the stdlib ElementTree API but blocks entity/DTD attacks.
51
+ from defusedxml.ElementTree import fromstring, ParseError
52
+
53
+ CORE_NS = "http://www.fixprotocol.org/FIXatdl-1-1/Core"
54
+
55
+ # Parameter xsi:type -> the FIXture field kind the form should render.
56
+ # (Coarser than the full ATDL type list; enough to pick an input widget.)
57
+ _TYPE_KIND = {
58
+ "Int_t": "int", "Float_t": "float", "Price_t": "float", "Qty_t": "float",
59
+ "Amt_t": "float", "PriceOffset_t": "float", "Percentage_t": "float",
60
+ "Boolean_t": "bool",
61
+ "Char_t": "char", "String_t": "string", "MultipleCharValue_t": "string",
62
+ "MultipleStringValue_t": "string", "Currency_t": "string",
63
+ "Country_t": "string", "Exchange_t": "string", "Tenor_t": "string",
64
+ "UTCTimestamp_t": "timestamp", "UTCTimeOnly_t": "time",
65
+ "LocalMktDate_t": "date", "MonthYear_t": "string",
66
+ }
67
+
68
+
69
+ class AtdlParseError(ValueError):
70
+ """Raised when the payload is not a parseable FIXatdl document."""
71
+
72
+
73
+ @dataclass
74
+ class EnumPair:
75
+ enum_id: str # ATDL-internal id, referenced by controls/edits
76
+ wire_value: str # what goes on the wire for this tag
77
+
78
+
79
+ @dataclass
80
+ class Parameter:
81
+ name: str
82
+ fix_tag: int # 0 if the file omitted fixTag
83
+ type: str # raw ATDL type, e.g. "Price_t"
84
+ kind: str # form kind: int|float|bool|string|…
85
+ required: bool
86
+ enums: list[EnumPair] = _dcfield(default_factory=list)
87
+
88
+
89
+ @dataclass
90
+ class Edit:
91
+ """A FIXatdl boolean-expression node.
92
+
93
+ Exactly one shape is populated: a *comparison/existence* leaf
94
+ (``operator`` + ``field`` + ``value``/``field2``), a *logical* node
95
+ (``logic`` + ``children``), or an *EditRef* leaf (``ref`` = a named Edit id).
96
+ """
97
+ operator: str = "" # EX/NX/EQ/NE/LE/LT/GE/GT ('' unless a comparison)
98
+ field: str = "" # parameter *name* being tested
99
+ field2: str = "" # compare against another field's value
100
+ value: str = "" # …or against this literal
101
+ has_value: bool = False # value="" (present) vs value absent
102
+ logic: str = "" # AND/OR/XOR/NOT ('' unless a logical node)
103
+ ref: str = "" # EditRef → id of a named Edit
104
+ id: str = "" # this Edit's own id (if reusable)
105
+ children: list["Edit"] = _dcfield(default_factory=list)
106
+
107
+
108
+ @dataclass
109
+ class StrategyEdit:
110
+ error_message: str # shown when the assertion (edit) is FALSE
111
+ edit: Edit
112
+
113
+
114
+ @dataclass
115
+ class StateRule:
116
+ param: str # parameter name (from the containing Control)
117
+ enabled: str # "true"/"false"/"" (unset) — applied when edit TRUE
118
+ visible: str # "true"/"false"/"" (unset)
119
+ value: str # value to set when edit TRUE ('' if unset)
120
+ edit: Edit
121
+
122
+
123
+ @dataclass
124
+ class Strategy:
125
+ name: str
126
+ ui_rep: str # display label (falls back to name)
127
+ wire_value: str # StrategyIdentifier value on the wire
128
+ fix_msg_type: str # usually "D"
129
+ parameters: list[Parameter] = _dcfield(default_factory=list)
130
+ edits: list[StrategyEdit] = _dcfield(default_factory=list) # validation
131
+ state_rules: list[StateRule] = _dcfield(default_factory=list) # enable/hide
132
+ named_edits: dict[str, Edit] = _dcfield(default_factory=dict) # id → Edit (for EditRef)
133
+ has_layout: bool = False # <StrategyLayout> present
134
+
135
+
136
+ @dataclass
137
+ class AtdlFile:
138
+ strategy_wire_tag: int # tag carrying the strategy id (attr on <Strategies>)
139
+ strategies: list[Strategy] = _dcfield(default_factory=list)
140
+
141
+
142
+ # --- helpers ---------------------------------------------------------------
143
+
144
+ def _local(tag: str) -> str:
145
+ """Strip a ``{namespace}local`` or ``prefix:local`` down to ``local``."""
146
+ if "}" in tag:
147
+ tag = tag.rsplit("}", 1)[1]
148
+ if ":" in tag:
149
+ tag = tag.rsplit(":", 1)[1]
150
+ return tag
151
+
152
+
153
+ def _xsi_type(el) -> str:
154
+ """Local part of the element's ``xsi:type`` (``"t:Int_t"`` -> ``"Int_t"``)."""
155
+ for k, v in el.attrib.items():
156
+ if _local(k) == "type":
157
+ return _local(v)
158
+ return ""
159
+
160
+
161
+ def _int_attr(el, name: str, default: int = 0) -> int:
162
+ try:
163
+ return int(el.get(name, ""))
164
+ except (TypeError, ValueError):
165
+ return default
166
+
167
+
168
+ def _find_local(parent, name: str):
169
+ """Yield direct children whose local tag == *name* (namespace-agnostic)."""
170
+ for child in parent:
171
+ if _local(child.tag) == name:
172
+ yield child
173
+
174
+
175
+ # --- parsing ---------------------------------------------------------------
176
+
177
+ def parse_atdl(xml_text: str) -> AtdlFile:
178
+ """Parse a FIXatdl 1.1 document. Raises :class:`AtdlParseError` on failure."""
179
+ try:
180
+ root = fromstring(xml_text)
181
+ except (ParseError, ValueError) as e:
182
+ raise AtdlParseError(f"Not parseable XML: {e}") from e
183
+
184
+ if _local(root.tag) != "Strategies":
185
+ raise AtdlParseError(
186
+ f"Root element is <{_local(root.tag)}>, expected <Strategies>"
187
+ )
188
+
189
+ # Tag that carries the strategy identifier on the wire. ATDL puts it on
190
+ # <Strategies strategyIdentifierTag="…">; default to StrategyName (tag 848?)
191
+ # — leave 0 and let the caller decide when absent.
192
+ strategy_tag = _int_attr(root, "strategyIdentifierTag", 0)
193
+
194
+ strategies = [_parse_strategy(s) for s in _find_local(root, "Strategy")]
195
+ if not strategies:
196
+ raise AtdlParseError("Document defines no <Strategy> elements")
197
+ return AtdlFile(strategy_wire_tag=strategy_tag, strategies=strategies)
198
+
199
+
200
+ def _parse_strategy(el) -> Strategy:
201
+ name = el.get("name", "")
202
+ strat = Strategy(
203
+ name=name,
204
+ ui_rep=el.get("uiRep") or name,
205
+ wire_value=el.get("wireValue", ""),
206
+ fix_msg_type=el.get("fixMsgType", "D"),
207
+ )
208
+ for child in el:
209
+ local = _local(child.tag)
210
+ if local == "Parameter":
211
+ strat.parameters.append(_parse_parameter(child))
212
+ elif local == "StrategyLayout":
213
+ strat.has_layout = True
214
+
215
+ # Validation assertions: <StrategyEdit> anywhere under the strategy (some
216
+ # files wrap them in a <StrategyEdits>/<Edits> container).
217
+ for se in _iter_local(el, "StrategyEdit"):
218
+ edit = _first_edit_child(se)
219
+ if edit is not None:
220
+ strat.edits.append(
221
+ StrategyEdit(error_message=se.get("errorMessage", ""), edit=edit))
222
+
223
+ # Flow control: a <StateRule> is nested in a <Control parameterRef=...>.
224
+ for ctrl in _iter_local(el, "Control"):
225
+ param_ref = ctrl.get("parameterRef", "")
226
+ for sr in _find_local(ctrl, "StateRule"):
227
+ edit = _first_edit_child(sr)
228
+ if edit is not None:
229
+ strat.state_rules.append(StateRule(
230
+ param=param_ref,
231
+ enabled=sr.get("enabled", ""),
232
+ visible=sr.get("visible", ""),
233
+ value=sr.get("value", ""),
234
+ edit=edit,
235
+ ))
236
+
237
+ # Named edits (<Edit id=...> anywhere) → resolvable by <EditRef id=...>.
238
+ _collect_named_edits(el, strat.named_edits)
239
+ return strat
240
+
241
+
242
+ def _iter_local(parent, name: str):
243
+ """Yield *all descendants* (any depth) whose local tag == *name*."""
244
+ for el in parent.iter():
245
+ if el is not parent and _local(el.tag) == name:
246
+ yield el
247
+
248
+
249
+ def _first_edit_child(el):
250
+ """The <Edit> (or <EditRef>) directly under a StrategyEdit/StateRule."""
251
+ for child in el:
252
+ local = _local(child.tag)
253
+ if local in ("Edit", "EditRef"):
254
+ return _parse_edit(child)
255
+ return None
256
+
257
+
258
+ def _parse_edit(el) -> Edit:
259
+ local = _local(el.tag)
260
+ if local == "EditRef":
261
+ return Edit(ref=el.get("id", ""))
262
+ e = Edit(
263
+ operator=el.get("operator", ""),
264
+ field=el.get("field", ""),
265
+ field2=el.get("field2", ""),
266
+ value=el.get("value", ""),
267
+ has_value="value" in el.attrib,
268
+ logic=el.get("logicOperator", ""),
269
+ id=el.get("id", ""),
270
+ )
271
+ for child in el:
272
+ if _local(child.tag) in ("Edit", "EditRef"):
273
+ e.children.append(_parse_edit(child))
274
+ return e
275
+
276
+
277
+ def _collect_named_edits(el, out: dict) -> None:
278
+ """Map every ``id`` → parsed Edit for EditRef resolution on the frontend."""
279
+ for e in _iter_local(el, "Edit"):
280
+ eid = e.get("id", "")
281
+ if eid and eid not in out:
282
+ out[eid] = _parse_edit(e)
283
+
284
+
285
+ # --- linting ---------------------------------------------------------------
286
+
287
+ def _iter_edit_nodes(edit: Edit):
288
+ """Yield an Edit and all its descendant Edit nodes (refs not resolved)."""
289
+ yield edit
290
+ for child in edit.children:
291
+ yield from _iter_edit_nodes(child)
292
+
293
+
294
+ def _edit_roots(s: Strategy):
295
+ """Every Edit tree in a strategy: validation, state rules, and named edits."""
296
+ for se in s.edits:
297
+ yield se.edit
298
+ for sr in s.state_rules:
299
+ yield sr.edit
300
+ yield from s.named_edits.values()
301
+
302
+
303
+ def atdl_warnings(atdl: AtdlFile) -> list[str]:
304
+ """Non-fatal lint findings for an otherwise-parseable ATDL file.
305
+
306
+ These don't stop the file being used, but flag things that would make an
307
+ order incomplete or a rule inert — useful when authoring/validating a file.
308
+ """
309
+ w: list[str] = []
310
+ if not atdl.strategy_wire_tag:
311
+ w.append("No strategyIdentifierTag on <Strategies> — the strategy "
312
+ "identifier won't be emitted on the order.")
313
+ for s in atdl.strategies:
314
+ if not s.parameters:
315
+ w.append(f"Strategy '{s.name}' declares no parameters.")
316
+ if not s.wire_value:
317
+ w.append(f"Strategy '{s.name}' has no wireValue.")
318
+ names = {p.name for p in s.parameters}
319
+ for p in s.parameters:
320
+ if not p.fix_tag:
321
+ w.append(f"Parameter '{p.name}' in '{s.name}' has no fixTag — "
322
+ "it can't be sent.")
323
+ refs, fields = set(), set()
324
+ for root in _edit_roots(s):
325
+ for node in _iter_edit_nodes(root):
326
+ if node.ref:
327
+ refs.add(node.ref)
328
+ if node.field:
329
+ fields.add(node.field)
330
+ if node.field2:
331
+ fields.add(node.field2)
332
+ for ref in sorted(refs):
333
+ if ref not in s.named_edits:
334
+ w.append(f"EditRef '{ref}' in '{s.name}' does not resolve to a "
335
+ "named <Edit id=...>.")
336
+ for fld in sorted(fields):
337
+ if fld not in names:
338
+ w.append(f"An Edit in '{s.name}' references unknown field '{fld}'.")
339
+ for sr in s.state_rules:
340
+ if sr.param and sr.param not in names:
341
+ w.append(f"A StateRule in '{s.name}' targets unknown parameter "
342
+ f"'{sr.param}'.")
343
+ return w
344
+
345
+
346
+ def _parse_parameter(el) -> Parameter:
347
+ atdl_type = _xsi_type(el)
348
+ return Parameter(
349
+ name=el.get("name", ""),
350
+ fix_tag=_int_attr(el, "fixTag", 0),
351
+ type=atdl_type,
352
+ kind=_TYPE_KIND.get(atdl_type, "string"),
353
+ # ATDL `use` is "optional"/"required"; absent → optional.
354
+ required=(el.get("use", "optional").lower() == "required"),
355
+ enums=[
356
+ EnumPair(enum_id=ep.get("enumID", ""), wire_value=ep.get("wireValue", ""))
357
+ for ep in _find_local(el, "EnumPair")
358
+ ],
359
+ )