piker/piker/ui/_graphics.py

336 lines
11 KiB
Python

"""
Chart graphics for displaying a slew of different data types.
"""
from typing import Dict, Any
from enum import Enum
from contextlib import contextmanager
import numpy as np
import pyqtgraph as pg
from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import QLineF
from .quantdom.utils import timeit
# from .quantdom.base import Quotes
from ._style import _xaxis_at # , _tina_mode
from ._axes import YAxisLabel, XAxisLabel
# TODO: checkout pyqtgraph.PlotCurveItem.setCompositionMode
_mouse_rate_limit = 50
class CrossHairItem(pg.GraphicsObject):
def __init__(self, parent, indicators=None, digits=0):
super().__init__()
# self.pen = pg.mkPen('#000000')
self.pen = pg.mkPen('#a9a9a9')
self.parent = parent
self.indicators = {}
self.activeIndicator = None
self.xaxis = self.parent.getAxis('bottom')
self.yaxis = self.parent.getAxis('right')
self.vline = self.parent.addLine(x=0, pen=self.pen, movable=False)
self.hline = self.parent.addLine(y=0, pen=self.pen, movable=False)
self.proxy_moved = pg.SignalProxy(
self.parent.scene().sigMouseMoved,
rateLimit=_mouse_rate_limit,
slot=self.mouseMoved,
)
self.yaxis_label = YAxisLabel(
parent=self.yaxis, digits=digits, opacity=1
)
indicators = indicators or []
if indicators:
# when there are indicators present in sub-plot rows
# take the last one (nearest to the bottom) and place the
# crosshair label on it's x-axis.
last_ind = indicators[-1]
self.proxy_enter = pg.SignalProxy(
self.parent.sig_mouse_enter,
rateLimit=_mouse_rate_limit,
slot=lambda: self.mouseAction('Enter', False),
)
self.proxy_leave = pg.SignalProxy(
self.parent.sig_mouse_leave,
rateLimit=_mouse_rate_limit,
slot=lambda: self.mouseAction('Leave', False),
)
# determine where to place x-axis label
if _xaxis_at == 'bottom':
# place below is last indicator subplot
self.xaxis_label = XAxisLabel(
parent=last_ind.getAxis('bottom'), opacity=1
)
else:
# keep x-axis right below main chart
self.xaxis_label = XAxisLabel(parent=self.xaxis, opacity=1)
for i in indicators:
# add vertial and horizonal lines and a y-axis label
vl = i.addLine(x=0, pen=self.pen, movable=False)
hl = i.addLine(y=0, pen=self.pen, movable=False)
yl = YAxisLabel(parent=i.getAxis('right'), opacity=1)
px_moved = pg.SignalProxy(
i.scene().sigMouseMoved,
rateLimit=_mouse_rate_limit,
slot=self.mouseMoved
)
px_enter = pg.SignalProxy(
i.sig_mouse_enter,
rateLimit=_mouse_rate_limit,
slot=lambda: self.mouseAction('Enter', i),
)
px_leave = pg.SignalProxy(
i.sig_mouse_leave,
rateLimit=_mouse_rate_limit,
slot=lambda: self.mouseAction('Leave', i),
)
self.indicators[i] = {
'vl': vl,
'hl': hl,
'yl': yl,
'px': (px_moved, px_enter, px_leave),
}
def mouseAction(self, action, ind=False): # noqa
if action == 'Enter':
# show horiz line and y-label
if ind:
self.indicators[ind]['hl'].show()
self.indicators[ind]['yl'].show()
self.activeIndicator = ind
else:
self.yaxis_label.show()
self.hline.show()
# Leave
else:
# hide horiz line and y-label
if ind:
self.indicators[ind]['hl'].hide()
self.indicators[ind]['yl'].hide()
self.activeIndicator = None
else:
self.yaxis_label.hide()
self.hline.hide()
def mouseMoved(self, evt): # noqa
"""Update horizonal and vertical lines when mouse moves inside
either the main chart or any indicator subplot.
"""
pos = evt[0]
# if the mouse is within the parent ``ChartPlotWidget``
if self.parent.sceneBoundingRect().contains(pos):
# mouse_point = self.vb.mapSceneToView(pos)
mouse_point = self.parent.mapToView(pos)
# move the vertial line to the current x coordinate
self.vline.setX(mouse_point.x())
# update the label on the bottom of the crosshair
self.xaxis_label.update_label(evt_post=pos, point_view=mouse_point)
# update the vertical line in any indicators subplots
for opts in self.indicators.values():
opts['vl'].setX(mouse_point.x())
if self.activeIndicator:
# vertial position of the mouse is inside an indicator
mouse_point_ind = self.activeIndicator.mapToView(pos)
self.indicators[self.activeIndicator]['hl'].setY(
mouse_point_ind.y()
)
self.indicators[self.activeIndicator]['yl'].update_label(
evt_post=pos, point_view=mouse_point_ind
)
else:
# vertial position of the mouse is inside the main chart
self.hline.setY(mouse_point.y())
self.yaxis_label.update_label(
evt_post=pos, point_view=mouse_point
)
def paint(self, p, *args):
pass
def boundingRect(self):
return self.parent.boundingRect()
class BarItems(pg.GraphicsObject):
"""Price range bars graphics rendered from a OHLC sequence.
"""
sigPlotChanged = QtCore.Signal(object)
w: float = 0.5
bull_brush = bear_brush = pg.mkPen('#808080')
# XXX: tina mode, see below
# bull_brush = pg.mkPen('#00cc00')
# bear_brush = pg.mkPen('#fa0000')
def __init__(self):
super().__init__()
self.picture = QtGui.QPicture()
# lines container
self.lines: np.ndarray = None
# TODO: can we be faster just dropping this?
@contextmanager
def painter(self):
# pre-computing a QPicture object allows paint() to run much
# more quickly, rather than re-drawing the shapes every time.
p = QtGui.QPainter(self.picture)
yield p
p.end()
@timeit
def draw_from_data(
self,
data: np.recarray,
):
"""Draw OHLC datum graphics from a ``np.recarray``.
"""
# XXX: not sure this actually needs to be an array other
# then for the old tina mode calcs for up/down bars below?
self.lines = lines = np.empty_like(
data, shape=(data.shape[0]*3,), dtype=object)
with self.painter() as p:
for i, q in enumerate(data):
# indexing here is as per the below comments
lines[3*i:3*i+3] = (
# high_to_low
QLineF(q['index'], q['low'], q['index'], q['high']),
# open_sticks
QLineF(q['index'] - self.w, q['open'], q['index'], q['open']),
# close_sticks
QtCore.QLineF(
q['index'] + self.w, q['close'], q['index'], q['close'])
)
# if not _tina_mode: # piker mode
p.setPen(self.bull_brush)
p.drawLines(*lines)
# else _tina_mode:
# self.lines = lines = np.concatenate(
# [high_to_low, open_sticks, close_sticks])
# use traditional up/down green/red coloring
# long_bars = np.resize(Quotes.close > Quotes.open, len(lines))
# short_bars = np.resize(
# Quotes.close < Quotes.open, len(lines))
# ups = lines[long_bars]
# downs = lines[short_bars]
# # draw "up" bars
# p.setPen(self.bull_brush)
# p.drawLines(*ups)
# # draw "down" bars
# p.setPen(self.bear_brush)
# p.drawLines(*downs)
def update_from_array(
self,
array: np.ndarray,
) -> None:
"""Update the last datum's bar graphic from input data array.
This routine should be interface compatible with
``pg.PlotCurveItem.setData()``. Normally this method in
``pyqtgraph`` seems to update all the data passed to the
graphics object, and then update/rerender, but here we're
assuming the prior graphics havent changed (OHLC history rarely
does) so this "should" be simpler and faster.
"""
# do we really need to verify the entire past data set?
last = array['close'][-1]
body, larm, rarm = self.lines[-3:]
# XXX: is there a faster way to modify this?
# update right arm
rarm.setLine(rarm.x1(), last, rarm.x2(), last)
# update body
high = body.y2()
low = body.y1()
if last < low:
low = last
if last > high:
high = last
body.setLine(body.x1(), low, body.x2(), high)
# draw the pic
with self.painter() as p:
p.setPen(self.bull_brush)
p.drawLines(*self.lines)
# trigger re-render
self.update()
# be compat with ``pg.PlotCurveItem``
setData = update_from_array
# XXX: From the customGraphicsItem.py example:
# The only required methods are paint() and boundingRect()
def paint(self, p, opt, widget):
p.drawPicture(0, 0, self.picture)
def boundingRect(self):
# boundingRect _must_ indicate the entire area that will be
# drawn on or else we will get artifacts and possibly crashing.
# (in this case, QPicture does all the work of computing the
# bounding rect for us)
return QtCore.QRectF(self.picture.boundingRect())
class CandlestickItems(BarItems):
w2 = 0.7
line_pen = pg.mkPen('#000000')
bull_brush = pg.mkBrush('#00ff00')
bear_brush = pg.mkBrush('#ff0000')
def _generate(self, p):
rects = np.array(
[
QtCore.QRectF(q.id - self.w, q.open, self.w2, q.close - q.open)
for q in Quotes
]
)
p.setPen(self.line_pen)
p.drawLines(
[QtCore.QLineF(q.id, q.low, q.id, q.high)
for q in Quotes]
)
p.setBrush(self.bull_brush)
p.drawRects(*rects[Quotes.close > Quotes.open])
p.setBrush(self.bear_brush)
p.drawRects(*rects[Quotes.close < Quotes.open])
class ChartType(Enum):
"""Bar type to graphics class map.
"""
BAR = BarItems
CANDLESTICK = CandlestickItems
LINE = pg.PlotDataItem