Add "contents" labels to charts

Add a default "contents label" (eg. OHLC values for bar charts) to each
chart and update on crosshair interaction.

Few technical changes to make this happen:
- adjust bar graphics to have the HL line be in the "middle" of the
  underlying arrays' "index range" in the containing view.
- add a label dict each chart's graphics name to a label + update routine
- use symbol names instead of this "main" identifier crap for referring to
  particular price curves/graphics
bar_select
Tyler Goodlet 2020-08-26 14:15:52 -04:00
parent 61e460a422
commit f46fa99a6e
2 changed files with 120 additions and 59 deletions

View File

@ -1,7 +1,7 @@
""" """
High level Qt chart widgets. High level Qt chart widgets.
""" """
from typing import Optional, Tuple, Dict, Any from typing import Tuple, Dict, Any
import time import time
from PyQt5 import QtCore, QtGui from PyQt5 import QtCore, QtGui
@ -172,14 +172,15 @@ class LinkedSplitCharts(QtGui.QWidget):
# add crosshairs # add crosshairs
self._ch = CrossHair( self._ch = CrossHair(
parent=self, linkedsplitcharts=self,
digits=self.digits digits=self.digits
) )
self.chart = self.add_plot( self.chart = self.add_plot(
name='main', name=symbol.key,
array=array, array=array,
xaxis=self.xaxis, xaxis=self.xaxis,
ohlc=True, ohlc=True,
_is_main=True,
) )
# add crosshair graphic # add crosshair graphic
self.chart.addItem(self._ch) self.chart.addItem(self._ch)
@ -195,12 +196,13 @@ class LinkedSplitCharts(QtGui.QWidget):
array: np.ndarray, array: np.ndarray,
xaxis: DynamicDateAxis = None, xaxis: DynamicDateAxis = None,
ohlc: bool = False, ohlc: bool = False,
_is_main: bool = False,
) -> 'ChartPlotWidget': ) -> 'ChartPlotWidget':
"""Add (sub)plots to chart widget by name. """Add (sub)plots to chart widget by name.
If ``name`` == ``"main"`` the chart will be the the primary view. If ``name`` == ``"main"`` the chart will be the the primary view.
""" """
if self.chart is None and name != 'main': if self.chart is None and not _is_main:
raise RuntimeError( raise RuntimeError(
"A main plot must be created first with `.plot_main()`") "A main plot must be created first with `.plot_main()`")
@ -230,14 +232,14 @@ class LinkedSplitCharts(QtGui.QWidget):
# draw curve graphics # draw curve graphics
if ohlc: if ohlc:
cpw.draw_ohlc(array) cpw.draw_ohlc(name, array)
else: else:
cpw.draw_curve(array) cpw.draw_curve(name, array)
# add to cross-hair's known plots # add to cross-hair's known plots
self._ch.add_plot(cpw) self._ch.add_plot(cpw)
if name != "main": if not _is_main:
# track by name # track by name
self.subplots[name] = cpw self.subplots[name] = cpw
@ -279,13 +281,7 @@ class ChartPlotWidget(pg.PlotWidget):
super().__init__(**kwargs) super().__init__(**kwargs)
self._array = array # readonly view of data self._array = array # readonly view of data
self._graphics = {} # registry of underlying graphics self._graphics = {} # registry of underlying graphics
self._labels = {} # registry of underlying graphics
# XXX: label setting doesn't seem to work?
# likely custom graphics need special handling
# label = pg.LabelItem(justify='right')
# self.addItem(label)
# label.setText("Yo yoyo")
# label.setText("<span style='font-size: 12pt'>x=")
# show only right side axes # show only right side axes
self.hideAxis('left') self.hideAxis('left')
@ -303,6 +299,11 @@ class ChartPlotWidget(pg.PlotWidget):
# based on ohlc contents # based on ohlc contents
self.sigXRangeChanged.connect(self._set_yrange) self.sigXRangeChanged.connect(self._set_yrange)
def _update_contents_label(self, index: int) -> None:
if index > 0 and index < len(self._array):
for name, (label, update) in self._labels.items():
update(index)
def _set_xlimits( def _set_xlimits(
self, self,
xfirst: int, xfirst: int,
@ -331,6 +332,7 @@ class ChartPlotWidget(pg.PlotWidget):
def draw_ohlc( def draw_ohlc(
self, self,
name: str,
data: np.ndarray, data: np.ndarray,
# XXX: pretty sure this is dumb and we don't need an Enum # XXX: pretty sure this is dumb and we don't need an Enum
style: pg.GraphicsObject = BarItems, style: pg.GraphicsObject = BarItems,
@ -345,7 +347,25 @@ class ChartPlotWidget(pg.PlotWidget):
graphics.draw_from_data(data) graphics.draw_from_data(data)
self.addItem(graphics) self.addItem(graphics)
self._graphics['ohlc_main'] = graphics self._graphics[name] = graphics
# XXX: How to stack labels vertically?
label = pg.LabelItem(
justify='left',
size='5pt',
)
self.scene().addItem(label)
def update(index: int) -> None:
label.setText(
"{name} O:{} H:{} L:{} C:{} V:{}".format(
*self._array[index].item()[2:],
name=name,
)
)
self._labels[name] = (label, update)
self._update_contents_label(index=-1)
# set xrange limits # set xrange limits
xlast = data[-1]['index'] xlast = data[-1]['index']
@ -357,8 +377,8 @@ class ChartPlotWidget(pg.PlotWidget):
def draw_curve( def draw_curve(
self, self,
name: str,
data: np.ndarray, data: np.ndarray,
name: Optional[str] = 'line_main',
) -> pg.PlotDataItem: ) -> pg.PlotDataItem:
# draw the indicator as a plain curve # draw the indicator as a plain curve
curve = pg.PlotDataItem( curve = pg.PlotDataItem(
@ -371,10 +391,24 @@ class ChartPlotWidget(pg.PlotWidget):
# register overlay curve with name # register overlay curve with name
if not self._graphics and name is None: if not self._graphics and name is None:
name = 'line_main' name = 'a_line_bby'
self._graphics[name] = curve self._graphics[name] = curve
# XXX: How to stack labels vertically?
label = pg.LabelItem(
justify='left',
size='5pt',
)
self.scene().addItem(label)
def update(index: int) -> None:
data = self._array[index]
label.setText(f"{name}: {index} {data}")
self._labels[name] = (label, update)
self._update_contents_label(index=-1)
# set a "startup view" # set a "startup view"
xlast = len(data) - 1 xlast = len(data) - 1
@ -531,29 +565,28 @@ async def add_new_bars(delay_s, linked_charts):
) )
# read value at "open" of bar # read value at "open" of bar
last_quote = ohlc[-1] # last_quote = ohlc[-1]
# XXX: If the last bar has not changed print a flat line and
# move to the next. This is a "animation" choice that we may not
# keep.
# if last_quote == ohlc[-1]:
# log.debug("Printing flat line for {sym}")
# update chart graphics and resize view
price_chart.update_from_array(price_chart.name, ohlc)
price_chart._set_yrange()
for name, chart in linked_charts.subplots.items():
chart.update_from_array(chart.name, chart._array)
chart._set_yrange()
# We **don't** update the bar right now # We **don't** update the bar right now
# since the next quote that arrives should in the # since the next quote that arrives should in the
# tick streaming task # tick streaming task
await sleep() await sleep()
# XXX: If the last bar has not changed print a flat line and # TODO: should we update a graphics again time here?
# move to the next. This is a "animation" choice that we may not # Think about race conditions with data update task.
# keep.
if last_quote == ohlc[-1]:
log.debug("Printing flat line for {sym}")
price_chart.update_from_array('ohlc_main', ohlc)
# resize view
price_chart._set_yrange()
for name, chart in linked_charts.subplots.items():
chart.update_from_array('line_main', chart._array)
# resize view
chart._set_yrange()
async def _async_main( async def _async_main(
@ -639,9 +672,10 @@ async def _async_main(
last, last,
) )
chart.update_from_array( chart.update_from_array(
'ohlc_main', chart.name,
chart._array, chart._array,
) )
chart._set_yrange()
async def chart_from_fsp( async def chart_from_fsp(
@ -676,7 +710,7 @@ async def chart_from_fsp(
stream = await portal.result() stream = await portal.result()
# receive processed historical data-array as first message # receive processed historical data-array as first message
history = (await stream.__anext__()) history: np.ndarray = (await stream.__anext__())
# TODO: enforce type checking here # TODO: enforce type checking here
newbars = np.array(history) newbars = np.array(history)
@ -686,10 +720,19 @@ async def chart_from_fsp(
array=newbars, array=newbars,
) )
# update sub-plot graphics # check for data length mis-allignment and fill missing values
diff = len(chart._array) - len(linked_charts.chart._array)
if diff < 0:
data = chart._array
chart._array = np.append(
data,
np.full(abs(diff), data[-1], dtype=data.dtype)
)
# update chart graphics
async for value in stream: async for value in stream:
chart._array[-1] = value chart._array[-1] = value
chart.update_from_array('line_main', chart._array) chart.update_from_array(chart.name, chart._array)
chart._set_yrange() chart._set_yrange()

View File

@ -2,7 +2,6 @@
Chart graphics for displaying a slew of different data types. Chart graphics for displaying a slew of different data types.
""" """
from typing import List from typing import List
from enum import Enum
from itertools import chain from itertools import chain
import numpy as np import numpy as np
@ -23,10 +22,14 @@ _mouse_rate_limit = 30
class CrossHair(pg.GraphicsObject): class CrossHair(pg.GraphicsObject):
def __init__(self, parent, digits: int = 0): def __init__(
self,
linkedsplitcharts: 'LinkedSplitCharts',
digits: int = 0
) -> None:
super().__init__() super().__init__()
self.pen = pg.mkPen('#a9a9a9') # gray? self.pen = pg.mkPen('#a9a9a9') # gray?
self.parent = parent self.lsc = linkedsplitcharts
self.graphics = {} self.graphics = {}
self.plots = [] self.plots = []
self.active_plot = None self.active_plot = None
@ -110,18 +113,27 @@ class CrossHair(pg.GraphicsObject):
# mouse was not on active plot # mouse was not on active plot
return return
self.graphics[self.active_plot]['hl'].setY( x, y = mouse_point.x(), mouse_point.y()
mouse_point.y()
) plot = self.active_plot
self.graphics[plot]['hl'].setY(y)
self.graphics[self.active_plot]['yl'].update_label( self.graphics[self.active_plot]['yl'].update_label(
evt_post=pos, point_view=mouse_point evt_post=pos, point_view=mouse_point
) )
# move the vertical line to the current x coordinate in all charts for plot, opts in self.graphics.items():
for opts in self.graphics.values(): # move the vertical line to the current x
opts['vl'].setX(mouse_point.x()) opts['vl'].setX(x)
# update the chart's "contents" label
plot._update_contents_label(int(x))
# update the label on the bottom of the crosshair # update the label on the bottom of the crosshair
self.xaxis_label.update_label(evt_post=pos, point_view=mouse_point) self.xaxis_label.update_label(
evt_post=pos,
point_view=mouse_point
)
def boundingRect(self): def boundingRect(self):
try: try:
@ -149,8 +161,8 @@ def _mk_lines_array(data: List, size: int) -> np.ndarray:
def bars_from_ohlc( def bars_from_ohlc(
data: np.ndarray, data: np.ndarray,
w: float,
start: int = 0, start: int = 0,
w: float = 0.43,
) -> np.ndarray: ) -> np.ndarray:
"""Generate an array of lines objects from input ohlc data. """Generate an array of lines objects from input ohlc data.
""" """
@ -160,9 +172,15 @@ def bars_from_ohlc(
open, high, low, close, index = q[ open, high, low, close, index = q[
['open', 'high', 'low', 'close', 'index']] ['open', 'high', 'low', 'close', 'index']]
# place the x-coord start as "middle" of the drawing range such
# that the open arm line-graphic is at the left-most-side of
# the indexe's range according to the view mapping.
index_start = index + w
# high - low line # high - low line
if low != high: if low != high:
hl = QLineF(index, low, index, high) # hl = QLineF(index, low, index, high)
hl = QLineF(index_start, low, index_start, high)
else: else:
# XXX: if we don't do it renders a weird rectangle? # XXX: if we don't do it renders a weird rectangle?
# see below too for handling this later... # see below too for handling this later...
@ -170,9 +188,9 @@ def bars_from_ohlc(
hl._flat = True hl._flat = True
# open line # open line
o = QLineF(index - w, open, index, open) o = QLineF(index_start - w, open, index_start, open)
# close line # close line
c = QLineF(index + w, close, index, close) c = QLineF(index_start + w, close, index_start, close)
# indexing here is as per the below comments # indexing here is as per the below comments
# lines[3*i:3*i+3] = (hl, o, c) # lines[3*i:3*i+3] = (hl, o, c)
@ -207,7 +225,7 @@ class BarItems(pg.GraphicsObject):
sigPlotChanged = QtCore.Signal(object) sigPlotChanged = QtCore.Signal(object)
# 0.5 is no overlap between arms, 1.0 is full overlap # 0.5 is no overlap between arms, 1.0 is full overlap
w: float = 0.4 w: float = 0.43
bull_pen = pg.mkPen('#808080') bull_pen = pg.mkPen('#808080')
# XXX: tina mode, see below # XXX: tina mode, see below
@ -234,7 +252,7 @@ class BarItems(pg.GraphicsObject):
): ):
"""Draw OHLC datum graphics from a ``np.recarray``. """Draw OHLC datum graphics from a ``np.recarray``.
""" """
lines = bars_from_ohlc(data, start=start) lines = bars_from_ohlc(data, self.w, start=start)
# save graphics for later reference and keep track # save graphics for later reference and keep track
# of current internal "last index" # of current internal "last index"
@ -280,7 +298,7 @@ class BarItems(pg.GraphicsObject):
if extra > 0: if extra > 0:
# generate new graphics to match provided array # generate new graphics to match provided array
new = array[index:index + extra] new = array[index:index + extra]
lines = bars_from_ohlc(new) lines = bars_from_ohlc(new, self.w)
bars_added = len(lines) bars_added = len(lines)
self.lines[index:index + bars_added] = lines self.lines[index:index + bars_added] = lines
self.index += bars_added self.index += bars_added
@ -311,7 +329,7 @@ class BarItems(pg.GraphicsObject):
# if the bar was flat it likely does not have # if the bar was flat it likely does not have
# the index set correctly due to a rendering bug # the index set correctly due to a rendering bug
# see above # see above
body.setLine(i, low, i, high) body.setLine(i + self.w, low, i + self.w, high)
body._flat = False body._flat = False
else: else:
body.setLine(body.x1(), low, body.x2(), high) body.setLine(body.x1(), low, body.x2(), high)