Search code examples
pythonpyqt5pyqtgraphpyside6pyqtchart

Best way to chart streamed data using PyQtChart or pyqtgraph with PyQt5 on Python?


I am streaming TimeSeries that I want to chart efficiently (20+ chart live on a small computer). I have tried PyQtChart and pyqtgraph on PyQt5, but with both libs, I am ending up redrawing the whole chart for each data that I receive, which doesn't feel optimal. I settled for PyQtChart because it was handling better DatetimeSeries, but happy to be proven wrong (and share the pyqtgraph code, just didn't want to make the post too big).

Bellow is my working code with PyQtChart using random datas, so that you can run it:

import sys
from random import randint
from typing import Union

from PyQt5.QtChart import (QChart, QChartView, QLineSeries, QDateTimeAxis, QValueAxis)
from PyQt5.QtCore import Qt, QDateTime, QTimer
from PyQt5.QtWidgets import QApplication
from PyQt5.QtWidgets import (QWidget, QGridLayout)


class Window(QWidget):
    def __init__(self, window_name: str = 'Ticker'):
        QWidget.__init__(self)
        # GUI
        self.setGeometry(200, 200, 600, 400)
        self.window_name: str = window_name
        self.setWindowTitle(self.window_name)
        layout = QGridLayout(self)

        # change the color of the window
        self.setStyleSheet('background-color:black')

        # Series
        self.high_dataset = QLineSeries()
        self.low_dataset = QLineSeries()
        self.mid_dataset = QLineSeries()

        self.low_of_day: Union[float, None] = 5
        self.high_of_day: Union[float, None] = 15

        # Y Axis
        self.time_axis_y = QValueAxis()
        self.time_axis_y.setLabelFormat("%.2f")
        self.time_axis_y.setTitleText("Price")
        # X Axis
        self.time_axis_x = QDateTimeAxis()
        self.time_axis_x.setFormat("hh:mm:ss")
        self.time_axis_x.setTitleText("Datetime")

        # Events
        self.qt_timer = QTimer()
        # QChart
        self.chart = QChart()
        self.chart.addSeries(self.mid_dataset)
        self.chart.addSeries(self.high_dataset)
        self.chart.addSeries(self.low_dataset)

        self.chart.setTitle("Barchart Percent Example")
        self.chart.setTheme(QChart.ChartThemeDark)

        # https://linuxtut.com/fr/35fb93c7ca35f9665d9f/

        self.chart.legend().setVisible(True)
        self.chart.legend().setAlignment(Qt.AlignBottom)

        self.chartview = QChartView(self.chart)

        # using -1 to span through all rows available in the window
        layout.addWidget(self.chartview, 2, 0, -1, 3)

        self.chartview.setChart(self.chart)

    def set_yaxis(self):
        # Y Axis Settings
        self.time_axis_y.setRange(int(self.low_of_day * .9), int(self.high_of_day * 1.1))

        self.chart.addAxis(self.time_axis_y, Qt.AlignLeft)

        self.mid_dataset.attachAxis(self.time_axis_y)
        self.high_dataset.attachAxis(self.time_axis_y)
        self.low_dataset.attachAxis(self.time_axis_y)

    def set_xaxis(self):
        # X Axis Settings
        self.chart.removeAxis(self.time_axis_x)

        self.time_axis_x = QDateTimeAxis()
        self.time_axis_x.setFormat("hh:mm:ss")
        self.time_axis_x.setTitleText("Datetime")
        self.chart.addAxis(self.time_axis_x, Qt.AlignBottom)

        self.mid_dataset.attachAxis(self.time_axis_x)
        self.high_dataset.attachAxis(self.time_axis_x)
        self.low_dataset.attachAxis(self.time_axis_x)

    def start_app(self):
        self.qt_timer.timeout.connect(self.retrieveStream, )
        time_to_wait: int = 500  # milliseconds
        self.qt_timer.start(time_to_wait)

    def retrieveStream(self):
        date_px = QDateTime()
        date_px = date_px.currentDateTime().toMSecsSinceEpoch()
        print(date_px)

        mid_px = randint(int((self.low_of_day + 2) * 100), int((self.high_of_day - 2) * 100)) / 100

        self.mid_dataset.append(date_px, mid_px)
        self.low_dataset.append(date_px, self.low_of_day)
        self.high_dataset.append(date_px, self.high_of_day)

        print(f"epoch: {date_px}, mid: {mid_px:.2f}")

        self.update()

    def update(self):
        print("updating chart")

        self.chart.removeSeries(self.mid_dataset)
        self.chart.removeSeries(self.low_dataset)
        self.chart.removeSeries(self.high_dataset)

        self.chart.addSeries(self.mid_dataset)
        self.chart.addSeries(self.high_dataset)
        self.chart.addSeries(self.low_dataset)

        self.set_yaxis()
        self.set_xaxis()


if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = Window()
    window.show()
    window.start_app()

    sys.exit(app.exec_())

Biggest worries with this code are:

  1. the 'update' method which basically re-draw every element of the chart=>I would have prefered a deque, refresh/update/refire type of solution
  2. QLineSeries do not seem to have a maxLen like deque collection, so I could end up with loads of data (ideally looking to run more than three QLineSeries)

Besides that, I would be grateful to receive any insigths about how to optimize this code. I am new to Qt/Asyncio/Threading and really keen to learn.

Best

EDIT chart now updating without redrawing everything Let me know if there is a better way, or code needing improvement as i am new to Qt.

Thanks to answer bellow (@domarm) I corrected the way I was updating chart and link bellow made me aware I needed to set a min max for axis at each refresh so that data are within scope.

Qchart update with axis


import sys
from datetime import datetime
from random import randint
from typing import Union, Optional

from PyQt5.QtChart import (QChart, QChartView, QLineSeries, QDateTimeAxis, QValueAxis)
from PyQt5.QtCore import (Qt, QDateTime, QTimer, QPointF)
from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import (QWidget, QGridLayout, QLabel, QApplication)


# https://doc.qt.io/qt-5/qtcharts-modeldata-example.html

class Window(QWidget):
    running = False

    def __init__(self, window_name: str = 'Chart',
                 chart_title: Optional[str] = None,
                 geometry_ratio: int = 2,
                 histo_tick_size: int = 200):
        QWidget.__init__(self)
        # GUI
        self.window_wideness: int = 300
        self.histo_tick_size: int = histo_tick_size
        self.setGeometry(200,
                         200,
                         int(self.window_wideness * geometry_ratio),
                         self.window_wideness
                         )
        self.window_name: str = window_name
        self.setWindowTitle(self.window_name)
        self.label_color: str = 'grey'
        self.text_color: str = 'white'
        # Layout
        layout = QGridLayout(self)

        # Gui components
        bold_font = QFont()
        bold_font.setBold(True)

        self.label_last_px = QLabel('-', self)
        self.label_last_px.setFont(bold_font)
        self.label_last_px.setStyleSheet("QLabel { color : blue; }")
        layout.addWidget(self.label_last_px)

        # change the color of the window
        self.setStyleSheet('background-color:black')
        # QChart
        self.chart = QChart()
        if chart_title:
            self.chart.setTitle(chart_title)
        # Series
        self.high_dataset = QLineSeries(self.chart)
        self.high_dataset.setName("High")

        self.low_dataset = QLineSeries(self.chart)
        self.low_dataset.setName("Low")

        self.mid_dataset = QLineSeries(self.chart)
        self.mid_dataset.setName("Mid")

        self.low_of_day: Union[float, None] = 5
        self.high_of_day: Union[float, None] = 15
        self.last_data_point: dict = {"last_date": None, "mid_px": None, "low_px": None, "high_px": None}

        # Y Axis
        self.time_axis_y = QValueAxis()
        self.time_axis_y.setLabelFormat("%.2f")
        self.time_axis_y.setTitleText("Price")

        # X Axis
        self.time_axis_x = QDateTimeAxis()
        self.time_axis_x.setTitleText("Datetime")

        # Events
        self.qt_timer = QTimer()

        self.chart.setTheme(QChart.ChartThemeDark)
        self.chart.addSeries(self.mid_dataset)
        self.chart.addSeries(self.low_dataset)
        self.chart.addSeries(self.high_dataset)
        # https://linuxtut.com/fr/35fb93c7ca35f9665d9f/

        self.chart.legend().setVisible(True)
        # self.chart.legend().setAlignment(Qt.AlignBottom)

        self.chartview = QChartView(self.chart)
        # self.chartview.chart().setAxisX(self.axisX, self.mid_dataset)

        # using -1 to span through all rows available in the window
        layout.addWidget(self.chartview, 2, 0, -1, 3)

        self.chartview.setChart(self.chart)

    def set_yaxis(self):
        # Y Axis Settings
        self.time_axis_y.setRange(int(self.low_of_day * .9), int(self.high_of_day * 1.1))

        self.chart.addAxis(self.time_axis_y, Qt.AlignLeft)

        self.mid_dataset.attachAxis(self.time_axis_y)
        self.high_dataset.attachAxis(self.time_axis_y)
        self.low_dataset.attachAxis(self.time_axis_y)

    def set_xaxis(self):
        # X Axis Settings
        self.chart.removeAxis(self.time_axis_x)
        # X Axis
        self.time_axis_x = QDateTimeAxis()
        self.time_axis_x.setFormat("hh:mm:ss")
        self.time_axis_x.setTitleText("Datetime")

        point_first: QPointF = self.mid_dataset.at(0)
        point_last: QPointF = self.mid_dataset.at(len(self.mid_dataset) - 1)

        # needs to be updated each time for chart to render
        # https://stackoverflow.com/questions/57079698/qdatetimeaxis-series-are-not-displayed
        self.time_axis_x.setMin(QDateTime().fromMSecsSinceEpoch(point_first.x()).addSecs(0))
        self.time_axis_x.setMax(QDateTime().fromMSecsSinceEpoch(point_last.x()).addSecs(0))

        self.chart.addAxis(self.time_axis_x, Qt.AlignBottom)

        self.mid_dataset.attachAxis(self.time_axis_x)
        self.high_dataset.attachAxis(self.time_axis_x)
        self.low_dataset.attachAxis(self.time_axis_x)

    def _update_label_last_px(self):
        last_point: QPointF = self.mid_dataset.at(self.mid_dataset.count() - 1)
        last_date: datetime = datetime.fromtimestamp(last_point.x() / 1000)
        last_price = last_point.y()
        self.label_last_px.setText(f"Date time: {last_date.strftime('%d-%m-%y %H:%M %S')}  "
                                   f"Price: {last_price:.2f}")

    def start_app(self):
        """Start Thread generator"""
        # This method is supposed to stream data but not the issue, problem is that chart is not updating
        self.qt_timer.timeout.connect(self.update, )
        time_to_wait: int = 250  # milliseconds
        self.qt_timer.start(time_to_wait)

    def update(self):
        """ Update chart and Label with the latest data in Series"""
        print("updating chart")
        self._update_label_last_px()
        # date_px = QDateTime()
        # self.last_data_point['last_date'] = date_px.currentDateTime().toMSecsSinceEpoch()

        date_px = datetime.now().timestamp() * 1000
        self.last_data_point['last_date'] = date_px
        # Make up a price
        self.last_data_point['mid_px'] = randint(int((self.low_of_day + 2) * 100),
                                                 int((self.high_of_day - 2) * 100)) / 100
        self.last_data_point['low_date'] = self.low_of_day
        self.last_data_point['high_date'] = self.high_of_day
        print(self.last_data_point)

        # Feed datasets and simulate deque
        # https://www.qtcentre.org/threads/67774-Dynamically-updating-QChart
        if self.mid_dataset.count() > self.histo_tick_size:
            self.mid_dataset.remove(0)
            self.low_dataset.remove(0)
            self.high_dataset.remove(0)

        self.mid_dataset.append(self.last_data_point['last_date'], self.last_data_point['mid_px'])
        self.low_dataset.append(self.last_data_point['last_date'], self.last_data_point['low_date'])
        self.high_dataset.append(self.last_data_point['last_date'], self.last_data_point['high_date'])
        self.set_xaxis()
        self.set_yaxis()


if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = Window()
    window.show()
    window.start_app()

    sys.exit(app.exec())

Solution

  • You can use pglive package to plot Your data from live stream. It's based on pyqtgraph and it can easily handle data rates of ~100Hz.
    It's using DataConnector, which stores data indeque and uses pyqt signal to update plot thread-safe. You can also set update rate in Hz, if Your input data is updated in a high rate.

    There are also some extra features available like leading line or crosshair, which makes it easy to show exact values under the mouse cursor.

    Here is an example code, based on Your input:

    import sys
    import time
    from random import randint
    from threading import Thread
    from time import sleep
    from typing import Union
    
    from PyQt5.QtWidgets import QWidget, QApplication, QGridLayout
    from pglive.kwargs import Axis
    from pglive.sources.data_connector import DataConnector
    from pglive.sources.live_axis import LiveAxis
    from pglive.sources.live_plot import LiveLinePlot
    from pglive.sources.live_plot_widget import LivePlotWidget
    
    
    class Window(QWidget):
        running = False
    
        def __init__(self, parent=None):
            super().__init__(parent)
            layout = QGridLayout(self)
            self.low_of_day: Union[float, None] = 5
            self.high_of_day: Union[float, None] = 15
    
            # Create one curve pre dataset
            high_plot = LiveLinePlot(pen="blue")
            low_plot = LiveLinePlot(pen="orange")
            mid_plot = LiveLinePlot(pen="green")
    
            # Data connectors for each plot with dequeue of 600 points
            self.high_connector = DataConnector(high_plot, max_points=600)
            self.low_connector = DataConnector(low_plot, max_points=600)
            self.mid_connector = DataConnector(mid_plot, max_points=600)
    
            # Setup bottom axis with TIME tick format
            # You can use Axis.DATETIME to show date as well
            bottom_axis = LiveAxis("bottom", **{Axis.TICK_FORMAT: Axis.TIME})
    
            # Create plot itself
            self.chart_view = LivePlotWidget(title="Line Plot - Time series @ 2Hz", axisItems={'bottom': bottom_axis})
            # Show grid
            self.chart_view.showGrid(x=True, y=True, alpha=0.3)
            # Set labels
            self.chart_view.setLabel('bottom', 'Datetime', units="s")
            self.chart_view.setLabel('left', 'Price')
            # Add all three curves
            self.chart_view.addItem(mid_plot)
            self.chart_view.addItem(low_plot)
            self.chart_view.addItem(high_plot)
    
            # using -1 to span through all rows available in the window
            layout.addWidget(self.chart_view, 2, 0, -1, 3)
    
        def update(self):
            """Generate data at 2Hz"""
            while self.running:
                timestamp = time.time()
    
                mid_px = randint(int((self.low_of_day + 2) * 100), int((self.high_of_day - 2) * 100)) / 100
    
                self.mid_connector.cb_append_data_point(mid_px, timestamp)
                self.low_connector.cb_append_data_point(self.low_of_day, timestamp)
                self.high_connector.cb_append_data_point(self.high_of_day, timestamp)
    
                print(f"epoch: {timestamp}, mid: {mid_px:.2f}")
                sleep(0.5)
    
        def start_app(self):
            """Start Thread generator"""
            self.running = True
            Thread(target=self.update).start()
    
    
    if __name__ == '__main__':
        app = QApplication(sys.argv)
        window = Window()
        window.show()
        window.start_app()
        app.exec()
        window.running = False
    

    Here is how it looks in motion: enter image description here

    Small disadvantage of pyqtgraph is a bit awkward customization of how the plot looks. But it's because pqytgraph is build for speed. pglive addresses also lack of time and datetime formatting for You.

    There are definitely other good packages handling this, but if Your aim is good performance, this might be a good choice.