diff --git a/piker/ui/qt/_axes.py b/piker/ui/qt/_axes.py new file mode 100644 index 00000000..96724a26 --- /dev/null +++ b/piker/ui/qt/_axes.py @@ -0,0 +1,171 @@ +""" +Chart axes graphics and behavior. +""" +import pyqtgraph as pg +from PyQt5 import QtCore, QtGui + + +from .quantdom.base import Quotes +from .quantdom.utils import fromtimestamp +from ._style import _font + + +class PriceAxis(pg.AxisItem): + + def __init__(self): + super().__init__(orientation='right') + self.setStyle(**{ + 'textFillLimits': [(0, 0.8)], + # 'tickTextWidth': 5, + # 'tickTextHeight': 5, + # 'autoExpandTextSpace': True, + # 'maxTickLength': -20, + }) + self.setLabel(**{'font-size': '10pt'}) + self.setTickFont(_font) + + # XXX: drop for now since it just eats up h space + + # def tickStrings(self, vals, scale, spacing): + # digts = max(0, np.ceil(-np.log10(spacing * scale))) + # return [ + # ('{:<8,.%df}' % digts).format(v).replace(',', ' ') for v in vals + # ] + + +class FromTimeFieldDateAxis(pg.AxisItem): + tick_tpl = {'D1': '%Y-%b-%d'} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setTickFont(_font) + self.quotes_count = len(Quotes) - 1 + + # default styling + self.setStyle( + tickTextOffset=7, + textFillLimits=[(0, 0.90)], + # TODO: doesn't seem to work -> bug in pyqtgraph? + # tickTextHeight=2, + ) + + def tickStrings(self, values, scale, spacing): + # if len(values) > 1 or not values: + # values = Quotes.time + + # strings = super().tickStrings(values, scale, spacing) + s_period = 'D1' + strings = [] + for ibar in values: + if ibar > self.quotes_count: + return strings + dt_tick = fromtimestamp(Quotes[int(ibar)].time) + strings.append( + dt_tick.strftime(self.tick_tpl[s_period]) + ) + return strings + + +class AxisLabel(pg.GraphicsObject): + + # bg_color = pg.mkColor('#a9a9a9') + bg_color = pg.mkColor('#808080') + fg_color = pg.mkColor('#000000') + + def __init__(self, parent=None, digits=0, color=None, opacity=1, **kwargs): + super().__init__(parent) + self.parent = parent + self.opacity = opacity + self.label_str = '' + self.digits = digits + # self.quotes_count = len(Quotes) - 1 + + if isinstance(color, QtGui.QPen): + self.bg_color = color.color() + self.fg_color = pg.mkColor('#ffffff') + elif isinstance(color, list): + self.bg_color = {'>0': color[0].color(), '<0': color[1].color()} + self.fg_color = pg.mkColor('#ffffff') + + self.setFlag(self.ItemIgnoresTransformations) + + def tick_to_string(self, tick_pos): + raise NotImplementedError() + + def boundingRect(self): # noqa + raise NotImplementedError() + + def update_label(self, evt_post, point_view): + raise NotImplementedError() + + def update_label_test(self, ypos=0, ydata=0): + self.label_str = self.tick_to_string(ydata) + height = self.boundingRect().height() + offset = 0 # if have margins + new_pos = QtCore.QPointF(0, ypos - height / 2 - offset) + self.setPos(new_pos) + + def paint(self, p, option, widget): + p.setRenderHint(p.TextAntialiasing, True) + p.setPen(self.fg_color) + if self.label_str: + if not isinstance(self.bg_color, dict): + bg_color = self.bg_color + else: + if int(self.label_str.replace(' ', '')) > 0: + bg_color = self.bg_color['>0'] + else: + bg_color = self.bg_color['<0'] + p.setOpacity(self.opacity) + p.fillRect(option.rect, bg_color) + p.setOpacity(1) + p.setFont(_font) + + p.drawText(option.rect, self.text_flags, self.label_str) + + +class XAxisLabel(AxisLabel): + + text_flags = ( + QtCore.Qt.TextDontClip | QtCore.Qt.AlignCenter | QtCore.Qt.AlignTop + ) + + def tick_to_string(self, tick_pos): + # TODO: change to actual period + tpl = self.parent.tick_tpl['D1'] + if tick_pos > len(Quotes): + return 'Unknown Time' + return fromtimestamp(Quotes[round(tick_pos)].time).strftime(tpl) + + def boundingRect(self): # noqa + return QtCore.QRectF(0, 0, 145, 50) + + def update_label(self, evt_post, point_view): + ibar = point_view.x() + # if ibar > self.quotes_count: + # return + self.label_str = self.tick_to_string(ibar) + width = self.boundingRect().width() + offset = 0 # if have margins + new_pos = QtCore.QPointF(evt_post.x() - width / 2 - offset, 0) + self.setPos(new_pos) + + +class YAxisLabel(AxisLabel): + + text_flags = ( + QtCore.Qt.TextDontClip | QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter + ) + + def tick_to_string(self, tick_pos): + return ('{: ,.%df}' % self.digits).format(tick_pos).replace(',', ' ') + + def boundingRect(self): # noqa + return QtCore.QRectF(0, 0, 80, 40) + + def update_label(self, evt_post, point_view): + self.label_str = self.tick_to_string(point_view.y()) + height = self.boundingRect().height() + offset = 0 # if have margins + new_pos = QtCore.QPointF(0, evt_post.y() - height / 2 - offset) + self.setPos(new_pos) diff --git a/piker/ui/qt/_chart.py b/piker/ui/qt/_chart.py index 8deda737..e77f1191 100644 --- a/piker/ui/qt/_chart.py +++ b/piker/ui/qt/_chart.py @@ -1,9 +1,29 @@ """ -High level Qt chart wrapping widgets. +High level Qt chart widgets. """ -from PyQt5 import QtGui +import numpy as np +import pyqtgraph as pg +from pyqtgraph import functions as fn +from PyQt5 import QtCore, QtGui -from .quantdom.charts import SplitterChart +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 + + +# white background (for tinas like our pal xb) +# pg.setConfigOption('background', 'w') + +# margins +CHART_MARGINS = (0, 0, 10, 3) class QuotesTabWidget(QtGui.QWidget): @@ -53,3 +73,383 @@ class QuotesTabWidget(QtGui.QWidget): 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): + 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.parent = 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, + ) + ind.plotItem.parent = 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 + + +# 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("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 + min_points_to_show = 20 + min_bars_in_view = 10 + 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) + self.chart = parent + + # 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'][:] + + # 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') diff --git a/piker/ui/qt/_graphics.py b/piker/ui/qt/_graphics.py new file mode 100644 index 00000000..bff8c043 --- /dev/null +++ b/piker/ui/qt/_graphics.py @@ -0,0 +1,252 @@ +""" +Chart graphics for displaying a slew of different data types. +""" +import numpy as np +import pyqtgraph as pg +from PyQt5 import QtCore, QtGui + +from .quantdom.utils import timeit +from .quantdom.base import Quotes + +from ._style import _xaxis_at +from ._axes import YAxisLabel, XAxisLabel + + +_mouse_rate_limit = 60 + + +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 BarItem(pg.GraphicsObject): + # XXX: From the customGraphicsItem.py example: + # The only required methods are paint() and boundingRect() + + w = 0.5 + + bull_brush = bear_brush = pg.mkPen('#808080') + # bull_brush = pg.mkPen('#00cc00') + # bear_brush = pg.mkPen('#fa0000') + + def __init__(self): + super().__init__() + self.generatePicture() + + # TODO: this is the routine to be retriggered for redraw + @timeit + def generatePicture(self): + # pre-computing a QPicture object allows paint() to run much + # more quickly, rather than re-drawing the shapes every time. + self.picture = QtGui.QPicture() + p = QtGui.QPainter(self.picture) + self._generate(p) + p.end() + + def _generate(self, p): + # XXX: overloaded method to allow drawing other candle types + + high_to_low = np.array( + [QtCore.QLineF(q.id, q.low, q.id, q.high) for q in Quotes] + ) + open_stick = np.array( + [QtCore.QLineF(q.id - self.w, q.open, q.id, q.open) + for q in Quotes] + ) + close_stick = np.array( + [ + QtCore.QLineF(q.id + self.w, q.close, q.id, q.close) + for q in Quotes + ] + ) + lines = np.concatenate([high_to_low, open_stick, close_stick]) + long_bars = np.resize(Quotes.close > Quotes.open, len(lines)) + short_bars = np.resize(Quotes.close < Quotes.open, len(lines)) + + p.setPen(self.bull_brush) + p.drawLines(*lines[long_bars]) + + p.setPen(self.bear_brush) + p.drawLines(*lines[short_bars]) + + def paint(self, p, *args): + 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 + # bouning rect for us) + return QtCore.QRectF(self.picture.boundingRect()) + + +class CandlestickItem(BarItem): + + 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]) diff --git a/piker/ui/qt/_style.py b/piker/ui/qt/_style.py new file mode 100644 index 00000000..8630d67d --- /dev/null +++ b/piker/ui/qt/_style.py @@ -0,0 +1,18 @@ +""" +Qt styling. +""" +from PyQt5 import QtGui + + +# TODO: add "tina mode" to make everything look "conventional" +# white background (for tinas like our pal xb) +# pg.setConfigOption('background', 'w') + + +# chart-wide font +_font = QtGui.QFont("Hack", 4) +_i3_rgba = QtGui.QColor.fromRgbF(*[0.14]*3 + [1]) + + +# splitter widget config +_xaxis_at = 'bottom' diff --git a/piker/ui/qt/quantdom/charts.py b/piker/ui/qt/quantdom/charts.py index 6a87ce2d..e2da8586 100644 --- a/piker/ui/qt/quantdom/charts.py +++ b/piker/ui/qt/quantdom/charts.py @@ -1,31 +1,9 @@ """ Real-time quotes charting components """ -from typing import List, Tuple - -import numpy as np import pyqtgraph as pg -from pyqtgraph import functions as fn from PyQt5 import QtCore, QtGui -from .base import Quotes -from .const import ChartType -from .portfolio import Order, Portfolio -from .utils import fromtimestamp, timeit - -__all__ = ('SplitterChart') - - -# white background (for tinas like our pal xb) -# pg.setConfigOption('background', 'w') - -# margins -CHART_MARGINS = (0, 0, 10, 3) - -# chart-wide font -_font = QtGui.QFont("Hack", 4) -_i3_rgba = QtGui.QColor.fromRgbF(*[0.14]*3 + [1]) - class SampleLegendItem(pg.graphicsItems.LegendItem.ItemSample): @@ -62,62 +40,6 @@ class SampleLegendItem(pg.graphicsItems.LegendItem.ItemSample): p.drawRect(0, 10, 18, 0.5) -class PriceAxis(pg.AxisItem): - - def __init__(self): - super().__init__(orientation='right') - self.setStyle(**{ - 'textFillLimits': [(0, 0.8)], - # 'tickTextWidth': 5, - # 'tickTextHeight': 5, - # 'autoExpandTextSpace': True, - # 'maxTickLength': -20, - }) - self.setLabel(**{'font-size': '10pt'}) - self.setTickFont(_font) - - # XXX: drop for now since it just eats up h space - - # def tickStrings(self, vals, scale, spacing): - # digts = max(0, np.ceil(-np.log10(spacing * scale))) - # return [ - # ('{:<8,.%df}' % digts).format(v).replace(',', ' ') for v in vals - # ] - - -class FromTimeFieldDateAxis(pg.AxisItem): - tick_tpl = {'D1': '%Y-%b-%d'} - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setTickFont(_font) - self.quotes_count = len(Quotes) - 1 - - # default styling - self.setStyle( - tickTextOffset=7, - textFillLimits=[(0, 0.90)], - # TODO: doesn't seem to work -> bug in pyqtgraph? - # tickTextHeight=2, - ) - - def tickStrings(self, values, scale, spacing): - # if len(values) > 1 or not values: - # values = Quotes.time - - # strings = super().tickStrings(values, scale, spacing) - s_period = 'D1' - strings = [] - for ibar in values: - if ibar > self.quotes_count: - return strings - dt_tick = fromtimestamp(Quotes[int(ibar)].time) - strings.append( - dt_tick.strftime(self.tick_tpl[s_period]) - ) - return strings - - class CenteredTextItem(QtGui.QGraphicsTextItem): def __init__( self, @@ -155,729 +77,3 @@ class CenteredTextItem(QtGui.QGraphicsTextItem): p.fillRect(option.rect, self.brush) p.setOpacity(1) p.drawText(option.rect, self.text_flags, self.toPlainText()) - - -class AxisLabel(pg.GraphicsObject): - - # bg_color = pg.mkColor('#a9a9a9') - bg_color = pg.mkColor('#808080') - fg_color = pg.mkColor('#000000') - - def __init__(self, parent=None, digits=0, color=None, opacity=1, **kwargs): - super().__init__(parent) - self.parent = parent - self.opacity = opacity - self.label_str = '' - self.digits = digits - # self.quotes_count = len(Quotes) - 1 - - if isinstance(color, QtGui.QPen): - self.bg_color = color.color() - self.fg_color = pg.mkColor('#ffffff') - elif isinstance(color, list): - self.bg_color = {'>0': color[0].color(), '<0': color[1].color()} - self.fg_color = pg.mkColor('#ffffff') - - self.setFlag(self.ItemIgnoresTransformations) - - def tick_to_string(self, tick_pos): - raise NotImplementedError() - - def boundingRect(self): # noqa - raise NotImplementedError() - - def update_label(self, evt_post, point_view): - raise NotImplementedError() - - def update_label_test(self, ypos=0, ydata=0): - self.label_str = self.tick_to_string(ydata) - height = self.boundingRect().height() - offset = 0 # if have margins - new_pos = QtCore.QPointF(0, ypos - height / 2 - offset) - self.setPos(new_pos) - - def paint(self, p, option, widget): - p.setRenderHint(p.TextAntialiasing, True) - p.setPen(self.fg_color) - if self.label_str: - if not isinstance(self.bg_color, dict): - bg_color = self.bg_color - else: - if int(self.label_str.replace(' ', '')) > 0: - bg_color = self.bg_color['>0'] - else: - bg_color = self.bg_color['<0'] - p.setOpacity(self.opacity) - p.fillRect(option.rect, bg_color) - p.setOpacity(1) - p.setFont(_font) - - p.drawText(option.rect, self.text_flags, self.label_str) - - -class XAxisLabel(AxisLabel): - - text_flags = ( - QtCore.Qt.TextDontClip | QtCore.Qt.AlignCenter | QtCore.Qt.AlignTop - ) - - def tick_to_string(self, tick_pos): - # TODO: change to actual period - tpl = self.parent.tick_tpl['D1'] - if tick_pos > len(Quotes): - return 'Unknown Time' - return fromtimestamp(Quotes[round(tick_pos)].time).strftime(tpl) - - def boundingRect(self): # noqa - return QtCore.QRectF(0, 0, 145, 50) - - def update_label(self, evt_post, point_view): - ibar = point_view.x() - # if ibar > self.quotes_count: - # return - self.label_str = self.tick_to_string(ibar) - width = self.boundingRect().width() - offset = 0 # if have margins - new_pos = QtCore.QPointF(evt_post.x() - width / 2 - offset, 0) - self.setPos(new_pos) - - -class YAxisLabel(AxisLabel): - - text_flags = ( - QtCore.Qt.TextDontClip | QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter - ) - - def tick_to_string(self, tick_pos): - return ('{: ,.%df}' % self.digits).format(tick_pos).replace(',', ' ') - - def boundingRect(self): # noqa - return QtCore.QRectF(0, 0, 80, 40) - - def update_label(self, evt_post, point_view): - self.label_str = self.tick_to_string(point_view.y()) - height = self.boundingRect().height() - offset = 0 # if have margins - new_pos = QtCore.QPointF(0, evt_post.y() - height / 2 - offset) - self.setPos(new_pos) - - -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) - self.chart = parent - - # 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'][:] - - # 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) - - -# 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("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 - min_points_to_show = 20 - min_bars_in_view = 10 - 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) - ind_chart.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) - - -_mouse_rate_limit = 60 -_xaxis_at = 'bottom' - - -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 BarItem(pg.GraphicsObject): - # XXX: From the customGraphicsItem.py example: - # The only required methods are paint() and boundingRect() - - w = 0.5 - - bull_brush = bear_brush = pg.mkPen('#808080') - # bull_brush = pg.mkPen('#00cc00') - # bear_brush = pg.mkPen('#fa0000') - - def __init__(self): - super().__init__() - self.generatePicture() - - # TODO: this is the routine to be retriggered for redraw - @timeit - def generatePicture(self): - # pre-computing a QPicture object allows paint() to run much - # more quickly, rather than re-drawing the shapes every time. - self.picture = QtGui.QPicture() - p = QtGui.QPainter(self.picture) - self._generate(p) - p.end() - - def _generate(self, p): - # XXX: overloaded method to allow drawing other candle types - - high_to_low = np.array( - [QtCore.QLineF(q.id, q.low, q.id, q.high) for q in Quotes] - ) - open_stick = np.array( - [QtCore.QLineF(q.id - self.w, q.open, q.id, q.open) - for q in Quotes] - ) - close_stick = np.array( - [ - QtCore.QLineF(q.id + self.w, q.close, q.id, q.close) - for q in Quotes - ] - ) - lines = np.concatenate([high_to_low, open_stick, close_stick]) - long_bars = np.resize(Quotes.close > Quotes.open, len(lines)) - short_bars = np.resize(Quotes.close < Quotes.open, len(lines)) - - p.setPen(self.bull_brush) - p.drawLines(*lines[long_bars]) - - p.setPen(self.bear_brush) - p.drawLines(*lines[short_bars]) - - def paint(self, p, *args): - 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 - # bouning rect for us) - return QtCore.QRectF(self.picture.boundingRect()) - - -class CandlestickItem(BarItem): - - 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 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): - 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.parent = 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, - ) - ind.plotItem.parent = 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 - - -# 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')