Allow for dynamically added plots

Add `ChartPlotWidget.add_plot()` to add sub charts for indicators which
can be updated independently. Clean up rt bar update code and drop some
legacy ohlc loading cruft.
its_happening
Tyler Goodlet 2020-07-17 09:06:20 -04:00
parent a1032a0cd7
commit b97286d7f5
2 changed files with 188 additions and 186 deletions

View File

@ -2,21 +2,24 @@
High level Qt chart widgets. High level Qt chart widgets.
""" """
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
import time
import trio from PyQt5 import QtCore, QtGui
from pyqtgraph import functions as fn
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from pyqtgraph import functions as fn import tractor
from PyQt5 import QtCore, QtGui import trio
from ._axes import ( from ._axes import (
DynamicDateAxis, DynamicDateAxis,
PriceAxis, PriceAxis,
) )
from ._graphics import CrossHairItem, ChartType from ._graphics import CrossHair, ChartType
from ._style import _xaxis_at from ._style import _xaxis_at
from ._source import Symbol from ._source import Symbol
from .. import brokers from .. import brokers
from .. import data
from ..log import get_logger from ..log import get_logger
@ -74,6 +77,7 @@ class ChartSpace(QtGui.QWidget):
"""Load a new contract into the charting app. """Load a new contract into the charting app.
""" """
# XXX: let's see if this causes mem problems # XXX: let's see if this causes mem problems
self.window.setWindowTitle(f'piker chart {symbol}')
self.chart = self._plot_cache.setdefault(symbol, LinkedSplitCharts()) self.chart = self._plot_cache.setdefault(symbol, LinkedSplitCharts())
s = Symbol(key=symbol) s = Symbol(key=symbol)
@ -107,22 +111,20 @@ class LinkedSplitCharts(QtGui.QWidget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.signals_visible = False self.signals_visible: bool = False
self._array: np.ndarray = None # main data source
# main data source self._ch: CrossHair = None # crosshair graphics
self._array: np.ndarray = None self.chart: ChartPlotWidget = None # main (ohlc) chart
self.subplots: List[ChartPlotWidget] = []
self._ch = None # crosshair graphics
self._index = 0
self.chart = None # main (ohlc) chart
self.indicators = []
self.xaxis = DynamicDateAxis( self.xaxis = DynamicDateAxis(
orientation='bottom', linked_charts=self) orientation='bottom',
linked_charts=self
)
self.xaxis_ind = DynamicDateAxis( self.xaxis_ind = DynamicDateAxis(
orientation='bottom', linked_charts=self) orientation='bottom',
linked_charts=self
)
if _xaxis_at == 'bottom': if _xaxis_at == 'bottom':
self.xaxis.setStyle(showValues=False) self.xaxis.setStyle(showValues=False)
@ -134,20 +136,18 @@ class LinkedSplitCharts(QtGui.QWidget):
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.layout.addWidget(self.splitter) self.layout.addWidget(self.splitter)
def set_split_sizes( def set_split_sizes(
self, self,
prop: float = 0.25 prop: float = 0.25 # proportion allocated to consumer subcharts
) -> None: ) -> None:
"""Set the proportion of space allocated for linked subcharts. """Set the proportion of space allocated for linked subcharts.
""" """
major = 1 - prop major = 1 - prop
# 20% allocated to consumer subcharts min_h_ind = int(self.height() * prop / len(self.subplots))
min_h_ind = int(self.height() * prop / len(self.indicators))
sizes = [int(self.height() * major)] sizes = [int(self.height() * major)]
sizes.extend([min_h_ind] * len(self.indicators)) sizes.extend([min_h_ind] * len(self.subplots))
self.splitter.setSizes(sizes) # , int(self.height()*0.2) self.splitter.setSizes(sizes) # , int(self.height()*0.2)
def plot( def plot(
@ -155,82 +155,94 @@ class LinkedSplitCharts(QtGui.QWidget):
symbol: Symbol, symbol: Symbol,
array: np.ndarray, array: np.ndarray,
ohlc: bool = True, ohlc: bool = True,
): ) -> None:
"""Start up and show main (price) chart and all linked subcharts. """Start up and show main (price) chart and all linked subcharts.
""" """
self.digits = symbol.digits() self.digits = symbol.digits()
# XXX: this may eventually be a view onto shared mem # XXX: this will eventually be a view onto shared mem
# or some higher level type / API # or some higher level type / API
self._array = array self._array = array
cv = ChartView() # add crosshairs
self.chart = ChartPlotWidget( self._ch = CrossHair(
linked_charts=self, parent=self, #.chart,
parent=self.splitter, # subplots=[plot for plot, d in self.subplots],
axisItems={'bottom': self.xaxis, 'right': PriceAxis()}, digits=self.digits
viewBox=cv,
# enableMenu=False,
) )
# TODO: ``pyqtgraph`` doesn't pass through a parent to the self.chart = self.add_plot(
# ``PlotItem`` by default; maybe we should PR this in? name='main',
cv.linked_charts = self array=array, #['close'],
self.chart.plotItem.vb.linked_charts = self xaxis=self.xaxis,
ohlc=True,
self.chart.getPlotItem().setContentsMargins(*CHART_MARGINS) )
self.chart.addItem(self._ch)
self.chart.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain) self.chart.setFrameStyle(QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain)
if ohlc:
self.chart.draw_ohlc(array)
else:
raise NotImplementedError(
"Only OHLC linked charts are supported currently"
)
# TODO: this is where we would load an indicator chain # TODO: this is where we would load an indicator chain
# XXX: note, if this isn't index aligned with # XXX: note, if this isn't index aligned with
# the source data the chart will go haywire. # the source data the chart will go haywire.
inds = [('open', lambda a: a['close'])] inds = [('open', lambda a: a['close'])]
for name, func in inds: for name, func in inds:
# compute historical subchart values from input array
data = func(array)
# create sub-plot
ind_chart = self.add_plot(name=name, array=data)
self.subplots.append((ind_chart, func))
# scale split regions
self.set_split_sizes()
def add_plot(
self,
name: str,
array: np.ndarray,
xaxis: DynamicDateAxis = None,
ohlc: bool = False,
) -> 'ChartPlotWidget':
"""Add (sub)plots to chart widget by name.
If ``name`` == ``"main"`` the chart will be the the primary view.
"""
cv = ChartView() cv = ChartView()
ind_chart = ChartPlotWidget( # use "indicator axis" by default
xaxis = self.xaxis_ind if xaxis is None else xaxis
cpw = ChartPlotWidget(
linked_charts=self, linked_charts=self,
parent=self.splitter, parent=self.splitter,
axisItems={'bottom': self.xaxis_ind, 'right': PriceAxis()}, axisItems={'bottom': xaxis, 'right': PriceAxis()},
# axisItems={'top': self.xaxis_ind, 'right': PriceAxis()}, # axisItems={'top': self.xaxis_ind, 'right': PriceAxis()},
viewBox=cv, viewBox=cv,
) )
# this name will be used to register the primary # this name will be used to register the primary
# graphics curve managed by the subchart # graphics curve managed by the subchart
ind_chart.name = name cpw.name = name
cv.linked_charts = self cv.linked_charts = self
self.chart.plotItem.vb.linked_charts = self cpw.plotItem.vb.linked_charts = self
ind_chart.setFrameStyle( cpw.setFrameStyle(
QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain QtGui.QFrame.StyledPanel | QtGui.QFrame.Plain
) )
ind_chart.getPlotItem().setContentsMargins(*CHART_MARGINS) cpw.getPlotItem().setContentsMargins(*CHART_MARGINS)
# self.splitter.addWidget(ind_chart) # self.splitter.addWidget(cpw)
# compute historical subchart values from input array
data = func(array)
self.indicators.append((ind_chart, func))
# link chart x-axis to main quotes chart # link chart x-axis to main quotes chart
ind_chart.setXLink(self.chart) cpw.setXLink(self.chart)
# draw curve graphics # draw curve graphics
ind_chart.draw_curve(data, name) if ohlc:
cpw.draw_ohlc(array)
else:
cpw.draw_curve(array, name)
self.set_split_sizes() # add to cross-hair's known plots
self._ch.add_plot(cpw)
ch = self._ch = CrossHairItem( return cpw
self.chart,
[_ind for _ind, d in self.indicators],
self.digits
)
self.chart.addItem(ch)
def update_from_quote( def update_from_quote(
self, self,
@ -243,14 +255,10 @@ class LinkedSplitCharts(QtGui.QWidget):
""" """
# TODO: eventually we'll want to update bid/ask labels and other # TODO: eventually we'll want to update bid/ask labels and other
# data as subscribed by underlying UI consumers. # data as subscribed by underlying UI consumers.
last = quote['last'] last = quote.get('last') or quote['close']
index, time, open, high, low, close, volume = self._array[-1] index, time, open, high, low, close, volume = self._array[-1]
# update ohlc (I guess we're enforcing this for now?) # update ohlc (I guess we're enforcing this for now?)
# self._array[-1]['close'] = last
# self._array[-1]['high'] = max(h, last)
# self._array[-1]['low'] = min(l, last)
# overwrite from quote # overwrite from quote
self._array[-1] = ( self._array[-1] = (
index, index,
@ -268,19 +276,25 @@ class LinkedSplitCharts(QtGui.QWidget):
array: np.ndarray, array: np.ndarray,
**kwargs, **kwargs,
) -> None: ) -> None:
# update the ohlc sequence graphics chart """Update all linked chart graphics with a new input array.
chart = self.chart
Return the modified graphics objects in a list.
"""
# update the ohlc sequence graphics chart
# we send a reference to the whole updated array # we send a reference to the whole updated array
chart.update_from_array(array, **kwargs) self.chart.update_from_array(array, **kwargs)
# TODO: the "data" here should really be a function # TODO: the "data" here should really be a function
# and it should be managed and computed outside of this UI # and it should be managed and computed outside of this UI
for chart, func in self.indicators: graphics = []
for chart, func in self.subplots:
# process array in entirely every update # process array in entirely every update
# TODO: change this for streaming # TODO: change this for streaming
data = func(array) data = func(array)
chart.update_from_array(data, name=chart.name, **kwargs) graphic = chart.update_from_array(data, name=chart.name, **kwargs)
graphics.append(graphic)
return graphics
_min_points_to_show = 3 _min_points_to_show = 3
@ -316,9 +330,6 @@ class ChartPlotWidget(pg.PlotWidget):
""" """
super().__init__(**kwargs) super().__init__(**kwargs)
self.parent = linked_charts self.parent = linked_charts
# this is the index of that last input array entry and is
# updated and used to figure out how many bars are in view
# self._xlast = 0
# XXX: label setting doesn't seem to work? # XXX: label setting doesn't seem to work?
# likely custom graphics need special handling # likely custom graphics need special handling
@ -327,8 +338,7 @@ class ChartPlotWidget(pg.PlotWidget):
# label.setText("Yo yoyo") # label.setText("Yo yoyo")
# label.setText("<span style='font-size: 12pt'>x=") # label.setText("<span style='font-size: 12pt'>x=")
# to be filled in when graphics are rendered # to be filled in when graphics are rendered by name
# by name
self._graphics = {} self._graphics = {}
# show only right side axes # show only right side axes
@ -355,7 +365,6 @@ class ChartPlotWidget(pg.PlotWidget):
"""Set view limits (what's shown in the main chart "pane") """Set view limits (what's shown in the main chart "pane")
based on max/min x/y coords. based on max/min x/y coords.
""" """
# set panning limits
self.setLimits( self.setLimits(
xMin=xfirst, xMin=xfirst,
xMax=xlast, xMax=xlast,
@ -393,7 +402,6 @@ class ChartPlotWidget(pg.PlotWidget):
# set xrange limits # set xrange limits
xlast = data[-1]['index'] xlast = data[-1]['index']
# show last 50 points on startup # show last 50 points on startup
self.plotItem.vb.setXRange(xlast - 50, xlast + 50) self.plotItem.vb.setXRange(xlast - 50, xlast + 50)
@ -416,7 +424,6 @@ class ChartPlotWidget(pg.PlotWidget):
# set a "startup view" # set a "startup view"
xlast = len(data) - 1 xlast = len(data) - 1
# self._set_xlimits(0, xlast)
# show last 50 points on startup # show last 50 points on startup
self.plotItem.vb.setXRange(xlast - 50, xlast + 50) self.plotItem.vb.setXRange(xlast - 50, xlast + 50)
@ -433,7 +440,6 @@ class ChartPlotWidget(pg.PlotWidget):
name: str = 'main', name: str = 'main',
**kwargs, **kwargs,
) -> pg.GraphicsObject: ) -> pg.GraphicsObject:
# self._xlast = len(array) - 1
graphics = self._graphics[name] graphics = self._graphics[name]
graphics.update_from_array(array, **kwargs) graphics.update_from_array(array, **kwargs)
@ -491,7 +497,7 @@ class ChartPlotWidget(pg.PlotWidget):
yhigh = bars.max() yhigh = bars.max()
std = np.std(bars) std = np.std(bars)
# view margins # view margins: stay within 10% of the "true range"
diff = yhigh - ylow diff = yhigh - ylow
ylow = ylow - (diff * 0.1) ylow = ylow - (diff * 0.1)
yhigh = yhigh + (diff * 0.1) yhigh = yhigh + (diff * 0.1)
@ -580,12 +586,15 @@ class ChartView(pg.ViewBox):
self.sigRangeChangedManually.emit(mask) self.sigRangeChangedManually.emit(mask)
def main(symbol): def _main(
sym: str,
brokername: str,
**qtractor_kwargs,
) -> None:
"""Entry point to spawn a chart app. """Entry point to spawn a chart app.
""" """
from ._exec import run_qtractor
from ._exec import run_qtrio from ._source import ohlc_dtype
# uses pandas_datareader
async def _main(widgets): async def _main(widgets):
"""Main Qt-trio routine invoked by the Qt loop with """Main Qt-trio routine invoked by the Qt loop with
@ -593,119 +602,112 @@ def main(symbol):
""" """
chart_app = widgets['main'] chart_app = widgets['main']
# data-feed setup # historical data fetch
sym = symbol or 'ES.GLOBEX' brokermod = brokers.get_brokermod(brokername)
brokermod = brokers.get_brokermod('ib')
async with brokermod.get_client() as client: async with brokermod.get_client() as client:
# figure out the exact symbol # figure out the exact symbol
bars = await client.bars(symbol=sym) bars = await client.bars(symbol=sym)
# ``from_buffer` return read-only # remember, msgpack-numpy's ``from_buffer` returns read-only array
bars = np.array(bars) bars = np.array(bars[list(ohlc_dtype.names)])
linked_charts = chart_app.load_symbol('ES', bars) linked_charts = chart_app.load_symbol(sym, bars)
async def add_new_bars(delay_s=5.): # determine ohlc delay between bars
import time times = bars['time']
delay = times[-1] - times[-2]
async def add_new_bars(delay_s):
"""Task which inserts new bars into the ohlc every ``delay_s`` seconds.
"""
# adjust delay to compensate for trio processing time
ad = delay_s - 0.002
ohlc = linked_charts._array ohlc = linked_charts._array
last_5s = ohlc[-1]['time'] async def sleep():
delay = max((last_5s + 4.99) - time.time(), 0) """Sleep until next time frames worth has passed from last bar.
"""
last_ts = ohlc[-1]['time']
delay = max((last_ts + ad) - time.time(), 0)
await trio.sleep(delay) await trio.sleep(delay)
while True: # sleep for duration of current bar
print('new bar') await sleep()
while True:
# TODO: bunch of stuff: # TODO: bunch of stuff:
# - I'm starting to think all this logic should be # - I'm starting to think all this logic should be
# done in one place and "graphics update routines" # done in one place and "graphics update routines"
# should not be doing any length checking and array diffing. # should not be doing any length checking and array diffing.
# - don't keep appending, but instead increase the # - don't keep appending, but instead increase the
# underlying array's size less frequently: # underlying array's size less frequently
# - handle odd lot orders # - handle odd lot orders
# - update last open price correctly instead # - update last open price correctly instead
# of copying it from last bar's close # of copying it from last bar's close
# - 5 sec bar lookback-autocorrection like tws does # - 5 sec bar lookback-autocorrection like tws does?
index, t, open, high, low, close, volume = ohlc[-1] (index, t, close) = ohlc[-1][['index', 'time', 'close']]
new = np.append( new = np.append(
ohlc, ohlc,
np.array( np.array(
[(index + 1, t + 5, close, close, close, close, 0)], [(index + 1, t + delay, close, close,
close, close, 0)],
dtype=ohlc.dtype dtype=ohlc.dtype
), ),
) )
ohlc = linked_charts._array = new ohlc = linked_charts._array = new
linked_charts.update_from_array(new) last_quote = ohlc[-1]
# sleep until next 5s from last bar # we **don't** update the bar right now
last_5s = ohlc[-1]['time'] # since the next quote that arrives should
delay = max((last_5s + 4.99) - time.time(), 0) await sleep()
await trio.sleep(4.9999)
# if the last bar has not changed print a flat line and
# move to the next
if last_quote == ohlc[-1]:
log.debug("Printing flat line for {sym}")
linked_charts.update_from_array(ohlc)
async def stream_to_chart(func):
async with tractor.open_nursery() as n:
portal = await n.run_in_actor(
f'fsp_{func.__name__}',
func,
brokername=brokermod.name,
sym=sym,
loglevel='info',
)
stream = await portal.result()
# retreive named layout and style instructions
layout = await stream.__anext__()
async for quote in stream:
ticks = quote.get('ticks')
if ticks:
for tick in ticks:
print(tick)
async with trio.open_nursery() as n: async with trio.open_nursery() as n:
n.start_soon(add_new_bars) from piker import fsp
async with brokermod.maybe_spawn_brokerd() as portal: async with data.open_feed(brokername, [sym]) as stream:
stream = await portal.run( # start graphics tasks
'piker.brokers.ib', n.start_soon(add_new_bars, delay)
'trio_stream_ticker', n.start_soon(stream_to_chart, fsp.broker_latency)
sym=sym,
) async for quote in stream:
# TODO: timeframe logic # XXX: why are we getting both of these again?
async for tick in stream: ticks = quote.get('ticks')
# breakpoint() if ticks:
for tick in ticks:
if tick['tickType'] in (48, 77): if tick['tickType'] in (48, 77):
linked_charts.update_from_quote( linked_charts.update_from_quote(
{'last': tick['price']} {'last': tick['price']}
) )
# else:
# from .quantdom.loaders import get_quotes # linked_charts.update_from_quote(
# from datetime import datetime # {'last': quote['close']}
# from ._source import from_df
# quotes = get_quotes(
# symbol=symbol,
# date_from=datetime(1900, 1, 1),
# date_to=datetime(2030, 12, 31),
# )
# quotes = from_df(quotes)
# feed = DataFeed(portal, brokermod)
# quote_gen, quotes = await feed.open_stream(
# symbols,
# 'stock',
# rate=rate,
# test=test,
# ) # )
# first_quotes, _ = feed.format_quotes(quotes) run_qtractor(_main, (), ChartSpace, **qtractor_kwargs)
# if first_quotes[0].get('last') is None:
# log.error("Broker API is down temporarily")
# return
# make some fake update data
# import itertools
# nums = itertools.cycle([315., 320., 325., 310., 3])
# def gen_nums():
# while True:
# yield quotes[-1].close + 2
# yield quotes[-1].close - 2
# nums = gen_nums()
# # await trio.sleep(10)
# import time
# while True:
# new = next(nums)
# quotes[-1].close = new
# # this updates the linked_charts internal array
# # and then passes that array to all subcharts to
# # render downstream graphics
# start = time.time()
# linked_charts.update_from_quote({'last': new})
# print(f"Render latency {time.time() - start}")
# # 20 Hz seems to be good enough
# await trio.sleep(0.05)
run_qtrio(_main, (), ChartSpace)

View File

@ -115,10 +115,10 @@ def optschain(config, symbol, date, tl, rate, test):
def chart(config, symbol, date, tl, rate, test): def chart(config, symbol, date, tl, rate, test):
"""Start an option chain UI """Start an option chain UI
""" """
from ._chart import main from ._chart import _main
# global opts # global opts
loglevel = config['loglevel'] loglevel = config['loglevel']
brokername = config['broker'] brokername = config['broker']
main(sym=symbol, brokername=brokername, loglevel=loglevel) _main(sym=symbol, brokername=brokername, loglevel=loglevel)