Merge pull request #255 from pikers/multichart_ux_improvements
Multichart ux improvementspy3.10_support
commit
ff8c33cf7e
|
@ -18,6 +18,7 @@
|
||||||
High level chart-widget apis.
|
High level chart-widget apis.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PyQt5 import QtCore, QtWidgets
|
from PyQt5 import QtCore, QtWidgets
|
||||||
|
@ -68,7 +69,7 @@ log = get_logger(__name__)
|
||||||
class GodWidget(QWidget):
|
class GodWidget(QWidget):
|
||||||
'''
|
'''
|
||||||
"Our lord and savior, the holy child of window-shua, there is no
|
"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
|
The highest level composed widget which contains layouts for
|
||||||
organizing charts as well as other sub-widgets used to control or
|
organizing charts as well as other sub-widgets used to control or
|
||||||
|
@ -104,8 +105,8 @@ class GodWidget(QWidget):
|
||||||
# self.init_strategy_ui()
|
# self.init_strategy_ui()
|
||||||
# self.vbox.addLayout(self.hbox)
|
# self.vbox.addLayout(self.hbox)
|
||||||
|
|
||||||
self._chart_cache = {}
|
self._chart_cache: dict[str, LinkedSplits] = {}
|
||||||
self.linkedsplits: 'LinkedSplits' = None
|
self.linkedsplits: Optional[LinkedSplits] = None
|
||||||
|
|
||||||
# assigned in the startup func `_async_main()`
|
# assigned in the startup func `_async_main()`
|
||||||
self._root_n: trio.Nursery = None
|
self._root_n: trio.Nursery = None
|
||||||
|
@ -135,7 +136,7 @@ class GodWidget(QWidget):
|
||||||
def set_chart_symbol(
|
def set_chart_symbol(
|
||||||
self,
|
self,
|
||||||
symbol_key: str, # of form <fqsn>.<providername>
|
symbol_key: str, # of form <fqsn>.<providername>
|
||||||
linkedsplits: 'LinkedSplits', # type: ignore
|
linkedsplits: LinkedSplits, # type: ignore
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
# re-sort org cache symbol list in LIFO order
|
# re-sort org cache symbol list in LIFO order
|
||||||
|
@ -146,20 +147,20 @@ class GodWidget(QWidget):
|
||||||
def get_chart_symbol(
|
def get_chart_symbol(
|
||||||
self,
|
self,
|
||||||
symbol_key: str,
|
symbol_key: str,
|
||||||
) -> 'LinkedSplits': # type: ignore
|
|
||||||
|
) -> LinkedSplits: # type: ignore
|
||||||
return self._chart_cache.get(symbol_key)
|
return self._chart_cache.get(symbol_key)
|
||||||
|
|
||||||
async def load_symbol(
|
async def load_symbol(
|
||||||
self,
|
self,
|
||||||
|
|
||||||
providername: str,
|
providername: str,
|
||||||
symbol_key: str,
|
symbol_key: str,
|
||||||
loglevel: str,
|
loglevel: str,
|
||||||
|
|
||||||
reset: bool = False,
|
reset: bool = False,
|
||||||
|
|
||||||
) -> trio.Event:
|
) -> 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.
|
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
|
# XXX: this is CRITICAL especially with pixel buffer caching
|
||||||
self.linkedsplits.hide()
|
self.linkedsplits.hide()
|
||||||
|
self.linkedsplits.unfocus()
|
||||||
|
|
||||||
# XXX: pretty sure we don't need this
|
# XXX: pretty sure we don't need this
|
||||||
# remove any existing plots?
|
# remove any existing plots?
|
||||||
|
@ -202,6 +204,11 @@ class GodWidget(QWidget):
|
||||||
)
|
)
|
||||||
|
|
||||||
self.set_chart_symbol(fqsn, linkedsplits)
|
self.set_chart_symbol(fqsn, linkedsplits)
|
||||||
|
self.vbox.addWidget(linkedsplits)
|
||||||
|
|
||||||
|
linkedsplits.show()
|
||||||
|
linkedsplits.focus()
|
||||||
|
await trio.sleep(0)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# symbol is already loaded and ems ready
|
# 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
|
# also switch it over to the new chart's interal-layout
|
||||||
# self.linkedsplits.chart.qframe.hbox.removeWidget(self.pp_pane)
|
# self.linkedsplits.chart.qframe.hbox.removeWidget(self.pp_pane)
|
||||||
chart = linkedsplits.chart
|
chart = linkedsplits.chart
|
||||||
await chart.resume_all_feeds()
|
|
||||||
|
|
||||||
# chart is already in memory so just focus it
|
# chart is already in memory so just focus it
|
||||||
if self.linkedsplits:
|
|
||||||
self.linkedsplits.unfocus()
|
|
||||||
|
|
||||||
self.vbox.addWidget(linkedsplits)
|
|
||||||
|
|
||||||
linkedsplits.show()
|
linkedsplits.show()
|
||||||
linkedsplits.focus()
|
linkedsplits.focus()
|
||||||
|
await trio.sleep(0)
|
||||||
|
|
||||||
|
# resume feeds *after* rendering chart view asap
|
||||||
|
chart.resume_all_feeds()
|
||||||
|
|
||||||
self.linkedsplits = linkedsplits
|
self.linkedsplits = linkedsplits
|
||||||
|
|
||||||
symbol = linkedsplits.symbol
|
symbol = linkedsplits.symbol
|
||||||
|
|
||||||
if symbol is not None:
|
if symbol is not None:
|
||||||
self.window.setWindowTitle(
|
self.window.setWindowTitle(
|
||||||
f'{symbol.key}@{symbol.brokers} '
|
f'{symbol.key}@{symbol.brokers} '
|
||||||
|
@ -239,7 +242,8 @@ class GodWidget(QWidget):
|
||||||
return order_mode_started
|
return order_mode_started
|
||||||
|
|
||||||
def focus(self) -> None:
|
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".
|
ala "view mode".
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
@ -247,9 +251,19 @@ class GodWidget(QWidget):
|
||||||
self.clearFocus()
|
self.clearFocus()
|
||||||
self.linkedsplits.chart.setFocus()
|
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):
|
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
|
+ sidepane (often a ``FieldsForm`` + other widgets if
|
||||||
provided) forming a, sort of, "chart row" with a side panel
|
provided) forming a, sort of, "chart row" with a side panel
|
||||||
for configuration and display of off-chart data.
|
for configuration and display of off-chart data.
|
||||||
|
@ -280,8 +294,6 @@ class ChartnPane(QFrame):
|
||||||
hbox.setContentsMargins(0, 0, 0, 0)
|
hbox.setContentsMargins(0, 0, 0, 0)
|
||||||
hbox.setSpacing(3)
|
hbox.setSpacing(3)
|
||||||
|
|
||||||
# self.setMaximumWidth()
|
|
||||||
|
|
||||||
|
|
||||||
class LinkedSplits(QWidget):
|
class LinkedSplits(QWidget):
|
||||||
'''
|
'''
|
||||||
|
@ -349,7 +361,7 @@ class LinkedSplits(QWidget):
|
||||||
if not prop:
|
if not prop:
|
||||||
# proportion allocated to consumer subcharts
|
# proportion allocated to consumer subcharts
|
||||||
if ln < 2:
|
if ln < 2:
|
||||||
prop = 1/(.666 * 6)
|
prop = 1/3
|
||||||
elif ln >= 2:
|
elif ln >= 2:
|
||||||
prop = 3/8
|
prop = 3/8
|
||||||
|
|
||||||
|
@ -379,16 +391,21 @@ class LinkedSplits(QWidget):
|
||||||
style: str = 'bar',
|
style: str = 'bar',
|
||||||
|
|
||||||
) -> 'ChartPlotWidget':
|
) -> '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.
|
The data input struct array must include OHLC fields.
|
||||||
"""
|
|
||||||
|
'''
|
||||||
# add crosshairs
|
# add crosshairs
|
||||||
self.cursor = Cursor(
|
self.cursor = Cursor(
|
||||||
linkedsplits=self,
|
linkedsplits=self,
|
||||||
digits=symbol.tick_size_digits,
|
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(
|
self.chart = self.add_plot(
|
||||||
|
|
||||||
name=symbol.key,
|
name=symbol.key,
|
||||||
|
@ -430,9 +447,7 @@ class LinkedSplits(QWidget):
|
||||||
**cpw_kwargs,
|
**cpw_kwargs,
|
||||||
|
|
||||||
) -> 'ChartPlotWidget':
|
) -> 'ChartPlotWidget':
|
||||||
'''Add (sub)plots to chart widget by name.
|
'''Add (sub)plots to chart widget by key.
|
||||||
|
|
||||||
If ``name`` == ``"main"`` the chart will be the the primary view.
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
if self.chart is None and not _is_main:
|
if self.chart is None and not _is_main:
|
||||||
|
@ -457,7 +472,10 @@ class LinkedSplits(QWidget):
|
||||||
self.xaxis.hide()
|
self.xaxis.hide()
|
||||||
self.xaxis = xaxis
|
self.xaxis = xaxis
|
||||||
|
|
||||||
qframe = ChartnPane(sidepane=sidepane, parent=self.splitter)
|
qframe = ChartnPane(
|
||||||
|
sidepane=sidepane,
|
||||||
|
parent=self.splitter,
|
||||||
|
)
|
||||||
cpw = ChartPlotWidget(
|
cpw = ChartPlotWidget(
|
||||||
|
|
||||||
# this name will be used to register the primary
|
# this name will be used to register the primary
|
||||||
|
@ -551,17 +569,23 @@ class LinkedSplits(QWidget):
|
||||||
else:
|
else:
|
||||||
assert style == 'bar', 'main chart must be OHLC'
|
assert style == 'bar', 'main chart must be OHLC'
|
||||||
|
|
||||||
|
self.resize_sidepanes()
|
||||||
return cpw
|
return cpw
|
||||||
|
|
||||||
def resize_sidepanes(
|
def resize_sidepanes(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> 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():
|
for name, cpw in self.subplots.items():
|
||||||
cpw.sidepane.setMinimumWidth(self.chart.sidepane.width())
|
cpw.sidepane.setMinimumWidth(sp_w)
|
||||||
cpw.sidepane.setMaximumWidth(self.chart.sidepane.width())
|
cpw.sidepane.setMaximumWidth(sp_w)
|
||||||
|
|
||||||
|
|
||||||
class ChartPlotWidget(pg.PlotWidget):
|
class ChartPlotWidget(pg.PlotWidget):
|
||||||
|
@ -600,12 +624,18 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
view_color: str = 'papas_special',
|
view_color: str = 'papas_special',
|
||||||
pen_color: str = 'bracket',
|
pen_color: str = 'bracket',
|
||||||
|
|
||||||
|
# TODO: load from config
|
||||||
|
use_open_gl: bool = False,
|
||||||
|
|
||||||
static_yrange: Optional[tuple[float, float]] = None,
|
static_yrange: Optional[tuple[float, float]] = None,
|
||||||
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
"""Configure chart display settings.
|
'''
|
||||||
"""
|
Configure initial display settings and connect view callback
|
||||||
|
handlers.
|
||||||
|
|
||||||
|
'''
|
||||||
self.view_color = view_color
|
self.view_color = view_color
|
||||||
self.pen_color = pen_color
|
self.pen_color = pen_color
|
||||||
|
|
||||||
|
@ -614,9 +644,9 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
# parent=None,
|
# parent=None,
|
||||||
# plotItem=None,
|
# plotItem=None,
|
||||||
# antialias=True,
|
# antialias=True,
|
||||||
# useOpenGL=True,
|
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
self.useOpenGL(use_open_gl)
|
||||||
self.name = name
|
self.name = name
|
||||||
self.data_key = data_key
|
self.data_key = data_key
|
||||||
self.linked = linkedsplits
|
self.linked = linkedsplits
|
||||||
|
@ -665,13 +695,13 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
# for when the splitter(s) are resized
|
# for when the splitter(s) are resized
|
||||||
self._vb.sigResized.connect(self._set_yrange)
|
self._vb.sigResized.connect(self._set_yrange)
|
||||||
|
|
||||||
async def resume_all_feeds(self):
|
def resume_all_feeds(self):
|
||||||
for feed in self._feeds.values():
|
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():
|
for feed in self._feeds.values():
|
||||||
await feed.pause()
|
self.linked.godwidget._root_n.start_soon(feed.pause)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def view(self) -> ChartView:
|
def view(self) -> ChartView:
|
||||||
|
@ -910,46 +940,50 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
|
|
||||||
def update_ohlc_from_array(
|
def update_ohlc_from_array(
|
||||||
self,
|
self,
|
||||||
name: str,
|
|
||||||
|
graphics_name: str,
|
||||||
array: np.ndarray,
|
array: np.ndarray,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> pg.GraphicsObject:
|
|
||||||
"""Update the named internal graphics from ``array``.
|
|
||||||
|
|
||||||
"""
|
) -> pg.GraphicsObject:
|
||||||
|
'''Update the named internal graphics from ``array``.
|
||||||
|
|
||||||
|
'''
|
||||||
self._arrays['ohlc'] = array
|
self._arrays['ohlc'] = array
|
||||||
graphics = self._graphics[name]
|
graphics = self._graphics[graphics_name]
|
||||||
graphics.update_from_array(array, **kwargs)
|
graphics.update_from_array(array, **kwargs)
|
||||||
return graphics
|
return graphics
|
||||||
|
|
||||||
def update_curve_from_array(
|
def update_curve_from_array(
|
||||||
self,
|
self,
|
||||||
|
|
||||||
name: str,
|
graphics_name: str,
|
||||||
array: np.ndarray,
|
array: np.ndarray,
|
||||||
array_key: Optional[str] = None,
|
array_key: Optional[str] = None,
|
||||||
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|
||||||
) -> pg.GraphicsObject:
|
) -> 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 graphics_name not in self._overlays:
|
||||||
if name not in self._overlays:
|
|
||||||
self._arrays['ohlc'] = array
|
self._arrays['ohlc'] = array
|
||||||
else:
|
else:
|
||||||
self._arrays[data_key] = array
|
self._arrays[data_key] = array
|
||||||
|
|
||||||
curve = self._graphics[name]
|
curve = self._graphics[graphics_name]
|
||||||
|
|
||||||
if len(array):
|
# NOTE: back when we weren't implementing the curve graphics
|
||||||
# TODO: we should instead implement a diff based
|
# ourselves you'd have updates using this method:
|
||||||
# "only update with new items" on the pg.PlotCurveItem
|
# curve.setData(y=array[graphics_name], x=array['index'], **kwargs)
|
||||||
# one place to dig around this might be the `QBackingStore`
|
|
||||||
# https://doc.qt.io/qt-5/qbackingstore.html
|
# NOTE: graphics **must** implement a diff based update
|
||||||
# curve.setData(y=array[name], x=array['index'], **kwargs)
|
# operation where an internal ``FastUpdateCurve._xrange`` is
|
||||||
|
# used to determine if the underlying path needs to be
|
||||||
|
# pre/ap-pended.
|
||||||
curve.update_from_array(
|
curve.update_from_array(
|
||||||
x=array['index'],
|
x=array['index'],
|
||||||
y=array[data_key],
|
y=array[data_key],
|
||||||
|
@ -964,7 +998,11 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
|
|
||||||
yrange: Optional[tuple[float, float]] = None,
|
yrange: Optional[tuple[float, float]] = None,
|
||||||
range_margin: float = 0.06,
|
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:
|
) -> None:
|
||||||
'''Set the viewable y-range based on embedded data.
|
'''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()
|
l, lbar, rbar, r = bars_range or self.bars_range()
|
||||||
|
|
||||||
# TODO: we need a loop for auto-scaled subplots to all
|
if autoscale_linked_plots:
|
||||||
# be triggered by one another
|
# avoid recursion by sibling plots
|
||||||
if self.name != 'volume':
|
linked = self.linked
|
||||||
vlm_chart = self.linked.subplots.get('volume')
|
plots = list(linked.subplots.copy().values())
|
||||||
if vlm_chart:
|
main = linked.chart
|
||||||
vlm_chart._set_yrange(bars_range=(l, lbar, rbar, r))
|
if main:
|
||||||
# curve = vlm_chart._graphics['volume']
|
plots.append(main)
|
||||||
# if rbar - lbar < 1500:
|
|
||||||
# # print('small range')
|
|
||||||
# curve._fill = True
|
|
||||||
# else:
|
|
||||||
# curve._fill = False
|
|
||||||
|
|
||||||
# figure out x-range in view such that user can scroll "off"
|
for chart in plots:
|
||||||
# the data set up to the point where ``_min_points_to_show``
|
if chart and not chart._static_yrange:
|
||||||
# are left.
|
chart._set_yrange(
|
||||||
# view_len = r - l
|
bars_range=(l, lbar, rbar, r),
|
||||||
|
autoscale_linked_plots=False,
|
||||||
|
)
|
||||||
|
|
||||||
# TODO: logic to check if end of bars in view
|
# TODO: logic to check if end of bars in view
|
||||||
# extra = view_len - _min_points_to_show
|
# extra = view_len - _min_points_to_show
|
||||||
|
|
||||||
# begin = self._arrays['ohlc'][0]['index'] - extra
|
# begin = self._arrays['ohlc'][0]['index'] - extra
|
||||||
|
|
||||||
# # end = len(self._arrays['ohlc']) - 1 + extra
|
# # end = len(self._arrays['ohlc']) - 1 + extra
|
||||||
# end = self._arrays['ohlc'][-1]['index'] - 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
|
# bars_len = rbar - lbar
|
||||||
# log.debug(
|
# log.debug(
|
||||||
# f"\nl: {l}, lbar: {lbar}, rbar: {rbar}, r: {r}\n"
|
# f"\nl: {l}, lbar: {lbar}, rbar: {rbar}, r: {r}\n"
|
||||||
# f"view_len: {view_len}, bars_len: {bars_len}\n"
|
# f"view_len: {view_len}, bars_len: {bars_len}\n"
|
||||||
# f"begin: {begin}, end: {end}, extra: {extra}"
|
# 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']
|
a = self._arrays['ohlc']
|
||||||
ifirst = a[0]['index']
|
ifirst = a[0]['index']
|
||||||
|
|
|
@ -36,7 +36,7 @@ import trio
|
||||||
|
|
||||||
from .. import brokers
|
from .. import brokers
|
||||||
from .._cacheables import maybe_open_context
|
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 ..data.feed import open_feed, Feed
|
||||||
from ._chart import (
|
from ._chart import (
|
||||||
ChartPlotWidget,
|
ChartPlotWidget,
|
||||||
|
@ -61,7 +61,10 @@ log = get_logger(__name__)
|
||||||
_quote_throttle_rate: int = 58 # Hz
|
_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``
|
Try to read the last row from a shared mem array or ``None``
|
||||||
if the array read returns a zero-length array result.
|
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
|
# something we need anyway, maybe there should be some kind of
|
||||||
# signal that a prepend is taking place and this consumer can
|
# signal that a prepend is taking place and this consumer can
|
||||||
# respond (eg. redrawing graphics) accordingly.
|
# respond (eg. redrawing graphics) accordingly.
|
||||||
log.warning(f'Read-race on shm array: {graphics_name}@{shm.token}')
|
|
||||||
|
|
||||||
# the array read was emtpy
|
# the array read was emtpy
|
||||||
return None
|
return None
|
||||||
|
@ -101,8 +103,10 @@ def update_fsp_chart(
|
||||||
|
|
||||||
array = shm.array
|
array = shm.array
|
||||||
last_row = try_read(array)
|
last_row = try_read(array)
|
||||||
|
|
||||||
# guard against unreadable case
|
# guard against unreadable case
|
||||||
if not last_row:
|
if not last_row:
|
||||||
|
log.warning(f'Read-race on shm array: {graphics_name}@{shm.token}')
|
||||||
return
|
return
|
||||||
|
|
||||||
# update graphics
|
# update graphics
|
||||||
|
@ -175,7 +179,6 @@ def chart_maxmin(
|
||||||
|
|
||||||
|
|
||||||
async def update_chart_from_quotes(
|
async def update_chart_from_quotes(
|
||||||
|
|
||||||
linked: LinkedSplits,
|
linked: LinkedSplits,
|
||||||
stream: tractor.MsgStream,
|
stream: tractor.MsgStream,
|
||||||
ohlcv: np.ndarray,
|
ohlcv: np.ndarray,
|
||||||
|
@ -247,24 +250,23 @@ async def update_chart_from_quotes(
|
||||||
chart.show()
|
chart.show()
|
||||||
last_quote = time.time()
|
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:
|
async for quotes in stream:
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
quote_period = now - last_quote
|
quote_period = time.time() - last_quote
|
||||||
quote_rate = round(1/quote_period, 1) if quote_period else float('inf')
|
quote_rate = round(
|
||||||
|
1/quote_period, 1) if quote_period > 0 else float('inf')
|
||||||
|
|
||||||
if (
|
if (
|
||||||
quote_period <= 1/_quote_throttle_rate
|
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}')
|
log.warning(f'High quote rate {symbol.key}: {quote_rate}')
|
||||||
last_quote = now
|
last_quote = now
|
||||||
|
|
||||||
# chart isn't active/shown so skip render cycle and pause feed(s)
|
# chart isn't active/shown so skip render cycle and pause feed(s)
|
||||||
if chart.linked.isHidden():
|
if chart.linked.isHidden():
|
||||||
await chart.pause_all_feeds()
|
chart.pause_all_feeds()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for sym, quote in quotes.items():
|
for sym, quote in quotes.items():
|
||||||
|
@ -454,7 +456,8 @@ def maybe_mk_fsp_shm(
|
||||||
readonly: bool = True,
|
readonly: bool = True,
|
||||||
|
|
||||||
) -> (ShmArray, bool):
|
) -> (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.
|
exists, otherwise load the shm already existing for that token.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
@ -481,7 +484,6 @@ def maybe_mk_fsp_shm(
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def open_fsp_sidepane(
|
async def open_fsp_sidepane(
|
||||||
|
|
||||||
linked: LinkedSplits,
|
linked: LinkedSplits,
|
||||||
conf: dict[str, dict[str, str]],
|
conf: dict[str, dict[str, str]],
|
||||||
|
|
||||||
|
@ -570,6 +572,7 @@ async def open_fsp_cluster(
|
||||||
async def maybe_open_fsp_cluster(
|
async def maybe_open_fsp_cluster(
|
||||||
workers: int = 2,
|
workers: int = 2,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
|
||||||
) -> AsyncGenerator[int, dict[str, tractor.Portal]]:
|
) -> AsyncGenerator[int, dict[str, tractor.Portal]]:
|
||||||
|
|
||||||
kwargs.update(
|
kwargs.update(
|
||||||
|
@ -589,7 +592,6 @@ async def maybe_open_fsp_cluster(
|
||||||
|
|
||||||
|
|
||||||
async def start_fsp_displays(
|
async def start_fsp_displays(
|
||||||
|
|
||||||
cluster_map: dict[str, tractor.Portal],
|
cluster_map: dict[str, tractor.Portal],
|
||||||
linkedsplits: LinkedSplits,
|
linkedsplits: LinkedSplits,
|
||||||
fsps: dict[str, str],
|
fsps: dict[str, str],
|
||||||
|
@ -599,11 +601,9 @@ async def start_fsp_displays(
|
||||||
group_status_key: str,
|
group_status_key: str,
|
||||||
loglevel: str,
|
loglevel: str,
|
||||||
|
|
||||||
# this con
|
|
||||||
display_in_own_task: bool = False,
|
|
||||||
|
|
||||||
) -> None:
|
) -> 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.
|
for each entry in config and attach to local graphics update tasks.
|
||||||
|
|
||||||
Pass target entrypoint and historical data.
|
Pass target entrypoint and historical data.
|
||||||
|
@ -623,7 +623,7 @@ async def start_fsp_displays(
|
||||||
for (display_name, conf), (name, portal) in zip(
|
for (display_name, conf), (name, portal) in zip(
|
||||||
fsps.items(),
|
fsps.items(),
|
||||||
|
|
||||||
# rr to cluster for now..
|
# round robin to cluster for now..
|
||||||
cycle(cluster_map.items()),
|
cycle(cluster_map.items()),
|
||||||
):
|
):
|
||||||
func_name = conf['func_name']
|
func_name = conf['func_name']
|
||||||
|
@ -668,9 +668,7 @@ async def start_fsp_displays(
|
||||||
|
|
||||||
|
|
||||||
async def update_chart_from_fsp(
|
async def update_chart_from_fsp(
|
||||||
|
|
||||||
portal: tractor.Portal,
|
portal: tractor.Portal,
|
||||||
|
|
||||||
linkedsplits: LinkedSplits,
|
linkedsplits: LinkedSplits,
|
||||||
brokermod: ModuleType,
|
brokermod: ModuleType,
|
||||||
sym: str,
|
sym: str,
|
||||||
|
@ -687,7 +685,8 @@ async def update_chart_from_fsp(
|
||||||
profiler: pg.debug.Profiler,
|
profiler: pg.debug.Profiler,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
'''FSP stream chart update loop.
|
'''
|
||||||
|
FSP stream chart update loop.
|
||||||
|
|
||||||
This is called once for each entry in the fsp
|
This is called once for each entry in the fsp
|
||||||
config map.
|
config map.
|
||||||
|
@ -792,9 +791,7 @@ async def update_chart_from_fsp(
|
||||||
level_line(chart, 80, orient_v='top')
|
level_line(chart, 80, orient_v='top')
|
||||||
|
|
||||||
chart._set_yrange()
|
chart._set_yrange()
|
||||||
|
done() # status updates
|
||||||
done()
|
|
||||||
chart.linked.resize_sidepanes()
|
|
||||||
|
|
||||||
profiler(f'fsp:{func_name} starting update loop')
|
profiler(f'fsp:{func_name} starting update loop')
|
||||||
profiler.finish()
|
profiler.finish()
|
||||||
|
@ -912,7 +909,6 @@ def has_vlm(ohlcv: ShmArray) -> bool:
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def maybe_open_vlm_display(
|
async def maybe_open_vlm_display(
|
||||||
|
|
||||||
linked: LinkedSplits,
|
linked: LinkedSplits,
|
||||||
ohlcv: ShmArray,
|
ohlcv: ShmArray,
|
||||||
|
|
||||||
|
@ -926,16 +922,14 @@ async def maybe_open_vlm_display(
|
||||||
|
|
||||||
shm, opened = maybe_mk_fsp_shm(
|
shm, opened = maybe_mk_fsp_shm(
|
||||||
linked.symbol.key,
|
linked.symbol.key,
|
||||||
'$_vlm',
|
'vlm',
|
||||||
readonly=True,
|
readonly=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with open_fsp_sidepane(
|
async with open_fsp_sidepane(
|
||||||
linked, {
|
linked, {
|
||||||
'vlm': {
|
'vlm': {
|
||||||
|
|
||||||
'params': {
|
'params': {
|
||||||
|
|
||||||
'price_func': {
|
'price_func': {
|
||||||
'default_value': 'chl3',
|
'default_value': 'chl3',
|
||||||
# tell target ``Edit`` widget to not allow
|
# tell target ``Edit`` widget to not allow
|
||||||
|
@ -963,9 +957,6 @@ async def maybe_open_vlm_display(
|
||||||
# we do this internally ourselves since
|
# we do this internally ourselves since
|
||||||
# the curve item internals are pretty convoluted.
|
# the curve item internals are pretty convoluted.
|
||||||
style='step',
|
style='step',
|
||||||
|
|
||||||
# original pyqtgraph flag for reference
|
|
||||||
# stepMode=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# XXX: ONLY for sub-chart fsps, overlays have their
|
# 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
|
# size view to data once at outset
|
||||||
chart._set_yrange()
|
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
|
yield chart
|
||||||
|
|
||||||
|
|
||||||
async def display_symbol_data(
|
async def display_symbol_data(
|
||||||
|
|
||||||
godwidget: GodWidget,
|
godwidget: GodWidget,
|
||||||
provider: str,
|
provider: str,
|
||||||
sym: str,
|
sym: str,
|
||||||
loglevel: str,
|
loglevel: str,
|
||||||
|
|
||||||
order_mode_started: trio.Event,
|
order_mode_started: trio.Event,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
@ -1037,8 +1017,7 @@ async def display_symbol_data(
|
||||||
# group_key=loading_sym_key,
|
# group_key=loading_sym_key,
|
||||||
# )
|
# )
|
||||||
|
|
||||||
async with async_enter_all(
|
async with open_feed(
|
||||||
open_feed(
|
|
||||||
provider,
|
provider,
|
||||||
[sym],
|
[sym],
|
||||||
loglevel=loglevel,
|
loglevel=loglevel,
|
||||||
|
@ -1046,11 +1025,8 @@ async def display_symbol_data(
|
||||||
# limit to at least display's FPS
|
# limit to at least display's FPS
|
||||||
# avoiding needless Qt-in-guest-mode context switches
|
# avoiding needless Qt-in-guest-mode context switches
|
||||||
tick_throttle=_quote_throttle_rate,
|
tick_throttle=_quote_throttle_rate,
|
||||||
),
|
|
||||||
maybe_open_fsp_cluster(),
|
|
||||||
|
|
||||||
) as (feed, cluster_map):
|
|
||||||
|
|
||||||
|
) as feed:
|
||||||
ohlcv: ShmArray = feed.shm
|
ohlcv: ShmArray = feed.shm
|
||||||
bars = ohlcv.array
|
bars = ohlcv.array
|
||||||
symbol = feed.symbols[sym]
|
symbol = feed.symbols[sym]
|
||||||
|
@ -1102,38 +1078,38 @@ async def display_symbol_data(
|
||||||
# TODO: eventually we'll support some kind of n-compose syntax
|
# TODO: eventually we'll support some kind of n-compose syntax
|
||||||
fsp_conf = {
|
fsp_conf = {
|
||||||
|
|
||||||
'dolla_vlm': {
|
# 'dolla_vlm': {
|
||||||
'func_name': 'dolla_vlm',
|
# 'func_name': 'dolla_vlm',
|
||||||
'zero_on_step': True,
|
# 'zero_on_step': True,
|
||||||
'params': {
|
# 'params': {
|
||||||
'price_func': {
|
# 'price_func': {
|
||||||
'default_value': 'chl3',
|
# 'default_value': 'chl3',
|
||||||
# tell target ``Edit`` widget to not allow
|
# # tell target ``Edit`` widget to not allow
|
||||||
# edits for now.
|
# # edits for now.
|
||||||
'widget_kwargs': {'readonly': True},
|
# 'widget_kwargs': {'readonly': True},
|
||||||
},
|
# },
|
||||||
},
|
# },
|
||||||
'chart_kwargs': {'style': 'step'}
|
# 'chart_kwargs': {'style': 'step'}
|
||||||
},
|
# },
|
||||||
|
|
||||||
'rsi': {
|
# 'rsi': {
|
||||||
'func_name': 'rsi', # literal python func ref lookup name
|
# 'func_name': 'rsi', # literal python func ref lookup name
|
||||||
|
|
||||||
# map of parameters to place on the fsp sidepane widget
|
# # map of parameters to place on the fsp sidepane widget
|
||||||
# which should map to dynamic inputs available to the
|
# # which should map to dynamic inputs available to the
|
||||||
# fsp function at runtime.
|
# # fsp function at runtime.
|
||||||
'params': {
|
# 'params': {
|
||||||
'period': {
|
# 'period': {
|
||||||
'default_value': 14,
|
# 'default_value': 14,
|
||||||
'widget_kwargs': {'readonly': True},
|
# 'widget_kwargs': {'readonly': True},
|
||||||
},
|
# },
|
||||||
},
|
# },
|
||||||
|
|
||||||
# ``ChartPlotWidget`` options passthrough
|
# # ``ChartPlotWidget`` options passthrough
|
||||||
'chart_kwargs': {
|
# 'chart_kwargs': {
|
||||||
'static_yrange': (0, 100),
|
# 'static_yrange': (0, 100),
|
||||||
},
|
# },
|
||||||
},
|
# },
|
||||||
}
|
}
|
||||||
|
|
||||||
if has_vlm(ohlcv): # and provider != 'binance':
|
if has_vlm(ohlcv): # and provider != 'binance':
|
||||||
|
@ -1158,10 +1134,15 @@ async def display_symbol_data(
|
||||||
await trio.sleep(0)
|
await trio.sleep(0)
|
||||||
|
|
||||||
vlm_chart = None
|
vlm_chart = None
|
||||||
async with (
|
|
||||||
trio.open_nursery() as ln,
|
async with gather_contexts(
|
||||||
maybe_open_vlm_display(linkedsplits, ohlcv) as vlm_chart,
|
(
|
||||||
):
|
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")
|
# load initial fsp chain (otherwise known as "indicators")
|
||||||
ln.start_soon(
|
ln.start_soon(
|
||||||
start_fsp_displays,
|
start_fsp_displays,
|
||||||
|
@ -1175,7 +1156,7 @@ async def display_symbol_data(
|
||||||
loglevel,
|
loglevel,
|
||||||
)
|
)
|
||||||
|
|
||||||
# start graphics update loop(s)after receiving first live quote
|
# start graphics update loop after receiving first live quote
|
||||||
ln.start_soon(
|
ln.start_soon(
|
||||||
update_chart_from_quotes,
|
update_chart_from_quotes,
|
||||||
linkedsplits,
|
linkedsplits,
|
||||||
|
@ -1201,4 +1182,10 @@ async def display_symbol_data(
|
||||||
order_mode_started
|
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()
|
await trio.sleep_forever()
|
||||||
|
|
|
@ -732,7 +732,7 @@ def mk_order_pane_layout(
|
||||||
|
|
||||||
) -> FieldsForm:
|
) -> FieldsForm:
|
||||||
|
|
||||||
font_size: int = _font.px_size - 1
|
font_size: int = _font.px_size - 2
|
||||||
|
|
||||||
# TODO: maybe just allocate the whole fields form here
|
# TODO: maybe just allocate the whole fields form here
|
||||||
# and expect an async ctx entry?
|
# and expect an async ctx entry?
|
||||||
|
|
|
@ -341,7 +341,14 @@ class ChartView(ViewBox):
|
||||||
**kwargs,
|
**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
|
# disable vertical scrolling
|
||||||
self.setMouseEnabled(x=True, y=False)
|
self.setMouseEnabled(x=True, y=False)
|
||||||
|
@ -533,7 +540,6 @@ class ChartView(ViewBox):
|
||||||
# self.updateScaleBox(ev.buttonDownPos(), ev.pos())
|
# self.updateScaleBox(ev.buttonDownPos(), ev.pos())
|
||||||
else:
|
else:
|
||||||
# default bevavior: click to pan view
|
# default bevavior: click to pan view
|
||||||
|
|
||||||
tr = self.childGroup.transform()
|
tr = self.childGroup.transform()
|
||||||
tr = fn.invertQTransform(tr)
|
tr = fn.invertQTransform(tr)
|
||||||
tr = tr.map(dif*mask) - tr.map(Point(0, 0))
|
tr = tr.map(dif*mask) - tr.map(Point(0, 0))
|
||||||
|
|
|
@ -110,7 +110,7 @@ class DpiAwareFont:
|
||||||
|
|
||||||
mx_dpi = max(pdpi, ldpi)
|
mx_dpi = max(pdpi, ldpi)
|
||||||
mn_dpi = min(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
|
if mx_dpi <= 97: # for low dpi use larger font sizes
|
||||||
inches = _font_sizes['lo'][self._font_size]
|
inches = _font_sizes['lo'][self._font_size]
|
||||||
|
@ -121,17 +121,29 @@ class DpiAwareFont:
|
||||||
dpi = mn_dpi
|
dpi = mn_dpi
|
||||||
|
|
||||||
# dpi is likely somewhat scaled down so use slightly larger font size
|
# dpi is likely somewhat scaled down so use slightly larger font size
|
||||||
if scale > 1 and self._font_size:
|
if scale >= 1.1 and self._font_size:
|
||||||
# TODO: this denominator should probably be determined from
|
|
||||||
|
# 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?
|
# relative aspect ratios or something?
|
||||||
inches = inches * (1 / scale) * (1 + 6/16)
|
inches *= mult
|
||||||
dpi = mx_dpi
|
|
||||||
|
|
||||||
|
# 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
|
self._font_inches = inches
|
||||||
|
|
||||||
font_size = math.floor(inches * dpi)
|
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"
|
f"\nOur best guess font size is {font_size}\n"
|
||||||
)
|
)
|
||||||
# apply the size
|
# apply the size
|
||||||
|
|
Loading…
Reference in New Issue