PyQtGraph应用(五):k线回放复盘功能实现

前言

PyQTGraph简介

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

PyQTGraph重要概念、类

PyQTGraph中的PlotWidget详解

PyQTGraph应用(一)

PyQTGraph应用(二)

PyQTGraph应用(三)

PyQTGraph应用(四)

本文示例在前面文章基础上扩展。

k线回放复盘这个功能感觉还是挺实用的,但国内各大主流行情软件却没有提供这个功能,目前只在国外的行情软件(如:TradingView-付费、AICoin-不支持国内A股)用过,索性就自己写个好了。

效果对比及预览

TradingView:
1

AICoin:
2

代码实现:
3

代码实现

主要在此前分享的:PyQTGraph应用(四)基础上扩展。

此前分享的IndicatorsViewWidget中k线、指标绘制逻辑是传入要绘制的k线数据,这里只需要控制一下绘制的范围,就可以实现k线指标播放、暂停、前进、后退的功能。

初始化回放动画

def init_animation(self, data, start_date, b_init=True):
    dict_return = {}
    code = data['code']
    self.logger.info(f"初始化动画:{code}, 日期:{start_date}")
    self.update_stock_data_dict(code)
    df = self.get_stock_data()

    if df is None or df.empty:
        self.logger.warning(f"数据为空,无法初始化动画:{code}")
        self.sig_init_review_animation_finished.emit(False, dict_return)
        return dict_return

    if start_date is not None and start_date != "":
        self.min_animation_index = 0
        self.max_animation_index = len(df) - 1

        checked_id = self.period_button_group.checkedId()
        last_period_text = self.period_button_group.button(self.last_period_btn_checked_id).text()
        last_period = TimePeriod.from_label(last_period_text)

        target_period_text = self.period_button_group.button(checked_id).text()
        target_period = TimePeriod.from_label(target_period_text)
        current_period_date_col = 'time' if TimePeriod.is_minute_level(last_period) else 'date'

        if b_init:
            matching_indices = df[df['date'] <= start_date].index
            if len(matching_indices) > 0:
                    # 普通处理
                    self.logger.info(f"动画初始化--找到 {len(matching_indices)} 个匹配的日期记录")
                    self.start_animation_index = matching_indices[-1]
                    self.logger.info(f"start_date索引: {self.start_animation_index}")

                    review_period_process_data = ReviewPeriodProcessData()
                    review_period_process_data.current_period = target_period
                    review_period_process_data.current_start_date_time = df['date'].iloc[self.start_animation_index]
                    review_period_process_data.current_min_index = 0
                    review_period_process_data.current_max_index = self.max_animation_index
                    review_period_process_data.current_index = self.start_animation_index
                    review_period_process_data.current_start_index = self.start_animation_index
                    self.dict_period_process_data[target_period] = review_period_process_data

                    self.update_chart(data, self.start_animation_index)
                    dict_return = {
                        "start_date_index": self.start_animation_index,
                        "min_index": self.min_animation_index,
                        "max_index": self.max_animation_index,
                        "start_date": review_period_process_data.current_start_date_time
                    }

                    self.last_period_btn_checked_id = 7
                    self.min_period = TimePeriod.DAY
            else:
                self.logger.warning(f"普通处理--未找到匹配的日期记录:{start_date}")
        else:
            if checked_id >= 8:
                matching_indices = df[df['date'] < start_date].index    # 这里使用<是因为本周未结束时,Baostock无本周周线数据,因此加载上周周线数据。
            else:
                matching_indices = df[df['date'] == start_date].index

            matching_indices_len = len(matching_indices)
            self.logger.info(f"切换--找到 {matching_indices_len} 个匹配的日期记录")

            if matching_indices_len > 0:
                # 周期切换步骤。来源周期,目标周期
                # 目标周期是否第一次切换?
                # 第一次切换默认到最后索引
                # 不是第一次切换则判断来源周期索引是否发生变化
                # 有发生变化则更新目标周期索引,没有发生变化则自动切换到目标周期索引
                s_last_period_text = TimePeriod.get_chinese_label(last_period)
                s_target_period_text = TimePeriod.get_chinese_label(target_period)
                self.logger.info(f"周期切换--来源周期:{s_last_period_text},目标周期:{s_target_period_text}")

                self.logger.info(f"来源周期当前索引:{self.current_animation_index},开始索引:{self.start_animation_index}")

                last_current_index = self.dict_period_process_data[last_period].current_index
                last_start_index = self.dict_period_process_data[last_period].current_start_index
                self.logger.info(f"来源周期[{s_last_period_text}]索引:{last_current_index},日期:{self.dict_period_process_data[last_period].current_date_time},来源周期[{s_last_period_text}]开始索引:{last_start_index}, 开始日期:{self.dict_period_process_data[last_period].current_start_date_time}")

                index = self.get_target_index_auto(last_period, target_period)
                self.logger.info(f"目标周期索引:{index}")
                self.start_animation_index = matching_indices[index]
                if target_period not in self.dict_period_process_data:
                    # 第1次切换,默认到最后索引
                    self.logger.info(f"第1次切换")
                    # index = self.get_target_index_auto(last_period, target_period)
                    # self.start_animation_index = matching_indices[index]
                else:
                    target_current_index = self.dict_period_process_data[target_period].current_index
                    target_start_index = self.dict_period_process_data[target_period].current_start_index
                    self.logger.info(f"上次目标周期[{s_target_period_text}]索引:{target_current_index},日期:{self.dict_period_process_data[target_period].current_date_time},上次目标周期[{s_target_period_text}]开始索引:{target_start_index},日期:{self.dict_period_process_data[target_period].current_start_date_time}")

                    # TODO优化:更新来源周期索引判断是否合成目标周期索引对应的值

                review_period_process_data = ReviewPeriodProcessData()
                review_period_process_data.current_period = target_period
                review_period_process_data.current_start_date_time = df['date'].iloc[self.start_animation_index]
                review_period_process_data.current_min_index = 0
                review_period_process_data.current_max_index = self.max_animation_index
                review_period_process_data.current_index = self.start_animation_index
                review_period_process_data.current_start_index = self.start_animation_index
                self.dict_period_process_data[target_period] = review_period_process_data
                if target_period < self.min_period:
                    self.min_period = target_period
                    self.logger.info(f"更新最小周期为:{TimePeriod.get_chinese_label(self.min_period)}")

                self.update_chart(data, self.start_animation_index)
                dict_return = {
                        "start_date_index": self.start_animation_index,
                        "min_index": self.min_animation_index,
                        "max_index": self.max_animation_index,
                        "start_date": review_period_process_data.current_start_date_time
                    }
            else:
                self.logger.warning(f"周期切换处理--未找到匹配的日期记录:{start_date}")

    else:
        self.logger.info("start_date为空")


    self.sig_init_review_animation_finished.emit(True, dict_return)
    return dict_return

维护了k线数据的索引以及当前显示索引位置后,添加播放/暂停控制接口:

播放:

def start_animation(self, start_index=None):
    """开始动画播放"""
    self.animation_timer.start(self.animation_speed)
    self.is_playing = True
    self.enable_period_btn(False)

暂停:

def pause_animation(self):
    """暂停动画播放"""
    self.animation_timer.stop()
    self.is_playing = False
    self.enable_period_btn(True)

停止:

def stop_animation(self):
    """停止动画播放"""
    self.animation_timer.stop()
    self.is_playing = False
    self.enable_period_btn(True)

设置播放速度:

def set_animation_speed(self, speed_ms):
    """设置动画播放速度"""
    self.animation_speed = speed_ms
    if self.is_playing:
        self.animation_timer.stop()
        self.animation_timer.start(self.animation_speed)

再添加前进、后退接口:

前进指定步数:

def step_forward(self, steps=1):
    """向前播放指定步数"""
    if self.df_data is None or self.df_data.empty:
        return

    new_index = self.current_animation_index + steps

    if new_index < 0:
        QMessageBox.warning(self, "提示", "已到达最前")
        self.sig_animation_play_finished.emit()
        return

    if new_index > self.max_animation_index:
        QMessageBox.warning(self, "提示", "已到达最后")
        return

    data = self.df_data.iloc[0]
    self.update_chart(data, new_index)

回退指定步数:

def step_backward(self, steps=1):
    """向后回退指定步数"""
    if self.df_data is None or self.df_data.empty:
        return

    new_index = self.current_animation_index - steps

    if new_index < 0:
        QMessageBox.warning(self, "提示", "已到达最前")
        return

    data = self.df_data.iloc[0]
    self.update_chart(data, new_index)

回退到最前:

def back_to_front(self):
    """回到最前"""
    if self.df_data is None or self.df_data.empty:
        return

    data = self.df_data.iloc[0]
    self.update_chart(data, 0)

前进到最后:

def back_to_end(self):
    """回到最后"""
    if self.df_data is None or self.df_data.empty:
        return

    data = self.df_data.iloc[0]
    self.update_chart(data, self.max_animation_index)

跳到指定索引:

def go_to_target_index(self, index):
    """跳转到指定索引"""
    if self.df_data is None or self.df_data.empty:
        return

    if index == self.current_animation_index:
        return

    data = self.df_data.iloc[0]
    self.update_chart(data, index)

然后就是外层的ReviewWidget实现,UI如下:
4

代码:

from PyQt5 import QtCore, uic, QtGui
from PyQt5.QtWidgets import QWidget, QDialog, QMessageBox, QListWidget, QListWidgetItem
from PyQt5.QtCore import QDate, QFile

import random
from datetime import date

from manager.logging_manager import get_logger
from gui.qt_widgets.MComponents.demo_trading_card_widget import DemoTradingCardWidget
from gui.qt_widgets.MComponents.demo_trading_record_widget import DemoTradingRecordWidget

from processor.baostock_processor import BaoStockProcessor
from manager.bao_stock_data_manager import BaostockDataManager
from manager.period_manager import TimePeriod
from manager.review_demo_trading_manager import ReviewDemoTradingManager

class ReviewWidget(QWidget):
    def __init__(self, parent=None):
        super(ReviewWidget, self).__init__(parent) 
        uic.loadUi('./src/gui/qt_widgets/MComponents/ReviewWidget.ui', self)

        self.init_para()
        self.init_ui()
        self.init_connect()

        # self.load_data('sh.600000', "2025-12-08")

    def init_para(self):
        self.logger = get_logger(__name__)

        self.type = 0   # 0: 模块;1:对话框
        self.dict_progress_data = {}

        self.current_load_code = ""

        self.demo_trading_manager = ReviewDemoTradingManager()

    def init_ui(self):
        from gui.qt_widgets.MComponents.indicators.indicators_view_widget import IndicatorsViewWidget
        self.indicators_view_widget = IndicatorsViewWidget(self)
        self.indicators_view_widget.setProperty("review", True)
        self.indicators_view_widget.show_review_btn(False)

        self.verticalLayout_indicators_view.addWidget(self.indicators_view_widget)

        self.comboBox_period.addItems([TimePeriod.get_chinese_label(TimePeriod.DAY), TimePeriod.get_chinese_label(TimePeriod.WEEK), TimePeriod.get_chinese_label(TimePeriod.MINUTE_15), TimePeriod.get_chinese_label(TimePeriod.MINUTE_30), TimePeriod.get_chinese_label(TimePeriod.MINUTE_60)])
        self.comboBox_period.setCurrentIndex(0)

        self.btn_load_data_random.setAutoDefault(False)
        self.btn_load_data_random.setDefault(False)

        self.btn_load_data.setAutoDefault(False)
        self.btn_load_data.setDefault(False)

        self.playing_enabled(True, True)

        self.label_total_assets.setText(str(self.demo_trading_manager.get_total_assets()))
        self.label_available_balance.setText(str(self.demo_trading_manager.get_available_balance()))

        self.listWidget_trading_record = QListWidget(self)
        self.demo_trading_record_widget = DemoTradingRecordWidget(self)

        self.stackedWidget_trading_record.addWidget(self.listWidget_trading_record)
        self.stackedWidget_trading_record.addWidget(self.demo_trading_record_widget)
        self.stackedWidget_trading_record.setCurrentWidget(self.listWidget_trading_record)

        self.load_qss()
        self.btn_play.setProperty("is_play", False)
        self.btn_play.style().unpolish(self.btn_play)
        self.btn_play.style().polish(self.btn_play)
        self.btn_play.update()

    def init_connect(self):
        self.indicators_view_widget.sig_current_animation_index_changed.connect(self.slot_current_animation_index_changed)
        self.indicators_view_widget.sig_init_review_animation_finished.connect(self.slot_init_review_animation_finished)
        self.indicators_view_widget.sig_animation_play_finished.connect(self.slot_animation_play_finished)

        self.lineEdit_code.editingFinished.connect(self.slot_lineEdit_code_editingFinished)
        self.dateEdit.dateChanged.connect(self.slot_dateEdit_dateChanged)
        self.comboBox_period.currentIndexChanged.connect(self.slot_comboBox_period_currentIndexChanged)

        self.btn_load_data_random.clicked.connect(self.slot_btn_load_data_random_clicked)
        self.btn_load_data.clicked.connect(self.slot_btn_load_data_clicked)

        self.btn_play.clicked.connect(self.slot_btn_play_clicked)

        self.btn_back_to_front.clicked.connect(self.slot_btn_back_to_front_clicked)
        self.btn_back_ten.clicked.connect(self.slot_btn_back_ten_clicked)
        self.btn_back.clicked.connect(self.slot_btn_back_clicked)
        self.btn_move_on.clicked.connect(self.slot_btn_move_on_clicked)
        self.btn_move_on_10.clicked.connect(self.slot_btn_move_on_10_clicked)
        self.btn_move_to_last.clicked.connect(self.slot_btn_move_to_last_clicked)

        self.horizontalSlider_progress.valueChanged.connect(self.slot_horizontalSlider_progress_valueChanged)

        # 模拟交易
        self.lineEdit_price.editingFinished.connect(self.slot_lineEdit_price_editingFinished)
        self.btn_all.clicked.connect(self.slot_btn_all_clicked)
        self.btn_one_half.clicked.connect(self.slot_btn_one_half_clicked)
        self.btn_one_third.clicked.connect(self.slot_btn_one_third_clicked)
        self.btn_a_quarter.clicked.connect(self.slot_btn_a_quarter_clicked)
        self.btn_one_in_five.clicked.connect(self.slot_btn_one_in_five_clicked)

        self.btn_buy.clicked.connect(self.slot_btn_buy_clicked)
        self.btn_sell.clicked.connect(self.slot_btn_sell_clicked)
        self.btn_pending_order_cancel.clicked.connect(self.slot_btn_pending_order_cancel_clicked)

        self.demo_trading_manager.sig_total_assets_and_available_balance_changed.connect(self.update_assets_and_available_balance)
        self.demo_trading_manager.sig_trading_status_changed.connect(self.slot_demo_trading_manager_sig_trading_status_changed)
        self.demo_trading_record_widget.sig_btn_return_clicked.connect(self.slot_demo_trading_record_widget_sig_btn_return_clicked)

    def load_qss(self, theme="default"):
        qss_file_name = f":/theme/{theme}/mcomponents/review_widget.qss"
        self.logger.info(f"回放模块样式表文件路径:{qss_file_name}")
        qssFile = QFile(qss_file_name)
        if qssFile.open(QFile.ReadOnly):
            str_qss = str(qssFile.readAll(), encoding='utf-8')
            # self.logger.info(f"回放模块样式表内容:{str_qss}")
            self.setStyleSheet(str_qss)
        else:
            self.logger.warning("无法打开回放模块样式表文件")
        qssFile.close()


    def preload_data(self, df_row):
        self.logger.info(f"回放预加载数据: {df_row}")
        if df_row is None:
            self.logger.warning("回放预加载数据为空")
            return

        # 对话框模式才会预加载
        self.type = 1

        self.label_name.setText(df_row["name"])
        self.lineEdit_code.setText(df_row["code"])

        # self.dateEdit.blockSignals(True)
        self.dateEdit.setDate(QDate.fromString(df_row['date'], "yyyy-MM-dd"))
        # self.dateEdit.blockSignals(False)

        self.btn_load_data_random.hide()
        self.lineEdit_code.setEnabled(False)

    def update_assets_and_available_balance(self, total_assets, available_balance):
        self.label_total_assets.setText(f"{total_assets:.2f}")
        self.label_available_balance.setText(f"{available_balance:.2f}")

    def update_count_and_amount_labels(self, price, count):
        self.lineEdit_count.setText(str(count))
        self.lineEdit_amount.setText(str(count * price))

    def playing_enabled(self, is_playing, b_init=False):
        self.lineEdit_code.setEnabled(not is_playing and self.type == 0)
        self.dateEdit.setEnabled(True if b_init else not is_playing)

        self.btn_back_to_front.setEnabled(not is_playing)
        self.btn_back_ten.setEnabled(not is_playing)
        self.btn_back.setEnabled(not is_playing)
        self.btn_move_on.setEnabled(not is_playing)
        self.btn_move_on_10.setEnabled(not is_playing)
        self.btn_move_to_last.setEnabled(not is_playing)

        self.horizontalSlider_progress.setEnabled(not is_playing)

        self.btn_buy.setEnabled(not is_playing)
        self.btn_sell.setEnabled(not is_playing)
        self.btn_pending_order_cancel.setEnabled(not is_playing)

        self.btn_all.setEnabled(not is_playing)
        self.btn_one_half.setEnabled(not is_playing)
        self.btn_one_third.setEnabled(not is_playing)
        self.btn_a_quarter.setEnabled(not is_playing)
        self.btn_one_in_five.setEnabled(not is_playing)


    def update_progress_label(self, current_index):
        if self.dict_progress_data is not None and self.dict_progress_data != {}:
            s_current_progress = f"{current_index}/{self.dict_progress_data['max_index']}"
            self.label_progress.setText(s_current_progress)
        else:
            self.logger.info("进度数据为空")
            self.label_progress.setText("")

    def update_trading_widgets_status(self):
        trading_status = self.demo_trading_manager.get_trading_status()

        if trading_status == 1 or trading_status == 3:
            self.btn_all.setEnabled(False)
            self.btn_one_half.setEnabled(False)
            self.btn_one_third.setEnabled(False)
            self.btn_a_quarter.setEnabled(False)
            self.btn_one_in_five.setEnabled(False)
            self.btn_buy.setEnabled(False)
            self.btn_sell.setEnabled(False)
            self.btn_pending_order_cancel.setEnabled(True)
        elif trading_status == 5:
            self.btn_all.setEnabled(False)
            self.btn_one_half.setEnabled(False)
            self.btn_one_third.setEnabled(False)
            self.btn_a_quarter.setEnabled(False)
            self.btn_one_in_five.setEnabled(False)
            self.btn_buy.setEnabled(False)
            self.btn_sell.setEnabled(True)
            self.btn_pending_order_cancel.setEnabled(False)
        else:
            self.btn_all.setEnabled(True)
            self.btn_one_half.setEnabled(True)
            self.btn_one_third.setEnabled(True)
            self.btn_a_quarter.setEnabled(True)
            self.btn_one_in_five.setEnabled(True)
            self.btn_buy.setEnabled(True)
            self.btn_sell.setEnabled(False)
            self.btn_pending_order_cancel.setEnabled(False)

    def reset_trading_record(self):
        self.lineEdit_price.blockSignals(True)
        self.lineEdit_price.clear()
        self.lineEdit_price.blockSignals(False)

        self.lineEdit_count.clear()
        self.lineEdit_amount.clear()

        # 清空收益率曲线

        # 清空交易记录列表

    def load_data(self, code, date):
        stock_codes = [code]
        bao_stock_data_manager = BaostockDataManager()
        new_dict_lastest_1d_stock_data = bao_stock_data_manager.get_lastest_row_data_dict_by_code_list_auto(stock_codes)
        # self.logger.info(f"new_dict_lastest_1d_stock_data: {new_dict_lastest_1d_stock_data}")

        if new_dict_lastest_1d_stock_data:
            self.indicators_view_widget.update_stock_data_dict(code)
            data = new_dict_lastest_1d_stock_data[code].iloc[-1]
            # self.indicators_view_widget.update_chart(data, '2025-12-08')
            self.dict_progress_data = self.indicators_view_widget.init_animation(data, date)

            self.current_load_code = code

            # self.demo_trading_manager.reset_trading_record()  # 重新加载不用情况当前收益
            self.playing_enabled(False)
            self.update_trading_widgets_status()
            self.reset_trading_record()

        else:
            self.logger.info(f"结果为空")

    def get_random_date(self, latest_date_str=None, days_before_start=360, days_before_end=120):
        '''
        根据给定的最新日期和范围参数,在指定范围内随机返回一个日期

        Args:
            latest_date_str (str): 最新日期字符串,格式为 "YYYY-MM-DD",默认为当前日期
            days_before_start (int): 最新日期往前推的起始天数,默认为30天
            days_before_end (int): 最新日期往前推的结束天数,默认为1天

        Returns:
            str: 格式为 "YYYY-MM-DD" 的随机日期字符串

        Example:
            # 在 2026-01-19 前 30 天到 1 天的范围内随机选择日期
            random_date = get_random_date("2026-01-19", 30, 1)
        '''
        from datetime import datetime, timedelta

        # 如果未提供最新日期,则使用当前日期
        if latest_date_str is None:
            latest_date = date.today()
        else:
            try:
                latest_date = datetime.strptime(latest_date_str, "%Y-%M-%D").date()
            except ValueError:
                # 处理输入日期格式错误的情况
                latest_date = date.today()

        # 计算范围边界
        start_date = latest_date - timedelta(days=days_before_start)
        end_date = latest_date - timedelta(days=days_before_end)

        # 确保开始日期不晚于结束日期
        if start_date > end_date:
            start_date, end_date = end_date, start_date

        # 计算日期范围内的总天数
        total_days = (end_date - start_date).days

        # 在范围内随机选择一天
        random_day_offset = random.randint(0, total_days)
        random_date = start_date + timedelta(days=random_day_offset)

        return random_date.strftime("%Y-%m-%d")



    # -----------------槽函数----------------
    def slot_current_animation_index_changed(self, index):
        # self.logger.info(f"收到k线图进度: {index}")

        self.horizontalSlider_progress.blockSignals(True)
        self.horizontalSlider_progress.setSliderPosition(index)
        self.horizontalSlider_progress.blockSignals(False)

        self.update_progress_label(index)

        date_time = self.indicators_view_widget.get_current_date_time_by_index(index)
        dict_kline_price = self.indicators_view_widget.get_kline_price_by_index(index)

        trading_status = self.demo_trading_manager.get_trading_status()

        target_status = 0
        if trading_status == 1:
            target_status = 1
        elif trading_status == 3:
            target_status = 2
        elif trading_status == 5:
            target_status = 5

        self.demo_trading_manager.update_trading_record(target_status, dict_kline_price, date_time)

    def slot_init_review_animation_finished(self, success, dict_progress_data):
        if success:
            self.logger.info("回放动画初始化成功")
            self.dict_progress_data = dict_progress_data
            if self.dict_progress_data is not None and self.dict_progress_data != {}:
                self.horizontalSlider_progress.setMinimum(self.dict_progress_data['min_index'])
                self.horizontalSlider_progress.setMaximum(self.dict_progress_data['max_index'])

                self.horizontalSlider_progress.blockSignals(True)
                self.horizontalSlider_progress.setSliderPosition(self.dict_progress_data['start_date_index'])
                self.horizontalSlider_progress.blockSignals(False)

                self.update_progress_label(self.dict_progress_data['start_date_index'])

            else:
                self.logger.info(f"初始化返回的进度数据为空")

    def slot_animation_play_finished(self):
        self.slot_btn_play_clicked()

    def slot_lineEdit_code_editingFinished(self):
        code = self.lineEdit_code.text()
        dict_lastest_1d_data = BaostockDataManager().get_lastest_1d_stock_data_dict_from_cache()
        if code not in dict_lastest_1d_data:
            QMessageBox.warning(self, "提示", "请输入正确的股票代码")
            return

        self.label_name.setText(str(dict_lastest_1d_data[code]['name'].iloc[0]))

        self.lineEdit_code.blockSignals(True)
        self.lineEdit_code.clearFocus()
        self.lineEdit_code.blockSignals(False)

    def slot_dateEdit_dateChanged(self, date):
        self.logger.info(f"收到日期选择: {date}")
        s_date = date.toString("yyyy-MM-dd")
        self.logger.info(f"收到日期选择--s_date: {s_date}")

    def slot_comboBox_period_currentIndexChanged(self, index):
        text = self.comboBox_period.currentText()
        self.logger.info(f"收到周期选择: {text}, index: {index}")

    def slot_btn_load_data_random_clicked(self):
        # 从 dict_lastest_1d_data 中获取一个随机的 code 和对应的 name
        dict_lastest_1d_data = BaostockDataManager().get_lastest_1d_stock_data_dict_from_cache()
        if dict_lastest_1d_data:
            # 随机选择一个 code
            code = random.choice(list(dict_lastest_1d_data.keys()))

            # 获取对应的 name
            name = dict_lastest_1d_data[code]['name'].iloc[0]

            newest_date = dict_lastest_1d_data[code]['date'].iloc[0]

            print(f"随机股票代码: {code}, 对应名称: {name},最新日期: {newest_date}")
        else:
            print("没有可用的股票数据")
            return

        date = self.get_random_date(newest_date)

        period = self.comboBox_period.currentText()
        self.logger.info(f"点击随机加载数据: {code}, {date}, {period}")

        if self.current_load_code == code:
            self.logger.info("当前股票数据已加载,无需重复加载")
            return

        self.load_data(code, date)

        start_date = self.dict_progress_data['start_date']
        self.logger.info(f"实际开始日期--start_date: {start_date}")

        self.label_name.setText(str(dict_lastest_1d_data[code]['name'].iloc[0]))

        self.lineEdit_code.blockSignals(True)
        self.lineEdit_code.setText(code)
        self.lineEdit_code.blockSignals(False)

        self.dateEdit.blockSignals(True)
        self.dateEdit.setDate(QDate.fromString(start_date, "yyyy-MM-dd"))
        self.dateEdit.blockSignals(False)

        self.btn_buy.setDefault(True)


    def slot_btn_load_data_clicked(self):
        code = self.lineEdit_code.text()
        date = self.dateEdit.date().toString("yyyy-MM-dd")
        period = self.comboBox_period.currentText()
        self.logger.info(f"点击加载数据: {code}, {date}, {period}")

        if self.current_load_code == code:
            self.logger.info("当前股票数据已加载,无需重复加载")
            return

        self.load_data(code, date)

        start_date = self.dict_progress_data['start_date']
        self.logger.info(f"实际开始日期--start_date: {start_date}")

        self.dateEdit.blockSignals(True)
        self.dateEdit.setDate(QDate.fromString(start_date, "yyyy-MM-dd"))
        self.dateEdit.blockSignals(False)

        self.btn_buy.setDefault(True)

    def slot_btn_play_clicked(self):
        if self.btn_play.property("is_play"):
            self.logger.info("暂停播放")
            self.indicators_view_widget.pause_animation()
            self.btn_play.setProperty("is_play", False)
            # self.btn_play.setIcon(QtGui.QIcon("./src/gui/qt_widgets/images/pause.png"))
            self.playing_enabled(False)
        else:
            self.logger.info("开始播放")
            self.indicators_view_widget.start_animation()
            self.btn_play.setProperty("is_play", True)
            self.playing_enabled(True)

        self.btn_play.style().unpolish(self.btn_play)
        self.btn_play.style().polish(self.btn_play)
        self.btn_play.update()

    def slot_btn_back_to_front_clicked(self):
        self.indicators_view_widget.back_to_front()

    def slot_btn_back_ten_clicked(self):
        self.indicators_view_widget.step_backward(10)

    def slot_btn_back_clicked(self):
        self.indicators_view_widget.step_backward()

    def slot_btn_move_on_clicked(self):
        self.indicators_view_widget.step_forward()

    def slot_btn_move_on_10_clicked(self):
        self.indicators_view_widget.step_forward(10)

    def slot_btn_move_to_last_clicked(self):
        self.indicators_view_widget.back_to_end()

    def slot_horizontalSlider_progress_valueChanged(self, value):
        self.logger.info(f"进度条值改变: {value}")
        self.indicators_view_widget.go_to_target_index(value)

    def slot_lineEdit_price_editingFinished(self):
        str_price = self.lineEdit_price.text()
        str_count = self.lineEdit_count.text()

        if str_price == "" or str_count == "":
            return
        self.lineEdit_amount.setText(str(float(str_price) * int(str_count)))

    def slot_btn_all_clicked(self):
        str_price = self.lineEdit_price.text()
        max_count = self.demo_trading_manager.get_buy_count(float(str_price))

        self.logger.info(f"最大可买数量: {max_count}")
        self.lineEdit_count.setText(str(max_count))
        self.lineEdit_amount.setText(str(max_count * float(str_price)))

    def slot_btn_one_half_clicked(self):
        str_price = self.lineEdit_price.text()
        max_count = self.demo_trading_manager.get_buy_count(float(str_price), 1)

        self.logger.info(f"最大可买数量: {max_count}")
        self.lineEdit_count.setText(str(max_count))
        self.lineEdit_amount.setText(str(max_count * float(str_price)))

    def slot_btn_one_third_clicked(self):
        str_price = self.lineEdit_price.text()
        max_count = self.demo_trading_manager.get_buy_count(float(str_price), 2)
        self.logger.info(f"最大可买数量: {max_count}")
        self.lineEdit_count.setText(str(max_count))
        self.lineEdit_amount.setText(str(max_count * float(str_price)))

    def slot_btn_a_quarter_clicked(self):
        str_price = self.lineEdit_price.text()
        max_count = self.demo_trading_manager.get_buy_count(float(str_price), 3)
        self.logger.info(f"最大可买数量: {max_count}")
        self.lineEdit_count.setText(str(max_count))
        self.lineEdit_amount.setText(str(max_count * float(str_price)))

    def slot_btn_one_in_five_clicked(self):
        str_price = self.lineEdit_price.text()
        max_count = self.demo_trading_manager.get_buy_count(float(str_price), 4)

        self.logger.info(f"最大可买数量: {max_count}")
        self.lineEdit_count.setText(str(max_count))
        self.lineEdit_amount.setText(str(max_count * float(str_price)))

    def slot_btn_buy_clicked(self):
        if self.current_load_code == "":
            self.logger.info("请先加载股票数据")
            return

        str_code = self.lineEdit_code.text()
        str_name = self.label_name.text()
        str_price = self.lineEdit_price.text()
        str_count = self.lineEdit_count.text()

        if str_price == "" or str_count == "":
            self.logger.info("请填写价格和数量")
            return

        current_index = self.horizontalSlider_progress.value()
        str_date_time = self.indicators_view_widget.get_current_date_time_by_index(current_index)
        self.logger.info(f"点击买入: {str_code}, {str_name}, {str_price}, {str_count}, {str_date_time}")
        self.demo_trading_manager.pending_order_buy(str_code, str_name, float(str_price), int(str_count), str_date_time)

    def slot_btn_sell_clicked(self):
        if self.current_load_code == "":
            self.logger.info("请先加载股票数据")
            return
        str_price = self.lineEdit_price.text()
        str_count = self.lineEdit_count.text()

        current_index = self.horizontalSlider_progress.value()
        str_date_time = self.indicators_view_widget.get_current_date_time_by_index(current_index)

        self.logger.info(f"点击卖出: {str_price}, {str_count}, {str_date_time}")
        self.demo_trading_manager.pending_order_sell(float(str_price), int(str_count), str_date_time)

    def slot_btn_pending_order_cancel_clicked(self):
        if self.current_load_code == "":
            self.logger.info("请先加载股票数据")
            return

        current_index = self.horizontalSlider_progress.value()
        str_date_time = self.indicators_view_widget.get_current_date_time_by_index(current_index)
        dict_kline_price = self.indicators_view_widget.get_kline_price_by_index(current_index)

        self.logger.info(f"点击取消挂单: {str_date_time}")
        self.demo_trading_manager.update_trading_record(0, dict_kline_price, str_date_time)

    def slot_demo_trading_manager_sig_trading_status_changed(self, status):
        self.update_trading_widgets_status()

        if status == 1:
            # 添加Item到ListWidget
            demo_trading_card_widget = DemoTradingCardWidget()
            demo_trading_card_widget.set_data(self.demo_trading_manager.current_trading_record)
            demo_trading_card_widget.update_ui()

            self.demo_trading_manager.sig_trading_yield_changed.connect(demo_trading_card_widget.slot_trading_status_changed)
            self.demo_trading_manager.sig_trading_status_changed.connect(demo_trading_card_widget.slot_trading_status_changed)
            demo_trading_card_widget.clicked.connect(self.slot_demo_trading_card_clicked)
            # demo_trading_card_widget.hovered.connect(self.slot_demo_trading_card_hovered)
            # demo_trading_card_widget.hoverLeft.connect(self.slot_demo_trading_card_hover_left)
            # demo_trading_card_widget.doubleClicked.connect(self.slot_demo_trading_card_double_clicked)

            item = QListWidgetItem(self.listWidget_trading_record)
            # 设置 item 的大小(可选)
            item.setSizeHint(demo_trading_card_widget.sizeHint())
            # item.setSizeHint(QtCore.QSize(200, 60))

            # 将 item 添加到 list widget,默认添加到最前
            # self.listWidget_trading_record.addItem(item)
            self.listWidget_trading_record.insertItem(0, item)

            # 将自定义 widget 设置为 item 的 widget
            self.listWidget_trading_record.setItemWidget(item, demo_trading_card_widget)
        elif status == 6:
            # 交易完成,更新收益曲线图
            list_data = self.demo_trading_manager.get_trding_record_list()
            self.logger.info(f"交易完成,更新收益曲线图,长度: {len(list_data)}")

            for data in list_data:
                self.logger.info(f"code: {data.code}")
                self.logger.info(f"name: {data.name}")

                self.logger.info(f"买入挂单时间: {data.pending_order_buy_date_time}")
                self.logger.info(f"买入挂单取消时间: {data.pending_order_buy_date_time}")

                self.logger.info(f"买入挂单价格: {data.buy_price}")
                self.logger.info(f"买入挂单成交时间: {data.buy_date_time}")

                # self.logger.info(f"买入金额: {data.buy_amount}")
                # self.logger.info(f"买入股数: {data.buy_count}")

                self.logger.info(f"买出挂单时间: {data.pending_order_sell_date_time}")
                self.logger.info(f"买出挂单取消时间: {data.pending_order_sell_cancel_date_time}")

                self.logger.info(f"买出挂单价格: {data.sell_price}")
                self.logger.info(f"买出挂单时间: {data.sell_date_time}")

                # self.logger.info(f"买出金额: {data.sell_amount}")
                # self.logger.info(f"买出股数: {data.sell_count}")

                self.logger.info(f"交易状态: {data.status}")
                self.logger.info(f"收益: {data.trading_yield}")

                self.logger.info("\n---------------------------------------------------------\n")

            self.widget_total_yield_curve.update_data(list_data)

    def slot_demo_trading_card_clicked(self, trading_record):
        self.logger.info(f"交易买入挂单时间:{trading_record.pending_order_buy_date_time}")
        self.demo_trading_record_widget.update_trading_record(trading_record)
        self.stackedWidget_trading_record.setCurrentWidget(self.demo_trading_record_widget)

    def slot_demo_trading_record_widget_sig_btn_return_clicked(self):
        self.stackedWidget_trading_record.setCurrentWidget(self.listWidget_trading_record)

然后效果就是上面展示的那样啦:
5

因为基于模块化设计实现,因此也可以嵌入到对话框中:
6

扩展

《PyQTGraph系列》文章:

PyQTGraph简介

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

PyQTGraph重要概念、类

PyQTGraph中的PlotWidget详解

PyQTGraph应用(一)

PyQTGraph应用(二)

PyQTGraph应用(三)

PyQTGraph应用(四)

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐