Files
QuantCore-FX/ui/layer2_monitor_tab.py
T

478 lines
19 KiB
Python
Raw Normal View History

2026-06-17 11:26:57 +01:00
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget, QTableWidgetItem,
QPushButton, QComboBox, QCheckBox, QMessageBox, QStatusBar, QProgressBar,
QHeaderView
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer
from PyQt5.QtGui import QColor, QFont, QBrush
from typing import Dict, Optional
from datetime import datetime, timezone
import config
from layer2_technical import TechnicalAnalyzer
from data_feeder import Mt5DataFeeder, MockDataFeeder
from currency_strength_matrix import CurrencyStrengthMatrix
class DataStreamerThread(QThread):
"""Background thread for data source polling."""
price_updated = pyqtSignal(dict)
error_occurred = pyqtSignal(str)
connected = pyqtSignal(bool)
def __init__(self, data_feeder, instruments):
super().__init__()
self.feeder = data_feeder
self.instruments = instruments
self.running = True
def run(self):
try:
if not self.feeder.test_connection():
self.connected.emit(False)
self.error_occurred.emit("Failed to connect to data source")
return
self.connected.emit(True)
self.feeder.stream_prices(self.instruments, callback=self._on_price)
except Exception as e:
self.error_occurred.emit(str(e))
self.connected.emit(False)
def _on_price(self, price_data):
self.price_updated.emit(price_data)
def stop(self):
self.running = False
if hasattr(self.feeder, 'stop_streaming'):
self.feeder.stop_streaming()
class Layer2MonitorTab(QWidget):
"""Layer 2 technical analysis monitoring tab.
Task 4.1 — Dynamic Session Visualizations:
- Active market session indicator (Tokyo / London / New York)
- High-contrast conditional formatting for ±2σ currency strength cells
"""
def __init__(self, technical_analyzer: TechnicalAnalyzer = None):
super().__init__()
self.tech_analyzer = technical_analyzer or TechnicalAnalyzer()
self.data_feeder = None
self.streamer_thread = None
self.connected = False
# Persistent matrix to retain SessionTracker state across refreshes
self.matrix = CurrencyStrengthMatrix()
self._init_ui()
self._setup_data_source()
self._refresh_display()
def _init_ui(self):
"""Build UI layout."""
layout = QVBoxLayout()
# ====== Connection Panel ======
connection_layout = QHBoxLayout()
connection_layout.addWidget(QLabel("Data Source:"))
self.source_combo = QComboBox()
self.source_combo.addItems(["Mock (Test)", "MT5 (Live)"])
self.source_combo.setCurrentIndex(0)
connection_layout.addWidget(self.source_combo)
self.connect_btn = QPushButton("Connect")
self.connect_btn.clicked.connect(self._on_connect_clicked)
connection_layout.addWidget(self.connect_btn)
self.status_label = QLabel("Disconnected")
self.status_label.setStyleSheet("color: red; font-weight: bold;")
connection_layout.addWidget(self.status_label)
connection_layout.addStretch()
layout.addLayout(connection_layout)
layout.addSpacing(10)
# ====== Active Session Indicator (Task 4.1) ======
session_layout = QHBoxLayout()
session_layout.addWidget(QLabel("Active Session:"))
self.session_label = QLabel("—")
self.session_label.setStyleSheet(
"font-weight: bold; font-size: 14px; padding: 2px 8px; "
"background-color: #ecf0f1; border-radius: 4px;"
)
session_layout.addWidget(self.session_label)
session_layout.addStretch()
layout.addLayout(session_layout)
layout.addSpacing(5)
# ====== Z-Score Table ======
layout.addWidget(QLabel("Technical Analysis — All Pairs"))
self.tech_table = QTableWidget()
self.tech_table.setColumnCount(7)
self.tech_table.setHorizontalHeaderLabels([
"Pair", "Current Price", "Z-Score", "Volatility", "Mean Price", "Status", "Signal"
])
self.tech_table.setRowCount(28)
self.tech_table.setAlternatingRowColors(True)
self.tech_table.horizontalHeader().setStretchLastSection(True)
layout.addWidget(self.tech_table)
layout.addSpacing(10)
# ====== Alerts Panel ======
alerts_layout = QHBoxLayout()
alerts_layout.addWidget(QLabel("Overbought Pairs:"))
self.overbought_label = QLabel("—")
self.overbought_label.setStyleSheet("color: #e74c3c; font-weight: bold;")
alerts_layout.addWidget(self.overbought_label)
alerts_layout.addSpacing(20)
alerts_layout.addWidget(QLabel("Oversold Pairs:"))
self.oversold_label = QLabel("—")
self.oversold_label.setStyleSheet("color: #27ae60; font-weight: bold;")
alerts_layout.addWidget(self.oversold_label)
alerts_layout.addStretch()
layout.addLayout(alerts_layout)
layout.addSpacing(10)
# ====== Currency Strength Matrix ======
matrix_group = QWidget()
matrix_layout = QVBoxLayout(matrix_group)
matrix_layout.setContentsMargins(0, 0, 0, 0)
matrix_layout.addWidget(QLabel("Currency Strength Matrix (S.A.T.O.R.I.)"))
self.matrix_cross_label = QLabel("Matrix Cross: —")
self.matrix_cross_label.setStyleSheet("font-weight: bold; font-size: 13px; color: #2c3e50;")
matrix_layout.addWidget(self.matrix_cross_label)
self.divergence_label = QLabel("Divergence Gap: 0.0")
self.divergence_label.setStyleSheet("color: #7f8c8d;")
matrix_layout.addWidget(self.divergence_label)
self.strong_alert = QLabel("")
matrix_layout.addWidget(self.strong_alert)
self.weak_alert = QLabel("")
matrix_layout.addWidget(self.weak_alert)
self.matrix_table = QTableWidget()
self.matrix_table.setColumnCount(5)
self.matrix_table.setHorizontalHeaderLabels([
"Rank", "Currency", "Strength Z", "Direction", "Session SRV"
])
self.matrix_table.setRowCount(8)
self.matrix_table.setMaximumHeight(240)
self.matrix_table.horizontalHeader().setStretchLastSection(True)
matrix_layout.addWidget(self.matrix_table)
layout.addWidget(matrix_group)
layout.addSpacing(10)
# ====== Refresh Button ======
button_layout = QHBoxLayout()
self.auto_refresh_check = QCheckBox("Auto-refresh (every 1s)")
self.auto_refresh_check.setChecked(True)
button_layout.addWidget(self.auto_refresh_check)
refresh_btn = QPushButton("Refresh Now")
refresh_btn.clicked.connect(self._refresh_display)
button_layout.addWidget(refresh_btn)
button_layout.addStretch()
layout.addLayout(button_layout)
layout.addStretch()
self.setLayout(layout)
self.refresh_timer = QTimer()
self.refresh_timer.timeout.connect(self._refresh_display)
def _setup_data_source(self):
"""Initialize data source."""
source = self.source_combo.currentText()
if "MT5" in source:
self.data_feeder = Mt5DataFeeder()
else:
self.data_feeder = MockDataFeeder()
def _on_connect_clicked(self):
"""Handle connect button click."""
if self.connected:
self._disconnect()
else:
self._connect()
def _seed_historical_bars(self):
"""Seed the analyzer with 24h of historical M5 bar data for stable Z-scores."""
if hasattr(self.data_feeder, 'generate_mock_bars'):
bars = self.data_feeder.generate_mock_bars(n_bars=config.BAR_LOOKBACK_BARS)
elif hasattr(self.data_feeder, 'fetch_historical_closes_all_pairs'):
bars = self.data_feeder.fetch_historical_closes_all_pairs(
days=1, interval="5min"
)
else:
return
self.tech_analyzer.seed_bars(bars)
def _connect(self):
"""Connect to data source."""
try:
if not self.data_feeder.test_connection():
reason = getattr(self.data_feeder, 'last_error', 'Unknown error')
QMessageBox.warning(self, "Connection Error", f"Failed to connect to data source:\n{reason}")
return
# Seed bar_history with 24h of M5 close prices so Z-scores are
# anchored to a meaningful multi-hour frame, not tick noise.
self._seed_historical_bars()
instruments = self.data_feeder.get_all_major_pairs()
self.streamer_thread = DataStreamerThread(self.data_feeder, instruments)
self.streamer_thread.price_updated.connect(self._on_price_received)
self.streamer_thread.error_occurred.connect(self._on_streamer_error)
self.streamer_thread.connected.connect(self._on_connected)
self.streamer_thread.start()
if self.auto_refresh_check.isChecked():
self.refresh_timer.start(1000)
self.connected = True
self.connect_btn.setText("Disconnect")
self.status_label.setText("Connected")
self.status_label.setStyleSheet("color: #27ae60; font-weight: bold;")
self._refresh_display()
except Exception as e:
QMessageBox.critical(self, "Error", f"Connection failed: {e}")
def _disconnect(self):
"""Disconnect from data source."""
if self.streamer_thread:
self.streamer_thread.stop()
self.streamer_thread.quit()
self.streamer_thread.wait()
self.refresh_timer.stop()
self.connected = False
self.connect_btn.setText("Connect")
self.status_label.setText("Disconnected")
self.status_label.setStyleSheet("color: #e74c3c; font-weight: bold;")
def _on_price_received(self, price_data):
"""Handle price update from data feeder."""
pair = price_data.get('pair')
mid_price = price_data.get('mid')
if pair and mid_price:
self.tech_analyzer.add_price_data(pair, mid_price)
self._refresh_display()
def _on_connected(self, is_connected):
"""Handle connection status change."""
if is_connected:
self.status_label.setText("Connected")
self.status_label.setStyleSheet("color: #27ae60; font-weight: bold;")
else:
self.status_label.setText("Disconnected")
self.status_label.setStyleSheet("color: #e74c3c; font-weight: bold;")
def _on_streamer_error(self, error_msg):
"""Handle streamer error."""
print(f"[Layer2] Streamer error: {error_msg}")
def _refresh_display(self):
"""Refresh the technical analysis display."""
try:
z_scores = self.tech_analyzer.get_all_z_scores()
overbought = self.tech_analyzer.get_overbought_pairs()
oversold = self.tech_analyzer.get_oversold_pairs()
for row, (pair, z_score) in enumerate(sorted(z_scores.items())):
if row >= self.tech_table.rowCount():
break
status = self.tech_analyzer.get_status_for_pair(pair)
pair_item = QTableWidgetItem(pair)
pair_item.setFlags(pair_item.flags() & ~Qt.ItemIsEditable)
self.tech_table.setItem(row, 0, pair_item)
last_price = self.tech_analyzer.get_last_price(pair)
price_text = f"{last_price:.4f}" if last_price else "—"
price_item = QTableWidgetItem(price_text)
price_item.setFlags(price_item.flags() & ~Qt.ItemIsEditable)
self.tech_table.setItem(row, 1, price_item)
z_item = QTableWidgetItem(f"{z_score:.2f}")
z_item.setFlags(z_item.flags() & ~Qt.ItemIsEditable)
z_item.setTextAlignment(Qt.AlignCenter)
if abs(z_score) >= config.Z_SCORE_THRESHOLD:
z_item.setBackground(QColor("#ffebee"))
z_item.setForeground(QColor("#c62828"))
self.tech_table.setItem(row, 2, z_item)
vol_item = QTableWidgetItem(f"{status['volatility']:.4f}")
vol_item.setFlags(vol_item.flags() & ~Qt.ItemIsEditable)
self.tech_table.setItem(row, 3, vol_item)
mean_item = QTableWidgetItem(f"{status['mean_price']:.4f}")
mean_item.setFlags(mean_item.flags() & ~Qt.ItemIsEditable)
self.tech_table.setItem(row, 4, mean_item)
status_item = QTableWidgetItem(status['status'])
status_item.setFlags(status_item.flags() & ~Qt.ItemIsEditable)
if "OVERBOUGHT" in status['status']:
status_item.setBackground(QColor("#ffebee"))
elif "OVERSOLD" in status['status']:
status_item.setBackground(QColor("#e8f5e9"))
self.tech_table.setItem(row, 5, status_item)
if status['is_extreme']:
signal = "EXTREME"
signal_item = QTableWidgetItem(signal)
signal_item.setBackground(QColor("#fff3e0"))
else:
signal = "Normal"
signal_item = QTableWidgetItem(signal)
signal_item.setFlags(signal_item.flags() & ~Qt.ItemIsEditable)
self.tech_table.setItem(row, 6, signal_item)
overbought_text = ", ".join(overbought) if overbought else "None"
oversold_text = ", ".join(oversold) if oversold else "None"
self.overbought_label.setText(overbought_text)
self.oversold_label.setText(oversold_text)
self.tech_table.resizeColumnsToContents()
# ====== Currency Strength Matrix (persistent instance) ======
current_prices = {}
for pair in z_scores:
lp = self.tech_analyzer.get_last_price(pair)
if lp is not None:
current_prices[pair] = lp
self.matrix.update(z_scores, current_prices=current_prices)
report = self.matrix.get_report()
matrix_cross = report["matrix_cross"]
gap = report["divergence_gap"]
self.matrix_cross_label.setText(
f"Matrix Cross: {matrix_cross or '—'} | Spread: {gap:.2f}σ"
)
if report["has_divergence"]:
self.matrix_cross_label.setStyleSheet(
"font-weight: bold; font-size: 13px; color: #e74c3c;"
)
self.divergence_label.setText(
"DIVERGENCE DETECTED — extreme strength vs extreme weakness"
)
self.divergence_label.setStyleSheet("color: #e74c3c; font-weight: bold;")
else:
self.matrix_cross_label.setStyleSheet(
"font-weight: bold; font-size: 13px; color: #2c3e50;"
)
self.divergence_label.setText("No extreme divergence")
self.divergence_label.setStyleSheet("color: #7f8c8d;")
ob_currencies = report["overbought"]
os_currencies = report["oversold"]
self.strong_alert.setText(
f"Overbought Currencies: {', '.join(ob_currencies) if ob_currencies else 'None'}"
)
self.weak_alert.setText(
f"Oversold Currencies: {', '.join(os_currencies) if os_currencies else 'None'}"
)
# ====== Active Session Indicator (Task 4.1) ======
active_session = report.get("active_session", "—")
session_colors = {
"Tokyo": "#8e44ad",
"London": "#2980b9",
"New York": "#e67e22",
"Off-Hours": "#7f8c8d",
}
session_color = session_colors.get(active_session, "#7f8c8d")
self.session_label.setText(active_session)
self.session_label.setStyleSheet(
f"font-weight: bold; font-size: 14px; padding: 2px 8px; "
f"color: white; background-color: {session_color}; "
f"border-radius: 4px;"
)
# ====== Ranked Currency Table with High-Contrast σ (Task 4.1) ======
ranked = report["ranked"]
for row, entry in enumerate(ranked):
ccy = entry[0]
z_val = entry[1]
direction = entry[2]
srv = entry[3] if len(entry) > 3 else 0.0
rank_item = QTableWidgetItem(str(row + 1))
rank_item.setFlags(rank_item.flags() & ~Qt.ItemIsEditable)
rank_item.setTextAlignment(Qt.AlignCenter)
self.matrix_table.setItem(row, 0, rank_item)
ccy_item = QTableWidgetItem(ccy)
ccy_item.setFlags(ccy_item.flags() & ~Qt.ItemIsEditable)
self.matrix_table.setItem(row, 1, ccy_item)
z_item = QTableWidgetItem(f"{z_val:.2f}")
z_item.setFlags(z_item.flags() & ~Qt.ItemIsEditable)
z_item.setTextAlignment(Qt.AlignCenter)
# High-contrast σ formatting (Task 4.1)
threshold = config.Z_SCORE_THRESHOLD
if z_val >= threshold:
z_item.setBackground(QColor("#c62828"))
z_item.setForeground(QColor("white"))
elif z_val <= -threshold:
z_item.setBackground(QColor("#2e7d32"))
z_item.setForeground(QColor("white"))
self.matrix_table.setItem(row, 2, z_item)
dir_item = QTableWidgetItem(direction)
dir_item.setFlags(dir_item.flags() & ~Qt.ItemIsEditable)
if direction == "OVERBOUGHT":
dir_item.setBackground(QColor("#ffebee"))
dir_item.setForeground(QColor("#c62828"))
elif direction == "OVERSOLD":
dir_item.setBackground(QColor("#e8f5e9"))
dir_item.setForeground(QColor("#2e7d32"))
self.matrix_table.setItem(row, 3, dir_item)
# Session Relative Velocity column
srv_sign = "+" if srv >= 0 else ""
srv_item = QTableWidgetItem(f"{srv_sign}{srv:.4f}%")
srv_item.setFlags(srv_item.flags() & ~Qt.ItemIsEditable)
srv_item.setTextAlignment(Qt.AlignCenter)
if abs(srv) > 0.5:
srv_item.setBackground(QColor("#fff3e0"))
self.matrix_table.setItem(row, 4, srv_item)
self.matrix_table.resizeColumnsToContents()
except Exception as e:
print(f"[Layer2] Display error: {e}")
def closeEvent(self, event):
"""Clean up on close."""
self._disconnect()
event.accept()