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 annotationskivy_mainline_and_py3.8
parent
0f3faec35d
commit
a7f3008d34
|
@ -7,8 +7,10 @@ Launch with ``piker monitor <watchlist name>``.
|
|||
"""
|
||||
from itertools import chain
|
||||
from types import ModuleType, AsyncGeneratorType
|
||||
from typing import List
|
||||
|
||||
import trio
|
||||
import tractor
|
||||
from kivy.uix.boxlayout import BoxLayout
|
||||
from kivy.uix.gridlayout import GridLayout
|
||||
from kivy.uix.stacklayout import StackLayout
|
||||
|
@ -41,41 +43,60 @@ def colorcode(name):
|
|||
return _colors[name if name else 'gray']
|
||||
|
||||
|
||||
_bs = 4 # border size
|
||||
_color = [0.13]*3 # nice shade of gray
|
||||
_bs = 0.75 # border size
|
||||
# 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'''
|
||||
#:kivy 1.10.0
|
||||
|
||||
<Cell>
|
||||
font_size: 20
|
||||
# text_size: self.size
|
||||
size: self.texture_size
|
||||
color: {colorcode('gray')}
|
||||
font_color: {colorcode('gray')}
|
||||
font_name: 'Roboto-Regular'
|
||||
background_color: [0.13]*3 + [1]
|
||||
background_normal: ''
|
||||
valign: 'middle'
|
||||
font_size: 21
|
||||
# make text wrap to botom
|
||||
text_size: self.size
|
||||
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>
|
||||
font_size: 20
|
||||
background_color: [0]*4
|
||||
canvas.before:
|
||||
Color:
|
||||
rgb: {_color}
|
||||
BorderImage: # use a fixed size border
|
||||
pos: self.pos
|
||||
size: [self.size[0] - {_bs}, self.size[1]]
|
||||
# 0s are because the containing TickerTable already has spacing
|
||||
border: [0, {_bs} , 0, {_bs}]
|
||||
font_size: 21
|
||||
background_color: [0]*4 # by default transparent; use row color
|
||||
# background_color: {_cell_rgba}
|
||||
# canvas.before:
|
||||
# Color:
|
||||
# rgba: [0.13]*4
|
||||
# BorderImage: # use a fixed size border
|
||||
# pos: self.pos
|
||||
# size: [self.size[0] - {_bs}, self.size[1]]
|
||||
# # 0s are because the containing TickerTable already has spacing
|
||||
# # border: [0, {_bs} , 0, {_bs}]
|
||||
# border: [0, {_bs} , 0, 0]
|
||||
|
||||
<TickerTable>
|
||||
spacing: '{_bs}dp'
|
||||
row_force_default: True
|
||||
row_default_height: 63
|
||||
spacing: [{_bs}]
|
||||
# row_force_default: True
|
||||
row_default_height: 62
|
||||
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>
|
||||
spacing: [{_bs}, 0]
|
||||
|
@ -84,14 +105,26 @@ _kv = (f'''
|
|||
# minimum_height: 200 # should be pulled from Cell text size
|
||||
# minimum_width: 200
|
||||
# row_force_default: True
|
||||
# row_default_height: 75
|
||||
# outline_color: [.7]*4
|
||||
# row_default_height: 61 # determines the header row size
|
||||
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>
|
||||
# part of the `PagerView`
|
||||
size_hint: 1, 0.03
|
||||
size_hint: 1, None
|
||||
# static size of 51 px
|
||||
height: 51
|
||||
font_size: 25
|
||||
background_color: [0.13]*3 + [1]
|
||||
background_color: {_i3_rgba}
|
||||
''')
|
||||
|
||||
|
||||
|
@ -103,6 +136,7 @@ class HeaderCell(Button):
|
|||
in `update_quotes()`.
|
||||
"""
|
||||
table = self.row.table
|
||||
# if this is a row header cell then sort by the clicked field
|
||||
if self.row.is_header:
|
||||
table.sort_key = self.key
|
||||
|
||||
|
@ -114,7 +148,7 @@ class HeaderCell(Button):
|
|||
# outline the header text to indicate it's been the last clicked
|
||||
self.underline = True
|
||||
self.bold = True
|
||||
# mark this cell as the last
|
||||
# mark this cell as the last selected
|
||||
table.last_clicked_col_cell = self
|
||||
# sort and render the rows immediately
|
||||
self.row.table.render_rows(table.quote_cache)
|
||||
|
@ -122,7 +156,7 @@ class HeaderCell(Button):
|
|||
# allow highlighting of row headers for tracking
|
||||
elif self.is_header:
|
||||
if self.background_color == self.color:
|
||||
self.background_color = [0]*4
|
||||
self.background_color = _black_rgba
|
||||
else:
|
||||
self.background_color = self.color
|
||||
|
||||
|
@ -137,24 +171,30 @@ class BidAskLayout(StackLayout):
|
|||
single unit oriented with the last 2 under the first.
|
||||
"""
|
||||
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)
|
||||
assert len(values) == 3, "You can only provide 3 values: last,bid,ask"
|
||||
self._keys2cells = {}
|
||||
cell_type = HeaderCell if header else Cell
|
||||
top_size = cell_type().font_size
|
||||
small_size = top_size - 2
|
||||
top_prop = 0.55 # proportion of size used by top cell
|
||||
small_size = top_size - 4
|
||||
top_prop = 0.5 # proportion of size used by top cell
|
||||
bottom_prop = 1 - top_prop
|
||||
for (key, size_hint, font_size), value in zip(
|
||||
[('last', (1, top_prop), top_size),
|
||||
('bid', (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
|
||||
):
|
||||
cell = cell_type(
|
||||
text=str(value),
|
||||
size_hint=size_hint,
|
||||
width=self.width/2 - 3,
|
||||
# width=self.width/2 - 3,
|
||||
font_size=font_size
|
||||
)
|
||||
self._keys2cells[key] = cell
|
||||
|
@ -249,21 +289,32 @@ class Row(GridLayout):
|
|||
return cell
|
||||
|
||||
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
|
||||
cells = {}
|
||||
gray = colorcode('gray')
|
||||
fgreen = colorcode('forestgreen')
|
||||
red = colorcode('red2')
|
||||
for key, val in record.items():
|
||||
# logic for cell text coloring: up-green, down-red
|
||||
if self._last_record[key] < val:
|
||||
color = colorcode('forestgreen')
|
||||
color = fgreen
|
||||
elif self._last_record[key] > val:
|
||||
color = colorcode('red2')
|
||||
color = red
|
||||
else:
|
||||
color = colorcode('gray')
|
||||
color = gray
|
||||
|
||||
cell = self.get_cell(key)
|
||||
cell.text = str(displayable[key])
|
||||
cell.color = color
|
||||
if color != gray:
|
||||
cells[key] = cell
|
||||
|
||||
self._last_record = record
|
||||
return cells
|
||||
|
||||
|
||||
class TickerTable(GridLayout):
|
||||
|
@ -277,6 +328,7 @@ class TickerTable(GridLayout):
|
|||
self.row_filter = lambda item: item
|
||||
# for tracking last clicked column header cell
|
||||
self.last_clicked_col_cell = None
|
||||
self._last_row_toggle = 0
|
||||
|
||||
def append_row(self, record, bidasks=None):
|
||||
"""Append a `Row` of `Cell` objects to this table.
|
||||
|
@ -288,7 +340,8 @@ class TickerTable(GridLayout):
|
|||
return row
|
||||
|
||||
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``.
|
||||
"""
|
||||
|
@ -317,7 +370,7 @@ class TickerTable(GridLayout):
|
|||
|
||||
|
||||
async def update_quotes(
|
||||
nursery: 'Nursery',
|
||||
nursery: trio._core._run.Nursery,
|
||||
brokermod: ModuleType,
|
||||
widgets: dict,
|
||||
agen: AsyncGeneratorType,
|
||||
|
@ -326,11 +379,19 @@ async def update_quotes(
|
|||
):
|
||||
"""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')
|
||||
chngcell = row.get_cell('%')
|
||||
|
||||
# determine daily change color
|
||||
daychange = float(data['%'])
|
||||
if daychange < 0.:
|
||||
color = colorcode('red2')
|
||||
|
@ -339,49 +400,86 @@ async def update_quotes(
|
|||
else:
|
||||
color = colorcode('gray')
|
||||
|
||||
# row header and % cell color
|
||||
# update row header and '%' cell text 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 hdrcell.background_color != [0]*4:
|
||||
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 = {}
|
||||
grid.quote_cache = cache
|
||||
table.quote_cache = cache
|
||||
|
||||
# initial coloring
|
||||
for sym, quote in first_quotes.items():
|
||||
row = grid.symbols2rows[sym]
|
||||
row = table.symbols2rows[sym]
|
||||
record, displayable = brokermod.format_quote(
|
||||
quote, symbol_data=symbol_data)
|
||||
row.update(record, displayable)
|
||||
color_row(row, record)
|
||||
color_row(row, record, {})
|
||||
cache[sym] = (record, row)
|
||||
|
||||
# render all rows once up front
|
||||
grid.render_rows(cache)
|
||||
table.render_rows(cache)
|
||||
|
||||
# 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(
|
||||
quote, symbol_data=symbol_data)
|
||||
row = grid.symbols2rows[symbol]
|
||||
row = table.symbols2rows[symbol]
|
||||
cache[symbol] = (record, row)
|
||||
row.update(record, displayable)
|
||||
color_row(row, record)
|
||||
cells = row.update(record, displayable)
|
||||
color_row(row, record, cells)
|
||||
|
||||
grid.render_rows(cache)
|
||||
table.render_rows(cache)
|
||||
log.debug("Waiting on quotes")
|
||||
|
||||
log.warn("`brokerd` connection dropped")
|
||||
log.warn("Data feed connection dropped")
|
||||
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.
|
||||
|
||||
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
|
||||
# where invalid symbols are discarded)
|
||||
sd = await portal.run(
|
||||
"piker.brokers.core", 'symbol_data',
|
||||
broker=brokermod.name, tickers=tickers)
|
||||
|
||||
# an async generator instance
|
||||
agen = await portal.run(
|
||||
"piker.brokers.core", 'start_quote_stream',
|
||||
"piker.brokers.data", 'symbol_data',
|
||||
broker=brokermod.name, tickers=tickers)
|
||||
|
||||
async with trio.open_nursery() as nursery:
|
||||
# get first quotes response
|
||||
log.debug("Waiting on first quote...")
|
||||
quotes = await agen.__anext__()
|
||||
quotes = await quote_gen.__anext__()
|
||||
first_quotes = [
|
||||
brokermod.format_quote(quote, symbol_data=sd)[0]
|
||||
for quote in quotes.values()]
|
||||
|
@ -413,7 +506,7 @@ async def _async_main(name, portal, tickers, brokermod, rate):
|
|||
# build out UI
|
||||
Window.set_title(f"monitor: {name}\t(press ? for help)")
|
||||
Builder.load_string(_kv)
|
||||
box = BoxLayout(orientation='vertical', padding=5, spacing=5)
|
||||
box = BoxLayout(orientation='vertical', spacing=0)
|
||||
|
||||
# define bid-ask "stacked" cells
|
||||
# (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)
|
||||
|
||||
# build grid
|
||||
grid = TickerTable(
|
||||
# build table
|
||||
table = TickerTable(
|
||||
cols=1,
|
||||
size_hint=(1, None),
|
||||
)
|
||||
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
|
||||
# they're technically wrapped separately in containing BoxLayout
|
||||
header.table = grid
|
||||
header.table = table
|
||||
|
||||
# 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
|
||||
grid.last_clicked_col_cell = sort_cell
|
||||
table.last_clicked_col_cell = sort_cell
|
||||
|
||||
# set up a pager view for large ticker lists
|
||||
grid.bind(minimum_height=grid.setter('height'))
|
||||
pager = PagerView(box, grid, nursery)
|
||||
table.bind(minimum_height=table.setter('height'))
|
||||
pager = PagerView(box, table, nursery)
|
||||
box.add_widget(pager)
|
||||
|
||||
widgets = {
|
||||
# 'anchor': anchor,
|
||||
'root': box,
|
||||
'grid': grid,
|
||||
'table': table,
|
||||
'box': box,
|
||||
'header': header,
|
||||
'pager': pager,
|
||||
}
|
||||
nursery.start_soon(
|
||||
update_quotes, nursery, brokermod, widgets, agen, sd, quotes)
|
||||
update_quotes, nursery, brokermod, widgets, quote_gen, sd, quotes)
|
||||
|
||||
try:
|
||||
# Trio-kivy entry point.
|
||||
await async_runTouchApp(widgets['root']) # run kivy
|
||||
await agen.aclose() # cancel aysnc gen call
|
||||
await quote_gen.aclose() # cancel aysnc gen call
|
||||
finally:
|
||||
# un-subscribe from symbols stream (cancel if brokerd
|
||||
# was already torn down - say by SIGINT)
|
||||
with trio.move_on_after(0.2):
|
||||
await portal.run(
|
||||
"piker.brokers.core", 'modify_quote_stream',
|
||||
"piker.brokers.data", 'modify_quote_stream',
|
||||
broker=brokermod.name, tickers=[])
|
||||
|
||||
# cancel GUI update task
|
||||
|
|
Loading…
Reference in New Issue