First (untested) draft remote annotation ctl API

Since we can and want to eventually allow remote control of pretty much
all UIs, this drafts out a new `.ui._remote_ctl` module with a new
`@tractor.context` called `remote_annotate()` which simply starts a msg
loop which allows for (eventual) initial control of a `SelectRect`
through IPC msgs.

Remote controller impl deats:
- make `._display.graphics_update_loop()` set a `._remote_ctl._dss:
  dict` for all chart actor-global `DisplayState` instances which can
  then be controlled from the `remote_annotate()` handler task.
- also stash any remote client controller `tractor.Context` handles in
  a module var for broadband IPC cancellation on any display loop
  shutdown.
- draft a further global map to track graphics object instances since
  likely we'll want to support remote mutation where the client can use
  the `id(obj): int` key as an IPC handle/uuid.
- just draft out a client-side `@acm` for now: `open_annots_client()` to
  be filled out in up coming commits.

UI component tweaks in support of the above:
- change/add `SelectRect.set_view_pos()` and `.set_scene_pos()` to allow
  specifying the rect coords in either of the scene or viewbox domains.
  - use these new apis in the interaction loop.
- add a `SelectRect.add_to_view()` to avoid having annotation client
  code knowing "how" a graphics obj needs to be added and can instead
  just pass only the target `ChartView` during init.
- drop all the status label updates from the display loop since they
  don't really work all the time, and probably it's not a feature we
  want to keep in the longer term (over just console output and/or using
  the status bar for simpler "current state / mkt" infos).
  - allows a bit of simplification of `.ui._fsp` method APIs to not pass
    around status (bar) callbacks as well!
distribute_dis
Tyler Goodlet 2023-12-19 15:36:54 -05:00
parent ab84303da7
commit 8d324acf91
7 changed files with 262 additions and 85 deletions

View File

@ -56,8 +56,8 @@ _line_styles: dict[str, int] = {
class FlowGraphic(pg.GraphicsObject): class FlowGraphic(pg.GraphicsObject):
''' '''
Base class with minimal interface for `QPainterPath` implemented, Base class with minimal interface for `QPainterPath`
real-time updated "data flow" graphics. implemented, real-time updated "data flow" graphics.
See subtypes below. See subtypes below.
@ -167,11 +167,12 @@ class FlowGraphic(pg.GraphicsObject):
return None return None
# XXX: due to a variety of weird jitter bugs and "smearing" # XXX: due to a variety of weird jitter bugs and "smearing"
# artifacts when click-drag panning and viewing history time series, # artifacts when click-drag panning and viewing history time
# we offer this ctx-mngr interface to allow temporarily disabling # series, we offer this ctx-mngr interface to allow temporarily
# Qt's graphics caching mode; this is now currently used from # disabling Qt's graphics caching mode; this is now currently
# ``ChartView.start/signal_ic()`` methods which also disable the # used from ``ChartView.start/signal_ic()`` methods which also
# rt-display loop when the user is moving around a view. # disable the rt-display loop when the user is moving around
# a view.
@cm @cm
def reset_cache(self) -> None: def reset_cache(self) -> None:
try: try:

View File

@ -563,7 +563,8 @@ class Viz(Struct):
def view_range(self) -> tuple[int, int]: def view_range(self) -> tuple[int, int]:
''' '''
Return the start and stop x-indexes for the managed ``ViewBox``. Return the start and stop x-indexes for the managed
``ViewBox``.
''' '''
vr = self.plot.viewRect() vr = self.plot.viewRect()

View File

@ -470,54 +470,64 @@ async def graphics_update_loop(
if ds.hist_vars['i_last'] < ds.hist_vars['i_last_append']: if ds.hist_vars['i_last'] < ds.hist_vars['i_last_append']:
await tractor.pause() await tractor.pause()
# main real-time quotes update loop try:
stream: tractor.MsgStream from . import _remote_ctl
async with feed.open_multi_stream() as stream: _remote_ctl._dss = dss
assert stream
async for quotes in stream:
quote_period = time.time() - last_quote_s
quote_rate = round(
1/quote_period, 1) if quote_period > 0 else float('inf')
if (
quote_period <= 1/_quote_throttle_rate
# in the absolute worst case we shouldn't see more then # main real-time quotes update loop
# twice the expected throttle rate right!? stream: tractor.MsgStream
# and quote_rate >= _quote_throttle_rate * 2 async with feed.open_multi_stream() as stream:
and quote_rate >= display_rate assert stream
): async for quotes in stream:
pass quote_period = time.time() - last_quote_s
# log.warning(f'High quote rate {mkt.fqme}: {quote_rate}') quote_rate = round(
1/quote_period, 1) if quote_period > 0 else float('inf')
last_quote_s = time.time()
for fqme, quote in quotes.items():
ds = dss[fqme]
ds.quotes = quote
rt_pi, hist_pi = pis[fqme]
# chart isn't active/shown so skip render cycle and
# pause feed(s)
if ( if (
fast_chart.linked.isHidden() quote_period <= 1/_quote_throttle_rate
or not rt_pi.isVisible()
# in the absolute worst case we shouldn't see more then
# twice the expected throttle rate right!?
# and quote_rate >= _quote_throttle_rate * 2
and quote_rate >= display_rate
): ):
print(f'{fqme} skipping update for HIDDEN CHART') pass
fast_chart.pause_all_feeds() # log.warning(f'High quote rate {mkt.fqme}: {quote_rate}')
continue
ic = fast_chart.view._in_interact last_quote_s = time.time()
if ic:
fast_chart.pause_all_feeds()
print(f'{fqme} PAUSING DURING INTERACTION')
await ic.wait()
fast_chart.resume_all_feeds()
# sync call to update all graphics/UX components. for fqme, quote in quotes.items():
graphics_update_cycle( ds = dss[fqme]
ds, ds.quotes = quote
quote, rt_pi, hist_pi = pis[fqme]
)
# chart isn't active/shown so skip render cycle and
# pause feed(s)
if (
fast_chart.linked.isHidden()
or not rt_pi.isVisible()
):
print(f'{fqme} skipping update for HIDDEN CHART')
fast_chart.pause_all_feeds()
continue
ic = fast_chart.view._in_interact
if ic:
fast_chart.pause_all_feeds()
print(f'{fqme} PAUSING DURING INTERACTION')
await ic.wait()
fast_chart.resume_all_feeds()
# sync call to update all graphics/UX components.
graphics_update_cycle(
ds,
quote,
)
finally:
# XXX: cancel any remote annotation control ctxs
_remote_ctl._dss = None
for ctx in _remote_ctl._ctxs:
await ctx.cancel()
def graphics_update_cycle( def graphics_update_cycle(
@ -1235,7 +1245,7 @@ async def display_symbol_data(
fast from a cached watch-list. fast from a cached watch-list.
''' '''
sbar = godwidget.window.status_bar # sbar = godwidget.window.status_bar
# historical data fetch # historical data fetch
# brokermod = brokers.get_brokermod(provider) # brokermod = brokers.get_brokermod(provider)
@ -1245,11 +1255,11 @@ async def display_symbol_data(
# group_key=loading_sym_key, # group_key=loading_sym_key,
# ) # )
for fqme in fqmes: # for fqme in fqmes:
loading_sym_key = sbar.open_status( # loading_sym_key = sbar.open_status(
f'loading {fqme} ->', # f'loading {fqme} ->',
group_key=True # group_key=True
) # )
# (TODO: make this not so shit XD) # (TODO: make this not so shit XD)
# close group status once a symbol feed fully loads to view. # close group status once a symbol feed fully loads to view.
@ -1422,7 +1432,7 @@ async def display_symbol_data(
start_fsp_displays, start_fsp_displays,
rt_linked, rt_linked,
flume, flume,
loading_sym_key, # loading_sym_key,
loglevel, loglevel,
) )

View File

@ -49,6 +49,7 @@ from ..log import get_logger
if TYPE_CHECKING: if TYPE_CHECKING:
from ._chart import GodWidget from ._chart import GodWidget
from ._interaction import ChartView
log = get_logger(__name__) log = get_logger(__name__)
@ -261,7 +262,7 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
super().__init__(0, 0, 1, 1) super().__init__(0, 0, 1, 1)
# self.rbScaleBox = QGraphicsRectItem(0, 0, 1, 1) # self.rbScaleBox = QGraphicsRectItem(0, 0, 1, 1)
self.vb = viewbox self.vb: ViewBox = viewbox
self._chart: 'ChartPlotWidget' = None # noqa self._chart: 'ChartPlotWidget' = None # noqa
# override selection box color # override selection box color
@ -297,6 +298,19 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
'sigma: {std:.2f}', 'sigma: {std:.2f}',
] ]
def add_to_view(
self,
view: ChartView,
) -> None:
'''
Self-defined view hookup impl.
'''
view.addItem(
self,
ignoreBounds=True,
)
@property @property
def chart(self) -> 'ChartPlotWidget': # noqa def chart(self) -> 'ChartPlotWidget': # noqa
return self._chart return self._chart
@ -324,21 +338,40 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
self.vb.mapFromView(self._abs_top_right) self.vb.mapFromView(self._abs_top_right)
) )
def set_pos( def set_scen_pos(
self, self,
p1: QPointF, p1: QPointF,
p2: QPointF p2: QPointF
) -> None: ) -> None:
"""Set position of selection rect and accompanying label, move '''
label to match. Set position from scene coords of selection rect (normally
from mouse position) and accompanying label, move label to
match.
""" '''
# map to view coords
self.set_view_pos(
self.vb.mapToView(p1),
self.vb.mapToView(p2),
)
def set_view_pos(
self,
start_pos: QPointF,
end_pos: QPointF,
) -> None:
'''
Set position from `ViewBox` coords (i.e. from the actual
data domain) of rect (and any accompanying label which is
moved to match).
'''
# https://doc.qt.io/qt-5/qgraphicsproxywidget.html
if self._label_proxy is None: if self._label_proxy is None:
# https://doc.qt.io/qt-5/qgraphicsproxywidget.html self._label_proxy = self.vb.scene(
self._label_proxy = self.vb.scene().addWidget(self._label) ).addWidget(self._label)
start_pos = self.vb.mapToView(p1)
end_pos = self.vb.mapToView(p2)
# map to view coords and update area # map to view coords and update area
r = QtCore.QRectF(start_pos, end_pos) r = QtCore.QRectF(start_pos, end_pos)
@ -398,8 +431,9 @@ class SelectRect(QtWidgets.QGraphicsRectItem):
# self._label.show() # self._label.show()
def clear(self): def clear(self):
"""Clear the selection box from view. '''
Clear the selection box from view.
""" '''
self._label.hide() self._label.hide()
self.hide() self.hide()

View File

@ -560,7 +560,7 @@ class FspAdmin:
conf: dict, # yeah probably dumb.. conf: dict, # yeah probably dumb..
loglevel: str = 'error', loglevel: str = 'error',
) -> (trio.Event, ChartPlotWidget): ) -> trio.Event:
flume, started = await self.start_engine_task( flume, started = await self.start_engine_task(
target, target,
@ -927,7 +927,7 @@ async def start_fsp_displays(
linked: LinkedSplits, linked: LinkedSplits,
flume: Flume, flume: Flume,
group_status_key: str, # group_status_key: str,
loglevel: str, loglevel: str,
) -> None: ) -> None:
@ -974,21 +974,23 @@ async def start_fsp_displays(
flume, flume,
) as admin, ) as admin,
): ):
statuses = [] statuses: list[trio.Event] = []
for target, conf in fsp_conf.items(): for target, conf in fsp_conf.items():
started = await admin.open_fsp_chart( started: trio.Event = await admin.open_fsp_chart(
target, target,
conf, conf,
) )
done = linked.window().status_bar.open_status( # done = linked.window().status_bar.open_status(
f'loading fsp, {target}..', # f'loading fsp, {target}..',
group_key=group_status_key, # group_key=group_status_key,
) # )
statuses.append((started, done)) # statuses.append((started, done))
statuses.append(started)
for fsp_loaded, status_cb in statuses: # for fsp_loaded, status_cb in statuses:
for fsp_loaded in statuses:
await fsp_loaded.wait() await fsp_loaded.wait()
profiler(f'attached to fsp portal: {target}') profiler(f'attached to fsp portal: {target}')
status_cb() # status_cb()
# blocks on nursery until all fsp actors complete # blocks on nursery until all fsp actors complete

View File

@ -500,7 +500,11 @@ class ChartView(ViewBox):
# add our selection box annotator # add our selection box annotator
self.select_box = SelectRect(self) self.select_box = SelectRect(self)
self.addItem(self.select_box, ignoreBounds=True) self.select_box.add_to_view(self)
# self.addItem(
# self.select_box,
# ignoreBounds=True,
# )
self.mode = None self.mode = None
self.order_mode: bool = False self.order_mode: bool = False
@ -761,14 +765,17 @@ class ChartView(ViewBox):
# This is the final position in the drag # This is the final position in the drag
if ev.isFinish(): if ev.isFinish():
self.select_box.mouse_drag_released(down_pos, pos) self.select_box.mouse_drag_released(
down_pos,
pos,
)
# NOTE: think of this as a `.mouse_drag_release()` # NOTE: think of this as a `.mouse_drag_release()`
# (bc HINT that's what i called the shit ass # (bc HINT that's what i called the shit ass
# method that wrapped this call [yes, as a single # method that wrapped this call [yes, as a single
# fucking call] originally.. you bish, guille) # fucking call] originally.. you bish, guille)
# Bo.. oraleeee # Bo.. oraleeee
self.select_box.set_pos( self.select_box.set_scen_pos(
down_pos, down_pos,
pos, pos,
) )
@ -786,7 +793,10 @@ class ChartView(ViewBox):
else: else:
print('drag finish?') print('drag finish?')
self.select_box.set_pos(down_pos, pos) self.select_box.set_scen_pos(
down_pos,
pos,
)
# update shape of scale box # update shape of scale box
# self.updateScaleBox(ev.buttonDownPos(), ev.pos()) # self.updateScaleBox(ev.buttonDownPos(), ev.pos())

View File

@ -0,0 +1,119 @@
# piker: trading gear for hackers
# Copyright (C) Tyler Goodlet (in stewardship for pikers)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
'''
Remote control tasks for sending annotations (and maybe more cmds)
to a chart from some other actor.
'''
from __future__ import annotations
from contextlib import asynccontextmanager as acm
import tractor
# import trio
from PyQt5.QtWidgets import (
QGraphicsItem,
)
from piker.log import get_logger
from ._display import DisplayState
from ._editors import SelectRect
from ._chart import ChartPlotWidget
log = get_logger(__name__)
# NOTE: this is set by the `._display.graphics_update_loop()` once
# all chart widgets / Viz per flume have been initialized allowing
# for remote annotation (control) of any chart-actor's mkt feed by
# fqme lookup Bo
_dss: dict[str, DisplayState] | None = None
# stash each and every client connection so that they can all
# be cancelled on shutdown/error.
_ctxs: set[tractor.Context] = set()
# global map of all uniquely created annotation-graphics
# so that they can be mutated (eventually) by a client.
_annots: dict[int, QGraphicsItem] = {}
@tractor.context
async def remote_annotate(
ctx: tractor.Context,
) -> None:
global _dss, _ctxs
assert _dss
_ctxs.add(ctx)
# send back full fqme symbology to caller
await ctx.started(list(_dss))
async with ctx.open_stream() as annot_req_stream:
async for msg in annot_req_stream:
match msg:
case {
'fqme': fqme,
'cmd': 'SelectRect',
'color': color,
'timeframe': timeframe,
# 'meth': str(meth),
'meth': 'set_view_pos',
'kwargs': {
'start_pos': tuple(start_pos),
'end_pos': tuple(end_pos),
},
}:
ds: DisplayState = _dss[fqme]
chart: ChartPlotWidget = {
60: ds.hist_chart,
1: ds.chart,
}[timeframe]
# annot type lookup from cmd
rect = SelectRect(
chart.cv,
# TODO: pull from conf.toml?
color=color or 'dad_blue',
)
rect.set_view_pos(
start_pos=start_pos,
end_pos=end_pos,
)
await annot_req_stream.send(id(rect))
case _:
log.error(
'Unknown remote annotation cmd:\n'
f'{msg}'
)
@acm
async def open_annots_client(
uid: tuple[str, str],
) -> 'AnnotClient':
# TODO: load connetion to a specific chart actor
# -[ ] pull from either service scan or config
# -[ ] return some kinda client/proxy thinger?
# -[ ] maybe we should finally just provide this as
# a `tractor.hilevel.CallableProxy` or wtv?
# -[ ] use this from the storage.cli stuff to mark up gaps!
...