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,22 +2,25 @@
High level Qt chart widgets.
"""
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 pyqtgraph as pg
from pyqtgraph import functions as fn
from PyQt5 import QtCore, QtGui
import tractor
import trio
from ._axes import (
DynamicDateAxis,
PriceAxis,
)
from ._graphics import CrossHairItem, ChartType
from ._graphics import CrossHair, ChartType
from ._style import _xaxis_at
from ._source import Symbol
from .. import brokers
from .. log import get_logger
from .. import data
from ..log import get_logger
log = get_logger(__name__)
@ -74,6 +77,7 @@ class ChartSpace(QtGui.QWidget):
"""Load a new contract into the charting app.
"""
# XXX: let's see if this causes mem problems
self.window.setWindowTitle(f'piker chart {symbol}')
self.chart = self._plot_cache.setdefault(symbol, LinkedSplitCharts())
s = Symbol(key=symbol)
@ -107,22 +111,20 @@ class LinkedSplitCharts(QtGui.QWidget):
def __init__(self):
super().__init__()
self.signals_visible = False
# main data source
self._array: np.ndarray = None
self._ch = None # crosshair graphics
self._index = 0
self.chart = None # main (ohlc) chart
self.indicators = []
self.signals_visible: bool = False
self._array: np.ndarray = None # main data source
self._ch: CrossHair = None # crosshair graphics
self.chart: ChartPlotWidget = None # main (ohlc) chart
self.subplots: List[ChartPlotWidget] = []
self.xaxis = DynamicDateAxis(
orientation='bottom', linked_charts=self)
orientation='bottom',
linked_charts=self
)
self.xaxis_ind = DynamicDateAxis(
orientation='bottom', linked_charts=self)
orientation='bottom',
linked_charts=self
)
if _xaxis_at == 'bottom':
self.xaxis.setStyle(showValues=False)
@ -134,20 +136,18 @@ class LinkedSplitCharts(QtGui.QWidget):
self.layout = QtGui.QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.addWidget(self.splitter)
def set_split_sizes(
self,
prop: float = 0.25
prop: float = 0.25 # proportion allocated to consumer subcharts
) -> None:
"""Set the proportion of space allocated for linked subcharts.
"""
major = 1 - prop
# 20% allocated to consumer subcharts
min_h_ind = int(self.height() * prop / len(self.indicators))
min_h_ind = int(self.height() * prop / len(self.subplots))
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)
def plot(
@ -155,82 +155,94 @@ class LinkedSplitCharts(QtGui.QWidget):
symbol: Symbol,
array: np.ndarray,
ohlc: bool = True,
):
) -> None:
"""Start up and show main (price) chart and all linked subcharts.
"""
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
self._array = array
cv = ChartView()
self.chart = ChartPlotWidget(
linked_charts=self,
parent=self.splitter,
axisItems={'bottom': self.xaxis, 'right': PriceAxis()},
viewBox=cv,
# enableMenu=False,
# add crosshairs
self._ch = CrossHair(
parent=self, #.chart,
# subplots=[plot for plot, d in self.subplots],
digits=self.digits
)
# TODO: ``pyqtgraph`` doesn't pass through a parent to the
# ``PlotItem`` by default; maybe we should PR this in?
cv.linked_charts = self
self.chart.plotItem.vb.linked_charts = self
self.chart.getPlotItem().setContentsMargins(*CHART_MARGINS)
self.chart = self.add_plot(
name='main',
array=array, #['close'],
xaxis=self.xaxis,
ohlc=True,
)
self.chart.addItem(self._ch)
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
# XXX: note, if this isn't index aligned with
# the source data the chart will go haywire.
inds = [('open', lambda a: a['close'])]
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()
ind_chart = ChartPlotWidget(
# use "indicator axis" by default
xaxis = self.xaxis_ind if xaxis is None else xaxis
cpw = ChartPlotWidget(
linked_charts=self,
parent=self.splitter,
axisItems={'bottom': self.xaxis_ind, 'right': PriceAxis()},
axisItems={'bottom': xaxis, 'right': PriceAxis()},
# axisItems={'top': self.xaxis_ind, 'right': PriceAxis()},
viewBox=cv,
)
# this name will be used to register the primary
# graphics curve managed by the subchart
ind_chart.name = name
cpw.name = name
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
)
ind_chart.getPlotItem().setContentsMargins(*CHART_MARGINS)
# self.splitter.addWidget(ind_chart)
# compute historical subchart values from input array
data = func(array)
self.indicators.append((ind_chart, func))
cpw.getPlotItem().setContentsMargins(*CHART_MARGINS)
# self.splitter.addWidget(cpw)
# link chart x-axis to main quotes chart
ind_chart.setXLink(self.chart)
cpw.setXLink(self.chart)
# 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(
self.chart,
[_ind for _ind, d in self.indicators],
self.digits
)
self.chart.addItem(ch)
return cpw
def update_from_quote(
self,
@ -243,14 +255,10 @@ class LinkedSplitCharts(QtGui.QWidget):
"""
# TODO: eventually we'll want to update bid/ask labels and other
# 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]
# 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
self._array[-1] = (
index,
@ -268,19 +276,25 @@ class LinkedSplitCharts(QtGui.QWidget):
array: np.ndarray,
**kwargs,
) -> None:
# update the ohlc sequence graphics chart
chart = self.chart
"""Update all linked chart graphics with a new input array.
Return the modified graphics objects in a list.
"""
# update the ohlc sequence graphics chart
# 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
# 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
# TODO: change this for streaming
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
@ -316,9 +330,6 @@ class ChartPlotWidget(pg.PlotWidget):
"""
super().__init__(**kwargs)
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?
# likely custom graphics need special handling
@ -327,8 +338,7 @@ class ChartPlotWidget(pg.PlotWidget):
# label.setText("Yo yoyo")
# label.setText("<span style='font-size: 12pt'>x=")
# to be filled in when graphics are rendered
# by name
# to be filled in when graphics are rendered by name
self._graphics = {}
# show only right side axes
@ -353,9 +363,8 @@ class ChartPlotWidget(pg.PlotWidget):
xlast: int
) -> None:
"""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(
xMin=xfirst,
xMax=xlast,
@ -393,7 +402,6 @@ class ChartPlotWidget(pg.PlotWidget):
# set xrange limits
xlast = data[-1]['index']
# show last 50 points on startup
self.plotItem.vb.setXRange(xlast - 50, xlast + 50)
@ -416,7 +424,6 @@ class ChartPlotWidget(pg.PlotWidget):
# set a "startup view"
xlast = len(data) - 1
# self._set_xlimits(0, xlast)
# show last 50 points on startup
self.plotItem.vb.setXRange(xlast - 50, xlast + 50)
@ -433,7 +440,6 @@ class ChartPlotWidget(pg.PlotWidget):
name: str = 'main',
**kwargs,
) -> pg.GraphicsObject:
# self._xlast = len(array) - 1
graphics = self._graphics[name]
graphics.update_from_array(array, **kwargs)
@ -491,7 +497,7 @@ class ChartPlotWidget(pg.PlotWidget):
yhigh = bars.max()
std = np.std(bars)
# view margins
# view margins: stay within 10% of the "true range"
diff = yhigh - ylow
ylow = ylow - (diff * 0.1)
yhigh = yhigh + (diff * 0.1)
@ -580,12 +586,15 @@ class ChartView(pg.ViewBox):
self.sigRangeChangedManually.emit(mask)
def main(symbol):
def _main(
sym: str,
brokername: str,
**qtractor_kwargs,
) -> None:
"""Entry point to spawn a chart app.
"""
from ._exec import run_qtrio
# uses pandas_datareader
from ._exec import run_qtractor
from ._source import ohlc_dtype
async def _main(widgets):
"""Main Qt-trio routine invoked by the Qt loop with
@ -593,119 +602,112 @@ def main(symbol):
"""
chart_app = widgets['main']
# data-feed setup
sym = symbol or 'ES.GLOBEX'
brokermod = brokers.get_brokermod('ib')
# historical data fetch
brokermod = brokers.get_brokermod(brokername)
async with brokermod.get_client() as client:
# figure out the exact symbol
bars = await client.bars(symbol=sym)
# ``from_buffer` return read-only
bars = np.array(bars)
linked_charts = chart_app.load_symbol('ES', bars)
# remember, msgpack-numpy's ``from_buffer` returns read-only array
bars = np.array(bars[list(ohlc_dtype.names)])
linked_charts = chart_app.load_symbol(sym, bars)
async def add_new_bars(delay_s=5.):
import time
# determine ohlc delay between bars
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
last_5s = ohlc[-1]['time']
delay = max((last_5s + 4.99) - time.time(), 0)
async def sleep():
"""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)
while True:
print('new bar')
# sleep for duration of current bar
await sleep()
while True:
# TODO: bunch of stuff:
# - I'm starting to think all this logic should be
# done in one place and "graphics update routines"
# should not be doing any length checking and array diffing.
# - don't keep appending, but instead increase the
# underlying array's size less frequently:
# underlying array's size less frequently
# - handle odd lot orders
# - update last open price correctly instead
# of copying it from last bar's close
# - 5 sec bar lookback-autocorrection like tws does
index, t, open, high, low, close, volume = ohlc[-1]
# - 5 sec bar lookback-autocorrection like tws does?
(index, t, close) = ohlc[-1][['index', 'time', 'close']]
new = np.append(
ohlc,
np.array(
[(index + 1, t + 5, close, close, close, close, 0)],
[(index + 1, t + delay, close, close,
close, close, 0)],
dtype=ohlc.dtype
),
)
ohlc = linked_charts._array = new
linked_charts.update_from_array(new)
last_quote = ohlc[-1]
# sleep until next 5s from last bar
last_5s = ohlc[-1]['time']
delay = max((last_5s + 4.99) - time.time(), 0)
await trio.sleep(4.9999)
# we **don't** update the bar right now
# since the next quote that arrives should
await sleep()
# 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:
n.start_soon(add_new_bars)
from piker import fsp
async with brokermod.maybe_spawn_brokerd() as portal:
stream = await portal.run(
'piker.brokers.ib',
'trio_stream_ticker',
sym=sym,
)
# TODO: timeframe logic
async for tick in stream:
# breakpoint()
async with data.open_feed(brokername, [sym]) as stream:
# start graphics tasks
n.start_soon(add_new_bars, delay)
n.start_soon(stream_to_chart, fsp.broker_latency)
async for quote in stream:
# XXX: why are we getting both of these again?
ticks = quote.get('ticks')
if ticks:
for tick in ticks:
if tick['tickType'] in (48, 77):
linked_charts.update_from_quote(
{'last': tick['price']}
)
# from .quantdom.loaders import get_quotes
# from datetime import datetime
# 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,
# else:
# linked_charts.update_from_quote(
# {'last': quote['close']}
# )
# first_quotes, _ = feed.format_quotes(quotes)
# 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)
run_qtractor(_main, (), ChartSpace, **qtractor_kwargs)

View File

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