mirror of
https://github.com/Sabermrddz/QuantCore-FX.git
synced 2026-07-29 03:27:46 +00:00
478 lines
19 KiB
Python
478 lines
19 KiB
Python
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()
|