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
its_happening
Tyler Goodlet 2020-08-26 14:15:52 -04:00
parent bfdd2c43cc
commit 69aced7521
2 changed files with 120 additions and 59 deletions

View File

@ -1,7 +1,7 @@
"""
High level Qt chart widgets.
"""
from typing import Optional, Tuple, Dict, Any
from typing import Tuple, Dict, Any
import time
from PyQt5 import QtCore, QtGui
@ -172,14 +172,15 @@ class LinkedSplitCharts(QtGui.QWidget):
# add crosshairs
self._ch = CrossHair(
parent=self,
linkedsplitcharts=self,
digits=self.digits
)
self.chart = self.add_plot(
name='main',
name=symbol.key,
array=array,
xaxis=self.xaxis,
ohlc=True,
_is_main=True,
)
# add crosshair graphic
self.chart.addItem(self._ch)
@ -195,12 +196,13 @@ class LinkedSplitCharts(QtGui.QWidget):
array: np.ndarray,
xaxis: DynamicDateAxis = None,
ohlc: bool = False,
_is_main: bool = False,
) -> 'ChartPlotWidget':
"""Add (sub)plots to chart widget by name.
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(
"A main plot must be created first with `.plot_main()`")
@ -230,14 +232,14 @@ class LinkedSplitCharts(QtGui.QWidget):
# draw curve graphics
if ohlc:
cpw.draw_ohlc(array)
cpw.draw_ohlc(name, array)
else:
cpw.draw_curve(array)
cpw.draw_curve(name, array)
# add to cross-hair's known plots
self._ch.add_plot(cpw)
if name != "main":
if not _is_main:
# track by name
self.subplots[name] = cpw
@ -279,13 +281,7 @@ class ChartPlotWidget(pg.PlotWidget):
super().__init__(**kwargs)
self._array = array # readonly view of data
self._graphics = {} # 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=")
self._labels = {} # registry of underlying graphics
# show only right side axes
self.hideAxis('left')
@ -303,6 +299,11 @@ class ChartPlotWidget(pg.PlotWidget):
# based on ohlc contents
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(
self,
xfirst: int,
@ -331,6 +332,7 @@ class ChartPlotWidget(pg.PlotWidget):
def draw_ohlc(
self,
name: str,
data: np.ndarray,
# XXX: pretty sure this is dumb and we don't need an Enum
style: pg.GraphicsObject = BarItems,
@ -345,7 +347,25 @@ class ChartPlotWidget(pg.PlotWidget):
graphics.draw_from_data(data)
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
xlast = data[-1]['index']
@ -357,8 +377,8 @@ class ChartPlotWidget(pg.PlotWidget):
def draw_curve(
self,
name: str,
data: np.ndarray,
name: Optional[str] = 'line_main',
) -> pg.PlotDataItem:
# draw the indicator as a plain curve
curve = pg.PlotDataItem(
@ -371,10 +391,24 @@ class ChartPlotWidget(pg.PlotWidget):
# register overlay curve with name
if not self._graphics and name is None:
name = 'line_main'
name = 'a_line_bby'
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"
xlast = len(data) - 1
@ -531,29 +565,28 @@ async def add_new_bars(delay_s, linked_charts):
)
# 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
# since the next quote that arrives should in the
# tick streaming task
await sleep()
# 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}")
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()
# TODO: should we update a graphics again time here?
# Think about race conditions with data update task.
async def _async_main(
@ -639,9 +672,10 @@ async def _async_main(
last,
)
chart.update_from_array(
'ohlc_main',
chart.name,
chart._array,
)
chart._set_yrange()
async def chart_from_fsp(
@ -676,7 +710,7 @@ async def chart_from_fsp(
stream = await portal.result()
# receive processed historical data-array as first message
history = (await stream.__anext__())
history: np.ndarray = (await stream.__anext__())
# TODO: enforce type checking here
newbars = np.array(history)
@ -686,10 +720,19 @@ async def chart_from_fsp(
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:
chart._array[-1] = value
chart.update_from_array('line_main', chart._array)
chart.update_from_array(chart.name, chart._array)
chart._set_yrange()

View File

@ -2,7 +2,6 @@
Chart graphics for displaying a slew of different data types.
"""
from typing import List
from enum import Enum
from itertools import chain
import numpy as np
@ -23,10 +22,14 @@ _mouse_rate_limit = 30
class CrossHair(pg.GraphicsObject):
def __init__(self, parent, digits: int = 0):
def __init__(
self,
linkedsplitcharts: 'LinkedSplitCharts',
digits: int = 0
) -> None:
super().__init__()
self.pen = pg.mkPen('#a9a9a9') # gray?
self.parent = parent
self.lsc = linkedsplitcharts
self.graphics = {}
self.plots = []
self.active_plot = None
@ -110,18 +113,27 @@ class CrossHair(pg.GraphicsObject):
# mouse was not on active plot
return
self.graphics[self.active_plot]['hl'].setY(
mouse_point.y()
)
x, y = mouse_point.x(), mouse_point.y()
plot = self.active_plot
self.graphics[plot]['hl'].setY(y)
self.graphics[self.active_plot]['yl'].update_label(
evt_post=pos, point_view=mouse_point
)
# move the vertical line to the current x coordinate in all charts
for opts in self.graphics.values():
opts['vl'].setX(mouse_point.x())
for plot, opts in self.graphics.items():
# move the vertical line to the current 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
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):
try:
@ -149,8 +161,8 @@ def _mk_lines_array(data: List, size: int) -> np.ndarray:
def bars_from_ohlc(
data: np.ndarray,
w: float,
start: int = 0,
w: float = 0.43,
) -> np.ndarray:
"""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']]
# 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
if low != high:
hl = QLineF(index, low, index, high)
# hl = QLineF(index, low, index, high)
hl = QLineF(index_start, low, index_start, high)
else:
# XXX: if we don't do it renders a weird rectangle?
# see below too for handling this later...
@ -170,9 +188,9 @@ def bars_from_ohlc(
hl._flat = True
# open line
o = QLineF(index - w, open, index, open)
o = QLineF(index_start - w, open, index_start, open)
# 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
# lines[3*i:3*i+3] = (hl, o, c)
@ -207,7 +225,7 @@ class BarItems(pg.GraphicsObject):
sigPlotChanged = QtCore.Signal(object)
# 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')
# XXX: tina mode, see below
@ -234,7 +252,7 @@ class BarItems(pg.GraphicsObject):
):
"""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
# of current internal "last index"
@ -280,7 +298,7 @@ class BarItems(pg.GraphicsObject):
if extra > 0:
# generate new graphics to match provided array
new = array[index:index + extra]
lines = bars_from_ohlc(new)
lines = bars_from_ohlc(new, self.w)
bars_added = len(lines)
self.lines[index:index + bars_added] = lines
self.index += bars_added
@ -311,7 +329,7 @@ class BarItems(pg.GraphicsObject):
# if the bar was flat it likely does not have
# the index set correctly due to a rendering bug
# see above
body.setLine(i, low, i, high)
body.setLine(i + self.w, low, i + self.w, high)
body._flat = False
else:
body.setLine(body.x1(), low, body.x2(), high)