Match the author's general apparel

It's still a bit of a shit show, and I've left a lot of commented tweaks
that need to be further played with, but I think this is a much
better look for what I'm considering to be one of the main "entry point"
apps for `piker`. To get any more serious fine tuning the way I want
I may have to talk to some kivy experts as I'm having some headaches
with button borders, padding, and the header row height..

Some of the new changes include:
- port to the new `brokers.data` module
- much darker theme with a stronger terminal vibe
- last trade price and volume amount flash on each trade
- fixed the symbol search bar to be a static height; before it was
  getting squashed oddly when using stacked windows
- make all the cells transparent (for now) such that I can just use
  a row color (relates to cell padding/spacing - can't seem to ditch it)
- start adding type annotations
kivy_mainline_and_py3.8
Tyler Goodlet 2018-11-23 10:50:40 -05:00
parent 0f3faec35d
commit a7f3008d34
1 changed files with 170 additions and 77 deletions

View File

@ -7,8 +7,10 @@ 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
import trio import trio
import tractor
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.stacklayout import StackLayout from kivy.uix.stacklayout import StackLayout
@ -41,41 +43,60 @@ def colorcode(name):
return _colors[name if name else 'gray'] return _colors[name if name else 'gray']
_bs = 4 # border size _bs = 0.75 # border size
_color = [0.13]*3 # nice shade of gray # medium shade of gray that seems to match the
# default i3 window borders
_i3_rgba = [0.14]*3 + [1]
# slightly off black like the jellybean bg from
# vim colorscheme
_cell_rgba = [0.07]*3 + [1]
_black_rgba = [0]*4
_kv = (f''' _kv = (f'''
#:kivy 1.10.0 #:kivy 1.10.0
<Cell> <Cell>
font_size: 20 font_size: 21
# text_size: self.size # make text wrap to botom
size: self.texture_size text_size: self.size
color: {colorcode('gray')}
font_color: {colorcode('gray')}
font_name: 'Roboto-Regular'
background_color: [0.13]*3 + [1]
background_normal: ''
valign: 'middle'
halign: 'center' halign: 'center'
# outline_color: [0.1]*4 valign: 'middle'
size: self.texture_size
# color: {colorcode('gray')}
# font_color: {colorcode('gray')}
font_name: 'Roboto-Regular'
background_color: [0]*4 # by default transparent; use row color
# background_color: {_cell_rgba}
# spacing: 0, 0
# padding: [0]*4
<HeaderCell> <HeaderCell>
font_size: 20 font_size: 21
background_color: [0]*4 background_color: [0]*4 # by default transparent; use row color
canvas.before: # background_color: {_cell_rgba}
Color: # canvas.before:
rgb: {_color} # Color:
BorderImage: # use a fixed size border # rgba: [0.13]*4
pos: self.pos # BorderImage: # use a fixed size border
size: [self.size[0] - {_bs}, self.size[1]] # pos: self.pos
# 0s are because the containing TickerTable already has spacing # size: [self.size[0] - {_bs}, self.size[1]]
border: [0, {_bs} , 0, {_bs}] # # 0s are because the containing TickerTable already has spacing
# # border: [0, {_bs} , 0, {_bs}]
# border: [0, {_bs} , 0, 0]
<TickerTable> <TickerTable>
spacing: '{_bs}dp' spacing: [{_bs}]
row_force_default: True # row_force_default: True
row_default_height: 63 row_default_height: 62
cols: 1 cols: 1
canvas.before:
Color:
# i3 style gray as background
rgba: {_i3_rgba}
# rgba: {_cell_rgba}
Rectangle:
# scale with container self here refers to the widget i.e BoxLayout
pos: self.pos
size: self.size
<BidAskLayout> <BidAskLayout>
spacing: [{_bs}, 0] spacing: [{_bs}, 0]
@ -84,14 +105,26 @@ _kv = (f'''
# minimum_height: 200 # should be pulled from Cell text size # minimum_height: 200 # should be pulled from Cell text size
# minimum_width: 200 # minimum_width: 200
# row_force_default: True # row_force_default: True
# row_default_height: 75 # row_default_height: 61 # determines the header row size
# outline_color: [.7]*4 padding: [0]*4
spacing: [0]
canvas.before:
Color:
# rgba: [0]*4
rgba: {_cell_rgba}
Rectangle:
# self here refers to the widget i.e Row(GridLayout)
pos: self.pos
size: self.size
# part of the `PagerView`
<SearchBar> <SearchBar>
# part of the `PagerView` size_hint: 1, None
size_hint: 1, 0.03 # static size of 51 px
height: 51
font_size: 25 font_size: 25
background_color: [0.13]*3 + [1] background_color: {_i3_rgba}
''') ''')
@ -103,6 +136,7 @@ class HeaderCell(Button):
in `update_quotes()`. in `update_quotes()`.
""" """
table = self.row.table table = self.row.table
# if this is a row header cell then sort by the clicked field
if self.row.is_header: if self.row.is_header:
table.sort_key = self.key table.sort_key = self.key
@ -114,7 +148,7 @@ class HeaderCell(Button):
# outline the header text to indicate it's been the last clicked # outline the header text to indicate it's been the last clicked
self.underline = True self.underline = True
self.bold = True self.bold = True
# mark this cell as the last # mark this cell as the last selected
table.last_clicked_col_cell = self table.last_clicked_col_cell = self
# 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)
@ -122,7 +156,7 @@ class HeaderCell(Button):
# 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:
self.background_color = [0]*4 self.background_color = _black_rgba
else: else:
self.background_color = self.color self.background_color = self.color
@ -137,24 +171,30 @@ class BidAskLayout(StackLayout):
single unit oriented with the last 2 under the first. single unit oriented with the last 2 under the first.
""" """
def __init__(self, values, header=False, **kwargs): def __init__(self, values, header=False, **kwargs):
# uncomment to get vertical stacked bid-ask
# super(BidAskLayout, self).__init__(orientation='bt-lr', **kwargs)
super(BidAskLayout, self).__init__(orientation='lr-tb', **kwargs) super(BidAskLayout, self).__init__(orientation='lr-tb', **kwargs)
assert len(values) == 3, "You can only provide 3 values: last,bid,ask" assert len(values) == 3, "You can only provide 3 values: last,bid,ask"
self._keys2cells = {} self._keys2cells = {}
cell_type = HeaderCell if header else Cell cell_type = HeaderCell if header else Cell
top_size = cell_type().font_size top_size = cell_type().font_size
small_size = top_size - 2 small_size = top_size - 4
top_prop = 0.55 # proportion of size used by top cell top_prop = 0.5 # proportion of size used by top cell
bottom_prop = 1 - top_prop bottom_prop = 1 - top_prop
for (key, size_hint, font_size), value in zip( for (key, size_hint, font_size), value in zip(
[('last', (1, top_prop), top_size), [('last', (1, top_prop), top_size),
('bid', (0.5, bottom_prop), small_size), ('bid', (0.5, bottom_prop), small_size),
('ask', (0.5, bottom_prop), small_size)], ('ask', (0.5, bottom_prop), small_size)],
# uncomment to get vertical stacked bid-ask
# [('last', (top_prop, 1), top_size),
# ('bid', (bottom_prop, 0.5), small_size),
# ('ask', (bottom_prop, 0.5), small_size)],
values values
): ):
cell = cell_type( cell = cell_type(
text=str(value), text=str(value),
size_hint=size_hint, size_hint=size_hint,
width=self.width/2 - 3, # width=self.width/2 - 3,
font_size=font_size font_size=font_size
) )
self._keys2cells[key] = cell self._keys2cells[key] = cell
@ -249,21 +289,32 @@ class Row(GridLayout):
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``.
Return all cells that changed in a ``dict``.
"""
# color changed field values # color changed field values
cells = {}
gray = colorcode('gray')
fgreen = colorcode('forestgreen')
red = colorcode('red2')
for key, val in record.items(): for key, val in record.items():
# logic for cell text coloring: up-green, down-red # logic for cell text coloring: up-green, down-red
if self._last_record[key] < val: if self._last_record[key] < val:
color = colorcode('forestgreen') color = fgreen
elif self._last_record[key] > val: elif self._last_record[key] > val:
color = colorcode('red2') color = red
else: else:
color = colorcode('gray') color = gray
cell = self.get_cell(key) cell = self.get_cell(key)
cell.text = str(displayable[key]) cell.text = str(displayable[key])
cell.color = color cell.color = color
if color != gray:
cells[key] = cell
self._last_record = record self._last_record = record
return cells
class TickerTable(GridLayout): class TickerTable(GridLayout):
@ -277,6 +328,7 @@ class TickerTable(GridLayout):
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
def append_row(self, record, bidasks=None): def append_row(self, record, bidasks=None):
"""Append a `Row` of `Cell` objects to this table. """Append a `Row` of `Cell` objects to this table.
@ -288,7 +340,8 @@ class TickerTable(GridLayout):
return row return row
def render_rows( def render_rows(
self, pairs: {str: (dict, Row)}, sort_key: str = None, row_filter=None, self, pairs: {str: (dict, 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``.
""" """
@ -317,7 +370,7 @@ class TickerTable(GridLayout):
async def update_quotes( async def update_quotes(
nursery: 'Nursery', nursery: trio._core._run.Nursery,
brokermod: ModuleType, brokermod: ModuleType,
widgets: dict, widgets: dict,
agen: AsyncGeneratorType, agen: AsyncGeneratorType,
@ -326,11 +379,19 @@ async def update_quotes(
): ):
"""Process live quotes by updating ticker rows. """Process live quotes by updating ticker rows.
""" """
grid = widgets['grid'] table = widgets['table']
flash_keys = {'low', 'high'}
def color_row(row, data): async def revert_cells_color(cells):
await trio.sleep(0.3)
for cell in cells:
cell.background_color = _black_rgba
def color_row(row, data, cells):
hdrcell = row.get_cell('symbol') hdrcell = row.get_cell('symbol')
chngcell = row.get_cell('%') chngcell = row.get_cell('%')
# determine daily change color
daychange = float(data['%']) daychange = float(data['%'])
if daychange < 0.: if daychange < 0.:
color = colorcode('red2') color = colorcode('red2')
@ -339,49 +400,86 @@ async def update_quotes(
else: else:
color = colorcode('gray') color = colorcode('gray')
# row header and % cell color # update row header and '%' cell text color
chngcell.color = hdrcell.color = color chngcell.color = hdrcell.color = color
# bgcolor = color.copy()
# bgcolor[-1] = 0.25
# chngcell.background_color = bgcolor
# if the cell has been "highlighted" make sure to change its color # if the cell has been "highlighted" make sure to change its color
if hdrcell.background_color != [0]*4: if hdrcell.background_color != [0]*4:
hdrcell.background_color = color hdrcell.background_color = color
# briefly highlight bg of certain cells on each trade execution
unflash = set()
tick_color = None
last = cells.get('last')
if not last:
vol = cells.get('vol')
if not vol:
return # no trade exec took place
# flash gray on volume tick
# (means trade exec @ current price)
last = row.get_cell('last')
tick_color = colorcode('gray')
else:
tick_color = last.color
last.background_color = tick_color
unflash.add(last)
# flash the size cell
size = row.get_cell('size')
size.background_color = tick_color
unflash.add(size)
# flash all other cells
for key in flash_keys:
cell = cells.get(key)
if cell:
cell.background_color = cell.color
unflash.add(cell)
# revert flash state momentarily
nursery.start_soon(revert_cells_color, unflash)
cache = {} cache = {}
grid.quote_cache = cache table.quote_cache = cache
# initial coloring # initial coloring
for sym, quote in first_quotes.items(): for sym, quote in first_quotes.items():
row = grid.symbols2rows[sym] row = table.symbols2rows[sym]
record, displayable = brokermod.format_quote( record, displayable = brokermod.format_quote(
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] = (record, row)
# render all rows once up front # render all rows once up front
grid.render_rows(cache) table.render_rows(cache)
# 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 = brokermod.format_quote(
quote, symbol_data=symbol_data) quote, symbol_data=symbol_data)
row = grid.symbols2rows[symbol] row = table.symbols2rows[symbol]
cache[symbol] = (record, row) cache[symbol] = (record, row)
row.update(record, displayable) cells = row.update(record, displayable)
color_row(row, record) color_row(row, record, cells)
grid.render_rows(cache) table.render_rows(cache)
log.debug("Waiting on quotes") log.debug("Waiting on quotes")
log.warn("`brokerd` connection dropped") log.warn("Data feed connection dropped")
nursery.cancel_scope.cancel() nursery.cancel_scope.cancel()
async def _async_main(name, portal, tickers, brokermod, rate): async def _async_main(
name: str,
portal: tractor._portal.Portal,
tickers: List[str],
brokermod: ModuleType,
rate: int,
# an async generator instance which yields quotes dict packets
quote_gen: AsyncGeneratorType,
) -> None:
'''Launch kivy app + all other related tasks. '''Launch kivy app + all other related tasks.
This is started with cli cmd `piker monitor`. This is started with cli cmd `piker monitor`.
@ -389,18 +487,13 @@ async def _async_main(name, portal, tickers, brokermod, rate):
# subscribe for tickers (this performs a possible filtering # subscribe for tickers (this performs a possible filtering
# where invalid symbols are discarded) # where invalid symbols are discarded)
sd = await portal.run( sd = await portal.run(
"piker.brokers.core", 'symbol_data', "piker.brokers.data", 'symbol_data',
broker=brokermod.name, tickers=tickers)
# an async generator instance
agen = await portal.run(
"piker.brokers.core", 'start_quote_stream',
broker=brokermod.name, tickers=tickers) broker=brokermod.name, tickers=tickers)
async with trio.open_nursery() as nursery: async with trio.open_nursery() as nursery:
# get first quotes response # get first quotes response
log.debug("Waiting on first quote...") log.debug("Waiting on first quote...")
quotes = await agen.__anext__() quotes = await quote_gen.__anext__()
first_quotes = [ first_quotes = [
brokermod.format_quote(quote, symbol_data=sd)[0] brokermod.format_quote(quote, symbol_data=sd)[0]
for quote in quotes.values()] for quote in quotes.values()]
@ -413,7 +506,7 @@ async def _async_main(name, portal, tickers, brokermod, rate):
# build out UI # build out UI
Window.set_title(f"monitor: {name}\t(press ? for help)") Window.set_title(f"monitor: {name}\t(press ? for help)")
Builder.load_string(_kv) Builder.load_string(_kv)
box = BoxLayout(orientation='vertical', padding=5, spacing=5) box = BoxLayout(orientation='vertical', spacing=0)
# define bid-ask "stacked" cells # define bid-ask "stacked" cells
# (TODO: needs some rethinking and renaming for sure) # (TODO: needs some rethinking and renaming for sure)
@ -430,48 +523,48 @@ async def _async_main(name, portal, tickers, brokermod, rate):
) )
box.add_widget(header) box.add_widget(header)
# build grid # build table
grid = TickerTable( table = TickerTable(
cols=1, cols=1,
size_hint=(1, None), size_hint=(1, None),
) )
for ticker_record in first_quotes: for ticker_record in first_quotes:
grid.append_row(ticker_record, bidasks=bidasks) table.append_row(ticker_record, bidasks=bidasks)
# associate the col headers row with the ticker table even though # associate the col headers row with the ticker table even though
# they're technically wrapped separately in containing BoxLayout # they're technically wrapped separately in containing BoxLayout
header.table = grid header.table = table
# mark the initial sorted column header as bold and underlined # mark the initial sorted column header as bold and underlined
sort_cell = header.get_cell(grid.sort_key) sort_cell = header.get_cell(table.sort_key)
sort_cell.bold = sort_cell.underline = True sort_cell.bold = sort_cell.underline = True
grid.last_clicked_col_cell = sort_cell table.last_clicked_col_cell = sort_cell
# set up a pager view for large ticker lists # set up a pager view for large ticker lists
grid.bind(minimum_height=grid.setter('height')) table.bind(minimum_height=table.setter('height'))
pager = PagerView(box, grid, nursery) pager = PagerView(box, table, nursery)
box.add_widget(pager) box.add_widget(pager)
widgets = { widgets = {
# 'anchor': anchor, # 'anchor': anchor,
'root': box, 'root': box,
'grid': grid, 'table': table,
'box': box, 'box': box,
'header': header, 'header': header,
'pager': pager, 'pager': pager,
} }
nursery.start_soon( nursery.start_soon(
update_quotes, nursery, brokermod, widgets, agen, sd, quotes) update_quotes, nursery, brokermod, widgets, quote_gen, sd, quotes)
try: try:
# Trio-kivy entry point. # Trio-kivy entry point.
await async_runTouchApp(widgets['root']) # run kivy await async_runTouchApp(widgets['root']) # run kivy
await agen.aclose() # cancel aysnc gen call await quote_gen.aclose() # cancel aysnc gen call
finally: finally:
# un-subscribe from symbols stream (cancel if brokerd # un-subscribe from symbols stream (cancel if brokerd
# was already torn down - say by SIGINT) # was already torn down - say by SIGINT)
with trio.move_on_after(0.2): with trio.move_on_after(0.2):
await portal.run( await portal.run(
"piker.brokers.core", 'modify_quote_stream', "piker.brokers.data", 'modify_quote_stream',
broker=brokermod.name, tickers=[]) broker=brokermod.name, tickers=[])
# cancel GUI update task # cancel GUI update task