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.
plotitem_overlays
Tyler Goodlet 2022-01-09 10:34:38 -05:00
parent fbb765e1d8
commit ced310c194
1 changed files with 111 additions and 130 deletions

View File

@ -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