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/_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/_axes.py b/piker/ui/_axes.py index d7f69742..5757b6b1 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 - bars = chart._ohlc - shm = self.linked_charts.chart._shm + chart = self.linkedsplits.chart + bars = chart._arrays['ohlc'] + shm = self.linkedsplits.chart._shm first = shm._first.value bars_len = len(bars) @@ -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/_chart.py b/piker/ui/_chart.py index 082dcb44..63cdec70 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -19,13 +19,13 @@ 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 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 @@ -44,10 +44,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 @@ -60,7 +56,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 +73,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 +102,53 @@ 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 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): @@ -148,64 +156,73 @@ class ChartSpace(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() # 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) + + order_mode_started = trio.Event() 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, + order_mode_started, ) - self.set_chart_symbol(fqsn, linkedcharts) + self.set_chart_symbol(fqsn, linkedsplits) - self.vbox.addWidget(linkedcharts) + 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 - 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( @@ -213,15 +230,19 @@ class ChartSpace(QtGui.QWidget): f'tick:{symbol.tick_size}' ) + return order_mode_started -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 +253,24 @@ class LinkedSplitCharts(QtGui.QWidget): def __init__( self, - chart_space: ChartSpace, + godwidget: GodWidget, ) -> None: + super().__init__() - self.signals_visible: bool = False - self._cursor: Cursor = None # crosshair graphics + + # 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) @@ -301,8 +325,8 @@ class LinkedSplitCharts(QtGui.QWidget): The data input struct array must include OHLC fields. """ # add crosshairs - self._cursor = Cursor( - linkedsplitcharts=self, + self.cursor = Cursor( + linkedsplits=self, digits=symbol.digits(), ) self.chart = self.add_plot( @@ -313,7 +337,7 @@ class LinkedSplitCharts(QtGui.QWidget): _is_main=True, ) # add crosshair graphic - self.chart.addItem(self._cursor) + self.chart.addItem(self.cursor) # axis placement if _xaxis_at == 'bottom': @@ -342,14 +366,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,14 +384,14 @@ 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, + # cursor=self.cursor, **cpw_kwargs, ) print(f'xaxis ps: {xaxis.pos()}') @@ -377,7 +401,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 @@ -387,7 +411,7 @@ class LinkedSplitCharts(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': @@ -415,7 +439,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 +450,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 +467,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,16 +491,20 @@ class ChartPlotWidget(pg.PlotWidget): **kwargs ) self.name = name - self._lc = linked_charts + 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 @@ -483,9 +513,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') @@ -512,25 +540,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, @@ -554,11 +567,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( @@ -568,7 +581,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 @@ -623,12 +636,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) @@ -700,32 +713,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, @@ -760,7 +759,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 @@ -776,7 +775,7 @@ class ChartPlotWidget(pg.PlotWidget): """ if name not in self._overlays: - self._ohlc = array + self._arrays['ohlc'] = array else: self._arrays[name] = array @@ -830,10 +829,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 @@ -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}" @@ -852,9 +851,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] @@ -925,91 +924,17 @@ class ChartPlotWidget(pg.PlotWidget): self.scene().leaveEvent(ev) -async def test_bed( - ohlcv, - chart, - lc, -): - 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 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. @@ -1034,7 +959,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() @@ -1211,12 +1136,15 @@ async def chart_from_quotes( async def spawn_fsps( - linked_charts: LinkedSplitCharts, + + 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. @@ -1224,7 +1152,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,13 +1199,14 @@ async def spawn_fsps( ln.start_soon( run_fsp, portal, - linked_charts, + linkedsplits, brokermod, sym, src_shm, fsp_func_name, display_name, conf, + group_status_key, ) # blocks here until all fsp actors complete @@ -1286,13 +1215,14 @@ async def spawn_fsps( async def run_fsp( portal: tractor._portal.Portal, - linked_charts: LinkedSplitCharts, + linkedsplits: LinkedSplits, brokermod: ModuleType, sym: str, src_shm: ShmArray, fsp_func_name: str, display_name: str, conf: Dict[str, Any], + group_status_key: str, ) -> None: """FSP stream chart update loop. @@ -1300,8 +1230,10 @@ async def run_fsp( This is called once for each entry in the fsp config map. """ - done = linked_charts.window().status_bar.open_status( - f'loading FSP: {display_name}..') + done = linkedsplits.window().status_bar.open_status( + f'loading {display_name}..', + group_key=group_status_key, + ) async with portal.open_stream_from( @@ -1324,7 +1256,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 +1266,7 @@ async def run_fsp( else: - chart = linked_charts.add_plot( + chart = linkedsplits.add_plot( name=fsp_func_name, array=shm.array, @@ -1347,7 +1279,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 ) @@ -1358,7 +1290,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] @@ -1385,6 +1317,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) @@ -1441,7 +1374,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 +1384,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 +1422,73 @@ 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, + + order_mode_started: trio.Event, + ) -> 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 - loading_sym_done = sbar.open_status(f'loading {sym}.{provider}..') + ''' + sbar = godwidget.window.status_bar + loading_sym_key = sbar.open_status( + f'loading {sym}.{provider} ->', + group_key=True + ) # historical data fetch brokermod = brokers.get_brokermod(provider) - async with data.open_feed( + # ohlc_status_done = sbar.open_status( + # 'retreiving OHLC history.. ', + # clear_on_next=True, + # group_key=loading_sym_key, + # ) - provider, - [sym], - loglevel=loglevel, + async with( - # 60 FPS to limit context switches - tick_throttle=_clear_throttle_rate, + data.open_feed( + provider, + [sym], + loglevel=loglevel, - ) as feed: + # 60 FPS to limit context switches + tick_throttle=_clear_throttle_rate, + + ) as feed, + + ): 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 @@ -1553,8 +1504,6 @@ async def chart_symbol( add_label=False, ) - loading_sym_done() - # size view to data once at outset chart._set_yrange() @@ -1592,15 +1541,15 @@ 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, + linkedsplits, fsp_conf, sym, ohlcv, brokermod, + loading_sym_key, loglevel, ) @@ -1613,6 +1562,7 @@ async def chart_symbol( 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}') @@ -1620,61 +1570,43 @@ async def chart_symbol( n.start_soon( check_for_new_bars, feed, - # delay, ohlcv, - linked_charts + linkedsplits ) - # interactive testing - # n.start_soon( - # test_bed, - # ohlcv, - # chart, - # linked_charts, - # ) - - await start_order_mode(chart, symbol, provider) + await start_order_mode(chart, symbol, provider, order_mode_started) -async def load_providers( - brokernames: list[str], +async def load_provider_search( + + 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() async def _async_main( + # implicit required argument provided by ``qtractor_run()`` - main_widget: ChartSpace, + main_widget: GodWidget, sym: str, brokernames: str, @@ -1688,13 +1620,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 +1635,35 @@ 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 - starting_done = sbar.open_status('starting ze chartz...') + sbar = godwidget.window.status_bar + starting_done = sbar.open_status('starting ze sexy chartz') async with trio.open_nursery() as root_n: - # 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 - # 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 + 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( @@ -1740,7 +1671,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, @@ -1748,21 +1679,23 @@ 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() # 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 +1713,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/_editors.py b/piker/ui/_editors.py new file mode 100644 index 00000000..00ef362d --- /dev/null +++ b/piker/ui/_editors.py @@ -0,0 +1,451 @@ +# 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 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 +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) + + +@dataclass +class LineEditor: + '''The great editor of linez. + + ''' + chart: 'ChartPlotWidget' = None # type: ignore # noqa + _order_lines: dict[str, LevelLine] = field(default_factory=dict) + _active_staged_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) + cursor = self.chart.linked.cursor + if not cursor: + return None + + chart = cursor.active_plot + y = cursor._datum_xy[1] + + symbol = chart._lc.symbol + + # 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.linked.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) -> list[LevelLine]: + """Get the line(s) under the cursor position. + + """ + # Delete any hoverable under the cursor + return self.chart.linked.cursor._hovered + + def all_lines(self) -> tuple[LevelLine]: + return tuple(self._order_lines.values()) + + 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 + 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 + cursor.show_xhair() + + 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._arrays['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/_event.py b/piker/ui/_event.py index c3d919dc..18dbd64f 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,41 +65,77 @@ 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) try: - yield recv - + async with send: + yield recv 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 diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index a32a3870..bb5d1a40 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, - linkedsplitcharts: 'LinkedSplitCharts', # noqa + 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 = linkedsplitcharts - 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,26 @@ 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 "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( self, item: pg.GraphicsObject, @@ -320,7 +423,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 +447,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 +495,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) 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 ac87e799..18cada9f 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -18,447 +18,209 @@ Chart view box primitives """ -from dataclasses import dataclass, field -from typing import Optional, Dict +from contextlib import asynccontextmanager +import time +from typing import Optional, Callable import pyqtgraph as pg -from PyQt5.QtCore import QPointF -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 ._graphics._lines import order_line, LevelLine +from ._style import _min_points_to_show +from ._editors import SelectRect +from ._window import main_window log = get_logger(__name__) -class SelectRect(QtGui.QGraphicsRectItem): +async def handle_viewmode_inputs( - def __init__( - self, - viewbox: ViewBox, - color: str = 'dad_blue', - ) -> None: - super().__init__(0, 0, 1, 1) + view: 'ChartView', + recv_chan: trio.abc.ReceiveChannel, - # self.rbScaleBox = QtGui.QGraphicsRectItem(0, 0, 1, 1) - self.vb = viewbox - self._chart: 'ChartPlotWidget' = None # noqa +) -> None: - # 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 + mode = view.mode - label = self._label = QtGui.QLabel() - label.setTextFormat(0) # markdown - label.setFont(_font.font) - label.setMargin(0) - label.setAlignment( - QtCore.Qt.AlignLeft - # | QtCore.Qt.AlignVCenter - ) + # track edge triggered keys + # (https://en.wikipedia.org/wiki/Interrupt#Triggering_methods) + pressed: set[str] = set() - # proxy is created after containing scene is initialized - self._label_proxy = None - self._abs_top_right = None + last = time.time() + trigger_mode: str + action: str - # 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}', - ] + # 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, + } - @property - def chart(self) -> 'ChartPlotWidget': # noqa - return self._chart + 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 - @chart.setter - def chart(self, chart: 'ChartPlotWidget') -> None: # noqa - self._chart = chart - chart.sigRangeChanged.connect(self.update_on_resize) - palette = self._label.palette() + # reset mods + ctrl: bool = False + shift: bool = False - # TODO: get bg color working - palette.setColor( - self._label.backgroundRole(), - # QtGui.QColor(chart.backgroundBrush()), - QtGui.QColor(hcolor('papas_special')), - ) + # press branch + if etype in {QEvent.KeyPress}: - def update_on_resize(self, vr, r): - """Re-position measure label on view range change. + pressed.add(key) - """ - if self._abs_top_right: - self._label_proxy.setPos( - self.vb.mapFromView(self._abs_top_right) - ) + 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 - def mouse_drag_released( - self, - p1: QPointF, - p2: QPointF - ) -> None: - """Called on final button release for mouse drag with start and - end positions. + # don't support more then 2 key sequences for now + len(fast_key_seq) > 2 + ): + fast_key_seq.clear() - """ - self.set_pos(p1, p2) + # 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}') - def set_pos( - self, - p1: QPointF, - p2: QPointF - ) -> None: - """Set position of selection rect and accompanying label, move - label to match. + # mods run through + if mods == Qt.ShiftModifier: + shift = True - """ - if self._label_proxy is None: - # https://doc.qt.io/qt-5/qgraphicsproxywidget.html - self._label_proxy = self.vb.scene().addWidget(self._label) + if mods == Qt.ControlModifier: + ctrl = True - start_pos = self.vb.mapToView(p1) - end_pos = self.vb.mapToView(p2) + # SEARCH MODE # + # ctlr-/ for "lookup", "search" -> open search tree + if ( + ctrl and key in { + Qt.Key_L, + Qt.Key_Space, + } + ): + view._chart._lc.godwidget.search.focus() - # map to view coords and update area - r = QtCore.QRectF(start_pos, end_pos) + # 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 + view.select_box.clear() - # old way; don't need right? - # lr = QtCore.QRectF(p1, p2) - # r = self.vb.childGroup.mapRectFromParent(lr) + # cancel order or clear graphics + if key == Qt.Key_C or key == Qt.Key_Delete: - self.setPos(r.topLeft()) - self.resetTransform() - self.scale(r.width(), r.height()) - self.show() + mode.cancel_orders_under_cursor() - y1, y2 = start_pos.y(), end_pos.y() - x1, x2 = start_pos.x(), end_pos.x() + # View modes + if key == Qt.Key_R: - # 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) + # edge triggered default view activation + view.chart.default_view() - pchng = (y2 - y1) / y1 * 100 - rng = abs(y1 - y2) + 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() - ixmn, ixmx = round(xmn), round(xmx) - nbars = ixmx - ixmn + 1 + # release branch + elif etype in {QEvent.KeyRelease}: - data = self._chart._ohlc[ixmn:ixmx] + if key in pressed: + pressed.remove(key) + + # QUERY MODE # + if {Qt.Key_Q}.intersection(pressed): + + view.linkedsplits.cursor.in_query_mode = True - if len(data): - std = data['close'].std() - dmx = data['high'].max() - dmn = data['low'].min() else: - dmn = dmx = std = np.nan + view.linkedsplits.cursor.in_query_mode = False - # update label info - self._label.setText('\n'.join(self._contents).format( - pchng=pchng, rng=rng, nbars=nbars, - std=std, dmx=dmx, dmn=dmn, - )) + # SELECTION MODE # - # 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() - - -# 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 + if shift: + if view.state['mouseMode'] == ViewBox.PanMode: + view.setMouseMode(ViewBox.RectMode) else: - assert line.oid == uuid - line.show_labels() + view.setMouseMode(ViewBox.PanMode) - # TODO: other flashy things to indicate the order is active + # ORDER MODE # + # live vs. dark trigger + an action {buy, sell, alert} - log.debug(f'Level active for level: {line.value()}') + order_keys_pressed = { + Qt.Key_A, + Qt.Key_F, + Qt.Key_D + }.intersection(pressed) - return line + if order_keys_pressed: + if ( + # 's' for "submit" to activate "live" order + Qt.Key_S in pressed or + ctrl + ): + trigger_mode: str = 'live' - def lines_under_cursor(self): - """Get the line(s) under the cursor position. + else: + trigger_mode: str = 'dark' - """ - # Delete any hoverable under the cursor - return self.chart._cursor._hovered + # order mode trigger "actions" + if Qt.Key_D in pressed: # for "damp eet" + action = 'sell' - def remove_line( - self, - line: LevelLine = None, - uuid: str = None, - ) -> LevelLine: - """Remove a line by refernce or uuid. + elif Qt.Key_F in pressed: # for "fillz eet" + action = 'buy' - If no lines or ids are provided remove all lines under the - cursor position. + elif Qt.Key_A in pressed: + action = 'alert' + trigger_mode = 'live' - """ - if line: - uuid = line.oid + view.order_mode = True - # try to look up line from our registry - line = self._order_lines.pop(uuid, None) - if line: + # XXX: order matters here for line style! + view.mode._exec_mode = trigger_mode + view.mode.set_exec(action) - # if hovered remove from cursor set - hovered = self.chart._cursor._hovered - if line in hovered: - hovered.remove(line) + prefix = trigger_mode + '-' if action != 'alert' else '' + view._chart.window().mode_label.setText( + f'mode: {prefix}{action}') - # make sure the xhair doesn't get left off - # just because we never got a un-hover event - self.chart._cursor.show_xhair() + else: # none active + # if none are pressed, remove "staged" level + # line under cursor position + view.mode.lines.unstage_line() - line.delete() - return line + if view.hasFocus(): + # update mode label + view._chart.window().mode_label.setText('mode: view') + view.order_mode = False -@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) + last = time.time() 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,31 +228,48 @@ 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 - self.mode = None + # add our selection box annotator + self.select_box = SelectRect(self) + self.addItem(self.select_box, ignoreBounds=True) - # kb ctrls processing - self._key_buffer = [] - self._key_active: bool = False + self.name = name + self.mode = None + self.order_mode: bool = False 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 +280,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() @@ -525,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 @@ -573,7 +352,6 @@ class ChartView(ViewBox): end_of_l1, key=lambda p: p.x() ) - # breakpoint() # focal = pg.Point(last_bar.x() + end_of_l1) self._resetTarget() @@ -693,131 +471,16 @@ 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() - def keyReleaseEvent(self, ev: QtCore.QEvent): - """ - Key release to normally to trigger release of input mode + def keyReleaseEvent(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 - - ev.accept() - # text = ev.text() - key = ev.key() - mods = ev.modifiers() - - if key == QtCore.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 == QtCore.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}: - # 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. - - """ - # 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() - - print(f'text: {text}, key: {key}') - - if mods == QtCore.Qt.ShiftModifier: - if self.state['mouseMode'] == ViewBox.PanMode: - self.setMouseMode(ViewBox.RectMode) - - # ctrl - ctrl = False - if mods == QtCore.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 { - QtCore.Qt.Key_L, - QtCore.Qt.Key_Space, - }: - search = self._chart._lc.chart_space.search - search.focus() - - # esc - if key == QtCore.Qt.Key_Escape or (ctrl and key == QtCore.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: - # 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 == QtCore.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" - self.mode.set_exec('sell') - - elif key == QtCore.Qt.Key_F: # for "fillz eet" - self.mode.set_exec('buy') - - elif key == QtCore.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" view 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 - ev.ignore() - self._key_active = False + def keyPressEvent(self, event: QtCore.QEvent) -> None: + '''This routine is rerouted to an async handler. + ''' + pass 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, 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 diff --git a/piker/ui/_window.py b/piker/ui/_window.py index 60210160..3668b6d5 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,25 +43,101 @@ class MultiStatus: def __init__(self, bar, statuses) -> None: self.bar = bar self.statuses = statuses + 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 old_msg in self._to_clear: + try: + self.statuses.remove(old_msg) + except ValueError: + pass + self.statuses.append(msg) def remove_msg() -> None: - self.statuses.remove(msg) + try: + self.statuses.remove(msg) + 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()` + # 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 + self.render() - return remove_msg + + if clear_on_next: + self._to_clear.add(msg) + + return ret def render(self) -> None: + '''Display all open statuses to bar. + + ''' if self.statuses: self.bar.showMessage(f'{" ".join(self.statuses)}') else: diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 9cb7d4d2..d57590cf 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -26,11 +26,12 @@ from typing import Optional, Dict, Callable, Any import uuid import pyqtgraph as pg -import trio from pydantic import BaseModel +import trio from ._graphics._lines import LevelLine, position_line -from ._interaction 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 from ..log import get_logger @@ -48,18 +49,31 @@ 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. + This object is chart oriented, so there is an instance per + chart / view currently. - """ + 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 + name: str = 'order' + _colors = { 'alert': 'alert_yellow', 'buy': 'buy_green', @@ -71,7 +85,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, @@ -108,12 +123,18 @@ class OrderMode: """Set execution mode. """ + # not initialized yet + if not self.chart.linked.cursor: + return + self._action = action self.lines.stage_line( 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, ) @@ -127,6 +148,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( @@ -182,8 +210,12 @@ 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: + order_line, func = pending + func() else: log.warning( f'Received cancel for unsubmitted order {pformat(msg)}' @@ -206,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 @@ -238,17 +271,70 @@ 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]: + '''Cancel all orders for the current chart. + + ''' + 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, @@ -274,13 +360,14 @@ async def open_order_mode( chart: pg.PlotWidget, book: OrderBook, ): + status_bar: MultiStatus = main_window().status_bar view = chart._vb - lines = LineEditor(view=view, chart=chart, _order_lines=_order_lines) + lines = LineEditor(chart=chart) 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 @@ -306,10 +393,13 @@ async def open_order_mode( async def start_order_mode( + chart: 'ChartPlotWidget', # noqa symbol: Symbol, brokername: str, + started: trio.Event, + ) -> None: '''Activate chart-trader order mode loop: - connect to emsd @@ -317,12 +407,16 @@ 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 ( 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 +439,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']), + )