From 35d9cacf33cb2eafa4833b80fcd19dc54aa7b970 Mon Sep 17 00:00:00 2001 From: "vn.py" Date: Wed, 17 Jul 2019 15:37:53 +0800 Subject: [PATCH] [Add] candle chart module --- examples/candle_chart/run.py | 23 ++++ examples/vn_trader/run.py | 4 +- vnpy/chart/__init__.py | 1 + vnpy/chart/axis.py | 44 +++++++ vnpy/chart/base.py | 9 ++ vnpy/chart/cursor.py | 9 ++ vnpy/chart/item.py | 232 +++++++++++++++++++++++++++++++++++ vnpy/chart/manager.py | 151 +++++++++++++++++++++++ vnpy/chart/widget.py | 179 +++++++++++++++++++++++++++ vnpy/trader/utility.py | 3 + 10 files changed, 653 insertions(+), 2 deletions(-) create mode 100644 examples/candle_chart/run.py create mode 100644 vnpy/chart/__init__.py create mode 100644 vnpy/chart/axis.py create mode 100644 vnpy/chart/base.py create mode 100644 vnpy/chart/cursor.py create mode 100644 vnpy/chart/item.py create mode 100644 vnpy/chart/manager.py create mode 100644 vnpy/chart/widget.py diff --git a/examples/candle_chart/run.py b/examples/candle_chart/run.py new file mode 100644 index 00000000..6a940833 --- /dev/null +++ b/examples/candle_chart/run.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from vnpy.trader.ui import QtWidgets +from vnpy.chart import ChartWidget +from vnpy.trader.database import database_manager +from vnpy.trader.constant import Exchange, Interval + +if __name__ == "__main__": + app = QtWidgets.QApplication([]) + + bars = database_manager.load_bar_data( + "IF88", + Exchange.CFFEX, + interval=Interval.MINUTE, + start=datetime(2019, 7, 1), + end=datetime(2019, 7, 17) + ) + + widget = ChartWidget() + widget.update_history(bars) + widget.show() + + app.exec_() diff --git a/examples/vn_trader/run.py b/examples/vn_trader/run.py index 32d3596e..7c1ccf8e 100644 --- a/examples/vn_trader/run.py +++ b/examples/vn_trader/run.py @@ -8,7 +8,7 @@ from vnpy.trader.ui import MainWindow, create_qapp # from vnpy.gateway.bitmex import BitmexGateway # from vnpy.gateway.futu import FutuGateway # from vnpy.gateway.ib import IbGateway -# from vnpy.gateway.ctp import CtpGateway +from vnpy.gateway.ctp import CtpGateway # from vnpy.gateway.ctptest import CtptestGateway # from vnpy.gateway.mini import MiniGateway # from vnpy.gateway.femas import FemasGateway @@ -44,7 +44,7 @@ def main(): main_engine = MainEngine(event_engine) # main_engine.add_gateway(BinanceGateway) - # main_engine.add_gateway(CtpGateway) + main_engine.add_gateway(CtpGateway) # main_engine.add_gateway(CtptestGateway) # main_engine.add_gateway(MiniGateway) # main_engine.add_gateway(FemasGateway) diff --git a/vnpy/chart/__init__.py b/vnpy/chart/__init__.py new file mode 100644 index 00000000..27ede33a --- /dev/null +++ b/vnpy/chart/__init__.py @@ -0,0 +1 @@ +from .widget import ChartWidget diff --git a/vnpy/chart/axis.py b/vnpy/chart/axis.py new file mode 100644 index 00000000..6a63d990 --- /dev/null +++ b/vnpy/chart/axis.py @@ -0,0 +1,44 @@ +from typing import List + +import pyqtgraph as pg + +from vnpy.trader.ui import QtGui + +from .manager import BarManager + + +AXIS_WIDTH = 0.8 +AXIS_FONT = QtGui.QFont("Arial", 10, QtGui.QFont.Bold) + + +class DatetimeAxis(pg.AxisItem): + """""" + + def __init__(self, manager: BarManager, *args, **kwargs): + """""" + super().__init__(*args, **kwargs) + + self._manager: BarManager = manager + + self.setPen(width=AXIS_WIDTH) + self.setStyle(tickFont=AXIS_FONT, autoExpandTextSpace=True) + + def tickStrings(self, values: List[int], scale: float, spacing: int): + """ + Convert original index to datetime string. + """ + strings = [] + + for ix in values: + dt = self._manager.get_datetime(ix) + + if not dt: + s = "" + elif dt.hour: + s = dt.strftime("%Y-%m-%d\n%H:%M:%S") + else: + s = dt.strftime("%Y-%m-%d") + + strings.append(s) + + return strings diff --git a/vnpy/chart/base.py b/vnpy/chart/base.py new file mode 100644 index 00000000..8d02527d --- /dev/null +++ b/vnpy/chart/base.py @@ -0,0 +1,9 @@ +WHITE_COLOR = (255, 255, 255) +BLACK_COLOR = (0, 0, 0) +GREY_COLOR = (100, 100, 100) + +UP_COLOR = (85, 234, 204) +DOWN_COLOR = (218, 75, 61) + +PEN_WIDTH = 1 +BAR_WIDTH = 0.4 diff --git a/vnpy/chart/cursor.py b/vnpy/chart/cursor.py new file mode 100644 index 00000000..9c84df9c --- /dev/null +++ b/vnpy/chart/cursor.py @@ -0,0 +1,9 @@ +from vnpy.trader.ui import QtCore + + +class ChartCursor(QtCore.QObject): + """""" + + def __init__(self): + """""" + pass diff --git a/vnpy/chart/item.py b/vnpy/chart/item.py new file mode 100644 index 00000000..196b0606 --- /dev/null +++ b/vnpy/chart/item.py @@ -0,0 +1,232 @@ +from abc import abstractmethod +from typing import List, Dict + +import pyqtgraph as pg + +from vnpy.trader.ui import QtCore, QtGui, QtWidgets +from vnpy.trader.object import BarData + +from .base import UP_COLOR, DOWN_COLOR, PEN_WIDTH, BAR_WIDTH +from .manager import BarManager + + +class ChartItem(pg.GraphicsObject): + """""" + + def __init__(self, manager: BarManager): + """""" + super().__init__() + + self._manager: BarManager = manager + + self._bar_picutures: Dict[int, QtGui.QPicture] = {} + self._item_picuture: QtGui.QPicture = None + + self._up_pen: QtGui.QPen = pg.mkPen( + color=UP_COLOR, width=PEN_WIDTH + ) + self._up_brush: QtGui.QBrush = pg.mkBrush(color=UP_COLOR) + + self._down_pen: QtGui.QPen = pg.mkPen( + color=DOWN_COLOR, width=PEN_WIDTH + ) + self._down_brush: QtGui.QBrush = pg.mkBrush(color=DOWN_COLOR) + + @abstractmethod + def _draw_bar_picture(self, ix: int, bar: BarData) -> QtGui.QPicture: + """ + Draw picture for specific bar. + """ + pass + + @abstractmethod + def boundingRect(self): + """ + Get bounding rectangles for item. + """ + pass + + def update_history(self, history: List[BarData]) -> BarData: + """ + Update a list of bar data. + """ + self._bar_picutures.clear() + + bars = self._manager.get_all_bars() + for ix, bar in enumerate(bars): + bar_picture = self._draw_bar_picture(ix, bar) + self._bar_picutures[ix] = bar_picture + + self.update() + + def update_bar(self, bar: BarData) -> BarData: + """ + Update single bar data. + """ + ix = self._manager.get_index(bar.datetime) + + bar_picture = self._draw_bar_picture(ix, bar) + self._bar_picutures[ix] = bar_picture + + self.update() + + def update(self) -> None: + """ + Refresh the item. + """ + if self.scene(): + self.scene().update() + + def paint( + self, + painter: QtGui.QPainter, + opt: QtWidgets.QStyleOptionGraphicsItem, + w: QtWidgets.QWidget + ): + """ + Reimplement the paint method of parent class. + + This function is called by external QGraphicsView. + """ + rect = opt.exposedRect + min_ix = int(rect.left()) + max_ix = int(rect.right()) + max_ix = min(max_ix, len(self._bar_picutures)) + + self._draw__item_picuture(min_ix, max_ix) + self._item_picuture.play(painter) + + def _draw__item_picuture(self, min_ix: int, max_ix: int) -> None: + """ + Draw the picture of item in specific range. + """ + self._item_picuture = QtGui.QPicture() + painter = QtGui.QPainter(self._item_picuture) + + for n in range(min_ix, max_ix): + bar_picture = self._bar_picutures[n] + bar_picture.play(painter) + + painter.end() + + def clear_all(self) -> None: + """ + Clear all data in the item. + """ + self._item_picuture = None + self._bar_picutures.clear() + self.update() + + +class CandleItem(ChartItem): + """""" + + def __init__(self, manager: BarManager): + """""" + super().__init__(manager) + + def _draw_bar_picture(self, ix: int, bar: BarData) -> QtGui.QPicture: + """""" + # Create objects + candle_picture = QtGui.QPicture() + painter = QtGui.QPainter(candle_picture) + + # Set painter color + if bar.close_price >= bar.open_price: + painter.setPen(self._up_pen) + painter.setBrush(self._up_brush) + else: + painter.setPen(self._down_pen) + painter.setBrush(self._down_brush) + + # Draw candle body + if bar.open_price == bar.close_price: + painter.drawLine( + QtCore.QPointF(ix - BAR_WIDTH, bar.open_price), + QtCore.QPointF(ix + BAR_WIDTH, bar.open_price), + ) + else: + rect = QtCore.QRectF( + ix - BAR_WIDTH, + bar.open_price, + BAR_WIDTH * 2, + bar.close_price - bar.open_price + ) + painter.drawRect(rect) + + # Draw candle shadow + body_bottom = min(bar.open_price, bar.close_price) + body_top = max(bar.open_price, bar.close_price) + + if bar.low_price < body_bottom: + painter.drawLine( + QtCore.QPointF(ix, bar.low_price), + QtCore.QPointF(ix, body_bottom), + ) + + if bar.high_price > body_top: + painter.drawLine( + QtCore.QPointF(ix, bar.high_price), + QtCore.QPointF(ix, body_top), + ) + + # Finish + painter.end() + return candle_picture + + def boundingRect(self) -> QtCore.QRectF: + """""" + min_price, max_price = self._manager.get_price_range() + rect = QtCore.QRectF( + 0, + max_price, + len(self._bar_picutures), + max_price - min_price + ) + return rect + + +class VolumeItem(ChartItem): + """""" + + def __init__(self, manager: BarManager): + """""" + super().__init__(manager) + + def _draw_bar_picture(self, ix: int, bar: BarData) -> QtGui.QPicture: + """""" + # Create objects + volume_picture = QtGui.QPicture() + painter = QtGui.QPainter(volume_picture) + + # Set painter color + if bar.close_price >= bar.open_price: + painter.setPen(self._up_pen) + painter.setBrush(self._up_brush) + else: + painter.setPen(self._down_pen) + painter.setBrush(self._down_brush) + + # Draw volume body + rect = QtCore.QRectF( + ix - BAR_WIDTH, + 0, + BAR_WIDTH * 2, + bar.volume + ) + painter.drawRect(rect) + + # Finish + painter.end() + return volume_picture + + def boundingRect(self) -> QtCore.QRectF: + """""" + min_volume, max_volume = self._manager.get_volume_range() + rect = QtCore.QRectF( + 0, + min_volume, + len(self._bar_picutures), + max_volume - min_volume + ) + return rect diff --git a/vnpy/chart/manager.py b/vnpy/chart/manager.py new file mode 100644 index 00000000..9744df7c --- /dev/null +++ b/vnpy/chart/manager.py @@ -0,0 +1,151 @@ +from typing import Dict, List, Tuple +from datetime import datetime +from functools import lru_cache + +from vnpy.trader.object import BarData + + +class BarManager: + """""" + + def __init__(self): + """""" + self._bars: Dict[datetime, BarData] = {} + self._datetime_index_map: Dict[datetime, int] = {} + self._index_datetime_map: Dict[int, datetime] = {} + + def update_history(self, history: List[BarData]) -> None: + """ + Update a list of bar data. + """ + # Put all new bars into dict + for bar in history: + self._bars[bar.datetime] = bar + + # Sort bars dict according to bar.datetime + self._bars = dict(sorted(self._bars.items(), key=lambda tp: tp[0])) + + # Update map relationiship + ix_list = range(len(self._bars)) + dt_list = self._bars.keys() + + self._datetime_index_map = dict(zip(dt_list, ix_list)) + self._index_datetime_map = dict(zip(ix_list, dt_list)) + + # Clear data range cache + self._clear_cache() + + def update_bar(self, bar: BarData) -> None: + """ + Update one single bar data. + """ + dt = bar.datetime + + if dt not in self._datetime_index_map: + ix = len(self._bars) + self._datetime_index_map[dt] = ix + self._index_datetime_map[ix] = dt + + self.datetime_bar_map[dt] = bar + + self._clear_cache() + + def get_count(self) -> int: + """ + Get total number of bars. + """ + return len(self._bars) + + def get_index(self, dt: datetime) -> int: + """ + Get index with datetime. + """ + return self._datetime_index_map.get(dt, None) + + def get_datetime(self, ix: int) -> datetime: + """ + Get datetime with index. + """ + return self._index_datetime_map.get(ix, None) + + def get_bar(self, ix: int) -> BarData: + """ + Get bar data with index. + """ + dt = self._index_datetime_map.get(ix, None) + if not dt: + return None + + return self._bars[dt] + + def get_all_bars(self) -> List[BarData]: + """ + Get all bar data. + """ + return list(self._bars.values()) + + @lru_cache(maxsize=99999) + def get_price_range(self, min_ix: int = None, max_ix: int = None) -> Tuple[float, float]: + """ + Get price range to show within given index range. + """ + if not self._bars: + return 0, 1 + + if not min_ix: + min_ix = 0 + max_ix = len(self._bars) - 1 + else: + max_ix = min(max_ix, self.get_count()) + + bar_list = list(self._bars.values())[min_ix:max_ix + 1] + first_bar = bar_list[0] + max_price = first_bar.high_price + min_price = first_bar.low_price + + for bar in bar_list[1:]: + max_price = max(max_price, bar.high_price) + min_price = min(min_price, bar.low_price) + + return min_price, max_price + + @lru_cache(maxsize=99999) + def get_volume_range(self, min_ix: int = None, max_ix: int = None) -> Tuple[float, float]: + """ + Get volume range to show within given index range. + """ + if not self._bars: + return 0, 1 + + if not min_ix: + min_ix = 0 + max_ix = len(self._bars) - 1 + + max_ix = min(max_ix, self.get_count()) + bar_list = list(self._bars.values())[min_ix:max_ix + 1] + + first_bar = bar_list[0] + max_volume = first_bar.volume + min_volume = 0 + + for bar in bar_list[1:]: + max_volume = max(max_volume, bar.volume) + + return min_volume, max_volume + + def _clear_cache(self) -> None: + """ + Clear lru_cache range data. + """ + self.get_price_range.cache_clear() + self.get_volume_range.cache_clear() + + def clear_all(self) -> None: + """ + Clear all data in manager. + """ + self._bars.clear() + self._datetime_index_map.clear() + self._index_datetime_map.clear() + + self._clear_cache() diff --git a/vnpy/chart/widget.py b/vnpy/chart/widget.py new file mode 100644 index 00000000..730b2118 --- /dev/null +++ b/vnpy/chart/widget.py @@ -0,0 +1,179 @@ +from typing import List + +import pyqtgraph as pg + +from vnpy.trader.ui import QtGui, QtWidgets +from vnpy.trader.object import BarData + +from .manager import BarManager +from .base import GREY_COLOR +from .axis import DatetimeAxis, AXIS_FONT +from .item import CandleItem, VolumeItem, ChartItem + + +class ChartWidget(pg.PlotWidget): + """""" + MIN_BAR_COUNT = 100 + + def __init__(self, parent: QtWidgets.QWidget = None): + """""" + super().__init__(parent) + + self._manager: BarManager = BarManager() + + self._plots: List[ChartItem] = [] + self._items: List[pg.GraphicsObject] = [] + + self._max_ix: int = 0 + self._bar_count: int = 0 + + self.init_ui() + + def init_ui(self) -> None: + """""" + self._layout = pg.GraphicsLayout() + self._layout.setContentsMargins(10, 10, 10, 10) + self._layout.setSpacing(0) + self._layout.setBorder(color=GREY_COLOR, width=0.8) + self._layout.setZValue(0) + + self.setCentralItem(self._layout) + + self._x_axis = DatetimeAxis(self._manager, orientation='bottom') + + self.init_candle() + self.init_volume() + self._volume_plot.setXLink(self._candle_plot) + + def new_plot(self) -> None: + """""" + plot = pg.PlotItem(axisItems={'bottom': self._x_axis}) + plot.setMenuEnabled(False) + plot.setClipToView(True) + plot.hideAxis('left') + plot.showAxis('right') + plot.setDownsampling(mode='peak') + plot.setRange(xRange=(0, 1), yRange=(0, 1)) + plot.hideButtons() + + view = plot.getViewBox() + view.sigXRangeChanged.connect(self._change_plot_y_range) + view.setMouseEnabled(x=True, y=False) + + right_axis = plot.getAxis('right') + right_axis.setWidth(60) + right_axis.setStyle(tickFont=AXIS_FONT) + + return plot + + def init_candle(self) -> None: + """ + Initialize candle plot. + """ + self._candle_item = CandleItem(self._manager) + self._items.append(self._candle_item) + + self._candle_plot = self.new_plot() + self._candle_plot.addItem(self._candle_item) + self._candle_plot.setMinimumHeight(80) + self._candle_plot.hideAxis('bottom') + self._plots.append(self._candle_plot) + + self._layout.nextRow() + self._layout.addItem(self._candle_plot) + + def init_volume(self) -> None: + """ + Initialize bar plot. + """ + self._volume_item = VolumeItem(self._manager) + self._items.append(self._volume_item) + + self._volume_plot = self.new_plot() + self._volume_plot.addItem(self._volume_item) + self._volume_plot.setMinimumHeight(80) + self._plots.append(self._volume_plot) + + self._layout.nextRow() + self._layout.addItem(self._volume_plot) + + def clear_all(self) -> None: + """ + Clear all data. + """ + self._manager.clear_all() + + for item in self._items: + item.clear_all() + + def update_history(self, history: List[BarData]) -> None: + """ + Update a list of bar data. + """ + self._manager.update_history(history) + + for item in self._items: + item.update_history(history) + + self._update_plot_range() + + def update_bar(self, bar: BarData) -> None: + """ + Update single bar data. + """ + self._manager.update_bar(bar) + + for item in self.items: + item.update_bar(bar) + + self._update_plot_range() + + def _update_plot_range(self) -> None: + """ + Update the range of plots. + """ + max_ix = self._max_ix + min_ix = self._max_ix - self._bar_count + + # Update limit and range for x-axis + for plot in self._plots: + plot.setLimits( + xMin=-self.MIN_BAR_COUNT, + xMax=self._manager.get_count() + ) + plot.setRange( + xRange=(min_ix, max_ix), + padding=0 + ) + + # Update limit for y-axis + min_price, max_price = self._manager.get_price_range() + self._candle_plot.setLimits(yMin=min_price, yMax=max_price) + + min_volume, max_volume = self._manager.get_volume_range() + self._volume_plot.setLimits(yMin=min_volume, yMax=max_volume) + + def _change_plot_y_range(self) -> None: + """ + Reset the y-axis range of plots. + """ + view = self._candle_plot.getViewBox() + view_range = view.viewRange() + min_ix = max(0, int(view_range[0][0])) + max_ix = min(self._manager.get_count(), int(view_range[0][1])) + + price_range = self._manager.get_price_range(min_ix, max_ix) + self._candle_plot.setRange(yRange=price_range) + + volume_range = self._manager.get_volume_range(min_ix, max_ix) + self._volume_plot.setRange(yRange=volume_range) + + def paintEvent(self, event: QtGui.QPaintEvent) -> None: + """ + Reimplement this method of parent to update current max_ix value. + """ + view = self._candle_plot.getViewBox() + view_range = view.viewRange() + self._max_ix = max(0, view_range[0][1]) + + super().paintEvent(event) diff --git a/vnpy/trader/utility.py b/vnpy/trader/utility.py index a154e175..88c1e105 100644 --- a/vnpy/trader/utility.py +++ b/vnpy/trader/utility.py @@ -22,6 +22,9 @@ def extract_vt_symbol(vt_symbol: str): def generate_vt_symbol(symbol: str, exchange: Exchange): + """ + return vt_symbol + """ return f"{symbol}.{exchange.value}"