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
"""
from .qt._exec import run_qtrio
from .qt._chart import QuotesTabWidget
from .qt.quantdom.base import Symbol
from .qt._chart import Chart
# uses pandas_datareader
from .qt.quantdom.loaders import get_quotes
async def plot_symbol(widgets):
"""Main Qt-trio routine invoked by the Qt loop with
the widgets ``dict``.
"""
qtw = widgets['main']
s = Symbol(ticker=symbol, mode=Symbol.SHARES)
get_quotes(
symbol=s.ticker,
quotes = get_quotes(
symbol=symbol,
date_from=datetime(1900, 1, 1),
date_to=datetime(2030, 12, 31),
)
# spawn chart
qtw.update_chart(s)
qtw.load_symbol(symbol, quotes)
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')
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)
self.parent = parent
self.opacity = opacity

View File

@ -10,12 +10,13 @@ from ._axes import (
FromTimeFieldDateAxis,
PriceAxis,
)
from ._graphics import CrossHairItem, CandlestickItem, BarItem
from ._graphics import CrossHairItem, ChartType
from ._style import _xaxis_at
from ._source import Symbol
from .quantdom.charts import CenteredTextItem
from .quantdom.base import Quotes
from .quantdom.const import ChartType
# from .quantdom.const import ChartType
from .quantdom.portfolio import Order, Portfolio
@ -23,20 +24,21 @@ from .quantdom.portfolio import Order, Portfolio
CHART_MARGINS = (0, 0, 10, 3)
class QuotesTabWidget(QtGui.QWidget):
class Chart(QtGui.QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.layout = QtGui.QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.v_layout = QtGui.QVBoxLayout(self)
self.v_layout.setContentsMargins(0, 0, 0, 0)
self.toolbar_layout = QtGui.QHBoxLayout()
self.toolbar_layout.setContentsMargins(10, 10, 15, 0)
self.chart_layout = QtGui.QHBoxLayout()
self.h_layout = QtGui.QHBoxLayout()
# self.init_timeframes_ui()
# self.init_strategy_ui()
self.layout.addLayout(self.toolbar_layout)
self.layout.addLayout(self.chart_layout)
self.v_layout.addLayout(self.toolbar_layout)
self.v_layout.addLayout(self.h_layout)
self._plot_cache = {}
def init_timeframes_ui(self):
self.tf_layout = QtGui.QHBoxLayout()
@ -58,21 +60,32 @@ class QuotesTabWidget(QtGui.QWidget):
# self.strategy_box = StrategyBoxWidget(self)
# self.toolbar_layout.addWidget(self.strategy_box)
# TODO: this needs to be changed to ``load_symbol()``
# which will not only load historical data but also a real-time
# stream and schedule the redraw events on new quotes
def update_chart(self, symbol):
if not self.chart_layout.isEmpty():
self.chart_layout.removeWidget(self.chart)
self.chart = SplitterChart()
self.chart.plot(symbol)
self.chart_layout.addWidget(self.chart)
def load_symbol(
self,
symbol: str,
data: np.ndarray,
) -> None:
"""Load a new contract into the charting app.
"""
# XXX: let's see if this causes mem problems
self.chart = self._plot_cache.setdefault(symbol, SplitterPlots())
s = Symbol(key=symbol)
def add_signals(self):
self.chart.add_signals()
# remove any existing plots
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_brush = pg.mkBrush('#00ff00')
@ -131,16 +144,21 @@ class SplitterChart(QtGui.QWidget):
sizes.extend([min_h_ind] * len(self.indicators))
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.
"""
self.digits = symbol.digits
self.digits = symbol.digits()
cv = ChartView()
self.chart = ChartPlotWidget(
split_charts=self,
parent=self.splitter,
axisItems={'bottom': self.xaxis, 'right': PriceAxis()},
viewBox=ChartView,
viewBox=cv,
# enableMenu=False,
)
# 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.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain)
self.chart.draw_ohlc(data)
# TODO: this is where we would load an indicator chain
inds = [Quotes.open]
for d in inds:
ind = ChartPlotWidget(
cv = ChartView()
ind_chart = ChartPlotWidget(
split_charts=self,
parent=self.splitter,
axisItems={'bottom': self.xaxis_ind, 'right': PriceAxis()},
# axisItems={'top': self.xaxis_ind, 'right': PriceAxis()},
viewBox=ChartView,
viewBox=cv,
)
self.chart.plotItem.vb.splitter_widget = 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:
ind_chart.setFrameStyle(
QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain
)
ind_chart.getPlotItem().setContentsMargins(*CHART_MARGINS)
# self.splitter.addWidget(ind_chart)
self.indicators.append((ind_chart, d))
# link chart x-axis to main quotes chart
ind_chart.setXLink(self.chart)
@ -245,8 +264,6 @@ _min_points_to_show = 20
_min_bars_in_view = 10
# 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``.
@ -260,6 +277,9 @@ class ChartPlotWidget(pg.PlotWidget):
sig_mouse_leave = QtCore.Signal(object)
sig_mouse_enter = QtCore.Signal(object)
# TODO: can take a ``background`` color setting - maybe there's
# a better one?
def __init__(
self,
split_charts,
@ -319,15 +339,18 @@ class ChartPlotWidget(pg.PlotWidget):
def draw_ohlc(
self,
data: np.ndarray,
style: ChartType = ChartType.BAR,
) -> None:
"""Draw OHLC datums to chart.
"""
# remember it's an enum type..
graphics = style.value()
# 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))
graphics.draw_from_data(data)
self.addItem(graphics)
def draw_curve(
self,
@ -422,9 +445,9 @@ class ChartView(pg.ViewBox):
else:
mask = self.state['mouseEnabled'][:]
# don't zoom more then the min points setting
lbar, rbar = self.splitter_widget.chart.bars_range()
if ev.delta() >= 0 and rbar - lbar <= _min_points_to_show:
# don't zoom more then the min points setting
return
# actual scaling factor
@ -447,14 +470,3 @@ class ChartView(pg.ViewBox):
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

@ -1,14 +1,18 @@
"""
Chart graphics for displaying a slew of different data types.
"""
from enum import Enum
from contextlib import contextmanager
import numpy as np
import pyqtgraph as pg
from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import QLineF
from .quantdom.utils import timeit
from .quantdom.base import Quotes
from ._style import _xaxis_at
from ._style import _xaxis_at, _tina_mode
from ._axes import YAxisLabel, XAxisLabel
@ -163,11 +167,10 @@ class CrossHairItem(pg.GraphicsObject):
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
class BarItems(pg.GraphicsObject):
"""Price range bars graphics rendered from a OHLC sequence.
"""
w: float = 0.5
bull_brush = bear_brush = pg.mkPen('#808080')
# bull_brush = pg.mkPen('#00cc00')
@ -175,44 +178,72 @@ class BarItem(pg.GraphicsObject):
def __init__(self):
super().__init__()
self.generatePicture()
self.picture = QtGui.QPicture()
self.lines = None
# self.generatePicture()
# TODO: this is the routine to be retriggered for redraw
@timeit
def generatePicture(self):
@contextmanager
def painter(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)
yield p
p.end()
def _generate(self, p):
@timeit
def draw_from_data(self, data):
# 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])
high_to_low = np.empty_like(data, dtype=object)
open_sticks = np.empty_like(data, dtype=object)
close_sticks = np.empty_like(data, dtype=object)
with self.painter() as p:
import time
start = time.time()
for i, q in enumerate(data):
high_to_low[i] = QLineF(q['id'], q['low'], q['id'], q['high'])
open_sticks[i] = QLineF(
q['id'] - self.w, q['open'], q['id'], q['open'])
close_sticks[i] = QtCore.QLineF(
q['id'] + self.w, q['close'], q['id'], q['close'])
# high_to_low = np.array(
# [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])
if _tina_mode:
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(*lines[long_bars])
p.drawLines(*ups)
# draw "down" bars
p.setPen(self.bear_brush)
p.drawLines(*lines[short_bars])
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):
p.drawPicture(0, 0, self.picture)
@ -224,7 +255,7 @@ class BarItem(pg.GraphicsObject):
return QtCore.QRectF(self.picture.boundingRect())
class CandlestickItem(BarItem):
class CandlestickItems(BarItems):
w2 = 0.7
line_pen = pg.mkPen('#000000')
@ -250,3 +281,11 @@ class CandlestickItem(BarItem):
p.setBrush(self.bear_brush)
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
# 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)
@ -16,3 +12,17 @@ _i3_rgba = QtGui.QColor.fromRgbF(*[0.14]*3 + [1])
# splitter widget config
_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')