piker/piker/ui/qt/_chart.py

461 lines
15 KiB
Python

"""
High level Qt chart widgets.
"""
import numpy as np
import pyqtgraph as pg
from pyqtgraph import functions as fn
from PyQt5 import QtCore, QtGui
from ._axes import (
FromTimeFieldDateAxis,
PriceAxis,
)
from ._graphics import CrossHairItem, CandlestickItem, BarItem
from ._style import _xaxis_at
from .quantdom.charts import CenteredTextItem
from .quantdom.base import Quotes
from .quantdom.const import ChartType
from .quantdom.portfolio import Order, Portfolio
# margins
CHART_MARGINS = (0, 0, 10, 3)
class QuotesTabWidget(QtGui.QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.layout = QtGui.QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.toolbar_layout = QtGui.QHBoxLayout()
self.toolbar_layout.setContentsMargins(10, 10, 15, 0)
self.chart_layout = QtGui.QHBoxLayout()
# self.init_timeframes_ui()
# self.init_strategy_ui()
self.layout.addLayout(self.toolbar_layout)
self.layout.addLayout(self.chart_layout)
def init_timeframes_ui(self):
self.tf_layout = QtGui.QHBoxLayout()
self.tf_layout.setSpacing(0)
self.tf_layout.setContentsMargins(0, 12, 0, 0)
time_frames = ('1M', '5M', '15M', '30M', '1H', '1D', '1W', 'MN')
btn_prefix = 'TF'
for tf in time_frames:
btn_name = ''.join([btn_prefix, tf])
btn = QtGui.QPushButton(tf)
# TODO:
btn.setEnabled(False)
setattr(self, btn_name, btn)
self.tf_layout.addWidget(btn)
self.toolbar_layout.addLayout(self.tf_layout)
# XXX: strat loader/saver that we don't need yet.
# def init_strategy_ui(self):
# self.strategy_box = StrategyBoxWidget(self)
# self.toolbar_layout.addWidget(self.strategy_box)
# TODO: this needs to be changed to ``load_symbol()``
# which will not only load historical data but also a real-time
# stream and schedule the redraw events on new quotes
def update_chart(self, symbol):
if not self.chart_layout.isEmpty():
self.chart_layout.removeWidget(self.chart)
self.chart = SplitterChart()
self.chart.plot(symbol)
self.chart_layout.addWidget(self.chart)
def add_signals(self):
self.chart.add_signals()
class SplitterChart(QtGui.QWidget):
long_pen = pg.mkPen('#006000')
long_brush = pg.mkBrush('#00ff00')
short_pen = pg.mkPen('#600000')
short_brush = pg.mkBrush('#ff0000')
zoomIsDisabled = QtCore.pyqtSignal(bool)
def __init__(self):
super().__init__()
self.signals_visible = False
self.indicators = []
self.xaxis = FromTimeFieldDateAxis(orientation='bottom')
# self.xaxis = pg.DateAxisItem()
self.xaxis_ind = FromTimeFieldDateAxis(orientation='bottom')
if _xaxis_at == 'bottom':
self.xaxis.setStyle(showValues=False)
else:
self.xaxis_ind.setStyle(showValues=False)
self.splitter = QtGui.QSplitter(QtCore.Qt.Vertical)
self.splitter.setHandleWidth(5)
self.layout = QtGui.QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.addWidget(self.splitter)
def _show_text_signals(self, lbar, rbar):
signals = [
sig
for sig in self.signals_text_items[lbar:rbar]
if isinstance(sig, CenteredTextItem)
]
if len(signals) <= 50:
for sig in signals:
sig.show()
else:
for sig in signals:
sig.hide()
def _remove_signals(self):
self.chart.removeItem(self.signals_group_arrow)
self.chart.removeItem(self.signals_group_text)
del self.signals_text_items
del self.signals_group_arrow
del self.signals_group_text
self.signals_visible = False
def _update_sizes(self):
min_h_ind = int(self.height() * 0.2 / len(self.indicators))
sizes = [int(self.height() * 0.8)]
sizes.extend([min_h_ind] * len(self.indicators))
self.splitter.setSizes(sizes) # , int(self.height()*0.2)
def plot(self, symbol):
"""Start up and show price chart and all registered indicators.
"""
self.digits = symbol.digits
self.chart = ChartPlotWidget(
split_charts=self,
parent=self.splitter,
axisItems={'bottom': self.xaxis, 'right': PriceAxis()},
viewBox=ChartView,
# enableMenu=False,
)
# TODO: ``pyqtgraph`` doesn't pass through a parent to the
# ``PlotItem`` by default; maybe we should PR this in?
self.chart.plotItem.vb.splitter_widget = self
self.chart.getPlotItem().setContentsMargins(*CHART_MARGINS)
self.chart.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain)
# TODO: this is where we would load an indicator chain
inds = [Quotes.open]
for d in inds:
ind = ChartPlotWidget(
split_charts=self,
parent=self.splitter,
axisItems={'bottom': self.xaxis_ind, 'right': PriceAxis()},
# axisItems={'top': self.xaxis_ind, 'right': PriceAxis()},
viewBox=ChartView,
)
self.chart.plotItem.vb.splitter_widget = self
ind.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain)
ind.getPlotItem().setContentsMargins(*CHART_MARGINS)
# self.splitter.addWidget(ind)
self.indicators.append((ind, d))
self.chart.draw_ohlc()
for ind_chart, d in self.indicators:
# link chart x-axis to main quotes chart
ind_chart.setXLink(self.chart)
# XXX: never do this lol
# ind.setAspectLocked(1)
ind_chart.draw_curve(d)
self._update_sizes()
ch = CrossHairItem(
self.chart, [_ind for _ind, d in self.indicators], self.digits
)
self.chart.addItem(ch)
def add_signals(self):
self.signals_group_text = QtGui.QGraphicsItemGroup()
self.signals_group_arrow = QtGui.QGraphicsItemGroup()
self.signals_text_items = np.empty(len(Quotes), dtype=object)
for p in Portfolio.positions:
x, price = p.id_bar_open, p.open_price
if p.type == Order.BUY:
y = Quotes[x].low * 0.99
pg.ArrowItem(
parent=self.signals_group_arrow,
pos=(x, y),
pen=self.long_pen,
brush=self.long_brush,
angle=90,
headLen=12,
tipAngle=50,
)
text_sig = CenteredTextItem(
parent=self.signals_group_text,
pos=(x, y),
pen=self.long_pen,
brush=self.long_brush,
text=('Buy at {:.%df}' % self.digits).format(price),
valign=QtCore.Qt.AlignBottom,
)
text_sig.hide()
else:
y = Quotes[x].high * 1.01
pg.ArrowItem(
parent=self.signals_group_arrow,
pos=(x, y),
pen=self.short_pen,
brush=self.short_brush,
angle=-90,
headLen=12,
tipAngle=50,
)
text_sig = CenteredTextItem(
parent=self.signals_group_text,
pos=(x, y),
pen=self.short_pen,
brush=self.short_brush,
text=('Sell at {:.%df}' % self.digits).format(price),
valign=QtCore.Qt.AlignTop,
)
text_sig.hide()
self.signals_text_items[x] = text_sig
self.chart.addItem(self.signals_group_arrow)
self.chart.addItem(self.signals_group_text)
self.signals_visible = True
_min_points_to_show = 20
_min_bars_in_view = 10
# TODO: This is a sub-class of ``GracphicView`` which can
# take a ``background`` color setting.
class ChartPlotWidget(pg.PlotWidget):
"""``GraphicsView`` subtype containing a single ``PlotItem``.
Overrides a ``pyqtgraph.PlotWidget`` (a ``GraphicsView`` containing
a single ``PlotItem``) to intercept and and re-emit mouse enter/exit
events.
(Could be replaced with a ``pg.GraphicsLayoutWidget`` if we
eventually want multiple plots managed together).
"""
sig_mouse_leave = QtCore.Signal(object)
sig_mouse_enter = QtCore.Signal(object)
def __init__(
self,
split_charts,
**kwargs,
# parent=None,
# background='default',
# plotItem=None,
# **kargs
):
"""Configure chart display settings.
"""
super().__init__(**kwargs)
# label = pg.LabelItem(justify='left')
# self.addItem(label)
# label.setText("Yo yoyo")
# label.setText("<span style='font-size: 12pt'>x=")
self.parent = split_charts
# show only right side axes
self.hideAxis('left')
self.showAxis('right')
# show background grid
self.showGrid(x=True, y=True, alpha=0.4)
# use cross-hair for cursor
self.setCursor(QtCore.Qt.CrossCursor)
# set panning limits
max_lookahead = _min_points_to_show - _min_bars_in_view
last = Quotes[-1].id
self.setLimits(
xMin=Quotes[0].id,
xMax=last + max_lookahead,
minXRange=_min_points_to_show,
# maxYRange=highest-lowest,
yMin=Quotes.low.min() * 0.98,
yMax=Quotes.high.max() * 1.02,
)
# show last 50 points on startup
self.plotItem.vb.setXRange(last - 50, last + max_lookahead)
# assign callback for rescaling y-axis automatically
# based on y-range contents
self.sigXRangeChanged.connect(self._update_yrange_limits)
self._update_yrange_limits()
def bars_range(self):
"""Return a range tuple for the bars present in view.
"""
vr = self.viewRect()
lbar, rbar = int(vr.left()), int(min(vr.right(), len(Quotes) - 1))
return lbar, rbar
def draw_ohlc(
self,
style: ChartType = ChartType.BAR,
) -> None:
"""Draw OHLC datums to chart.
"""
# adds all bar/candle graphics objects for each
# data point in the np array buffer to
# be drawn on next render cycle
self.addItem(_get_chart_points(style))
def draw_curve(
self,
data: np.ndarray,
) -> None:
# draw the indicator as a plain curve
curve = pg.PlotDataItem(data, antialias=True)
self.addItem(curve)
def _update_yrange_limits(self):
"""Callback for each y-range update.
This adds auto-scaling like zoom on the scroll wheel such
that data always fits nicely inside the current view of the
data set.
"""
# TODO: this can likely be ported in part to the built-ins:
# self.setYRange(Quotes.low.min() * .98, Quotes.high.max() * 1.02)
# self.setMouseEnabled(x=True, y=False)
# self.setXRange(Quotes[0].id, Quotes[-1].id)
# self.setAutoVisible(x=False, y=True)
# self.enableAutoRange(x=False, y=True)
chart = self
chart_parent = self.parent
lbar, rbar = self.bars_range()
# vr = chart.viewRect()
# lbar, rbar = int(vr.left()), int(vr.right())
if chart_parent.signals_visible:
chart_parent._show_text_signals(lbar, rbar)
bars = Quotes[lbar:rbar]
ylow = bars.low.min() * 0.98
yhigh = bars.high.max() * 1.02
std = np.std(bars.close)
chart.setLimits(yMin=ylow, yMax=yhigh, minYRange=std)
chart.setYRange(ylow, yhigh)
for i, d in chart_parent.indicators:
# ydata = i.plotItem.items[0].getData()[1]
ydata = d[lbar:rbar]
ylow = ydata.min() * 0.98
yhigh = ydata.max() * 1.02
std = np.std(ydata)
i.setLimits(yMin=ylow, yMax=yhigh, minYRange=std)
i.setYRange(ylow, yhigh)
def enterEvent(self, ev): # noqa
# pg.PlotWidget.enterEvent(self, ev)
self.sig_mouse_enter.emit(self)
def leaveEvent(self, ev): # noqa
# pg.PlotWidget.leaveEvent(self, ev)
self.sig_mouse_leave.emit(self)
self.scene().leaveEvent(ev)
class ChartView(pg.ViewBox):
"""Price chart view box with interaction behaviors you'd expect from
an interactive platform:
- zoom on mouse scroll that auto fits y-axis
- no vertical scrolling
- zoom to a "fixed point" on the y-axis
"""
def __init__(
self,
parent=None,
**kwargs,
# invertY=False,
):
super().__init__(parent=parent, **kwargs)
# disable vertical scrolling
self.setMouseEnabled(x=True, y=False)
def wheelEvent(self, ev, axis=None):
"""Override "center-point" location for scrolling.
This is an override of the ``ViewBox`` method simply changing
the center of the zoom to be the y-axis.
TODO: PR a method into ``pyqtgraph`` to make this configurable
"""
if axis in (0, 1):
mask = [False, False]
mask[axis] = self.state['mouseEnabled'][axis]
else:
mask = self.state['mouseEnabled'][:]
lbar, rbar = self.splitter_widget.chart.bars_range()
if ev.delta() >= 0 and rbar - lbar <= _min_points_to_show:
# don't zoom more then the min points setting
return
# actual scaling factor
s = 1.02 ** (ev.delta() * self.state['wheelScaleFactor'])
s = [(None if m is False else s) for m in mask]
# center = pg.Point(
# fn.invertQTransform(self.childGroup.transform()).map(ev.pos())
# )
# XXX: scroll "around" the right most element in the view
furthest_right_coord = self.boundingRect().topRight()
center = pg.Point(
fn.invertQTransform(
self.childGroup.transform()
).map(furthest_right_coord)
)
self._resetTarget()
self.scaleBy(s, center)
ev.accept()
self.sigRangeChangedManually.emit(mask)
# this function is borderline ridiculous.
# The creation of these chart types mutates all the input data
# inside each type's constructor (mind blown)
def _get_chart_points(style):
if style == ChartType.CANDLESTICK:
return CandlestickItem()
elif style == ChartType.BAR:
return BarItem()
return pg.PlotDataItem(Quotes.close, pen='b')