Files

564 lines
22 KiB
Python
Raw Permalink Normal View History

2026-06-17 11:26:57 +01:00
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget, QTableWidgetItem,
2026-06-18 09:35:03 +01:00
QPushButton, QComboBox, QCheckBox, QMessageBox,
2026-06-17 11:26:57 +01:00
QHeaderView
)
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer
2026-06-18 09:35:03 +01:00
from PyQt5.QtGui import QColor, QFont
2026-06-17 11:26:57 +01:00
from typing import Dict, Optional
from datetime import datetime, timezone
import config
from layer2_technical import TechnicalAnalyzer
2026-06-18 09:35:03 +01:00
from data_feeder import Mt5DataFeeder
2026-06-17 11:26:57 +01:00
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()
2026-06-18 09:35:03 +01:00
layout.setSpacing(12)
2026-06-17 11:26:57 +01:00
# ====== Connection Panel ======
connection_layout = QHBoxLayout()
connection_layout.addWidget(QLabel("Data Source:"))
self.source_combo = QComboBox()
2026-06-18 09:35:03 +01:00
self.source_combo.addItems(["MT5 (Live)"])
2026-06-17 11:26:57 +01:00
self.source_combo.setCurrentIndex(0)
connection_layout.addWidget(self.source_combo)
2026-06-18 09:35:03 +01:00
connection_layout.addWidget(QLabel("TF:"))
self.tf_combo = QComboBox()
for tf_key in config.TIMEFRAMES:
self.tf_combo.addItem(config.TIMEFRAMES[tf_key]["label"], tf_key)
self.tf_combo.setCurrentText(config.TIMEFRAMES[config.DEFAULT_TIMEFRAME]["label"])
self.tf_combo.currentIndexChanged.connect(self._on_timeframe_changed)
connection_layout.addWidget(self.tf_combo)
2026-06-17 11:26:57 +01:00
self.connect_btn = QPushButton("Connect")
self.connect_btn.clicked.connect(self._on_connect_clicked)
connection_layout.addWidget(self.connect_btn)
2026-06-18 09:35:03 +01:00
# Status dot indicator
self.status_dot = QLabel("●")
self.status_dot.setStyleSheet("color: #e74c3c; font-size: 18px;")
connection_layout.addWidget(self.status_dot)
2026-06-17 11:26:57 +01:00
self.status_label = QLabel("Disconnected")
2026-06-18 09:35:03 +01:00
self.status_label.setStyleSheet("color: #e74c3c; font-weight: 600;")
2026-06-17 11:26:57 +01:00
connection_layout.addWidget(self.status_label)
connection_layout.addStretch()
layout.addLayout(connection_layout)
# ====== Active Session Indicator (Task 4.1) ======
session_layout = QHBoxLayout()
session_layout.addWidget(QLabel("Active Session:"))
self.session_label = QLabel("—")
self.session_label.setStyleSheet(
2026-06-18 09:35:03 +01:00
"font-weight: 700; font-size: 14px; padding: 4px 14px; "
"background-color: #ecf0f1; border-radius: 12px;"
2026-06-17 11:26:57 +01:00
)
session_layout.addWidget(self.session_label)
session_layout.addStretch()
layout.addLayout(session_layout)
# ====== Z-Score Table ======
2026-06-18 09:35:03 +01:00
heading = QLabel("Technical Analysis — All Pairs")
heading.setProperty("heading", True)
layout.addWidget(heading)
2026-06-17 11:26:57 +01:00
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)
2026-06-18 09:35:03 +01:00
header = self.tech_table.horizontalHeader()
for c in range(7):
header.setSectionResizeMode(c, QHeaderView.Stretch)
2026-06-17 11:26:57 +01:00
layout.addWidget(self.tech_table)
# ====== Alerts Panel ======
alerts_layout = QHBoxLayout()
2026-06-18 09:35:03 +01:00
alerts_layout.addWidget(QLabel("Overbought:"))
2026-06-17 11:26:57 +01:00
self.overbought_label = QLabel("—")
2026-06-18 09:35:03 +01:00
self.overbought_label.setStyleSheet("color: #e74c3c; font-weight: 700;")
2026-06-17 11:26:57 +01:00
alerts_layout.addWidget(self.overbought_label)
alerts_layout.addSpacing(20)
2026-06-18 09:35:03 +01:00
alerts_layout.addWidget(QLabel("Oversold:"))
2026-06-17 11:26:57 +01:00
self.oversold_label = QLabel("—")
2026-06-18 09:35:03 +01:00
self.oversold_label.setStyleSheet("color: #27ae60; font-weight: 700;")
2026-06-17 11:26:57 +01:00
alerts_layout.addWidget(self.oversold_label)
alerts_layout.addStretch()
layout.addLayout(alerts_layout)
# ====== Currency Strength Matrix ======
2026-06-18 09:35:03 +01:00
matrix_heading = QLabel("Currency Strength Matrix (S.A.T.O.R.I.)")
matrix_heading.setProperty("heading", True)
layout.addWidget(matrix_heading)
2026-06-17 11:26:57 +01:00
self.matrix_cross_label = QLabel("Matrix Cross: —")
2026-06-18 09:35:03 +01:00
self.matrix_cross_label.setStyleSheet("font-weight: 700; font-size: 14px;")
layout.addWidget(self.matrix_cross_label)
2026-06-17 11:26:57 +01:00
self.divergence_label = QLabel("Divergence Gap: 0.0")
2026-06-18 09:35:03 +01:00
self.divergence_label.setStyleSheet("color: #7f8c8d; font-size: 12px;")
layout.addWidget(self.divergence_label)
2026-06-17 11:26:57 +01:00
2026-06-18 09:35:03 +01:00
alert_row = QHBoxLayout()
2026-06-17 11:26:57 +01:00
self.strong_alert = QLabel("")
2026-06-18 09:35:03 +01:00
self.strong_alert.setStyleSheet("color: #27ae60; font-weight: 600;")
alert_row.addWidget(self.strong_alert)
2026-06-17 11:26:57 +01:00
self.weak_alert = QLabel("")
2026-06-18 09:35:03 +01:00
self.weak_alert.setStyleSheet("color: #e74c3c; font-weight: 600;")
alert_row.addWidget(self.weak_alert)
alert_row.addStretch()
layout.addLayout(alert_row)
2026-06-17 11:26:57 +01:00
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)
2026-06-18 09:35:03 +01:00
self.matrix_table.setMaximumHeight(220)
m_header = self.matrix_table.horizontalHeader()
for c in range(5):
m_header.setSectionResizeMode(c, QHeaderView.Stretch)
layout.addWidget(self.matrix_table)
2026-06-17 11:26:57 +01:00
2026-06-18 09:35:03 +01:00
# ====== Controls ======
2026-06-17 11:26:57 +01:00
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")
2026-06-18 09:35:03 +01:00
refresh_btn.setObjectName("secondary")
2026-06-17 11:26:57 +01:00
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)
2026-06-18 09:35:03 +01:00
self._debounce_timer = QTimer()
self._debounce_timer.setSingleShot(True)
self._debounce_timer.timeout.connect(self._refresh_display)
2026-06-17 11:26:57 +01:00
def _setup_data_source(self):
"""Initialize data source."""
2026-06-18 09:35:03 +01:00
self.data_feeder = Mt5DataFeeder()
2026-06-17 11:26:57 +01:00
def _on_connect_clicked(self):
"""Handle connect button click."""
if self.connected:
self._disconnect()
else:
self._connect()
2026-06-18 09:35:03 +01:00
def _on_timeframe_changed(self, idx: int):
tf_key = self.tf_combo.itemData(idx)
if tf_key:
self.tech_analyzer.set_timeframe(tf_key)
if self.connected and hasattr(self.data_feeder, 'USD_PAIRS'):
self._seed_historical_bars()
2026-06-17 11:26:57 +01:00
def _seed_historical_bars(self):
2026-06-18 09:35:03 +01:00
"""Seed the analyzer with bar data at the selected timeframe.
Fetches only the 7 USD pairs and derives all cross rates.
"""
if not hasattr(self.data_feeder, 'USD_PAIRS'):
return
interval_map = {
"M5": "5min", "M15": "15min",
"H1": "1h", "H4": "4h",
}
tf_key = self.tech_analyzer.current_timeframe
interval = interval_map.get(tf_key, "15min")
outputsize = "full" if tf_key in ("M5", "M15") else "full"
usd_pairs = self.data_feeder.USD_PAIRS
raw_ohlc: dict[str, list[dict]] = {}
for pair in usd_pairs:
base, quote = pair.split("_")
candles = self.data_feeder.get_historical_candles(
from_currency=base, to_currency=quote,
interval=interval, outputsize=outputsize
2026-06-17 11:26:57 +01:00
)
2026-06-18 09:35:03 +01:00
if candles:
raw_ohlc[pair] = candles
if not raw_ohlc:
2026-06-17 11:26:57 +01:00
return
2026-06-18 09:35:03 +01:00
n_bars = min(len(c) for c in raw_ohlc.values())
if n_bars < 2:
return
currencies = config.CURRENCIES
derived_ohlc: dict[str, list[dict]] = {}
for base in currencies:
for quote in currencies:
if base == quote:
continue
derived_ohlc[f"{base}_{quote}"] = []
for i in range(n_bars):
usd_rates: dict[str, float] = {"USD": 1.0}
for pair in usd_pairs:
base, quote = pair.split("_")
c = raw_ohlc[pair][i]
mid = c["close"]
if base == "USD":
usd_rates[quote] = 1.0 / mid if mid else None
else:
usd_rates[base] = mid
for base in currencies:
bv = usd_rates.get(base)
if bv is None:
continue
for quote in currencies:
if base == quote:
continue
qv = usd_rates.get(quote)
if qv is not None:
rate = bv / qv
# Estimate OHLC for the cross pair
derived_ohlc[f"{base}_{quote}"].append({
"close": rate,
"high": rate * 1.0003,
"low": rate * 0.9997,
})
self.tech_analyzer.seed_ohlc(derived_ohlc)
2026-06-17 11:26:57 +01:00
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
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():
2026-06-18 09:35:03 +01:00
self.refresh_timer.start(3000)
2026-06-17 11:26:57 +01:00
self.connected = True
self.connect_btn.setText("Disconnect")
2026-06-18 09:35:03 +01:00
self.connect_btn.setObjectName("danger")
self.connect_btn.style().unpolish(self.connect_btn)
self.connect_btn.style().polish(self.connect_btn)
self.status_dot.setStyleSheet("color: #27ae60; font-size: 18px;")
2026-06-17 11:26:57 +01:00
self.status_label.setText("Connected")
2026-06-18 09:35:03 +01:00
self.status_label.setStyleSheet("color: #27ae60; font-weight: 600;")
2026-06-17 11:26:57 +01:00
self._refresh_display()
2026-06-18 09:35:03 +01:00
QTimer.singleShot(0, self._seed_historical_bars)
2026-06-17 11:26:57 +01:00
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")
2026-06-18 09:35:03 +01:00
self.connect_btn.setObjectName("")
self.connect_btn.style().unpolish(self.connect_btn)
self.connect_btn.style().polish(self.connect_btn)
self.status_dot.setStyleSheet("color: #e74c3c; font-size: 18px;")
2026-06-17 11:26:57 +01:00
self.status_label.setText("Disconnected")
2026-06-18 09:35:03 +01:00
self.status_label.setStyleSheet("color: #e74c3c; font-weight: 600;")
2026-06-17 11:26:57 +01:00
def _on_price_received(self, price_data):
2026-06-18 09:35:03 +01:00
"""Handle price update — just add data, debounce display refresh."""
2026-06-17 11:26:57 +01:00
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)
2026-06-18 09:35:03 +01:00
if not self._debounce_timer.isActive():
self._debounce_timer.start(2000)
2026-06-17 11:26:57 +01:00
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)
2026-06-18 10:16:51 +01:00
if abs(z_score) >= config.SCALP_Z_SCORE_THRESHOLD:
2026-06-17 11:26:57 +01:00
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)
# ====== 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)
2026-06-18 10:16:51 +01:00
threshold = config.SCALP_Z_SCORE_THRESHOLD
2026-06-17 11:26:57 +01:00
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)
except Exception as e:
print(f"[Layer2] Display error: {e}")
def closeEvent(self, event):
"""Clean up on close."""
self._disconnect()
event.accept()