"""Chart.""" import numpy as np import pyqtgraph as pg from PyQt5 import QtCore, QtGui from .base import Quotes from .const import ChartType from .portfolio import Order, Portfolio from .utils import fromtimestamp, timeit __all__ = ('QuotesChart', 'EquityChart') pg.setConfigOption('background', 'w') CHART_MARGINS = (0, 0, 20, 5) class SampleLegendItem(pg.graphicsItems.LegendItem.ItemSample): def paint(self, p, *args): p.setRenderHint(p.Antialiasing) if isinstance(self.item, tuple): positive = self.item[0].opts negative = self.item[1].opts p.setPen(pg.mkPen(positive['pen'])) p.setBrush(pg.mkBrush(positive['brush'])) p.drawPolygon( QtGui.QPolygonF( [ QtCore.QPointF(0, 0), QtCore.QPointF(18, 0), QtCore.QPointF(18, 18), ] ) ) p.setPen(pg.mkPen(negative['pen'])) p.setBrush(pg.mkBrush(negative['brush'])) p.drawPolygon( QtGui.QPolygonF( [ QtCore.QPointF(0, 0), QtCore.QPointF(0, 18), QtCore.QPointF(18, 18), ] ) ) else: opts = self.item.opts p.setPen(pg.mkPen(opts['pen'])) p.drawRect(0, 10, 18, 0.5) class PriceAxis(pg.AxisItem): def __init__(self): super().__init__(orientation='right') self.style.update({'textFillLimits': [(0, 0.8)]}) 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 DateAxis(pg.AxisItem): tick_tpl = {'D1': '%d %b\n%Y'} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.quotes_count = len(Quotes) - 1 def tickStrings(self, 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, text='', parent=None, pos=(0, 0), pen=None, brush=None, valign=None, opacity=0.1, ): super().__init__(text, parent) self.pen = pen self.brush = brush self.opacity = opacity self.valign = valign self.text_flags = QtCore.Qt.AlignCenter self.setPos(*pos) self.setFlag(self.ItemIgnoresTransformations) def boundingRect(self): # noqa r = super().boundingRect() if self.valign == QtCore.Qt.AlignTop: return QtCore.QRectF(-r.width() / 2, -37, r.width(), r.height()) elif self.valign == QtCore.Qt.AlignBottom: return QtCore.QRectF(-r.width() / 2, 15, r.width(), r.height()) def paint(self, p, option, widget): p.setRenderHint(p.Antialiasing, False) p.setRenderHint(p.TextAntialiasing, True) p.setPen(self.pen) if self.brush.style() != QtCore.Qt.NoBrush: p.setOpacity(self.opacity) 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('#dbdbdb') 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.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'] return fromtimestamp(Quotes[round(tick_pos)].time).strftime(tpl) def boundingRect(self): # noqa return QtCore.QRectF(0, 0, 60, 38) 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, 74, 24) 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 CustomPlotWidget(pg.PlotWidget): sig_mouse_leave = QtCore.Signal(object) sig_mouse_enter = QtCore.Signal(object) def enterEvent(self, ev): # noqa self.sig_mouse_enter.emit(self) def leaveEvent(self, ev): # noqa self.sig_mouse_leave.emit(self) self.scene().leaveEvent(ev) class CrossHairItem(pg.GraphicsObject): def __init__(self, parent, indicators=None, digits=0): super().__init__() self.pen = pg.mkPen('#000000') 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=60, slot=self.mouseMoved, ) self.yaxis_label = YAxisLabel( parent=self.yaxis, digits=digits, opacity=1 ) indicators = indicators or [] if indicators: last_ind = indicators[-1] self.xaxis_label = XAxisLabel( parent=last_ind.getAxis('bottom'), opacity=1 ) self.proxy_enter = pg.SignalProxy( self.parent.sig_mouse_enter, rateLimit=60, slot=lambda: self.mouseAction('Enter', False), ) self.proxy_leave = pg.SignalProxy( self.parent.sig_mouse_leave, rateLimit=60, slot=lambda: self.mouseAction('Leave', False), ) else: self.xaxis_label = XAxisLabel(parent=self.xaxis, opacity=1) for i in indicators: 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=60, slot=self.mouseMoved ) px_enter = pg.SignalProxy( i.sig_mouse_enter, rateLimit=60, slot=lambda: self.mouseAction('Enter', i), ) px_leave = pg.SignalProxy( i.sig_mouse_leave, rateLimit=60, 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': if ind: self.indicators[ind]['hl'].show() self.indicators[ind]['yl'].show() self.activeIndicator = ind else: self.yaxis_label.show() self.hline.show() else: # Leave 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 pos = evt[0] if self.parent.sceneBoundingRect().contains(pos): # mouse_point = self.vb.mapSceneToView(pos) mouse_point = self.parent.mapToView(pos) self.vline.setX(mouse_point.x()) self.xaxis_label.update_label(evt_post=pos, point_view=mouse_point) for opts in self.indicators.values(): opts['vl'].setX(mouse_point.x()) if self.activeIndicator: 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: 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): w = 0.35 bull_brush = pg.mkBrush('#00cc00') bear_brush = pg.mkBrush('#fa0000') def __init__(self): super().__init__() self.generatePicture() def _generate(self, p): hl = np.array( [QtCore.QLineF(q.id, q.low, q.id, q.high) for q in Quotes] ) op = np.array( [QtCore.QLineF(q.id - self.w, q.open, q.id, q.open) for q in Quotes] ) cl = np.array( [ QtCore.QLineF(q.id + self.w, q.close, q.id, q.close) for q in Quotes ] ) lines = np.concatenate([hl, op, cl]) 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]) @timeit def generatePicture(self): self.picture = QtGui.QPicture() p = QtGui.QPainter(self.picture) self._generate(p) p.end() def paint(self, p, *args): p.drawPicture(0, 0, self.picture) def boundingRect(self): 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 QuotesChart(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.style = ChartType.CANDLESTICK self.indicators = [] self.xaxis = DateAxis(orientation='bottom') self.xaxis.setStyle( tickTextOffset=7, textFillLimits=[(0, 0.80)], showValues=False ) self.xaxis_ind = DateAxis(orientation='bottom') self.xaxis_ind.setStyle(tickTextOffset=7, textFillLimits=[(0, 0.80)]) self.layout = QtGui.QVBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) self.splitter = QtGui.QSplitter(QtCore.Qt.Vertical) self.splitter.setHandleWidth(4) 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_quotes_chart(self): self.chart.hideAxis('left') self.chart.showAxis('right') self.chart.addItem(_get_chart_points(self.style)) self.chart.setLimits( xMin=Quotes[0].id, xMax=Quotes[-1].id, minXRange=60, yMin=Quotes.low.min() * 0.98, yMax=Quotes.high.max() * 1.02, ) self.chart.showGrid(x=True, y=True) self.chart.setCursor(QtCore.Qt.BlankCursor) self.chart.sigXRangeChanged.connect(self._update_yrange_limits) def _update_ind_charts(self): for ind, d in self.indicators: curve = pg.PlotDataItem(d, pen='b', antialias=True) ind.addItem(curve) ind.hideAxis('left') ind.showAxis('right') # ind.setAspectLocked(1) ind.setXLink(self.chart) ind.setLimits( xMin=Quotes[0].id, xMax=Quotes[-1].id, minXRange=60, yMin=Quotes.open.min() * 0.98, yMax=Quotes.open.max() * 1.02, ) ind.showGrid(x=True, y=True) ind.setCursor(QtCore.Qt.BlankCursor) def _update_sizes(self): min_h_ind = int(self.height() * 0.3 / len(self.indicators)) sizes = [int(self.height() * 0.7)] sizes.extend([min_h_ind] * len(self.indicators)) self.splitter.setSizes(sizes) # , int(self.height()*0.2) def _update_yrange_limits(self): vr = self.chart.viewRect() lbar, rbar = int(vr.left()), int(vr.right()) if self.signals_visible: self._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) self.chart.setLimits(yMin=ylow, yMax=yhigh, minYRange=std) self.chart.setYRange(ylow, yhigh) for i, d in self.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 plot(self, symbol): self.digits = symbol.digits self.chart = CustomPlotWidget( parent=self.splitter, axisItems={'bottom': self.xaxis, 'right': PriceAxis()}, enableMenu=False, ) self.chart.getPlotItem().setContentsMargins(*CHART_MARGINS) self.chart.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain) inds = [Quotes.open] for d in inds: ind = CustomPlotWidget( parent=self.splitter, axisItems={'bottom': self.xaxis_ind, 'right': PriceAxis()}, enableMenu=False, ) ind.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain) ind.getPlotItem().setContentsMargins(*CHART_MARGINS) # self.splitter.addWidget(ind) self.indicators.append((ind, d)) self._update_quotes_chart() self._update_ind_charts() 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 class EquityChart(QtGui.QWidget): eq_pen_pos_color = pg.mkColor('#00cc00') eq_pen_neg_color = pg.mkColor('#cc0000') eq_brush_pos_color = pg.mkColor('#40ee40') eq_brush_neg_color = pg.mkColor('#ee4040') long_pen_color = pg.mkColor('#008000') short_pen_color = pg.mkColor('#800000') buy_and_hold_pen_color = pg.mkColor('#4444ff') def __init__(self): super().__init__() self.xaxis = DateAxis(orientation='bottom') self.xaxis.setStyle(tickTextOffset=7, textFillLimits=[(0, 0.80)]) self.yaxis = PriceAxis() self.layout = QtGui.QVBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) self.chart = pg.PlotWidget( axisItems={'bottom': self.xaxis, 'right': self.yaxis}, enableMenu=False, ) self.chart.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain) self.chart.getPlotItem().setContentsMargins(*CHART_MARGINS) self.chart.showGrid(x=True, y=True) self.chart.hideAxis('left') self.chart.showAxis('right') self.chart.setCursor(QtCore.Qt.BlankCursor) self.chart.sigXRangeChanged.connect(self._update_yrange_limits) self.layout.addWidget(self.chart) def _add_legend(self): legend = pg.LegendItem((140, 100), offset=(10, 10)) legend.setParentItem(self.chart.getPlotItem()) for arr, item in self.curves: legend.addItem( SampleLegendItem(item), item.opts['name'] if not isinstance(item, tuple) else item[0].opts['name'], ) def _add_ylabels(self): self.ylabels = [] for arr, item in self.curves: color = ( item.opts['pen'] if not isinstance(item, tuple) else [i.opts['pen'] for i in item] ) label = YAxisLabel(parent=self.yaxis, color=color) self.ylabels.append(label) def _update_ylabels(self, vb, rbar): for i, curve in enumerate(self.curves): arr, item = curve ylast = arr[rbar] ypos = vb.mapFromView(QtCore.QPointF(0, ylast)).y() axlabel = self.ylabels[i] axlabel.update_label_test(ypos=ypos, ydata=ylast) def _update_yrange_limits(self, vb=None): if not hasattr(self, 'min_curve'): return vr = self.chart.viewRect() lbar, rbar = int(vr.left()), int(vr.right()) ylow = self.min_curve[lbar:rbar].min() * 1.1 yhigh = self.max_curve[lbar:rbar].max() * 1.1 std = np.std(self.max_curve[lbar:rbar]) * 4 self.chart.setLimits(yMin=ylow, yMax=yhigh, minYRange=std) self.chart.setYRange(ylow, yhigh) self._update_ylabels(vb, rbar) @timeit def plot(self): equity_curve = Portfolio.equity_curve eq_pos = np.zeros_like(equity_curve) eq_neg = np.zeros_like(equity_curve) eq_pos[equity_curve >= 0] = equity_curve[equity_curve >= 0] eq_neg[equity_curve <= 0] = equity_curve[equity_curve <= 0] # Equity self.eq_pos_curve = pg.PlotCurveItem( eq_pos, name='Equity', fillLevel=0, antialias=True, pen=self.eq_pen_pos_color, brush=self.eq_brush_pos_color, ) self.eq_neg_curve = pg.PlotCurveItem( eq_neg, name='Equity', fillLevel=0, antialias=True, pen=self.eq_pen_neg_color, brush=self.eq_brush_neg_color, ) self.chart.addItem(self.eq_pos_curve) self.chart.addItem(self.eq_neg_curve) # Only Long self.long_curve = pg.PlotCurveItem( Portfolio.long_curve, name='Only Long', pen=self.long_pen_color, antialias=True, ) self.chart.addItem(self.long_curve) # Only Short self.short_curve = pg.PlotCurveItem( Portfolio.short_curve, name='Only Short', pen=self.short_pen_color, antialias=True, ) self.chart.addItem(self.short_curve) # Buy and Hold self.buy_and_hold_curve = pg.PlotCurveItem( Portfolio.buy_and_hold_curve, name='Buy and Hold', pen=self.buy_and_hold_pen_color, antialias=True, ) self.chart.addItem(self.buy_and_hold_curve) self.curves = [ (Portfolio.equity_curve, (self.eq_pos_curve, self.eq_neg_curve)), (Portfolio.long_curve, self.long_curve), (Portfolio.short_curve, self.short_curve), (Portfolio.buy_and_hold_curve, self.buy_and_hold_curve), ] self._add_legend() self._add_ylabels() ch = CrossHairItem(self.chart) self.chart.addItem(ch) arrs = ( Portfolio.equity_curve, Portfolio.buy_and_hold_curve, Portfolio.long_curve, Portfolio.short_curve, ) np_arrs = np.concatenate(arrs) _min = abs(np_arrs.min()) * -1.1 _max = np_arrs.max() * 1.1 self.chart.setLimits( xMin=Quotes[0].id, xMax=Quotes[-1].id, yMin=_min, yMax=_max, minXRange=60, ) self.min_curve = arrs[0].copy() self.max_curve = arrs[0].copy() for arr in arrs[1:]: self.min_curve = np.minimum(self.min_curve, arr) self.max_curve = np.maximum(self.max_curve, arr) def _get_chart_points(style): if style == ChartType.CANDLESTICK: return CandlestickItem() elif style == ChartType.BAR: return BarItem() return pg.PlotDataItem(Quotes.close, pen='b')