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
parent
fbb765e1d8
commit
ced310c194
|
@ -19,6 +19,8 @@ High level chart-widget apis.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from functools import partial
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PyQt5 import QtCore, QtWidgets
|
from PyQt5 import QtCore, QtWidgets
|
||||||
|
@ -29,10 +31,10 @@ from PyQt5.QtWidgets import (
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
# QSizePolicy,
|
|
||||||
)
|
)
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
|
from pyqtgraph.widgets._plotitemoverlay import PlotItemOverlay
|
||||||
import trio
|
import trio
|
||||||
|
|
||||||
from ._axes import (
|
from ._axes import (
|
||||||
|
@ -484,7 +486,7 @@ class LinkedSplits(QWidget):
|
||||||
# presuming we only want it at the true bottom of all charts.
|
# presuming we only want it at the true bottom of all charts.
|
||||||
# XXX: uses new api from our ``pyqtgraph`` fork.
|
# XXX: uses new api from our ``pyqtgraph`` fork.
|
||||||
# https://github.com/pikers/pyqtgraph/tree/plotitemoverlay_onto_pg_master
|
# 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
|
assert 'bottom' not in self.xaxis_chart.plotItem.axes
|
||||||
self.xaxis_chart = cpw
|
self.xaxis_chart = cpw
|
||||||
|
|
||||||
|
@ -608,6 +610,18 @@ class LinkedSplits(QWidget):
|
||||||
cpw.sidepane.setMinimumWidth(sp_w)
|
cpw.sidepane.setMinimumWidth(sp_w)
|
||||||
cpw.sidepane.setMaximumWidth(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):
|
class ChartPlotWidget(pg.PlotWidget):
|
||||||
'''
|
'''
|
||||||
|
@ -683,9 +697,12 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
# (see our custom view mode in `._interactions.py`)
|
# (see our custom view mode in `._interactions.py`)
|
||||||
cv.chart = self
|
cv.chart = self
|
||||||
|
|
||||||
|
# ensure internal pi matches
|
||||||
|
assert self.cv is self.plotItem.vb
|
||||||
|
|
||||||
self.useOpenGL(use_open_gl)
|
self.useOpenGL(use_open_gl)
|
||||||
self.name = name
|
self.name = name
|
||||||
self.data_key = data_key
|
self.data_key = data_key or name
|
||||||
|
|
||||||
# scene-local placeholder for book graphics
|
# scene-local placeholder for book graphics
|
||||||
# sizing to avoid overlap with data contents
|
# sizing to avoid overlap with data contents
|
||||||
|
@ -694,9 +711,10 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
# self.setViewportMargins(0, 0, 0, 0)
|
# self.setViewportMargins(0, 0, 0, 0)
|
||||||
# self._ohlc = array # readonly view of ohlc data
|
# self._ohlc = array # readonly view of ohlc data
|
||||||
|
|
||||||
|
# TODO: move to Aggr above XD
|
||||||
# readonly view of data arrays
|
# readonly view of data arrays
|
||||||
self._arrays = {
|
self._arrays = {
|
||||||
'ohlc': array,
|
self.data_key: array,
|
||||||
}
|
}
|
||||||
self._graphics = {} # registry of underlying graphics
|
self._graphics = {} # registry of underlying graphics
|
||||||
# registry of overlay curve names
|
# registry of overlay curve names
|
||||||
|
@ -707,7 +725,6 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
self._labels = {} # registry of underlying graphics
|
self._labels = {} # registry of underlying graphics
|
||||||
self._ysticks = {} # 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._static_yrange = static_yrange # for "known y-range style"
|
||||||
self._view_mode: str = 'follow'
|
self._view_mode: str = 'follow'
|
||||||
|
|
||||||
|
@ -720,14 +737,8 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
self.showGrid(x=False, y=True, alpha=0.3)
|
self.showGrid(x=False, y=True, alpha=0.3)
|
||||||
|
|
||||||
self.default_view()
|
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)
|
self.overlay: PlotItemOverlay = PlotItemOverlay(self.plotItem)
|
||||||
|
|
||||||
def resume_all_feeds(self):
|
def resume_all_feeds(self):
|
||||||
|
@ -740,16 +751,16 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def view(self) -> ChartView:
|
def view(self) -> ChartView:
|
||||||
return self._vb
|
return self.plotItem.vb
|
||||||
|
|
||||||
def focus(self) -> None:
|
def focus(self) -> None:
|
||||||
self._vb.setFocus()
|
self.view.setFocus()
|
||||||
|
|
||||||
def last_bar_in_view(self) -> int:
|
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:
|
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(
|
def _set_xlimits(
|
||||||
self,
|
self,
|
||||||
|
@ -773,7 +784,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
"""Return a range tuple for the bars present in view.
|
"""Return a range tuple for the bars present in view.
|
||||||
"""
|
"""
|
||||||
l, r = self.view_range()
|
l, r = self.view_range()
|
||||||
array = self._arrays['ohlc']
|
array = self._arrays[self.name]
|
||||||
lbar = max(l, array[0]['index'])
|
lbar = max(l, array[0]['index'])
|
||||||
rbar = min(r, array[-1]['index'])
|
rbar = min(r, array[-1]['index'])
|
||||||
return l, lbar, rbar, r
|
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.
|
"""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
|
begin = xlast - _bars_to_left_in_follow_mode
|
||||||
end = xlast + _bars_from_right_in_follow_mode
|
end = xlast + _bars_from_right_in_follow_mode
|
||||||
|
|
||||||
|
@ -793,12 +804,13 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
if self._static_yrange == 'axis':
|
if self._static_yrange == 'axis':
|
||||||
self._static_yrange = None
|
self._static_yrange = None
|
||||||
|
|
||||||
self.plotItem.vb.setXRange(
|
view = self.view
|
||||||
|
view.setXRange(
|
||||||
min=begin,
|
min=begin,
|
||||||
max=end,
|
max=end,
|
||||||
padding=0,
|
padding=0,
|
||||||
)
|
)
|
||||||
self._set_yrange()
|
view._set_yrange()
|
||||||
|
|
||||||
def increment_view(
|
def increment_view(
|
||||||
self,
|
self,
|
||||||
|
@ -809,7 +821,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
l, r = self.view_range()
|
l, r = self.view_range()
|
||||||
self._vb.setXRange(
|
self.view.setXRange(
|
||||||
min=l + 1,
|
min=l + 1,
|
||||||
max=r + 1,
|
max=r + 1,
|
||||||
|
|
||||||
|
@ -908,22 +920,31 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
|
|
||||||
# register curve graphics and backing array for name
|
# register curve graphics and backing array for name
|
||||||
self._graphics[name] = curve
|
self._graphics[name] = curve
|
||||||
self._arrays[data_key or name] = data
|
self._arrays[data_key] = data
|
||||||
|
|
||||||
if overlay:
|
if overlay:
|
||||||
anchor_at = ('bottom', 'left')
|
# anchor_at = ('bottom', 'left')
|
||||||
self._overlays[name] = None
|
self._overlays[name] = None
|
||||||
|
|
||||||
if separate_axes:
|
if separate_axes:
|
||||||
|
|
||||||
# Custom viewbox impl
|
# Custom viewbox impl
|
||||||
cv = self.mk_vb(name)
|
cv = self.mk_vb(name)
|
||||||
cv.chart = self
|
|
||||||
# cv.enableAutoRange(axis=1)
|
|
||||||
|
|
||||||
xaxis = DynamicDateAxis(
|
def maxmin():
|
||||||
orientation='bottom',
|
return self.maxmin(name=data_key)
|
||||||
linkedsplits=self.linked,
|
|
||||||
)
|
# 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(
|
yaxis = PriceAxis(
|
||||||
orientation='right',
|
orientation='right',
|
||||||
linkedsplits=self.linked,
|
linkedsplits=self.linked,
|
||||||
|
@ -950,10 +971,11 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
# plotite.hideAxis('right')
|
# plotite.hideAxis('right')
|
||||||
# plotite.hideAxis('bottom')
|
# plotite.hideAxis('bottom')
|
||||||
plotitem.addItem(curve)
|
plotitem.addItem(curve)
|
||||||
|
cv.enable_auto_yrange()
|
||||||
|
|
||||||
# config
|
# config
|
||||||
# plotitem.enableAutoRange(axis='y')
|
|
||||||
# plotitem.setAutoVisible(y=True)
|
# plotitem.setAutoVisible(y=True)
|
||||||
|
# plotitem.enableAutoRange(axis='y')
|
||||||
plotitem.hideButtons()
|
plotitem.hideButtons()
|
||||||
|
|
||||||
self.overlay.add_plotitem(
|
self.overlay.add_plotitem(
|
||||||
|
@ -971,7 +993,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
# graphics object
|
# graphics object
|
||||||
self.addItem(curve)
|
self.addItem(curve)
|
||||||
|
|
||||||
anchor_at = ('top', 'left')
|
# anchor_at = ('top', 'left')
|
||||||
|
|
||||||
# TODO: something instead of stickies for overlays
|
# TODO: something instead of stickies for overlays
|
||||||
# (we need something that avoids clutter on x-axis).
|
# (we need something that avoids clutter on x-axis).
|
||||||
|
@ -1018,7 +1040,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
'''Update the named internal graphics from ``array``.
|
'''Update the named internal graphics from ``array``.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
self._arrays['ohlc'] = array
|
self._arrays[self.name] = array
|
||||||
graphics = self._graphics[graphics_name]
|
graphics = self._graphics[graphics_name]
|
||||||
graphics.update_from_array(array, **kwargs)
|
graphics.update_from_array(array, **kwargs)
|
||||||
return graphics
|
return graphics
|
||||||
|
@ -1039,7 +1061,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
data_key = array_key or graphics_name
|
data_key = array_key or graphics_name
|
||||||
|
|
||||||
if graphics_name not in self._overlays:
|
if graphics_name not in self._overlays:
|
||||||
self._arrays['ohlc'] = array
|
self._arrays[self.name] = array
|
||||||
else:
|
else:
|
||||||
self._arrays[data_key] = array
|
self._arrays[data_key] = array
|
||||||
|
|
||||||
|
@ -1061,103 +1083,6 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
|
|
||||||
return curve
|
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:
|
# def _label_h(self, yhigh: float, ylow: float) -> float:
|
||||||
# # compute contents label "height" in view terms
|
# # compute contents label "height" in view terms
|
||||||
# # to avoid having data "contents" overlap with them
|
# # to avoid having data "contents" overlap with them
|
||||||
|
@ -1210,3 +1135,59 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
return ohlc['index'][indexes][-1]
|
return ohlc['index'][indexes][-1]
|
||||||
else:
|
else:
|
||||||
return ohlc['index'][-1]
|
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
|
||||||
|
|
Loading…
Reference in New Issue