orderflow_backtest/desktop_app.py

699 lines
28 KiB
Python

"""
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("<br>".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()