""" Desktop visualization application using PySide6 and PyQtGraph. This module provides a native desktop replacement for the Dash web application, offering better performance, debugging capabilities, and real-time potential. """ import sys import json import logging from pathlib import Path from typing import List, Optional, Dict, Any from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QHBoxLayout from PySide6.QtCore import QTimer import pyqtgraph as pg from pyqtgraph import QtCore, QtGui import numpy as np from ohlc_processor import OHLCProcessor class OHLCItem(pg.GraphicsObject): """Custom OHLC candlestick item for PyQtGraph.""" def __init__(self, data): """ Initialize OHLC item with data. Args: data: List of tuples (timestamp, open, high, low, close, volume) """ pg.GraphicsObject.__init__(self) self.data = data self.generatePicture() def generatePicture(self): """Generate the candlestick chart picture.""" self.picture = QtGui.QPicture() painter = QtGui.QPainter(self.picture) pen_up = pg.mkPen(color='#00ff00', width=1) # Green for up candles pen_down = pg.mkPen(color='#ff0000', width=1) # Red for down candles brush_up = pg.mkBrush(color='#00ff00') brush_down = pg.mkBrush(color='#ff0000') # Dynamic candle width based on data density if len(self.data) > 1: time_diff = (self.data[1][0] - self.data[0][0]) / 1000 # Time between candles in seconds width = time_diff * 0.8 # 80% of time interval else: width = 30 # Default width in seconds for timestamp, open_price, high, low, close, volume in self.data: x = timestamp / 1000 # Convert ms to seconds # Determine candle color is_up = close >= open_price pen = pen_up if is_up else pen_down brush = brush_up if is_up else brush_down # Draw wick (high-low line) painter.setPen(pen) painter.drawLine(QtCore.QPointF(x, low), QtCore.QPointF(x, high)) # Draw body (open-close rectangle) body_height = abs(close - open_price) body_bottom = min(open_price, close) painter.setPen(pen) painter.setBrush(brush) painter.drawRect(QtCore.QRectF(x - width/2, body_bottom, width, body_height)) painter.end() def paint(self, painter, option, widget): """Paint the candlestick chart.""" painter.drawPicture(0, 0, self.picture) def boundingRect(self): """Return the bounding rectangle of the item.""" return QtCore.QRectF(self.picture.boundingRect()) class OBIItem(pg.GraphicsObject): """Custom OBI candlestick item with blue styling.""" def __init__(self, data): """Initialize OBI item with blue color scheme.""" pg.GraphicsObject.__init__(self) self.data = data self.generatePicture() def generatePicture(self): """Generate OBI candlestick chart with blue styling.""" self.picture = QtGui.QPicture() painter = QtGui.QPainter(self.picture) # Blue color scheme for OBI pen_up = pg.mkPen(color='#4a9eff', width=1) # Light blue for up pen_down = pg.mkPen(color='#1f5f99', width=1) # Dark blue for down brush_up = pg.mkBrush(color='#4a9eff') brush_down = pg.mkBrush(color='#1f5f99') # Dynamic width calculation if len(self.data) > 1: time_diff = (self.data[1][0] - self.data[0][0]) / 1000 width = time_diff * 0.8 else: width = 30 for timestamp, open_price, high, low, close, _ in self.data: x = timestamp / 1000 # Determine color is_up = close >= open_price pen = pen_up if is_up else pen_down brush = brush_up if is_up else brush_down # Draw wick painter.setPen(pen) painter.drawLine(QtCore.QPointF(x, low), QtCore.QPointF(x, high)) # Draw body body_height = abs(close - open_price) body_bottom = min(open_price, close) painter.setPen(pen) painter.setBrush(brush) painter.drawRect(QtCore.QRectF(x - width/2, body_bottom, width, body_height)) painter.end() def paint(self, painter, option, widget): """Paint the OBI candlestick chart.""" painter.drawPicture(0, 0, self.picture) def boundingRect(self): """Return the bounding rectangle.""" return QtCore.QRectF(self.picture.boundingRect()) class MainWindow(QMainWindow): """Main application window for orderflow visualization.""" def __init__(self): super().__init__() self.ohlc_data = [] self.metrics_data = [] self.depth_data = {"bids": [], "asks": []} # Cache for depth data self.last_data_size = 0 # Track OHLC data changes self.last_metrics_size = 0 # Track metrics data changes self.last_depth_data = None # Track depth data changes self.setup_ui() def setup_ui(self): """Initialize the user interface.""" self.setWindowTitle("Orderflow Backtest Visualizer") self.setGeometry(100, 100, 1200, 800) # Central widget and main horizontal layout central_widget = QWidget() self.setCentralWidget(central_widget) main_layout = QHBoxLayout(central_widget) # Left side: Charts layout (OHLC, Volume, OBI, CVD) charts_widget = QWidget() charts_layout = QVBoxLayout(charts_widget) # Right side: Depth chart widget depth_widget = QWidget() depth_layout = QVBoxLayout(depth_widget) # Configure PyQtGraph pg.setConfigOptions(antialias=True, background='k', foreground='w') # Create multiple plot widgets for different charts self.ohlc_plot = pg.PlotWidget(title="OHLC Candlestick Chart") self.ohlc_plot.setLabel('left', 'Price', units='USD') self.ohlc_plot.showGrid(x=True, y=True, alpha=0.3) self.ohlc_plot.setMouseEnabled(x=True, y=True) self.ohlc_plot.enableAutoRange(axis='xy', enable=False) # Disable auto-range for better control self.volume_plot = pg.PlotWidget(title="Volume") self.volume_plot.setLabel('left', 'Volume') self.volume_plot.showGrid(x=True, y=True, alpha=0.3) self.volume_plot.setMouseEnabled(x=True, y=True) self.volume_plot.enableAutoRange(axis='xy', enable=False) self.obi_plot = pg.PlotWidget(title="Order Book Imbalance (OBI)") self.obi_plot.setLabel('left', 'OBI') self.obi_plot.showGrid(x=True, y=True, alpha=0.3) self.obi_plot.setMouseEnabled(x=True, y=True) self.obi_plot.enableAutoRange(axis='xy', enable=False) self.cvd_plot = pg.PlotWidget(title="Cumulative Volume Delta (CVD)") self.cvd_plot.setLabel('left', 'CVD') self.cvd_plot.setLabel('bottom', 'Time', units='s') self.cvd_plot.showGrid(x=True, y=True, alpha=0.3) self.cvd_plot.setMouseEnabled(x=True, y=True) self.cvd_plot.enableAutoRange(axis='xy', enable=False) # Create depth chart (right side) self.depth_plot = pg.PlotWidget(title="Order Book Depth") self.depth_plot.setLabel('left', 'Price', units='USD') self.depth_plot.setLabel('bottom', 'Cumulative Volume') self.depth_plot.showGrid(x=True, y=True, alpha=0.3) self.depth_plot.setMouseEnabled(x=True, y=True) # Link x-axes for synchronized zooming/panning (main charts only) self.volume_plot.setXLink(self.ohlc_plot) self.obi_plot.setXLink(self.ohlc_plot) self.cvd_plot.setXLink(self.ohlc_plot) # Add crosshairs to time-series charts self._setup_crosshairs() # Add charts to left layout charts_layout.addWidget(self.ohlc_plot, 3) # Larger space for OHLC charts_layout.addWidget(self.volume_plot, 1) charts_layout.addWidget(self.obi_plot, 1) charts_layout.addWidget(self.cvd_plot, 1) # Add depth chart to right layout depth_layout.addWidget(self.depth_plot) # Add both sides to main layout (3:1 ratio similar to original Dash) main_layout.addWidget(charts_widget, 3) main_layout.addWidget(depth_widget, 1) logging.info("UI setup completed") def _setup_crosshairs(self): """Setup crosshair functionality for time-series charts.""" # Create crosshair lines with proper pen style crosshair_pen = pg.mkPen(color='#888888', width=1, style=QtCore.Qt.DashLine) self.vline = pg.InfiniteLine(angle=90, movable=False, pen=crosshair_pen) self.hline_ohlc = pg.InfiniteLine(angle=0, movable=False, pen=crosshair_pen) self.hline_volume = pg.InfiniteLine(angle=0, movable=False, pen=crosshair_pen) self.hline_obi = pg.InfiniteLine(angle=0, movable=False, pen=crosshair_pen) self.hline_cvd = pg.InfiniteLine(angle=0, movable=False, pen=crosshair_pen) # Add crosshairs to plots self.ohlc_plot.addItem(self.vline, ignoreBounds=True) self.ohlc_plot.addItem(self.hline_ohlc, ignoreBounds=True) self.volume_plot.addItem(self.vline, ignoreBounds=True) self.volume_plot.addItem(self.hline_volume, ignoreBounds=True) self.obi_plot.addItem(self.vline, ignoreBounds=True) self.obi_plot.addItem(self.hline_obi, ignoreBounds=True) self.cvd_plot.addItem(self.vline, ignoreBounds=True) self.cvd_plot.addItem(self.hline_cvd, ignoreBounds=True) # Connect mouse move events self.ohlc_plot.scene().sigMouseMoved.connect(self._on_mouse_moved) self.volume_plot.scene().sigMouseMoved.connect(self._on_mouse_moved) self.obi_plot.scene().sigMouseMoved.connect(self._on_mouse_moved) self.cvd_plot.scene().sigMouseMoved.connect(self._on_mouse_moved) # Create data inspection label self.data_label = pg.LabelItem(justify='left') self.ohlc_plot.addItem(self.data_label) # Add rectangle selection for zoom functionality self._setup_rectangle_selection() logging.debug("Crosshairs setup completed") def _setup_rectangle_selection(self): """Setup rectangle selection for zoom functionality.""" # Enable rectangle selection on OHLC plot (main chart) self.ohlc_plot.setMenuEnabled(False) # Disable context menu for cleaner interaction # Add double-click to auto-range self.ohlc_plot.plotItem.vb.mouseDoubleClickEvent = self._on_double_click # Rectangle selection is handled by PyQtGraph's built-in ViewBox behavior # Users can drag to select area and right-click to zoom to selection logging.debug("Rectangle selection setup completed") def _on_double_click(self, event): """Handle double-click to auto-range all charts.""" try: # Auto-range all linked charts self.ohlc_plot.autoRange() self.volume_plot.autoRange() self.obi_plot.autoRange() self.cvd_plot.autoRange() logging.debug("Auto-range applied to all charts") except Exception as e: logging.debug(f"Error in double-click handler: {e}") def update_data(self, data_processor: OHLCProcessor): """Update chart data from direct processor or JSON files.""" self._get_data_from_processor(data_processor) def _load_ohlc_data(self): """Load OHLC data from JSON file.""" ohlc_file = Path("ohlc_data.json") if not ohlc_file.exists(): return try: with open(ohlc_file, 'r') as f: data = json.load(f) # Only update if data has changed if len(data) != self.last_data_size: self.ohlc_data = data self.last_data_size = len(data) logging.debug(f"Loaded {len(data)} OHLC bars") except (json.JSONDecodeError, FileNotFoundError) as e: logging.warning(f"Failed to load OHLC data: {e}") def _load_metrics_data(self): """Load metrics data (OBI, CVD) from JSON file.""" metrics_file = Path("metrics_data.json") if not metrics_file.exists(): return try: with open(metrics_file, 'r') as f: data = json.load(f) # Only update if data has changed if len(data) != self.last_metrics_size: self.metrics_data = data self.last_metrics_size = len(data) logging.debug(f"Loaded {len(data)} metrics bars") except (json.JSONDecodeError, FileNotFoundError) as e: logging.warning(f"Failed to load metrics data: {e}") def _load_depth_data(self): """Load depth data (bids, asks) from JSON file.""" depth_file = Path("depth_data.json") if not depth_file.exists(): return try: with open(depth_file, 'r') as f: data = json.load(f) # Only update if data has changed if data != self.last_depth_data: self.depth_data = data self.last_depth_data = data.copy() if isinstance(data, dict) else data logging.debug(f"Loaded depth data: {len(data.get('bids', []))} bids, {len(data.get('asks', []))} asks") except (json.JSONDecodeError, FileNotFoundError) as e: logging.warning(f"Failed to load depth data: {e}") def _update_all_plots(self): """Update all chart plots with current data.""" self._update_ohlc_plot() self._update_volume_plot() self._update_obi_plot() self._update_cvd_plot() self._update_depth_plot() def _update_ohlc_plot(self): """Update the OHLC plot with candlestick chart.""" if not self.ohlc_data: return # Clear existing plot items (but preserve crosshairs) items = [item for item in self.ohlc_plot.items() if not isinstance(item, pg.InfiniteLine)] for item in items: self.ohlc_plot.removeItem(item) # Create OHLC candlestick item ohlc_item = OHLCItem(self.ohlc_data) self.ohlc_plot.addItem(ohlc_item) logging.debug(f"Updated OHLC chart with {len(self.ohlc_data)} bars") def _update_volume_plot(self): """Update volume bar chart.""" if not self.ohlc_data: return # Clear existing plot items (but preserve crosshairs) items = [item for item in self.volume_plot.items() if not isinstance(item, pg.InfiniteLine)] for item in items: self.volume_plot.removeItem(item) # Extract volume and price change data timestamps = [bar[0] / 1000 for bar in self.ohlc_data] volumes = [bar[5] for bar in self.ohlc_data] # Create volume bars with color coding for i, (ts, vol) in enumerate(zip(timestamps, volumes)): # Determine color based on price movement if i > 0: prev_close = self.ohlc_data[i-1][4] # Previous close curr_close = self.ohlc_data[i][4] # Current close color = '#00ff00' if curr_close >= prev_close else '#ff0000' else: color = '#888888' # Neutral for first bar # Create bar bar_item = pg.BarGraphItem(x=[ts], height=[vol], width=30, brush=color) self.volume_plot.addItem(bar_item) logging.debug(f"Updated volume chart with {len(volumes)} bars") def _update_obi_plot(self): """Update OBI candlestick chart.""" if not self.metrics_data: return # Clear existing plot items (but preserve crosshairs) items = [item for item in self.obi_plot.items() if not isinstance(item, pg.InfiniteLine)] for item in items: self.obi_plot.removeItem(item) # Extract OBI data and create candlestick format obi_candlesticks = [] for bar in self.metrics_data: if len(bar) >= 5: # Ensure we have OBI data timestamp, obi_open, obi_high, obi_low, obi_close = bar[:5] obi_candlesticks.append([timestamp, obi_open, obi_high, obi_low, obi_close, 0]) if obi_candlesticks: # Create OBI candlestick item with blue styling obi_item = OBIItem(obi_candlesticks) # Will create this class self.obi_plot.addItem(obi_item) logging.debug(f"Updated OBI chart with {len(obi_candlesticks)} bars") def _update_cvd_plot(self): """Update CVD line chart.""" if not self.metrics_data: return # Clear existing plot items (but preserve crosshairs) items = [item for item in self.cvd_plot.items() if not isinstance(item, pg.InfiniteLine)] for item in items: self.cvd_plot.removeItem(item) # Extract CVD data timestamps = [] cvd_values = [] for bar in self.metrics_data: if len(bar) >= 6: # Check for CVD value timestamps.append(bar[0] / 1000) # Convert to seconds cvd_values.append(bar[5]) # CVD value elif len(bar) >= 5: # Fallback for older format timestamps.append(bar[0] / 1000) cvd_values.append(0.0) # Default CVD if timestamps and cvd_values: # Plot CVD as line chart self.cvd_plot.plot(timestamps, cvd_values, pen=pg.mkPen(color='#ffff00', width=2), name='CVD') logging.debug(f"Updated CVD chart with {len(cvd_values)} points") def _cumulate_levels(self, levels, reverse=False, limit=50): """ Convert individual price levels to cumulative volumes. Args: levels: List of [price, size] pairs reverse: If True, sort in descending order (for bids) limit: Maximum number of levels to include Returns: List of (price, cumulative_volume) tuples """ if not levels: return [] try: # Sort levels by price sorted_levels = sorted(levels[:limit], key=lambda x: x[0], reverse=reverse) # Calculate cumulative volumes cumulative = [] total_volume = 0.0 for price, size in sorted_levels: total_volume += size cumulative.append((price, total_volume)) return cumulative except Exception as e: logging.warning(f"Error in cumulate_levels: {e}") return [] def _update_depth_plot(self): """Update depth chart with cumulative bid/ask visualization.""" if not self.depth_data or not isinstance(self.depth_data, dict): return # Clear all items for depth chart (no crosshairs here) self.depth_plot.clear() bids = self.depth_data.get('bids', []) asks = self.depth_data.get('asks', []) # Calculate cumulative levels cum_bids = self._cumulate_levels(bids, reverse=True, limit=50) cum_asks = self._cumulate_levels(asks, reverse=False, limit=50) # Plot bids (green) if cum_bids: bid_volumes = [vol for _, vol in cum_bids] bid_prices = [price for price, _ in cum_bids] # Create stepped line plot for bids self.depth_plot.plot(bid_volumes, bid_prices, pen=pg.mkPen(color='#00c800', width=2), stepMode='left', name='Bids') # Add fill area fill_curve = pg.PlotCurveItem(bid_volumes + [0], bid_prices + [bid_prices[-1]], fillLevel=0, fillBrush=pg.mkBrush(color=(0, 200, 0, 50))) self.depth_plot.addItem(fill_curve) # Plot asks (red) if cum_asks: ask_volumes = [vol for _, vol in cum_asks] ask_prices = [price for price, _ in cum_asks] # Create stepped line plot for asks self.depth_plot.plot(ask_volumes, ask_prices, pen=pg.mkPen(color='#c80000', width=2), stepMode='left', name='Asks') # Add fill area fill_curve = pg.PlotCurveItem(ask_volumes + [0], ask_prices + [ask_prices[-1]], fillLevel=0, fillBrush=pg.mkBrush(color=(200, 0, 0, 50))) self.depth_plot.addItem(fill_curve) logging.debug(f"Updated depth chart: {len(cum_bids)} bid levels, {len(cum_asks)} ask levels") def _on_mouse_moved(self, pos): """Handle mouse movement for crosshair and data inspection.""" try: # Determine which plot triggered the event sender = self.sender() if not sender: return # Find the plot widget from the scene plot_widget = None for plot in [self.ohlc_plot, self.volume_plot, self.obi_plot, self.cvd_plot]: if plot.scene() == sender: plot_widget = plot break if not plot_widget: return # Convert scene coordinates to plot coordinates if plot_widget.sceneBoundingRect().contains(pos): mouse_point = plot_widget.plotItem.vb.mapSceneToView(pos) x_pos = mouse_point.x() y_pos = mouse_point.y() # Update crosshair positions self.vline.setPos(x_pos) self.hline_ohlc.setPos(y_pos if plot_widget == self.ohlc_plot else self.hline_ohlc.pos()[1]) self.hline_volume.setPos(y_pos if plot_widget == self.volume_plot else self.hline_volume.pos()[1]) self.hline_obi.setPos(y_pos if plot_widget == self.obi_plot else self.hline_obi.pos()[1]) self.hline_cvd.setPos(y_pos if plot_widget == self.cvd_plot else self.hline_cvd.pos()[1]) # Update data inspection self._update_data_inspection(x_pos, plot_widget) except Exception as e: logging.debug(f"Error in mouse move handler: {e}") def _update_data_inspection(self, x_pos, plot_widget): """Update data inspection label with values at cursor position.""" try: info_parts = [] # Find closest data point for OHLC if self.ohlc_data: closest_ohlc = self._find_closest_data_point(x_pos, self.ohlc_data) if closest_ohlc: ts, open_p, high, low, close, volume = closest_ohlc time_str = self._format_timestamp(ts) info_parts.append(f"Time: {time_str}") info_parts.append(f"OHLC: O:{open_p:.2f} H:{high:.2f} L:{low:.2f} C:{close:.2f}") info_parts.append(f"Volume: {volume:.4f}") # Find closest data point for Metrics (OBI/CVD) if self.metrics_data: closest_metrics = self._find_closest_data_point(x_pos, self.metrics_data) if closest_metrics and len(closest_metrics) >= 5: ts, obi_o, obi_h, obi_l, obi_c = closest_metrics[:5] cvd_val = closest_metrics[5] if len(closest_metrics) > 5 else 0.0 info_parts.append(f"OBI: O:{obi_o:.2f} H:{obi_h:.2f} L:{obi_l:.2f} C:{obi_c:.2f}") info_parts.append(f"CVD: {cvd_val:.2f}") # Update label if info_parts: self.data_label.setText("
".join(info_parts)) else: self.data_label.setText("No data") except Exception as e: logging.debug(f"Error updating data inspection: {e}") def _find_closest_data_point(self, x_pos, data): """Find the closest data point to the given x position.""" if not data: return None # Convert x_pos (seconds) back to milliseconds for comparison x_ms = x_pos * 1000 # Find closest timestamp closest_idx = 0 min_diff = abs(data[0][0] - x_ms) for i, bar in enumerate(data): diff = abs(bar[0] - x_ms) if diff < min_diff: min_diff = diff closest_idx = i return data[closest_idx] def _format_timestamp(self, timestamp_ms): """Format timestamp for display.""" from datetime import datetime try: dt = datetime.fromtimestamp(timestamp_ms / 1000) return dt.strftime("%H:%M:%S") except: return str(int(timestamp_ms)) def setup_data_processor(self, processor): """Setup direct data integration with OHLCProcessor.""" self.data_processor = processor # For now, use JSON mode as the direct mode implementation is incomplete self.direct_mode = False # Setup callbacks for real-time data updates self._setup_processor_callbacks() logging.info("Data processor reference set, using JSON file mode for visualization") def _setup_processor_callbacks(self): """Setup callbacks to receive data directly from processor.""" if not self.data_processor: return # Replace JSON polling with direct data access # Note: This is a simplified approach - in production, you'd want proper callbacks # from the processor when new data is available logging.debug("Processor callbacks setup completed") def _get_data_from_processor(self, data_processor: OHLCProcessor): """Get data directly from processor instead of JSON files.""" try: self.ohlc_data = data_processor.get_ohlc_data() self.metrics_data = data_processor.get_metrics_data() self.depth_data = data_processor.get_current_depth() # Get OHLC data from processor (placeholder - needs actual processor API) # processor_ohlc = self.data_processor.get_ohlc_data() # if processor_ohlc: # self.ohlc_data = processor_ohlc # Get metrics data from processor # processor_metrics = self.data_processor.get_metrics_data() # if processor_metrics: # self.metrics_data = processor_metrics # Get depth data from processor # processor_depth = self.data_processor.get_current_depth() # if processor_depth: # self.depth_data = processor_depth logging.debug("Retrieved data directly from processor") except Exception as e: logging.warning(f"Error getting data from processor: {e}") # Fallback to JSON mode self.direct_mode = False def main(): """Application entry point.""" logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") app = QApplication(sys.argv) window = MainWindow() window.show() logging.info("Desktop application started") sys.exit(app.exec()) if __name__ == "__main__": main()