Styling, start re-org, commenting

- Move out equity plotting to new module.
- Make axis margins and fonts look good on i3.
- Adjust axis labels colors to gray.
- Start commenting a lot of the code after figuring out what it all does
  when cross referencing with ``pyqtgraph``.
- Add option to move date axis to middle.
its_happening
Tyler Goodlet 2020-06-13 09:49:21 -04:00
parent 49949ae6d5
commit 9d4a432757
6 changed files with 405 additions and 237 deletions

View File

@ -32,7 +32,6 @@ def run_qtrio(
args, args,
main_widget, main_widget,
) -> None: ) -> None:
# avoids annoying message when entering debugger from qt loop # avoids annoying message when entering debugger from qt loop
pyqtRemoveInputHook() pyqtRemoveInputHook()

View File

@ -0,0 +1,188 @@
"""
Strategy and performance charting
"""
import numpy as np
import pyqtgraph as pg
from PyQt5 import QtCore, QtGui
from .base import Quotes
from .portfolio import Portfolio
from .utils import timeit
from .charts import (
PriceAxis,
CHART_MARGINS,
SampleLegendItem,
YAxisLabel,
CrossHairItem,
)
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 = pg.DateAxisItem()
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)

View File

@ -10,6 +10,16 @@ from .const import ChartType, TimeFrame
__all__ = ('Indicator', 'Symbol', 'Quotes') __all__ = ('Indicator', 'Symbol', 'Quotes')
# I actually can't think of a worse reason to override an array than
# this:
# - a method .new() that mutates the data from an input data frame
# - mutating the time column wholesale based on a setting
# - enforcing certain fields / columns
# - zero overriding of any of the array interface for the purposes of
# a different underlying implementation.
# Literally all this can be done in a simple function with way less
# confusion for the reader.
class BaseQuotes(np.recarray): class BaseQuotes(np.recarray):
def __new__(cls, shape=None, dtype=None, order='C'): def __new__(cls, shape=None, dtype=None, order='C'):
dt = np.dtype( dt = np.dtype(
@ -49,6 +59,8 @@ class BaseQuotes(np.recarray):
minutes = int(np.diff(self.time[-10:]).min() / 60) minutes = int(np.diff(self.time[-10:]).min() / 60)
self.timeframe = tf.get(minutes) or tf[default_tf] self.timeframe = tf.get(minutes) or tf[default_tf]
# bruh this isn't creating anything it's copying data in
# from a data frame...
def new(self, data, source=None, default_tf=None): def new(self, data, source=None, default_tf=None):
shape = (len(data),) shape = (len(data),)
self.resize(shape, refcheck=False) self.resize(shape, refcheck=False)
@ -77,7 +89,7 @@ class BaseQuotes(np.recarray):
return self return self
def convert_dates(self, dates): def convert_dates(self, dates):
return np.array([d.timestamp() for d in dates]) return np.array([d.timestamp().time for d in dates])
class SymbolType(Enum): class SymbolType(Enum):
@ -129,4 +141,6 @@ class Indicator:
self.lineStyle.update(kwargs) self.lineStyle.update(kwargs)
# This creates a global array that seems to be shared between all
# charting UI components
Quotes = BaseQuotes() Quotes = BaseQuotes()

View File

@ -1,7 +1,11 @@
"""Chart.""" """
Real-time quotes charting components
"""
from typing import Callable
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from pyqtgraph import functions as fn
from PyQt5 import QtCore, QtGui from PyQt5 import QtCore, QtGui
from .base import Quotes from .base import Quotes
@ -9,14 +13,22 @@ from .const import ChartType
from .portfolio import Order, Portfolio from .portfolio import Order, Portfolio
from .utils import fromtimestamp, timeit from .utils import fromtimestamp, timeit
__all__ = ('QuotesChart', 'EquityChart') __all__ = ('QuotesChart')
# white background for tinas like xb
# pg.setConfigOption('background', 'w') # pg.setConfigOption('background', 'w')
CHART_MARGINS = (0, 0, 20, 5)
# margins
CHART_MARGINS = (0, 0, 10, 3)
# chart-wide font
_font = QtGui.QFont("Hack", 4)
_i3_rgba = QtGui.QColor.fromRgbF(*[0.14]*3 + [1])
class SampleLegendItem(pg.graphicsItems.LegendItem.ItemSample): class SampleLegendItem(pg.graphicsItems.LegendItem.ItemSample):
def paint(self, p, *args): def paint(self, p, *args):
p.setRenderHint(p.Antialiasing) p.setRenderHint(p.Antialiasing)
if isinstance(self.item, tuple): if isinstance(self.item, tuple):
@ -51,32 +63,58 @@ class SampleLegendItem(pg.graphicsItems.LegendItem.ItemSample):
class PriceAxis(pg.AxisItem): class PriceAxis(pg.AxisItem):
def __init__(self): def __init__(self):
super().__init__(orientation='right') super().__init__(orientation='right')
self.style.update({'textFillLimits': [(0, 0.8)]}) # self.setStyle(**{
# 'textFillLimits': [(0, 0.8)],
# # 'tickTextWidth': 5,
# # 'tickTextHeight': 5,
# # 'autoExpandTextSpace': True,
# # 'maxTickLength': -20,
# })
# self.setLabel(**{'font-size':'10pt'})
self.setTickFont(_font)
def tickStrings(self, vals, scale, spacing): # XXX: drop for now since it just eats up h space
digts = max(0, np.ceil(-np.log10(spacing * scale)))
return [ # def tickStrings(self, vals, scale, spacing):
('{:<8,.%df}' % digts).format(v).replace(',', ' ') for v in vals # digts = max(0, np.ceil(-np.log10(spacing * scale)))
] # return [
# ('{:<8,.%df}' % digts).format(v).replace(',', ' ') for v in vals
# ]
class DateAxis(pg.AxisItem): class FromTimeFieldDateAxis(pg.AxisItem):
tick_tpl = {'D1': '%d %b\n%Y'} tick_tpl = {'D1': '%Y-%b-%d'}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.setTickFont(_font)
self.quotes_count = len(Quotes) - 1 self.quotes_count = len(Quotes) - 1
# default styling
self.setStyle(
tickTextOffset=7,
textFillLimits=[(0, 0.90)],
# TODO: doesn't seem to work -> bug in pyqtgraph?
# tickTextHeight=2,
)
def tickStrings(self, values, scale, spacing): def tickStrings(self, values, scale, spacing):
# if len(values) > 1 or not values:
# values = Quotes.time
# strings = super().tickStrings(values, scale, spacing)
s_period = 'D1' s_period = 'D1'
strings = [] strings = []
for ibar in values: for ibar in values:
if ibar > self.quotes_count: if ibar > self.quotes_count:
return strings return strings
dt_tick = fromtimestamp(Quotes[int(ibar)].time) dt_tick = fromtimestamp(Quotes[int(ibar)].time)
strings.append(dt_tick.strftime(self.tick_tpl[s_period])) strings.append(
dt_tick.strftime(self.tick_tpl[s_period])
)
return strings return strings
@ -121,7 +159,8 @@ class CenteredTextItem(QtGui.QGraphicsTextItem):
class AxisLabel(pg.GraphicsObject): class AxisLabel(pg.GraphicsObject):
bg_color = pg.mkColor('#dbdbdb') # bg_color = pg.mkColor('#a9a9a9')
bg_color = pg.mkColor('#808080')
fg_color = pg.mkColor('#000000') fg_color = pg.mkColor('#000000')
def __init__(self, parent=None, digits=0, color=None, opacity=1, **kwargs): def __init__(self, parent=None, digits=0, color=None, opacity=1, **kwargs):
@ -130,13 +169,15 @@ class AxisLabel(pg.GraphicsObject):
self.opacity = opacity self.opacity = opacity
self.label_str = '' self.label_str = ''
self.digits = digits self.digits = digits
self.quotes_count = len(Quotes) - 1 # self.quotes_count = len(Quotes) - 1
if isinstance(color, QtGui.QPen): if isinstance(color, QtGui.QPen):
self.bg_color = color.color() self.bg_color = color.color()
self.fg_color = pg.mkColor('#ffffff') self.fg_color = pg.mkColor('#ffffff')
elif isinstance(color, list): elif isinstance(color, list):
self.bg_color = {'>0': color[0].color(), '<0': color[1].color()} self.bg_color = {'>0': color[0].color(), '<0': color[1].color()}
self.fg_color = pg.mkColor('#ffffff') self.fg_color = pg.mkColor('#ffffff')
self.setFlag(self.ItemIgnoresTransformations) self.setFlag(self.ItemIgnoresTransformations)
def tick_to_string(self, tick_pos): def tick_to_string(self, tick_pos):
@ -169,6 +210,8 @@ class AxisLabel(pg.GraphicsObject):
p.setOpacity(self.opacity) p.setOpacity(self.opacity)
p.fillRect(option.rect, bg_color) p.fillRect(option.rect, bg_color)
p.setOpacity(1) p.setOpacity(1)
p.setFont(_font)
p.drawText(option.rect, self.text_flags, self.label_str) p.drawText(option.rect, self.text_flags, self.label_str)
@ -184,12 +227,12 @@ class XAxisLabel(AxisLabel):
return fromtimestamp(Quotes[round(tick_pos)].time).strftime(tpl) return fromtimestamp(Quotes[round(tick_pos)].time).strftime(tpl)
def boundingRect(self): # noqa def boundingRect(self): # noqa
return QtCore.QRectF(0, 0, 60, 38) return QtCore.QRectF(0, 0, 145, 50)
def update_label(self, evt_post, point_view): def update_label(self, evt_post, point_view):
ibar = point_view.x() ibar = point_view.x()
if ibar > self.quotes_count: # if ibar > self.quotes_count:
return # return
self.label_str = self.tick_to_string(ibar) self.label_str = self.tick_to_string(ibar)
width = self.boundingRect().width() width = self.boundingRect().width()
offset = 0 # if have margins offset = 0 # if have margins
@ -207,7 +250,7 @@ class YAxisLabel(AxisLabel):
return ('{: ,.%df}' % self.digits).format(tick_pos).replace(',', ' ') return ('{: ,.%df}' % self.digits).format(tick_pos).replace(',', ' ')
def boundingRect(self): # noqa def boundingRect(self): # noqa
return QtCore.QRectF(0, 0, 74, 24) return QtCore.QRectF(0, 0, 80, 40)
def update_label(self, evt_post, point_view): def update_label(self, evt_post, point_view):
self.label_str = self.tick_to_string(point_view.y()) self.label_str = self.tick_to_string(point_view.y())
@ -217,10 +260,28 @@ class YAxisLabel(AxisLabel):
self.setPos(new_pos) self.setPos(new_pos)
# TODO: convert this to a ``ViewBox`` type giving us
# control over mouse scrolling and a context menu
class CustomPlotWidget(pg.PlotWidget): class CustomPlotWidget(pg.PlotWidget):
sig_mouse_leave = QtCore.Signal(object) sig_mouse_leave = QtCore.Signal(object)
sig_mouse_enter = QtCore.Signal(object) sig_mouse_enter = QtCore.Signal(object)
# def wheelEvent(self, ev, axis=None):
# if axis in (0, 1):
# mask = [False, False]
# mask[axis] = self.state['mouseEnabled'][axis]
# else:
# mask = self.state['mouseEnabled'][:]
# # actual scaling factor
# s = 1.02 ** (ev.delta() * self.state['wheelScaleFactor'])
# s = [(None if m is False else s) for m in mask]
# self._resetTarget()
# self.scaleBy(s, center)
# ev.accept()
# self.sigRangeChangedManually.emit(mask)
def enterEvent(self, ev): # noqa def enterEvent(self, ev): # noqa
self.sig_mouse_enter.emit(self) self.sig_mouse_enter.emit(self)
@ -229,7 +290,8 @@ class CustomPlotWidget(pg.PlotWidget):
self.scene().leaveEvent(ev) self.scene().leaveEvent(ev)
_rate_limit = 30 _mouse_rate_limit = 60
_xaxis_at = 'bottom'
class CrossHairItem(pg.GraphicsObject): class CrossHairItem(pg.GraphicsObject):
@ -249,7 +311,7 @@ class CrossHairItem(pg.GraphicsObject):
self.proxy_moved = pg.SignalProxy( self.proxy_moved = pg.SignalProxy(
self.parent.scene().sigMouseMoved, self.parent.scene().sigMouseMoved,
rateLimit=_rate_limit, rateLimit=_mouse_rate_limit,
slot=self.mouseMoved, slot=self.mouseMoved,
) )
@ -258,39 +320,52 @@ class CrossHairItem(pg.GraphicsObject):
) )
indicators = indicators or [] indicators = indicators or []
if indicators: if indicators:
# when there are indicators present in sub-plot rows
# take the last one (nearest to the bottom) and place the
# crosshair label on it's x-axis.
last_ind = indicators[-1] last_ind = indicators[-1]
self.xaxis_label = XAxisLabel(
parent=last_ind.getAxis('bottom'), opacity=1
)
self.proxy_enter = pg.SignalProxy( self.proxy_enter = pg.SignalProxy(
self.parent.sig_mouse_enter, self.parent.sig_mouse_enter,
rateLimit=_rate_limit, rateLimit=_mouse_rate_limit,
slot=lambda: self.mouseAction('Enter', False), slot=lambda: self.mouseAction('Enter', False),
) )
self.proxy_leave = pg.SignalProxy( self.proxy_leave = pg.SignalProxy(
self.parent.sig_mouse_leave, self.parent.sig_mouse_leave,
rateLimit=_rate_limit, rateLimit=_mouse_rate_limit,
slot=lambda: self.mouseAction('Leave', False), slot=lambda: self.mouseAction('Leave', False),
) )
if _xaxis_at == 'bottom':
# place below is last indicator subplot
self.xaxis_label = XAxisLabel(
parent=last_ind.getAxis('bottom'), opacity=1
)
else: else:
# keep x-axis right below main chart
self.xaxis_label = XAxisLabel(parent=self.xaxis, opacity=1) self.xaxis_label = XAxisLabel(parent=self.xaxis, opacity=1)
for i in indicators: for i in indicators:
# add vertial and horizonal lines and a y-axis label
vl = i.addLine(x=0, pen=self.pen, movable=False) vl = i.addLine(x=0, pen=self.pen, movable=False)
hl = i.addLine(y=0, pen=self.pen, movable=False) hl = i.addLine(y=0, pen=self.pen, movable=False)
yl = YAxisLabel(parent=i.getAxis('right'), opacity=1) yl = YAxisLabel(parent=i.getAxis('right'), opacity=1)
px_moved = pg.SignalProxy( px_moved = pg.SignalProxy(
i.scene().sigMouseMoved, rateLimit=_rate_limit, slot=self.mouseMoved i.scene().sigMouseMoved,
rateLimit=_mouse_rate_limit,
slot=self.mouseMoved
) )
px_enter = pg.SignalProxy( px_enter = pg.SignalProxy(
i.sig_mouse_enter, i.sig_mouse_enter,
rateLimit=_rate_limit, rateLimit=_mouse_rate_limit,
slot=lambda: self.mouseAction('Enter', i), slot=lambda: self.mouseAction('Enter', i),
) )
px_leave = pg.SignalProxy( px_leave = pg.SignalProxy(
i.sig_mouse_leave, i.sig_mouse_leave,
rateLimit=_rate_limit, rateLimit=_mouse_rate_limit,
slot=lambda: self.mouseAction('Leave', i), slot=lambda: self.mouseAction('Leave', i),
) )
self.indicators[i] = { self.indicators[i] = {
@ -302,6 +377,7 @@ class CrossHairItem(pg.GraphicsObject):
def mouseAction(self, action, ind=False): # noqa def mouseAction(self, action, ind=False): # noqa
if action == 'Enter': if action == 'Enter':
# show horiz line and y-label
if ind: if ind:
self.indicators[ind]['hl'].show() self.indicators[ind]['hl'].show()
self.indicators[ind]['yl'].show() self.indicators[ind]['yl'].show()
@ -310,6 +386,7 @@ class CrossHairItem(pg.GraphicsObject):
self.yaxis_label.show() self.yaxis_label.show()
self.hline.show() self.hline.show()
else: # Leave else: # Leave
# hide horiz line and y-label
if ind: if ind:
self.indicators[ind]['hl'].hide() self.indicators[ind]['hl'].hide()
self.indicators[ind]['yl'].hide() self.indicators[ind]['yl'].hide()
@ -319,16 +396,29 @@ class CrossHairItem(pg.GraphicsObject):
self.hline.hide() self.hline.hide()
def mouseMoved(self, evt): # noqa def mouseMoved(self, evt): # noqa
"""Update horizonal and vertical lines when mouse moves inside
either the main chart or any indicator subplot.
"""
pos = evt[0] pos = evt[0]
# if the mouse is within the parent ``CustomPlotWidget``
if self.parent.sceneBoundingRect().contains(pos): if self.parent.sceneBoundingRect().contains(pos):
# mouse_point = self.vb.mapSceneToView(pos) # mouse_point = self.vb.mapSceneToView(pos)
mouse_point = self.parent.mapToView(pos) mouse_point = self.parent.mapToView(pos)
# move the vertial line to the current x coordinate
self.vline.setX(mouse_point.x()) self.vline.setX(mouse_point.x())
# update the label on the bottom of the crosshair
self.xaxis_label.update_label(evt_post=pos, point_view=mouse_point) self.xaxis_label.update_label(evt_post=pos, point_view=mouse_point)
# update the vertical line in any indicators subplots
for opts in self.indicators.values(): for opts in self.indicators.values():
opts['vl'].setX(mouse_point.x()) opts['vl'].setX(mouse_point.x())
if self.activeIndicator: if self.activeIndicator:
# vertial position of the mouse is inside an indicator
mouse_point_ind = self.activeIndicator.mapToView(pos) mouse_point_ind = self.activeIndicator.mapToView(pos)
self.indicators[self.activeIndicator]['hl'].setY( self.indicators[self.activeIndicator]['hl'].setY(
mouse_point_ind.y() mouse_point_ind.y()
@ -337,6 +427,7 @@ class CrossHairItem(pg.GraphicsObject):
evt_post=pos, point_view=mouse_point_ind evt_post=pos, point_view=mouse_point_ind
) )
else: else:
# vertial position of the mouse is inside and main chart
self.hline.setY(mouse_point.y()) self.hline.setY(mouse_point.y())
self.yaxis_label.update_label( self.yaxis_label.update_label(
evt_post=pos, point_view=mouse_point evt_post=pos, point_view=mouse_point
@ -351,28 +442,31 @@ class CrossHairItem(pg.GraphicsObject):
class BarItem(pg.GraphicsObject): class BarItem(pg.GraphicsObject):
w = 0.35 w = 0.5
bull_brush = pg.mkPen('#00cc00')
bear_brush = pg.mkPen('#fa0000') bull_brush = bear_brush = pg.mkPen('#808080')
# bull_brush = pg.mkPen('#00cc00')
# bear_brush = pg.mkPen('#fa0000')
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.generatePicture() self.generatePicture()
def _generate(self, p): def _generate(self, p):
hl = np.array( high_to_low = np.array(
[QtCore.QLineF(q.id, q.low, q.id, q.high) for q in Quotes] [QtCore.QLineF(q.id, q.low, q.id, q.high) for q in Quotes]
) )
op = np.array( open_stick = np.array(
[QtCore.QLineF(q.id - self.w, q.open, q.id, q.open) for q in Quotes] [QtCore.QLineF(q.id - self.w, q.open, q.id, q.open)
for q in Quotes]
) )
cl = np.array( close_stick = np.array(
[ [
QtCore.QLineF(q.id + self.w, q.close, q.id, q.close) QtCore.QLineF(q.id + self.w, q.close, q.id, q.close)
for q in Quotes for q in Quotes
] ]
) )
lines = np.concatenate([hl, op, cl]) lines = np.concatenate([high_to_low, open_stick, close_stick])
long_bars = np.resize(Quotes.close > Quotes.open, len(lines)) long_bars = np.resize(Quotes.close > Quotes.open, len(lines))
short_bars = np.resize(Quotes.close < Quotes.open, len(lines)) short_bars = np.resize(Quotes.close < Quotes.open, len(lines))
@ -382,6 +476,7 @@ class BarItem(pg.GraphicsObject):
p.setPen(self.bear_brush) p.setPen(self.bear_brush)
p.drawLines(*lines[short_bars]) p.drawLines(*lines[short_bars])
# TODO: this is the routine to be retriggered for redraw
@timeit @timeit
def generatePicture(self): def generatePicture(self):
self.picture = QtGui.QPicture() self.picture = QtGui.QPicture()
@ -412,7 +507,10 @@ class CandlestickItem(BarItem):
) )
p.setPen(self.line_pen) p.setPen(self.line_pen)
p.drawLines([QtCore.QLineF(q.id, q.low, q.id, q.high) for q in Quotes]) p.drawLines(
[QtCore.QLineF(q.id, q.low, q.id, q.high)
for q in Quotes]
)
p.setBrush(self.bull_brush) p.setBrush(self.bull_brush)
p.drawRects(*rects[Quotes.close > Quotes.open]) p.drawRects(*rects[Quotes.close > Quotes.open])
@ -421,6 +519,34 @@ class CandlestickItem(BarItem):
p.drawRects(*rects[Quotes.close < Quotes.open]) p.drawRects(*rects[Quotes.close < Quotes.open])
def _configure_quotes_chart(
chart: CustomPlotWidget,
style: ChartType,
update_yrange_limits: Callable,
) -> None:
"""Update and format a chart with quotes data.
"""
chart.hideAxis('left')
chart.showAxis('right')
chart.addItem(_get_chart_points(style))
chart.setLimits(
xMin=Quotes[0].id,
xMax=Quotes[-1].id,
minXRange=60,
yMin=Quotes.low.min() * 0.98,
yMax=Quotes.high.max() * 1.02,
)
chart.showGrid(x=True, y=True)
chart.setCursor(QtCore.Qt.BlankCursor)
# assign callback for rescaling y-axis automatically
# based on y-range contents
# TODO: this can likely be ported to built-in: .enableAutoRange()
# but needs testing
chart.sigXRangeChanged.connect(update_yrange_limits)
class QuotesChart(QtGui.QWidget): class QuotesChart(QtGui.QWidget):
long_pen = pg.mkPen('#006000') long_pen = pg.mkPen('#006000')
@ -436,19 +562,21 @@ class QuotesChart(QtGui.QWidget):
self.style = ChartType.BAR self.style = ChartType.BAR
self.indicators = [] self.indicators = []
self.xaxis = DateAxis(orientation='bottom') self.xaxis = FromTimeFieldDateAxis(orientation='bottom')
self.xaxis.setStyle( # self.xaxis = pg.DateAxisItem()
tickTextOffset=7, textFillLimits=[(0, 0.80)], showValues=False
)
self.xaxis_ind = DateAxis(orientation='bottom') self.xaxis_ind = FromTimeFieldDateAxis(orientation='bottom')
self.xaxis_ind.setStyle(tickTextOffset=7, textFillLimits=[(0, 0.80)])
if _xaxis_at == 'bottom':
self.xaxis.setStyle(showValues=False)
else:
self.xaxis_ind.setStyle(showValues=False)
self.layout = QtGui.QVBoxLayout(self) self.layout = QtGui.QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setContentsMargins(0, 0, 0, 0)
self.splitter = QtGui.QSplitter(QtCore.Qt.Vertical) self.splitter = QtGui.QSplitter(QtCore.Qt.Vertical)
self.splitter.setHandleWidth(4) self.splitter.setHandleWidth(5)
self.layout.addWidget(self.splitter) self.layout.addWidget(self.splitter)
@ -473,21 +601,6 @@ class QuotesChart(QtGui.QWidget):
del self.signals_group_text del self.signals_group_text
self.signals_visible = False 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): def _update_ind_charts(self):
for ind, d in self.indicators: for ind, d in self.indicators:
curve = pg.PlotDataItem(d, pen='b', antialias=True) curve = pg.PlotDataItem(d, pen='b', antialias=True)
@ -513,10 +626,14 @@ class QuotesChart(QtGui.QWidget):
self.splitter.setSizes(sizes) # , int(self.height()*0.2) self.splitter.setSizes(sizes) # , int(self.height()*0.2)
def _update_yrange_limits(self): def _update_yrange_limits(self):
"""Callback for each y-range update.
"""
vr = self.chart.viewRect() vr = self.chart.viewRect()
lbar, rbar = int(vr.left()), int(vr.right()) lbar, rbar = int(vr.left()), int(vr.right())
if self.signals_visible: if self.signals_visible:
self._show_text_signals(lbar, rbar) self._show_text_signals(lbar, rbar)
bars = Quotes[lbar:rbar] bars = Quotes[lbar:rbar]
ylow = bars.low.min() * 0.98 ylow = bars.low.min() * 0.98
yhigh = bars.high.max() * 1.02 yhigh = bars.high.max() * 1.02
@ -540,15 +657,17 @@ class QuotesChart(QtGui.QWidget):
axisItems={'bottom': self.xaxis, 'right': PriceAxis()}, axisItems={'bottom': self.xaxis, 'right': PriceAxis()},
enableMenu=False, enableMenu=False,
) )
# self.chart.getPlotItem().setContentsMargins(*CHART_MARGINS) self.chart.getPlotItem().setContentsMargins(*CHART_MARGINS)
self.chart.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain) self.chart.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain)
# TODO: this is where we would load an indicator chain
inds = [Quotes.open] inds = [Quotes.open]
for d in inds: for d in inds:
ind = CustomPlotWidget( ind = CustomPlotWidget(
parent=self.splitter, parent=self.splitter,
axisItems={'bottom': self.xaxis_ind, 'right': PriceAxis()}, axisItems={'bottom': self.xaxis_ind, 'right': PriceAxis()},
# axisItems={'top': self.xaxis_ind, 'right': PriceAxis()},
enableMenu=False, enableMenu=False,
) )
ind.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain) ind.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain)
@ -556,7 +675,11 @@ class QuotesChart(QtGui.QWidget):
# self.splitter.addWidget(ind) # self.splitter.addWidget(ind)
self.indicators.append((ind, d)) self.indicators.append((ind, d))
self._update_quotes_chart() _configure_quotes_chart(
self.chart,
self.style,
self._update_yrange_limits
)
self._update_ind_charts() self._update_ind_charts()
self._update_sizes() self._update_sizes()
@ -620,177 +743,9 @@ class QuotesChart(QtGui.QWidget):
self.signals_visible = True self.signals_visible = True
class EquityChart(QtGui.QWidget): # this function is borderline rediculous.
# The creation of these chart types mutates all the input data
eq_pen_pos_color = pg.mkColor('#00cc00') # inside each type's constructor (mind blown)
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): def _get_chart_points(style):
if style == ChartType.CANDLESTICK: if style == ChartType.CANDLESTICK:
return CandlestickItem() return CandlestickItem()

View File

@ -3,7 +3,9 @@
import logging import logging
import os.path import os.path
import pickle import pickle
import datetime
import numpy as np
import pandas as pd import pandas as pd
import pandas_datareader.data as web import pandas_datareader.data as web
from pandas_datareader._utils import RemoteDataError from pandas_datareader._utils import RemoteDataError
@ -15,7 +17,7 @@ from pandas_datareader.data import (
from pandas_datareader.nasdaq_trader import get_nasdaq_symbols from pandas_datareader.nasdaq_trader import get_nasdaq_symbols
from pandas_datareader.exceptions import ImmediateDeprecationError from pandas_datareader.exceptions import ImmediateDeprecationError
from .base import Quotes from .base import Quotes, Symbol
from .utils import get_data_path, timeit from .utils import get_data_path, timeit
__all__ = ( __all__ = (
@ -71,7 +73,15 @@ class QuotesLoader:
@classmethod @classmethod
@timeit @timeit
def get_quotes(cls, symbol, date_from, date_to): def get_quotes(
cls,
symbol: Symbol,
date_from: datetime.datetime,
date_to: datetime.datetime,
) -> Quotes:
"""Retrieve quotes data from a provider and return a ``numpy.ndarray`` subtype.
"""
quotes = None quotes = None
fpath = cls._get_file_path(symbol, cls.timeframe, date_from, date_to) fpath = cls._get_file_path(symbol, cls.timeframe, date_from, date_to)
if os.path.exists(fpath): if os.path.exists(fpath):

View File

@ -62,7 +62,9 @@ class BasePortfolio:
for p in self.positions: for p in self.positions:
if p.status == Position.OPEN: if p.status == Position.OPEN:
p.close( p.close(
price=Quotes[-1].open, volume=p.volume, time=Quotes[-1].time price=Quotes[-1].open,
volume=p.volume,
time=Quotes[-1].time
) )
def _get_market_position(self): def _get_market_position(self):