
1. 为什么我最后选了 pyqtgraph1.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、aksharepip install pandas akshare -i https://pypi.tuna.tsinghua.edu.cn/simple另外说一句如果你用的是 Python 3.10 以上的版本pip 安装 pyqtgraph 时它会自动帮你把 PyQt5 的依赖解析好一般不会出现版本冲突。真要遇到依赖解析失败可以单独指定版本安装pip install PyQt55.15.10 pyqtgraph0.13.32.2 OpenGL 导致界面无显示怎么排查这是 pyqtgraph 用户碰到最多的灵异事件之一程序跑了进程在就是窗口白屏或者直接报错退出。十有八九是 OpenGL 的问题。pyqtgraph 默认会尝试启用 OpenGL 加速来提高渲染性能但 OpenGL 这玩意儿在 Windows 虚拟桌面、远程桌面、显卡驱动不完整的机器上非常容易翻车。特别是很多开发机用的是远程桌面连接微软的远程桌面默认不支持完整的 OpenGL API导致 pyqtgraph 在初始化视图时崩溃。解决方式很简单在创建任何 pyqtgraph 绘图控件之前先强制关闭 OpenGLimport pyqtgraph as pg # 必须在创建PlotWidget之前设置放在最顶部 pg.setConfigOptions(useOpenGLFalse)如果关掉之后还白屏再检查一下是否设置了高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(useOpenGLFalse)能省掉一晚上的排查时间。2.3 用 uv 快速安装与虚拟环境隔离最近 uv 这个工具很火确实好用安装速度比 pip 快一大截。它的用法也很简洁# 安装 uv pip install uv # 创建虚拟环境 uv venv kline_env # 激活Windows kline_env\Scripts\activate # 安装依赖 uv pip install PyQt5 pyqtgraph pandas akshareuv 最大的优势是并行下载和高效的依赖解析对于 PyQt5 这种动辄几十MB的包体验提升极其明显。另外建议在项目里固定 requirements.txt避免某天升级了某个库版本导致界面异常这种坑在开源生态里太常见了。3. 开始动手K线图的核心数据结构与绘制逻辑3.1 K线数据模型OHLCVK线图的每一根蜡烛本质上就是四个价格加一个成交量开盘价Open、最高价High、最低价Low、收盘价Close和成交量Volume缩写叫 OHLCV。这五个数据是一切的基石。在内存里一般用 pandas DataFrame 来存结构大致长这样dateopenhighlowclosevolume2024-01-0210.510.810.210.61523002024-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, dataNone, candle_width0.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()会是0Qt 渲染的时候这个矩形就不会被绘制。所以我加了一个判空逻辑高度为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(xTrue, yTrue, alpha0.3) # 副图 - 成交量 self.volume_plot pg.PlotWidget() self.volume_plot.setBackground(#111111) self.volume_plot.showGrid(xTrue, yTrue, alpha0.3) layout.addWidget(self.main_plot, stretch3) # 主图占3份 layout.addWidget(self.volume_plot, stretch1) # 副图占1份 # x轴联动 self.main_plot.setXLink(self.volume_plot) # 隐藏副图的x轴标签只保留主图的 self.volume_plot.getAxis(bottom).setStyle(showValuesFalse)这样做的好处是主图和副图之间的垂直比例是固定的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 时指定 axisItemsaxis DateAxis(orientationbottom) pw pg.PlotWidget(axisItems{bottom: axis}) # 或者 self.main_plot.getAxis(bottom).setTickSpacing(level086400, level186400*5) self.main_plot.getAxis(bottom).setStyle(tickFontQFont(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(angle90, movableFalse, penpg.mkPen(#ffffff, width1, styleQtCore.Qt.DashLine)) self.h_line pg.InfiniteLine(angle0, movableFalse, penpg.mkPen(#ffffff, width1, styleQtCore.Qt.DashLine)) self.plot_widget.addItem(self.v_line, ignoreBoundsTrue) self.plot_widget.addItem(self.h_line, ignoreBoundsTrue) # 使用SignalProxy降低信号频率避免UI卡顿 self.proxy pg.SignalProxy( self.plot_widget.scene().sigMouseMoved, rateLimit60, slotself.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 ( fTime: {time_str}\n fOpen: {open_:.2f}\n fHigh: {high_:.2f}\n fLow: {low_:.2f}\n fClose: {close_:.2f}\n fVol: {volume:.0f} ) self.label_widget.setText(text)这里有个性能细节sigMouseMoved信号在鼠标移动时触发频率极高如果每次触发都去遍历整个数据集查找最近的K线大数据量下会卡。所以我加了rateLimit60限制每秒最多处理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 d4.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, padding0)思路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(symbol600519, start_date20230101, end_date20241231): 获取A股日K线数据 symbol: 股票代码如600519是贵州茅台 注意akshare的接口返回的列名是中文 df ak.stock_zh_a_hist( symbolsymbol, perioddaily, start_datestart_date, end_dateend_date, adjustqfq # 前复权去除分红送股造成的价格跳空 ) # 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这里有个关键参数adjustqfq也就是前复权。A股股票经常除权除息比如10送10如果不复权K线图上会出现价格凭空跳空的缺口技术指标全部失真。前复权是把历史价格按最新股价做调整让K线连续这是做技术分析必须的。5.2 数据预处理与均线计算拿到DataFrame之后均线、成交量均线、MACD等指标的计算可以直接用 pandas 完成# 计算均线 df[ma5] df[close].rolling(window5).mean() df[ma10] df[close].rolling(window10).mean() df[ma20] df[close].rolling(window20).mean() # 计算成交量均线 df[vol_ma5] df[volume].rolling(window5).mean()然后用 pyqtgraph 的 PlotDataItem 把均线叠加到主图上# 主图叠加均线 self.ma5_line self.main_plot.plot(df[date_ts], df[ma5], penpg.mkPen(#f9d900, width1)) self.ma10_line self.main_plot.plot(df[date_ts], df[ma10], penpg.mkPen(#ff8c00, width1)) self.ma20_line self.main_plot.plot(df[date_ts], df[ma20], penpg.mkPen(#ff4da6, width1))注意均线数据里有 NaN 值pandas 的 rolling 计算前几行是 NaNpyqtgraph 遇到 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 抛给主线程在主线程的槽函数里更新UIfrom 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根日Kpyqtgraph 完全可以应付。但A股一分钟级别的K线数据一年就有约12万根十年就是120万根这个量级下再不优化就会出现卡顿。我的优化优先级是这样的第一开启 OpenGL。前提是你的显卡驱动正常本地物理机没问题远程桌面下不建议开。开启方式pg.setConfigOptions(useOpenGLTrue)第二减少预生成的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])))这个方法的核心思路是只渲染看得见的本质和地图引擎的 LODLevel 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(tickFontQFont(Microsoft YaHei, 8)) item.getAxis(axis_name).setPen(pg.mkPen(#888888))注意setStyle 里的 tickFont 对坐标轴刻度生效而当你使用 TextItem 添加自定义文本时要单独设置 TextItem 的字体。6.3 常见问题速查表问题现象可能原因排查/解决程序启动后白屏、无窗口内容OpenGL渲染问题设置pg.setConfigOptions(useOpenGLFalse)或环境变量QT_OPENGLsoftwareK线蜡烛显示成一条直线数据x坐标未转换成时间戳所有值都相同检查date_ts列是否成功转换打印前几行确认缩放平移卡顿数据量过大、每次重绘全量数据启用LOD策略只渲染可视区域数据开启OpenGL均线没有显示rolling计算产生NaN且没排除直接plot即可pyqtgraph自动跳过NaN如果是空列表检查列名日期坐标轴显示乱码系统字体问题设置tickFontQFont(Microsoft YaHei, 8)副图成交量不在底部y轴范围没有单独设置给副图单独调用setYRange或者使用setLabel窗口关闭后进程不退出定时器未停止在closeEvent中调用self.timer.stop()跨线程更新UI崩溃子线程直接操作控件使用 signal/slot 通信在主线程槽函数中更新UIK线实体颜色相反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源码才搞明白坐标变换和鼠标交互的细节。工具这东西会用是基本功能深入源码层去理解它才真正算是自己的本事。