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.
Files changed (54) hide show
  1. fixture/__init__.py +22 -0
  2. fixture/__main__.py +161 -0
  3. fixture/api/__init__.py +0 -0
  4. fixture/api/app.py +95 -0
  5. fixture/api/connection_manager.py +161 -0
  6. fixture/api/deps.py +73 -0
  7. fixture/api/routers/__init__.py +0 -0
  8. fixture/api/routers/admin.py +178 -0
  9. fixture/api/routers/auth.py +74 -0
  10. fixture/api/routers/branding.py +33 -0
  11. fixture/api/routers/fix_spec.py +41 -0
  12. fixture/api/routers/messages.py +137 -0
  13. fixture/api/routers/scenarios.py +65 -0
  14. fixture/api/routers/sessions.py +272 -0
  15. fixture/api/routers/setup.py +42 -0
  16. fixture/api/routers/templates.py +36 -0
  17. fixture/api/routers/ws.py +129 -0
  18. fixture/api/schemas.py +289 -0
  19. fixture/config/__init__.py +0 -0
  20. fixture/core/__init__.py +0 -0
  21. fixture/core/auth.py +68 -0
  22. fixture/core/config_store.py +85 -0
  23. fixture/core/events.py +22 -0
  24. fixture/core/fix_application.py +67 -0
  25. fixture/core/fix_parser.py +79 -0
  26. fixture/core/fix_spec_parser.py +172 -0
  27. fixture/core/fix_tags.py +297 -0
  28. fixture/core/housekeeping.py +107 -0
  29. fixture/core/message_log.py +115 -0
  30. fixture/core/message_store.py +246 -0
  31. fixture/core/models.py +87 -0
  32. fixture/core/scenario_runner.py +331 -0
  33. fixture/core/scenario_store.py +70 -0
  34. fixture/core/session.py +278 -0
  35. fixture/core/session_manager.py +173 -0
  36. fixture/core/template_store.py +70 -0
  37. fixture/core/user_store.py +186 -0
  38. fixture/core/venue_responses.py +94 -0
  39. fixture/fix_specs/FIX42.xml +2746 -0
  40. fixture/fix_specs/FIX44.xml +6593 -0
  41. fixture/server.py +37 -0
  42. fixture/static/assets/ag-grid-_QKprVdm.js +326 -0
  43. fixture/static/assets/index-B31-1dt-.css +1 -0
  44. fixture/static/assets/index-CTsKxGdI.js +87 -0
  45. fixture/static/assets/react-vendor-2eF0YfZT.js +1 -0
  46. fixture/static/favicon.svg +12 -0
  47. fixture/static/index.html +15 -0
  48. fixture/ui/__init__.py +0 -0
  49. fixtureqa-0.1.0.dist-info/METADATA +16 -0
  50. fixtureqa-0.1.0.dist-info/RECORD +54 -0
  51. fixtureqa-0.1.0.dist-info/WHEEL +5 -0
  52. fixtureqa-0.1.0.dist-info/entry_points.txt +2 -0
  53. fixtureqa-0.1.0.dist-info/licenses/LICENSE +21 -0
  54. 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
@@ -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)