diff --git a/vnpy/trader/ui/kline/crosshair.py b/vnpy/trader/ui/kline/crosshair.py new file mode 100644 index 00000000..a1aceec1 --- /dev/null +++ b/vnpy/trader/ui/kline/crosshair.py @@ -0,0 +1,287 @@ +# flake8: noqa + +import pyqtgraph as pg +import datetime as dt +import traceback +from qtpy import QtCore +from pyqtgraph.Point import Point +import pandas as pd + + +# 十字光标支持 +class Crosshair(QtCore.QObject): + """ + 此类给pg.PlotWidget()添加crossHair功能,PlotWidget实例需要初始化时传入 + """ + signal = QtCore.Signal(type(tuple([]))) + signalInfo = QtCore.Signal(float, float) + + def __init__(self, parent, master): + """Constructor""" + self.__view = parent # PlatWidget + self.master = master # KLineWidget + super(Crosshair, self).__init__() + + self.xAxis = 0 + self.yAxis = 0 + + self.datas = None + + self.yAxises = [0 for i in range(3)] + self.leftX = [0 for i in range(3)] + self.showHLine = [False for i in range(3)] + self.textPrices = [pg.TextItem('', anchor=(1, 1)) for i in range(3)] + # 取得 widget上的:主图/成交量/副图指标 + self.views = [parent.centralWidget.getItem(i + 1, 0) for i in range(3)] + self.rects = [self.views[i].sceneBoundingRect() for i in range(3)] + self.vLines = [pg.InfiniteLine(angle=90, movable=False) for i in range(3)] + self.hLines = [pg.InfiniteLine(angle=0, movable=False) for i in range(3)] + + # mid 在y轴动态跟随最新价显示最新价和最新时间 + self.__textDate = pg.TextItem('date', anchor=(1, 1)) # 文本:日期/时间 + self.__textInfo = pg.TextItem('lastBarInfo') # 文本:bar信息 + self.__text_main_indicators = pg.TextItem('lastIndicatorsInfo', anchor=(1, 0)) # 文本:主图指标 + self.__text_sub_indicators = pg.TextItem('lastSubIndicatorsInfo', anchor=(1, 0)) # 文本: 副图指标 + self.__textVolume = pg.TextItem('lastBarVolume', anchor=(1, 0)) # 文本: 成交量 + + self.__textDate.setZValue(2) + self.__textInfo.setZValue(2) + self.__text_main_indicators.setZValue(2) + self.__text_sub_indicators.setZValue(2) + self.__textVolume.setZValue(2) + self.__textInfo.border = pg.mkPen(color=(230, 255, 0, 255), width=1.2) + + for i in range(3): + self.textPrices[i].setZValue(2) + self.vLines[i].setPos(0) + self.hLines[i].setPos(0) + self.vLines[i].setZValue(0) + self.hLines[i].setZValue(0) + self.views[i].addItem(self.vLines[i]) + self.views[i].addItem(self.hLines[i]) + self.views[i].addItem(self.textPrices[i]) + + self.views[0].addItem(self.__textInfo, ignoreBounds=True) + self.views[0].addItem(self.__text_main_indicators, ignoreBounds=True) + + # 添加成交量的提示信息到 volume view + if self.master.display_vol: + self.views[1].addItem(self.__textVolume, ignoreBounds=True) + + # 添加 副图指标/日期信息到 副图 + if self.master.display_sub: + self.views[2].addItem(self.__textDate, ignoreBounds=True) + self.views[2].addItem(self.__text_sub_indicators, ignoreBounds=True) + else: + # 没有启用副图,日期信息放在主图 + self.views[0].addItem(self.__textDate, ignoreBounds=True) + + self.proxy = pg.SignalProxy(self.__view.scene().sigMouseMoved, rateLimit=360, slot=self.__mouseMoved) + # 跨线程刷新界面支持 + self.signal.connect(self.update) + self.signalInfo.connect(self.plotInfo) + + def update(self, pos): + """刷新界面显示""" + try: + xAxis, yAxis = pos + xAxis, yAxis = (self.xAxis, self.yAxis) if xAxis is None else (xAxis, yAxis) + self.moveTo(xAxis, yAxis) + except Exception as ex: + print(u'Crosshair.update() exception:{},trace:{}'.format(str(ex), traceback.format_exc())) + + def __mouseMoved(self, evt): + """鼠标移动回调""" + try: + pos = evt[0] + self.rects = [self.views[i].sceneBoundingRect() for i in range(3)] + for i in range(3): + self.showHLine[i] = False + if i == 1 and not self.master.display_vol: + continue + + if i == 2 and not self.master.display_sub: + continue + + if self.rects[i].contains(pos): + mousePoint = self.views[i].vb.mapSceneToView(pos) + xAxis = mousePoint.x() + yAxis = mousePoint.y() + self.yAxises[i] = yAxis + self.showHLine[i] = True + self.moveTo(xAxis, yAxis) + except Exception as ex: + print(u'_mouseMove() exception:{},trace:{}'.format(str(ex), traceback.format_exc())) + + def moveTo(self, xAxis, yAxis): + """ + 移动 + :param xAxis: + :param yAxis: + :return: + """ + try: + xAxis, yAxis = (self.xAxis, self.yAxis) if xAxis is None else (int(xAxis), yAxis) + self.rects = [self.views[i].sceneBoundingRect() for i in range(3)] + if not xAxis or not yAxis: + return + self.xAxis = xAxis + self.yAxis = yAxis + self.vhLinesSetXY(xAxis, yAxis) + self.plotInfo(xAxis, yAxis) + self.master.pi_volume.update() + except Exception as ex: + print(u'_mouseMove() exception:{},trace:{}'.format(str(ex), traceback.format_exc())) + + def vhLinesSetXY(self, xAxis, yAxis): + """水平和竖线位置设置""" + for i in range(3): + self.vLines[i].setPos(xAxis) + if self.showHLine[i]: + self.hLines[i].setPos(yAxis if i == 0 else self.yAxises[i]) + self.hLines[i].show() + else: + self.hLines[i].hide() + + def plotInfo(self, xAxis, yAxis): + """ + 被嵌入的plotWidget在需要的时候通过调用此方法显示K线信息 + """ + if self.datas is None or xAxis >= len(self.datas): + return + + tickDatetime = None + openPrice = 0 + closePrice = 0 + highPrice = 0 + lowPrice = 0 + preClosePrice = 0 + volume = 0 + # open_interest = 0 + + try: + # 获取K线数据 + data = self.datas[xAxis] + lastdata = self.datas[xAxis - 1] + tickDatetime = pd.to_datetime(data['datetime']) + openPrice = data['open'] + closePrice = data['close'] + lowPrice = data['low'] + highPrice = data['high'] + volume = int(data['volume']) + # open_interest = int(data['open_interest']) + preClosePrice = lastdata['close'] + + except Exception as ex: + print(u'exception:{},trace:{}'.format(str(ex), traceback.format_exc())) + return + + if (isinstance(tickDatetime, dt.datetime)): + datetimeText = dt.datetime.strftime(tickDatetime, '%Y-%m-%d %H:%M:%S') + dateText = dt.datetime.strftime(tickDatetime, '%Y-%m-%d') + timeText = dt.datetime.strftime(tickDatetime, '%H:%M:%S') + else: + datetimeText = "" + dateText = "" + timeText = "" + + # 显示所有的主图技术指标 + html = u'
' + for indicator in self.master.main_indicator_data: + val = self.master.main_indicator_data[indicator][xAxis] + col = self.master.main_indicator_colors[indicator] + html += u'  %s:%.2f' % (col, indicator, val) + html += u'
' + self.__text_main_indicators.setHtml(html) + + # 显示所有的主图技术指标 + html = u'
' + for indicator in self.master.sub_indicator_data: + val = self.master.sub_indicator_data[indicator][xAxis] + col = self.master.sub_indicator_colors[indicator] + html += u'  %s:%.2f' % (col, indicator, val) + html += u'
' + self.__text_sub_indicators.setHtml(html) + + # 和上一个收盘价比较,决定K线信息的字符颜色 + cOpen = 'red' if openPrice > preClosePrice else 'green' + cClose = 'red' if closePrice > preClosePrice else 'green' + cHigh = 'red' if highPrice > preClosePrice else 'green' + cLow = 'red' if lowPrice > preClosePrice else 'green' + + self.__textInfo.setHtml( + u'
\ + 日期
\ + %s
\ + 时间
\ + %s
\ + 价格
\ + (开) %.3f
\ + (高) %.3f
\ + (低) %.3f
\ + (收) %.3f
\ + 成交量
\ + (量) %d
\ +
' \ + % (dateText, timeText, cOpen, openPrice, cHigh, highPrice, + cLow, lowPrice, cClose, closePrice, volume)) + self.__textDate.setHtml( + '
\ + %s\ +
' \ + % (datetimeText)) + + self.__textVolume.setHtml( + '
\ + VOL : %.3f\ +
' \ + % (volume)) + # 坐标轴宽度 + rightAxisWidth = self.views[0].getAxis('right').width() + bottomAxisHeight = self.views[2].getAxis('bottom').height() + offset = QtCore.QPointF(rightAxisWidth, bottomAxisHeight) + + # 各个顶点 + t1 = [None, None, None] + br = [None, None, None] + t1[0] = self.views[0].vb.mapSceneToView(self.rects[0].topLeft()) + br[0] = self.views[0].vb.mapSceneToView(self.rects[0].bottomRight() - offset) + + if self.master.display_vol: + t1[1] = self.views[1].vb.mapSceneToView(self.rects[1].topLeft()) + br[1] = self.views[1].vb.mapSceneToView(self.rects[1].bottomRight() - offset) + if self.master.display_sub: + t1[2] = self.views[2].vb.mapSceneToView(self.rects[2].topLeft()) + br[2] = self.views[2].vb.mapSceneToView(self.rects[2].bottomRight() - offset) + + # 显示价格 + for i in range(3): + if self.showHLine[i] and t1[i] is not None and br[i] is not None: + self.textPrices[i].setHtml( + '
\ + \ + %0.3f\ + \ +
' \ + % (yAxis if i == 0 else self.yAxises[i])) + self.textPrices[i].setPos(br[i].x(), yAxis if i == 0 else self.yAxises[i]) + self.textPrices[i].show() + else: + self.textPrices[i].hide() + + # 设置坐标 + self.__textInfo.setPos(t1[0]) + self.__text_main_indicators.setPos(br[0].x(), t1[0].y()) + if self.master.display_sub: + self.__text_sub_indicators.setPos(br[2].x(), t1[2].y()) + if self.master.display_vol: + self.__textVolume.setPos(br[1].x(), t1[1].y()) + + # 修改对称方式防止遮挡 + self.__textDate.anchor = Point((1, 1)) if xAxis > self.master.index else Point((0, 1)) + if br[2] is not None: + self.__textDate.setPos(xAxis, br[2].y()) + elif br[1] is not None: + self.__textDate.setPos(xAxis, br[1].y()) + else: + self.__textDate.setPos(xAxis, br[0].y()) diff --git a/vnpy/trader/ui/kline/css.qss b/vnpy/trader/ui/kline/css.qss new file mode 100644 index 00000000..acb366d8 --- /dev/null +++ b/vnpy/trader/ui/kline/css.qss @@ -0,0 +1,1342 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) <2013-2014> + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +QToolTip +{ + border: 1px solid #76797C; + background-color: rgb(90, 102, 117);; + color: white; + padding: 5px; + opacity: 200; +} + +QWidget +{ + color: #eff0f1; + background-color: #31363b; + selection-background-color:#3daee9; + selection-color: #eff0f1; + background-clip: border; + border-image: none; + border: 0px transparent black; + outline: 0; +} + +QWidget:item:hover +{ + background-color: #3daee9; + color: #eff0f1; +} + +QWidget:item:selected +{ + background-color: #3daee9; +} + +QCheckBox +{ + spacing: 5px; + outline: none; + color: #eff0f1; + margin-bottom: 2px; +} + +QCheckBox:disabled +{ + color: #76797C; +} + +QCheckBox::indicator, +QGroupBox::indicator +{ + width: 18px; + height: 18px; +} +QGroupBox::indicator +{ + margin-left: 2px; +} + +QCheckBox::indicator:unchecked +{ + image: url(:/qss_icons/rc/checkbox_unchecked.png); +} + +QCheckBox::indicator:unchecked:hover, +QCheckBox::indicator:unchecked:focus, +QCheckBox::indicator:unchecked:pressed, +QGroupBox::indicator:unchecked:hover, +QGroupBox::indicator:unchecked:focus, +QGroupBox::indicator:unchecked:pressed +{ + border: none; + image: url(:/qss_icons/rc/checkbox_unchecked_focus.png); +} + +QCheckBox::indicator:checked +{ + image: url(:/qss_icons/rc/checkbox_checked.png); +} + +QCheckBox::indicator:checked:hover, +QCheckBox::indicator:checked:focus, +QCheckBox::indicator:checked:pressed, +QGroupBox::indicator:checked:hover, +QGroupBox::indicator:checked:focus, +QGroupBox::indicator:checked:pressed +{ + border: none; + image: url(:/qss_icons/rc/checkbox_checked_focus.png); +} + + +QCheckBox::indicator:indeterminate +{ + image: url(:/qss_icons/rc/checkbox_indeterminate.png); +} + +QCheckBox::indicator:indeterminate:focus, +QCheckBox::indicator:indeterminate:hover, +QCheckBox::indicator:indeterminate:pressed +{ + image: url(:/qss_icons/rc/checkbox_indeterminate_focus.png); +} + +QCheckBox::indicator:checked:disabled, +QGroupBox::indicator:checked:disabled +{ + image: url(:/qss_icons/rc/checkbox_checked_disabled.png); +} + +QCheckBox::indicator:unchecked:disabled, +QGroupBox::indicator:unchecked:disabled +{ + image: url(:/qss_icons/rc/checkbox_unchecked_disabled.png); +} + +QRadioButton +{ + spacing: 5px; + outline: none; + color: #eff0f1; + margin-bottom: 2px; +} + +QRadioButton:disabled +{ + color: #76797C; +} +QRadioButton::indicator +{ + width: 21px; + height: 21px; +} + +QRadioButton::indicator:unchecked +{ + image: url(:/qss_icons/rc/radio_unchecked.png); +} + + +QRadioButton::indicator:unchecked:hover, +QRadioButton::indicator:unchecked:focus, +QRadioButton::indicator:unchecked:pressed +{ + border: none; + outline: none; + image: url(:/qss_icons/rc/radio_unchecked_focus.png); +} + +QRadioButton::indicator:checked +{ + border: none; + outline: none; + image: url(:/qss_icons/rc/radio_checked.png); +} + +QRadioButton::indicator:checked:hover, +QRadioButton::indicator:checked:focus, +QRadioButton::indicator:checked:pressed +{ + border: none; + outline: none; + image: url(:/qss_icons/rc/radio_checked_focus.png); +} + +QRadioButton::indicator:checked:disabled +{ + outline: none; + image: url(:/qss_icons/rc/radio_checked_disabled.png); +} + +QRadioButton::indicator:unchecked:disabled +{ + image: url(:/qss_icons/rc/radio_unchecked_disabled.png); +} + + +QMenuBar +{ + background-color: #31363b; + color: #eff0f1; +} + +QMenuBar::item +{ + background: transparent; +} + +QMenuBar::item:selected +{ + background: transparent; + border: 1px solid #76797C; +} + +QMenuBar::item:pressed +{ + border: 1px solid #76797C; + background-color: #3daee9; + color: #eff0f1; + margin-bottom:-1px; + padding-bottom:1px; +} + +QMenu +{ + border: 1px solid #76797C; + color: #eff0f1; + margin: 2px; +} + +QMenu::icon +{ + margin: 5px; +} + +QMenu::item +{ + padding: 5px 30px 5px 30px; + margin-left: 5px; + border: 1px solid transparent; /* reserve space for selection border */ +} + +QMenu::item:selected +{ + color: #eff0f1; +} + +QMenu::separator { + height: 2px; + background: lightblue; + margin-left: 10px; + margin-right: 5px; +} + +QMenu::indicator { + width: 18px; + height: 18px; +} + +/* non-exclusive indicator = check box style indicator + (see QActionGroup::setExclusive) */ +QMenu::indicator:non-exclusive:unchecked { + image: url(:/qss_icons/rc/checkbox_unchecked.png); +} + +QMenu::indicator:non-exclusive:unchecked:selected { + image: url(:/qss_icons/rc/checkbox_unchecked_disabled.png); +} + +QMenu::indicator:non-exclusive:checked { + image: url(:/qss_icons/rc/checkbox_checked.png); +} + +QMenu::indicator:non-exclusive:checked:selected { + image: url(:/qss_icons/rc/checkbox_checked_disabled.png); +} + +/* exclusive indicator = radio button style indicator (see QActionGroup::setExclusive) */ +QMenu::indicator:exclusive:unchecked { + image: url(:/qss_icons/rc/radio_unchecked.png); +} + +QMenu::indicator:exclusive:unchecked:selected { + image: url(:/qss_icons/rc/radio_unchecked_disabled.png); +} + +QMenu::indicator:exclusive:checked { + image: url(:/qss_icons/rc/radio_checked.png); +} + +QMenu::indicator:exclusive:checked:selected { + image: url(:/qss_icons/rc/radio_checked_disabled.png); +} + +QMenu::right-arrow { + margin: 5px; + image: url(:/qss_icons/rc/right_arrow.png) +} + + +QWidget:disabled +{ + color: #454545; + background-color: #31363b; +} + +QAbstractItemView +{ + alternate-background-color: #31363b; + color: #eff0f1; + border: 1px solid 3A3939; + border-radius: 2px; +} + +QWidget:focus, QMenuBar:focus +{ + border: 1px solid #3daee9; +} + +QTabWidget:focus, QCheckBox:focus, QRadioButton:focus, QSlider:focus +{ + border: none; +} + +QLineEdit +{ + background-color: #232629; + padding: 5px; + border-style: solid; + border: 1px solid #76797C; + border-radius: 2px; + color: #eff0f1; +} + +QGroupBox { + border:1px solid #76797C; + border-radius: 2px; + margin-top: 20px; + font-size:18px; + font-weight:bold; +} + +QGroupBox::title { + subcontrol-origin: margin; + subcontrol-position: top center; + padding-left: 10px; + padding-right: 10px; + padding-top: 10px; + font-size:18px; + font-weight:bold; +} + +QAbstractScrollArea +{ + border-radius: 2px; + border: 1px solid #76797C; + background-color: transparent; +} + +QScrollBar:horizontal +{ + height: 15px; + margin: 3px 15px 3px 15px; + border: 1px transparent #2A2929; + border-radius: 4px; + background-color: #2A2929; +} + +QScrollBar::handle:horizontal +{ + background-color: #605F5F; + min-width: 5px; + border-radius: 4px; +} + +QScrollBar::add-line:horizontal +{ + margin: 0px 3px 0px 3px; + border-image: url(:/qss_icons/rc/right_arrow_disabled.png); + width: 10px; + height: 10px; + subcontrol-position: right; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:horizontal +{ + margin: 0px 3px 0px 3px; + border-image: url(:/qss_icons/rc/left_arrow_disabled.png); + height: 10px; + width: 10px; + subcontrol-position: left; + subcontrol-origin: margin; +} + +QScrollBar::add-line:horizontal:hover,QScrollBar::add-line:horizontal:on +{ + border-image: url(:/qss_icons/rc/right_arrow.png); + height: 10px; + width: 10px; + subcontrol-position: right; + subcontrol-origin: margin; +} + + +QScrollBar::sub-line:horizontal:hover, QScrollBar::sub-line:horizontal:on +{ + border-image: url(:/qss_icons/rc/left_arrow.png); + height: 10px; + width: 10px; + subcontrol-position: left; + subcontrol-origin: margin; +} + +QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal +{ + background: none; +} + + +QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal +{ + background: none; +} + +QScrollBar:vertical +{ + background-color: #2A2929; + width: 15px; + margin: 15px 3px 15px 3px; + border: 1px transparent #2A2929; + border-radius: 4px; +} + +QScrollBar::handle:vertical +{ + background-color: #605F5F; + min-height: 5px; + border-radius: 4px; +} + +QScrollBar::sub-line:vertical +{ + margin: 3px 0px 3px 0px; + border-image: url(:/qss_icons/rc/up_arrow_disabled.png); + height: 10px; + width: 10px; + subcontrol-position: top; + subcontrol-origin: margin; +} + +QScrollBar::add-line:vertical +{ + margin: 3px 0px 3px 0px; + border-image: url(:/qss_icons/rc/down_arrow_disabled.png); + height: 10px; + width: 10px; + subcontrol-position: bottom; + subcontrol-origin: margin; +} + +QScrollBar::sub-line:vertical:hover,QScrollBar::sub-line:vertical:on +{ + + border-image: url(:/qss_icons/rc/up_arrow.png); + height: 10px; + width: 10px; + subcontrol-position: top; + subcontrol-origin: margin; +} + + +QScrollBar::add-line:vertical:hover, QScrollBar::add-line:vertical:on +{ + border-image: url(:/qss_icons/rc/down_arrow.png); + height: 10px; + width: 10px; + subcontrol-position: bottom; + subcontrol-origin: margin; +} + +QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical +{ + background: none; +} + + +QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical +{ + background: none; +} + +QTextEdit +{ + background-color: #232629; + color: #eff0f1; + border: 1px solid #76797C; +} + +QPlainTextEdit +{ + background-color: #232629;; + color: #eff0f1; + border-radius: 2px; + border: 1px solid #76797C; +} + +QHeaderView::section +{ + background-color: #76797C; + color: #eff0f1; + padding: 5px; + border: 1px solid #76797C; +} + +QSizeGrip { + image: url(:/qss_icons/rc/sizegrip.png); + width: 12px; + height: 12px; +} + + +QMainWindow::separator +{ + background-color: #31363b; + color: white; + padding-left: 4px; + spacing: 2px; + border: 1px dashed #76797C; +} + +QMainWindow::separator:hover +{ + + background-color: #787876; + color: white; + padding-left: 4px; + border: 1px solid #76797C; + spacing: 2px; +} + + +QMenu::separator +{ + height: 1px; + background-color: #76797C; + color: white; + padding-left: 4px; + margin-left: 10px; + margin-right: 5px; +} + + +QFrame +{ + border-radius: 2px; + border: 1px solid #76797C; +} + +QFrame[frameShape="0"] +{ + border-radius: 2px; + border: 1px transparent #76797C; +} + +QStackedWidget +{ + border: 1px transparent black; +} + +QToolBar { + border: 1px transparent #393838; + background: 1px solid #31363b; + font-weight: bold; +} + +QToolBar::handle:horizontal { + image: url(:/qss_icons/rc/Hmovetoolbar.png); +} +QToolBar::handle:vertical { + image: url(:/qss_icons/rc/Vmovetoolbar.png); +} +QToolBar::separator:horizontal { + image: url(:/qss_icons/rc/Hsepartoolbar.png); +} +QToolBar::separator:vertical { + image: url(:/qss_icons/rc/Vsepartoolbar.png); +} +QToolButton#qt_toolbar_ext_button { + background: #58595a +} + +QPushButton +{ + color: #eff0f1; + background-color: #31363b; + border-width: 1px; + border-color: #76797C; + border-style: solid; + padding: 5px; + border-radius: 2px; + outline: none; +} + +QPushButton:disabled +{ + background-color: #31363b; + border-width: 1px; + border-color: #454545; + border-style: solid; + padding-top: 5px; + padding-bottom: 5px; + padding-left: 10px; + padding-right: 10px; + border-radius: 2px; + color: #454545; +} + +QPushButton:focus { + background-color: #3daee9; + color: white; +} + +QPushButton:pressed +{ + background-color: #3daee9; + padding-top: -15px; + padding-bottom: -17px; +} + +QPushButton#blueButton{ + border-radius: 4px; + border: 2px solid rgb(41, 41, 41); + background-color: rgb(0, 112, 193); +} +QPushButton#blueButton:hover{ + border-color: rgb(45, 45, 45); +} +QPushButton#blueButton:pressed, QPushButton#buleButton:checked{ + border-color: rgb(0, 160, 230); +} + + +QPushButton#redButton{ + border-radius: 4px; + border: 2px solid rgb(41, 41, 41); + background-color: rgb(114, 26, 20); +} +QPushButton#redButton:hover{ + border-color: rgb(45, 45, 45); +} +QPushButton#redButton:pressed, QPushButton#buleButton:checked{ + border-color: rgb(0, 160, 230); +} + +QPushButton#greenButton{ + border-radius: 4px; + border: 2px solid rgb(41, 41, 41); + background-color: rgb(118, 197, 126); +} +QPushButton#greenButton:hover{ + border-color: rgb(45, 45, 45); +} +QPushButton#greenButton:pressed, QPushButton#buleButton:checked{ + border-color: rgb(0, 160, 230); +} + + +QComboBox +{ + selection-background-color: #3daee9; + border-style: solid; + border: 1px solid #76797C; + border-radius: 2px; + padding: 5px; + min-width: 75px; +} + +QPushButton:checked{ + background-color: #76797C; + border-color: #6A6969; +} + +QComboBox:hover,QPushButton:hover,QAbstractSpinBox:hover,QLineEdit:hover,QTextEdit:hover,QPlainTextEdit:hover,QAbstractView:h +over,QTreeView:hover +{ + border: 1px solid #3daee9; + color: #eff0f1; +} + +QComboBox:on +{ + padding-top: 3px; + padding-left: 4px; + selection-background-color: #4a4a4a; +} + +QComboBox QAbstractItemView +{ + background-color: #232629; + border-radius: 2px; + border: 1px solid #76797C; + selection-background-color: #3daee9; +} + +QComboBox::drop-down +{ + subcontrol-origin: padding; + subcontrol-position: top right; + width: 15px; + + border-left-width: 0px; + border-left-color: darkgray; + border-left-style: solid; + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} + +QComboBox::down-arrow +{ + image: url(:/qss_icons/rc/down_arrow_disabled.png); +} + +QComboBox::down-arrow:on, QComboBox::down-arrow:hover, +QComboBox::down-arrow:focus +{ + image: url(:/qss_icons/rc/down_arrow.png); +} + +QAbstractSpinBox { + padding: 5px; + border: 1px solid #76797C; + background-color: #232629; + color: #eff0f1; + border-radius: 2px; + min-width: 75px; +} + +QAbstractSpinBox:up-button +{ + background-color: transparent; + subcontrol-origin: border; + subcontrol-position: center right; +} + +QAbstractSpinBox:down-button +{ + background-color: transparent; + subcontrol-origin: border; + subcontrol-position: center left; +} + +QAbstractSpinBox::up-arrow,QAbstractSpinBox::up-arrow:disabled,QAbstractSpinBox::up-arrow:off { + image: url(:/qss_icons/rc/up_arrow_disabled.png); + width: 10px; + height: 10px; +} +QAbstractSpinBox::up-arrow:hover +{ + image: url(:/qss_icons/rc/up_arrow.png); +} + + +QAbstractSpinBox::down-arrow,QAbstractSpinBox::down-arrow:disabled,QAbstractSpinBox::down-arrow:off +{ + image: url(:/qss_icons/rc/down_arrow_disabled.png); + width: 10px; + height: 10px; +} +QAbstractSpinBox::down-arrow:hover +{ + image: url(:/qss_icons/rc/down_arrow.png); +} + + +QLabel +{ + border: 0px solid black; +} + +QLabel#whiteLabel +{ + border: 0px solid black; + background:white; + color:rgb(100,100,100,250); + font-size:15px; + font-weight:bold; + font-family:Roman times; +} + +QLabel#whiteLabel:hover +{ + color:rgb(100,100,100,120); +} + +QLabel#blueLabel +{ + border: 0px solid black; + background:rgb(0, 112, 193); + font-size:15px; + font-weight:bold; + font-family:Roman times; +} + +QLabel#blueLabel:hover +{ + color:rgb(100,100,100,120); +} + +QLabel#redLabel +{ + border: 0px solid black; + background: rgb(114, 26, 20); + font-size:15px; + font-weight:bold; + font-family:Roman times; +} + +QLabel#redLabel:hover +{ + color:rgb(100,100,100,120); +} + +QLabel#greenLabel +{ + border: 0px solid black; + background: rgb(118, 197, 126); + font-size:15px; + font-weight:bold; + font-family:Roman times; +} + +QLabel#greenLabel:hover +{ + color:rgb(100,100,100,120); +} + +QLabel#orangeLabel +{ + border: 0px solid black; + background: rgb(255, 121, 0); + font-size:15px; + font-weight:bold; + font-family:Roman times; +} + +QLabel#orangeLabel:hover +{ + color:rgb(100,100,100,120); +} + +QLabel#purseLabel +{ + border: 0px solid black; + background: rgb(98, 43, 98); + font-size:15px; + font-weight:bold; + font-family:Roman times; +} + +QLabel#purseLabel:hover +{ + color:rgb(100,100,100,120); +} + +QTabWidget{ + border: 0px transparent black; +} + +QTabWidget::pane { + border: 1px solid #76797C; + padding: 5px; + margin: 0px; +} + +QTabBar +{ + qproperty-drawBase: 0; + left: 5px; /* move to the right by 5px */ + border-radius: 3px; +} + +QTabBar:focus +{ + border: 0px transparent black; +} + +QTabBar::close-button { + image: url(:/qss_icons/rc/close.png); + background: transparent; +} + +QTabBar::close-button:hover +{ + image: url(:/qss_icons/rc/close-hover.png); + background: transparent; +} + +QTabBar::close-button:pressed { + image: url(:/qss_icons/rc/close-pressed.png); + background: transparent; +} + +/* TOP TABS */ +QTabBar::tab:top { + color: #eff0f1; + border: 1px solid #76797C; + border-bottom: 1px transparent black; + background-color: #31363b; + padding: 5px; + min-width: 50px; + border-top-left-radius: 2px; + border-top-right-radius: 2px; +} + +QTabBar::tab:top:!selected +{ + color: #eff0f1; + background-color: #54575B; + border: 1px solid #76797C; + border-bottom: 1px transparent black; + border-top-left-radius: 2px; + border-top-right-radius: 2px; +} + +QTabBar::tab:top:!selected:hover { + background-color: #3daee9; +} + +/* BOTTOM TABS */ +QTabBar::tab:bottom { + color: #eff0f1; + border: 1px solid #76797C; + border-top: 1px transparent black; + background-color: #31363b; + padding: 5px; + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + min-width: 50px; +} + +QTabBar::tab:bottom:!selected +{ + color: #eff0f1; + background-color: #54575B; + border: 1px solid #76797C; + border-top: 1px transparent black; + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; +} + +QTabBar::tab:bottom:!selected:hover { + background-color: #3daee9; +} + +/* LEFT TABS */ +QTabBar::tab:left { + color: #eff0f1; + border: 1px solid #76797C; + border-left: 1px transparent black; + background-color: #31363b; + padding: 5px; + border-top-right-radius: 2px; + border-bottom-right-radius: 2px; + min-height: 50px; +} + +QTabBar::tab:left:!selected +{ + color: #eff0f1; + background-color: #54575B; + border: 1px solid #76797C; + border-left: 1px transparent black; + border-top-right-radius: 2px; + border-bottom-right-radius: 2px; +} + +QTabBar::tab:left:!selected:hover { + background-color: #3daee9; +} + + +/* RIGHT TABS */ +QTabBar::tab:right { + color: #eff0f1; + border: 1px solid #76797C; + border-right: 1px transparent black; + background-color: #31363b; + padding: 5px; + border-top-left-radius: 2px; + border-bottom-left-radius: 2px; + min-height: 50px; +} + +QTabBar::tab:right:!selected +{ + color: #eff0f1; + background-color: #54575B; + border: 1px solid #76797C; + border-right: 1px transparent black; + border-top-left-radius: 2px; + border-bottom-left-radius: 2px; +} + +QTabBar::tab:right:!selected:hover { + background-color: #3daee9; +} + +QTabBar QToolButton::right-arrow:enabled { + image: url(:/qss_icons/rc/right_arrow.png); + } + + QTabBar QToolButton::left-arrow:enabled { + image: url(:/qss_icons/rc/left_arrow.png); + } + +QTabBar QToolButton::right-arrow:disabled { + image: url(:/qss_icons/rc/right_arrow_disabled.png); + } + + QTabBar QToolButton::left-arrow:disabled { + image: url(:/qss_icons/rc/left_arrow_disabled.png); + } + + +QDockWidget { + background: #31363b; + border: 1px solid #403F3F; + titlebar-close-icon: url(:/qss_icons/rc/close.png); + titlebar-normal-icon: url(:/qss_icons/rc/undock.png); +} + +QDockWidget::close-button, QDockWidget::float-button { + border: 1px solid transparent; + border-radius: 2px; + background: transparent; +} + +QDockWidget::close-button:hover, QDockWidget::float-button:hover { + background: rgba(255, 255, 255, 10); +} + +QDockWidget::close-button:pressed, QDockWidget::float-button:pressed { + padding: 1px -1px -1px 1px; + background: rgba(255, 255, 255, 10); +} + +QTreeView, QListView +{ + border: 1px solid #76797C; + background-color: #232629; +} + +QTreeView:branch:selected, QTreeView:branch:hover +{ + background: url(:/qss_icons/rc/transparent.png); +} + +QTreeView::branch:has-siblings:!adjoins-item { + border-image: url(:/qss_icons/rc/transparent.png); +} + +QTreeView::branch:has-siblings:adjoins-item { + border-image: url(:/qss_icons/rc/transparent.png); +} + +QTreeView::branch:!has-children:!has-siblings:adjoins-item { + border-image: url(:/qss_icons/rc/transparent.png); +} + +QTreeView::branch:has-children:!has-siblings:closed, +QTreeView::branch:closed:has-children:has-siblings { + image: url(:/qss_icons/rc/branch_closed.png); +} + +QTreeView::branch:open:has-children:!has-siblings, +QTreeView::branch:open:has-children:has-siblings { + image: url(:/qss_icons/rc/branch_open.png); +} + +QTreeView::branch:has-children:!has-siblings:closed:hover, +QTreeView::branch:closed:has-children:has-siblings:hover { + image: url(:/qss_icons/rc/branch_closed-on.png); + } + +QTreeView::branch:open:has-children:!has-siblings:hover, +QTreeView::branch:open:has-children:has-siblings:hover { + image: url(:/qss_icons/rc/branch_open-on.png); + } + +QListView::item:!selected:hover, QTreeView::item:!selected:hover { + background: rgba(167,218,245, 0.3); + outline: 0; + color: #eff0f1 +} + +QListView::item:selected:hover, QTreeView::item:selected:hover { + background: #3daee9; + color: #eff0f1; +} + +QSlider::groove:horizontal { + border: 1px solid #565a5e; + height: 4px; + background: #565a5e; + margin: 0px; + border-radius: 2px; +} + +QSlider::handle:horizontal { + background: #232629; + border: 1px solid #565a5e; + width: 16px; + height: 16px; + margin: -8px 0; + border-radius: 9px; +} + +QSlider::groove:vertical { + border: 1px solid #565a5e; + width: 4px; + background: #565a5e; + margin: 0px; + border-radius: 3px; +} + +QSlider::handle:vertical { + background: #232629; + border: 1px solid #565a5e; + width: 16px; + height: 16px; + margin: 0 -8px; + border-radius: 9px; +} + +QToolButton { + background-color: transparent; + border: 1px transparent #76797C; + border-radius: 2px; + margin: 3px; + padding: 5px; +} + +QToolButton[popupMode="1"] { /* only for MenuButtonPopup */ + padding-right: 20px; /* make way for the popup button */ + border: 1px #76797C; + border-radius: 5px; +} + +QToolButton[popupMode="2"] { /* only for InstantPopup */ + padding-right: 10px; /* make way for the popup button */ + border: 1px #76797C; +} + + +QToolButton:hover, QToolButton::menu-button:hover { + background-color: transparent; + border: 1px solid #3daee9; + padding: 5px; +} + +QToolButton:checked, QToolButton:pressed, + QToolButton::menu-button:pressed { + background-color: #3daee9; + border: 1px solid #3daee9; + padding: 5px; +} + +/* the subcontrol below is used only in the InstantPopup or DelayedPopup mode */ +QToolButton::menu-indicator { + image: url(:/qss_icons/rc/down_arrow.png); + top: -7px; left: -2px; /* shift it a bit */ +} + +/* the subcontrols below are used only in the MenuButtonPopup mode */ +QToolButton::menu-button { + border: 1px transparent #76797C; + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + /* 16px width + 4px for border = 20px allocated above */ + width: 16px; + outline: none; +} + +QToolButton::menu-arrow { + image: url(:/qss_icons/rc/down_arrow.png); +} + +QToolButton::menu-arrow:open { + border: 1px solid #76797C; +} + +QPushButton::menu-indicator { + subcontrol-origin: padding; + subcontrol-position: bottom right; + left: 8px; +} + +QTableView +{ + border: 1px solid #76797C; + gridline-color: #31363b; + background-color: #232629; +} + + +QTableView, QHeaderView +{ + border-radius: 0px; +} + +QTableView::item:pressed, QListView::item:pressed, QTreeView::item:pressed { + background: #3daee9; + color: #eff0f1; +} + +QTableView::item:selected:active, QTreeView::item:selected:active, QListView::item:selected:active { + background: #3daee9; + color: #eff0f1; +} + + +QHeaderView +{ + background-color: #31363b; + border: 1px transparent; + border-radius: 0px; + margin: 0px; + padding: 0px; + +} + +QHeaderView::section { + background-color: #31363b; + color: #eff0f1; + padding: 5px; + border: 1px solid #76797C; + border-radius: 0px; + text-align: center; +} + +QHeaderView::section::vertical::first, QHeaderView::section::vertical::only-one +{ + border-top: 1px solid #76797C; +} + +QHeaderView::section::vertical +{ + border-top: transparent; +} + +QHeaderView::section::horizontal::first, QHeaderView::section::horizontal::only-one +{ + border-left: 1px solid #76797C; +} + +QHeaderView::section::horizontal +{ + border-left: transparent; +} + + +QHeaderView::section:checked + { + color: white; + background-color: #334e5e; + } + + /* style the sort indicator */ +QHeaderView::down-arrow { + image: url(:/qss_icons/rc/down_arrow.png); +} + +QHeaderView::up-arrow { + image: url(:/qss_icons/rc/up_arrow.png); +} + + +QTableCornerButton::section { + background-color: #31363b; + border: 1px transparent #76797C; + border-radius: 0px; +} + +QToolBox { + padding: 5px; + border: 1px transparent black; +} + +QToolBox::tab { + color: #eff0f1; + background-color: #31363b; + border: 1px solid #76797C; + border-bottom: 1px transparent #31363b; + border-top-left-radius: 5px; + border-top-right-radius: 5px; +} + +QToolBox::tab:selected { /* italicize selected tabs */ + font: italic; + background-color: #31363b; + border-color: #3daee9; + } + +QStatusBar::item { + border: 0px transparent dark; + } + + +QFrame[height="3"], QFrame[width="3"] { + background-color: #76797C; +} + + +QSplitter::handle { + border: 1px dashed #76797C; +} + +QSplitter::handle:hover { + background-color: #787876; + border: 1px solid #76797C; +} + +QSplitter::handle:horizontal { + width: 1px; +} + +QSplitter::handle:vertical { + height: 1px; +} + +QProgressBar { + border: 1px solid #76797C; + border-radius: 5px; + text-align: center; +} + +QProgressBar::chunk { + background-color: #05B8CC; +} + + diff --git a/vnpy/trader/ui/kline/cta.ico b/vnpy/trader/ui/kline/cta.ico new file mode 100644 index 00000000..1cc84063 Binary files /dev/null and b/vnpy/trader/ui/kline/cta.ico differ diff --git a/vnpy/trader/ui/kline/kline.py b/vnpy/trader/ui/kline/kline.py new file mode 100644 index 00000000..04b19b49 --- /dev/null +++ b/vnpy/trader/ui/kline/kline.py @@ -0,0 +1,1413 @@ +# flake8: noqa +""" +Python K线模块,包含十字光标和鼠标键盘交互 +Support By 量投科技(http://www.quantdo.com.cn/) + +修改by:华富资产,李来佳 +20180515 change log: +1.修改命名规则,将图形划分为 主图(main),副图1(volume),副图2:(sub),能够设置开关显示/关闭 两个副图。区分indicator/signal +2.修改鼠标滚动得操作,去除focus,增加双击鼠标事件 +3.增加重定位接口,供上层界面调用,实现多周期窗口的时间轴同步。 + +""" + +# Qt相关和十字光标 +import sys +import traceback +import copy +import numpy as np +import pandas as pd +import pyqtgraph as pg + +from functools import partial +from datetime import datetime +from collections import deque, OrderedDict +from qtpy import QtGui, QtCore, QtWidgets + +# 其他 +from vnpy.trader.ui.kline.crosshair import Crosshair +from vnpy.trader.constant import Direction, Offset + + +######################################################################## +# 键盘鼠标功能 +######################################################################## +class KeyWraper(QtWidgets.QWidget): + """键盘鼠标功能支持的窗体元类""" + + # 初始化 + + def __init__(self, parent=None): + QtWidgets.QWidget.__init__(self, parent) + + # 定时器(for 鼠标双击) + self.timer = QtCore.QTimer() + self.timer.setInterval(300) + self.timer.setSingleShot(True) + self.timer.timeout.connect(self.timeout) + self.click_count = 0 # 鼠标点击次数 + self.pos = None # 鼠标点击的位置 + + # 激活鼠标跟踪功能 + self.setMouseTracking(True) + + def timeout(self): + """鼠标双击定时检查""" + if self.click_count == 1 and self.pos is not None: + self.onLClick(self.pos) + + self.click_count = 0 + self.pos = None + + def keyPressEvent(self, event): + """ + 重载方法keyPressEvent(self,event),即按键按下事件方法 + :param event: + :return: + """ + if event.key() == QtCore.Qt.Key_Up: + self.onUp() + elif event.key() == QtCore.Qt.Key_Down: + self.onDown() + elif event.key() == QtCore.Qt.Key_Left: + self.onLeft() + elif event.key() == QtCore.Qt.Key_Right: + self.onRight() + elif event.key() == QtCore.Qt.Key_PageUp: + self.onPre() + elif event.key() == QtCore.Qt.Key_PageDown: + self.onNxt() + event.accept() + + def mousePressEvent(self, event): + """ + 重载方法mousePressEvent(self,event),即鼠标点击事件方法 + :param event: + :return: + """ + if event.button() == QtCore.Qt.RightButton: + self.onRClick(event.pos()) + elif event.button() == QtCore.Qt.LeftButton: + self.click_count += 1 + if not self.timer.isActive(): + self.timer.start() + + if self.click_count > 1: + self.onDoubleClick(event.pos()) + else: + self.pos = event.pos() + + event.accept() + + def mouseRelease(self, event): + """ + 重载方法mouseReleaseEvent(self,event),即鼠标点击事件方法 + :param event: + :return: + """ + if event.button() == QtCore.Qt.RightButton: + self.onRRelease(event.pos()) + elif event.button() == QtCore.Qt.LeftButton: + self.onLRelease(event.pos()) + self.releaseMouse() + + def wheelEvent(self, event): + """ + 重载方法wheelEvent(self,event),即滚轮事件方法 + :param event: + :return: + """ + try: + pos = event.angleDelta() + if pos.y() > 0: + self.onUp() + else: + self.onDown() + event.accept() + except Exception as ex: + print(u'wheelEvent exception:{},{}'.format(str(ex), traceback.format_exc())) + + def paintEvent(self, event): + """ + 重载方法paintEvent(self,event),即拖动事件方法 + :param event: + :return: + """ + self.onPaint() + event.accept() + + # PgDown键 + + def onNxt(self): + pass + + # PgUp键 + + def onPre(self): + pass + + # 向上键和滚轮向上 + + def onUp(self): + pass + + # 向下键和滚轮向下 + + def onDown(self): + pass + + # 向左键 + + def onLeft(self): + pass + + # 向右键 + + def onRight(self): + pass + + # 鼠标左单击 + + def onLClick(self, pos): + print('single left click') + + # 鼠标右单击 + + def onRClick(self, pos): + pass + + def onDoubleClick(self, pos): + print('double click') + + # 鼠标左释放 + + def onLRelease(self, pos): + pass + + # 鼠标右释放 + + def onRRelease(self, pos): + pass + + # 画图 + + def onPaint(self): + pass + + +# 选择缩放功能支持 +class CustomViewBox(pg.ViewBox): + + def __init__(self, *args, **kwds): + pg.ViewBox.__init__(self, *args, **kwds) + # 拖动放大模式 + # self.setMouseMode(self.RectMode) + + # 右键自适应 + + def mouseClickEvent(self, ev): + if ev.button() == QtCore.Qt.RightButton: + self.autoRange() + + +class MyStringAxis(pg.AxisItem): + """ + 时间序列横坐标支持 + changelog: by 李来佳 + 增加时间与x轴的双向映射 + """ + + # 初始化 + + def __init__(self, xdict, *args, **kwargs): + pg.AxisItem.__init__(self, *args, **kwargs) + self.minVal = 0 + self.maxVal = 0 + # 序列 <= > 时间 + self.xdict = OrderedDict() + self.xdict.update(xdict) + # 时间 <=> 序列 + self.tdict = OrderedDict([(v, k) for k, v in xdict.items()]) + self.x_values = np.asarray(xdict.keys()) + self.x_strings = list(xdict.values()) + self.setPen(color=(255, 255, 255, 255), width=0.8) + self.setStyle(tickFont=QtGui.QFont("Roman times", 10, QtGui.QFont.Bold), autoExpandTextSpace=True) + + def update_xdict(self, xdict): + """ + 更新坐标映射表 + :param xdict: + :return: + """ + # 更新 x轴-时间映射 + self.xdict.update(xdict) + # 更新 时间-x轴映射 + tdict = dict([(v, k) for k, v in xdict.items()]) + self.tdict.update(tdict) + + # 重新生成x轴队列和时间字符串显示队列 + self.x_values = np.asarray(self.xdict.keys()) + self.x_strings = list(self.xdict.values()) + + def get_x_by_time(self, t_value): + """ + 通过 时间,找到匹配或最接近x轴 + :param t_value: datetime 类型时间 + :return: + """ + last_time = None + for t in self.x_strings: + if last_time is None: + last_time = t + continue + if t > t_value: + break + last_time = t + + x = self.tdict.get(last_time, 0) + return x + + def tickStrings(self, values, scale, spacing): + """ + 将原始横坐标转换为时间字符串,第一个坐标包含日期 + :param values: + :param scale: + :param spacing: + :return: + """ + strings = [] + for v in values: + vs = v * scale + if vs in self.x_values: + vstr = self.x_strings[np.abs(self.x_values - vs).argmin()] + vstr = vstr.strftime('%Y-%m-%d %H:%M:%S') + else: + vstr = "" + strings.append(vstr) + return strings + + +class CandlestickItem(pg.GraphicsObject): + """K线图形对象""" + + # 初始化 + + def __init__(self, data): + """初始化""" + pg.GraphicsObject.__init__(self) + # 数据格式: [ (time, open, close, low, high),...] + self.data = data + # 只重画部分图形,大大提高界面更新速度 + self.rect = None + self.picture = None + self.setFlag(self.ItemUsesExtendedStyleOption) + # 画笔和画刷 + w = 0.4 + self.offset = 0 + self.low = 0 + self.high = 1 + self.picture = QtGui.QPicture() + self.pictures = [] + self.bPen = pg.mkPen(color=(0, 240, 240, 255), width=w * 2) # 阴线画笔 + self.bBrush = pg.mkBrush((0, 240, 240, 255)) # 阴线主体 + self.rPen = pg.mkPen(color=(255, 60, 60, 255), width=w * 2) # 阳线画笔 + self.rBrush = pg.mkBrush((255, 60, 60, 255)) # 阳线主体 + self.rBrush.setStyle(QtCore.Qt.NoBrush) + # 刷新K线 + self.generatePicture(self.data) + + # 画K线 + + def generatePicture(self, data=None, redraw=False): + """重新生成图形对象""" + # 重画或者只更新最后一个K线 + if redraw: + self.pictures = [] + elif self.pictures: + self.pictures.pop() + w = 0.4 + bPen = self.bPen + bBrush = self.bBrush + rPen = self.rPen + rBrush = self.rBrush + low, high = (data[0]['low'], data[0]['high']) if len(data) > 0 else (0, 1) + for (t, open0, close0, low0, high0) in data: + # t 并不是时间,是序列 + if t >= len(self.pictures): + # 每一个K线创建一个picture + picture = QtGui.QPicture() + p = QtGui.QPainter(picture) + low, high = (min(low, low0), max(high, high0)) + + # 下跌蓝色(实心), 上涨红色(空心) + pen, brush, pmin, pmax = (bPen, bBrush, close0, open0) \ + if open0 > close0 else (rPen, rBrush, open0, close0) + p.setPen(pen) + p.setBrush(brush) + + # 画K线方块和上下影线 + if open0 == close0: + p.drawLine(QtCore.QPointF(t - w, open0), QtCore.QPointF(t + w, close0)) + else: + p.drawRect(QtCore.QRectF(t - w, open0, w * 2, close0 - open0)) + if pmin > low0: + p.drawLine(QtCore.QPointF(t, low0), QtCore.QPointF(t, pmin)) + if high0 > pmax: + p.drawLine(QtCore.QPointF(t, pmax), QtCore.QPointF(t, high0)) + + p.end() + # 添加到队列中 + self.pictures.append(picture) + + # 更新所有K线的最高/最低 + self.low, self.high = low, high + + # 手动重画 + + def update(self): + if not self.scene() is None: + self.scene().update() + + # 自动重画 + + def paint(self, painter, opt, w): + # 获取显示区域 + rect = opt.exposedRect + # 获取显示区域/数据的滑动最小值/最大值,即需要显示的数据最小值/最大值。 + xmin, xmax = (max(0, int(rect.left())), min(int(len(self.pictures)), int(rect.right()))) + + # 区域发生变化,或者没有最新图片(缓存),重画 + if not self.rect == (rect.left(), rect.right()) or self.picture is None: + # 更新显示区域 + self.rect = (rect.left(), rect.right()) + # 重画,并缓存为最新图片 + self.picture = self.createPic(xmin, xmax) + self.picture.play(painter) + + # 存在缓存,直接显示出来 + elif self.picture: + self.picture.play(painter) + + # 缓存图片 + + def createPic(self, xmin, xmax): + picture = QtGui.QPicture() + p = QtGui.QPainter(picture) + # 全部数据,[xmin~xmax]的k线,重画一次 + [pic.play(p) for pic in self.pictures[xmin:xmax]] + p.end() + return picture + + # 定义显示边界,x轴:0~K线数据长度;Y轴,最低值~最高值-最低值 + + def boundingRect(self): + return QtCore.QRectF(0, self.low, len(self.pictures), (self.high - self.low)) + + +######################################################################## +class KLineWidget(KeyWraper): + """用于显示价格走势图""" + + # 是否完成了历史数据的读取 + initCompleted = False + clsId = 0 + + def __init__(self, parent=None, display_vol=False, display_sub=False, **kargs): + """Constructor""" + self.parent = parent + super(KLineWidget, self).__init__(parent) + + # 当前序号 + self.index = None # 下标 + self.countK = 60 # 显示的K线数量范围 + + KLineWidget.clsId += 1 + self.windowId = str(KLineWidget.clsId) + + self.title = u'KLineWidget' + + # # 保存K线数据的列表和Numpy Array对象 + self.datas = [] # 'datetime','open','close','low','high','volume','openInterest + self.listBar = [] # 蜡烛图使用的Bar list :'time_int','open','close','low','high' + self.listVol = [] # 成交量(副图使用)的 volume list + + # 交易事务有关的线段 + self.list_trans = [] # 交易事务( {'start_time','end_time','tns_type','start_price','end_price','start_x','end_x','completed'} + self.list_trans_lines = [] + + # 交易记录相关的箭头 + self.list_trade_arrow = [] # 交易图标 list + self.x_t_trade_map = OrderedDict() # x 轴 与交易信号的映射 + self.t_trade_dict = OrderedDict() # t 时间的交易记录 + + # 标记相关 + self.list_markup = [] + self.x_t_markup_map = OrderedDict() # x轴与标记的映射 + self.t_markup_dict = OrderedDict() # t 时间的标记 + + # 所有K线上指标 + self.main_color_pool = deque(['red', 'green', 'yellow', 'white']) + self.main_indicator_data = {} # 主图指标数据(字典,key是指标,value是list) + self.main_indicator_colors = {} # 主图指标颜色(字典,key是指标,value是list + self.main_indicator_plots = {} # 主图指标的所有画布(字典,key是指标,value是plot) + + self.display_vol = display_vol + self.display_sub = display_sub + + # 所副图上信号图 + self.sub_color_pool = deque(['red', 'green', 'yellow', 'white']) + self.sub_indicator_data = {} + self.sub_indicator_colors = {} + self.sub_indicator_plots = {} + + # 初始化完成 + self.initCompleted = False + + # 调用函数 + self.initUi() + + # 通知上层时间切换点的回调函数 + self.relocate_notify_func = None + + # 初始化相关 + def initUi(self): + """ + 初始化界面 + leyout 如下: + ------------------———————— + \ 主图(K线/主图指标/交易信号 \ + \ \ + ----------------------------------- + \ 副图1(成交量) \ + ----------------------------------- + \ 副图2(持仓量/副图指标) \ + ----------------------------------- + + """ + self.setWindowTitle(u'K线工具') + # 主图 + self.pw = pg.PlotWidget() + # 界面布局 + self.lay_KL = pg.GraphicsLayout(border=(100, 100, 100)) + # self.lay_KL.setContentsMargins(10, 10, 10, 10) + self.lay_KL.setContentsMargins(5, 5, 5, 5) + self.lay_KL.setSpacing(0) + self.lay_KL.setBorder(color=(100, 100, 100, 250), width=0.4) + self.lay_KL.setZValue(0) + self.KLtitle = self.lay_KL.addLabel(u'') + self.pw.setCentralItem(self.lay_KL) + # 设置横坐标 + xdict = {} + self.axisTime = MyStringAxis(xdict, orientation='bottom') + # 初始化子图 + self.init_plot_main() + self.init_plot_volume() + self.init_plot_sub() + # 注册十字光标 + self.crosshair = Crosshair(self.pw, self) + # 设置界面 + self.vb = QtWidgets.QVBoxLayout() + self.vb.addWidget(self.pw) + self.setLayout(self.vb) + # 初始化完成 + self.initCompleted = True + + def create_plot_item(self, name): + """生成PlotItem对象""" + vb = CustomViewBox() + plotItem = pg.PlotItem(viewBox=vb, name=name, axisItems={'bottom': self.axisTime}) + plotItem.setMenuEnabled(False) + plotItem.setClipToView(True) + plotItem.hideAxis('left') + plotItem.showAxis('right') + plotItem.setDownsampling(mode='peak') + plotItem.setRange(xRange=(0, 1), yRange=(0, 1)) + plotItem.getAxis('right').setWidth(60) + plotItem.getAxis('right').setStyle(tickFont=QtGui.QFont("Roman times", 10, QtGui.QFont.Bold)) + plotItem.getAxis('right').setPen(color=(255, 255, 255, 255), width=0.8) + plotItem.showGrid(True, True) + plotItem.hideButtons() + return plotItem + + def init_plot_main(self): + """ + 初始化主图 + 1、添加 K线(蜡烛图) + :return: + """ + # 创建K线PlotItem + self.pi_main = self.create_plot_item('_'.join([self.windowId, 'Plot_Main'])) + + # 创建蜡烛图 + self.ci_candle = CandlestickItem(self.listBar) + + # 添加蜡烛图到主图 + self.pi_main.addItem(self.ci_candle) + self.pi_main.setMinimumHeight(200) + self.pi_main.setXLink('_'.join([self.windowId, 'Plot_Sub'])) + self.pi_main.hideAxis('bottom') + + # 添加主图到window layout + self.lay_KL.nextRow() + self.lay_KL.addItem(self.pi_main) + + def init_plot_volume(self): + """ + 初始化成交量副图 + :return: + """ + + # 创建plot item + self.pi_volume = self.create_plot_item('_'.join([self.windowId, 'Plot_Volume'])) + + if self.display_vol: + # 以蜡烛图(柱状图)的形式创建成交量图形对象 + self.ci_volume = CandlestickItem(self.listVol) + + # 副图1,添加成交量子图 + self.pi_volume.addItem(self.ci_volume) + self.pi_volume.setMaximumHeight(150) + self.pi_volume.setXLink('_'.join([self.windowId, 'Plot_Sub'])) + self.pi_volume.hideAxis('bottom') + else: + self.pi_volume.setMaximumHeight(1) + self.pi_volume.setXLink('_'.join([self.windowId, 'Plot_Sub'])) + self.pi_volume.hideAxis('bottom') + + # 添加副图1到window layout + self.lay_KL.nextRow() + self.lay_KL.addItem(self.pi_volume) + + def init_plot_sub(self): + """ + 初始化副图(只有一个图层) + :return: + """ + self.pi_sub = self.create_plot_item('_'.join([self.windowId, 'Plot_Sub'])) + + if self.display_sub: + # 副图的plot对象 + self.curve_sub = self.pi_sub.plot() + else: + self.pi_sub.setMaximumHeight(1) + self.pi_sub.setXLink('_'.join([self.windowId, 'Plot_Sub'])) + self.pi_sub.hideAxis('bottom') + + # 添加副图到窗体layer中 + self.lay_KL.nextRow() + self.lay_KL.addItem(self.pi_sub) + + # 画图相关 + + def plot_volume(self, redraw=False, xmin=0, xmax=-1): + """重画成交量子图""" + if self.initCompleted: + self.ci_volume.generatePicture(self.listVol[xmin:xmax], redraw) # 画成交量子图 + + def plot_kline(self, redraw=False, xmin=0, xmax=-1): + """重画K线子图""" + if self.initCompleted: + self.ci_candle.generatePicture(self.listBar[xmin:xmax], redraw) # 画K线 + + for indicator in list(self.main_indicator_data.keys()): + if indicator in self.main_indicator_plots: + self.main_indicator_plots[indicator].setData(self.main_indicator_data[indicator], + pen=self.main_indicator_colors[indicator][0], + name=indicator) + + def plot_sub(self, xmin=0, xmax=-1): + """重画持仓量子图""" + if self.initCompleted: + for indicator in list(self.sub_indicator_data.keys()): + # 调用该信号/指标画布(plotDataItem.setData()),更新数据,更新画笔颜色,更新名称 + if indicator in self.sub_indicator_plots: + self.sub_indicator_plots[indicator].setData(self.sub_indicator_data[indicator], + pen=self.sub_indicator_colors[indicator][0], + name=indicator) + + def add_indicator(self, indicator, is_main=True): + """ + 新增指标信号图 + :param indicator: 指标/信号的名称,如ma10, + :param is_main: 是否为主图 + :return: + """ + if is_main: + if indicator in self.main_indicator_plots: + self.pi_main.removeItem(self.main_indicator_plots[indicator]) # 存在该指标/信号,先移除原有画布 + + self.main_indicator_plots[indicator] = self.pi_main.plot() # 为该指标/信号,创建新的主图画布,登记字典 + self.main_indicator_colors[indicator] = self.main_color_pool[0] # 登记该指标/信号使用的颜色 + self.main_color_pool.append(self.main_color_pool.popleft()) # 调整剩余颜色 + if indicator not in self.main_indicator_data: + self.main_indicator_data[indicator] = [] + else: + if indicator in self.sub_indicator_plots: + self.pi_sub.removeItem(self.sub_indicator_plots[indicator]) # 若存在该指标/信号,先移除原有的附图画布 + self.sub_indicator_plots[indicator] = self.pi_sub.plot() # 为该指标/信号,创建新的主图画布,登记字典 + self.sub_indicator_colors[indicator] = self.sub_color_pool[0] # 登记该指标/信号使用的颜色 + self.sub_color_pool.append(self.sub_color_pool.popleft()) # 调整剩余颜色 + if indicator not in self.sub_indicator_data: + self.sub_indicator_data[indicator] = [] + + def plot_indicator(self, datas, is_main=True, clear=False): + """ + 刷新指标/信号图( 新数据) + :param datas: 所有数据 + :param is_main: 是否为主图 + :param clear: 是否要清除旧数据 + :return: + """ + if clear: + self.clear_indicator(is_main) # 清除主图/副图 + + if is_main: + for indicator in datas: + self.add_indicator(indicator, is_main) # 逐一添加主图信号/指标 + self.main_indicator_data[indicator] = datas[indicator] # 更新组件数据字典 + # 调用该信号/指标画布(plotDataItem.setData()),更新数据,更新画笔颜色,更新名称 + self.main_indicator_plots[indicator].setData(datas[indicator], + pen=self.main_indicator_colors[indicator][0], + name=indicator) + else: + for indicator in datas: + self.add_indicator(indicator, is_main) # 逐一增加子图指标/信号 + self.sub_indicator_data[indicator] = datas[indicator] # 更新组件数据字典 + # 调用该信号/指标画布(plotDataItem.setData()),更新数据,更新画笔颜色,更新名称 + self.sub_indicator_plots[indicator].setData(datas[indicator], + pen=self.sub_indicator_colors[indicator][0], name=indicator) + + def update_all(self): + """ + 手动更新所有K线图形,K线播放模式下需要 + """ + datas = self.datas + + if self.display_vol: + self.ci_volume.pictrue = None + self.ci_volume.update() + + self.ci_candle.pictrue = None + self.ci_candle.update() + + def update(view, low, high): + """ + 更新视图 + :param view: viewbox + :param low: + :param high: + :return: + """ + vRange = view.viewRange() + xmin = max(0, int(vRange[0][0])) + xmax = max(0, int(vRange[0][1])) + xmax = min(xmax, len(datas)) + if len(datas) > 0 and xmax > xmin: + ymin = min(datas[xmin:xmax][low]) + ymax = max(datas[xmin:xmax][high]) + view.setRange(yRange=(ymin, ymax)) + else: + view.setRange(yRange=(0, 1)) + + update(self.pi_main.getViewBox(), 'low', 'high') + update(self.pi_volume.getViewBox(), 'volume', 'volume') + + def plot_all(self, redraw=True, xMin=0, xMax=-1): + """ + 重画所有界面 + redraw :False=重画最后一根K线; True=重画所有 + xMin,xMax : 数据范围 + """ + + xMax = len(self.datas) if xMax < 0 else xMax + self.countK = xMax - xMin + self.index = int((xMax + xMin) / 2) # 设置当前索引所在位置为数据的中心点 + self.pi_sub.setLimits(xMin=xMin, xMax=xMax) + self.pi_main.setLimits(xMin=xMin, xMax=xMax) + self.plot_kline(redraw, xMin, xMax) # K线图 + + if self.display_vol: + self.pi_volume.setLimits(xMin=xMin, xMax=xMax) + self.plot_volume(redraw, xMin, xMax) # K线副图,成交量 + + self.plot_sub(0, len(self.datas)) # K线副图,持仓量 + self.refresh() + + def refresh(self): + """ + 刷新三个子图的显示范围 + """ + # 计算界面上显示数量的最小x/最大x + minutes = int(self.countK / 2) + xmin = max(0, self.index - minutes) + xmax = xmin + 2 * minutes + + # 更新主图/副图/成交量的 x范围 + self.pi_sub.setRange(xRange=(xmin, xmax)) + self.pi_main.setRange(xRange=(xmin, xmax)) + self.pi_volume.setRange(xRange=(xmin, xmax)) + + # 快捷键相关 + + def onNxt(self): + """跳转到下一个开平仓点""" + try: + if len(self.x_t_trade_map) > 0 and self.index is not None: + datalen = len(self.datas) + self.index += 1 + while self.index < datalen and self.index in self.x_t_trade_map: + self.index += 1 + self.refresh() + x = self.index + y = self.datas[x]['close'] + self.crosshair.signal.emit((x, y)) + except Exception as ex: + print(u'{} onDown() exception:{},trace:{}'.format(self.title, str(ex), traceback.format_exc())) + + def onPre(self): + """跳转到上一个开平仓点""" + try: + if len(self.x_t_trade_map) > 0 and self.index: + self.index -= 1 + while self.index > 0 and self.index in self.x_t_trade_map: + self.index -= 1 + self.refresh() + x = self.index + y = self.datas[x]['close'] + self.crosshair.signal.emit((x, y)) + except Exception as ex: + print(u'{}.onDown() exception:{},trace:{}'.format(self.title, str(ex), traceback.format_exc())) + + def onDown(self): + """放大显示区间""" + try: + self.countK = min(len(self.datas), int(self.countK * 1.2) + 1) + self.refresh() + if len(self.datas) > 0: + x = self.index - self.countK / 2 + 2 if int( + self.crosshair.xAxis) < self.index - self.countK / 2 + 2 else int(self.crosshair.xAxis) + x = self.index + self.countK / 2 - 2 if x > self.index + self.countK / 2 - 2 else x + x = int(x) + y = self.datas[x][2] + self.crosshair.signal.emit((x, y)) + print(u'onDown:countK:{},x:{},y:{},index:{}'.format(self.countK, x, y, self.index)) + except Exception as ex: + print(u'{}.onDown() exception:{},trace:{}'.format(self.title, str(ex), traceback.format_exc())) + + def onUp(self): + """缩小显示区间""" + try: + # 减少界面显示K线数量 + self.countK = max(3, int(self.countK / 1.2) - 1) + self.refresh() + if len(self.datas) > 0: + x = self.index - int(self.countK / 2) + 2 if int(self.crosshair.xAxis) < self.index - int( + self.countK / 2) + 2 else int(self.crosshair.xAxis) + x = self.index + int(self.countK / 2) - 2 if x > self.index + (self.countK / 2) - 2 else x + x = int(x) + y = self.datas[x]['close'] + self.crosshair.signal.emit((x, y)) + print(u'onUp:countK:{},x:{},y:{},index:{}'.format(self.countK, x, y, self.index)) + except Exception as ex: + print(u'{}.onDown() exception:{},trace:{}'.format(self.title, str(ex), traceback.format_exc())) + + def onLeft(self): + """向左移动""" + try: + if len(self.datas) > 0 and int(self.crosshair.xAxis) > 2: + x = int(self.crosshair.xAxis) - 1 + y = self.datas[x]['close'] + if x <= self.index - self.countK / 2 + 2 and self.index > 1: + self.index -= 1 + self.refresh() + self.crosshair.signal.emit((x, y)) + + print(u'onLeft:countK:{},x:{},y:{},index:{}'.format(self.countK, x, y, self.index)) + except Exception as ex: + print(u'{}.onLeft() exception:{},trace:{}'.format(self.title, str(ex), traceback.format_exc())) + + def onRight(self): + """向右移动""" + try: + if len(self.datas) > 0 and int(self.crosshair.xAxis) < len(self.datas) - 1: + x = int(self.crosshair.xAxis) + 1 + y = self.datas[x]['close'] + if x >= self.index + int(self.countK / 2) - 2: + self.index += 1 + self.refresh() + self.crosshair.signal.emit((x, y)) + except Exception as ex: + print(u'{}.onLeft() exception:{},trace:{}'.format(self.title, str(ex), traceback.format_exc())) + + def onDoubleClick(self, pos): + """ + 鼠标双击事件 + :param pos: + :return: + """ + try: + if len(self.datas) > 0 and int(self.crosshair.xAxis) >= 0: + x = int(self.crosshair.xAxis) + time_value = self.axisTime.xdict.get(x, None) + self.index = x + + print(u'{} doubleclick: {},x:{},index:{}'.format(self.title, time_value, x, self.index)) + + if self.relocate_notify_func is not None and time_value is not None: + self.relocate_notify_func(self.windowId, time_value, self.countK) + except Exception as ex: + print(u'{}.onDoubleClick() exception:{},trace:{}'.format(self.title, str(ex), traceback.format_exc())) + + def relocate(self, window_id, t_value, count_k): + """ + 重定位到最靠近t_value的x坐标 + :param window_id: + :param t_value: + :param count_k + :return: + """ + if self.windowId == window_id or count_k < 2: + return + + try: + x_value = self.axisTime.get_x_by_time(t_value) + self.countK = count_k + + if 0 < x_value <= len(self.datas): + self.index = x_value + x = self.index + y = self.datas[x]['close'] + self.refresh() + self.crosshair.signal.emit((x, y)) + print(u'{} reloacate to :{},{}'.format(self.title, x, y)) + except Exception as ex: + print(u'{}.relocate() exception:{},trace:{}'.format(self.title, str(ex), traceback.format_exc())) + + # 界面回调相关 + + def onPaint(self): + """界面刷新回调""" + view = self.pi_main.getViewBox() + vRange = view.viewRange() + xmin = max(0, int(vRange[0][0])) + xmax = max(0, int(vRange[0][1])) + self.index = int((xmin + xmax) / 2) + 1 + + def resignData(self, datas): + """更新数据,用于Y坐标自适应""" + self.crosshair.datas = datas + + def viewXRangeChanged(low, high, self): + vRange = self.viewRange() + xmin = max(0, int(vRange[0][0])) + xmax = max(0, int(vRange[0][1])) + xmax = min(xmax, len(datas)) + if len(datas) > 0 and xmax > xmin: + ymin = min(datas[xmin:xmax][low]) + ymax = max(datas[xmin:xmax][high]) + self.setRange(yRange=(ymin, ymax)) + else: + self.setRange(yRange=(0, 1)) + + view = self.pi_main.getViewBox() + view.sigXRangeChanged.connect(partial(viewXRangeChanged, 'low', 'high')) + + if self.display_vol: + view = self.pi_volume.getViewBox() + view.sigXRangeChanged.connect(partial(viewXRangeChanged, 'volume', 'volume')) + if self.display_sub: + view = self.pi_sub.getViewBox() + # view.sigXRangeChanged.connect(partial(viewXRangeChanged,'openInterest','openInterest')) + view.setRange(yRange=(0, 100)) + + # 数据相关 + + def clearData(self): + """清空数据""" + # 清空数据,重新画图 + self.time_index = [] + self.listBar = [] + self.listVol = [] + + self.list_trade_arrow = [] + self.x_t_trade_map = OrderedDict() + self.t_trade_dict = OrderedDict() + + self.list_trans = [] + self.list_trans_lines = [] + + self.list_markup = [] + self.x_t_markup_map = OrderedDict() + self.t_markup_dict = OrderedDict() + + # 清空主图指标 + self.main_indicator_data = {} + # 清空副图指标 + self.sub_indicator_data = {} + + self.datas = None + + def clear_indicator(self, main=True): + """清空指标图形""" + # 清空信号图 + if main: + for indicator in self.main_indicator_plots: + self.pi_main.removeItem(self.main_indicator_plots[indicator]) + self.main_indicator_data = {} + self.main_indicator_plots = {} + else: + for indicator in self.sub_indicator_plots: + self.pi_sub.removeItem(self.sub_indicator_plots[indicator]) + self.sub_indicator_data = {} + self.sub_indicator_plots = {} + + def onBar(self, bar, main_indicator_datas, sub_indicator_datas, nWindow=20, inited=False): + """ + 新增K线数据,K线播放模式 + + :param bar: dict + :param main_indicator_datas: + :param sub_indicator_datas: + :param nWindow: + :return: nWindow : 最大数据窗口 + """ + bar_datetime = bar.get('datetime', '') + try: + bar_datetime = datetime.strptime(bar_datetime, '%Y-%m-%d %H:%M:%S') + except: # noqa + bar_datetime = datetime.now() + bar_open = bar.get('open', 0) + bar_close = bar.get('close', 0) + bar_high = bar.get('high', 0) + bar_low = bar.get('low', 0) + bar_volume = bar.get('volume', 0) + bar_openInterest = bar.get('openInterest') + if bar_openInterest == np.inf or bar_openInterest == -np.inf: + bar_openInterest = np.random.randint(0, 3) + + # 是否需要更新K线 + newBar = False if self.datas and bar_datetime == self.datas[-1].datetime else True + nrecords = len(self.datas) if newBar else len(self.datas) - 1 + + recordVol = (nrecords, bar_volume, 0, 0, bar_volume) if bar_close < bar_open else ( + nrecords, 0, bar_volume, 0, bar_volume) + + if newBar and any(self.datas): + # 主图数据增加一项 + self.datas.resize(nrecords + 1, refcheck=0) + self.listBar.resize(nrecords + 1, refcheck=0) + # 成交量指标,增加一项 + self.listVol.resize(nrecords + 1, refcheck=0) + + # 主图指标,增加一项 + for indicator in list(self.main_indicator_data.keys()): + indicator_data = self.main_indicator_data.get(indicator, []) + indicator_data.append(0) + + # 副图指标,增加一行 + for indicator in list(self.sub_indicator_data.keys()): + indicator_data = self.sub_indicator_data.get(indicator, []) + indicator_data.append(0) + + elif any(self.datas): + + # 主图指标,移除第一项 + for indicator in list(self.main_indicator_data.keys()): + indicator_data = self.main_indicator_data.get(indicator, []) + indicator_data.pop() + + # 副图指标,移除第一项 + for indicator in list(self.sub_indicator_data.keys()): + indicator_data = self.sub_indicator_data.get(indicator, []) + indicator_data.pop() + + if any(self.datas): + self.datas[-1] = (bar_datetime, bar_open, bar_close, bar_low, bar_high, bar_volume, bar_openInterest) + self.listBar[-1] = (nrecords, bar_open, bar_close, bar_low, bar_high) + self.listVol[-1] = recordVol + + # 主图指标,更新最后记录 + for indicator in list(self.main_indicator_data.keys()): + indicator_data = self.main_indicator_data.get(indicator, []) + indicator_data[-1] = main_indicator_datas.get(indicator, 0) + + # 副图指标,更新最后记录 + for indicator in list(self.sub_indicator_data.keys()): + indicator_data = self.sub_indicator_data.get(indicator, []) + indicator_data[-1] = sub_indicator_datas.get(indicator, 0) + + else: + self.datas = np.rec.array( + [(datetime, bar_open, bar_close, bar_low, bar_high, bar_volume, bar_openInterest)], + names=('datetime', 'open', 'close', 'low', 'high', 'volume', 'openInterest')) + self.listBar = np.rec.array([(nrecords, bar_open, bar_close, bar_low, bar_high)], + names=('time_int', 'open', 'close', 'low', 'high')) + self.listVol = np.rec.array([recordVol], names=('time_int', 'open', 'close', 'low', 'high')) + + # 主图指标,添加数据 + for indicator in list(self.main_indicator_data.keys()): + indicator_data = self.main_indicator_data.get(indicator, []) + indicator_data.append(main_indicator_datas.get(indicator, 0)) + + # 副图指标,添加数据 + for indicator in list(self.sub_indicator_data.keys()): + indicator_data = self.sub_indicator_data.get(indicator, []) + indicator_data.append(sub_indicator_datas.get(indicator, 0)) + + self.resignData(self.datas) + + self.axisTime.update_xdict({nrecords: bar_datetime}) + + if 'openInterest' in self.sub_indicator_data: + self.sub_indicator_data['openInterest'].append(bar_openInterest) + + self.resignData(self.datas) + nWindow0 = min(nrecords, nWindow) + xMax = nrecords + 2 + xMin = max(0, nrecords - nWindow0) + if inited: + self.plot_all(False, xMin, xMax) + if not newBar: + self.update_all() + self.index = 0 + self.crosshair.signal.emit((None, None)) + + def add_signal(self, t_value, direction, offset, price, volume): + """ + 增加信号 + :param t_value: + :param direction: + :param offset: + :param price: + :param volume: + :return: + """ + # 找到信号时间最贴近的bar x轴 + x = self.axisTime.get_x_by_time(t_value) + need_plot_arrow = False + + # 修正一下 信号时间,改为bar的时间 + if x not in self.x_t_trade_map: + bar_time = self.axisTime.xdict.get(x, t_value) + else: + # 如果存在映射,就更新 + bar_time = self.x_t_trade_map[x] + + trade_node = self.t_trade_dict.get(bar_time, None) + if trade_node is None: + # 当前时间无交易信号 + self.t_trade_dict[bar_time] = {'x': x, 'signals': [ + {'direction': direction, 'offset': offset, 'price': price, 'volume': volume}]} + self.x_t_trade_map[x] = bar_time + need_plot_arrow = True + else: + # match_signals = [t for t in trade_node['signals'] if t['direction'] == direction and t['offset'] == offset] + # if len(match_signals) == 0: + need_plot_arrow = True + trade_node['signals'].append({'direction': direction, 'offset': offset, 'price': price, 'volume': volume}) + self.x_t_trade_map[x] = bar_time + + # 需要显示图标 + if need_plot_arrow: + arrow = None + # 多信号 + if direction == Direction.LONG: + if offset == Offset.OPEN: + # buy + arrow = pg.ArrowItem(pos=(x, price), angle=135, brush=None, pen={'color': 'r', 'width': 1}, + tipAngle=30, baseAngle=20, tailLen=10, tailWidth=2) + # d = { + # "pos": (x, price), + # "data": 1, + # "size": 14, + # "pen": pg.mkPen((255, 255, 255)), + # "symbol": "t1", + # "brush": pg.mkBrush((255, 255, 0)) + # } + # arrow = pg.ScatterPlotItem() + # arrow.setData([d]) + else: + # cover + arrow = pg.ArrowItem(pos=(x, price), angle=0, brush=(255, 0, 0), pen=None, headLen=20, headWidth=20, + tailLen=10, tailWidth=2) + # 空信号 + elif direction == Direction.SHORT: + if offset == Offset.CLOSE: + # sell + arrow = pg.ArrowItem(pos=(x, price), angle=0, brush=(0, 255, 0), pen=None, headLen=20, headWidth=20, + tailLen=10, tailWidth=2) + else: + # short + arrow = pg.ArrowItem(pos=(x, price), angle=-135, brush=None, pen={'color': 'g', 'width': 1}, + tipAngle=30, baseAngle=20, tailLen=10, tailWidth=2) + if arrow: + self.pi_main.addItem(arrow) + self.list_trade_arrow.append(arrow) + + def add_trades(self, df_trades): + """ + 批量导入交易记录(vnpy回测中导出的trade.csv) + :param df_trades: + :return: + """ + if df_trades is None or len(df_trades) == 0: + print(u'dataframe is None or Empty', file=sys.stderr) + return + + for idx in df_trades.index: + # 时间 + trade_time = df_trades['time'].loc[idx] + if not isinstance(trade_time, datetime) and isinstance(trade_time, str): + trade_time = datetime.strptime(trade_time, '%Y-%m-%d %H:%M:%S') + + price = df_trades['price'].loc[idx] + direction = df_trades['direction'].loc[idx] + if direction.lower() in ['long', 'direction.long']: + direction = Direction.LONG + else: + direction = Direction.SHORT + offset = df_trades['offset'].loc[idx] + if offset.lower() in ['open', 'offset.open']: + offset = Offset.OPEN + else: + offset = Offset.CLOSE + + volume = df_trades['volume'].loc[idx] + + # 添加开仓信号 + self.add_signal(t_value=trade_time, direction=direction, offset=offset, price=price, + volume=volume) + + def add_signals(self, df_trade_list): + """ + 批量导入交易记录(vnpy回测中导出的trade_list.csv) + :param df_trade_list: + :return: + """ + if df_trade_list is None or len(df_trade_list) == 0: + print(u'dataframe is None or Empty', file=sys.stderr) + return + + for idx in df_trade_list.index: + # 开仓时间 + open_time = df_trade_list['open_time'].loc[idx] + if not isinstance(open_time, datetime) and isinstance(open_time, str): + open_time = datetime.strptime(open_time, '%Y-%m-%d %H:%M:%S') + + open_price = df_trade_list['open_price'].loc[idx] + direction = df_trade_list['direction'].loc[idx] + if direction.lower() == 'long': + open_direction = Direction.LONG + close_direction = Direction.SHORT + else: + open_direction = Direction.SHORT + close_direction = Direction.LONG + + close_time = df_trade_list['close_time'].loc[idx] + if not isinstance(close_time, datetime) and isinstance(close_time, str): + close_time = datetime.strptime(close_time, '%Y-%m-%d %H:%M:%S') + + close_price = df_trade_list['close_price'].loc[idx] + volume = df_trade_list['volume'].loc[idx] + + # 添加开仓信号 + self.add_signal(t_value=open_time, direction=open_direction, offset=Offset.OPEN, price=open_price, + volume=volume) + + # 添加平仓信号 + self.add_signal(t_value=close_time, direction=close_direction, offset=Offset.CLOSE, price=close_price, + volume=volume) + + def add_trans(self, tns_dict): + """ + 添加事务画线 + {'start_time','end_time','tns_type','start_price','end_price','start_x','end_x','completed'} + :return: + """ + if len(self.datas) == 0: + print(u'No datas exist', file=sys.stderr) + return + tns = copy.copy(tns_dict) + + completed = tns.get('completed', False) + end_price = tns.get('end_price', 0) + if not completed: + end_x = len(self.datas) - 1 + end_price = self.datas[end_x]['close'] + tns['end_x'] = end_x + tns['end_price'] = end_price + tns['completed'] = False + else: + tns['end_x'] = self.axisTime.get_x_by_time(tns['end_time']) + + tns['start_x'] = self.axisTime.get_x_by_time(tns['start_time']) + # 将上一个线段设置为True + if len(self.list_trans) > 0: + self.list_trans[-1]['completed'] = True + pos = np.array([[tns['start_x'], tns['start_price']], [tns['end_x'], tns['end_price']]]) + tns_type = tns.get('tns_type', None) + if tns_type == Direction.LONG: + pen = pg.mkPen({'color': 'r', 'width': 1}) + elif tns_type == Direction.SHORT: + pen = pg.mkPen({'color': 'g', 'width': 1}) + else: + pen = 'default' + tns_line = pg.GraphItem(pos=pos, adj=np.array([[0, 1]]), pen=pen) + self.pi_main.addItem(tns_line) + self.list_trans.append(tns) + + def add_trans_df(self, df_trans): + """ + 批量增加,多空的切换 + :param df_trans: + :return: + """ + if df_trans is None or len(df_trans) == 0: + print(u'dataframe is None or Empty', file=sys.stderr) + return + + for idx in df_trans.index: + if idx == 0: + continue + # 事务开始时间 + start_time = df_trans['datetime'].loc[idx - 1] + if not isinstance(start_time, datetime) and isinstance(start_time, str): + start_time = datetime.strptime(start_time, '%Y-%m-%d %H:%M:%S') + + end_time = df_trans['datetime'].loc[idx] + if not isinstance(end_time, datetime) and isinstance(end_time, str): + end_time = datetime.strptime(end_time, '%Y-%m-%d %H:%M:%S') + + tns_type = df_trans['direction'].loc[idx - 1] + tns_type = Direction.LONG if tns_type.lower() == 'long' else Direction.SHORT + + start_price = df_trans['price'].loc[idx - 1] + end_price = df_trans['price'].loc[idx] + start_x = self.axisTime.get_x_by_time(start_time) + end_x = self.axisTime.get_x_by_time(end_time) + + self.add_trans({'start_time': start_time, 'end_time': end_time, + 'start_price': start_price, 'end_price': end_price, + 'start_x': start_x, 'end_x': end_x, + 'tns_type': tns_type, 'completed': True}) + + def add_markup(self, t_value, price, txt): + """ + 添加标记 + :param t_value: 时间-》坐标x + :param price: 坐标y + :param txt: 文字 + :return: + """ + # 找到信号时间最贴近的bar x轴 + x = self.axisTime.get_x_by_time(t_value) + + # 修正一下 标记时间,改为bar的时间 + if x not in self.x_t_markup_map: + bar_time = self.axisTime.xdict.get(x, t_value) + else: + # 如果存在映射,就更新 + bar_time = self.x_t_markup_map[x] + + markup_node = self.t_markup_dict.get(bar_time, None) + if markup_node is None: + # 当前时间无标记 + markup_node = {'x': x, 'markup': [txt]} + self.t_markup_dict[bar_time] = markup_node + self.x_t_markup_map[x] = bar_time + else: + if '.' in txt: + txt_list = txt.split('.') + else: + txt_list = [txt] + + for t in txt_list: + if t in markup_node['markup']: + continue + markup_node['markup'].append(t) + + if 'textitem' in markup_node: + markup_node['textitem'].setText(';'.join(markup_node.get('markup', []))) + else: + textitem = pg.TextItem(markup_node['markup'][0]) + textitem.setPos(x, price) + markup_node['textitem'] = textitem + self.list_markup.append(textitem) + self.pi_main.addItem(textitem) + + def add_markups(self, df_markup, include_list=[], exclude_list=[]): + """ + 批量增加标记 + :param df_markup: Dataframe(datetime, price, markup) + :param include_list: 如果len(include_list)>0,只显示里面的内容 + :param exclude_list: 如果exclude_list里面存在,不显示 + :return: + """ + if df_markup is None or len(df_markup) == 0: + print(u'df_markup is None or Empty', file=sys.stderr) + return + + for idx in df_markup.index: + # 标记时间 + t_value = df_markup['datetime'].loc[idx] + if not isinstance(t_value, datetime) and isinstance(t_value, str): + t_value = datetime.strptime(t_value, '%Y-%m-%d %H:%M:%S') + + price = df_markup['price'].loc[idx] + markup_text = df_markup['markup'].loc[idx] + if '.' in markup_text: + markup_texts = markup_text.split('.') + else: + markup_texts = [markup_text] + + for txt in markup_texts: + if len(include_list) > 0 and markup_text not in include_list: + continue + + if len(exclude_list) > 0 and markup_text in exclude_list: + continue + + self.add_markup(t_value=t_value, price=price, txt=markup_text) + + def loadData(self, df_datas, main_indicators=[], sub_indicators=[]): + """ + 载入pandas.DataFrame数据 + :param df_datas:DataFrame数据格式,cols : datetime, open, close, low, high, ,,indicator,indicator2,indicator,,, + :param main_indicators: 主图的indicator list + :param sub_indicators: 副图的indicator list + :return: + """ + # 设置中心点时间 + self.index = 0 + # 绑定数据,更新横坐标映射,更新Y轴自适应函数,更新十字光标映射 + if 'open_interest' not in df_datas.columns: + df_datas['open_interest'] = 0 + df_datas['time_int'] = np.array(range(len(df_datas.index))) + self.datas = df_datas[['open', 'close', 'low', 'high', 'volume', 'open_interest']].to_records() + self.axisTime.xdict = {} + xdict = dict(enumerate(df_datas.index.tolist())) + self.axisTime.update_xdict(xdict) + self.resignData(self.datas) + # 更新画图用到的数据 + self.listBar = df_datas[['time_int', 'open', 'close', 'low', 'high']].to_records(False) + + # 成交量颜色和涨跌同步,K线方向由涨跌决定 + datas0 = pd.DataFrame() + datas0['open'] = df_datas.apply(lambda x: 0 if x['close'] >= x['open'] else x['volume'], axis=1) + datas0['close'] = df_datas.apply(lambda x: 0 if x['close'] < x['open'] else x['volume'], axis=1) + datas0['low'] = 0 + datas0['high'] = df_datas['volume'] + datas0['time_int'] = np.array(range(len(df_datas.index))) + self.listVol = datas0[['time_int', 'open', 'close', 'low', 'high']].to_records(False) + + for indicator in main_indicators: + list_indicator = list(df_datas[indicator]) + self.main_indicator_data[indicator] = list_indicator + for indicator in sub_indicators: + list_indicator = list(df_datas[indicator]) + self.sub_indicator_data[indicator] = list_indicator + + # 调用画图函数 + self.plot_all(redraw=True, xMin=0, xMax=len(self.datas)) + self.crosshair.signal.emit((None, None)) + print('finished load Data') diff --git a/vnpy/trader/ui/kline/kline_widgets.py b/vnpy/trader/ui/kline/kline_widgets.py new file mode 100644 index 00000000..cd1e17c5 --- /dev/null +++ b/vnpy/trader/ui/kline/kline_widgets.py @@ -0,0 +1,269 @@ +# -*- coding: utf-8 -*- +""" +基于uiKline扩展得widgets +华富资产/李来佳 +""" + +import os +import json +import traceback +import pandas as pd +from threading import Thread +from time import sleep +from pymongo import MongoClient +from pymongo.errors import ConnectionFailure, AutoReconnect +from vnpy.trader.ui.kline.kline import QtWidgets, KLineWidget +from vnpy.amqp.consumer import subscriber + + +class RenkoKline(QtWidgets.QWidget): + """ + 砖型图得显示 + 1、显示 + 2、数据加载:从Mongodb,从本地文件 + 3、定时数据更新 + 4、加载文件时,选择指标 + """ + + def __init__(self, parent=None, setting={}): + self.parent = parent + super(RenkoKline, self).__init__(parent) + + self.kline_name = setting.get('kline_name', 'kline_name') + + self.canvas = None + + self.main_indicators = sorted(setting.get('main_indicators', [])) + self.sub_indicators = sorted(setting.get('sub_indicators', [])) + + # 数据来自文件 + self.csv_file = setting.get('csv_file', None) + + # 数据来自mongodb + self.host = None + self.port = None + self.db = None + self.collection = None + + self.sub = None + live_setting = setting.get('live', {}) + if len(live_setting) > 0: + try: + self.sub = subscriber(host=live_setting.get('host', 'localhost'), + port=live_setting.get('port', 5672), + user=live_setting.get('user', 'admin'), + password=live_setting.get('pasword', 'admin'), + exchange=live_setting.get('exchange', 'x_fanout'), + routing_key=live_setting.get('routing_key', 'default')) + + self.sub.set_callback(self.on_bar_message) + except Exception as ex: + print(u'创建消息订阅失败:{}'.format(str(ex))) + traceback.print_exc() + + # MongoDB数据库相关 + self.dbClient = None # MongoDB客户端对象 + self.db_has_connected = False + + self.is_loaded = False + self.last_bar_dt = None + + self.initUI() + + if self.csv_file is not None and os.path.exists(self.csv_file): + self.datathread = Thread(self.load_csv) + self.datathread.start() + + elif 'mongo' in setting: + mongo_setting = setting.get('mongo') + self.host = mongo_setting.get('ip', 'localhost') + self.port = mongo_setting.get('port', 27017) + self.db = mongo_setting.get('db', None) + self.collection = mongo_setting.get('collection', None) + self.kline_name = self.collection + + if self.db is not None and self.collection is not None: + self.dbConnect() + self.datathread = Thread(target=self.load_db, args=()) + self.datathread.start() + + def initUI(self): + """ + 初始化界面 + :return: + """ + vbox = QtWidgets.QVBoxLayout() + self.canvas = KLineWidget(display_vol=False, display_sub=True) + self.canvas.show() + self.canvas.KLtitle.setText('{}'.format(self.kline_name), size='18pt') + + for indicator in self.main_indicators: + self.canvas.add_indicator(indicator=indicator, is_main=True) + + for indicator in self.sub_indicators: + self.canvas.add_indicator(indicator=indicator, is_main=False) + + vbox.addWidget(self.canvas) + self.setLayout(vbox) + + def load_csv(self): + """从文件加载renko数据""" + if self.canvas: + df = pd.read_csv(self.csv_file) + df = df.set_index(pd.DatetimeIndex(df['datetime'])) + self.canvas.loadData(df, main_indicators=self.main_indicators, sub_indicators=self.sub_indicators) + self.is_loaded = True + + if self.sub: + self.sub.start() + + def load_db(self): + """从数据库加载renko数据""" + try: + d = {} + qryData = self.dbQueryBySort(dbName=self.db, collectionName=self.collection, d=d, sortName='datetime', + sortType=1) + bars = [] + for data in qryData: + bar = { + 'datetime': data.get('datetime'), + 'open': data.get('open'), + 'close': data.get('close'), + 'high': data.get('high'), + 'low': data.get('low'), + 'openInterest': 0, + 'volume': data.get('volume', 0) + } + bars.append(bar) + self.last_bar_dt = data.get('datetime') + + print(u'一共从数据库加载{}根bar'.format(len(bars))) + df = pd.DataFrame(bars) + df = df.set_index(pd.DatetimeIndex(df['datetime'])) + self.canvas.loadData(df, main_indicators=self.main_indicators, sub_indicators=self.sub_indicators) + + self.is_loaded = True + if self.sub: + self.sub.start() + + except Exception as ex: + print(u'加载bar异常:{}'.format(str(ex))) + + def on_bar_message(self, chan, method_frame, _header_frame, body, userdata=None): + try: + str_bar = body.decode('utf-8') + bar = json.loads(str_bar) + bar_name = bar.get('bar_name') + + if bar_name == self.kline_name: + print(u'on_bar_message from rabbitmq :{}'.format(bar)) + self.canvas.on_bar(bar, [], []) + + except Exception as ex: + print(u'on_bar_message Exception:{}'.format(str(ex))) + traceback.print_exc() + + def dbConnect(self): + try: + self.dbClient = MongoClient(self.host, self.port, connectTimeoutMS=500) + except Exception as ex: + print(u'连接Mongodb:{} {} 异常:{}'.format(self.host, self.port, str(ex))) + + def dbQueryBySort(self, dbName, collectionName, d, sortName, sortType, limitNum=0): + """从MongoDB中读取数据,d是查询要求,sortName是排序的字段,sortType是排序类型 + 返回的是数据库查询的指针""" + try: + if self.dbClient: + db = self.dbClient[dbName] + collection = db[collectionName] + if limitNum > 0: + cursor = collection.find(d).sort(sortName, sortType).limit(limitNum) + else: + cursor = collection.find(d).sort(sortName, sortType) + if cursor: + return list(cursor) + else: + return [] + else: + print(u'数据库查询出错') + if self.db_has_connected: + self.writeLog(u'重新尝试连接数据库') + self.dbConnect() + except AutoReconnect as ex: + print(u'数据库连接断开重连:{}'.format(str(ex))) + sleep(1) + except ConnectionFailure: + self.dbClient = None + print(u'数据库连接断开') + if self.db_has_connected: + print(u'重新尝试连接数据库') + self.dbConnect() + except Exception as ex: + self.writeError(u'dbQueryBySort exception:{}'.format(str(ex))) + + return [] + + +######################################################################## +class MultiKlineWindow(QtWidgets.QMainWindow): + """多窗口显示K线 + 包括: + + """ + + def __init__(self, parent=None, settings=[]): + """Constructor""" + super(MultiKlineWindow, self).__init__(parent) + + self.settings = settings + self.kline_dict = {} + self.initUi() + + self.load_multi_kline() + + def initUi(self): + """初始化界面""" + self.setWindowTitle(u'多K线') + self.maximumSize() + self.mdi = QtWidgets.QMdiArea() + self.setCentralWidget(self.mdi) + + # 创建菜单 + menubar = self.menuBar() + file_menu = menubar.addMenu("File") + file_menu.addAction("Cascade") + file_menu.addAction("Tiled") + file_menu.triggered[QtWidgets.QAction].connect(self.windowaction) + + def windowaction(self, q): + if q.text() == "cascade": + self.mdi.cascadeSubWindows() + + if q.text() == "Cascade": + self.mdi.tileSubWindows() + + def load_multi_kline(self): + """加载多周期窗口""" + + try: + for setting in self.settings: + sub_window = QtWidgets.QMdiSubWindow() + sub_window.setWindowTitle(setting.get('kline_name')) + renko_kline = RenkoKline(parent=self, setting=setting) + sub_window.setWidget(renko_kline) + self.mdi.addSubWindow(renko_kline) + # renko_kline.loadData() + renko_kline.show() + + self.mdi.tileSubWindows() + + except Exception as ex: # noqa + traceback.print_exc() + QtWidgets.QMessageBox.warning(self, 'Exception', u' Exception', QtWidgets.QMessageBox.Cancel, + QtWidgets.QMessageBox.NoButton) + + return + + def closeEvent(self, event): + """关闭窗口时的事件""" + os._exit(0)