From f16591612eb913393f7a6f9703ccc9c418282173 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 10 Sep 2021 11:50:24 -0400 Subject: [PATCH] Support real-time account switch and status update Make a pp tracker per account and load on order mode boot. Only show details on the pp tracker for the selected account. Make the settings pane assign a `.current_pp` state on the order mode instance (for the charted symbol) on account selection switches and no longer keep a ref to a single pp tracker and allocator in the pane. `SettingsPane.update_status_ui()` now expects an explicit tracker reference as input. Still need to figure out the pnl update task logic despite the intermittent account changes. --- piker/ui/_interaction.py | 13 +-- piker/ui/_position.py | 77 ++++++++++------- piker/ui/order_mode.py | 174 +++++++++++++++++++++++---------------- 3 files changed, 159 insertions(+), 105 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 022566d4..d33d553e 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -198,7 +198,7 @@ async def handle_viewmode_kb_inputs( Qt.Key_P, } ): - pp_pane = order_mode.pp.pane + pp_pane = order_mode.current_pp.pane if pp_pane.isHidden(): pp_pane.show() else: @@ -213,7 +213,7 @@ async def handle_viewmode_kb_inputs( if order_keys_pressed: # show the pp size label - order_mode.pp.show() + order_mode.current_pp.show() # TODO: show pp config mini-params in status bar widget # mode.pp_config.show() @@ -259,20 +259,23 @@ async def handle_viewmode_kb_inputs( ) and key in NUMBER_LINE ): - # hot key to set order slots size + # hot key to set order slots size. + # change edit field to current number line value, + # update the pp allocator bar, unhighlight the + # field when ctrl is released. num = int(text) pp_pane = order_mode.pane pp_pane.on_ui_settings_change('slots', num) edit = pp_pane.form.fields['slots'] edit.selectAll() + # un-highlight on ctrl release on_next_release = edit.deselect - pp_pane.update_status_ui() else: # none active # hide pp label - order_mode.pp.hide_info() + order_mode.current_pp.hide_info() # if none are pressed, remove "staged" level # line under cursor position diff --git a/piker/ui/_position.py b/piker/ui/_position.py index 29fc34b3..c3161751 100644 --- a/piker/ui/_position.py +++ b/piker/ui/_position.py @@ -50,10 +50,6 @@ class SettingsPane: order entry sizes and position limits per tradable instrument. ''' - # config for and underlying validation model - tracker: PositionTracker - alloc: Allocator - # input fields form: FieldsForm @@ -64,9 +60,8 @@ class SettingsPane: pnl_label: QLabel limit_label: QLabel - def transform_to(self, size_unit: str) -> None: - if self.alloc.size_unit == size_unit: - return + # encompasing high level namespace + order_mode: Optional['OrderMode'] = None # typing: ignore # noqa def on_selection_change( self, @@ -79,7 +74,6 @@ class SettingsPane: ''' log.info(f'selection input: {text}') - setattr(self.alloc, key, text) self.on_ui_settings_change(key, text) def on_ui_settings_change( @@ -92,10 +86,34 @@ class SettingsPane: '''Called on any order pane edit field value change. ''' - alloc = self.alloc + mode = self.order_mode + + if key == 'account': + # an account switch request + + # hide detail on the old pp + old_tracker = mode.current_pp + old_tracker.hide_info() + + # re-assign the order mode tracker + account_name = value + tracker = mode.trackers[account_name] + self.order_mode.current_pp = tracker + assert tracker.alloc.account_name() == account_name + self.form.fields['account'].setCurrentText(account_name) + tracker.show() + tracker.hide_info() + + # load the new account's allocator + alloc = tracker.alloc + + else: + tracker = mode.current_pp + alloc = tracker.alloc + size_unit = alloc.size_unit - # write any passed settings to allocator + # WRITE any settings to current pp's allocator if key == 'limit': if size_unit == 'currency': alloc.currency_limit = float(value) @@ -110,14 +128,10 @@ class SettingsPane: # the current settings in the new units pass - elif key == 'account': - account_name = value or 'paper' - alloc.account = account_name - - else: + elif key != 'account': raise ValueError(f'Unknown setting {key}') - # read out settings and update UI + # READ out settings and update UI log.info(f'settings change: {key}: {value}') suffix = {'currency': ' $', 'units': ' u'}[size_unit] @@ -125,7 +139,7 @@ class SettingsPane: # TODO: a reverse look up from the position to the equivalent # account(s), if none then look to user config for default? - self.update_status_ui() + self.update_status_ui(pp=tracker) step_size, currency_per_slot = alloc.step_sizes() @@ -143,7 +157,6 @@ class SettingsPane: self.form.fields['size_unit'].setCurrentText( alloc._size_units[alloc.size_unit] ) - self.form.fields['account'].setCurrentText(alloc.account_name()) self.form.fields['slots'].setText(str(alloc.slots)) self.form.fields['limit'].setText(str(limit)) @@ -154,13 +167,14 @@ class SettingsPane: def update_status_ui( self, - size: float = None, + + pp: PositionTracker, ) -> None: - alloc = self.alloc + alloc = pp.alloc slots = alloc.slots - used = alloc.slots_used(self.tracker.live_pp) + used = alloc.slots_used(pp.live_pp) # calculate proportion of position size limit # that exists and display in fill bar @@ -173,12 +187,17 @@ class SettingsPane: min(used, slots) ) + # TODO: move to order mode module! doesn't need to be a method since + # we partial in all the state def on_level_change_update_next_order_info( self, level: float, + + # these are all ``partial``-ed in at callback assignment time. line: LevelLine, order: Order, + tracker: PositionTracker, ) -> None: '''A callback applied for each level change to the line @@ -187,9 +206,9 @@ class SettingsPane: ``OrderMode.line_from_order()`` ''' - order_info = self.alloc.next_order_info( - startup_pp=self.tracker.startup_pp, - live_pp=self.tracker.live_pp, + order_info = tracker.alloc.next_order_info( + startup_pp=tracker.startup_pp, + live_pp=tracker.live_pp, price=level, action=order.action, ) @@ -267,8 +286,8 @@ def position_line( class PositionTracker: - '''Track and display a real-time position for a single symbol - on a chart. + '''Track and display real-time positions for a single symbol + over multiple accounts 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 @@ -277,11 +296,12 @@ class PositionTracker: ''' # inputs chart: 'ChartPlotWidget' # noqa + alloc: Allocator startup_pp: Position + live_pp: Position # allocated - live_pp: Position pp_label: Label size_label: Label line: Optional[LevelLine] = None @@ -297,6 +317,7 @@ class PositionTracker: ) -> None: self.chart = chart + self.alloc = alloc self.startup_pp = startup_pp self.live_pp = startup_pp.copy() @@ -375,7 +396,6 @@ class PositionTracker: ''' # live pp updates pp = position or self.live_pp - # pp.update_from_msg(msg) self.update_line( pp.avg_price, @@ -413,7 +433,6 @@ class PositionTracker: def show(self) -> None: if self.live_pp.size: - self.line.show() self.line.show_labels() diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 49a6cb99..f61fa179 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -27,7 +27,6 @@ import time from typing import Optional, Dict, Callable, Any import uuid -from bidict import bidict from pydantic import BaseModel import tractor import trio @@ -36,7 +35,6 @@ from .. import config from ..calc import pnl from ..clearing._client import open_ems, OrderBook from ..clearing._allocate import ( - Allocator, mk_allocator, Position, ) @@ -102,12 +100,12 @@ class OrderMode: lines: LineEditor arrows: ArrowEditor multistatus: MultiStatus - pp: PositionTracker - alloc: 'Allocator' # noqa pane: SettingsPane + trackers: dict[str, PositionTracker] + # switched state, the current position + current_pp: Optional[PositionTracker] = None active: bool = False - name: str = 'order' dialogs: dict[str, OrderDialog] = field(default_factory=dict) @@ -155,6 +153,7 @@ class OrderMode: self.pane.on_level_change_update_next_order_info, line=line, order=order, + tracker=self.current_pp, ) else: @@ -193,7 +192,7 @@ class OrderMode: order = self._staged_order = Order( action=action, price=price, - account=self.alloc.account_name(), + account=self.current_pp.alloc.account_name(), size=0, symbol=symbol, brokers=symbol.brokers, @@ -499,7 +498,7 @@ async def open_order_mode( book: OrderBook trades_stream: tractor.MsgStream - positions: dict + position_msgs: dict # spawn EMS actor-service async with ( @@ -507,7 +506,7 @@ async def open_order_mode( open_ems(brokername, symbol) as ( book, trades_stream, - positions + position_msgs ), trio.open_nursery() as tn, @@ -520,64 +519,91 @@ async def open_order_mode( lines = LineEditor(chart=chart) arrows = ArrowEditor(chart, {}) + # allocation and account settings side pane form = chart.sidepane - # update any from exising positions received from ``brokerd`` + # symbol id symbol = chart.linked.symbol - symkey = chart.linked._symbol.key + symkey = symbol.key - # NOTE: requires that the backend exactly specifies - # the expected symbol key in it's positions msg. - pp_msg = positions.get(symkey) - - # net-zero pp - startup_pp = Position( - symbol=symbol, - size=0, - avg_price=0, - ) + # map of per-provider account keys to position tracker instances + trackers: dict[str, PositionTracker] = {} # load account names from ``brokers.toml`` - accounts = bidict(config.load_accounts()) - # process pps back from broker, only present - # account names reported back from ``brokerd``. - pp_account = None + accounts = config.load_accounts(providers=symbol.brokers) + if accounts: + # first account listed is the one we select at startup + # (aka order based selection). + pp_account = next(iter(accounts.keys())) + else: + pp_account = 'paper' - if pp_msg: - log.info(f'Loading pp for {symkey}:\n{pformat(pp_msg)}') - startup_pp.update_from_msg(pp_msg) - pp_account = accounts.inverse.get(pp_msg.get('account')) + # for each account with this broker, allocate pp tracker components + for config_name, account in accounts.items(): + # net-zero pp + startup_pp = Position( + symbol=symbol, + size=0, + avg_price=0, + ) - # lookup account for this pp or load the user default - # for this backend - - # allocator - alloc = mk_allocator( - symbol=symbol, - accounts=accounts, - account=pp_account, - startup_pp=startup_pp, - ) - form.model = alloc - - pp_tracker = PositionTracker( - chart, - alloc, - startup_pp - ) - pp_tracker.update_from_pp(startup_pp) - - if startup_pp.size == 0: - # if no position, don't show pp tracking graphics + # allocator + alloc = mk_allocator( + symbol=symbol, + accounts=accounts, + account=config_name, + startup_pp=startup_pp, + ) + pp_tracker = PositionTracker( + chart, + alloc, + startup_pp + ) pp_tracker.hide() + trackers[config_name] = pp_tracker + + # TODO: preparse and account-map these msgs and do it all in ONE LOOP! + + # NOTE: requires the backend exactly specifies + # the expected symbol key in its positions msg. + pp_msgs = position_msgs.get(symkey, ()) + + # update all pp trackers with existing data relayed + # from ``brokerd``. + for msg in pp_msgs: + log.info(f'Loading pp for {symkey}:\n{pformat(msg)}') + account_name = accounts.inverse.get(msg.get('account')) + tracker = trackers[account_name] + + # TODO: do we even really need the "startup pp" or can we + # just take the max and pass that into the some state / the + # alloc? + tracker.startup_pp.update_from_msg(msg) + tracker.live_pp.update_from_msg(msg) + + # TODO: + # if this startup size is greater the allocator limit, + # increase the limit to the current pp size which is done + # in this alloc factory.. + # tracker.alloc = mk_allocator( + # symbol=symbol, + # accounts=accounts, + # account=account_name, + # startup_pp=tracker.live_pp, + # ) + + # tracker.update_from_pp(tracker.startup_pp) + tracker.update_from_pp(tracker.live_pp) + + if tracker.startup_pp.size != 0: + # if no position, don't show pp tracking graphics + tracker.show() + + tracker.hide_info() # order pane widgets and allocation model order_pane = SettingsPane( - - tracker=pp_tracker, form=form, - alloc=alloc, - # XXX: ugh, so hideous... fill_bar=form.fill_bar, pnl_label=form.left_label, @@ -585,13 +611,6 @@ async def open_order_mode( limit_label=form.top_label, ) - # set startup limit value read during alloc init - order_pane.on_ui_settings_change('limit', alloc.limit()) - order_pane.on_ui_settings_change('account', pp_account) - - # make fill bar and positioning snapshot - order_pane.update_status_ui(size=startup_pp.size) - # top level abstraction which wraps all this crazyness into # a namespace.. mode = OrderMode( @@ -600,10 +619,18 @@ async def open_order_mode( lines, arrows, multistatus, - pp_tracker, - alloc=alloc, pane=order_pane, + trackers=trackers, + ) + # XXX: MUST be set + order_pane.order_mode = mode + + # select a pp to track + tracker = trackers[pp_account] + mode.current_pp = tracker + tracker.show() + tracker.hide_info() # XXX: would love to not have to do this separate from edit # fields (which are done in an async loop - see below) @@ -619,13 +646,19 @@ async def open_order_mode( ) ) + # make fill bar and positioning snapshot + order_pane.on_ui_settings_change('limit', tracker.alloc.limit()) + order_pane.on_ui_settings_change('account', pp_account) + # order_pane.update_status_ui(pp=tracker.startup_pp) + order_pane.update_status_ui(pp=tracker) + # TODO: create a mode "manager" of sorts? # -> probably just call it "UxModes" err sumthin? # so that view handlers can access it view.order_mode = mode # real-time pnl display task allocation - live_pp = mode.pp.live_pp + live_pp = mode.current_pp.live_pp size = live_pp.size if size: global _pnl_tasks @@ -697,7 +730,7 @@ async def display_pnl( ''' global _pnl_tasks - pp = order_mode.pp + pp = order_mode.current_pp live = pp.live_pp sym = live.symbol.key @@ -727,7 +760,7 @@ async def display_pnl( for tick in iterticks(quote, types): # print(f'{1/period} Hz') - size = live.size + size = order_mode.current_pp.live_pp.size if size == 0: # terminate this update task since we're # no longer in a pp @@ -738,7 +771,8 @@ async def display_pnl( # compute and display pnl status order_mode.pane.pnl_label.format( pnl=copysign(1, size) * pnl( - live.avg_price, + # live.avg_price, + order_mode.current_pp.live_pp.avg_price, tick['price'], ), ) @@ -760,7 +794,6 @@ async def process_trades_and_update_ui( ) -> None: get_index = mode.chart.get_index - tracker = mode.pp global _pnl_tasks # this is where we receive **back** messages @@ -774,16 +807,15 @@ async def process_trades_and_update_ui( if name in ( 'position', ): - # show line label once order is live - sym = mode.chart.linked.symbol if msg['symbol'].lower() in sym.key: + tracker = mode.trackers[msg['account']] tracker.live_pp.update_from_msg(msg) tracker.update_from_pp() # update order pane widgets - mode.pane.update_status_ui() + mode.pane.update_status_ui(tracker.live_pp) if ( tracker.live_pp.size and @@ -795,7 +827,7 @@ async def process_trades_and_update_ui( mode, ) # short circuit to next msg to avoid - # uncessary msg content lookups + # unnecessary msg content lookups continue resp = msg['resp']