Add "follow mode"

Makes the chart act like tws where each new time step increment the
chart shifts to the right so that the last bar stays in place. This
gets things looking like a proper auto-trading UX.

Added a couple methods to ``ChartPlotWidget`` to make this work:
- ``.default_view()`` to set the preferred view based on user settings
- ``.increment_view()`` to shift the view one time frame right

Also, split up the `.update_from_array()` method to be curve/ohlc
specific allowing for passing in a struct array with a named field
containing curve data more straightforwardly. This also simplifies the
contest label update functions.
bar_select
Tyler Goodlet 2020-10-20 08:43:51 -04:00
parent 1902507703
commit 88583d999a
3 changed files with 95 additions and 58 deletions

View File

@ -253,7 +253,7 @@ class YSticky(YAxisLabel):
else: else:
# non-ohlc case # non-ohlc case
index = len(a) - 1 index = len(a) - 1
last = a[-1] last = a[chart.name][-1]
self.update_from_data( self.update_from_data(
index, index,
last, last,

View File

@ -16,8 +16,10 @@ from ._axes import (
from ._graphics import CrossHair, BarItems, h_line from ._graphics import CrossHair, BarItems, h_line
from ._axes import YSticky from ._axes import YSticky
from ._style import ( from ._style import (
_xaxis_at, _min_points_to_show, hcolor, hcolor,
CHART_MARGINS, CHART_MARGINS,
_xaxis_at,
_min_points_to_show,
_bars_from_right_in_follow_mode, _bars_from_right_in_follow_mode,
_bars_to_left_in_follow_mode, _bars_to_left_in_follow_mode,
# _font, # _font,
@ -155,7 +157,7 @@ class LinkedSplitCharts(QtGui.QWidget):
def set_split_sizes( def set_split_sizes(
self, self,
prop: float = 0.25 # proportion allocated to consumer subcharts prop: float = 0.28 # proportion allocated to consumer subcharts
) -> None: ) -> None:
"""Set the proportion of space allocated for linked subcharts. """Set the proportion of space allocated for linked subcharts.
""" """
@ -306,6 +308,7 @@ class ChartPlotWidget(pg.PlotWidget):
self._ysticks = {} # registry of underlying graphics self._ysticks = {} # registry of underlying graphics
self._vb = self.plotItem.vb 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'
# show only right side axes # show only right side axes
self.hideAxis('left') self.hideAxis('left')
@ -314,9 +317,6 @@ class ChartPlotWidget(pg.PlotWidget):
# show background grid # show background grid
self.showGrid(x=True, y=True, alpha=0.4) self.showGrid(x=True, y=True, alpha=0.4)
# don't need right?
# self._vb.setXRange(0, 0)
# use cross-hair for cursor? # use cross-hair for cursor?
# self.setCursor(QtCore.Qt.CrossCursor) # self.setCursor(QtCore.Qt.CrossCursor)
@ -330,6 +330,9 @@ class ChartPlotWidget(pg.PlotWidget):
# for when the splitter(s) are resized # for when the splitter(s) are resized
self._vb.sigResized.connect(self._set_yrange) self._vb.sigResized.connect(self._set_yrange)
def last_bar_in_view(self) -> bool:
self._array[-1]['index']
def _update_contents_label(self, index: int) -> None: def _update_contents_label(self, index: int) -> None:
if index >= 0 and index < len(self._array): if index >= 0 and index < len(self._array):
for name, (label, update) in self._labels.items(): for name, (label, update) in self._labels.items():
@ -356,6 +359,8 @@ class ChartPlotWidget(pg.PlotWidget):
def bars_range(self) -> Tuple[int, int, int, int]: def bars_range(self) -> Tuple[int, int, int, int]:
"""Return a range tuple for the bars present in view. """Return a range tuple for the bars present in view.
""" """
# vr = self.viewRect()
# l, r = int(vr.left()), int(vr.right())
l, r = self.view_range() l, r = self.view_range()
lbar = max(l, 0) lbar = max(l, 0)
rbar = min(r, len(self._array)) rbar = min(r, len(self._array))
@ -405,19 +410,43 @@ class ChartPlotWidget(pg.PlotWidget):
self._update_contents_label(len(data) - 1) self._update_contents_label(len(data) - 1)
label.show() label.show()
# set xrange limits
xlast = data[-1]['index']
# show last 50 points on startup
self.plotItem.vb.setXRange(
xlast - _bars_to_left_in_follow_mode,
xlast + _bars_from_right_in_follow_mode
)
self._add_sticky(name) self._add_sticky(name)
return graphics return graphics
def default_view(
self,
index: int = -1,
) -> None:
"""Set the view box to the "default" startup view of the scene.
"""
xlast = self._array[index]['index']
begin = xlast - _bars_to_left_in_follow_mode
end = xlast + _bars_from_right_in_follow_mode
self.plotItem.vb.setXRange(
min=begin,
max=end,
padding=0,
)
def increment_view(
self,
) -> None:
"""Increment the data view one step to the right thus "following"
the current time slot/step/bar.
"""
l, r = self.view_range()
self._vb.setXRange(
min=l + 1,
max=r + 1,
# holy shit, wtf dude... why tf would this not be 0 by
# default... speechless.
padding=0,
)
def draw_curve( def draw_curve(
self, self,
name: str, name: str,
@ -432,7 +461,7 @@ class ChartPlotWidget(pg.PlotWidget):
pdi_kwargs.update(_pdi_defaults) pdi_kwargs.update(_pdi_defaults)
curve = pg.PlotDataItem( curve = pg.PlotDataItem(
data, data[name],
# antialias=True, # antialias=True,
name=name, name=name,
# TODO: see how this handles with custom ohlcv bars graphics # TODO: see how this handles with custom ohlcv bars graphics
@ -459,39 +488,19 @@ class ChartPlotWidget(pg.PlotWidget):
label.anchor(itemPos=(1, 1), parentPos=(1, 1), offset=(0, 3)) label.anchor(itemPos=(1, 1), parentPos=(1, 1), offset=(0, 3))
self._overlays[name] = curve self._overlays[name] = curve
def update(index: int) -> None:
data = self._array[index][name]
label.setText(f"{name}: {data:.2f}")
else: else:
label.anchor(itemPos=(0, 0), parentPos=(0, 0), offset=(0, -4)) label.anchor(itemPos=(0, 0), parentPos=(0, 0), offset=(0, -4))
def update(index: int) -> None: def update(index: int) -> None:
data = self._array[index] data = self._array[index][name]
label.setText(f"{name}: {data:.2f}") label.setText(f"{name}: {data:.2f}")
# def update(index: int) -> None:
# data = self._array[index]
# label.setText(f"{name} -> {data:.2f}")
label.show() label.show()
self.scene().addItem(label) self.scene().addItem(label)
self._labels[name] = (label, update) self._labels[name] = (label, update)
self._update_contents_label(len(data) - 1) self._update_contents_label(len(data) - 1)
# set a "startup view"
xlast = len(data) - 1
# configure "follow mode" view on startup
self.plotItem.vb.setXRange(
xlast - _bars_to_left_in_follow_mode,
xlast + _bars_from_right_in_follow_mode
)
# TODO: we should instead implement a diff based
# "only update with new items" on the pg.PlotDataItem
curve.update_from_array = curve.setData
self._add_sticky(name) self._add_sticky(name)
return curve return curve
@ -511,20 +520,40 @@ class ChartPlotWidget(pg.PlotWidget):
) )
return last return last
def update_from_array( def update_ohlc_from_array(
self, self,
name: str, name: str,
array: np.ndarray, array: np.ndarray,
**kwargs, **kwargs,
) -> pg.GraphicsObject: ) -> pg.GraphicsObject:
"""Update the named internal graphics from ``array``.
"""
if name not in self._overlays: if name not in self._overlays:
self._array = array self._array = array
graphics = self._graphics[name] graphics = self._graphics[name]
graphics.update_from_array(array, **kwargs) graphics.update_from_array(array, **kwargs)
return graphics return graphics
def update_curve_from_array(
self,
name: str,
array: np.ndarray,
**kwargs,
) -> pg.GraphicsObject:
"""Update the named internal graphics from ``array``.
"""
if name not in self._overlays:
self._array = array
curve = self._graphics[name]
# TODO: we should instead implement a diff based
# "only update with new items" on the pg.PlotDataItem
curve.setData(array[name], **kwargs)
return curve
def _set_yrange( def _set_yrange(
self, self,
*, *,
@ -535,12 +564,8 @@ class ChartPlotWidget(pg.PlotWidget):
This adds auto-scaling like zoom on the scroll wheel such This adds auto-scaling like zoom on the scroll wheel such
that data always fits nicely inside the current view of the that data always fits nicely inside the current view of the
data set. data set.
""" """
# yrange
# if self._static_yrange is not None:
# yrange = self._static_yrange
if self._static_yrange is not None: if self._static_yrange is not None:
ylow, yhigh = self._static_yrange ylow, yhigh = self._static_yrange
@ -552,6 +577,7 @@ class ChartPlotWidget(pg.PlotWidget):
# the data set up to the point where ``_min_points_to_show`` # the data set up to the point where ``_min_points_to_show``
# are left. # are left.
view_len = r - l view_len = r - l
# TODO: logic to check if end of bars in view # TODO: logic to check if end of bars in view
extra = view_len - _min_points_to_show extra = view_len - _min_points_to_show
begin = 0 - extra begin = 0 - extra
@ -673,7 +699,7 @@ async def _async_main(
vwap_in_history = True vwap_in_history = True
chart.draw_curve( chart.draw_curve(
name='vwap', name='vwap',
data=bars['vwap'], data=bars,
overlay=True, overlay=True,
) )
@ -751,6 +777,8 @@ async def chart_from_quotes(
last_bars_range, last_mx, last_mn = maxmin() last_bars_range, last_mx, last_mn = maxmin()
chart.default_view()
async for quotes in stream: async for quotes in stream:
for sym, quote in quotes.items(): for sym, quote in quotes.items():
for tick in iterticks(quote, type='trade'): for tick in iterticks(quote, type='trade'):
@ -761,7 +789,7 @@ async def chart_from_quotes(
last_price_sticky.update_from_data(*last[['index', 'close']]) last_price_sticky.update_from_data(*last[['index', 'close']])
# update price bar # update price bar
chart.update_from_array( chart.update_ohlc_from_array(
chart.name, chart.name,
array, array,
) )
@ -778,7 +806,7 @@ async def chart_from_quotes(
if vwap_in_history: if vwap_in_history:
# update vwap overlay line # update vwap overlay line
chart.update_from_array('vwap', ohlcv.array['vwap']) chart.update_curve_from_array('vwap', ohlcv.array)
# TODO: # TODO:
# - eventually we'll want to update bid/ask labels and # - eventually we'll want to update bid/ask labels and
@ -847,7 +875,10 @@ async def chart_from_fsp(
chart = linked_charts.add_plot( chart = linked_charts.add_plot(
name=fsp_func_name, name=fsp_func_name,
array=shm.array[fsp_func_name], array=shm.array,
# curve by default
ohlc=False,
# settings passed down to ``ChartPlotWidget`` # settings passed down to ``ChartPlotWidget``
static_yrange=(0, 100), static_yrange=(0, 100),
@ -862,7 +893,8 @@ async def chart_from_fsp(
last_val_sticky = chart._ysticks[chart.name] last_val_sticky = chart._ysticks[chart.name]
last_val_sticky.update_from_data(-1, value) last_val_sticky.update_from_data(-1, value)
chart.update_from_array(chart.name, array[fsp_func_name]) chart.update_curve_from_array(fsp_func_name, array)
chart.default_view()
# TODO: figure out if we can roll our own `FillToThreshold` to # TODO: figure out if we can roll our own `FillToThreshold` to
# get brush filled polygons for OS/OB conditions. # get brush filled polygons for OS/OB conditions.
@ -887,7 +919,7 @@ async def chart_from_fsp(
array = shm.array array = shm.array
value = array[-1][fsp_func_name] value = array[-1][fsp_func_name]
last_val_sticky.update_from_data(-1, value) last_val_sticky.update_from_data(-1, value)
chart.update_from_array(chart.name, array[fsp_func_name]) chart.update_curve_from_array(fsp_func_name, array)
# p('rendered rsi datum') # p('rendered rsi datum')
@ -901,11 +933,12 @@ async def check_for_new_bars(feed, ohlcv, linked_charts):
# aware of the instrument's tradable hours? # aware of the instrument's tradable hours?
price_chart = linked_charts.chart price_chart = linked_charts.chart
price_chart.default_view()
async for index in await feed.index_stream(): async for index in await feed.index_stream():
# update chart historical bars graphics # update chart historical bars graphics
price_chart.update_from_array( price_chart.update_ohlc_from_array(
price_chart.name, price_chart.name,
ohlcv.array, ohlcv.array,
# When appending a new bar, in the time between the insert # When appending a new bar, in the time between the insert
@ -922,15 +955,17 @@ async def check_for_new_bars(feed, ohlcv, linked_charts):
# TODO: standard api for signal lookups per plot # TODO: standard api for signal lookups per plot
if name in price_chart._array.dtype.fields: if name in price_chart._array.dtype.fields:
# should have already been incremented above # should have already been incremented above
price_chart.update_from_array( price_chart.update_curve_from_array(
name, name,
price_chart._array[name], price_chart._array,
) )
for name, chart in linked_charts.subplots.items(): for name, chart in linked_charts.subplots.items():
chart.update_from_array(chart.name, chart._shm.array[chart.name]) chart.update_curve_from_array(chart.name, chart._shm.array)
# chart._set_yrange() # chart._set_yrange()
price_chart.increment_view()
def _main( def _main(
sym: str, sym: str,

View File

@ -404,7 +404,7 @@ class BarItems(pg.GraphicsObject):
# @timeit # @timeit
def paint(self, p, opt, widget): def paint(self, p, opt, widget):
profiler = pg.debug.Profiler(disabled=False, delayed=False) # profiler = pg.debug.Profiler(disabled=False, delayed=False)
# TODO: use to avoid drawing artefacts? # TODO: use to avoid drawing artefacts?
# self.prepareGeometryChange() # self.prepareGeometryChange()
@ -425,7 +425,7 @@ class BarItems(pg.GraphicsObject):
# self._pmi.setPixmap(self.picture) # self._pmi.setPixmap(self.picture)
# print(self.scene()) # print(self.scene())
profiler('bars redraw:') # profiler('bars redraw:')
def boundingRect(self): def boundingRect(self):
# TODO: can we do rect caching to make this faster? # TODO: can we do rect caching to make this faster?
@ -482,7 +482,9 @@ class BarItems(pg.GraphicsObject):
def h_line(level: float) -> pg.InfiniteLine: def h_line(level: float) -> pg.InfiniteLine:
"""Convenience routine to add a styled horizontal line to a plot.
"""
line = pg.InfiniteLine( line = pg.InfiniteLine(
movable=True, movable=True,
angle=0, angle=0,