diff --git a/piker/brokers/ib/api.py b/piker/brokers/ib/api.py index f5d61879..2e699a0b 100644 --- a/piker/brokers/ib/api.py +++ b/piker/brokers/ib/api.py @@ -470,10 +470,14 @@ class Client: # TODO add search though our adhoc-locally defined symbol set # for futes/cmdtys/ - results = await self.search_stocks( - pattern, - upto=upto, - ) + try: + results = await self.search_stocks( + pattern, + upto=upto, + ) + except ConnectionError: + return {} + for key, deats in results.copy().items(): tract = deats.contract diff --git a/piker/clearing/_allocate.py b/piker/clearing/_allocate.py index 9529991a..34f59571 100644 --- a/piker/clearing/_allocate.py +++ b/piker/clearing/_allocate.py @@ -93,6 +93,9 @@ class Allocator(Struct): else: return self.units_limit + def limit_info(self) -> tuple[str, float]: + return self.size_unit, self.limit() + def next_order_info( self, diff --git a/piker/clearing/_ems.py b/piker/clearing/_ems.py index 473a9e95..045134bc 100644 --- a/piker/clearing/_ems.py +++ b/piker/clearing/_ems.py @@ -617,8 +617,9 @@ async def translate_and_relay_brokerd_events( f'Received broker trade event:\n' f'{fmsg}' ) - match brokerd_msg: + status_msg: Optional[Status] = None + match brokerd_msg: # BrokerdPosition case { 'name': 'position', @@ -866,6 +867,7 @@ async def translate_and_relay_brokerd_events( }: log.error(f'Broker error:\n{fmsg}') # XXX: we presume the brokerd cancels its own order + continue # TOO FAST ``BrokerdStatus`` that arrives # before the ``BrokerdAck``. @@ -894,8 +896,8 @@ async def translate_and_relay_brokerd_events( raise ValueError(f'Brokerd message {brokerd_msg} is invalid') # XXX: ugh sometimes we don't access it? - if status_msg: - del status_msg + # if status_msg is not None: + # del status_msg # TODO: do we want this to keep things cleaned up? # it might require a special status from brokerd to affirm the @@ -1107,7 +1109,7 @@ async def process_client_order_cmds( # sometimes the real-time feed hasn't come up # so just pull from the latest history. if isnan(last): - last = feed.shm.array[-1]['close'] + last = feed.rt_shm.array[-1]['close'] pred = mk_check(trigger_price, last, action) diff --git a/piker/data/_sampling.py b/piker/data/_sampling.py index 77b15d7f..015de05e 100644 --- a/piker/data/_sampling.py +++ b/piker/data/_sampling.py @@ -37,6 +37,9 @@ if TYPE_CHECKING: log = get_logger(__name__) +_default_delay_s: float = 1.0 + + class sampler: ''' Global sampling engine registry. @@ -104,14 +107,18 @@ async def increment_ohlc_buffer( # TODO: do we want to support dynamically # adding a "lower" lowest increment period? await trio.sleep(ad) - total_s += lowest + total_s += delay_s # increment all subscribed shm arrays # TODO: # - this in ``numba`` # - just lookup shms for this step instead of iterating? - for delay_s, shms in sampler.ohlcv_shms.items(): - if total_s % delay_s != 0: + for this_delay_s, shms in sampler.ohlcv_shms.items(): + + # short-circuit on any not-ready because slower sample + # rate consuming shm buffers. + if total_s % this_delay_s != 0: + # print(f'skipping `{this_delay_s}s` sample update') continue # TODO: ``numba`` this! @@ -130,7 +137,7 @@ async def increment_ohlc_buffer( # this copies non-std fields (eg. vwap) from the last datum last[ ['time', 'volume', 'open', 'high', 'low', 'close'] - ][0] = (t + delay_s, 0, close, close, close, close) + ][0] = (t + this_delay_s, 0, close, close, close, close) # write to the buffer shm.push(last) @@ -152,7 +159,6 @@ async def broadcast( ''' subs = sampler.subscribers.get(delay_s, ()) - first = last = -1 if shm is None: @@ -221,7 +227,8 @@ async def iter_ohlc_periods( async def sample_and_broadcast( bus: _FeedsBus, # noqa - shm: ShmArray, + rt_shm: ShmArray, + hist_shm: ShmArray, quote_stream: trio.abc.ReceiveChannel, brokername: str, sum_tick_vlm: bool = True, @@ -257,41 +264,45 @@ async def sample_and_broadcast( last = tick['price'] - # update last entry - # benchmarked in the 4-5 us range - o, high, low, v = shm.array[-1][ - ['open', 'high', 'low', 'volume'] - ] + # more compact inline-way to do this assignment + # to both buffers? + for shm in [rt_shm, hist_shm]: + # update last entry + # benchmarked in the 4-5 us range + # for shm in [rt_shm, hist_shm]: + o, high, low, v = shm.array[-1][ + ['open', 'high', 'low', 'volume'] + ] - new_v = tick.get('size', 0) + new_v = tick.get('size', 0) - if v == 0 and new_v: - # no trades for this bar yet so the open - # is also the close/last trade price - o = last + if v == 0 and new_v: + # no trades for this bar yet so the open + # is also the close/last trade price + o = last - if sum_tick_vlm: - volume = v + new_v - else: - # presume backend takes care of summing - # it's own vlm - volume = quote['volume'] + if sum_tick_vlm: + volume = v + new_v + else: + # presume backend takes care of summing + # it's own vlm + volume = quote['volume'] - shm.array[[ - 'open', - 'high', - 'low', - 'close', - 'bar_wap', # can be optionally provided - 'volume', - ]][-1] = ( - o, - max(high, last), - min(low, last), - last, - quote.get('bar_wap', 0), - volume, - ) + shm.array[[ + 'open', + 'high', + 'low', + 'close', + 'bar_wap', # can be optionally provided + 'volume', + ]][-1] = ( + o, + max(high, last), + min(low, last), + last, + quote.get('bar_wap', 0), + volume, + ) # XXX: we need to be very cautious here that no # context-channel is left lingering which doesn't have diff --git a/piker/data/feed.py b/piker/data/feed.py index dfd47852..66b540ee 100644 --- a/piker/data/feed.py +++ b/piker/data/feed.py @@ -56,6 +56,7 @@ from ._sharedmem import ( maybe_open_shm_array, attach_shm_array, ShmArray, + _secs_in_day, ) from .ingest import get_ingestormod from .types import Struct @@ -72,6 +73,7 @@ from ._sampling import ( iter_ohlc_periods, sample_and_broadcast, uniform_rate_send, + _default_delay_s, ) from ..brokers._util import ( NoData, @@ -256,7 +258,7 @@ async def start_backfill( write_tsdb: bool = True, tsdb_is_up: bool = False, - task_status: TaskStatus[trio.CancelScope] = trio.TASK_STATUS_IGNORED, + task_status: TaskStatus[tuple] = trio.TASK_STATUS_IGNORED, ) -> int: @@ -294,7 +296,7 @@ async def start_backfill( bf_done = trio.Event() # let caller unblock and deliver latest history frame - task_status.started((shm, start_dt, end_dt, bf_done)) + task_status.started((start_dt, end_dt, bf_done)) # based on the sample step size, maybe load a certain amount history if last_tsdb_dt is None: @@ -544,7 +546,6 @@ async def start_backfill( ) frames.pop(epoch) continue - # await tractor.breakpoint() if diff > step_size_s: @@ -672,8 +673,8 @@ async def manage_history( ''' # (maybe) allocate shm array for this broker/symbol which will # be used for fast near-term history capture and processing. - shm, opened = maybe_open_shm_array( - key=fqsn, + hist_shm, opened = maybe_open_shm_array( + key=f'{fqsn}_hist', # use any broker defined ohlc dtype: dtype=getattr(mod, '_ohlc_dtype', base_iohlc_dtype), @@ -687,6 +688,21 @@ async def manage_history( "Persistent shm for sym was already open?!" ) + rt_shm, opened = maybe_open_shm_array( + key=f'{fqsn}_rt', + + # use any broker defined ohlc dtype: + dtype=getattr(mod, '_ohlc_dtype', base_iohlc_dtype), + + # we expect the sub-actor to write + readonly=False, + size=3*_secs_in_day, + ) + if not opened: + raise RuntimeError( + "Persistent shm for sym was already open?!" + ) + log.info('Scanning for existing `marketstored`') is_up = await check_for_service('marketstored') @@ -714,7 +730,6 @@ async def manage_history( broker, symbol, expiry = unpack_fqsn(fqsn) ( - shm, latest_start_dt, latest_end_dt, bf_done, @@ -723,14 +738,14 @@ async def manage_history( start_backfill, mod, bfqsn, - shm, + hist_shm, last_tsdb_dt=last_tsdb_dt, tsdb_is_up=True, storage=storage, ) ) - # if len(shm.array) < 2: + # if len(hist_shm.array) < 2: # TODO: there's an edge case here to solve where if the last # frame before market close (at least on ib) was pushed and # there was only "1 new" row pushed from the first backfill @@ -740,7 +755,7 @@ async def manage_history( # the tsdb series and stash that somewhere as meta data on # the shm buffer?.. no se. - task_status.started(shm) + task_status.started((hist_shm, rt_shm)) some_data_ready.set() await bf_done.wait() @@ -758,7 +773,7 @@ async def manage_history( # TODO: see if there's faster multi-field reads: # https://numpy.org/doc/stable/user/basics.rec.html#accessing-multiple-fields # re-index with a `time` and index field - prepend_start = shm._first.value + prepend_start = hist_shm._first.value # sanity check on most-recent-data loading assert prepend_start > dt_diff_s @@ -768,7 +783,7 @@ async def manage_history( fastest = history[0] to_push = fastest[:prepend_start] - shm.push( + hist_shm.push( to_push, # insert the history pre a "days worth" of samples @@ -784,7 +799,7 @@ async def manage_history( count = 0 end = fastest['Epoch'][0] - while shm._first.value > 0: + while hist_shm._first.value > 0: count += 1 series = await storage.read_ohlcv( fqsn, @@ -796,7 +811,7 @@ async def manage_history( prepend_start -= len(to_push) to_push = fastest[:prepend_start] - shm.push( + hist_shm.push( to_push, # insert the history pre a "days worth" of samples @@ -840,12 +855,12 @@ async def manage_history( start_backfill, mod, bfqsn, - shm, + hist_shm, ) ) # yield back after client connect with filled shm - task_status.started(shm) + task_status.started((hist_shm, rt_shm)) # indicate to caller that feed can be delivered to # remote requesting client since we've loaded history @@ -891,7 +906,7 @@ async def allocate_persistent_feed( # mem chan handed to broker backend so it can push real-time # quotes to this task for sampling and history storage (see below). - send, quote_stream = trio.open_memory_channel(10) + send, quote_stream = trio.open_memory_channel(616) # data sync signals for both history loading and market quotes some_data_ready = trio.Event() @@ -922,7 +937,7 @@ async def allocate_persistent_feed( # https://github.com/python-trio/trio/issues/2258 # bus.nursery.start_soon( # await bus.start_task( - shm = await bus.nursery.start( + hist_shm, rt_shm = await bus.nursery.start( manage_history, mod, bus, @@ -935,7 +950,9 @@ async def allocate_persistent_feed( # can read directly from the memory which will be written by # this task. msg = init_msg[symbol] - msg['shm_token'] = shm.token + msg['hist_shm_token'] = hist_shm.token + msg['startup_hist_index'] = hist_shm.index - 1 + msg['rt_shm_token'] = rt_shm.token # true fqsn fqsn = '.'.join((bfqsn, brokername)) @@ -971,7 +988,25 @@ async def allocate_persistent_feed( # for ambiguous names we simply apply the retreived # feed to that name (for now). - # task_status.started((init_msg, generic_first_quotes)) + sampler.ohlcv_shms.setdefault( + 1, + [] + ).append(rt_shm) + ohlckeys = ['open', 'high', 'low', 'close'] + + # set the rt (hft) shm array as append only + # (for now). + rt_shm._first.value = 0 + rt_shm._last.value = 0 + + # push last sample from history to rt buffer just as a filler datum + # but we don't want a history sized datum outlier so set vlm to zero + # and ohlc to the close value. + rt_shm.push(hist_shm.array[-2:-1]) + + rt_shm.array[ohlckeys] = hist_shm.array['close'][-1] + rt_shm._array['volume'] = 0 + task_status.started() if not start_stream: @@ -983,14 +1018,18 @@ async def allocate_persistent_feed( # start shm incrementer task for OHLC style sampling # at the current detected step period. - times = shm.array['time'] + times = hist_shm.array['time'] delay_s = times[-1] - times[times != times[-1]][-1] + sampler.ohlcv_shms.setdefault(delay_s, []).append(hist_shm) - sampler.ohlcv_shms.setdefault(delay_s, []).append(shm) - if sampler.incrementers.get(delay_s) is None: + # create buffer a single incrementer task broker backend + # (aka `brokerd`) using the lowest sampler period. + # await tractor.breakpoint() + # for delay_s in sampler.ohlcv_shms: + if sampler.incrementers.get(_default_delay_s) is None: await bus.start_task( increment_ohlc_buffer, - delay_s, + _default_delay_s, ) sum_tick_vlm: bool = init_msg.get( @@ -1001,7 +1040,8 @@ async def allocate_persistent_feed( try: await sample_and_broadcast( bus, - shm, + rt_shm, + hist_shm, quote_stream, brokername, sum_tick_vlm @@ -1164,34 +1204,6 @@ async def open_feed_bus( log.warning(f'{sub} for {symbol} was already removed?') -@asynccontextmanager -async def open_sample_step_stream( - portal: tractor.Portal, - delay_s: int, - -) -> tractor.ReceiveMsgStream: - - # XXX: this should be singleton on a host, - # a lone broker-daemon per provider should be - # created for all practical purposes - async with maybe_open_context( - acm_func=partial( - portal.open_context, - iter_ohlc_periods, - ), - kwargs={'delay_s': delay_s}, - - ) as (cache_hit, (ctx, first)): - async with ctx.open_stream() as istream: - if cache_hit: - # add a new broadcast subscription for the quote stream - # if this feed is likely already in use - async with istream.subscribe() as bistream: - yield bistream - else: - yield istream - - @dataclass class Feed: ''' @@ -1204,13 +1216,16 @@ class Feed: ''' name: str - shm: ShmArray + hist_shm: ShmArray + rt_shm: ShmArray mod: ModuleType first_quotes: dict # symbol names to first quote dicts _portal: tractor.Portal stream: trio.abc.ReceiveChannel[dict[str, Any]] status: dict[str, Any] + startup_hist_index: int = 0 + throttle_rate: Optional[int] = None _trade_stream: Optional[AsyncIterator[dict[str, Any]]] = None @@ -1230,17 +1245,28 @@ class Feed: @asynccontextmanager async def index_stream( self, - delay_s: Optional[int] = None + delay_s: int = 1, ) -> AsyncIterator[int]: - delay_s = delay_s or self._max_sample_rate - - async with open_sample_step_stream( - self.portal, - delay_s, - ) as istream: - yield istream + # XXX: this should be singleton on a host, + # a lone broker-daemon per provider should be + # created for all practical purposes + async with maybe_open_context( + acm_func=partial( + self.portal.open_context, + iter_ohlc_periods, + ), + kwargs={'delay_s': delay_s}, + ) as (cache_hit, (ctx, first)): + async with ctx.open_stream() as istream: + if cache_hit: + # add a new broadcast subscription for the quote stream + # if this feed is likely already in use + async with istream.subscribe() as bistream: + yield bistream + else: + yield istream async def pause(self) -> None: await self.stream.send('pause') @@ -1248,6 +1274,34 @@ class Feed: async def resume(self) -> None: await self.stream.send('resume') + def get_ds_info( + self, + ) -> tuple[float, float, float]: + ''' + Compute the "downsampling" ratio info between the historical shm + buffer and the real-time (HFT) one. + + Return a tuple of the fast sample period, historical sample + period and ratio between them. + + ''' + times = self.hist_shm.array['time'] + end = pendulum.from_timestamp(times[-1]) + start = pendulum.from_timestamp(times[times != times[-1]][-1]) + hist_step_size_s = (end - start).seconds + + times = self.rt_shm.array['time'] + end = pendulum.from_timestamp(times[-1]) + start = pendulum.from_timestamp(times[times != times[-1]][-1]) + rt_step_size_s = (end - start).seconds + + ratio = hist_step_size_s / rt_step_size_s + return ( + rt_step_size_s, + hist_step_size_s, + ratio, + ) + @asynccontextmanager async def install_brokerd_search( @@ -1337,21 +1391,29 @@ async def open_feed( ) as stream, ): + init = init_msg[bfqsn] # we can only read from shm - shm = attach_shm_array( - token=init_msg[bfqsn]['shm_token'], + hist_shm = attach_shm_array( + token=init['hist_shm_token'], readonly=True, ) + rt_shm = attach_shm_array( + token=init['rt_shm_token'], + readonly=True, + ) + assert fqsn in first_quotes feed = Feed( name=brokername, - shm=shm, + hist_shm=hist_shm, + rt_shm=rt_shm, mod=mod, first_quotes=first_quotes, stream=stream, _portal=portal, status={}, + startup_hist_index=init['startup_hist_index'], throttle_rate=tick_throttle, ) @@ -1364,7 +1426,7 @@ async def open_feed( 'actor_name': feed.portal.channel.uid[0], 'host': host, 'port': port, - 'shm': f'{humanize(feed.shm._shm.size)}', + 'shm': f'{humanize(feed.hist_shm._shm.size)}', 'throttle_rate': feed.throttle_rate, }) feed.status.update(init_msg.pop('status', {})) @@ -1382,13 +1444,17 @@ async def open_feed( feed.symbols[sym] = symbol # cast shm dtype to list... can't member why we need this - shm_token = data['shm_token'] + for shm_key, shm in [ + ('rt_shm_token', rt_shm), + ('hist_shm_token', hist_shm), + ]: + shm_token = data[shm_key] - # XXX: msgspec won't relay through the tuples XD - shm_token['dtype_descr'] = tuple( - map(tuple, shm_token['dtype_descr'])) + # XXX: msgspec won't relay through the tuples XD + shm_token['dtype_descr'] = tuple( + map(tuple, shm_token['dtype_descr'])) - assert shm_token == shm.token # sanity + assert shm_token == shm.token # sanity feed._max_sample_rate = 1 diff --git a/piker/fsp/_engine.py b/piker/fsp/_engine.py index d9f3af26..5ba3d376 100644 --- a/piker/fsp/_engine.py +++ b/piker/fsp/_engine.py @@ -37,6 +37,7 @@ from .. import data from ..data import attach_shm_array from ..data.feed import Feed from ..data._sharedmem import ShmArray +from ..data._sampling import _default_delay_s from ..data._source import Symbol from ._api import ( Fsp, @@ -105,7 +106,7 @@ async def fsp_compute( filter_quotes_by_sym(fqsn, quote_stream), # XXX: currently the ``ohlcv`` arg - feed.shm, + feed.rt_shm, ) # Conduct a single iteration of fsp with historical bars input @@ -313,7 +314,7 @@ async def cascade( profiler(f'{func}: feed up') - assert src.token == feed.shm.token + assert src.token == feed.rt_shm.token # last_len = new_len = len(src.array) func_name = func.__name__ @@ -420,7 +421,11 @@ async def cascade( # detect sample period step for subscription to increment # signal times = src.array['time'] - delay_s = times[-1] - times[times != times[-1]][-1] + if len(times) > 1: + delay_s = times[-1] - times[times != times[-1]][-1] + else: + # our default "HFT" sample rate. + delay_s = _default_delay_s # Increment the underlying shared memory buffer on every # "increment" msg received from the underlying data feed. @@ -431,7 +436,8 @@ async def cascade( profiler(f'{func_name}: sample stream up') profiler.finish() - async for _ in istream: + async for i in istream: + # log.runtime(f'FSP incrementing {i}') # respawn the compute task if the source # array has been updated such that we compute diff --git a/piker/ui/_annotate.py b/piker/ui/_annotate.py index 6e0e84d1..32a67980 100644 --- a/piker/ui/_annotate.py +++ b/piker/ui/_annotate.py @@ -32,16 +32,22 @@ def mk_marker_path( style: str, ) -> QGraphicsPathItem: - """Add a marker to be displayed on the line wrapped in a ``QGraphicsPathItem`` - ready to be placed using scene coordinates (not view). + ''' + Add a marker to be displayed on the line wrapped in + a ``QGraphicsPathItem`` ready to be placed using scene coordinates + (not view). **Arguments** style String indicating the style of marker to add: ``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``, ``'>|<'``, ``'^'``, ``'v'``, ``'o'`` - size Size of the marker in pixels. - """ + This code is taken nearly verbatim from the + `InfiniteLine.addMarker()` method but does not attempt do be aware + of low(er) level graphics controls and expects for the output + polygon to be applied to a ``QGraphicsPathItem``. + + ''' path = QtGui.QPainterPath() if style == 'o': @@ -87,7 +93,8 @@ def mk_marker_path( class LevelMarker(QGraphicsPathItem): - '''An arrow marker path graphich which redraws itself + ''' + An arrow marker path graphich which redraws itself to the specified view coordinate level on each paint cycle. ''' @@ -114,6 +121,7 @@ class LevelMarker(QGraphicsPathItem): self.get_level = get_level self._on_paint = on_paint + self.scene_x = lambda: chart.marker_right_points()[1] self.level: float = 0 self.keep_in_view = keep_in_view @@ -149,12 +157,9 @@ class LevelMarker(QGraphicsPathItem): def w(self) -> float: return self.path_br().width() - def position_in_view( - self, - # level: float, - - ) -> None: - '''Show a pp off-screen indicator for a level label. + def position_in_view(self) -> None: + ''' + Show a pp off-screen indicator for a level label. This is like in fps games where you have a gps "nav" indicator but your teammate is outside the range of view, except in 2D, on @@ -162,7 +167,6 @@ class LevelMarker(QGraphicsPathItem): ''' level = self.get_level() - view = self.chart.getViewBox() vr = view.state['viewRange'] ymn, ymx = vr[1] @@ -186,7 +190,6 @@ class LevelMarker(QGraphicsPathItem): ) elif level < ymn: # pin to bottom of view - self.setPos( QPointF( x, @@ -211,7 +214,8 @@ class LevelMarker(QGraphicsPathItem): w: QtWidgets.QWidget ) -> None: - '''Core paint which we override to always update + ''' + Core paint which we override to always update our marker position in scene coordinates from a view cooridnate "level". @@ -235,11 +239,12 @@ def qgo_draw_markers( right_offset: float, ) -> float: - """Paint markers in ``pg.GraphicsItem`` style by first + ''' + Paint markers in ``pg.GraphicsItem`` style by first removing the view transform for the painter, drawing the markers in scene coords, then restoring the view coords. - """ + ''' # paint markers in native coordinate system orig_tr = p.transform() diff --git a/piker/ui/_app.py b/piker/ui/_app.py index 998815ba..c99e2866 100644 --- a/piker/ui/_app.py +++ b/piker/ui/_app.py @@ -107,9 +107,8 @@ async def _async_main( # setup search widget and focus main chart view at startup # search widget is a singleton alongside the godwidget search = _search.SearchWidget(godwidget=godwidget) - search.bar.unfocus() - - godwidget.hbox.addWidget(search) + # search.bar.unfocus() + # godwidget.hbox.addWidget(search) godwidget.search = search symbol, _, provider = sym.rpartition('.') @@ -178,6 +177,6 @@ def _main( run_qtractor( func=_async_main, args=(sym, brokernames, piker_loglevel), - main_widget=GodWidget, + main_widget_type=GodWidget, tractor_kwargs=tractor_kwargs, ) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 3231698b..e0b92b56 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -19,7 +19,11 @@ High level chart-widget apis. ''' from __future__ import annotations -from typing import Optional, TYPE_CHECKING +from typing import ( + Iterator, + Optional, + TYPE_CHECKING, +) from PyQt5 import QtCore, QtWidgets from PyQt5.QtCore import ( @@ -68,6 +72,7 @@ from ._forms import FieldsForm from .._profile import pg_profile_enabled, ms_slower_then from ._overlay import PlotItemOverlay from ._flows import Flow +from ._search import SearchWidget if TYPE_CHECKING: from ._display import DisplayState @@ -85,6 +90,9 @@ class GodWidget(QWidget): modify them. ''' + search: SearchWidget + mode_name: str = 'god' + def __init__( self, @@ -94,6 +102,8 @@ class GodWidget(QWidget): super().__init__(parent) + self.search: Optional[SearchWidget] = None + self.hbox = QHBoxLayout(self) self.hbox.setContentsMargins(0, 0, 0, 0) self.hbox.setSpacing(6) @@ -115,7 +125,10 @@ class GodWidget(QWidget): # self.vbox.addLayout(self.hbox) self._chart_cache: dict[str, LinkedSplits] = {} - self.linkedsplits: Optional[LinkedSplits] = None + + self.hist_linked: Optional[LinkedSplits] = None + self.rt_linked: Optional[LinkedSplits] = None + self._active_cursor: Optional[Cursor] = None # assigned in the startup func `_async_main()` self._root_n: trio.Nursery = None @@ -123,6 +136,14 @@ class GodWidget(QWidget): self._widgets: dict[str, QWidget] = {} self._resizing: bool = False + # TODO: do we need this, when would god get resized + # and the window does not? Never right?! + # self.reg_for_resize(self) + + @property + def linkedsplits(self) -> LinkedSplits: + return self.rt_linked + # def init_timeframes_ui(self): # self.tf_layout = QHBoxLayout() # self.tf_layout.setSpacing(0) @@ -148,19 +169,19 @@ class GodWidget(QWidget): def set_chart_symbol( self, symbol_key: str, # of form . - linkedsplits: LinkedSplits, # type: ignore + all_linked: tuple[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] = linkedsplits + cache[symbol_key] = all_linked def get_chart_symbol( self, symbol_key: str, - ) -> LinkedSplits: # type: ignore + ) -> tuple[LinkedSplits, LinkedSplits]: # type: ignore return self._chart_cache.get(symbol_key) async def load_symbol( @@ -182,28 +203,33 @@ class GodWidget(QWidget): # fully qualified symbol name (SNS i guess is what we're making?) fqsn = '.'.join([symbol_key, providername]) - - linkedsplits = self.get_chart_symbol(fqsn) - + all_linked = 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() - self.linkedsplits.unfocus() + # XXX: seems to make switching slower? + # qframe = self.hist_linked.chart.qframe + # if qframe.sidepane is self.search: + # qframe.hbox.removeWidget(self.search) - # 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.linkedsplits) + for linked in [self.rt_linked, self.hist_linked]: + # XXX: this is CRITICAL especially with pixel buffer caching + linked.hide() + linked.unfocus() + + # XXX: pretty sure we don't need this + # remove any existing plots? + # XXX: ahh we might want to support cache unloading.. + # self.vbox.removeWidget(linked) # switching to a new viewable chart - if linkedsplits is None or reset: + if all_linked is None or reset: from ._display import display_symbol_data # we must load a fresh linked charts set - linkedsplits = LinkedSplits(self) + self.rt_linked = rt_charts = LinkedSplits(self) + self.hist_linked = hist_charts = LinkedSplits(self) # spawn new task to start up and update new sub-chart instances self._root_n.start_soon( @@ -215,44 +241,70 @@ class GodWidget(QWidget): order_mode_started, ) - self.set_chart_symbol(fqsn, linkedsplits) - self.vbox.addWidget(linkedsplits) + # self.vbox.addWidget(hist_charts) + self.vbox.addWidget(rt_charts) + self.set_chart_symbol( + fqsn, + (hist_charts, rt_charts), + ) + + for linked in [hist_charts, rt_charts]: + linked.show() + linked.focus() - linkedsplits.show() - linkedsplits.focus() await trio.sleep(0) else: # symbol is already loaded and ems ready order_mode_started.set() - # TODO: - # - we'll probably want per-instrument/provider state here? - # change the order config form over to the new chart + self.hist_linked, self.rt_linked = all_linked - # chart is already in memory so just focus it - linkedsplits.show() - linkedsplits.focus() - linkedsplits.graphics_cycle() + for linked in all_linked: + # TODO: + # - we'll probably want per-instrument/provider state here? + # change the order config form over to the new chart + + # chart is already in memory so just focus it + linked.show() + linked.focus() + linked.graphics_cycle() + await trio.sleep(0) + + # resume feeds *after* rendering chart view asap + chart = linked.chart + if chart: + chart.resume_all_feeds() + + # TODO: we need a check to see if the chart + # last had the xlast in view, if so then shift so it's + # still in view, if the user was viewing history then + # do nothing yah? + self.rt_linked.chart.default_view() + + # if a history chart instance is already up then + # set the search widget as its sidepane. + hist_chart = self.hist_linked.chart + if hist_chart: + hist_chart.qframe.set_sidepane(self.search) + + # NOTE: this is really stupid/hard to follow. + # we have to reposition the active position nav + # **AFTER** applying the search bar as a sidepane + # to the newly switched to symbol. await trio.sleep(0) - # XXX: since the pp config is a singleton widget we have to - # also switch it over to the new chart's interal-layout - # self.linkedsplits.chart.qframe.hbox.removeWidget(self.pp_pane) - chart = linkedsplits.chart + # TODO: probably stick this in some kinda `LooknFeel` API? + for tracker in self.rt_linked.mode.trackers.values(): + pp_nav = tracker.nav + if tracker.live_pp.size: + pp_nav.show() + pp_nav.hide_info() + else: + pp_nav.hide() - # resume feeds *after* rendering chart view asap - if chart: - chart.resume_all_feeds() - - # TODO: we need a check to see if the chart - # last had the xlast in view, if so then shift so it's - # still in view, if the user was viewing history then - # do nothing yah? - chart.default_view() - - self.linkedsplits = linkedsplits - symbol = linkedsplits.symbol + # set window titlebar info + symbol = self.rt_linked.symbol if symbol is not None: self.window.setWindowTitle( f'{symbol.front_fqsn()} ' @@ -269,11 +321,23 @@ class GodWidget(QWidget): ''' # go back to view-mode focus (aka chart focus) self.clearFocus() - self.linkedsplits.chart.setFocus() + chart = self.rt_linked.chart + if chart: + chart.setFocus() - def resizeEvent(self, event: QtCore.QEvent) -> None: + def reg_for_resize( + self, + widget: QWidget, + ) -> None: + getattr(widget, 'on_resize') + self._widgets[widget.mode_name] = widget + + def on_win_resize(self, event: QtCore.QEvent) -> None: ''' - Top level god widget resize handler. + Top level god widget handler from window (the real yaweh) resize + events such that any registered widgets which wish to be + notified are invoked using our pythonic `.on_resize()` method + api. Where we do UX magic to make things not suck B) @@ -289,6 +353,28 @@ class GodWidget(QWidget): self._resizing = False + # on_resize = on_win_resize + + def get_cursor(self) -> Cursor: + return self._active_cursor + + def iter_linked(self) -> Iterator[LinkedSplits]: + for linked in [self.hist_linked, self.rt_linked]: + yield linked + + def resize_all(self) -> None: + ''' + Dynamic resize sequence: adjusts all sub-widgets/charts to + sensible default ratios of what space is detected as available + on the display / window. + + ''' + rt_linked = self.rt_linked + rt_linked.set_split_sizes() + self.rt_linked.resize_sidepanes() + self.hist_linked.resize_sidepanes(from_linked=rt_linked) + self.search.on_resize() + class ChartnPane(QFrame): ''' @@ -301,9 +387,9 @@ class ChartnPane(QFrame): https://doc.qt.io/qt-5/qwidget.html#composite-widgets ''' - sidepane: FieldsForm + sidepane: FieldsForm | SearchWidget hbox: QHBoxLayout - chart: Optional['ChartPlotWidget'] = None + chart: Optional[ChartPlotWidget] = None def __init__( self, @@ -315,7 +401,7 @@ class ChartnPane(QFrame): super().__init__(parent) - self.sidepane = sidepane + self._sidepane = sidepane self.chart = None hbox = self.hbox = QHBoxLayout(self) @@ -323,6 +409,21 @@ class ChartnPane(QFrame): hbox.setContentsMargins(0, 0, 0, 0) hbox.setSpacing(3) + def set_sidepane( + self, + sidepane: FieldsForm | SearchWidget, + ) -> None: + + # add sidepane **after** chart; place it on axis side + self.hbox.addWidget( + sidepane, + alignment=Qt.AlignTop + ) + self._sidepane = sidepane + + def sidepane(self) -> FieldsForm | SearchWidget: + return self._sidepane + class LinkedSplits(QWidget): ''' @@ -357,6 +458,7 @@ class LinkedSplits(QWidget): self.splitter = QSplitter(QtCore.Qt.Vertical) self.splitter.setMidLineWidth(0) self.splitter.setHandleWidth(2) + self.splitter.splitterMoved.connect(self.on_splitter_adjust) self.layout = QVBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) @@ -369,6 +471,16 @@ class LinkedSplits(QWidget): self._symbol: Symbol = None + def on_splitter_adjust( + self, + pos: int, + index: int, + ) -> None: + # print(f'splitter moved pos:{pos}, index:{index}') + godw = self.godwidget + if self is godw.rt_linked: + godw.search.on_resize() + def graphics_cycle(self, **kwargs) -> None: from . import _display ds = self.display_state @@ -384,28 +496,32 @@ class LinkedSplits(QWidget): prop: Optional[float] = None, ) -> None: - '''Set the proportion of space allocated for linked subcharts. + ''' + Set the proportion of space allocated for linked subcharts. ''' - ln = len(self.subplots) + ln = len(self.subplots) or 1 # proportion allocated to consumer subcharts if not prop: - prop = 3/8*5/8 + prop = 3/8 - # if ln < 2: - # prop = 3/8*5/8 - - # elif ln >= 2: - # prop = 3/8 + h = self.height() + histview_h = h * (6/16) + h = h - histview_h major = 1 - prop - min_h_ind = int((self.height() * prop) / ln) + min_h_ind = int((h * prop) / ln) + sizes = [ + int(histview_h), + int(h * major), + ] - sizes = [int(self.height() * major)] + # give all subcharts the same remaining proportional height sizes.extend([min_h_ind] * ln) - self.splitter.setSizes(sizes) + if self.godwidget.rt_linked is self: + self.splitter.setSizes(sizes) def focus(self) -> None: if self.chart is not None: @@ -498,10 +614,15 @@ class LinkedSplits(QWidget): 'bottom': xaxis, } - qframe = ChartnPane( - sidepane=sidepane, - parent=self.splitter, - ) + if sidepane is not False: + parent = qframe = ChartnPane( + sidepane=sidepane, + parent=self.splitter, + ) + else: + parent = self.splitter + qframe = None + cpw = ChartPlotWidget( # this name will be used to register the primary @@ -509,7 +630,7 @@ class LinkedSplits(QWidget): name=name, data_key=array_key or name, - parent=qframe, + parent=parent, linkedsplits=self, axisItems=axes, **cpw_kwargs, @@ -537,22 +658,25 @@ class LinkedSplits(QWidget): self.xaxis_chart = cpw cpw.showAxis('bottom') - qframe.chart = cpw - qframe.hbox.addWidget(cpw) + if qframe is not None: + qframe.chart = cpw + qframe.hbox.addWidget(cpw) - # so we can look this up and add back to the splitter - # on a symbol switch - cpw.qframe = qframe - assert cpw.parent() == qframe + # so we can look this up and add back to the splitter + # on a symbol switch + cpw.qframe = qframe + assert cpw.parent() == qframe - # add sidepane **after** chart; place it on axis side - qframe.hbox.addWidget( - sidepane, - alignment=Qt.AlignTop - ) - cpw.sidepane = sidepane + # add sidepane **after** chart; place it on axis side + qframe.set_sidepane(sidepane) + # qframe.hbox.addWidget( + # sidepane, + # alignment=Qt.AlignTop + # ) - cpw.plotItem.vb.linkedsplits = self + cpw.sidepane = sidepane + + cpw.plotItem.vb.linked = self cpw.setFrameStyle( QtWidgets.QFrame.StyledPanel # | QtWidgets.QFrame.Plain @@ -613,9 +737,8 @@ class LinkedSplits(QWidget): if not _is_main: # track by name self.subplots[name] = cpw - self.splitter.addWidget(qframe) - # scale split regions - self.set_split_sizes() + if qframe is not None: + self.splitter.addWidget(qframe) else: assert style == 'bar', 'main chart must be OHLC' @@ -641,19 +764,28 @@ class LinkedSplits(QWidget): def resize_sidepanes( self, + from_linked: Optional[LinkedSplits] = None, + ) -> None: ''' Size all sidepanes based on the OHLC "main" plot and its sidepane width. ''' - main_chart = self.chart - if main_chart: + if from_linked: + main_chart = from_linked.chart + else: + main_chart = self.chart + + if main_chart and main_chart.sidepane: sp_w = main_chart.sidepane.width() for name, cpw in self.subplots.items(): cpw.sidepane.setMinimumWidth(sp_w) cpw.sidepane.setMaximumWidth(sp_w) + if from_linked: + self.chart.sidepane.setMinimumWidth(sp_w) + class ChartPlotWidget(pg.PlotWidget): ''' @@ -711,6 +843,7 @@ class ChartPlotWidget(pg.PlotWidget): # NOTE: must be set bfore calling ``.mk_vb()`` self.linked = linkedsplits + self.sidepane: Optional[FieldsForm] = None # source of our custom interactions self.cv = cv = self.mk_vb(name) @@ -867,7 +1000,8 @@ class ChartPlotWidget(pg.PlotWidget): def default_view( self, - bars_from_y: int = 616, + bars_from_y: int = int(616 * 3/8), + y_offset: int = 0, do_ds: bool = True, ) -> None: @@ -906,8 +1040,12 @@ class ChartPlotWidget(pg.PlotWidget): # terms now that we've scaled either by user control # or to the default set of bars as per the immediate block # above. - marker_pos, l1_len = self.pre_l1_xs() - end = xlast + l1_len + 1 + if not y_offset: + marker_pos, l1_len = self.pre_l1_xs() + end = xlast + l1_len + 1 + else: + end = xlast + y_offset + 1 + begin = end - (r - l) # for debugging diff --git a/piker/ui/_cursor.py b/piker/ui/_cursor.py index 606ff3f2..a27aca8c 100644 --- a/piker/ui/_cursor.py +++ b/piker/ui/_cursor.py @@ -18,8 +18,13 @@ Mouse interaction graphics """ +from __future__ import annotations from functools import partial -from typing import Optional, Callable +from typing import ( + Optional, + Callable, + TYPE_CHECKING, +) import inspect import numpy as np @@ -36,6 +41,12 @@ from ._style import ( from ._axes import YAxisLabel, XAxisLabel from ..log import get_logger +if TYPE_CHECKING: + from ._chart import ( + ChartPlotWidget, + LinkedSplits, + ) + log = get_logger(__name__) @@ -58,7 +69,7 @@ class LineDot(pg.CurvePoint): curve: pg.PlotCurveItem, index: int, - plot: 'ChartPlotWidget', # type: ingore # noqa + plot: ChartPlotWidget, # type: ingore # noqa pos=None, color: str = 'default_light', @@ -151,7 +162,7 @@ class ContentsLabel(pg.LabelItem): def __init__( self, - # chart: 'ChartPlotWidget', # noqa + # chart: ChartPlotWidget, # noqa view: pg.ViewBox, anchor_at: str = ('top', 'right'), @@ -244,7 +255,7 @@ class ContentsLabels: ''' def __init__( self, - linkedsplits: 'LinkedSplits', # type: ignore # noqa + linkedsplits: LinkedSplits, # type: ignore # noqa ) -> None: @@ -289,7 +300,7 @@ class ContentsLabels: def add_label( self, - chart: 'ChartPlotWidget', # type: ignore # noqa + chart: ChartPlotWidget, # type: ignore # noqa name: str, anchor_at: tuple[str, str] = ('top', 'left'), update_func: Callable = ContentsLabel.update_from_value, @@ -316,7 +327,7 @@ class Cursor(pg.GraphicsObject): def __init__( self, - linkedsplits: 'LinkedSplits', # noqa + linkedsplits: LinkedSplits, # noqa digits: int = 0 ) -> None: @@ -325,6 +336,8 @@ class Cursor(pg.GraphicsObject): self.linked = linkedsplits self.graphics: dict[str, pg.GraphicsObject] = {} + self.xaxis_label: Optional[XAxisLabel] = None + self.always_show_xlabel: bool = True self.plots: list['PlotChartWidget'] = [] # type: ignore # noqa self.active_plot = None self.digits: int = digits @@ -385,7 +398,7 @@ class Cursor(pg.GraphicsObject): def add_plot( self, - plot: 'ChartPlotWidget', # noqa + plot: ChartPlotWidget, # noqa digits: int = 0, ) -> None: @@ -469,7 +482,7 @@ class Cursor(pg.GraphicsObject): def add_curve_cursor( self, - plot: 'ChartPlotWidget', # noqa + plot: ChartPlotWidget, # noqa curve: 'PlotCurveItem', # noqa ) -> LineDot: @@ -491,17 +504,29 @@ class Cursor(pg.GraphicsObject): log.debug(f"{(action, plot.name)}") if action == 'Enter': self.active_plot = plot + plot.linked.godwidget._active_cursor = self # show horiz line and y-label self.graphics[plot]['hl'].show() self.graphics[plot]['yl'].show() - else: # Leave + if ( + not self.always_show_xlabel + and not self.xaxis_label.isVisible() + ): + self.xaxis_label.show() - # hide horiz line and y-label + # Leave: hide horiz line and y-label + else: self.graphics[plot]['hl'].hide() self.graphics[plot]['yl'].hide() + if ( + not self.always_show_xlabel + and self.xaxis_label.isVisible() + ): + self.xaxis_label.hide() + def mouseMoved( self, coords: tuple[QPointF], # noqa @@ -590,13 +615,17 @@ class Cursor(pg.GraphicsObject): left_axis_width += left.width() # map back to abs (label-local) coordinates - self.xaxis_label.update_label( - abs_pos=( - plot.mapFromView(QPointF(vl_x, iy)) - - QPointF(left_axis_width, 0) - ), - value=ix, - ) + if ( + self.always_show_xlabel + or self.xaxis_label.isVisible() + ): + self.xaxis_label.update_label( + abs_pos=( + plot.mapFromView(QPointF(vl_x, iy)) - + QPointF(left_axis_width, 0) + ), + value=ix, + ) self._datum_xy = ix, iy diff --git a/piker/ui/_display.py b/piker/ui/_display.py index 05603c63..4d24f5ca 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -21,19 +21,20 @@ this module ties together quote and computational (fsp) streams with graphics update methods via our custom ``pyqtgraph`` charting api. ''' -from dataclasses import dataclass from functools import partial import time from typing import Optional, Any, Callable -import numpy as np import tractor import trio -import pendulum import pyqtgraph as pg # from .. import brokers -from ..data.feed import open_feed +from ..data.feed import ( + open_feed, + Feed, +) +from ..data.types import Struct from ._axes import YAxisLabel from ._chart import ( ChartPlotWidget, @@ -41,6 +42,7 @@ from ._chart import ( GodWidget, ) from ._l1 import L1Labels +from ._style import hcolor from ._fsp import ( update_fsp_chart, start_fsp_displays, @@ -53,7 +55,10 @@ from ._forms import ( FieldsForm, mk_order_pane_layout, ) -from .order_mode import open_order_mode +from .order_mode import ( + open_order_mode, + OrderMode, +) from .._profile import ( pg_profile_enabled, ms_slower_then, @@ -63,7 +68,7 @@ from ..log import get_logger log = get_logger(__name__) # TODO: load this from a config.toml! -_quote_throttle_rate: int = 22 # Hz +_quote_throttle_rate: int = 16 # Hz # a working tick-type-classes template @@ -122,39 +127,105 @@ def chart_maxmin( ) -@dataclass -class DisplayState: +class DisplayState(Struct): ''' Chart-local real-time graphics state container. ''' + godwidget: GodWidget quotes: dict[str, Any] maxmin: Callable ohlcv: ShmArray + hist_ohlcv: ShmArray # high level chart handles - linked: LinkedSplits chart: ChartPlotWidget # axis labels l1: L1Labels last_price_sticky: YAxisLabel + hist_last_price_sticky: YAxisLabel # misc state tracking - vars: dict[str, Any] + vars: dict[str, Any] = { + 'tick_margin': 0, + 'i_last': 0, + 'i_last_append': 0, + 'last_mx_vlm': 0, + 'last_mx': 0, + 'last_mn': 0, + } vlm_chart: Optional[ChartPlotWidget] = None vlm_sticky: Optional[YAxisLabel] = None wap_in_history: bool = False + def incr_info( + self, + chart: Optional[ChartPlotWidget] = None, + shm: Optional[ShmArray] = None, + state: Optional[dict] = None, # pass in a copy if you don't + + update_state: bool = True, + update_uppx: float = 16, + + ) -> tuple: + + shm = shm or self.ohlcv + chart = chart or self.chart + state = state or self.vars + + if not update_state: + state = state.copy() + + # compute the first available graphic's x-units-per-pixel + uppx = chart.view.x_uppx() + + # NOTE: this used to be implemented in a dedicated + # "increment task": ``check_for_new_bars()`` but it doesn't + # make sense to do a whole task switch when we can just do + # this simple index-diff and all the fsp sub-curve graphics + # are diffed on each draw cycle anyway; so updates to the + # "curve" length is already automatic. + + # increment the view position by the sample offset. + i_step = shm.index + i_diff = i_step - state['i_last'] + state['i_last'] = i_step + + append_diff = i_step - state['i_last_append'] + + # update the "last datum" (aka extending the flow graphic with + # new data) only if the number of unit steps is >= the number of + # such unit steps per pixel (aka uppx). Iow, if the zoom level + # is such that a datum(s) update to graphics wouldn't span + # to a new pixel, we don't update yet. + do_append = (append_diff >= uppx) + if do_append: + state['i_last_append'] = i_step + + do_rt_update = uppx < update_uppx + + _, _, _, r = chart.bars_range() + liv = r >= i_step + + # TODO: pack this into a struct + return ( + uppx, + liv, + do_append, + i_diff, + append_diff, + do_rt_update, + ) + async def graphics_update_loop( - linked: LinkedSplits, - stream: tractor.MsgStream, - ohlcv: np.ndarray, - + nurse: trio.Nursery, + godwidget: GodWidget, + feed: Feed, wap_in_history: bool = False, vlm_chart: Optional[ChartPlotWidget] = None, @@ -175,9 +246,14 @@ async def graphics_update_loop( # of copying it from last bar's close # - 1-5 sec bar lookback-autocorrection like tws does? # (would require a background history checker task) - display_rate = linked.godwidget.window.current_screen().refreshRate() + linked: LinkedSplits = godwidget.rt_linked + display_rate = godwidget.window.current_screen().refreshRate() chart = linked.chart + hist_chart = godwidget.hist_linked.chart + + ohlcv = feed.rt_shm + hist_ohlcv = feed.hist_shm # update last price sticky last_price_sticky = chart._ysticks[chart.name] @@ -185,6 +261,11 @@ async def graphics_update_loop( *ohlcv.array[-1][['index', 'close']] ) + hist_last_price_sticky = hist_chart._ysticks[hist_chart.name] + hist_last_price_sticky.update_from_data( + *hist_ohlcv.array[-1][['index', 'close']] + ) + maxmin = partial( chart_maxmin, chart, @@ -227,12 +308,14 @@ async def graphics_update_loop( i_last = ohlcv.index ds = linked.display_state = DisplayState(**{ + 'godwidget': godwidget, 'quotes': {}, - 'linked': linked, 'maxmin': maxmin, 'ohlcv': ohlcv, + 'hist_ohlcv': hist_ohlcv, 'chart': chart, 'last_price_sticky': last_price_sticky, + 'hist_last_price_sticky': hist_last_price_sticky, 'l1': l1, 'vars': { @@ -252,7 +335,62 @@ async def graphics_update_loop( chart.default_view() + # TODO: probably factor this into some kinda `DisplayState` + # API that can be reused at least in terms of pulling view + # params (eg ``.bars_range()``). + async def increment_history_view(): + i_last = hist_ohlcv.index + state = ds.vars.copy() | { + 'i_last_append': i_last, + 'i_last': i_last, + } + _, hist_step_size_s, _ = feed.get_ds_info() + + async with feed.index_stream( + # int(hist_step_size_s) + # TODO: seems this is more reliable at keeping the slow + # chart incremented in view more correctly? + # - It might make sense to just inline this logic with the + # main display task? => it's a tradeoff of slower task + # wakeups/ctx switches verus logic checks (as normal) + # - we need increment logic that only does the view shift + # call when the uppx permits/needs it + int(1), + ) as istream: + async for msg in istream: + + # check if slow chart needs an x-domain shift and/or + # y-range resize. + ( + uppx, + liv, + do_append, + i_diff, + append_diff, + do_rt_update, + ) = ds.incr_info( + chart=hist_chart, + shm=ds.hist_ohlcv, + state=state, + # update_state=False, + ) + # print( + # f'liv: {liv}\n' + # f'do_append: {do_append}\n' + # f'append_diff: {append_diff}\n' + # ) + + if ( + do_append + and liv + ): + hist_chart.increment_view(steps=i_diff) + hist_chart.view._set_yrange(yrange=hist_chart.maxmin()) + + nurse.start_soon(increment_history_view) + # main real-time quotes update loop + stream: tractor.MsgStream = feed.stream async for quotes in stream: ds.quotes = quotes @@ -273,7 +411,7 @@ async def graphics_update_loop( # chart isn't active/shown so skip render cycle and pause feed(s) if chart.linked.isHidden(): - print('skipping update') + # print('skipping update') chart.pause_all_feeds() continue @@ -298,6 +436,8 @@ def graphics_update_cycle( # hopefully XD chart = ds.chart + # TODO: just pass this as a direct ref to avoid so many attr accesses? + hist_chart = ds.godwidget.hist_linked.chart profiler = pg.debug.Profiler( msg=f'Graphics loop cycle for: `{chart.name}`', @@ -311,53 +451,24 @@ def graphics_update_cycle( # unpack multi-referenced components vlm_chart = ds.vlm_chart + + # rt "HFT" chart l1 = ds.l1 ohlcv = ds.ohlcv array = ohlcv.array + vars = ds.vars tick_margin = vars['tick_margin'] - update_uppx = 16 - for sym, quote in ds.quotes.items(): - - # compute the first available graphic's x-units-per-pixel - uppx = chart.view.x_uppx() - - # NOTE: vlm may be written by the ``brokerd`` backend - # event though a tick sample is not emitted. - # TODO: show dark trades differently - # https://github.com/pikers/piker/issues/116 - - # NOTE: this used to be implemented in a dedicated - # "increment task": ``check_for_new_bars()`` but it doesn't - # make sense to do a whole task switch when we can just do - # this simple index-diff and all the fsp sub-curve graphics - # are diffed on each draw cycle anyway; so updates to the - # "curve" length is already automatic. - - # increment the view position by the sample offset. - i_step = ohlcv.index - i_diff = i_step - vars['i_last'] - vars['i_last'] = i_step - - append_diff = i_step - vars['i_last_append'] - - # update the "last datum" (aka extending the flow graphic with - # new data) only if the number of unit steps is >= the number of - # such unit steps per pixel (aka uppx). Iow, if the zoom level - # is such that a datum(s) update to graphics wouldn't span - # to a new pixel, we don't update yet. - do_append = (append_diff >= uppx) - if do_append: - vars['i_last_append'] = i_step - - do_rt_update = uppx < update_uppx - # print( - # f'append_diff:{append_diff}\n' - # f'uppx:{uppx}\n' - # f'do_append: {do_append}' - # ) + ( + uppx, + liv, + do_append, + i_diff, + append_diff, + do_rt_update, + ) = ds.incr_info() # TODO: we should only run mxmn when we know # an update is due via ``do_append`` above. @@ -373,8 +484,6 @@ def graphics_update_cycle( profiler('`ds.maxmin()` call') - liv = r >= i_step # the last datum is in view - if ( prepend_update_index is not None and lbar > prepend_update_index @@ -389,16 +498,10 @@ def graphics_update_cycle( # don't real-time "shift" the curve to the # left unless we get one of the following: if ( - ( - # i_diff > 0 # no new sample step - do_append - # and uppx < 4 # chart is zoomed out very far - and liv - ) + (do_append and liv) or trigger_all ): chart.increment_view(steps=i_diff) - # chart.increment_view(steps=i_diff + round(append_diff - uppx)) if vlm_chart: vlm_chart.increment_view(steps=i_diff) @@ -458,6 +561,10 @@ def graphics_update_cycle( chart.name, do_append=do_append, ) + hist_chart.update_graphics_from_flow( + chart.name, + do_append=do_append, + ) # NOTE: we always update the "last" datum # since the current range should at least be updated @@ -495,6 +602,9 @@ def graphics_update_cycle( ds.last_price_sticky.update_from_data( *end[['index', 'close']] ) + ds.hist_last_price_sticky.update_from_data( + *end[['index', 'close']] + ) if wap_in_history: # update vwap overlay line @@ -542,26 +652,44 @@ def graphics_update_cycle( l1.bid_label.update_fields({'level': price, 'size': size}) # check for y-range re-size - if ( - (mx > vars['last_mx']) or (mn < vars['last_mn']) - and not chart._static_yrange == 'axis' - and liv - ): - main_vb = chart.view + if (mx > vars['last_mx']) or (mn < vars['last_mn']): + + # fast chart resize case if ( - main_vb._ic is None - or not main_vb._ic.is_set() + liv + and not chart._static_yrange == 'axis' ): - # print(f'updating range due to mxmn') - main_vb._set_yrange( - # TODO: we should probably scale - # the view margin based on the size - # of the true range? This way you can - # slap in orders outside the current - # L1 (only) book range. - # range_margin=0.1, - yrange=(mn, mx), - ) + main_vb = chart.view + if ( + main_vb._ic is None + or not main_vb._ic.is_set() + ): + # print(f'updating range due to mxmn') + main_vb._set_yrange( + # TODO: we should probably scale + # the view margin based on the size + # of the true range? This way you can + # slap in orders outside the current + # L1 (only) book range. + # range_margin=0.1, + yrange=(mn, mx), + ) + + # check if slow chart needs a resize + ( + _, + hist_liv, + _, + _, + _, + _, + ) = ds.incr_info( + chart=hist_chart, + shm=ds.hist_ohlcv, + update_state=False, + ) + if hist_liv: + hist_chart.view._set_yrange(yrange=hist_chart.maxmin()) # XXX: update this every draw cycle to make L1-always-in-view work. vars['last_mx'], vars['last_mn'] = mx, mn @@ -719,15 +847,17 @@ async def display_symbol_data( tick_throttle=_quote_throttle_rate, ) as feed: - ohlcv: ShmArray = feed.shm - bars = ohlcv.array + ohlcv: ShmArray = feed.rt_shm + hist_ohlcv: ShmArray = feed.hist_shm + + # this value needs to be pulled once and only once during + # startup + end_index = feed.startup_hist_index + symbol = feed.symbols[sym] fqsn = symbol.front_fqsn() - times = bars['time'] - end = pendulum.from_timestamp(times[-1]) - start = pendulum.from_timestamp(times[times != times[-1]][-1]) - step_size_s = (end - start).seconds + step_size_s = 1 tf_key = tf_in_1s[step_size_s] # load in symbol's ohlc data @@ -737,51 +867,158 @@ async def display_symbol_data( f'step:{tf_key} ' ) - linked = godwidget.linkedsplits - linked._symbol = symbol + rt_linked = godwidget.rt_linked + rt_linked._symbol = symbol + + # create top history view chart above the "main rt chart". + hist_linked = godwidget.hist_linked + hist_linked._symbol = symbol + hist_chart = hist_linked.plot_ohlc_main( + symbol, + feed.hist_shm, + # in the case of history chart we explicitly set `False` + # to avoid internal pane creation. + # sidepane=False, + sidepane=godwidget.search, + ) + # don't show when not focussed + hist_linked.cursor.always_show_xlabel = False # generate order mode side-pane UI # A ``FieldsForm`` form to configure order entry + # and add as next-to-y-axis singleton pane pp_pane: FieldsForm = mk_order_pane_layout(godwidget) - - # add as next-to-y-axis singleton pane godwidget.pp_pane = pp_pane # create main OHLC chart - chart = linked.plot_ohlc_main( + chart = rt_linked.plot_ohlc_main( symbol, ohlcv, + # in the case of history chart we explicitly set `False` + # to avoid internal pane creation. sidepane=pp_pane, ) - chart.default_view() + chart._feeds[symbol.key] = feed chart.setFocus() + # XXX: FOR SOME REASON THIS IS CAUSING HANGZ!?! # plot historical vwap if available wap_in_history = False + # if ( + # brokermod._show_wap_in_history + # and 'bar_wap' in bars.dtype.fields + # ): + # wap_in_history = True + # chart.draw_curve( + # name='bar_wap', + # shm=ohlcv, + # color='default_light', + # add_label=False, + # ) - # XXX: FOR SOME REASON THIS IS CAUSING HANGZ!?! - # if brokermod._show_wap_in_history: + # Add the LinearRegionItem to the ViewBox, but tell the ViewBox + # to exclude this item when doing auto-range calculations. + rt_pi = chart.plotItem + hist_pi = hist_chart.plotItem + region = pg.LinearRegionItem( + # color scheme that matches sidepane styling + pen=pg.mkPen(hcolor('gunmetal')), + brush=pg.mkBrush(hcolor('default_darkest')), + ) + region.setZValue(10) # put linear region "in front" in layer terms + hist_pi.addItem(region, ignoreBounds=True) + flow = chart._flows[hist_chart.name] + assert flow + # XXX: no idea why this doesn't work but it's causing + # a weird placement of the region on the way-far-left.. + # region.setClipItem(flow.graphics) - # if 'bar_wap' in bars.dtype.fields: - # wap_in_history = True - # chart.draw_curve( - # name='bar_wap', - # shm=ohlcv, - # color='default_light', - # add_label=False, - # ) + # poll for datums load and timestep detection + for _ in range(100): + try: + _, _, ratio = feed.get_ds_info() + break + except IndexError: + await trio.sleep(0.01) + continue + else: + raise RuntimeError( + 'Failed to detect sampling periods from shm!?') - # size view to data once at outset - chart.cv._set_yrange() + def update_pi_from_region(): + region.setZValue(10) + mn, mx = region.getRegion() + # print(f'region_x: {(mn, mx)}') + + # XXX: seems to cause a real perf hit? + rt_pi.setXRange( + (mn - end_index) * ratio, + (mx - end_index) * ratio, + padding=0, + ) + + region.sigRegionChanged.connect(update_pi_from_region) + + def update_region_from_pi( + window, + viewRange: tuple[tuple, tuple], + is_manual: bool = True, + + ) -> None: + # set the region on the history chart + # to the range currently viewed in the + # HFT/real-time chart. + mn, mx = viewRange[0] + ds_mn = mn/ratio + ds_mx = mx/ratio + # print( + # f'rt_view_range: {(mn, mx)}\n' + # f'ds_mn, ds_mx: {(ds_mn, ds_mx)}\n' + # ) + lhmn = ds_mn + end_index + lhmx = ds_mx + end_index + region.setRegion(( + lhmn, + lhmx, + )) + + # TODO: if we want to have the slow chart adjust range to + # match the fast chart's selection -> results in the + # linear region expansion never can go "outside of view". + # hmn, hmx = hvr = hist_chart.view.state['viewRange'][0] + # print((hmn, hmx)) + # if ( + # hvr + # and (lhmn < hmn or lhmx > hmx) + # ): + # hist_pi.setXRange( + # lhmn, + # lhmx, + # padding=0, + # ) + # hist_linked.graphics_cycle() + + # connect region to be updated on plotitem interaction. + rt_pi.sigRangeChanged.connect(update_region_from_pi) # NOTE: we must immediately tell Qt to show the OHLC chart # to avoid a race where the subplots get added/shown to # the linked set *before* the main price chart! - linked.show() - linked.focus() + rt_linked.show() + rt_linked.focus() await trio.sleep(0) + # NOTE: here we insert the slow-history chart set into + # the fast chart's splitter -> so it's a splitter of charts + # inside the first widget slot of a splitter of charts XD + rt_linked.splitter.insertWidget(0, hist_linked) + # XXX: if we wanted it at the bottom? + # rt_linked.splitter.addWidget(hist_linked) + rt_linked.focus() + + godwidget.resize_all() + vlm_chart: Optional[ChartPlotWidget] = None async with trio.open_nursery() as ln: @@ -792,7 +1029,7 @@ async def display_symbol_data( ): vlm_chart = await ln.start( open_vlm_displays, - linked, + rt_linked, ohlcv, ) @@ -800,7 +1037,7 @@ async def display_symbol_data( # from an input config. ln.start_soon( start_fsp_displays, - linked, + rt_linked, ohlcv, loading_sym_key, loglevel, @@ -809,39 +1046,73 @@ async def display_symbol_data( # start graphics update loop after receiving first live quote ln.start_soon( graphics_update_loop, - linked, - feed.stream, - ohlcv, + ln, + godwidget, + feed, wap_in_history, vlm_chart, ) + await trio.sleep(0) + + # size view to data prior to order mode init + chart.default_view() + rt_linked.graphics_cycle() + await trio.sleep(0) + + hist_chart.default_view( + bars_from_y=int(len(hist_ohlcv.array)), # size to data + y_offset=6116*2, # push it a little away from the y-axis + ) + hist_linked.graphics_cycle() + await trio.sleep(0) + + godwidget.resize_all() + + mode: OrderMode async with ( open_order_mode( feed, - chart, + godwidget, fqsn, order_mode_started - ) + ) as mode ): if not vlm_chart: + # trigger another view reset if no sub-chart chart.default_view() + rt_linked.mode = mode + # let Qt run to render all widgets and make sure the # sidepanes line up vertically. await trio.sleep(0) - linked.resize_sidepanes() + # dynamic resize steps + godwidget.resize_all() + + # TODO: look into this because not sure why it was + # commented out / we ever needed it XD # NOTE: we pop the volume chart from the subplots set so # that it isn't double rendered in the display loop # above since we do a maxmin calc on the volume data to # determine if auto-range adjustements should be made. - # linked.subplots.pop('volume', None) + # rt_linked.subplots.pop('volume', None) # TODO: make this not so shit XD # close group status sbar._status_groups[loading_sym_key][1]() + hist_linked.graphics_cycle() + await trio.sleep(0) + + bars_in_mem = int(len(hist_ohlcv.array)) + hist_chart.default_view( + bars_from_y=bars_in_mem, # size to data + # push it 1/16th away from the y-axis + y_offset=round(bars_in_mem / 16), + ) + godwidget.resize_all() + # let the app run.. bby - # linked.graphics_cycle() await trio.sleep_forever() diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index 38d30da4..2633cf40 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -18,8 +18,12 @@ Higher level annotation editors. """ -from dataclasses import dataclass, field -from typing import Optional +from __future__ import annotations +from collections import defaultdict +from typing import ( + Optional, + TYPE_CHECKING +) import pyqtgraph as pg from pyqtgraph import ViewBox, Point, QtCore, QtGui @@ -30,28 +34,34 @@ import numpy as np from ._style import hcolor, _font from ._lines import LevelLine from ..log import get_logger +from ..data.types import Struct + +if TYPE_CHECKING: + from ._chart import GodWidget log = get_logger(__name__) -@dataclass -class ArrowEditor: +class ArrowEditor(Struct): - chart: 'ChartPlotWidget' # noqa - _arrows: field(default_factory=dict) + godw: GodWidget = None # type: ignore # noqa + _arrows: dict[str, list[pg.ArrowItem]] = {} def add( self, + plot: pg.PlotItem, uid: str, x: float, y: float, color='default', pointing: Optional[str] = None, - ) -> pg.ArrowItem: - """Add an arrow graphic to view at given (x, y). - """ + ) -> pg.ArrowItem: + ''' + Add an arrow graphic to view at given (x, y). + + ''' angle = { 'up': 90, 'down': -90, @@ -74,25 +84,25 @@ class ArrowEditor: brush=pg.mkBrush(hcolor(color)), ) arrow.setPos(x, y) - - self._arrows[uid] = arrow + self._arrows.setdefault(uid, []).append(arrow) # render to view - self.chart.plotItem.addItem(arrow) + plot.addItem(arrow) return arrow def remove(self, arrow) -> bool: - self.chart.plotItem.removeItem(arrow) + for linked in self.godw.iter_linked(): + linked.chart.plotItem.removeItem(arrow) -@dataclass -class LineEditor: - '''The great editor of linez. +class LineEditor(Struct): + ''' + The great editor of linez. ''' - chart: 'ChartPlotWidget' = None # type: ignore # noqa - _order_lines: dict[str, LevelLine] = field(default_factory=dict) + godw: GodWidget = None # type: ignore # noqa + _order_lines: defaultdict[str, LevelLine] = defaultdict(list) _active_staged_line: LevelLine = None def stage_line( @@ -100,11 +110,11 @@ class LineEditor: line: LevelLine, ) -> LevelLine: - """Stage a line at the current chart's cursor position + ''' + Stage a line at the current chart's cursor position and return it. - """ - + ''' # add a "staged" cursor-tracking line to view # and cash it in a a var if self._active_staged_line: @@ -115,17 +125,25 @@ class LineEditor: return line def unstage_line(self) -> LevelLine: - """Inverse of ``.stage_line()``. + ''' + Inverse of ``.stage_line()``. - """ - # chart = self.chart._cursor.active_plot - # # chart.setCursor(QtCore.Qt.ArrowCursor) - cursor = self.chart.linked.cursor + ''' + cursor = self.godw.get_cursor() + if not cursor: + return None # delete "staged" cursor tracking line from view line = self._active_staged_line if line: - cursor._trackers.remove(line) + try: + cursor._trackers.remove(line) + except KeyError: + # when the current cursor doesn't have said line + # registered (probably means that user held order mode + # key while panning to another view) then we just + # ignore the remove error. + pass line.delete() self._active_staged_line = None @@ -133,9 +151,9 @@ class LineEditor: # show the crosshair y line and label cursor.show_xhair() - def submit_line( + def submit_lines( self, - line: LevelLine, + lines: list[LevelLine], uuid: str, ) -> LevelLine: @@ -145,43 +163,46 @@ class LineEditor: # raise RuntimeError("No line is currently staged!?") # for now, until submission reponse arrives - line.hide_labels() + for line in lines: + line.hide_labels() # register for later lookup/deletion - self._order_lines[uuid] = line + self._order_lines[uuid] += lines - return line + return lines - def commit_line(self, uuid: str) -> LevelLine: - """Commit a "staged line" to view. + def commit_line(self, uuid: str) -> list[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: - line.show_labels() + ''' + lines = self._order_lines[uuid] + if lines: + for line in lines: + line.show_labels() + line.hide_markers() + log.debug(f'Level active for level: {line.value()}') + # TODO: other flashy things to indicate the order is active - # TODO: other flashy things to indicate the order is active - - log.debug(f'Level active for level: {line.value()}') - - return line + return lines def lines_under_cursor(self) -> list[LevelLine]: - """Get the line(s) under the cursor position. + ''' + Get the line(s) under the cursor position. - """ + ''' # Delete any hoverable under the cursor - return self.chart.linked.cursor._hovered + return self.godw.get_cursor()._hovered - def all_lines(self) -> tuple[LevelLine]: - return tuple(self._order_lines.values()) + def all_lines(self) -> list[LevelLine]: + all_lines = [] + for lines in list(self._order_lines.values()): + all_lines.extend(lines) + + return all_lines def remove_line( self, @@ -196,26 +217,27 @@ class LineEditor: ''' # try to look up line from our registry - line = self._order_lines.pop(uuid, line) - if line: + lines = self._order_lines.pop(uuid, None) + if lines: + cursor = self.godw.get_cursor() + if cursor: + for line in lines: + # if hovered remove from cursor set + hovered = cursor._hovered + if line in hovered: + hovered.remove(line) - # if hovered remove from cursor set - cursor = self.chart.linked.cursor - hovered = cursor._hovered - if line in hovered: - hovered.remove(line) + log.debug(f'deleting {line} with oid: {uuid}') + line.delete() - # make sure the xhair doesn't get left off - # just because we never got a un-hover event - cursor.show_xhair() - - log.debug(f'deleting {line} with oid: {uuid}') - line.delete() + # make sure the xhair doesn't get left off + # just because we never got a un-hover event + cursor.show_xhair() else: log.warning(f'Could not find line for {line}') - return line + return lines class SelectRect(QtGui.QGraphicsRectItem): diff --git a/piker/ui/_event.py b/piker/ui/_event.py index 9c006dc8..3edfb2ff 100644 --- a/piker/ui/_event.py +++ b/piker/ui/_event.py @@ -18,10 +18,11 @@ Qt event proxying and processing using ``trio`` mem chans. """ -from contextlib import asynccontextmanager, AsyncExitStack +from contextlib import asynccontextmanager as acm from typing import Callable import trio +from tractor.trionics import gather_contexts from PyQt5 import QtCore from PyQt5.QtCore import QEvent, pyqtBoundSignal from PyQt5.QtWidgets import QWidget @@ -155,7 +156,7 @@ class EventRelay(QtCore.QObject): return False -@asynccontextmanager +@acm async def open_event_stream( source_widget: QWidget, @@ -181,7 +182,7 @@ async def open_event_stream( source_widget.removeEventFilter(kc) -@asynccontextmanager +@acm async def open_signal_handler( signal: pyqtBoundSignal, @@ -206,7 +207,7 @@ async def open_signal_handler( yield -@asynccontextmanager +@acm async def open_handlers( source_widgets: list[QWidget], @@ -215,16 +216,14 @@ async def open_handlers( **kwargs, ) -> None: - async with ( trio.open_nursery() as n, - AsyncExitStack() as stack, + gather_contexts([ + open_event_stream(widget, event_types, **kwargs) + for widget in source_widgets + ]) as streams, ): - for widget in source_widgets: - - event_recv_stream = await stack.enter_async_context( - open_event_stream(widget, event_types, **kwargs) - ) + for widget, event_recv_stream in zip(source_widgets, streams): n.start_soon(async_handler, widget, event_recv_stream) yield diff --git a/piker/ui/_exec.py b/piker/ui/_exec.py index 1d1a9c3d..090b783a 100644 --- a/piker/ui/_exec.py +++ b/piker/ui/_exec.py @@ -20,13 +20,16 @@ Trio - Qt integration Run ``trio`` in guest mode on top of the Qt event loop. All global Qt runtime settings are mostly defined here. """ -from typing import Tuple, Callable, Dict, Any +from typing import ( + Callable, + Any, + Type, +) import platform import traceback # Qt specific import PyQt5 # noqa -import pyqtgraph as pg from pyqtgraph import QtGui from PyQt5 import QtCore # from PyQt5.QtGui import QLabel, QStatusBar @@ -37,7 +40,7 @@ from PyQt5.QtCore import ( ) import qdarkstyle from qdarkstyle import DarkPalette -# import qdarkgraystyle +# import qdarkgraystyle # TODO: play with it import trio from outcome import Error @@ -72,10 +75,11 @@ if platform.system() == "Windows": def run_qtractor( func: Callable, - args: Tuple, - main_widget: QtGui.QWidget, - tractor_kwargs: Dict[str, Any] = {}, + args: tuple, + main_widget_type: Type[QtGui.QWidget], + tractor_kwargs: dict[str, Any] = {}, window_type: QtGui.QMainWindow = None, + ) -> None: # avoids annoying message when entering debugger from qt loop pyqtRemoveInputHook() @@ -156,7 +160,7 @@ def run_qtractor( # hook into app focus change events app.focusChanged.connect(window.on_focus_change) - instance = main_widget() + instance = main_widget_type() instance.window = window # override tractor's defaults @@ -178,7 +182,7 @@ def run_qtractor( # restrict_keyboard_interrupt_to_checkpoints=True, ) - window.main_widget = main_widget + window.godwidget: GodWidget = instance window.setCentralWidget(instance) if is_windows: window.configure_to_desktop() diff --git a/piker/ui/_forms.py b/piker/ui/_forms.py index f62363f3..a6cddae9 100644 --- a/piker/ui/_forms.py +++ b/piker/ui/_forms.py @@ -644,7 +644,7 @@ def mk_fill_status_bar( # TODO: calc this height from the ``ChartnPane`` chart_h = round(parent_pane.height() * 5/8) - bar_h = chart_h * 0.375 + bar_h = chart_h * 0.375*0.9 # TODO: once things are sized to screen bar_label_font_size = label_font_size or _font.px_size - 2 diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 71797a33..b9ac32ea 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -141,13 +141,16 @@ async def handle_viewmode_kb_inputs( Qt.Key_Space, } ): - view._chart.linked.godwidget.search.focus() + godw = view._chart.linked.godwidget + godw.hist_linked.resize_sidepanes(from_linked=godw.rt_linked) + godw.search.focus() # 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() + view.linked.focus() # cancel order or clear graphics if key == Qt.Key_C or key == Qt.Key_Delete: @@ -178,17 +181,17 @@ async def handle_viewmode_kb_inputs( if key in pressed: pressed.remove(key) - # QUERY/QUOTE MODE # + # QUERY/QUOTE MODE + # ---------------- if {Qt.Key_Q}.intersection(pressed): - view.linkedsplits.cursor.in_query_mode = True + view.linked.cursor.in_query_mode = True else: - view.linkedsplits.cursor.in_query_mode = False + view.linked.cursor.in_query_mode = False # SELECTION MODE # -------------- - if shift: if view.state['mouseMode'] == ViewBox.PanMode: view.setMouseMode(ViewBox.RectMode) @@ -209,14 +212,22 @@ async def handle_viewmode_kb_inputs( # ORDER MODE # ---------- - # live vs. dark trigger + an action {buy, sell, alert} order_keys_pressed = ORDER_MODE.intersection(pressed) if order_keys_pressed: - # show the pp size label - order_mode.current_pp.show() + # TODO: it seems like maybe the composition should be + # reversed here? Like, maybe we should have the nav have + # access to the pos state and then make encapsulated logic + # that shows the right stuff on screen instead or order mode + # and position-related abstractions doing this? + + # show the pp size label only if there is + # a non-zero pos existing + tracker = order_mode.current_pp + if tracker.live_pp.size: + tracker.nav.show() # TODO: show pp config mini-params in status bar widget # mode.pp_config.show() @@ -257,8 +268,8 @@ async def handle_viewmode_kb_inputs( Qt.Key_S in pressed or order_keys_pressed or Qt.Key_O in pressed - ) and - key in NUMBER_LINE + ) + and key in NUMBER_LINE ): # hot key to set order slots size. # change edit field to current number line value, @@ -276,7 +287,7 @@ async def handle_viewmode_kb_inputs( else: # none active # hide pp label - order_mode.current_pp.hide_info() + order_mode.current_pp.nav.hide_info() # if none are pressed, remove "staged" level # line under cursor position @@ -373,7 +384,7 @@ class ChartView(ViewBox): y=True, ) - self.linkedsplits = None + self.linked = None self._chart: 'ChartPlotWidget' = None # noqa # add our selection box annotator @@ -484,7 +495,7 @@ class ChartView(ViewBox): else: mask = self.state['mouseEnabled'][:] - chart = self.linkedsplits.chart + chart = self.linked.chart # don't zoom more then the min points setting l, lbar, rbar, r = chart.bars_range() @@ -919,7 +930,7 @@ class ChartView(ViewBox): # TODO: a faster single-loop-iterator way of doing this XD chart = self._chart - linked = self.linkedsplits + linked = self.linked plots = linked.subplots | {chart.name: chart} for chart_name, chart in plots.items(): for name, flow in chart._flows.items(): diff --git a/piker/ui/_lines.py b/piker/ui/_lines.py index 697e889f..461544e7 100644 --- a/piker/ui/_lines.py +++ b/piker/ui/_lines.py @@ -18,9 +18,14 @@ Lines for orders, alerts, L2. """ +from __future__ import annotations from functools import partial from math import floor -from typing import Optional, Callable +from typing import ( + Optional, + Callable, + TYPE_CHECKING, +) import pyqtgraph as pg from pyqtgraph import Point, functions as fn @@ -37,6 +42,9 @@ from ..calc import humanize from ._label import Label from ._style import hcolor, _font +if TYPE_CHECKING: + from ._cursor import Cursor + # TODO: probably worth investigating if we can # make .boundingRect() faster: @@ -84,7 +92,7 @@ class LevelLine(pg.InfiniteLine): self._marker = None self.only_show_markers_on_hover = only_show_markers_on_hover - self.show_markers: bool = True # presuming the line is hovered at init + self.track_marker_pos: bool = False # should line go all the way to far end or leave a "margin" # space for other graphics (eg. L1 book) @@ -122,6 +130,9 @@ class LevelLine(pg.InfiniteLine): self._y_incr_mult = 1 / chart.linked.symbol.tick_size self._right_end_sc: float = 0 + # use px caching + self.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache) + def txt_offsets(self) -> tuple[int, int]: return 0, 0 @@ -216,20 +227,23 @@ class LevelLine(pg.InfiniteLine): y: float ) -> None: - '''Chart coordinates cursor tracking callback. + ''' + Chart coordinates cursor tracking callback. this is called by our ``Cursor`` type once this line is set to track the cursor: for every movement this callback is invoked to reposition the line with the current view coordinates. + ''' self.movable = True self.set_level(y) # implictly calls reposition handler def mouseDragEvent(self, ev): - """Override the ``InfiniteLine`` handler since we need more + ''' + Override the ``InfiniteLine`` handler since we need more detailed control and start end signalling. - """ + ''' cursor = self._chart.linked.cursor # hide y-crosshair @@ -281,10 +295,20 @@ class LevelLine(pg.InfiniteLine): # show y-crosshair again cursor.show_xhair() - def delete(self) -> None: - """Remove this line from containing chart/view/scene. + def get_cursor(self) -> Optional[Cursor]: - """ + chart = self._chart + cur = chart.linked.cursor + if self in cur._hovered: + return cur + + return None + + def delete(self) -> None: + ''' + Remove this line from containing chart/view/scene. + + ''' scene = self.scene() if scene: for label in self._labels: @@ -298,9 +322,8 @@ class LevelLine(pg.InfiniteLine): # remove from chart/cursor states chart = self._chart - cur = chart.linked.cursor - - if self in cur._hovered: + cur = self.get_cursor() + if cur: cur._hovered.remove(self) chart.plotItem.removeItem(self) @@ -308,8 +331,8 @@ class LevelLine(pg.InfiniteLine): def mouseDoubleClickEvent( self, ev: QtGui.QMouseEvent, - ) -> None: + ) -> None: # TODO: enter labels edit mode print(f'double click {ev}') @@ -334,30 +357,22 @@ class LevelLine(pg.InfiniteLine): line_end, marker_right, r_axis_x = self._chart.marker_right_points() - if self.show_markers and self.markers: - - p.setPen(self.pen) - qgo_draw_markers( - self.markers, - self.pen.color(), - p, - vb_left, - vb_right, - marker_right, - ) - # marker_size = self.markers[0][2] - self._maxMarkerSize = max([m[2] / 2. for m in self.markers]) - - # this seems slower when moving around - # order lines.. not sure wtf is up with that. - # for now we're just using it on the position line. - elif self._marker: + # (legacy) NOTE: at one point this seemed slower when moving around + # order lines.. not sure if that's still true or why but we've + # dropped the original hacky `.pain()` transform stuff for inf + # line markers now - check the git history if it needs to be + # reverted. + if self._marker: + if self.track_marker_pos: + # make the line end at the marker's x pos + line_end = marker_right = self._marker.pos().x() # TODO: make this label update part of a scene-aware-marker # composed annotation self._marker.setPos( QPointF(marker_right, self.scene_y()) ) + if hasattr(self._marker, 'label'): self._marker.label.update() @@ -379,16 +394,14 @@ class LevelLine(pg.InfiniteLine): def hide(self) -> None: super().hide() - if self._marker: - self._marker.hide() - # needed for ``order_line()`` lines currently - self._marker.label.hide() + mkr = self._marker + if mkr: + mkr.hide() def show(self) -> None: super().show() if self._marker: self._marker.show() - # self._marker.label.show() def scene_y(self) -> float: return self.getViewBox().mapFromView( @@ -433,17 +446,16 @@ class LevelLine(pg.InfiniteLine): cur = self._chart.linked.cursor # hovered - if (not ev.isExit()) and ev.acceptDrags(QtCore.Qt.LeftButton): - + if ( + not ev.isExit() + and ev.acceptDrags(QtCore.Qt.LeftButton) + ): # if already hovered we don't need to run again if self.mouseHovering is True: return if self.only_show_markers_on_hover: - self.show_markers = True - - if self._marker: - self._marker.show() + self.show_markers() # highlight if so configured if self.highlight_on_hover: @@ -486,11 +498,7 @@ class LevelLine(pg.InfiniteLine): cur._hovered.remove(self) if self.only_show_markers_on_hover: - self.show_markers = False - - if self._marker: - self._marker.hide() - self._marker.label.hide() + self.hide_markers() if self not in cur._trackers: cur.show_xhair(y_label_level=self.value()) @@ -502,6 +510,15 @@ class LevelLine(pg.InfiniteLine): self.update() + def hide_markers(self) -> None: + if self._marker: + self._marker.hide() + self._marker.label.hide() + + def show_markers(self) -> None: + if self._marker: + self._marker.show() + def level_line( @@ -522,9 +539,10 @@ def level_line( **kwargs, ) -> LevelLine: - """Convenience routine to add a styled horizontal line to a plot. + ''' + Convenience routine to add a styled horizontal line to a plot. - """ + ''' hl_color = color + '_light' if highlight_on_hover else color line = LevelLine( @@ -706,7 +724,7 @@ def order_line( marker = LevelMarker( chart=chart, style=marker_style, - get_level=line.value, + get_level=line.value, # callback size=marker_size, keep_in_view=False, ) @@ -715,7 +733,8 @@ def order_line( marker = line.add_marker(marker) # XXX: DON'T COMMENT THIS! - # this fixes it the artifact issue! .. of course, bounding rect stuff + # this fixes it the artifact issue! + # .. of course, bounding rect stuff line._maxMarkerSize = marker_size assert line._marker is marker @@ -736,7 +755,8 @@ def order_line( if action != 'alert': - # add a partial position label if we also added a level marker + # add a partial position label if we also added a level + # marker pp_size_label = Label( view=view, color=line.color, @@ -770,9 +790,9 @@ def order_line( # XXX: without this the pp proportion label next the marker # seems to lag? this is the same issue we had with position # lines which we handle with ``.update_graphcis()``. - # marker._on_paint=lambda marker: pp_size_label.update() marker._on_paint = lambda marker: pp_size_label.update() + # XXX: THIS IS AN UNTYPED MONKEY PATCH!?!?! marker.label = label # sanity check diff --git a/piker/ui/_position.py b/piker/ui/_position.py index 9e4c5ff4..c986022a 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -23,7 +23,11 @@ from copy import copy from dataclasses import dataclass from functools import partial from math import floor, copysign -from typing import Optional +from typing import ( + Callable, + Optional, + TYPE_CHECKING, +) # from PyQt5.QtWidgets import QStyle @@ -41,12 +45,18 @@ from ..calc import humanize, pnl, puterize from ..clearing._allocate import Allocator, Position from ..data._normalize import iterticks from ..data.feed import Feed +from ..data.types import Struct from ._label import Label from ._lines import LevelLine, order_line from ._style import _font from ._forms import FieldsForm, FillStatusBar, QLabel from ..log import get_logger +if TYPE_CHECKING: + from ._chart import ( + ChartPlotWidget, + ) + log = get_logger(__name__) _pnl_tasks: dict[str, bool] = {} @@ -58,7 +68,8 @@ async def update_pnl_from_feed( tracker: PositionTracker, ) -> None: - '''Real-time display the current pp's PnL in the appropriate label. + ''' + Real-time display the current pp's PnL in the appropriate label. ``ValueError`` if this task is spawned where there is a net-zero pp. @@ -67,7 +78,7 @@ async def update_pnl_from_feed( pp = order_mode.current_pp live = pp.live_pp - key = live.symbol.key + key = live.symbol.front_fqsn() log.info(f'Starting pnl display for {pp.alloc.account}') @@ -168,12 +179,12 @@ class SettingsPane: ) -> None: ''' - Try to apply some input setting (by the user), revert to previous setting if it fails - display new value if applied. + Try to apply some input setting (by the user), revert to + previous setting if it fails display new value if applied. ''' self.apply_setting(key, value) - self.update_status_ui(pp=self.order_mode.current_pp) + self.update_status_ui(self.order_mode.current_pp) def apply_setting( self, @@ -195,7 +206,7 @@ class SettingsPane: # hide details on the old selection old_tracker = mode.current_pp - old_tracker.hide_info() + old_tracker.nav.hide_info() # re-assign the order mode tracker account_name = value @@ -205,7 +216,7 @@ class SettingsPane: # a ``brokerd`) then error and switch back to the last # selection. if tracker is None: - sym = old_tracker.chart.linked.symbol.key + sym = old_tracker.charts[0].linked.symbol.key log.error( f'Account `{account_name}` can not be set for {sym}' ) @@ -216,8 +227,8 @@ class SettingsPane: self.order_mode.current_pp = tracker assert tracker.alloc.account == account_name self.form.fields['account'].setCurrentText(account_name) - tracker.show() - tracker.hide_info() + tracker.nav.show() + tracker.nav.hide_info() self.display_pnl(tracker) @@ -251,7 +262,9 @@ class SettingsPane: log.error( f'limit must > then current pp: {dsize}' ) - raise ValueError + # reset position size value + alloc.currency_limit = dsize + return False alloc.currency_limit = value @@ -288,22 +301,29 @@ class SettingsPane: def update_status_ui( self, - pp: PositionTracker, + tracker: PositionTracker, ) -> None: - alloc = pp.alloc + alloc = tracker.alloc slots = alloc.slots - used = alloc.slots_used(pp.live_pp) + used = alloc.slots_used(tracker.live_pp) + size = tracker.live_pp.size + dsize = tracker.live_pp.dsize # READ out settings and update the status UI / settings widgets suffix = {'currency': ' $', 'units': ' u'}[alloc.size_unit] - limit = alloc.limit() + size_unit, limit = alloc.limit_info() step_size, currency_per_slot = alloc.step_sizes() if alloc.size_unit == 'currency': step_size = currency_per_slot + if dsize >= limit: + self.apply_setting('limit', limit) + + elif size >= limit: + self.apply_setting('limit', limit) self.step_label.format( step_size=str(humanize(step_size)) + suffix @@ -320,7 +340,7 @@ class SettingsPane: self.form.fields['limit'].setText(str(limit)) # update of level marker size label based on any new settings - pp.update_from_pp() + tracker.update_from_pp() # calculate proportion of position size limit # that exists and display in fill bar @@ -332,7 +352,7 @@ class SettingsPane: # min(round(prop * slots), slots) min(used, slots) ) - self.update_account_icons({alloc.account: pp.live_pp}) + self.update_account_icons({alloc.account: tracker.live_pp}) def update_account_icons( self, @@ -358,7 +378,9 @@ class SettingsPane: tracker: PositionTracker, ) -> None: - '''Display the PnL for the current symbol and personal positioning (pp). + ''' + Display the PnL for the current symbol and personal positioning + (pp). If a position is open start a background task which will real-time update the pnl label in the settings pane. @@ -372,7 +394,7 @@ class SettingsPane: if size: # last historical close price - last = feed.shm.array[-1][['close']][0] + last = feed.rt_shm.array[-1][['close']][0] pnl_value = copysign(1, size) * pnl( tracker.live_pp.ppu, last, @@ -380,8 +402,9 @@ class SettingsPane: # maybe start update task global _pnl_tasks - if sym.key not in _pnl_tasks: - _pnl_tasks[sym.key] = True + fqsn = sym.front_fqsn() + if fqsn not in _pnl_tasks: + _pnl_tasks[fqsn] = True self.order_mode.nursery.start_soon( update_pnl_from_feed, feed, @@ -393,15 +416,15 @@ class SettingsPane: self.pnl_label.format(pnl=pnl_value) -def position_line( +def pp_line( - chart: 'ChartPlotWidget', # noqa + chart: ChartPlotWidget, # noqa size: float, level: float, color: str, + marker: LevelMarker, orient_v: str = 'bottom', - marker: Optional[LevelMarker] = None, ) -> LevelLine: ''' @@ -432,28 +455,20 @@ def position_line( show_markers=False, ) - if marker: - # configure marker to position data + # TODO: use `LevelLine.add_marker()`` for this instead? + # set marker color to same as line + marker.setPen(line.currentPen) + marker.setBrush(fn.mkBrush(line.currentPen.color())) + marker.level = level + marker.update() + marker.show() - if size > 0: # long - style = '|<' # point "up to" the line - elif size < 0: # short - style = '>|' # point "down to" the line + line._marker = marker + line.track_marker_pos = True - marker.style = style - - # set marker color to same as line - marker.setPen(line.currentPen) - marker.setBrush(fn.mkBrush(line.currentPen.color())) - marker.level = level - marker.update() - marker.show() - - # show position marker on view "edge" when out of view - vb = line.getViewBox() - vb.sigRangeChanged.connect(marker.position_in_view) - - line.set_level(level) + # show position marker on view "edge" when out of view + vb = line.getViewBox() + vb.sigRangeChanged.connect(marker.position_in_view) return line @@ -466,85 +481,338 @@ _derivs = ( ) +# TODO: move into annoate module? +def mk_level_marker( + chart: ChartPlotWidget, + size: float, + level: float, + on_paint: Callable, + +) -> LevelMarker: + ''' + Allocate and return nan arrow graphics element. + + ''' + # scale marker size with dpi-aware font size + font_size = _font.font.pixelSize() + arrow_size = floor(1.375 * font_size) + arrow = LevelMarker( + chart=chart, + style='|<', # actual style is set by caller based on size + get_level=level, + size=arrow_size, + on_paint=on_paint, + ) + arrow.show() + return arrow + + +class Nav(Struct): + ''' + Composite for holding a set of charts and respective (by order) + graphics-elements which display position information acting as sort + of "navigation" system for a position. + + ''' + charts: dict[int, ChartPlotWidget] + pp_labels: dict[str, Label] = {} + size_labels: dict[str, Label] = {} + lines: dict[str, Optional[LevelLine]] = {} + level_markers: dict[str, Optional[LevelMarker]] = {} + color: str = 'default_lightest' + + def update_ui( + self, + account: str, + price: float, + size: float, + slots_used: float, + size_digits: Optional[int] = None, + + ) -> None: + ''' + Update personal position level line. + + ''' + for key, chart in self.charts.items(): + size_digits = size_digits or chart.linked.symbol.lot_size_digits + line = self.lines.get(key) + level_marker = self.level_markers[key] + pp_label = self.pp_labels[key] + + if size: + # create and show a pp line if none yet exists + if line is None: + arrow = self.level_markers[key] + line = pp_line( + chart=chart, + level=price, + size=size, + color=self.color, + marker=arrow, + ) + self.lines[key] = line + + # modify existing indicator line + line.set_level(price) + + # update LHS sizing label + line.update_labels({ + 'size': size, + 'size_digits': size_digits, + 'fiat_size': round(price * size, ndigits=2), + + # TODO: per account lines on a single (or very + # related) symbol + 'account': account, + }) + line.show() + + # always show arrow-marker when a non-zero + # pos size. + level_marker.show() + + # configure marker to position data + if size > 0: # long + # point "up to" the line + level_marker.style = '|<' + + elif size < 0: # short + # point "down to" the line + level_marker.style = '>|' + + # remove line from view for a net-zero pos + else: + self.hide() + + # label updates + size_label = self.size_labels[key] + size_label.fields['slots_used'] = slots_used + size_label.render() + + # set arrow marker to correct level + level_marker.level = price + + # these updates are critical to avoid lag on view/scene changes + # TODO: couldn't we integrate this into + # a ``.inter_ui_elements_and_update()``? + level_marker.update() # trigger paint + pp_label.update() + size_label.update() + + def level(self) -> float: + ''' + Return the "level" value from the underlying ``LevelLine`` which tracks + the "average position" price defined the represented position instance. + + ''' + if self.lines: + for key, line in self.lines.items(): + if line: + return line.value() + return 0 + + def iter_ui_elements(self) -> tuple[ + Label, + Label, + LevelLine, + LevelMarker, + ]: + for key, chart in self.charts.items(): + yield ( + self.pp_labels[key], + self.size_labels[key], + self.lines.get(key), + self.level_markers[key], + ) + + def show(self) -> None: + ''' + Show all UI elements on all managed charts. + + ''' + for ( + pp_label, + size_label, + line, + level_marker, + ) in self.iter_ui_elements(): + + # NOTE: be sure to re-trigger arrow/label placement in case + # a new sidepane or other widget (like the search bar) was + # dynamically swapped into the chart-row-widget-space in + # which case we want to reposition in the view but including + # the new x-distance added by that sidepane. See details in + # ``LevelMarker.position_in_view()`` but more less ``. + # ``ChartPlotWidget.self.marker_right_points()`` gets called + # which itself eventually calls `.getAxis.pos().x()` and + # it's THIS that needs to be called **AFTER** the sidepane + # has been added.. + level_marker.show() + level_marker.position_in_view() + + # labels + pp_label.show() + size_label.show() + + if line: + line.show() + line.show_labels() + + def hide(self) -> None: + for ( + pp_label, + size_label, + line, + level_marker, + ) in self.iter_ui_elements(): + pp_label.hide() + level_marker.hide() + size_label.hide() + if line: + line.hide() + + def update_graphics( + self, + marker: LevelMarker, + ) -> None: + ''' + Update all labels callback. + + Meant to be called from the marker ``.paint()`` + for immediate, lag free label draws. + + ''' + for ( + pp_label, + size_label, + line, + level_marker, + ) in self.iter_ui_elements(): + + pp_label.update() + size_label.update() + + # XXX: can't call this because it causes a recursive paint/render + # level_marker.update() + + def hide_info(self) -> None: + ''' + Hide details (just size label?) of position nav elements. + + ''' + for ( + pp_label, + size_label, + line, + level_marker, + ) in self.iter_ui_elements(): + + size_label.hide() + if line: + line.hide_labels() + + class PositionTracker: ''' - Track and display real-time positions for a single symbol - over multiple accounts on a single chart. + Track and display real-time positions for a single asset-symbol + held in a single account, normally shown on a single chart. Graphically composed of a level line and marker as well as labels for indcating current position information. Updates are made to the corresponding "settings pane" for the chart's "order mode" UX. ''' - # inputs - chart: 'ChartPlotWidget' # noqa - alloc: Allocator startup_pp: Position live_pp: Position - - # allocated - pp_label: Label - size_label: Label - line: Optional[LevelLine] = None - - _color: str = 'default_lightest' + nav: Nav # holds all UI elements across all charts def __init__( self, - chart: 'ChartPlotWidget', # noqa + charts: list[ChartPlotWidget], alloc: Allocator, startup_pp: Position, ) -> None: - self.chart = chart - + nav = self.nav = Nav(charts={id(chart): chart for chart in charts}) self.alloc = alloc self.startup_pp = startup_pp self.live_pp = copy(startup_pp) - view = chart.getViewBox() + # TODO: maybe add this as a method ``Nav.add_chart()`` + # init all UI elements + for key, chart in nav.charts.items(): + view = chart.getViewBox() - # literally the 'pp' (pee pee) label that's always in view - self.pp_label = pp_label = Label( - view=view, - fmt_str='pp', - color=self._color, - update_on_range_change=False, - ) + arrow = mk_level_marker( + chart=chart, + size=1, + level=nav.level, + on_paint=nav.update_graphics, + ) - # create placeholder 'up' level arrow - self._level_marker = None - self._level_marker = self.level_marker(size=1) + # TODO: we really need some kinda "spacing" manager for all + # this stuff... + def offset_from_yaxis() -> float: + ''' + If no L1 labels are present beside the x-axis place + the line label offset from the y-axis just enough to avoid + label overlap with any sticky labels. - pp_label.scene_anchor = partial( - gpath_pin, - gpath=self._level_marker, - label=pp_label, - ) - pp_label.render() + ''' + x = chart.marker_right_points()[1] + if chart._max_l1_line_len == 0: + mkw = pp_label.txt.boundingRect().width() + x -= 1.5 * mkw - self.size_label = size_label = Label( - view=view, - color=self._color, + return x - # this is "static" label - # update_on_range_change=False, - fmt_str='\n'.join(( - ':{slots_used:.1f}x', - )), + arrow.scene_x = offset_from_yaxis + view.scene().addItem(arrow) + arrow.hide() # never show on startup + nav.level_markers[key] = arrow - fields={ - 'slots_used': 0, - }, - ) - size_label.render() + # literally the 'pp' (pee pee) "position price" label that's + # always in view + pp_label = Label( + view=view, + fmt_str='pp', + color=nav.color, + update_on_range_change=False, + ) + pp_label.render() + nav.pp_labels[key] = pp_label - size_label.scene_anchor = partial( - pp_tight_and_right, - label=self.pp_label, - ) + size_label = Label( + view=view, + color=self.nav.color, + + # this is "static" label + # update_on_range_change=False, + fmt_str='\n'.join(( + ':{slots_used:.1f}x', + )), + + fields={ + 'slots_used': 0, + }, + ) + size_label.render() + size_label.scene_anchor = partial( + pp_tight_and_right, + label=pp_label, + ) + nav.size_labels[key] = size_label + + pp_label.scene_anchor = partial( + gpath_pin, + gpath=arrow, + label=pp_label, + ) + + nav.show() @property def pane(self) -> FieldsForm: @@ -554,21 +822,6 @@ class PositionTracker: ''' return self.chart.linked.godwidget.pp_pane - def update_graphics( - self, - marker: LevelMarker - - ) -> None: - ''' - Update all labels. - - Meant to be called from the maker ``.paint()`` - for immediate, lag free label draws. - - ''' - self.pp_label.update() - self.size_label.update() - def update_from_pp( self, position: Optional[Position] = None, @@ -621,142 +874,22 @@ class PositionTracker: if asset_type in _derivs: alloc.slots = alloc.units_limit - self.update_line( + self.nav.update_ui( + self.alloc.account, pp.ppu, pp.size, - self.chart.linked.symbol.lot_size_digits, + round(alloc.slots_used(pp), ndigits=1), # slots used ) - # label updates - self.size_label.fields['slots_used'] = round( - alloc.slots_used(pp), ndigits=1) - self.size_label.render() - - if pp.size == 0: - self.hide() - - else: - self._level_marker.level = pp.ppu - - # these updates are critical to avoid lag on view/scene changes - self._level_marker.update() # trigger paint - self.pp_label.update() - self.size_label.update() - - self.show() - - # don't show side and status widgets unless - # order mode is "engaged" (which done via input controls) - self.hide_info() - - def level(self) -> float: - if self.line: - return self.line.value() - else: - return 0 - - def show(self) -> None: if self.live_pp.size: - self.line.show() - self.line.show_labels() + # print("SHOWING NAV") + self.nav.show() - self._level_marker.show() - self.pp_label.show() - self.size_label.show() + # if pp.size == 0: + else: + # print("HIDING NAV") + self.nav.hide() - def hide(self) -> None: - self.pp_label.hide() - self._level_marker.hide() - self.size_label.hide() - if self.line: - self.line.hide() - - def hide_info(self) -> None: - '''Hide details (right now just size label?) of position. - - ''' - self.size_label.hide() - if self.line: - self.line.hide_labels() - - # TODO: move into annoate module - def level_marker( - self, - size: float, - - ) -> LevelMarker: - - if self._level_marker: - self._level_marker.delete() - - # arrow marker - # scale marker size with dpi-aware font size - font_size = _font.font.pixelSize() - - # scale marker size with dpi-aware font size - arrow_size = floor(1.375 * font_size) - - if size > 0: - style = '|<' - - elif size < 0: - style = '>|' - - arrow = LevelMarker( - chart=self.chart, - style=style, - get_level=self.level, - size=arrow_size, - on_paint=self.update_graphics, - ) - - self.chart.getViewBox().scene().addItem(arrow) - arrow.show() - - return arrow - - def update_line( - self, - price: float, - size: float, - size_digits: int, - - ) -> None: - '''Update personal position level line. - - ''' - # do line update - line = self.line - - if size: - if line is None: - - # create and show a pp line - line = self.line = position_line( - chart=self.chart, - level=price, - size=size, - color=self._color, - marker=self._level_marker, - ) - - else: - - line.set_level(price) - self._level_marker.level = price - self._level_marker.update() - - # update LHS sizing label - line.update_labels({ - 'size': size, - 'size_digits': size_digits, - 'fiat_size': round(price * size, ndigits=2), - - # TODO: per account lines on a single (or very related) symbol - 'account': self.alloc.account, - }) - line.show() - - elif line: # remove pp line from view if it exists on a net-zero pp - line.delete() - self.line = None + # don't show side and status widgets unless + # order mode is "engaged" (which done via input controls) + self.nav.hide_info() diff --git a/piker/ui/_search.py b/piker/ui/_search.py index 8cac6b1a..bbe88320 100644 --- a/piker/ui/_search.py +++ b/piker/ui/_search.py @@ -35,9 +35,13 @@ from collections import defaultdict from contextlib import asynccontextmanager from functools import partial from typing import ( - Optional, Callable, - Awaitable, Sequence, - Any, AsyncIterator + Optional, + Callable, + Awaitable, + Sequence, + Any, + AsyncIterator, + Iterator, ) import time # from pprint import pformat @@ -119,7 +123,7 @@ class CompleterView(QTreeView): # TODO: size this based on DPI font self.setIndentation(_font.px_size) - # self.setUniformRowHeights(True) + self.setUniformRowHeights(True) # self.setColumnWidth(0, 3) # self.setVerticalBarPolicy(Qt.ScrollBarAlwaysOff) # self.setSizeAdjustPolicy(QAbstractScrollArea.AdjustIgnored) @@ -138,13 +142,15 @@ class CompleterView(QTreeView): model.setHorizontalHeaderLabels(labels) self._font_size: int = 0 # pixels + self._init: bool = False async def on_pressed(self, idx: QModelIndex) -> None: - '''Mouse pressed on view handler. + ''' + Mouse pressed on view handler. ''' search = self.parent() - await search.chart_current_item(clear_to_cache=False) + await search.chart_current_item() search.focus() def set_font_size(self, size: int = 18): @@ -156,56 +162,64 @@ class CompleterView(QTreeView): self.setStyleSheet(f"font: {size}px") - # def resizeEvent(self, event: 'QEvent') -> None: - # event.accept() - # super().resizeEvent(event) + def resize_to_results( + self, + w: Optional[float] = 0, + h: Optional[float] = None, - def on_resize(self) -> None: - ''' - Resize relay event from god. - - ''' - self.resize_to_results() - - def resize_to_results(self): + ) -> None: model = self.model() cols = model.columnCount() - # rows = model.rowCount() + cidx = self.selectionModel().currentIndex() + rows = model.rowCount() + self.expandAll() + + # compute the approx height in pixels needed to include + # all result rows in view. + row_h = rows_h = self.rowHeight(cidx) * (rows + 1) + for idx, item in self.iter_df_rows(): + row_h = self.rowHeight(idx) + rows_h += row_h + # print(f'row_h: {row_h}\nrows_h: {rows_h}') + + # TODO: could we just break early here on detection + # of ``rows_h >= h``? col_w_tot = 0 for i in range(cols): + # only slap in a rows's height's worth + # of padding once at startup.. no idea + if ( + not self._init + and row_h + ): + col_w_tot = row_h + self._init = True + self.resizeColumnToContents(i) col_w_tot += self.columnWidth(i) - win = self.window() - win_h = win.height() - edit_h = self.parent().bar.height() - sb_h = win.statusBar().height() + # NOTE: if the heigh `h` set here is **too large** then the + # resize event will perpetually trigger as the window causes + # some kind of recompute of callbacks.. so we have to ensure + # it's limited. + if h: + h: int = round(h) + abs_mx = round(0.91 * h) + self.setMaximumHeight(abs_mx) - # TODO: probably make this more general / less hacky - # we should figure out the exact number of rows to allow - # inclusive of search bar and header "rows", in pixel terms. - # Eventually when we have an "info" widget below the results we - # will want space for it and likely terminating the results-view - # space **exactly on a row** would be ideal. - # if row_px > 0: - # rows = ceil(window_h / row_px) - 4 - # else: - # rows = 16 - # self.setFixedHeight(rows * row_px) - # self.resize(self.width(), rows * row_px) + if rows_h <= abs_mx: + # self.setMinimumHeight(rows_h) + self.setMinimumHeight(rows_h) + # self.setFixedHeight(rows_h) - # NOTE: if the heigh set here is **too large** then the resize - # event will perpetually trigger as the window causes some kind - # of recompute of callbacks.. so we have to ensure it's limited. - h = win_h - (edit_h + 1.666*sb_h) - assert h > 0 - self.setFixedHeight(round(h)) + else: + self.setMinimumHeight(abs_mx) - # size to width of longest result seen thus far - # TODO: should we always dynamically scale to longest result? - if self.width() < col_w_tot: - self.setFixedWidth(col_w_tot) + # dyncamically size to width of longest result seen + curr_w = self.width() + if curr_w < col_w_tot: + self.setMinimumWidth(col_w_tot) self.update() @@ -331,6 +345,23 @@ class CompleterView(QTreeView): item = model.itemFromIndex(idx) yield idx, item + def iter_df_rows( + self, + iparent: QModelIndex = QModelIndex(), + + ) -> Iterator[tuple[QModelIndex, QStandardItem]]: + + model = self.model() + isections = model.rowCount(iparent) + for i in range(isections): + idx = model.index(i, 0, iparent) + item = model.itemFromIndex(idx) + yield idx, item + + if model.hasChildren(idx): + # recursively yield child items depth-first + yield from self.iter_df_rows(idx) + def find_section( self, section: str, @@ -354,7 +385,8 @@ class CompleterView(QTreeView): status_field: str = None, ) -> None: - '''Clear all result-rows from under the depth = 1 section. + ''' + Clear all result-rows from under the depth = 1 section. ''' idx = self.find_section(section) @@ -375,8 +407,6 @@ class CompleterView(QTreeView): else: model.setItem(idx.row(), 1, QStandardItem()) - self.resize_to_results() - return idx else: return None @@ -444,9 +474,22 @@ class CompleterView(QTreeView): self.show_matches() - def show_matches(self) -> None: + def show_matches( + self, + wh: Optional[tuple[float, float]] = None, + + ) -> None: + + if wh: + self.resize_to_results(*wh) + else: + # case where it's just an update from results and *NOT* + # a resize of some higher level parent-container widget. + search = self.parent() + w, h = search.space_dims() + self.resize_to_results(w=w, h=h) + self.show() - self.resize_to_results() class SearchBar(Edit): @@ -466,18 +509,15 @@ class SearchBar(Edit): self.godwidget = godwidget super().__init__(parent, **kwargs) self.view: CompleterView = view - godwidget._widgets[view.mode_name] = view - - def show(self) -> None: - super().show() - self.view.show_matches() def unfocus(self) -> None: self.parent().hide() self.clearFocus() + def hide(self) -> None: if self.view: self.view.hide() + super().hide() class SearchWidget(QtWidgets.QWidget): @@ -496,15 +536,16 @@ class SearchWidget(QtWidgets.QWidget): parent=None, ) -> None: - super().__init__(parent or godwidget) + super().__init__(parent) # size it as we specify self.setSizePolicy( QtWidgets.QSizePolicy.Fixed, - QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Fixed, ) self.godwidget = godwidget + godwidget.reg_for_resize(self) self.vbox = QtWidgets.QVBoxLayout(self) self.vbox.setContentsMargins(0, 4, 4, 0) @@ -554,17 +595,22 @@ class SearchWidget(QtWidgets.QWidget): self.vbox.setAlignment(self.view, Qt.AlignTop | Qt.AlignLeft) def focus(self) -> None: - - if self.view.model().rowCount(QModelIndex()) == 0: - # fill cache list if nothing existing - self.view.set_section_entries( - 'cache', - list(reversed(self.godwidget._chart_cache)), - clear_all=True, - ) - - self.bar.focus() self.show() + self.bar.focus() + + def show_only_cache_entries(self) -> None: + ''' + Clear the search results view and show only cached (aka recently + loaded with active data) feeds in the results section. + + ''' + godw = self.godwidget + self.view.set_section_entries( + 'cache', + list(reversed(godw._chart_cache)), + # remove all other completion results except for cache + clear_all=True, + ) def get_current_item(self) -> Optional[tuple[str, str]]: '''Return the current completer tree selection as @@ -603,7 +649,8 @@ class SearchWidget(QtWidgets.QWidget): clear_to_cache: bool = True, ) -> Optional[str]: - '''Attempt to load and switch the current selected + ''' + Attempt to load and switch the current selected completion result to the affiliated chart app. Return any loaded symbol. @@ -614,11 +661,11 @@ class SearchWidget(QtWidgets.QWidget): return None provider, symbol = value - chart = self.godwidget + godw = self.godwidget log.info(f'Requesting symbol: {symbol}.{provider}') - await chart.load_symbol( + await godw.load_symbol( provider, symbol, 'info', @@ -635,18 +682,46 @@ class SearchWidget(QtWidgets.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.linkedsplits) - - self.view.set_section_entries( - 'cache', - values=list(reversed(chart._chart_cache)), - - # remove all other completion results except for cache - clear_all=True, + godw.set_chart_symbol( + fqsn, ( + godw.hist_linked, + godw.rt_linked, + ) ) + self.show_only_cache_entries() + self.bar.focus() return fqsn + def space_dims(self) -> tuple[float, float]: + ''' + Compute and return the "available space dimentions" for this + search widget in terms of px space for results by return the + pair of width and height. + + ''' + # XXX: dun need dis rite? + # win = self.window() + # win_h = win.height() + # sb_h = win.statusBar().height() + godw = self.godwidget + hl = godw.hist_linked + edit_h = self.bar.height() + h = hl.height() - edit_h + w = hl.width() + return w, h + + def on_resize(self) -> None: + ''' + Resize relay event from god, resize all child widgets. + + Right now this is just view to contents and/or the fast chart + height. + + ''' + w, h = self.space_dims() + self.bar.view.show_matches(wh=(w, h)) + _search_active: trio.Event = trio.Event() _search_enabled: bool = False @@ -712,10 +787,11 @@ async def fill_results( max_pause_time: float = 6/16 + 0.001, ) -> None: - """Task to search through providers and fill in possible + ''' + Task to search through providers and fill in possible completion results. - """ + ''' global _search_active, _search_enabled, _searcher_cache bar = search.bar @@ -729,6 +805,10 @@ async def fill_results( matches = defaultdict(list) has_results: defaultdict[str, set[str]] = defaultdict(set) + # show cached feed list at startup + search.show_only_cache_entries() + search.on_resize() + while True: await _search_active.wait() period = None @@ -742,7 +822,7 @@ async def fill_results( pattern = await recv_chan.receive() period = time.time() - wait_start - print(f'{pattern} after {period}') + log.debug(f'{pattern} after {period}') # during fast multiple key inputs, wait until a pause # (in typing) to initiate search @@ -841,8 +921,7 @@ async def handle_keyboard_input( godwidget = search.godwidget view = bar.view view.set_font_size(bar.dpi_font.px_size) - - send, recv = trio.open_memory_channel(16) + send, recv = trio.open_memory_channel(616) async with trio.open_nursery() as n: @@ -857,6 +936,10 @@ async def handle_keyboard_input( ) ) + bar.focus() + search.show_only_cache_entries() + await trio.sleep(0) + async for kbmsg in recv_chan: event, etype, key, mods, txt = kbmsg.to_tuple() @@ -867,10 +950,11 @@ async def handle_keyboard_input( ctl = True if key in (Qt.Key_Enter, Qt.Key_Return): - - await search.chart_current_item(clear_to_cache=True) _search_enabled = False - continue + await search.chart_current_item(clear_to_cache=True) + search.show_only_cache_entries() + view.show_matches() + search.focus() elif not ctl and not bar.text(): # if nothing in search text show the cache @@ -887,7 +971,7 @@ async def handle_keyboard_input( Qt.Key_Space, # i feel like this is the "native" one Qt.Key_Alt, }: - search.bar.unfocus() + bar.unfocus() # kill the search and focus back on main chart if godwidget: @@ -935,9 +1019,10 @@ async def handle_keyboard_input( if item: parent_item = item.parent() + # if we're in the cache section and thus the next + # selection is a cache item, switch and show it + # immediately since it should be very fast. if parent_item and parent_item.text() == 'cache': - - # if it's a cache item, switch and show it immediately await search.chart_current_item(clear_to_cache=False) elif not ctl: diff --git a/piker/ui/_window.py b/piker/ui/_window.py index 6a39b0c5..e574da23 100644 --- a/piker/ui/_window.py +++ b/piker/ui/_window.py @@ -21,7 +21,11 @@ Qt main window singletons and stuff. import os import signal import time -from typing import Callable, Optional, Union +from typing import ( + Callable, + Optional, + Union, +) import uuid from pyqtgraph import QtGui @@ -30,6 +34,7 @@ from PyQt5.QtWidgets import QLabel, QStatusBar from ..log import get_logger from ._style import _font_small, hcolor +from ._chart import GodWidget log = get_logger(__name__) @@ -153,7 +158,8 @@ class MainWindow(QtGui.QMainWindow): # XXX: for tiling wms this should scale # with the alloted window size. # TODO: detect for tiling and if untrue set some size? - size = (300, 500) + # size = (300, 500) + godwidget: GodWidget title = 'piker chart (ur symbol is loading bby)' @@ -162,6 +168,9 @@ class MainWindow(QtGui.QMainWindow): # self.setMinimumSize(*self.size) self.setWindowTitle(self.title) + # set by runtime after `trio` is engaged. + self.godwidget: Optional[GodWidget] = None + self._status_bar: QStatusBar = None self._status_label: QLabel = None self._size: Optional[tuple[int, int]] = None @@ -248,9 +257,10 @@ class MainWindow(QtGui.QMainWindow): self.set_mode_name(name) def current_screen(self) -> QtGui.QScreen: - """Get a frickin screen (if we can, gawd). + ''' + Get a frickin screen (if we can, gawd). - """ + ''' app = QtGui.QApplication.instance() for _ in range(3): @@ -284,7 +294,7 @@ class MainWindow(QtGui.QMainWindow): ''' # https://stackoverflow.com/a/18975846 if not size and not self._size: - app = QtGui.QApplication.instance() + # app = QtGui.QApplication.instance() geo = self.current_screen().geometry() h, w = geo.height(), geo.width() # use approx 1/3 of the area of the screen by default @@ -292,6 +302,33 @@ class MainWindow(QtGui.QMainWindow): self.resize(*size or self._size) + def resizeEvent(self, event: QtCore.QEvent) -> None: + if ( + # event.spontaneous() + event.oldSize().height == event.size().height + ): + event.ignore() + return + + # XXX: uncomment for debugging.. + # attrs = {} + # for key in dir(event): + # if key == '__dir__': + # continue + # attr = getattr(event, key) + # try: + # attrs[key] = attr() + # except TypeError: + # attrs[key] = attr + + # from pprint import pformat + # print( + # f'{pformat(attrs)}\n' + # f'WINDOW RESIZE: {self.size()}\n\n' + # ) + self.godwidget.on_win_resize(event) + event.accept() + # singleton app per actor _qt_win: QtGui.QMainWindow = None diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index f83787ec..d2196b69 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -18,13 +18,19 @@ Chart trading, the only way to scalp. """ +from __future__ import annotations from contextlib import asynccontextmanager from dataclasses import dataclass, field from functools import partial from pprint import pformat import platform import time -from typing import Optional, Dict, Callable, Any +from typing import ( + Optional, + Callable, + Any, + TYPE_CHECKING, +) import uuid import tractor @@ -60,6 +66,12 @@ from ..clearing._messages import ( from ._forms import open_form_input_handling +if TYPE_CHECKING: + from ._chart import ( + ChartPlotWidget, + GodWidget, + ) + log = get_logger(__name__) @@ -73,10 +85,10 @@ class Dialog(Struct): uuid: str order: Order symbol: Symbol - line: LevelLine + lines: list[LevelLine] last_status_close: Callable = lambda: None msgs: dict[str, dict] = {} - fills: Dict[str, Any] = {} + fills: dict[str, Any] = {} @dataclass @@ -100,8 +112,11 @@ class OrderMode: mouse click and drag -> modify current order under cursor ''' - chart: 'ChartPlotWidget' # type: ignore # noqa - nursery: trio.Nursery + godw: GodWidget + feed: Feed + chart: ChartPlotWidget # type: ignore # noqa + hist_chart: ChartPlotWidget # type: ignore # noqa + nursery: trio.Nursery # used by ``ui._position`` code? quote_feed: Feed book: OrderBook lines: LineEditor @@ -162,14 +177,15 @@ class OrderMode: def line_from_order( self, order: Order, + chart: Optional[ChartPlotWidget] = None, **line_kwargs, ) -> LevelLine: level = order.price - line = order_line( - self.chart, + line = order_line( + chart or self.chart, # TODO: convert these values into human-readable form # (i.e. with k, m, M, B) type embedded suffixes level=level, @@ -211,24 +227,61 @@ class OrderMode: return line + def lines_from_order( + self, + order: Order, + **line_kwargs, + + ) -> list[LevelLine]: + + lines: list[LevelLine] = [] + for chart, kwargs in [ + (self.chart, {}), + (self.hist_chart, {'only_show_markers_on_hover': True}), + ]: + kwargs.update(line_kwargs) + line = self.line_from_order( + order=order, + chart=chart, + **kwargs, + ) + lines.append(line) + + return lines + def stage_order( self, action: str, trigger_type: str, - ) -> None: - '''Stage an order for submission. + ) -> list[LevelLine]: + ''' + Stage an order for submission by showing level lines and + configuring the order request message dynamically based on + allocator settings. ''' # not initialized yet - chart = self.chart - cursor = chart.linked.cursor - if not (chart and cursor and cursor.active_plot): + cursor = self.godw.get_cursor() + if not cursor: + return + + chart = cursor.linked.chart + if ( + not chart + and cursor + and cursor.active_plot + ): return chart = cursor.active_plot price = cursor._datum_xy[1] + if not price: + # zero prices are not supported by any means + # since that's illogical / a no-op. + return + symbol = self.chart.linked.symbol order = self._staged_order = Order( @@ -242,27 +295,43 @@ class OrderMode: exec_mode=trigger_type, # dark or live ) + # TODO: staged line mirroring? - need to keep track of multiple + # staged lines in editor - need to call + # `LineEditor.unstage_line()` on all staged lines.. + # lines = self.lines_from_order( + line = self.line_from_order( order, + chart=chart, show_markers=True, + # 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, + # don't highlight the "staging" line highlight_on_hover=False, + # prevent flickering of marker while moving/tracking cursor only_show_markers_on_hover=False, ) - line = self.lines.stage_line(line) - - # hide crosshair y-line and label - cursor.hide_xhair() + self.lines.stage_line(line) # add line to cursor trackers cursor._trackers.add(line) + # TODO: see above about mirroring. + # for line in lines: + # if line._chart is chart: + # self.lines.stage_line(line) + # cursor._trackers.add(line) + # break + + # hide crosshair y-line and label + cursor.hide_xhair() + return line def submit_order( @@ -285,13 +354,10 @@ class OrderMode: order.symbol = order.symbol.front_fqsn() - line = self.line_from_order( + lines = self.lines_from_order( order, - show_markers=True, - only_show_markers_on_hover=True, ) - # register the "submitted" line under the cursor # to be displayed when above order ack arrives # (means the marker graphic doesn't show on screen until the @@ -302,8 +368,8 @@ class OrderMode: # maybe place a grey line in "submission" mode # which will be updated to it's appropriate action # color once the submission ack arrives. - self.lines.submit_line( - line=line, + self.lines.submit_lines( + lines=lines, uuid=order.oid, ) @@ -311,24 +377,25 @@ class OrderMode: uuid=order.oid, order=order, symbol=order.symbol, - line=line, + lines=lines, last_status_close=self.multistatus.open_status( f'submitting {order.exec_mode}-{order.action}', final_msg=f'submitted {order.exec_mode}-{order.action}', clear_on_next=True, ) ) - - # TODO: create a new ``OrderLine`` with this optional var defined - line.dialog = dialog - # enter submission which will be popped once a response # from the EMS is received to move the order to a different# status self.dialogs[order.oid] = dialog - # hook up mouse drag handlers - line._on_drag_start = self.order_line_modify_start - line._on_drag_end = self.order_line_modify_complete + for line in lines: + + # TODO: create a new ``OrderLine`` with this optional var defined + line.dialog = dialog + + # hook up mouse drag handlers + line._on_drag_start = self.order_line_modify_start + line._on_drag_end = self.order_line_modify_complete # send order cmd to ems if send_msg: @@ -350,7 +417,7 @@ class OrderMode: ) -> None: - print(f'Line modify: {line}') + log.info(f'Order modify: {line}') # cancel original order until new position is found? # TODO: make a config option for this behaviour.. @@ -361,8 +428,9 @@ class OrderMode: ) -> None: level = line.value() - # updateb by level change callback set in ``.line_from_order()`` - size = line.dialog.order.size + # updated by level change callback set in ``.line_from_order()`` + dialog = line.dialog + size = dialog.order.size self.book.update( uuid=line.dialog.uuid, @@ -370,8 +438,13 @@ class OrderMode: size=size, ) - # ems response loop handlers + # adjust corresponding slow/fast chart line + # to match level + for ln in dialog.lines: + if ln is not line: + ln.set_level(line.value()) + # EMS response msg handlers def on_submit( self, uuid: str @@ -383,13 +456,18 @@ class OrderMode: Commit the order line and registered order uuid, store ack time stamp. ''' - line = self.lines.commit_line(uuid) + lines = self.lines.commit_line(uuid) # a submission is the start of a new order dialog dialog = self.dialogs[uuid] - dialog.line = line + dialog.lines = lines dialog.last_status_close() + for line in lines: + # hide any lines not currently moused-over + if not line.get_cursor(): + line.hide_labels() + return dialog def on_fill( @@ -415,17 +493,26 @@ class OrderMode: ''' dialog = self.dialogs[uuid] - line = dialog.line - if line: - self.arrows.add( - uuid, - arrow_index, - price, - pointing=pointing, - color=line.color - ) + lines = dialog.lines + # XXX: seems to fail on certain types of races? + # assert len(lines) == 2 + if lines: + _, _, ratio = self.feed.get_ds_info() + for i, chart in [ + (arrow_index, self.chart), + (self.feed.startup_hist_index + round(arrow_index/ratio), + self.hist_chart) + ]: + self.arrows.add( + chart.plotItem, + uuid, + i, + price, + pointing=pointing, + color=lines[0].color + ) else: - log.warn("No line for order {uuid}!?") + log.warn("No line(s) for order {uuid}!?") async def on_exec( self, @@ -486,7 +573,8 @@ class OrderMode: ) def cancel_all_orders(self) -> list[str]: - '''Cancel all orders for the current chart. + ''' + Cancel all orders for the current chart. ''' return self.cancel_orders_from_lines( @@ -568,7 +656,7 @@ class OrderMode: async def open_order_mode( feed: Feed, - chart: 'ChartPlotWidget', # noqa + godw: GodWidget, fqsn: str, started: trio.Event, @@ -581,6 +669,9 @@ async def open_order_mode( state, mostly graphics / UI. ''' + chart = godw.rt_linked.chart + hist_chart = godw.hist_linked.chart + multistatus = chart.window().status_bar done = multistatus.open_status('starting order mode..') @@ -606,11 +697,10 @@ async def open_order_mode( ): log.info(f'Opening order mode for {fqsn}') - view = chart.view # annotations editors - lines = LineEditor(chart=chart) - arrows = ArrowEditor(chart, {}) + lines = LineEditor(godw=godw) + arrows = ArrowEditor(godw=godw) # symbol id symbol = chart.linked.symbol @@ -663,11 +753,11 @@ async def open_order_mode( ) pp_tracker = PositionTracker( - chart, + [chart, hist_chart], alloc, startup_pp ) - pp_tracker.hide() + pp_tracker.nav.hide() trackers[account_name] = pp_tracker assert pp_tracker.startup_pp.size == pp_tracker.live_pp.size @@ -679,8 +769,8 @@ async def open_order_mode( # on existing position, show pp tracking graphics if pp_tracker.startup_pp.size != 0: - pp_tracker.show() - pp_tracker.hide_info() + pp_tracker.nav.show() + pp_tracker.nav.hide_info() # setup order mode sidepane widgets form: FieldsForm = chart.sidepane @@ -720,7 +810,10 @@ async def open_order_mode( # top level abstraction which wraps all this crazyness into # a namespace.. mode = OrderMode( + godw, + feed, chart, + hist_chart, tn, feed, book, @@ -737,8 +830,8 @@ async def open_order_mode( # select a pp to track tracker: PositionTracker = trackers[pp_account] mode.current_pp = tracker - tracker.show() - tracker.hide_info() + tracker.nav.show() + tracker.nav.hide_info() # XXX: would love to not have to do this separate from edit # fields (which are done in an async loop - see below) @@ -754,13 +847,13 @@ async def open_order_mode( ) # make fill bar and positioning snapshot - order_pane.on_ui_settings_change('limit', tracker.alloc.limit()) - order_pane.update_status_ui(pp=tracker) + order_pane.update_status_ui(tracker) # TODO: create a mode "manager" of sorts? # -> probably just call it "UxModes" err sumthin? # so that view handlers can access it - view.order_mode = mode + chart.view.order_mode = mode + hist_chart.view.order_mode = mode order_pane.on_ui_settings_change('account', pp_account) mode.pane.display_pnl(mode.current_pp) @@ -785,6 +878,7 @@ async def open_order_mode( # ``ChartView`` input async handler startup chart.view.open_async_input_handler(), + hist_chart.view.open_async_input_handler(), # pp pane kb inputs open_form_input_handling(