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
parent
5e8e48c7b7
commit
2f1fdaf9e5
|
@ -33,14 +33,13 @@ class PriceAxis(pg.AxisItem):
|
|||
# ]
|
||||
|
||||
|
||||
class FromTimeFieldDateAxis(pg.AxisItem):
|
||||
class DynamicDateAxis(pg.AxisItem):
|
||||
tick_tpl = {'D1': '%Y-%b-%d'}
|
||||
|
||||
def __init__(self, splitter, *args, **kwargs):
|
||||
def __init__(self, linked_charts, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.splitter = splitter
|
||||
self.linked_charts = linked_charts
|
||||
self.setTickFont(_font)
|
||||
# self.quotes_count = len(self.splitter.chart._array) - 1
|
||||
|
||||
# default styling
|
||||
self.setStyle(
|
||||
|
@ -57,12 +56,12 @@ class FromTimeFieldDateAxis(pg.AxisItem):
|
|||
# strings = super().tickStrings(values, scale, spacing)
|
||||
s_period = 'D1'
|
||||
strings = []
|
||||
quotes_count = len(self.splitter.chart._array) - 1
|
||||
bars = self.linked_charts._array
|
||||
quotes_count = len(bars) - 1
|
||||
|
||||
for ibar in values:
|
||||
if ibar > quotes_count:
|
||||
return strings
|
||||
bars = self.splitter.chart._array
|
||||
dt_tick = fromtimestamp(bars[int(ibar)].time)
|
||||
strings.append(
|
||||
dt_tick.strftime(self.tick_tpl[s_period])
|
||||
|
@ -144,7 +143,7 @@ class XAxisLabel(AxisLabel):
|
|||
def tick_to_string(self, tick_pos):
|
||||
# TODO: change to actual period
|
||||
tpl = self.parent.tick_tpl['D1']
|
||||
bars = self.parent.splitter.chart._array
|
||||
bars = self.parent.linked_charts._array
|
||||
if tick_pos > len(bars):
|
||||
return 'Unknown Time'
|
||||
return fromtimestamp(bars[round(tick_pos)].time).strftime(tpl)
|
||||
|
@ -173,7 +172,7 @@ class YAxisLabel(AxisLabel):
|
|||
return ('{: ,.%df}' % self.digits).format(tick_pos).replace(',', ' ')
|
||||
|
||||
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):
|
||||
self.label_str = self.tick_to_string(point_view.y())
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
"""
|
||||
High level Qt chart widgets.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
|
||||
import trio
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
@ -8,12 +10,12 @@ from pyqtgraph import functions as fn
|
|||
from PyQt5 import QtCore, QtGui
|
||||
|
||||
from ._axes import (
|
||||
FromTimeFieldDateAxis,
|
||||
DynamicDateAxis,
|
||||
PriceAxis,
|
||||
)
|
||||
from ._graphics import CrossHairItem, ChartType
|
||||
from ._style import _xaxis_at
|
||||
from ._source import Symbol, ohlc_zeros
|
||||
from ._source import Symbol
|
||||
|
||||
|
||||
# margins
|
||||
|
@ -85,9 +87,13 @@ class ChartSpace(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_brush = pg.mkBrush('#00ff00')
|
||||
short_pen = pg.mkPen('#600000')
|
||||
|
@ -98,13 +104,21 @@ class LinkedSplitCharts(QtGui.QWidget):
|
|||
def __init__(self):
|
||||
super().__init__()
|
||||
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.xaxis = FromTimeFieldDateAxis(orientation='bottom', splitter=self)
|
||||
# self.xaxis = pg.DateAxisItem()
|
||||
self.xaxis = DynamicDateAxis(
|
||||
orientation='bottom', linked_charts=self)
|
||||
|
||||
self.xaxis_ind = FromTimeFieldDateAxis(
|
||||
orientation='bottom', splitter=self)
|
||||
self.xaxis_ind = DynamicDateAxis(
|
||||
orientation='bottom', linked_charts=self)
|
||||
|
||||
if _xaxis_at == 'bottom':
|
||||
self.xaxis.setStyle(showValues=False)
|
||||
|
@ -119,24 +133,36 @@ class LinkedSplitCharts(QtGui.QWidget):
|
|||
|
||||
self.layout.addWidget(self.splitter)
|
||||
|
||||
def _update_sizes(self):
|
||||
min_h_ind = int(self.height() * 0.2 / len(self.indicators))
|
||||
sizes = [int(self.height() * 0.8)]
|
||||
def set_split_sizes(
|
||||
self,
|
||||
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))
|
||||
self.splitter.setSizes(sizes) # , int(self.height()*0.2)
|
||||
|
||||
def plot(
|
||||
self,
|
||||
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()
|
||||
|
||||
# XXX: this may eventually be a view onto shared mem
|
||||
# or some higher level type / API
|
||||
self._array = array
|
||||
|
||||
cv = ChartView()
|
||||
self.chart = ChartPlotWidget(
|
||||
split_charts=self,
|
||||
linked_charts=self,
|
||||
parent=self.splitter,
|
||||
axisItems={'bottom': self.xaxis, 'right': PriceAxis()},
|
||||
viewBox=cv,
|
||||
|
@ -144,52 +170,96 @@ class LinkedSplitCharts(QtGui.QWidget):
|
|||
)
|
||||
# TODO: ``pyqtgraph`` doesn't pass through a parent to the
|
||||
# ``PlotItem`` by default; maybe we should PR this in?
|
||||
cv.splitter_widget = self
|
||||
self.chart.plotItem.vb.splitter_widget = self
|
||||
cv.linked_charts = self
|
||||
self.chart.plotItem.vb.linked_charts = self
|
||||
|
||||
self.chart.getPlotItem().setContentsMargins(*CHART_MARGINS)
|
||||
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
|
||||
# XXX: note, if this isn't index aligned with
|
||||
# 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()
|
||||
ind_chart = ChartPlotWidget(
|
||||
split_charts=self,
|
||||
linked_charts=self,
|
||||
parent=self.splitter,
|
||||
axisItems={'bottom': self.xaxis_ind, 'right': PriceAxis()},
|
||||
# axisItems={'top': self.xaxis_ind, 'right': PriceAxis()},
|
||||
viewBox=cv,
|
||||
)
|
||||
cv.splitter_widget = self
|
||||
self.chart.plotItem.vb.splitter_widget = self
|
||||
# this name will be used to register the primary
|
||||
# graphics curve managed by the subchart
|
||||
ind_chart.name = name
|
||||
cv.linked_charts = self
|
||||
self.chart.plotItem.vb.linked_charts = self
|
||||
|
||||
ind_chart.setFrameStyle(
|
||||
QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain
|
||||
)
|
||||
ind_chart.getPlotItem().setContentsMargins(*CHART_MARGINS)
|
||||
# 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
|
||||
ind_chart.setXLink(self.chart)
|
||||
|
||||
# XXX: never do this lol
|
||||
# ind.setAspectLocked(1)
|
||||
ind_chart.draw_curve(d)
|
||||
# draw curve graphics
|
||||
ind_chart.draw_curve(data, name)
|
||||
|
||||
self._update_sizes()
|
||||
self.set_split_sizes()
|
||||
|
||||
ch = CrossHairItem(
|
||||
self.chart, [_ind for _ind, d in self.indicators], self.digits
|
||||
ch = self._ch = CrossHairItem(
|
||||
self.chart,
|
||||
[_ind for _ind, d in self.indicators],
|
||||
self.digits
|
||||
)
|
||||
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_bars_in_view = 10
|
||||
|
@ -198,12 +268,14 @@ _min_bars_in_view = 10
|
|||
class ChartPlotWidget(pg.PlotWidget):
|
||||
"""``GraphicsView`` subtype containing a single ``PlotItem``.
|
||||
|
||||
Overrides a ``pyqtgraph.PlotWidget`` (a ``GraphicsView`` containing
|
||||
a single ``PlotItem``) to intercept and and re-emit mouse enter/exit
|
||||
events.
|
||||
- The added methods allow for plotting OHLC sequences from
|
||||
``np.recarray``s with appropriate field names.
|
||||
- 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
|
||||
eventually want multiple plots managed together).
|
||||
eventually want multiple plots managed together?)
|
||||
"""
|
||||
sig_mouse_leave = QtCore.Signal(object)
|
||||
sig_mouse_enter = QtCore.Signal(object)
|
||||
|
@ -213,29 +285,29 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
split_charts,
|
||||
linked_charts,
|
||||
**kwargs,
|
||||
# parent=None,
|
||||
# background='default',
|
||||
# plotItem=None,
|
||||
# **kargs
|
||||
):
|
||||
"""Configure chart display settings.
|
||||
"""
|
||||
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?
|
||||
# likely custom graphics need special handling
|
||||
|
||||
# label = pg.LabelItem(justify='left')
|
||||
# self.addItem(label)
|
||||
# label.setText("Yo yoyo")
|
||||
# label.setText("<span style='font-size: 12pt'>x=")
|
||||
self.parent = split_charts
|
||||
|
||||
# placeholder for source of data
|
||||
self._array = ohlc_zeros(1)
|
||||
|
||||
# to be filled in when data is loaded
|
||||
# to be filled in when graphics are rendered
|
||||
# by name
|
||||
self._graphics = {}
|
||||
|
||||
# show only right side axes
|
||||
|
@ -251,40 +323,32 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
self.setCursor(QtCore.Qt.CrossCursor)
|
||||
|
||||
# assign callback for rescaling y-axis automatically
|
||||
# based on y-range contents
|
||||
self.sigXRangeChanged.connect(self._update_yrange_limits)
|
||||
# based on ohlc contents
|
||||
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
|
||||
|
||||
# set panning limits
|
||||
# last = data[-1]['id']
|
||||
self.setLimits(
|
||||
# xMin=data[0]['id'],
|
||||
xMin=xfirst,
|
||||
# xMax=last + _min_points_to_show - 3,
|
||||
xMax=xlast + _min_points_to_show - 3,
|
||||
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):
|
||||
"""Return a range tuple for the bars present in view.
|
||||
"""
|
||||
vr = self.viewRect()
|
||||
lbar = int(vr.left())
|
||||
rbar = int(min(vr.right(), len(self._array) - 1))
|
||||
rbar = int(vr.right())
|
||||
return lbar, rbar
|
||||
|
||||
def draw_ohlc(
|
||||
|
@ -301,35 +365,55 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
# adds all bar/candle graphics objects for each data point in
|
||||
# the np array buffer to be drawn on next render cycle
|
||||
graphics.draw_from_data(data)
|
||||
self._graphics['ohlc'] = graphics
|
||||
self._graphics['main'] = graphics
|
||||
self.addItem(graphics)
|
||||
self._array = data
|
||||
|
||||
# update view limits
|
||||
self.set_view_limits(
|
||||
data[0]['index'],
|
||||
data[-1]['index'],
|
||||
data['low'].min(),
|
||||
data['high'].max()
|
||||
)
|
||||
# set xrange limits
|
||||
self._xlast = xlast = data[-1]['index']
|
||||
self._set_xlimits(data[0]['index'], xlast)
|
||||
|
||||
# show last 50 points on startup
|
||||
self.plotItem.vb.setXRange(xlast - 50, xlast + 50)
|
||||
|
||||
return graphics
|
||||
|
||||
def draw_curve(
|
||||
self,
|
||||
data: np.ndarray,
|
||||
name: Optional[str] = None,
|
||||
) -> None:
|
||||
# draw the indicator as a plain curve
|
||||
curve = pg.PlotDataItem(data, antialias=True)
|
||||
self.addItem(curve)
|
||||
|
||||
# update view limits
|
||||
self.set_view_limits(0, len(data)-1, data.min(), data.max())
|
||||
self._array = data
|
||||
# register overlay curve with name
|
||||
if not self._graphics and name is None:
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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.enableAutoRange(x=False, y=True)
|
||||
|
||||
# figure out x-range bars on screen
|
||||
lbar, rbar = self.bars_range()
|
||||
|
||||
# if chart_parent.signals_visible:
|
||||
# chart_parent._show_text_signals(lbar, rbar)
|
||||
|
||||
bars = self._array[lbar:rbar]
|
||||
# TODO: this should be some kind of numpy view api
|
||||
bars = self.parent._array[lbar:rbar]
|
||||
if not len(bars):
|
||||
# likely no data loaded yet
|
||||
return
|
||||
|
@ -389,22 +472,21 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
class ChartView(pg.ViewBox):
|
||||
"""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
|
||||
- no vertical scrolling
|
||||
- zoom to a "fixed point" on the y-axis
|
||||
- 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)
|
||||
# disable vertical scrolling
|
||||
self.setMouseEnabled(x=True, y=False)
|
||||
self.splitter_widget = None
|
||||
self.linked_charts = None
|
||||
|
||||
def wheelEvent(self, ev, axis=None):
|
||||
"""Override "center-point" location for scrolling.
|
||||
|
@ -422,13 +504,12 @@ class ChartView(pg.ViewBox):
|
|||
mask = self.state['mouseEnabled'][:]
|
||||
|
||||
# don't zoom more then the min points setting
|
||||
lbar, rbar = self.splitter_widget.chart.bars_range()
|
||||
# breakpoint()
|
||||
lbar, rbar = self.linked_charts.chart.bars_range()
|
||||
if ev.delta() >= 0 and rbar - lbar <= _min_points_to_show:
|
||||
return
|
||||
|
||||
# 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]
|
||||
|
||||
# center = pg.Point(
|
||||
|
@ -473,26 +554,27 @@ def main(symbol):
|
|||
quotes = from_df(quotes)
|
||||
|
||||
# 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
|
||||
nums = itertools.cycle([315., 320., 325., 310., 3])
|
||||
|
||||
def gen_nums():
|
||||
for i in itertools.count():
|
||||
yield quotes[-1].close + i
|
||||
yield quotes[-1].close - i
|
||||
|
||||
chart = splitter_chart.chart
|
||||
while True:
|
||||
yield quotes[-1].close + 1
|
||||
|
||||
nums = gen_nums()
|
||||
|
||||
await trio.sleep(10)
|
||||
while True:
|
||||
await trio.sleep(0.1)
|
||||
new = next(nums)
|
||||
quotes[-1].close = new
|
||||
chart._graphics['ohlc'].update_last_bar({'last': new})
|
||||
|
||||
# LOL this clearly isn't catching edge cases
|
||||
chart._update_yrange_limits()
|
||||
# this updates the linked_charts internal array
|
||||
# and then passes that array to all subcharts to
|
||||
# render downstream graphics
|
||||
linked_charts.update_from_quote({'last': new})
|
||||
await trio.sleep(.1)
|
||||
|
||||
await trio.sleep_forever()
|
||||
|
||||
|
|
Loading…
Reference in New Issue