目录
写在前面:
继前面文章提到筛出低位股票后,想逐一查看这些股票今年的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