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.
- {fixtureqa-0.15.0/fixtureqa.egg-info → fixtureqa-0.16.0}/PKG-INFO +2 -1
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/app.py +4 -1
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/deps.py +5 -0
- fixtureqa-0.16.0/fixture/api/routers/atdl.py +148 -0
- fixtureqa-0.16.0/fixture/core/atdl_parser.py +359 -0
- fixtureqa-0.16.0/fixture/core/atdl_store.py +119 -0
- fixtureqa-0.16.0/fixture/static/assets/index-DCRhkrrW.js +103 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/static/index.html +1 -1
- {fixtureqa-0.15.0 → fixtureqa-0.16.0/fixtureqa.egg-info}/PKG-INFO +2 -1
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixtureqa.egg-info/SOURCES.txt +5 -1
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixtureqa.egg-info/requires.txt +1 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/pyproject.toml +2 -1
- fixtureqa-0.16.0/tests/test_atdl.py +350 -0
- fixtureqa-0.15.0/fixture/static/assets/index-DQvZ5mvq.js +0 -102
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/LICENSE +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/README.md +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/__init__.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/__main__.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/__init__.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/connection_manager.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/__init__.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/admin.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/auth.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/branding.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/custom_tags.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/fix_spec.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/messages.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/perf.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/scenarios.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/sessions.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/setup.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/spec_overlay.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/specs.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/templates.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/routers/ws.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/api/schemas.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/config/__init__.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/__init__.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/atomic_io.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/auth.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/config_store.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/custom_tag_store.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/db_migrations.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/events.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/fix_application.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/fix_builder.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/fix_parser.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/fix_spec_parser.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/fix_tags.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/fix_time.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/housekeeping.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/inbound.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/json_store.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/latency_csv_writer.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/message_log.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/message_store.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/models.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/perf_charts.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/perf_engine.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/perf_models.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/perf_payload.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/perf_stats.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/perf_store.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/perf_writer.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/scenario_runner.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/scenario_store.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/session.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/session_manager.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/spec_overlay_store.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/spec_store.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/template_store.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/user_store.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/value_gen.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/core/venue_responses.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/server.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/static/assets/ag-grid-_QKprVdm.js +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/static/assets/index-CyNOPa0n.js +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/static/assets/index-D0a-mw9U.css +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/static/assets/react-vendor-2eF0YfZT.js +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/static/favicon.svg +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixture/ui/__init__.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixtureqa.egg-info/dependency_links.txt +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixtureqa.egg-info/entry_points.txt +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/fixtureqa.egg-info/top_level.txt +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/setup.cfg +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_atomic_io.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_auth.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_config_store.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_connection_manager.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_db_migrations.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_fix_builder.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_fix_spec_tree.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_health.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_inbound.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_inbound_validation.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_message_store.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_msg_user_attribution.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_non_stop_session.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_perf_api.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_perf_engine.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_perf_models.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_perf_payload.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_perf_rehydrate.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_scenarios.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_session_auto_response.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_session_lifecycle.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_session_manager_concurrency.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_session_visibility.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_sessions.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_specs.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_templates.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_timezone_data.py +0 -0
- {fixtureqa-0.15.0 → fixtureqa-0.16.0}/tests/test_value_gen.py +0 -0
- {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.
|
|
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
|
+
)
|