python_PyQt5开发股票指定区间K线操作工具_裸K

目录

写在前面:

工具使用演示:

代码:

导入包

横坐标控件、K线控件、带查询下拉列表控件

K线图控件

主界面代码

执行代码


写在前面:

继前面文章提到筛出低位股票后,想逐一查看这些股票今年的K线走势,发现股票软件要查看指定时间范围的K线不是很方便,所以萌生了开发这个工具的想法。另外,考虑到后续可能还会有很多数据在股票软件上不方便查看,就觉得开发个可用的工具十分必要。

工具使用演示:

1 按钮"选择股票日数据所在目录":股票日数据目录。

注:这里的股票日数据来自优矿,所以涉及到日数据字段的操作,参看优矿官网上的数据说明。

2 按钮"选择股票代码和股票名键值对json文件":这里选择放置要查看的股票列表{股票代码:股票名}的json文件

3 第2步选择json文件后,程序会读取股票名信息放入下拉列表中

4 全局区间控制区域:这里设置了时间区间,那么下面K线图就都会显示这个区间,不需要一个个股票设置

5 K线图展示区域

代码:

导入包

import os,sys,json
import pandas as pd
from threading import Thread
from typing import Dict,List,Any
from PyQt5 import QtCore,QtWidgets,QtGui
from PyQt5.QtCore import Qt
import pyqtgraph as pg
pg.setConfigOption('background','w')
pg.setConfigOption('foreground','k')

横坐标控件、K线控件、带查询下拉列表控件

class RotateAxisItem(pg.AxisItem):
    def drawPicture(self, p, axisSpec, tickSpecs, textSpecs):
        p.setRenderHint(p.Antialiasing,False)
        p.setRenderHint(p.TextAntialiasing,True)

        ## draw long line along axis
        pen,p1,p2 = axisSpec
        p.setPen(pen)
        p.drawLine(p1,p2)
        p.translate(0.5,0)  ## resolves some damn pixel ambiguity

        ## draw ticks
        for pen,p1,p2 in tickSpecs:
            p.setPen(pen)
            p.drawLine(p1,p2)

        ## draw all text
        # if self.tickFont is not None:
        #     p.setFont(self.tickFont)
        p.setPen(self.pen())
        for rect,flags,text in textSpecs:
            # this is the important part
            p.save()
            p.translate(rect.x(),rect.y())
            p.rotate(-30)
            p.drawText(int(-rect.width()),int(rect.height()),int(rect.width()),int(rect.height()),flags,text)
            # restoring the painter is *required*!!!
            p.restore()

class CandlestickItem(pg.GraphicsObject):
    def __init__(self, data):
        pg.GraphicsObject.__init__(self)
        self.data = data  ## data must have fields: time, open, close, min, max
        self.generatePicture()

    def generatePicture(self):
        ## pre-computing a QPicture object allows paint() to run much more quickly,
        ## rather than re-drawing the shapes every time.
        self.picture = QtGui.QPicture()
        p = QtGui.QPainter(self.picture)
        p.setPen(pg.mkPen('d'))
        w = (self.data[1][0] - self.data[0][0]) / 3.
        for (t, open, close, min, max) in self.data:
            p.drawLine(QtCore.QPointF(t, min), QtCore.QPointF(t, max))
            if open < close:
                p.setBrush(pg.mkBrush('r'))
            else:
                p.setBrush(pg.mkBrush('g'))
            p.drawRect(QtCore.QRectF(t-w, open, w * 2, close - open))
        p.end()

    def paint(self, p, *args):
        p.drawPicture(0, 0, self.picture)

    def boundingRect(self):
        ## boundingRect _must_ indicate the entire area that will be drawn on
        ## or else we will get artifacts and possibly crashing.
        ## (in this case, QPicture does all the work of computing the bouning rect for us)
        return QtCore.QRectF(self.picture.boundingRect())

class ExtendedComboBox(QtWidgets.QComboBox):
    def __init__(self, parent=None):
        super(ExtendedComboBox, self).__init__(parent)

        self.setFocusPolicy(Qt.StrongFocus)
        self.setEditable(True)

        # add a filter model to filter matching items
        self.pFilterModel = QtCore.QSortFilterProxyModel(self)
        self.pFilterModel.setFilterCaseSensitivity(Qt.CaseInsensitive)
        self.pFilterModel.setSourceModel(self.model())

        # add a completer, which uses the filter model
        self.completer = QtWidgets.QCompleter(self.pFilterModel, self)
        # always show all (filtered) completions
        self.completer.setCompletionMode(QtWidgets.QCompleter.UnfilteredPopupCompletion)
        self.setCompleter(self.completer)

        # connect signals
        self.lineEdit().textEdited.connect(self.pFilterModel.setFilterFixedString)
        self.completer.activated.connect(self.on_completer_activated)

    # on selection of an item from the completer, select the corresponding item from combobox
    def on_completer_activated(self, text):
        if text:
            index = self.findText(text)
            self.setCurrentIndex(index)
            self.activated[str].emit(self.itemText(index))

    # on model change, update the models of the filter and completer as well
    def setModel(self, model):
        super(ExtendedComboBox, self).setModel(model)
        self.pFilterModel.setSourceModel(model)
        self.completer.setModel(self.pFilterModel)

    # on model column change, update the model column of the filter and completer as well
    def setModelColumn(self, column):
        self.completer.setCompletionColumn(column)
        self.pFilterModel.setFilterKeyColumn(column)
        super(ExtendedComboBox, self).setModelColumn(column)

K线图控件

class K_Widget(QtWidgets.QWidget):
    def __init__(self):
        super().__init__()
        self.init_data()
        self.init_ui()
        pass
    def init_data(self):
        self.whole_header = None
        self.whole_df = None
        self.whole_pd_header = None
        self.whole_xTick = None

        self.current_df = None
        self.current_data = None
        self.dur_len = 20
        pass
    def init_ui(self):
        self.duration_label = QtWidgets.QLabel('左边界~右边界')

        self.left_label = QtWidgets.QLabel('左边:')
        self.left_slider = QtWidgets.QSlider(Qt.Horizontal)
        self.left_slider.valueChanged.connect(self.left_slider_valueChanged)
        self.right_slider = QtWidgets.QSlider(Qt.Horizontal)
        self.right_slider.valueChanged.connect(self.right_slider_valueChanged)
        self.right_label = QtWidgets.QLabel(':右边')

        check_btn = QtWidgets.QPushButton('确定')
        check_btn.clicked.connect(self.check_btn_clicked)

        layout_top = QtWidgets.QHBoxLayout()
        layout_top.addWidget(self.duration_label)
        layout_top.addWidget(self.left_label)
        layout_top.addWidget(self.left_slider)
        layout_top.addWidget(self.right_slider)
        layout_top.addWidget(self.right_label)
        layout_top.addWidget(check_btn)

        xax = RotateAxisItem(orientation='bottom')
        xax.setHeight(h=50)
        self.pw = pg.PlotWidget(axisItems={'bottom': xax})
        self.pw.setMouseEnabled(x=True, y=False)
        # self.pw.enableAutoRange(x=False,y=True)
        self.pw.setAutoVisible(x=False, y=True)

        layout = QtWidgets.QVBoxLayout()
        layout.addLayout(layout_top)
        layout.addWidget(self.pw)
        self.setLayout(layout)
        pass

    def first_setData(self,data:Dict):
        whole_header = data['whole_header']
        whole_df = data['whole_df']
        whole_pd_header = data['whole_pd_header']

        whole_xTick = whole_df['xTick'].values.tolist()

        self.whole_header = whole_header
        self.whole_df = whole_df
        self.whole_pd_header = whole_pd_header
        self.whole_xTick = whole_xTick

        self.left_slider.setMinimum(0)
        self.left_slider.setMaximum(len(whole_xTick)-1)
        self.right_slider.setMinimum(0)
        self.right_slider.setMaximum(len(whole_xTick)-1)

        self.left_slider.setValue(0)
        self.right_slider.setValue(len(whole_xTick)-1)
        self.left_label.setText(f"左边:{whole_xTick[0]}")
        self.right_label.setText(f"{whole_xTick[-1]}:右边")

        self.current_df = self.whole_df.copy()
        self.caculate_and_show_data()
        pass

    def caculate_and_show_data(self):
        df = self.current_df.copy()
        df.reset_index(inplace=True)
        df['x'] = [i for i in range(len(df))]
        xTick = df['xTick'].values.tolist()
        xTick00 = []
        dur_num = int(len(xTick)/self.dur_len)
        if dur_num > 2:
            for i in range(0,len(xTick),dur_num):
                xTick00.append((i,xTick[i]))
            pass
        else:
            for i,item in enumerate(xTick):
                xTick00.append((i,item))
            pass
        candle_data = []
        for i,row in df.iterrows():
            candle_data.append((row['x'],row['open'],row['close'],row['lowest'],row['highest']))
        self.current_data = df.loc[:,self.whole_pd_header].values.tolist()
        y_min = df['lowest'].min()
        y_max = df['highest'].max()

        # 开始配置显示内容
        self.pw.clear()

        self.duration_label.setText(f"{xTick[0]}~{xTick[-1]}")

        xax = self.pw.getAxis('bottom')
        xax.setTicks([xTick00])

        self.vb = self.pw.getViewBox()
        self.vb.setLimits(yMin=y_min,yMax=y_max)

        candle_fixed_target = CandlestickItem(candle_data)
        self.pw.addItem(candle_fixed_target)

        self.vLine = pg.InfiniteLine(angle=90,movable=False)
        self.hLine = pg.InfiniteLine(angle=0,movable=False)
        self.label = pg.TextItem()

        self.pw.addItem(self.vLine,ignoreBounds=True)
        self.pw.addItem(self.hLine,ignoreBounds=True)
        self.pw.addItem(self.label,ignoreBounds=True)

        self.proxy = pg.SignalProxy(self.pw.scene().sigMouseMoved,rateLimit=60,slot=self.mouseMoved)
        self.pw.enableAutoRange()
        self.pw.setAutoVisible()
        pass

    def mouseMoved(self,evt):
        pos = evt[0]
        if self.pw.sceneBoundingRect().contains(pos):
            mousePoint = self.vb.mapSceneToView(pos)
            index = int(mousePoint.x())
            if index>=0 and index<len(self.current_data):
                target_data = self.current_data[index]
                html_str = ''
                for i,item in enumerate(self.whole_header):
                    html_str += f"<br/>{item}:{target_data[i]}"
                self.label.setHtml(html_str)
                self.label.setPos(mousePoint.x(),mousePoint.y())
                pass
            self.vLine.setPos(mousePoint.x())
            self.hLine.setPos(mousePoint.y())
            pass
        pass

    def left_slider_valueChanged(self):
        left_value = self.left_slider.value()
        self.left_label.setText(f"左边:{self.whole_xTick[left_value]}")
        pass
    def right_slider_valueChanged(self):
        right_value = self.right_slider.value()
        self.right_label.setText(f"{self.whole_xTick[right_value]}:右边")
        pass
    def check_btn_clicked(self):
        left_value = self.left_slider.value()
        right_value = self.right_slider.value()

        if right_value <= left_value:
            QtWidgets.QMessageBox.information(
                self,
                '提示',
                '左边界不能大于右边界',
                QtWidgets.QMessageBox.Yes
            )
            return
        self.current_df = self.whole_df.iloc[left_value:right_value].copy()
        self.caculate_and_show_data()
        pass
    pass

主界面代码

class KMainWidget(QtWidgets.QWidget):
    signal_excute = QtCore.pyqtSignal(object)
    def __init__(self):
        super().__init__()

        self.thread_caculate: Thread = None

        self.init_data()
        self.init_ui()
        self.register_event()
        self.progress_init()
        self.global_duration_checkbox_clicked()
        pass
    def init_data(self):
        self.please_select_str: str = '-- 请选择 --'
        self.current_stock_name: str = None
        self.ticker_name_map: Dict[str,str] = {}
        self.name_ticker_map: Dict[str,str] = {}
        self.stockname_list: List[str] = []
        self.target_column_list: List[str] = ['xTick','open','close','highest','lowest']
        pass
    def init_ui(self):
        self.setWindowTitle('股票指定区间K线工具')

        self.caculate_progress = QtWidgets.QProgressBar()
        self.caculate_status_label = QtWidgets.QLabel()

        layout_progress = QtWidgets.QHBoxLayout()
        layout_progress.addWidget(self.caculate_progress)
        layout_progress.addWidget(self.caculate_status_label)

        choice_stock_dir_btn = QtWidgets.QPushButton('选择股票日数据所在目录')
        choice_stock_dir_btn.clicked.connect(self.choice_stock_dir_btn_clicked)
        self.stock_dir_lineedit = QtWidgets.QLineEdit()
        choice_stockname_map_file_btn = QtWidgets.QPushButton('选择股票代码和股票名键值对json文件')
        choice_stockname_map_file_btn.clicked.connect(self.choice_stockname_map_file_btn_clicked)
        self.stockname_json_lineedit = QtWidgets.QLineEdit()

        layout_one = QtWidgets.QFormLayout()
        layout_one.addRow(choice_stock_dir_btn,self.stock_dir_lineedit)
        layout_one.addRow(choice_stockname_map_file_btn,self.stockname_json_lineedit)

        tip_label = QtWidgets.QLabel('股票列表')
        self.stock_combox = ExtendedComboBox()
        self.stock_combox.addItem(self.please_select_str)
        self.stock_combox.currentTextChanged.connect(self.stock_combox_currentTextChanged)

        self.global_duration_checkbox = QtWidgets.QCheckBox('全局区间')
        self.global_duration_checkbox.clicked.connect(self.global_duration_checkbox_clicked)
        self.left_point = QtWidgets.QDateEdit()
        self.left_point.setDisplayFormat('yyyy-MM-dd')
        self.left_point.setCalendarPopup(True)
        self.right_point = QtWidgets.QDateEdit()
        self.right_point.setDisplayFormat('yyyy-MM-dd')
        self.right_point.setCalendarPopup(True)
        self.global_duration_btn = QtWidgets.QPushButton('确定')
        self.global_duration_btn.clicked.connect(self.global_duration_btn_clicked)

        layout_two = QtWidgets.QHBoxLayout()
        layout_two.addWidget(tip_label)
        layout_two.addWidget(self.stock_combox)
        layout_two.addStretch(1)
        layout_two.addWidget(self.global_duration_checkbox)
        layout_two.addWidget(self.left_point)
        layout_two.addWidget(self.right_point)
        layout_two.addWidget(self.global_duration_btn)

        layout_up = QtWidgets.QVBoxLayout()
        layout_up.addLayout(layout_one)
        layout_up.addLayout(layout_two)

        groupbox = QtWidgets.QGroupBox('全局设置',self)
        groupbox.setLayout(layout_up)

        self.title_label = QtWidgets.QLabel('股票简称')
        self.title_label.setAlignment(QtCore.Qt.AlignCenter)
        self.title_label.setStyleSheet('QLabel{font-size:16px;font-weight:bold}')

        self.k_widget = K_Widget()

        layout_three = QtWidgets.QVBoxLayout()
        layout_three.addWidget(self.title_label)
        layout_three.addWidget(self.k_widget)

        up_btn = QtWidgets.QPushButton('上一个')
        up_btn.clicked.connect(self.up_btn_clicked)
        down_btn = QtWidgets.QPushButton('下一个')
        down_btn.clicked.connect(self.down_btn_clicked)

        layout_four = QtWidgets.QVBoxLayout()
        layout_four.addWidget(up_btn)
        layout_four.addWidget(down_btn)

        layout_five = QtWidgets.QHBoxLayout()
        layout_five.addLayout(layout_three,11)
        layout_five.addLayout(layout_four,1)

        layout = QtWidgets.QVBoxLayout()
        layout.addLayout(layout_progress)
        layout.addWidget(groupbox)
        layout.addLayout(layout_five)
        self.setLayout(layout)
        pass
    def register_event(self):
        self.signal_excute.connect(self.process_excute_event)
        pass
    def process_excute_event(self,data:Dict):
        mark_str = data['mark_str']
        status = data['status']
        if mark_str == 'show_stock_candle':
            if status == 'error':
                self.thread_caculate = None
                self.progress_finished()
                QtWidgets.QMessageBox.information(
                    self,
                    '提示',
                    data['data'],
                    QtWidgets.QMessageBox.Yes
                )
                return
            else:
                self.title_label.setText(self.current_stock_name)
                self.k_widget.first_setData(data['data'])

                self.thread_caculate = None
                self.progress_finished()
                pass
        pass
    def choice_stock_dir_btn_clicked(self):
        path = QtWidgets.QFileDialog.getExistingDirectory(
            self,
            '打开股票日数据所在目录',
            '.'
        )
        if not path:
            return
        self.stock_dir_lineedit.setText(path)
        pass
    def choice_stockname_map_file_btn_clicked(self):
        path,_ = QtWidgets.QFileDialog.getOpenFileName(
            self,
            '选择股票代码与名称键值对json文件',
            '.',
            'JOSN(*.json)'
        )
        if not path:
            return
        self.stockname_json_lineedit.setText(path)
        with open(path,'r',encoding='utf-8') as fr:
            self.ticker_name_map = json.load(fr)
        self.name_ticker_map = {}
        for key,val in self.ticker_name_map.items():
            self.name_ticker_map[val] = key
        self.stockname_list = list(self.name_ticker_map.keys())
        self.stock_combox.clear()
        self.stock_combox.addItem(self.please_select_str)
        self.stock_combox.addItems(self.stockname_list)
        pass
    def stock_combox_currentTextChanged(self,txt:str):
        cur_txt = self.stock_combox.currentText()
        if not cur_txt \
                or cur_txt==self.current_stock_name \
                or cur_txt==self.please_select_str:
            return
        self.current_stock_name = cur_txt
        self.pre_caculate_data()
        pass
    def global_duration_checkbox_clicked(self):
        if self.global_duration_checkbox.isChecked():
            # 勾选,则表示要遵从全局区间
            self.left_point.setDisabled(False)
            self.right_point.setDisabled(False)
            self.global_duration_btn.setDisabled(False)
            pass
        else:
            # 没有勾选,表示不设全局区间
            self.left_point.setDisabled(True)
            self.right_point.setDisabled(True)
            self.global_duration_btn.setDisabled(True)
            pass
        pass
    def global_duration_btn_clicked(self):
        if self.current_stock_name:
            self.pre_caculate_data()
        pass
    def up_btn_clicked(self):
        if not self.current_stock_name:
            self.current_stock_name = self.stockname_list[-1]
        else:
            try:
                cur_index = self.stockname_list.index(self.current_stock_name)
                if cur_index == 0:
                    cur_index = len(self.stockname_list)-1
                else:
                    cur_index = cur_index -1
                self.current_stock_name = self.stockname_list[cur_index]
            except:
                QtWidgets.QMessageBox.information(
                    self,
                    '提示',
                    f'{self.current_stock_name}在股票列表中不存在',
                    QtWidgets.QMessageBox.Yes
                )
                return
        self.pre_caculate_data()
        pass
    def down_btn_clicked(self):
        if not self.current_stock_name:
            self.current_stock_name = self.stockname_list[0]
        else:
            try:
                cur_index = self.stockname_list.index(self.current_stock_name)
                if cur_index >= len(self.stockname_list) - 1:
                    cur_index = 0
                else:
                    cur_index = cur_index + 1
                self.current_stock_name = self.stockname_list[cur_index]
            except:
                QtWidgets.QMessageBox.information(
                    self,
                    '提示',
                    f'{self.current_stock_name}在股票列表中不存在',
                    QtWidgets.QMessageBox.Yes
                )
                return
        self.pre_caculate_data()
        pass
    def pre_caculate_data(self):
        # 股票日数据目录是否已经指定
        daily_dir = self.stock_dir_lineedit.text()
        daily_dir = daily_dir.strip()
        if not daily_dir:
            QtWidgets.QMessageBox.information(
                self,
                '提示',
                '请选择股票日数据目录',
                QtWidgets.QMessageBox.Yes
            )
            return
        if not self.ticker_name_map:
            QtWidgets.QMessageBox.information(
                self,
                '提示',
                '请选择股票代码股票名键值对json文件',
                QtWidgets.QMessageBox.Yes
            )
            return
        daily_file_path = daily_dir + os.path.sep + self.name_ticker_map[self.current_stock_name] + '.csv'
        if not os.path.exists(daily_file_path):
            QtWidgets.QMessageBox.information(
                self,
                '提示',
                f'不存在{self.current_stock_name}的日数据文件',
                QtWidgets.QMessageBox.Yes
            )
            return

        # 判断是否有全局设置
        if self.global_duration_checkbox.isChecked():
            global_dur_yeah = True
            left_point = self.left_point.date().toString('yyyy-MM-dd')
            right_point = self.right_point.date().toString('yyyy-MM-dd')
            global_dur_list = [left_point,right_point]
            pass
        else:
            global_dur_yeah = False
            global_dur_list = []
            pass
        mark_str = 'show_stock_candle'
        pre_map = {
            'global_dur_yeah':global_dur_yeah,
            'global_dur_list':global_dur_list,
            'daily_file_path':daily_file_path
        }
        self.start_caculate_thread(mark_str,pre_map)
        self.progress_busy()
        pass

    def start_caculate_thread(self,mark_str:str,data:Dict[str,Any]):
        if self.thread_caculate:
            QtWidgets.QMessageBox.information(
                self,
                '提示',
                '线程正在执行任务,请稍后。。。',
                QtWidgets.QMessageBox.Yes
            )
            return
        self.thread_caculate = Thread(
            target=self.running_caculate_thread,
            args=(
                mark_str, data,
            )
        )
        self.thread_caculate.start()
        self.progress_busy()
        pass
    def running_caculate_thread(self,mark_str:str,data:Dict[str,Any]):
        if mark_str == 'show_stock_candle':
            global_dur_yeah = data['global_dur_yeah']
            global_dur_list = data['global_dur_list']
            daily_file_path = data['daily_file_path']

            df = pd.read_csv(daily_file_path,encoding='utf-8')
            # 去除停牌的数据
            df = df.loc[df['openPrice']>0].copy()
            # 计算前复权数据
            df['open'] = df['openPrice']*df['accumAdjFactor']
            df['close'] = df['closePrice']*df['accumAdjFactor']
            df['highest'] = df['highestPrice']*df['accumAdjFactor']
            df['lowest'] = df['lowestPrice']*df['accumAdjFactor']
            df['xTick'] = df['tradeDate']

            if global_dur_yeah:
                df['o_date'] = pd.to_datetime(df['tradeDate'])
                df = df.loc[(df['o_date']>=global_dur_list[0]) & (df['o_date']<=global_dur_list[1])].copy()
                if len(df)<2:
                    res_map = {
                        'mark_str': mark_str,
                        'status': 'error',
                        'data': '全局区间对应的时间段没有数据'
                    }
                    self.signal_excute.emit(res_map)
                    return
            res_data = {
                'whole_df':df,
                'whole_header':['日期','开盘','收盘','最高','最低'],
                'whole_pd_header':self.target_column_list
            }
            res_map = {
                'mark_str': mark_str,
                'status': 'success',
                'data': res_data
            }
            self.signal_excute.emit(res_map)
            pass
        pass
    def progress_init(self) -> None:
        self.caculate_progress.setValue(0)
        self.caculate_status_label.setText('无任务')
    def progress_busy(self) -> None:
        self.caculate_progress.setRange(0, 0)
        self.caculate_status_label.setText('正在执行')
    def progress_finished(self) -> None:
        self.caculate_progress.setRange(0, 100)
        self.caculate_progress.setValue(100)
        self.caculate_status_label.setText('执行完毕')
        pass
    pass

执行代码

if __name__ == '__main__':
    QtCore.QCoreApplication.setAttribute(QtCore.Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
    app = QtWidgets.QApplication(sys.argv)
    main_window = KMainWidget()
    main_window.showMaximized()
    app.exec()
    pass
相关推荐
q567315237 分钟前
在 Bash 中获取 Python 模块变量列
开发语言·python·bash
是萝卜干呀8 分钟前
Backend - Python 爬取网页数据并保存在Excel文件中
python·excel·table·xlwt·爬取网页数据
代码欢乐豆9 分钟前
数据采集之selenium模拟登录
python·selenium·测试工具
狂奔solar44 分钟前
yelp数据集上识别潜在的热门商家
开发语言·python
Tassel_YUE1 小时前
网络自动化04:python实现ACL匹配信息(主机与主机信息)
网络·python·自动化
聪明的墨菲特i1 小时前
Python爬虫学习
爬虫·python·学习
努力的家伙是不讨厌的2 小时前
解析json导出csv或者直接入库
开发语言·python·json
云空2 小时前
《Python 与 SQLite:强大的数据库组合》
数据库·python·sqlite
凤枭香3 小时前
Python OpenCV 傅里叶变换
开发语言·图像处理·python·opencv
测试杂货铺3 小时前
外包干了2年,快要废了。。
自动化测试·软件测试·python·功能测试·测试工具·面试·职场和发展