PyQtGraph应用(四):基于PyQtGraph的K线指标图绘制

PyQtGraph应用(四):基于PyQtGraph的K线指标图绘制

前言

PyQTGraph简介

深入理解PyQtGraph核心组件及交互

PyQTGraph重要概念、类

PyQTGraph中的PlotWidget详解

PyQTGraph应用(一)

PyQTGraph应用(二)

PyQTGraph应用(三)

通过之前分享的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指标图及交互效果如下:

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

相关推荐
2301_790300964 小时前
Python单元测试(unittest)实战指南
jvm·数据库·python
VCR__4 小时前
python第三次作业
开发语言·python
韩立学长4 小时前
【开题答辩实录分享】以《助农信息发布系统设计与实现》为例进行选题答辩实录分享
python·web
wkd_0074 小时前
【Qt | QTableWidget】QTableWidget 类的详细解析与代码实践
开发语言·qt·qtablewidget·qt5.12.12·qt表格
2401_838472514 小时前
使用Scikit-learn构建你的第一个机器学习模型
jvm·数据库·python
u0109272714 小时前
使用Python进行网络设备自动配置
jvm·数据库·python
工程师老罗5 小时前
优化器、反向传播、损失函数之间是什么关系,Pytorch中如何使用和设置?
人工智能·pytorch·python
Fleshy数模5 小时前
我的第一只Python爬虫:从Requests库到爬取整站新书
开发语言·爬虫·python
CoLiuRs5 小时前
Image-to-3D — 让 2D 图片跃然立体*
python·3d·flask
残梦53145 小时前
Qt6.9.1起一个图片服务器(支持前端跨域请求,不支持上传,可扩展)
运维·服务器·开发语言·c++·qt