Rework charting internals for real-time plotting

`pg.PlotCurveItem.setData()` is normally used for real-time updates to
curves and takes in a whole new array of data to graphics.
It makes sense to stick with this interface especially if
the current datum graphic will originally be drawn from tick quotes and
later filled in when bars data is available (eg. IB has this option in
TWS charts for volume). Additionally, having a data feed api where the push
process/task can write to shared memory and the UI task(s) can read from
that space is ideal. It allows for indicator and algo calculations to be
run in parallel (via actors) with initial price draw instructions
such that plotting of downstream metrics can be "pipelined" into the
chart UI's render loop. This essentially makes the chart UI async
programmable from multiple remote processes (or at least that's the
goal).

Some details:
- Only store a single ref to the source array data on the
  `LinkedSplitCharts`.  There should only be one reference since the main
  relation is **that** x-time aligned sequence.
- Add `LinkedSplitCharts.update_from_quote()` which takes in a quote
  dict and updates the OHLC array from it's contents.
- Add `ChartPlotWidget.update_from_array()` method to trigger graphics
  updates per chart with consideration for overlay curves.
bar_select
Tyler Goodlet 2020-06-19 08:01:10 -04:00
parent 5e8e48c7b7
commit 2f1fdaf9e5
2 changed files with 186 additions and 105 deletions

View File

@ -33,14 +33,13 @@ class PriceAxis(pg.AxisItem):
# ] # ]
class FromTimeFieldDateAxis(pg.AxisItem): class DynamicDateAxis(pg.AxisItem):
tick_tpl = {'D1': '%Y-%b-%d'} tick_tpl = {'D1': '%Y-%b-%d'}
def __init__(self, splitter, *args, **kwargs): def __init__(self, linked_charts, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.splitter = splitter self.linked_charts = linked_charts
self.setTickFont(_font) self.setTickFont(_font)
# self.quotes_count = len(self.splitter.chart._array) - 1
# default styling # default styling
self.setStyle( self.setStyle(
@ -57,12 +56,12 @@ class FromTimeFieldDateAxis(pg.AxisItem):
# strings = super().tickStrings(values, scale, spacing) # strings = super().tickStrings(values, scale, spacing)
s_period = 'D1' s_period = 'D1'
strings = [] strings = []
quotes_count = len(self.splitter.chart._array) - 1 bars = self.linked_charts._array
quotes_count = len(bars) - 1
for ibar in values: for ibar in values:
if ibar > quotes_count: if ibar > quotes_count:
return strings return strings
bars = self.splitter.chart._array
dt_tick = fromtimestamp(bars[int(ibar)].time) dt_tick = fromtimestamp(bars[int(ibar)].time)
strings.append( strings.append(
dt_tick.strftime(self.tick_tpl[s_period]) dt_tick.strftime(self.tick_tpl[s_period])
@ -144,7 +143,7 @@ class XAxisLabel(AxisLabel):
def tick_to_string(self, tick_pos): def tick_to_string(self, tick_pos):
# TODO: change to actual period # TODO: change to actual period
tpl = self.parent.tick_tpl['D1'] tpl = self.parent.tick_tpl['D1']
bars = self.parent.splitter.chart._array bars = self.parent.linked_charts._array
if tick_pos > len(bars): if tick_pos > len(bars):
return 'Unknown Time' return 'Unknown Time'
return fromtimestamp(bars[round(tick_pos)].time).strftime(tpl) return fromtimestamp(bars[round(tick_pos)].time).strftime(tpl)
@ -173,7 +172,7 @@ class YAxisLabel(AxisLabel):
return ('{: ,.%df}' % self.digits).format(tick_pos).replace(',', ' ') return ('{: ,.%df}' % self.digits).format(tick_pos).replace(',', ' ')
def boundingRect(self): # noqa def boundingRect(self): # noqa
return QtCore.QRectF(0, 0, 80, 40) return QtCore.QRectF(0, 0, 100, 40)
def update_label(self, evt_post, point_view): def update_label(self, evt_post, point_view):
self.label_str = self.tick_to_string(point_view.y()) self.label_str = self.tick_to_string(point_view.y())

View File

@ -1,6 +1,8 @@
""" """
High level Qt chart widgets. High level Qt chart widgets.
""" """
from typing import List, Optional
import trio import trio
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
@ -8,12 +10,12 @@ from pyqtgraph import functions as fn
from PyQt5 import QtCore, QtGui from PyQt5 import QtCore, QtGui
from ._axes import ( from ._axes import (
FromTimeFieldDateAxis, DynamicDateAxis,
PriceAxis, PriceAxis,
) )
from ._graphics import CrossHairItem, ChartType from ._graphics import CrossHairItem, ChartType
from ._style import _xaxis_at from ._style import _xaxis_at
from ._source import Symbol, ohlc_zeros from ._source import Symbol
# margins # margins
@ -85,9 +87,13 @@ class ChartSpace(QtGui.QWidget):
class LinkedSplitCharts(QtGui.QWidget): class LinkedSplitCharts(QtGui.QWidget):
"""Widget that holds a price chart plus indicators separated by splitters. """Widget that holds a central chart plus derived
""" subcharts computed from the original data set apart
by splitters for resizing.
A single internal references to the data is maintained
for each chart and can be updated externally.
"""
long_pen = pg.mkPen('#006000') long_pen = pg.mkPen('#006000')
long_brush = pg.mkBrush('#00ff00') long_brush = pg.mkBrush('#00ff00')
short_pen = pg.mkPen('#600000') short_pen = pg.mkPen('#600000')
@ -98,13 +104,21 @@ class LinkedSplitCharts(QtGui.QWidget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.signals_visible = False self.signals_visible = False
# main data source
self._array = None
self._ch = None # crosshair graphics
self._index = 0
self.chart = None # main (ohlc) chart
self.indicators = [] self.indicators = []
self.xaxis = FromTimeFieldDateAxis(orientation='bottom', splitter=self) self.xaxis = DynamicDateAxis(
# self.xaxis = pg.DateAxisItem() orientation='bottom', linked_charts=self)
self.xaxis_ind = FromTimeFieldDateAxis( self.xaxis_ind = DynamicDateAxis(
orientation='bottom', splitter=self) orientation='bottom', linked_charts=self)
if _xaxis_at == 'bottom': if _xaxis_at == 'bottom':
self.xaxis.setStyle(showValues=False) self.xaxis.setStyle(showValues=False)
@ -119,24 +133,36 @@ class LinkedSplitCharts(QtGui.QWidget):
self.layout.addWidget(self.splitter) self.layout.addWidget(self.splitter)
def _update_sizes(self): def set_split_sizes(
min_h_ind = int(self.height() * 0.2 / len(self.indicators)) self,
sizes = [int(self.height() * 0.8)] prop: float = 0.2
) -> None:
"""Set the proportion of space allocated for linked subcharts.
"""
major = 1 - prop
# 20% allocated to consumer subcharts
min_h_ind = int(self.height() * prop / len(self.indicators))
sizes = [int(self.height() * major)]
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 plot( def plot(
self, self,
symbol: Symbol, symbol: Symbol,
data: np.ndarray, array: np.ndarray,
ohlc: bool = True,
): ):
"""Start up and show price chart and all registered indicators. """Start up and show main (price) chart and all linked subcharts.
""" """
self.digits = symbol.digits() self.digits = symbol.digits()
# XXX: this may eventually be a view onto shared mem
# or some higher level type / API
self._array = array
cv = ChartView() cv = ChartView()
self.chart = ChartPlotWidget( self.chart = ChartPlotWidget(
split_charts=self, linked_charts=self,
parent=self.splitter, parent=self.splitter,
axisItems={'bottom': self.xaxis, 'right': PriceAxis()}, axisItems={'bottom': self.xaxis, 'right': PriceAxis()},
viewBox=cv, viewBox=cv,
@ -144,52 +170,96 @@ class LinkedSplitCharts(QtGui.QWidget):
) )
# TODO: ``pyqtgraph`` doesn't pass through a parent to the # TODO: ``pyqtgraph`` doesn't pass through a parent to the
# ``PlotItem`` by default; maybe we should PR this in? # ``PlotItem`` by default; maybe we should PR this in?
cv.splitter_widget = self cv.linked_charts = self
self.chart.plotItem.vb.splitter_widget = self self.chart.plotItem.vb.linked_charts = 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)
self.chart.draw_ohlc(data) if ohlc:
self.chart.draw_ohlc(array)
else:
raise NotImplementedError(
"Only OHLC linked charts are supported currently"
)
# TODO: this is where we would load an indicator chain # TODO: this is where we would load an indicator chain
# XXX: note, if this isn't index aligned with # XXX: note, if this isn't index aligned with
# the source data the chart will go haywire. # the source data the chart will go haywire.
inds = [data.open] inds = [('open', lambda a: a.close)]
for d in inds: for name, func in inds:
cv = ChartView() cv = ChartView()
ind_chart = ChartPlotWidget( ind_chart = ChartPlotWidget(
split_charts=self, linked_charts=self,
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()},
viewBox=cv, viewBox=cv,
) )
cv.splitter_widget = self # this name will be used to register the primary
self.chart.plotItem.vb.splitter_widget = self # graphics curve managed by the subchart
ind_chart.name = name
cv.linked_charts = self
self.chart.plotItem.vb.linked_charts = self
ind_chart.setFrameStyle( ind_chart.setFrameStyle(
QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain
) )
ind_chart.getPlotItem().setContentsMargins(*CHART_MARGINS) ind_chart.getPlotItem().setContentsMargins(*CHART_MARGINS)
# self.splitter.addWidget(ind_chart) # self.splitter.addWidget(ind_chart)
self.indicators.append((ind_chart, d))
# compute historical subchart values from input array
data = func(array)
self.indicators.append((ind_chart, func))
# link chart x-axis to main quotes chart # link chart x-axis to main quotes chart
ind_chart.setXLink(self.chart) ind_chart.setXLink(self.chart)
# XXX: never do this lol # draw curve graphics
# ind.setAspectLocked(1) ind_chart.draw_curve(data, name)
ind_chart.draw_curve(d)
self._update_sizes() self.set_split_sizes()
ch = CrossHairItem( ch = self._ch = CrossHairItem(
self.chart, [_ind for _ind, d in self.indicators], self.digits self.chart,
[_ind for _ind, d in self.indicators],
self.digits
) )
self.chart.addItem(ch) self.chart.addItem(ch)
def update_from_quote(
self,
quote: dict
) -> List[pg.GraphicsObject]:
"""Update all linked chart graphics with a new quote
datum.
Return the modified graphics objects in a list.
"""
# TODO: eventually we'll want to update bid/ask labels and other
# data as subscribed by underlying UI consumers.
last = quote['last']
current = self._array[-1]
# update ohlc (I guess we're enforcing this for now?)
current['close'] = last
current['high'] = max(current['high'], last)
current['low'] = min(current['low'], last)
# update the ohlc sequence graphics chart
chart = self.chart
# we send a reference to the whole updated array
chart.update_from_array(self._array)
# TODO: the "data" here should really be a function
# and it should be managed and computed outside of this UI
for chart, func in self.indicators:
# process array in entirely every update
# TODO: change this for streaming
data = func(self._array)
chart.update_from_array(data, chart.name)
_min_points_to_show = 15 _min_points_to_show = 15
_min_bars_in_view = 10 _min_bars_in_view = 10
@ -198,12 +268,14 @@ _min_bars_in_view = 10
class ChartPlotWidget(pg.PlotWidget): class ChartPlotWidget(pg.PlotWidget):
"""``GraphicsView`` subtype containing a single ``PlotItem``. """``GraphicsView`` subtype containing a single ``PlotItem``.
Overrides a ``pyqtgraph.PlotWidget`` (a ``GraphicsView`` containing - The added methods allow for plotting OHLC sequences from
a single ``PlotItem``) to intercept and and re-emit mouse enter/exit ``np.recarray``s with appropriate field names.
events. - Overrides a ``pyqtgraph.PlotWidget`` (a ``GraphicsView`` containing
a single ``PlotItem``) to intercept and and re-emit mouse enter/exit
events.
(Could be replaced with a ``pg.GraphicsLayoutWidget`` if we (Could be replaced with a ``pg.GraphicsLayoutWidget`` if we
eventually want multiple plots managed together). eventually want multiple plots managed together?)
""" """
sig_mouse_leave = QtCore.Signal(object) sig_mouse_leave = QtCore.Signal(object)
sig_mouse_enter = QtCore.Signal(object) sig_mouse_enter = QtCore.Signal(object)
@ -213,29 +285,29 @@ class ChartPlotWidget(pg.PlotWidget):
def __init__( def __init__(
self, self,
split_charts, linked_charts,
**kwargs, **kwargs,
# parent=None, # parent=None,
# background='default', # background='default',
# plotItem=None, # plotItem=None,
# **kargs
): ):
"""Configure chart display settings. """Configure chart display settings.
""" """
super().__init__(**kwargs) super().__init__(**kwargs)
self.parent = linked_charts
# this is the index of that last input array entry and is
# updated and used to figure out how many bars are in view
self._xlast = 0
# XXX: label setting doesn't seem to work? # XXX: label setting doesn't seem to work?
# likely custom graphics need special handling # likely custom graphics need special handling
# label = pg.LabelItem(justify='left') # label = pg.LabelItem(justify='left')
# self.addItem(label) # self.addItem(label)
# label.setText("Yo yoyo") # label.setText("Yo yoyo")
# label.setText("<span style='font-size: 12pt'>x=") # label.setText("<span style='font-size: 12pt'>x=")
self.parent = split_charts
# placeholder for source of data # to be filled in when graphics are rendered
self._array = ohlc_zeros(1) # by name
# to be filled in when data is loaded
self._graphics = {} self._graphics = {}
# show only right side axes # show only right side axes
@ -251,40 +323,32 @@ class ChartPlotWidget(pg.PlotWidget):
self.setCursor(QtCore.Qt.CrossCursor) self.setCursor(QtCore.Qt.CrossCursor)
# assign callback for rescaling y-axis automatically # assign callback for rescaling y-axis automatically
# based on y-range contents # based on ohlc contents
self.sigXRangeChanged.connect(self._update_yrange_limits) self.sigXRangeChanged.connect(self._set_yrange)
def set_view_limits(self, xfirst, xlast, ymin, ymax): def _set_xlimits(
self,
xfirst: int,
xlast: int
) -> None:
"""Set view limits (what's shown in the main chart "pane")
based on max / min x / y coords.
"""
# max_lookahead = _min_points_to_show - _min_bars_in_view # max_lookahead = _min_points_to_show - _min_bars_in_view
# set panning limits # set panning limits
# last = data[-1]['id']
self.setLimits( self.setLimits(
# xMin=data[0]['id'],
xMin=xfirst, xMin=xfirst,
# xMax=last + _min_points_to_show - 3,
xMax=xlast + _min_points_to_show - 3, xMax=xlast + _min_points_to_show - 3,
minXRange=_min_points_to_show, minXRange=_min_points_to_show,
# maxYRange=highest-lowest,
# yMin=data['low'].min() * 0.98,
# yMax=data['high'].max() * 1.02,
yMin=ymin * 0.98,
yMax=ymax * 1.02,
) )
# show last 50 points on startup
# self.plotItem.vb.setXRange(last - 50, last + 50)
self.plotItem.vb.setXRange(xlast - 50, xlast + 50)
# fit y
self._update_yrange_limits()
def bars_range(self): def bars_range(self):
"""Return a range tuple for the bars present in view. """Return a range tuple for the bars present in view.
""" """
vr = self.viewRect() vr = self.viewRect()
lbar = int(vr.left()) lbar = int(vr.left())
rbar = int(min(vr.right(), len(self._array) - 1)) rbar = int(vr.right())
return lbar, rbar return lbar, rbar
def draw_ohlc( def draw_ohlc(
@ -301,35 +365,55 @@ class ChartPlotWidget(pg.PlotWidget):
# adds all bar/candle graphics objects for each data point in # adds all bar/candle graphics objects for each data point in
# the np array buffer to be drawn on next render cycle # the np array buffer to be drawn on next render cycle
graphics.draw_from_data(data) graphics.draw_from_data(data)
self._graphics['ohlc'] = graphics self._graphics['main'] = graphics
self.addItem(graphics) self.addItem(graphics)
self._array = data
# update view limits # set xrange limits
self.set_view_limits( self._xlast = xlast = data[-1]['index']
data[0]['index'], self._set_xlimits(data[0]['index'], xlast)
data[-1]['index'],
data['low'].min(), # show last 50 points on startup
data['high'].max() self.plotItem.vb.setXRange(xlast - 50, xlast + 50)
)
return graphics return graphics
def draw_curve( def draw_curve(
self, self,
data: np.ndarray, data: np.ndarray,
name: Optional[str] = None,
) -> None: ) -> None:
# draw the indicator as a plain curve # draw the indicator as a plain curve
curve = pg.PlotDataItem(data, antialias=True) curve = pg.PlotDataItem(data, antialias=True)
self.addItem(curve) self.addItem(curve)
# update view limits # register overlay curve with name
self.set_view_limits(0, len(data)-1, data.min(), data.max()) if not self._graphics and name is None:
self._array = data name = 'main'
self._graphics[name] = curve
# set a "startup view"
xlast = len(data)-1
self._set_xlimits(0, xlast)
# show last 50 points on startup
self.plotItem.vb.setXRange(xlast - 50, xlast + 50)
return curve return curve
def _update_yrange_limits(self): def update_from_array(
self,
array: np.ndarray,
name: str = 'main',
) -> None:
self._xlast = len(array) - 1
graphics = self._graphics[name]
graphics.setData(array)
# update view
self._set_yrange()
def _set_yrange(
self,
) -> None:
"""Callback for each y-range update. """Callback for each y-range update.
This adds auto-scaling like zoom on the scroll wheel such This adds auto-scaling like zoom on the scroll wheel such
@ -343,12 +427,11 @@ class ChartPlotWidget(pg.PlotWidget):
# self.setAutoVisible(x=False, y=True) # self.setAutoVisible(x=False, y=True)
# self.enableAutoRange(x=False, y=True) # self.enableAutoRange(x=False, y=True)
# figure out x-range bars on screen
lbar, rbar = self.bars_range() lbar, rbar = self.bars_range()
# if chart_parent.signals_visible: # TODO: this should be some kind of numpy view api
# chart_parent._show_text_signals(lbar, rbar) bars = self.parent._array[lbar:rbar]
bars = self._array[lbar:rbar]
if not len(bars): if not len(bars):
# likely no data loaded yet # likely no data loaded yet
return return
@ -389,22 +472,21 @@ class ChartPlotWidget(pg.PlotWidget):
class ChartView(pg.ViewBox): class ChartView(pg.ViewBox):
"""Price chart view box with interaction behaviors you'd expect from """Price chart view box with interaction behaviors you'd expect from
an interactive platform: any interactive platform:
- zoom on mouse scroll that auto fits y-axis - zoom on mouse scroll that auto fits y-axis
- no vertical scrolling - no vertical scrolling
- zoom to a "fixed point" on the y-axis - zoom to a "fixed point" on the y-axis
""" """
def __init__( def __init__(
self, self,
parent=None, parent=None,
**kwargs, **kwargs,
# invertY=False,
): ):
super().__init__(parent=parent, **kwargs) super().__init__(parent=parent, **kwargs)
# disable vertical scrolling # disable vertical scrolling
self.setMouseEnabled(x=True, y=False) self.setMouseEnabled(x=True, y=False)
self.splitter_widget = None self.linked_charts = None
def wheelEvent(self, ev, axis=None): def wheelEvent(self, ev, axis=None):
"""Override "center-point" location for scrolling. """Override "center-point" location for scrolling.
@ -422,13 +504,12 @@ class ChartView(pg.ViewBox):
mask = self.state['mouseEnabled'][:] mask = self.state['mouseEnabled'][:]
# don't zoom more then the min points setting # don't zoom more then the min points setting
lbar, rbar = self.splitter_widget.chart.bars_range() lbar, rbar = self.linked_charts.chart.bars_range()
# breakpoint()
if ev.delta() >= 0 and rbar - lbar <= _min_points_to_show: if ev.delta() >= 0 and rbar - lbar <= _min_points_to_show:
return return
# actual scaling factor # actual scaling factor
s = 1.02 ** (ev.delta() * -1/10) # self.state['wheelScaleFactor']) s = 1.015 ** (ev.delta() * -1/20) # self.state['wheelScaleFactor'])
s = [(None if m is False else s) for m in mask] s = [(None if m is False else s) for m in mask]
# center = pg.Point( # center = pg.Point(
@ -473,26 +554,27 @@ def main(symbol):
quotes = from_df(quotes) quotes = from_df(quotes)
# spawn chart # spawn chart
splitter_chart = chart_app.load_symbol(symbol, quotes) linked_charts = chart_app.load_symbol(symbol, quotes)
# make some fake update data
import itertools import itertools
nums = itertools.cycle([315., 320., 325., 310., 3]) nums = itertools.cycle([315., 320., 325., 310., 3])
def gen_nums(): def gen_nums():
for i in itertools.count(): while True:
yield quotes[-1].close + i yield quotes[-1].close + 1
yield quotes[-1].close - i
chart = splitter_chart.chart
nums = gen_nums() nums = gen_nums()
await trio.sleep(10)
while True: while True:
await trio.sleep(0.1)
new = next(nums) new = next(nums)
quotes[-1].close = new quotes[-1].close = new
chart._graphics['ohlc'].update_last_bar({'last': new}) # this updates the linked_charts internal array
# and then passes that array to all subcharts to
# LOL this clearly isn't catching edge cases # render downstream graphics
chart._update_yrange_limits() linked_charts.update_from_quote({'last': new})
await trio.sleep(.1)
await trio.sleep_forever() await trio.sleep_forever()