Tabular kivy UI improvements
`Row`: - `no_cell`: support a list of keys for which no cells will be created - allow passing in a `cell_type` at instantiation `TickerTable`: - keep track of rendered rows via a private `_rendered` set - don't create rows inside `append_row()` expect caller to do it - never render already rendered widgets in `render_rows()` Miscellaneous: - generalize `update_quotes()` to not be tied to specific quote fields and allow passing in a quote `formatter()` func - don't bother creating a nursery block until necessary in main - more commentingkivy_mainline_and_py3.8
parent
20778b02b5
commit
8647216b75
|
@ -7,7 +7,7 @@ Launch with ``piker monitor <watchlist name>``.
|
||||||
"""
|
"""
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from types import ModuleType, AsyncGeneratorType
|
from types import ModuleType, AsyncGeneratorType
|
||||||
from typing import List
|
from typing import List, Callable, Dict
|
||||||
|
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
|
@ -19,7 +19,6 @@ from kivy.lang import Builder
|
||||||
from kivy import utils
|
from kivy import utils
|
||||||
from kivy.app import async_runTouchApp
|
from kivy.app import async_runTouchApp
|
||||||
from kivy.core.window import Window
|
from kivy.core.window import Window
|
||||||
from async_generator import aclosing
|
|
||||||
|
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
from .pager import PagerView
|
from .pager import PagerView
|
||||||
|
@ -126,10 +125,11 @@ _kv = (f'''
|
||||||
# row higlighting on mouse over
|
# row higlighting on mouse over
|
||||||
Color:
|
Color:
|
||||||
rgba: {_i3_rgba}
|
rgba: {_i3_rgba}
|
||||||
RoundedRectangle:
|
# RoundedRectangle:
|
||||||
|
Rectangle:
|
||||||
size: self.width, self.height if self.hovered else 1
|
size: self.width, self.height if self.hovered else 1
|
||||||
pos: self.pos
|
pos: self.pos
|
||||||
radius: (10,)
|
# radius: (0,)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -148,9 +148,10 @@ class Cell(Button):
|
||||||
|
|
||||||
``key`` is the column name index value.
|
``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)
|
super(Cell, self).__init__(**kwargs)
|
||||||
self.key = key
|
self.key = key
|
||||||
|
self.is_header = is_header
|
||||||
|
|
||||||
|
|
||||||
class HeaderCell(Cell):
|
class HeaderCell(Cell):
|
||||||
|
@ -178,6 +179,8 @@ class HeaderCell(Cell):
|
||||||
# sort and render the rows immediately
|
# sort and render the rows immediately
|
||||||
self.row.table.render_rows(table.quote_cache)
|
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
|
# allow highlighting of row headers for tracking
|
||||||
elif self.is_header:
|
elif self.is_header:
|
||||||
if self.background_color == self.color:
|
if self.background_color == self.color:
|
||||||
|
@ -227,7 +230,7 @@ class BidAskLayout(StackLayout):
|
||||||
self.row = None
|
self.row = None
|
||||||
|
|
||||||
def get_cell(self, key):
|
def get_cell(self, key):
|
||||||
return self._keys2cells[key]
|
return self._keys2cells.get(key)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def row(self):
|
def row(self):
|
||||||
|
@ -246,13 +249,16 @@ class BidAskLayout(StackLayout):
|
||||||
|
|
||||||
class Row(GridLayout, HoverBehavior):
|
class Row(GridLayout, HoverBehavior):
|
||||||
"""A grid for displaying a row of ticker quote data.
|
"""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__(
|
def __init__(
|
||||||
self, record, headers=(), bidasks=None, table=None,
|
self,
|
||||||
|
record,
|
||||||
|
headers=(),
|
||||||
|
no_cell=(),
|
||||||
|
bidasks=None,
|
||||||
|
table=None,
|
||||||
is_header=False,
|
is_header=False,
|
||||||
|
cell_type=None,
|
||||||
**kwargs
|
**kwargs
|
||||||
):
|
):
|
||||||
super(Row, self).__init__(cols=len(record), **kwargs)
|
super(Row, self).__init__(cols=len(record), **kwargs)
|
||||||
|
@ -260,11 +266,13 @@ class Row(GridLayout, HoverBehavior):
|
||||||
self._last_record = record
|
self._last_record = record
|
||||||
self.table = table
|
self.table = table
|
||||||
self.is_header = is_header
|
self.is_header = is_header
|
||||||
|
self._cell_type = cell_type
|
||||||
|
self.widget = self
|
||||||
|
|
||||||
# selection state
|
# Create `BidAskCells` first.
|
||||||
self.mouse_over = False
|
# bid/ask cells are just 3 cells grouped in a
|
||||||
|
# ``BidAskLayout`` which just stacks the parent cell
|
||||||
# create `BidAskCells` first
|
# on top of 2 children.
|
||||||
layouts = {}
|
layouts = {}
|
||||||
bidasks = bidasks or {}
|
bidasks = bidasks or {}
|
||||||
ba_cells = {}
|
ba_cells = {}
|
||||||
|
@ -291,20 +299,20 @@ class Row(GridLayout, HoverBehavior):
|
||||||
elif key in children_flat:
|
elif key in children_flat:
|
||||||
# these cells have already been added to the `BidAskLayout`
|
# these cells have already been added to the `BidAskLayout`
|
||||||
continue
|
continue
|
||||||
else:
|
elif key not in no_cell:
|
||||||
cell = self._append_cell(val, key, header=header)
|
cell = self._append_cell(val, key, header=header)
|
||||||
cell.key = key
|
cell.key = key
|
||||||
self._cell_widgets[key] = cell
|
self._cell_widgets[key] = cell
|
||||||
|
|
||||||
def get_cell(self, key):
|
def get_cell(self, key):
|
||||||
return self._cell_widgets[key]
|
return self._cell_widgets.get(key)
|
||||||
|
|
||||||
def _append_cell(self, text, key, header=False):
|
def _append_cell(self, text, key, header=False):
|
||||||
if not len(self._cell_widgets) < self.cols:
|
if not len(self._cell_widgets) < self.cols:
|
||||||
raise ValueError(f"Can not append more then {self.cols} cells")
|
raise ValueError(f"Can not append more then {self.cols} cells")
|
||||||
|
|
||||||
# header cells just have a different colour
|
# 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 = celltype(text=str(text), key=key)
|
||||||
cell.is_header = header
|
cell.is_header = header
|
||||||
cell.row = self
|
cell.row = self
|
||||||
|
@ -312,7 +320,8 @@ class Row(GridLayout, HoverBehavior):
|
||||||
return cell
|
return cell
|
||||||
|
|
||||||
def update(self, record, displayable):
|
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``.
|
Return all cells that changed in a ``dict``.
|
||||||
"""
|
"""
|
||||||
|
@ -331,10 +340,13 @@ class Row(GridLayout, HoverBehavior):
|
||||||
color = gray
|
color = gray
|
||||||
|
|
||||||
cell = self.get_cell(key)
|
cell = self.get_cell(key)
|
||||||
cell.text = str(displayable[key])
|
# some displayable fields might have specifically
|
||||||
cell.color = color
|
# not had cells created as set in the `no_cell` attr
|
||||||
if color != gray:
|
if cell is not None:
|
||||||
cells[key] = cell
|
cell.text = str(displayable[key])
|
||||||
|
cell.color = color
|
||||||
|
if color != gray:
|
||||||
|
cells[key] = cell
|
||||||
|
|
||||||
self._last_record = record
|
self._last_record = record
|
||||||
return cells
|
return cells
|
||||||
|
@ -359,40 +371,53 @@ class Row(GridLayout, HoverBehavior):
|
||||||
class TickerTable(GridLayout):
|
class TickerTable(GridLayout):
|
||||||
"""A grid for displaying ticker quote records as a table.
|
"""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)
|
super(TickerTable, self).__init__(**kwargs)
|
||||||
self.symbols2rows = {}
|
self.symbols2rows = {}
|
||||||
self.sort_key = sort_key
|
self.sort_key = sort_key
|
||||||
self.quote_cache = quote_cache
|
self.quote_cache = {}
|
||||||
self.row_filter = lambda item: item
|
self.row_filter = lambda item: item
|
||||||
# for tracking last clicked column header cell
|
# for tracking last clicked column header cell
|
||||||
self.last_clicked_col_cell = None
|
self.last_clicked_col_cell = None
|
||||||
self._last_row_toggle = 0
|
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.
|
"""Append a `Row` of `Cell` objects to this table.
|
||||||
"""
|
"""
|
||||||
row = Row(record, headers=('symbol',), bidasks=bidasks, table=self)
|
|
||||||
# store ref to each row
|
# store ref to each row
|
||||||
self.symbols2rows[row._last_record['symbol']] = row
|
self.symbols2rows[key] = row
|
||||||
self.add_widget(row)
|
self.add_widget(row)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
def render_rows(
|
def render_rows(
|
||||||
self, pairs: {str: (dict, Row)}, sort_key: str = None,
|
self,
|
||||||
row_filter=None,
|
pairs: Dict[str, Row],
|
||||||
|
sort_key: str = None,
|
||||||
|
row_filter=None,
|
||||||
):
|
):
|
||||||
"""Sort and render all rows on the ticker grid from ``pairs``.
|
"""Sort and render all rows on the ticker grid from ``pairs``.
|
||||||
"""
|
"""
|
||||||
self.clear_widgets()
|
self.clear_widgets()
|
||||||
|
self._rendered.clear()
|
||||||
sort_key = sort_key or self.sort_key
|
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,
|
row_filter or self.row_filter,
|
||||||
reversed(
|
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):
|
def ticker_search(self, patt):
|
||||||
"""Return sequence of matches when pattern ``patt`` is in a
|
"""Return sequence of matches when pattern ``patt`` is in a
|
||||||
|
@ -402,6 +427,9 @@ class TickerTable(GridLayout):
|
||||||
if patt in symbol:
|
if patt in symbol:
|
||||||
yield symbol, row
|
yield symbol, row
|
||||||
|
|
||||||
|
def get_row(self, symbol: str) -> Row:
|
||||||
|
return self.symbols2rows[symbol]
|
||||||
|
|
||||||
def search(self, patt):
|
def search(self, patt):
|
||||||
"""Search bar api compat.
|
"""Search bar api compat.
|
||||||
"""
|
"""
|
||||||
|
@ -410,7 +438,7 @@ class TickerTable(GridLayout):
|
||||||
|
|
||||||
async def update_quotes(
|
async def update_quotes(
|
||||||
nursery: trio._core._run.Nursery,
|
nursery: trio._core._run.Nursery,
|
||||||
brokermod: ModuleType,
|
formatter: Callable,
|
||||||
widgets: dict,
|
widgets: dict,
|
||||||
agen: AsyncGeneratorType,
|
agen: AsyncGeneratorType,
|
||||||
symbol_data: dict,
|
symbol_data: dict,
|
||||||
|
@ -426,24 +454,27 @@ async def update_quotes(
|
||||||
for cell in cells:
|
for cell in cells:
|
||||||
cell.background_color = _black_rgba
|
cell.background_color = _black_rgba
|
||||||
|
|
||||||
def color_row(row, data, cells):
|
def color_row(row, record, cells):
|
||||||
hdrcell = row.get_cell('symbol')
|
hdrcell = row.get_cell('symbol')
|
||||||
chngcell = row.get_cell('%')
|
chngcell = row.get_cell('%')
|
||||||
|
|
||||||
# determine daily change color
|
# determine daily change color
|
||||||
daychange = float(data['%'])
|
color = colorcode('gray')
|
||||||
if daychange < 0.:
|
percent_change = record.get('%')
|
||||||
color = colorcode('red2')
|
if percent_change:
|
||||||
elif daychange > 0.:
|
daychange = float(record['%'])
|
||||||
color = colorcode('forestgreen')
|
if daychange < 0.:
|
||||||
else:
|
color = colorcode('red2')
|
||||||
color = colorcode('gray')
|
elif daychange > 0.:
|
||||||
|
color = colorcode('forestgreen')
|
||||||
|
|
||||||
# update row header and '%' cell text color
|
# update row header and '%' cell text color
|
||||||
chngcell.color = hdrcell.color = color
|
if chngcell:
|
||||||
# if the cell has been "highlighted" make sure to change its color
|
chngcell.color = color
|
||||||
if hdrcell.background_color != [0]*4:
|
hdrcell.color = color
|
||||||
hdrcell.background_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
|
# briefly highlight bg of certain cells on each trade execution
|
||||||
unflash = set()
|
unflash = set()
|
||||||
|
@ -483,12 +514,12 @@ async def update_quotes(
|
||||||
|
|
||||||
# initial coloring
|
# initial coloring
|
||||||
for sym, quote in first_quotes.items():
|
for sym, quote in first_quotes.items():
|
||||||
row = table.symbols2rows[sym]
|
row = table.get_row(sym)
|
||||||
record, displayable = brokermod.format_quote(
|
record, displayable = formatter(
|
||||||
quote, symbol_data=symbol_data)
|
quote, symbol_data=symbol_data)
|
||||||
row.update(record, displayable)
|
row.update(record, displayable)
|
||||||
color_row(row, record, {})
|
color_row(row, record, {})
|
||||||
cache[sym] = (record, row)
|
cache[sym] = row
|
||||||
|
|
||||||
# render all rows once up front
|
# render all rows once up front
|
||||||
table.render_rows(cache)
|
table.render_rows(cache)
|
||||||
|
@ -496,12 +527,12 @@ async def update_quotes(
|
||||||
# real-time cell update loop
|
# real-time cell update loop
|
||||||
async for quotes in agen: # new quotes data only
|
async for quotes in agen: # new quotes data only
|
||||||
for symbol, quote in quotes.items():
|
for symbol, quote in quotes.items():
|
||||||
record, displayable = brokermod.format_quote(
|
record, displayable = formatter(
|
||||||
quote, symbol_data=symbol_data)
|
quote, symbol_data=symbol_data)
|
||||||
row = table.symbols2rows[symbol]
|
row = table.get_row(symbol)
|
||||||
cache[symbol] = (record, row)
|
|
||||||
cells = row.update(record, displayable)
|
cells = row.update(record, displayable)
|
||||||
color_row(row, record, cells)
|
color_row(row, record, cells)
|
||||||
|
cache[symbol] = row
|
||||||
|
|
||||||
table.render_rows(cache)
|
table.render_rows(cache)
|
||||||
log.debug("Waiting on quotes")
|
log.debug("Waiting on quotes")
|
||||||
|
@ -528,6 +559,9 @@ async def _async_main(
|
||||||
"piker.brokers.data", 'stream_from_file',
|
"piker.brokers.data", 'stream_from_file',
|
||||||
filename=test
|
filename=test
|
||||||
)
|
)
|
||||||
|
# TODO: need a set of test packets to make this work
|
||||||
|
# seriously fu QT
|
||||||
|
# sd = {}
|
||||||
else:
|
else:
|
||||||
# start live streaming from broker daemon
|
# start live streaming from broker daemon
|
||||||
quote_gen = await portal.run(
|
quote_gen = await portal.run(
|
||||||
|
@ -540,62 +574,69 @@ async def _async_main(
|
||||||
"piker.brokers.data", 'symbol_data',
|
"piker.brokers.data", 'symbol_data',
|
||||||
broker=brokermod.name, tickers=tickers)
|
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:
|
async with trio.open_nursery() as nursery:
|
||||||
# get first quotes response
|
pager = PagerView(
|
||||||
log.debug("Waiting on first quote...")
|
container=box,
|
||||||
quotes = await quote_gen.__anext__()
|
contained=table,
|
||||||
first_quotes = [
|
nursery=nursery
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
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)
|
box.add_widget(pager)
|
||||||
|
|
||||||
widgets = {
|
widgets = {
|
||||||
# 'anchor': anchor,
|
|
||||||
'root': box,
|
'root': box,
|
||||||
'table': table,
|
'table': table,
|
||||||
'box': box,
|
'box': box,
|
||||||
|
@ -603,7 +644,14 @@ async def _async_main(
|
||||||
'pager': pager,
|
'pager': pager,
|
||||||
}
|
}
|
||||||
nursery.start_soon(
|
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:
|
try:
|
||||||
# Trio-kivy entry point.
|
# Trio-kivy entry point.
|
||||||
|
|
Loading…
Reference in New Issue