From 69aced752181349e5c249eb154ed918d03b62cd5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 26 Aug 2020 14:15:52 -0400 Subject: [PATCH] Add "contents" labels to charts Add a default "contents label" (eg. OHLC values for bar charts) to each chart and update on crosshair interaction. Few technical changes to make this happen: - adjust bar graphics to have the HL line be in the "middle" of the underlying arrays' "index range" in the containing view. - add a label dict each chart's graphics name to a label + update routine - use symbol names instead of this "main" identifier crap for referring to particular price curves/graphics --- piker/ui/_chart.py | 125 ++++++++++++++++++++++++++++-------------- piker/ui/_graphics.py | 54 ++++++++++++------ 2 files changed, 120 insertions(+), 59 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index cc5f1515..b92103ae 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -1,7 +1,7 @@ """ High level Qt chart widgets. """ -from typing import Optional, Tuple, Dict, Any +from typing import Tuple, Dict, Any import time from PyQt5 import QtCore, QtGui @@ -172,14 +172,15 @@ class LinkedSplitCharts(QtGui.QWidget): # add crosshairs self._ch = CrossHair( - parent=self, + linkedsplitcharts=self, digits=self.digits ) self.chart = self.add_plot( - name='main', + name=symbol.key, array=array, xaxis=self.xaxis, ohlc=True, + _is_main=True, ) # add crosshair graphic self.chart.addItem(self._ch) @@ -195,12 +196,13 @@ class LinkedSplitCharts(QtGui.QWidget): array: np.ndarray, xaxis: DynamicDateAxis = None, ohlc: bool = False, + _is_main: bool = False, ) -> 'ChartPlotWidget': """Add (sub)plots to chart widget by name. If ``name`` == ``"main"`` the chart will be the the primary view. """ - if self.chart is None and name != 'main': + if self.chart is None and not _is_main: raise RuntimeError( "A main plot must be created first with `.plot_main()`") @@ -230,14 +232,14 @@ class LinkedSplitCharts(QtGui.QWidget): # draw curve graphics if ohlc: - cpw.draw_ohlc(array) + cpw.draw_ohlc(name, array) else: - cpw.draw_curve(array) + cpw.draw_curve(name, array) # add to cross-hair's known plots self._ch.add_plot(cpw) - if name != "main": + if not _is_main: # track by name self.subplots[name] = cpw @@ -279,13 +281,7 @@ class ChartPlotWidget(pg.PlotWidget): super().__init__(**kwargs) self._array = array # readonly view of data self._graphics = {} # registry of underlying graphics - - # XXX: label setting doesn't seem to work? - # likely custom graphics need special handling - # label = pg.LabelItem(justify='right') - # self.addItem(label) - # label.setText("Yo yoyo") - # label.setText("x=") + self._labels = {} # registry of underlying graphics # show only right side axes self.hideAxis('left') @@ -303,6 +299,11 @@ class ChartPlotWidget(pg.PlotWidget): # based on ohlc contents self.sigXRangeChanged.connect(self._set_yrange) + def _update_contents_label(self, index: int) -> None: + if index > 0 and index < len(self._array): + for name, (label, update) in self._labels.items(): + update(index) + def _set_xlimits( self, xfirst: int, @@ -331,6 +332,7 @@ class ChartPlotWidget(pg.PlotWidget): def draw_ohlc( self, + name: str, data: np.ndarray, # XXX: pretty sure this is dumb and we don't need an Enum style: pg.GraphicsObject = BarItems, @@ -345,7 +347,25 @@ class ChartPlotWidget(pg.PlotWidget): graphics.draw_from_data(data) self.addItem(graphics) - self._graphics['ohlc_main'] = graphics + self._graphics[name] = graphics + + # XXX: How to stack labels vertically? + label = pg.LabelItem( + justify='left', + size='5pt', + ) + self.scene().addItem(label) + + def update(index: int) -> None: + label.setText( + "{name} O:{} H:{} L:{} C:{} V:{}".format( + *self._array[index].item()[2:], + name=name, + ) + ) + + self._labels[name] = (label, update) + self._update_contents_label(index=-1) # set xrange limits xlast = data[-1]['index'] @@ -357,8 +377,8 @@ class ChartPlotWidget(pg.PlotWidget): def draw_curve( self, + name: str, data: np.ndarray, - name: Optional[str] = 'line_main', ) -> pg.PlotDataItem: # draw the indicator as a plain curve curve = pg.PlotDataItem( @@ -371,10 +391,24 @@ class ChartPlotWidget(pg.PlotWidget): # register overlay curve with name if not self._graphics and name is None: - name = 'line_main' + name = 'a_line_bby' self._graphics[name] = curve + # XXX: How to stack labels vertically? + label = pg.LabelItem( + justify='left', + size='5pt', + ) + self.scene().addItem(label) + + def update(index: int) -> None: + data = self._array[index] + label.setText(f"{name}: {index} {data}") + + self._labels[name] = (label, update) + self._update_contents_label(index=-1) + # set a "startup view" xlast = len(data) - 1 @@ -531,29 +565,28 @@ async def add_new_bars(delay_s, linked_charts): ) # read value at "open" of bar - last_quote = ohlc[-1] + # last_quote = ohlc[-1] + # XXX: If the last bar has not changed print a flat line and + # move to the next. This is a "animation" choice that we may not + # keep. + # if last_quote == ohlc[-1]: + # log.debug("Printing flat line for {sym}") + + # update chart graphics and resize view + price_chart.update_from_array(price_chart.name, ohlc) + price_chart._set_yrange() + + for name, chart in linked_charts.subplots.items(): + chart.update_from_array(chart.name, chart._array) + chart._set_yrange() # We **don't** update the bar right now # since the next quote that arrives should in the # tick streaming task await sleep() - # XXX: If the last bar has not changed print a flat line and - # move to the next. This is a "animation" choice that we may not - # keep. - if last_quote == ohlc[-1]: - log.debug("Printing flat line for {sym}") - price_chart.update_from_array('ohlc_main', ohlc) - - # resize view - price_chart._set_yrange() - - - for name, chart in linked_charts.subplots.items(): - chart.update_from_array('line_main', chart._array) - - # resize view - chart._set_yrange() + # TODO: should we update a graphics again time here? + # Think about race conditions with data update task. async def _async_main( @@ -639,9 +672,10 @@ async def _async_main( last, ) chart.update_from_array( - 'ohlc_main', + chart.name, chart._array, ) + chart._set_yrange() async def chart_from_fsp( @@ -676,7 +710,7 @@ async def chart_from_fsp( stream = await portal.result() # receive processed historical data-array as first message - history = (await stream.__anext__()) + history: np.ndarray = (await stream.__anext__()) # TODO: enforce type checking here newbars = np.array(history) @@ -686,10 +720,19 @@ async def chart_from_fsp( array=newbars, ) - # update sub-plot graphics + # check for data length mis-allignment and fill missing values + diff = len(chart._array) - len(linked_charts.chart._array) + if diff < 0: + data = chart._array + chart._array = np.append( + data, + np.full(abs(diff), data[-1], dtype=data.dtype) + ) + + # update chart graphics async for value in stream: chart._array[-1] = value - chart.update_from_array('line_main', chart._array) + chart.update_from_array(chart.name, chart._array) chart._set_yrange() @@ -703,7 +746,7 @@ def _main( # Qt entry point run_qtractor( func=_async_main, - args=(sym, brokername), - main_widget=ChartSpace, - tractor_kwargs=tractor_kwargs, + args=(sym, brokername), + main_widget=ChartSpace, + tractor_kwargs=tractor_kwargs, ) diff --git a/piker/ui/_graphics.py b/piker/ui/_graphics.py index 54cbcd43..edd75987 100644 --- a/piker/ui/_graphics.py +++ b/piker/ui/_graphics.py @@ -2,7 +2,6 @@ Chart graphics for displaying a slew of different data types. """ from typing import List -from enum import Enum from itertools import chain import numpy as np @@ -23,10 +22,14 @@ _mouse_rate_limit = 30 class CrossHair(pg.GraphicsObject): - def __init__(self, parent, digits: int = 0): + def __init__( + self, + linkedsplitcharts: 'LinkedSplitCharts', + digits: int = 0 + ) -> None: super().__init__() self.pen = pg.mkPen('#a9a9a9') # gray? - self.parent = parent + self.lsc = linkedsplitcharts self.graphics = {} self.plots = [] self.active_plot = None @@ -110,18 +113,27 @@ class CrossHair(pg.GraphicsObject): # mouse was not on active plot return - self.graphics[self.active_plot]['hl'].setY( - mouse_point.y() - ) + x, y = mouse_point.x(), mouse_point.y() + + plot = self.active_plot + + self.graphics[plot]['hl'].setY(y) + self.graphics[self.active_plot]['yl'].update_label( evt_post=pos, point_view=mouse_point ) - # move the vertical line to the current x coordinate in all charts - for opts in self.graphics.values(): - opts['vl'].setX(mouse_point.x()) + for plot, opts in self.graphics.items(): + # move the vertical line to the current x + opts['vl'].setX(x) + + # update the chart's "contents" label + plot._update_contents_label(int(x)) # update the label on the bottom of the crosshair - self.xaxis_label.update_label(evt_post=pos, point_view=mouse_point) + self.xaxis_label.update_label( + evt_post=pos, + point_view=mouse_point + ) def boundingRect(self): try: @@ -149,8 +161,8 @@ def _mk_lines_array(data: List, size: int) -> np.ndarray: def bars_from_ohlc( data: np.ndarray, + w: float, start: int = 0, - w: float = 0.43, ) -> np.ndarray: """Generate an array of lines objects from input ohlc data. """ @@ -160,9 +172,15 @@ def bars_from_ohlc( open, high, low, close, index = q[ ['open', 'high', 'low', 'close', 'index']] + # place the x-coord start as "middle" of the drawing range such + # that the open arm line-graphic is at the left-most-side of + # the indexe's range according to the view mapping. + index_start = index + w + # high - low line if low != high: - hl = QLineF(index, low, index, high) + # hl = QLineF(index, low, index, high) + hl = QLineF(index_start, low, index_start, high) else: # XXX: if we don't do it renders a weird rectangle? # see below too for handling this later... @@ -170,9 +188,9 @@ def bars_from_ohlc( hl._flat = True # open line - o = QLineF(index - w, open, index, open) + o = QLineF(index_start - w, open, index_start, open) # close line - c = QLineF(index + w, close, index, close) + c = QLineF(index_start + w, close, index_start, close) # indexing here is as per the below comments # lines[3*i:3*i+3] = (hl, o, c) @@ -207,7 +225,7 @@ class BarItems(pg.GraphicsObject): sigPlotChanged = QtCore.Signal(object) # 0.5 is no overlap between arms, 1.0 is full overlap - w: float = 0.4 + w: float = 0.43 bull_pen = pg.mkPen('#808080') # XXX: tina mode, see below @@ -234,7 +252,7 @@ class BarItems(pg.GraphicsObject): ): """Draw OHLC datum graphics from a ``np.recarray``. """ - lines = bars_from_ohlc(data, start=start) + lines = bars_from_ohlc(data, self.w, start=start) # save graphics for later reference and keep track # of current internal "last index" @@ -280,7 +298,7 @@ class BarItems(pg.GraphicsObject): if extra > 0: # generate new graphics to match provided array new = array[index:index + extra] - lines = bars_from_ohlc(new) + lines = bars_from_ohlc(new, self.w) bars_added = len(lines) self.lines[index:index + bars_added] = lines self.index += bars_added @@ -311,7 +329,7 @@ class BarItems(pg.GraphicsObject): # if the bar was flat it likely does not have # the index set correctly due to a rendering bug # see above - body.setLine(i, low, i, high) + body.setLine(i + self.w, low, i + self.w, high) body._flat = False else: body.setLine(body.x1(), low, body.x2(), high)