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 #80bar_select
							parent
							
								
									9c84e3c45d
								
							
						
					
					
						commit
						eddd8aacab
					
				|  | @ -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 | ||||||
|  | """ | ||||||
|  | @ -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() | ||||||
|  | @ -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') | ||||||
|  | @ -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, | ||||||
|  | # } | ||||||
|  | @ -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 | ||||||
|  | @ -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 | ||||||
|  | @ -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] | ||||||
|  | @ -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)] | ||||||
		Loading…
	
		Reference in New Issue