Merge pull request #7 from pikers/wl_fine_tuning

Watchlist fine tuning
kivy_mainline_and_py3.8
goodboy 2018-02-14 16:41:03 -05:00 committed by GitHub
commit 0faec1537e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 204 additions and 109 deletions

View File

@ -301,7 +301,7 @@ async def poll_tickers(
client: Client, tickers: [str], client: Client, tickers: [str],
q: trio.Queue, q: trio.Queue,
rate: int = 5, # 200ms delay between quotes rate: int = 5, # 200ms delay between quotes
time_cached: bool = True, # only deliver "new" quotes to the queue diff_cached: bool = True, # only deliver "new" quotes to the queue
) -> None: ) -> None:
"""Stream quotes for a sequence of tickers at the given ``rate`` """Stream quotes for a sequence of tickers at the given ``rate``
per second. per second.
@ -323,13 +323,14 @@ async def poll_tickers(
if quote['delay'] > 0: if quote['delay'] > 0:
log.warning(f"Delayed quote:\n{quote}") log.warning(f"Delayed quote:\n{quote}")
if time_cached: # if cache is enabled then only deliver "new" changes if diff_cached:
# if cache is enabled then only deliver "new" changes
symbol = quote['symbol'] symbol = quote['symbol']
last = _cache.setdefault(symbol, {}) last = _cache.setdefault(symbol, {})
timekey = 'lastTradeTime' new = set(quote.items()) - set(last.items())
if quote[timekey] != last.get(timekey): if new:
log.info( log.info(
f"New quote {quote['symbol']} @ {quote[timekey]}") f"New quote {quote['symbol']}:\n{new}")
_cache[symbol] = quote _cache[symbol] = quote
payload.append(quote) payload.append(quote)
else: else:
@ -342,7 +343,8 @@ async def poll_tickers(
delay = sleeptime - proc_time delay = sleeptime - proc_time
if delay <= 0: if delay <= 0:
log.warn(f"Took {proc_time} seconds for processing quotes?") log.warn(f"Took {proc_time} seconds for processing quotes?")
await trio.sleep(delay) else:
await trio.sleep(delay)
async def api(methname: str, **kwargs) -> dict: async def api(methname: str, **kwargs) -> dict:

View File

@ -9,12 +9,18 @@ def humanize(number):
"""Convert large numbers to something with at most 3 digits and """Convert large numbers to something with at most 3 digits and
a letter suffix (eg. k: thousand, M: million, B: billion). a letter suffix (eg. k: thousand, M: million, B: billion).
""" """
if number <= 0: try:
float(number)
except ValueError:
return 0
if not number or number <= 0:
return number return number
mag2suffix = {3: 'k', 6: 'M', 9: 'B'} mag2suffix = {3: 'k', 6: 'M', 9: 'B'}
mag = math.floor(math.log(number, 10)) mag = math.floor(math.log(number, 10))
if mag < 3:
return number
maxmag = max(itertools.takewhile(lambda key: mag >= key, mag2suffix)) maxmag = max(itertools.takewhile(lambda key: mag >= key, mag2suffix))
return "{:.3f}{}".format(number/10**maxmag, mag2suffix[maxmag]) return "{:.2f}{}".format(number/10**maxmag, mag2suffix[maxmag])
def percent_change(init, new): def percent_change(init, new):

View File

@ -5,11 +5,14 @@ Launch with ``piker watch <watchlist name>``.
(Currently there's a bunch of QT specific stuff in here) (Currently there's a bunch of QT specific stuff in here)
""" """
from itertools import chain
from functools import partial
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.stacklayout import StackLayout
from kivy.uix.button import Button from kivy.uix.button import Button
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
from kivy import utils from kivy import utils
@ -38,79 +41,75 @@ def colorcode(name):
return _colors[name if name else 'gray'] return _colors[name if name else 'gray']
_bs = 3 # border size
_color = [0.13]*3 # nice shade of gray
_kv = (f''' _kv = (f'''
#:kivy 1.10.0 #:kivy 1.10.0
<Cell> <Cell>
font_size: 18
text_size: self.size text_size: self.size
size: self.texture_size size: self.texture_size
font_size: '20'
color: {colorcode('gray')} color: {colorcode('gray')}
# size_hint_y: None
font_color: {colorcode('gray')} font_color: {colorcode('gray')}
font_name: 'Roboto-Regular' font_name: 'Roboto-Regular'
# height: 50 background_color: [0.13]*3 + [1]
# width: 50 background_normal: ''
background_color: [0]*4
valign: 'middle' valign: 'middle'
halign: 'center' halign: 'center'
outline_color: [0.1]*4 outline_color: [0.1]*4
canvas.before:
Color:
rgb: [0.08]*4
Rectangle:
pos: self.pos
size: self.size
<HeaderCell> <HeaderCell>
# bold: True font_size: 20
font_size: '20'
background_color: [0]*4 background_color: [0]*4
canvas.before: canvas.before:
Color: Color:
rgb: [0.13]*4 rgb: {_color}
Rectangle: BorderImage: # use a fixed size border
pos: self.pos pos: self.pos
size: self.size size: [self.size[0] - {_bs}, self.size[1]]
# RoundedRectangle: # 0s are because the containing TickerTable already has spacing
# pos: self.pos border: [0, {_bs} , 0, {_bs}]
# size: self.size
# radius: [8,]
<TickerTable> <TickerTable>
spacing: '5dp' spacing: '{_bs}dp'
row_force_default: True row_force_default: True
row_default_height: 75 row_default_height: 75
cols: 1 cols: 1
<BidAskLayout>
spacing: [{_bs}, 0]
<Row> <Row>
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: 75
outline_color: [.7]*4 outline_color: [.7]*4
''') ''')
# Questrade key conversion # Questrade key conversion / column order
_qt_keys = { _qt_keys = {
# 'symbol': 'symbol', # done manually in qtconvert 'symbol': 'symbol', # done manually in qtconvert
'%': '%',
'lastTradePrice': 'last', 'lastTradePrice': 'last',
'askPrice': 'ask', 'askPrice': 'ask',
'bidPrice': 'bid', 'bidPrice': 'bid',
'lastTradeSize': 'last size', 'lastTradeSize': 'size',
'bidSize': 'bid size', 'bidSize': 'bsize',
'askSize': 'ask size', 'askSize': 'asize',
'VWAP': ('VWAP', partial(round, ndigits=3)),
'volume': ('vol', humanize), 'volume': ('vol', humanize),
'VWAP': ('VWAP', "{:.3f}".format), 'mktcap': ('mktcap', humanize),
'high52w': 'high52w', 'openPrice': 'open',
'lowPrice': 'low',
'highPrice': 'high', 'highPrice': 'high',
'low52w': 'low52w',
'high52w': 'high52w',
# "lastTradePriceTrHrs": 7.99, # "lastTradePriceTrHrs": 7.99,
# "lastTradeTick": "Equal", # "lastTradeTick": "Equal",
# "lastTradeTime": "2018-01-30T18:28:23.434000-05:00", # "lastTradeTime": "2018-01-30T18:28:23.434000-05:00",
# 'low52w': 'low52w',
'lowPrice': 'low day',
'openPrice': 'open',
# "symbolId": 3575753, # "symbolId": 3575753,
# "tier": "", # "tier": "",
# 'isHalted': 'halted', # 'isHalted': 'halted',
@ -128,19 +127,25 @@ def qtconvert(
and the second is the same but with all values converted to a and the second is the same but with all values converted to a
"display-friendly" string format. "display-friendly" string format.
""" """
last = quote['lastTradePrice']
symbol = quote['symbol']
if symbol_data: # we can only compute % change from symbols data if symbol_data: # we can only compute % change from symbols data
previous = symbol_data[quote['symbol']]['prevDayClosePrice'] previous = symbol_data[symbol]['prevDayClosePrice']
change = percent_change(previous, quote['lastTradePrice']) change = percent_change(previous, last)
share_count = symbol_data[symbol].get('outstandingShares', None)
mktcap = share_count * last if share_count else 'NA'
else: else:
change = 0 change = 0
new = { computed = {
'symbol': quote['symbol'], 'symbol': quote['symbol'],
'%': round(change, 3) '%': round(change, 3),
'mktcap': mktcap,
} }
displayable = new.copy() new = {}
displayable = {}
for key, new_key in keymap.items(): for key, new_key in keymap.items():
display_value = value = quote[key] display_value = value = quote.get(key) or computed.get(key)
# API servers can return `None` vals when markets are closed (weekend) # API servers can return `None` vals when markets are closed (weekend)
value = 0 if value is None else value value = 0 if value is None else value
@ -162,10 +167,11 @@ class HeaderCell(Button):
def on_press(self, value=None): def on_press(self, value=None):
# clicking on a col header indicates to rows by this column # clicking on a col header indicates to rows by this column
# in `update_quotes()` # in `update_quotes()`
table = self.row.table
if self.row.is_header: if self.row.is_header:
self.row.table.sort_key = self.key table.sort_key = self.key
last = self.row.table.last_clicked_col_cell last = table.last_clicked_col_cell
if last and last is not self: if last and last is not self:
last.underline = False last.underline = False
last.bold = False last.bold = False
@ -174,7 +180,9 @@ class HeaderCell(Button):
self.underline = True self.underline = True
self.bold = True self.bold = True
# mark this cell as the last # mark this cell as the last
self.row.table.last_clicked_col_cell = self table.last_clicked_col_cell = self
# sort and render the rows immediately
self.row.table.render_rows(table.quote_cache)
# allow highlighting of row headers for tracking # allow highlighting of row headers for tracking
elif self.is_header: elif self.is_header:
@ -184,11 +192,62 @@ class HeaderCell(Button):
self.background_color = self.color self.background_color = self.color
class Cell(Label): class Cell(Button):
"""Data cell label. """Data cell.
""" """
class BidAskLayout(StackLayout):
"""Cell which houses three buttons containing a last, bid, and ask in a
single unit oriented with the last 2 under the first.
"""
def __init__(self, values, header=False, **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 - 5
top_prop = 0.7
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)],
values
):
cell = cell_type(
text=str(value),
size_hint=size_hint,
width=self.width/2 - 3,
font_size=font_size
)
self._keys2cells[key] = cell
cell.key = value
cell.is_header = header
setattr(self, key, cell)
self.add_widget(cell)
# should be assigned by referrer
self.row = None
def get_cell(self, key):
return self._keys2cells[key]
@property
def row(self):
return self.row
@row.setter
def row(self, row):
for cell in self.cells:
cell.row = row
@property
def cells(self):
return [self.last, self.bid, self.ask]
class Row(GridLayout): class Row(GridLayout):
"""A grid for displaying a row of ticker quote data. """A grid for displaying a row of ticker quote data.
@ -205,12 +264,40 @@ class Row(GridLayout):
self.table = table self.table = table
self.is_header = is_header_row self.is_header = is_header_row
# create `BidAskCells` first
bidasks = {
'last': ['bid', 'ask'],
'size': ['bsize', 'asize']
}
ba_cells = {}
layouts = {}
for key, children in bidasks.items():
layout = BidAskLayout(
[record[key]] + [record[child] for child in children],
header=is_header_row
)
layout.row = self
layouts[key] = layout
for i, child in enumerate([key] + children):
ba_cells[child] = layout.cells[i]
children_flat = list(chain.from_iterable(bidasks.values()))
self._cell_widgets.update(ba_cells)
# 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)
self._cell_widgets[key] = cell # handle bidask cells
cell.key = key if key in layouts:
self.add_widget(layouts[key])
elif key in children_flat:
# these cells have already been added to the `BidAskLayout`
continue
else:
cell = self._append_cell(val, header=header)
cell.key = key
self._cell_widgets[key] = cell
def get_cell(self, key): def get_cell(self, key):
return self._cell_widgets[key] return self._cell_widgets[key]
@ -224,22 +311,35 @@ class Row(GridLayout):
cell = celltype(text=str(text)) cell = celltype(text=str(text))
cell.is_header = header cell.is_header = header
cell.row = self cell.row = self
# don't bold the header row
if header and self.is_header:
cell.bold = False
self.add_widget(cell) self.add_widget(cell)
return cell return cell
def update(self, record, displayable):
# color changed field values
for key, val in record.items():
# logic for cell text coloring: up-green, down-red
if self._last_record[key] < val:
color = colorcode('forestgreen')
elif self._last_record[key] > val:
color = colorcode('red2')
else:
color = colorcode('gray')
cell = self.get_cell(key)
cell.text = str(displayable[key])
cell.color = color
self._last_record = record
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='%', **kwargs): def __init__(self, sort_key='%', quote_cache={}, **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
# 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
@ -252,6 +352,16 @@ class TickerTable(GridLayout):
self.add_widget(row) self.add_widget(row)
return row return row
def render_rows(self, pairs: (dict, Row), sort_key: str = None):
"""Sort and render all rows on the ticker grid from ``pairs``.
"""
self.clear_widgets()
sort_key = sort_key or self.sort_key
for data, row in reversed(
sorted(pairs.values(), key=lambda item: item[0][sort_key])
):
self.add_widget(row) # row append
def header_row(headers, **kwargs): 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.
@ -281,8 +391,8 @@ async def update_quotes(
grid = widgets['grid'] grid = widgets['grid']
def color_row(row, data): def color_row(row, data):
hdrcell = row._cell_widgets['symbol'] hdrcell = row.get_cell('symbol')
chngcell = row._cell_widgets['%'] chngcell = row.get_cell('%')
daychange = float(data['%']) daychange = float(data['%'])
if daychange < 0.: if daychange < 0.:
color = colorcode('red2') color = colorcode('red2')
@ -295,61 +405,41 @@ async def update_quotes(
# 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
cache = {} cache = {}
grid.quote_cache = cache
# initial coloring # initial coloring
for quote in first_quotes: for quote in first_quotes:
sym = quote['symbol'] sym = quote['symbol']
row = grid.symbols2rows[sym] row = grid.symbols2rows[sym]
data, _ = qtconvert(quote, symbol_data=symbol_data) record, displayable = qtconvert(quote, symbol_data=symbol_data)
color_row(row, data) row.update(record, displayable)
cache[sym] = (data, row) color_row(row, record)
cache[sym] = (record, row)
# the core cell update loop grid.render_rows(cache)
# core cell update loop
while True: while True:
log.debug("Waiting on quotes") log.debug("Waiting on quotes")
quotes = await queue.get() quotes = await queue.get() # new quotes data only
for quote in quotes: for quote in quotes:
data, displayable = qtconvert(quote, symbol_data=symbol_data) record, displayable = qtconvert(quote, symbol_data=symbol_data)
row = grid.symbols2rows[data['symbol']] row = grid.symbols2rows[record['symbol']]
# only updates newly timestamped quotes cache[record['symbol']] = (record, row)
cache[data['symbol']] = (data, row) row.update(record, displayable)
color_row(row, record)
# color changed field values grid.render_rows(cache)
for key, val in data.items():
# logic for cell text coloring: up-green, down-red
if row._last_record[key] < val:
color = colorcode('green')
elif row._last_record[key] > val:
color = colorcode('red')
else:
color = colorcode('gray')
cell = row._cell_widgets[key]
cell.text = str(displayable[key])
cell.color = color
color_row(row, data)
row._last_record = data
# sort rows by daily % change since open
grid.clear_widgets()
sort_key = grid.sort_key
for data, row in reversed(
sorted(cache.values(), key=lambda item: item[0][sort_key])
):
grid.add_widget(row) # row append
async def run_kivy(root, nursery): async def run_kivy(root, nursery):
'''Trio-kivy entry point. '''Trio-kivy entry point.
''' '''
# run kivy await async_runTouchApp(root) # run kivy
await async_runTouchApp(root) nursery.cancel_scope.cancel() # cancel all other tasks that may be running
# now cancel all the other tasks that may be running
nursery.cancel_scope.cancel()
async def _async_main(name, watchlists, brokermod): async def _async_main(name, watchlists, brokermod):
@ -380,19 +470,16 @@ async def _async_main(name, watchlists, brokermod):
# build out UI # build out UI
Builder.load_string(_kv) Builder.load_string(_kv)
root = BoxLayout(orientation='vertical', padding=5, spacing=-20) root = BoxLayout(orientation='vertical', padding=5, spacing=5)
header = header_row( header = header_row(
first_quotes[0].keys(), first_quotes[0].keys(),
size_hint=(1, None), size_hint=(1, None),
# put black lines between cells on the header row
spacing='3dp',
) )
root.add_widget(header) root.add_widget(header)
grid = ticker_table( grid = ticker_table(
first_quotes, first_quotes,
size_hint=(1, None), size_hint=(1, None),
) )
# 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 = grid
@ -401,6 +488,7 @@ async def _async_main(name, watchlists, brokermod):
sort_cell.bold = sort_cell.underline = True sort_cell.bold = sort_cell.underline = True
grid.last_clicked_col_cell = sort_cell grid.last_clicked_col_cell = sort_cell
# set up a scroll view for large ticker lists
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)
@ -412,6 +500,5 @@ async def _async_main(name, watchlists, brokermod):
'header': header, 'header': header,
'scroll': scroll, 'scroll': scroll,
} }
nursery.start_soon(run_kivy, widgets['root'], nursery) nursery.start_soon(run_kivy, widgets['root'], nursery)
nursery.start_soon(update_quotes, widgets, queue, sd, pkts) nursery.start_soon(update_quotes, widgets, queue, sd, pkts)