Render plots from provided input sequence(s)

Previously graphics were loaded and rendered implicitly during the
import and creation of certain objects. Remove all this and instead
expect client code to pass in the OHLC sequence to plot. Speed up
the bars graphics rendering by simplifying to a single iteration of
the input array; gives about a 2x speedup.
its_happening
Tyler Goodlet 2020-06-16 13:32:03 -04:00
parent d102537ca8
commit 11a7530d09
6 changed files with 263 additions and 94 deletions

View File

@ -117,20 +117,24 @@ def chart(config, symbol, date, tl, rate, test):
"""Start an option chain UI """Start an option chain UI
""" """
from .qt._exec import run_qtrio from .qt._exec import run_qtrio
from .qt._chart import QuotesTabWidget from .qt._chart import Chart
from .qt.quantdom.base import Symbol
# uses pandas_datareader
from .qt.quantdom.loaders import get_quotes from .qt.quantdom.loaders import get_quotes
async def plot_symbol(widgets): async def plot_symbol(widgets):
"""Main Qt-trio routine invoked by the Qt loop with
the widgets ``dict``.
"""
qtw = widgets['main'] qtw = widgets['main']
s = Symbol(ticker=symbol, mode=Symbol.SHARES) quotes = get_quotes(
get_quotes( symbol=symbol,
symbol=s.ticker,
date_from=datetime(1900, 1, 1), date_from=datetime(1900, 1, 1),
date_to=datetime(2030, 12, 31), date_to=datetime(2030, 12, 31),
) )
# spawn chart # spawn chart
qtw.update_chart(s) qtw.load_symbol(symbol, quotes)
await trio.sleep_forever() await trio.sleep_forever()
run_qtrio(plot_symbol, (), QuotesTabWidget) run_qtrio(plot_symbol, (), Chart)

View File

@ -72,7 +72,14 @@ class AxisLabel(pg.GraphicsObject):
bg_color = pg.mkColor('#808080') 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
):
super().__init__(parent) super().__init__(parent)
self.parent = parent self.parent = parent
self.opacity = opacity self.opacity = opacity

View File

@ -10,12 +10,13 @@ from ._axes import (
FromTimeFieldDateAxis, FromTimeFieldDateAxis,
PriceAxis, PriceAxis,
) )
from ._graphics import CrossHairItem, CandlestickItem, BarItem from ._graphics import CrossHairItem, ChartType
from ._style import _xaxis_at from ._style import _xaxis_at
from ._source import Symbol
from .quantdom.charts import CenteredTextItem from .quantdom.charts import CenteredTextItem
from .quantdom.base import Quotes from .quantdom.base import Quotes
from .quantdom.const import ChartType # from .quantdom.const import ChartType
from .quantdom.portfolio import Order, Portfolio from .quantdom.portfolio import Order, Portfolio
@ -23,20 +24,21 @@ from .quantdom.portfolio import Order, Portfolio
CHART_MARGINS = (0, 0, 10, 3) CHART_MARGINS = (0, 0, 10, 3)
class QuotesTabWidget(QtGui.QWidget): class Chart(QtGui.QWidget):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.layout = QtGui.QVBoxLayout(self) self.v_layout = QtGui.QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0) self.v_layout.setContentsMargins(0, 0, 0, 0)
self.toolbar_layout = QtGui.QHBoxLayout() self.toolbar_layout = QtGui.QHBoxLayout()
self.toolbar_layout.setContentsMargins(10, 10, 15, 0) self.toolbar_layout.setContentsMargins(10, 10, 15, 0)
self.chart_layout = QtGui.QHBoxLayout() self.h_layout = QtGui.QHBoxLayout()
# self.init_timeframes_ui() # self.init_timeframes_ui()
# self.init_strategy_ui() # self.init_strategy_ui()
self.layout.addLayout(self.toolbar_layout) self.v_layout.addLayout(self.toolbar_layout)
self.layout.addLayout(self.chart_layout) self.v_layout.addLayout(self.h_layout)
self._plot_cache = {}
def init_timeframes_ui(self): def init_timeframes_ui(self):
self.tf_layout = QtGui.QHBoxLayout() self.tf_layout = QtGui.QHBoxLayout()
@ -58,21 +60,32 @@ class QuotesTabWidget(QtGui.QWidget):
# self.strategy_box = StrategyBoxWidget(self) # self.strategy_box = StrategyBoxWidget(self)
# self.toolbar_layout.addWidget(self.strategy_box) # self.toolbar_layout.addWidget(self.strategy_box)
# TODO: this needs to be changed to ``load_symbol()`` def load_symbol(
# which will not only load historical data but also a real-time self,
# stream and schedule the redraw events on new quotes symbol: str,
def update_chart(self, symbol): data: np.ndarray,
if not self.chart_layout.isEmpty(): ) -> None:
self.chart_layout.removeWidget(self.chart) """Load a new contract into the charting app.
self.chart = SplitterChart() """
self.chart.plot(symbol) # XXX: let's see if this causes mem problems
self.chart_layout.addWidget(self.chart) self.chart = self._plot_cache.setdefault(symbol, SplitterPlots())
s = Symbol(key=symbol)
def add_signals(self): # remove any existing plots
self.chart.add_signals() if not self.h_layout.isEmpty():
self.h_layout.removeWidget(self.chart)
self.chart.plot(s, data)
self.h_layout.addWidget(self.chart)
# TODO: add signalling painter system
# def add_signals(self):
# self.chart.add_signals()
class SplitterChart(QtGui.QWidget): class SplitterPlots(QtGui.QWidget):
"""Widget that holds a price chart plus indicators separated by splitters.
"""
long_pen = pg.mkPen('#006000') long_pen = pg.mkPen('#006000')
long_brush = pg.mkBrush('#00ff00') long_brush = pg.mkBrush('#00ff00')
@ -131,16 +144,21 @@ class SplitterChart(QtGui.QWidget):
sizes.extend([min_h_ind] * len(self.indicators)) sizes.extend([min_h_ind] * len(self.indicators))
self.splitter.setSizes(sizes) # , int(self.height()*0.2) self.splitter.setSizes(sizes) # , int(self.height()*0.2)
def plot(self, symbol): def plot(
self,
symbol: Symbol,
data: np.ndarray,
):
"""Start up and show price chart and all registered indicators. """Start up and show price chart and all registered indicators.
""" """
self.digits = symbol.digits self.digits = symbol.digits()
cv = ChartView()
self.chart = ChartPlotWidget( self.chart = ChartPlotWidget(
split_charts=self, split_charts=self,
parent=self.splitter, parent=self.splitter,
axisItems={'bottom': self.xaxis, 'right': PriceAxis()}, axisItems={'bottom': self.xaxis, 'right': PriceAxis()},
viewBox=ChartView, viewBox=cv,
# enableMenu=False, # enableMenu=False,
) )
# TODO: ``pyqtgraph`` doesn't pass through a parent to the # TODO: ``pyqtgraph`` doesn't pass through a parent to the
@ -150,27 +168,28 @@ class SplitterChart(QtGui.QWidget):
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)
self.chart.draw_ohlc(data)
# TODO: this is where we would load an indicator chain # 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 = ChartPlotWidget( cv = ChartView()
ind_chart = ChartPlotWidget(
split_charts=self, split_charts=self,
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()}, # axisItems={'top': self.xaxis_ind, 'right': PriceAxis()},
viewBox=ChartView, viewBox=cv,
) )
self.chart.plotItem.vb.splitter_widget = self self.chart.plotItem.vb.splitter_widget = self
ind.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain) ind_chart.setFrameStyle(
ind.getPlotItem().setContentsMargins(*CHART_MARGINS) QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain
# self.splitter.addWidget(ind) )
self.indicators.append((ind, d)) ind_chart.getPlotItem().setContentsMargins(*CHART_MARGINS)
# self.splitter.addWidget(ind_chart)
self.chart.draw_ohlc() self.indicators.append((ind_chart, d))
for ind_chart, d in self.indicators:
# link chart x-axis to main quotes chart # link chart x-axis to main quotes chart
ind_chart.setXLink(self.chart) ind_chart.setXLink(self.chart)
@ -245,8 +264,6 @@ _min_points_to_show = 20
_min_bars_in_view = 10 _min_bars_in_view = 10
# TODO: This is a sub-class of ``GracphicView`` which can
# take a ``background`` color setting.
class ChartPlotWidget(pg.PlotWidget): class ChartPlotWidget(pg.PlotWidget):
"""``GraphicsView`` subtype containing a single ``PlotItem``. """``GraphicsView`` subtype containing a single ``PlotItem``.
@ -260,6 +277,9 @@ class ChartPlotWidget(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)
# TODO: can take a ``background`` color setting - maybe there's
# a better one?
def __init__( def __init__(
self, self,
split_charts, split_charts,
@ -319,15 +339,18 @@ class ChartPlotWidget(pg.PlotWidget):
def draw_ohlc( def draw_ohlc(
self, self,
data: np.ndarray,
style: ChartType = ChartType.BAR, style: ChartType = ChartType.BAR,
) -> None: ) -> None:
"""Draw OHLC datums to chart. """Draw OHLC datums to chart.
""" """
# remember it's an enum type..
graphics = style.value()
# adds all bar/candle graphics objects for each # adds all bar/candle graphics objects for each
# data point in the np array buffer to # data point in the np array buffer to
# be drawn on next render cycle # be drawn on next render cycle
self.addItem(_get_chart_points(style)) graphics.draw_from_data(data)
self.addItem(graphics)
def draw_curve( def draw_curve(
self, self,
@ -422,9 +445,9 @@ class ChartView(pg.ViewBox):
else: else:
mask = self.state['mouseEnabled'][:] mask = self.state['mouseEnabled'][:]
# don't zoom more then the min points setting
lbar, rbar = self.splitter_widget.chart.bars_range() lbar, rbar = self.splitter_widget.chart.bars_range()
if ev.delta() >= 0 and rbar - lbar <= _min_points_to_show: if ev.delta() >= 0 and rbar - lbar <= _min_points_to_show:
# don't zoom more then the min points setting
return return
# actual scaling factor # actual scaling factor
@ -447,14 +470,3 @@ class ChartView(pg.ViewBox):
self.scaleBy(s, center) self.scaleBy(s, center)
ev.accept() ev.accept()
self.sigRangeChangedManually.emit(mask) 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

@ -1,14 +1,18 @@
""" """
Chart graphics for displaying a slew of different data types. Chart graphics for displaying a slew of different data types.
""" """
from enum import Enum
from contextlib import contextmanager
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from PyQt5 import QtCore, QtGui from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import QLineF
from .quantdom.utils import timeit from .quantdom.utils import timeit
from .quantdom.base import Quotes from .quantdom.base import Quotes
from ._style import _xaxis_at from ._style import _xaxis_at, _tina_mode
from ._axes import YAxisLabel, XAxisLabel from ._axes import YAxisLabel, XAxisLabel
@ -163,11 +167,10 @@ class CrossHairItem(pg.GraphicsObject):
return self.parent.boundingRect() return self.parent.boundingRect()
class BarItem(pg.GraphicsObject): class BarItems(pg.GraphicsObject):
# XXX: From the customGraphicsItem.py example: """Price range bars graphics rendered from a OHLC sequence.
# The only required methods are paint() and boundingRect() """
w: float = 0.5
w = 0.5
bull_brush = bear_brush = pg.mkPen('#808080') bull_brush = bear_brush = pg.mkPen('#808080')
# bull_brush = pg.mkPen('#00cc00') # bull_brush = pg.mkPen('#00cc00')
@ -175,44 +178,72 @@ class BarItem(pg.GraphicsObject):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.generatePicture() self.picture = QtGui.QPicture()
self.lines = None
# self.generatePicture()
# TODO: this is the routine to be retriggered for redraw # TODO: this is the routine to be retriggered for redraw
@timeit @contextmanager
def generatePicture(self): def painter(self):
# pre-computing a QPicture object allows paint() to run much # pre-computing a QPicture object allows paint() to run much
# more quickly, rather than re-drawing the shapes every time. # more quickly, rather than re-drawing the shapes every time.
self.picture = QtGui.QPicture()
p = QtGui.QPainter(self.picture) p = QtGui.QPainter(self.picture)
self._generate(p) yield p
p.end() p.end()
def _generate(self, p): @timeit
def draw_from_data(self, data):
# XXX: overloaded method to allow drawing other candle types # XXX: overloaded method to allow drawing other candle types
high_to_low = np.array( high_to_low = np.empty_like(data, dtype=object)
[QtCore.QLineF(q.id, q.low, q.id, q.high) for q in Quotes] open_sticks = np.empty_like(data, dtype=object)
) close_sticks = np.empty_like(data, dtype=object)
open_stick = np.array( with self.painter() as p:
[QtCore.QLineF(q.id - self.w, q.open, q.id, q.open) import time
for q in Quotes] start = time.time()
) for i, q in enumerate(data):
close_stick = np.array( high_to_low[i] = QLineF(q['id'], q['low'], q['id'], q['high'])
[ open_sticks[i] = QLineF(
QtCore.QLineF(q.id + self.w, q.close, q.id, q.close) q['id'] - self.w, q['open'], q['id'], q['open'])
for q in Quotes close_sticks[i] = QtCore.QLineF(
] q['id'] + self.w, q['close'], q['id'], q['close'])
)
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) # high_to_low = np.array(
p.drawLines(*lines[long_bars]) # [QtCore.QLineF(q.id, q.low, q.id, q.high) for q in Quotes]
# )
# open_sticks = np.array(
# [QtCore.QLineF(q.id - self.w, q.open, q.id, q.open)
# for q in Quotes]
# )
# close_sticks = np.array(
# [
# QtCore.QLineF(q.id + self.w, q.close, q.id, q.close)
# for q in Quotes
# ]
# )
print(f"took {time.time() - start}")
self.lines = lines = np.concatenate([high_to_low, open_sticks, close_sticks])
p.setPen(self.bear_brush) if _tina_mode:
p.drawLines(*lines[short_bars]) long_bars = np.resize(Quotes.close > Quotes.open, len(lines))
short_bars = np.resize(Quotes.close < Quotes.open, len(lines))
ups = lines[long_bars]
downs = lines[short_bars]
# draw "up" bars
p.setPen(self.bull_brush)
p.drawLines(*ups)
# draw "down" bars
p.setPen(self.bear_brush)
p.drawLines(*downs)
else: # piker mode
p.setPen(self.bull_brush)
p.drawLines(*lines)
# XXX: From the customGraphicsItem.py example:
# The only required methods are paint() and boundingRect()
def paint(self, p, *args): def paint(self, p, *args):
p.drawPicture(0, 0, self.picture) p.drawPicture(0, 0, self.picture)
@ -224,7 +255,7 @@ class BarItem(pg.GraphicsObject):
return QtCore.QRectF(self.picture.boundingRect()) return QtCore.QRectF(self.picture.boundingRect())
class CandlestickItem(BarItem): class CandlestickItems(BarItems):
w2 = 0.7 w2 = 0.7
line_pen = pg.mkPen('#000000') line_pen = pg.mkPen('#000000')
@ -250,3 +281,11 @@ class CandlestickItem(BarItem):
p.setBrush(self.bear_brush) p.setBrush(self.bear_brush)
p.drawRects(*rects[Quotes.close < Quotes.open]) p.drawRects(*rects[Quotes.close < Quotes.open])
class ChartType(Enum):
"""Bar type to graphics class map.
"""
BAR = BarItems
CANDLESTICK = CandlestickItems
LINE = pg.PlotDataItem

View File

@ -0,0 +1,97 @@
"""
Numpy data source machinery.
"""
import math
from dataclasses import dataclass
import numpy as np
import pandas as pd
OHLC_dtype = np.dtype(
[
('id', int),
('time', float),
('open', float),
('high', float),
('low', float),
('close', float),
('volume', int),
]
)
# tf = {
# 1: TimeFrame.M1,
# 5: TimeFrame.M5,
# 15: TimeFrame.M15,
# 30: TimeFrame.M30,
# 60: TimeFrame.H1,
# 240: TimeFrame.H4,
# 1440: TimeFrame.D1,
# }
@dataclass
class Symbol:
"""I guess this is some kinda container thing for dealing with
all the different meta-data formats from brokers?
"""
key: str = ''
min_tick: float = 0.01
contract: str = ''
def digits(self) -> int:
"""Return the trailing number of digits specified by the
min tick size for the instrument.
"""
return int(math.log(self.min_tick, 0.1))
def from_df(
df: pd.DataFrame,
source=None,
default_tf=None
):
"""Cast OHLC ``pandas.DataFrame`` to ``numpy.structarray``.
"""
# shape = (len(df),)
# array.resize(shape, refcheck=False)
array = np.array([], dtype=OHLC_dtype)
df.reset_index(inplace=True)
df.insert(0, 'id', df.index)
array['time'] = np.array([d.timestamp().time for d in df.Date])
# try to rename from some camel case
df = df.rename(
columns={
# 'Date': 'time',
'Open': 'open',
'High': 'high',
'Low': 'low',
'Close': 'close',
'Volume': 'volume',
}
)
for name in array.dtype.names:
array[name] = df[name]
array[:] = df[:]
_nan_to_closest_num(array)
# self._set_time_frame(default_tf)
return array
def _nan_to_closest_num(array: np.ndarray):
"""Return interpolated values instead of NaN.
"""
for col in ['open', 'high', 'low', 'close']:
mask = np.isnan(array[col])
if not mask.size:
continue
array[col][mask] = np.interp(
np.flatnonzero(mask), np.flatnonzero(~mask), array[col][~mask]
)

View File

@ -4,10 +4,6 @@ Qt styling.
from PyQt5 import QtGui 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 # chart-wide font
_font = QtGui.QFont("Hack", 4) _font = QtGui.QFont("Hack", 4)
@ -16,3 +12,17 @@ _i3_rgba = QtGui.QColor.fromRgbF(*[0.14]*3 + [1])
# splitter widget config # splitter widget config
_xaxis_at = 'bottom' _xaxis_at = 'bottom'
_tina_mode = False
def enable_tina_mode() -> None:
"""Enable "tina mode" to make everything look "conventional"
like your pet hedgehog always wanted.
"""
_tina_mode = True
# white background (for tinas like our pal xb)
pg.setConfigOption('background', 'w')