diff --git a/piker/ui/monitor.py b/piker/ui/monitor.py index d9568135..9d15b257 100644 --- a/piker/ui/monitor.py +++ b/piker/ui/monitor.py @@ -7,7 +7,7 @@ Launch with ``piker monitor ``. """ from itertools import chain from types import ModuleType, AsyncGeneratorType -from typing import List +from typing import List, Callable, Dict import trio import tractor @@ -19,7 +19,6 @@ from kivy.lang import Builder from kivy import utils from kivy.app import async_runTouchApp from kivy.core.window import Window -from async_generator import aclosing from ..log import get_logger from .pager import PagerView @@ -126,10 +125,11 @@ _kv = (f''' # row higlighting on mouse over Color: rgba: {_i3_rgba} - RoundedRectangle: + # RoundedRectangle: + Rectangle: size: self.width, self.height if self.hovered else 1 pos: self.pos - radius: (10,) + # radius: (0,) @@ -148,9 +148,10 @@ class Cell(Button): ``key`` is the column name index value. """ - def __init__(self, key=None, **kwargs): + def __init__(self, key=None, is_header=False, **kwargs): super(Cell, self).__init__(**kwargs) self.key = key + self.is_header = is_header class HeaderCell(Cell): @@ -178,6 +179,8 @@ class HeaderCell(Cell): # sort and render the rows immediately self.row.table.render_rows(table.quote_cache) + # TODO: make this some kind of small geometry instead + # (maybe like how trading view does it). # allow highlighting of row headers for tracking elif self.is_header: if self.background_color == self.color: @@ -227,7 +230,7 @@ class BidAskLayout(StackLayout): self.row = None def get_cell(self, key): - return self._keys2cells[key] + return self._keys2cells.get(key) @property def row(self): @@ -246,13 +249,16 @@ class BidAskLayout(StackLayout): class Row(GridLayout, HoverBehavior): """A grid for displaying a row of ticker quote data. - - The row fields can be updated using the ``fields`` property which will in - turn adjust the text color of the values based on content changes. """ def __init__( - self, record, headers=(), bidasks=None, table=None, + self, + record, + headers=(), + no_cell=(), + bidasks=None, + table=None, is_header=False, + cell_type=None, **kwargs ): super(Row, self).__init__(cols=len(record), **kwargs) @@ -260,11 +266,13 @@ class Row(GridLayout, HoverBehavior): self._last_record = record self.table = table self.is_header = is_header + self._cell_type = cell_type + self.widget = self - # selection state - self.mouse_over = False - - # create `BidAskCells` first + # Create `BidAskCells` first. + # bid/ask cells are just 3 cells grouped in a + # ``BidAskLayout`` which just stacks the parent cell + # on top of 2 children. layouts = {} bidasks = bidasks or {} ba_cells = {} @@ -291,20 +299,20 @@ class Row(GridLayout, HoverBehavior): elif key in children_flat: # these cells have already been added to the `BidAskLayout` continue - else: + elif key not in no_cell: cell = self._append_cell(val, key, header=header) cell.key = key self._cell_widgets[key] = cell def get_cell(self, key): - return self._cell_widgets[key] + return self._cell_widgets.get(key) def _append_cell(self, text, key, header=False): if not len(self._cell_widgets) < self.cols: raise ValueError(f"Can not append more then {self.cols} cells") # header cells just have a different colour - celltype = HeaderCell if header else Cell + celltype = self._cell_type or (HeaderCell if header else Cell) cell = celltype(text=str(text), key=key) cell.is_header = header cell.row = self @@ -312,7 +320,8 @@ class Row(GridLayout, HoverBehavior): return cell def update(self, record, displayable): - """Update this row's cells with new values from a quote ``record``. + """Update this row's cells with new values from a quote + ``record``. Return all cells that changed in a ``dict``. """ @@ -331,10 +340,13 @@ class Row(GridLayout, HoverBehavior): color = gray cell = self.get_cell(key) - cell.text = str(displayable[key]) - cell.color = color - if color != gray: - cells[key] = cell + # some displayable fields might have specifically + # not had cells created as set in the `no_cell` attr + if cell is not None: + cell.text = str(displayable[key]) + cell.color = color + if color != gray: + cells[key] = cell self._last_record = record return cells @@ -359,40 +371,53 @@ class Row(GridLayout, HoverBehavior): class TickerTable(GridLayout): """A grid for displaying ticker quote records as a table. """ - def __init__(self, sort_key='%', quote_cache={}, **kwargs): + def __init__(self, sort_key='%', **kwargs): super(TickerTable, self).__init__(**kwargs) self.symbols2rows = {} self.sort_key = sort_key - self.quote_cache = quote_cache + self.quote_cache = {} self.row_filter = lambda item: item # for tracking last clicked column header cell self.last_clicked_col_cell = None self._last_row_toggle = 0 + self._rendered = set() - def append_row(self, record, bidasks=None): + def append_row(self, key, row): """Append a `Row` of `Cell` objects to this table. """ - row = Row(record, headers=('symbol',), bidasks=bidasks, table=self) # store ref to each row - self.symbols2rows[row._last_record['symbol']] = row + self.symbols2rows[key] = row self.add_widget(row) return row def render_rows( - self, pairs: {str: (dict, Row)}, sort_key: str = None, - row_filter=None, + self, + pairs: Dict[str, Row], + sort_key: str = None, + row_filter=None, ): """Sort and render all rows on the ticker grid from ``pairs``. """ self.clear_widgets() + self._rendered.clear() sort_key = sort_key or self.sort_key - for data, row in filter( + # TODO: intead of constantly re-rendering on every + # change do a binary search insert using ``bisect.insort()`` + for row in filter( row_filter or self.row_filter, reversed( - sorted(pairs.values(), key=lambda item: item[0][sort_key]) + sorted( + pairs.values(), + key=lambda row: row._last_record[sort_key] + ) ) ): - self.add_widget(row) # row append + widget = row.widget + if widget not in self._rendered: + self.add_widget(widget) # row append + self._rendered.add(widget) + else: + log.debug(f"Skipping adding widget {widget}") def ticker_search(self, patt): """Return sequence of matches when pattern ``patt`` is in a @@ -402,6 +427,9 @@ class TickerTable(GridLayout): if patt in symbol: yield symbol, row + def get_row(self, symbol: str) -> Row: + return self.symbols2rows[symbol] + def search(self, patt): """Search bar api compat. """ @@ -410,7 +438,7 @@ class TickerTable(GridLayout): async def update_quotes( nursery: trio._core._run.Nursery, - brokermod: ModuleType, + formatter: Callable, widgets: dict, agen: AsyncGeneratorType, symbol_data: dict, @@ -426,24 +454,27 @@ async def update_quotes( for cell in cells: cell.background_color = _black_rgba - def color_row(row, data, cells): + def color_row(row, record, cells): hdrcell = row.get_cell('symbol') chngcell = row.get_cell('%') # determine daily change color - daychange = float(data['%']) - if daychange < 0.: - color = colorcode('red2') - elif daychange > 0.: - color = colorcode('forestgreen') - else: - color = colorcode('gray') + color = colorcode('gray') + percent_change = record.get('%') + if percent_change: + daychange = float(record['%']) + if daychange < 0.: + color = colorcode('red2') + elif daychange > 0.: + color = colorcode('forestgreen') # update row header and '%' cell text color - chngcell.color = hdrcell.color = color - # if the cell has been "highlighted" make sure to change its color - if hdrcell.background_color != [0]*4: - hdrcell.background_color = color + if chngcell: + chngcell.color = color + hdrcell.color = color + # if the cell has been "highlighted" make sure to change its color + if hdrcell.background_color != [0]*4: + hdrcell.background_color = color # briefly highlight bg of certain cells on each trade execution unflash = set() @@ -483,12 +514,12 @@ async def update_quotes( # initial coloring for sym, quote in first_quotes.items(): - row = table.symbols2rows[sym] - record, displayable = brokermod.format_quote( + row = table.get_row(sym) + record, displayable = formatter( quote, symbol_data=symbol_data) row.update(record, displayable) color_row(row, record, {}) - cache[sym] = (record, row) + cache[sym] = row # render all rows once up front table.render_rows(cache) @@ -496,12 +527,12 @@ async def update_quotes( # real-time cell update loop async for quotes in agen: # new quotes data only for symbol, quote in quotes.items(): - record, displayable = brokermod.format_quote( + record, displayable = formatter( quote, symbol_data=symbol_data) - row = table.symbols2rows[symbol] - cache[symbol] = (record, row) + row = table.get_row(symbol) cells = row.update(record, displayable) color_row(row, record, cells) + cache[symbol] = row table.render_rows(cache) log.debug("Waiting on quotes") @@ -528,6 +559,9 @@ async def _async_main( "piker.brokers.data", 'stream_from_file', filename=test ) + # TODO: need a set of test packets to make this work + # seriously fu QT + # sd = {} else: # start live streaming from broker daemon quote_gen = await portal.run( @@ -540,62 +574,69 @@ async def _async_main( "piker.brokers.data", 'symbol_data', broker=brokermod.name, tickers=tickers) + # get first quotes response + log.debug("Waiting on first quote...") + quotes = await quote_gen.__anext__() + first_quotes = [ + brokermod.format_stock_quote(quote, symbol_data=sd)[0] + for quote in quotes.values()] + + if first_quotes[0].get('last') is None: + log.error("Broker API is down temporarily") + return + + # build out UI + Window.set_title(f"monitor: {name}\t(press ? for help)") + Builder.load_string(_kv) + box = BoxLayout(orientation='vertical', spacing=0) + + # define bid-ask "stacked" cells + # (TODO: needs some rethinking and renaming for sure) + bidasks = brokermod._stock_bidasks + + # add header row + headers = first_quotes[0].keys() + header = Row( + {key: key for key in headers}, + headers=headers, + bidasks=bidasks, + is_header=True, + size_hint=(1, None), + ) + box.add_widget(header) + + # build table + table = TickerTable( + cols=1, + size_hint=(1, None), + ) + for ticker_record in first_quotes: + table.append_row( + ticker_record['symbol'], + Row(ticker_record, headers=('symbol',), bidasks=bidasks, table=table) + ) + + # associate the col headers row with the ticker table even though + # they're technically wrapped separately in containing BoxLayout + header.table = table + + # mark the initial sorted column header as bold and underlined + sort_cell = header.get_cell(table.sort_key) + sort_cell.bold = sort_cell.underline = True + table.last_clicked_col_cell = sort_cell + + # set up a pager view for large ticker lists + table.bind(minimum_height=table.setter('height')) + async with trio.open_nursery() as nursery: - # get first quotes response - log.debug("Waiting on first quote...") - quotes = await quote_gen.__anext__() - first_quotes = [ - brokermod.format_quote(quote, symbol_data=sd)[0] - for quote in quotes.values()] - - if first_quotes[0].get('last') is None: - log.error("Broker API is down temporarily") - nursery.cancel_scope.cancel() - return - - # build out UI - Window.set_title(f"monitor: {name}\t(press ? for help)") - Builder.load_string(_kv) - box = BoxLayout(orientation='vertical', spacing=0) - - # define bid-ask "stacked" cells - # (TODO: needs some rethinking and renaming for sure) - bidasks = brokermod._bidasks - - # add header row - headers = first_quotes[0].keys() - header = Row( - {key: key for key in headers}, - headers=headers, - bidasks=bidasks, - is_header=True, - size_hint=(1, None), + pager = PagerView( + container=box, + contained=table, + nursery=nursery ) - box.add_widget(header) - - # build table - table = TickerTable( - cols=1, - size_hint=(1, None), - ) - for ticker_record in first_quotes: - table.append_row(ticker_record, bidasks=bidasks) - # associate the col headers row with the ticker table even though - # they're technically wrapped separately in containing BoxLayout - header.table = table - - # mark the initial sorted column header as bold and underlined - sort_cell = header.get_cell(table.sort_key) - sort_cell.bold = sort_cell.underline = True - table.last_clicked_col_cell = sort_cell - - # set up a pager view for large ticker lists - table.bind(minimum_height=table.setter('height')) - pager = PagerView(box, table, nursery) box.add_widget(pager) widgets = { - # 'anchor': anchor, 'root': box, 'table': table, 'box': box, @@ -603,7 +644,14 @@ async def _async_main( 'pager': pager, } nursery.start_soon( - update_quotes, nursery, brokermod, widgets, quote_gen, sd, quotes) + update_quotes, + nursery, + brokermod.format_stock_quote, + widgets, + quote_gen, + sd, + quotes + ) try: # Trio-kivy entry point.