Factor components into more suitably named modules

its_happening
Tyler Goodlet 2020-06-15 10:48:00 -04:00
parent 44984272be
commit ef214226a2
5 changed files with 844 additions and 807 deletions

View File

@ -0,0 +1,171 @@
"""
Chart axes graphics and behavior.
"""
import pyqtgraph as pg
from PyQt5 import QtCore, QtGui
from .quantdom.base import Quotes
from .quantdom.utils import fromtimestamp
from ._style import _font
class PriceAxis(pg.AxisItem):
def __init__(self):
super().__init__(orientation='right')
self.setStyle(**{
'textFillLimits': [(0, 0.8)],
# 'tickTextWidth': 5,
# 'tickTextHeight': 5,
# 'autoExpandTextSpace': True,
# 'maxTickLength': -20,
})
self.setLabel(**{'font-size': '10pt'})
self.setTickFont(_font)
# XXX: drop for now since it just eats up h space
# def tickStrings(self, vals, scale, spacing):
# digts = max(0, np.ceil(-np.log10(spacing * scale)))
# return [
# ('{:<8,.%df}' % digts).format(v).replace(',', ' ') for v in vals
# ]
class FromTimeFieldDateAxis(pg.AxisItem):
tick_tpl = {'D1': '%Y-%b-%d'}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setTickFont(_font)
self.quotes_count = len(Quotes) - 1
# default styling
self.setStyle(
tickTextOffset=7,
textFillLimits=[(0, 0.90)],
# TODO: doesn't seem to work -> bug in pyqtgraph?
# tickTextHeight=2,
)
def tickStrings(self, values, scale, spacing):
# if len(values) > 1 or not values:
# values = Quotes.time
# strings = super().tickStrings(values, scale, spacing)
s_period = 'D1'
strings = []
for ibar in values:
if ibar > self.quotes_count:
return strings
dt_tick = fromtimestamp(Quotes[int(ibar)].time)
strings.append(
dt_tick.strftime(self.tick_tpl[s_period])
)
return strings
class AxisLabel(pg.GraphicsObject):
# bg_color = pg.mkColor('#a9a9a9')
bg_color = pg.mkColor('#808080')
fg_color = pg.mkColor('#000000')
def __init__(self, parent=None, digits=0, color=None, opacity=1, **kwargs):
super().__init__(parent)
self.parent = parent
self.opacity = opacity
self.label_str = ''
self.digits = digits
# self.quotes_count = len(Quotes) - 1
if isinstance(color, QtGui.QPen):
self.bg_color = color.color()
self.fg_color = pg.mkColor('#ffffff')
elif isinstance(color, list):
self.bg_color = {'>0': color[0].color(), '<0': color[1].color()}
self.fg_color = pg.mkColor('#ffffff')
self.setFlag(self.ItemIgnoresTransformations)
def tick_to_string(self, tick_pos):
raise NotImplementedError()
def boundingRect(self): # noqa
raise NotImplementedError()
def update_label(self, evt_post, point_view):
raise NotImplementedError()
def update_label_test(self, ypos=0, ydata=0):
self.label_str = self.tick_to_string(ydata)
height = self.boundingRect().height()
offset = 0 # if have margins
new_pos = QtCore.QPointF(0, ypos - height / 2 - offset)
self.setPos(new_pos)
def paint(self, p, option, widget):
p.setRenderHint(p.TextAntialiasing, True)
p.setPen(self.fg_color)
if self.label_str:
if not isinstance(self.bg_color, dict):
bg_color = self.bg_color
else:
if int(self.label_str.replace(' ', '')) > 0:
bg_color = self.bg_color['>0']
else:
bg_color = self.bg_color['<0']
p.setOpacity(self.opacity)
p.fillRect(option.rect, bg_color)
p.setOpacity(1)
p.setFont(_font)
p.drawText(option.rect, self.text_flags, self.label_str)
class XAxisLabel(AxisLabel):
text_flags = (
QtCore.Qt.TextDontClip | QtCore.Qt.AlignCenter | QtCore.Qt.AlignTop
)
def tick_to_string(self, tick_pos):
# TODO: change to actual period
tpl = self.parent.tick_tpl['D1']
if tick_pos > len(Quotes):
return 'Unknown Time'
return fromtimestamp(Quotes[round(tick_pos)].time).strftime(tpl)
def boundingRect(self): # noqa
return QtCore.QRectF(0, 0, 145, 50)
def update_label(self, evt_post, point_view):
ibar = point_view.x()
# if ibar > self.quotes_count:
# return
self.label_str = self.tick_to_string(ibar)
width = self.boundingRect().width()
offset = 0 # if have margins
new_pos = QtCore.QPointF(evt_post.x() - width / 2 - offset, 0)
self.setPos(new_pos)
class YAxisLabel(AxisLabel):
text_flags = (
QtCore.Qt.TextDontClip | QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter
)
def tick_to_string(self, tick_pos):
return ('{: ,.%df}' % self.digits).format(tick_pos).replace(',', ' ')
def boundingRect(self): # noqa
return QtCore.QRectF(0, 0, 80, 40)
def update_label(self, evt_post, point_view):
self.label_str = self.tick_to_string(point_view.y())
height = self.boundingRect().height()
offset = 0 # if have margins
new_pos = QtCore.QPointF(0, evt_post.y() - height / 2 - offset)
self.setPos(new_pos)

View File

@ -1,9 +1,29 @@
"""
High level Qt chart wrapping widgets.
High level Qt chart widgets.
"""
from PyQt5 import QtGui
import numpy as np
import pyqtgraph as pg
from pyqtgraph import functions as fn
from PyQt5 import QtCore, QtGui
from .quantdom.charts import SplitterChart
from ._axes import (
FromTimeFieldDateAxis,
PriceAxis,
)
from ._graphics import CrossHairItem, CandlestickItem, BarItem
from ._style import _xaxis_at
from .quantdom.charts import CenteredTextItem
from .quantdom.base import Quotes
from .quantdom.const import ChartType
from .quantdom.portfolio import Order, Portfolio
# white background (for tinas like our pal xb)
# pg.setConfigOption('background', 'w')
# margins
CHART_MARGINS = (0, 0, 10, 3)
class QuotesTabWidget(QtGui.QWidget):
@ -53,3 +73,383 @@ class QuotesTabWidget(QtGui.QWidget):
def add_signals(self):
self.chart.add_signals()
class SplitterChart(QtGui.QWidget):
long_pen = pg.mkPen('#006000')
long_brush = pg.mkBrush('#00ff00')
short_pen = pg.mkPen('#600000')
short_brush = pg.mkBrush('#ff0000')
zoomIsDisabled = QtCore.pyqtSignal(bool)
def __init__(self):
super().__init__()
self.signals_visible = False
self.indicators = []
self.xaxis = FromTimeFieldDateAxis(orientation='bottom')
# self.xaxis = pg.DateAxisItem()
self.xaxis_ind = FromTimeFieldDateAxis(orientation='bottom')
if _xaxis_at == 'bottom':
self.xaxis.setStyle(showValues=False)
else:
self.xaxis_ind.setStyle(showValues=False)
self.splitter = QtGui.QSplitter(QtCore.Qt.Vertical)
self.splitter.setHandleWidth(5)
self.layout = QtGui.QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.addWidget(self.splitter)
def _show_text_signals(self, lbar, rbar):
signals = [
sig
for sig in self.signals_text_items[lbar:rbar]
if isinstance(sig, CenteredTextItem)
]
if len(signals) <= 50:
for sig in signals:
sig.show()
else:
for sig in signals:
sig.hide()
def _remove_signals(self):
self.chart.removeItem(self.signals_group_arrow)
self.chart.removeItem(self.signals_group_text)
del self.signals_text_items
del self.signals_group_arrow
del self.signals_group_text
self.signals_visible = False
def _update_sizes(self):
min_h_ind = int(self.height() * 0.2 / len(self.indicators))
sizes = [int(self.height() * 0.8)]
sizes.extend([min_h_ind] * len(self.indicators))
self.splitter.setSizes(sizes) # , int(self.height()*0.2)
def plot(self, symbol):
self.digits = symbol.digits
self.chart = ChartPlotWidget(
split_charts=self,
parent=self.splitter,
axisItems={'bottom': self.xaxis, 'right': PriceAxis()},
viewBox=ChartView,
# enableMenu=False,
)
# TODO: ``pyqtgraph`` doesn't pass through a parent to the
# ``PlotItem`` by default; maybe we should PR this in?
self.chart.plotItem.parent = self
self.chart.getPlotItem().setContentsMargins(*CHART_MARGINS)
self.chart.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain)
# TODO: this is where we would load an indicator chain
inds = [Quotes.open]
for d in inds:
ind = ChartPlotWidget(
split_charts=self,
parent=self.splitter,
axisItems={'bottom': self.xaxis_ind, 'right': PriceAxis()},
# axisItems={'top': self.xaxis_ind, 'right': PriceAxis()},
viewBox=ChartView,
)
ind.plotItem.parent = self
ind.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain)
ind.getPlotItem().setContentsMargins(*CHART_MARGINS)
# self.splitter.addWidget(ind)
self.indicators.append((ind, d))
self.chart.draw_ohlc()
for ind_chart, d in self.indicators:
# link chart x-axis to main quotes chart
ind_chart.setXLink(self.chart)
# XXX: never do this lol
# ind.setAspectLocked(1)
ind_chart.draw_curve(d)
self._update_sizes()
ch = CrossHairItem(
self.chart, [_ind for _ind, d in self.indicators], self.digits
)
self.chart.addItem(ch)
def add_signals(self):
self.signals_group_text = QtGui.QGraphicsItemGroup()
self.signals_group_arrow = QtGui.QGraphicsItemGroup()
self.signals_text_items = np.empty(len(Quotes), dtype=object)
for p in Portfolio.positions:
x, price = p.id_bar_open, p.open_price
if p.type == Order.BUY:
y = Quotes[x].low * 0.99
pg.ArrowItem(
parent=self.signals_group_arrow,
pos=(x, y),
pen=self.long_pen,
brush=self.long_brush,
angle=90,
headLen=12,
tipAngle=50,
)
text_sig = CenteredTextItem(
parent=self.signals_group_text,
pos=(x, y),
pen=self.long_pen,
brush=self.long_brush,
text=('Buy at {:.%df}' % self.digits).format(price),
valign=QtCore.Qt.AlignBottom,
)
text_sig.hide()
else:
y = Quotes[x].high * 1.01
pg.ArrowItem(
parent=self.signals_group_arrow,
pos=(x, y),
pen=self.short_pen,
brush=self.short_brush,
angle=-90,
headLen=12,
tipAngle=50,
)
text_sig = CenteredTextItem(
parent=self.signals_group_text,
pos=(x, y),
pen=self.short_pen,
brush=self.short_brush,
text=('Sell at {:.%df}' % self.digits).format(price),
valign=QtCore.Qt.AlignTop,
)
text_sig.hide()
self.signals_text_items[x] = text_sig
self.chart.addItem(self.signals_group_arrow)
self.chart.addItem(self.signals_group_text)
self.signals_visible = True
# TODO: This is a sub-class of ``GracphicView`` which can
# take a ``background`` color setting.
class ChartPlotWidget(pg.PlotWidget):
"""``GraphicsView`` subtype containing a single ``PlotItem``.
Overrides a ``pyqtgraph.PlotWidget`` (a ``GraphicsView`` containing
a single ``PlotItem``) to intercept and and re-emit mouse enter/exit
events.
(Could be replaced with a ``pg.GraphicsLayoutWidget`` if we
eventually want multiple plots managed together).
"""
sig_mouse_leave = QtCore.Signal(object)
sig_mouse_enter = QtCore.Signal(object)
def __init__(
self,
split_charts,
**kwargs,
# parent=None,
# background='default',
# plotItem=None,
# **kargs
):
"""Configure chart display settings.
"""
super().__init__(**kwargs)
# label = pg.LabelItem(justify='left')
# self.addItem(label)
# label.setText("Yo yoyo")
# label.setText("<span style='font-size: 12pt'>x=")
self.parent = split_charts
# show only right side axes
self.hideAxis('left')
self.showAxis('right')
# show background grid
self.showGrid(x=True, y=True, alpha=0.4)
# use cross-hair for cursor
self.setCursor(QtCore.Qt.CrossCursor)
# set panning limits
min_points_to_show = 20
min_bars_in_view = 10
max_lookahead = min_points_to_show - min_bars_in_view
last = Quotes[-1].id
self.setLimits(
xMin=Quotes[0].id,
xMax=last + max_lookahead,
minXRange=min_points_to_show,
# maxYRange=highest-lowest,
yMin=Quotes.low.min() * 0.98,
yMax=Quotes.high.max() * 1.02,
)
# show last 50 points on startup
self.plotItem.vb.setXRange(last - 50, last + max_lookahead)
# assign callback for rescaling y-axis automatically
# based on y-range contents
self.sigXRangeChanged.connect(self._update_yrange_limits)
self._update_yrange_limits()
def bars_range(self):
"""Return a range tuple for the bars present in view.
"""
vr = self.viewRect()
lbar, rbar = int(vr.left()), int(min(vr.right(), len(Quotes) - 1))
return lbar, rbar
def draw_ohlc(
self,
style: ChartType = ChartType.BAR,
) -> None:
"""Draw OHLC datums to chart.
"""
# adds all bar/candle graphics objects for each
# data point in the np array buffer to
# be drawn on next render cycle
self.addItem(_get_chart_points(style))
def draw_curve(
self,
data: np.ndarray,
) -> None:
# draw the indicator as a plain curve
curve = pg.PlotDataItem(data, antialias=True)
self.addItem(curve)
def _update_yrange_limits(self):
"""Callback for each y-range update.
This adds auto-scaling like zoom on the scroll wheel such
that data always fits nicely inside the current view of the
data set.
"""
# TODO: this can likely be ported in part to the built-ins:
# self.setYRange(Quotes.low.min() * .98, Quotes.high.max() * 1.02)
# self.setMouseEnabled(x=True, y=False)
# self.setXRange(Quotes[0].id, Quotes[-1].id)
# self.setAutoVisible(x=False, y=True)
# self.enableAutoRange(x=False, y=True)
chart = self
chart_parent = self.parent
lbar, rbar = self.bars_range()
# vr = chart.viewRect()
# lbar, rbar = int(vr.left()), int(vr.right())
if chart_parent.signals_visible:
chart_parent._show_text_signals(lbar, rbar)
bars = Quotes[lbar:rbar]
ylow = bars.low.min() * 0.98
yhigh = bars.high.max() * 1.02
std = np.std(bars.close)
chart.setLimits(yMin=ylow, yMax=yhigh, minYRange=std)
chart.setYRange(ylow, yhigh)
for i, d in chart_parent.indicators:
# ydata = i.plotItem.items[0].getData()[1]
ydata = d[lbar:rbar]
ylow = ydata.min() * 0.98
yhigh = ydata.max() * 1.02
std = np.std(ydata)
i.setLimits(yMin=ylow, yMax=yhigh, minYRange=std)
i.setYRange(ylow, yhigh)
def enterEvent(self, ev): # noqa
# pg.PlotWidget.enterEvent(self, ev)
self.sig_mouse_enter.emit(self)
def leaveEvent(self, ev): # noqa
# pg.PlotWidget.leaveEvent(self, ev)
self.sig_mouse_leave.emit(self)
self.scene().leaveEvent(ev)
class ChartView(pg.ViewBox):
"""Price chart view box with interaction behaviors you'd expect from
an interactive platform:
- zoom on mouse scroll that auto fits y-axis
- no vertical scrolling
- zoom to a "fixed point" on the y-axis
"""
def __init__(
self,
parent=None,
**kwargs,
# invertY=False,
):
super().__init__(parent=parent, **kwargs)
self.chart = parent
# disable vertical scrolling
self.setMouseEnabled(x=True, y=False)
def wheelEvent(self, ev, axis=None):
"""Override "center-point" location for scrolling.
This is an override of the ``ViewBox`` method simply changing
the center of the zoom to be the y-axis.
TODO: PR a method into ``pyqtgraph`` to make this configurable
"""
if axis in (0, 1):
mask = [False, False]
mask[axis] = self.state['mouseEnabled'][axis]
else:
mask = self.state['mouseEnabled'][:]
# actual scaling factor
s = 1.02 ** (ev.delta() * self.state['wheelScaleFactor'])
s = [(None if m is False else s) for m in mask]
# center = pg.Point(
# fn.invertQTransform(self.childGroup.transform()).map(ev.pos())
# )
# XXX: scroll "around" the right most element in the view
furthest_right_coord = self.boundingRect().topRight()
center = pg.Point(
fn.invertQTransform(
self.childGroup.transform()
).map(furthest_right_coord)
)
self._resetTarget()
self.scaleBy(s, center)
ev.accept()
self.sigRangeChangedManually.emit(mask)
# this function is borderline ridiculous.
# The creation of these chart types mutates all the input data
# inside each type's constructor (mind blown)
def _get_chart_points(style):
if style == ChartType.CANDLESTICK:
return CandlestickItem()
elif style == ChartType.BAR:
return BarItem()
return pg.PlotDataItem(Quotes.close, pen='b')

View File

@ -0,0 +1,252 @@
"""
Chart graphics for displaying a slew of different data types.
"""
import numpy as np
import pyqtgraph as pg
from PyQt5 import QtCore, QtGui
from .quantdom.utils import timeit
from .quantdom.base import Quotes
from ._style import _xaxis_at
from ._axes import YAxisLabel, XAxisLabel
_mouse_rate_limit = 60
class CrossHairItem(pg.GraphicsObject):
def __init__(self, parent, indicators=None, digits=0):
super().__init__()
# self.pen = pg.mkPen('#000000')
self.pen = pg.mkPen('#a9a9a9')
self.parent = parent
self.indicators = {}
self.activeIndicator = None
self.xaxis = self.parent.getAxis('bottom')
self.yaxis = self.parent.getAxis('right')
self.vline = self.parent.addLine(x=0, pen=self.pen, movable=False)
self.hline = self.parent.addLine(y=0, pen=self.pen, movable=False)
self.proxy_moved = pg.SignalProxy(
self.parent.scene().sigMouseMoved,
rateLimit=_mouse_rate_limit,
slot=self.mouseMoved,
)
self.yaxis_label = YAxisLabel(
parent=self.yaxis, digits=digits, opacity=1
)
indicators = indicators or []
if indicators:
# when there are indicators present in sub-plot rows
# take the last one (nearest to the bottom) and place the
# crosshair label on it's x-axis.
last_ind = indicators[-1]
self.proxy_enter = pg.SignalProxy(
self.parent.sig_mouse_enter,
rateLimit=_mouse_rate_limit,
slot=lambda: self.mouseAction('Enter', False),
)
self.proxy_leave = pg.SignalProxy(
self.parent.sig_mouse_leave,
rateLimit=_mouse_rate_limit,
slot=lambda: self.mouseAction('Leave', False),
)
# determine where to place x-axis label
if _xaxis_at == 'bottom':
# place below is last indicator subplot
self.xaxis_label = XAxisLabel(
parent=last_ind.getAxis('bottom'), opacity=1
)
else:
# keep x-axis right below main chart
self.xaxis_label = XAxisLabel(parent=self.xaxis, opacity=1)
for i in indicators:
# add vertial and horizonal lines and a y-axis label
vl = i.addLine(x=0, pen=self.pen, movable=False)
hl = i.addLine(y=0, pen=self.pen, movable=False)
yl = YAxisLabel(parent=i.getAxis('right'), opacity=1)
px_moved = pg.SignalProxy(
i.scene().sigMouseMoved,
rateLimit=_mouse_rate_limit,
slot=self.mouseMoved
)
px_enter = pg.SignalProxy(
i.sig_mouse_enter,
rateLimit=_mouse_rate_limit,
slot=lambda: self.mouseAction('Enter', i),
)
px_leave = pg.SignalProxy(
i.sig_mouse_leave,
rateLimit=_mouse_rate_limit,
slot=lambda: self.mouseAction('Leave', i),
)
self.indicators[i] = {
'vl': vl,
'hl': hl,
'yl': yl,
'px': (px_moved, px_enter, px_leave),
}
def mouseAction(self, action, ind=False): # noqa
if action == 'Enter':
# show horiz line and y-label
if ind:
self.indicators[ind]['hl'].show()
self.indicators[ind]['yl'].show()
self.activeIndicator = ind
else:
self.yaxis_label.show()
self.hline.show()
# Leave
else:
# hide horiz line and y-label
if ind:
self.indicators[ind]['hl'].hide()
self.indicators[ind]['yl'].hide()
self.activeIndicator = None
else:
self.yaxis_label.hide()
self.hline.hide()
def mouseMoved(self, evt): # noqa
"""Update horizonal and vertical lines when mouse moves inside
either the main chart or any indicator subplot.
"""
pos = evt[0]
# if the mouse is within the parent ``ChartPlotWidget``
if self.parent.sceneBoundingRect().contains(pos):
# mouse_point = self.vb.mapSceneToView(pos)
mouse_point = self.parent.mapToView(pos)
# move the vertial line to the current x coordinate
self.vline.setX(mouse_point.x())
# update the label on the bottom of the crosshair
self.xaxis_label.update_label(evt_post=pos, point_view=mouse_point)
# update the vertical line in any indicators subplots
for opts in self.indicators.values():
opts['vl'].setX(mouse_point.x())
if self.activeIndicator:
# vertial position of the mouse is inside an indicator
mouse_point_ind = self.activeIndicator.mapToView(pos)
self.indicators[self.activeIndicator]['hl'].setY(
mouse_point_ind.y()
)
self.indicators[self.activeIndicator]['yl'].update_label(
evt_post=pos, point_view=mouse_point_ind
)
else:
# vertial position of the mouse is inside the main chart
self.hline.setY(mouse_point.y())
self.yaxis_label.update_label(
evt_post=pos, point_view=mouse_point
)
def paint(self, p, *args):
pass
def boundingRect(self):
return self.parent.boundingRect()
class BarItem(pg.GraphicsObject):
# XXX: From the customGraphicsItem.py example:
# The only required methods are paint() and boundingRect()
w = 0.5
bull_brush = bear_brush = pg.mkPen('#808080')
# bull_brush = pg.mkPen('#00cc00')
# bear_brush = pg.mkPen('#fa0000')
def __init__(self):
super().__init__()
self.generatePicture()
# TODO: this is the routine to be retriggered for redraw
@timeit
def generatePicture(self):
# pre-computing a QPicture object allows paint() to run much
# more quickly, rather than re-drawing the shapes every time.
self.picture = QtGui.QPicture()
p = QtGui.QPainter(self.picture)
self._generate(p)
p.end()
def _generate(self, p):
# XXX: overloaded method to allow drawing other candle types
high_to_low = np.array(
[QtCore.QLineF(q.id, q.low, q.id, q.high) for q in Quotes]
)
open_stick = np.array(
[QtCore.QLineF(q.id - self.w, q.open, q.id, q.open)
for q in Quotes]
)
close_stick = np.array(
[
QtCore.QLineF(q.id + self.w, q.close, q.id, q.close)
for q in Quotes
]
)
lines = np.concatenate([high_to_low, open_stick, close_stick])
long_bars = np.resize(Quotes.close > Quotes.open, len(lines))
short_bars = np.resize(Quotes.close < Quotes.open, len(lines))
p.setPen(self.bull_brush)
p.drawLines(*lines[long_bars])
p.setPen(self.bear_brush)
p.drawLines(*lines[short_bars])
def paint(self, p, *args):
p.drawPicture(0, 0, self.picture)
def boundingRect(self):
# boundingRect _must_ indicate the entire area that will be
# drawn on or else we will get artifacts and possibly crashing.
# (in this case, QPicture does all the work of computing the
# bouning rect for us)
return QtCore.QRectF(self.picture.boundingRect())
class CandlestickItem(BarItem):
w2 = 0.7
line_pen = pg.mkPen('#000000')
bull_brush = pg.mkBrush('#00ff00')
bear_brush = pg.mkBrush('#ff0000')
def _generate(self, p):
rects = np.array(
[
QtCore.QRectF(q.id - self.w, q.open, self.w2, q.close - q.open)
for q in Quotes
]
)
p.setPen(self.line_pen)
p.drawLines(
[QtCore.QLineF(q.id, q.low, q.id, q.high)
for q in Quotes]
)
p.setBrush(self.bull_brush)
p.drawRects(*rects[Quotes.close > Quotes.open])
p.setBrush(self.bear_brush)
p.drawRects(*rects[Quotes.close < Quotes.open])

View File

@ -0,0 +1,18 @@
"""
Qt styling.
"""
from PyQt5 import QtGui
# TODO: add "tina mode" to make everything look "conventional"
# white background (for tinas like our pal xb)
# pg.setConfigOption('background', 'w')
# chart-wide font
_font = QtGui.QFont("Hack", 4)
_i3_rgba = QtGui.QColor.fromRgbF(*[0.14]*3 + [1])
# splitter widget config
_xaxis_at = 'bottom'

View File

@ -1,31 +1,9 @@
"""
Real-time quotes charting components
"""
from typing import List, Tuple
import numpy as np
import pyqtgraph as pg
from pyqtgraph import functions as fn
from PyQt5 import QtCore, QtGui
from .base import Quotes
from .const import ChartType
from .portfolio import Order, Portfolio
from .utils import fromtimestamp, timeit
__all__ = ('SplitterChart')
# white background (for tinas like our pal xb)
# pg.setConfigOption('background', 'w')
# margins
CHART_MARGINS = (0, 0, 10, 3)
# chart-wide font
_font = QtGui.QFont("Hack", 4)
_i3_rgba = QtGui.QColor.fromRgbF(*[0.14]*3 + [1])
class SampleLegendItem(pg.graphicsItems.LegendItem.ItemSample):
@ -62,62 +40,6 @@ class SampleLegendItem(pg.graphicsItems.LegendItem.ItemSample):
p.drawRect(0, 10, 18, 0.5)
class PriceAxis(pg.AxisItem):
def __init__(self):
super().__init__(orientation='right')
self.setStyle(**{
'textFillLimits': [(0, 0.8)],
# 'tickTextWidth': 5,
# 'tickTextHeight': 5,
# 'autoExpandTextSpace': True,
# 'maxTickLength': -20,
})
self.setLabel(**{'font-size': '10pt'})
self.setTickFont(_font)
# XXX: drop for now since it just eats up h space
# def tickStrings(self, vals, scale, spacing):
# digts = max(0, np.ceil(-np.log10(spacing * scale)))
# return [
# ('{:<8,.%df}' % digts).format(v).replace(',', ' ') for v in vals
# ]
class FromTimeFieldDateAxis(pg.AxisItem):
tick_tpl = {'D1': '%Y-%b-%d'}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setTickFont(_font)
self.quotes_count = len(Quotes) - 1
# default styling
self.setStyle(
tickTextOffset=7,
textFillLimits=[(0, 0.90)],
# TODO: doesn't seem to work -> bug in pyqtgraph?
# tickTextHeight=2,
)
def tickStrings(self, values, scale, spacing):
# if len(values) > 1 or not values:
# values = Quotes.time
# strings = super().tickStrings(values, scale, spacing)
s_period = 'D1'
strings = []
for ibar in values:
if ibar > self.quotes_count:
return strings
dt_tick = fromtimestamp(Quotes[int(ibar)].time)
strings.append(
dt_tick.strftime(self.tick_tpl[s_period])
)
return strings
class CenteredTextItem(QtGui.QGraphicsTextItem):
def __init__(
self,
@ -155,729 +77,3 @@ class CenteredTextItem(QtGui.QGraphicsTextItem):
p.fillRect(option.rect, self.brush)
p.setOpacity(1)
p.drawText(option.rect, self.text_flags, self.toPlainText())
class AxisLabel(pg.GraphicsObject):
# bg_color = pg.mkColor('#a9a9a9')
bg_color = pg.mkColor('#808080')
fg_color = pg.mkColor('#000000')
def __init__(self, parent=None, digits=0, color=None, opacity=1, **kwargs):
super().__init__(parent)
self.parent = parent
self.opacity = opacity
self.label_str = ''
self.digits = digits
# self.quotes_count = len(Quotes) - 1
if isinstance(color, QtGui.QPen):
self.bg_color = color.color()
self.fg_color = pg.mkColor('#ffffff')
elif isinstance(color, list):
self.bg_color = {'>0': color[0].color(), '<0': color[1].color()}
self.fg_color = pg.mkColor('#ffffff')
self.setFlag(self.ItemIgnoresTransformations)
def tick_to_string(self, tick_pos):
raise NotImplementedError()
def boundingRect(self): # noqa
raise NotImplementedError()
def update_label(self, evt_post, point_view):
raise NotImplementedError()
def update_label_test(self, ypos=0, ydata=0):
self.label_str = self.tick_to_string(ydata)
height = self.boundingRect().height()
offset = 0 # if have margins
new_pos = QtCore.QPointF(0, ypos - height / 2 - offset)
self.setPos(new_pos)
def paint(self, p, option, widget):
p.setRenderHint(p.TextAntialiasing, True)
p.setPen(self.fg_color)
if self.label_str:
if not isinstance(self.bg_color, dict):
bg_color = self.bg_color
else:
if int(self.label_str.replace(' ', '')) > 0:
bg_color = self.bg_color['>0']
else:
bg_color = self.bg_color['<0']
p.setOpacity(self.opacity)
p.fillRect(option.rect, bg_color)
p.setOpacity(1)
p.setFont(_font)
p.drawText(option.rect, self.text_flags, self.label_str)
class XAxisLabel(AxisLabel):
text_flags = (
QtCore.Qt.TextDontClip | QtCore.Qt.AlignCenter | QtCore.Qt.AlignTop
)
def tick_to_string(self, tick_pos):
# TODO: change to actual period
tpl = self.parent.tick_tpl['D1']
if tick_pos > len(Quotes):
return 'Unknown Time'
return fromtimestamp(Quotes[round(tick_pos)].time).strftime(tpl)
def boundingRect(self): # noqa
return QtCore.QRectF(0, 0, 145, 50)
def update_label(self, evt_post, point_view):
ibar = point_view.x()
# if ibar > self.quotes_count:
# return
self.label_str = self.tick_to_string(ibar)
width = self.boundingRect().width()
offset = 0 # if have margins
new_pos = QtCore.QPointF(evt_post.x() - width / 2 - offset, 0)
self.setPos(new_pos)
class YAxisLabel(AxisLabel):
text_flags = (
QtCore.Qt.TextDontClip | QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter
)
def tick_to_string(self, tick_pos):
return ('{: ,.%df}' % self.digits).format(tick_pos).replace(',', ' ')
def boundingRect(self): # noqa
return QtCore.QRectF(0, 0, 80, 40)
def update_label(self, evt_post, point_view):
self.label_str = self.tick_to_string(point_view.y())
height = self.boundingRect().height()
offset = 0 # if have margins
new_pos = QtCore.QPointF(0, evt_post.y() - height / 2 - offset)
self.setPos(new_pos)
class ChartView(pg.ViewBox):
"""Price chart view box with interaction behaviors you'd expect from
an interactive platform:
- zoom on mouse scroll that auto fits y-axis
- no vertical scrolling
- zoom to a "fixed point" on the y-axis
"""
def __init__(
self,
parent=None,
**kwargs,
# invertY=False,
):
super().__init__(parent=parent, **kwargs)
self.chart = parent
# disable vertical scrolling
self.setMouseEnabled(x=True, y=False)
def wheelEvent(self, ev, axis=None):
"""Override "center-point" location for scrolling.
This is an override of the ``ViewBox`` method simply changing
the center of the zoom to be the y-axis.
TODO: PR a method into ``pyqtgraph`` to make this configurable
"""
if axis in (0, 1):
mask = [False, False]
mask[axis] = self.state['mouseEnabled'][axis]
else:
mask = self.state['mouseEnabled'][:]
# actual scaling factor
s = 1.02 ** (ev.delta() * self.state['wheelScaleFactor'])
s = [(None if m is False else s) for m in mask]
# center = pg.Point(
# fn.invertQTransform(self.childGroup.transform()).map(ev.pos())
# )
# XXX: scroll "around" the right most element in the view
furthest_right_coord = self.boundingRect().topRight()
center = pg.Point(
fn.invertQTransform(
self.childGroup.transform()
).map(furthest_right_coord)
)
self._resetTarget()
self.scaleBy(s, center)
ev.accept()
self.sigRangeChangedManually.emit(mask)
# TODO: This is a sub-class of ``GracphicView`` which can
# take a ``background`` color setting.
class ChartPlotWidget(pg.PlotWidget):
"""``GraphicsView`` subtype containing a single ``PlotItem``.
Overrides a ``pyqtgraph.PlotWidget`` (a ``GraphicsView`` containing
a single ``PlotItem``) to intercept and and re-emit mouse enter/exit
events.
(Could be replaced with a ``pg.GraphicsLayoutWidget`` if we
eventually want multiple plots managed together).
"""
sig_mouse_leave = QtCore.Signal(object)
sig_mouse_enter = QtCore.Signal(object)
def __init__(
self,
split_charts,
**kwargs,
# parent=None,
# background='default',
# plotItem=None,
# **kargs
):
"""Configure chart display settings.
"""
super().__init__(**kwargs)
# label = pg.LabelItem(justify='left')
# self.addItem(label)
# label.setText("Yo yoyo")
# label.setText("<span style='font-size: 12pt'>x=")
self.parent = split_charts
# show only right side axes
self.hideAxis('left')
self.showAxis('right')
# show background grid
self.showGrid(x=True, y=True, alpha=0.4)
# use cross-hair for cursor
self.setCursor(QtCore.Qt.CrossCursor)
# set panning limits
min_points_to_show = 20
min_bars_in_view = 10
max_lookahead = min_points_to_show - min_bars_in_view
last = Quotes[-1].id
self.setLimits(
xMin=Quotes[0].id,
xMax=last + max_lookahead,
minXRange=min_points_to_show,
# maxYRange=highest-lowest,
yMin=Quotes.low.min() * 0.98,
yMax=Quotes.high.max() * 1.02,
)
# show last 50 points on startup
self.plotItem.vb.setXRange(last - 50, last + max_lookahead)
# assign callback for rescaling y-axis automatically
# based on y-range contents
self.sigXRangeChanged.connect(self._update_yrange_limits)
self._update_yrange_limits()
def bars_range(self):
"""Return a range tuple for the bars present in view.
"""
vr = self.viewRect()
lbar, rbar = int(vr.left()), int(min(vr.right(), len(Quotes) - 1))
return lbar, rbar
def draw_ohlc(
self,
style: ChartType = ChartType.BAR,
) -> None:
"""Draw OHLC datums to chart.
"""
# adds all bar/candle graphics objects for each
# data point in the np array buffer to
# be drawn on next render cycle
self.addItem(_get_chart_points(style))
def draw_curve(
self,
data: np.ndarray,
) -> None:
# draw the indicator as a plain curve
curve = pg.PlotDataItem(data, antialias=True)
ind_chart.addItem(curve)
def _update_yrange_limits(self):
"""Callback for each y-range update.
This adds auto-scaling like zoom on the scroll wheel such
that data always fits nicely inside the current view of the
data set.
"""
# TODO: this can likely be ported in part to the built-ins:
# self.setYRange(Quotes.low.min() * .98, Quotes.high.max() * 1.02)
# self.setMouseEnabled(x=True, y=False)
# self.setXRange(Quotes[0].id, Quotes[-1].id)
# self.setAutoVisible(x=False, y=True)
# self.enableAutoRange(x=False, y=True)
chart = self
chart_parent = self.parent
lbar, rbar = self.bars_range()
# vr = chart.viewRect()
# lbar, rbar = int(vr.left()), int(vr.right())
if chart_parent.signals_visible:
chart_parent._show_text_signals(lbar, rbar)
bars = Quotes[lbar:rbar]
ylow = bars.low.min() * 0.98
yhigh = bars.high.max() * 1.02
std = np.std(bars.close)
chart.setLimits(yMin=ylow, yMax=yhigh, minYRange=std)
chart.setYRange(ylow, yhigh)
for i, d in chart_parent.indicators:
# ydata = i.plotItem.items[0].getData()[1]
ydata = d[lbar:rbar]
ylow = ydata.min() * 0.98
yhigh = ydata.max() * 1.02
std = np.std(ydata)
i.setLimits(yMin=ylow, yMax=yhigh, minYRange=std)
i.setYRange(ylow, yhigh)
def enterEvent(self, ev): # noqa
# pg.PlotWidget.enterEvent(self, ev)
self.sig_mouse_enter.emit(self)
def leaveEvent(self, ev): # noqa
# pg.PlotWidget.leaveEvent(self, ev)
self.sig_mouse_leave.emit(self)
self.scene().leaveEvent(ev)
_mouse_rate_limit = 60
_xaxis_at = 'bottom'
class CrossHairItem(pg.GraphicsObject):
def __init__(self, parent, indicators=None, digits=0):
super().__init__()
# self.pen = pg.mkPen('#000000')
self.pen = pg.mkPen('#a9a9a9')
self.parent = parent
self.indicators = {}
self.activeIndicator = None
self.xaxis = self.parent.getAxis('bottom')
self.yaxis = self.parent.getAxis('right')
self.vline = self.parent.addLine(x=0, pen=self.pen, movable=False)
self.hline = self.parent.addLine(y=0, pen=self.pen, movable=False)
self.proxy_moved = pg.SignalProxy(
self.parent.scene().sigMouseMoved,
rateLimit=_mouse_rate_limit,
slot=self.mouseMoved,
)
self.yaxis_label = YAxisLabel(
parent=self.yaxis, digits=digits, opacity=1
)
indicators = indicators or []
if indicators:
# when there are indicators present in sub-plot rows
# take the last one (nearest to the bottom) and place the
# crosshair label on it's x-axis.
last_ind = indicators[-1]
self.proxy_enter = pg.SignalProxy(
self.parent.sig_mouse_enter,
rateLimit=_mouse_rate_limit,
slot=lambda: self.mouseAction('Enter', False),
)
self.proxy_leave = pg.SignalProxy(
self.parent.sig_mouse_leave,
rateLimit=_mouse_rate_limit,
slot=lambda: self.mouseAction('Leave', False),
)
# determine where to place x-axis label
if _xaxis_at == 'bottom':
# place below is last indicator subplot
self.xaxis_label = XAxisLabel(
parent=last_ind.getAxis('bottom'), opacity=1
)
else:
# keep x-axis right below main chart
self.xaxis_label = XAxisLabel(parent=self.xaxis, opacity=1)
for i in indicators:
# add vertial and horizonal lines and a y-axis label
vl = i.addLine(x=0, pen=self.pen, movable=False)
hl = i.addLine(y=0, pen=self.pen, movable=False)
yl = YAxisLabel(parent=i.getAxis('right'), opacity=1)
px_moved = pg.SignalProxy(
i.scene().sigMouseMoved,
rateLimit=_mouse_rate_limit,
slot=self.mouseMoved
)
px_enter = pg.SignalProxy(
i.sig_mouse_enter,
rateLimit=_mouse_rate_limit,
slot=lambda: self.mouseAction('Enter', i),
)
px_leave = pg.SignalProxy(
i.sig_mouse_leave,
rateLimit=_mouse_rate_limit,
slot=lambda: self.mouseAction('Leave', i),
)
self.indicators[i] = {
'vl': vl,
'hl': hl,
'yl': yl,
'px': (px_moved, px_enter, px_leave),
}
def mouseAction(self, action, ind=False): # noqa
if action == 'Enter':
# show horiz line and y-label
if ind:
self.indicators[ind]['hl'].show()
self.indicators[ind]['yl'].show()
self.activeIndicator = ind
else:
self.yaxis_label.show()
self.hline.show()
# Leave
else:
# hide horiz line and y-label
if ind:
self.indicators[ind]['hl'].hide()
self.indicators[ind]['yl'].hide()
self.activeIndicator = None
else:
self.yaxis_label.hide()
self.hline.hide()
def mouseMoved(self, evt): # noqa
"""Update horizonal and vertical lines when mouse moves inside
either the main chart or any indicator subplot.
"""
pos = evt[0]
# if the mouse is within the parent ``ChartPlotWidget``
if self.parent.sceneBoundingRect().contains(pos):
# mouse_point = self.vb.mapSceneToView(pos)
mouse_point = self.parent.mapToView(pos)
# move the vertial line to the current x coordinate
self.vline.setX(mouse_point.x())
# update the label on the bottom of the crosshair
self.xaxis_label.update_label(evt_post=pos, point_view=mouse_point)
# update the vertical line in any indicators subplots
for opts in self.indicators.values():
opts['vl'].setX(mouse_point.x())
if self.activeIndicator:
# vertial position of the mouse is inside an indicator
mouse_point_ind = self.activeIndicator.mapToView(pos)
self.indicators[self.activeIndicator]['hl'].setY(
mouse_point_ind.y()
)
self.indicators[self.activeIndicator]['yl'].update_label(
evt_post=pos, point_view=mouse_point_ind
)
else:
# vertial position of the mouse is inside the main chart
self.hline.setY(mouse_point.y())
self.yaxis_label.update_label(
evt_post=pos, point_view=mouse_point
)
def paint(self, p, *args):
pass
def boundingRect(self):
return self.parent.boundingRect()
class BarItem(pg.GraphicsObject):
# XXX: From the customGraphicsItem.py example:
# The only required methods are paint() and boundingRect()
w = 0.5
bull_brush = bear_brush = pg.mkPen('#808080')
# bull_brush = pg.mkPen('#00cc00')
# bear_brush = pg.mkPen('#fa0000')
def __init__(self):
super().__init__()
self.generatePicture()
# TODO: this is the routine to be retriggered for redraw
@timeit
def generatePicture(self):
# pre-computing a QPicture object allows paint() to run much
# more quickly, rather than re-drawing the shapes every time.
self.picture = QtGui.QPicture()
p = QtGui.QPainter(self.picture)
self._generate(p)
p.end()
def _generate(self, p):
# XXX: overloaded method to allow drawing other candle types
high_to_low = np.array(
[QtCore.QLineF(q.id, q.low, q.id, q.high) for q in Quotes]
)
open_stick = np.array(
[QtCore.QLineF(q.id - self.w, q.open, q.id, q.open)
for q in Quotes]
)
close_stick = np.array(
[
QtCore.QLineF(q.id + self.w, q.close, q.id, q.close)
for q in Quotes
]
)
lines = np.concatenate([high_to_low, open_stick, close_stick])
long_bars = np.resize(Quotes.close > Quotes.open, len(lines))
short_bars = np.resize(Quotes.close < Quotes.open, len(lines))
p.setPen(self.bull_brush)
p.drawLines(*lines[long_bars])
p.setPen(self.bear_brush)
p.drawLines(*lines[short_bars])
def paint(self, p, *args):
p.drawPicture(0, 0, self.picture)
def boundingRect(self):
# boundingRect _must_ indicate the entire area that will be
# drawn on or else we will get artifacts and possibly crashing.
# (in this case, QPicture does all the work of computing the
# bouning rect for us)
return QtCore.QRectF(self.picture.boundingRect())
class CandlestickItem(BarItem):
w2 = 0.7
line_pen = pg.mkPen('#000000')
bull_brush = pg.mkBrush('#00ff00')
bear_brush = pg.mkBrush('#ff0000')
def _generate(self, p):
rects = np.array(
[
QtCore.QRectF(q.id - self.w, q.open, self.w2, q.close - q.open)
for q in Quotes
]
)
p.setPen(self.line_pen)
p.drawLines(
[QtCore.QLineF(q.id, q.low, q.id, q.high)
for q in Quotes]
)
p.setBrush(self.bull_brush)
p.drawRects(*rects[Quotes.close > Quotes.open])
p.setBrush(self.bear_brush)
p.drawRects(*rects[Quotes.close < Quotes.open])
class SplitterChart(QtGui.QWidget):
long_pen = pg.mkPen('#006000')
long_brush = pg.mkBrush('#00ff00')
short_pen = pg.mkPen('#600000')
short_brush = pg.mkBrush('#ff0000')
zoomIsDisabled = QtCore.pyqtSignal(bool)
def __init__(self):
super().__init__()
self.signals_visible = False
self.indicators = []
self.xaxis = FromTimeFieldDateAxis(orientation='bottom')
# self.xaxis = pg.DateAxisItem()
self.xaxis_ind = FromTimeFieldDateAxis(orientation='bottom')
if _xaxis_at == 'bottom':
self.xaxis.setStyle(showValues=False)
else:
self.xaxis_ind.setStyle(showValues=False)
self.splitter = QtGui.QSplitter(QtCore.Qt.Vertical)
self.splitter.setHandleWidth(5)
self.layout = QtGui.QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.addWidget(self.splitter)
def _show_text_signals(self, lbar, rbar):
signals = [
sig
for sig in self.signals_text_items[lbar:rbar]
if isinstance(sig, CenteredTextItem)
]
if len(signals) <= 50:
for sig in signals:
sig.show()
else:
for sig in signals:
sig.hide()
def _remove_signals(self):
self.chart.removeItem(self.signals_group_arrow)
self.chart.removeItem(self.signals_group_text)
del self.signals_text_items
del self.signals_group_arrow
del self.signals_group_text
self.signals_visible = False
def _update_sizes(self):
min_h_ind = int(self.height() * 0.2 / len(self.indicators))
sizes = [int(self.height() * 0.8)]
sizes.extend([min_h_ind] * len(self.indicators))
self.splitter.setSizes(sizes) # , int(self.height()*0.2)
def plot(self, symbol):
self.digits = symbol.digits
self.chart = ChartPlotWidget(
split_charts=self,
parent=self.splitter,
axisItems={'bottom': self.xaxis, 'right': PriceAxis()},
viewBox=ChartView,
# enableMenu=False,
)
# TODO: ``pyqtgraph`` doesn't pass through a parent to the
# ``PlotItem`` by default; maybe we should PR this in?
self.chart.plotItem.parent = self
self.chart.getPlotItem().setContentsMargins(*CHART_MARGINS)
self.chart.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain)
# TODO: this is where we would load an indicator chain
inds = [Quotes.open]
for d in inds:
ind = ChartPlotWidget(
split_charts=self,
parent=self.splitter,
axisItems={'bottom': self.xaxis_ind, 'right': PriceAxis()},
# axisItems={'top': self.xaxis_ind, 'right': PriceAxis()},
viewBox=ChartView,
)
ind.plotItem.parent = self
ind.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain)
ind.getPlotItem().setContentsMargins(*CHART_MARGINS)
# self.splitter.addWidget(ind)
self.indicators.append((ind, d))
self.chart.draw_ohlc()
for ind_chart, d in self.indicators:
# link chart x-axis to main quotes chart
ind_chart.setXLink(self.chart)
# XXX: never do this lol
# ind.setAspectLocked(1)
ind_chart.draw_curve(d)
self._update_sizes()
ch = CrossHairItem(
self.chart, [_ind for _ind, d in self.indicators], self.digits
)
self.chart.addItem(ch)
def add_signals(self):
self.signals_group_text = QtGui.QGraphicsItemGroup()
self.signals_group_arrow = QtGui.QGraphicsItemGroup()
self.signals_text_items = np.empty(len(Quotes), dtype=object)
for p in Portfolio.positions:
x, price = p.id_bar_open, p.open_price
if p.type == Order.BUY:
y = Quotes[x].low * 0.99
pg.ArrowItem(
parent=self.signals_group_arrow,
pos=(x, y),
pen=self.long_pen,
brush=self.long_brush,
angle=90,
headLen=12,
tipAngle=50,
)
text_sig = CenteredTextItem(
parent=self.signals_group_text,
pos=(x, y),
pen=self.long_pen,
brush=self.long_brush,
text=('Buy at {:.%df}' % self.digits).format(price),
valign=QtCore.Qt.AlignBottom,
)
text_sig.hide()
else:
y = Quotes[x].high * 1.01
pg.ArrowItem(
parent=self.signals_group_arrow,
pos=(x, y),
pen=self.short_pen,
brush=self.short_brush,
angle=-90,
headLen=12,
tipAngle=50,
)
text_sig = CenteredTextItem(
parent=self.signals_group_text,
pos=(x, y),
pen=self.short_pen,
brush=self.short_brush,
text=('Sell at {:.%df}' % self.digits).format(price),
valign=QtCore.Qt.AlignTop,
)
text_sig.hide()
self.signals_text_items[x] = text_sig
self.chart.addItem(self.signals_group_arrow)
self.chart.addItem(self.signals_group_text)
self.signals_visible = True
# this function is borderline ridiculous.
# The creation of these chart types mutates all the input data
# inside each type's constructor (mind blown)
def _get_chart_points(style):
if style == ChartType.CANDLESTICK:
return CandlestickItem()
elif style == ChartType.BAR:
return BarItem()
return pg.PlotDataItem(Quotes.close, pen='b')