Move contents labels management to cursor mod

Add a new type/api to manage "contents labels" (labels that sit in
a view and display info about viewed data) since it's mostly used by
the linked charts cursor. Make `LinkedSplits.cursor` the new and only
instance var for the cursor such that charts can look it up from that
common class. Drop the `ChartPlotWidget._ohlc` array, just add
a `'ohlc'` entry to `._arrays`.
asyncify_input_modes
Tyler Goodlet 2021-06-21 16:45:27 -04:00
parent d3d5d4ad06
commit b6eeed1ae0
2 changed files with 164 additions and 164 deletions

View File

@ -20,7 +20,7 @@ High level Qt chart widgets.
""" """
import time import time
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from typing import Tuple, Dict, Any, Optional, Callable from typing import Tuple, Dict, Any, Optional
from types import ModuleType from types import ModuleType
from functools import partial from functools import partial
@ -261,7 +261,7 @@ class LinkedSplits(QtGui.QWidget):
super().__init__() super().__init__()
# self.signals_visible: bool = False # self.signals_visible: bool = False
self._cursor: Cursor = None # crosshair graphics self.cursor: Cursor = None # crosshair graphics
self.godwidget = godwidget self.godwidget = godwidget
self.chart: ChartPlotWidget = None # main (ohlc) chart self.chart: ChartPlotWidget = None # main (ohlc) chart
@ -326,7 +326,7 @@ class LinkedSplits(QtGui.QWidget):
The data input struct array must include OHLC fields. The data input struct array must include OHLC fields.
""" """
# add crosshairs # add crosshairs
self._cursor = Cursor( self.cursor = Cursor(
linkedsplits=self, linkedsplits=self,
digits=symbol.digits(), digits=symbol.digits(),
) )
@ -338,7 +338,7 @@ class LinkedSplits(QtGui.QWidget):
_is_main=True, _is_main=True,
) )
# add crosshair graphic # add crosshair graphic
self.chart.addItem(self._cursor) self.chart.addItem(self.cursor)
# axis placement # axis placement
if _xaxis_at == 'bottom': if _xaxis_at == 'bottom':
@ -392,7 +392,7 @@ class LinkedSplits(QtGui.QWidget):
'left': PriceAxis(linkedsplits=self, orientation='left'), 'left': PriceAxis(linkedsplits=self, orientation='left'),
}, },
viewBox=cv, viewBox=cv,
cursor=self._cursor, # cursor=self.cursor,
**cpw_kwargs, **cpw_kwargs,
) )
print(f'xaxis ps: {xaxis.pos()}') print(f'xaxis ps: {xaxis.pos()}')
@ -412,7 +412,7 @@ class LinkedSplits(QtGui.QWidget):
cpw.setXLink(self.chart) cpw.setXLink(self.chart)
# add to cross-hair's known plots # add to cross-hair's known plots
self._cursor.add_plot(cpw) self.cursor.add_plot(cpw)
# draw curve graphics # draw curve graphics
if style == 'bar': if style == 'bar':
@ -493,15 +493,19 @@ class ChartPlotWidget(pg.PlotWidget):
) )
self.name = name self.name = name
self._lc = linkedsplits self._lc = linkedsplits
self.linked = linkedsplits
# scene-local placeholder for book graphics # scene-local placeholder for book graphics
# sizing to avoid overlap with data contents # sizing to avoid overlap with data contents
self._max_l1_line_len: float = 0 self._max_l1_line_len: float = 0
# self.setViewportMargins(0, 0, 0, 0) # self.setViewportMargins(0, 0, 0, 0)
self._ohlc = array # readonly view of ohlc data # self._ohlc = array # readonly view of ohlc data
self._arrays = {} # readonly view of overlays # readonly view of data arrays
self._arrays = {
'ohlc': array,
}
self._graphics = {} # registry of underlying graphics self._graphics = {} # registry of underlying graphics
self._overlays = set() # registry of overlay curve names self._overlays = set() # registry of overlay curve names
@ -510,9 +514,7 @@ class ChartPlotWidget(pg.PlotWidget):
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' self._view_mode: str = 'follow'
self._cursor = cursor # placehold for mouse
# show only right side axes # show only right side axes
self.hideAxis('left') self.hideAxis('left')
@ -539,25 +541,10 @@ class ChartPlotWidget(pg.PlotWidget):
self._vb.setFocus() self._vb.setFocus()
def last_bar_in_view(self) -> int: def last_bar_in_view(self) -> int:
self._ohlc[-1]['index'] self._arrays['ohlc'][-1]['index']
def update_contents_labels( def is_valid_index(self, index: int) -> bool:
self, return index >= 0 and index < self._arrays['ohlc'][-1]['index']
index: int,
# array_name: str,
) -> None:
if index >= 0 and index < self._ohlc[-1]['index']:
for name, (label, update) in self._labels.items():
if name is self.name:
array = self._ohlc
else:
array = self._arrays[name]
try:
update(index, array)
except IndexError:
log.exception(f"Failed to update label: {name}")
def _set_xlimits( def _set_xlimits(
self, self,
@ -581,11 +568,11 @@ class ChartPlotWidget(pg.PlotWidget):
"""Return a range tuple for the bars present in view. """Return a range tuple for the bars present in view.
""" """
l, r = self.view_range() l, r = self.view_range()
a = self._ohlc a = self._arrays['ohlc']
lbar = max(l, a[0]['index']) lbar = max(l, a[0]['index'])
rbar = min(r, a[-1]['index']) rbar = min(r, a[-1]['index'])
# lbar = max(l, 0) # lbar = max(l, 0)
# rbar = min(r, len(self._ohlc)) # rbar = min(r, len(self._arrays['ohlc']))
return l, lbar, rbar, r return l, lbar, rbar, r
def default_view( def default_view(
@ -595,7 +582,7 @@ class ChartPlotWidget(pg.PlotWidget):
"""Set the view box to the "default" startup view of the scene. """Set the view box to the "default" startup view of the scene.
""" """
xlast = self._ohlc[index]['index'] xlast = self._arrays['ohlc'][index]['index']
begin = xlast - _bars_to_left_in_follow_mode begin = xlast - _bars_to_left_in_follow_mode
end = xlast + _bars_from_right_in_follow_mode end = xlast + _bars_from_right_in_follow_mode
@ -650,12 +637,12 @@ class ChartPlotWidget(pg.PlotWidget):
self._graphics[name] = graphics self._graphics[name] = graphics
self.add_contents_label( self.linked.cursor.contents_labels.add_label(
name, self,
'ohlc',
anchor_at=('top', 'left'), anchor_at=('top', 'left'),
update_func=ContentsLabel.update_from_ohlc, update_func=ContentsLabel.update_from_ohlc,
) )
self.update_contents_labels(len(data) - 1)
self._add_sticky(name) self._add_sticky(name)
@ -727,32 +714,18 @@ class ChartPlotWidget(pg.PlotWidget):
# (we need something that avoids clutter on x-axis). # (we need something that avoids clutter on x-axis).
self._add_sticky(name, bg_color='default_light') self._add_sticky(name, bg_color='default_light')
if add_label: if self.linked.cursor:
self.add_contents_label(name, anchor_at=anchor_at) self.linked.cursor.add_curve_cursor(self, curve)
self.update_contents_labels(len(data) - 1)
if self._cursor: if add_label:
self._cursor.add_curve_cursor(self, curve) self.linked.cursor.contents_labels.add_label(
self,
name,
anchor_at=anchor_at
)
return curve return curve
def add_contents_label(
self,
name: str,
anchor_at: Tuple[str, str] = ('top', 'left'),
update_func: Callable = ContentsLabel.update_from_value,
) -> ContentsLabel:
label = ContentsLabel(chart=self, anchor_at=anchor_at)
self._labels[name] = (
# calls class method on instance
label,
partial(update_func, label, name)
)
label.show()
return label
def _add_sticky( def _add_sticky(
self, self,
name: str, name: str,
@ -787,7 +760,7 @@ class ChartPlotWidget(pg.PlotWidget):
"""Update the named internal graphics from ``array``. """Update the named internal graphics from ``array``.
""" """
self._ohlc = array self._arrays['ohlc'] = 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
@ -803,7 +776,7 @@ class ChartPlotWidget(pg.PlotWidget):
""" """
if name not in self._overlays: if name not in self._overlays:
self._ohlc = array self._arrays['ohlc'] = array
else: else:
self._arrays[name] = array self._arrays[name] = array
@ -857,10 +830,10 @@ class ChartPlotWidget(pg.PlotWidget):
# 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 = self._ohlc[0]['index'] - extra # begin = self._arrays['ohlc'][0]['index'] - extra
# # end = len(self._ohlc) - 1 + extra # # end = len(self._arrays['ohlc']) - 1 + extra
# end = self._ohlc[-1]['index'] - 1 + extra # end = self._arrays['ohlc'][-1]['index'] - 1 + extra
# XXX: test code for only rendering lines for the bars in view. # XXX: test code for only rendering lines for the bars in view.
# This turns out to be very very poor perf when scaling out to # This turns out to be very very poor perf when scaling out to
@ -879,9 +852,9 @@ class ChartPlotWidget(pg.PlotWidget):
# self._set_xlimits(begin, end) # self._set_xlimits(begin, end)
# TODO: this should be some kind of numpy view api # TODO: this should be some kind of numpy view api
# bars = self._ohlc[lbar:rbar] # bars = self._arrays['ohlc'][lbar:rbar]
a = self._ohlc a = self._arrays['ohlc']
ifirst = a[0]['index'] ifirst = a[0]['index']
bars = a[lbar - ifirst:rbar - ifirst + 1] bars = a[lbar - ifirst:rbar - ifirst + 1]
@ -952,84 +925,6 @@ class ChartPlotWidget(pg.PlotWidget):
self.scene().leaveEvent(ev) self.scene().leaveEvent(ev)
async def test_bed(
ohlcv,
chart,
lc,
):
from ._graphics._lines import order_line
sleep = 6
# from PyQt5.QtCore import QPointF
vb = chart._vb
# scene = vb.scene()
# raxis = chart.getAxis('right')
# vb_right = vb.boundingRect().right()
last, i_end = ohlcv.array[-1][['close', 'index']]
line = order_line(
chart,
level=last,
level_digits=2
)
# eps = line.getEndpoints()
# llabel = line._labels[1][1]
line.update_labels({'level': last})
return
# rl = eps[1]
# rlabel.setPos(rl)
# ti = pg.TextItem(text='Fuck you')
# ti.setPos(pg.Point(i_end, last))
# ti.setParentItem(line)
# ti.setAnchor(pg.Point(1, 1))
# vb.addItem(ti)
# chart.plotItem.addItem(ti)
from ._label import Label
txt = Label(
vb,
fmt_str='fuck {it}',
)
txt.format(it='boy')
txt.place_on_scene('left')
txt.set_view_y(last)
# txt = QtGui.QGraphicsTextItem()
# txt.setPlainText("FUCK YOU")
# txt.setFont(_font.font)
# txt.setDefaultTextColor(pg.mkColor(hcolor('bracket')))
# # txt.setParentItem(vb)
# w = txt.boundingRect().width()
# scene.addItem(txt)
# txt.setParentItem(line)
# d_coords = vb.mapFromView(QPointF(i_end, last))
# txt.setPos(vb_right - w, d_coords.y())
# txt.show()
# txt.update()
# rlabel.setPos(vb_right - 2*w, d_coords.y())
# rlabel.show()
i = 0
while True:
await trio.sleep(sleep)
await tractor.breakpoint()
txt.format(it=f'dog_{i}')
# d_coords = vb.mapFromView(QPointF(i_end, last))
# txt.setPos(vb_right - w, d_coords.y())
# txt.setPlainText(f"FUCK YOU {i}")
i += 1
_clear_throttle_rate: int = 60 # Hz _clear_throttle_rate: int = 60 # Hz
_book_throttle_rate: int = 16 # Hz _book_throttle_rate: int = 16 # Hz
@ -1065,7 +960,7 @@ async def chart_from_quotes(
# https://arxiv.org/abs/cs/0610046 # https://arxiv.org/abs/cs/0610046
# https://github.com/lemire/pythonmaxmin # https://github.com/lemire/pythonmaxmin
array = chart._ohlc array = chart._arrays['ohlc']
ifirst = array[0]['index'] ifirst = array[0]['index']
last_bars_range = chart.bars_range() last_bars_range = chart.bars_range()
@ -1385,7 +1280,7 @@ async def run_fsp(
) )
# display contents labels asap # display contents labels asap
chart.update_contents_labels( chart.linked.cursor.contents_labels.update_labels(
len(shm.array) - 1, len(shm.array) - 1,
# fsp_func_name # fsp_func_name
) )

View File

@ -18,8 +18,8 @@
Mouse interaction graphics Mouse interaction graphics
""" """
import math from functools import partial
from typing import Optional, Tuple, Set, Dict from typing import Optional, Callable
import inspect import inspect
import numpy as np import numpy as np
@ -30,7 +30,6 @@ from PyQt5.QtCore import QPointF, QRectF
from .._style import ( from .._style import (
_xaxis_at, _xaxis_at,
hcolor, hcolor,
_font,
_font_small, _font_small,
) )
from .._axes import YAxisLabel, XAxisLabel from .._axes import YAxisLabel, XAxisLabel
@ -98,7 +97,7 @@ class LineDot(pg.CurvePoint):
(x, y) = self.curve().getData() (x, y) = self.curve().getData()
index = self.property('index') index = self.property('index')
# first = self._plot._ohlc[0]['index'] # first = self._plot._arrays['ohlc'][0]['index']
# first = x[0] # first = x[0]
# i = index - first # i = index - first
i = index - x[0] i = index - x[0]
@ -133,11 +132,15 @@ class ContentsLabel(pg.LabelItem):
} }
def __init__( def __init__(
self, self,
chart: 'ChartPlotWidget', # noqa # chart: 'ChartPlotWidget', # noqa
view: pg.ViewBox,
anchor_at: str = ('top', 'right'), anchor_at: str = ('top', 'right'),
justify_text: str = 'left', justify_text: str = 'left',
font_size: Optional[int] = None, font_size: Optional[int] = None,
) -> None: ) -> None:
font_size = font_size or _font_small.px_size font_size = font_size or _font_small.px_size
@ -148,9 +151,10 @@ class ContentsLabel(pg.LabelItem):
) )
# anchor to viewbox # anchor to viewbox
self.setParentItem(chart._vb) self.setParentItem(view)
chart.scene().addItem(self)
self.chart = chart self.vb = view
view.scene().addItem(self)
v, h = anchor_at v, h = anchor_at
index = (self._corner_anchors[h], self._corner_anchors[v]) index = (self._corner_anchors[h], self._corner_anchors[v])
@ -163,10 +167,12 @@ class ContentsLabel(pg.LabelItem):
self.anchor(itemPos=index, parentPos=index, offset=margins) self.anchor(itemPos=index, parentPos=index, offset=margins)
def update_from_ohlc( def update_from_ohlc(
self, self,
name: str, name: str,
index: int, index: int,
array: np.ndarray, array: np.ndarray,
) -> None: ) -> None:
# this being "html" is the dumbest shit :eyeroll: # this being "html" is the dumbest shit :eyeroll:
first = array[0]['index'] first = array[0]['index']
@ -188,25 +194,111 @@ class ContentsLabel(pg.LabelItem):
) )
def update_from_value( def update_from_value(
self, self,
name: str, name: str,
index: int, index: int,
array: np.ndarray, array: np.ndarray,
) -> None: ) -> None:
first = array[0]['index'] first = array[0]['index']
if index < array[-1]['index'] and index > first: if index < array[-1]['index'] and index > first:
data = array[index - first][name] data = array[index - first][name]
self.setText(f"{name}: {data:.2f}") self.setText(f"{name}: {data:.2f}")
class ContentsLabels:
'''Collection of labels that span a ``LinkedSplits`` set of chart plots
and can be updated from the underlying data from an x-index value sent
as input from a cursor or other query mechanism.
'''
def __init__(
self,
linkedsplits: 'LinkedSplits', # type: ignore # noqa
) -> None:
self.linkedsplits = linkedsplits
self._labels: list[(
'CharPlotWidget', # type: ignore # noqa
str,
ContentsLabel,
Callable
)] = []
def update_labels(
self,
index: int,
# array_name: str,
) -> None:
# for name, (label, update) in self._labels.items():
for chart, name, label, update in self._labels:
if not (index >= 0 and index < chart._arrays['ohlc'][-1]['index']):
# out of range
continue
array = chart._arrays[name]
# call provided update func with data point
try:
label.show()
update(index, array)
except IndexError:
log.exception(f"Failed to update label: {name}")
def hide(self) -> None:
for chart, name, label, update in self._labels:
label.hide()
def add_label(
self,
chart: 'ChartPlotWidget', # type: ignore # noqa
name: str,
anchor_at: tuple[str, str] = ('top', 'left'),
update_func: Callable = ContentsLabel.update_from_value,
) -> ContentsLabel:
label = ContentsLabel(
view=chart._vb,
anchor_at=anchor_at,
)
self._labels.append(
(chart, name, label, partial(update_func, label, name))
)
# label.hide()
return label
class Cursor(pg.GraphicsObject): class Cursor(pg.GraphicsObject):
def __init__( def __init__(
self, self,
linkedsplits: 'LinkedSplits', # noqa linkedsplits: 'LinkedSplits', # noqa
digits: int = 0 digits: int = 0
) -> None: ) -> None:
super().__init__() super().__init__()
self.linked = linkedsplits
self.graphics: dict[str, pg.GraphicsObject] = {}
self.plots: List['PlotChartWidget'] = [] # type: ignore # noqa
self.active_plot = None
self.digits: int = digits
self._datum_xy: tuple[int, float] = (0, 0)
self._hovered: set[pg.GraphicsObject] = set()
self._trackers: set[pg.GraphicsObject] = set()
# XXX: not sure why these are instance variables? # XXX: not sure why these are instance variables?
# It's not like we can change them on the fly..? # It's not like we can change them on the fly..?
self.pen = pg.mkPen( self.pen = pg.mkPen(
@ -217,19 +309,10 @@ class Cursor(pg.GraphicsObject):
color=hcolor('davies'), color=hcolor('davies'),
style=QtCore.Qt.DashLine, style=QtCore.Qt.DashLine,
) )
self.lsc = linkedsplits
self.graphics: Dict[str, pg.GraphicsObject] = {}
self.plots: List['PlotChartWidget'] = [] # type: ignore # noqa
self.active_plot = None
self.digits: int = digits
self._datum_xy: Tuple[int, float] = (0, 0)
self._hovered: Set[pg.GraphicsObject] = set()
self._trackers: Set[pg.GraphicsObject] = set()
# value used for rounding y-axis discreet tick steps # value used for rounding y-axis discreet tick steps
# computing once, up front, here cuz why not # computing once, up front, here cuz why not
self._y_incr_mult = 1 / self.lsc._symbol.tick_size self._y_incr_mult = 1 / self.linked._symbol.tick_size
# line width in view coordinates # line width in view coordinates
self._lw = self.pixelWidth() * self.lines_pen.width() self._lw = self.pixelWidth() * self.lines_pen.width()
@ -239,6 +322,22 @@ class Cursor(pg.GraphicsObject):
self._y_label_update: bool = True self._y_label_update: bool = True
self.contents_labels = ContentsLabels(self.linked)
self._in_query_mode: bool = False
@property
def in_query_mode(self) -> bool:
return self._in_query_mode
@in_query_mode.setter
def in_query_mode(self, value: bool) -> None:
if self._in_query_mode and not value:
# edge trigger hide all labels
self.contents_labels.hide()
self._in_query_mode = value
def add_hovered( def add_hovered(
self, self,
item: pg.GraphicsObject, item: pg.GraphicsObject,
@ -320,7 +419,7 @@ class Cursor(pg.GraphicsObject):
# the current sample under the mouse # the current sample under the mouse
cursor = LineDot( cursor = LineDot(
curve, curve,
index=plot._ohlc[-1]['index'], index=plot._arrays['ohlc'][-1]['index'],
plot=plot plot=plot
) )
plot.addItem(cursor) plot.addItem(cursor)
@ -344,7 +443,7 @@ class Cursor(pg.GraphicsObject):
def mouseMoved( def mouseMoved(
self, self,
evt: 'Tuple[QMouseEvent]', # noqa evt: 'tuple[QMouseEvent]', # noqa
) -> None: # noqa ) -> None: # noqa
"""Update horizonal and vertical lines when mouse moves inside """Update horizonal and vertical lines when mouse moves inside
either the main chart or any indicator subplot. either the main chart or any indicator subplot.
@ -392,10 +491,16 @@ class Cursor(pg.GraphicsObject):
item.on_tracked_source(ix, iy) item.on_tracked_source(ix, iy)
if ix != last_ix: if ix != last_ix:
if self.in_query_mode:
# show contents labels on all linked charts and update
# with cursor movement
self.contents_labels.update_labels(ix)
for plot, opts in self.graphics.items(): for plot, opts in self.graphics.items():
# update the chart's "contents" label # update the chart's "contents" label
plot.update_contents_labels(ix) # plot.update_contents_labels(ix)
# move the vertical line to the current "center of bar" # move the vertical line to the current "center of bar"
opts['vl'].setX(ix + line_offset) opts['vl'].setX(ix + line_offset)