☰
PyQt5 + pyqtgraph 实现高性能自定义K线图行情终端
2026/10/5 6:19:19 网站建设 项目流程

1. 为什么我最后选了 pyqtgraph

1.1 三款主流绘图方案对比

K线图这活儿,搁三五年前我肯定直接上 matplotlib,毕竟那时候大家都这么干,资料也多,出图也像模像样。但 matplotlib 的短板太明显了:渲染速度慢,交互反馈迟钝,鼠标拖拽缩放K线图的时候能明显感觉到掉帧,数据量一旦上万根K线,整个界面就跟冻住似的。做量化交易终端的人应该都有这个体会,回测结果展示和历史数据浏览,最烦的就是图表卡顿。

中途我也考虑过 pyecharts,它在网页端确实漂亮,交互也丰富,但问题是它本质上是跑在浏览器里的技术栈,数据得通过 JSON 或者回调通道来回传输。本地桌面应用里用它,等于平白多了一层进程通信开销,而且Python进程和浏览器进程之间的数据同步一旦处理不好,很容易出现图表和业务状态不一致的问题。对实时性要求高的行情展示来说,这条路天然有缺陷。

最后我老老实实回到 pyqtgraph。这玩意儿的核心卖点在于它是基于 Qt 的 GraphicsView 框架深度定制的绘图库,绘制单元是 Scene/Item,渲染管线针对大数据量场景做了大量优化。配合 OpenGL 加速,十万根K线级别的数据量,缩放、平移、十字光标跟手程度几乎是实时的。更关键的是,pyqtgraph 本身就是为 PyQt5 设计的,两者是原生级配合,完全不需要额外的适配层。如果要做的是本地桌面行情终端,这就是当下最务实的选择。

1.2 pyqtgraph 的绘图核心机制

pyqtgraph 的绘图思路和 matplotlib 完全不同。matplotlib 是"画好了给你看",pyqtgraph 是"按需重绘,并且只重绘视口内可见的部分"。它底层有 GraphicsView 和 Scene 这套 Qt 原生的图形架构支撑,坐标变换、裁剪、碰撞检测都走的是 Qt 的 C++ 实现,Python 层只负责描述图形对象。

自定义K线图的关键在于继承GraphicsObject这个类。官方的 example 里其实已经给了 CandlestickItem 的参考实现,思路是先通过generatePicture()方法把事先计算好的图形元素绘制进 QPicture,然后通过QPainter在paint()方法里将 QPicture 加载到视图上。这样做的妙处在于:K线图每次重绘时不需要重新逐根计算和绘制矩形、线条,只需要把预先渲染好的图片重放一遍,性能开销极小。

理解这个机制之后,自定义一个K线图其实就三步:第一,定义一个数据结构保存OHLCV数据;第二,把数据换算成像素级别的坐标映射关系;第三,用 QPainter 原语把蜡烛画出来。剩下的放大缩小、平移,pyqtgraph 的 GraphicsView 全部帮你处理好了,你只需要实现boundingRect()方法告诉框架你的绘图区域有多大。

注意:boundingRect()如果你偷懒不写或者写小了,pyqtgraph 会在视图缩放的时候直接把你图形裁掉一半,这个坑我踩过,后来老老实实的每次都计算完整范围。

2. 先把环境装明白,这几个坑一次说清

2.1 标准安装方式与国内镜像源

先把最基础的安装流程过一遍。PyQt5 和 pyqtgraph 都是纯 pip 包,直接装就行:

pip install PyQt5 pyqtgraph

但国内网络环境下,PyPI 官方源经常慢到怀疑人生,甚至出现超时中断。我自己平时习惯用清华镜像源,速度稳定得多:

pip install PyQt5 pyqtgraph -i https://pypi.tuna.tsinghua.edu.cn/simple

如果涉及数据处理、获取股票数据,通常还需要安装 pandas、akshare:

pip install pandas akshare -i https://pypi.tuna.tsinghua.edu.cn/simple

另外说一句,如果你用的是 Python 3.10 以上的版本,pip 安装 pyqtgraph 时它会自动帮你把 PyQt5 的依赖解析好,一般不会出现版本冲突。真要遇到依赖解析失败,可以单独指定版本安装:

pip install PyQt5==5.15.10 pyqtgraph==0.13.3

2.2 OpenGL 导致界面无显示,怎么排查

这是 pyqtgraph 用户碰到最多的灵异事件之一:程序跑了,进程在,就是窗口白屏或者直接报错退出。十有八九是 OpenGL 的问题。

pyqtgraph 默认会尝试启用 OpenGL 加速来提高渲染性能,但 OpenGL 这玩意儿在 Windows 虚拟桌面、远程桌面、显卡驱动不完整的机器上非常容易翻车。特别是很多开发机用的是远程桌面连接,微软的远程桌面默认不支持完整的 OpenGL API,导致 pyqtgraph 在初始化视图时崩溃。

解决方式很简单,在创建任何 pyqtgraph 绘图控件之前,先强制关闭 OpenGL:

import pyqtgraph as pg # 必须在创建PlotWidget之前设置,放在最顶部 pg.setConfigOptions(useOpenGL=False)

如果关掉之后还白屏,再检查一下是否设置了高DPI缩放属性。Windows 下高分屏容易触发 Qt 的缩放适配问题:

import os import ctypes # Windows 下设置 DPI 感知,避免界面模糊或显示异常 if os.name == 'nt': ctypes.windll.shcore.SetProcessDpiAwareness(1)

还有一个比较玄学的情况:某些精简版显卡驱动或者双显卡笔记本,Qt 默认用的显卡驱动路径出问题。可以在环境变量里指定使用软件渲染:

os.environ["QT_OPENGL"] = "software"

经验:凡是远程桌面、虚拟机、云主机上跑 pyqtgraph,直接在项目入口处加pg.setConfigOptions(useOpenGL=False),能省掉一晚上的排查时间。

2.3 用 uv 快速安装与虚拟环境隔离

最近 uv 这个工具很火,确实好用,安装速度比 pip 快一大截。它的用法也很简洁:

# 安装 uv pip install uv # 创建虚拟环境 uv venv kline_env # 激活(Windows) kline_env\Scripts\activate # 安装依赖 uv pip install PyQt5 pyqtgraph pandas akshare

uv 最大的优势是并行下载和高效的依赖解析,对于 PyQt5 这种动辄几十MB的包,体验提升极其明显。另外建议在项目里固定 requirements.txt,避免某天升级了某个库版本导致界面异常,这种坑在开源生态里太常见了。

3. 开始动手:K线图的核心数据结构与绘制逻辑

3.1 K线数据模型:OHLCV

K线图的每一根蜡烛,本质上就是四个价格加一个成交量:开盘价(Open)、最高价(High)、最低价(Low)、收盘价(Close)和成交量(Volume),缩写叫 OHLCV。这五个数据是一切的基石。

在内存里,一般用 pandas DataFrame 来存,结构大致长这样:

dateopenhighlowclosevolume
2024-01-0210.510.810.210.6152300
2024-01-0310.611.010.410.9187600

绘制K线图时,x轴通常是时间序列,但 pyqtgraph 的 PlotItem 并不直接支持日期类型作为坐标轴。所以实际的做法是把日期转换成数值:要么用时间戳(毫秒级),要么直接用序号索引。用时间戳的好处是坐标定位准确,放大后能精确到某一天;用序号的好处是实现简单,但放大后x轴刻度要自己换算回日期,很麻烦。

我自己的方案是用时间戳,因为后续加数据、平移、定位到某一天的操作都方便很多。用 pandas 转换很简单:

import pandas as pd df['date_ts'] = df['date'].astype('int64') // 10**9 # 转成秒级时间戳

3.2 自定义 CandlestickItem 的思路

pyqtgraph 官方虽然没把K线图封装成一个现成控件,但好在自定义的路径是敞开的。核心思路前面已经说过了:继承pg.GraphicsObject,实现paint()和boundingRect()两个方法。官方示例里有一个 CandlestickItem 类,但说实话那个版本功能比较简单,只支持显示蜡烛本体,没有成交量,也没有均线叠加。

我做了扩展,主要增加四个能力:

  • 支持传入完整的 OHLCV 数组,内部统一转换坐标
  • 支持设置每个蜡烛的宽度(以像素为单位换算)
  • 支持绘制过程中分离上涨和下跌的颜色(A股习惯红涨绿跌)
  • 支持在K线图上叠加均线(MA5、MA10、MA20等)

绘制蜡烛本体的逻辑分两部分:实体部分用矩形绘制,影线部分用线段绘制。上涨时实体为红色,用实心矩形;下跌时实体为绿色,用实心矩形或者空心矩形都可以,看个人喜好。影线就是最高价到最低价之间的一条竖线。

3.3 核心代码:用 QPicture 批量绘制

直接上可运行的代码,这段是我在实际项目中打磨过的版本:

import pyqtgraph as pg from pyqtgraph.Qt import QtCore, QtGui import numpy as np class CandlestickItem(pg.GraphicsObject): """自定义K线图绘制项,支持OHLCV数据和均线叠加""" def __init__(self, data=None, candle_width=0.6): super().__init__() self.data = data # 期望格式: [(time, open, high, low, close), ...] self.candle_width = candle_width self.picture = QtGui.QPicture() self._bounds = QtCore.QRectF() self._prepare = False if data is not None: self.generatePicture() def set_data(self, data): self.data = data self._prepare = False self.generatePicture() self.informViewBoundsChanged() self.update() def generatePicture(self): """预生成图片,这里是性能优化的关键""" if self.data is None or len(self.data) == 0: return self.picture = QtGui.QPicture() painter = QtGui.QPainter(self.picture) painter.setRenderHint(QtGui.QPainter.Antialiasing, False) # 计算价格范围,用于坐标映射 lows = [d[3] for d in self.data] highs = [d[2] for d in self.data] min_price = min(lows) max_price = max(highs) # 每个蜡烛的像素间距 # 这里用时间戳x坐标,间距取相邻时间差的平均值,避免缩放时蜡烛重叠 xs = [d[0] for d in self.data] if len(self.data) > 1: spacing = (xs[-1] - xs[0]) / (len(self.data) - 1) else: spacing = 1.0 w = spacing * self.candle_width for x, open_, high, low, close in self.data: # 上涨: 收盘 >= 开盘, 红色; 下跌: 绿色 if close >= open_: painter.setPen(pg.mkPen('#ef232a')) painter.setBrush(pg.mkBrush('#ef232a')) else: painter.setPen(pg.mkPen('#14b143')) painter.setBrush(pg.mkBrush('#14b143')) # 画影线 painter.drawLine(QtCore.QPointF(x, low), QtCore.QPointF(x, high)) # 画实体 if close >= open_: rect = QtCore.QRectF(x - w / 2, open_, w, close - open_) else: rect = QtCore.QRectF(x - w / 2, close, w, open_ - close) # 实体太小(如一字涨停板)时,至少画一条横线确保可见 if rect.height() < 1e-6: painter.drawLine(QtCore.QPointF(x - w / 2, close), QtCore.QPointF(x + w / 2, close)) else: painter.drawRect(rect) painter.end() # 计算边界 self._bounds = QtCore.QRectF( xs[0] - w, min_price, xs[-1] - xs[0] + 2 * w, max_price - min_price ) def paint(self, painter, *args): """重绘时只需把预生成的图片重放出来,性能极高""" painter.drawPicture(0, 0, self.picture) def boundingRect(self): return QtCore.QRectF(self._bounds)

这段代码的细节解释一下:generatePicture()在数据更新时调用一次,把所有蜡烛一次性绘制进 QPicture。QPicture 是一个记录绘图指令的"画布",它不像像素图那样占大内存,而是保存了画笔操作序列。paint()里直接重放这些指令,这样即使底层的 GraphicsView 频繁触发重绘,每次的代价也只是一个drawPicture调用,几乎可以忽略不计。

有一个细节容易翻车:实体部分如果开盘价等于收盘价(比如涨停一字板),rect.height()会是0,Qt 渲染的时候这个矩形就不会被绘制。所以我加了一个判空逻辑,高度为0时画一条横线,保证价格能直观显示。

注意:这里的坐标映射我直接用了"价格数值作为y坐标",因为 pyqtgraph 的 PlotItem 默认会根据字符串的边界和 ViewBox 的变换自动完成坐标缩放。不需要在绘制前手动做归一化。这也是 pyqtgraph 的一个优势,省了不少事。

4. 从单图到完整看盘界面

4.1 主图+副图的布局方式

真实的行情软件里,K线图从来不是孤零零的,主图下方通常还有成交量副图。pyqtgraph 里实现这个有两种思路:一种是在同一个 PlotWidget 里把两个 Item 放在不同的 Y 轴区域,通过设置setGeometry或者使用多个 ViewBox 来实现;另一种更简单粗暴,直接用 Qt 的布局管理器,放两个独立 PlotWidget 上下排布,x轴联动就行。

我推荐第二种。因为用两个独立的 PlotWidget,每个图的功能可以独立维护(比如副图可以切换成交量、MACD、RSI等指标),同时通过 signal 同步两个轴的显示范围,实现联动缩放。

布局代码大概长这样:

from PyQt5.QtWidgets import QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSpinBox import pyqtgraph as pg class KLineWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("PyQtGraph K线图") self.resize(1200, 800) central = QWidget() self.setCentralWidget(central) layout = QVBoxLayout(central) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) # 主图 - K线 self.main_plot = pg.PlotWidget() self.main_plot.setBackground('#111111') self.main_plot.showGrid(x=True, y=True, alpha=0.3) # 副图 - 成交量 self.volume_plot = pg.PlotWidget() self.volume_plot.setBackground('#111111') self.volume_plot.showGrid(x=True, y=True, alpha=0.3) layout.addWidget(self.main_plot, stretch=3) # 主图占3份 layout.addWidget(self.volume_plot, stretch=1) # 副图占1份 # x轴联动 self.main_plot.setXLink(self.volume_plot) # 隐藏副图的x轴标签,只保留主图的 self.volume_plot.getAxis('bottom').setStyle(showValues=False)

这样做的好处是,主图和副图之间的垂直比例是固定的3:1,类似常见的行情终端。setXLink会把两个 PlotItem 的 x 轴绑定,拖动任何一个图的水平范围,另一个跟着动,非常顺手。

4.2 自定义日期坐标轴

pyqtgraph 默认的 AxisItem 显示的是数值,股票K线上我们需要显示日期。这里要用 pyqtgraph 的AxisItem自定义坐标轴,重写tickStrings()方法把毫秒时间戳转换成YYYY-MM-DD格式。

class DateAxis(pg.AxisItem): """自定义x轴,显示日期字符串""" def tickStrings(self, values, scale, spacing): strings = [] for v in values: try: # 时间戳转为日期格式 dt = QtCore.QDateTime.fromSecsSinceEpoch(int(v)) # 根据跨度决定显示格式 if spacing >= 86400 * 30: strings.append(dt.toString("yyyy-MM")) elif spacing >= 86400: strings.append(dt.toString("MM-dd")) else: strings.append(dt.toString("HH:mm")) except Exception: strings.append(str(int(v))) return strings

这里面的 spacing 参数很关键。当视图放大到只显示几天的数据时,x轴刻度间隔是小时级别,显示"MM-dd"就会显得太稀疏;缩小到显示几个月时,显示"yyyy-MM"更合适。根据间距动态调整日期格式,是让图表专业感提升的关键细节。

注册这个坐标轴的方式是创建 PlotWidget 时指定 axisItems:

axis = DateAxis(orientation='bottom') pw = pg.PlotWidget(axisItems={'bottom': axis}) # 或者 self.main_plot.getAxis('bottom').setTickSpacing(level0=86400, level1=86400*5) self.main_plot.getAxis('bottom').setStyle(tickFont=QFont("Microsoft YaHei", 8))

如果你不想走自定义AxisItem这条路,还有一个偷懒的办法:在PlotWidget上把原始x轴setVisible(False),自己在布局下面叠加一个 QLabel 显示当前的时间范围。但这种方法只适合静态展示,交互时坐标提示不会跟随,不够专业,所以我还是推荐第一种。

4.3 十字光标与悬浮提示

十字光标是行情软件的灵魂功能,鼠标移动时跟随显示当前位置的时间、开高低收价格。pyqtgraph 里实现这个就非常方便了,核心是用SignalProxy监听scene().sigMouseMoved信号。

import pyqtgraph as pg from pyqtgraph.Qt import QtCore, QtGui class Crosshair: def __init__(self, plot_widget, kline_item, label_widget): self.plot_widget = plot_widget self.kline_item = kline_item self.label_widget = label_widget # 两条虚线 self.v_line = pg.InfiniteLine(angle=90, movable=False, pen=pg.mkPen('#ffffff', width=1, style=QtCore.Qt.DashLine)) self.h_line = pg.InfiniteLine(angle=0, movable=False, pen=pg.mkPen('#ffffff', width=1, style=QtCore.Qt.DashLine)) self.plot_widget.addItem(self.v_line, ignoreBounds=True) self.plot_widget.addItem(self.h_line, ignoreBounds=True) # 使用SignalProxy降低信号频率,避免UI卡顿 self.proxy = pg.SignalProxy( self.plot_widget.scene().sigMouseMoved, rateLimit=60, slot=self.on_mouse_moved ) def on_mouse_moved(self, event): pos = event[0] if self.plot_widget.sceneBoundingRect().contains(pos): mouse_point = self.plot_widget.plotItem.vb.mapSceneToView(pos) x = mouse_point.x() y = mouse_point.y() self.v_line.setPos(x) self.h_line.setPos(y) # 从K线数据中查找最近的时间点对应的OHLC info = self.kline_item.get_info_by_x(x) if info: time_str, open_, high, low, close, volume = info text = ( f"Time: {time_str}\n" f"Open: {open_:.2f}\n" f"High: {high_:.2f}\n" f"Low: {low_:.2f}\n" f"Close: {close_:.2f}\n" f"Vol: {volume:.0f}" ) self.label_widget.setText(text)

这里有个性能细节:sigMouseMoved信号在鼠标移动时触发频率极高,如果每次触发都去遍历整个数据集查找最近的K线,大数据量下会卡。所以我加了rateLimit=60,限制每秒最多处理60次,并且get_info_by_x()内部用二分查找:

def get_info_by_x(self, x): if self.data is None: return None xs = [d[0] for d in self.data] import bisect idx = bisect.bisect_left(xs, x) if idx >= len(self.data): idx = len(self.data) - 1 if idx < 0: return None d = self.data[idx] return d

4.4 让K线滚动起来:自动滚动与缩放

实时行情终端最核心的交互是:最新一根K线始终保持在窗口右侧,同时跟随行情自动推移。pyqtgraph 里实现这个有两种思路:

思路A:每次数据刷新后,直接把 ViewBox 的 x 范围设置到最新位置:

def scroll_to_latest(self): if len(self.data) == 0: return latest_x = self.data[-1][0] # 设置显示最近120根K线 x_range_min = latest_x - 120 * 86400 x_range_max = latest_x + 5 * 86400 # 留出右侧空隙 self.main_plot.setXRange(x_range_min, x_range_max, padding=0)

思路B:设置 AutoRange 的 Padding,让 ViewBox 自动跟随数据范围。但这种方式不适合K线图,因为成交量远大于价格波动,自动范围会被带偏。

实际使用中我建议思路A,并且把显示多少根K线做成一个可配置参数。比如界面上放一个 QSpinBox 让用户选择"显示最近50/100/250根K线"。行情刷新时调用一次scroll_to_latest(),就能实现类似东方财富的跟盘效果。

缩放方面,pyqtgraph 默认支持的鼠标滚轮缩放是等比缩放,对K线图来说基本够用。但有些用户习惯按住鼠标右键框选缩放,这个可以通过 ViewBox 的setMouseMode配置:

self.main_plot.plotItem.vb.setMouseMode(pg.ViewBox.RectMode)

RectMode模式下,按住 Ctrl+左键拖拽可以框选局部区域放大,这个模式做技术面分析时特别好用。

5. 数据从哪来:接入真实A股行情

5.1 用 akshare 获取股票日K数据

没有真实数据,图表做得再漂亮也是花架子。国内获取A股行情数据,akshare 是最方便的开源方案,不需要 token,直接 pip 安装就能用。

import akshare as ak def fetch_stock_daily(symbol="600519", start_date="20230101", end_date="20241231"): """ 获取A股日K线数据 symbol: 股票代码,如600519是贵州茅台 注意:akshare的接口返回的列名是中文 """ df = ak.stock_zh_a_hist( symbol=symbol, period="daily", start_date=start_date, end_date=end_date, adjust="qfq" # 前复权,去除分红送股造成的价格跳空 ) # akshare返回的字段是:日期, 开盘, 收盘, 最高, 最低, 成交量, 成交额, ... df = df.rename(columns={ "日期": "date", "开盘": "open", "最高": "high", "最低": "low", "收盘": "close", "成交量": "volume", }) df['date'] = pd.to_datetime(df['date']) df['date_ts'] = df['date'].astype('int64') // 10**9 # 秒级时间戳 # 转成CandlestickItem需要的格式 data = list(zip(df['date_ts'], df['open'], df['high'], df['low'], df['close'])) return df, data

这里有个关键参数adjust="qfq",也就是前复权。A股股票经常除权除息(比如10送10),如果不复权,K线图上会出现价格凭空跳空的缺口,技术指标全部失真。前复权是把历史价格按最新股价做调整,让K线连续,这是做技术分析必须的。

5.2 数据预处理与均线计算

拿到DataFrame之后,均线、成交量均线、MACD等指标的计算可以直接用 pandas 完成:

# 计算均线 df['ma5'] = df['close'].rolling(window=5).mean() df['ma10'] = df['close'].rolling(window=10).mean() df['ma20'] = df['close'].rolling(window=20).mean() # 计算成交量均线 df['vol_ma5'] = df['volume'].rolling(window=5).mean()

然后用 pyqtgraph 的 PlotDataItem 把均线叠加到主图上:

# 主图叠加均线 self.ma5_line = self.main_plot.plot(df['date_ts'], df['ma5'], pen=pg.mkPen('#f9d900', width=1)) self.ma10_line = self.main_plot.plot(df['date_ts'], df['ma10'], pen=pg.mkPen('#ff8c00', width=1)) self.ma20_line = self.main_plot.plot(df['date_ts'], df['ma20'], pen=pg.mkPen('#ff4da6', width=1))

注意均线数据里有 NaN 值,pandas 的 rolling 计算前几行是 NaN,pyqtgraph 遇到 NaN 会自动断开线段,效果正好是均线从有效点开始画,不需要额外处理。

5.3 定时刷新与跨线程更新UI的坑

行情终端不能只显示静态历史数据,还得定期刷新。最简单的做法是用 QTimer 定时拉取最新数据:

from PyQt5.QtCore import QTimer self.timer = QTimer() self.timer.timeout.connect(self.update_quotes) self.timer.start(5000) # 每5秒刷新一次 def update_quotes(self): # 注意:akshare接口调用是同步阻塞的,这里不建议直接在主线程里跑 # 最好放到子线程里 pass

重点说下跨线程更新的坑:如果你用 QThread 去拉数据,拉完之后直接调用set_data()更新图表控件,极大概率会触发QObject::setParent: Cannot set parent, new parent is in a different thread之类的崩溃,或者界面假死。原因是 Qt 的 GUI 控件不能在非主线程直接操作。

正解有两个:

方案一:用信号槽,在子线程里把数据通过 signal 抛给主线程,在主线程的槽函数里更新UI:

from PyQt5.QtCore import QThread, pyqtSignal class DataFetcher(QThread): new_data_ready = pyqtSignal(object) def __init__(self): super().__init__() self.running = True def run(self): while self.running: df, data = fetch_stock_daily() self.new_data_ready.emit(data) self.msleep(5000) # 在主线程中连接 self.fetcher = DataFetcher() self.fetcher.new_data_ready.connect(self.on_data_ready) self.fetcher.start() def on_data_ready(self, data): self.kline_item.set_data(data)

方案二:用 QTimer + 全局变量,子线程写变量,主线程定时读:

# 子线程负责更新 self.latest_kline_data # 主线程的QTimer每500ms检查一次变量是否变化,有变化就更新图表

两种方案我都用过,方案一更规范,代码结构更清晰,推荐优先使用。

6. 性能优化与高频踩坑实录

6.1 数据量增大时如何保持流畅

如果你把回测的十年历史数据一次性全画上去,大约2500根日K,pyqtgraph 完全可以应付。但A股一分钟级别的K线数据,一年就有约12万根,十年就是120万根,这个量级下再不优化就会出现卡顿。

我的优化优先级是这样的:

第一,开启 OpenGL。前提是你的显卡驱动正常,本地物理机没问题,远程桌面下不建议开。开启方式:

pg.setConfigOptions(useOpenGL=True)

第二,减少预生成的QPicture重绘频率。QPicture 虽然有重放优势,但图片指令本身也有开销。如果数据量太大,可以改成 "LOD策略":当视图缩放级别只显示最近500根K线时,只从全量数据中切片出可视区域的数据来生成Picture,而不是每次把100万根全部画出来。

def update_visible_data(self, view_range): x_min, x_max = view_range mask = (self.df['date_ts'] >= x_min) & (self.df['date_ts'] <= x_max) vis_df = self.df[mask] # 如果可视区域数据太多,抽样显示 if len(vis_df) > 5000: vis_df = vis_df.iloc[::2, :] self.kline_item.set_data(list(zip(vis_df['date_ts'], vis_df['open'], vis_df['high'], vis_df['low'], vis_df['close'])))

这个方法的核心思路是"只渲染看得见的",本质和地图引擎的 LOD(Level of Detail)一样。缺点是视图平移时要不断重建Picture,所以要把范围变化信号和重建逻辑绑定好,用 SignalProxy 限频,避免频繁触发。

第三,profile 定位性能瓶颈。pyqtgraph 内置了 profiling 工具,可以用pyqtgraph.debug()开启 debug,或者直接给生成Picture的耗时打日志,看瓶颈在哪。

6.2 pyqtgraph 显示中文乱码

pyqtgraph 默认字体是不支持中文的。直接在图中加中文文字标签,画面上会出现一堆方框。设置全局字体就好:

from PyQt5.QtGui import QFont import pyqtgraph as pg font = QFont("Microsoft YaHei", 9) pg.setConfigOption('foreground', '#d0d0d0') # 为每个轴设置字体 for axis_name in ['left', 'bottom', 'right', 'top']: pg.graphicsItems.AxisItem.AxisItem.setFont(font)

更彻底的做法是在创建 PlotWidget 后对所有轴统一设置字体:

for item in [self.main_plot, self.volume_plot]: for axis_name in ['left', 'bottom', 'right', 'top']: item.getAxis(axis_name).setStyle(tickFont=QFont("Microsoft YaHei", 8)) item.getAxis(axis_name).setPen(pg.mkPen('#888888'))

注意:setStyle 里的 tickFont 对坐标轴刻度生效,而当你使用 TextItem 添加自定义文本时,要单独设置 TextItem 的字体。

6.3 常见问题速查表

问题现象可能原因排查/解决
程序启动后白屏、无窗口内容OpenGL渲染问题设置pg.setConfigOptions(useOpenGL=False),或环境变量QT_OPENGL=software
K线蜡烛显示成一条直线数据x坐标未转换成时间戳,所有值都相同检查date_ts列是否成功转换,打印前几行确认
缩放平移卡顿数据量过大、每次重绘全量数据启用LOD策略,只渲染可视区域数据;开启OpenGL
均线没有显示rolling计算产生NaN,且没排除直接plot即可,pyqtgraph自动跳过NaN;如果是空列表检查列名
日期坐标轴显示乱码系统字体问题设置tickFont=QFont("Microsoft YaHei", 8)
副图成交量不在底部y轴范围没有单独设置给副图单独调用setYRange或者使用setLabel
窗口关闭后进程不退出定时器未停止在closeEvent中调用self.timer.stop()
跨线程更新UI崩溃子线程直接操作控件使用 signal/slot 通信,在主线程槽函数中更新UI
K线实体颜色相反A股习惯和欧美习惯不同上涨判断逻辑改为close >= open_为红,下跌为绿

6.4 独家小技巧:成交量与K线图联动配色

一个让副图成交量更有信息量的做法:给成交量柱子也按照当天的涨跌配色,上涨日红色柱子,下跌日绿色柱子。这样一眼扫过去,成交量柱的颜色和主图蜡烛颜色对应,复盘时特别直观。

class VolumeBarItem(pg.GraphicsObject): def __init__(self, data): super().__init__() self.data = data self.picture = QtGui.QPicture() self.generatePicture() def generatePicture(self): self.picture = QtGui.QPicture() painter = QtGui.QPainter(self.picture) xs = [d[0] for d in self.data] spacing = (xs[-1] - xs[0]) / max(len(self.data) - 1, 1) w = spacing * 0.6 for x, open_, high, low, close, volume in self.data: if close >= open_: painter.setBrush(pg.mkBrush('#ef232a')) painter.setPen(pg.mkPen('#ef232a')) else: painter.setBrush(pg.mkBrush('#14b143')) painter.setPen(pg.mkPen('#14b143')) painter.drawRect(QtCore.QRectF(x - w / 2, 0, w, volume)) painter.end()

这里的 y 轴单位是成交量(手),默认从0开始,所以副图范围用setYRange(0, max_volume)就能限制好。

结尾:这个方案后续还能扩展什么

写到这里,核心内容基本都覆盖了。就我自己这几年的实际开发感受来说,用 PyQt5 + pyqtgraph 组合做行情终端,最大的感受是"可控性极强"——所有交互细节、绘制细节、数据链路都在自己手里,遇到问题能定位能修,不像引用了某些重型商业组件,出问题只能干瞪眼。

如果你准备把这个项目继续往下做,我给你三个方向参考:

第一个方向是把 K 线交互做深,比如支持在图上画趋势线、斐波那契回撤线,这些本质上就是往PlotItem里添加自定义GraphicsObject,跟CandlestickItem的实现思路如出一辙。第二个方向是把指标系统做成插件化,MA、MACD、KDJ这类指标各自封装成独立的 Item 类,通过复选框控制显隐,这就非常接近专业软件的架构了。第三个方向是接入实时行情推送,国内很多免费接口(比如某些WebSocket的公开频道)可以直接推送 tick 级行情,把推送的数据在本地合成分钟K线,再通过前面讲的信号槽机制更新UI,整套链路就完整了。

最后分享一个实用小经验:pyqtgraph 的官方文档相对简略,但源代码写得很清晰。遇到难啃的功能,直接去site-packages/pyqtgraph目录里翻源码,很多问题就迎刃而解。我自己就有好几次是翻PlotItem.py和ViewBox.py源码才搞明白坐标变换和鼠标交互的细节。工具这东西,会用是基本功,能深入源码层去理解它,才真正算是自己的本事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询