From 85621af8aff1073b573fa6f9c2558c71e136bc31 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 15 Jun 2021 18:14:24 -0400 Subject: [PATCH 01/26] Extend Qt event relaying Add an `open_handler()` ctx manager for wholesale handling event sets with a passed in async func. Better document and implement the event filtering core including adding support for key "auto repeat" filtering; it turns out the events delivered when `trio` does its guest-most tick are not the same (Qt has somehow consumed them or something) so we have to do certain things (like getting the `.type()`, `.isAutoRepeat()`, etc.) before shipping over the mem chan. The alt might be to copy the event objects first but haven't tried it yet. For now just offer auto-repeat filtering through a flag. --- piker/ui/_event.py | 92 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 72 insertions(+), 20 deletions(-) diff --git a/piker/ui/_event.py b/piker/ui/_event.py index c3d919dc..bbb60896 100644 --- a/piker/ui/_event.py +++ b/piker/ui/_event.py @@ -19,27 +19,41 @@ Qt event proxying and processing using ``trio`` mem chans. """ from contextlib import asynccontextmanager +from typing import Callable -from PyQt5 import QtCore, QtGui +from PyQt5 import QtCore from PyQt5.QtCore import QEvent +from PyQt5.QtGui import QWidget import trio -class EventCloner(QtCore.QObject): - """Clone and forward keyboard events over a trio memory channel - for later async processing. +class EventRelay(QtCore.QObject): + ''' + Relay Qt events over a trio memory channel for async processing. - """ + ''' _event_types: set[QEvent] = set() _send_chan: trio.abc.SendChannel = None + _filter_auto_repeats: bool = True def eventFilter( self, - source: QtGui.QWidget, + source: QWidget, ev: QEvent, ) -> None: + ''' + Qt global event filter: return `False` to pass through and `True` + to filter event out. - if ev.type() in self._event_types: + https://doc.qt.io/qt-5/qobject.html#eventFilter + https://doc.qt.io/qtforpython/overviews/eventsandfilters.html#event-filters + + ''' + etype = ev.type() + # print(f'etype: {etype}') + + if etype in self._event_types: + # ev.accept() # TODO: what's the right way to allow this? # if ev.isAutoRepeat(): @@ -51,35 +65,54 @@ class EventCloner(QtCore.QObject): # something to do with Qt internals and calling the # parent handler? - key = ev.key() - mods = ev.modifiers() - txt = ev.text() + if etype in {QEvent.KeyPress, QEvent.KeyRelease}: - # run async processing - self._send_chan.send_nowait((ev, key, mods, txt)) + # TODO: is there a global setting for this? + if ev.isAutoRepeat() and self._filter_auto_repeats: + ev.ignore() + return True - # never intercept the event + key = ev.key() + mods = ev.modifiers() + txt = ev.text() + + # NOTE: the event object instance coming out + # the other side is mutated since Qt resumes event + # processing **before** running a ``trio`` guest mode + # tick, thus special handling or copying must be done. + + # send elements to async handler + self._send_chan.send_nowait((ev, etype, key, mods, txt)) + + else: + # send event to async handler + self._send_chan.send_nowait(ev) + + # **do not** filter out this event + # and instead forward to the source widget + return False + + # filter out this event + # https://doc.qt.io/qt-5/qobject.html#installEventFilter return False @asynccontextmanager -async def open_key_stream( +async def open_event_stream( - source_widget: QtGui.QWidget, + source_widget: QWidget, event_types: set[QEvent] = {QEvent.KeyPress}, - - # TODO: should we offer some kinda option for toggling releases? - # would it require a channel per event type? - # QEvent.KeyRelease, + filter_auto_repeats: bool = True, ) -> trio.abc.ReceiveChannel: # 1 to force eager sending send, recv = trio.open_memory_channel(16) - kc = EventCloner() + kc = EventRelay() kc._send_chan = send kc._event_types = event_types + kc._filter_auto_repeats = filter_auto_repeats source_widget.installEventFilter(kc) @@ -89,3 +122,22 @@ async def open_key_stream( finally: await send.aclose() source_widget.removeEventFilter(kc) + + +@asynccontextmanager +async def open_handler( + + source_widget: QWidget, + event_types: set[QEvent], + async_handler: Callable[[QWidget, trio.abc.ReceiveChannel], None], + **kwargs, + +) -> None: + + async with ( + trio.open_nursery() as n, + open_event_stream(source_widget, event_types, **kwargs) as event_recv_stream, + ): + n.start_soon(async_handler, source_widget, event_recv_stream) + yield + n.cancel_scope.cancel() From 75804a441c33aeca504971688431a2114285a810 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 15 Jun 2021 18:19:59 -0400 Subject: [PATCH 02/26] Convert view box to async input handling Instead of callbacks for key presses/releases convert our `ChartView`'s kb input handling to async code using our event relaying-over-mem-chan system. This is a first step toward a more async driven modal control UX. Changed a bunch of "chart" component naming as part of this as well, namely: `ChartSpace` -> `GodWidget` and `LinkedSplitCharts` -> `LinkedSplits`. Engage the view boxe's async handler code as part of new symbol data loading in `display_symbol_data()`. More re-orging to come! --- piker/ui/_chart.py | 351 ++++++++++++++++++++++----------------- piker/ui/_interaction.py | 146 ++++++++++------ piker/ui/_search.py | 28 ++-- 3 files changed, 306 insertions(+), 219 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 082dcb44..463960eb 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -26,6 +26,7 @@ from functools import partial from PyQt5 import QtCore, QtGui from PyQt5.QtCore import Qt +from PyQt5.QtCore import QEvent import numpy as np import pyqtgraph as pg import tractor @@ -60,7 +61,7 @@ from ._style import ( _bars_to_left_in_follow_mode, ) from . import _search -from ._event import open_key_stream +from . import _event from ..data._source import Symbol from ..data._sharedmem import ShmArray from ..data import maybe_open_shm_array @@ -77,13 +78,23 @@ from ..data import feed log = get_logger(__name__) -class ChartSpace(QtGui.QWidget): - '''Highest level composed widget which contains layouts for +class GodWidget(QtGui.QWidget): + ''' + "Our lord and savior, the holy child of window-shua, there is no + widget above thee." - 6|6 + + The highest level composed widget which contains layouts for organizing lower level charts as well as other widgets used to control or modify them. ''' - def __init__(self, parent=None): + def __init__( + + self, + parent=None, + + ) -> None: + super().__init__(parent) self.hbox = QtGui.QHBoxLayout(self) @@ -96,51 +107,54 @@ class ChartSpace(QtGui.QWidget): self.hbox.addLayout(self.vbox) - self.toolbar_layout = QtGui.QHBoxLayout() - self.toolbar_layout.setContentsMargins(0, 0, 0, 0) + # self.toolbar_layout = QtGui.QHBoxLayout() + # self.toolbar_layout.setContentsMargins(0, 0, 0, 0) + # self.vbox.addLayout(self.toolbar_layout) # self.init_timeframes_ui() # self.init_strategy_ui() - self.vbox.addLayout(self.toolbar_layout) # self.vbox.addLayout(self.hbox) self._chart_cache = {} - self.linkedcharts: 'LinkedSplitCharts' = None - self._root_n: Optional[trio.Nursery] = None + self.linkedsplits: 'LinkedSplits' = None + + # assigned in the startup func `_async_main()` + self._root_n: trio.Nursery = None + self._task_stack: AsyncExitStack = None def set_chart_symbol( self, symbol_key: str, # of form . - linked_charts: 'LinkedSplitCharts', # type: ignore + linkedsplits: 'LinkedSplits', # type: ignore ) -> None: # re-sort org cache symbol list in LIFO order cache = self._chart_cache cache.pop(symbol_key, None) - cache[symbol_key] = linked_charts + cache[symbol_key] = linkedsplits def get_chart_symbol( self, symbol_key: str, - ) -> 'LinkedSplitCharts': # type: ignore + ) -> 'LinkedSplits': # type: ignore return self._chart_cache.get(symbol_key) - def init_timeframes_ui(self): - self.tf_layout = QtGui.QHBoxLayout() - self.tf_layout.setSpacing(0) - self.tf_layout.setContentsMargins(0, 12, 0, 0) - time_frames = ('1M', '5M', '15M', '30M', '1H', '1D', '1W', 'MN') - btn_prefix = 'TF' + # def init_timeframes_ui(self): + # self.tf_layout = QtGui.QHBoxLayout() + # self.tf_layout.setSpacing(0) + # self.tf_layout.setContentsMargins(0, 12, 0, 0) + # time_frames = ('1M', '5M', '15M', '30M', '1H', '1D', '1W', 'MN') + # btn_prefix = 'TF' - for tf in time_frames: - btn_name = ''.join([btn_prefix, tf]) - btn = QtGui.QPushButton(tf) - # TODO: - btn.setEnabled(False) - setattr(self, btn_name, btn) - self.tf_layout.addWidget(btn) + # for tf in time_frames: + # btn_name = ''.join([btn_prefix, tf]) + # btn = QtGui.QPushButton(tf) + # # TODO: + # btn.setEnabled(False) + # setattr(self, btn_name, btn) + # self.tf_layout.addWidget(btn) - self.toolbar_layout.addLayout(self.tf_layout) + # self.toolbar_layout.addLayout(self.tf_layout) # XXX: strat loader/saver that we don't need yet. # def init_strategy_ui(self): @@ -166,46 +180,46 @@ class ChartSpace(QtGui.QWidget): # fully qualified symbol name (SNS i guess is what we're making?) fqsn = '.'.join([symbol_key, providername]) - linkedcharts = self.get_chart_symbol(fqsn) + linkedsplits = self.get_chart_symbol(fqsn) if not self.vbox.isEmpty(): # XXX: this is CRITICAL especially with pixel buffer caching - self.linkedcharts.hide() + self.linkedsplits.hide() # XXX: pretty sure we don't need this # remove any existing plots? # XXX: ahh we might want to support cache unloading.. - self.vbox.removeWidget(self.linkedcharts) + self.vbox.removeWidget(self.linkedsplits) # switching to a new viewable chart - if linkedcharts is None or reset: + if linkedsplits is None or reset: # we must load a fresh linked charts set - linkedcharts = LinkedSplitCharts(self) + linkedsplits = LinkedSplits(self) # spawn new task to start up and update new sub-chart instances self._root_n.start_soon( - chart_symbol, + display_symbol_data, self, providername, symbol_key, loglevel, ) - self.set_chart_symbol(fqsn, linkedcharts) + self.set_chart_symbol(fqsn, linkedsplits) - self.vbox.addWidget(linkedcharts) + self.vbox.addWidget(linkedsplits) # chart is already in memory so just focus it - if self.linkedcharts: - self.linkedcharts.unfocus() + if self.linkedsplits: + self.linkedsplits.unfocus() - # self.vbox.addWidget(linkedcharts) - linkedcharts.show() - linkedcharts.focus() - self.linkedcharts = linkedcharts + # self.vbox.addWidget(linkedsplits) + linkedsplits.show() + linkedsplits.focus() + self.linkedsplits = linkedsplits - symbol = linkedcharts.symbol + symbol = linkedsplits.symbol if symbol is not None: self.window.setWindowTitle( @@ -214,14 +228,16 @@ class ChartSpace(QtGui.QWidget): ) -class LinkedSplitCharts(QtGui.QWidget): - """Widget that holds a central chart plus derived +class LinkedSplits(QtGui.QWidget): + ''' + Widget that holds a central chart plus derived subcharts computed from the original data set apart by splitters for resizing. A single internal references to the data is maintained for each chart and can be updated externally. - """ + + ''' long_pen = pg.mkPen('#006000') long_brush = pg.mkBrush('#00ff00') short_pen = pg.mkPen('#600000') @@ -232,21 +248,24 @@ class LinkedSplitCharts(QtGui.QWidget): def __init__( self, - chart_space: ChartSpace, + godwidget: GodWidget, ) -> None: + super().__init__() - self.signals_visible: bool = False + + # self.signals_visible: bool = False self._cursor: Cursor = None # crosshair graphics + + self.godwidget = godwidget self.chart: ChartPlotWidget = None # main (ohlc) chart self.subplots: Dict[Tuple[str, ...], ChartPlotWidget] = {} - self.chart_space = chart_space - self.chart_space = chart_space + self.godwidget = godwidget self.xaxis = DynamicDateAxis( orientation='bottom', - linked_charts=self + linkedsplits=self ) # if _xaxis_at == 'bottom': # self.xaxis.setStyle(showValues=False) @@ -302,7 +321,7 @@ class LinkedSplitCharts(QtGui.QWidget): """ # add crosshairs self._cursor = Cursor( - linkedsplitcharts=self, + linkedsplits=self, digits=symbol.digits(), ) self.chart = self.add_plot( @@ -342,14 +361,14 @@ class LinkedSplitCharts(QtGui.QWidget): "A main plot must be created first with `.plot_ohlc_main()`") # source of our custom interactions - cv = ChartView() - cv.linked_charts = self + cv = ChartView(name) + cv.linkedsplits = self # use "indicator axis" by default if xaxis is None: xaxis = DynamicDateAxis( orientation='bottom', - linked_charts=self + linkedsplits=self ) cpw = ChartPlotWidget( @@ -360,11 +379,11 @@ class LinkedSplitCharts(QtGui.QWidget): array=array, parent=self.splitter, - linked_charts=self, + linkedsplits=self, axisItems={ 'bottom': xaxis, - 'right': PriceAxis(linked_charts=self, orientation='right'), - 'left': PriceAxis(linked_charts=self, orientation='left'), + 'right': PriceAxis(linkedsplits=self, orientation='right'), + 'left': PriceAxis(linkedsplits=self, orientation='left'), }, viewBox=cv, cursor=self._cursor, @@ -377,7 +396,7 @@ class LinkedSplitCharts(QtGui.QWidget): # (see our custom view mode in `._interactions.py`) cv.chart = cpw - cpw.plotItem.vb.linked_charts = self + cpw.plotItem.vb.linkedsplits = self cpw.setFrameStyle(QtGui.QFrame.StyledPanel) # | QtGui.QFrame.Plain) cpw.hideButtons() # XXX: gives us outline on backside of y-axis @@ -415,7 +434,8 @@ class LinkedSplitCharts(QtGui.QWidget): class ChartPlotWidget(pg.PlotWidget): - """``GraphicsView`` subtype containing a single ``PlotItem``. + ''' + ``GraphicsView`` subtype containing a single ``PlotItem``. - The added methods allow for plotting OHLC sequences from ``np.ndarray``s with appropriate field names. @@ -425,7 +445,8 @@ class ChartPlotWidget(pg.PlotWidget): (Could be replaced with a ``pg.GraphicsLayoutWidget`` if we eventually want multiple plots managed together?) - """ + + ''' sig_mouse_leave = QtCore.Signal(object) sig_mouse_enter = QtCore.Signal(object) @@ -441,7 +462,7 @@ class ChartPlotWidget(pg.PlotWidget): # the data view we generate graphics from name: str, array: np.ndarray, - linked_charts: LinkedSplitCharts, + linkedsplits: LinkedSplits, view_color: str = 'papas_special', pen_color: str = 'bracket', @@ -465,7 +486,7 @@ class ChartPlotWidget(pg.PlotWidget): **kwargs ) self.name = name - self._lc = linked_charts + self._lc = linkedsplits # scene-local placeholder for book graphics # sizing to avoid overlap with data contents @@ -1006,10 +1027,12 @@ _book_throttle_rate: int = 16 # Hz async def chart_from_quotes( + chart: ChartPlotWidget, - stream, + stream: tractor.MsgStream, ohlcv: np.ndarray, wap_in_history: bool = False, + ) -> None: """The 'main' (price) chart real-time update loop. @@ -1211,12 +1234,14 @@ async def chart_from_quotes( async def spawn_fsps( - linked_charts: LinkedSplitCharts, + + linkedsplits: LinkedSplits, fsps: Dict[str, str], sym, src_shm, brokermod, loglevel, + ) -> None: """Start financial signal processing in subactor. @@ -1224,7 +1249,7 @@ async def spawn_fsps( """ - linked_charts.focus() + linkedsplits.focus() # spawns sub-processes which execute cpu bound FSP code async with tractor.open_nursery(loglevel=loglevel) as n: @@ -1271,7 +1296,7 @@ async def spawn_fsps( ln.start_soon( run_fsp, portal, - linked_charts, + linkedsplits, brokermod, sym, src_shm, @@ -1286,7 +1311,7 @@ async def spawn_fsps( async def run_fsp( portal: tractor._portal.Portal, - linked_charts: LinkedSplitCharts, + linkedsplits: LinkedSplits, brokermod: ModuleType, sym: str, src_shm: ShmArray, @@ -1300,7 +1325,7 @@ async def run_fsp( This is called once for each entry in the fsp config map. """ - done = linked_charts.window().status_bar.open_status( + done = linkedsplits.window().status_bar.open_status( f'loading FSP: {display_name}..') async with portal.open_stream_from( @@ -1324,7 +1349,7 @@ async def run_fsp( shm = conf['shm'] if conf.get('overlay'): - chart = linked_charts.chart + chart = linkedsplits.chart chart.draw_curve( name='vwap', data=shm.array, @@ -1334,7 +1359,7 @@ async def run_fsp( else: - chart = linked_charts.add_plot( + chart = linkedsplits.add_plot( name=fsp_func_name, array=shm.array, @@ -1358,7 +1383,7 @@ async def run_fsp( chart._shm = shm # should **not** be the same sub-chart widget - assert chart.name != linked_charts.chart.name + assert chart.name != linkedsplits.chart.name # sticky only on sub-charts atm last_val_sticky = chart._ysticks[chart.name] @@ -1441,7 +1466,7 @@ async def run_fsp( last = now -async def check_for_new_bars(feed, ohlcv, linked_charts): +async def check_for_new_bars(feed, ohlcv, linkedsplits): """Task which updates from new bars in the shared ohlcv buffer every ``delay_s`` seconds. @@ -1451,7 +1476,7 @@ async def check_for_new_bars(feed, ohlcv, linked_charts): # Likely the best way to solve this is to make this task # aware of the instrument's tradable hours? - price_chart = linked_charts.chart + price_chart = linkedsplits.chart price_chart.default_view() async with feed.index_stream() as stream: @@ -1489,55 +1514,63 @@ async def check_for_new_bars(feed, ohlcv, linked_charts): price_chart._arrays[name] ) - for name, chart in linked_charts.subplots.items(): + for name, chart in linkedsplits.subplots.items(): chart.update_curve_from_array(chart.name, chart._shm.array) # shift the view if in follow mode price_chart.increment_view() -async def chart_symbol( - chart_app: ChartSpace, +async def display_symbol_data( + + godwidget: GodWidget, provider: str, sym: str, loglevel: str, + ) -> None: - """Spawn a real-time chart widget for this symbol and app session. + '''Spawn a real-time displayed and updated chart for provider symbol. - These widgets can remain up but hidden so that multiple symbols - can be viewed and switched between extremely fast. + Spawned ``LinkedSplits`` chart widgets can remain up but hidden so + that multiple symbols can be viewed and switched between extremely + fast from a cached watch-list. - """ - sbar = chart_app.window.status_bar + ''' + sbar = godwidget.window.status_bar loading_sym_done = sbar.open_status(f'loading {sym}.{provider}..') # historical data fetch brokermod = brokers.get_brokermod(provider) - async with data.open_feed( + async with( - provider, - [sym], - loglevel=loglevel, + data.open_feed( + provider, + [sym], + loglevel=loglevel, - # 60 FPS to limit context switches - tick_throttle=_clear_throttle_rate, + # 60 FPS to limit context switches + tick_throttle=_clear_throttle_rate, - ) as feed: + ) as feed, + + trio.open_nursery() as n, + ): ohlcv: ShmArray = feed.shm bars = ohlcv.array symbol = feed.symbols[sym] # load in symbol's ohlc data - chart_app.window.setWindowTitle( + godwidget.window.setWindowTitle( f'{symbol.key}@{symbol.brokers} ' f'tick:{symbol.tick_size}' ) - linked_charts = chart_app.linkedcharts - linked_charts._symbol = symbol - chart = linked_charts.plot_ohlc_main(symbol, bars) + linkedsplits = godwidget.linkedsplits + linkedsplits._symbol = symbol + + chart = linkedsplits.plot_ohlc_main(symbol, bars) chart.setFocus() # plot historical vwap if available @@ -1591,54 +1624,59 @@ async def chart_symbol( }, }) - async with trio.open_nursery() as n: - # load initial fsp chain (otherwise known as "indicators") - n.start_soon( - spawn_fsps, - linked_charts, - fsp_conf, - sym, - ohlcv, - brokermod, - loglevel, - ) + # load initial fsp chain (otherwise known as "indicators") + n.start_soon( + spawn_fsps, + linkedsplits, + fsp_conf, + sym, + ohlcv, + brokermod, + loglevel, + ) - # start graphics update loop(s)after receiving first live quote - n.start_soon( - chart_from_quotes, - chart, - feed.stream, - ohlcv, - wap_in_history, - ) + # start graphics update loop(s)after receiving first live quote + n.start_soon( + chart_from_quotes, + chart, + feed.stream, + ohlcv, + wap_in_history, + ) - # wait for a first quote before we start any update tasks - # quote = await feed.receive() - # log.info(f'Received first quote {quote}') + # wait for a first quote before we start any update tasks + # quote = await feed.receive() + # log.info(f'Received first quote {quote}') - n.start_soon( - check_for_new_bars, - feed, - # delay, - ohlcv, - linked_charts - ) + n.start_soon( + check_for_new_bars, + feed, + # delay, + ohlcv, + linkedsplits + ) - # interactive testing - # n.start_soon( - # test_bed, - # ohlcv, - # chart, - # linked_charts, - # ) + # interactive testing + # n.start_soon( + # test_bed, + # ohlcv, + # chart, + # linkedsplits, + # ) + + # start async input handling for chart's view + # await godwidget._task_stack.enter_async_context( + async with chart._vb.open_async_input_handler(): await start_order_mode(chart, symbol, provider) async def load_providers( + brokernames: list[str], loglevel: str, + ) -> None: # TODO: seems like our incentive for brokerd caching lelel @@ -1673,8 +1711,9 @@ async def load_providers( async def _async_main( + # implicit required argument provided by ``qtractor_run()`` - main_widget: ChartSpace, + main_widget: GodWidget, sym: str, brokernames: str, @@ -1688,13 +1727,14 @@ async def _async_main( """ - chart_app = main_widget + godwidget = main_widget # attempt to configure DPI aware font size - screen = chart_app.window.current_screen() + screen = godwidget.window.current_screen() # configure graphics update throttling based on display refresh rate global _clear_throttle_rate + _clear_throttle_rate = min( round(screen.refreshRate()), _clear_throttle_rate, @@ -1702,37 +1742,39 @@ async def _async_main( log.info(f'Set graphics update rate to {_clear_throttle_rate} Hz') # TODO: do styling / themeing setup - # _style.style_ze_sheets(chart_app) + # _style.style_ze_sheets(godwidget) - sbar = chart_app.window.status_bar + sbar = godwidget.window.status_bar starting_done = sbar.open_status('starting ze chartz...') - async with trio.open_nursery() as root_n: + async with ( + trio.open_nursery() as root_n, + AsyncExitStack() as chart_task_stack, + ): - # set root nursery for spawning other charts/feeds + # set root nursery and task stack for spawning other charts/feeds # that run cached in the bg - chart_app._root_n = root_n + godwidget._root_n = root_n + godwidget._task_stack = chart_task_stack - # setup search widget - search = _search.SearchWidget(chart_space=chart_app) - - # the main chart's view is given focus at startup + # setup search widget and focus main chart view at startup + search = _search.SearchWidget(godwidget=godwidget) search.bar.unfocus() # add search singleton to global chart-space widget - chart_app.hbox.addWidget( + godwidget.hbox.addWidget( search, # alights to top and uses minmial space based on # search bar size hint (i think?) alignment=Qt.AlignTop ) - chart_app.search = search + godwidget.search = search symbol, _, provider = sym.rpartition('.') - # this internally starts a ``chart_symbol()`` task above - chart_app.load_symbol(provider, symbol, loglevel) + # this internally starts a ``display_symbol_data()`` task above + godwidget.load_symbol(provider, symbol, loglevel) # spin up a search engine for the local cached symbol set async with _search.register_symbol_search( @@ -1740,7 +1782,7 @@ async def _async_main( provider_name='cache', search_routine=partial( _search.search_simple_dict, - source=chart_app._chart_cache, + source=godwidget._chart_cache, ), # cache is super fast so debounce on super short period pause_period=0.01, @@ -1751,18 +1793,17 @@ async def _async_main( root_n.start_soon(load_providers, brokernames, loglevel) # start handling search bar kb inputs - async with open_key_stream( - search.bar, - ) as key_stream: + async with ( - # start kb handling task for searcher - root_n.start_soon( - _search.handle_keyboard_input, - # chart_app, - search, - key_stream, + _event.open_handler( + search.bar, + event_types={QEvent.KeyPress}, + async_handler=_search.handle_keyboard_input, + # let key repeats pass through for search + filter_auto_repeats=False, ) - + ): + # remove startup status text starting_done() await trio.sleep_forever() @@ -1780,6 +1821,6 @@ def _main( run_qtractor( func=_async_main, args=(sym, brokernames, piker_loglevel), - main_widget=ChartSpace, + main_widget=GodWidget, tractor_kwargs=tractor_kwargs, ) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index ac87e799..65655ddc 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -18,14 +18,17 @@ Chart view box primitives """ +from contextlib import asynccontextmanager from dataclasses import dataclass, field from typing import Optional, Dict import pyqtgraph as pg -from PyQt5.QtCore import QPointF +from PyQt5.QtCore import QPointF, Qt +from PyQt5.QtCore import QEvent from pyqtgraph import ViewBox, Point, QtCore, QtGui from pyqtgraph import functions as fn import numpy as np +import trio from ..log import get_logger from ._style import _min_points_to_show, hcolor, _font @@ -457,8 +460,28 @@ class ArrowEditor: self.chart.plotItem.removeItem(arrow) +async def handle_viewmode_inputs( + + view: 'ChartView', + recv_chan: trio.abc.ReceiveChannel, + +) -> None: + + async for event, etype, key, mods, text in recv_chan: + log.debug(f'key: {key}, mods: {mods}, text: {text}') + + if etype in {QEvent.KeyPress}: + + await view.on_key_press(text, key, mods) + + elif etype in {QEvent.KeyRelease}: + + await view.on_key_release(text, key, mods) + + class ChartView(ViewBox): - """Price chart view box with interaction behaviors you'd expect from + ''' + Price chart view box with interaction behaviors you'd expect from any interactive platform: - zoom on mouse scroll that auto fits y-axis @@ -466,23 +489,30 @@ class ChartView(ViewBox): - zoom on x to most recent in view datum - zoom on right-click-n-drag to cursor position - """ - + ''' mode_name: str = 'mode: view' def __init__( + self, + name: str, parent: pg.PlotItem = None, **kwargs, + ): super().__init__(parent=parent, **kwargs) + # disable vertical scrolling self.setMouseEnabled(x=True, y=False) - self.linked_charts = None - self.select_box = SelectRect(self) - self.addItem(self.select_box, ignoreBounds=True) + + self.linkedsplits = None self._chart: 'ChartPlotWidget' = None # noqa + # add our selection box annotator + self.select_box = SelectRect(self) + self.addItem(self.select_box, ignoreBounds=True) + + self.name = name self.mode = None # kb ctrls processing @@ -491,6 +521,19 @@ class ChartView(ViewBox): self.setFocusPolicy(QtCore.Qt.StrongFocus) + @asynccontextmanager + async def open_async_input_handler( + self, + ) -> 'ChartView': + from . import _event + + async with _event.open_handler( + self, + event_types={QEvent.KeyPress, QEvent.KeyRelease}, + async_handler=handle_viewmode_inputs, + ): + yield self + @property def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa return self._chart @@ -501,21 +544,21 @@ class ChartView(ViewBox): self.select_box.chart = chart def wheelEvent(self, ev, axis=None): - """Override "center-point" location for scrolling. + '''Override "center-point" location for scrolling. This is an override of the ``ViewBox`` method simply changing the center of the zoom to be the y-axis. TODO: PR a method into ``pyqtgraph`` to make this configurable - """ + ''' if axis in (0, 1): mask = [False, False] mask[axis] = self.state['mouseEnabled'][axis] else: mask = self.state['mouseEnabled'][:] - chart = self.linked_charts.chart + chart = self.linkedsplits.chart # don't zoom more then the min points setting l, lbar, rbar, r = chart.bars_range() @@ -573,7 +616,6 @@ class ChartView(ViewBox): end_of_l1, key=lambda p: p.x() ) - # breakpoint() # focal = pg.Point(last_bar.x() + end_of_l1) self._resetTarget() @@ -697,22 +739,26 @@ class ChartView(ViewBox): ev.accept() self.mode.submit_exec() - def keyReleaseEvent(self, ev: QtCore.QEvent): + # def keyReleaseEvent(self, ev: QtCore.QEvent): + def keyReleaseEvent(self, event: QtCore.QEvent) -> None: + '''This routine is rerouted to an async handler. + ''' + pass + + async def on_key_release( + + self, + + text: str, + key: int, # 3-digit + mods: int, # 7-digit + + ) -> None: """ Key release to normally to trigger release of input mode """ - # TODO: is there a global setting for this? - if ev.isAutoRepeat(): - ev.ignore() - return - - ev.accept() - # text = ev.text() - key = ev.key() - mods = ev.modifiers() - - if key == QtCore.Qt.Key_Shift: + if key == Qt.Key_Shift: # if self.state['mouseMode'] == ViewBox.RectMode: self.setMouseMode(ViewBox.PanMode) @@ -722,39 +768,37 @@ class ChartView(ViewBox): # if self.state['mouseMode'] == ViewBox.RectMode: # if key == QtCore.Qt.Key_Space: - if mods == QtCore.Qt.ControlModifier or key == QtCore.Qt.Key_Control: + if mods == Qt.ControlModifier or key == QtCore.Qt.Key_Control: self.mode._exec_mode = 'dark' - if key in {QtCore.Qt.Key_A, QtCore.Qt.Key_F, QtCore.Qt.Key_D}: + if key in {Qt.Key_A, Qt.Key_F, Qt.Key_D}: # remove "staged" level line under cursor position self.mode.lines.unstage_line() self._key_active = False - def keyPressEvent(self, ev: QtCore.QEvent) -> None: - """ - This routine should capture key presses in the current view box. + def keyPressEvent(self, event: QtCore.QEvent) -> None: + '''This routine is rerouted to an async handler. + ''' + pass - """ - # TODO: is there a global setting for this? - if ev.isAutoRepeat(): - ev.ignore() - return + async def on_key_press( - ev.accept() - text = ev.text() - key = ev.key() - mods = ev.modifiers() + self, - print(f'text: {text}, key: {key}') + text: str, + key: int, # 3-digit + mods: int, # 7-digit - if mods == QtCore.Qt.ShiftModifier: + ) -> None: + + if mods == Qt.ShiftModifier: if self.state['mouseMode'] == ViewBox.PanMode: self.setMouseMode(ViewBox.RectMode) # ctrl ctrl = False - if mods == QtCore.Qt.ControlModifier: + if mods == Qt.ControlModifier: ctrl = True self.mode._exec_mode = 'live' @@ -767,20 +811,20 @@ class ChartView(ViewBox): # ctlr-/ for "lookup", "search" -> open search tree if ctrl and key in { - QtCore.Qt.Key_L, - QtCore.Qt.Key_Space, + Qt.Key_L, + Qt.Key_Space, }: - search = self._chart._lc.chart_space.search + search = self._chart._lc.godwidget.search search.focus() # esc - if key == QtCore.Qt.Key_Escape or (ctrl and key == QtCore.Qt.Key_C): + if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C): # ctrl-c as cancel # https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9 self.select_box.clear() # cancel order or clear graphics - if key == QtCore.Qt.Key_C or key == QtCore.Qt.Key_Delete: + if key == Qt.Key_C or key == Qt.Key_Delete: # delete any lines under the cursor mode = self.mode for line in mode.lines.lines_under_cursor(): @@ -789,18 +833,18 @@ class ChartView(ViewBox): self._key_buffer.append(text) # View modes - if key == QtCore.Qt.Key_R: + if key == Qt.Key_R: self.chart.default_view() # Order modes: stage orders at the current cursor level - elif key == QtCore.Qt.Key_D: # for "damp eet" + elif key == Qt.Key_D: # for "damp eet" self.mode.set_exec('sell') - elif key == QtCore.Qt.Key_F: # for "fillz eet" + elif key == Qt.Key_F: # for "fillz eet" self.mode.set_exec('buy') - elif key == QtCore.Qt.Key_A: + elif key == Qt.Key_A: self.mode.set_exec('alert') # XXX: Leaving this for light reference purposes, there @@ -808,7 +852,7 @@ class ChartView(ViewBox): # Key presses are used only when mouse mode is RectMode # The following events are implemented: - # ctrl-A : zooms out to the default "full" view of the plot + # ctrl-A : zooms out to the default "full" self of the plot # ctrl-+ : moves forward in the zooming stack (if it exists) # ctrl-- : moves backward in the zooming stack (if it exists) @@ -819,5 +863,5 @@ class ChartView(ViewBox): # self.scaleHistory(len(self.axHistory)) else: # maybe propagate to parent widget - ev.ignore() + # event.ignore() self._key_active = False diff --git a/piker/ui/_search.py b/piker/ui/_search.py index 4360fd5b..d1fec5a1 100644 --- a/piker/ui/_search.py +++ b/piker/ui/_search.py @@ -447,7 +447,7 @@ class SearchBar(QtWidgets.QLineEdit): self.view: CompleterView = view self.dpi_font = font - self.chart_app = parent_chart + self.godwidget = parent_chart # size it as we specify # https://doc.qt.io/qt-5/qsizepolicy.html#Policy-enum @@ -496,12 +496,12 @@ class SearchWidget(QtGui.QWidget): def __init__( self, - chart_space: 'ChartSpace', # type: ignore # noqa + godwidget: 'GodWidget', # type: ignore # noqa columns: List[str] = ['src', 'symbol'], parent=None, ) -> None: - super().__init__(parent or chart_space) + super().__init__(parent or godwidget) # size it as we specify self.setSizePolicy( @@ -509,7 +509,7 @@ class SearchWidget(QtGui.QWidget): QtWidgets.QSizePolicy.Fixed, ) - self.chart_app = chart_space + self.godwidget = godwidget self.vbox = QtGui.QVBoxLayout(self) self.vbox.setContentsMargins(0, 0, 0, 0) @@ -540,7 +540,7 @@ class SearchWidget(QtGui.QWidget): ) self.bar = SearchBar( parent=self, - parent_chart=chart_space, + parent_chart=godwidget, view=self.view, ) self.bar_hbox.addWidget(self.bar) @@ -557,7 +557,7 @@ class SearchWidget(QtGui.QWidget): # fill cache list if nothing existing self.view.set_section_entries( 'cache', - list(reversed(self.chart_app._chart_cache)), + list(reversed(self.godwidget._chart_cache)), clear_all=True, ) @@ -611,7 +611,7 @@ class SearchWidget(QtGui.QWidget): return None provider, symbol = value - chart = self.chart_app + chart = self.godwidget log.info(f'Requesting symbol: {symbol}.{provider}') @@ -632,7 +632,7 @@ class SearchWidget(QtGui.QWidget): # Re-order the symbol cache on the chart to display in # LIFO order. this is normally only done internally by # the chart on new symbols being loaded into memory - chart.set_chart_symbol(fqsn, chart.linkedcharts) + chart.set_chart_symbol(fqsn, chart.linkedsplits) self.view.set_section_entries( 'cache', @@ -650,6 +650,7 @@ _search_enabled: bool = False async def pack_matches( + view: CompleterView, has_results: dict[str, set[str]], matches: dict[(str, str), [str]], @@ -823,7 +824,7 @@ async def fill_results( async def handle_keyboard_input( - search: SearchWidget, + searchbar: SearchBar, recv_chan: trio.abc.ReceiveChannel, ) -> None: @@ -831,8 +832,9 @@ async def handle_keyboard_input( global _search_active, _search_enabled # startup - chart = search.chart_app - bar = search.bar + bar = searchbar + search = searchbar.parent() + chart = search.godwidget view = bar.view view.set_font_size(bar.dpi_font.px_size) @@ -851,7 +853,7 @@ async def handle_keyboard_input( ) ) - async for event, key, mods, txt in recv_chan: + async for event, etype, key, mods, txt in recv_chan: log.debug(f'key: {key}, mods: {mods}, txt: {txt}') @@ -889,7 +891,7 @@ async def handle_keyboard_input( # kill the search and focus back on main chart if chart: - chart.linkedcharts.focus() + chart.linkedsplits.focus() continue From 93af8c936ce234d3ea92c66d67be01cb41bb5aff Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 15 Jun 2021 18:25:03 -0400 Subject: [PATCH 03/26] Port cursor and axes to new widget names --- piker/ui/_axes.py | 8 ++++---- piker/ui/_graphics/_cursor.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index d7f69742..3e66ada3 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.py @@ -38,7 +38,7 @@ class Axis(pg.AxisItem): """ def __init__( self, - linked_charts, + linkedsplits, typical_max_str: str = '100 000.000', min_tick: int = 2, **kwargs @@ -49,7 +49,7 @@ class Axis(pg.AxisItem): # XXX: pretty sure this makes things slower # self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache) - self.linked_charts = linked_charts + self.linkedsplits = linkedsplits self._min_tick = min_tick self._dpi_font = _font @@ -132,9 +132,9 @@ class DynamicDateAxis(Axis): ) -> List[str]: # try: - chart = self.linked_charts.chart + chart = self.linkedsplits.chart bars = chart._ohlc - shm = self.linked_charts.chart._shm + shm = self.linkedsplits.chart._shm first = shm._first.value bars_len = len(bars) diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index a32a3870..0f250e7a 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_graphics/_cursor.py @@ -203,7 +203,7 @@ class Cursor(pg.GraphicsObject): def __init__( self, - linkedsplitcharts: 'LinkedSplitCharts', # noqa + linkedsplits: 'LinkedSplits', # noqa digits: int = 0 ) -> None: super().__init__() @@ -217,7 +217,7 @@ class Cursor(pg.GraphicsObject): color=hcolor('davies'), style=QtCore.Qt.DashLine, ) - self.lsc = linkedsplitcharts + self.lsc = linkedsplits self.graphics: Dict[str, pg.GraphicsObject] = {} self.plots: List['PlotChartWidget'] = [] # type: ignore # noqa self.active_plot = None From 4a37cf768f26b1109f2f4830084e18c12486c0c3 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 15 Jun 2021 18:54:28 -0400 Subject: [PATCH 04/26] Move line and arrow editors to new mod --- piker/ui/_annotate.py | 1 - piker/ui/_chart.py | 7 +- piker/ui/_editors.py | 291 +++++++++++++++++++++++++++++++++++++++ piker/ui/_interaction.py | 263 +---------------------------------- piker/ui/order_mode.py | 4 +- 5 files changed, 297 insertions(+), 269 deletions(-) create mode 100644 piker/ui/_editors.py diff --git a/piker/ui/_annotate.py b/piker/ui/_annotate.py index e1711bf1..d7193434 100644 --- a/piker/ui/_annotate.py +++ b/piker/ui/_annotate.py @@ -18,7 +18,6 @@ Annotations for ur faces. """ - from PyQt5 import QtCore, QtGui from PyQt5.QtGui import QGraphicsPathItem from pyqtgraph import Point, functions as fn, Color diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 463960eb..80ce283e 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -45,10 +45,6 @@ from ._graphics._cursor import ( Cursor, ContentsLabel, ) -from ._graphics._lines import ( - level_line, - order_line, -) from ._l1 import L1Labels from ._graphics._ohlc import BarItems from ._graphics._curve import FastAppendCurve @@ -951,6 +947,8 @@ async def test_bed( chart, lc, ): + from ._graphics._lines import order_line + sleep = 6 # from PyQt5.QtCore import QPointF @@ -1410,6 +1408,7 @@ async def run_fsp( # graphics.curve.setFillLevel(50) if fsp_func_name == 'rsi': + from ._graphics._lines import level_line # add moveable over-[sold/bought] lines # and labels only for the 70/30 lines level_line(chart, 20) diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py new file mode 100644 index 00000000..373d19a2 --- /dev/null +++ b/piker/ui/_editors.py @@ -0,0 +1,291 @@ +# piker: trading gear for hackers +# Copyright (C) Tyler Goodlet (in stewardship for piker0) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +""" +Higher level annotation editors. + +""" +from dataclasses import dataclass, field +from typing import Optional + +import pyqtgraph as pg + +from ._style import hcolor, _font +from ._graphics._lines import order_line, LevelLine +from ..log import get_logger + + +log = get_logger(__name__) + + +@dataclass +class ArrowEditor: + + chart: 'ChartPlotWidget' # noqa + _arrows: field(default_factory=dict) + + def add( + self, + uid: str, + x: float, + y: float, + color='default', + pointing: Optional[str] = None, + ) -> pg.ArrowItem: + """Add an arrow graphic to view at given (x, y). + + """ + angle = { + 'up': 90, + 'down': -90, + None: 180, # pointing to right (as in an alert) + }[pointing] + + # scale arrow sizing to dpi-aware font + size = _font.font.pixelSize() * 0.8 + + arrow = pg.ArrowItem( + angle=angle, + baseAngle=0, + headLen=size, + headWidth=size/2, + tailLen=None, + pxMode=True, + + # coloring + pen=pg.mkPen(hcolor('papas_special')), + brush=pg.mkBrush(hcolor(color)), + ) + arrow.setPos(x, y) + + self._arrows[uid] = arrow + + # render to view + self.chart.plotItem.addItem(arrow) + + return arrow + + def remove(self, arrow) -> bool: + self.chart.plotItem.removeItem(arrow) + + +# global store of order-lines graphics +# keyed by uuid4 strs - used to sync draw +# order lines **after** the order is 100% +# active in emsd +_order_lines: dict[str, LevelLine] = {} + + +@dataclass +class LineEditor: + """The great editor of linez.. + + """ + # TODO: drop this? + # view: 'ChartView' + + _order_lines: field(default_factory=_order_lines) + chart: 'ChartPlotWidget' = None # type: ignore # noqa + _active_staged_line: LevelLine = None + _stage_line: LevelLine = None + + def stage_line( + self, + action: str, + + color: str = 'alert_yellow', + hl_on_hover: bool = False, + dotted: bool = False, + + # fields settings + size: Optional[int] = None, + ) -> LevelLine: + """Stage a line at the current chart's cursor position + and return it. + + """ + # chart.setCursor(QtCore.Qt.PointingHandCursor) + + chart = self.chart._cursor.active_plot + cursor = chart._cursor + y = chart._cursor._datum_xy[1] + + symbol = chart._lc.symbol + + # line = self._stage_line + # if not line: + # add a "staged" cursor-tracking line to view + # and cash it in a a var + if self._active_staged_line: + self.unstage_line() + + line = order_line( + chart, + + level=y, + level_digits=symbol.digits(), + size=size, + size_digits=symbol.lot_digits(), + + # just for the stage line to avoid + # flickering while moving the cursor + # around where it might trigger highlight + # then non-highlight depending on sensitivity + always_show_labels=True, + + # kwargs + color=color, + # don't highlight the "staging" line + hl_on_hover=hl_on_hover, + dotted=dotted, + exec_type='dark' if dotted else 'live', + action=action, + show_markers=True, + + # prevent flickering of marker while moving/tracking cursor + only_show_markers_on_hover=False, + ) + + self._active_staged_line = line + + # hide crosshair y-line and label + cursor.hide_xhair() + + # add line to cursor trackers + cursor._trackers.add(line) + + return line + + def unstage_line(self) -> LevelLine: + """Inverse of ``.stage_line()``. + + """ + # chart = self.chart._cursor.active_plot + # # chart.setCursor(QtCore.Qt.ArrowCursor) + cursor = self.chart._cursor + + # delete "staged" cursor tracking line from view + line = self._active_staged_line + if line: + cursor._trackers.remove(line) + line.delete() + + self._active_staged_line = None + + # show the crosshair y line and label + cursor.show_xhair() + + def create_order_line( + self, + uuid: str, + level: float, + chart: 'ChartPlotWidget', # noqa + size: float, + action: str, + ) -> LevelLine: + + line = self._active_staged_line + if not line: + raise RuntimeError("No line is currently staged!?") + + sym = chart._lc.symbol + + line = order_line( + chart, + + # label fields default values + level=level, + level_digits=sym.digits(), + + size=size, + size_digits=sym.lot_digits(), + + # LevelLine kwargs + color=line.color, + dotted=line._dotted, + + show_markers=True, + only_show_markers_on_hover=True, + + action=action, + ) + + # for now, until submission reponse arrives + line.hide_labels() + + # register for later lookup/deletion + self._order_lines[uuid] = line + + return line + + def commit_line(self, uuid: str) -> LevelLine: + """Commit a "staged line" to view. + + Submits the line graphic under the cursor as a (new) permanent + graphic in view. + + """ + try: + line = self._order_lines[uuid] + except KeyError: + log.warning(f'No line for {uuid} could be found?') + return + else: + assert line.oid == uuid + line.show_labels() + + # TODO: other flashy things to indicate the order is active + + log.debug(f'Level active for level: {line.value()}') + + return line + + def lines_under_cursor(self): + """Get the line(s) under the cursor position. + + """ + # Delete any hoverable under the cursor + return self.chart._cursor._hovered + + def remove_line( + self, + line: LevelLine = None, + uuid: str = None, + ) -> LevelLine: + """Remove a line by refernce or uuid. + + If no lines or ids are provided remove all lines under the + cursor position. + + """ + if line: + uuid = line.oid + + # try to look up line from our registry + line = self._order_lines.pop(uuid, None) + if line: + + # if hovered remove from cursor set + hovered = self.chart._cursor._hovered + if line in hovered: + hovered.remove(line) + + # make sure the xhair doesn't get left off + # just because we never got a un-hover event + self.chart._cursor.show_xhair() + + line.delete() + return line diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 65655ddc..244e27b1 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -19,8 +19,7 @@ Chart view box primitives """ from contextlib import asynccontextmanager -from dataclasses import dataclass, field -from typing import Optional, Dict +from typing import Optional import pyqtgraph as pg from PyQt5.QtCore import QPointF, Qt @@ -32,7 +31,6 @@ import trio from ..log import get_logger from ._style import _min_points_to_show, hcolor, _font -from ._graphics._lines import order_line, LevelLine log = get_logger(__name__) @@ -201,265 +199,6 @@ class SelectRect(QtGui.QGraphicsRectItem): self.hide() -# global store of order-lines graphics -# keyed by uuid4 strs - used to sync draw -# order lines **after** the order is 100% -# active in emsd -_order_lines: Dict[str, LevelLine] = {} - - -@dataclass -class LineEditor: - """The great editor of linez.. - - """ - view: 'ChartView' - - _order_lines: field(default_factory=_order_lines) - chart: 'ChartPlotWidget' = None # type: ignore # noqa - _active_staged_line: LevelLine = None - _stage_line: LevelLine = None - - def stage_line( - self, - action: str, - - color: str = 'alert_yellow', - hl_on_hover: bool = False, - dotted: bool = False, - - # fields settings - size: Optional[int] = None, - ) -> LevelLine: - """Stage a line at the current chart's cursor position - and return it. - - """ - # chart.setCursor(QtCore.Qt.PointingHandCursor) - - chart = self.chart._cursor.active_plot - cursor = chart._cursor - y = chart._cursor._datum_xy[1] - - symbol = chart._lc.symbol - - # line = self._stage_line - # if not line: - # add a "staged" cursor-tracking line to view - # and cash it in a a var - if self._active_staged_line: - self.unstage_line() - - line = order_line( - chart, - - level=y, - level_digits=symbol.digits(), - size=size, - size_digits=symbol.lot_digits(), - - # just for the stage line to avoid - # flickering while moving the cursor - # around where it might trigger highlight - # then non-highlight depending on sensitivity - always_show_labels=True, - - # kwargs - color=color, - # don't highlight the "staging" line - hl_on_hover=hl_on_hover, - dotted=dotted, - exec_type='dark' if dotted else 'live', - action=action, - show_markers=True, - - # prevent flickering of marker while moving/tracking cursor - only_show_markers_on_hover=False, - ) - - self._active_staged_line = line - - # hide crosshair y-line and label - cursor.hide_xhair() - - # add line to cursor trackers - cursor._trackers.add(line) - - return line - - def unstage_line(self) -> LevelLine: - """Inverse of ``.stage_line()``. - - """ - # chart = self.chart._cursor.active_plot - # # chart.setCursor(QtCore.Qt.ArrowCursor) - cursor = self.chart._cursor - - # delete "staged" cursor tracking line from view - line = self._active_staged_line - if line: - cursor._trackers.remove(line) - line.delete() - - self._active_staged_line = None - - # show the crosshair y line and label - cursor.show_xhair() - - def create_order_line( - self, - uuid: str, - level: float, - chart: 'ChartPlotWidget', # noqa - size: float, - action: str, - ) -> LevelLine: - - line = self._active_staged_line - if not line: - raise RuntimeError("No line is currently staged!?") - - sym = chart._lc.symbol - - line = order_line( - chart, - - # label fields default values - level=level, - level_digits=sym.digits(), - - size=size, - size_digits=sym.lot_digits(), - - # LevelLine kwargs - color=line.color, - dotted=line._dotted, - - show_markers=True, - only_show_markers_on_hover=True, - - action=action, - ) - - # for now, until submission reponse arrives - line.hide_labels() - - # register for later lookup/deletion - self._order_lines[uuid] = line - - return line - - def commit_line(self, uuid: str) -> LevelLine: - """Commit a "staged line" to view. - - Submits the line graphic under the cursor as a (new) permanent - graphic in view. - - """ - try: - line = self._order_lines[uuid] - except KeyError: - log.warning(f'No line for {uuid} could be found?') - return - else: - assert line.oid == uuid - line.show_labels() - - # TODO: other flashy things to indicate the order is active - - log.debug(f'Level active for level: {line.value()}') - - return line - - def lines_under_cursor(self): - """Get the line(s) under the cursor position. - - """ - # Delete any hoverable under the cursor - return self.chart._cursor._hovered - - def remove_line( - self, - line: LevelLine = None, - uuid: str = None, - ) -> LevelLine: - """Remove a line by refernce or uuid. - - If no lines or ids are provided remove all lines under the - cursor position. - - """ - if line: - uuid = line.oid - - # try to look up line from our registry - line = self._order_lines.pop(uuid, None) - if line: - - # if hovered remove from cursor set - hovered = self.chart._cursor._hovered - if line in hovered: - hovered.remove(line) - - # make sure the xhair doesn't get left off - # just because we never got a un-hover event - self.chart._cursor.show_xhair() - - line.delete() - return line - - -@dataclass -class ArrowEditor: - - chart: 'ChartPlotWidget' # noqa - _arrows: field(default_factory=dict) - - def add( - self, - uid: str, - x: float, - y: float, - color='default', - pointing: Optional[str] = None, - ) -> pg.ArrowItem: - """Add an arrow graphic to view at given (x, y). - - """ - angle = { - 'up': 90, - 'down': -90, - None: 180, # pointing to right (as in an alert) - }[pointing] - - # scale arrow sizing to dpi-aware font - size = _font.font.pixelSize() * 0.8 - - arrow = pg.ArrowItem( - angle=angle, - baseAngle=0, - headLen=size, - headWidth=size/2, - tailLen=None, - pxMode=True, - - # coloring - pen=pg.mkPen(hcolor('papas_special')), - brush=pg.mkBrush(hcolor(color)), - ) - arrow.setPos(x, y) - - self._arrows[uid] = arrow - - # render to view - self.chart.plotItem.addItem(arrow) - - return arrow - - def remove(self, arrow) -> bool: - self.chart.plotItem.removeItem(arrow) - - async def handle_viewmode_inputs( view: 'ChartView', diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 9cb7d4d2..081a896e 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -30,7 +30,7 @@ import trio from pydantic import BaseModel from ._graphics._lines import LevelLine, position_line -from ._interaction import LineEditor, ArrowEditor, _order_lines +from ._editors import LineEditor, ArrowEditor, _order_lines from ..clearing._client import open_ems, OrderBook from ..data._source import Symbol from ..log import get_logger @@ -275,7 +275,7 @@ async def open_order_mode( book: OrderBook, ): view = chart._vb - lines = LineEditor(view=view, chart=chart, _order_lines=_order_lines) + lines = LineEditor(chart=chart, _order_lines=_order_lines) arrows = ArrowEditor(chart, {}) log.info("Opening order mode") From 569b2efb510df85a65bd5747853ab4976b3a6e7d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 15 Jun 2021 19:02:46 -0400 Subject: [PATCH 05/26] Move region selection to editors mod --- piker/ui/_editors.py | 167 ++++++++++++++++++++++++++++++++++++++ piker/ui/_interaction.py | 171 +-------------------------------------- 2 files changed, 171 insertions(+), 167 deletions(-) diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index 373d19a2..c54a5ddf 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -22,6 +22,10 @@ from dataclasses import dataclass, field from typing import Optional import pyqtgraph as pg +from pyqtgraph import ViewBox, Point, QtCore, QtGui +from pyqtgraph import functions as fn +from PyQt5.QtCore import QPointF +import numpy as np from ._style import hcolor, _font from ._graphics._lines import order_line, LevelLine @@ -289,3 +293,166 @@ class LineEditor: line.delete() return line + + +class SelectRect(QtGui.QGraphicsRectItem): + + def __init__( + self, + viewbox: ViewBox, + color: str = 'dad_blue', + ) -> None: + super().__init__(0, 0, 1, 1) + + # self.rbScaleBox = QtGui.QGraphicsRectItem(0, 0, 1, 1) + self.vb = viewbox + self._chart: 'ChartPlotWidget' = None # noqa + + # override selection box color + color = QtGui.QColor(hcolor(color)) + self.setPen(fn.mkPen(color, width=1)) + color.setAlpha(66) + self.setBrush(fn.mkBrush(color)) + self.setZValue(1e9) + self.hide() + self._label = None + + label = self._label = QtGui.QLabel() + label.setTextFormat(0) # markdown + label.setFont(_font.font) + label.setMargin(0) + label.setAlignment( + QtCore.Qt.AlignLeft + # | QtCore.Qt.AlignVCenter + ) + + # proxy is created after containing scene is initialized + self._label_proxy = None + self._abs_top_right = None + + # TODO: "swing %" might be handy here (data's max/min # % change) + self._contents = [ + 'change: {pchng:.2f} %', + 'range: {rng:.2f}', + 'bars: {nbars}', + 'max: {dmx}', + 'min: {dmn}', + # 'time: {nbars}m', # TODO: compute this per bar size + 'sigma: {std:.2f}', + ] + + @property + def chart(self) -> 'ChartPlotWidget': # noqa + return self._chart + + @chart.setter + def chart(self, chart: 'ChartPlotWidget') -> None: # noqa + self._chart = chart + chart.sigRangeChanged.connect(self.update_on_resize) + palette = self._label.palette() + + # TODO: get bg color working + palette.setColor( + self._label.backgroundRole(), + # QtGui.QColor(chart.backgroundBrush()), + QtGui.QColor(hcolor('papas_special')), + ) + + def update_on_resize(self, vr, r): + """Re-position measure label on view range change. + + """ + if self._abs_top_right: + self._label_proxy.setPos( + self.vb.mapFromView(self._abs_top_right) + ) + + def mouse_drag_released( + self, + p1: QPointF, + p2: QPointF + ) -> None: + """Called on final button release for mouse drag with start and + end positions. + + """ + self.set_pos(p1, p2) + + def set_pos( + self, + p1: QPointF, + p2: QPointF + ) -> None: + """Set position of selection rect and accompanying label, move + label to match. + + """ + if self._label_proxy is None: + # https://doc.qt.io/qt-5/qgraphicsproxywidget.html + self._label_proxy = self.vb.scene().addWidget(self._label) + + start_pos = self.vb.mapToView(p1) + end_pos = self.vb.mapToView(p2) + + # map to view coords and update area + r = QtCore.QRectF(start_pos, end_pos) + + # old way; don't need right? + # lr = QtCore.QRectF(p1, p2) + # r = self.vb.childGroup.mapRectFromParent(lr) + + self.setPos(r.topLeft()) + self.resetTransform() + self.scale(r.width(), r.height()) + self.show() + + y1, y2 = start_pos.y(), end_pos.y() + x1, x2 = start_pos.x(), end_pos.x() + + # TODO: heh, could probably use a max-min streamin algo here too + _, xmn = min(y1, y2), min(x1, x2) + ymx, xmx = max(y1, y2), max(x1, x2) + + pchng = (y2 - y1) / y1 * 100 + rng = abs(y1 - y2) + + ixmn, ixmx = round(xmn), round(xmx) + nbars = ixmx - ixmn + 1 + + data = self._chart._ohlc[ixmn:ixmx] + + if len(data): + std = data['close'].std() + dmx = data['high'].max() + dmn = data['low'].min() + else: + dmn = dmx = std = np.nan + + # update label info + self._label.setText('\n'.join(self._contents).format( + pchng=pchng, rng=rng, nbars=nbars, + std=std, dmx=dmx, dmn=dmn, + )) + + # print(f'x2, y2: {(x2, y2)}') + # print(f'xmn, ymn: {(xmn, ymx)}') + + label_anchor = Point(xmx + 2, ymx) + + # XXX: in the drag bottom-right -> top-left case we don't + # want the label to overlay the box. + # if (x2, y2) == (xmn, ymx): + # # could do this too but needs to be added after coords transform + # # label_anchor = Point(x2, y2 + self._label.height()) + # label_anchor = Point(xmn, ymn) + + self._abs_top_right = label_anchor + self._label_proxy.setPos(self.vb.mapFromView(label_anchor)) + # self._label.show() + + def clear(self): + """Clear the selection box from view. + + """ + self._label.hide() + self.hide() diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 244e27b1..2bf46256 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -22,183 +22,20 @@ from contextlib import asynccontextmanager from typing import Optional import pyqtgraph as pg -from PyQt5.QtCore import QPointF, Qt -from PyQt5.QtCore import QEvent -from pyqtgraph import ViewBox, Point, QtCore, QtGui +from PyQt5.QtCore import Qt, QEvent +from pyqtgraph import ViewBox, Point, QtCore from pyqtgraph import functions as fn import numpy as np import trio from ..log import get_logger -from ._style import _min_points_to_show, hcolor, _font +from ._style import _min_points_to_show +from ._editors import SelectRect log = get_logger(__name__) -class SelectRect(QtGui.QGraphicsRectItem): - - def __init__( - self, - viewbox: ViewBox, - color: str = 'dad_blue', - ) -> None: - super().__init__(0, 0, 1, 1) - - # self.rbScaleBox = QtGui.QGraphicsRectItem(0, 0, 1, 1) - self.vb = viewbox - self._chart: 'ChartPlotWidget' = None # noqa - - # override selection box color - color = QtGui.QColor(hcolor(color)) - self.setPen(fn.mkPen(color, width=1)) - color.setAlpha(66) - self.setBrush(fn.mkBrush(color)) - self.setZValue(1e9) - self.hide() - self._label = None - - label = self._label = QtGui.QLabel() - label.setTextFormat(0) # markdown - label.setFont(_font.font) - label.setMargin(0) - label.setAlignment( - QtCore.Qt.AlignLeft - # | QtCore.Qt.AlignVCenter - ) - - # proxy is created after containing scene is initialized - self._label_proxy = None - self._abs_top_right = None - - # TODO: "swing %" might be handy here (data's max/min # % change) - self._contents = [ - 'change: {pchng:.2f} %', - 'range: {rng:.2f}', - 'bars: {nbars}', - 'max: {dmx}', - 'min: {dmn}', - # 'time: {nbars}m', # TODO: compute this per bar size - 'sigma: {std:.2f}', - ] - - @property - def chart(self) -> 'ChartPlotWidget': # noqa - return self._chart - - @chart.setter - def chart(self, chart: 'ChartPlotWidget') -> None: # noqa - self._chart = chart - chart.sigRangeChanged.connect(self.update_on_resize) - palette = self._label.palette() - - # TODO: get bg color working - palette.setColor( - self._label.backgroundRole(), - # QtGui.QColor(chart.backgroundBrush()), - QtGui.QColor(hcolor('papas_special')), - ) - - def update_on_resize(self, vr, r): - """Re-position measure label on view range change. - - """ - if self._abs_top_right: - self._label_proxy.setPos( - self.vb.mapFromView(self._abs_top_right) - ) - - def mouse_drag_released( - self, - p1: QPointF, - p2: QPointF - ) -> None: - """Called on final button release for mouse drag with start and - end positions. - - """ - self.set_pos(p1, p2) - - def set_pos( - self, - p1: QPointF, - p2: QPointF - ) -> None: - """Set position of selection rect and accompanying label, move - label to match. - - """ - if self._label_proxy is None: - # https://doc.qt.io/qt-5/qgraphicsproxywidget.html - self._label_proxy = self.vb.scene().addWidget(self._label) - - start_pos = self.vb.mapToView(p1) - end_pos = self.vb.mapToView(p2) - - # map to view coords and update area - r = QtCore.QRectF(start_pos, end_pos) - - # old way; don't need right? - # lr = QtCore.QRectF(p1, p2) - # r = self.vb.childGroup.mapRectFromParent(lr) - - self.setPos(r.topLeft()) - self.resetTransform() - self.scale(r.width(), r.height()) - self.show() - - y1, y2 = start_pos.y(), end_pos.y() - x1, x2 = start_pos.x(), end_pos.x() - - # TODO: heh, could probably use a max-min streamin algo here too - _, xmn = min(y1, y2), min(x1, x2) - ymx, xmx = max(y1, y2), max(x1, x2) - - pchng = (y2 - y1) / y1 * 100 - rng = abs(y1 - y2) - - ixmn, ixmx = round(xmn), round(xmx) - nbars = ixmx - ixmn + 1 - - data = self._chart._ohlc[ixmn:ixmx] - - if len(data): - std = data['close'].std() - dmx = data['high'].max() - dmn = data['low'].min() - else: - dmn = dmx = std = np.nan - - # update label info - self._label.setText('\n'.join(self._contents).format( - pchng=pchng, rng=rng, nbars=nbars, - std=std, dmx=dmx, dmn=dmn, - )) - - # print(f'x2, y2: {(x2, y2)}') - # print(f'xmn, ymn: {(xmn, ymx)}') - - label_anchor = Point(xmx + 2, ymx) - - # XXX: in the drag bottom-right -> top-left case we don't - # want the label to overlay the box. - # if (x2, y2) == (xmn, ymx): - # # could do this too but needs to be added after coords transform - # # label_anchor = Point(x2, y2 + self._label.height()) - # label_anchor = Point(xmn, ymn) - - self._abs_top_right = label_anchor - self._label_proxy.setPos(self.vb.mapFromView(label_anchor)) - # self._label.show() - - def clear(self): - """Clear the selection box from view. - - """ - self._label.hide() - self.hide() - - async def handle_viewmode_inputs( view: 'ChartView', From 03c95ca9084f15349a87e432a390c9b2fee348b6 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 16 Jun 2021 05:24:04 -0400 Subject: [PATCH 06/26] Factor press and release handling into same qtloop --- piker/ui/_interaction.py | 213 +++++++++++++++++---------------------- 1 file changed, 94 insertions(+), 119 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 2bf46256..70732205 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -48,11 +48,103 @@ async def handle_viewmode_inputs( if etype in {QEvent.KeyPress}: - await view.on_key_press(text, key, mods) + # await view.on_key_press(text, key, mods) + + if mods == Qt.ShiftModifier: + if view.state['mouseMode'] == ViewBox.PanMode: + view.setMouseMode(ViewBox.RectMode) + + # ctrl + ctrl = False + if mods == Qt.ControlModifier: + ctrl = True + view.mode._exec_mode = 'live' + + view._key_active = True + + # ctrl + alt + # ctlalt = False + # if (QtCore.Qt.AltModifier | QtCore.Qt.ControlModifier) == mods: + # ctlalt = True + + # ctlr-/ for "lookup", "search" -> open search tree + if ctrl and key in { + Qt.Key_L, + Qt.Key_Space, + }: + search = view._chart._lc.godwidget.search + search.focus() + + # esc + if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C): + # ctrl-c as cancel + # https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9 + view.select_box.clear() + + # cancel order or clear graphics + if key == Qt.Key_C or key == Qt.Key_Delete: + # delete any lines under the cursor + mode = view.mode + for line in mode.lines.lines_under_cursor(): + mode.book.cancel(uuid=line.oid) + + view._key_buffer.append(text) + + # View modes + if key == Qt.Key_R: + view.chart.default_view() + + # Order modes: stage orders at the current cursor level + + elif key == Qt.Key_D: # for "damp eet" + view.mode.set_exec('sell') + + elif key == Qt.Key_F: # for "fillz eet" + view.mode.set_exec('buy') + + elif key == Qt.Key_A: + view.mode.set_exec('alert') + + # XXX: Leaving this for light reference purposes, there + # seems to be some work to at least gawk at for history mgmt. + + # Key presses are used only when mouse mode is RectMode + # The following events are implemented: + # ctrl-A : zooms out to the default "full" view of the plot + # ctrl-+ : moves forward in the zooming stack (if it exists) + # ctrl-- : moves backward in the zooming stack (if it exists) + + # view.scaleHistory(-1) + # elif ev.text() in ['+', '=']: + # view.scaleHistory(1) + # elif ev.key() == QtCore.Qt.Key_Backspace: + # view.scaleHistory(len(view.axHistory)) + else: + # maybe propagate to parent widget + # event.ignore() + view._key_active = False elif etype in {QEvent.KeyRelease}: - await view.on_key_release(text, key, mods) + # await view.on_key_release(text, key, mods) + if key == Qt.Key_Shift: + # if view.state['mouseMode'] == ViewBox.RectMode: + view.setMouseMode(ViewBox.PanMode) + + # ctlalt = False + # if (QtCore.Qt.AltModifier | QtCore.Qt.ControlModifier) == mods: + # ctlalt = True + + # if view.state['mouseMode'] == ViewBox.RectMode: + # if key == QtCore.Qt.Key_Space: + if mods == Qt.ControlModifier or key == QtCore.Qt.Key_Control: + view.mode._exec_mode = 'dark' + + if key in {Qt.Key_A, Qt.Key_F, Qt.Key_D}: + # remove "staged" level line under cursor position + view.mode.lines.unstage_line() + + view._key_active = False class ChartView(ViewBox): @@ -315,129 +407,12 @@ class ChartView(ViewBox): ev.accept() self.mode.submit_exec() - # def keyReleaseEvent(self, ev: QtCore.QEvent): def keyReleaseEvent(self, event: QtCore.QEvent) -> None: '''This routine is rerouted to an async handler. ''' pass - async def on_key_release( - - self, - - text: str, - key: int, # 3-digit - mods: int, # 7-digit - - ) -> None: - """ - Key release to normally to trigger release of input mode - - """ - if key == Qt.Key_Shift: - # if self.state['mouseMode'] == ViewBox.RectMode: - self.setMouseMode(ViewBox.PanMode) - - # ctlalt = False - # if (QtCore.Qt.AltModifier | QtCore.Qt.ControlModifier) == mods: - # ctlalt = True - - # if self.state['mouseMode'] == ViewBox.RectMode: - # if key == QtCore.Qt.Key_Space: - if mods == Qt.ControlModifier or key == QtCore.Qt.Key_Control: - self.mode._exec_mode = 'dark' - - if key in {Qt.Key_A, Qt.Key_F, Qt.Key_D}: - # remove "staged" level line under cursor position - self.mode.lines.unstage_line() - - self._key_active = False - def keyPressEvent(self, event: QtCore.QEvent) -> None: '''This routine is rerouted to an async handler. ''' pass - - async def on_key_press( - - self, - - text: str, - key: int, # 3-digit - mods: int, # 7-digit - - ) -> None: - - if mods == Qt.ShiftModifier: - if self.state['mouseMode'] == ViewBox.PanMode: - self.setMouseMode(ViewBox.RectMode) - - # ctrl - ctrl = False - if mods == Qt.ControlModifier: - ctrl = True - self.mode._exec_mode = 'live' - - self._key_active = True - - # ctrl + alt - # ctlalt = False - # if (QtCore.Qt.AltModifier | QtCore.Qt.ControlModifier) == mods: - # ctlalt = True - - # ctlr-/ for "lookup", "search" -> open search tree - if ctrl and key in { - Qt.Key_L, - Qt.Key_Space, - }: - search = self._chart._lc.godwidget.search - search.focus() - - # esc - if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C): - # ctrl-c as cancel - # https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9 - self.select_box.clear() - - # cancel order or clear graphics - if key == Qt.Key_C or key == Qt.Key_Delete: - # delete any lines under the cursor - mode = self.mode - for line in mode.lines.lines_under_cursor(): - mode.book.cancel(uuid=line.oid) - - self._key_buffer.append(text) - - # View modes - if key == Qt.Key_R: - self.chart.default_view() - - # Order modes: stage orders at the current cursor level - - elif key == Qt.Key_D: # for "damp eet" - self.mode.set_exec('sell') - - elif key == Qt.Key_F: # for "fillz eet" - self.mode.set_exec('buy') - - elif key == Qt.Key_A: - self.mode.set_exec('alert') - - # XXX: Leaving this for light reference purposes, there - # seems to be some work to at least gawk at for history mgmt. - - # Key presses are used only when mouse mode is RectMode - # The following events are implemented: - # ctrl-A : zooms out to the default "full" self of the plot - # ctrl-+ : moves forward in the zooming stack (if it exists) - # ctrl-- : moves backward in the zooming stack (if it exists) - - # self.scaleHistory(-1) - # elif ev.text() in ['+', '=']: - # self.scaleHistory(1) - # elif ev.key() == QtCore.Qt.Key_Backspace: - # self.scaleHistory(len(self.axHistory)) - else: - # maybe propagate to parent widget - # event.ignore() - self._key_active = False From 983a517a8c58ed7a872a77233df3ba31862ec87a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 16 Jun 2021 05:43:35 -0400 Subject: [PATCH 07/26] Drop old commented behaviour; see parent class if needed --- piker/ui/_interaction.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 70732205..70b9a390 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -105,20 +105,6 @@ async def handle_viewmode_inputs( elif key == Qt.Key_A: view.mode.set_exec('alert') - # XXX: Leaving this for light reference purposes, there - # seems to be some work to at least gawk at for history mgmt. - - # Key presses are used only when mouse mode is RectMode - # The following events are implemented: - # ctrl-A : zooms out to the default "full" view of the plot - # ctrl-+ : moves forward in the zooming stack (if it exists) - # ctrl-- : moves backward in the zooming stack (if it exists) - - # view.scaleHistory(-1) - # elif ev.text() in ['+', '=']: - # view.scaleHistory(1) - # elif ev.key() == QtCore.Qt.Key_Backspace: - # view.scaleHistory(len(view.axHistory)) else: # maybe propagate to parent widget # event.ignore() From aabbc12b82cfe5551d821f6f196b0b691bb8b233 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 16 Jun 2021 08:27:34 -0400 Subject: [PATCH 08/26] Beautifully simplify kb handling code with set ops --- piker/ui/_interaction.py | 117 ++++++++++++++++++++++----------------- 1 file changed, 65 insertions(+), 52 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 70b9a390..cdaa881c 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -43,39 +43,44 @@ async def handle_viewmode_inputs( ) -> None: + # track edge triggered keys + # (https://en.wikipedia.org/wiki/Interrupt#Triggering_methods) + pressed: set[str] = set() + async for event, etype, key, mods, text in recv_chan: log.debug(f'key: {key}, mods: {mods}, text: {text}') + # reset mods + ctrl: bool = False + alt: bool = False + shift: bool = False + + # press branch if etype in {QEvent.KeyPress}: - # await view.on_key_press(text, key, mods) + pressed.add(key) + # mods run through if mods == Qt.ShiftModifier: - if view.state['mouseMode'] == ViewBox.PanMode: - view.setMouseMode(ViewBox.RectMode) + shift = True - # ctrl - ctrl = False if mods == Qt.ControlModifier: ctrl = True - view.mode._exec_mode = 'live' - view._key_active = True - - # ctrl + alt - # ctlalt = False - # if (QtCore.Qt.AltModifier | QtCore.Qt.ControlModifier) == mods: - # ctlalt = True + if QtCore.Qt.AltModifier == mods: + alt = True # ctlr-/ for "lookup", "search" -> open search tree - if ctrl and key in { - Qt.Key_L, - Qt.Key_Space, - }: + if ( + ctrl and key in { + Qt.Key_L, + Qt.Key_Space, + } + ): search = view._chart._lc.godwidget.search search.focus() - # esc + # esc and ctrl-c if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C): # ctrl-c as cancel # https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9 @@ -83,53 +88,65 @@ async def handle_viewmode_inputs( # cancel order or clear graphics if key == Qt.Key_C or key == Qt.Key_Delete: + # delete any lines under the cursor mode = view.mode for line in mode.lines.lines_under_cursor(): mode.book.cancel(uuid=line.oid) - view._key_buffer.append(text) - # View modes if key == Qt.Key_R: + + # edge triggered default view activation view.chart.default_view() - # Order modes: stage orders at the current cursor level - - elif key == Qt.Key_D: # for "damp eet" - view.mode.set_exec('sell') - - elif key == Qt.Key_F: # for "fillz eet" - view.mode.set_exec('buy') - - elif key == Qt.Key_A: - view.mode.set_exec('alert') - - else: - # maybe propagate to parent widget - # event.ignore() - view._key_active = False - + # release branch elif etype in {QEvent.KeyRelease}: - # await view.on_key_release(text, key, mods) - if key == Qt.Key_Shift: - # if view.state['mouseMode'] == ViewBox.RectMode: - view.setMouseMode(ViewBox.PanMode) + if key in pressed: + pressed.remove(key) - # ctlalt = False - # if (QtCore.Qt.AltModifier | QtCore.Qt.ControlModifier) == mods: - # ctlalt = True + # selection mode + if shift: + if view.state['mouseMode'] == ViewBox.PanMode: + view.setMouseMode(ViewBox.RectMode) + else: # if view.state['mouseMode'] == ViewBox.RectMode: - # if key == QtCore.Qt.Key_Space: - if mods == Qt.ControlModifier or key == QtCore.Qt.Key_Control: - view.mode._exec_mode = 'dark' + view.setMouseMode(ViewBox.PanMode) - if key in {Qt.Key_A, Qt.Key_F, Qt.Key_D}: - # remove "staged" level line under cursor position - view.mode.lines.unstage_line() + # order mode live vs. dark trigger + # 's' or ctrl to activate "live" submissions + if ( + Qt.Key_S in pressed or + ctrl + ): + view.mode._exec_mode = 'live' + else: + view.mode._exec_mode = 'dark' + + order_keys_pressed = {Qt.Key_A, Qt.Key_F, Qt.Key_D}.intersection(pressed) + + # order mode "action" + + if order_keys_pressed: + view._key_active = True + + # order mode trigger "actions" + if Qt.Key_D in pressed: # for "damp eet" + view.mode.set_exec('sell') + + elif Qt.Key_F in pressed: # for "fillz eet" + view.mode.set_exec('buy') + + elif Qt.Key_A in pressed: + view.mode.set_exec('alert') + + else: # none active + # if none are pressed, remove "staged" level + # line under cursor position + view.mode.lines.unstage_line() view._key_active = False @@ -169,10 +186,6 @@ class ChartView(ViewBox): self.name = name self.mode = None - # kb ctrls processing - self._key_buffer = [] - self._key_active: bool = False - self.setFocusPolicy(QtCore.Qt.StrongFocus) @asynccontextmanager From c971997f1a538fde34abc54140d988c746d9357c Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 16 Jun 2021 08:28:11 -0400 Subject: [PATCH 09/26] Don't access unset cursor --- piker/ui/_editors.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index c54a5ddf..7026282d 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -122,6 +122,8 @@ class LineEditor: """ # chart.setCursor(QtCore.Qt.PointingHandCursor) + if not self.chart._cursor: + return None chart = self.chart._cursor.active_plot cursor = chart._cursor From ca23825aff25c3134b6ffeec1b4ecd196df51ba9 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 16 Jun 2021 08:28:57 -0400 Subject: [PATCH 10/26] Start input handling **after** order mode is up --- piker/ui/_chart.py | 40 +++++----- piker/ui/order_mode.py | 168 +++++++++++++++++++++++------------------ 2 files changed, 115 insertions(+), 93 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 80ce283e..8337d75d 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -158,18 +158,20 @@ class GodWidget(QtGui.QWidget): # self.toolbar_layout.addWidget(self.strategy_box) def load_symbol( + self, providername: str, symbol_key: str, loglevel: str, ohlc: bool = True, reset: bool = False, - ) -> None: - """Load a new contract into the charting app. + + ) -> trio.Event: + '''Load a new contract into the charting app. Expects a ``numpy`` structured array containing all the ohlcv fields. - """ + ''' # our symbol key style is always lower case symbol_key = symbol_key.lower() @@ -178,6 +180,8 @@ class GodWidget(QtGui.QWidget): linkedsplits = self.get_chart_symbol(fqsn) + order_mode_started = trio.Event() + if not self.vbox.isEmpty(): # XXX: this is CRITICAL especially with pixel buffer caching self.linkedsplits.hide() @@ -200,10 +204,15 @@ class GodWidget(QtGui.QWidget): providername, symbol_key, loglevel, + order_mode_started, ) self.set_chart_symbol(fqsn, linkedsplits) + else: + # symbol is already loaded and ems ready + order_mode_started.set() + self.vbox.addWidget(linkedsplits) # chart is already in memory so just focus it @@ -223,6 +232,8 @@ class GodWidget(QtGui.QWidget): f'tick:{symbol.tick_size}' ) + return order_mode_started + class LinkedSplits(QtGui.QWidget): ''' @@ -1527,6 +1538,8 @@ async def display_symbol_data( sym: str, loglevel: str, + order_mode_started: trio.Event, + ) -> None: '''Spawn a real-time displayed and updated chart for provider symbol. @@ -1623,7 +1636,6 @@ async def display_symbol_data( }, }) - # load initial fsp chain (otherwise known as "indicators") n.start_soon( spawn_fsps, @@ -1644,6 +1656,7 @@ async def display_symbol_data( wap_in_history, ) + # TODO: instead we should start based on instrument trading hours? # wait for a first quote before we start any update tasks # quote = await feed.receive() # log.info(f'Received first quote {quote}') @@ -1651,24 +1664,11 @@ async def display_symbol_data( n.start_soon( check_for_new_bars, feed, - # delay, ohlcv, linkedsplits ) - # interactive testing - # n.start_soon( - # test_bed, - # ohlcv, - # chart, - # linkedsplits, - # ) - - # start async input handling for chart's view - # await godwidget._task_stack.enter_async_context( - async with chart._vb.open_async_input_handler(): - - await start_order_mode(chart, symbol, provider) + await start_order_mode(chart, symbol, provider, order_mode_started) async def load_providers( @@ -1773,7 +1773,7 @@ async def _async_main( symbol, _, provider = sym.rpartition('.') # this internally starts a ``display_symbol_data()`` task above - godwidget.load_symbol(provider, symbol, loglevel) + order_mode_ready = godwidget.load_symbol(provider, symbol, loglevel) # spin up a search engine for the local cached symbol set async with _search.register_symbol_search( @@ -1791,6 +1791,8 @@ async def _async_main( # the chart's select cache root_n.start_soon(load_providers, brokernames, loglevel) + await order_mode_ready.wait() + # start handling search bar kb inputs async with ( diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 081a896e..78c4b693 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -26,8 +26,9 @@ from typing import Optional, Dict, Callable, Any import uuid import pyqtgraph as pg -import trio from pydantic import BaseModel +import trio +# from trio_typing import TaskStatus from ._graphics._lines import LevelLine, position_line from ._editors import LineEditor, ArrowEditor, _order_lines @@ -108,6 +109,10 @@ class OrderMode: """Set execution mode. """ + # not initialized yet + if not self.chart._cursor: + return + self._action = action self.lines.stage_line( @@ -306,10 +311,14 @@ async def open_order_mode( async def start_order_mode( + chart: 'ChartPlotWidget', # noqa symbol: Symbol, brokername: str, + # task_status: TaskStatus[trio.Event] = trio.TASK_STATUS_IGNORED, + started: trio.Event, + ) -> None: '''Activate chart-trader order mode loop: - connect to emsd @@ -322,7 +331,11 @@ async def start_order_mode( # spawn EMS actor-service async with ( open_ems(brokername, symbol) as (book, trades_stream, positions), - open_order_mode(symbol, chart, book) as order_mode + open_order_mode(symbol, chart, book) as order_mode, + + # # start async input handling for chart's view + # # await godwidget._task_stack.enter_async_context( + # chart._vb.open_async_input_handler(), ): # update any exising positions @@ -345,83 +358,90 @@ async def start_order_mode( # Begin order-response streaming done() - # this is where we receive **back** messages - # about executions **from** the EMS actor - async for msg in trades_stream: + # start async input handling for chart's view + async with chart._vb.open_async_input_handler(): - fmsg = pformat(msg) - log.info(f'Received order msg:\n{fmsg}') + # signal to top level symbol loading task we're ready + # to handle input since the ems connection is ready + started.set() - name = msg['name'] - if name in ( - 'position', - ): - # show line label once order is live - order_mode.on_position_update(msg) - continue + # this is where we receive **back** messages + # about executions **from** the EMS actor + async for msg in trades_stream: - resp = msg['resp'] - oid = msg['oid'] + fmsg = pformat(msg) + log.info(f'Received order msg:\n{fmsg}') - # response to 'action' request (buy/sell) - if resp in ( - 'dark_submitted', - 'broker_submitted' - ): - - # show line label once order is live - order_mode.on_submit(oid) - - # resp to 'cancel' request or error condition - # for action request - elif resp in ( - 'broker_cancelled', - 'broker_inactive', - 'dark_cancelled' - ): - # delete level line from view - order_mode.on_cancel(oid) - - elif resp in ( - 'dark_triggered' - ): - log.info(f'Dark order triggered for {fmsg}') - - elif resp in ( - 'alert_triggered' - ): - # should only be one "fill" for an alert - # add a triangle and remove the level line - order_mode.on_fill( - oid, - price=msg['trigger_price'], - arrow_index=get_index(time.time()) - ) - await order_mode.on_exec(oid, msg) - - # response to completed 'action' request for buy/sell - elif resp in ( - 'broker_executed', - ): - await order_mode.on_exec(oid, msg) - - # each clearing tick is responded individually - elif resp in ('broker_filled',): - - known_order = book._sent_orders.get(oid) - if not known_order: - log.warning(f'order {oid} is unknown') + name = msg['name'] + if name in ( + 'position', + ): + # show line label once order is live + order_mode.on_position_update(msg) continue - action = known_order.action - details = msg['brokerd_msg'] + resp = msg['resp'] + oid = msg['oid'] - # TODO: some kinda progress system - order_mode.on_fill( - oid, - price=details['price'], - pointing='up' if action == 'buy' else 'down', + # response to 'action' request (buy/sell) + if resp in ( + 'dark_submitted', + 'broker_submitted' + ): - # TODO: put the actual exchange timestamp - arrow_index=get_index(details['broker_time']), - ) + # show line label once order is live + order_mode.on_submit(oid) + + # resp to 'cancel' request or error condition + # for action request + elif resp in ( + 'broker_cancelled', + 'broker_inactive', + 'dark_cancelled' + ): + # delete level line from view + order_mode.on_cancel(oid) + + elif resp in ( + 'dark_triggered' + ): + log.info(f'Dark order triggered for {fmsg}') + + elif resp in ( + 'alert_triggered' + ): + # should only be one "fill" for an alert + # add a triangle and remove the level line + order_mode.on_fill( + oid, + price=msg['trigger_price'], + arrow_index=get_index(time.time()) + ) + await order_mode.on_exec(oid, msg) + + # response to completed 'action' request for buy/sell + elif resp in ( + 'broker_executed', + ): + await order_mode.on_exec(oid, msg) + + # each clearing tick is responded individually + elif resp in ('broker_filled',): + + known_order = book._sent_orders.get(oid) + if not known_order: + log.warning(f'order {oid} is unknown') + continue + + action = known_order.action + details = msg['brokerd_msg'] + + # TODO: some kinda progress system + order_mode.on_fill( + oid, + price=details['price'], + pointing='up' if action == 'buy' else 'down', + + # TODO: put the actual exchange timestamp + arrow_index=get_index(details['broker_time']), + ) From 0ded790330464704a694c30ea24d25d31a1e81da Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 16 Jun 2021 08:45:11 -0400 Subject: [PATCH 11/26] Didn't end up needing a task stack --- piker/ui/_chart.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 8337d75d..b76b9bbf 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -116,7 +116,6 @@ class GodWidget(QtGui.QWidget): # assigned in the startup func `_async_main()` self._root_n: trio.Nursery = None - self._task_stack: AsyncExitStack = None def set_chart_symbol( self, @@ -1748,13 +1747,11 @@ async def _async_main( async with ( trio.open_nursery() as root_n, - AsyncExitStack() as chart_task_stack, ): # set root nursery and task stack for spawning other charts/feeds # that run cached in the bg godwidget._root_n = root_n - godwidget._task_stack = chart_task_stack # setup search widget and focus main chart view at startup search = _search.SearchWidget(godwidget=godwidget) From 3650db33212ddfbf548de479ebcf8ad895a243f1 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 16 Jun 2021 10:31:00 -0400 Subject: [PATCH 12/26] Make alerts solid line only --- piker/ui/order_mode.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 78c4b693..5ed9f96d 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -118,7 +118,9 @@ class OrderMode: color=self._colors[action], # hl_on_hover=True if self._exec_mode == 'live' else False, - dotted=True if self._exec_mode == 'dark' else False, + dotted=True if ( + self._exec_mode == 'dark' and action != 'alert' + ) else False, size=size or self._size, action=action, ) From 53074b552a8da7afe4becc307dd8dbd5de589bd8 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 17 Jun 2021 16:52:54 -0400 Subject: [PATCH 13/26] Add fast tap key sequence support and order-mode-type statuses --- piker/ui/_interaction.py | 126 +++++++++++++++++++++++++++++---------- 1 file changed, 94 insertions(+), 32 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index cdaa881c..eec8cc38 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -19,7 +19,8 @@ Chart view box primitives """ from contextlib import asynccontextmanager -from typing import Optional +import time +from typing import Optional, Callable import pyqtgraph as pg from PyQt5.QtCore import Qt, QEvent @@ -31,6 +32,7 @@ import trio from ..log import get_logger from ._style import _min_points_to_show from ._editors import SelectRect +from ._window import main_window log = get_logger(__name__) @@ -43,16 +45,34 @@ async def handle_viewmode_inputs( ) -> None: + mode = view.mode + status_bar = main_window().status_bar + # track edge triggered keys # (https://en.wikipedia.org/wiki/Interrupt#Triggering_methods) pressed: set[str] = set() + last = time.time() + trigger_mode: str + action: str + + # for quick key sequence-combo pattern matching + # we have a min_tap period and these should not + # ever be auto-repeats since we filter those at the + # event filter level prior to the above mem chan. + min_tap = 1/6 + fast_key_seq: list[str] = [] + fast_taps: dict[str, Callable] = { + 'cc': mode.cancel_all_orders, + } + async for event, etype, key, mods, text in recv_chan: log.debug(f'key: {key}, mods: {mods}, text: {text}') + now = time.time() + period = now - last # reset mods ctrl: bool = False - alt: bool = False shift: bool = False # press branch @@ -60,6 +80,27 @@ async def handle_viewmode_inputs( pressed.add(key) + if ( + # clear any old values not part of a "fast" tap sequence: + # presumes the period since last tap is longer then our + # min_tap period + fast_key_seq and period >= min_tap or + + # don't support more then 2 key sequences for now + len(fast_key_seq) > 2 + ): + fast_key_seq.clear() + + # capture key to fast tap sequence if we either + # have no previous keys or we do and the min_tap period is + # met + if ( + not fast_key_seq or + period <= min_tap and fast_key_seq + ): + fast_key_seq.append(text) + log.debug(f'fast keys seqs {fast_key_seq}') + # mods run through if mods == Qt.ShiftModifier: shift = True @@ -67,9 +108,7 @@ async def handle_viewmode_inputs( if mods == Qt.ControlModifier: ctrl = True - if QtCore.Qt.AltModifier == mods: - alt = True - + # SEARCH MODE # # ctlr-/ for "lookup", "search" -> open search tree if ( ctrl and key in { @@ -77,8 +116,7 @@ async def handle_viewmode_inputs( Qt.Key_Space, } ): - search = view._chart._lc.godwidget.search - search.focus() + view._chart._lc.godwidget.search.focus() # esc and ctrl-c if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C): @@ -89,10 +127,7 @@ async def handle_viewmode_inputs( # cancel order or clear graphics if key == Qt.Key_C or key == Qt.Key_Delete: - # delete any lines under the cursor - mode = view.mode - for line in mode.lines.lines_under_cursor(): - mode.book.cancel(uuid=line.oid) + mode.cancel_orders_under_cursor() # View modes if key == Qt.Key_R: @@ -100,54 +135,80 @@ async def handle_viewmode_inputs( # edge triggered default view activation view.chart.default_view() + if len(fast_key_seq) > 1: + # begin matches against sequences + func: Callable = fast_taps.get(''.join(fast_key_seq)) + if func: + func() + fast_key_seq.clear() + # release branch elif etype in {QEvent.KeyRelease}: if key in pressed: pressed.remove(key) - # selection mode + # SELECTION MODE # if shift: if view.state['mouseMode'] == ViewBox.PanMode: view.setMouseMode(ViewBox.RectMode) else: - # if view.state['mouseMode'] == ViewBox.RectMode: view.setMouseMode(ViewBox.PanMode) - # order mode live vs. dark trigger + # ORDER MODE # + # live vs. dark trigger + an action {buy, sell, alert} - # 's' or ctrl to activate "live" submissions - if ( - Qt.Key_S in pressed or - ctrl - ): - view.mode._exec_mode = 'live' - else: - view.mode._exec_mode = 'dark' - - order_keys_pressed = {Qt.Key_A, Qt.Key_F, Qt.Key_D}.intersection(pressed) - - # order mode "action" + order_keys_pressed = { + Qt.Key_A, + Qt.Key_F, + Qt.Key_D + }.intersection(pressed) if order_keys_pressed: - view._key_active = True + if ( + # 's' for "submit" to activate "live" order + Qt.Key_S in pressed or + ctrl + ): + trigger_mode: str = 'live' + + else: + trigger_mode: str = 'dark' # order mode trigger "actions" if Qt.Key_D in pressed: # for "damp eet" - view.mode.set_exec('sell') + action = 'sell' elif Qt.Key_F in pressed: # for "fillz eet" - view.mode.set_exec('buy') + action = 'buy' elif Qt.Key_A in pressed: - view.mode.set_exec('alert') + action = 'alert' + trigger_mode = 'live' + + view.order_mode = True + + # XXX: order matters here for line style! + view.mode._exec_mode = trigger_mode + view.mode.set_exec(action) + + prefix = trigger_mode + '-' if action != 'alert' else '' + view._chart.window().mode_label.setText( + f'mode: {prefix}{action}') else: # none active # if none are pressed, remove "staged" level # line under cursor position view.mode.lines.unstage_line() - view._key_active = False + + if view.hasFocus(): + # update mode label + view._chart.window().mode_label.setText('mode: view') + + view.order_mode = False + + last = time.time() class ChartView(ViewBox): @@ -185,6 +246,7 @@ class ChartView(ViewBox): self.name = name self.mode = None + self.order_mode: bool = False self.setFocusPolicy(QtCore.Qt.StrongFocus) @@ -402,7 +464,7 @@ class ChartView(ViewBox): elif button == QtCore.Qt.LeftButton: # when in order mode, submit execution - if self._key_active: + if self.order_mode: ev.accept() self.mode.submit_exec() From 0133f0b58905e68af66d0928de2c7512a0b000b2 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 17 Jun 2021 16:53:19 -0400 Subject: [PATCH 14/26] Add "group statuses" support to status bar Allows for submitting a top level "group status" associated with a "group key" which eventually resolves once all sub-statuses associated with that group key (and thus top level status) complete and are also removed. Also add support for a "final message" for each status such that once the status clear callback is called a final msg is placed on the status bar that is then removed when the next status is set. It's all a questionable bunch of closures/callbacks but it worx. --- piker/ui/_window.py | 85 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/piker/ui/_window.py b/piker/ui/_window.py index 60210160..e45b58e4 100644 --- a/piker/ui/_window.py +++ b/piker/ui/_window.py @@ -21,7 +21,8 @@ Qt main window singletons and stuff. import os import signal import time -from typing import Callable +from typing import Callable, Optional, Union +import uuid from pyqtgraph import QtGui from PyQt5 import QtCore @@ -42,23 +43,97 @@ class MultiStatus: def __init__(self, bar, statuses) -> None: self.bar = bar self.statuses = statuses + # self._clear_last: Optional[Callable[..., None]] = None + self._to_clear: set = set() + self._status_groups: dict[str, (set, Callable)] = {} def open_status( + self, msg: str, - ) -> Callable[..., None]: + final_msg: Optional[str] = None, + clear_on_next: bool = False, + group_key: Optional[Union[bool, str]] = False, + + ) -> Union[Callable[..., None], str]: '''Add a status to the status bar and return a close callback which when called will remove the status ``msg``. ''' + for msg in self._to_clear: + try: + self.statuses.remove(msg) + except ValueError: + pass + self.statuses.append(msg) def remove_msg() -> None: - self.statuses.remove(msg) - self.render() + try: + self.statuses.remove(msg) + self.render() + except ValueError: + pass + + if final_msg is not None: + self.statuses.append(final_msg) + self.render() + self._to_clear.add(final_msg) + + + ret = remove_msg + + # create a "status group" such that new `.open_status()` + # calls can be made passing in the returned group key. + # once all clear callbacks have been called from all statuses + # in the group the final status msg to be removed will be the one + # the one provided when `group_key=True`, this way you can + # create a long living status that completes once all + # sub-statuses have finished. + if group_key is True: + if clear_on_next: + ValueError("Can't create group status and clear it on next?") + + # generate a key for a new "status group" + new_group_key = str(uuid.uuid4()) + + def pop_group_and_clear(): + + subs, final_clear = self._status_groups.pop(new_group_key) + assert not subs + return remove_msg() + + self._status_groups[new_group_key] = (set(), pop_group_and_clear) + ret = new_group_key + + elif group_key: + + def pop_from_group_and_maybe_clear_group(): + # remove the message for this sub-status + remove_msg() + + # check to see if all other substatuses have cleared + group_tup = self._status_groups.get(group_key) + + if group_tup: + subs, group_clear = group_tup + try: + subs.remove(msg) + except KeyError: + raise KeyError(f'no msg {msg} for group {group_key}!?') + + if not subs: + group_clear() + + self._status_groups[group_key][0].add(msg) + ret = pop_from_group_and_maybe_clear_group + + if clear_on_next: + self._to_clear.add(msg) self.render() - return remove_msg + + return ret def render(self) -> None: if self.statuses: From 572f984d06b687666405a4c1dfa60adfd256b84d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 17 Jun 2021 17:00:10 -0400 Subject: [PATCH 15/26] Add an all order lines getter method --- piker/ui/_editors.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index 7026282d..a3b36b7c 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -95,12 +95,9 @@ _order_lines: dict[str, LevelLine] = {} @dataclass class LineEditor: - """The great editor of linez.. - - """ - # TODO: drop this? - # view: 'ChartView' + '''The great editor of linez. + ''' _order_lines: field(default_factory=_order_lines) chart: 'ChartPlotWidget' = None # type: ignore # noqa _active_staged_line: LevelLine = None @@ -259,13 +256,16 @@ class LineEditor: return line - def lines_under_cursor(self): + def lines_under_cursor(self) -> list[LevelLine]: """Get the line(s) under the cursor position. """ # Delete any hoverable under the cursor return self.chart._cursor._hovered + def all_lines(self) -> tuple[LevelLine]: + return tuple(self._order_lines.values()) + def remove_line( self, line: LevelLine = None, From eeeeb29f715fc08a92e43ec13586695e0cf321db Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 17 Jun 2021 17:00:57 -0400 Subject: [PATCH 16/26] Add order cancellation and submission statuses Generalize the methods for cancelling groups of orders (all or those under cursor) and add new group status support such that statuses for each cancel or order submission is displayed in the status bar. In the "cancel-all-orders" case, use the new group status stuff. --- piker/clearing/_client.py | 4 +- piker/ui/order_mode.py | 94 ++++++++++++++++++++++++++++++++++----- 2 files changed, 86 insertions(+), 12 deletions(-) diff --git a/piker/clearing/_client.py b/piker/clearing/_client.py index fcceeaac..97869bb9 100644 --- a/piker/clearing/_client.py +++ b/piker/clearing/_client.py @@ -125,7 +125,9 @@ def get_orders( if _orders is None: # setup local ui event streaming channels for request/resp # streamging with EMS daemon - _orders = OrderBook(*trio.open_memory_channel(1)) + _orders = OrderBook( + *trio.open_memory_channel(100), + ) return _orders diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 5ed9f96d..f4e58329 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -28,10 +28,10 @@ import uuid import pyqtgraph as pg from pydantic import BaseModel import trio -# from trio_typing import TaskStatus from ._graphics._lines import LevelLine, position_line from ._editors import LineEditor, ArrowEditor, _order_lines +from ._window import MultiStatus, main_window from ..clearing._client import open_ems, OrderBook from ..data._source import Symbol from ..log import get_logger @@ -49,18 +49,28 @@ class Position(BaseModel): @dataclass class OrderMode: - """Major mode for placing orders on a chart view. + '''Major mode for placing orders on a chart view. This is the default mode that pairs with "follow mode" (when wathing the rt price update at the current time step) - and allows entering orders using the ``a, d, f`` keys and - cancelling moused-over orders with the ``c`` key. + and allows entering orders using mouse and keyboard. - """ + Current manual: + a -> alert + s/ctrl -> submission type modifier {on: live, off: dark} + f (fill) -> buy limit order + d (dump) -> sell limit order + c (cancel) -> cancel order under cursor + cc -> cancel all submitted orders on chart + mouse click and drag -> modify current order under cursor + + ''' chart: 'ChartPlotWidget' # type: ignore # noqa book: OrderBook lines: LineEditor arrows: ArrowEditor + status_bar: MultiStatus + _colors = { 'alert': 'alert_yellow', 'buy': 'buy_green', @@ -72,7 +82,8 @@ class OrderMode: _position: Dict[str, Any] = field(default_factory=dict) _position_line: dict = None - key_map: Dict[str, Callable] = field(default_factory=dict) + _pending_submissions: dict[str, (LevelLine, Callable)] = field( + default_factory=dict) def on_position_update( self, @@ -134,6 +145,13 @@ class OrderMode: """ line = self.lines.commit_line(uuid) + + pending = self._pending_submissions.get(uuid) + if pending: + order_line, func = pending + assert order_line is line + func() + return line def on_fill( @@ -191,6 +209,10 @@ class OrderMode: self.lines.remove_line(uuid=uuid) self.chart._cursor.show_xhair() + pending = self._pending_submissions.pop(uuid, None) + if pending: + order_line, func = pending + func() else: log.warning( f'Received cancel for unsubmitted order {pformat(msg)}' @@ -245,17 +267,67 @@ class OrderMode: ) line.oid = uid + # enter submission which will be popped once a response + # from the EMS is received to move the order to a different# status + self._pending_submissions[uid] = ( + line, + self.status_bar.open_status( + f'submitting {self._exec_mode}-{action}', + final_msg=f'submitted {self._exec_mode}-{action}', + clear_on_next=True, + ) + ) + # hook up mouse drag handlers line._on_drag_start = self.order_line_modify_start line._on_drag_end = self.order_line_modify_complete return line - def cancel_order_under_cursor(self) -> None: - for line in self.lines.lines_under_cursor(): - self.book.cancel(uuid=line.oid) + def cancel_orders_under_cursor(self) -> list[str]: + return self.cancel_orders_from_lines( + self.lines.lines_under_cursor() + ) + + def cancel_all_orders(self) -> list[str]: + return self.cancel_orders_from_lines( + self.lines.all_lines() + ) + + def cancel_orders_from_lines( + self, + lines: list[LevelLine], + + ) -> list[str]: + + ids: list = [] + if lines: + key = self.status_bar.open_status( + f'cancelling {len(lines)} orders', + final_msg=f'cancelled {len(lines)} orders', + group_key=True + ) + + # cancel all active orders and triggers + for line in lines: + oid = getattr(line, 'oid', None) + + if oid: + self._pending_submissions[oid] = ( + line, + self.status_bar.open_status( + f'cancelling order {oid[:6]}', + group_key=key, + ), + ) + + ids.append(oid) + self.book.cancel(uuid=oid) + + return ids # order-line modify handlers + def order_line_modify_start( self, line: LevelLine, @@ -281,13 +353,14 @@ async def open_order_mode( chart: pg.PlotWidget, book: OrderBook, ): + status_bar: MultiStatus = main_window().status_bar view = chart._vb lines = LineEditor(chart=chart, _order_lines=_order_lines) arrows = ArrowEditor(chart, {}) log.info("Opening order mode") - mode = OrderMode(chart, book, lines, arrows) + mode = OrderMode(chart, book, lines, arrows, status_bar) view.mode = mode asset_type = symbol.type_key @@ -318,7 +391,6 @@ async def start_order_mode( symbol: Symbol, brokername: str, - # task_status: TaskStatus[trio.Event] = trio.TASK_STATUS_IGNORED, started: trio.Event, ) -> None: From 37180a4e4e06f36443bf00b66bdc5ce4fc2f3860 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 18 Jun 2021 09:37:55 -0400 Subject: [PATCH 17/26] Fix old msg clearing var name --- piker/ui/_window.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/piker/ui/_window.py b/piker/ui/_window.py index e45b58e4..3668b6d5 100644 --- a/piker/ui/_window.py +++ b/piker/ui/_window.py @@ -43,7 +43,6 @@ class MultiStatus: def __init__(self, bar, statuses) -> None: self.bar = bar self.statuses = statuses - # self._clear_last: Optional[Callable[..., None]] = None self._to_clear: set = set() self._status_groups: dict[str, (set, Callable)] = {} @@ -60,9 +59,9 @@ class MultiStatus: when called will remove the status ``msg``. ''' - for msg in self._to_clear: + for old_msg in self._to_clear: try: - self.statuses.remove(msg) + self.statuses.remove(old_msg) except ValueError: pass @@ -71,16 +70,16 @@ class MultiStatus: def remove_msg() -> None: try: self.statuses.remove(msg) - self.render() except ValueError: pass + self.render() + if final_msg is not None: self.statuses.append(final_msg) self.render() self._to_clear.add(final_msg) - ret = remove_msg # create a "status group" such that new `.open_status()` @@ -128,14 +127,17 @@ class MultiStatus: self._status_groups[group_key][0].add(msg) ret = pop_from_group_and_maybe_clear_group + self.render() + if clear_on_next: self._to_clear.add(msg) - self.render() - return ret def render(self) -> None: + '''Display all open statuses to bar. + + ''' if self.statuses: self.bar.showMessage(f'{" ".join(self.statuses)}') else: From 91209b7d6e45b295b5e9dcd87c3f2124113ca348 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 18 Jun 2021 09:38:14 -0400 Subject: [PATCH 18/26] Use group status for symbol loading --- piker/ui/_chart.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index b76b9bbf..aca62fe0 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -1245,10 +1245,11 @@ async def spawn_fsps( linkedsplits: LinkedSplits, fsps: Dict[str, str], - sym, - src_shm, - brokermod, - loglevel, + sym: str, + src_shm: list, + brokermod: ModuleType, + group_status_key: str, + loglevel: str, ) -> None: """Start financial signal processing in subactor. @@ -1311,6 +1312,7 @@ async def spawn_fsps( fsp_func_name, display_name, conf, + group_status_key, ) # blocks here until all fsp actors complete @@ -1326,6 +1328,7 @@ async def run_fsp( fsp_func_name: str, display_name: str, conf: Dict[str, Any], + group_status_key: str, ) -> None: """FSP stream chart update loop. @@ -1334,7 +1337,9 @@ async def run_fsp( config map. """ done = linkedsplits.window().status_bar.open_status( - f'loading FSP: {display_name}..') + f'loading {display_name}..', + group_key=group_status_key, + ) async with portal.open_stream_from( @@ -1548,7 +1553,10 @@ async def display_symbol_data( ''' sbar = godwidget.window.status_bar - loading_sym_done = sbar.open_status(f'loading {sym}.{provider}..') + loading_sym_key = sbar.open_status( + f'loading {sym}.{provider} -> ', + group_key=True + ) # historical data fetch brokermod = brokers.get_brokermod(provider) @@ -1597,8 +1605,6 @@ async def display_symbol_data( add_label=False, ) - loading_sym_done() - # size view to data once at outset chart._set_yrange() @@ -1643,6 +1649,7 @@ async def display_symbol_data( sym, ohlcv, brokermod, + loading_sym_key, loglevel, ) @@ -1743,7 +1750,7 @@ async def _async_main( # _style.style_ze_sheets(godwidget) sbar = godwidget.window.status_bar - starting_done = sbar.open_status('starting ze chartz...') + starting_done = sbar.open_status('starting ze sexy chartz') async with ( trio.open_nursery() as root_n, From d3d5d4ad065610e284f7db8b48e8663b06552c1f Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 18 Jun 2021 17:13:39 -0400 Subject: [PATCH 19/26] Drop global order lines map Orders in order mode should be chart oriented since there's a mode per chart. If you want all orders just ask the ems or query all the charts in a loop. This fixes cancel-all-orders such that when 'cc' is tapped only the orders on the *current* chart are cancelled, lel. --- piker/ui/_chart.py | 8 +++++++- piker/ui/_editors.py | 12 +----------- piker/ui/order_mode.py | 12 +++++++++--- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index aca62fe0..24362456 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -1554,13 +1554,19 @@ async def display_symbol_data( ''' sbar = godwidget.window.status_bar loading_sym_key = sbar.open_status( - f'loading {sym}.{provider} -> ', + f'loading {sym}.{provider} ->', group_key=True ) # historical data fetch brokermod = brokers.get_brokermod(provider) + # ohlc_status_done = sbar.open_status( + # 'retreiving OHLC history.. ', + # clear_on_next=True, + # group_key=loading_sym_key, + # ) + async with( data.open_feed( diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index a3b36b7c..17b39b5d 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -86,22 +86,14 @@ class ArrowEditor: self.chart.plotItem.removeItem(arrow) -# global store of order-lines graphics -# keyed by uuid4 strs - used to sync draw -# order lines **after** the order is 100% -# active in emsd -_order_lines: dict[str, LevelLine] = {} - - @dataclass class LineEditor: '''The great editor of linez. ''' - _order_lines: field(default_factory=_order_lines) chart: 'ChartPlotWidget' = None # type: ignore # noqa + _order_lines: dict[str, LevelLine] = field(default_factory=dict) _active_staged_line: LevelLine = None - _stage_line: LevelLine = None def stage_line( self, @@ -128,8 +120,6 @@ class LineEditor: symbol = chart._lc.symbol - # line = self._stage_line - # if not line: # add a "staged" cursor-tracking line to view # and cash it in a a var if self._active_staged_line: diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index f4e58329..a4e55862 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -30,7 +30,7 @@ from pydantic import BaseModel import trio from ._graphics._lines import LevelLine, position_line -from ._editors import LineEditor, ArrowEditor, _order_lines +from ._editors import LineEditor, ArrowEditor from ._window import MultiStatus, main_window from ..clearing._client import open_ems, OrderBook from ..data._source import Symbol @@ -54,6 +54,8 @@ class OrderMode: This is the default mode that pairs with "follow mode" (when wathing the rt price update at the current time step) and allows entering orders using mouse and keyboard. + This object is chart oriented, so there is an instance per + chart / view currently. Current manual: a -> alert @@ -70,6 +72,7 @@ class OrderMode: lines: LineEditor arrows: ArrowEditor status_bar: MultiStatus + name: str = 'order' _colors = { 'alert': 'alert_yellow', @@ -290,6 +293,9 @@ class OrderMode: ) def cancel_all_orders(self) -> list[str]: + '''Cancel all orders for the current chart. + + ''' return self.cancel_orders_from_lines( self.lines.all_lines() ) @@ -355,7 +361,7 @@ async def open_order_mode( ): status_bar: MultiStatus = main_window().status_bar view = chart._vb - lines = LineEditor(chart=chart, _order_lines=_order_lines) + lines = LineEditor(chart=chart) arrows = ArrowEditor(chart, {}) log.info("Opening order mode") @@ -400,7 +406,7 @@ async def start_order_mode( - begin order handling loop ''' - done = chart.window().status_bar.open_status('Starting order mode...') + done = chart.window().status_bar.open_status('starting order mode..') # spawn EMS actor-service async with ( From b6eeed1ae0b5776e19535a8d49d554c44bec535e Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 21 Jun 2021 16:45:27 -0400 Subject: [PATCH 20/26] Move contents labels management to cursor mod Add a new type/api to manage "contents labels" (labels that sit in a view and display info about viewed data) since it's mostly used by the linked charts cursor. Make `LinkedSplits.cursor` the new and only instance var for the cursor such that charts can look it up from that common class. Drop the `ChartPlotWidget._ohlc` array, just add a `'ohlc'` entry to `._arrays`. --- piker/ui/_chart.py | 181 +++++++--------------------------- piker/ui/_graphics/_cursor.py | 147 +++++++++++++++++++++++---- 2 files changed, 164 insertions(+), 164 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 24362456..680e76fc 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -20,7 +20,7 @@ High level Qt chart widgets. """ import time from contextlib import AsyncExitStack -from typing import Tuple, Dict, Any, Optional, Callable +from typing import Tuple, Dict, Any, Optional from types import ModuleType from functools import partial @@ -261,7 +261,7 @@ class LinkedSplits(QtGui.QWidget): super().__init__() # self.signals_visible: bool = False - self._cursor: Cursor = None # crosshair graphics + self.cursor: Cursor = None # crosshair graphics self.godwidget = godwidget self.chart: ChartPlotWidget = None # main (ohlc) chart @@ -326,7 +326,7 @@ class LinkedSplits(QtGui.QWidget): The data input struct array must include OHLC fields. """ # add crosshairs - self._cursor = Cursor( + self.cursor = Cursor( linkedsplits=self, digits=symbol.digits(), ) @@ -338,7 +338,7 @@ class LinkedSplits(QtGui.QWidget): _is_main=True, ) # add crosshair graphic - self.chart.addItem(self._cursor) + self.chart.addItem(self.cursor) # axis placement if _xaxis_at == 'bottom': @@ -392,7 +392,7 @@ class LinkedSplits(QtGui.QWidget): 'left': PriceAxis(linkedsplits=self, orientation='left'), }, viewBox=cv, - cursor=self._cursor, + # cursor=self.cursor, **cpw_kwargs, ) print(f'xaxis ps: {xaxis.pos()}') @@ -412,7 +412,7 @@ class LinkedSplits(QtGui.QWidget): cpw.setXLink(self.chart) # add to cross-hair's known plots - self._cursor.add_plot(cpw) + self.cursor.add_plot(cpw) # draw curve graphics if style == 'bar': @@ -493,15 +493,19 @@ class ChartPlotWidget(pg.PlotWidget): ) self.name = name self._lc = linkedsplits + self.linked = linkedsplits # scene-local placeholder for book graphics # sizing to avoid overlap with data contents self._max_l1_line_len: float = 0 # self.setViewportMargins(0, 0, 0, 0) - self._ohlc = array # readonly view of ohlc data + # self._ohlc = array # readonly view of ohlc data - self._arrays = {} # readonly view of overlays + # readonly view of data arrays + self._arrays = { + 'ohlc': array, + } self._graphics = {} # registry of underlying graphics self._overlays = set() # registry of overlay curve names @@ -510,9 +514,7 @@ class ChartPlotWidget(pg.PlotWidget): self._vb = self.plotItem.vb self._static_yrange = static_yrange # for "known y-range style" - self._view_mode: str = 'follow' - self._cursor = cursor # placehold for mouse # show only right side axes self.hideAxis('left') @@ -539,25 +541,10 @@ class ChartPlotWidget(pg.PlotWidget): self._vb.setFocus() def last_bar_in_view(self) -> int: - self._ohlc[-1]['index'] + self._arrays['ohlc'][-1]['index'] - def update_contents_labels( - self, - index: int, - # array_name: str, - ) -> None: - if index >= 0 and index < self._ohlc[-1]['index']: - for name, (label, update) in self._labels.items(): - - if name is self.name: - array = self._ohlc - else: - array = self._arrays[name] - - try: - update(index, array) - except IndexError: - log.exception(f"Failed to update label: {name}") + def is_valid_index(self, index: int) -> bool: + return index >= 0 and index < self._arrays['ohlc'][-1]['index'] def _set_xlimits( self, @@ -581,11 +568,11 @@ class ChartPlotWidget(pg.PlotWidget): """Return a range tuple for the bars present in view. """ l, r = self.view_range() - a = self._ohlc + a = self._arrays['ohlc'] lbar = max(l, a[0]['index']) rbar = min(r, a[-1]['index']) # lbar = max(l, 0) - # rbar = min(r, len(self._ohlc)) + # rbar = min(r, len(self._arrays['ohlc'])) return l, lbar, rbar, r def default_view( @@ -595,7 +582,7 @@ class ChartPlotWidget(pg.PlotWidget): """Set the view box to the "default" startup view of the scene. """ - xlast = self._ohlc[index]['index'] + xlast = self._arrays['ohlc'][index]['index'] begin = xlast - _bars_to_left_in_follow_mode end = xlast + _bars_from_right_in_follow_mode @@ -650,12 +637,12 @@ class ChartPlotWidget(pg.PlotWidget): self._graphics[name] = graphics - self.add_contents_label( - name, + self.linked.cursor.contents_labels.add_label( + self, + 'ohlc', anchor_at=('top', 'left'), update_func=ContentsLabel.update_from_ohlc, ) - self.update_contents_labels(len(data) - 1) self._add_sticky(name) @@ -727,32 +714,18 @@ class ChartPlotWidget(pg.PlotWidget): # (we need something that avoids clutter on x-axis). self._add_sticky(name, bg_color='default_light') - if add_label: - self.add_contents_label(name, anchor_at=anchor_at) - self.update_contents_labels(len(data) - 1) + if self.linked.cursor: + self.linked.cursor.add_curve_cursor(self, curve) - if self._cursor: - self._cursor.add_curve_cursor(self, curve) + if add_label: + self.linked.cursor.contents_labels.add_label( + self, + name, + anchor_at=anchor_at + ) return curve - def add_contents_label( - self, - name: str, - anchor_at: Tuple[str, str] = ('top', 'left'), - update_func: Callable = ContentsLabel.update_from_value, - ) -> ContentsLabel: - - label = ContentsLabel(chart=self, anchor_at=anchor_at) - self._labels[name] = ( - # calls class method on instance - label, - partial(update_func, label, name) - ) - label.show() - - return label - def _add_sticky( self, name: str, @@ -787,7 +760,7 @@ class ChartPlotWidget(pg.PlotWidget): """Update the named internal graphics from ``array``. """ - self._ohlc = array + self._arrays['ohlc'] = array graphics = self._graphics[name] graphics.update_from_array(array, **kwargs) return graphics @@ -803,7 +776,7 @@ class ChartPlotWidget(pg.PlotWidget): """ if name not in self._overlays: - self._ohlc = array + self._arrays['ohlc'] = array else: self._arrays[name] = array @@ -857,10 +830,10 @@ class ChartPlotWidget(pg.PlotWidget): # TODO: logic to check if end of bars in view # extra = view_len - _min_points_to_show - # begin = self._ohlc[0]['index'] - extra + # begin = self._arrays['ohlc'][0]['index'] - extra - # # end = len(self._ohlc) - 1 + extra - # end = self._ohlc[-1]['index'] - 1 + 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 @@ -879,9 +852,9 @@ class ChartPlotWidget(pg.PlotWidget): # self._set_xlimits(begin, end) # TODO: this should be some kind of numpy view api - # bars = self._ohlc[lbar:rbar] + # bars = self._arrays['ohlc'][lbar:rbar] - a = self._ohlc + a = self._arrays['ohlc'] ifirst = a[0]['index'] bars = a[lbar - ifirst:rbar - ifirst + 1] @@ -952,84 +925,6 @@ class ChartPlotWidget(pg.PlotWidget): self.scene().leaveEvent(ev) -async def test_bed( - ohlcv, - chart, - lc, -): - from ._graphics._lines import order_line - - sleep = 6 - - # from PyQt5.QtCore import QPointF - vb = chart._vb - # scene = vb.scene() - - # raxis = chart.getAxis('right') - # vb_right = vb.boundingRect().right() - - last, i_end = ohlcv.array[-1][['close', 'index']] - - line = order_line( - chart, - level=last, - level_digits=2 - ) - # eps = line.getEndpoints() - - # llabel = line._labels[1][1] - - line.update_labels({'level': last}) - return - - # rl = eps[1] - # rlabel.setPos(rl) - - # ti = pg.TextItem(text='Fuck you') - # ti.setPos(pg.Point(i_end, last)) - # ti.setParentItem(line) - # ti.setAnchor(pg.Point(1, 1)) - # vb.addItem(ti) - # chart.plotItem.addItem(ti) - - from ._label import Label - - txt = Label( - vb, - fmt_str='fuck {it}', - ) - txt.format(it='boy') - txt.place_on_scene('left') - txt.set_view_y(last) - - # txt = QtGui.QGraphicsTextItem() - # txt.setPlainText("FUCK YOU") - # txt.setFont(_font.font) - # txt.setDefaultTextColor(pg.mkColor(hcolor('bracket'))) - # # txt.setParentItem(vb) - # w = txt.boundingRect().width() - # scene.addItem(txt) - - # txt.setParentItem(line) - # d_coords = vb.mapFromView(QPointF(i_end, last)) - # txt.setPos(vb_right - w, d_coords.y()) - # txt.show() - # txt.update() - - # rlabel.setPos(vb_right - 2*w, d_coords.y()) - # rlabel.show() - - i = 0 - while True: - await trio.sleep(sleep) - await tractor.breakpoint() - txt.format(it=f'dog_{i}') - # d_coords = vb.mapFromView(QPointF(i_end, last)) - # txt.setPos(vb_right - w, d_coords.y()) - # txt.setPlainText(f"FUCK YOU {i}") - i += 1 - - _clear_throttle_rate: int = 60 # Hz _book_throttle_rate: int = 16 # Hz @@ -1065,7 +960,7 @@ async def chart_from_quotes( # https://arxiv.org/abs/cs/0610046 # https://github.com/lemire/pythonmaxmin - array = chart._ohlc + array = chart._arrays['ohlc'] ifirst = array[0]['index'] last_bars_range = chart.bars_range() @@ -1385,7 +1280,7 @@ async def run_fsp( ) # display contents labels asap - chart.update_contents_labels( + chart.linked.cursor.contents_labels.update_labels( len(shm.array) - 1, # fsp_func_name ) diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index 0f250e7a..49cf23d3 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_graphics/_cursor.py @@ -18,8 +18,8 @@ Mouse interaction graphics """ -import math -from typing import Optional, Tuple, Set, Dict +from functools import partial +from typing import Optional, Callable import inspect import numpy as np @@ -30,7 +30,6 @@ from PyQt5.QtCore import QPointF, QRectF from .._style import ( _xaxis_at, hcolor, - _font, _font_small, ) from .._axes import YAxisLabel, XAxisLabel @@ -98,7 +97,7 @@ class LineDot(pg.CurvePoint): (x, y) = self.curve().getData() index = self.property('index') - # first = self._plot._ohlc[0]['index'] + # first = self._plot._arrays['ohlc'][0]['index'] # first = x[0] # i = index - first i = index - x[0] @@ -133,11 +132,15 @@ class ContentsLabel(pg.LabelItem): } def __init__( + self, - chart: 'ChartPlotWidget', # noqa + # chart: 'ChartPlotWidget', # noqa + view: pg.ViewBox, + anchor_at: str = ('top', 'right'), justify_text: str = 'left', font_size: Optional[int] = None, + ) -> None: font_size = font_size or _font_small.px_size @@ -148,9 +151,10 @@ class ContentsLabel(pg.LabelItem): ) # anchor to viewbox - self.setParentItem(chart._vb) - chart.scene().addItem(self) - self.chart = chart + self.setParentItem(view) + + self.vb = view + view.scene().addItem(self) v, h = anchor_at index = (self._corner_anchors[h], self._corner_anchors[v]) @@ -163,10 +167,12 @@ class ContentsLabel(pg.LabelItem): self.anchor(itemPos=index, parentPos=index, offset=margins) def update_from_ohlc( + self, name: str, index: int, array: np.ndarray, + ) -> None: # this being "html" is the dumbest shit :eyeroll: first = array[0]['index'] @@ -188,25 +194,111 @@ class ContentsLabel(pg.LabelItem): ) def update_from_value( + self, name: str, index: int, array: np.ndarray, + ) -> None: + first = array[0]['index'] if index < array[-1]['index'] and index > first: data = array[index - first][name] self.setText(f"{name}: {data:.2f}") +class ContentsLabels: + '''Collection of labels that span a ``LinkedSplits`` set of chart plots + and can be updated from the underlying data from an x-index value sent + as input from a cursor or other query mechanism. + + ''' + def __init__( + self, + linkedsplits: 'LinkedSplits', # type: ignore # noqa + + ) -> None: + + self.linkedsplits = linkedsplits + self._labels: list[( + 'CharPlotWidget', # type: ignore # noqa + str, + ContentsLabel, + Callable + )] = [] + + def update_labels( + self, + index: int, + # array_name: str, + + ) -> None: + # for name, (label, update) in self._labels.items(): + for chart, name, label, update in self._labels: + + if not (index >= 0 and index < chart._arrays['ohlc'][-1]['index']): + # out of range + continue + + array = chart._arrays[name] + + # call provided update func with data point + try: + label.show() + update(index, array) + + except IndexError: + log.exception(f"Failed to update label: {name}") + + def hide(self) -> None: + for chart, name, label, update in self._labels: + label.hide() + + def add_label( + + self, + chart: 'ChartPlotWidget', # type: ignore # noqa + name: str, + anchor_at: tuple[str, str] = ('top', 'left'), + update_func: Callable = ContentsLabel.update_from_value, + + ) -> ContentsLabel: + + label = ContentsLabel( + view=chart._vb, + anchor_at=anchor_at, + ) + self._labels.append( + (chart, name, label, partial(update_func, label, name)) + ) + # label.hide() + + return label + + class Cursor(pg.GraphicsObject): def __init__( + self, linkedsplits: 'LinkedSplits', # noqa digits: int = 0 + ) -> None: + super().__init__() + + self.linked = linkedsplits + self.graphics: dict[str, pg.GraphicsObject] = {} + self.plots: List['PlotChartWidget'] = [] # type: ignore # noqa + self.active_plot = None + self.digits: int = digits + self._datum_xy: tuple[int, float] = (0, 0) + + self._hovered: set[pg.GraphicsObject] = set() + self._trackers: set[pg.GraphicsObject] = set() + # XXX: not sure why these are instance variables? # It's not like we can change them on the fly..? self.pen = pg.mkPen( @@ -217,19 +309,10 @@ class Cursor(pg.GraphicsObject): color=hcolor('davies'), style=QtCore.Qt.DashLine, ) - self.lsc = linkedsplits - self.graphics: Dict[str, pg.GraphicsObject] = {} - self.plots: List['PlotChartWidget'] = [] # type: ignore # noqa - self.active_plot = None - self.digits: int = digits - self._datum_xy: Tuple[int, float] = (0, 0) - - self._hovered: Set[pg.GraphicsObject] = set() - self._trackers: Set[pg.GraphicsObject] = set() # value used for rounding y-axis discreet tick steps # computing once, up front, here cuz why not - self._y_incr_mult = 1 / self.lsc._symbol.tick_size + self._y_incr_mult = 1 / self.linked._symbol.tick_size # line width in view coordinates self._lw = self.pixelWidth() * self.lines_pen.width() @@ -239,6 +322,22 @@ class Cursor(pg.GraphicsObject): self._y_label_update: bool = True + self.contents_labels = ContentsLabels(self.linked) + self._in_query_mode: bool = False + + @property + def in_query_mode(self) -> bool: + return self._in_query_mode + + @in_query_mode.setter + def in_query_mode(self, value: bool) -> None: + if self._in_query_mode and not value: + + # edge trigger hide all labels + self.contents_labels.hide() + + self._in_query_mode = value + def add_hovered( self, item: pg.GraphicsObject, @@ -320,7 +419,7 @@ class Cursor(pg.GraphicsObject): # the current sample under the mouse cursor = LineDot( curve, - index=plot._ohlc[-1]['index'], + index=plot._arrays['ohlc'][-1]['index'], plot=plot ) plot.addItem(cursor) @@ -344,7 +443,7 @@ class Cursor(pg.GraphicsObject): def mouseMoved( self, - evt: 'Tuple[QMouseEvent]', # noqa + evt: 'tuple[QMouseEvent]', # noqa ) -> None: # noqa """Update horizonal and vertical lines when mouse moves inside either the main chart or any indicator subplot. @@ -392,10 +491,16 @@ class Cursor(pg.GraphicsObject): item.on_tracked_source(ix, iy) if ix != last_ix: + + if self.in_query_mode: + # show contents labels on all linked charts and update + # with cursor movement + self.contents_labels.update_labels(ix) + for plot, opts in self.graphics.items(): # update the chart's "contents" label - plot.update_contents_labels(ix) + # plot.update_contents_labels(ix) # move the vertical line to the current "center of bar" opts['vl'].setX(ix + line_offset) From 8b966cd7e407dcd317c144ba6719e095f5cf0ede Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 22 Jun 2021 07:17:49 -0400 Subject: [PATCH 21/26] Port all to use new cursor and ohlc refs --- piker/ui/_axes.py | 13 +++++------ piker/ui/_editors.py | 19 ++++++++-------- piker/ui/_graphics/_lines.py | 44 ++++++++++++++++++++++++++++-------- piker/ui/_interaction.py | 11 +++++++-- piker/ui/order_mode.py | 9 ++++---- 5 files changed, 65 insertions(+), 31 deletions(-) diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index 3e66ada3..5757b6b1 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.py @@ -133,7 +133,7 @@ class DynamicDateAxis(Axis): # try: chart = self.linkedsplits.chart - bars = chart._ohlc + bars = chart._arrays['ohlc'] shm = self.linkedsplits.chart._shm first = shm._first.value @@ -232,7 +232,6 @@ class AxisLabel(pg.GraphicsObject): p.setPen(self.fg_color) p.drawText(self.rect, self.text_flags, self.label_str) - def draw( self, p: QtGui.QPainter, @@ -250,9 +249,9 @@ class AxisLabel(pg.GraphicsObject): # reason; ok by us p.setOpacity(self.opacity) - # this cause the L1 labels to glitch out if used - # in the subtype and it will leave a small black strip - # with the arrow path if done before the above + # this cause the L1 labels to glitch out if used in the subtype + # and it will leave a small black strip with the arrow path if + # done before the above p.fillRect(self.rect, self.bg_color) @@ -295,8 +294,8 @@ class AxisLabel(pg.GraphicsObject): self.rect = QtCore.QRectF( 0, 0, - (w or txt_w) + self._x_margin /2, - (h or txt_h) + self._y_margin /2, + (w or txt_w) + self._x_margin / 2, + (h or txt_h) + self._y_margin / 2, ) # print(self.rect) # hb = self.path.controlPointRect() diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index 17b39b5d..00ef362d 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -111,12 +111,12 @@ class LineEditor: """ # chart.setCursor(QtCore.Qt.PointingHandCursor) - if not self.chart._cursor: + cursor = self.chart.linked.cursor + if not cursor: return None - chart = self.chart._cursor.active_plot - cursor = chart._cursor - y = chart._cursor._datum_xy[1] + chart = cursor.active_plot + y = cursor._datum_xy[1] symbol = chart._lc.symbol @@ -168,7 +168,7 @@ class LineEditor: """ # chart = self.chart._cursor.active_plot # # chart.setCursor(QtCore.Qt.ArrowCursor) - cursor = self.chart._cursor + cursor = self.chart.linked.cursor # delete "staged" cursor tracking line from view line = self._active_staged_line @@ -251,7 +251,7 @@ class LineEditor: """ # Delete any hoverable under the cursor - return self.chart._cursor._hovered + return self.chart.linked.cursor._hovered def all_lines(self) -> tuple[LevelLine]: return tuple(self._order_lines.values()) @@ -275,13 +275,14 @@ class LineEditor: if line: # if hovered remove from cursor set - hovered = self.chart._cursor._hovered + cursor = self.chart.linked.cursor + hovered = cursor._hovered if line in hovered: hovered.remove(line) # make sure the xhair doesn't get left off # just because we never got a un-hover event - self.chart._cursor.show_xhair() + cursor.show_xhair() line.delete() return line @@ -411,7 +412,7 @@ class SelectRect(QtGui.QGraphicsRectItem): ixmn, ixmx = round(xmn), round(xmx) nbars = ixmx - ixmn + 1 - data = self._chart._ohlc[ixmn:ixmx] + data = self._chart._arrays['ohlc'][ixmn:ixmx] if len(data): std = data['close'].std() diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index d2c5cf87..2e3324f8 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -259,10 +259,10 @@ class LevelLine(pg.InfiniteLine): detailed control and start end signalling. """ - chart = self._chart + cursor = self._chart.linked.cursor # hide y-crosshair - chart._cursor.hide_xhair() + cursor.hide_xhair() # highlight self.currentPen = self.hoverPen @@ -308,7 +308,7 @@ class LevelLine(pg.InfiniteLine): # This is the final position in the drag if ev.isFinish(): # show y-crosshair again - chart._cursor.show_xhair() + cursor.show_xhair() def delete(self) -> None: """Remove this line from containing chart/view/scene. @@ -326,7 +326,7 @@ class LevelLine(pg.InfiniteLine): # remove from chart/cursor states chart = self._chart - cur = chart._cursor + cur = chart.linked.cursor if self in cur._hovered: cur._hovered.remove(self) @@ -457,8 +457,7 @@ class LevelLine(pg.InfiniteLine): """Mouse hover callback. """ - chart = self._chart - cur = chart._cursor + cur = self._chart.linked.cursor # hovered if (not ev.isExit()) and ev.acceptDrags(QtCore.Qt.LeftButton): @@ -648,7 +647,10 @@ def order_line( # use ``QPathGraphicsItem``s to draw markers in scene coords # instead of the old way that was doing the same but by # resetting the graphics item transform intermittently + + # XXX: this is our new approach but seems slower? # line.add_marker(mk_marker(marker_style, marker_size)) + assert not line.markers # the old way which is still somehow faster? @@ -659,7 +661,10 @@ def order_line( marker_size, use_qgpath=False, ) - # manually append for later ``.pain()`` drawing + # manually append for later ``InfiniteLine.paint()`` drawing + # XXX: this was manually tested as faster then using the + # QGraphicsItem around a painter path.. probably needs further + # testing to figure out why tf that's true. line.markers.append((path, 0, marker_size)) orient_v = 'top' if action == 'sell' else 'bottom' @@ -754,9 +759,29 @@ def position_line( ymn, ymx = vr[1] level = line.value() - if level > ymx or level < ymn: - line._marker.hide() + if gt := level > ymx or (lt := level < ymn): + + if chartview.mode.name == 'order': + + # provide "nav hub" like indicator for where + # the position is on the y-dimension + if gt: + # pin to top of view since position is above current + # y-range + pass + + elif lt: + # pin to bottom of view since position is above + # below y-range + pass + + else: + # order mode is not active + # so hide the pp market + line._marker.hide() + else: + # pp line is viewable so show marker line._marker.show() vb.sigYRangeChanged.connect(update_pp_nav) @@ -787,6 +812,7 @@ def position_line( style = '>|' arrow_path = mk_marker(style, size=arrow_size) + # XXX: uses new marker drawing approach line.add_marker(arrow_path) line.set_level(level) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index eec8cc38..18cada9f 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -46,7 +46,6 @@ async def handle_viewmode_inputs( ) -> None: mode = view.mode - status_bar = main_window().status_bar # track edge triggered keys # (https://en.wikipedia.org/wiki/Interrupt#Triggering_methods) @@ -148,6 +147,14 @@ async def handle_viewmode_inputs( if key in pressed: pressed.remove(key) + # QUERY MODE # + if {Qt.Key_Q}.intersection(pressed): + + view.linkedsplits.cursor.in_query_mode = True + + else: + view.linkedsplits.cursor.in_query_mode = False + # SELECTION MODE # if shift: @@ -297,7 +304,7 @@ class ChartView(ViewBox): log.debug("Max zoom bruh...") return - if ev.delta() < 0 and vl >= len(chart._ohlc) + 666: + if ev.delta() < 0 and vl >= len(chart._arrays['ohlc']) + 666: log.debug("Min zoom bruh...") return diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index a4e55862..d57590cf 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -124,7 +124,7 @@ class OrderMode: """ # not initialized yet - if not self.chart._cursor: + if not self.chart.linked.cursor: return self._action = action @@ -210,7 +210,7 @@ class OrderMode: if msg is not None: self.lines.remove_line(uuid=uuid) - self.chart._cursor.show_xhair() + self.chart.linked.cursor.show_xhair() pending = self._pending_submissions.pop(uuid, None) if pending: @@ -238,8 +238,9 @@ class OrderMode: size = size or self._size - chart = self.chart._cursor.active_plot - y = chart._cursor._datum_xy[1] + cursor = self.chart.linked.cursor + chart = cursor.active_plot + y = cursor._datum_xy[1] symbol = self.chart._lc._symbol From 830ef907210b92d226c406dc9e8cf690c06598a8 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 22 Jun 2021 08:18:18 -0400 Subject: [PATCH 22/26] Enable contents labels on q for "query" --- piker/ui/_graphics/_cursor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index 49cf23d3..bb5d1a40 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_graphics/_cursor.py @@ -333,9 +333,13 @@ class Cursor(pg.GraphicsObject): def in_query_mode(self, value: bool) -> None: if self._in_query_mode and not value: - # edge trigger hide all labels + # edge trigger "off" hide all labels self.contents_labels.hide() + elif not self._in_query_mode and value: + # edge trigger "on" hide all labels + self.contents_labels.update_labels(self._datum_xy[0]) + self._in_query_mode = value def add_hovered( From 48053588da1b775f31b1770a42c2ee53a8bae08f Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 22 Jun 2021 10:58:52 -0400 Subject: [PATCH 23/26] Don't cancel handler nursery, let errors bubble --- piker/ui/_event.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/piker/ui/_event.py b/piker/ui/_event.py index bbb60896..282711ab 100644 --- a/piker/ui/_event.py +++ b/piker/ui/_event.py @@ -117,10 +117,9 @@ async def open_event_stream( source_widget.installEventFilter(kc) try: - yield recv - + async with (send, recv): + yield recv finally: - await send.aclose() source_widget.removeEventFilter(kc) @@ -140,4 +139,3 @@ async def open_handler( ): n.start_soon(async_handler, source_widget, event_recv_stream) yield - n.cancel_scope.cancel() From 26c333ff22fcc0b140a296fc823a07e8ffb3951b Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 23 Jun 2021 10:04:56 -0400 Subject: [PATCH 24/26] Only close event send side (facepalm) --- piker/ui/_event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piker/ui/_event.py b/piker/ui/_event.py index 282711ab..18dbd64f 100644 --- a/piker/ui/_event.py +++ b/piker/ui/_event.py @@ -117,7 +117,7 @@ async def open_event_stream( source_widget.installEventFilter(kc) try: - async with (send, recv): + async with send: yield recv finally: source_widget.removeEventFilter(kc) From 90588018a63b470e72c32ec8783fd6ce3277891f Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 23 Jun 2021 10:06:27 -0400 Subject: [PATCH 25/26] Label doc tweak --- piker/ui/_label.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/piker/ui/_label.py b/piker/ui/_label.py index 8f776279..bf05c2ac 100644 --- a/piker/ui/_label.py +++ b/piker/ui/_label.py @@ -89,11 +89,16 @@ def right_axis( class Label: """ + A plain ol' "scene label" using an underlying ``QGraphicsTextItem``. + After hacking for many days on multiple "label" systems inside ``pyqtgraph`` yet again we're left writing our own since it seems - all of those are over complicated, ad-hoc, pieces of garbage that - can't accomplish the simplest things, such as pinning to the left - hand side of a view box. + all of those are over complicated, ad-hoc, transform-mangling, + messes which can't accomplish the simplest things via their inputs + (such as pinning to the left hand side of a view box). + + Here we do the simple thing where the label uses callables to figure + out the (x, y) coordinate "pin point": nice and simple. This type is another effort (see our graphics) to start making small, re-usable label components that can actually be used to build @@ -104,6 +109,7 @@ class Label: self, view: pg.ViewBox, + fmt_str: str, color: str = 'bracket', x_offset: float = 0, From 83ad071cb4a0744d14d07849d0cdc94ea02335b6 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 5 Jul 2021 09:53:19 -0400 Subject: [PATCH 26/26] Load provider search engines in tasks instead of exit stack --- piker/ui/_chart.py | 112 ++++++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 63 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 680e76fc..63cdec70 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -19,7 +19,6 @@ High level Qt chart widgets. """ import time -from contextlib import AsyncExitStack from typing import Tuple, Dict, Any, Optional from types import ModuleType from functools import partial @@ -844,7 +843,7 @@ class ChartPlotWidget(pg.PlotWidget): # istart=max(lbar, l), iend=min(rbar, r), just_history=True) # bars_len = rbar - lbar - # log.trace( + # 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}" @@ -1474,7 +1473,6 @@ async def display_symbol_data( ) as feed, - trio.open_nursery() as n, ): ohlcv: ShmArray = feed.shm @@ -1542,77 +1540,66 @@ async def display_symbol_data( }, }) - # load initial fsp chain (otherwise known as "indicators") - n.start_soon( - spawn_fsps, - linkedsplits, - fsp_conf, - sym, - ohlcv, - brokermod, - loading_sym_key, - loglevel, - ) + async with trio.open_nursery() as n: + # load initial fsp chain (otherwise known as "indicators") + n.start_soon( + spawn_fsps, + linkedsplits, + fsp_conf, + sym, + ohlcv, + brokermod, + loading_sym_key, + loglevel, + ) - # start graphics update loop(s)after receiving first live quote - n.start_soon( - chart_from_quotes, - chart, - feed.stream, - ohlcv, - wap_in_history, - ) + # start graphics update loop(s)after receiving first live quote + n.start_soon( + chart_from_quotes, + chart, + feed.stream, + ohlcv, + wap_in_history, + ) - # TODO: instead we should start based on instrument trading hours? - # wait for a first quote before we start any update tasks - # quote = await feed.receive() - # log.info(f'Received first quote {quote}') + # TODO: instead we should start based on instrument trading hours? + # wait for a first quote before we start any update tasks + # quote = await feed.receive() + # log.info(f'Received first quote {quote}') - n.start_soon( - check_for_new_bars, - feed, - ohlcv, - linkedsplits - ) + n.start_soon( + check_for_new_bars, + feed, + ohlcv, + linkedsplits + ) - await start_order_mode(chart, symbol, provider, order_mode_started) + await start_order_mode(chart, symbol, provider, order_mode_started) -async def load_providers( +async def load_provider_search( - brokernames: list[str], + broker: str, loglevel: str, ) -> None: - # TODO: seems like our incentive for brokerd caching lelel - backends = {} + log.info(f'loading brokerd for {broker}..') - async with AsyncExitStack() as stack: - # TODO: spawn these async in nursery. - # load all requested brokerd's at startup and load their - # search engines. - for broker in brokernames: + async with ( - log.info(f'loading brokerd for {broker}..') - # spin up broker daemons for each provider - portal = await stack.enter_async_context( - maybe_spawn_brokerd( - broker, - loglevel=loglevel - ) - ) + maybe_spawn_brokerd( + broker, + loglevel=loglevel + ) as portal, - backends[broker] = portal + feed.install_brokerd_search( + portal, + get_brokermod(broker), + ), + ): - await stack.enter_async_context( - feed.install_brokerd_search( - portal, - get_brokermod(broker), - ) - ) - - # keep search engines up until cancelled + # keep search engine stream up until cancelled await trio.sleep_forever() @@ -1653,9 +1640,7 @@ async def _async_main( sbar = godwidget.window.status_bar starting_done = sbar.open_status('starting ze sexy chartz') - async with ( - trio.open_nursery() as root_n, - ): + async with trio.open_nursery() as root_n: # set root nursery and task stack for spawning other charts/feeds # that run cached in the bg @@ -1694,7 +1679,8 @@ async def _async_main( ): # load other providers into search **after** # the chart's select cache - root_n.start_soon(load_providers, brokernames, loglevel) + for broker in brokernames: + root_n.start_soon(load_provider_search, broker, loglevel) await order_mode_ready.wait()