From 4d2708cd42267ee16d93257a22d639e1aff294c2 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 21 Aug 2022 08:19:23 -0400 Subject: [PATCH 01/84] Force 1s sample step so crypto boiz can seee --- piker/data/feed.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/piker/data/feed.py b/piker/data/feed.py index dfd47852..a29f337a 100644 --- a/piker/data/feed.py +++ b/piker/data/feed.py @@ -971,7 +971,6 @@ 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)) task_status.started() if not start_stream: @@ -984,13 +983,13 @@ async def allocate_persistent_feed( # start shm incrementer task for OHLC style sampling # at the current detected step period. times = shm.array['time'] - delay_s = times[-1] - times[times != times[-1]][-1] + delay_s = 1 #times[-1] - times[times != times[-1]][-1] sampler.ohlcv_shms.setdefault(delay_s, []).append(shm) if sampler.incrementers.get(delay_s) is None: await bus.start_task( increment_ohlc_buffer, - delay_s, + 1, ) sum_tick_vlm: bool = init_msg.get( @@ -1179,7 +1178,8 @@ async def open_sample_step_stream( portal.open_context, iter_ohlc_periods, ), - kwargs={'delay_s': delay_s}, + # kwargs={'delay_s': delay_s}, + kwargs={'delay_s': 1}, ) as (cache_hit, (ctx, first)): async with ctx.open_stream() as istream: @@ -1234,7 +1234,7 @@ class Feed: ) -> AsyncIterator[int]: - delay_s = delay_s or self._max_sample_rate + delay_s = 1 #delay_s or self._max_sample_rate async with open_sample_step_stream( self.portal, From 60052ff73a70fe7876b0f07b35c53c7801f511a1 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 29 Aug 2022 18:02:46 -0400 Subject: [PATCH 02/84] Presume shortest delay input to `increment_ohlc_buffer()` Instead of worrying about the increment period per shm subscription, just use the value passed as input and presume the caller knows that only one task is necessary and that the wakeup (sampling) period should be the shortest that is needed. It's very unlikely we don't want at least a 1s sampling (both in terms of task switching cost and general usage) which will eventually ship as the default "real-time" feed "timeframe". Further, this "fast" increment sampling task can handle all lower sampling periods (eg. 1m, 5m, 1H) based on the current implementation just the same. Also, add a global default sample period as `_defaul_delay_s` for use in other internal modules. --- piker/data/_sampling.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/piker/data/_sampling.py b/piker/data/_sampling.py index 77b15d7f..428540a8 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! @@ -152,7 +159,6 @@ async def broadcast( ''' subs = sampler.subscribers.get(delay_s, ()) - first = last = -1 if shm is None: From 861fe791ebf22fa7fe1f4cf2f439033efdd570e2 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 30 Aug 2022 10:53:59 -0400 Subject: [PATCH 03/84] Allocate 2 shm buffers for history and real-time As part of supporting a "history view" chart which shows downsampled datums alongside our 1s (or higher) sampled OHLC we need a separate buffer to store a the slower history from broker backends. This begins that design by allocating 2 buffers: - `rt_shm: ShmArray` which maps to a `/dev/shm/` file with `_rt` suffix - `hist_shm: ShmArray` which maps to a file with `_hist` suffix Deliver both of these shms back from both `manage_history()` and load them as `Feed.rt_shm`/`.hist_shm` on the client side. Impl deats: - init the rt buffer with the first datum from loaded history and assign all OHLC values to that row's 'close' and the vlm to 0. - pass the hist buffer to the backfiller task - only spawn **one** global sampler array-row increment task per `brokerd` and pass in the 1s delay which we presume is our lowest OHLC sample rate for now. - drop `open_sample_step_stream()` and just move its body contents into `Feed.index_stream()` --- piker/data/feed.py | 168 +++++++++++++++++++++++++++------------------ 1 file changed, 101 insertions(+), 67 deletions(-) diff --git a/piker/data/feed.py b/piker/data/feed.py index a29f337a..ef027322 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 @@ -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,8 @@ 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['rt_shm_token'] = rt_shm.token # true fqsn fqsn = '.'.join((bfqsn, brokername)) @@ -971,6 +987,25 @@ async def allocate_persistent_feed( # for ambiguous names we simply apply the retreived # feed to that name (for now). + 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: @@ -982,14 +1017,18 @@ async def allocate_persistent_feed( # start shm incrementer task for OHLC style sampling # at the current detected step period. - times = shm.array['time'] - delay_s = 1 #times[-1] - times[times != times[-1]][-1] + 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, - 1, + _default_delay_s, ) sum_tick_vlm: bool = init_msg.get( @@ -1000,7 +1039,7 @@ async def allocate_persistent_feed( try: await sample_and_broadcast( bus, - shm, + rt_shm, quote_stream, brokername, sum_tick_vlm @@ -1163,35 +1202,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}, - kwargs={'delay_s': 1}, - - ) 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,7 +1214,8 @@ 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 @@ -1236,11 +1247,24 @@ class Feed: delay_s = 1 #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') @@ -1338,15 +1362,21 @@ async def open_feed( ): # we can only read from shm - shm = attach_shm_array( - token=init_msg[bfqsn]['shm_token'], + hist_shm = attach_shm_array( + token=init_msg[bfqsn]['hist_shm_token'], readonly=True, ) + rt_shm = attach_shm_array( + token=init_msg[bfqsn]['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, @@ -1364,7 +1394,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 +1412,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 From f79c3617d6c88dd918876335c834f235900e81f3 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 30 Aug 2022 10:55:11 -0400 Subject: [PATCH 04/84] Always load FSPs with the default (fast) sampling period --- piker/fsp/_engine.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 From 97b074365b4f95283675a478a4698d509e451aea Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 30 Aug 2022 11:48:49 -0400 Subject: [PATCH 05/84] Use rt buffer for close price pnl calcs --- piker/ui/_position.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piker/ui/_position.py b/piker/ui/_position.py index 9e4c5ff4..f7fc447f 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -372,7 +372,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, From 55fc4114b4fb1594eba28e067b27ec02d5b5a331 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 30 Aug 2022 11:49:30 -0400 Subject: [PATCH 06/84] Initial draft code working with `pg.LinearRegionItem` --- piker/ui/_display.py | 52 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index 05603c63..0a540778 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -63,7 +63,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 @@ -719,15 +719,17 @@ async def display_symbol_data( tick_throttle=_quote_throttle_rate, ) as feed: - ohlcv: ShmArray = feed.shm + ohlcv: ShmArray = feed.rt_shm bars = ohlcv.array symbol = feed.symbols[sym] fqsn = symbol.front_fqsn() - times = bars['time'] + times = feed.hist_shm.array['time'] end = pendulum.from_timestamp(times[-1]) - start = pendulum.from_timestamp(times[times != times[-1]][-1]) - step_size_s = (end - start).seconds + # 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 @@ -753,7 +755,8 @@ async def display_symbol_data( ohlcv, sidepane=pp_pane, ) - chart.default_view() + + # chart.default_view() chart._feeds[symbol.key] = feed chart.setFocus() @@ -773,7 +776,38 @@ async def display_symbol_data( # ) # size view to data once at outset - chart.cv._set_yrange() + # chart.cv._set_yrange() + + # Add the LinearRegionItem to the ViewBox, but tell the ViewBox + # to exclude this item when doing auto-range calculations. + hist_pi = chart.plotItem + region = pg.LinearRegionItem() + region.setZValue(10) + # hist_pi.addItem(region, ignoreBounds=True) + flow = chart._flows[chart.name] + region.setClipItem(flow.graphics) + + def update(): + region.setZValue(10) + minX, maxX = region.getRegion() + # p1.setXRange(minX, maxX, padding=0) + + region.sigRegionChanged.connect(update) + + def update_region_from_pi( + window, + viewRange: tuple[tuple, tuple], + ) -> None: + # set the region on the history chart + # to the range currently viewed in the + # HFT/real-time chart. + rgn = viewRange[0] + # region.setRegion(rgn) + + # connect region to be updated on plotitem interaction. + hist_pi.sigRangeChanged.connect(update_region_from_pi) + # causes recursion error right now!?.. + # region.setRegion([l, r]) # NOTE: we must immediately tell Qt to show the OHLC chart # to avoid a race where the subplots get added/shown to @@ -816,6 +850,10 @@ async def display_symbol_data( vlm_chart, ) + await trio.sleep(0) + chart.default_view() + l, lbar, rbar, r = chart.bars_range() + async with ( open_order_mode( feed, From f0d417ce42f9fb214d975f2c9ec7d12f8b865ecb Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 30 Aug 2022 16:26:31 -0400 Subject: [PATCH 07/84] Drop status msg var deleting from ns --- piker/clearing/_ems.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/piker/clearing/_ems.py b/piker/clearing/_ems.py index 473a9e95..6a9025ff 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 From 9846396df2584bbc4b7df7484b8c116f3f362afb Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 30 Aug 2022 19:09:18 -0400 Subject: [PATCH 08/84] Add initial history (view) to charting sys Adds an additional `GodWidget.hist_linked: LinkedSplits` alongside the renamed `.rt_linked` to enable 2 sets of linked charts with different sampled data sets/flows. The history set is added without "all the fixins" for now (i.e. no order mode sidepane or search integration) such that it is merely a top level chart which shows a much longer term history and can be added to the UI via embedding the entire history linked-splits instance into the real-time linked set's splitter. Further impl deats: - adjust the `GodWidget._chart_cache: dict[str, tuple]]` to store both linked split chart sets per symbol so that symbol switching will continue to work with the added history chart (set). - rework `.load_symbol()` to operate on both the real-time (HFT) chart set and the history set. - rework `LinkedSplits.set_split_sizes()` to compensate for the history chart and do more detailed height calcs arithmetic to make it appear by default as a minor sub-chart. - adjust `LinkedSplits.add_plot()` and `ChartPlotWidget` internals to allow adding a plot without a sidepane and/or container `ChartnPane` composite widget by checking for a `sidepane == False` input. - make `.default_view()` accept a manual y-axis offset kwarg. - adjust search mode to provide history linked splits to `.set_chart_symbol()` call. --- piker/ui/_chart.py | 172 +++++++++++++++++++++++++++----------------- piker/ui/_search.py | 7 +- 2 files changed, 112 insertions(+), 67 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 3231698b..c13b1629 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -115,7 +115,9 @@ 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 # assigned in the startup func `_async_main()` self._root_n: trio.Nursery = None @@ -123,6 +125,10 @@ class GodWidget(QWidget): self._widgets: dict[str, QWidget] = {} self._resizing: bool = False + @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 +154,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 +188,30 @@ 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(): + for linked in [self.rt_linked, self.hist_linked]: # XXX: this is CRITICAL especially with pixel buffer caching - self.linkedsplits.hide() - self.linkedsplits.unfocus() + linked.hide() + linked.unfocus() + # self.hist_linked.hide() + # self.hist_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(self.linkedsplits) + # 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 +223,53 @@ 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() - await trio.sleep(0) + 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 - # 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 + # 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 - if chart: - chart.resume_all_feeds() + # XXX: since the pp config is a singleton widget we have to + # also switch it over to the new chart's interal-layout + # linked.chart.qframe.hbox.removeWidget(self.pp_pane) + chart = linked.chart - # 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() + # resume feeds *after* rendering chart view asap + if chart: + chart.resume_all_feeds() - self.linkedsplits = linkedsplits - symbol = linkedsplits.symbol + # 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() + + # set window titlebar info + symbol = self.rt_linked.symbol if symbol is not None: self.window.setWindowTitle( f'{symbol.front_fqsn()} ' @@ -399,10 +416,18 @@ class LinkedSplits(QWidget): # elif ln >= 2: # prop = 3/8 - major = 1 - prop - min_h_ind = int((self.height() * prop) / ln) + h = self.height() + histview_h = h * 1.6/6 + h = h - histview_h - sizes = [int(self.height() * major)] + major = 1 - prop + min_h_ind = int((h * prop) / ln) + sizes = [ + int(histview_h), + int(h * major), + ] + + # give all subcharts the same remaining proportional height sizes.extend([min_h_ind] * ln) self.splitter.setSizes(sizes) @@ -498,10 +523,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 +539,7 @@ class LinkedSplits(QWidget): name=name, data_key=array_key or name, - parent=qframe, + parent=parent, linkedsplits=self, axisItems=axes, **cpw_kwargs, @@ -537,20 +567,22 @@ 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.hbox.addWidget( + sidepane, + alignment=Qt.AlignTop + ) + + cpw.sidepane = sidepane cpw.plotItem.vb.linkedsplits = self cpw.setFrameStyle( @@ -613,7 +645,9 @@ class LinkedSplits(QWidget): if not _is_main: # track by name self.subplots[name] = cpw - self.splitter.addWidget(qframe) + if qframe is not None: + self.splitter.addWidget(qframe) + # scale split regions self.set_split_sizes() @@ -648,7 +682,7 @@ class LinkedSplits(QWidget): ''' main_chart = self.chart - if main_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) @@ -711,6 +745,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 +902,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 +942,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/_search.py b/piker/ui/_search.py index 8cac6b1a..80bff43c 100644 --- a/piker/ui/_search.py +++ b/piker/ui/_search.py @@ -635,7 +635,12 @@ 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) + chart.set_chart_symbol( + fqsn, ( + chart.linkedsplits, + self.godwidget.hist_linked, + ) + ) self.view.set_section_entries( 'cache', From bb4dc448b38d649bd1ba810d491dbbe60dd7bd19 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 30 Aug 2022 20:15:31 -0400 Subject: [PATCH 09/84] Add history chart and "linear region" for syncing Add a first draft of a working `pyqtgraph.LinearRegionItem` link between a history view chart (+ data set) and the normal real-time "HFT" chart set. Add the history view (aka more downsampled data view) chart set to the rt/hft set's splitter as it's "first widget". Hook up linear region callbacks to enable syncing between charts including compenstating for the downsampling rate ration (in this case hardcoded 60 since 1s to 1M, but we'll actually compute it going forward obvs). More to come dawgys.. --- piker/ui/_display.py | 102 ++++++++++++++++++++++++++++++------------- 1 file changed, 71 insertions(+), 31 deletions(-) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index 0a540778..b9066847 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -29,7 +29,7 @@ from typing import Optional, Any, Callable import numpy as np import tractor import trio -import pendulum +# import pendulum import pyqtgraph as pg # from .. import brokers @@ -720,12 +720,15 @@ async def display_symbol_data( ) as feed: ohlcv: ShmArray = feed.rt_shm - bars = ohlcv.array + hist_ohlcv: ShmArray = feed.hist_shm + end_index = hist_ohlcv.index + + # bars = ohlcv.array symbol = feed.symbols[sym] fqsn = symbol.front_fqsn() - times = feed.hist_shm.array['time'] - end = pendulum.from_timestamp(times[-1]) + # times = feed.hist_shm.array['time'] + # end = pendulum.from_timestamp(times[-1]) # start = pendulum.from_timestamp(times[times != times[-1]][-1]) # step_size_s = (end - start).seconds @@ -739,7 +742,7 @@ async def display_symbol_data( f'step:{tf_key} ' ) - linked = godwidget.linkedsplits + linked = godwidget.rt_linked linked._symbol = symbol # generate order mode side-pane UI @@ -749,10 +752,27 @@ async def display_symbol_data( # add as next-to-y-axis singleton pane godwidget.pp_pane = pp_pane + # 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, + ) + 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 + ) + # create main OHLC chart chart = linked.plot_ohlc_main( symbol, ohlcv, + # in the case of history chart we explicitly set `False` + # to avoid internal pane creation. sidepane=pp_pane, ) @@ -760,54 +780,66 @@ async def display_symbol_data( chart._feeds[symbol.key] = feed chart.setFocus() + # XXX: FOR SOME REASON THIS IS CAUSING HANGZ!?! # plot historical vwap if available wap_in_history = False - - # XXX: FOR SOME REASON THIS IS CAUSING HANGZ!?! - # if brokermod._show_wap_in_history: - - # 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, - # ) - - # size view to data once at outset - # chart.cv._set_yrange() + # 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, + # ) # Add the LinearRegionItem to the ViewBox, but tell the ViewBox # to exclude this item when doing auto-range calculations. - hist_pi = chart.plotItem + rt_pi = chart.plotItem + hist_pi = hist_chart.plotItem region = pg.LinearRegionItem() region.setZValue(10) - # hist_pi.addItem(region, ignoreBounds=True) - flow = chart._flows[chart.name] - region.setClipItem(flow.graphics) + 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) def update(): region.setZValue(10) - minX, maxX = region.getRegion() - # p1.setXRange(minX, maxX, padding=0) + mn, mx = region.getRegion() + # print(f'region_x: {(mn, mx)}') + + # XXX: seems to cause a real perf hit? + rt_pi.setXRange( + (mn - end_index) * 60, + (mx - end_index) * 60, + padding=0, + ) region.sigRegionChanged.connect(update) def update_region_from_pi( window, viewRange: tuple[tuple, tuple], + ) -> None: # set the region on the history chart # to the range currently viewed in the # HFT/real-time chart. rgn = viewRange[0] - # region.setRegion(rgn) + # print(f'rt_view_range: {rgn}') + mn, mx = rgn + region.setRegion(( + mn/60 + end_index, + mx/60 + end_index, + )) # connect region to be updated on plotitem interaction. - hist_pi.sigRangeChanged.connect(update_region_from_pi) - # causes recursion error right now!?.. - # region.setRegion([l, r]) + 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 @@ -816,6 +848,11 @@ async def display_symbol_data( linked.focus() await trio.sleep(0) + linked.splitter.insertWidget(0, hist_linked) + # XXX: if we wanted it at the bottom? + # linked.splitter.addWidget(hist_linked) + linked.focus() + vlm_chart: Optional[ChartPlotWidget] = None async with trio.open_nursery() as ln: @@ -851,8 +888,9 @@ async def display_symbol_data( ) await trio.sleep(0) + + # size view to data prior to order mode init chart.default_view() - l, lbar, rbar, r = chart.bars_range() async with ( open_order_mode( @@ -863,12 +901,14 @@ async def display_symbol_data( ) ): if not vlm_chart: + # trigger another view reset if no sub-chart chart.default_view() # let Qt run to render all widgets and make sure the # sidepanes line up vertically. await trio.sleep(0) linked.resize_sidepanes() + linked.set_split_sizes() # NOTE: we pop the volume chart from the subplots set so # that it isn't double rendered in the display loop From 3a434f312bf38d2f4016dfc9ade7c5f54ac255b4 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 31 Aug 2022 17:12:09 -0400 Subject: [PATCH 10/84] Add sidepane like color region styling --- piker/ui/_display.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index b9066847..e806660e 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -41,6 +41,7 @@ from ._chart import ( GodWidget, ) from ._l1 import L1Labels +from ._style import hcolor from ._fsp import ( update_fsp_chart, start_fsp_displays, @@ -799,7 +800,11 @@ async def display_symbol_data( # to exclude this item when doing auto-range calculations. rt_pi = chart.plotItem hist_pi = hist_chart.plotItem - region = pg.LinearRegionItem() + region = pg.LinearRegionItem( + # color scheme that matches sidepane styling + pen=pg.mkPen(hcolor('gunmetal')), + brush=pg.mkBrush(hcolor('default_darkest')), + ) region.setZValue(10) hist_pi.addItem(region, ignoreBounds=True) flow = chart._flows[hist_chart.name] From 49ccfdd673c030bd88d7fe23b5149c2ec19c6477 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 1 Sep 2022 11:26:29 -0400 Subject: [PATCH 11/84] Pass history shm "last index" in init msg, assign on feed --- piker/data/feed.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/piker/data/feed.py b/piker/data/feed.py index ef027322..a2b64c44 100644 --- a/piker/data/feed.py +++ b/piker/data/feed.py @@ -951,6 +951,7 @@ async def allocate_persistent_feed( # this task. msg = init_msg[symbol] msg['hist_shm_token'] = hist_shm.token + msg['startup_hist_index'] = hist_shm.index - 1 msg['rt_shm_token'] = rt_shm.token # true fqsn @@ -1040,6 +1041,7 @@ async def allocate_persistent_feed( await sample_and_broadcast( bus, rt_shm, + hist_shm, quote_stream, brokername, sum_tick_vlm @@ -1222,6 +1224,8 @@ class Feed: 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 @@ -1361,13 +1365,14 @@ async def open_feed( ) as stream, ): + init = init_msg[bfqsn] # we can only read from shm hist_shm = attach_shm_array( - token=init_msg[bfqsn]['hist_shm_token'], + token=init['hist_shm_token'], readonly=True, ) rt_shm = attach_shm_array( - token=init_msg[bfqsn]['rt_shm_token'], + token=init['rt_shm_token'], readonly=True, ) @@ -1382,6 +1387,7 @@ async def open_feed( stream=stream, _portal=portal, status={}, + startup_hist_index=init['startup_hist_index'], throttle_rate=tick_throttle, ) From 6e574835c85a104dc6eb1fe5e41fb9d768d3ea98 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 1 Sep 2022 11:27:39 -0400 Subject: [PATCH 12/84] Update history shm buffer in ohlc sampler loop --- piker/data/_sampling.py | 71 ++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/piker/data/_sampling.py b/piker/data/_sampling.py index 428540a8..015de05e 100644 --- a/piker/data/_sampling.py +++ b/piker/data/_sampling.py @@ -137,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) @@ -227,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, @@ -263,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 From e06e257a8188142c54b48875325bd263e4474f30 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 1 Sep 2022 11:27:57 -0400 Subject: [PATCH 13/84] Another history view splitter proportion tweak --- piker/ui/_chart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index c13b1629..968fb3e2 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -417,7 +417,7 @@ class LinkedSplits(QWidget): # prop = 3/8 h = self.height() - histview_h = h * 1.6/6 + histview_h = h * 5/16 h = h - histview_h major = 1 - prop From 59884d251e3dfd2839caa7c05670c4e9bc891982 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 1 Sep 2022 11:28:49 -0400 Subject: [PATCH 14/84] Update history "last" bar, compute ampling ratio Add an update call to the display loop to consistently update the last datum in the history view chart. Compute the inter-chart sampling ratio and use it to sync the linear region. --- piker/ui/_display.py | 71 ++++++++++++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 19 deletions(-) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index e806660e..e91b567c 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -29,7 +29,7 @@ from typing import Optional, Any, Callable import numpy as np import tractor import trio -# import pendulum +import pendulum import pyqtgraph as pg # from .. import brokers @@ -129,13 +129,14 @@ class DisplayState: 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 @@ -155,6 +156,7 @@ async def graphics_update_loop( linked: LinkedSplits, stream: tractor.MsgStream, ohlcv: np.ndarray, + hist_ohlcv: np.ndarray, wap_in_history: bool = False, vlm_chart: Optional[ChartPlotWidget] = None, @@ -176,7 +178,8 @@ 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() + godwidget = linked.godwidget + display_rate = godwidget.window.current_screen().refreshRate() chart = linked.chart @@ -228,10 +231,11 @@ 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, 'l1': l1, @@ -299,6 +303,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}`', @@ -312,9 +318,16 @@ 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 + + # history view chart + hist_ohlcv = ds.hist_ohlcv + hist_array = hist_ohlcv.array + vars = ds.vars tick_margin = vars['tick_margin'] @@ -459,6 +472,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 @@ -722,16 +739,27 @@ async def display_symbol_data( ) as feed: ohlcv: ShmArray = feed.rt_shm hist_ohlcv: ShmArray = feed.hist_shm - end_index = hist_ohlcv.index + + times = hist_ohlcv.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 = ohlcv.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 + + # this value needs to be pulled once and only once during + # startup + end_index = feed.startup_hist_index # bars = ohlcv.array symbol = feed.symbols[sym] fqsn = symbol.front_fqsn() - # times = feed.hist_shm.array['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] @@ -813,34 +841,38 @@ async def display_symbol_data( # a weird placement of the region on the way-far-left.. # region.setClipItem(flow.graphics) - def update(): + 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) * 60, - (mx - end_index) * 60, + (mn - end_index) * ratio, + (mx - end_index) * ratio, padding=0, ) - region.sigRegionChanged.connect(update) + 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. - rgn = viewRange[0] - # print(f'rt_view_range: {rgn}') - mn, mx = rgn + 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' + # ) region.setRegion(( - mn/60 + end_index, - mx/60 + end_index, + ds_mn + end_index, + ds_mx + end_index, )) # connect region to be updated on plotitem interaction. @@ -888,6 +920,7 @@ async def display_symbol_data( linked, feed.stream, ohlcv, + hist_ohlcv, wap_in_history, vlm_chart, ) From dd03ef42ac329aa838fa9592d304e9590d289854 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 1 Sep 2022 14:11:26 -0400 Subject: [PATCH 15/84] Return empty search result on connection failure If you spawn a brokerd set and no `ib` data feed was started (via our `.data.feed.Feed` api) then there will be no active client loaded and thus wont' be connected. So in these cases just return nothing, and I guess we'll figure out real connection failures later? --- piker/brokers/ib/api.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 From 5e98a305374a58a75ede56d24b9031cdf879c34d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 1 Sep 2022 14:26:40 -0400 Subject: [PATCH 16/84] Add simplified history incrementer consumer task --- piker/ui/_display.py | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index e91b567c..c220a68e 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -324,10 +324,6 @@ def graphics_update_cycle( ohlcv = ds.ohlcv array = ohlcv.array - # history view chart - hist_ohlcv = ds.hist_ohlcv - hist_array = hist_ohlcv.array - vars = ds.vars tick_margin = vars['tick_margin'] @@ -925,6 +921,38 @@ async def display_symbol_data( vlm_chart, ) + async def increment_history_view(): + i_last_append = i_last = hist_ohlcv.index + + async with feed.index_stream( + int(hist_step_size_s) + ) as istream: + async for msg in istream: + + # increment the view position by the sample offset. + uppx = hist_chart.view.x_uppx() + l, lbar, rbar, r = hist_chart.bars_range() + + i_step = hist_ohlcv.index + i_diff = i_step - i_last + i_last = i_step + liv = r >= i_step + append_diff = i_step - i_last_append + do_append = (append_diff >= uppx) + + if do_append: + i_last_append = i_step + + if ( + # i_diff > 0 # no new sample step + do_append + # and uppx < 4 # chart is zoomed out very far + and liv + ): + hist_chart.increment_view(steps=i_diff) + + ln.start_soon(increment_history_view) + await trio.sleep(0) # size view to data prior to order mode init From 2ef6460853c9889b891f45f2731ecf8c499d081e Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 1 Sep 2022 15:22:14 -0400 Subject: [PATCH 17/84] Add `Feed.get_ds_info()` to detect/compute sample rates --- piker/data/feed.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/piker/data/feed.py b/piker/data/feed.py index a2b64c44..13495178 100644 --- a/piker/data/feed.py +++ b/piker/data/feed.py @@ -1245,12 +1245,10 @@ class Feed: @asynccontextmanager async def index_stream( self, - delay_s: Optional[int] = None + delay_s: int = 1, ) -> AsyncIterator[int]: - delay_s = 1 #delay_s or self._max_sample_rate - # XXX: this should be singleton on a host, # a lone broker-daemon per provider should be # created for all practical purposes @@ -1276,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( From 31735f26d38a37cd4713e598634cca81b50e1af3 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 1 Sep 2022 16:25:54 -0400 Subject: [PATCH 18/84] Poll for sampling info at startup, tolerate races Use the new `Feed.get_ds_info()` method in a poll loop to definitively get the inter-chart sampling info and avoid races with shm buffer backfilling. Also, factor the history increment closure-task into `graphics_update_loop()` which will make it clearer how to factor all the "should we update" logic into some `DisplayState` API. --- piker/ui/_display.py | 152 ++++++++++++++++++++++--------------------- 1 file changed, 78 insertions(+), 74 deletions(-) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index c220a68e..c24d1d9c 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -26,14 +26,15 @@ 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 ._axes import YAxisLabel from ._chart import ( ChartPlotWidget, @@ -153,11 +154,9 @@ class DisplayState: async def graphics_update_loop( - linked: LinkedSplits, - stream: tractor.MsgStream, - ohlcv: np.ndarray, - hist_ohlcv: np.ndarray, - + nurse: trio.Nursery, + godwidget: GodWidget, + feed: Feed, wap_in_history: bool = False, vlm_chart: Optional[ChartPlotWidget] = None, @@ -178,10 +177,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) - godwidget = linked.godwidget + 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] @@ -257,7 +260,44 @@ 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_append = i_last = hist_ohlcv.index + _, hist_step_size_s, _ = feed.get_ds_info() + + async with feed.index_stream( + int(hist_step_size_s) + ) as istream: + async for msg in istream: + + # increment the view position by the sample offset. + uppx = hist_chart.view.x_uppx() + l, lbar, rbar, r = hist_chart.bars_range() + + i_step = hist_ohlcv.index + i_diff = i_step - i_last + i_last = i_step + liv = r >= i_step + append_diff = i_step - i_last_append + do_append = (append_diff >= uppx) + + if do_append: + i_last_append = i_step + + if ( + # i_diff > 0 # no new sample step + do_append + # and uppx < 4 # chart is zoomed out very far + and liv + ): + hist_chart.increment_view(steps=i_diff) + + 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 @@ -736,27 +776,13 @@ async def display_symbol_data( ohlcv: ShmArray = feed.rt_shm hist_ohlcv: ShmArray = feed.hist_shm - times = hist_ohlcv.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 = ohlcv.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 - # this value needs to be pulled once and only once during # startup end_index = feed.startup_hist_index - # bars = ohlcv.array symbol = feed.symbols[sym] fqsn = symbol.front_fqsn() - step_size_s = 1 tf_key = tf_in_1s[step_size_s] @@ -767,8 +793,8 @@ async def display_symbol_data( f'step:{tf_key} ' ) - linked = godwidget.rt_linked - linked._symbol = symbol + rt_linked = godwidget.rt_linked + rt_linked._symbol = symbol # generate order mode side-pane UI # A ``FieldsForm`` form to configure order entry @@ -793,7 +819,7 @@ async def display_symbol_data( ) # 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` @@ -801,7 +827,6 @@ async def display_symbol_data( sidepane=pp_pane, ) - # chart.default_view() chart._feeds[symbol.key] = feed chart.setFocus() @@ -837,6 +862,18 @@ async def display_symbol_data( # a weird placement of the region on the way-far-left.. # region.setClipItem(flow.graphics) + # poll for datums load and timestep detection + for _ in range(10): + try: + _, _, ratio = feed.get_ds_info() + break + except IndexError: + await trio.sleep(0.001) + continue + else: + raise RuntimeError( + 'Failed to detect sampling periods from shm!?') + def update_pi_from_region(): region.setZValue(10) mn, mx = region.getRegion() @@ -877,14 +914,14 @@ async def display_symbol_data( # 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) - linked.splitter.insertWidget(0, hist_linked) + rt_linked.splitter.insertWidget(0, hist_linked) # XXX: if we wanted it at the bottom? - # linked.splitter.addWidget(hist_linked) - linked.focus() + # rt_linked.splitter.addWidget(hist_linked) + rt_linked.focus() vlm_chart: Optional[ChartPlotWidget] = None async with trio.open_nursery() as ln: @@ -896,7 +933,7 @@ async def display_symbol_data( ): vlm_chart = await ln.start( open_vlm_displays, - linked, + rt_linked, ohlcv, ) @@ -904,7 +941,7 @@ async def display_symbol_data( # from an input config. ln.start_soon( start_fsp_displays, - linked, + rt_linked, ohlcv, loading_sym_key, loglevel, @@ -913,46 +950,13 @@ async def display_symbol_data( # start graphics update loop after receiving first live quote ln.start_soon( graphics_update_loop, - linked, - feed.stream, - ohlcv, - hist_ohlcv, + ln, + godwidget, + feed, wap_in_history, vlm_chart, ) - async def increment_history_view(): - i_last_append = i_last = hist_ohlcv.index - - async with feed.index_stream( - int(hist_step_size_s) - ) as istream: - async for msg in istream: - - # increment the view position by the sample offset. - uppx = hist_chart.view.x_uppx() - l, lbar, rbar, r = hist_chart.bars_range() - - i_step = hist_ohlcv.index - i_diff = i_step - i_last - i_last = i_step - liv = r >= i_step - append_diff = i_step - i_last_append - do_append = (append_diff >= uppx) - - if do_append: - i_last_append = i_step - - if ( - # i_diff > 0 # no new sample step - do_append - # and uppx < 4 # chart is zoomed out very far - and liv - ): - hist_chart.increment_view(steps=i_diff) - - ln.start_soon(increment_history_view) - await trio.sleep(0) # size view to data prior to order mode init @@ -973,19 +977,19 @@ async def display_symbol_data( # let Qt run to render all widgets and make sure the # sidepanes line up vertically. await trio.sleep(0) - linked.resize_sidepanes() - linked.set_split_sizes() + rt_linked.resize_sidepanes() + rt_linked.set_split_sizes() # 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]() # let the app run.. bby - # linked.graphics_cycle() + # rt_linked.graphics_cycle() await trio.sleep_forever() From 50c5dc255ce2dc9359f51877f92ca1d95e535b65 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 1 Sep 2022 18:59:50 -0400 Subject: [PATCH 19/84] Update history view y-sticky with last clear price --- piker/ui/_display.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index c24d1d9c..e0b0711d 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -143,6 +143,7 @@ class DisplayState: # axis labels l1: L1Labels last_price_sticky: YAxisLabel + hist_last_price_sticky: YAxisLabel # misc state tracking vars: dict[str, Any] @@ -192,6 +193,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, @@ -241,6 +247,7 @@ async def graphics_update_loop( 'hist_ohlcv': hist_ohlcv, 'chart': chart, 'last_price_sticky': last_price_sticky, + 'hist_last_price_sticky': hist_last_price_sticky, 'l1': l1, 'vars': { @@ -549,6 +556,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 @@ -868,7 +878,7 @@ async def display_symbol_data( _, _, ratio = feed.get_ds_info() break except IndexError: - await trio.sleep(0.001) + await trio.sleep(0.01) continue else: raise RuntimeError( From 7958d8ad4f2f79e6eb9130a835aa2eb45fc8a62a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 2 Sep 2022 13:56:01 -0400 Subject: [PATCH 20/84] Up sample info poll loop iters --- piker/ui/_display.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index e0b0711d..0439a897 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -873,7 +873,7 @@ async def display_symbol_data( # region.setClipItem(flow.graphics) # poll for datums load and timestep detection - for _ in range(10): + for _ in range(100): try: _, _, ratio = feed.get_ds_info() break From 10c1944de5877ea9ad444b187ccfb2fcaebc76c4 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 2 Sep 2022 16:42:48 -0400 Subject: [PATCH 21/84] Proper slow chart auto y-range support The slow (history) chart requires it's own y-range checker logic which needs to be run in 2 cases: - the last datum is in view and goes outside the previous mx/mn in view - the chart is incremented a step Since we need this duplicate logic this patch also factors the incremental graphics update info "reading" into a new `DisplayState.incr_info()` method that can be configured to a chart and input state and returns all relevant "graphics update measure" in a tuple (for now). Use this method throughout the rest of the display loop for both fast and slow chart checks and in the `increment_history_view()` slow chart task. --- piker/ui/_display.py | 221 ++++++++++++++++++++++++++----------------- 1 file changed, 133 insertions(+), 88 deletions(-) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index 0439a897..da5791d4 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -21,7 +21,6 @@ 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 @@ -35,6 +34,7 @@ from ..data.feed import ( open_feed, Feed, ) +from ..data.types import Struct from ._axes import YAxisLabel from ._chart import ( ChartPlotWidget, @@ -124,8 +124,7 @@ def chart_maxmin( ) -@dataclass -class DisplayState: +class DisplayState(Struct): ''' Chart-local real-time graphics state container. @@ -146,12 +145,77 @@ class DisplayState: 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( @@ -271,7 +335,11 @@ async def graphics_update_loop( # API that can be reused at least in terms of pulling view # params (eg ``.bars_range()``). async def increment_history_view(): - i_last_append = i_last = hist_ohlcv.index + 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( @@ -279,27 +347,26 @@ async def graphics_update_loop( ) as istream: async for msg in istream: - # increment the view position by the sample offset. - uppx = hist_chart.view.x_uppx() - l, lbar, rbar, r = hist_chart.bars_range() - - i_step = hist_ohlcv.index - i_diff = i_step - i_last - i_last = i_step - liv = r >= i_step - append_diff = i_step - i_last_append - do_append = (append_diff >= uppx) - - if do_append: - i_last_append = i_step - + # check if slow chart needs a 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, + ) if ( - # i_diff > 0 # no new sample step do_append - # and uppx < 4 # chart is zoomed out very far and liv ): hist_chart.increment_view(steps=i_diff) + hist_chart.view._set_yrange(yrange=hist_chart.maxmin()) nurse.start_soon(increment_history_view) @@ -374,47 +441,15 @@ def graphics_update_cycle( 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. @@ -430,8 +465,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 @@ -446,16 +479,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) @@ -606,26 +633,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 From 14bee778ecd4ef6501b06bdc7eac2b7a0ddc7f9a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 6 Sep 2022 08:14:54 -0400 Subject: [PATCH 22/84] Hook up kb ctrls to hist chart, order mode not working yet --- piker/ui/_display.py | 1 + piker/ui/order_mode.py | 25 +++++++++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index da5791d4..e55f6ce3 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -1021,6 +1021,7 @@ async def display_symbol_data( open_order_mode( feed, chart, + hist_chart, fqsn, order_mode_started ) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index f83787ec..4fdfafcb 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,9 @@ from ..clearing._messages import ( from ._forms import open_form_input_handling +if TYPE_CHECKING: + from ._chart import ChartPlotWidget + log = get_logger(__name__) @@ -76,7 +85,7 @@ class Dialog(Struct): line: LevelLine last_status_close: Callable = lambda: None msgs: dict[str, dict] = {} - fills: Dict[str, Any] = {} + fills: dict[str, Any] = {} @dataclass @@ -100,7 +109,7 @@ class OrderMode: mouse click and drag -> modify current order under cursor ''' - chart: 'ChartPlotWidget' # type: ignore # noqa + chart: ChartPlotWidget # type: ignore # noqa nursery: trio.Nursery quote_feed: Feed book: OrderBook @@ -568,7 +577,8 @@ class OrderMode: async def open_order_mode( feed: Feed, - chart: 'ChartPlotWidget', # noqa + chart: ChartPlotWidget, # noqa + hist_chart: ChartPlotWidget, # noqa fqsn: str, started: trio.Event, @@ -606,7 +616,8 @@ async def open_order_mode( ): log.info(f'Opening order mode for {fqsn}') - view = chart.view + rt_view = chart.view + hist_view = chart.view # annotations editors lines = LineEditor(chart=chart) @@ -760,7 +771,8 @@ async def open_order_mode( # 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 +797,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( From 416270ee6ca90a627b414be52021e26324cec55d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 6 Sep 2022 08:36:28 -0400 Subject: [PATCH 23/84] Refocus view on ctl-c from search --- piker/ui/_chart.py | 2 +- piker/ui/_interaction.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 968fb3e2..c14ccadd 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -584,7 +584,7 @@ class LinkedSplits(QWidget): cpw.sidepane = sidepane - cpw.plotItem.vb.linkedsplits = self + cpw.plotItem.vb.linked = self cpw.setFrameStyle( QtWidgets.QFrame.StyledPanel # | QtWidgets.QFrame.Plain diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 71797a33..6ec79682 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -148,6 +148,7 @@ async def handle_viewmode_kb_inputs( # 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: @@ -181,10 +182,10 @@ async def handle_viewmode_kb_inputs( # 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 # -------------- @@ -373,7 +374,7 @@ class ChartView(ViewBox): y=True, ) - self.linkedsplits = None + self.linked = None self._chart: 'ChartPlotWidget' = None # noqa # add our selection box annotator @@ -484,7 +485,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 +920,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(): From f070f9a984daca2e9b147432f9368ab7711e498d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 6 Sep 2022 14:23:39 -0400 Subject: [PATCH 24/84] Add "active cursor" api to god widget --- piker/ui/_chart.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index c14ccadd..f7d7a9df 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -118,6 +118,7 @@ class GodWidget(QWidget): 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 @@ -306,6 +307,12 @@ class GodWidget(QWidget): self._resizing = False + def get_cursor(self) -> Cursor: + c = self._active_cursor + assert c + return c + + class ChartnPane(QFrame): ''' From d3402f715bf4163a3a986a8064e5575b3ce68abf Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 6 Sep 2022 14:24:19 -0400 Subject: [PATCH 25/84] Set godwidget active cursor from xhair callback --- piker/ui/_cursor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/piker/ui/_cursor.py b/piker/ui/_cursor.py index 606ff3f2..366788b4 100644 --- a/piker/ui/_cursor.py +++ b/piker/ui/_cursor.py @@ -491,6 +491,7 @@ 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() From 2dfa8976a0f8732ca783a93d78460b3abfa4af36 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 6 Sep 2022 14:25:01 -0400 Subject: [PATCH 26/84] Make line editor expect god as input, use new .`get_cursor()` api --- piker/ui/_editors.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index 38d30da4..9443f600 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -19,7 +19,10 @@ Higher level annotation editors. """ from dataclasses import dataclass, field -from typing import Optional +from typing import ( + Optional, + TYPE_CHECKING +) import pyqtgraph as pg from pyqtgraph import ViewBox, Point, QtCore, QtGui @@ -31,6 +34,9 @@ from ._style import hcolor, _font from ._lines import LevelLine from ..log import get_logger +if TYPE_CHECKING: + from ._chart import GodWidget + log = get_logger(__name__) @@ -88,10 +94,11 @@ class ArrowEditor: @dataclass class LineEditor: - '''The great editor of linez. + ''' + The great editor of linez. ''' - chart: 'ChartPlotWidget' = None # type: ignore # noqa + godw: 'ChartPlotWidget' = None # type: ignore # noqa _order_lines: dict[str, LevelLine] = field(default_factory=dict) _active_staged_line: LevelLine = None @@ -119,13 +126,19 @@ class LineEditor: """ # chart = self.chart._cursor.active_plot - # # chart.setCursor(QtCore.Qt.ArrowCursor) - cursor = self.chart.linked.cursor + cursor = self.godw.get_cursor() # 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 vieww) then we just + # ignore the remove error. + pass line.delete() self._active_staged_line = None @@ -178,7 +191,7 @@ class LineEditor: """ # 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()) @@ -200,7 +213,7 @@ class LineEditor: if line: # if hovered remove from cursor set - cursor = self.chart.linked.cursor + cursor = self.godw.get_cursor() hovered = cursor._hovered if line in hovered: hovered.remove(line) From 2b76baeb1003cea99189475038bd44c3503e429b Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 6 Sep 2022 14:30:35 -0400 Subject: [PATCH 27/84] Pass god widget to line editor and order mode instances --- piker/ui/_display.py | 3 +-- piker/ui/order_mode.py | 17 +++++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index e55f6ce3..0cd21dbb 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -1020,8 +1020,7 @@ async def display_symbol_data( async with ( open_order_mode( feed, - chart, - hist_chart, + godwidget, fqsn, order_mode_started ) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 4fdfafcb..d13d2b86 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -67,7 +67,10 @@ from ._forms import open_form_input_handling if TYPE_CHECKING: - from ._chart import ChartPlotWidget + from ._chart import ( + ChartPlotWidget, + GodWidget, + ) log = get_logger(__name__) @@ -110,6 +113,7 @@ class OrderMode: ''' chart: ChartPlotWidget # type: ignore # noqa + hist_chart: ChartPlotWidget # type: ignore # noqa nursery: trio.Nursery quote_feed: Feed book: OrderBook @@ -577,8 +581,7 @@ class OrderMode: async def open_order_mode( feed: Feed, - chart: ChartPlotWidget, # noqa - hist_chart: ChartPlotWidget, # noqa + godw: GodWidget, fqsn: str, started: trio.Event, @@ -591,6 +594,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..') @@ -616,11 +622,9 @@ async def open_order_mode( ): log.info(f'Opening order mode for {fqsn}') - rt_view = chart.view - hist_view = chart.view # annotations editors - lines = LineEditor(chart=chart) + lines = LineEditor(godw=godw) arrows = ArrowEditor(chart, {}) # symbol id @@ -732,6 +736,7 @@ async def open_order_mode( # a namespace.. mode = OrderMode( chart, + hist_chart, tn, feed, book, From a4935b8fa87f079d7fa44c01b159bcb24c224753 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 6 Sep 2022 16:09:13 -0400 Subject: [PATCH 28/84] Make line editor multi-line aware, drop `dataclass` for `Struct` --- piker/ui/_editors.py | 103 +++++++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 48 deletions(-) diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index 9443f600..74842964 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -18,7 +18,8 @@ Higher level annotation editors. """ -from dataclasses import dataclass, field +from __future__ import annotations +from collections import defaultdict from typing import ( Optional, TYPE_CHECKING @@ -33,6 +34,7 @@ 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 @@ -41,11 +43,10 @@ if TYPE_CHECKING: log = get_logger(__name__) -@dataclass -class ArrowEditor: +class ArrowEditor(Struct): chart: 'ChartPlotWidget' # noqa - _arrows: field(default_factory=dict) + _arrows: dict[str, pg.ArrowItem] def add( self, @@ -55,9 +56,10 @@ class ArrowEditor: color='default', pointing: Optional[str] = None, ) -> pg.ArrowItem: - """Add an arrow graphic to view at given (x, y). + ''' + Add an arrow graphic to view at given (x, y). - """ + ''' angle = { 'up': 90, 'down': -90, @@ -92,14 +94,13 @@ class ArrowEditor: self.chart.plotItem.removeItem(arrow) -@dataclass -class LineEditor: +class LineEditor(Struct): ''' The great editor of linez. ''' - godw: '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( @@ -122,10 +123,10 @@ class LineEditor: return line def unstage_line(self) -> LevelLine: - """Inverse of ``.stage_line()``. + ''' + Inverse of ``.stage_line()``. - """ - # chart = self.chart._cursor.active_plot + ''' cursor = self.godw.get_cursor() # delete "staged" cursor tracking line from view @@ -146,9 +147,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: @@ -158,33 +159,30 @@ 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.get(uuid) + if lines: + for line in self._order_lines.get(uuid, []): + line.show_labels() + 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. @@ -193,8 +191,13 @@ class LineEditor: # Delete any hoverable under the cursor 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 tuple(self._order_lines.values()) + return all_lines def remove_line( self, @@ -209,26 +212,30 @@ class LineEditor: ''' # try to look up line from our registry - line = self._order_lines.pop(uuid, line) - if line: - - # if hovered remove from cursor set + # line = self._order_lines.pop(uuid, line) + lines = self._order_lines.pop(uuid) + # if line: + if lines: cursor = self.godw.get_cursor() - hovered = cursor._hovered - if line in hovered: - hovered.remove(line) - # make sure the xhair doesn't get left off - # just because we never got a un-hover event - cursor.show_xhair() + for line in lines: + # if hovered remove from cursor set + # cursor = self.godw.get_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() else: log.warning(f'Could not find line for {line}') - return line + return lines class SelectRect(QtGui.QGraphicsRectItem): From 8e07fda88f834f6b718d9a52a3eee57c849b6f87 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 6 Sep 2022 16:10:40 -0400 Subject: [PATCH 29/84] Expose multi-chart-lines support through to order mode api --- piker/ui/order_mode.py | 76 +++++++++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 27 deletions(-) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index d13d2b86..57d6be82 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -85,7 +85,7 @@ 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] = {} @@ -175,14 +175,16 @@ class OrderMode: def line_from_order( self, order: Order, + chart: Optional[ChartPlotWidget] = None, **line_kwargs, ) -> LevelLine: level = order.price + line = order_line( - self.chart, + chart or self.chart, # TODO: convert these values into human-readable form # (i.e. with k, m, M, B) type embedded suffixes level=level, @@ -224,6 +226,24 @@ class OrderMode: return line + def lines_from_order( + self, + order: Order, + **line_kwargs, + + ) -> list[LevelLine]: + + lines: list[LevelLine] = [] + for chart in [self.chart, self.hist_chart]: + line = self.line_from_order( + order=order, + chart=chart, + **line_kwargs, + ) + lines.append(line) + + return lines + def stage_order( self, @@ -236,6 +256,7 @@ class OrderMode: ''' # not initialized yet chart = self.chart + cursor = chart.linked.cursor if not (chart and cursor and cursor.active_plot): return @@ -298,13 +319,12 @@ class OrderMode: order.symbol = order.symbol.front_fqsn() - line = self.line_from_order( + # 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 @@ -315,8 +335,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, ) @@ -324,24 +344,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: @@ -396,11 +417,11 @@ 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() return dialog @@ -428,17 +449,18 @@ 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 + if lines: + for line in lines: + self.arrows.add( + uuid, + arrow_index, + price, + pointing=pointing, + color=line.color + ) else: - log.warn("No line for order {uuid}!?") + log.warn("No line(s) for order {uuid}!?") async def on_exec( self, From 271e378ce3413690dc6d188f160461aece5f5487 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 6 Sep 2022 21:18:41 -0400 Subject: [PATCH 30/84] Add `GodWidget.iter_linked()` interator over linked split charts --- piker/ui/_chart.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index f7d7a9df..f919f396 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 ( @@ -195,7 +199,7 @@ class GodWidget(QWidget): if not self.vbox.isEmpty(): for linked in [self.rt_linked, self.hist_linked]: - # XXX: this is CRITICAL especially with pixel buffer caching + # XXX: this is CRITICAL especially with pixel buffer caching linked.hide() linked.unfocus() # self.hist_linked.hide() @@ -308,10 +312,11 @@ class GodWidget(QWidget): self._resizing = False def get_cursor(self) -> Cursor: - c = self._active_cursor - assert c - return c + return self._active_cursor + def iter_linked(self) -> Iterator[LinkedSplits]: + for linked in [self.hist_linked, self.rt_linked]: + yield linked class ChartnPane(QFrame): From 412197019e1bdda88dc325d8c1157395d0c50fc5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 6 Sep 2022 21:21:25 -0400 Subject: [PATCH 31/84] Make ArrowEditor.add()` expect a `PlotItem` as input for render --- piker/ui/_editors.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index 74842964..31d7b7b8 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -45,16 +45,18 @@ log = get_logger(__name__) class ArrowEditor(Struct): - chart: 'ChartPlotWidget' # noqa - _arrows: dict[str, pg.ArrowItem] + 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). @@ -82,16 +84,16 @@ class ArrowEditor(Struct): 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) class LineEditor(Struct): @@ -128,6 +130,8 @@ class LineEditor(Struct): ''' cursor = self.godw.get_cursor() + if not cursor: + return None # delete "staged" cursor tracking line from view line = self._active_staged_line @@ -212,26 +216,23 @@ class LineEditor(Struct): ''' # try to look up line from our registry - # line = self._order_lines.pop(uuid, line) lines = self._order_lines.pop(uuid) - # if line: if lines: cursor = self.godw.get_cursor() for line in lines: # if hovered remove from cursor set - # cursor = self.godw.get_cursor() hovered = cursor._hovered if line in hovered: hovered.remove(line) - # make sure the xhair doesn't get left off - # just because we never got a un-hover event - cursor.show_xhair() - 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}') From 006190d227a2aa1bf7409780180d8a9c5328018f Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 6 Sep 2022 21:21:57 -0400 Subject: [PATCH 32/84] Add fill arrow-mark support to history view --- piker/ui/order_mode.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 57d6be82..ebeec261 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -112,9 +112,10 @@ class OrderMode: mouse click and drag -> modify current order under cursor ''' + feed: Feed chart: ChartPlotWidget # type: ignore # noqa hist_chart: ChartPlotWidget # type: ignore # noqa - nursery: trio.Nursery + nursery: trio.Nursery # used by ``ui._position`` code? quote_feed: Feed book: OrderBook lines: LineEditor @@ -319,7 +320,6 @@ class OrderMode: order.symbol = order.symbol.front_fqsn() - # line = self.line_from_order( lines = self.lines_from_order( order, show_markers=True, @@ -450,14 +450,21 @@ class OrderMode: ''' dialog = self.dialogs[uuid] lines = dialog.lines + assert len(lines) == 2 if lines: - for line in 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, - arrow_index, + i, price, pointing=pointing, - color=line.color + color=lines[0].color ) else: log.warn("No line(s) for order {uuid}!?") @@ -647,7 +654,7 @@ async def open_order_mode( # annotations editors lines = LineEditor(godw=godw) - arrows = ArrowEditor(chart, {}) + arrows = ArrowEditor(godw=godw) # symbol id symbol = chart.linked.symbol @@ -757,6 +764,7 @@ async def open_order_mode( # top level abstraction which wraps all this crazyness into # a namespace.. mode = OrderMode( + feed, chart, hist_chart, tn, From a07367fae297f5b970db84d8de7a43d1393d0f5c Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 7 Sep 2022 10:18:52 -0400 Subject: [PATCH 33/84] Fix div-by-zero split sizing bug --- piker/ui/_chart.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index f919f396..d932c49c 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -416,18 +416,12 @@ class LinkedSplits(QWidget): '''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 - # if ln < 2: - # prop = 3/8*5/8 - - # elif ln >= 2: - # prop = 3/8 - h = self.height() histview_h = h * 5/16 h = h - histview_h From ceac3f2ee4723dc799b958286744c66813cf80f1 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 7 Sep 2022 10:42:12 -0400 Subject: [PATCH 34/84] Adjust corresponding fast/slow chart line level on edits --- piker/ui/order_mode.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index ebeec261..af72900a 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -384,7 +384,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.. @@ -396,7 +396,8 @@ class OrderMode: level = line.value() # updateb by level change callback set in ``.line_from_order()`` - size = line.dialog.order.size + dialog = line.dialog + size = dialog.order.size self.book.update( uuid=line.dialog.uuid, @@ -404,8 +405,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 From 1c685189d1cd78d781472c8d8996c8edce544d96 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 7 Sep 2022 11:29:27 -0400 Subject: [PATCH 35/84] Change to using real type annots --- piker/ui/_cursor.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/piker/ui/_cursor.py b/piker/ui/_cursor.py index 366788b4..2c7bb460 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: @@ -385,7 +396,7 @@ class Cursor(pg.GraphicsObject): def add_plot( self, - plot: 'ChartPlotWidget', # noqa + plot: ChartPlotWidget, # noqa digits: int = 0, ) -> None: @@ -469,7 +480,7 @@ class Cursor(pg.GraphicsObject): def add_curve_cursor( self, - plot: 'ChartPlotWidget', # noqa + plot: ChartPlotWidget, # noqa curve: 'PlotCurveItem', # noqa ) -> LineDot: From 161448c31aba48bbb347131f5ea54b1ab9dd081a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 7 Sep 2022 11:30:41 -0400 Subject: [PATCH 36/84] Support order staging from slow chart using `.get_cursor()` --- piker/ui/_editors.py | 6 +++--- piker/ui/order_mode.py | 23 ++++++++++++++--------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index 31d7b7b8..e7e1bdf0 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -110,11 +110,11 @@ class LineEditor(Struct): 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: diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index af72900a..d1031d38 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -112,6 +112,7 @@ class OrderMode: mouse click and drag -> modify current order under cursor ''' + godw: GodWidget feed: Feed chart: ChartPlotWidget # type: ignore # noqa hist_chart: ChartPlotWidget # type: ignore # noqa @@ -251,12 +252,15 @@ class OrderMode: 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 + chart = self.godw.get_cursor().linked.chart cursor = chart.linked.cursor if not (chart and cursor and cursor.active_plot): @@ -277,7 +281,7 @@ class OrderMode: exec_mode=trigger_type, # dark or live ) - line = self.line_from_order( + lines = self.lines_from_order( order, show_markers=True, # just for the stage line to avoid @@ -290,15 +294,15 @@ class OrderMode: # prevent flickering of marker while moving/tracking cursor only_show_markers_on_hover=False, ) - line = self.lines.stage_line(line) + for line in lines: + line = self.lines.stage_line(line) + # add line to cursor trackers + cursor._trackers.add(line) # hide crosshair y-line and label cursor.hide_xhair() - # add line to cursor trackers - cursor._trackers.add(line) - - return line + return lines def submit_order( self, @@ -770,6 +774,7 @@ async def open_order_mode( # top level abstraction which wraps all this crazyness into # a namespace.. mode = OrderMode( + godw, feed, chart, hist_chart, From 58fe220fdef76ed881e0236c1228e79b4338c6ec Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 7 Sep 2022 12:50:18 -0400 Subject: [PATCH 37/84] Use ref annotations in position mod --- piker/ui/_position.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/piker/ui/_position.py b/piker/ui/_position.py index f7fc447f..197610e7 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -23,7 +23,10 @@ from copy import copy from dataclasses import dataclass from functools import partial from math import floor, copysign -from typing import Optional +from typing import ( + Optional, + TYPE_CHECKING, +) # from PyQt5.QtWidgets import QStyle @@ -47,6 +50,11 @@ 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] = {} @@ -168,8 +176,8 @@ 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) @@ -358,7 +366,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. @@ -395,7 +405,7 @@ class SettingsPane: def position_line( - chart: 'ChartPlotWidget', # noqa + chart: ChartPlotWidget, # noqa size: float, level: float, color: str, @@ -477,7 +487,7 @@ class PositionTracker: ''' # inputs - chart: 'ChartPlotWidget' # noqa + chart: ChartPlotWidget # noqa alloc: Allocator startup_pp: Position @@ -492,7 +502,7 @@ class PositionTracker: def __init__( self, - chart: 'ChartPlotWidget', # noqa + chart: ChartPlotWidget, # noqa alloc: Allocator, startup_pp: Position, From 8f2823d5f0aec44401089f6b79a04bc6f4434f14 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 7 Sep 2022 13:24:17 -0400 Subject: [PATCH 38/84] Stage line only on active cursor chart --- piker/ui/order_mode.py | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index d1031d38..1c78951a 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -185,7 +185,6 @@ class OrderMode: level = order.price 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 @@ -260,10 +259,14 @@ class OrderMode: ''' # not initialized yet - chart = self.godw.get_cursor().linked.chart + cursor = self.godw.get_cursor() + chart = cursor.linked.chart - cursor = chart.linked.cursor - if not (chart and cursor and cursor.active_plot): + if ( + not chart + and cursor + and cursor.active_plot + ): return chart = cursor.active_plot @@ -281,8 +284,14 @@ class OrderMode: exec_mode=trigger_type, # dark or live ) - lines = self.lines_from_order( + # 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 @@ -294,15 +303,22 @@ class OrderMode: # prevent flickering of marker while moving/tracking cursor only_show_markers_on_hover=False, ) - for line in lines: - line = self.lines.stage_line(line) - # add line to cursor trackers - cursor._trackers.add(line) + 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 lines + return line def submit_order( self, @@ -399,7 +415,7 @@ class OrderMode: ) -> None: level = line.value() - # updateb by level change callback set in ``.line_from_order()`` + # updated by level change callback set in ``.line_from_order()`` dialog = line.dialog size = dialog.order.size From a786df65dec4ffae44b165620c5b74a5396b19e5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 7 Sep 2022 15:50:03 -0400 Subject: [PATCH 39/84] Factor pos tracker UI element mgmt into new type More or less moves all the UI related position "nav" logic and graphics item management into a new `._position.Nav` composite type + api for high level mgmt of position graphics indicators across multiple charts (fast and slow). --- piker/ui/_position.py | 475 +++++++++++++++++++++++++----------------- 1 file changed, 280 insertions(+), 195 deletions(-) diff --git a/piker/ui/_position.py b/piker/ui/_position.py index 197610e7..98e18506 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -24,6 +24,7 @@ from dataclasses import dataclass from functools import partial from math import floor, copysign from typing import ( + Callable, Optional, TYPE_CHECKING, ) @@ -44,6 +45,7 @@ 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 @@ -203,7 +205,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 @@ -213,7 +215,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}' ) @@ -224,8 +226,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) @@ -476,6 +478,221 @@ _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) + + if size > 0: + style = '|<' + + elif size < 0: + style = '>|' + + arrow = LevelMarker( + chart=chart, + style=style, + 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 = position_line( + chart=chart, + level=price, + size=size, + color=self.color, + marker=arrow, + ) + self.lines[key] = line + + # modify existing indicator line + else: + line.set_level(price) + level_marker.level = price + 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': account, + }) + line.show() + + # remove line from view for a net-zero pos + elif line: + line.delete() + self.lines[key] = None + + # label updates + size_label = self.size_labels[key] + size_label.fields['slots_used'] = slots_used + size_label.render() + + 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(): + 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 charts. + + ''' + for ( + pp_label, + size_label, + line, + level_marker, + ) in self.iter_ui_elements(): + + # labels + level_marker.show() + 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: MEGALOLZ - this will cause the ui to hannngggg!!!?!?!?! + # 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 @@ -486,75 +703,80 @@ class PositionTracker: 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, - ) + # 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, + ) + # if nav._level_marker: + # nav._level_marker.delete() - # create placeholder 'up' level arrow - self._level_marker = None - self._level_marker = self.level_marker(size=1) + arrow = mk_level_marker( + chart=chart, + size=1, + level=nav.level, + on_paint=nav.update_graphics, + ) - pp_label.scene_anchor = partial( - gpath_pin, - gpath=self._level_marker, - label=pp_label, - ) - pp_label.render() + view.scene().addItem(arrow) + nav.level_markers[key] = arrow - self.size_label = size_label = Label( - view=view, - color=self._color, + pp_label.scene_anchor = partial( + gpath_pin, + gpath=arrow, + label=pp_label, + ) + pp_label.render() + nav.pp_labels[key] = pp_label - # this is "static" label - # update_on_range_change=False, - fmt_str='\n'.join(( - ':{slots_used:.1f}x', - )), + size_label = Label( + view=view, + color=self.nav.color, - fields={ - 'slots_used': 0, - }, - ) - size_label.render() + # this is "static" label + # update_on_range_change=False, + fmt_str='\n'.join(( + ':{slots_used:.1f}x', + )), - size_label.scene_anchor = partial( - pp_tight_and_right, - label=self.pp_label, - ) + 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 + + nav.show() @property def pane(self) -> FieldsForm: @@ -564,21 +786,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, @@ -631,142 +838,20 @@ 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() + self.nav.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() + if self.live_pp.size: + self.nav.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() - - self._level_marker.show() - self.pp_label.show() - self.size_label.show() - - 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 + self.nav.hide_info() From 6b93eedcda6bba83ac9bd9ec5fec0eb32f57e4a5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 7 Sep 2022 15:55:23 -0400 Subject: [PATCH 40/84] Port to new `._position.Nav` apis in order mode --- piker/ui/_interaction.py | 4 ++-- piker/ui/order_mode.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 6ec79682..ee2053c2 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -217,7 +217,7 @@ async def handle_viewmode_kb_inputs( if order_keys_pressed: # show the pp size label - order_mode.current_pp.show() + order_mode.current_pp.nav.show() # TODO: show pp config mini-params in status bar widget # mode.pp_config.show() @@ -277,7 +277,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 diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 1c78951a..b97619f3 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -733,11 +733,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 @@ -749,8 +749,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 @@ -810,8 +810,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) From 2a06dc997f965211169c7365071d8ef4cbf2542d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 7 Sep 2022 16:12:57 -0400 Subject: [PATCH 41/84] Use pixel caching on our level lines --- piker/ui/_lines.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/piker/ui/_lines.py b/piker/ui/_lines.py index 697e889f..1e20958c 100644 --- a/piker/ui/_lines.py +++ b/piker/ui/_lines.py @@ -122,6 +122,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 From 1fa6e8d9ba34054a8986bd9e9e0c78ae51e4d79e Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 7 Sep 2022 16:35:01 -0400 Subject: [PATCH 42/84] Only show slow chart xlabel when focussed --- piker/ui/_cursor.py | 35 ++++++++++++++++++++++++++--------- piker/ui/_display.py | 3 +++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/piker/ui/_cursor.py b/piker/ui/_cursor.py index 2c7bb460..a27aca8c 100644 --- a/piker/ui/_cursor.py +++ b/piker/ui/_cursor.py @@ -336,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 @@ -508,12 +510,23 @@ class Cursor(pg.GraphicsObject): 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 @@ -602,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 0cd21dbb..83b7119b 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -868,6 +868,9 @@ async def display_symbol_data( # to avoid internal pane creation. sidepane=False, ) + # don't show when not focussed + hist_linked.cursor.always_show_xlabel = False + 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 From addedc20f10e5d089b43822f464813a151899a02 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 7 Sep 2022 17:50:10 -0400 Subject: [PATCH 43/84] WIP search pane always shown.. --- piker/ui/_app.py | 5 ++--- piker/ui/_chart.py | 40 +++++++++++++++++++++++++++++++++++----- piker/ui/_display.py | 4 +++- piker/ui/_interaction.py | 4 +++- piker/ui/_search.py | 27 ++++++++++++++++++++------- 5 files changed, 63 insertions(+), 17 deletions(-) diff --git a/piker/ui/_app.py b/piker/ui/_app.py index 998815ba..3fa9d1b4 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('.') diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index d932c49c..dd52699f 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -72,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 @@ -89,6 +90,8 @@ class GodWidget(QWidget): modify them. ''' + search: SearchWidget + def __init__( self, @@ -98,6 +101,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) @@ -239,6 +244,7 @@ class GodWidget(QWidget): linked.show() linked.focus() + self.search.focus() await trio.sleep(0) else: @@ -352,6 +358,17 @@ class ChartnPane(QFrame): hbox.setContentsMargins(0, 0, 0, 0) hbox.setSpacing(3) + def set_sidepane( + self, + sidepane: FieldsForm, + ) -> None: + + # add sidepane **after** chart; place it on axis side + self.hbox.addWidget( + sidepane, + alignment=Qt.AlignTop + ) + class LinkedSplits(QWidget): ''' @@ -583,10 +600,11 @@ class LinkedSplits(QWidget): assert cpw.parent() == qframe # add sidepane **after** chart; place it on axis side - qframe.hbox.addWidget( - sidepane, - alignment=Qt.AlignTop - ) + qframe.set_sidepane(sidepane) + # qframe.hbox.addWidget( + # sidepane, + # alignment=Qt.AlignTop + # ) cpw.sidepane = sidepane @@ -681,19 +699,31 @@ 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 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) + self.chart.sidepane.setMaximumWidth(sp_w) + else: + self.godwidget.hist_linked.resize_sidepanes(from_linked=self) + class ChartPlotWidget(pg.PlotWidget): ''' diff --git a/piker/ui/_display.py b/piker/ui/_display.py index 83b7119b..f2d015f1 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -866,7 +866,8 @@ async def display_symbol_data( feed.hist_shm, # in the case of history chart we explicitly set `False` # to avoid internal pane creation. - sidepane=False, + # sidepane=False, + sidepane=godwidget.search, ) # don't show when not focussed hist_linked.cursor.always_show_xlabel = False @@ -1037,6 +1038,7 @@ async def display_symbol_data( await trio.sleep(0) rt_linked.resize_sidepanes() rt_linked.set_split_sizes() + hist_linked.resize_sidepanes(from_linked=rt_linked) # NOTE: we pop the volume chart from the subplots set so # that it isn't double rendered in the display loop diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index ee2053c2..796ed07d 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -141,7 +141,9 @@ async def handle_viewmode_kb_inputs( Qt.Key_Space, } ): - view._chart.linked.godwidget.search.focus() + godw = view._chart.linked.godwidget + godw.search.focus() + # godw.hist_linked.resize_sidepanes(from_linked=godw.rt_linked) # esc and ctrl-c if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C): diff --git a/piker/ui/_search.py b/piker/ui/_search.py index 80bff43c..b0dcf0f2 100644 --- a/piker/ui/_search.py +++ b/piker/ui/_search.py @@ -140,7 +140,8 @@ class CompleterView(QTreeView): self._font_size: int = 0 # pixels async def on_pressed(self, idx: QModelIndex) -> None: - '''Mouse pressed on view handler. + ''' + Mouse pressed on view handler. ''' search = self.parent() @@ -555,16 +556,24 @@ class SearchWidget(QtWidgets.QWidget): def focus(self) -> None: + godw = self.godwidget 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)), + list(reversed(godw._chart_cache)), clear_all=True, ) - self.bar.focus() + hist_linked = godw.hist_linked + hist_chart = hist_linked.chart + if hist_chart: + rt_linked = godw.rt_linked + hist_chart.qframe.set_sidepane(self) + hist_linked.resize_sidepanes(from_linked=rt_linked) + self.show() + self.bar.focus() def get_current_item(self) -> Optional[tuple[str, str]]: '''Return the current completer tree selection as @@ -603,7 +612,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. @@ -650,6 +660,8 @@ class SearchWidget(QtWidgets.QWidget): clear_all=True, ) + self.focus() + self.bar.focus() return fqsn @@ -717,10 +729,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 @@ -892,7 +905,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: From f9dc5637fa016a0896f1e08b8c6dbc216b245c23 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 8 Sep 2022 10:13:20 -0400 Subject: [PATCH 44/84] Use rt buffer for last price on nan in ems --- piker/clearing/_ems.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piker/clearing/_ems.py b/piker/clearing/_ems.py index 6a9025ff..045134bc 100644 --- a/piker/clearing/_ems.py +++ b/piker/clearing/_ems.py @@ -1109,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) From 994427709635670bfd4d255c30450ff79d3d64d4 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 8 Sep 2022 13:49:13 -0400 Subject: [PATCH 45/84] Handle null lines that were removed, don't error on bad $size --- piker/ui/_position.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/piker/ui/_position.py b/piker/ui/_position.py index 98e18506..c2b4141e 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -261,7 +261,7 @@ class SettingsPane: log.error( f'limit must > then current pp: {dsize}' ) - raise ValueError + return False alloc.currency_limit = value @@ -602,8 +602,8 @@ class Nav(Struct): ''' if self.lines: for key, line in self.lines.items(): - return line.value() - + if line: + return line.value() return 0 def iter_ui_elements(self) -> tuple[ From 256bcf36d3aeca684f19ef931959dcbbbe34645e Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 8 Sep 2022 15:02:55 -0400 Subject: [PATCH 46/84] Drop use `tractor.trionics.gather_contexts()` in `open_handlers()` --- piker/ui/_event.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) 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 From 40a97619432c709e8c2e37c222e5a084efe7288e Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 8 Sep 2022 20:00:50 -0400 Subject: [PATCH 47/84] Actually support resize events.. Turns out god widget resizes aren't triggered implicitly by window resizes, so instead, hook into the window by moving what was our useless method to that class. Further we explicitly define and declare that our window has a `.godwidget: GodWidget` and set it up in the bootstrap phase - in `run_qutractor()` during `trio` guest mode configuration. Further deatz: - retype the runtime/bootstrap routines to take a qwidget "type" not an instance, and drop the whole implicit `.main_widget` stuff. - delegate into the `GodWidget.on_win_resize()` for any window resize which then triggers all the custom resize callbacks we already had in place. - privatize `ChartnPane.sidepane` so that it can't be mutated willy nilly without calling `.set_sidepane()`. - always adjust splitter sizes inside `LinkeSplits.add_plot()`. --- piker/ui/_app.py | 2 +- piker/ui/_chart.py | 62 ++++++++++++++++++++++++++++++++++----------- piker/ui/_exec.py | 20 +++++++++------ piker/ui/_window.py | 23 ++++++++++++++--- 4 files changed, 79 insertions(+), 28 deletions(-) diff --git a/piker/ui/_app.py b/piker/ui/_app.py index 3fa9d1b4..c99e2866 100644 --- a/piker/ui/_app.py +++ b/piker/ui/_app.py @@ -177,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 dd52699f..e03f7fe0 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -91,6 +91,7 @@ class GodWidget(QWidget): ''' search: SearchWidget + mode_name: str = 'god' def __init__( @@ -135,6 +136,10 @@ 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 @@ -203,11 +208,14 @@ class GodWidget(QWidget): if not self.vbox.isEmpty(): + qframe = self.hist_linked.chart.qframe + if qframe.sidepane is self.search: + qframe.hbox.removeWidget(self.search) + for linked in [self.rt_linked, self.hist_linked]: # XXX: this is CRITICAL especially with pixel buffer caching linked.hide() linked.unfocus() - # self.hist_linked.hide() # self.hist_linked.unfocus() # XXX: pretty sure we don't need this @@ -244,7 +252,6 @@ class GodWidget(QWidget): linked.show() linked.focus() - self.search.focus() await trio.sleep(0) else: @@ -279,6 +286,17 @@ class GodWidget(QWidget): # do nothing yah? 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 resizes the fast chart as well as all it's + # downstream fsp subcharts AND the slow chart which is part of + # the same splitter. + self.rt_linked.set_split_sizes() + # set window titlebar info symbol = self.rt_linked.symbol if symbol is not None: @@ -297,11 +315,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) @@ -317,6 +347,8 @@ class GodWidget(QWidget): self._resizing = False + # on_resize = on_win_resize + def get_cursor(self) -> Cursor: return self._active_cursor @@ -336,9 +368,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, @@ -350,7 +382,7 @@ class ChartnPane(QFrame): super().__init__(parent) - self.sidepane = sidepane + self._sidepane = sidepane self.chart = None hbox = self.hbox = QHBoxLayout(self) @@ -360,7 +392,7 @@ class ChartnPane(QFrame): def set_sidepane( self, - sidepane: FieldsForm, + sidepane: FieldsForm | SearchWidget, ) -> None: # add sidepane **after** chart; place it on axis side @@ -368,6 +400,10 @@ class ChartnPane(QFrame): sidepane, alignment=Qt.AlignTop ) + self._sidepane = sidepane + + def sidepane(self) -> FieldsForm | SearchWidget: + return self._sidepane class LinkedSplits(QWidget): @@ -672,9 +708,6 @@ class LinkedSplits(QWidget): if qframe is not None: self.splitter.addWidget(qframe) - # scale split regions - self.set_split_sizes() - else: assert style == 'bar', 'main chart must be OHLC' @@ -694,6 +727,8 @@ class LinkedSplits(QWidget): anchor_at=anchor_at, ) + # scale split regions + self.set_split_sizes() self.resize_sidepanes() return cpw @@ -720,9 +755,6 @@ class LinkedSplits(QWidget): if from_linked: self.chart.sidepane.setMinimumWidth(sp_w) - self.chart.sidepane.setMaximumWidth(sp_w) - else: - self.godwidget.hist_linked.resize_sidepanes(from_linked=self) class ChartPlotWidget(pg.PlotWidget): 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/_window.py b/piker/ui/_window.py index 6a39b0c5..5d720c0b 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__) @@ -154,6 +159,7 @@ class MainWindow(QtGui.QMainWindow): # with the alloted window size. # TODO: detect for tiling and if untrue set some size? 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,11 @@ class MainWindow(QtGui.QMainWindow): self.resize(*size or self._size) + def resizeEvent(self, event: QtCore.QEvent) -> None: + print('window resize') + # self.godwidget.resizeEvent(event) + self.godwidget.on_win_resize(event) + # singleton app per actor _qt_win: QtGui.QMainWindow = None From 1e81feee46377c6c56163f973fca5a4a5f786a6e Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 8 Sep 2022 22:48:34 -0400 Subject: [PATCH 48/84] Finally get chart startup view-state kinda correct It ended up being what'd you expect, races on the accessing shm buffer data by the UI during the whole "mega-async-startup-everything" phase XD So we add the following list of ad-hoc startup steps: - do `.default_view()` on the slow chart after the fast chart is mostly fully spawned with the intention being to capture the state where the historical buffer is mostly loaded before sizing the view to the graphical form of the data. - resize slow chart sidepanes from the fast chart just before sleeping forever (and after order mode has booted). --- piker/ui/_display.py | 35 +++++++++++++++++++++++------------ piker/ui/_interaction.py | 7 +++---- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index f2d015f1..27bb466b 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -851,13 +851,6 @@ async def display_symbol_data( rt_linked = godwidget.rt_linked rt_linked._symbol = symbol - # generate order mode side-pane UI - # A ``FieldsForm`` form to configure order entry - pp_pane: FieldsForm = mk_order_pane_layout(godwidget) - - # add as next-to-y-axis singleton pane - godwidget.pp_pane = pp_pane - # create top history view chart above the "main rt chart". hist_linked = godwidget.hist_linked hist_linked._symbol = symbol @@ -872,10 +865,11 @@ async def display_symbol_data( # don't show when not focussed hist_linked.cursor.always_show_xlabel = False - 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 - ) + # 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) + godwidget.pp_pane = pp_pane # create main OHLC chart chart = rt_linked.plot_ohlc_main( @@ -1020,6 +1014,15 @@ async def display_symbol_data( # 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) async with ( open_order_mode( @@ -1040,6 +1043,8 @@ async def display_symbol_data( rt_linked.set_split_sizes() hist_linked.resize_sidepanes(from_linked=rt_linked) + # 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 @@ -1050,6 +1055,12 @@ async def display_symbol_data( # close group status sbar._status_groups[loading_sym_key][1]() + hist_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 + ) + # let the app run.. bby - # rt_linked.graphics_cycle() await trio.sleep_forever() diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 796ed07d..9b7eba6d 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -142,8 +142,8 @@ async def handle_viewmode_kb_inputs( } ): godw = view._chart.linked.godwidget + godw.hist_linked.resize_sidepanes(from_linked=godw.rt_linked) godw.search.focus() - # godw.hist_linked.resize_sidepanes(from_linked=godw.rt_linked) # esc and ctrl-c if key == Qt.Key_Escape or (ctrl and key == Qt.Key_C): @@ -181,7 +181,8 @@ 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.linked.cursor.in_query_mode = True @@ -191,7 +192,6 @@ async def handle_viewmode_kb_inputs( # SELECTION MODE # -------------- - if shift: if view.state['mouseMode'] == ViewBox.PanMode: view.setMouseMode(ViewBox.RectMode) @@ -212,7 +212,6 @@ async def handle_viewmode_kb_inputs( # ORDER MODE # ---------- - # live vs. dark trigger + an action {buy, sell, alert} order_keys_pressed = ORDER_MODE.intersection(pressed) From d11dc787a15671ebde96beda529609d299c1d352 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 9 Sep 2022 21:46:57 -0400 Subject: [PATCH 49/84] First working attempt of search results view scaling Scales the "view" instance that holds search results to the size of the accompanying "slow chart" for which the search pane is a "sidepane". A lot of mucking about was required due to resizing of the view seemingly feeding back into window resizing and further implementing the sizing logic such that the parent `QSplitter` can be resized as the user's whim as well. Details, - add a `CompleterView._init: bool` which is set once (and only once) after startup where the first display of the current symbol/feed is shown allowing and a single *width* padding applied once at startup to ensure we don't have an awkward line to the right of the longest result. - in `.resize_to_results()` only apply a minimum height to the view using `.setMinimumHeight()` with a down-scaled (`0.91` for now) height value from input. - re-implement `CompleterView.show_matches()` to accept and optional width, heigh tuple and when not supplied pull the slow chart's dimensions and pass as input to the resize method. - Make `SearchWidget` x dim sizing policy "fixed". - register the `SearchWidget` for resize events with god. - add `.show_only_cache_entries()` for easy results clearing. - add `.space_dims()` to retrieve slow linked-charts dimensions. - implement `SearchWidget.on_resize()` which is the caller of all the previously mentioned resizing routines. - do resizing and cache entry showing on search loop startup and be sure to clear to cache when the user selects a symbol-feed with Enter. --- piker/ui/_search.py | 233 +++++++++++++++++++++++++++----------------- 1 file changed, 145 insertions(+), 88 deletions(-) diff --git a/piker/ui/_search.py b/piker/ui/_search.py index b0dcf0f2..db8b1729 100644 --- a/piker/ui/_search.py +++ b/piker/ui/_search.py @@ -138,6 +138,7 @@ class CompleterView(QTreeView): model.setHorizontalHeaderLabels(labels) self._font_size: int = 0 # pixels + self._init: bool = False async def on_pressed(self, idx: QModelIndex) -> None: ''' @@ -145,7 +146,7 @@ class CompleterView(QTreeView): ''' 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): @@ -157,56 +158,58 @@ 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() + row_h = self.rowHeight(cidx) + # print(f'row_h: {row_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() + # 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. - # 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 h: + h: int = round(h) + # mn = row_h * 2 + # self.setMinimumHeight(mn) + # abs_mx = h - row_h + # print(f'set min {abs_mx}') + # self.setFixedHeight(round(0.9 * h)) + self.setMinimumHeight(round(0.91 * 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)) + # self.setMaximumHeight(0.9 * abs_mx) + # 6 result row slots and 3 rows for sections and headers + # mn = (6 + 3) * row_h - # 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() @@ -376,8 +379,6 @@ class CompleterView(QTreeView): else: model.setItem(idx.row(), 1, QStandardItem()) - self.resize_to_results() - return idx else: return None @@ -445,9 +446,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) + self.show() - self.resize_to_results() class SearchBar(Edit): @@ -467,18 +481,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): @@ -497,15 +508,19 @@ class SearchWidget(QtWidgets.QWidget): parent=None, ) -> None: - super().__init__(parent or godwidget) + super().__init__(parent) # size it as we specify self.setSizePolicy( + # QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Fixed, + # QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed, - QtWidgets.QSizePolicy.Expanding, ) self.godwidget = godwidget + godwidget.reg_for_resize(self) + self._last_h: float = 0 self.vbox = QtWidgets.QVBoxLayout(self) self.vbox.setContentsMargins(0, 4, 4, 0) @@ -555,26 +570,24 @@ class SearchWidget(QtWidgets.QWidget): self.vbox.setAlignment(self.view, Qt.AlignTop | Qt.AlignLeft) def focus(self) -> None: - - godw = self.godwidget - if self.view.model().rowCount(QModelIndex()) == 0: - # fill cache list if nothing existing - self.view.set_section_entries( - 'cache', - list(reversed(godw._chart_cache)), - clear_all=True, - ) - - hist_linked = godw.hist_linked - hist_chart = hist_linked.chart - if hist_chart: - rt_linked = godw.rt_linked - hist_chart.qframe.set_sidepane(self) - hist_linked.resize_sidepanes(from_linked=rt_linked) - 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 + print('showing cache only') + 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 a tuple ``(parent: str, child: str)`` if valid, else ``None``. @@ -624,11 +637,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', @@ -645,25 +658,60 @@ 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( + godw.set_chart_symbol( fqsn, ( - chart.linkedsplits, - self.godwidget.hist_linked, + godw.hist_linked, + godw.rt_linked, ) ) + self.show_only_cache_entries() - self.view.set_section_entries( - 'cache', - values=list(reversed(chart._chart_cache)), - - # remove all other completion results except for cache - clear_all=True, - ) - - self.focus() + # self.focus() 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. + + ''' + # 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. + w, h = self.space_dims() + if ( + not self._last_h + or self._last_h != h + ): + # print( + # f'w: {w}\n' + # f'h: {h}\n' + # f'._last_h: {self._last_h}\n' + # ) + self._last_h = h + self.bar.view.show_matches(wh=(w, h)) + _search_active: trio.Event = trio.Event() _search_enabled: bool = False @@ -747,6 +795,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 @@ -859,8 +911,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: @@ -875,6 +926,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() @@ -885,10 +940,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) + view.show_matches() + search.show_only_cache_entries() + search.focus() elif not ctl and not bar.text(): # if nothing in search text show the cache @@ -905,7 +961,7 @@ async def handle_keyboard_input( Qt.Key_Space, # i feel like this is the "native" one Qt.Key_Alt, }: - #bar.unfocus() + bar.unfocus() # kill the search and focus back on main chart if godwidget: @@ -953,9 +1009,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: From d5f0c59b574dd4ab3a36d7f39412ddc2389297d2 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 9 Sep 2022 22:07:51 -0400 Subject: [PATCH 50/84] Ignore resize events with the same height (for now) --- piker/ui/_window.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/piker/ui/_window.py b/piker/ui/_window.py index 5d720c0b..e574da23 100644 --- a/piker/ui/_window.py +++ b/piker/ui/_window.py @@ -158,7 +158,7 @@ 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)' @@ -303,9 +303,31 @@ class MainWindow(QtGui.QMainWindow): self.resize(*size or self._size) def resizeEvent(self, event: QtCore.QEvent) -> None: - print('window resize') - # self.godwidget.resizeEvent(event) + 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 From eed47b373353b57733e42cc1e59c2fe869ca10ec Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 9 Sep 2022 22:08:30 -0400 Subject: [PATCH 51/84] Add splitter move handler which calls search widget resizer method --- piker/ui/_chart.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index e03f7fe0..038e957c 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -439,6 +439,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) @@ -451,6 +452,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 @@ -489,7 +500,8 @@ class LinkedSplits(QWidget): # 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: From 80929d080f1708e61046ffa5d3c669fdfeff84e6 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 9 Sep 2022 22:08:52 -0400 Subject: [PATCH 52/84] Add more detailed splitter of splitters comment --- piker/ui/_display.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index 27bb466b..fa07a03c 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -971,6 +971,9 @@ async def display_symbol_data( 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) From bcd6bbb7caa38928878f991bf860013316c976c5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 10 Sep 2022 12:51:07 -0400 Subject: [PATCH 53/84] Increase the `brokerd` mem-chan size Intention is to hopefully minimize (as many) context switches when processing (near-)HFT feeds - tho not sure if it's improving things that much XD --- piker/data/feed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piker/data/feed.py b/piker/data/feed.py index 13495178..66b540ee 100644 --- a/piker/data/feed.py +++ b/piker/data/feed.py @@ -906,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() From ead426abc4b6f6d11d63c41251fca391845f9aa2 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 11 Sep 2022 17:24:09 -0400 Subject: [PATCH 54/84] More space to fast chart(s), less to slow chart --- piker/ui/_chart.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 038e957c..575e6286 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -477,17 +477,18 @@ 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) or 1 # proportion allocated to consumer subcharts if not prop: - prop = 3/8*5/8 + prop = 3/8 h = self.height() - histview_h = h * 5/16 + histview_h = h * ((6/16) ** 2) h = h - histview_h major = 1 - prop From f7c0ee930a87a7daec532737b2f4d07b45e0e675 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 11 Sep 2022 17:33:22 -0400 Subject: [PATCH 55/84] Offset last (live) datum from y-axis by a 16th --- piker/ui/_display.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index fa07a03c..3edf8b20 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -1060,10 +1060,14 @@ async def display_symbol_data( hist_linked.graphics_cycle() await trio.sleep(0) + + bars_in_mem = int(len(hist_ohlcv.array)) 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 + 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), ) + rt_linked.set_split_sizes() # let the app run.. bby await trio.sleep_forever() From b734af6dd07535afe471a3c9b48e54c9c23cc242 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 11 Sep 2022 17:33:57 -0400 Subject: [PATCH 56/84] Only delete lines under cursor if not `None` --- piker/ui/_editors.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index e7e1bdf0..e250eee3 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -219,19 +219,19 @@ class LineEditor(Struct): lines = self._order_lines.pop(uuid) 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) - for line in lines: - # if hovered remove from cursor set - hovered = cursor._hovered - if line in hovered: - hovered.remove(line) + log.debug(f'deleting {line} with oid: {uuid}') + line.delete() - 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() + # 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}') From 73a02d54b785d677805944711cf7a9ba073dad29 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 11 Sep 2022 17:35:40 -0400 Subject: [PATCH 57/84] Down size the slots bar by .9 --- piker/ui/_forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 3fd7107e08d55a8b856b1f12d564d8c1159520c5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 12 Sep 2022 09:58:11 -0400 Subject: [PATCH 58/84] Scale view to measured results row count In other words instead of some static view size previously determined by the accompanying (slow) chart's height, (recursively) calculate the number of displayed rows and compute the minimal height needed. This still caps the view at the height of the chart such that the view will switch to scroll bar mode when too many results are shown and can't all be fit in the vertical space. Deats: - add a ``CompleterView.iter_df_rows()`` which recursively iterates all rows in depth-first order making it simple to compute the absolute number of result rows in view and thus the minimal number of pixels to show all results. - always pass the height in the `.on_resize()` handler to ensure triggering the height logic when new results are generated in the search loop. --- piker/ui/_search.py | 66 +++++++++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/piker/ui/_search.py b/piker/ui/_search.py index db8b1729..a9557b7b 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) @@ -166,11 +170,15 @@ class CompleterView(QTreeView): ) -> None: model = self.model() cols = model.columnCount() - - # rows = model.rowCount() cidx = self.selectionModel().currentIndex() - row_h = self.rowHeight(cidx) - # print(f'row_h: {row_h}') + rows = model.rowCount() + self.expandAll() + + row_h = rows_h = self.rowHeight(cidx) * rows + 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}') col_w_tot = 0 for i in range(cols): @@ -195,16 +203,16 @@ class CompleterView(QTreeView): if h: h: int = round(h) - # mn = row_h * 2 - # self.setMinimumHeight(mn) - # abs_mx = h - row_h - # print(f'set min {abs_mx}') - # self.setFixedHeight(round(0.9 * h)) - self.setMinimumHeight(round(0.91 * h)) + abs_mx = round(0.91 * h) + self.setMaximumHeight(abs_mx) - # self.setMaximumHeight(0.9 * abs_mx) - # 6 result row slots and 3 rows for sections and headers - # mn = (6 + 3) * row_h + if rows_h <= abs_mx: + # self.setMinimumHeight(rows_h) + self.setMinimumHeight(rows_h) + # self.setFixedHeight(rows_h) + + else: + self.setMinimumHeight(abs_mx) # dyncamically size to width of longest result seen curr_w = self.width() @@ -335,6 +343,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, @@ -358,7 +383,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) @@ -459,7 +485,7 @@ class CompleterView(QTreeView): # a resize of some higher level parent-container widget. search = self.parent() w, h = search.space_dims() - self.resize_to_results(w=w) + self.resize_to_results(w=w, h=h) self.show() @@ -942,8 +968,8 @@ async def handle_keyboard_input( if key in (Qt.Key_Enter, Qt.Key_Return): _search_enabled = False await search.chart_current_item(clear_to_cache=True) - view.show_matches() search.show_only_cache_entries() + view.show_matches() search.focus() elif not ctl and not bar.text(): From 445849337fff7d655373a39e866554aa0796f528 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 12 Sep 2022 13:45:48 -0400 Subject: [PATCH 59/84] Always resize to slow chart height, not just on changes --- piker/ui/_search.py | 31 ++++++++++--------------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/piker/ui/_search.py b/piker/ui/_search.py index a9557b7b..f447ea14 100644 --- a/piker/ui/_search.py +++ b/piker/ui/_search.py @@ -174,12 +174,17 @@ class CompleterView(QTreeView): 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 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 @@ -194,13 +199,11 @@ class CompleterView(QTreeView): self.resizeColumnToContents(i) col_w_tot += self.columnWidth(i) - # 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. + # 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) @@ -546,7 +549,6 @@ class SearchWidget(QtWidgets.QWidget): self.godwidget = godwidget godwidget.reg_for_resize(self) - self._last_h: float = 0 self.vbox = QtWidgets.QVBoxLayout(self) self.vbox.setContentsMargins(0, 4, 4, 0) @@ -722,21 +724,8 @@ class SearchWidget(QtWidgets.QWidget): height. ''' - # 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. w, h = self.space_dims() - if ( - not self._last_h - or self._last_h != h - ): - # print( - # f'w: {w}\n' - # f'h: {h}\n' - # f'._last_h: {self._last_h}\n' - # ) - self._last_h = h - self.bar.view.show_matches(wh=(w, h)) + self.bar.view.show_matches(wh=(w, h)) _search_active: trio.Event = trio.Event() From 4f15ce346bd0dc3c7b99c30214c034363ee0cef1 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 12 Sep 2022 13:51:37 -0400 Subject: [PATCH 60/84] Drop splitter resizes except for once at startup Also adds a `GodWidget.resize_all()` helper method which resizes all sub-widgets and charts to their default ratios and/or parent-widget dependent defaults using the detected available space on screen. This is a "default layout" config method that eventually we'll probably want allow users to customize. --- piker/ui/_chart.py | 22 ++++++++++++++-------- piker/ui/_display.py | 12 ++++++++---- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 575e6286..eb76112f 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -292,11 +292,6 @@ class GodWidget(QWidget): if hist_chart: hist_chart.qframe.set_sidepane(self.search) - # NOTE: this resizes the fast chart as well as all it's - # downstream fsp subcharts AND the slow chart which is part of - # the same splitter. - self.rt_linked.set_split_sizes() - # set window titlebar info symbol = self.rt_linked.symbol if symbol is not None: @@ -356,6 +351,19 @@ class GodWidget(QWidget): 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): ''' @@ -488,7 +496,7 @@ class LinkedSplits(QWidget): prop = 3/8 h = self.height() - histview_h = h * ((6/16) ** 2) + histview_h = h * (6/16) * 0.666 h = h - histview_h major = 1 - prop @@ -740,8 +748,6 @@ class LinkedSplits(QWidget): anchor_at=anchor_at, ) - # scale split regions - self.set_split_sizes() self.resize_sidepanes() return cpw diff --git a/piker/ui/_display.py b/piker/ui/_display.py index 3edf8b20..ada76f01 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -979,6 +979,8 @@ async def display_symbol_data( # rt_linked.splitter.addWidget(hist_linked) rt_linked.focus() + godwidget.resize_all() + vlm_chart: Optional[ChartPlotWidget] = None async with trio.open_nursery() as ln: @@ -1027,6 +1029,8 @@ async def display_symbol_data( hist_linked.graphics_cycle() await trio.sleep(0) + godwidget.resize_all() + async with ( open_order_mode( feed, @@ -1042,9 +1046,9 @@ async def display_symbol_data( # let Qt run to render all widgets and make sure the # sidepanes line up vertically. await trio.sleep(0) - rt_linked.resize_sidepanes() - rt_linked.set_split_sizes() - hist_linked.resize_sidepanes(from_linked=rt_linked) + + # dynamic resize steps + godwidget.resize_all() # TODO: look into this because not sure why it was # commented out / we ever needed it XD @@ -1067,7 +1071,7 @@ async def display_symbol_data( # push it 1/16th away from the y-axis y_offset=round(bars_in_mem / 16), ) - rt_linked.set_split_sizes() + godwidget.resize_all() # let the app run.. bby await trio.sleep_forever() From b3fcc25e212f62f39790d1ba88b5a6347b0f72f8 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 12 Sep 2022 15:37:16 -0400 Subject: [PATCH 61/84] Add extra row count for header, drop prints --- piker/ui/_search.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/piker/ui/_search.py b/piker/ui/_search.py index f447ea14..bbe88320 100644 --- a/piker/ui/_search.py +++ b/piker/ui/_search.py @@ -176,7 +176,7 @@ class CompleterView(QTreeView): # compute the approx height in pixels needed to include # all result rows in view. - row_h = rows_h = self.rowHeight(cidx) * rows + 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 @@ -199,7 +199,6 @@ class CompleterView(QTreeView): self.resizeColumnToContents(i) col_w_tot += self.columnWidth(i) - # 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 @@ -541,9 +540,7 @@ class SearchWidget(QtWidgets.QWidget): # size it as we specify self.setSizePolicy( - # QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed, - # QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed, ) @@ -608,7 +605,6 @@ class SearchWidget(QtWidgets.QWidget): ''' godw = self.godwidget - print('showing cache only') self.view.set_section_entries( 'cache', list(reversed(godw._chart_cache)), @@ -694,7 +690,6 @@ class SearchWidget(QtWidgets.QWidget): ) self.show_only_cache_entries() - # self.focus() self.bar.focus() return fqsn @@ -827,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 From 70f2241d229776608c057b4f4ff09fa3ad34e016 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 12 Sep 2022 15:37:44 -0400 Subject: [PATCH 62/84] Hide pp markers on startup --- piker/ui/_position.py | 1 + 1 file changed, 1 insertion(+) diff --git a/piker/ui/_position.py b/piker/ui/_position.py index c2b4141e..0d0a1870 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -542,6 +542,7 @@ class Nav(Struct): size_digits = size_digits or chart.linked.symbol.lot_size_digits line = self.lines.get(key) level_marker = self.level_markers[key] + level_marker.hide() pp_label = self.pp_labels[key] if size: From 20663dfa1ce05cdfd0638a2eb65d2622c3398b40 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 12 Sep 2022 15:39:40 -0400 Subject: [PATCH 63/84] Add (more) order mode race guards to avoid crashes on "kitty-keys" --- piker/ui/order_mode.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index b97619f3..3f225104 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -260,8 +260,10 @@ class OrderMode: ''' # not initialized yet cursor = self.godw.get_cursor() - chart = cursor.linked.chart + if not cursor: + return + chart = cursor.linked.chart if ( not chart and cursor @@ -271,6 +273,11 @@ class OrderMode: 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( From ae64ac79a6158f1f6287d63bc8b2e7b3553adfbb Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 13 Sep 2022 16:13:46 -0400 Subject: [PATCH 64/84] Doc str tweaks --- piker/ui/_annotate.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/piker/ui/_annotate.py b/piker/ui/_annotate.py index 6e0e84d1..5cae0bdd 100644 --- a/piker/ui/_annotate.py +++ b/piker/ui/_annotate.py @@ -32,8 +32,10 @@ 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: @@ -87,7 +89,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. ''' @@ -154,7 +157,8 @@ class LevelMarker(QGraphicsPathItem): # level: float, ) -> None: - '''Show a pp off-screen indicator for a level label. + ''' + 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 @@ -211,7 +215,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". From ad2100fe3fdd033b70f80d33b20a2256108bb6d8 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 13 Sep 2022 16:21:49 -0400 Subject: [PATCH 65/84] Only don't pp arrow on startup --- piker/ui/_position.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/piker/ui/_position.py b/piker/ui/_position.py index 0d0a1870..01dcb1ca 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -542,7 +542,6 @@ class Nav(Struct): size_digits = size_digits or chart.linked.symbol.lot_size_digits line = self.lines.get(key) level_marker = self.level_markers[key] - level_marker.hide() pp_label = self.pp_labels[key] if size: @@ -675,7 +674,7 @@ class Nav(Struct): pp_label.update() size_label.update() - # XXX: MEGALOLZ - this will cause the ui to hannngggg!!!?!?!?! + # XXX: can't call this because it causes a recursive paint/render # level_marker.update() def hide_info(self) -> None: @@ -738,21 +737,6 @@ class PositionTracker: # if nav._level_marker: # nav._level_marker.delete() - arrow = mk_level_marker( - chart=chart, - size=1, - level=nav.level, - on_paint=nav.update_graphics, - ) - - view.scene().addItem(arrow) - nav.level_markers[key] = arrow - - pp_label.scene_anchor = partial( - gpath_pin, - gpath=arrow, - label=pp_label, - ) pp_label.render() nav.pp_labels[key] = pp_label @@ -777,6 +761,22 @@ class PositionTracker: ) nav.size_labels[key] = size_label + arrow = mk_level_marker( + chart=chart, + size=1, + level=nav.level, + on_paint=nav.update_graphics, + ) + arrow.hide() # never show on startup + view.scene().addItem(arrow) + nav.level_markers[key] = arrow + + pp_label.scene_anchor = partial( + gpath_pin, + gpath=arrow, + label=pp_label, + ) + nav.show() @property From 44c6f6dfdad0f15e11bbd6104a5f855cfba70a96 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 13 Sep 2022 17:43:04 -0400 Subject: [PATCH 66/84] Add level line flag to allow tracking its marker x-position --- piker/ui/_annotate.py | 13 +++++-------- piker/ui/_lines.py | 26 ++++++++++++++++---------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/piker/ui/_annotate.py b/piker/ui/_annotate.py index 5cae0bdd..35f6d548 100644 --- a/piker/ui/_annotate.py +++ b/piker/ui/_annotate.py @@ -117,6 +117,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 @@ -152,11 +153,7 @@ class LevelMarker(QGraphicsPathItem): def w(self) -> float: return self.path_br().width() - def position_in_view( - self, - # level: float, - - ) -> None: + def position_in_view(self) -> None: ''' Show a pp off-screen indicator for a level label. @@ -190,7 +187,6 @@ class LevelMarker(QGraphicsPathItem): ) elif level < ymn: # pin to bottom of view - self.setPos( QPointF( x, @@ -240,11 +236,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/_lines.py b/piker/ui/_lines.py index 1e20958c..033464c3 100644 --- a/piker/ui/_lines.py +++ b/piker/ui/_lines.py @@ -85,6 +85,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) @@ -329,7 +330,7 @@ class LevelLine(pg.InfiniteLine): from pg.. ''' - p.setRenderHint(p.Antialiasing) + # p.setRenderHint(p.Antialiasing) # these are in viewbox coords vb_left, vb_right = self._endPoints @@ -355,14 +356,18 @@ class LevelLine(pg.InfiniteLine): # order lines.. not sure wtf is up with that. # for now we're just using it on the position line. elif self._marker: + if self.track_marker_pos: + # make the line end at the marker's x pos + line_end = 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() + else: + # 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() elif not self.use_marker_margin: # basically means **don't** shorten the line with normally @@ -525,9 +530,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( From e492e9ca0c2b35f1fc3b9c0fda795bd2526ca7cd Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 13 Sep 2022 17:46:50 -0400 Subject: [PATCH 67/84] Fix pp arrow/label placement bugs - Every time a symbol is switched on chart we need to wait until the search bar sidepane has been added beside the slow chart before determining the offset for the pp line's arrow/labels; trigger this in `GodWidget.load_symbol()` -> required monkeypatching on a `.mode: OrderMode` to the `.rt_linked` for now.. - Drop the search pane widget removal from the current linked chart, seems faster? - On the slow chart override the `LevelMarker.scene_x()` callback to adjust for the case where no L1 labels are shown beside the y-axis. --- piker/ui/_chart.py | 25 +++++++++++++++---------- piker/ui/_display.py | 10 ++++++++-- piker/ui/_position.py | 37 +++++++++++++++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index eb76112f..8f2718ca 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -208,15 +208,15 @@ class GodWidget(QWidget): if not self.vbox.isEmpty(): - qframe = self.hist_linked.chart.qframe - if qframe.sidepane is self.search: - qframe.hbox.removeWidget(self.search) + # XXX: seems to make switching slower? + # qframe = self.hist_linked.chart.qframe + # if qframe.sidepane is self.search: + # qframe.hbox.removeWidget(self.search) for linked in [self.rt_linked, self.hist_linked]: # XXX: this is CRITICAL especially with pixel buffer caching linked.hide() linked.unfocus() - # self.hist_linked.unfocus() # XXX: pretty sure we don't need this # remove any existing plots? @@ -271,12 +271,8 @@ class GodWidget(QWidget): linked.graphics_cycle() 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 - # linked.chart.qframe.hbox.removeWidget(self.pp_pane) - chart = linked.chart - # resume feeds *after* rendering chart view asap + chart = linked.chart if chart: chart.resume_all_feeds() @@ -284,7 +280,7 @@ class GodWidget(QWidget): # 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() + # chart.default_view() # if a history chart instance is already up then # set the search widget as its sidepane. @@ -292,6 +288,15 @@ class GodWidget(QWidget): 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) + pp_nav = self.rt_linked.mode.current_pp.nav + pp_nav.show() + pp_nav.hide_info() + # set window titlebar info symbol = self.rt_linked.symbol if symbol is not None: diff --git a/piker/ui/_display.py b/piker/ui/_display.py index ada76f01..c4891c7e 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -55,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, @@ -1031,18 +1034,21 @@ async def display_symbol_data( godwidget.resize_all() + mode: OrderMode async with ( open_order_mode( feed, 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) diff --git a/piker/ui/_position.py b/piker/ui/_position.py index 01dcb1ca..9b9976ab 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -405,7 +405,7 @@ class SettingsPane: self.pnl_label.format(pnl=pnl_value) -def position_line( +def pp_line( chart: ChartPlotWidget, # noqa size: float, @@ -461,6 +461,9 @@ def position_line( marker.update() marker.show() + line._marker = marker + line.track_marker_pos = True + # show position marker on view "edge" when out of view vb = line.getViewBox() vb.sigRangeChanged.connect(marker.position_in_view) @@ -548,7 +551,7 @@ class Nav(Struct): # create and show a pp line if none yet exists if line is None: arrow = self.level_markers[key] - line = position_line( + line = pp_line( chart=chart, level=price, size=size, @@ -634,6 +637,18 @@ class Nav(Struct): # labels level_marker.show() + + # 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.position_in_view() pp_label.show() size_label.show() @@ -767,6 +782,24 @@ class PositionTracker: level=nav.level, on_paint=nav.update_graphics, ) + + # 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. + + ''' + x = chart.marker_right_points()[1] + if chart._max_l1_line_len == 0: + mkw = pp_label.txt.boundingRect().width() + x -= 1.5 * mkw + + return x + + arrow.scene_x = offset_from_yaxis arrow.hide() # never show on startup view.scene().addItem(arrow) nav.level_markers[key] = arrow From df42e7acc41d65686165a838681ceae62d373327 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 13 Sep 2022 18:26:06 -0400 Subject: [PATCH 68/84] Add `LevelLine.get_cursor()` to get any currently hovering mouse-cursor --- piker/ui/_lines.py | 42 +++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/piker/ui/_lines.py b/piker/ui/_lines.py index 033464c3..7756f1a7 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: @@ -220,20 +228,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 @@ -285,10 +296,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: @@ -302,9 +323,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) @@ -312,8 +332,8 @@ class LevelLine(pg.InfiniteLine): def mouseDoubleClickEvent( self, ev: QtGui.QMouseEvent, - ) -> None: + ) -> None: # TODO: enter labels edit mode print(f'double click {ev}') From b7e60b965316686ac9ce0e4e023d947ae4de96aa Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 13 Sep 2022 18:31:21 -0400 Subject: [PATCH 69/84] Hide labels, show markers for lines on slow chart --- piker/ui/order_mode.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 3f225104..73c54ba3 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -235,11 +235,15 @@ class OrderMode: ) -> list[LevelLine]: lines: list[LevelLine] = [] - for chart in [self.chart, self.hist_chart]: + for chart, kwargs in [ + (self.chart, {}), + (self.hist_chart, {'only_show_markers_on_hover': False}), + ]: + kwargs.update(line_kwargs) line = self.line_from_order( order=order, chart=chart, - **line_kwargs, + **kwargs, ) lines.append(line) @@ -350,7 +354,6 @@ class OrderMode: 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 @@ -457,6 +460,11 @@ class OrderMode: 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( @@ -561,7 +569,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( From 286f620f8e1737b4a58a51afd5a3745d7522961d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 13 Sep 2022 18:59:12 -0400 Subject: [PATCH 70/84] Use fqsn to key pnl tasks --- piker/ui/_position.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/piker/ui/_position.py b/piker/ui/_position.py index 9b9976ab..15dc2363 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -77,7 +77,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}') @@ -392,8 +392,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, From a61a11f86b7d9432d1d835f9107fa16b54f994a3 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 14 Sep 2022 10:11:43 -0400 Subject: [PATCH 71/84] Add draft but commented "scale-to-fast-chart" logic --- piker/ui/_display.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index c4891c7e..6ac8109f 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -910,7 +910,7 @@ async def display_symbol_data( pen=pg.mkPen(hcolor('gunmetal')), brush=pg.mkBrush(hcolor('default_darkest')), ) - region.setZValue(10) + 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 @@ -948,6 +948,7 @@ async def display_symbol_data( window, viewRange: tuple[tuple, tuple], is_manual: bool = True, + ) -> None: # set the region on the history chart # to the range currently viewed in the @@ -959,11 +960,29 @@ async def display_symbol_data( # 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(( - ds_mn + end_index, - ds_mx + end_index, + 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) From 6897aed6b6e91beedaff02213b4b5af5bb6995eb Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 14 Sep 2022 16:02:07 -0400 Subject: [PATCH 72/84] Don't call show on marker in `Nav.show()` --- piker/ui/_position.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/piker/ui/_position.py b/piker/ui/_position.py index 15dc2363..e678b5d3 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -626,7 +626,7 @@ class Nav(Struct): def show(self) -> None: ''' - Show all UI elements on all charts. + Show all UI elements on all managed charts. ''' for ( @@ -636,9 +636,6 @@ class Nav(Struct): level_marker, ) in self.iter_ui_elements(): - # labels - level_marker.show() - # 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 @@ -650,6 +647,8 @@ class Nav(Struct): # it's THIS that needs to be called **AFTER** the sidepane # has been added.. level_marker.position_in_view() + + # labels pp_label.show() size_label.show() @@ -796,13 +795,13 @@ class PositionTracker: x = chart.marker_right_points()[1] if chart._max_l1_line_len == 0: mkw = pp_label.txt.boundingRect().width() - x -= 1.5 * mkw + x -= 1.5 * mkw return x arrow.scene_x = offset_from_yaxis - arrow.hide() # never show on startup view.scene().addItem(arrow) + arrow.hide() # never show on startup nav.level_markers[key] = arrow pp_label.scene_anchor = partial( From ed868f6246d35749d20391493e2d67b44f5794fe Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 15 Sep 2022 13:46:36 -0400 Subject: [PATCH 73/84] Go back to origin slow chart split proportion --- piker/ui/_chart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 8f2718ca..20fa1e73 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -501,7 +501,7 @@ class LinkedSplits(QWidget): prop = 3/8 h = self.height() - histview_h = h * (6/16) * 0.666 + histview_h = h * (6/16) h = h - histview_h major = 1 - prop From cf11e8d7d8c7e1e2d111986c7f3272bf38e4b382 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 18 Sep 2022 12:33:54 -0400 Subject: [PATCH 74/84] Update navs on all slow and fast charts, only default the fast chart on switch --- piker/ui/_chart.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 20fa1e73..e0b92b56 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -276,11 +276,11 @@ class GodWidget(QWidget): 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() + # 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. @@ -293,9 +293,15 @@ class GodWidget(QWidget): # **AFTER** applying the search bar as a sidepane # to the newly switched to symbol. await trio.sleep(0) - pp_nav = self.rt_linked.mode.current_pp.nav - pp_nav.show() - pp_nav.hide_info() + + # 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() # set window titlebar info symbol = self.rt_linked.symbol From 5d65c86c848506b1eb99f29714d58b4c0efb3f81 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 19 Sep 2022 13:11:08 -0400 Subject: [PATCH 75/84] Don't delete pp lines or markers Bit of a face palm but obviously `LevelLine.delete()` also removes any `._marker` from the view which makes it disappear permanently when moving from non-zero to zero to non-zero positions.. We don't really need to delete the line since it can be re-used so just remove that code. Further this patch removes marker style setting logic from within the `pp_line()` factory and instead expects the caller to set the correct "direction" (for long / short) afterward. --- piker/ui/_position.py | 165 ++++++++++++++++++++---------------------- 1 file changed, 80 insertions(+), 85 deletions(-) diff --git a/piker/ui/_position.py b/piker/ui/_position.py index e678b5d3..2b914c6e 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -68,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. @@ -412,9 +413,9 @@ def pp_line( size: float, level: float, color: str, + marker: LevelMarker, orient_v: str = 'bottom', - marker: Optional[LevelMarker] = None, ) -> LevelLine: ''' @@ -445,31 +446,20 @@ def pp_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() - - line._marker = marker - line.track_marker_pos = True - - # 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 @@ -497,16 +487,9 @@ def mk_level_marker( # scale marker size with dpi-aware font size font_size = _font.font.pixelSize() arrow_size = floor(1.375 * font_size) - - if size > 0: - style = '|<' - - elif size < 0: - style = '>|' - arrow = LevelMarker( chart=chart, - style=style, + style='|<', # actual style is set by caller based on size get_level=level, size=arrow_size, on_paint=on_paint, @@ -562,10 +545,7 @@ class Nav(Struct): self.lines[key] = line # modify existing indicator line - else: - line.set_level(price) - level_marker.level = price - level_marker.update() + line.set_level(price) # update LHS sizing label line.update_labels({ @@ -579,16 +559,29 @@ class Nav(Struct): }) 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 - elif line: - line.delete() - self.lines[key] = None + 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 @@ -646,6 +639,7 @@ class Nav(Struct): # 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 @@ -686,6 +680,7 @@ class Nav(Struct): line, level_marker, ) in self.iter_ui_elements(): + pp_label.update() size_label.update() @@ -703,6 +698,7 @@ class Nav(Struct): line, level_marker, ) in self.iter_ui_elements(): + size_label.hide() if line: line.hide_labels() @@ -710,8 +706,8 @@ class Nav(Struct): 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 @@ -741,41 +737,6 @@ class PositionTracker: for key, chart in nav.charts.items(): view = chart.getViewBox() - # 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, - ) - # if nav._level_marker: - # nav._level_marker.delete() - - pp_label.render() - nav.pp_labels[key] = 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 - arrow = mk_level_marker( chart=chart, size=1, @@ -804,6 +765,38 @@ class PositionTracker: arrow.hide() # never show on startup nav.level_markers[key] = arrow + # 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 = 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, @@ -879,13 +872,15 @@ class PositionTracker: round(alloc.slots_used(pp), ndigits=1), # slots used ) - if pp.size == 0: + if self.live_pp.size: + # print("SHOWING NAV") + self.nav.show() + + # if pp.size == 0: + else: + # print("HIDING NAV") self.nav.hide() - else: - if self.live_pp.size: - self.nav.show() - - # don't show side and status widgets unless - # order mode is "engaged" (which done via input controls) - self.nav.hide_info() + # don't show side and status widgets unless + # order mode is "engaged" (which done via input controls) + self.nav.hide_info() From fd8c05e024ba2e2f3cca3c6e74f5ab0db15ad538 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 19 Sep 2022 14:04:54 -0400 Subject: [PATCH 76/84] A lines entry should always exist or it's a bug --- piker/ui/_editors.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index e250eee3..b065fef4 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -179,9 +179,9 @@ class LineEditor(Struct): graphic in view. ''' - lines = self._order_lines.get(uuid) + lines = self._order_lines[uuid] if lines: - for line in self._order_lines.get(uuid, []): + for line in lines: line.show_labels() log.debug(f'Level active for level: {line.value()}') # TODO: other flashy things to indicate the order is active @@ -189,9 +189,10 @@ class LineEditor(Struct): 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.godw.get_cursor()._hovered From 7c6d12d9826c549261b56e1edfae9b3c4be76312 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 19 Sep 2022 14:05:58 -0400 Subject: [PATCH 77/84] Always set marker y-pos even if we're tracking its x-pos --- piker/ui/_lines.py | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/piker/ui/_lines.py b/piker/ui/_lines.py index 7756f1a7..5ff48211 100644 --- a/piker/ui/_lines.py +++ b/piker/ui/_lines.py @@ -350,7 +350,7 @@ class LevelLine(pg.InfiniteLine): from pg.. ''' - # p.setRenderHint(p.Antialiasing) + p.setRenderHint(p.Antialiasing) # these are in viewbox coords vb_left, vb_right = self._endPoints @@ -378,16 +378,16 @@ class LevelLine(pg.InfiniteLine): elif self._marker: if self.track_marker_pos: # make the line end at the marker's x pos - line_end = self._marker.pos().x() + line_end = marker_right = self._marker.pos().x() - else: - # 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() + # 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() elif not self.use_marker_margin: # basically means **don't** shorten the line with normally @@ -407,16 +407,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( @@ -461,8 +459,10 @@ 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 @@ -765,7 +765,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, @@ -799,9 +800,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 From c0d490ed63743a7f8bed8f6807aafaf7b414d860 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 19 Sep 2022 16:05:18 -0400 Subject: [PATCH 78/84] Only show pos nav on non-zero size --- piker/ui/_editors.py | 2 +- piker/ui/_interaction.py | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index b065fef4..87e6b36d 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -141,7 +141,7 @@ class LineEditor(Struct): except KeyError: # when the current cursor doesn't have said line # registered (probably means that user held order mode - # key while panning to another vieww) then we just + # key while panning to another view) then we just # ignore the remove error. pass line.delete() diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 9b7eba6d..b9ac32ea 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -217,8 +217,17 @@ async def handle_viewmode_kb_inputs( if order_keys_pressed: - # show the pp size label - order_mode.current_pp.nav.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() @@ -259,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, From 90754f979b4c8d43fd2e3484a931bac875218ca9 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 19 Sep 2022 17:39:26 -0400 Subject: [PATCH 79/84] Tick the slow chart task on a 1sec index event --- piker/ui/_display.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/piker/ui/_display.py b/piker/ui/_display.py index 6ac8109f..4d24f5ca 100644 --- a/piker/ui/_display.py +++ b/piker/ui/_display.py @@ -206,6 +206,7 @@ class DisplayState(Struct): state['i_last_append'] = i_step do_rt_update = uppx < update_uppx + _, _, _, r = chart.bars_range() liv = r >= i_step @@ -346,11 +347,20 @@ async def graphics_update_loop( _, hist_step_size_s, _ = feed.get_ds_info() async with feed.index_stream( - int(hist_step_size_s) + # 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 a resize + # check if slow chart needs an x-domain shift and/or + # y-range resize. ( uppx, liv, @@ -364,6 +374,12 @@ async def graphics_update_loop( 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 @@ -395,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 From 25ac6e6665d7026c4ecaab0ccfbfa7d8b33c3593 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 21 Sep 2022 14:33:17 -0400 Subject: [PATCH 80/84] Soft pop lines, handle error-cancel races --- piker/ui/_editors.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index 87e6b36d..cc89fb78 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -201,7 +201,6 @@ class LineEditor(Struct): for lines in list(self._order_lines.values()): all_lines.extend(lines) - # return tuple(self._order_lines.values()) return all_lines def remove_line( @@ -217,7 +216,7 @@ class LineEditor(Struct): ''' # try to look up line from our registry - lines = self._order_lines.pop(uuid) + lines = self._order_lines.pop(uuid, None) if lines: cursor = self.godw.get_cursor() if cursor: From 2cf174299936468b7f13ba2f13032cd6de0d182c Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 21 Sep 2022 15:42:29 -0400 Subject: [PATCH 81/84] Always apply at least the pos size as the limit --- piker/clearing/_allocate.py | 3 +++ piker/ui/_position.py | 23 ++++++++++++++++------- piker/ui/order_mode.py | 6 +++--- 3 files changed, 22 insertions(+), 10 deletions(-) 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/ui/_position.py b/piker/ui/_position.py index 2b914c6e..c986022a 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -184,7 +184,7 @@ class SettingsPane: ''' 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, @@ -262,6 +262,8 @@ class SettingsPane: log.error( f'limit must > then current pp: {dsize}' ) + # reset position size value + alloc.currency_limit = dsize return False alloc.currency_limit = value @@ -299,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 @@ -331,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 @@ -343,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, diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 73c54ba3..72571d90 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -491,7 +491,8 @@ class OrderMode: ''' dialog = self.dialogs[uuid] lines = dialog.lines - assert len(lines) == 2 + # 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 [ @@ -843,8 +844,7 @@ 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? From 4818af14459f9d80dcdd00b5d02b2a2045f3a682 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 21 Sep 2022 15:43:05 -0400 Subject: [PATCH 82/84] Add better doc string on marker factory --- piker/ui/_annotate.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/piker/ui/_annotate.py b/piker/ui/_annotate.py index 35f6d548..8c555f64 100644 --- a/piker/ui/_annotate.py +++ b/piker/ui/_annotate.py @@ -32,7 +32,7 @@ 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). @@ -41,9 +41,13 @@ def mk_marker_path( 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': From cc67d23eeed1a60f17a2d1fcfdd58d73552d59ce Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 23 Sep 2022 17:12:57 -0400 Subject: [PATCH 83/84] Drop old marker drawing code from `LevelLine.paint()` We haven't been using it for a while and the supposed (remembered) latency issue on interaction doesn't seem existing after applying the cache mode. This allows dropping some internal state-logic and generally simplifying the show-on-hover checks. Further add `.show_markers()` and `.hide_markers()` as explicit methods that can be called externally by UI business logic. --- piker/ui/_lines.py | 50 +++++++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/piker/ui/_lines.py b/piker/ui/_lines.py index 5ff48211..461544e7 100644 --- a/piker/ui/_lines.py +++ b/piker/ui/_lines.py @@ -92,7 +92,6 @@ 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" @@ -358,24 +357,12 @@ 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() @@ -468,10 +455,7 @@ class LevelLine(pg.InfiniteLine): 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: @@ -514,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()) @@ -530,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( @@ -735,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, ) @@ -744,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 From 41b0c11aaa5d5ffd976bb151d45f1ca0e91e2b7f Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 23 Sep 2022 17:16:48 -0400 Subject: [PATCH 84/84] Hide existing level line markers on startup --- piker/ui/_annotate.py | 1 - piker/ui/_editors.py | 1 + piker/ui/order_mode.py | 5 ++++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/piker/ui/_annotate.py b/piker/ui/_annotate.py index 8c555f64..32a67980 100644 --- a/piker/ui/_annotate.py +++ b/piker/ui/_annotate.py @@ -167,7 +167,6 @@ class LevelMarker(QGraphicsPathItem): ''' level = self.get_level() - view = self.chart.getViewBox() vr = view.state['viewRange'] ymn, ymx = vr[1] diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index cc89fb78..2633cf40 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -183,6 +183,7 @@ class LineEditor(Struct): 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 diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 72571d90..d2196b69 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -237,7 +237,7 @@ class OrderMode: lines: list[LevelLine] = [] for chart, kwargs in [ (self.chart, {}), - (self.hist_chart, {'only_show_markers_on_hover': False}), + (self.hist_chart, {'only_show_markers_on_hover': True}), ]: kwargs.update(line_kwargs) line = self.line_from_order( @@ -304,13 +304,16 @@ class OrderMode: 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, )