Merge pull request #255 from pikers/multichart_ux_improvements

Multichart ux improvements
py3.10_support
goodboy 2022-01-25 07:56:04 -05:00 committed by GitHub
commit ff8c33cf7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 216 additions and 190 deletions

View File

@ -18,6 +18,7 @@
High level chart-widget apis.
'''
from __future__ import annotations
from typing import Optional
from PyQt5 import QtCore, QtWidgets
@ -68,7 +69,7 @@ log = get_logger(__name__)
class GodWidget(QWidget):
'''
"Our lord and savior, the holy child of window-shua, there is no
widget above thee." - 6||6
widget above thee." - 6|6
The highest level composed widget which contains layouts for
organizing charts as well as other sub-widgets used to control or
@ -104,8 +105,8 @@ class GodWidget(QWidget):
# self.init_strategy_ui()
# self.vbox.addLayout(self.hbox)
self._chart_cache = {}
self.linkedsplits: 'LinkedSplits' = None
self._chart_cache: dict[str, LinkedSplits] = {}
self.linkedsplits: Optional[LinkedSplits] = None
# assigned in the startup func `_async_main()`
self._root_n: trio.Nursery = None
@ -135,7 +136,7 @@ class GodWidget(QWidget):
def set_chart_symbol(
self,
symbol_key: str, # of form <fqsn>.<providername>
linkedsplits: 'LinkedSplits', # type: ignore
linkedsplits: LinkedSplits, # type: ignore
) -> None:
# re-sort org cache symbol list in LIFO order
@ -146,20 +147,20 @@ class GodWidget(QWidget):
def get_chart_symbol(
self,
symbol_key: str,
) -> 'LinkedSplits': # type: ignore
) -> LinkedSplits: # type: ignore
return self._chart_cache.get(symbol_key)
async def load_symbol(
self,
providername: str,
symbol_key: str,
loglevel: str,
reset: bool = False,
) -> trio.Event:
'''Load a new contract into the charting app.
'''
Load a new contract into the charting app.
Expects a ``numpy`` structured array containing all the ohlcv fields.
@ -178,6 +179,7 @@ class GodWidget(QWidget):
# XXX: this is CRITICAL especially with pixel buffer caching
self.linkedsplits.hide()
self.linkedsplits.unfocus()
# XXX: pretty sure we don't need this
# remove any existing plots?
@ -202,6 +204,11 @@ class GodWidget(QWidget):
)
self.set_chart_symbol(fqsn, linkedsplits)
self.vbox.addWidget(linkedsplits)
linkedsplits.show()
linkedsplits.focus()
await trio.sleep(0)
else:
# symbol is already loaded and ems ready
@ -215,21 +222,17 @@ class GodWidget(QWidget):
# also switch it over to the new chart's interal-layout
# self.linkedsplits.chart.qframe.hbox.removeWidget(self.pp_pane)
chart = linkedsplits.chart
await chart.resume_all_feeds()
# chart is already in memory so just focus it
if self.linkedsplits:
self.linkedsplits.unfocus()
self.vbox.addWidget(linkedsplits)
linkedsplits.show()
linkedsplits.focus()
await trio.sleep(0)
# resume feeds *after* rendering chart view asap
chart.resume_all_feeds()
self.linkedsplits = linkedsplits
symbol = linkedsplits.symbol
if symbol is not None:
self.window.setWindowTitle(
f'{symbol.key}@{symbol.brokers} '
@ -239,7 +242,8 @@ class GodWidget(QWidget):
return order_mode_started
def focus(self) -> None:
'''Focus the top level widget which in turn focusses the chart
'''
Focus the top level widget which in turn focusses the chart
ala "view mode".
'''
@ -247,9 +251,19 @@ class GodWidget(QWidget):
self.clearFocus()
self.linkedsplits.chart.setFocus()
def resizeEvent(self, event: QtCore.QEvent) -> None:
'''
Top level god widget resize handler.
Where we do UX magic to make things not suck B)
'''
log.debug('god widget resize')
class ChartnPane(QFrame):
'''One-off ``QFrame`` composite which pairs a chart
'''
One-off ``QFrame`` composite which pairs a chart
+ sidepane (often a ``FieldsForm`` + other widgets if
provided) forming a, sort of, "chart row" with a side panel
for configuration and display of off-chart data.
@ -280,8 +294,6 @@ class ChartnPane(QFrame):
hbox.setContentsMargins(0, 0, 0, 0)
hbox.setSpacing(3)
# self.setMaximumWidth()
class LinkedSplits(QWidget):
'''
@ -349,7 +361,7 @@ class LinkedSplits(QWidget):
if not prop:
# proportion allocated to consumer subcharts
if ln < 2:
prop = 1/(.666 * 6)
prop = 1/3
elif ln >= 2:
prop = 3/8
@ -379,16 +391,21 @@ class LinkedSplits(QWidget):
style: str = 'bar',
) -> 'ChartPlotWidget':
"""Start up and show main (price) chart and all linked subcharts.
'''Start up and show main (price) chart and all linked subcharts.
The data input struct array must include OHLC fields.
"""
'''
# add crosshairs
self.cursor = Cursor(
linkedsplits=self,
digits=symbol.tick_size_digits,
)
# NOTE: atm the first (and only) OHLC price chart for the symbol
# is given a special reference but in the future there shouldn't
# be no distinction since we will have multiple symbols per
# view as part of "aggregate feeds".
self.chart = self.add_plot(
name=symbol.key,
@ -430,9 +447,7 @@ class LinkedSplits(QWidget):
**cpw_kwargs,
) -> 'ChartPlotWidget':
'''Add (sub)plots to chart widget by name.
If ``name`` == ``"main"`` the chart will be the the primary view.
'''Add (sub)plots to chart widget by key.
'''
if self.chart is None and not _is_main:
@ -457,7 +472,10 @@ class LinkedSplits(QWidget):
self.xaxis.hide()
self.xaxis = xaxis
qframe = ChartnPane(sidepane=sidepane, parent=self.splitter)
qframe = ChartnPane(
sidepane=sidepane,
parent=self.splitter,
)
cpw = ChartPlotWidget(
# this name will be used to register the primary
@ -551,17 +569,23 @@ class LinkedSplits(QWidget):
else:
assert style == 'bar', 'main chart must be OHLC'
self.resize_sidepanes()
return cpw
def resize_sidepanes(
self,
) -> None:
'''Size all sidepanes based on the OHLC "main" plot.
'''
Size all sidepanes based on the OHLC "main" plot and its
sidepane width.
'''
main_chart = self.chart
if main_chart:
sp_w = main_chart.sidepane.width()
for name, cpw in self.subplots.items():
cpw.sidepane.setMinimumWidth(self.chart.sidepane.width())
cpw.sidepane.setMaximumWidth(self.chart.sidepane.width())
cpw.sidepane.setMinimumWidth(sp_w)
cpw.sidepane.setMaximumWidth(sp_w)
class ChartPlotWidget(pg.PlotWidget):
@ -600,12 +624,18 @@ class ChartPlotWidget(pg.PlotWidget):
view_color: str = 'papas_special',
pen_color: str = 'bracket',
# TODO: load from config
use_open_gl: bool = False,
static_yrange: Optional[tuple[float, float]] = None,
**kwargs,
):
"""Configure chart display settings.
"""
'''
Configure initial display settings and connect view callback
handlers.
'''
self.view_color = view_color
self.pen_color = pen_color
@ -614,9 +644,9 @@ class ChartPlotWidget(pg.PlotWidget):
# parent=None,
# plotItem=None,
# antialias=True,
# useOpenGL=True,
**kwargs
)
self.useOpenGL(use_open_gl)
self.name = name
self.data_key = data_key
self.linked = linkedsplits
@ -665,13 +695,13 @@ class ChartPlotWidget(pg.PlotWidget):
# for when the splitter(s) are resized
self._vb.sigResized.connect(self._set_yrange)
async def resume_all_feeds(self):
def resume_all_feeds(self):
for feed in self._feeds.values():
await feed.resume()
self.linked.godwidget._root_n.start_soon(feed.resume)
async def pause_all_feeds(self):
def pause_all_feeds(self):
for feed in self._feeds.values():
await feed.pause()
self.linked.godwidget._root_n.start_soon(feed.pause)
@property
def view(self) -> ChartView:
@ -910,46 +940,50 @@ class ChartPlotWidget(pg.PlotWidget):
def update_ohlc_from_array(
self,
name: str,
graphics_name: str,
array: np.ndarray,
**kwargs,
) -> pg.GraphicsObject:
"""Update the named internal graphics from ``array``.
"""
) -> pg.GraphicsObject:
'''Update the named internal graphics from ``array``.
'''
self._arrays['ohlc'] = array
graphics = self._graphics[name]
graphics = self._graphics[graphics_name]
graphics.update_from_array(array, **kwargs)
return graphics
def update_curve_from_array(
self,
name: str,
graphics_name: str,
array: np.ndarray,
array_key: Optional[str] = None,
**kwargs,
) -> pg.GraphicsObject:
"""Update the named internal graphics from ``array``.
'''Update the named internal graphics from ``array``.
"""
'''
assert len(array)
data_key = array_key or graphics_name
data_key = array_key or name
if name not in self._overlays:
if graphics_name not in self._overlays:
self._arrays['ohlc'] = array
else:
self._arrays[data_key] = array
curve = self._graphics[name]
curve = self._graphics[graphics_name]
if len(array):
# TODO: we should instead implement a diff based
# "only update with new items" on the pg.PlotCurveItem
# one place to dig around this might be the `QBackingStore`
# https://doc.qt.io/qt-5/qbackingstore.html
# curve.setData(y=array[name], x=array['index'], **kwargs)
# NOTE: back when we weren't implementing the curve graphics
# ourselves you'd have updates using this method:
# curve.setData(y=array[graphics_name], x=array['index'], **kwargs)
# NOTE: graphics **must** implement a diff based update
# operation where an internal ``FastUpdateCurve._xrange`` is
# used to determine if the underlying path needs to be
# pre/ap-pended.
curve.update_from_array(
x=array['index'],
y=array[data_key],
@ -964,7 +998,11 @@ class ChartPlotWidget(pg.PlotWidget):
yrange: Optional[tuple[float, float]] = None,
range_margin: float = 0.06,
bars_range: Optional[tuple[int, int, int, int]] = None
bars_range: Optional[tuple[int, int, int, int]] = None,
# flag to prevent triggering sibling charts from the same linked
# set from recursion errors.
autoscale_linked_plots: bool = True,
) -> None:
'''Set the viewable y-range based on embedded data.
@ -991,50 +1029,33 @@ class ChartPlotWidget(pg.PlotWidget):
l, lbar, rbar, r = bars_range or self.bars_range()
# TODO: we need a loop for auto-scaled subplots to all
# be triggered by one another
if self.name != 'volume':
vlm_chart = self.linked.subplots.get('volume')
if vlm_chart:
vlm_chart._set_yrange(bars_range=(l, lbar, rbar, r))
# curve = vlm_chart._graphics['volume']
# if rbar - lbar < 1500:
# # print('small range')
# curve._fill = True
# else:
# curve._fill = False
if autoscale_linked_plots:
# avoid recursion by sibling plots
linked = self.linked
plots = list(linked.subplots.copy().values())
main = linked.chart
if main:
plots.append(main)
# figure out x-range in view such that user can scroll "off"
# the data set up to the point where ``_min_points_to_show``
# are left.
# view_len = r - l
for chart in plots:
if chart and not chart._static_yrange:
chart._set_yrange(
bars_range=(l, lbar, rbar, r),
autoscale_linked_plots=False,
)
# TODO: logic to check if end of bars in view
# extra = view_len - _min_points_to_show
# begin = self._arrays['ohlc'][0]['index'] - extra
# # end = len(self._arrays['ohlc']) - 1 + extra
# end = self._arrays['ohlc'][-1]['index'] - 1 + extra
# XXX: test code for only rendering lines for the bars in view.
# This turns out to be very very poor perf when scaling out to
# many bars (think > 1k) on screen.
# name = self.name
# bars = self._graphics[self.name]
# bars.draw_lines(
# istart=max(lbar, l), iend=min(rbar, r), just_history=True)
# bars_len = rbar - lbar
# log.debug(
# f"\nl: {l}, lbar: {lbar}, rbar: {rbar}, r: {r}\n"
# f"view_len: {view_len}, bars_len: {bars_len}\n"
# f"begin: {begin}, end: {end}, extra: {extra}"
# )
# self._set_xlimits(begin, end)
# TODO: this should be some kind of numpy view api
# bars = self._arrays['ohlc'][lbar:rbar]
a = self._arrays['ohlc']
ifirst = a[0]['index']

View File

@ -36,7 +36,7 @@ import trio
from .. import brokers
from .._cacheables import maybe_open_context
from ..trionics import async_enter_all
from tractor.trionics import gather_contexts
from ..data.feed import open_feed, Feed
from ._chart import (
ChartPlotWidget,
@ -61,7 +61,10 @@ log = get_logger(__name__)
_quote_throttle_rate: int = 58 # Hz
def try_read(array: np.ndarray) -> Optional[np.ndarray]:
def try_read(
array: np.ndarray
) -> Optional[np.ndarray]:
'''
Try to read the last row from a shared mem array or ``None``
if the array read returns a zero-length array result.
@ -85,7 +88,6 @@ def try_read(array: np.ndarray) -> Optional[np.ndarray]:
# something we need anyway, maybe there should be some kind of
# signal that a prepend is taking place and this consumer can
# respond (eg. redrawing graphics) accordingly.
log.warning(f'Read-race on shm array: {graphics_name}@{shm.token}')
# the array read was emtpy
return None
@ -101,8 +103,10 @@ def update_fsp_chart(
array = shm.array
last_row = try_read(array)
# guard against unreadable case
if not last_row:
log.warning(f'Read-race on shm array: {graphics_name}@{shm.token}')
return
# update graphics
@ -175,7 +179,6 @@ def chart_maxmin(
async def update_chart_from_quotes(
linked: LinkedSplits,
stream: tractor.MsgStream,
ohlcv: np.ndarray,
@ -247,24 +250,23 @@ async def update_chart_from_quotes(
chart.show()
last_quote = time.time()
# NOTE: all code below this loop is expected to be synchronous
# and thus draw instructions are not picked up jntil the next
# wait / iteration.
async for quotes in stream:
now = time.time()
quote_period = now - last_quote
quote_rate = round(1/quote_period, 1) if quote_period else float('inf')
quote_period = time.time() - last_quote
quote_rate = round(
1/quote_period, 1) if quote_period > 0 else float('inf')
if (
quote_period <= 1/_quote_throttle_rate
and quote_rate > _quote_throttle_rate + 2
and quote_rate > _quote_throttle_rate * 1.5
):
log.warning(f'High quote rate {symbol.key}: {quote_rate}')
last_quote = now
# chart isn't active/shown so skip render cycle and pause feed(s)
if chart.linked.isHidden():
await chart.pause_all_feeds()
chart.pause_all_feeds()
continue
for sym, quote in quotes.items():
@ -454,7 +456,8 @@ def maybe_mk_fsp_shm(
readonly: bool = True,
) -> (ShmArray, bool):
'''Allocate a single row shm array for an symbol-fsp pair if none
'''
Allocate a single row shm array for an symbol-fsp pair if none
exists, otherwise load the shm already existing for that token.
'''
@ -481,7 +484,6 @@ def maybe_mk_fsp_shm(
@acm
async def open_fsp_sidepane(
linked: LinkedSplits,
conf: dict[str, dict[str, str]],
@ -570,6 +572,7 @@ async def open_fsp_cluster(
async def maybe_open_fsp_cluster(
workers: int = 2,
**kwargs,
) -> AsyncGenerator[int, dict[str, tractor.Portal]]:
kwargs.update(
@ -589,7 +592,6 @@ async def maybe_open_fsp_cluster(
async def start_fsp_displays(
cluster_map: dict[str, tractor.Portal],
linkedsplits: LinkedSplits,
fsps: dict[str, str],
@ -599,11 +601,9 @@ async def start_fsp_displays(
group_status_key: str,
loglevel: str,
# this con
display_in_own_task: bool = False,
) -> None:
'''Create sub-actors (under flat tree)
'''
Create sub-actors (under flat tree)
for each entry in config and attach to local graphics update tasks.
Pass target entrypoint and historical data.
@ -623,7 +623,7 @@ async def start_fsp_displays(
for (display_name, conf), (name, portal) in zip(
fsps.items(),
# rr to cluster for now..
# round robin to cluster for now..
cycle(cluster_map.items()),
):
func_name = conf['func_name']
@ -668,9 +668,7 @@ async def start_fsp_displays(
async def update_chart_from_fsp(
portal: tractor.Portal,
linkedsplits: LinkedSplits,
brokermod: ModuleType,
sym: str,
@ -687,7 +685,8 @@ async def update_chart_from_fsp(
profiler: pg.debug.Profiler,
) -> None:
'''FSP stream chart update loop.
'''
FSP stream chart update loop.
This is called once for each entry in the fsp
config map.
@ -792,9 +791,7 @@ async def update_chart_from_fsp(
level_line(chart, 80, orient_v='top')
chart._set_yrange()
done()
chart.linked.resize_sidepanes()
done() # status updates
profiler(f'fsp:{func_name} starting update loop')
profiler.finish()
@ -912,7 +909,6 @@ def has_vlm(ohlcv: ShmArray) -> bool:
@acm
async def maybe_open_vlm_display(
linked: LinkedSplits,
ohlcv: ShmArray,
@ -926,16 +922,14 @@ async def maybe_open_vlm_display(
shm, opened = maybe_mk_fsp_shm(
linked.symbol.key,
'$_vlm',
'vlm',
readonly=True,
)
async with open_fsp_sidepane(
linked, {
'vlm': {
'params': {
'price_func': {
'default_value': 'chl3',
# tell target ``Edit`` widget to not allow
@ -963,9 +957,6 @@ async def maybe_open_vlm_display(
# we do this internally ourselves since
# the curve item internals are pretty convoluted.
style='step',
# original pyqtgraph flag for reference
# stepMode=True,
)
# XXX: ONLY for sub-chart fsps, overlays have their
@ -992,25 +983,14 @@ async def maybe_open_vlm_display(
# size view to data once at outset
chart._set_yrange()
# size pain to parent chart
# TODO: this appears to nearly fix a bug where the vlm sidepane
# could be sized correctly nearly immediately (since the
# order pane is already sized), right now it doesn't seem to
# fully align until the VWAP fsp-actor comes up...
await trio.sleep(0)
chart.linked.resize_sidepanes()
await trio.sleep(0)
yield chart
async def display_symbol_data(
godwidget: GodWidget,
provider: str,
sym: str,
loglevel: str,
order_mode_started: trio.Event,
) -> None:
@ -1037,8 +1017,7 @@ async def display_symbol_data(
# group_key=loading_sym_key,
# )
async with async_enter_all(
open_feed(
async with open_feed(
provider,
[sym],
loglevel=loglevel,
@ -1046,11 +1025,8 @@ async def display_symbol_data(
# limit to at least display's FPS
# avoiding needless Qt-in-guest-mode context switches
tick_throttle=_quote_throttle_rate,
),
maybe_open_fsp_cluster(),
) as (feed, cluster_map):
) as feed:
ohlcv: ShmArray = feed.shm
bars = ohlcv.array
symbol = feed.symbols[sym]
@ -1102,38 +1078,38 @@ async def display_symbol_data(
# TODO: eventually we'll support some kind of n-compose syntax
fsp_conf = {
'dolla_vlm': {
'func_name': 'dolla_vlm',
'zero_on_step': True,
'params': {
'price_func': {
'default_value': 'chl3',
# tell target ``Edit`` widget to not allow
# edits for now.
'widget_kwargs': {'readonly': True},
},
},
'chart_kwargs': {'style': 'step'}
},
# 'dolla_vlm': {
# 'func_name': 'dolla_vlm',
# 'zero_on_step': True,
# 'params': {
# 'price_func': {
# 'default_value': 'chl3',
# # tell target ``Edit`` widget to not allow
# # edits for now.
# 'widget_kwargs': {'readonly': True},
# },
# },
# 'chart_kwargs': {'style': 'step'}
# },
'rsi': {
'func_name': 'rsi', # literal python func ref lookup name
# 'rsi': {
# 'func_name': 'rsi', # literal python func ref lookup name
# map of parameters to place on the fsp sidepane widget
# which should map to dynamic inputs available to the
# fsp function at runtime.
'params': {
'period': {
'default_value': 14,
'widget_kwargs': {'readonly': True},
},
},
# # map of parameters to place on the fsp sidepane widget
# # which should map to dynamic inputs available to the
# # fsp function at runtime.
# 'params': {
# 'period': {
# 'default_value': 14,
# 'widget_kwargs': {'readonly': True},
# },
# },
# ``ChartPlotWidget`` options passthrough
'chart_kwargs': {
'static_yrange': (0, 100),
},
},
# # ``ChartPlotWidget`` options passthrough
# 'chart_kwargs': {
# 'static_yrange': (0, 100),
# },
# },
}
if has_vlm(ohlcv): # and provider != 'binance':
@ -1158,10 +1134,15 @@ async def display_symbol_data(
await trio.sleep(0)
vlm_chart = None
async with (
trio.open_nursery() as ln,
maybe_open_vlm_display(linkedsplits, ohlcv) as vlm_chart,
):
async with gather_contexts(
(
trio.open_nursery(),
maybe_open_vlm_display(linkedsplits, ohlcv),
maybe_open_fsp_cluster(),
)
) as (ln, vlm_chart, cluster_map):
# load initial fsp chain (otherwise known as "indicators")
ln.start_soon(
start_fsp_displays,
@ -1175,7 +1156,7 @@ async def display_symbol_data(
loglevel,
)
# start graphics update loop(s)after receiving first live quote
# start graphics update loop after receiving first live quote
ln.start_soon(
update_chart_from_quotes,
linkedsplits,
@ -1201,4 +1182,10 @@ async def display_symbol_data(
order_mode_started
)
):
# let Qt run to render all widgets and make sure the
# sidepanes line up vertically.
await trio.sleep(0)
linkedsplits.resize_sidepanes()
# let the app run.
await trio.sleep_forever()

View File

@ -732,7 +732,7 @@ def mk_order_pane_layout(
) -> FieldsForm:
font_size: int = _font.px_size - 1
font_size: int = _font.px_size - 2
# TODO: maybe just allocate the whole fields form here
# and expect an async ctx entry?

View File

@ -341,7 +341,14 @@ class ChartView(ViewBox):
**kwargs,
):
super().__init__(parent=parent, **kwargs)
super().__init__(
parent=parent,
# TODO: look into the default view padding
# support that might replace somem of our
# ``ChartPlotWidget._set_yrange()`
# defaultPadding=0.,
**kwargs
)
# disable vertical scrolling
self.setMouseEnabled(x=True, y=False)
@ -533,7 +540,6 @@ class ChartView(ViewBox):
# self.updateScaleBox(ev.buttonDownPos(), ev.pos())
else:
# default bevavior: click to pan view
tr = self.childGroup.transform()
tr = fn.invertQTransform(tr)
tr = tr.map(dif*mask) - tr.map(Point(0, 0))

View File

@ -110,7 +110,7 @@ class DpiAwareFont:
mx_dpi = max(pdpi, ldpi)
mn_dpi = min(pdpi, ldpi)
scale = round(ldpi/pdpi)
scale = round(ldpi/pdpi, ndigits=2)
if mx_dpi <= 97: # for low dpi use larger font sizes
inches = _font_sizes['lo'][self._font_size]
@ -121,17 +121,29 @@ class DpiAwareFont:
dpi = mn_dpi
# dpi is likely somewhat scaled down so use slightly larger font size
if scale > 1 and self._font_size:
# TODO: this denominator should probably be determined from
if scale >= 1.1 and self._font_size:
# no idea why
if 1.2 <= scale:
mult = 1.0375
if scale >= 1.5:
mult = 1.375
# TODO: this multiplier should probably be determined from
# relative aspect ratios or something?
inches = inches * (1 / scale) * (1 + 6/16)
dpi = mx_dpi
inches *= mult
# TODO: we might want to fiddle with incrementing font size by
# +1 for the edge cases above. it seems doing it via scaling is
# always going to hit that error in range mapping from inches:
# float to px size: int.
self._font_inches = inches
font_size = math.floor(inches * dpi)
log.debug(
f"\nscreen:{screen.name()} with pDPI: {pdpi}, lDPI: {ldpi}"
log.info(
f"screen:{screen.name()}]\n"
f"pDPI: {pdpi}, lDPI: {ldpi}, scale: {scale}\n"
f"\nOur best guess font size is {font_size}\n"
)
# apply the size