From 72f417b9c2d4962e51f0da990c5befe84d129129 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 30 Dec 2018 15:00:46 -0500 Subject: [PATCH] Support monitor linked symbol selection This allows for using a monitor to select the current option chain symbol! The deats: - start a bg task which streams the monitor selected symbol - dynamically repopulate expiry buttons on a newly published symbol - move static widget creation into a chain method to avoid multiple quotes requests at startup - rename a bunch of methods --- piker/ui/option_chain.py | 302 ++++++++++++++++++++++----------------- 1 file changed, 171 insertions(+), 131 deletions(-) diff --git a/piker/ui/option_chain.py b/piker/ui/option_chain.py index 5fae8dbf..86654175 100644 --- a/piker/ui/option_chain.py +++ b/piker/ui/option_chain.py @@ -141,11 +141,7 @@ class ExpiryButton(Cell): log.info(f"Clicked {self}") self.click_toggle = True - if self.chain.sub[1] == self.key: - log.info(f"Clicked {self} is already selected") - return - log.info(f"Subscribing for {self.chain.sub}") - self.chain.start_displaying(self.chain.sub[0], self.key) + self.chain.start_displaying(self.chain.symbol, self.key) class DataFeed(object): @@ -194,14 +190,32 @@ class DataFeed(object): self.quote_gen = quote_gen self.first_quotes = quotes - # self.records = records - # self.displayables = displayables return quote_gen, quotes except Exception: if self.quote_gen: await self.quote_gen.aclose() + self.quote_gen = None raise + def format_quotes(self, quotes): + records, displayables = zip(*[ + self.brokermod.format_option_quote(quote, {}) + for quote in quotes.values() + ]) + return records, displayables + + +@asynccontextmanager +async def find_local_monitor(): + """Establish a portal to a local monitor for triggering + symbol changes. + """ + async with tractor.find_actor('monitor') as portal: + if not portal: + log.warn( + "No monitor app could be found, no symbol link established..") + yield portal + class OptionChain(object): """A real-time options chain UI. @@ -210,14 +224,13 @@ class OptionChain(object): def __init__( self, - symbol: str, - expiry: str, widgets: dict, bidasks: Dict[str, List[str]], feed: DataFeed, rate: int, ): - self.sub = (symbol, expiry) + self.symbol = None + self.expiry = None self.widgets = widgets self.bidasks = bidasks self._strikes2rows = {} @@ -230,21 +243,35 @@ class OptionChain(object): # right now it's only needed for the UI uupdate loop to cancel itself self._first_quotes = None self._last_expiry = None + # flag to determine if one-time widgets have been generated + self._static_widgets_initialized = False + + async def _rx_symbols(self): + async with find_local_monitor() as portal: + if not portal: + log.warn("No local monitor could be found") + return + async for symbol in await portal.run( + 'piker.ui.monitor', + 'stream_symbol_selection', + ): + log.info(f"Changing symbol subscriptions to {symbol}") + self.start_displaying(symbol, self.expiry) @asynccontextmanager - async def open_update_scope(self): + async def open_rt_display(self, nursery, symbol, expiry=None): """Open an internal update task scope required to allow for dynamic real-time operation. """ - # assign us to each expiry button - for key, button in ( - self.widgets['expiry_buttons']._cell_widgets.items() - ): - button.chain = self - + self._parent_nursery = nursery async with trio.open_nursery() as n: self._nursery = n - n.start_soon(self._start_displaying, *self.sub) + # fill out and start updatingn strike table + n.start_soon( + partial(self._start_displaying, symbol, expiry=expiry) + ) + # listen for undlerlying symbol changes from a local monitor app + n.start_soon(self._rx_symbols) yield self n.cancel_scope.cancel() @@ -259,9 +286,6 @@ class OptionChain(object): table.clear() self._strikes2rows.clear() - def clear_expiries(self): - pass - def render_rows(self, records, displayables): """Render all strike rows in the internal table. """ @@ -295,7 +319,7 @@ class OptionChain(object): log.debug("Finished rendering rows!") - async def _start_displaying(self, symbol, expiry): + async def _start_displaying(self, symbol, expiry=None): """Main routine to start displaying the real time updated strike table. @@ -303,10 +327,34 @@ class OptionChain(object): (eg. when clicking a new expiry button) spin up a new subscription, populate the table and start updating it. """ - # set window title - self.widgets['window'].set_title( - self._title.format(symbol=symbol) - ) + # redraw any symbol specific UI components + if self.symbol != symbol or expiry is None: + # set window title + self.widgets['window'].set_title( + self._title.format(symbol=symbol) + ) + + # retreive all contracts to populate expiry row + all_contracts = await contracts(self.feed.brokermod, symbol) + # start streaming soonest contract by default if not provided + expiry = next(iter(all_contracts)).expiry if not expiry else expiry + + # TODO: figure out how to compact these buttons + expiries = { + key.expiry: key.expiry[:key.expiry.find('T')] + for key in all_contracts + } + expiry_row = self.widgets['expiry_row'] + expiry_row.clear_widgets() + + for expiry, justdate in expiries.items(): + button = ExpiryButton(text=str(justdate), key=expiry) + # assign us to each expiry button + button.chain = self + expiry_row.add_widget(button) + + if self.widgets.get('table'): + self.clear_strikes() if self._update_cs: log.warn("Cancelling existing update task") @@ -316,24 +364,23 @@ class OptionChain(object): if self._quote_gen: await self._quote_gen.aclose() - self.clear_strikes() - if self._nursery is None: raise RuntimeError( "You must call open this chain's update scope first!") - n = self._nursery log.debug(f"Waiting on first_quotes for {symbol}:{expiry}") self._quote_gen, first_quotes = await self.feed.open_stream( [(symbol, expiry)] ) log.debug(f"Got first_quotes for {symbol}:{expiry}") + records, displayables = self.feed.format_quotes(first_quotes) - # redraw the UI - records, displayables = zip(*[ - self.feed.brokermod.format_option_quote(quote, {}) - for quote in first_quotes.values() - ]) + # draw static widgets only once + if self._static_widgets_initialized is False: + self._init_static_widgets(displayables) + self._static_widgets_initialized = True + + n = self._nursery self.render_rows(records, displayables) with trio.open_cancel_scope() as cs: @@ -349,10 +396,78 @@ class OptionChain(object): first_quotes=first_quotes, ) ) + self.symbol, self.expiry = symbol, expiry def start_displaying(self, symbol, expiry): - self.sub = (symbol, expiry) - self._nursery.start_soon(self._start_displaying, symbol, expiry) + if self.symbol == symbol and self.expiry == expiry: + log.info(f"Clicked {symbol}:{expiry} is already selected") + return + + log.info(f"Subscribing for {symbol}:{expiry}") + self._nursery.start_soon( + partial(self._start_displaying, symbol, expiry=expiry) + ) + + def _init_static_widgets(self, displayables): + assert self._static_widgets_initialized is False + container = self.widgets['container'] + + # calls / puts header + type_header = BoxLayout( + orientation='horizontal', + size_hint=(1, 1/30.), + ) + calls = Label(text='calls', font_size='20') + puts = Label(text='puts', font_size='20') + type_header.add_widget(calls) + type_header.add_widget(puts) + container.add_widget(type_header) + + # figure out header fields for each table based on quote keys + headers = displayables[0].keys() + header_row = StrikeRow(strike='strike', size_hint=(1, None)) + header_record = {key: key for key in headers} + header_record['contract_type'] = 'put' + header_row.append_sub_row( + header_record, + header_record, + headers=headers, + bidasks=self.bidasks, + is_header=True, + size_hint=(1, None), + ) + header_record['contract_type'] = 'call' + header_row.append_sub_row( + header_record, + header_record, + headers=headers, + bidasks=self.bidasks, + is_header=True, + size_hint=(1, None), + ) + container.add_widget(header_row) + + # build out chain tables + table = TickerTable( + sort_key='strike', + cols=1, + size_hint=(1, None), + ) + header_row.table = table + table.bind(minimum_height=table.setter('height')) + pager = PagerView( + container=container, + contained=table, + nursery=self._nursery + ) + container.add_widget(pager) + + self.widgets.update({ + 'table': table, + 'type_header': type_header, + 'table': table, + 'pager': pager, + }) async def new_chain_ui( @@ -364,33 +479,6 @@ async def new_chain_ui( ) -> None: """Create and return a new option chain UI. """ - # retreive all contracts just because we need a default when the - # UI starts up - all_contracts = await contracts(brokermod, symbol) - # start streaming soonest contract by default - expiry = next(iter(all_contracts)).expiry - - widgets = {} - - # define bid-ask "stacked" cells - # (TODO: needs some rethinking and renaming for sure) - bidasks = brokermod._option_bidasks - - feed = DataFeed(portal, brokermod) - chain = OptionChain( - symbol, - expiry, - widgets, - bidasks, - feed, - rate=rate, - ) - quote_gen, first_quotes = await chain.feed.open_stream([chain.sub]) - records, displayables = zip(*[ - brokermod.format_option_quote(quote, {}) - for quote in first_quotes.values() - ]) - # use `monitor` styling for now from .monitor import _kv Builder.load_string(_kv) @@ -398,78 +486,30 @@ async def new_chain_ui( # the master container container = BoxLayout(orientation='vertical', spacing=0) - # TODO: figure out how to compact these buttons - expiries = { - key.expiry: key.expiry[:key.expiry.find('T')] - for key in all_contracts - } - expiry_buttons = Row( - record=expiries, - headers=expiries, - is_header=True, - size_hint=(1, None), - cell_type=ExpiryButton, - ) - # top row of expiry buttons - container.add_widget(expiry_buttons) - - # denote calls vs. puts side of table - type_header = BoxLayout( + # expiry buttons row (populated later once contracts are retreived) + expiry_row = BoxLayout( orientation='horizontal', - size_hint=(1, 1/28.), + size_hint=(1, None), ) - calls = Label(text='calls', font_size='20') - puts = Label(text='puts', font_size='20') - type_header.add_widget(calls) - type_header.add_widget(puts) - container.add_widget(type_header) + container.add_widget(expiry_row) - # figure out header fields for each table based on quote keys - headers = displayables[0].keys() - header_row = StrikeRow(strike='strike', size_hint=(1, None)) - header_record = {key: key for key in headers} - header_record['contract_type'] = 'put' - header_row.append_sub_row( - header_record, - header_record, - headers=headers, - bidasks=bidasks, - is_header=True, - size_hint=(1, None), - ) - header_record['contract_type'] = 'call' - header_row.append_sub_row( - header_record, - header_record, - headers=headers, - bidasks=bidasks, - is_header=True, - size_hint=(1, None), - ) - container.add_widget(header_row) - - # build out chain tables - table = TickerTable( - sort_key='strike', - cols=1, - size_hint=(1, None), - ) - header_row.table = table - table.bind(minimum_height=table.setter('height')) - pager = PagerView( - container=container, - contained=table, - nursery=nursery - ) - container.add_widget(pager) - widgets.update({ + widgets = { 'window': Window, 'root': container, 'container': container, - 'table': table, - 'expiry_buttons': expiry_buttons, - 'pager': pager, - }) + 'expiry_row': expiry_row, + } + # define bid-ask "stacked" cells + # (TODO: needs some rethinking and renaming for sure) + bidasks = brokermod._option_bidasks + + feed = DataFeed(portal, brokermod) + chain = OptionChain( + widgets, + bidasks, + feed, + rate=rate, + ) return chain @@ -493,9 +533,9 @@ async def _async_main( nursery, rate=rate, ) - async with chain.open_update_scope(): + async with chain.open_rt_display(nursery, symbol): try: - # Trio-kivy entry point. + # trio-kivy entry point. await async_runTouchApp(chain.widgets['root']) # run kivy finally: # cancel GUI update task