PyQtGraph应用(四):基于PyQtGraph的K线指标图绘制
前言
通过之前分享的PyQtGraph文章的学习,能够熟练的使用PyQtGraph绘制各种常用的图表图形,本文将介绍,使用PyQtGraph,实现一个类似主流股票行情软件的K线指标图。
效果对比及预览
首先看一下主流的看盘行情软件(如:同花顺、东方财富、通信达等)的效果:
同花顺经典版:

同花顺远航版:

东方财富:

通信达:

自己代码实现后的效果:

切换效果:

同样支持指标参数设置:

实现原理及代码
通过之前的文章示例,可以发现使用PyQtGraph提供的PlotWidget,只能绘制基本的图表。
而对于k线图及其相关的指标图表,则需要和之前实现自定义坐标轴一样,需要自己继承实现图表图元项的绘制。
k线图及其相关的指标图(如:成交额、成交量、MACD、KDJ、RSI、BOLL等)都可以定义为指标,因此首先实现一个指标基类,在基类中维护复用的图表对象及相应的事件处理。
python
# base_chart_widget.py
from PyQt5.QtCore import Qt, pyqtSignal, QObject
from PyQt5 import QtWidgets, uic, QtGui
from PyQt5.QtWidgets import QWidget, QVBoxLayout
import pyqtgraph as pg
import numpy as np
from manager.period_manager import TimePeriod
# 需要发送到外部控件(如BaseIndicatorWidget的父控件或其他子类)时,使用全局信号
# 问题:当存在多个BaseIndicatorWidget(及其子类)对象连接同一个全局信号时,触发全局信号后所有槽函数都会响应。解决方案:传递self,在槽函数中判断,不是当前实例,则不响应。
class SignalManager(QObject):
# 全局信号管理器
global_update_labels = pyqtSignal(object, int)
global_show_overview_label = pyqtSignal(object, int, float, float, float, bool)
# 若是只需要处理当前视图鼠标移动事件,可放在父类中处理(如:只在触发鼠标事件的视图中显示y轴水平、y轴标签,而所有视图都需显示x轴垂直线,则放在子类中处理)
global_sig_v_line_moved = pyqtSignal(object, float)
global_sig_hide_v_line = pyqtSignal()
global_reset_labels = pyqtSignal(object)
# 创建全局信号管理器实例
signal_manager = SignalManager()
class BaseIndicatorWidget(QWidget):
# 定义自定义信号。问题:虽然信号属于类,但是连接却是和示例绑定的,因此无法实现父类中触发信号,传递到所有子类槽函数响应。实际上只有触发信号的子类示例的槽函数响应。
# sig_update_labels = pyqtSignal(int) # 点击信号
# sig_v_line_moved = pyqtSignal(float)
def __init__(self, data, type=0, parent=None):
super(BaseIndicatorWidget, self).__init__(parent)
self.item = None
self.df_data = None
self.logger = None
self.plot_widget = None
self.type = type # 0:行情,1:策略,2:复盘
self.period = TimePeriod.DAY
self.init_para(data) # 注意:若子类中有重写这三个init_函数,必须显示调用父类对应的init_函数,否则会覆盖掉父类的初始化处理。
self.init_ui()
self.init_connect()
# 清理资源
def __del__(self):
# 从共享字典中移除
chart_name = self.get_chart_name()
self.logger.info(f"开始清理类型为{self.type}的{chart_name}及其资源")
def init_ui(self):
# 加载UI文件(子类需提供)
uic.loadUi(self.get_ui_path(), self)
layout = self.layout()
if layout is None:
self.logger.info("没有布局,创建一个")
self.setLayout(QVBoxLayout())
layout = self.layout()
self.plot_widget = pg.PlotWidget()
self.setup_plot_widget()
layout.addWidget(self.plot_widget)
self.draw()
def setup_plot_widget(self):
"""设置plot widget的基本属性"""
self.plot_widget.hideAxis('bottom')
self.plot_widget.getAxis('left').setWidth(60)
self.plot_widget.setBackground('w')
self.plot_widget.showGrid(x=True, y=True)
self.plot_widget.setMouseEnabled(x=True, y=False)
self.plot_widget.getViewBox().setMouseMode(pg.ViewBox.PanMode) # 平移模式
# 设置坐标轴颜色
self.plot_widget.getAxis('left').setPen(QtGui.QColor(110, 110, 110))
self.plot_widget.getAxis('bottom').setPen(QtGui.QColor(110, 110, 110))
self.plot_widget.getAxis('left').setTextPen(QtGui.QColor(110, 110, 110))
self.plot_widget.getAxis('bottom').setTextPen(QtGui.QColor(110, 110, 110))
# 添加十字线
self.v_line = pg.InfiniteLine(angle=90, movable=False, pen=pg.mkPen('#808286', width=2, style=Qt.DashLine))
self.h_line = pg.InfiniteLine(angle=0, movable=False, pen=pg.mkPen('#808286', width=2, style=Qt.DashLine))
self.v_line.setZValue(1000)
self.h_line.setZValue(1000)
chart_name = self.get_chart_name()
main_viewbox = self.plot_widget.getViewBox()
main_viewbox.addItem(self.v_line, ignoreBounds=True)
main_viewbox.addItem(self.h_line, ignoreBounds=True)
# 添加x轴标签
self.x_label = pg.TextItem("", anchor=(0.5, 1))
self.x_label.setZValue(1000)
self.x_label.setFont(pg.QtGui.QFont("Arial", 9))
self.x_label.setColor(pg.QtGui.QColor(0, 0, 0))
main_viewbox.addItem(self.x_label, ignoreBounds=True)
# 添加左y轴标签
self.left_y_label = pg.TextItem("", anchor=(0, 0.5))
self.left_y_label.setZValue(1000)
self.left_y_label.setFont(pg.QtGui.QFont("Arial", 9))
self.left_y_label.setColor(pg.QtGui.QColor(255, 0, 0))
main_viewbox.addItem(self.left_y_label, ignoreBounds=True)
self.hide_all_labels()
def zoom_in(self, x_factor=0.8, y_factor=0.8):
"""放大视图"""
viewbox = self.plot_widget.getViewBox()
viewbox.scaleBy((x_factor, y_factor)) # 按0.8倍缩放,数值越小越放大
def zoom_out(self, x_factor=1.2, y_factor=1.2):
"""缩小视图"""
viewbox = self.plot_widget.getViewBox()
viewbox.scaleBy((x_factor, y_factor)) # 按1.2倍缩放,数值越大越缩小
def reset_zoom(self):
"""恢复到原始缩放状态"""
# 方法1: 重新设置原始范围
# self.set_axis_ranges() # 调用您已有的设置轴范围方法
# 方法2: 使用autoRange
# viewbox = self.plot_widget.getViewBox()
# viewbox.autoRange()
# 方法3:使用自定义默认范围
self.auto_scale_to_latest(120)
def get_current_range(self):
"""获取当前视图范围"""
return self.plot_widget.viewRange() # 返回 [[x_min, x_max], [y_min, y_max]]
def update_data(self, data):
# self.logger.info(f"更新数据{self.get_chart_name()}, data长度:{len(data)}")
self.df_data = data
self.update_widget_labels()
self.draw()
self.update()
def update_widget_labels(self):
"""钩子方法:子类可以重写此方法添加额外的标签更新"""
pass
def get_data(self):
return self.df_data
def get_plot_widget(self):
return self.plot_widget
def get_date_text_with_style(self, index):
try:
# 检查索引是否有效
if index < 0 or index >= len(self.df_data):
return ""
# 使用 .loc 访问器获取指定行的 时间列数据
s_col_name = 'date'
if TimePeriod.is_minute_level(self.period):
s_col_name = 'time'
date_str = self.df_data.loc[index, s_col_name]
label_main_x_text_with_style = '<div style="color: black; background-color: white; border: 3px solid black; padding: 2px;">{}</div>'.format(date_str)
return label_main_x_text_with_style
except Exception as e:
self.logger.error(f"获取日期文本时出错: {e}")
return ""
def get_left_y_text_with_style(self, y_val):
left_y_label_text = f"{y_val:.2f}"
left_y_label_text_with_style = '<div style="color: black; background-color: white; border: 3px solid black; padding: 2px;">{}</div>'.format(left_y_label_text)
return left_y_label_text_with_style
def draw(self):
if self.df_data is None or self.df_data.empty:
self.logger.info(f"数据为空,无法绘制{self.get_chart_name()}")
return
if not self.validate_data():
self.logger.warning(f"缺少必要的数据列来绘制{self.get_chart_name()}")
return
self.plot_widget.clear()
self.create_and_add_item()
self.set_axis_ranges()
# 调用钩子方法,允许子类添加额外绘制逻辑
self.additional_draw()
def additional_draw(self):
"""钩子方法:子类可以重写此方法添加额外的绘制逻辑"""
pass
def get_ui_path(self):
"""返回UI文件路径"""
raise NotImplementedError("子类必须实现 get_ui_path 方法")
def validate_data(self):
"""验证数据是否满足要求"""
raise NotImplementedError("子类必须实现 validate_data 方法")
def create_and_add_item(self):
"""创建并添加图表项"""
raise NotImplementedError("子类必须实现 create_and_add_item 方法")
def set_axis_ranges(self):
"""设置坐标轴范围"""
raise NotImplementedError("子类必须实现 set_axis_ranges 方法")
def get_chart_name(self):
"""返回图表名称"""
raise NotImplementedError("子类必须实现 get_chart_name 方法")
def init_para(self, data):
"""初始化参数"""
raise NotImplementedError("子类必须实现 init_para 方法")
def init_connect(self):
"""初始化信号连接"""
# 连接到全局信号
signal_manager.global_update_labels.connect(self.slot_global_update_labels)
signal_manager.global_show_overview_label.connect(self.slot_global_show_overview_label)
signal_manager.global_sig_v_line_moved.connect(self.slot_v_line_mouse_moved)
signal_manager.global_sig_hide_v_line.connect(self.slot_hide_v_line)
signal_manager.global_reset_labels.connect(self.slot_global_reset_labels)
self.addtional_connect()
def addtional_connect(self):
# raise NotImplementedError("子类必须实现 addtional_connect 方法")
pass
def set_period(self, period):
self.period = period
def get_period(self):
return self.period
def get_visible_data_range(self):
'''
获取当前视图范围内X轴对应的数据索引范围和数据
返回: (visible_data, x_min, x_max) 或 (None, None, None) 如果无效
'''
if self.plot_widget is None or self.df_data is None or self.df_data.empty:
return None, None, None
# 获取当前X轴的视图范围
view_range = self.plot_widget.viewRange()
x_range = view_range[0] # X轴范围 [min, max]
# 确定可视范围内的数据索引
x_min, x_max = int(max(0, x_range[0])), int(min(len(self.df_data), x_range[1]))
# 确保范围有效
if x_min >= len(self.df_data) or x_max <= 0 or x_min >= x_max:
return None, None, None
# 获取可视范围内的数据
visible_data = self.df_data.iloc[x_min:x_max]
if visible_data.empty:
return None, None, None
return visible_data, x_min, x_max
def slot_range_changed(self):
'''当视图范围改变时调用'''
# y轴坐标值同步
# 获取当前x轴视图范围内的数据
# 根据当前可视范围内的数据的最大、最小值调整Y轴坐标值范围
# 重新设置Y轴刻度
pass
def set_default_view_range(self, visible_days=None):
"""
设置默认视图范围,显示最新的数据,最后一个数据显示在视图左边2/3的位置,为右边预留1/3的空白
:param visible_days: 默认显示的天数
"""
if self.plot_widget is None or self.df_data is None or self.df_data.empty:
return
total_days = len(self.df_data)
if visible_days is None:
# 显示所有数据,最后一个数据显示在视图左边2/3位置
display_range = total_days / (2/3) # 总视图范围,使得total_days占据2/3
start_index = 0
end_index = display_range # 结束位置要给右侧留出1/3空白
self.plot_widget.setXRange(start_index, end_index, padding=0)
else:
# 显示指定天数的数据
if total_days <= visible_days:
# 数据量不足指定天数
display_range = visible_days / (2/3)
start_index = 0
end_index = display_range
self.plot_widget.setXRange(start_index, end_index, padding=0)
else:
# 数据量超过指定天数,显示最新的visible_days天数据
end_index = total_days
# 计算起始索引,使得最后一天位于视图的2/3位置
display_range = visible_days / (2/3)
start_index = end_index - visible_days # 显示visible_days根K线
end_index = start_index + display_range # 但视图范围要给右侧留1/3空白
self.plot_widget.setXRange(start_index, end_index, padding=0)
def auto_scale_to_latest(self, visible_days=None):
"""
自动缩放到最新数据并触发Y轴自适应
:param visible_days: 默认显示的天数
"""
self.set_default_view_range(visible_days)
# 触发Y轴范围调整
self.slot_range_changed()
def slot_mouse_moved(self, pos):
"""鼠标移动事件处理"""
if self.plot_widget is None or self.df_data is None or self.df_data.empty:
return
if self.plot_widget.sceneBoundingRect().contains(pos):
view_range = self.plot_widget.getViewBox().viewRange()
mouse_point = self.plot_widget.getViewBox().mapSceneToView(pos)
x_val = mouse_point.x()
y_val = mouse_point.y()
self.left_y_label.setHtml(self.get_left_y_text_with_style(y_val))
self.left_y_label.setPos(view_range[0][0], y_val)
self.left_y_label.show()
bar_centers = list(range(len(self.df_data)))
closest_index = None
min_distance = float('inf')
for i, center in enumerate(bar_centers):
distance = abs(center - x_val)
if distance <= 0.25 / 2:
if distance < min_distance:
min_distance = distance
closest_index = i
closest_x = None # 这里closest_x其实和closest_index一样,都是从0开始
if closest_index is not None:
closest_x = bar_centers[closest_index]
# 全局信号通知显示所有图表的垂直线
signal_manager.global_sig_v_line_moved.emit(self, closest_x)
# 只显示鼠标所在图表的X轴标签
self.x_label.setHtml(self.get_date_text_with_style(closest_index))
self.x_label.setPos(closest_x, view_range[1][0])
self.x_label.show()
# 全局信号通知更新所有图表父控件的指标标签值
signal_manager.global_update_labels.emit(self, closest_index)
# k线图中显示标签预览
signal_manager.global_show_overview_label.emit(self, closest_index, y_val, closest_x, y_val, True)
else:
# signal_manager.global_show_overview_label.emit(closest_x, closest_index, y_val, False)
# self.hide_all_labels()
pass
else:
# self.logger.info(f"鼠标位置超出图表范围")
self.hide_all_labels()
def additional_mouse_moved(self, closest_x):
"""钩子方法:子类可以重写此方法添加鼠标移动处理"""
pass
def hide_all_labels(self):
signal_manager.global_sig_hide_v_line.emit()
self.h_line.hide()
self.x_label.hide()
self.left_y_label.hide()
signal_manager.global_show_overview_label.emit(self, 0, 0, 0, 0, False)
signal_manager.global_reset_labels.emit(self)
self.plot_widget.prepareGeometryChange() # 手动刷新避免十字线重影
self.plot_widget.update()
def enterEvent(self, event):
"""
当鼠标进入控件时的处理
"""
super().enterEvent(event)
def leaveEvent(self, event):
"""
当鼠标离开控件时,隐藏所有标签和十字线
"""
self.hide_all_labels()
super().leaveEvent(event)
def slot_global_update_labels(self, sender, closest_index):
pass
def slot_global_reset_labels(self, sender):
pass
def slot_global_show_overview_label(self, sender, index, y_val, x_pos, y_pos, bool_show=True):
pass
def slot_v_line_mouse_moved(self, sender, x_pos):
pass
def slot_hide_v_line(self):
self.v_line.hide()
K线图
这里以K线图为例,实现一个继承自BaseIndicatorWidget的KLineWidget子类,再子类中实现k线的绘制。
ui如图:

python
from PyQt5 import QtWidgets, uic, QtGui
from PyQt5.QtWidgets import QApplication, QWidget, QDialog
from PyQt5.QtCore import pyqtSlot, Qt
import pyqtgraph as pg
import numpy as np
import pandas as pd
from manager.logging_manager import get_logger
from gui.qt_widgets.MComponents.indicators.base_indicator_widget import BaseIndicatorWidget, signal_manager
from gui.qt_widgets.MComponents.indicators.item.candlestick_item import CandlestickItem
from gui.qt_widgets.MComponents.indicators.setting.kline_indicator_setting_dialog import KLineIndicatorSettingDialog
from manager.indicators_config_manager import *
from gui.qt_widgets.MComponents.indicators.kline_overview_widget import KLineOverviewWidget
from indicators.stock_data_indicators import *
class KLineWidget(BaseIndicatorWidget):
def __init__(self, data, type, parent=None):
# 调用父类初始化,这会自动调用init_para, init_ui, init_connect
super(KLineWidget, self).__init__(data, type, parent)
self.custom_init()
# 使用QWidget作为概览标签弹窗显示。
self.overview_widget = KLineOverviewWidget(self)
self.overview_widget.setFixedSize(200, 360)
self.overview_widget.move(80, 40) # 60是同步y轴宽度的位置 self.frame_title.height()
self.overview_widget.hide()
self.load_qss()
def custom_init(self):
self.label_ma20.hide()
self.label_ma30.hide()
self.label_ma60.hide()
self.btn_restore.clicked.connect(self.slot_btn_restore_clicked)
self.btn_zoom_in.clicked.connect(self.slot_btn_zoom_in_clicked)
self.btn_zoom_out.clicked.connect(self.slot_btn_zoom_out_clicked)
self.btn_setting.clicked.connect(self.slot_btn_setting_clicked)
def init_para(self, data):
self.logger = get_logger(__name__)
# 检查是否有数据
if data is None or data.empty:
# raise ValueError("数据为空,无法绘制k线图")
self.logger.warning("数据为空,无法绘制k线图")
return
# 确保数据列存在
required_columns = ['open', 'high', 'low', 'close']
if not all(col in data.columns for col in required_columns):
self.logger.warning("缺少必要的数据列来绘制k线图")
raise ValueError("缺少必要的数据列来绘制k线图")
self.df_data = data
def load_qss(self):
self.dict_ma_label = {
0: self.label_ma5,
1: self.label_ma10,
2: self.label_ma20,
3: self.label_ma24,
4: self.label_ma30,
5: self.label_ma52,
6: self.label_ma60,
}
dict_ma_settings = get_indicator_config_manager().get_user_config_by_indicator_type(IndicatrosEnum.MA.value)
for id, ma_setting in dict_ma_settings.items():
if id in self.dict_ma_label.keys():
self.dict_ma_label[id].setStyleSheet(f"color: {ma_setting.color_hex}")
def addtional_connect(self):
pass
def get_ui_path(self):
return './src/gui/qt_widgets/MComponents/indicators/KLineWidget.ui'
def reset_labels(self):
# self.label_ma_period.setText("") # 选中周期时设置
self.label_stock_name.setText("") # 点击卡片Item时,根据当前选中的股票设置
self.label_ma5.setText(f"MA5: ")
self.label_ma10.setText(f"MA10: ")
self.label_ma20.setText(f"MA20: ")
self.label_ma24.setText(f"MA24: ")
self.label_ma30.setText(f"MA30: ")
self.label_ma52.setText(f"MA52: ")
self.label_ma60.setText(f"MA60: ")
def validate_data(self):
required_columns = ['open', 'high', 'low', 'close']
return all(col in self.df_data.columns for col in required_columns)
def create_and_add_item(self):
if self.item is None:
self.item = CandlestickItem(self.df_data)
else:
self.item.update_data(self.df_data)
self.plot_widget.addItem(self.item)
def set_axis_ranges(self):
data_high = np.max(self.df_data['high'])
data_low = np.min(self.df_data['low'])
self.plot_widget.setXRange(-1, len(self.df_data) + 1, padding=0)
self.plot_widget.setYRange(data_low * 0.95, data_high * 1.05, padding=0)
def get_chart_name(self):
return "K线图"
def is_ma_show(self):
return self.item.is_ma_show() if self.item else False
def show_ma(self, b_show=True):
if self.item:
self.item.show_ma(b_show)
def set_period_text(self, period):
self.label_ma_period.setText(period)
def set_stock_name(self, stock_name):
self.label_stock_name.setText(stock_name)
def get_overview_text_with_style(self, index, y_val):
'''获取概览标签的文本,并设置样式'''
if self.df_data is None or self.df_data.empty:
return ""
row = self.df_data.iloc[index]
date = row['date']
open = row['open']
close = row['close']
high = row['high']
low = row['low']
change_percent = row['change_percent']
amplitude = (high - low) / low * 100
volume = row['volume'] / 10000 # 单位:万
amount = row['amount'] / 100000000 # 单位:亿
turnover_rate = row['turnover_rate']
volume_ratio = row['volume_ratio']
label_text = f"日期: {date}<br>数值:{y_val:.2f}<br>开盘:{open:.2f} <br>收盘: {close:.2f} \
<br>最高: {high:.2f} <br>最低: {low:.2f}<br>涨跌幅: {change_percent:.2f}%<br>振幅: {amplitude:.2f}%\
<br>成交量:{volume:.2f}万<br>成交额:{amount:.2f}亿<br>换手率:{turnover_rate:.2f}%<br>量比:{volume_ratio:.2f}"
text_with_style = '<div style="color: black; background-color: white; border: 3px solid black; padding: 2px;">{}</div>'.format(label_text)
return text_with_style
def update_overview_widget_qss(self, index, y_val):
if index < 0 or index >= len(self.df_data): return
def update_widget_labels(self):
self.slot_global_update_labels(self, -1)
def set_indicator_name(self, indicator_name):
self.label_indicator.setText(indicator_name)
def slot_btn_restore_clicked(self):
self.reset_zoom()
def slot_btn_zoom_in_clicked(self):
self.zoom_in()
def slot_btn_zoom_out_clicked(self):
self.zoom_out()
def slot_btn_setting_clicked(self):
dlg = KLineIndicatorSettingDialog()
result = dlg.exec()
if result == QDialog.Accepted:
self.logger.info("更新k线设置")
auto_ma_calulate(self.df_data)
# 刷新K线图
self.update_data(self.df_data)
self.auto_scale_to_latest(120)
def slot_range_changed(self):
'''当视图范围改变时调用'''
# y轴坐标值同步
# 获取可视范围内的数据
visible_data, x_min, x_max = self.get_visible_data_range()
if visible_data is None or visible_data.empty:
return
# 根据当前可视范围内的数据的最大、最小值调整Y轴坐标值范围
required_columns = []
if 'high' in visible_data.columns and 'low' in visible_data.columns:
required_columns.extend(['high', 'low'])
# TODO: 如果MA线显示,也需要考虑MA线的值
ma_columns = ['ma5', 'ma10', 'ma20', 'ma30', 'ma60']
for col in ma_columns:
if col in visible_data.columns and self.is_ma_show():
required_columns.append(col)
if not required_columns:
return
# 计算可见范围内的最大值和最小值
max_val = visible_data[required_columns].max().max()
min_val = visible_data[required_columns].min().min()
# 添加一些padding以确保K线不会触及边界
padding = (max_val - min_val) * 0.05 # 5%的padding
y_min = min_val - padding
y_max = max_val + padding
# 重新设置Y轴刻度
self.plot_widget.setYRange(y_min, y_max, padding=0)
def slot_global_update_labels(self, sender, closest_index):
if self.df_data is None or self.df_data.empty:
return
if self.type != sender.type:
# self.logger.info(f"不响应其他窗口的鼠标移动事件")
return
# 设置MA值
dict_ma_settings = get_indicator_config_manager().get_user_config_by_indicator_type(IndicatrosEnum.MA.value)
self.dict_ma_label = {
0: self.label_ma5,
1: self.label_ma10,
2: self.label_ma20,
3: self.label_ma24,
4: self.label_ma30,
5: self.label_ma52,
6: self.label_ma60,
}
for id, ma_setting in dict_ma_settings.items():
if id in self.dict_ma_label.keys() and ma_setting.name in self.df_data.columns:
self.dict_ma_label[id].setText(f"{ma_setting.name}:{self.df_data.iloc[closest_index][ma_setting.name]:.2f}")
self.dict_ma_label[id].setVisible(ma_setting.visible)
self.dict_ma_label[id].setStyleSheet(f"color: {ma_setting.color_hex}")
def slot_global_reset_labels(self, sender):
self.slot_global_update_labels(sender, -1)
def slot_global_show_overview_label(self, sender, index, y_val, x_pos, y_pos, bool_show=True):
if self.type != sender.type:
# self.logger.info(f"不响应其他窗口的鼠标移动事件")
return
if not hasattr(self, 'overview_widget') or self.overview_widget is None:
return
if bool_show:
if index < 0 or index >= len(self.df_data):
return
if self.df_data is None or self.df_data.empty:
return
if index == 0:
last_row = None # 第一行没有上一行
else:
last_row = self.df_data.iloc[index - 1] if index > 0 else None
current_row = self.df_data.iloc[index] if index >= 0 and index < len(self.df_data) else None
self.overview_widget.update_labels(last_row, current_row, y_val)
self.overview_widget.update_labels(last_row, current_row, y_val)
# 自动更新概览标签位置,避免遮挡K线。
view_range = self.plot_widget.getViewBox().viewRange()
left_x = view_range[0][0]
right_x = view_range[0][1]
limit_range = (right_x - left_x) / 3
left_x_limit = left_x + limit_range
right_x_limit = right_x - limit_range
# self.logger.info(f"view_range:\n{view_range}")
if x_pos >= left_x and x_pos <= left_x_limit:
self.overview_widget.move(self.width() - self.overview_widget.width(), 40)
elif x_pos >= right_x_limit and x_pos <= right_x:
self.overview_widget.move(80, 40) # 注意不能使用plot_widget视图坐标设置上层的overview_widget的位置。
self.overview_widget.show()
self.plot_widget.prepareGeometryChange() # 手动刷新避免十字线重影
self.plot_widget.update()
else:
self.overview_widget.hide()
def slot_v_line_mouse_moved(self, sender, x_pos):
# self.logger.info(f"正在处理{self.get_chart_name()}鼠标移动响应, self: {self}, sender: {sender}")
# self.logger.info(f"self.type: {self.type}, sender.type: {sender.type}")
if self.type != sender.type:
# self.logger.info(f"不响应其他窗口的鼠标移动事件")
return
self.v_line.setPos(x_pos)
self.v_line.show()
对应的k线图元Item实现如下:
python
import pyqtgraph as pg
from PyQt5 import QtGui, QtCore
import numpy as np
from manager.indicators_config_manager import get_kline_half_width, IndicatrosEnum, get_indicator_config_manager, get_dict_kline_color
class CandlestickItem(pg.GraphicsObject):
# "."蜡烛图绘制类...·
def __init__(self, data):
pg.GraphicsObject.__init__(self)
# 数据验证
required_columns = ['open', 'close', 'high', 'low'] # , 'ma5', 'ma10', 'ma20', 'ma24', 'ma30', 'ma52', 'ma60'
if not all(col in data.columns for col in required_columns):
raise ValueError(f"缺少必要的数据列,需要: {required_columns}")
self.data = data # data should be a list or Pandas.DataFrame (date, code, open, high, low, close...)
self.ma_visible = True
self.generatePicture()
def get_data(self):
return self.data
def update_data(self, data):
# 数据验证
required_columns = ['open', 'close', 'high', 'low'] # , 'ma5', 'ma10', 'ma20', 'ma24', 'ma30', 'ma52', 'ma60'
if not all(col in data.columns for col in required_columns):
raise ValueError(f"缺少必要的数据列,需要: {required_columns}")
self.data = data
self.generatePicture()
self.prepareGeometryChange() # 通知框架几何形状可能发生了变化
self.update() # 触发重绘
def is_ma_show(self):
return self.ma_visible
def show_ma(self, b_show=True):
if self.ma_visible == b_show:
return
self.ma_visible = b_show
self.generatePicture()
self.prepareGeometryChange()
self.update()
def generatePicture(self):
# 生成蜡烛图
self.picture = QtGui.QPicture()
p = QtGui.QPainter(self.picture)
pg.setConfigOptions(leftButtonPan=False, antialias=False)
w = get_kline_half_width()
# 长度检查
if len(self.data) == 0:
p.end()
return
#绘制移动平均线
if self.ma_visible:
all_user_configs = get_indicator_config_manager().get_user_configs()
dict_ma_setting_user = all_user_configs.get(IndicatrosEnum.MA.value, {})
for id, ma_setting in dict_ma_setting_user.items():
if ma_setting.visible and ma_setting.name in self.data.columns and len(self.data) > ma_setting.period:
ma = self.data[ma_setting.name]
ma_lines = self._get_quota_lines(ma)
if ma_lines: # 确保有线段可绘制
p.setPen(pg.mkPen(ma_setting.color_hex, width=ma_setting.line_width))
p.drawLines(*tuple(ma_lines))
#绘制蜡烛图
dict_kline_color = get_dict_kline_color()
for i in range(len(self.data)):
# 使用 iloc 按位置访问数据,而不是按索引访问
open_price = self.data['open'].iloc[i]
close_price = self.data['close'].iloc[i]
high_price = self.data['high'].iloc[i]
low_price = self.data['low'].iloc[i]
if close_price < open_price:
#下跌-绿色
p.setPen(pg.mkPen(dict_kline_color[IndicatrosEnum.KLINE_DESC.value]))
p.setBrush(pg.mkBrush(dict_kline_color[IndicatrosEnum.KLINE_DESC.value]))
p.drawLine(QtCore.QPointF(i, low_price), QtCore.QPointF(i, high_price))
p.drawRect(QtCore.QRectF(i - w, open_price, w * 2, close_price - open_price))
else:
#上涨-红色 空心蜡烛
p.setPen(pg.mkPen(dict_kline_color[IndicatrosEnum.KLINE_ASC.value]))
p.setBrush(QtGui.QBrush(QtCore.Qt.NoBrush)) # 设置为空画刷,绘制空心矩形
# 绘制上下影线
if high_price != close_price:
p.drawLine(QtCore.QPointF(i, high_price), QtCore.QPointF(i, close_price))
if low_price != open_price:
p.drawLine(QtCore.QPointF(i, open_price), QtCore.QPointF(i, low_price))
#绘制实体(空心)
if close_price == open_price:
p.drawLine(QtCore.QPointF(i - w, open_price), QtCore.QPointF(i + w, open_price))
else:
p.drawRect(QtCore.QRectF(i - w, open_price, w * 2, close_price - open_price))
p.end()
def _get_quota_lines(self, data):
#...获取指标线段的坐标点·
lines = []
for i in range(1, len(data)):
if not np.isnan(data.iloc[i-1]) and not np.isnan(data.iloc[i]):
lines.append(QtCore.QLineF(QtCore.QPointF(i-1, data.iloc[i-1]),
QtCore.QPointF(i, data.iloc[i])))
return lines
def paint(self, p, *args):
p.drawPicture(0, 0, self.picture)
def boundingRect(self) :
return QtCore.QRectF(self.picture.boundingRect())
绘制的k线图及交互效果如下:

这里简单小结下上面的实现逻辑:
①定义一个指标基类,基类中维护管理各个子类都复用绘图对象(plot_widget),并处理鼠标相关事件。
②根据不用的指标类型,实现对应的子类(如:K线图子类、成交量子类、MACD子类等)。
③实现对应指标类型子类的图元Item(如:K线图Item、成交量Item、MACD Item等),来完成具体的指标绘制。
成交量
类似的成交量指标图实现如下:
继承自BaseIndicatorWidget的VolumeWidget子类:
python
from PyQt5 import QtWidgets, uic, QtGui
from PyQt5.QtWidgets import QDialog
from PyQt5.QtCore import pyqtSlot
import pyqtgraph as pg
import numpy as np
from manager.logging_manager import get_logger
from gui.qt_widgets.MComponents.indicators.base_indicator_widget import BaseIndicatorWidget, signal_manager
from gui.qt_widgets.MComponents.indicators.item.volume_item import VolumeItem
from manager.indicators_config_manager import *
from gui.qt_widgets.MComponents.indicators.setting.volume_setting_dialog import VolumeSettingDialog
class VolumeWidget(BaseIndicatorWidget):
def __init__(self, data, type, parent=None):
super(VolumeWidget, self).__init__(data, type, parent)
self.custom_init()
self.load_qss()
def custom_init(self):
self.label_ma5.hide()
self.label_ma10.hide()
self.label_ma20.hide()
self.btn_close.hide()
self.btn_setting.clicked.connect(self.slot_btn_setting_clicked)
def init_para(self, data):
self.logger = get_logger(__name__)
self.indicator_type = IndicatrosEnum.VOLUME.value
if data is None or data.empty:
# raise ValueError("数据为空,无法绘制成交量指标图")
self.logger.warning("数据为空,无法绘制成交量指标图")
return
required_columns = ['open', 'close', 'volume']
if not all(col in data.columns for col in required_columns):
raise ValueError("缺少必要的数据列来绘制成交量指标图")
self.df_data = data
def load_qss(self):
self.dict_ma_label = {
0: self.label_ma5,
1: self.label_ma10,
2: self.label_ma20
}
dict_ma_settings = get_indicator_config_manager().get_user_config_by_indicator_type(self.indicator_type)
for id, ma_setting in dict_ma_settings.items():
if id in self.dict_ma_label.keys():
self.dict_ma_label[id].setStyleSheet(f"color: {ma_setting.color_hex}")
def addtional_connect(self):
pass
def get_ui_path(self):
return './src/gui/qt_widgets/MComponents/indicators/VolumeWidget.ui'
def validate_data(self):
required_columns = ['open', 'close', 'volume']
return all(col in self.df_data.columns for col in required_columns)
def create_and_add_item(self):
if self.item is None:
self.item = VolumeItem(self.df_data)
else:
self.item.update_data(self.df_data)
self.plot_widget.addItem(self.item)
def set_axis_ranges(self):
self.plot_widget.setXRange(-1, len(self.df_data) + 1, padding=0)
max_vol = np.max(self.df_data['volume'])
# self.logger.info(f"最大成交量-max_vol: {max_vol}")
self.plot_widget.setYRange(0, max_vol / 10000 * 1.1, padding=0)
def get_chart_name(self):
return "成交量"
def update_widget_labels(self):
self.slot_global_update_labels(self, -1)
def slot_btn_setting_clicked(self):
dlg = VolumeSettingDialog()
result = dlg.exec()
if result == QDialog.Accepted:
self.logger.info("更新成交量设置")
# auto_ma_calulate(self.df_data)
# 刷新K线图
self.update_data(self.df_data)
self.auto_scale_to_latest(120)
def slot_range_changed(self):
'''当视图范围改变时调用'''
# y轴坐标值同步
# 获取可视范围内的数据
visible_data, x_min, x_max = self.get_visible_data_range()
if visible_data is None or visible_data.empty:
return
# 根据当前可视范围内的数据的最大、最小值调整Y轴坐标值范围
# 成交量图只需要考虑volume列的最大值,最小值始终为0
max_volume = visible_data['volume'].max()
max_volume = max_volume / 10000 # 单位:万
# 添加一些padding以确保柱状图不会触及顶部边界
padding = max_volume * 0.05 # 5%的padding
y_min = 0 # 成交量最小值始终为0
y_max = max_volume + padding
# 重新设置Y轴刻度
self.plot_widget.setYRange(y_min, y_max, padding=0)
def slot_global_update_labels(self, sender, closest_index):
if self.df_data is None or self.df_data.empty:
return
if self.type != sender.type:
# self.logger.info(f"不响应其他窗口的鼠标移动事件")
return
volume = self.df_data.iloc[closest_index]['volume'] / 10000
self.label_total_volume.setText(f"总量:{volume:.2f}万")
change_percent = self.df_data.iloc[closest_index]['change_percent']
if change_percent > 0:
self.label_total_volume.setStyleSheet(f"color: {dict_kline_color_hex[IndicatrosEnum.KLINE_ASC.value]};")
else:
self.label_total_volume.setStyleSheet(f"color: {dict_kline_color_hex[IndicatrosEnum.KLINE_DESC.value]};")
dict_settings = get_indicator_config_manager().get_user_config_by_indicator_type(self.indicator_type)
self.dict_ma_label = {
0: self.label_ma5,
1: self.label_ma10,
2: self.label_ma20
}
for id, setting in dict_settings.items():
if id in self.dict_ma_label.keys() and setting.name in self.df_data.columns:
# 暂未计算成交量的均线
# self.dict_ma_label[id].setText(f"{setting.name}:{self.df_data.iloc[closest_index][setting.name]:.2f}")
# self.dict_ma_label[id].setVisible(setting.visible)
self.dict_ma_label[id].setStyleSheet(f"color: {setting.color_hex}")
def slot_global_reset_labels(self, sender):
self.slot_global_update_labels(sender, -1)
def slot_v_line_mouse_moved(self, sender, x_pos):
# self.logger.info(f"正在处理{self.get_chart_name()}鼠标移动响应, self: {self}, sender: {sender}")
if self.type != sender.type:
# self.logger.info(f"不响应其他窗口的鼠标移动事件")
return
self.v_line.setPos(x_pos)
self.v_line.show()
对应ui:

再实现对应VolumeItem来完成成交量指标的绘制:
python
import pyqtgraph as pg
from PyQt5 import QtGui, QtCore
import numpy as np
from manager.indicators_config_manager import *
class VolumeItem(pg.GraphicsObject):
# ..."交易量柱状图绘制类......
def __init__(self, data):
pg.GraphicsObject.__init__(self)
# 数据验证
required_columns = ['open', 'close', 'volume'] # , 'low', 'ma5', 'ma10', 'ma20', 'ma24', 'ma30', 'ma52', 'ma60'
if not all(col in data.columns for col in required_columns):
raise ValueError(f"缺少必要的数据列,需要: {required_columns}")
self.data = data
self.generatePicture()
def get_data(self):
return self.data
def update_data(self, data):
# 数据验证
required_columns = ['open', 'close', 'volume']
if not all(col in data.columns for col in required_columns):
raise ValueError(f"缺少必要的数据列,需要: {required_columns}")
self.data = data
self.generatePicture()
self.prepareGeometryChange() # 通知框架几何形状可能发生了变化
self.update() # 触发重绘
def generatePicture(self):
# ...生成交易量柱状图?...
self.picture = QtGui.QPicture()
p = QtGui.QPainter(self.picture)
pg.setConfigOptions(leftButtonPan=False, antialias=False)
w = 0.25
for i in range(len(self.data['volume'])):
open_price = self.data['open'][i]
close_price = self.data['close'][i]
volume = self.data['volume'][i] / 10000 # 单位:万
if close_price < open_price:
#下跌- 绿色填充
p.setPen(pg.mkPen(dict_kline_color[IndicatrosEnum.KLINE_DESC.value]))
p.drawRect(QtCore.QRectF(i - w, 0, w * 2, volume))
p.setBrush(pg.mkBrush(dict_kline_color[IndicatrosEnum.KLINE_DESC.value]))
else:
#上涨- 红色空心
p.setPen(pg.mkPen(dict_kline_color[IndicatrosEnum.KLINE_ASC.value]))
p.drawLines(
QtCore.QLineF(QtCore.QPointF(i - w, 0), QtCore.QPointF(i - w, volume)),
QtCore.QLineF(QtCore.QPointF(i - w, volume), QtCore.QPointF(i + w, volume)),
QtCore.QLineF(QtCore.QPointF(i + w, volume), QtCore.QPointF(i + w, 0)),
QtCore.QLineF(QtCore.QPointF(i + w, 0), QtCore.QPointF(i - w, 0))
)
p.end()
def paint(self, p, *args):
p.drawPicture(0, 0, self.picture)
def boundingRect(self):
return QtCore.QRectF(self.picture.boundingRect())
绘制的成交量指标图及交互效果如下:

MACD
MACD指标图也是同理,继承自BaseIndicatorWidget的MacdWidget子类:
python
from PyQt5 import QtCore
from PyQt5 import QtWidgets, uic, QtGui
from PyQt5.QtWidgets import QDialog
from PyQt5.QtCore import pyqtSlot
import pyqtgraph as pg
import numpy as np
from manager.logging_manager import get_logger
from gui.qt_widgets.MComponents.indicators.base_indicator_widget import BaseIndicatorWidget
from gui.qt_widgets.MComponents.indicators.item.macd_item import MACDItem
from gui.qt_widgets.MComponents.indicators.setting.macd_setting_dialog import MacdSettingDialog
from indicators.stock_data_indicators import *
from manager.indicators_config_manager import *
class MacdWidget(BaseIndicatorWidget):
def __init__(self, data, type, parent=None):
super(MacdWidget, self).__init__(data, type, parent)
self.custom_init()
self.load_qss()
def custom_init(self):
self.btn_close.hide()
self.btn_setting.clicked.connect(self.slot_btn_setting_clicked)
def init_para(self, data):
self.logger = get_logger(__name__)
self.indicator_type = IndicatrosEnum.MACD.value
# 检查是否有数据
if data is None or data.empty:
# raise ValueError("数据为空,无法绘制MACD指标图")
self.logger.warning("数据为空,无法绘制MACD指标图")
return
# 确保数据列存在
required_columns = [IndicatrosEnum.MACD_DIFF.value, IndicatrosEnum.MACD_DEA.value, IndicatrosEnum.MACD.value]
if not all(col in data.columns for col in required_columns):
self.logger.warning("缺少必要的数据列来绘制MACD指标图")
raise ValueError("缺少必要的数据列来绘制MACD指标图")
self.df_data = data
def load_qss(self):
self.dict_macd_label = {
0: self.label_diff,
1: self.label_dea
}
dict_settings = get_indicator_config_manager().get_user_config_by_indicator_type(self.indicator_type)
if len(dict_settings) == 3:
self.label_param.setText(f"{dict_settings[0].period, dict_settings[1].period, dict_settings[2].period}")
for id, ma_setting in dict_settings.items():
if id in self.dict_macd_label.keys():
self.dict_macd_label[id].setStyleSheet(f"color: {ma_setting.color_hex}")
def get_ui_path(self):
return './src/gui/qt_widgets/MComponents/indicators/MacdWidget.ui'
def validate_data(self):
required_columns = [IndicatrosEnum.MACD_DIFF.value, IndicatrosEnum.MACD_DEA.value, IndicatrosEnum.MACD.value]
return all(col in self.df_data.columns for col in required_columns)
def create_and_add_item(self):
if self.item is None:
self.item = MACDItem(self.df_data)
else:
self.item.update_data(self.df_data)
self.plot_widget.addItem(self.item)
def set_axis_ranges(self):
# 设置坐标范围
self.plot_widget.setXRange(-1, len(self.df_data) + 1, padding=0)
# 计算Y轴范围
diff_values = self.df_data[IndicatrosEnum.MACD_DIFF.value].dropna()
dea_values = self.df_data[IndicatrosEnum.MACD_DEA.value].dropna()
macd_values = self.df_data[IndicatrosEnum.MACD.value].dropna()
if len(diff_values) > 0 and len(dea_values) > 0 and len(macd_values) > 0:
y_max = max(np.max(np.abs(diff_values)), np.max(np.abs(dea_values)), np.max(np.abs(macd_values)))
y_max = y_max * 1.2 if y_max > 0 else 1
self.plot_widget.setYRange(-y_max, y_max, padding=0)
def get_chart_name(self):
return "MACD"
def update_widget_labels(self):
self.slot_global_update_labels(self, -1)
def additional_draw(self):
"""添加零轴线"""
# 添加零轴线
zero_line = pg.InfiniteLine(pos=0, angle=0, pen=pg.mkPen('g', width=1, style=QtCore.Qt.DashLine))
self.plot_widget.addItem(zero_line)
def slot_btn_setting_clicked(self):
dlg = MacdSettingDialog()
result = dlg.exec()
if result == QDialog.Accepted:
self.logger.info("更新MACD设置")
auto_macd_calulate(self.df_data)
self.update_data(self.df_data)
self.auto_scale_to_latest(120)
def slot_range_changed(self):
'''当视图范围改变时调用'''
# y轴坐标值同步
# 获取当前x轴视图范围内的数据
visible_data, x_min, x_max = self.get_visible_data_range()
if visible_data is None:
return
# 根据当前可视范围内的数据的最大、最小值调整Y轴坐标值范围
# MACD指标需要考虑diff、dea、macd三列数据
required_columns = [IndicatrosEnum.MACD_DIFF.value, IndicatrosEnum.MACD_DEA.value, IndicatrosEnum.MACD.value]
# 检查所需列是否存在
if not all(col in visible_data.columns for col in required_columns):
return
# 计算可视范围内的绝对值最大值(MACD通常围绕0轴对称)
y_max = visible_data[required_columns].abs().max().max()
# 防止y_max为0的情况
y_max = y_max * 1.2 if y_max > 0 else 1
# 重新设置Y轴刻度(保持对称)
self.plot_widget.setYRange(-y_max, y_max, padding=0)
def slot_global_update_labels(self, sender, closest_index):
if self.df_data is None or self.df_data.empty:
return
if self.type != sender.type:
# self.logger.info(f"不响应其他窗口的鼠标移动事件")
return
macd = self.df_data.iloc[closest_index][IndicatrosEnum.MACD.value] # 这里直接使用枚举值,是因为计算时就以枚举值固定命名
if macd > 0:
self.label_macd.setStyleSheet(f"color: {dict_kline_color_hex[IndicatrosEnum.KLINE_ASC.value]};")
else:
self.label_macd.setStyleSheet(f"color: {dict_kline_color_hex[IndicatrosEnum.KLINE_DESC.value]};")
dict_settings = get_indicator_config_manager().get_user_config_by_indicator_type(self.indicator_type)
if len(dict_settings) == 3:
self.label_param.setText(f"{dict_settings[0].period, dict_settings[1].period, dict_settings[2].period}")
self.dict_macd_label = {
0: self.label_diff,
1: self.label_dea
}
for id, setting in dict_settings.items():
if id in self.dict_macd_label.keys() and setting.name in self.df_data.columns:
if id == 2:
continue
self.dict_macd_label[id].setText(f"{setting.name}:{self.df_data.iloc[closest_index][setting.name]:.2f}")
self.dict_macd_label[id].setVisible(setting.visible)
self.dict_macd_label[id].setStyleSheet(f"color: {setting.color_hex}")
def slot_global_reset_labels(self, sender):
self.slot_global_update_labels(sender, -1)
def slot_v_line_mouse_moved(self, sender, x_pos):
# self.logger.info(f"正在处理{self.get_chart_name()}鼠标移动响应, self: {self}, sender: {sender}")
if self.type != sender.type:
# self.logger.info(f"不响应其他窗口的鼠标移动事件")
return
self.v_line.setPos(x_pos)
self.v_line.show()
对应ui:

再实现对应MACDItem来完成MACD指标的绘制:
python
# file: gui/qt_widgets/MComponents/macd_item.py
import pyqtgraph as pg
from PyQt5 import QtGui, QtCore
import numpy as np
from manager.indicators_config_manager import *
class MACDItem(pg.GraphicsObject):
"""MACD指标绘制类"""
def __init__(self, data):
pg.GraphicsObject.__init__(self)
# 数据验证
required_columns = [IndicatrosEnum.MACD_DIFF.value, IndicatrosEnum.MACD_DEA.value, IndicatrosEnum.MACD.value]
if not all(col in data.columns for col in required_columns):
raise ValueError(f"缺少必要的数据列,需要: {required_columns}")
self.data = data
self.generatePicture()
def get_data(self):
return self.data
def update_data(self, data):
# 数据验证
required_columns = [IndicatrosEnum.MACD_DIFF.value, IndicatrosEnum.MACD_DEA.value, IndicatrosEnum.MACD.value]
if not all(col in data.columns for col in required_columns):
raise ValueError(f"缺少必要的数据列,需要: {required_columns}")
self.data = data
self.generatePicture()
self.prepareGeometryChange() # 通知框架几何形状可能发生了变化
self.update() # 触发重绘
def generatePicture(self):
"""生成MACD图"""
self.picture = QtGui.QPicture()
p = QtGui.QPainter(self.picture)
pg.setConfigOptions(leftButtonPan=False, antialias=False)
w = 0.3
# 绘制MACD柱状图
for i in range(len(self.data)):
macd_value = self.data[IndicatrosEnum.MACD.value].iloc[i]
if not np.isnan(macd_value):
if macd_value >= 0:
# 正数 - 红色
p.setPen(pg.mkPen(dict_kline_color[IndicatrosEnum.KLINE_ASC.value]))
p.setBrush(pg.mkBrush(dict_kline_color[IndicatrosEnum.KLINE_ASC.value]))
else:
# 负数 - 绿色
p.setPen(pg.mkPen(dict_kline_color[IndicatrosEnum.KLINE_DESC.value]))
p.setBrush(pg.mkBrush(dict_kline_color[IndicatrosEnum.KLINE_DESC.value]))
# 绘制柱状图
p.drawRect(QtCore.QRectF(i - w, 0, w * 2, macd_value))
dict_settings = get_indicator_config_manager().get_user_config_by_indicator_type(IndicatrosEnum.MACD.value)
if dict_settings is None or len(dict_settings) < 2:
dict_settings = get_indicator_config_manager().get_default_config_by_indicator_type(IndicatrosEnum.MACD.value)
# 绘制DIFF线 (MACD线)
if dict_settings[0].visible:
diff_points = []
for i in range(len(self.data)):
diff_value = self.data[dict_settings[0].name].iloc[i]
if not np.isnan(diff_value):
diff_points.append(QtCore.QPointF(i, diff_value))
if len(diff_points) > 1:
p.setPen(pg.mkPen(dict_settings[0].color_hex, width=dict_settings[0].line_width))
for i in range(len(diff_points) - 1):
p.drawLine(diff_points[i], diff_points[i + 1])
# 绘制DEA线 (信号线)
if dict_settings[1].visible:
dea_points = []
for i in range(len(self.data)):
dea_value = self.data[dict_settings[1].name].iloc[i]
if not np.isnan(dea_value):
dea_points.append(QtCore.QPointF(i, dea_value))
if len(dea_points) > 1:
p.setPen(pg.mkPen(dict_settings[1].color_hex, width=dict_settings[1].line_width))
for i in range(len(dea_points) - 1):
p.drawLine(dea_points[i], dea_points[i + 1])
p.end()
def paint(self, p, *args):
p.drawPicture(0, 0, self.picture)
def boundingRect(self):
return QtCore.QRectF(self.picture.boundingRect())
绘制的MACD指标图及交互效果如下:

其他指标的实现及处理类似,这里就不再赘述了。