Files
QuantCore-FX/ui/layer2_monitor_tab.py
T
2026-06-17 11:26:57 +01:00

478 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()