From 42aa2bce5bb935dfba5b5591facb5b1d7d33f562 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 10 Jun 2020 12:50:09 -0400 Subject: [PATCH] Add charting components from `Quantdom` Hand select necessary components to get real-time charting with `pyqtgraph` from the `Quantdom` projects: https://github.com/constverum/Quantdom We've offered to collaborate with the author but have received no response and the project has not been updated in over a year. Given this, we are moving forward with taking the required components to make further improvements upon especially since the `pyqtgraph` project is now being actively maintained again. If the author comes back we will be more then happy to contribute modified components upstream: https://github.com/constverum/Quantdom/issues/18 Relates to #80 --- piker/ui/qt/quantdom/__init__.py | 10 + piker/ui/qt/quantdom/base.py | 132 +++++ piker/ui/qt/quantdom/charts.py | 799 ++++++++++++++++++++++++++++ piker/ui/qt/quantdom/const.py | 36 ++ piker/ui/qt/quantdom/loaders.py | 172 ++++++ piker/ui/qt/quantdom/performance.py | 350 ++++++++++++ piker/ui/qt/quantdom/portfolio.py | 410 ++++++++++++++ piker/ui/qt/quantdom/utils.py | 82 +++ 8 files changed, 1991 insertions(+) create mode 100644 piker/ui/qt/quantdom/__init__.py create mode 100644 piker/ui/qt/quantdom/base.py create mode 100644 piker/ui/qt/quantdom/charts.py create mode 100644 piker/ui/qt/quantdom/const.py create mode 100644 piker/ui/qt/quantdom/loaders.py create mode 100644 piker/ui/qt/quantdom/performance.py create mode 100644 piker/ui/qt/quantdom/portfolio.py create mode 100644 piker/ui/qt/quantdom/utils.py diff --git a/piker/ui/qt/quantdom/__init__.py b/piker/ui/qt/quantdom/__init__.py new file mode 100644 index 00000000..ae7e2e8c --- /dev/null +++ b/piker/ui/qt/quantdom/__init__.py @@ -0,0 +1,10 @@ +""" +Curated set of components from ``Quantdom`` used as a starting +draft for real-time charting with ``pyqtgraph``. + +Much thanks to the author: +https://github.com/constverum/Quantdom + +Note this code is licensed Apache 2.0: +https://github.com/constverum/Quantdom/blob/master/LICENSE +""" diff --git a/piker/ui/qt/quantdom/base.py b/piker/ui/qt/quantdom/base.py new file mode 100644 index 00000000..a53b18c2 --- /dev/null +++ b/piker/ui/qt/quantdom/base.py @@ -0,0 +1,132 @@ +"""Base classes.""" + +from enum import Enum, auto + +import numpy as np +import pandas as pd + +from .const import ChartType, TimeFrame + +__all__ = ('Indicator', 'Symbol', 'Quotes') + + +class BaseQuotes(np.recarray): + def __new__(cls, shape=None, dtype=None, order='C'): + dt = np.dtype( + [ + ('id', int), + ('time', float), + ('open', float), + ('high', float), + ('low', float), + ('close', float), + ('volume', int), + ] + ) + shape = shape or (1,) + return np.ndarray.__new__(cls, shape, (np.record, dt), order=order) + + def _nan_to_closest_num(self): + """Return interpolated values instead of NaN.""" + for col in ['open', 'high', 'low', 'close']: + mask = np.isnan(self[col]) + if not mask.size: + continue + self[col][mask] = np.interp( + np.flatnonzero(mask), np.flatnonzero(~mask), self[col][~mask] + ) + + def _set_time_frame(self, default_tf): + tf = { + 1: TimeFrame.M1, + 5: TimeFrame.M5, + 15: TimeFrame.M15, + 30: TimeFrame.M30, + 60: TimeFrame.H1, + 240: TimeFrame.H4, + 1440: TimeFrame.D1, + } + minutes = int(np.diff(self.time[-10:]).min() / 60) + self.timeframe = tf.get(minutes) or tf[default_tf] + + def new(self, data, source=None, default_tf=None): + shape = (len(data),) + self.resize(shape, refcheck=False) + + if isinstance(data, pd.DataFrame): + data.reset_index(inplace=True) + data.insert(0, 'id', data.index) + data.Date = self.convert_dates(data.Date) + data = data.rename( + columns={ + 'Date': 'time', + 'Open': 'open', + 'High': 'high', + 'Low': 'low', + 'Close': 'close', + 'Volume': 'volume', + } + ) + for name in self.dtype.names: + self[name] = data[name] + elif isinstance(data, (np.recarray, BaseQuotes)): + self[:] = data[:] + + self._nan_to_closest_num() + self._set_time_frame(default_tf) + return self + + def convert_dates(self, dates): + return np.array([d.timestamp() for d in dates]) + + +class SymbolType(Enum): + FOREX = auto() + CFD = auto() + FUTURES = auto() + SHARES = auto() + + +class Symbol: + + FOREX = SymbolType.FOREX + CFD = SymbolType.CFD + FUTURES = SymbolType.FUTURES + SHARES = SymbolType.SHARES + + def __init__(self, ticker, mode, tick_size=0, tick_value=None): + self.ticker = ticker + self.mode = mode + if self.mode in [self.FOREX, self.CFD]: + # number of units of the commodity, currency + # or financial asset in one lot + self.contract_size = 100_000 # (100000 == 1 Lot) + elif self.mode == self.FUTURES: + # cost of a single price change point ($10) / + # one minimum price movement + self.tick_value = tick_value + # minimum price change step (0.0001) + self.tick_size = tick_size + if isinstance(tick_size, float): + self.digits = len(str(tick_size).split('.')[1]) + else: + self.digits = 0 + + def __repr__(self): + return 'Symbol (%s | %s)' % (self.ticker, self.mode) + + +class Indicator: + def __init__( + self, label=None, window=None, data=None, tp=None, base=None, **kwargs + ): + self.label = label + self.window = window + self.data = data or [0] + self.type = tp or ChartType.LINE + self.base = base or {'linewidth': 0.5, 'color': 'black'} + self.lineStyle = {'linestyle': '-', 'linewidth': 0.5, 'color': 'blue'} + self.lineStyle.update(kwargs) + + +Quotes = BaseQuotes() diff --git a/piker/ui/qt/quantdom/charts.py b/piker/ui/qt/quantdom/charts.py new file mode 100644 index 00000000..c1cf3712 --- /dev/null +++ b/piker/ui/qt/quantdom/charts.py @@ -0,0 +1,799 @@ +"""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) + + +_rate_limit = 30 + + +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=_rate_limit, + 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=_rate_limit, + slot=lambda: self.mouseAction('Enter', False), + ) + self.proxy_leave = pg.SignalProxy( + self.parent.sig_mouse_leave, + rateLimit=_rate_limit, + 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=_rate_limit, slot=self.mouseMoved + ) + px_enter = pg.SignalProxy( + i.sig_mouse_enter, + rateLimit=_rate_limit, + slot=lambda: self.mouseAction('Enter', i), + ) + px_leave = pg.SignalProxy( + i.sig_mouse_leave, + rateLimit=_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': + 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.mkPen('#00cc00') + bear_brush = pg.mkPen('#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.BAR + 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') diff --git a/piker/ui/qt/quantdom/const.py b/piker/ui/qt/quantdom/const.py new file mode 100644 index 00000000..eee1f55e --- /dev/null +++ b/piker/ui/qt/quantdom/const.py @@ -0,0 +1,36 @@ +"""Constants.""" + +from enum import Enum, auto + +__all__ = ('ChartType', 'TimeFrame') + + +class ChartType(Enum): + BAR = auto() + CANDLESTICK = auto() + LINE = auto() + + +class TimeFrame(Enum): + M1 = auto() + M5 = auto() + M15 = auto() + M30 = auto() + H1 = auto() + H4 = auto() + D1 = auto() + W1 = auto() + MN = auto() + + +ANNUAL_PERIOD = 252 # number of trading days in a year + +# # TODO: 6.5 - US trading hours (trading session); fix it for fx +# ANNUALIZATION_FACTORS = { +# TimeFrame.M1: int(252 * 6.5 * 60), +# TimeFrame.M5: int(252 * 6.5 * 12), +# TimeFrame.M15: int(252 * 6.5 * 4), +# TimeFrame.M30: int(252 * 6.5 * 2), +# TimeFrame.H1: int(252 * 6.5), +# TimeFrame.D1: 252, +# } diff --git a/piker/ui/qt/quantdom/loaders.py b/piker/ui/qt/quantdom/loaders.py new file mode 100644 index 00000000..0bb18d89 --- /dev/null +++ b/piker/ui/qt/quantdom/loaders.py @@ -0,0 +1,172 @@ +"""Parser.""" + +import logging +import os.path +import pickle + +import pandas as pd +import pandas_datareader.data as web +from pandas_datareader._utils import RemoteDataError +from pandas_datareader.data import ( + get_data_quandl, + get_data_yahoo, + get_data_alphavantage, +) +from pandas_datareader.nasdaq_trader import get_nasdaq_symbols +from pandas_datareader.exceptions import ImmediateDeprecationError + +from .base import Quotes +from .utils import get_data_path, timeit + +__all__ = ( + 'YahooQuotesLoader', + 'QuandleQuotesLoader', + 'get_symbols', + 'get_quotes', +) + + +logger = logging.getLogger(__name__) + + +class QuotesLoader: + + source = None + timeframe = '1D' + sort_index = False + default_tf = None + name_format = '%(symbol)s_%(tf)s_%(date_from)s_%(date_to)s.%(ext)s' + + @classmethod + def _get(cls, symbol, date_from, date_to): + quotes = web.DataReader( + symbol, cls.source, start=date_from, end=date_to + ) + if cls.sort_index: + quotes.sort_index(inplace=True) + return quotes + + @classmethod + def _get_file_path(cls, symbol, tf, date_from, date_to): + fname = cls.name_format % { + 'symbol': symbol, + 'tf': tf, + 'date_from': date_from.isoformat(), + 'date_to': date_to.isoformat(), + 'ext': 'qdom', + } + return os.path.join(get_data_path('stock_data'), fname) + + @classmethod + def _save_to_disk(cls, fpath, data): + logger.debug('Saving quotes to a file: %s', fpath) + with open(fpath, 'wb') as f: + pickle.dump(data, f, pickle.HIGHEST_PROTOCOL) + + @classmethod + def _load_from_disk(cls, fpath): + logger.debug('Loading quotes from a file: %s', fpath) + with open(fpath, 'rb') as f: + return pickle.load(f) + + @classmethod + @timeit + def get_quotes(cls, symbol, date_from, date_to): + quotes = None + fpath = cls._get_file_path(symbol, cls.timeframe, date_from, date_to) + if os.path.exists(fpath): + quotes = Quotes.new(cls._load_from_disk(fpath)) + else: + quotes_raw = cls._get(symbol, date_from, date_to) + quotes = Quotes.new( + quotes_raw, source=cls.source, default_tf=cls.default_tf + ) + cls._save_to_disk(fpath, quotes) + return quotes + + +class YahooQuotesLoader(QuotesLoader): + + source = 'yahoo' + + @classmethod + def _get(cls, symbol, date_from, date_to): + return get_data_yahoo(symbol, date_from, date_to) + + +class QuandleQuotesLoader(QuotesLoader): + + source = 'quandle' + + @classmethod + def _get(cls, symbol, date_from, date_to): + quotes = get_data_quandl(symbol, date_from, date_to) + quotes.sort_index(inplace=True) + return quotes + + +class AlphaVantageQuotesLoader(QuotesLoader): + + source = 'alphavantage' + api_key = 'demo' + + @classmethod + def _get(cls, symbol, date_from, date_to): + quotes = get_data_alphavantage( + symbol, date_from, date_to, api_key=cls.api_key + ) + return quotes + + +class StooqQuotesLoader(QuotesLoader): + + source = 'stooq' + sort_index = True + default_tf = 1440 + + +class IEXQuotesLoader(QuotesLoader): + + source = 'iex' + + @classmethod + def _get(cls, symbol, date_from, date_to): + quotes = web.DataReader( + symbol, cls.source, start=date_from, end=date_to + ) + quotes['Date'] = pd.to_datetime(quotes.index) + return quotes + + +class RobinhoodQuotesLoader(QuotesLoader): + + source = 'robinhood' + + +def get_symbols(): + fpath = os.path.join(get_data_path('stock_data'), 'symbols.qdom') + if os.path.exists(fpath): + with open(fpath, 'rb') as f: + symbols = pickle.load(f) + else: + symbols = get_nasdaq_symbols() + symbols.reset_index(inplace=True) + with open(fpath, 'wb') as f: + pickle.dump(symbols, f, pickle.HIGHEST_PROTOCOL) + return symbols + + +def get_quotes(*args, **kwargs): + quotes = [] + # don't work: + # GoogleQuotesLoader, QuandleQuotesLoader, + # AlphaVantageQuotesLoader, RobinhoodQuotesLoader + loaders = [YahooQuotesLoader, IEXQuotesLoader, StooqQuotesLoader] + while loaders: + loader = loaders.pop(0) + try: + quotes = loader.get_quotes(*args, **kwargs) + break + except (RemoteDataError, ImmediateDeprecationError) as e: + logger.error('get_quotes => error: %r', e) + return quotes diff --git a/piker/ui/qt/quantdom/performance.py b/piker/ui/qt/quantdom/performance.py new file mode 100644 index 00000000..f95fecca --- /dev/null +++ b/piker/ui/qt/quantdom/performance.py @@ -0,0 +1,350 @@ +"""Performance.""" + +import codecs +import json +from collections import OrderedDict, defaultdict + +import numpy as np + +from .base import Quotes +from .const import ANNUAL_PERIOD +from .utils import fromtimestamp, get_resource_path + +__all__ = ( + 'BriefPerformance', + 'Performance', + 'Stats', + 'REPORT_COLUMNS', + 'REPORT_ROWS', +) + + +REPORT_COLUMNS = ('All', 'Long', 'Short', 'Market') +with codecs.open( + get_resource_path('report_rows.json'), mode='r', encoding='utf-8' +) as f: + REPORT_ROWS = OrderedDict(json.load(f)) + + +class Stats(np.recarray): + def __new__(cls, positions, shape=None, dtype=None, order='C'): + shape = shape or (len(positions['All']),) + dtype = np.dtype( + [ + ('type', object), + ('symbol', object), + ('volume', float), + ('open_time', float), + ('close_time', float), + ('open_price', float), + ('close_price', float), + ('total_profit', float), + ('entry_name', object), + ('exit_name', object), + ('status', object), + ('comment', object), + ('abs', float), + ('perc', float), + ('bars', float), + ('on_bar', float), + ('mae', float), + ('mfe', float), + ] + ) + dt = [(col, dtype) for col in REPORT_COLUMNS] + return np.ndarray.__new__(cls, shape, (np.record, dt), order=order) + + def __init__(self, positions, **kwargs): + for col, _positions in positions.items(): + for i, p in enumerate(_positions): + self._add_position(p, col, i) + + def _add_position(self, p, col, i): + self[col][i].type = p.type + self[col][i].symbol = p.symbol + self[col][i].volume = p.volume + self[col][i].open_time = p.open_time + self[col][i].close_time = p.close_time + self[col][i].open_price = p.open_price + self[col][i].close_price = p.close_price + self[col][i].total_profit = p.total_profit + self[col][i].entry_name = p.entry_name + self[col][i].exit_name = p.exit_name + self[col][i].status = p.status + self[col][i].comment = p.comment + self[col][i].abs = p.profit + self[col][i].perc = p.profit_perc + + quotes_on_trade = Quotes[p.id_bar_open : p.id_bar_close] + + if not quotes_on_trade.size: + # if position was opened and closed on the last bar + quotes_on_trade = Quotes[p.id_bar_open : p.id_bar_close + 1] + + kwargs = { + 'low': quotes_on_trade.low.min(), + 'high': quotes_on_trade.high.max(), + } + self[col][i].mae = p.calc_mae(**kwargs) + self[col][i].mfe = p.calc_mfe(**kwargs) + + bars = p.id_bar_close - p.id_bar_open + self[col][i].bars = bars + self[col][i].on_bar = p.profit_perc / bars + + +class BriefPerformance(np.recarray): + def __new__(cls, shape=None, dtype=None, order='C'): + dt = np.dtype( + [ + ('kwargs', object), + ('net_profit_abs', float), + ('net_profit_perc', float), + ('year_profit', float), + ('win_average_profit_perc', float), + ('loss_average_profit_perc', float), + ('max_drawdown_abs', float), + ('total_trades', int), + ('win_trades_abs', int), + ('win_trades_perc', float), + ('profit_factor', float), + ('recovery_factor', float), + ('payoff_ratio', float), + ] + ) + shape = shape or (1,) + return np.ndarray.__new__(cls, shape, (np.record, dt), order=order) + + def _days_count(self, positions): + if hasattr(self, 'days'): + return self.days + self.days = ( + ( + fromtimestamp(positions[-1].close_time) + - fromtimestamp(positions[0].open_time) + ).days + if positions + else 1 + ) + return self.days + + def add(self, initial_balance, positions, i, kwargs): + position_count = len(positions) + profit = np.recarray( + (position_count,), dtype=[('abs', float), ('perc', float)] + ) + for n, position in enumerate(positions): + profit[n].abs = position.profit + profit[n].perc = position.profit_perc + s = self[i] + s.kwargs = kwargs + s.net_profit_abs = np.sum(profit.abs) + s.net_profit_perc = np.sum(profit.perc) + days = self._days_count(positions) + gain_factor = (s.net_profit_abs + initial_balance) / initial_balance + s.year_profit = (gain_factor ** (365 / days) - 1) * 100 + s.win_average_profit_perc = np.mean(profit.perc[profit.perc > 0]) + s.loss_average_profit_perc = np.mean(profit.perc[profit.perc < 0]) + s.max_drawdown_abs = profit.abs.min() + s.total_trades = position_count + wins = profit.abs[profit.abs > 0] + loss = profit.abs[profit.abs < 0] + s.win_trades_abs = len(wins) + s.win_trades_perc = round(s.win_trades_abs / s.total_trades * 100, 2) + s.profit_factor = abs(np.sum(wins) / np.sum(loss)) + s.recovery_factor = abs(s.net_profit_abs / s.max_drawdown_abs) + s.payoff_ratio = abs(np.mean(wins) / np.mean(loss)) + + +class Performance: + """Performance Metrics.""" + + rows = REPORT_ROWS + columns = REPORT_COLUMNS + + def __init__(self, initial_balance, stats, positions): + self._data = {} + for col in self.columns: + column = type('Column', (object,), dict.fromkeys(self.rows, 0)) + column.initial_balance = initial_balance + self._data[col] = column + self.calculate(column, stats[col], positions[col]) + + def __getitem__(self, col): + return self._data[col] + + def _calc_trade_series(self, col, positions): + win_in_series, loss_in_series = 0, 0 + for i, p in enumerate(positions): + if p.profit >= 0: + win_in_series += 1 + loss_in_series = 0 + if win_in_series > col.win_in_series: + col.win_in_series = win_in_series + else: + win_in_series = 0 + loss_in_series += 1 + if loss_in_series > col.loss_in_series: + col.loss_in_series = loss_in_series + + def calculate(self, col, stats, positions): + self._calc_trade_series(col, positions) + + col.total_trades = len(positions) + + profit_abs = stats[np.flatnonzero(stats.abs)].abs + profit_perc = stats[np.flatnonzero(stats.perc)].perc + bars = stats[np.flatnonzero(stats.bars)].bars + on_bar = stats[np.flatnonzero(stats.on_bar)].on_bar + + gt_zero_abs = stats[stats.abs > 0].abs + gt_zero_perc = stats[stats.perc > 0].perc + win_bars = stats[stats.perc > 0].bars + + lt_zero_abs = stats[stats.abs < 0].abs + lt_zero_perc = stats[stats.perc < 0].perc + los_bars = stats[stats.perc < 0].bars + + col.average_profit_abs = np.mean(profit_abs) if profit_abs.size else 0 + col.average_profit_perc = ( + np.mean(profit_perc) if profit_perc.size else 0 + ) + col.bars_on_trade = np.mean(bars) if bars.size else 0 + col.bar_profit = np.mean(on_bar) if on_bar.size else 0 + + col.win_average_profit_abs = ( + np.mean(gt_zero_abs) if gt_zero_abs.size else 0 + ) + col.win_average_profit_perc = ( + np.mean(gt_zero_perc) if gt_zero_perc.size else 0 + ) + col.win_bars_on_trade = np.mean(win_bars) if win_bars.size else 0 + + col.loss_average_profit_abs = ( + np.mean(lt_zero_abs) if lt_zero_abs.size else 0 + ) + col.loss_average_profit_perc = ( + np.mean(lt_zero_perc) if lt_zero_perc.size else 0 + ) + col.loss_bars_on_trade = np.mean(los_bars) if los_bars.size else 0 + + col.win_trades_abs = len(gt_zero_abs) + col.win_trades_perc = ( + round(col.win_trades_abs / col.total_trades * 100, 2) + if col.total_trades + else 0 + ) + + col.loss_trades_abs = len(lt_zero_abs) + col.loss_trades_perc = ( + round(col.loss_trades_abs / col.total_trades * 100, 2) + if col.total_trades + else 0 + ) + + col.total_profit = np.sum(gt_zero_abs) + col.total_loss = np.sum(lt_zero_abs) + col.net_profit_abs = np.sum(stats.abs) + col.net_profit_perc = np.sum(stats.perc) + col.total_mae = np.sum(stats.mae) + col.total_mfe = np.sum(stats.mfe) + + # https://financial-calculators.com/roi-calculator + + days = ( + ( + fromtimestamp(positions[-1].close_time) + - fromtimestamp(positions[0].open_time) + ).days + if positions + else 1 + ) + gain_factor = ( + col.net_profit_abs + col.initial_balance + ) / col.initial_balance + col.year_profit = (gain_factor ** (365 / days) - 1) * 100 + col.month_profit = (gain_factor ** (365 / days / 12) - 1) * 100 + + col.max_profit_abs = stats.abs.max() + col.max_profit_perc = stats.perc.max() + col.max_profit_abs_day = fromtimestamp( + stats.close_time[stats.abs == col.max_profit_abs][0] + ) + col.max_profit_perc_day = fromtimestamp( + stats.close_time[stats.perc == col.max_profit_perc][0] + ) + + col.max_drawdown_abs = stats.abs.min() + col.max_drawdown_perc = stats.perc.min() + col.max_drawdown_abs_day = fromtimestamp( + stats.close_time[stats.abs == col.max_drawdown_abs][0] + ) + col.max_drawdown_perc_day = fromtimestamp( + stats.close_time[stats.perc == col.max_drawdown_perc][0] + ) + + col.profit_factor = ( + abs(col.total_profit / col.total_loss) if col.total_loss else 0 + ) + col.recovery_factor = ( + abs(col.net_profit_abs / col.max_drawdown_abs) + if col.max_drawdown_abs + else 0 + ) + col.payoff_ratio = ( + abs(col.win_average_profit_abs / col.loss_average_profit_abs) + if col.loss_average_profit_abs + else 0 + ) + col.sharpe_ratio = annualized_sharpe_ratio(stats) + col.sortino_ratio = annualized_sortino_ratio(stats) + + # TODO: + col.alpha_ratio = np.nan + col.beta_ratio = np.nan + + +def day_percentage_returns(stats): + days = defaultdict(float) + trade_count = np.count_nonzero(stats) + + if trade_count == 1: + # market position, so returns should based on quotes + # calculate percentage changes on a list of quotes + changes = np.diff(Quotes.close) / Quotes[:-1].close * 100 + data = np.column_stack((Quotes[1:].time, changes)) # np.c_ + else: + # slice `:trade_count` to exclude zero values in long/short columns + data = stats[['close_time', 'perc']][:trade_count] + + # FIXME: [FutureWarning] https://github.com/numpy/numpy/issues/8383 + for close_time, perc in data: + days[fromtimestamp(close_time).date()] += perc + returns = np.array(list(days.values())) + + # if np.count_nonzero(stats) == 1: + # import pudb; pudb.set_trace() + if len(returns) >= ANNUAL_PERIOD: + return returns + + _returns = np.zeros(ANNUAL_PERIOD) + _returns[: len(returns)] = returns + return _returns + + +def annualized_sharpe_ratio(stats): + # risk_free = 0 + returns = day_percentage_returns(stats) + return np.sqrt(ANNUAL_PERIOD) * np.mean(returns) / np.std(returns) + + +def annualized_sortino_ratio(stats): + # http://www.cmegroup.com/education/files/sortino-a-sharper-ratio.pdf + required_return = 0 + returns = day_percentage_returns(stats) + mask = [returns < required_return] + tdd = np.zeros(len(returns)) + tdd[mask] = returns[mask] # keep only negative values and zeros + # "or 1" to prevent division by zero, if we don't have negative returns + tdd = np.sqrt(np.mean(np.square(tdd))) or 1 + return np.sqrt(ANNUAL_PERIOD) * np.mean(returns) / tdd diff --git a/piker/ui/qt/quantdom/portfolio.py b/piker/ui/qt/quantdom/portfolio.py new file mode 100644 index 00000000..79908d61 --- /dev/null +++ b/piker/ui/qt/quantdom/portfolio.py @@ -0,0 +1,410 @@ +"""Portfolio.""" + +import itertools +from contextlib import contextmanager +from enum import Enum, auto + +import numpy as np + +from .base import Quotes +from .performance import BriefPerformance, Performance, Stats +from .utils import fromtimestamp, timeit + +__all__ = ('Portfolio', 'Position', 'Order') + + +class BasePortfolio: + def __init__(self, balance=100_000, leverage=5): + self._initial_balance = balance + self.balance = balance + self.equity = None + # TODO: + # self.cash + # self.currency + self.leverage = leverage + self.positions = [] + + self.balance_curve = None + self.equity_curve = None + self.long_curve = None + self.short_curve = None + self.mae_curve = None + self.mfe_curve = None + + self.stats = None + self.performance = None + self.brief_performance = None + + def clear(self): + self.positions.clear() + self.balance = self._initial_balance + + @property + def initial_balance(self): + return self._initial_balance + + @initial_balance.setter + def initial_balance(self, value): + self._initial_balance = value + + def add_position(self, position): + position.ticket = len(self.positions) + 1 + self.positions.append(position) + + def position_count(self, tp=None): + if tp == Order.BUY: + return len([p for p in self.positions if p.type == Order.BUY]) + elif tp == Order.SELL: + return len([p for p in self.positions if p.type == Order.SELL]) + return len(self.positions) + + def _close_open_positions(self): + for p in self.positions: + if p.status == Position.OPEN: + p.close( + price=Quotes[-1].open, volume=p.volume, time=Quotes[-1].time + ) + + def _get_market_position(self): + p = self.positions[0] # real postions + p = Position( + symbol=p.symbol, + ptype=Order.BUY, + volume=p.volume, + price=Quotes[0].open, + open_time=Quotes[0].time, + close_price=Quotes[-1].close, + close_time=Quotes[-1].time, + id_bar_close=len(Quotes) - 1, + status=Position.CLOSED, + ) + p.profit = p.calc_profit(close_price=Quotes[-1].close) + p.profit_perc = p.profit / self._initial_balance * 100 + return p + + def _calc_equity_curve(self): + """Equity curve.""" + self.equity_curve = np.zeros_like(Quotes.time) + for i, p in enumerate(self.positions): + balance = np.sum(self.stats['All'][:i].abs) + for ibar in range(p.id_bar_open, p.id_bar_close): + profit = p.calc_profit(close_price=Quotes[ibar].close) + self.equity_curve[ibar] = balance + profit + # taking into account the real balance after the last trade + self.equity_curve[-1] = self.balance_curve[-1] + + def _calc_buy_and_hold_curve(self): + """Buy and Hold.""" + p = self._get_market_position() + self.buy_and_hold_curve = np.array( + [p.calc_profit(close_price=price) for price in Quotes.close] + ) + + def _calc_long_short_curves(self): + """Only Long/Short positions curve.""" + self.long_curve = np.zeros_like(Quotes.time) + self.short_curve = np.zeros_like(Quotes.time) + + for i, p in enumerate(self.positions): + if p.type == Order.BUY: + name = 'Long' + curve = self.long_curve + else: + name = 'Short' + curve = self.short_curve + balance = np.sum(self.stats[name][:i].abs) + # Calculate equity for this position + for ibar in range(p.id_bar_open, p.id_bar_close): + profit = p.calc_profit(close_price=Quotes[ibar].close) + curve[ibar] = balance + profit + + for name, curve in [ + ('Long', self.long_curve), + ('Short', self.short_curve), + ]: + curve[:] = fill_zeros_with_last(curve) + # taking into account the real balance after the last trade + curve[-1] = np.sum(self.stats[name].abs) + + def _calc_curves(self): + self.mae_curve = np.cumsum(self.stats['All'].mae) + self.mfe_curve = np.cumsum(self.stats['All'].mfe) + self.balance_curve = np.cumsum(self.stats['All'].abs) + self._calc_equity_curve() + self._calc_buy_and_hold_curve() + self._calc_long_short_curves() + + @contextmanager + def optimization_mode(self): + """Backup and restore current balance and positions.""" + # mode='general', + self.backup_balance = self.balance + self.backup_positions = self.positions.copy() + self.balance = self._initial_balance + self.positions.clear() + yield + self.balance = self.backup_balance + self.positions = self.backup_positions.copy() + self.backup_positions.clear() + + @timeit + def run_optimization(self, strategy, params): + keys = list(params.keys()) + vals = list(params.values()) + variants = list(itertools.product(*vals)) + self.brief_performance = BriefPerformance(shape=(len(variants),)) + with self.optimization_mode(): + for i, vals in enumerate(variants): + kwargs = {keys[n]: val for n, val in enumerate(vals)} + strategy.start(**kwargs) + self._close_open_positions() + self.brief_performance.add( + self._initial_balance, self.positions, i, kwargs + ) + self.clear() + + @timeit + def summarize(self): + self._close_open_positions() + positions = { + 'All': self.positions, + 'Long': [p for p in self.positions if p.type == Order.BUY], + 'Short': [p for p in self.positions if p.type == Order.SELL], + 'Market': [self._get_market_position()], + } + self.stats = Stats(positions) + self.performance = Performance( + self._initial_balance, self.stats, positions + ) + self._calc_curves() + + +Portfolio = BasePortfolio() + + +class PositionStatus(Enum): + OPEN = auto() + CLOSED = auto() + CANCELED = auto() + + +class Position: + + OPEN = PositionStatus.OPEN + CLOSED = PositionStatus.CLOSED + CANCELED = PositionStatus.CANCELED + + __slots__ = ( + 'type', + 'symbol', + 'ticket', + 'open_price', + 'close_price', + 'open_time', + 'close_time', + 'volume', + 'sl', + 'tp', + 'status', + 'profit', + 'profit_perc', + 'commis', + 'id_bar_open', + 'id_bar_close', + 'entry_name', + 'exit_name', + 'total_profit', + 'comment', + ) + + def __init__( + self, + symbol, + ptype, + price, + volume, + open_time, + sl=None, + tp=None, + status=OPEN, + entry_name='', + exit_name='', + comment='', + **kwargs, + ): + self.type = ptype + self.symbol = symbol + self.ticket = None + self.open_price = price + self.close_price = None + self.open_time = open_time + self.close_time = None + self.volume = volume + self.sl = sl + self.tp = tp + self.status = status + self.profit = None + self.profit_perc = None + self.commis = None + self.id_bar_open = np.where(Quotes.time == self.open_time)[0][0] + self.id_bar_close = None + self.entry_name = entry_name + self.exit_name = exit_name + self.total_profit = 0 + self.comment = comment + # self.bars_on_trade = None + # self.is_profitable = False + + for k, v in kwargs.items(): + setattr(self, k, v) + + def __repr__(self): + _type = 'LONG' if self.type == Order.BUY else 'SHORT' + time = fromtimestamp(self.open_time).strftime('%d.%m.%y %H:%M') + return '%s/%s/[%s - %.4f]' % ( + self.status.name, + _type, + time, + self.open_price, + ) + + def close(self, price, time, volume=None): + # TODO: allow closing only part of the volume + self.close_price = price + self.close_time = time + self.id_bar_close = np.where(Quotes.time == self.close_time)[0][0] + self.profit = self.calc_profit(volume=volume or self.volume) + self.profit_perc = self.profit / Portfolio.balance * 100 + + Portfolio.balance += self.profit + + self.total_profit = Portfolio.balance - Portfolio.initial_balance + self.status = self.CLOSED + + def calc_profit(self, volume=None, close_price=None): + # TODO: rewrite it + close_price = close_price or self.close_price + volume = volume or self.volume + factor = 1 if self.type == Order.BUY else -1 + price_delta = (close_price - self.open_price) * factor + if self.symbol.mode in [self.symbol.FOREX, self.symbol.CFD]: + # Margin: Lots*Contract_Size/Leverage + if ( + self.symbol.mode == self.symbol.FOREX + and self.symbol.ticker[:3] == 'USD' + ): + # Example: 'USD/JPY' + # Прибыль Размер Объем Текущий + # в пунктах пункта позиции курс + # 1 * 0.0001 * 100000 / 1.00770 + # USD/CHF: 1*0.0001*100000/1.00770 = $9.92 + # 0.01 + # USD/JPY: 1*0.01*100000/121.35 = $8.24 + # (1.00770-1.00595)/0.0001 = 17.5 пунктов + # (1.00770-1.00595)/0.0001*0.0001*100000*1/1.00770*1 + _points = price_delta / self.symbol.tick_size + _profit = ( + _points + * self.symbol.tick_size + * self.symbol.contract_size + / close_price + * volume + ) + elif ( + self.symbol.mode == self.symbol.FOREX + and self.symbol.ticker[-3:] == 'USD' + ): + # Example: 'EUR/USD' + # Profit: (close_price-open_price)*Contract_Size*Lots + # EUR/USD BUY: (1.05875-1.05850)*100000*1 = +$25 (без комиссии) + _profit = price_delta * self.symbol.contract_size * volume + else: + # Cross rates. Example: 'GBP/CHF' + # Цена пункта = + # объем поз.*размер п.*тек.курс баз.вал. к USD/тек. кросс-курс + # GBP/CHF: 100000*0.0001*1.48140/1.48985 = $9.94 + # TODO: temporary patch (same as the previous choice) - + # in the future connect to some quotes provider and get rates + _profit = price_delta * self.symbol.contract_size * volume + elif self.symbol.mode == self.symbol.FUTURES: + # Margin: Lots *InitialMargin*Percentage/100 + # Profit: (close_price-open_price)*TickPrice/TickSize*Lots + # CL BUY: (46.35-46.30)*10/0.01*1 = $50 (без учета комиссии!) + # EuroFX(6E) BUY:(1.05875-1.05850)*12.50/0.0001*1 =$31.25 (без ком) + # RTS (RIH5) BUY:(84510-84500)*12.26506/10*1 = @12.26506 (без ком) + # E-miniSP500 BUY:(2065.95-2065.25)*12.50/0.25 = $35 (без ком) + # http://americanclearing.ru/specifications.php + # http://www.moex.com/ru/contract.aspx?code=RTS-3.18 + # http://www.cmegroup.com/trading/equity-index/us-index/e-mini-sandp500_contract_specifications.html + _profit = ( + price_delta + * self.symbol.tick_value + / self.symbol.tick_size + * volume + ) + else: + # shares + _profit = price_delta * volume + + return _profit + + def calc_mae(self, low, high): + """Return [MAE] Maximum Adverse Excursion.""" + if self.type == Order.BUY: + return self.calc_profit(close_price=low) + return self.calc_profit(close_price=high) + + def calc_mfe(self, low, high): + """Return [MFE] Maximum Favorable Excursion.""" + if self.type == Order.BUY: + return self.calc_profit(close_price=high) + return self.calc_profit(close_price=low) + + +class OrderType(Enum): + BUY = auto() + SELL = auto() + BUY_LIMIT = auto() + SELL_LIMIT = auto() + BUY_STOP = auto() + SELL_STOP = auto() + + +class Order: + + BUY = OrderType.BUY + SELL = OrderType.SELL + BUY_LIMIT = OrderType.BUY_LIMIT + SELL_LIMIT = OrderType.SELL_LIMIT + BUY_STOP = OrderType.BUY_STOP + SELL_STOP = OrderType.SELL_STOP + + @staticmethod + def open(symbol, otype, price, volume, time, sl=None, tp=None): + # TODO: add margin calculation + # and if the margin is not enough - do not open the position + position = Position( + symbol=symbol, + ptype=otype, + price=price, + volume=volume, + open_time=time, + sl=sl, + tp=tp, + ) + Portfolio.add_position(position) + return position + + @staticmethod + def close(position, price, time, volume=None): + # FIXME: may be closed not the whole volume, but + # the position status will be changed to CLOSED + position.close(price=price, time=time, volume=volume) + + +def fill_zeros_with_last(arr): + """Fill empty(zero) elements (between positions).""" + index = np.arange(len(arr)) + index[arr == 0] = 0 + index = np.maximum.accumulate(index) + return arr[index] diff --git a/piker/ui/qt/quantdom/utils.py b/piker/ui/qt/quantdom/utils.py new file mode 100644 index 00000000..0324b16e --- /dev/null +++ b/piker/ui/qt/quantdom/utils.py @@ -0,0 +1,82 @@ +"""Utils.""" + +import importlib.util +import inspect +import logging +import os +import os.path +import sys +import time +from datetime import datetime +from functools import wraps + +from PyQt5 import QtCore + +__all__ = ( + 'BASE_DIR', + 'Settings', + 'timeit', + 'fromtimestamp', + 'get_data_path', + 'get_resource_path', + 'strategies_from_file', +) + + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def get_data_path(path=''): + data_path = QtCore.QStandardPaths.writableLocation( + QtCore.QStandardPaths.AppDataLocation + ) + data_path = os.path.join(data_path, path) + os.makedirs(data_path, mode=0o755, exist_ok=True) + return data_path + + +def get_resource_path(relative_path): + # PyInstaller creates a temp folder and stores path in _MEIPASS + base_path = getattr(sys, '_MEIPASS', BASE_DIR) + return os.path.join(base_path, relative_path) + + +config_path = os.path.join(get_data_path(), 'Quantdom', 'config.ini') +Settings = QtCore.QSettings(config_path, QtCore.QSettings.IniFormat) + + +def timeit(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + t = time.time() + res = fn(*args, **kwargs) + logger = logging.getLogger('runtime') + logger.debug( + '%s.%s: %.4f sec' + % (fn.__module__, fn.__qualname__, time.time() - t) + ) + return res + + return wrapper + + +def fromtimestamp(timestamp): + if timestamp == 0: + # on Win zero timestamp cause error + return datetime(1970, 1, 1) + return datetime.fromtimestamp(timestamp) + + +def strategies_from_file(filepath): + from .strategy import AbstractStrategy + + spec = importlib.util.spec_from_file_location('Strategy', filepath) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + is_strategy = lambda _class: ( # noqa:E731 + inspect.isclass(_class) + and issubclass(_class, AbstractStrategy) + and _class.__name__ != 'AbstractStrategy' + ) + return [_class for _, _class in inspect.getmembers(module, is_strategy)]