699 lines
28 KiB
Python
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()
|