Add watchlist sort-by-column and row header highlighting

kivy_mainline_and_py3.8
Tyler Goodlet 2018-02-09 22:03:37 -05:00
parent 0997418a47
commit 1af14bc46f
3 changed files with 112 additions and 32 deletions

15
piker/calc.py 100644
View File

@ -0,0 +1,15 @@
"""
Handy financial calculations.
"""
import math
import itertools
def humanize(number):
"""Convert large numbers to something with at most 3 digits and
a letter suffix (eg. k: thousand, M: million, B: billion).
"""
mag2suffix = {3: 'k', 6: 'M', 9: 'B'}
mag = math.floor(math.log(number, 10))
maxmag = max(itertools.takewhile(lambda key: mag >= key, mag2suffix))
return "{:.3f}{}".format(number/10**maxmag, mag2suffix[maxmag])

View File

@ -128,4 +128,4 @@ def watch(loglevel, broker, watchlist_name):
# broker_conf_path = os.path.join( # broker_conf_path = os.path.join(
# click.get_app_dir('piker'), 'watchlists.json') # click.get_app_dir('piker'), 'watchlists.json')
# from piker.testing import _quote_streamer as brokermod # from piker.testing import _quote_streamer as brokermod
trio.run(_async_main, watchlists[watchlist_name], brokermod) trio.run(_async_main, watchlist_name, watchlists, brokermod)

View File

@ -2,14 +2,14 @@
A real-time, sorted watchlist. A real-time, sorted watchlist.
Launch with ``piker watch <watchlist name>``. Launch with ``piker watch <watchlist name>``.
"""
from importlib import import_module
import click (Currently there's a bunch of QT specific stuff in here)
"""
import trio import trio
from kivy.uix.boxlayout import BoxLayout from kivy.uix.boxlayout import BoxLayout
from kivy.uix.gridlayout import GridLayout from kivy.uix.gridlayout import GridLayout
from kivy.uix.button import Button
from kivy.uix.label import Label from kivy.uix.label import Label
from kivy.uix.scrollview import ScrollView from kivy.uix.scrollview import ScrollView
from kivy.lang import Builder from kivy.lang import Builder
@ -17,8 +17,8 @@ from kivy import utils
from kivy.app import async_runTouchApp from kivy.app import async_runTouchApp
from ..cli import cli from ..calc import humanize
from ..log import get_logger, get_console_log from ..log import get_logger
log = get_logger('watchlist') log = get_logger('watchlist')
@ -54,24 +54,28 @@ _kv = (f'''
<Cell> <Cell>
text_size: self.size text_size: self.size
size: self.texture_size size: self.texture_size
# font_size: '15' font_size: '18sp'
# size_hint_y: None # size_hint_y: None
font_color: {colorcode('gray')} font_color: {colorcode('gray')}
# font_name: 'sans serif' font_name: 'Roboto-Regular'
# height: 50 # height: 50
# width: 50 # width: 50
background_color: 0,0,0,0
valign: 'middle' valign: 'middle'
halign: 'center' halign: 'center'
outline_color: {same_rgb(0.001)} # outline_color: {same_rgb(0.001)}
canvas.before: canvas.before:
Color: Color:
rgb: {same_rgb(0.03)} rgb: {same_rgb(0.05)}
RoundedRectangle: RoundedRectangle:
pos: self.pos pos: self.pos
size: self.size size: self.size
radius: [7,] radius: [7,]
<HeaderCell@Cell> <HeaderCell>
bold: True
font_size: '18sp'
background_color: 0,0,0,0
canvas.before: canvas.before:
Color: Color:
rgb: {same_rgb(0.12)} rgb: {same_rgb(0.12)}
@ -97,15 +101,15 @@ _kv = (f'''
_qt_keys = { _qt_keys = {
# 'symbol': 'symbol', # done manually in remap_keys # 'symbol': 'symbol', # done manually in qtconvert
'lastTradePrice': 'last', 'lastTradePrice': 'last',
'lastTradeSize': 'last size',
'askPrice': 'ask', 'askPrice': 'ask',
'askSize': 'ask size',
'bidPrice': 'bid', 'bidPrice': 'bid',
'lastTradeSize': 'last size',
'bidSize': 'bid size', 'bidSize': 'bid size',
'volume': 'vol', 'askSize': 'ask size',
'VWAP': 'VWAP', 'volume': ('vol', humanize),
'VWAP': ('VWAP', "{:.3f}".format),
'high52w': 'high52w', 'high52w': 'high52w',
'highPrice': 'high', 'highPrice': 'high',
# "lastTradePriceTrHrs": 7.99, # "lastTradePriceTrHrs": 7.99,
@ -121,7 +125,7 @@ _qt_keys = {
} }
def remap_keys(quote: dict, keymap: dict = _qt_keys, symbol_data: dict = None): def qtconvert(quote: dict, keymap: dict = _qt_keys, symbol_data: dict = None):
"""Remap a list of quote dicts ``quotes`` using """Remap a list of quote dicts ``quotes`` using
the mapping of old keys -> new keys ``keymap``. the mapping of old keys -> new keys ``keymap``.
""" """
@ -136,18 +140,46 @@ def remap_keys(quote: dict, keymap: dict = _qt_keys, symbol_data: dict = None):
} }
for key, new_key in keymap.items(): for key, new_key in keymap.items():
value = quote[key] value = quote[key]
if isinstance(new_key, tuple):
new_key, func = new_key
value = func(value)
new[new_key] = value new[new_key] = value
return new return new
class HeaderCell(Label): class HeaderCell(Button):
"""Column header cell label. """Column header cell label.
""" """
def on_press(self, value=None):
# clicking on a col header indicates to rows by this column
# in `update_quotes()`
if self.row.is_header:
self.row.table.sort_key = self.key
last = self.row.table._last_clicked_col_cell
if last and last is not self:
last.underline = False
last.bold = False
# outline the header text to indicate it's been the last clicked
self.underline = True
self.bold = True
# mark this cell as the last
self.row.table._last_clicked_col_cell = self
# allow highlighting row headers for visual following of
# specific tickers
elif self.is_header:
if self.background_color == self.color:
self.background_color = [0]*4
else:
self.background_color = self.color
class Cell(Label): class Cell(Label):
"""Data header cell label. """Data cell label.
""" """
@ -157,16 +189,25 @@ class Row(GridLayout):
The row fields can be updated using the ``fields`` property which will in 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. turn adjust the text color of the values based on content changes.
""" """
def __init__(self, record, headers=(), cell_type=Cell, **kwargs): def __init__(
self, record, headers=(), table=None, is_header_row=False,
**kwargs
):
super(Row, self).__init__(cols=len(record), **kwargs) super(Row, self).__init__(cols=len(record), **kwargs)
self._cell_widgets = {} self._cell_widgets = {}
self._last_record = record self._last_record = record
self.table = table
self.is_header = is_header_row
# build out row using Cell labels # build out row using Cell labels
for key, val in record.items(): for key, val in record.items():
header = key in headers header = key in headers
cell = self._append_cell(val, header=header) cell = self._append_cell(val, header=header)
self._cell_widgets[key] = cell self._cell_widgets[key] = cell
cell.key = key
def get_cell(self, key):
return self._cell_widgets[key]
def _append_cell(self, text, colorname=None, header=False): def _append_cell(self, text, colorname=None, header=False):
if not len(self._cell_widgets) < self.cols: if not len(self._cell_widgets) < self.cols:
@ -175,6 +216,8 @@ class Row(GridLayout):
# header cells just have a different colour # header cells just have a different colour
celltype = HeaderCell if header else Cell celltype = HeaderCell if header else Cell
cell = celltype(text=str(text), color=colorcode(colorname)) cell = celltype(text=str(text), color=colorcode(colorname))
cell.is_header = header
cell.row = self
self.add_widget(cell) self.add_widget(cell)
return cell return cell
@ -182,14 +225,17 @@ class Row(GridLayout):
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, **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
# for tracking last clicked column header cell
self._last_clicked_col_cell = None
def append_row(self, record): def append_row(self, record):
"""Append a `Row` of `Cell` objects to this table. """Append a `Row` of `Cell` objects to this table.
""" """
row = Row(record, headers=('symbol',)) row = Row(record, headers=('symbol',), table=self)
# store ref to each row # store ref to each row
self.symbols2rows[row._last_record['symbol']] = row self.symbols2rows[row._last_record['symbol']] = row
self.add_widget(row) self.add_widget(row)
@ -200,7 +246,7 @@ def header_row(headers, **kwargs):
"""Create a single "header" row from a sequence of keys. """Create a single "header" row from a sequence of keys.
""" """
headers_dict = {key: key for key in headers} headers_dict = {key: key for key in headers}
row = Row(headers_dict, headers=headers, **kwargs) row = Row(headers_dict, headers=headers, is_header_row=True, **kwargs)
return row return row
@ -232,23 +278,30 @@ async def update_quotes(
elif daychange > 0.: elif daychange > 0.:
color = colorcode('forestgreen') color = colorcode('forestgreen')
else: else:
color = colorcode('gray') color = colorcode('darkgray')
chngcell.color = hdrcell.color = 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
# initial coloring # initial coloring
all_rows = [] syms2rows = {}
for quote in first_quotes: for quote in first_quotes:
row = grid.symbols2rows[quote['symbol']] sym = quote['symbol']
all_rows.append((quote, row)) row = grid.symbols2rows[sym]
syms2rows[sym] = row
color_row(row, quote) color_row(row, quote)
while True: while True:
log.debug("Waiting on quotes") log.debug("Waiting on quotes")
quotes = await queue.get() quotes = await queue.get()
datas = []
for quote in quotes: for quote in quotes:
data = remap_keys(quote, symbol_data=symbol_data) data = qtconvert(quote, symbol_data=symbol_data)
row = grid.symbols2rows[data['symbol']] row = grid.symbols2rows[data['symbol']]
datas.append((data, row))
# color changed field values # color changed field values
for key, val in data.items(): for key, val in data.items():
@ -269,10 +322,13 @@ async def update_quotes(
# sort rows by daily % change since open # sort rows by daily % change since open
grid.clear_widgets() grid.clear_widgets()
sort_key = grid.sort_key
for i, (data, row) in enumerate( for i, (data, row) in enumerate(
sorted(all_rows, key=lambda item: float(item[0]['%'])) reversed(sorted(datas, key=lambda item: item[0][sort_key]))
): ):
grid.add_widget(row, index=i) grid.add_widget(row) # row append
# print(f'{i} {data["symbol"]} {data["%"]}')
# await trio.sleep(0.1)
async def run_kivy(root, nursery): async def run_kivy(root, nursery):
@ -284,9 +340,10 @@ async def run_kivy(root, nursery):
nursery.cancel_scope.cancel() nursery.cancel_scope.cancel()
async def _async_main(tickers, brokermod): async def _async_main(name, watchlists, brokermod):
'''Launch kivy app + all other related tasks. '''Launch kivy app + all other related tasks.
''' '''
tickers = watchlists[name]
queue = trio.Queue(1000) queue = trio.Queue(1000)
async with brokermod.get_client() as client: async with brokermod.get_client() as client:
@ -305,7 +362,7 @@ async def _async_main(tickers, brokermod):
return return
first_quotes = [ first_quotes = [
remap_keys(quote, symbol_data=sd) for quote in pkts] qtconvert(quote, symbol_data=sd) for quote in pkts]
# build out UI # build out UI
Builder.load_string(_kv) Builder.load_string(_kv)
@ -319,6 +376,14 @@ async def _async_main(tickers, brokermod):
first_quotes, first_quotes,
size_hint=(1, None), size_hint=(1, None),
) )
# associate the col headers row with the ticker table even
# though they're technically wrapped separately in containing BoxLayout
header.table = grid
# mark the initial sorted column header as bold and underlined
sort_cell = header.get_cell(grid.sort_key)
sort_cell.bold = sort_cell.underline = True
grid.bind(minimum_height=grid.setter('height')) grid.bind(minimum_height=grid.setter('height'))
scroll = ScrollView() scroll = ScrollView()
scroll.add_widget(grid) scroll.add_widget(grid)