From 45906c2729089bdcba2a8937018f9653a46e2dcf Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 16 Jun 2020 13:32:03 -0400 Subject: [PATCH] Render plots from provided input sequence(s) Previously graphics were loaded and rendered implicitly during the import and creation of certain objects. Remove all this and instead expect client code to pass in the OHLC sequence to plot. Speed up the bars graphics rendering by simplifying to a single iteration of the input array; gives about a 2x speedup. --- piker/ui/cli.py | 18 ++++--- piker/ui/qt/_axes.py | 9 +++- piker/ui/qt/_chart.py | 110 ++++++++++++++++++++++----------------- piker/ui/qt/_graphics.py | 105 +++++++++++++++++++++++++------------ piker/ui/qt/_source.py | 97 ++++++++++++++++++++++++++++++++++ piker/ui/qt/_style.py | 18 +++++-- 6 files changed, 263 insertions(+), 94 deletions(-) create mode 100644 piker/ui/qt/_source.py diff --git a/piker/ui/cli.py b/piker/ui/cli.py index d5f6a9ed..54a77eee 100644 --- a/piker/ui/cli.py +++ b/piker/ui/cli.py @@ -118,20 +118,24 @@ def chart(config, symbol, date, tl, rate, test): """Start an option chain UI """ from .qt._exec import run_qtrio - from .qt._chart import QuotesTabWidget - from .qt.quantdom.base import Symbol + from .qt._chart import Chart + + # uses pandas_datareader from .qt.quantdom.loaders import get_quotes async def plot_symbol(widgets): + """Main Qt-trio routine invoked by the Qt loop with + the widgets ``dict``. + """ + qtw = widgets['main'] - s = Symbol(ticker=symbol, mode=Symbol.SHARES) - get_quotes( - symbol=s.ticker, + quotes = get_quotes( + symbol=symbol, date_from=datetime(1900, 1, 1), date_to=datetime(2030, 12, 31), ) # spawn chart - qtw.update_chart(s) + qtw.load_symbol(symbol, quotes) await trio.sleep_forever() - run_qtrio(plot_symbol, (), QuotesTabWidget) + run_qtrio(plot_symbol, (), Chart) diff --git a/piker/ui/qt/_axes.py b/piker/ui/qt/_axes.py index 96724a26..90b5c5f2 100644 --- a/piker/ui/qt/_axes.py +++ b/piker/ui/qt/_axes.py @@ -72,7 +72,14 @@ class AxisLabel(pg.GraphicsObject): bg_color = pg.mkColor('#808080') fg_color = pg.mkColor('#000000') - def __init__(self, parent=None, digits=0, color=None, opacity=1, **kwargs): + def __init__( + self, + parent=None, + digits=0, + color=None, + opacity=1, + **kwargs + ): super().__init__(parent) self.parent = parent self.opacity = opacity diff --git a/piker/ui/qt/_chart.py b/piker/ui/qt/_chart.py index b01a4110..cdf6b988 100644 --- a/piker/ui/qt/_chart.py +++ b/piker/ui/qt/_chart.py @@ -10,12 +10,13 @@ from ._axes import ( FromTimeFieldDateAxis, PriceAxis, ) -from ._graphics import CrossHairItem, CandlestickItem, BarItem +from ._graphics import CrossHairItem, ChartType from ._style import _xaxis_at +from ._source import Symbol from .quantdom.charts import CenteredTextItem from .quantdom.base import Quotes -from .quantdom.const import ChartType +# from .quantdom.const import ChartType from .quantdom.portfolio import Order, Portfolio @@ -23,20 +24,21 @@ from .quantdom.portfolio import Order, Portfolio CHART_MARGINS = (0, 0, 10, 3) -class QuotesTabWidget(QtGui.QWidget): +class Chart(QtGui.QWidget): def __init__(self, parent=None): super().__init__(parent) - self.layout = QtGui.QVBoxLayout(self) - self.layout.setContentsMargins(0, 0, 0, 0) + self.v_layout = QtGui.QVBoxLayout(self) + self.v_layout.setContentsMargins(0, 0, 0, 0) self.toolbar_layout = QtGui.QHBoxLayout() self.toolbar_layout.setContentsMargins(10, 10, 15, 0) - self.chart_layout = QtGui.QHBoxLayout() + self.h_layout = QtGui.QHBoxLayout() # self.init_timeframes_ui() # self.init_strategy_ui() - self.layout.addLayout(self.toolbar_layout) - self.layout.addLayout(self.chart_layout) + self.v_layout.addLayout(self.toolbar_layout) + self.v_layout.addLayout(self.h_layout) + self._plot_cache = {} def init_timeframes_ui(self): self.tf_layout = QtGui.QHBoxLayout() @@ -58,21 +60,32 @@ class QuotesTabWidget(QtGui.QWidget): # self.strategy_box = StrategyBoxWidget(self) # self.toolbar_layout.addWidget(self.strategy_box) - # TODO: this needs to be changed to ``load_symbol()`` - # which will not only load historical data but also a real-time - # stream and schedule the redraw events on new quotes - def update_chart(self, symbol): - if not self.chart_layout.isEmpty(): - self.chart_layout.removeWidget(self.chart) - self.chart = SplitterChart() - self.chart.plot(symbol) - self.chart_layout.addWidget(self.chart) + def load_symbol( + self, + symbol: str, + data: np.ndarray, + ) -> None: + """Load a new contract into the charting app. + """ + # XXX: let's see if this causes mem problems + self.chart = self._plot_cache.setdefault(symbol, SplitterPlots()) + s = Symbol(key=symbol) - def add_signals(self): - self.chart.add_signals() + # remove any existing plots + if not self.h_layout.isEmpty(): + self.h_layout.removeWidget(self.chart) + + self.chart.plot(s, data) + self.h_layout.addWidget(self.chart) + + # TODO: add signalling painter system + # def add_signals(self): + # self.chart.add_signals() -class SplitterChart(QtGui.QWidget): +class SplitterPlots(QtGui.QWidget): + """Widget that holds a price chart plus indicators separated by splitters. + """ long_pen = pg.mkPen('#006000') long_brush = pg.mkBrush('#00ff00') @@ -131,16 +144,21 @@ class SplitterChart(QtGui.QWidget): sizes.extend([min_h_ind] * len(self.indicators)) self.splitter.setSizes(sizes) # , int(self.height()*0.2) - def plot(self, symbol): + def plot( + self, + symbol: Symbol, + data: np.ndarray, + ): """Start up and show price chart and all registered indicators. """ - self.digits = symbol.digits + self.digits = symbol.digits() + cv = ChartView() self.chart = ChartPlotWidget( split_charts=self, parent=self.splitter, axisItems={'bottom': self.xaxis, 'right': PriceAxis()}, - viewBox=ChartView, + viewBox=cv, # enableMenu=False, ) # TODO: ``pyqtgraph`` doesn't pass through a parent to the @@ -150,27 +168,28 @@ class SplitterChart(QtGui.QWidget): self.chart.getPlotItem().setContentsMargins(*CHART_MARGINS) self.chart.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain) + self.chart.draw_ohlc(data) + # TODO: this is where we would load an indicator chain inds = [Quotes.open] for d in inds: - ind = ChartPlotWidget( + cv = ChartView() + ind_chart = ChartPlotWidget( split_charts=self, parent=self.splitter, axisItems={'bottom': self.xaxis_ind, 'right': PriceAxis()}, # axisItems={'top': self.xaxis_ind, 'right': PriceAxis()}, - viewBox=ChartView, + viewBox=cv, ) self.chart.plotItem.vb.splitter_widget = self - ind.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain) - ind.getPlotItem().setContentsMargins(*CHART_MARGINS) - # self.splitter.addWidget(ind) - self.indicators.append((ind, d)) - - self.chart.draw_ohlc() - - for ind_chart, d in self.indicators: + ind_chart.setFrameStyle( + QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain + ) + ind_chart.getPlotItem().setContentsMargins(*CHART_MARGINS) + # self.splitter.addWidget(ind_chart) + self.indicators.append((ind_chart, d)) # link chart x-axis to main quotes chart ind_chart.setXLink(self.chart) @@ -245,8 +264,6 @@ _min_points_to_show = 20 _min_bars_in_view = 10 -# TODO: This is a sub-class of ``GracphicView`` which can -# take a ``background`` color setting. class ChartPlotWidget(pg.PlotWidget): """``GraphicsView`` subtype containing a single ``PlotItem``. @@ -260,6 +277,9 @@ class ChartPlotWidget(pg.PlotWidget): sig_mouse_leave = QtCore.Signal(object) sig_mouse_enter = QtCore.Signal(object) + # TODO: can take a ``background`` color setting - maybe there's + # a better one? + def __init__( self, split_charts, @@ -319,15 +339,18 @@ class ChartPlotWidget(pg.PlotWidget): def draw_ohlc( self, + data: np.ndarray, style: ChartType = ChartType.BAR, ) -> None: """Draw OHLC datums to chart. """ - + # remember it's an enum type.. + graphics = style.value() # adds all bar/candle graphics objects for each # data point in the np array buffer to # be drawn on next render cycle - self.addItem(_get_chart_points(style)) + graphics.draw_from_data(data) + self.addItem(graphics) def draw_curve( self, @@ -422,9 +445,9 @@ class ChartView(pg.ViewBox): else: mask = self.state['mouseEnabled'][:] + # don't zoom more then the min points setting lbar, rbar = self.splitter_widget.chart.bars_range() if ev.delta() >= 0 and rbar - lbar <= _min_points_to_show: - # don't zoom more then the min points setting return # actual scaling factor @@ -447,14 +470,3 @@ class ChartView(pg.ViewBox): self.scaleBy(s, center) ev.accept() self.sigRangeChangedManually.emit(mask) - - -# this function is borderline ridiculous. -# The creation of these chart types mutates all the input data -# inside each type's constructor (mind blown) -def _get_chart_points(style): - if style == ChartType.CANDLESTICK: - return CandlestickItem() - elif style == ChartType.BAR: - return BarItem() - return pg.PlotDataItem(Quotes.close, pen='b') diff --git a/piker/ui/qt/_graphics.py b/piker/ui/qt/_graphics.py index bff8c043..9b90e8cf 100644 --- a/piker/ui/qt/_graphics.py +++ b/piker/ui/qt/_graphics.py @@ -1,14 +1,18 @@ """ Chart graphics for displaying a slew of different data types. """ +from enum import Enum +from contextlib import contextmanager + import numpy as np import pyqtgraph as pg from PyQt5 import QtCore, QtGui +from PyQt5.QtCore import QLineF from .quantdom.utils import timeit from .quantdom.base import Quotes -from ._style import _xaxis_at +from ._style import _xaxis_at, _tina_mode from ._axes import YAxisLabel, XAxisLabel @@ -163,11 +167,10 @@ class CrossHairItem(pg.GraphicsObject): return self.parent.boundingRect() -class BarItem(pg.GraphicsObject): - # XXX: From the customGraphicsItem.py example: - # The only required methods are paint() and boundingRect() - - w = 0.5 +class BarItems(pg.GraphicsObject): + """Price range bars graphics rendered from a OHLC sequence. + """ + w: float = 0.5 bull_brush = bear_brush = pg.mkPen('#808080') # bull_brush = pg.mkPen('#00cc00') @@ -175,44 +178,72 @@ class BarItem(pg.GraphicsObject): def __init__(self): super().__init__() - self.generatePicture() + self.picture = QtGui.QPicture() + self.lines = None + # self.generatePicture() # TODO: this is the routine to be retriggered for redraw - @timeit - def generatePicture(self): + @contextmanager + def painter(self): # pre-computing a QPicture object allows paint() to run much # more quickly, rather than re-drawing the shapes every time. - self.picture = QtGui.QPicture() p = QtGui.QPainter(self.picture) - self._generate(p) + yield p p.end() - def _generate(self, p): + @timeit + def draw_from_data(self, data): # XXX: overloaded method to allow drawing other candle types - high_to_low = np.array( - [QtCore.QLineF(q.id, q.low, q.id, q.high) for q in Quotes] - ) - open_stick = np.array( - [QtCore.QLineF(q.id - self.w, q.open, q.id, q.open) - for q in Quotes] - ) - close_stick = np.array( - [ - QtCore.QLineF(q.id + self.w, q.close, q.id, q.close) - for q in Quotes - ] - ) - lines = np.concatenate([high_to_low, open_stick, close_stick]) - long_bars = np.resize(Quotes.close > Quotes.open, len(lines)) - short_bars = np.resize(Quotes.close < Quotes.open, len(lines)) + high_to_low = np.empty_like(data, dtype=object) + open_sticks = np.empty_like(data, dtype=object) + close_sticks = np.empty_like(data, dtype=object) + with self.painter() as p: + import time + start = time.time() + for i, q in enumerate(data): + high_to_low[i] = QLineF(q['id'], q['low'], q['id'], q['high']) + open_sticks[i] = QLineF( + q['id'] - self.w, q['open'], q['id'], q['open']) + close_sticks[i] = QtCore.QLineF( + q['id'] + self.w, q['close'], q['id'], q['close']) - p.setPen(self.bull_brush) - p.drawLines(*lines[long_bars]) + # high_to_low = np.array( + # [QtCore.QLineF(q.id, q.low, q.id, q.high) for q in Quotes] + # ) + # open_sticks = np.array( + # [QtCore.QLineF(q.id - self.w, q.open, q.id, q.open) + # for q in Quotes] + # ) + # close_sticks = np.array( + # [ + # QtCore.QLineF(q.id + self.w, q.close, q.id, q.close) + # for q in Quotes + # ] + # ) + print(f"took {time.time() - start}") + self.lines = lines = np.concatenate([high_to_low, open_sticks, close_sticks]) - p.setPen(self.bear_brush) - p.drawLines(*lines[short_bars]) + if _tina_mode: + long_bars = np.resize(Quotes.close > Quotes.open, len(lines)) + short_bars = np.resize(Quotes.close < Quotes.open, len(lines)) + ups = lines[long_bars] + downs = lines[short_bars] + # draw "up" bars + p.setPen(self.bull_brush) + p.drawLines(*ups) + + # draw "down" bars + p.setPen(self.bear_brush) + p.drawLines(*downs) + + else: # piker mode + p.setPen(self.bull_brush) + p.drawLines(*lines) + + # XXX: From the customGraphicsItem.py example: + # The only required methods are paint() and boundingRect() def paint(self, p, *args): p.drawPicture(0, 0, self.picture) @@ -224,7 +255,7 @@ class BarItem(pg.GraphicsObject): return QtCore.QRectF(self.picture.boundingRect()) -class CandlestickItem(BarItem): +class CandlestickItems(BarItems): w2 = 0.7 line_pen = pg.mkPen('#000000') @@ -250,3 +281,11 @@ class CandlestickItem(BarItem): p.setBrush(self.bear_brush) p.drawRects(*rects[Quotes.close < Quotes.open]) + + +class ChartType(Enum): + """Bar type to graphics class map. + """ + BAR = BarItems + CANDLESTICK = CandlestickItems + LINE = pg.PlotDataItem diff --git a/piker/ui/qt/_source.py b/piker/ui/qt/_source.py new file mode 100644 index 00000000..3d6fbae7 --- /dev/null +++ b/piker/ui/qt/_source.py @@ -0,0 +1,97 @@ +""" +Numpy data source machinery. +""" +import math +from dataclasses import dataclass + +import numpy as np +import pandas as pd + + +OHLC_dtype = np.dtype( + [ + ('id', int), + ('time', float), + ('open', float), + ('high', float), + ('low', float), + ('close', float), + ('volume', int), + ] +) + +# tf = { +# 1: TimeFrame.M1, +# 5: TimeFrame.M5, +# 15: TimeFrame.M15, +# 30: TimeFrame.M30, +# 60: TimeFrame.H1, +# 240: TimeFrame.H4, +# 1440: TimeFrame.D1, +# } + + +@dataclass +class Symbol: + """I guess this is some kinda container thing for dealing with + all the different meta-data formats from brokers? + """ + key: str = '' + min_tick: float = 0.01 + contract: str = '' + + def digits(self) -> int: + """Return the trailing number of digits specified by the + min tick size for the instrument. + """ + return int(math.log(self.min_tick, 0.1)) + + +def from_df( + df: pd.DataFrame, + source=None, + default_tf=None +): + """Cast OHLC ``pandas.DataFrame`` to ``numpy.structarray``. + """ + # shape = (len(df),) + # array.resize(shape, refcheck=False) + array = np.array([], dtype=OHLC_dtype) + + df.reset_index(inplace=True) + df.insert(0, 'id', df.index) + array['time'] = np.array([d.timestamp().time for d in df.Date]) + + # try to rename from some camel case + df = df.rename( + columns={ + # 'Date': 'time', + 'Open': 'open', + 'High': 'high', + 'Low': 'low', + 'Close': 'close', + 'Volume': 'volume', + } + ) + for name in array.dtype.names: + array[name] = df[name] + + array[:] = df[:] + + _nan_to_closest_num(array) + # self._set_time_frame(default_tf) + + return array + + +def _nan_to_closest_num(array: np.ndarray): + """Return interpolated values instead of NaN. + """ + + for col in ['open', 'high', 'low', 'close']: + mask = np.isnan(array[col]) + if not mask.size: + continue + array[col][mask] = np.interp( + np.flatnonzero(mask), np.flatnonzero(~mask), array[col][~mask] + ) diff --git a/piker/ui/qt/_style.py b/piker/ui/qt/_style.py index 8630d67d..b35e2e47 100644 --- a/piker/ui/qt/_style.py +++ b/piker/ui/qt/_style.py @@ -4,10 +4,6 @@ Qt styling. from PyQt5 import QtGui -# TODO: add "tina mode" to make everything look "conventional" -# white background (for tinas like our pal xb) -# pg.setConfigOption('background', 'w') - # chart-wide font _font = QtGui.QFont("Hack", 4) @@ -16,3 +12,17 @@ _i3_rgba = QtGui.QColor.fromRgbF(*[0.14]*3 + [1]) # splitter widget config _xaxis_at = 'bottom' + + +_tina_mode = False + + +def enable_tina_mode() -> None: + """Enable "tina mode" to make everything look "conventional" + like your pet hedgehog always wanted. + """ + + _tina_mode = True + + # white background (for tinas like our pal xb) + pg.setConfigOption('background', 'w')