From ced310c194e56b08058db9f346f4548184130b26 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 9 Jan 2022 10:34:38 -0500 Subject: [PATCH] Add `ChartPlotWidget.maxmin()` to calc in-view hi/lo y-values As part of factoring `._set_yrange()` into the lower level view box, move the y-range calculations into a new method. These calcs should eventually be completely separate (as they are for the real-time version in the graphics display update loop) and likely part of some kind of graphics-related lower level management API. Draft such an API as an `ArrayScene` (commented for now) as a sketch toward factoring array tracking **out of** the chart widget. Drop the `'ohlc'` array name and instead always use whatever `.name` was assigned to the chart widget to lookup its "main" / source data array for now. Enable auto-yranging on overlayed plotitems by enabling on its viewbox and, for now, assign an ad-hoc `._maxmin()` since the widget version from this commit has no easy way to know which internal array to use. If an FSP (`dolla_vlm` in this case) is overlayed on an existing chart without also having a full widget (which it doesn't in this case since we're using an overlayed `PlotItem` instead of a full `ChartPlotWidget`) we need some way to define the `.maxmin()` for the overlayed data/graphics. This likely means the `.maxmin()` will eventually get factored into wtv lowlevel `ArrayScene` API mentioned above. --- piker/ui/_chart.py | 241 +++++++++++++++++++++------------------------ 1 file changed, 111 insertions(+), 130 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 5f1e352f..1fec22c7 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -19,6 +19,8 @@ High level chart-widget apis. ''' from __future__ import annotations +from functools import partial +from dataclasses import dataclass from typing import Optional from PyQt5 import QtCore, QtWidgets @@ -29,10 +31,10 @@ from PyQt5.QtWidgets import ( QHBoxLayout, QVBoxLayout, QSplitter, - # QSizePolicy, ) import numpy as np import pyqtgraph as pg +from pyqtgraph.widgets._plotitemoverlay import PlotItemOverlay import trio from ._axes import ( @@ -484,7 +486,7 @@ class LinkedSplits(QWidget): # presuming we only want it at the true bottom of all charts. # XXX: uses new api from our ``pyqtgraph`` fork. # https://github.com/pikers/pyqtgraph/tree/plotitemoverlay_onto_pg_master - axis = self.xaxis_chart.removeAxis('bottom', unlink=False) + _ = self.xaxis_chart.removeAxis('bottom', unlink=False) assert 'bottom' not in self.xaxis_chart.plotItem.axes self.xaxis_chart = cpw @@ -608,6 +610,18 @@ class LinkedSplits(QWidget): cpw.sidepane.setMinimumWidth(sp_w) cpw.sidepane.setMaximumWidth(sp_w) +# import pydantic + +# class ArrayScene(pydantic.BaseModel): +# ''' +# Data-AGGRegate: high level API onto multiple (categorized) +# ``ShmArray``s with high level processing routines mostly for +# graphics summary and display. + +# ''' +# arrays: dict[str, np.ndarray] = {} +# graphics: dict[str, pg.GraphicsObject] = {} + class ChartPlotWidget(pg.PlotWidget): ''' @@ -683,9 +697,12 @@ class ChartPlotWidget(pg.PlotWidget): # (see our custom view mode in `._interactions.py`) cv.chart = self + # ensure internal pi matches + assert self.cv is self.plotItem.vb + self.useOpenGL(use_open_gl) self.name = name - self.data_key = data_key + self.data_key = data_key or name # scene-local placeholder for book graphics # sizing to avoid overlap with data contents @@ -694,9 +711,10 @@ class ChartPlotWidget(pg.PlotWidget): # self.setViewportMargins(0, 0, 0, 0) # self._ohlc = array # readonly view of ohlc data + # TODO: move to Aggr above XD # readonly view of data arrays self._arrays = { - 'ohlc': array, + self.data_key: array, } self._graphics = {} # registry of underlying graphics # registry of overlay curve names @@ -707,7 +725,6 @@ class ChartPlotWidget(pg.PlotWidget): self._labels = {} # registry of underlying graphics self._ysticks = {} # registry of underlying graphics - self._vb = self.plotItem.vb self._static_yrange = static_yrange # for "known y-range style" self._view_mode: str = 'follow' @@ -720,14 +737,8 @@ class ChartPlotWidget(pg.PlotWidget): self.showGrid(x=False, y=True, alpha=0.3) self.default_view() + self.cv.enable_auto_yrange() - # Assign callback for rescaling y-axis automatically - # based on data contents and ``ViewBox`` state. - self.sigXRangeChanged.connect(self._set_yrange) - self._vb.sigRangeChangedManually.connect(self._set_yrange) # mouse wheel doesn't emit XRangeChanged - self._vb.sigResized.connect(self._set_yrange) # splitter(s) resizing - - from pyqtgraph.widgets._plotitemoverlay import PlotItemOverlay self.overlay: PlotItemOverlay = PlotItemOverlay(self.plotItem) def resume_all_feeds(self): @@ -740,16 +751,16 @@ class ChartPlotWidget(pg.PlotWidget): @property def view(self) -> ChartView: - return self._vb + return self.plotItem.vb def focus(self) -> None: - self._vb.setFocus() + self.view.setFocus() def last_bar_in_view(self) -> int: - self._arrays['ohlc'][-1]['index'] + self._arrays[self.name][-1]['index'] def is_valid_index(self, index: int) -> bool: - return index >= 0 and index < self._arrays['ohlc'][-1]['index'] + return index >= 0 and index < self._arrays[self.name][-1]['index'] def _set_xlimits( self, @@ -773,7 +784,7 @@ class ChartPlotWidget(pg.PlotWidget): """Return a range tuple for the bars present in view. """ l, r = self.view_range() - array = self._arrays['ohlc'] + array = self._arrays[self.name] lbar = max(l, array[0]['index']) rbar = min(r, array[-1]['index']) return l, lbar, rbar, r @@ -785,7 +796,7 @@ class ChartPlotWidget(pg.PlotWidget): """Set the view box to the "default" startup view of the scene. """ - xlast = self._arrays['ohlc'][index]['index'] + xlast = self._arrays[self.name][index]['index'] begin = xlast - _bars_to_left_in_follow_mode end = xlast + _bars_from_right_in_follow_mode @@ -793,12 +804,13 @@ class ChartPlotWidget(pg.PlotWidget): if self._static_yrange == 'axis': self._static_yrange = None - self.plotItem.vb.setXRange( + view = self.view + view.setXRange( min=begin, max=end, padding=0, ) - self._set_yrange() + view._set_yrange() def increment_view( self, @@ -809,7 +821,7 @@ class ChartPlotWidget(pg.PlotWidget): """ l, r = self.view_range() - self._vb.setXRange( + self.view.setXRange( min=l + 1, max=r + 1, @@ -908,22 +920,31 @@ class ChartPlotWidget(pg.PlotWidget): # register curve graphics and backing array for name self._graphics[name] = curve - self._arrays[data_key or name] = data + self._arrays[data_key] = data if overlay: - anchor_at = ('bottom', 'left') + # anchor_at = ('bottom', 'left') self._overlays[name] = None if separate_axes: + # Custom viewbox impl cv = self.mk_vb(name) - cv.chart = self - # cv.enableAutoRange(axis=1) - xaxis = DynamicDateAxis( - orientation='bottom', - linkedsplits=self.linked, - ) + def maxmin(): + return self.maxmin(name=data_key) + + # ensure view maxmin is computed from correct array + # cv._maxmin = partial(self.maxmin, name=data_key) + + cv._maxmin = maxmin + + cv.chart = self + + # xaxis = DynamicDateAxis( + # orientation='bottom', + # linkedsplits=self.linked, + # ) yaxis = PriceAxis( orientation='right', linkedsplits=self.linked, @@ -950,10 +971,11 @@ class ChartPlotWidget(pg.PlotWidget): # plotite.hideAxis('right') # plotite.hideAxis('bottom') plotitem.addItem(curve) + cv.enable_auto_yrange() # config - # plotitem.enableAutoRange(axis='y') # plotitem.setAutoVisible(y=True) + # plotitem.enableAutoRange(axis='y') plotitem.hideButtons() self.overlay.add_plotitem( @@ -971,7 +993,7 @@ class ChartPlotWidget(pg.PlotWidget): # graphics object self.addItem(curve) - anchor_at = ('top', 'left') + # anchor_at = ('top', 'left') # TODO: something instead of stickies for overlays # (we need something that avoids clutter on x-axis). @@ -1018,7 +1040,7 @@ class ChartPlotWidget(pg.PlotWidget): '''Update the named internal graphics from ``array``. ''' - self._arrays['ohlc'] = array + self._arrays[self.name] = array graphics = self._graphics[graphics_name] graphics.update_from_array(array, **kwargs) return graphics @@ -1039,7 +1061,7 @@ class ChartPlotWidget(pg.PlotWidget): data_key = array_key or graphics_name if graphics_name not in self._overlays: - self._arrays['ohlc'] = array + self._arrays[self.name] = array else: self._arrays[data_key] = array @@ -1061,103 +1083,6 @@ class ChartPlotWidget(pg.PlotWidget): return curve - def _set_yrange( - self, - *, - - yrange: Optional[tuple[float, float]] = None, - range_margin: float = 0.06, - bars_range: Optional[tuple[int, int, int, int]] = None, - - # flag to prevent triggering sibling charts from the same linked - # set from recursion errors. - autoscale_linked_plots: bool = True, - - ) -> None: - ''' - Set the viewable y-range based on embedded data. - - This adds auto-scaling like zoom on the scroll wheel such - that data always fits nicely inside the current view of the - data set. - - ''' - set_range = True - - if self._static_yrange == 'axis': - set_range = False - - elif self._static_yrange is not None: - ylow, yhigh = self._static_yrange - - elif yrange is not None: - ylow, yhigh = yrange - - else: - # Determine max, min y values in viewable x-range from data. - # Make sure min bars/datums on screen is adhered. - - l, lbar, rbar, r = bars_range or self.bars_range() - - if autoscale_linked_plots: - # avoid recursion by sibling plots - linked = self.linked - plots = list(linked.subplots.copy().values()) - main = linked.chart - if main: - plots.append(main) - - for chart in plots: - if chart and not chart._static_yrange: - chart._set_yrange( - bars_range=(l, lbar, rbar, r), - autoscale_linked_plots=False, - ) - - # TODO: logic to check if end of bars in view - # extra = view_len - _min_points_to_show - # begin = self._arrays['ohlc'][0]['index'] - extra - # # end = len(self._arrays['ohlc']) - 1 + extra - # end = self._arrays['ohlc'][-1]['index'] - 1 + extra - - # bars_len = rbar - lbar - # log.debug( - # f"\nl: {l}, lbar: {lbar}, rbar: {rbar}, r: {r}\n" - # f"view_len: {view_len}, bars_len: {bars_len}\n" - # f"begin: {begin}, end: {end}, extra: {extra}" - # ) - - a = self._arrays['ohlc'] - ifirst = a[0]['index'] - bars = a[lbar - ifirst:rbar - ifirst + 1] - - if not len(bars): - # likely no data loaded yet or extreme scrolling? - log.error(f"WTF bars_range = {lbar}:{rbar}") - return - - if self.data_key != self.linked.symbol.key: - bars = bars[self.data_key] - ylow = np.nanmin(bars) - yhigh = np.nanmax(bars) - # print(f'{(ylow, yhigh)}') - else: - # just the std ohlc bars - ylow = np.nanmin(bars['low']) - yhigh = np.nanmax(bars['high']) - - if set_range: - # view margins: stay within a % of the "true range" - diff = yhigh - ylow - ylow = ylow - (diff * range_margin) - yhigh = yhigh + (diff * range_margin) - - self.setLimits( - yMin=ylow, - yMax=yhigh, - ) - self.setYRange(ylow, yhigh) - # def _label_h(self, yhigh: float, ylow: float) -> float: # # compute contents label "height" in view terms # # to avoid having data "contents" overlap with them @@ -1210,3 +1135,59 @@ class ChartPlotWidget(pg.PlotWidget): return ohlc['index'][indexes][-1] else: return ohlc['index'][-1] + + def maxmin( + self, + name: Optional[str] = None, + bars_range: Optional[tuple[int, int, int, int]] = None, + + ) -> tuple[float, float]: + ''' + Return the max and min y-data values "in view". + + If ``bars_range`` is provided use that range. + + ''' + l, lbar, rbar, r = bars_range or self.bars_range() + # TODO: logic to check if end of bars in view + # extra = view_len - _min_points_to_show + # begin = self._arrays['ohlc'][0]['index'] - extra + # # end = len(self._arrays['ohlc']) - 1 + extra + # end = self._arrays['ohlc'][-1]['index'] - 1 + extra + + # bars_len = rbar - lbar + # log.debug( + # f"\nl: {l}, lbar: {lbar}, rbar: {rbar}, r: {r}\n" + # f"view_len: {view_len}, bars_len: {bars_len}\n" + # f"begin: {begin}, end: {end}, extra: {extra}" + # ) + + a = self._arrays[name or self.name] + ifirst = a[0]['index'] + bars = a[lbar - ifirst:rbar - ifirst + 1] + + if not len(bars): + # likely no data loaded yet or extreme scrolling? + log.error(f"WTF bars_range = {lbar}:{rbar}") + return + + if ( + self.data_key == self.linked.symbol.key + ): + # ohlc sampled bars hi/lo lookup + ylow = np.nanmin(bars['low']) + yhigh = np.nanmax(bars['high']) + + else: + try: + view = bars[name or self.data_key] + except: + breakpoint() + # if self.data_key != 'volume': + # else: + # view = bars + ylow = np.nanmin(view) + yhigh = np.nanmax(view) + # print(f'{(ylow, yhigh)}') + + return ylow, yhigh