fixcore-engine 0.1.1__tar.gz → 0.1.3__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 (54) hide show
  1. {fixcore_engine-0.1.1/fixcore_engine.egg-info → fixcore_engine-0.1.3}/PKG-INFO +1 -1
  2. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/gui.py +26 -11
  3. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/gui_ui/app.js +158 -0
  4. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/gui_ui/index.html +18 -2
  5. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/gui_ui/style.css +87 -0
  6. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/transport/initiator.py +14 -1
  7. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3/fixcore_engine.egg-info}/PKG-INFO +1 -1
  8. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/pyproject.toml +1 -1
  9. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/LICENSE +0 -0
  10. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/README.md +0 -0
  11. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/__init__.py +0 -0
  12. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/application.py +0 -0
  13. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/gui_ui/fixcore_logo.svg +0 -0
  14. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/log/__init__.py +0 -0
  15. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/log/base.py +0 -0
  16. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/log/factory.py +0 -0
  17. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/log/file_log.py +0 -0
  18. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/log/screen.py +0 -0
  19. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/message/__init__.py +0 -0
  20. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/message/cracker.py +0 -0
  21. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/message/data_dictionary.py +0 -0
  22. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/message/exceptions.py +0 -0
  23. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/message/field.py +0 -0
  24. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/message/message.py +0 -0
  25. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/session/__init__.py +0 -0
  26. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/session/session.py +0 -0
  27. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/session/session_id.py +0 -0
  28. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/session/session_settings.py +0 -0
  29. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/session/state.py +0 -0
  30. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/store/__init__.py +0 -0
  31. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/store/base.py +0 -0
  32. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/store/factory.py +0 -0
  33. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/store/file_store.py +0 -0
  34. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/store/memory.py +0 -0
  35. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/transport/__init__.py +0 -0
  36. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/transport/acceptor.py +0 -0
  37. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore/transport/framer.py +0 -0
  38. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore_engine.egg-info/SOURCES.txt +0 -0
  39. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore_engine.egg-info/dependency_links.txt +0 -0
  40. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore_engine.egg-info/entry_points.txt +0 -0
  41. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore_engine.egg-info/requires.txt +0 -0
  42. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/fixcore_engine.egg-info/top_level.txt +0 -0
  43. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/setup.cfg +0 -0
  44. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/tests/test_cracker.py +0 -0
  45. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/tests/test_data_dictionary.py +0 -0
  46. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/tests/test_file_log.py +0 -0
  47. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/tests/test_file_store.py +0 -0
  48. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/tests/test_framer.py +0 -0
  49. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/tests/test_integration.py +0 -0
  50. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/tests/test_message.py +0 -0
  51. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/tests/test_session.py +0 -0
  52. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/tests/test_session_id.py +0 -0
  53. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/tests/test_store.py +0 -0
  54. {fixcore_engine-0.1.1 → fixcore_engine-0.1.3}/tests/test_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixcore-engine
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Pure Python FIX protocol engine
5
5
  Author-email: Aidan Chisholm <aidan.chisholm@gmail.com>
6
6
  License: MIT License
@@ -224,6 +224,8 @@ async def _dispatch(method: str, body: dict[str, Any]) -> Any:
224
224
  )
225
225
  if method == "update_session":
226
226
  return await _update_session(body.get("old_key", ""), body)
227
+ if method == "stop":
228
+ return await _stop(body.get("sid_key", ""))
227
229
  return {"ok": False, "error": f"Unknown method: {method!r}"}
228
230
 
229
231
 
@@ -377,24 +379,37 @@ async def _logon(sid_key: str) -> dict[str, Any]:
377
379
 
378
380
 
379
381
  async def _logout(sid_key: str) -> dict[str, Any]:
382
+ """Graceful FIX logout — sends Logout message and disables auto-reconnect."""
383
+ entry = _sessions.get(sid_key)
384
+ if entry is None:
385
+ return {"ok": False, "error": "Unknown session"}
386
+ transport = entry.get("transport")
387
+ if transport is None:
388
+ return {"ok": False, "error": "No transport"}
389
+ try:
390
+ if isinstance(transport, SocketInitiator):
391
+ await transport.logout()
392
+ else:
393
+ session = next(iter(transport.sessions.values()), None)
394
+ if session and session.is_logged_on:
395
+ await session.send_logout()
396
+ entry["started"] = False
397
+ except Exception as exc:
398
+ return {"ok": False, "error": str(exc)}
399
+ return {"ok": True}
400
+
401
+
402
+ async def _stop(sid_key: str) -> dict[str, Any]:
403
+ """Hard stop — kills the transport immediately without sending FIX Logout."""
380
404
  entry = _sessions.get(sid_key)
381
405
  if entry is None:
382
406
  return {"ok": False, "error": "Unknown session"}
383
407
  transport = entry.get("transport")
384
408
  if transport is None:
385
409
  return {"ok": False, "error": "No transport"}
386
- session = next(iter(transport.sessions.values()), None)
387
- if session is None:
388
- return {"ok": False, "error": "No session"}
389
- if not session.is_logged_on:
390
- try:
391
- await transport.stop()
392
- entry["started"] = False
393
- except Exception as exc:
394
- return {"ok": False, "error": str(exc)}
395
- return {"ok": True}
396
410
  try:
397
- await session.send_logout()
411
+ await transport.stop()
412
+ entry["started"] = False
398
413
  except Exception as exc:
399
414
  return {"ok": False, "error": str(exc)}
400
415
  return {"ok": True}
@@ -76,12 +76,22 @@ document.addEventListener('DOMContentLoaded', () => {
76
76
  connectWS();
77
77
  apiCall('get_version').then(v => { $id('version').textContent = v; });
78
78
  startPolling();
79
+ loadLog();
80
+ rebuildLog();
79
81
  updateAutoClordidVisibility();
80
82
  updateClordidPreview();
81
83
  $id('chk-auto-clordid').addEventListener('change', () => {
82
84
  updateClordidPreview();
83
85
  });
84
86
  $id('inp-clordid-prefix').addEventListener('input', updateClordidPreview);
87
+
88
+ $id('btn-close-detail').addEventListener('click', closeMsgDetail);
89
+ $id('log-entries').addEventListener('click', e => {
90
+ const row = e.target.closest('.log-entry');
91
+ if (!row || !row._logEntry) return;
92
+ if (row === selectedLogRow) { closeMsgDetail(); return; }
93
+ showMsgDetail(row, row._logEntry);
94
+ });
85
95
  });
86
96
 
87
97
  // ── Polling (seq nums + state) ────────────────────────────────────────────
@@ -153,6 +163,7 @@ function dotClass(state) {
153
163
  function selectSession(key) {
154
164
  activeSid = key;
155
165
  refreshSessions();
166
+ rebuildLog();
156
167
  }
157
168
 
158
169
  function renderSessionDetail(s) {
@@ -293,6 +304,14 @@ $id('btn-logout').addEventListener('click', () => {
293
304
  });
294
305
  });
295
306
 
307
+ $id('btn-stop').addEventListener('click', () => {
308
+ if (!activeSid) return;
309
+ apiCall('stop', { sid_key: activeSid }).then(res => {
310
+ if (!res.ok) showToast('Stop: ' + res.error, 'error');
311
+ else { showToast('Session stopped'); refreshSessions(); }
312
+ });
313
+ });
314
+
296
315
  $id('btn-edit').addEventListener('click', () => {
297
316
  if (!activeSid) return;
298
317
  openEditModal(activeSid);
@@ -343,6 +362,24 @@ function generateClordId() {
343
362
  return prefix + String(clordIdSeq).padStart(6, '0');
344
363
  }
345
364
 
365
+ // ── Send panel collapse ───────────────────────────────────────────────────
366
+ const SEND_COLLAPSED_KEY = 'fixcore_send_collapsed';
367
+
368
+ function setSendCollapsed(collapsed) {
369
+ $id('panel-send').classList.toggle('panel-send-collapsed', collapsed);
370
+ try { localStorage.setItem(SEND_COLLAPSED_KEY, collapsed ? '1' : '0'); } catch (_) {}
371
+ }
372
+
373
+ $id('btn-toggle-send').addEventListener('click', () => {
374
+ const collapsed = !$id('panel-send').classList.contains('panel-send-collapsed');
375
+ setSendCollapsed(collapsed);
376
+ });
377
+
378
+ // Restore collapse state on load
379
+ try {
380
+ if (localStorage.getItem(SEND_COLLAPSED_KEY) === '1') setSendCollapsed(true);
381
+ } catch (_) {}
382
+
346
383
  // ── Message builder ───────────────────────────────────────────────────────
347
384
  const MSG_TYPE_DEFAULTS = {
348
385
  'D': [{tag:'11',value:'ORD0001'},{tag:'55',value:'AAPL'},{tag:'54',value:'1'},
@@ -521,11 +558,115 @@ $id('paste-fix-input').addEventListener('keydown', e => {
521
558
  }
522
559
  });
523
560
 
561
+ // ── FIX tag reference ─────────────────────────────────────────────────────
562
+ const FIX_TAG_NAMES = {
563
+ 1:'Account', 6:'AvgPx', 7:'BeginSeqNo', 8:'BeginString', 9:'BodyLength',
564
+ 10:'CheckSum', 11:'ClOrdID', 14:'CumQty', 15:'Currency', 16:'EndSeqNo',
565
+ 31:'LastPx', 32:'LastQty',
566
+ 17:'ExecID', 18:'ExecInst', 20:'ExecTransType', 21:'HandlInst',
567
+ 34:'MsgSeqNum', 35:'MsgType', 36:'NewSeqNo', 37:'OrderID', 38:'OrderQty',
568
+ 39:'OrdStatus', 40:'OrdType', 41:'OrigClOrdID', 43:'PossDupFlag',
569
+ 44:'Price', 45:'RefSeqNum', 49:'SenderCompID', 50:'SenderSubID',
570
+ 52:'SendingTime', 54:'Side', 55:'Symbol', 56:'TargetCompID',
571
+ 57:'TargetSubID', 58:'Text', 60:'TransactTime', 97:'PossResend',
572
+ 98:'EncryptMethod', 108:'HeartBtInt', 110:'MinQty', 111:'MaxFloor',
573
+ 112:'TestReqID', 114:'LocateReqd', 122:'OrigSendingTime', 123:'GapFillFlag',
574
+ 126:'ExpireTime', 131:'QuoteReqID', 140:'PrevClosePx', 146:'NoRelatedSym',
575
+ 150:'ExecType', 151:'LeavesQty', 167:'SecurityType', 200:'MaturityMonthYear',
576
+ 202:'StrikePrice', 207:'SecurityExchange', 262:'MDReqID',
577
+ 263:'SubscriptionRequestType', 264:'MarketDepth', 267:'NoMDEntryTypes',
578
+ 268:'NoMDEntries', 269:'MDEntryType', 270:'MDEntryPx', 271:'MDEntrySize',
579
+ };
580
+
581
+ const FIX_ENUM_VALUES = {
582
+ 35: {'0':'Heartbeat','1':'TestRequest','2':'ResendRequest','3':'Reject',
583
+ '4':'SequenceReset','5':'Logout','8':'ExecutionReport',
584
+ '9':'OrderCancelReject','A':'Logon','D':'NewOrderSingle',
585
+ 'F':'OrderCancelRequest','G':'OrderCancelReplaceRequest',
586
+ 'H':'OrderStatusRequest','V':'MarketDataRequest',
587
+ 'W':'MarketDataSnapshot','X':'MarketDataIncremental','Y':'MarketDataRequestReject'},
588
+ 39: {'0':'New','1':'PartiallyFilled','2':'Filled','3':'DoneForDay',
589
+ '4':'Canceled','5':'Replaced','6':'PendingCancel','7':'Stopped',
590
+ '8':'Rejected','9':'Suspended','A':'PendingNew','C':'Expired'},
591
+ 40: {'1':'Market','2':'Limit','3':'Stop','4':'StopLimit','5':'MarketOnClose'},
592
+ 54: {'1':'Buy','2':'Sell','3':'BuyMinus','4':'SellPlus','5':'SellShort','6':'SellShortExempt'},
593
+ 98: {'0':'None','1':'Value','2':'DES','3':'PKCS','4':'PGP','5':'PCSDSS','6':'PEM'},
594
+ 150:{'0':'New','1':'PartialFill','2':'Fill','3':'DoneForDay','4':'Canceled',
595
+ '5':'Replace','6':'PendingCancel','7':'Stopped','8':'Rejected',
596
+ 'D':'Restated','E':'Trade','F':'TradeCorrect','G':'TradeCancel','H':'OrderStatus'},
597
+ };
598
+
599
+ function fixTagName(tag) { return FIX_TAG_NAMES[parseInt(tag)] || ''; }
600
+ function fixEnumVal(tag, val) {
601
+ const map = FIX_ENUM_VALUES[parseInt(tag)];
602
+ return map ? (map[val] || val) : val;
603
+ }
604
+
605
+ // ── Message detail panel ──────────────────────────────────────────────────
606
+ let selectedLogRow = null;
607
+
608
+ function showMsgDetail(row, entry) {
609
+ if (selectedLogRow) selectedLogRow.classList.remove('selected');
610
+ selectedLogRow = row;
611
+ row.classList.add('selected');
612
+
613
+ const content = $id('msg-detail-content');
614
+ content.innerHTML = '';
615
+
616
+ // Meta info
617
+ const meta = document.createElement('div');
618
+ meta.className = 'msg-detail-meta';
619
+ meta.innerHTML = `<strong>${dirArrow(entry.dir)} ${entry.dir.toUpperCase()}</strong>
620
+ &nbsp;${escHtml(entry.time)}
621
+ &nbsp;<span title="${escHtml(entry.sid)}">${escHtml(shortSid(entry.sid))}</span>`;
622
+ content.appendChild(meta);
623
+
624
+ // Parse fields
625
+ entry.raw.split('|').filter(Boolean).forEach(part => {
626
+ const eq = part.indexOf('=');
627
+ if (eq < 1) return;
628
+ const tag = part.slice(0, eq).trim();
629
+ const val = part.slice(eq + 1);
630
+ const name = fixTagName(tag);
631
+ const display = fixEnumVal(tag, val);
632
+
633
+ const row = document.createElement('div');
634
+ row.className = 'msg-detail-row';
635
+ row.innerHTML =
636
+ `<span class="detail-tag">${escHtml(tag)}</span>` +
637
+ `<span class="detail-name" title="${escHtml(name)}">${escHtml(name)}</span>` +
638
+ `<span class="detail-val" title="${escHtml(val)}">${escHtml(display)}</span>`;
639
+ content.appendChild(row);
640
+ });
641
+
642
+ $id('msg-detail-panel').classList.remove('hidden');
643
+ }
644
+
645
+ function closeMsgDetail() {
646
+ if (selectedLogRow) { selectedLogRow.classList.remove('selected'); selectedLogRow = null; }
647
+ $id('msg-detail-panel').classList.add('hidden');
648
+ }
649
+
650
+
524
651
  // ── Message log ───────────────────────────────────────────────────────────
652
+ const LOG_STORAGE_KEY = 'fixcore_log';
653
+
654
+ function saveLog() {
655
+ try { localStorage.setItem(LOG_STORAGE_KEY, JSON.stringify(logEntries)); } catch (_) {}
656
+ }
657
+
658
+ function loadLog() {
659
+ try {
660
+ const raw = localStorage.getItem(LOG_STORAGE_KEY);
661
+ if (raw) logEntries = JSON.parse(raw);
662
+ } catch (_) {}
663
+ }
664
+
525
665
  function addLogEntry(dir, sid, raw) {
526
666
  const entry = { dir, sid, raw, time: nowStr() };
527
667
  logEntries.push(entry);
528
668
  if (logEntries.length > 2000) logEntries.shift();
669
+ saveLog();
529
670
  appendLogRow(entry);
530
671
  }
531
672
 
@@ -537,6 +678,7 @@ function appendLogRow(entry) {
537
678
  const row = document.createElement('div');
538
679
  row.className = `log-entry ${entry.dir}`;
539
680
  row.dataset.dir = entry.dir;
681
+ row._logEntry = entry;
540
682
  row.innerHTML = `
541
683
  <span class="log-time">${escHtml(entry.time)}</span>
542
684
  <span class="log-dir">${dirArrow(entry.dir)}</span>
@@ -562,8 +704,21 @@ function shortSid(sid) {
562
704
  : sid.slice(0, 12);
563
705
  }
564
706
 
707
+ const SESSION_MSG_TYPES = new Set(['0','1','2','3','4','5','A']);
708
+
709
+ function getMsgType(raw) {
710
+ const m = raw.match(/\b35=([^|]+)/);
711
+ return m ? m[1].trim() : null;
712
+ }
713
+
565
714
  function matchesFilter(entry) {
715
+ if (activeSid && entry.sid !== activeSid) return false;
566
716
  if (logFilter === 'all') return true;
717
+ if (logFilter === 'app') {
718
+ if (entry.dir === 'evt') return false;
719
+ const mt = getMsgType(entry.raw);
720
+ return mt !== null && !SESSION_MSG_TYPES.has(mt);
721
+ }
567
722
  if (logFilter === 'in') return entry.dir === 'in';
568
723
  if (logFilter === 'out') return entry.dir === 'out';
569
724
  if (logFilter === 'event') return entry.dir === 'evt';
@@ -588,7 +743,9 @@ $id('panel-log').querySelectorAll('.filter-btn').forEach(btn => {
588
743
 
589
744
  $id('btn-clear-log').addEventListener('click', () => {
590
745
  logEntries = [];
746
+ localStorage.removeItem(LOG_STORAGE_KEY);
591
747
  $id('log-entries').innerHTML = '';
748
+ closeMsgDetail();
592
749
  });
593
750
 
594
751
  // ── Keyboard shortcuts ────────────────────────────────────────────────────
@@ -597,6 +754,7 @@ document.addEventListener('keydown', e => {
597
754
  $id('modal-overlay').classList.add('hidden');
598
755
  $id('paste-modal-overlay').classList.add('hidden');
599
756
  editMode = false; editOldKey = null;
757
+ closeMsgDetail();
600
758
  }
601
759
  if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
602
760
  e.preventDefault();
@@ -43,6 +43,7 @@
43
43
  <div class="detail-actions">
44
44
  <button class="btn btn-primary" id="btn-logon">Logon</button>
45
45
  <button class="btn btn-warn" id="btn-logout">Logout</button>
46
+ <button class="btn btn-secondary" id="btn-stop">Stop</button>
46
47
  <button class="btn btn-secondary" id="btn-reset">Reset SeqNums</button>
47
48
  <button class="btn btn-secondary" id="btn-edit">Edit</button>
48
49
  <button class="btn btn-danger" id="btn-remove">Remove</button>
@@ -52,7 +53,11 @@
52
53
 
53
54
  <!-- Send message panel -->
54
55
  <section class="panel" id="panel-send">
55
- <div class="panel-title">Send Message</div>
56
+ <div class="panel-title panel-title-collapsible" id="btn-toggle-send">
57
+ <span>Send Message</span>
58
+ <span class="collapse-chevron" id="send-chevron">▲</span>
59
+ </div>
60
+ <div id="panel-send-body">
56
61
  <div class="send-row">
57
62
  <label class="send-label">MsgType</label>
58
63
  <select id="msg-type-select" class="select-msg-type">
@@ -87,6 +92,7 @@
87
92
  <button class="btn btn-secondary" id="btn-add-field">+ Add Field</button>
88
93
  <button class="btn btn-primary" id="btn-send">Send Message</button>
89
94
  </div>
95
+ </div><!-- /panel-send-body -->
90
96
  </section>
91
97
 
92
98
  <!-- Message log -->
@@ -95,13 +101,23 @@
95
101
  <span class="panel-title">Message Log</span>
96
102
  <div class="log-filters">
97
103
  <button class="filter-btn active" data-filter="all">All</button>
104
+ <button class="filter-btn" data-filter="app">App</button>
98
105
  <button class="filter-btn" data-filter="out">▶ Out</button>
99
106
  <button class="filter-btn" data-filter="in">◀ In</button>
100
107
  <button class="filter-btn" data-filter="event">Events</button>
101
108
  </div>
102
109
  <button class="btn btn-secondary btn-sm" id="btn-clear-log">Clear</button>
103
110
  </div>
104
- <div id="log-entries" class="log-entries"></div>
111
+ <div class="log-body">
112
+ <div id="log-entries" class="log-entries"></div>
113
+ <aside id="msg-detail-panel" class="msg-detail-panel hidden">
114
+ <div class="msg-detail-header">
115
+ <span class="msg-detail-title">Message Detail</span>
116
+ <button id="btn-close-detail" class="btn-close-detail">×</button>
117
+ </div>
118
+ <div id="msg-detail-content" class="msg-detail-content"></div>
119
+ </aside>
120
+ </div>
105
121
  </section>
106
122
  </main>
107
123
  </div>
@@ -138,6 +138,19 @@ body {
138
138
  color: var(--text-dim);
139
139
  margin-bottom: 10px;
140
140
  }
141
+ .panel-title-collapsible {
142
+ display: flex;
143
+ justify-content: space-between;
144
+ align-items: center;
145
+ cursor: pointer;
146
+ user-select: none;
147
+ margin-bottom: 10px;
148
+ }
149
+ .panel-title-collapsible:hover { color: var(--text); }
150
+ .collapse-chevron { font-size: 10px; transition: transform .2s; }
151
+ .panel-send-collapsed .collapse-chevron { transform: rotate(180deg); }
152
+ .panel-send-collapsed #panel-send-body { display: none; }
153
+ .panel-send-collapsed { padding-bottom: 14px; }
141
154
  .placeholder {
142
155
  color: var(--text-dim);
143
156
  font-style: italic;
@@ -302,6 +315,12 @@ body {
302
315
  border-color: var(--accent);
303
316
  color: #fff;
304
317
  }
318
+ .log-body {
319
+ display: flex;
320
+ flex: 1;
321
+ gap: 0;
322
+ overflow: hidden;
323
+ }
305
324
  .log-entries {
306
325
  flex: 1;
307
326
  overflow-y: auto;
@@ -311,6 +330,7 @@ body {
311
330
  border: 1px solid var(--border);
312
331
  border-radius: var(--radius);
313
332
  padding: 6px 0;
333
+ min-width: 0;
314
334
  }
315
335
  .log-entry {
316
336
  display: flex;
@@ -318,8 +338,10 @@ body {
318
338
  padding: 2px 10px;
319
339
  border-bottom: 1px solid transparent;
320
340
  line-height: 1.5;
341
+ cursor: pointer;
321
342
  }
322
343
  .log-entry:hover { background: var(--bg2); }
344
+ .log-entry.selected { background: var(--bg3); outline: 1px solid var(--accent); }
323
345
  .log-entry.in .log-dir { color: var(--in-color); }
324
346
  .log-entry.out .log-dir { color: var(--out-color); }
325
347
  .log-entry.evt .log-dir { color: var(--event-color); }
@@ -329,6 +351,71 @@ body {
329
351
  .log-sid { color: var(--text-dim); flex-shrink: 0; max-width: 120px; overflow: hidden; text-overflow: ellipsis; }
330
352
  .log-msg { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
331
353
 
354
+ /* ── Message detail panel ─────────────────────────────────────────────── */
355
+ .msg-detail-panel {
356
+ width: 320px;
357
+ flex-shrink: 0;
358
+ border-left: 1px solid var(--border);
359
+ display: flex;
360
+ flex-direction: column;
361
+ overflow: hidden;
362
+ background: var(--bg);
363
+ margin-left: 8px;
364
+ border-radius: var(--radius);
365
+ border: 1px solid var(--border);
366
+ }
367
+ .msg-detail-header {
368
+ display: flex;
369
+ align-items: center;
370
+ justify-content: space-between;
371
+ padding: 6px 10px;
372
+ border-bottom: 1px solid var(--border);
373
+ flex-shrink: 0;
374
+ background: var(--bg2);
375
+ }
376
+ .msg-detail-title {
377
+ font-size: 11px;
378
+ font-weight: 600;
379
+ text-transform: uppercase;
380
+ letter-spacing: .08em;
381
+ color: var(--text-dim);
382
+ }
383
+ .btn-close-detail {
384
+ background: none;
385
+ border: none;
386
+ color: var(--text-dim);
387
+ cursor: pointer;
388
+ font-size: 16px;
389
+ padding: 0 2px;
390
+ line-height: 1;
391
+ }
392
+ .btn-close-detail:hover { color: var(--text); }
393
+ .msg-detail-content {
394
+ flex: 1;
395
+ overflow-y: auto;
396
+ padding: 4px 0;
397
+ }
398
+ .msg-detail-meta {
399
+ font-size: 11px;
400
+ color: var(--text-dim);
401
+ padding: 4px 10px 6px;
402
+ border-bottom: 1px solid var(--border);
403
+ margin-bottom: 2px;
404
+ }
405
+ .msg-detail-meta strong { color: var(--text); }
406
+ .msg-detail-row {
407
+ display: grid;
408
+ grid-template-columns: 42px minmax(0,1fr) minmax(0,1.4fr);
409
+ gap: 0 6px;
410
+ padding: 2px 10px;
411
+ font-size: 11px;
412
+ line-height: 1.6;
413
+ }
414
+ .msg-detail-row:hover { background: var(--bg2); }
415
+ .detail-tag { color: var(--text-dim); font-variant-numeric: tabular-nums; }
416
+ .detail-name { color: var(--accent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
417
+ .detail-val { color: var(--text); word-break: break-all; }
418
+
332
419
  /* ── Buttons ─────────────────────────────────────────────────────────── */
333
420
  .btn {
334
421
  font-family: var(--font);
@@ -42,6 +42,7 @@ class SocketInitiator:
42
42
  self._sessions: dict[SessionID, Session] = {}
43
43
  self._tasks: list[asyncio.Task[None]] = []
44
44
  self._stop_event = asyncio.Event()
45
+ self._no_reconnect = asyncio.Event()
45
46
 
46
47
  for sid in settings.session_ids:
47
48
  conn_type = settings.get_or(sid, "ConnectionType", "initiator").lower()
@@ -59,6 +60,7 @@ class SocketInitiator:
59
60
  async def start(self) -> None:
60
61
  """Launch a connect-loop task for each configured initiator session."""
61
62
  self._stop_event.clear()
63
+ self._no_reconnect.clear()
62
64
  for sid, session in self._sessions.items():
63
65
  task = asyncio.create_task(
64
66
  self._connect_loop(sid, session),
@@ -73,6 +75,17 @@ class SocketInitiator:
73
75
  await asyncio.gather(*self._tasks, return_exceptions=True)
74
76
  self._tasks.clear()
75
77
 
78
+ async def logout(self) -> None:
79
+ """Send FIX Logout for all active sessions and disable auto-reconnect.
80
+
81
+ The connect loop will exit cleanly after the connection drops rather
82
+ than sleeping and retrying. Call :meth:`start` to reconnect later.
83
+ """
84
+ self._no_reconnect.set()
85
+ for session in self._sessions.values():
86
+ if session.is_logged_on:
87
+ await session.send_logout()
88
+
76
89
  async def __aenter__(self) -> "SocketInitiator":
77
90
  await self.start()
78
91
  return self
@@ -132,7 +145,7 @@ class SocketInitiator:
132
145
  pass
133
146
  framer.reset()
134
147
 
135
- if not self._stop_event.is_set():
148
+ if not self._stop_event.is_set() and not self._no_reconnect.is_set():
136
149
  await self._interruptible_sleep(reconnect)
137
150
 
138
151
  async def _interruptible_sleep(self, seconds: float) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fixcore-engine
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Pure Python FIX protocol engine
5
5
  Author-email: Aidan Chisholm <aidan.chisholm@gmail.com>
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fixcore-engine"
7
- version = "0.1.1"
7
+ version = "0.1.3"
8
8
  description = "Pure Python FIX protocol engine"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
File without changes
File without changes
File without changes