diff --git a/piker/ui/cli.py b/piker/ui/cli.py index d5f6a9ed..54a77eee 100644 --- a/piker/ui/cli.py +++ b/piker/ui/cli.py @@ -118,20 +118,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) diff --git a/piker/ui/qt/_axes.py b/piker/ui/qt/_axes.py index 96724a26..90b5c5f2 100644 --- a/piker/ui/qt/_axes.py +++ b/piker/ui/qt/_axes.py @@ -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 diff --git a/piker/ui/qt/_chart.py b/piker/ui/qt/_chart.py index b01a4110..cdf6b988 100644 --- a/piker/ui/qt/_chart.py +++ b/piker/ui/qt/_chart.py @@ -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') diff --git a/piker/ui/qt/_graphics.py b/piker/ui/qt/_graphics.py index bff8c043..9b90e8cf 100644 --- a/piker/ui/qt/_graphics.py +++ b/piker/ui/qt/_graphics.py @@ -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]) - long_bars = np.resize(Quotes.close > Quotes.open, len(lines)) - short_bars = np.resize(Quotes.close < Quotes.open, len(lines)) + 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']) - p.setPen(self.bull_brush) - p.drawLines(*lines[long_bars]) + # 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]) - p.setPen(self.bear_brush) - p.drawLines(*lines[short_bars]) + 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(*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): 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 diff --git a/piker/ui/qt/_source.py b/piker/ui/qt/_source.py new file mode 100644 index 00000000..3d6fbae7 --- /dev/null +++ b/piker/ui/qt/_source.py @@ -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] + ) diff --git a/piker/ui/qt/_style.py b/piker/ui/qt/_style.py index 8630d67d..b35e2e47 100644 --- a/piker/ui/qt/_style.py +++ b/piker/ui/qt/_style.py @@ -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')