fixtureqa 0.1.0__py3-none-any.whl
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.
- fixture/__init__.py +22 -0
- fixture/__main__.py +161 -0
- fixture/api/__init__.py +0 -0
- fixture/api/app.py +95 -0
- fixture/api/connection_manager.py +161 -0
- fixture/api/deps.py +73 -0
- fixture/api/routers/__init__.py +0 -0
- fixture/api/routers/admin.py +178 -0
- fixture/api/routers/auth.py +74 -0
- fixture/api/routers/branding.py +33 -0
- fixture/api/routers/fix_spec.py +41 -0
- fixture/api/routers/messages.py +137 -0
- fixture/api/routers/scenarios.py +65 -0
- fixture/api/routers/sessions.py +272 -0
- fixture/api/routers/setup.py +42 -0
- fixture/api/routers/templates.py +36 -0
- fixture/api/routers/ws.py +129 -0
- fixture/api/schemas.py +289 -0
- fixture/config/__init__.py +0 -0
- fixture/core/__init__.py +0 -0
- fixture/core/auth.py +68 -0
- fixture/core/config_store.py +85 -0
- fixture/core/events.py +22 -0
- fixture/core/fix_application.py +67 -0
- fixture/core/fix_parser.py +79 -0
- fixture/core/fix_spec_parser.py +172 -0
- fixture/core/fix_tags.py +297 -0
- fixture/core/housekeeping.py +107 -0
- fixture/core/message_log.py +115 -0
- fixture/core/message_store.py +246 -0
- fixture/core/models.py +87 -0
- fixture/core/scenario_runner.py +331 -0
- fixture/core/scenario_store.py +70 -0
- fixture/core/session.py +278 -0
- fixture/core/session_manager.py +173 -0
- fixture/core/template_store.py +70 -0
- fixture/core/user_store.py +186 -0
- fixture/core/venue_responses.py +94 -0
- fixture/fix_specs/FIX42.xml +2746 -0
- fixture/fix_specs/FIX44.xml +6593 -0
- fixture/server.py +37 -0
- fixture/static/assets/ag-grid-_QKprVdm.js +326 -0
- fixture/static/assets/index-B31-1dt-.css +1 -0
- fixture/static/assets/index-CTsKxGdI.js +87 -0
- fixture/static/assets/react-vendor-2eF0YfZT.js +1 -0
- fixture/static/favicon.svg +12 -0
- fixture/static/index.html +15 -0
- fixture/ui/__init__.py +0 -0
- fixtureqa-0.1.0.dist-info/METADATA +16 -0
- fixtureqa-0.1.0.dist-info/RECORD +54 -0
- fixtureqa-0.1.0.dist-info/WHEEL +5 -0
- fixtureqa-0.1.0.dist-info/entry_points.txt +2 -0
- fixtureqa-0.1.0.dist-info/licenses/LICENSE +21 -0
- fixtureqa-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parse bundled FIX spec XMLs (FIX42.xml / FIX44.xml) into structured field and
|
|
3
|
+
message definitions. Results are cached in-process — parsed once on first use.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class FieldDef:
|
|
20
|
+
number: int
|
|
21
|
+
name: str
|
|
22
|
+
type: str # STRING, CHAR, INT, PRICE, etc.
|
|
23
|
+
values: dict[str, str] # enum_value → description; empty if not enum
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class FieldRef:
|
|
28
|
+
tag: int # 0 if unknown
|
|
29
|
+
name: str
|
|
30
|
+
type: str
|
|
31
|
+
required: bool
|
|
32
|
+
values: dict[str, str]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class MessageDef:
|
|
37
|
+
msg_type: str
|
|
38
|
+
name: str
|
|
39
|
+
category: str # 'admin' | 'app'
|
|
40
|
+
fields: list[FieldRef] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Internal cache
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class _Spec:
|
|
49
|
+
field_defs: dict[int, FieldDef] # tag number → FieldDef
|
|
50
|
+
field_by_name: dict[str, FieldDef] # name → FieldDef
|
|
51
|
+
messages: dict[str, MessageDef] # msg_type → MessageDef
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_CACHE: dict[str, _Spec] = {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _spec_path(begin_string: str) -> str:
|
|
58
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
59
|
+
fname = "FIX42.xml" if "4.2" in begin_string else "FIX44.xml"
|
|
60
|
+
return os.path.abspath(os.path.join(here, "..", "fix_specs", fname))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _load_spec(begin_string: str) -> _Spec:
|
|
64
|
+
path = _spec_path(begin_string)
|
|
65
|
+
try:
|
|
66
|
+
tree = ET.parse(path)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logger.error("Failed to parse FIX spec %s: %s", path, e)
|
|
69
|
+
return _Spec(field_defs={}, field_by_name={}, messages={})
|
|
70
|
+
|
|
71
|
+
root = tree.getroot()
|
|
72
|
+
|
|
73
|
+
# ── 1. Parse <fields> section ──────────────────────────────────────────
|
|
74
|
+
field_defs: dict[int, FieldDef] = {}
|
|
75
|
+
field_by_name: dict[str, FieldDef] = {}
|
|
76
|
+
|
|
77
|
+
fields_el = root.find("fields")
|
|
78
|
+
if fields_el is not None:
|
|
79
|
+
for fel in fields_el.findall("field"):
|
|
80
|
+
try:
|
|
81
|
+
number = int(fel.get("number", "0"))
|
|
82
|
+
except ValueError:
|
|
83
|
+
continue
|
|
84
|
+
name = fel.get("name", "")
|
|
85
|
+
ftype = fel.get("type", "STRING")
|
|
86
|
+
values: dict[str, str] = {}
|
|
87
|
+
for vel in fel.findall("value"):
|
|
88
|
+
enum = vel.get("enum", "")
|
|
89
|
+
desc = vel.get("description", "")
|
|
90
|
+
if enum:
|
|
91
|
+
values[enum] = desc
|
|
92
|
+
fd = FieldDef(number=number, name=name, type=ftype, values=values)
|
|
93
|
+
field_defs[number] = fd
|
|
94
|
+
field_by_name[name] = fd
|
|
95
|
+
|
|
96
|
+
# ── 2. Parse <messages> section ────────────────────────────────────────
|
|
97
|
+
messages: dict[str, MessageDef] = {}
|
|
98
|
+
|
|
99
|
+
messages_el = root.find("messages")
|
|
100
|
+
if messages_el is not None:
|
|
101
|
+
for mel in messages_el.findall("message"):
|
|
102
|
+
msg_type = mel.get("msgtype", "")
|
|
103
|
+
name = mel.get("name", "")
|
|
104
|
+
category = mel.get("msgcat", "app")
|
|
105
|
+
if not msg_type:
|
|
106
|
+
continue
|
|
107
|
+
refs: list[FieldRef] = _collect_field_refs(mel, field_by_name, required_override=None)
|
|
108
|
+
messages[msg_type] = MessageDef(
|
|
109
|
+
msg_type=msg_type,
|
|
110
|
+
name=name,
|
|
111
|
+
category=category,
|
|
112
|
+
fields=refs,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return _Spec(field_defs=field_defs, field_by_name=field_by_name, messages=messages)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _collect_field_refs(
|
|
119
|
+
el: ET.Element,
|
|
120
|
+
field_by_name: dict[str, FieldDef],
|
|
121
|
+
required_override: Optional[bool],
|
|
122
|
+
) -> list[FieldRef]:
|
|
123
|
+
"""Recursively collect field refs from <field> and <group> children."""
|
|
124
|
+
refs: list[FieldRef] = []
|
|
125
|
+
for child in el:
|
|
126
|
+
if child.tag == "field":
|
|
127
|
+
fname = child.get("name", "")
|
|
128
|
+
req = child.get("required", "N") == "Y"
|
|
129
|
+
if required_override is not None:
|
|
130
|
+
req = required_override
|
|
131
|
+
fd = field_by_name.get(fname)
|
|
132
|
+
refs.append(FieldRef(
|
|
133
|
+
tag=fd.number if fd else 0,
|
|
134
|
+
name=fname,
|
|
135
|
+
type=fd.type if fd else "STRING",
|
|
136
|
+
required=req,
|
|
137
|
+
values=fd.values if fd else {},
|
|
138
|
+
))
|
|
139
|
+
elif child.tag == "group":
|
|
140
|
+
# Flatten group members; all members are optional (group itself may be required)
|
|
141
|
+
refs.extend(_collect_field_refs(child, field_by_name, required_override=False))
|
|
142
|
+
return refs
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _get_spec(begin_string: str) -> _Spec:
|
|
146
|
+
if begin_string not in _CACHE:
|
|
147
|
+
_CACHE[begin_string] = _load_spec(begin_string)
|
|
148
|
+
return _CACHE[begin_string]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# Public API
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def get_messages(begin_string: str) -> list[MessageDef]:
|
|
156
|
+
"""Return all message definitions for the given FIX version, sorted."""
|
|
157
|
+
spec = _get_spec(begin_string)
|
|
158
|
+
result = list(spec.messages.values())
|
|
159
|
+
result.sort(key=lambda m: (m.category != "app", m.name))
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_message(begin_string: str, msg_type: str) -> MessageDef | None:
|
|
164
|
+
"""Return the full message definition for one MsgType, or None."""
|
|
165
|
+
spec = _get_spec(begin_string)
|
|
166
|
+
return spec.messages.get(msg_type)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_fields(begin_string: str) -> dict[int, FieldDef]:
|
|
170
|
+
"""Return all field definitions keyed by tag number."""
|
|
171
|
+
spec = _get_spec(begin_string)
|
|
172
|
+
return spec.field_defs
|
fixture/core/fix_tags.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# FIX tag number -> field name (covers FIX 4.2 / 4.4 common fields)
|
|
2
|
+
TAG_NAMES: dict[int, str] = {
|
|
3
|
+
1: "Account",
|
|
4
|
+
6: "AvgPx",
|
|
5
|
+
7: "BeginSeqNo",
|
|
6
|
+
8: "BeginString",
|
|
7
|
+
9: "BodyLength",
|
|
8
|
+
10: "CheckSum",
|
|
9
|
+
11: "ClOrdID",
|
|
10
|
+
12: "Commission",
|
|
11
|
+
13: "CommType",
|
|
12
|
+
14: "CumQty",
|
|
13
|
+
15: "Currency",
|
|
14
|
+
16: "EndSeqNo",
|
|
15
|
+
17: "ExecID",
|
|
16
|
+
18: "ExecInst",
|
|
17
|
+
19: "ExecRefID",
|
|
18
|
+
20: "ExecTransType",
|
|
19
|
+
21: "HandlInst",
|
|
20
|
+
22: "IDSource",
|
|
21
|
+
23: "IOIID",
|
|
22
|
+
24: "IOIOthSvc",
|
|
23
|
+
25: "IOIQltyInd",
|
|
24
|
+
26: "IOIRefID",
|
|
25
|
+
27: "IOIShares",
|
|
26
|
+
28: "IOITransType",
|
|
27
|
+
29: "LastCapacity",
|
|
28
|
+
30: "LastMkt",
|
|
29
|
+
31: "LastPx",
|
|
30
|
+
32: "LastShares",
|
|
31
|
+
33: "LinesOfText",
|
|
32
|
+
34: "MsgSeqNum",
|
|
33
|
+
35: "MsgType",
|
|
34
|
+
36: "NewSeqNo",
|
|
35
|
+
37: "OrderID",
|
|
36
|
+
38: "OrderQty",
|
|
37
|
+
39: "OrdStatus",
|
|
38
|
+
40: "OrdType",
|
|
39
|
+
41: "OrigClOrdID",
|
|
40
|
+
42: "OrigTime",
|
|
41
|
+
43: "PossDupFlag",
|
|
42
|
+
44: "Price",
|
|
43
|
+
45: "RefSeqNum",
|
|
44
|
+
48: "SecurityID",
|
|
45
|
+
49: "SenderCompID",
|
|
46
|
+
50: "SenderSubID",
|
|
47
|
+
52: "SendingTime",
|
|
48
|
+
54: "Side",
|
|
49
|
+
55: "Symbol",
|
|
50
|
+
56: "TargetCompID",
|
|
51
|
+
57: "TargetSubID",
|
|
52
|
+
58: "Text",
|
|
53
|
+
59: "TimeInForce",
|
|
54
|
+
60: "TransactTime",
|
|
55
|
+
61: "Urgency",
|
|
56
|
+
62: "ValidUntilTime",
|
|
57
|
+
63: "SettlmntTyp",
|
|
58
|
+
64: "FutSettDate",
|
|
59
|
+
65: "SymbolSfx",
|
|
60
|
+
66: "ListID",
|
|
61
|
+
67: "ListSeqNo",
|
|
62
|
+
68: "TotNoOrders",
|
|
63
|
+
69: "ListExecInst",
|
|
64
|
+
70: "AllocID",
|
|
65
|
+
73: "NoOrders",
|
|
66
|
+
74: "AvgPrxPrecision",
|
|
67
|
+
75: "TradeDate",
|
|
68
|
+
76: "ExecBroker",
|
|
69
|
+
77: "OpenClose",
|
|
70
|
+
78: "NoAllocs",
|
|
71
|
+
79: "AllocAccount",
|
|
72
|
+
80: "AllocShares",
|
|
73
|
+
81: "ProcessCode",
|
|
74
|
+
82: "NoRpts",
|
|
75
|
+
83: "RptSeq",
|
|
76
|
+
84: "CxlQty",
|
|
77
|
+
87: "AllocStatus",
|
|
78
|
+
88: "AllocRejCode",
|
|
79
|
+
89: "Signature",
|
|
80
|
+
90: "SecureDataLen",
|
|
81
|
+
91: "SecureData",
|
|
82
|
+
93: "SignatureLength",
|
|
83
|
+
94: "EmailType",
|
|
84
|
+
95: "RawDataLength",
|
|
85
|
+
96: "RawData",
|
|
86
|
+
97: "PossResend",
|
|
87
|
+
98: "EncryptMethod",
|
|
88
|
+
99: "StopPx",
|
|
89
|
+
100: "ExDestination",
|
|
90
|
+
102: "CxlRejReason",
|
|
91
|
+
103: "OrdRejReason",
|
|
92
|
+
104: "IOIQualifier",
|
|
93
|
+
107: "SecurityDesc",
|
|
94
|
+
108: "HeartBtInt",
|
|
95
|
+
109: "ClientID",
|
|
96
|
+
110: "MinQty",
|
|
97
|
+
111: "MaxFloor",
|
|
98
|
+
112: "TestReqID",
|
|
99
|
+
113: "ReportToExch",
|
|
100
|
+
114: "LocateReqd",
|
|
101
|
+
115: "OnBehalfOfCompID",
|
|
102
|
+
116: "OnBehalfOfSubID",
|
|
103
|
+
117: "QuoteID",
|
|
104
|
+
118: "NetMoney",
|
|
105
|
+
119: "SettlCurrAmt",
|
|
106
|
+
120: "SettlCurrency",
|
|
107
|
+
121: "ForexReq",
|
|
108
|
+
122: "OrigSendingTime",
|
|
109
|
+
123: "GapFillFlag",
|
|
110
|
+
124: "NoExecs",
|
|
111
|
+
126: "ExpireTime",
|
|
112
|
+
127: "DKReason",
|
|
113
|
+
128: "DeliverToCompID",
|
|
114
|
+
129: "DeliverToSubID",
|
|
115
|
+
130: "IOINaturalFlag",
|
|
116
|
+
131: "QuoteReqID",
|
|
117
|
+
132: "BidPx",
|
|
118
|
+
133: "OfferPx",
|
|
119
|
+
134: "BidSize",
|
|
120
|
+
135: "OfferSize",
|
|
121
|
+
136: "NoMiscFees",
|
|
122
|
+
137: "MiscFeeAmt",
|
|
123
|
+
138: "MiscFeeCurr",
|
|
124
|
+
139: "MiscFeeType",
|
|
125
|
+
140: "PrevClosePx",
|
|
126
|
+
141: "ResetSeqNumFlag",
|
|
127
|
+
142: "SenderLocationID",
|
|
128
|
+
143: "TargetLocationID",
|
|
129
|
+
144: "OnBehalfOfLocationID",
|
|
130
|
+
145: "DeliverToLocationID",
|
|
131
|
+
146: "NoRelatedSym",
|
|
132
|
+
147: "Subject",
|
|
133
|
+
148: "Headline",
|
|
134
|
+
149: "URLLink",
|
|
135
|
+
150: "ExecType",
|
|
136
|
+
151: "LeavesQty",
|
|
137
|
+
152: "CashOrderQty",
|
|
138
|
+
153: "AllocAvgPx",
|
|
139
|
+
154: "AllocNetMoney",
|
|
140
|
+
155: "SettlCurrFxRate",
|
|
141
|
+
156: "SettlCurrFxRateCalc",
|
|
142
|
+
157: "NumDaysInterest",
|
|
143
|
+
158: "AccruedInterestRate",
|
|
144
|
+
159: "AccruedInterestAmt",
|
|
145
|
+
160: "SettlInstMode",
|
|
146
|
+
161: "AllocText",
|
|
147
|
+
162: "SettlInstID",
|
|
148
|
+
163: "SettlInstTransType",
|
|
149
|
+
164: "EmailThreadID",
|
|
150
|
+
165: "SettlInstSource",
|
|
151
|
+
166: "SettlLocation",
|
|
152
|
+
167: "SecurityType",
|
|
153
|
+
168: "EffectiveTime",
|
|
154
|
+
169: "StandInstDbType",
|
|
155
|
+
170: "StandInstDbName",
|
|
156
|
+
171: "StandInstDbID",
|
|
157
|
+
172: "SettlDeliveryType",
|
|
158
|
+
173: "SettlDepositoryCode",
|
|
159
|
+
174: "SettlBrkrCode",
|
|
160
|
+
175: "SettlInstCode",
|
|
161
|
+
176: "SecuritySettlAgentName",
|
|
162
|
+
177: "SecuritySettlAgentCode",
|
|
163
|
+
178: "SecuritySettlAgentAcctNum",
|
|
164
|
+
179: "SecuritySettlAgentAcctName",
|
|
165
|
+
180: "SecuritySettlAgentContactName",
|
|
166
|
+
181: "SecuritySettlAgentContactPhone",
|
|
167
|
+
182: "CashSettlAgentName",
|
|
168
|
+
183: "CashSettlAgentCode",
|
|
169
|
+
184: "CashSettlAgentAcctNum",
|
|
170
|
+
185: "CashSettlAgentAcctName",
|
|
171
|
+
186: "CashSettlAgentContactName",
|
|
172
|
+
187: "CashSettlAgentContactPhone",
|
|
173
|
+
188: "BidSpotRate",
|
|
174
|
+
189: "BidForwardPoints",
|
|
175
|
+
190: "OfferSpotRate",
|
|
176
|
+
191: "OfferForwardPoints",
|
|
177
|
+
192: "OrderQty2",
|
|
178
|
+
193: "FutSettDate2",
|
|
179
|
+
194: "LastSpotRate",
|
|
180
|
+
195: "LastForwardPoints",
|
|
181
|
+
196: "AllocLinkID",
|
|
182
|
+
197: "AllocLinkType",
|
|
183
|
+
198: "SecondaryOrderID",
|
|
184
|
+
199: "NoIOIQualifiers",
|
|
185
|
+
200: "MaturityMonthYear",
|
|
186
|
+
201: "PutOrCall",
|
|
187
|
+
202: "StrikePrice",
|
|
188
|
+
203: "CoveredOrUncovered",
|
|
189
|
+
204: "CustomerOrFirm",
|
|
190
|
+
205: "MaturityDay",
|
|
191
|
+
206: "OptAttribute",
|
|
192
|
+
207: "SecurityExchange",
|
|
193
|
+
208: "NotifyBrokerOfCredit",
|
|
194
|
+
209: "AllocHandlInst",
|
|
195
|
+
210: "MaxShow",
|
|
196
|
+
211: "PegDifference",
|
|
197
|
+
212: "XmlDataLen",
|
|
198
|
+
213: "XmlData",
|
|
199
|
+
214: "SettlInstRefID",
|
|
200
|
+
215: "NoRoutingIDs",
|
|
201
|
+
216: "RoutingType",
|
|
202
|
+
217: "RoutingID",
|
|
203
|
+
# FIX 4.4 additions
|
|
204
|
+
369: "LastMsgSeqNumProcessed",
|
|
205
|
+
371: "RefTagID",
|
|
206
|
+
372: "RefMsgType",
|
|
207
|
+
373: "SessionRejectReason",
|
|
208
|
+
374: "BidRequestTransType",
|
|
209
|
+
375: "ContraBroker",
|
|
210
|
+
376: "ComplianceID",
|
|
211
|
+
377: "SolicitedFlag",
|
|
212
|
+
378: "ExecRestatementReason",
|
|
213
|
+
379: "BusinessRejectRefID",
|
|
214
|
+
380: "BusinessRejectReason",
|
|
215
|
+
381: "GrossTradeAmt",
|
|
216
|
+
382: "NoContraBrokers",
|
|
217
|
+
383: "MaxMessageSize",
|
|
218
|
+
384: "NoMsgTypes",
|
|
219
|
+
385: "MsgDirection",
|
|
220
|
+
386: "NoTradingSessions",
|
|
221
|
+
387: "TotalVolumeTraded",
|
|
222
|
+
388: "DiscretionInst",
|
|
223
|
+
389: "DiscretionOffset",
|
|
224
|
+
390: "BidID",
|
|
225
|
+
391: "ClientBidID",
|
|
226
|
+
392: "ListName",
|
|
227
|
+
393: "TotalNumSecurities",
|
|
228
|
+
394: "BidType",
|
|
229
|
+
395: "NumTickets",
|
|
230
|
+
396: "SideValue1",
|
|
231
|
+
397: "SideValue2",
|
|
232
|
+
398: "NoBidDescriptors",
|
|
233
|
+
399: "BidDescriptorType",
|
|
234
|
+
400: "BidDescriptor",
|
|
235
|
+
553: "Username",
|
|
236
|
+
554: "Password",
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
# MsgType (tag 35) value -> readable name
|
|
240
|
+
MSG_TYPE_NAMES: dict[str, str] = {
|
|
241
|
+
"0": "Heartbeat",
|
|
242
|
+
"1": "TestRequest",
|
|
243
|
+
"2": "ResendRequest",
|
|
244
|
+
"3": "Reject",
|
|
245
|
+
"4": "SequenceReset",
|
|
246
|
+
"5": "Logout",
|
|
247
|
+
"6": "IndicationOfInterest",
|
|
248
|
+
"7": "Advertisement",
|
|
249
|
+
"8": "ExecutionReport",
|
|
250
|
+
"9": "OrderCancelReject",
|
|
251
|
+
"A": "Logon",
|
|
252
|
+
"B": "News",
|
|
253
|
+
"C": "Email",
|
|
254
|
+
"D": "NewOrderSingle",
|
|
255
|
+
"E": "NewOrderList",
|
|
256
|
+
"F": "OrderCancelRequest",
|
|
257
|
+
"G": "OrderCancelReplaceRequest",
|
|
258
|
+
"H": "OrderStatusRequest",
|
|
259
|
+
"J": "Allocation",
|
|
260
|
+
"K": "ListCancelRequest",
|
|
261
|
+
"L": "ListExecute",
|
|
262
|
+
"M": "ListStatusRequest",
|
|
263
|
+
"N": "ListStatus",
|
|
264
|
+
"P": "AllocationAck",
|
|
265
|
+
"Q": "DontKnowTrade",
|
|
266
|
+
"R": "QuoteRequest",
|
|
267
|
+
"S": "Quote",
|
|
268
|
+
"T": "SettlementInstructions",
|
|
269
|
+
"V": "MarketDataRequest",
|
|
270
|
+
"W": "MarketDataSnapshotFullRefresh",
|
|
271
|
+
"X": "MarketDataIncrementalRefresh",
|
|
272
|
+
"Y": "MarketDataRequestReject",
|
|
273
|
+
"Z": "QuoteCancel",
|
|
274
|
+
"a": "QuoteStatusRequest",
|
|
275
|
+
"b": "MassQuoteAcknowledgement",
|
|
276
|
+
"c": "SecurityDefinitionRequest",
|
|
277
|
+
"d": "SecurityDefinition",
|
|
278
|
+
"e": "SecurityStatusRequest",
|
|
279
|
+
"f": "SecurityStatus",
|
|
280
|
+
"g": "TradingSessionStatusRequest",
|
|
281
|
+
"h": "TradingSessionStatus",
|
|
282
|
+
"i": "MassQuote",
|
|
283
|
+
"j": "BusinessMessageReject",
|
|
284
|
+
"k": "BidRequest",
|
|
285
|
+
"l": "BidResponse",
|
|
286
|
+
"m": "ListStrikePrice",
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def tag_name(tag: int) -> str:
|
|
291
|
+
"""Return the field name for a tag number, or 'Tag<N>' if unknown."""
|
|
292
|
+
return TAG_NAMES.get(tag, f"Tag{tag}")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def msg_type_name(msg_type: str) -> str:
|
|
296
|
+
"""Return the human-readable name for a MsgType value."""
|
|
297
|
+
return MSG_TYPE_NAMES.get(msg_type, f"Unknown({msg_type})")
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Housekeeping service: age-based cleanup of SQLite messages and QuickFIX log files.
|
|
3
|
+
|
|
4
|
+
One instance is created at app startup and shared. It starts a daemon background
|
|
5
|
+
thread that runs cleanup once on startup (after a grace period) then every 24 h.
|
|
6
|
+
It can also be triggered synchronously via run_now() from an API endpoint.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import sqlite3
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .user_store import UserStore
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HousekeepingService:
|
|
24
|
+
"""
|
|
25
|
+
Cleans up old messages from SQLite and old QuickFIX .log files.
|
|
26
|
+
|
|
27
|
+
Settings are read from UserStore on each run so that changes made in the
|
|
28
|
+
admin UI take effect on the next scheduled run without a restart.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, msg_db_path: str, log_dir: str, user_store: "UserStore"):
|
|
32
|
+
self._msg_db_path = msg_db_path
|
|
33
|
+
self._log_dir = log_dir
|
|
34
|
+
self._user_store = user_store
|
|
35
|
+
|
|
36
|
+
def start(self) -> None:
|
|
37
|
+
"""Start the background daemon thread."""
|
|
38
|
+
t = threading.Thread(target=self._loop, daemon=True, name="housekeeping")
|
|
39
|
+
t.start()
|
|
40
|
+
|
|
41
|
+
def run_now(self) -> dict:
|
|
42
|
+
"""
|
|
43
|
+
Run cleanup synchronously. Returns {"msgs_deleted": N, "logs_deleted": N}.
|
|
44
|
+
Safe to call from any thread (used by the API endpoint).
|
|
45
|
+
"""
|
|
46
|
+
settings = self._load_settings()
|
|
47
|
+
return {
|
|
48
|
+
"msgs_deleted": self._purge_messages(settings["msg_retention_days"]),
|
|
49
|
+
"logs_deleted": self._purge_logs(settings["log_retention_days"]),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
# Internal
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def _load_settings(self) -> dict:
|
|
57
|
+
us = self._user_store
|
|
58
|
+
return {
|
|
59
|
+
"enabled": us.get_setting("housekeeping_enabled", "1") == "1",
|
|
60
|
+
"msg_retention_days": int(us.get_setting("msg_retention_days", "90")),
|
|
61
|
+
"log_retention_days": int(us.get_setting("log_retention_days", "30")),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def _loop(self) -> None:
|
|
65
|
+
time.sleep(30) # startup grace period — let sessions initialise first
|
|
66
|
+
while True:
|
|
67
|
+
try:
|
|
68
|
+
settings = self._load_settings()
|
|
69
|
+
if settings["enabled"]:
|
|
70
|
+
result = self.run_now()
|
|
71
|
+
if result["msgs_deleted"] or result["logs_deleted"]:
|
|
72
|
+
logger.info(
|
|
73
|
+
"deleted %d messages, %d log files",
|
|
74
|
+
result["msgs_deleted"], result["logs_deleted"],
|
|
75
|
+
)
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
logger.error("housekeeping error: %s", exc)
|
|
78
|
+
time.sleep(86400) # 24 h
|
|
79
|
+
|
|
80
|
+
def _purge_messages(self, retention_days: int) -> int:
|
|
81
|
+
if retention_days == 0:
|
|
82
|
+
return 0
|
|
83
|
+
if not os.path.isfile(self._msg_db_path):
|
|
84
|
+
return 0
|
|
85
|
+
cutoff = (datetime.now(tz=timezone.utc) - timedelta(days=retention_days)).isoformat()
|
|
86
|
+
with sqlite3.connect(self._msg_db_path, timeout=30) as conn:
|
|
87
|
+
cur = conn.execute("DELETE FROM messages WHERE ts < ?", (cutoff,))
|
|
88
|
+
return cur.rowcount
|
|
89
|
+
|
|
90
|
+
def _purge_logs(self, retention_days: int) -> int:
|
|
91
|
+
if retention_days == 0:
|
|
92
|
+
return 0
|
|
93
|
+
if not os.path.isdir(self._log_dir):
|
|
94
|
+
return 0
|
|
95
|
+
cutoff = time.time() - retention_days * 86400
|
|
96
|
+
count = 0
|
|
97
|
+
for root, _, files in os.walk(self._log_dir):
|
|
98
|
+
for fname in files:
|
|
99
|
+
if fname.endswith(".log"):
|
|
100
|
+
path = os.path.join(root, fname)
|
|
101
|
+
try:
|
|
102
|
+
if os.path.getmtime(path) < cutoff:
|
|
103
|
+
os.remove(path)
|
|
104
|
+
count += 1
|
|
105
|
+
except OSError:
|
|
106
|
+
pass
|
|
107
|
+
return count
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-session message log with structured entries and filtering.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from .fix_parser import parse_raw, get_msg_type, get_seq_num
|
|
11
|
+
from .fix_tags import msg_type_name
|
|
12
|
+
|
|
13
|
+
DEFAULT_MAX_ENTRIES = 10_000
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class LogEntry:
|
|
18
|
+
direction: str # "IN" or "OUT"
|
|
19
|
+
admin: bool # True = session-level (Logon, Heartbeat, etc.)
|
|
20
|
+
raw: str # raw FIX string (SOH-delimited)
|
|
21
|
+
timestamp: datetime # UTC capture time
|
|
22
|
+
seq_num: int # MsgSeqNum (tag 34); 0 if absent
|
|
23
|
+
msg_type: str # raw MsgType (tag 35), e.g. "D", "8", "A"
|
|
24
|
+
msg_type_name: str # human-readable, e.g. "NewOrderSingle"
|
|
25
|
+
fields: dict = field(default_factory=dict) # {tag_int: value_str}
|
|
26
|
+
id: Optional[int] = None # SQLite rowid; None for in-memory entries
|
|
27
|
+
|
|
28
|
+
def pretty(self, use_names: bool = True) -> str:
|
|
29
|
+
"""Multi-line human-readable display."""
|
|
30
|
+
from .fix_parser import format_fields
|
|
31
|
+
lines = [
|
|
32
|
+
f"[{self.msg_type_name}] {self.direction} "
|
|
33
|
+
f"SeqNum={self.seq_num} "
|
|
34
|
+
f"{self.timestamp.strftime('%H:%M:%S.%f')[:-3]}"
|
|
35
|
+
]
|
|
36
|
+
for label, value in format_fields(self.fields, use_names=use_names):
|
|
37
|
+
lines.append(f" {label} = {value}")
|
|
38
|
+
return "\n".join(lines)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MessageLog:
|
|
42
|
+
"""
|
|
43
|
+
Thread-safe message log for one FIX session.
|
|
44
|
+
|
|
45
|
+
Messages arrive from the QuickFIX I/O thread; reads happen from the
|
|
46
|
+
UI or CLI thread — all access is protected by a lock.
|
|
47
|
+
|
|
48
|
+
When max_entries is reached, oldest entries are dropped (ring-buffer
|
|
49
|
+
behaviour) to prevent unbounded memory growth.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, max_entries: int = DEFAULT_MAX_ENTRIES):
|
|
53
|
+
self._entries: list[LogEntry] = []
|
|
54
|
+
self._max = max_entries
|
|
55
|
+
self._lock = threading.Lock()
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
# Write
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def add(self, direction: str, admin: bool, raw: str) -> LogEntry:
|
|
62
|
+
"""Parse and store a message. Returns the created LogEntry."""
|
|
63
|
+
fields = parse_raw(raw)
|
|
64
|
+
mt = get_msg_type(fields)
|
|
65
|
+
entry = LogEntry(
|
|
66
|
+
direction=direction,
|
|
67
|
+
admin=admin,
|
|
68
|
+
raw=raw,
|
|
69
|
+
timestamp=datetime.now(tz=timezone.utc),
|
|
70
|
+
seq_num=get_seq_num(fields),
|
|
71
|
+
msg_type=mt,
|
|
72
|
+
msg_type_name=msg_type_name(mt) if mt else "Unknown",
|
|
73
|
+
fields=fields,
|
|
74
|
+
)
|
|
75
|
+
with self._lock:
|
|
76
|
+
if len(self._entries) >= self._max:
|
|
77
|
+
self._entries = self._entries[self._max // 10:] # drop oldest 10%
|
|
78
|
+
self._entries.append(entry)
|
|
79
|
+
return entry
|
|
80
|
+
|
|
81
|
+
def clear(self) -> None:
|
|
82
|
+
with self._lock:
|
|
83
|
+
self._entries.clear()
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
# Read
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def get_entries(
|
|
90
|
+
self,
|
|
91
|
+
*,
|
|
92
|
+
direction: Optional[str] = None, # "IN" or "OUT"
|
|
93
|
+
admin: Optional[bool] = None, # True/False to filter; None = both
|
|
94
|
+
msg_type: Optional[str] = None, # raw MsgType value, e.g. "D"
|
|
95
|
+
limit: Optional[int] = None, # return last N entries
|
|
96
|
+
before_id: Optional[int] = None, # ignored for in-memory log (no DB rowids)
|
|
97
|
+
) -> list[LogEntry]:
|
|
98
|
+
"""Return a snapshot (copy) of entries matching the given filters."""
|
|
99
|
+
with self._lock:
|
|
100
|
+
entries = list(self._entries)
|
|
101
|
+
|
|
102
|
+
if direction is not None:
|
|
103
|
+
entries = [e for e in entries if e.direction == direction]
|
|
104
|
+
if admin is not None:
|
|
105
|
+
entries = [e for e in entries if e.admin == admin]
|
|
106
|
+
if msg_type is not None:
|
|
107
|
+
entries = [e for e in entries if e.msg_type == msg_type]
|
|
108
|
+
if limit is not None:
|
|
109
|
+
entries = entries[-limit:]
|
|
110
|
+
|
|
111
|
+
return entries
|
|
112
|
+
|
|
113
|
+
def __len__(self) -> int:
|
|
114
|
+
with self._lock:
|
|
115
|
+
return len(self._entries)
|