Start grouping interactions into a ``ViewBox``

Move chart resize code into our ``ViewBox`` subtype (a ``ChartView``)
in an effort to start organizing interaction behaviour closer to the
appropriate underlying objects. Add some docs for all this and do some
renaming.
its_happening
Tyler Goodlet 2020-06-14 15:10:08 -04:00
parent 68266f5a20
commit ea234f4472
2 changed files with 97 additions and 53 deletions

View File

@ -89,5 +89,6 @@ def run_qtrio(
window.main_widget = main_widget window.main_widget = main_widget
window.setCentralWidget(instance) window.setCentralWidget(instance)
# actually render to screen
window.show() window.show()
app.exec_() app.exec_()

View File

@ -1,7 +1,7 @@
""" """
Real-time quotes charting components Real-time quotes charting components
""" """
from typing import Callable, List, Tuple from typing import List, Tuple
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
@ -13,10 +13,10 @@ from .const import ChartType
from .portfolio import Order, Portfolio from .portfolio import Order, Portfolio
from .utils import fromtimestamp, timeit from .utils import fromtimestamp, timeit
__all__ = ('QuotesChart') __all__ = ('SplitterChart')
# white background for tinas like xb # white background (for tinas like our pal xb)
# pg.setConfigOption('background', 'w') # pg.setConfigOption('background', 'w')
# margins # margins
@ -260,9 +260,79 @@ class YAxisLabel(AxisLabel):
self.setPos(new_pos) self.setPos(new_pos)
class ScrollFromRightView(pg.ViewBox): class ChartView(pg.ViewBox):
"""Price chart view box with interaction behaviors you'd expect from
an interactive platform:
- zoom on mouse scroll that auto fits y-axis
- no vertical scrolling
- zoom to a "fixed point" on the y-axis
"""
def __init__(
self,
parent=None,
**kwargs,
# invertY=False,
):
super().__init__(parent=parent, **kwargs)
self.chart = parent
# disable vertical scrolling
self.setMouseEnabled(x=True, y=False)
# assign callback for rescaling y-axis automatically
# based on y-range contents
self.chart.sigXRangeChanged.connect(self._update_yrange_limits)
def _update_yrange_limits(self):
"""Callback for each y-range update.
This adds auto-scaling like zoom on the scroll wheel such
that data always fits nicely inside the current view of the
data set.
"""
# TODO: this can likely be ported in part to the built-ins:
# self.setYRange(Quotes.low.min() * .98, Quotes.high.max() * 1.02)
# self.setMouseEnabled(x=True, y=False)
# self.setXRange(Quotes[0].id, Quotes[-1].id)
# self.setAutoVisible(x=False, y=True)
# self.enableAutoRange(x=False, y=True)
chart = self.chart
chart_parent = chart.parent
vr = self.chart.viewRect()
lbar, rbar = int(vr.left()), int(vr.right())
if chart_parent.signals_visible:
chart_parent._show_text_signals(lbar, rbar)
bars = Quotes[lbar:rbar]
ylow = bars.low.min() * 0.98
yhigh = bars.high.max() * 1.02
std = np.std(bars.close)
chart.setLimits(yMin=ylow, yMax=yhigh, minYRange=std)
chart.setYRange(ylow, yhigh)
for i, d in chart_parent.indicators:
# ydata = i.plotItem.items[0].getData()[1]
ydata = d[lbar:rbar]
ylow = ydata.min() * 0.98
yhigh = ydata.max() * 1.02
std = np.std(ydata)
i.setLimits(yMin=ylow, yMax=yhigh, minYRange=std)
i.setYRange(ylow, yhigh)
def wheelEvent(self, ev, axis=None): def wheelEvent(self, ev, axis=None):
"""Override "center-point" location for scrolling.
This is an override of the ``ViewBox`` method simply changing
the center of the zoom to be the y-axis.
TODO: PR a method into ``pyqtgraph`` to make this configurable
"""
if axis in (0, 1): if axis in (0, 1):
mask = [False, False] mask = [False, False]
mask[axis] = self.state['mouseEnabled'][axis] mask[axis] = self.state['mouseEnabled'][axis]
@ -307,11 +377,11 @@ class ChartPlotWidget(pg.PlotWidget):
sig_mouse_enter = QtCore.Signal(object) sig_mouse_enter = QtCore.Signal(object)
def enterEvent(self, ev): # noqa def enterEvent(self, ev): # noqa
pg.PlotWidget.enterEvent(self, ev) # pg.PlotWidget.enterEvent(self, ev)
self.sig_mouse_enter.emit(self) self.sig_mouse_enter.emit(self)
def leaveEvent(self, ev): # noqa def leaveEvent(self, ev): # noqa
pg.PlotWidget.leaveEvent(self, ev) # pg.PlotWidget.leaveEvent(self, ev)
self.sig_mouse_leave.emit(self) self.sig_mouse_leave.emit(self)
self.scene().leaveEvent(ev) self.scene().leaveEvent(ev)
@ -562,16 +632,19 @@ def _configure_chart(
) -> None: ) -> None:
"""Configure a chart with common settings. """Configure a chart with common settings.
""" """
# show only right side axes # show only right side axes
chart.hideAxis('left') chart.hideAxis('left')
chart.showAxis('right') chart.showAxis('right')
# highest = Quotes.high.max() * 1.02
# lowest = Quotes.low.min() * 0.98
# set panning limits # set panning limits
chart.setLimits( chart.setLimits(
xMin=Quotes[0].id, xMin=Quotes[0].id,
xMax=Quotes[-1].id, xMax=Quotes[-1].id,
minXRange=60, minXRange=40,
# maxYRange=highest-lowest,
yMin=Quotes.low.min() * 0.98, yMin=Quotes.low.min() * 0.98,
yMax=Quotes.high.max() * 1.02, yMax=Quotes.high.max() * 1.02,
) )
@ -584,8 +657,7 @@ def _configure_chart(
def _configure_quotes_chart( def _configure_quotes_chart(
chart: ChartPlotWidget, chart: ChartPlotWidget,
style: ChartType, style: ChartType = ChartType.BAR,
update_yrange_limits: Callable,
) -> None: ) -> None:
"""Update and format a chart with quotes data. """Update and format a chart with quotes data.
""" """
@ -596,19 +668,15 @@ def _configure_quotes_chart(
# be drawn on next render cycle # be drawn on next render cycle
chart.addItem(_get_chart_points(style)) chart.addItem(_get_chart_points(style))
# assign callback for rescaling y-axis automatically
# based on y-range contents
# TODO: this can likely be ported to built-in: .enableAutoRange()
# but needs testing
chart.sigXRangeChanged.connect(update_yrange_limits)
def _configure_ind_charts( def _configure_ind_charts(
indicators: List[Tuple[ChartPlotWidget, np.ndarray]], indicators: List[Tuple[ChartPlotWidget, np.ndarray]],
xlink_to_chart: ChartPlotWidget, xlink_to_chart: ChartPlotWidget,
) -> None: ) -> None:
for ind_chart, d in indicators: for ind_chart, d in indicators:
# link chart x-axis to main quotes chart
ind_chart.setXLink(xlink_to_chart)
# default config # default config
_configure_chart(ind_chart) _configure_chart(ind_chart)
@ -618,11 +686,8 @@ def _configure_ind_charts(
# XXX: never do this lol # XXX: never do this lol
# ind.setAspectLocked(1) # ind.setAspectLocked(1)
# link chart x-axis to main quotes chart
ind_chart.setXLink(xlink_to_chart)
class SplitterChart(QtGui.QWidget):
class QuotesChart(QtGui.QWidget):
long_pen = pg.mkPen('#006000') long_pen = pg.mkPen('#006000')
long_brush = pg.mkBrush('#00ff00') long_brush = pg.mkBrush('#00ff00')
@ -634,7 +699,6 @@ class QuotesChart(QtGui.QWidget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.signals_visible = False self.signals_visible = False
self.style = ChartType.BAR
self.indicators = [] self.indicators = []
self.xaxis = FromTimeFieldDateAxis(orientation='bottom') self.xaxis = FromTimeFieldDateAxis(orientation='bottom')
@ -677,44 +741,23 @@ class QuotesChart(QtGui.QWidget):
self.signals_visible = False self.signals_visible = False
def _update_sizes(self): def _update_sizes(self):
min_h_ind = int(self.height() * 0.3 / len(self.indicators)) min_h_ind = int(self.height() * 0.2 / len(self.indicators))
sizes = [int(self.height() * 0.7)] sizes = [int(self.height() * 0.8)]
sizes.extend([min_h_ind] * len(self.indicators)) sizes.extend([min_h_ind] * len(self.indicators))
self.splitter.setSizes(sizes) # , int(self.height()*0.2) self.splitter.setSizes(sizes) # , int(self.height()*0.2)
def _update_yrange_limits(self):
"""Callback for each y-range update.
"""
vr = self.chart.viewRect()
lbar, rbar = int(vr.left()), int(vr.right())
if self.signals_visible:
self._show_text_signals(lbar, rbar)
bars = Quotes[lbar:rbar]
ylow = bars.low.min() * 0.98
yhigh = bars.high.max() * 1.02
std = np.std(bars.close)
self.chart.setLimits(yMin=ylow, yMax=yhigh, minYRange=std)
self.chart.setYRange(ylow, yhigh)
for i, d in self.indicators:
# ydata = i.plotItem.items[0].getData()[1]
ydata = d[lbar:rbar]
ylow = ydata.min() * 0.98
yhigh = ydata.max() * 1.02
std = np.std(ydata)
i.setLimits(yMin=ylow, yMax=yhigh, minYRange=std)
i.setYRange(ylow, yhigh)
def plot(self, symbol): def plot(self, symbol):
self.digits = symbol.digits self.digits = symbol.digits
self.chart = ChartPlotWidget( self.chart = ChartPlotWidget(
parent=self.splitter, parent=self.splitter,
axisItems={'bottom': self.xaxis, 'right': PriceAxis()}, axisItems={'bottom': self.xaxis, 'right': PriceAxis()},
viewBox=ScrollFromRightView, viewBox=ChartView,
# enableMenu=False, # enableMenu=False,
) )
# TODO: ``pyqtgraph`` doesn't pass through a parent to the
# ``PlotItem`` by default; maybe we should PR this in?
self.chart.plotItem.parent = self
self.chart.getPlotItem().setContentsMargins(*CHART_MARGINS) self.chart.getPlotItem().setContentsMargins(*CHART_MARGINS)
self.chart.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain) self.chart.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain)
@ -726,8 +769,10 @@ class QuotesChart(QtGui.QWidget):
parent=self.splitter, parent=self.splitter,
axisItems={'bottom': self.xaxis_ind, 'right': PriceAxis()}, axisItems={'bottom': self.xaxis_ind, 'right': PriceAxis()},
# axisItems={'top': self.xaxis_ind, 'right': PriceAxis()}, # axisItems={'top': self.xaxis_ind, 'right': PriceAxis()},
# enableMenu=False, viewBox=ChartView,
) )
ind.plotItem.parent = self
ind.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain) ind.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain)
ind.getPlotItem().setContentsMargins(*CHART_MARGINS) ind.getPlotItem().setContentsMargins(*CHART_MARGINS)
# self.splitter.addWidget(ind) # self.splitter.addWidget(ind)
@ -735,8 +780,6 @@ class QuotesChart(QtGui.QWidget):
_configure_quotes_chart( _configure_quotes_chart(
self.chart, self.chart,
self.style,
self._update_yrange_limits
) )
_configure_ind_charts( _configure_ind_charts(
self.indicators, self.indicators,