415 lines
12 KiB
Python
415 lines
12 KiB
Python
"""
|
|
A real-time, sorted watchlist.
|
|
|
|
Launch with ``piker watch <watchlist name>``.
|
|
|
|
(Currently there's a bunch of QT specific stuff in here)
|
|
"""
|
|
import trio
|
|
from kivy.uix.boxlayout import BoxLayout
|
|
from kivy.uix.gridlayout import GridLayout
|
|
from kivy.uix.button import Button
|
|
from kivy.uix.label import Label
|
|
from kivy.uix.scrollview import ScrollView
|
|
from kivy.lang import Builder
|
|
from kivy import utils
|
|
from kivy.app import async_runTouchApp
|
|
|
|
|
|
from ..calc import humanize, percent_change
|
|
from ..log import get_logger
|
|
log = get_logger('watchlist')
|
|
|
|
|
|
_colors2hexs = {
|
|
'darkgray': 'a9a9a9',
|
|
'gray': '808080',
|
|
'green': '008000',
|
|
'forestgreen': '228b22',
|
|
'red2': 'ff3333',
|
|
'red': 'ff0000',
|
|
'firebrick': 'b22222',
|
|
}
|
|
|
|
_colors = {key: utils.rgba(val) for key, val in _colors2hexs.items()}
|
|
|
|
|
|
def colorcode(name):
|
|
return _colors[name if name else 'gray']
|
|
|
|
|
|
_kv = (f'''
|
|
#:kivy 1.10.0
|
|
|
|
<Cell>
|
|
text_size: self.size
|
|
size: self.texture_size
|
|
font_size: '20'
|
|
# size_hint_y: None
|
|
font_color: {colorcode('gray')}
|
|
font_name: 'Roboto-Regular'
|
|
# height: 50
|
|
# width: 50
|
|
background_color: [0]*4
|
|
valign: 'middle'
|
|
halign: 'center'
|
|
outline_color: [0.1]*4
|
|
canvas.before:
|
|
Color:
|
|
rgb: [0.08]*4
|
|
Rectangle:
|
|
pos: self.pos
|
|
size: self.size
|
|
|
|
<HeaderCell>
|
|
bold: True
|
|
font_size: '20'
|
|
background_color: 0,0,0,0
|
|
canvas.before:
|
|
Color:
|
|
rgb: [0.13]*4
|
|
Rectangle:
|
|
pos: self.pos
|
|
size: self.size
|
|
# RoundedRectangle:
|
|
# pos: self.pos
|
|
# size: self.size
|
|
# radius: [8,]
|
|
|
|
<TickerTable>
|
|
spacing: '5dp'
|
|
row_force_default: True
|
|
row_default_height: 75
|
|
cols: 1
|
|
|
|
<Row>
|
|
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
|
|
''')
|
|
|
|
|
|
# Questrade key conversion
|
|
_qt_keys = {
|
|
# 'symbol': 'symbol', # done manually in qtconvert
|
|
'lastTradePrice': 'last',
|
|
'askPrice': 'ask',
|
|
'bidPrice': 'bid',
|
|
'lastTradeSize': 'last size',
|
|
'bidSize': 'bid size',
|
|
'askSize': 'ask size',
|
|
'volume': ('vol', humanize),
|
|
'VWAP': ('VWAP', "{:.3f}".format),
|
|
'high52w': 'high52w',
|
|
'highPrice': 'high',
|
|
# "lastTradePriceTrHrs": 7.99,
|
|
# "lastTradeTick": "Equal",
|
|
# "lastTradeTime": "2018-01-30T18:28:23.434000-05:00",
|
|
# 'low52w': 'low52w',
|
|
'lowPrice': 'low day',
|
|
'openPrice': 'open',
|
|
# "symbolId": 3575753,
|
|
# "tier": "",
|
|
# 'isHalted': 'halted',
|
|
# 'delay': 'delay', # as subscript 'p'
|
|
}
|
|
|
|
|
|
def qtconvert(
|
|
quote: dict, keymap: dict = _qt_keys, symbol_data: dict = None
|
|
) -> (dict, dict):
|
|
"""Remap a list of quote dicts ``quotes`` using the mapping of old keys
|
|
-> new keys ``keymap``.
|
|
|
|
Returns 2 dicts: first is the original values mapped by new keys,
|
|
and the second is the same but with all values converted to a
|
|
"display-friendly" string format.
|
|
"""
|
|
if symbol_data: # we can only compute % change from symbols data
|
|
previous = symbol_data[quote['symbol']]['prevDayClosePrice']
|
|
change = percent_change(previous, quote['lastTradePrice'])
|
|
else:
|
|
change = 0
|
|
new = {
|
|
'symbol': quote['symbol'],
|
|
'%': round(change, 3)
|
|
}
|
|
displayable = new.copy()
|
|
|
|
for key, new_key in keymap.items():
|
|
display_value = value = quote[key]
|
|
|
|
# API servers can return `None` vals when markets are closed (weekend)
|
|
value = 0 if value is None else value
|
|
|
|
# convert values to a displayble format
|
|
if isinstance(new_key, tuple):
|
|
new_key, func = new_key
|
|
display_value = func(value)
|
|
|
|
new[new_key] = value
|
|
displayable[new_key] = display_value
|
|
|
|
return new, displayable
|
|
|
|
|
|
class HeaderCell(Button):
|
|
"""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 of row headers for tracking
|
|
elif self.is_header:
|
|
if self.background_color == self.color:
|
|
self.background_color = [0]*4
|
|
else:
|
|
self.background_color = self.color
|
|
|
|
|
|
class Cell(Label):
|
|
"""Data cell label.
|
|
"""
|
|
|
|
|
|
class Row(GridLayout):
|
|
"""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__(
|
|
self, record, headers=(), table=None, is_header_row=False,
|
|
**kwargs
|
|
):
|
|
super(Row, self).__init__(cols=len(record), **kwargs)
|
|
self._cell_widgets = {}
|
|
self._last_record = record
|
|
self.table = table
|
|
self.is_header = is_header_row
|
|
|
|
# build out row using Cell labels
|
|
for (key, val) in record.items():
|
|
header = key in headers
|
|
cell = self._append_cell(val, header=header)
|
|
self._cell_widgets[key] = cell
|
|
cell.key = key
|
|
|
|
def get_cell(self, key):
|
|
return self._cell_widgets[key]
|
|
|
|
def _append_cell(self, text, header=False):
|
|
if not len(self._cell_widgets) < self.cols:
|
|
raise ValueError(f"Can not append more then {self.cols} cells")
|
|
|
|
# header cells just have a different colour
|
|
celltype = HeaderCell if header else Cell
|
|
cell = celltype(text=str(text))
|
|
cell.is_header = header
|
|
cell.row = self
|
|
|
|
# don't bold the header row
|
|
if header and self.is_header:
|
|
cell.bold = False
|
|
|
|
self.add_widget(cell)
|
|
return cell
|
|
|
|
|
|
class TickerTable(GridLayout):
|
|
"""A grid for displaying ticker quote records as a table.
|
|
"""
|
|
def __init__(self, sort_key='%', **kwargs):
|
|
super(TickerTable, self).__init__(**kwargs)
|
|
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):
|
|
"""Append a `Row` of `Cell` objects to this table.
|
|
"""
|
|
row = Row(record, headers=('symbol',), table=self)
|
|
# store ref to each row
|
|
self.symbols2rows[row._last_record['symbol']] = row
|
|
self.add_widget(row)
|
|
return row
|
|
|
|
|
|
def header_row(headers, **kwargs):
|
|
"""Create a single "header" row from a sequence of keys.
|
|
"""
|
|
headers_dict = {key: key for key in headers}
|
|
row = Row(headers_dict, headers=headers, is_header_row=True, **kwargs)
|
|
return row
|
|
|
|
|
|
def ticker_table(quotes, **kwargs):
|
|
"""Create a new ticker table from a list of quote dicts.
|
|
"""
|
|
table = TickerTable(cols=1, **kwargs)
|
|
for ticker_record in quotes:
|
|
table.append_row(ticker_record)
|
|
return table
|
|
|
|
|
|
async def update_quotes(
|
|
widgets: dict,
|
|
queue: trio.Queue,
|
|
symbol_data: dict,
|
|
first_quotes: dict
|
|
):
|
|
"""Process live quotes by updating ticker rows.
|
|
"""
|
|
grid = widgets['grid']
|
|
|
|
def color_row(row, data):
|
|
hdrcell = row._cell_widgets['symbol']
|
|
chngcell = row._cell_widgets['%']
|
|
daychange = float(data['%'])
|
|
if daychange < 0.:
|
|
color = colorcode('red2')
|
|
elif daychange > 0.:
|
|
color = colorcode('forestgreen')
|
|
else:
|
|
color = colorcode('darkgray')
|
|
|
|
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
|
|
syms2rows = {}
|
|
for quote in first_quotes:
|
|
sym = quote['symbol']
|
|
row = grid.symbols2rows[sym]
|
|
syms2rows[sym] = row
|
|
color_row(row, quote)
|
|
|
|
# the core cell update loop
|
|
while True:
|
|
log.debug("Waiting on quotes")
|
|
quotes = await queue.get()
|
|
datas = []
|
|
for quote in quotes:
|
|
data, displayable = qtconvert(quote, symbol_data=symbol_data)
|
|
row = grid.symbols2rows[data['symbol']]
|
|
datas.append((data, row))
|
|
|
|
# color changed field values
|
|
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 i, (data, row) in enumerate(
|
|
reversed(sorted(datas, key=lambda item: item[0][sort_key]))
|
|
):
|
|
grid.add_widget(row) # row append
|
|
|
|
|
|
async def run_kivy(root, nursery):
|
|
'''Trio-kivy entry point.
|
|
'''
|
|
# run kivy
|
|
await async_runTouchApp(root)
|
|
# now cancel all the other tasks that may be running
|
|
nursery.cancel_scope.cancel()
|
|
|
|
|
|
async def _async_main(name, watchlists, brokermod):
|
|
'''Launch kivy app + all other related tasks.
|
|
|
|
This is started with cli command `piker watch`.
|
|
'''
|
|
tickers = watchlists[name]
|
|
queue = trio.Queue(1000)
|
|
|
|
async with brokermod.get_client() as client:
|
|
async with trio.open_nursery() as nursery:
|
|
# get long term data including last days close price
|
|
sd = await client.symbols(tickers)
|
|
|
|
nursery.start_soon(brokermod.poll_tickers, client, tickers, queue)
|
|
|
|
# get first quotes response
|
|
pkts = await queue.get()
|
|
|
|
if pkts[0]['lastTradePrice'] is None:
|
|
log.error("Questrade API is down temporarily")
|
|
nursery.cancel_scope.cancel()
|
|
return
|
|
|
|
first_quotes = [
|
|
qtconvert(quote, symbol_data=sd)[0] for quote in pkts]
|
|
|
|
# build out UI
|
|
Builder.load_string(_kv)
|
|
root = BoxLayout(orientation='vertical', padding=5, spacing=-20)
|
|
header = header_row(
|
|
first_quotes[0].keys(),
|
|
size_hint=(1, None),
|
|
# put black lines between cells on the header row
|
|
spacing='3dp',
|
|
)
|
|
root.add_widget(header)
|
|
grid = ticker_table(
|
|
first_quotes,
|
|
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.last_clicked_col_cell = sort_cell
|
|
|
|
grid.bind(minimum_height=grid.setter('height'))
|
|
scroll = ScrollView()
|
|
scroll.add_widget(grid)
|
|
root.add_widget(scroll)
|
|
|
|
widgets = {
|
|
'grid': grid,
|
|
'root': root,
|
|
'header': header,
|
|
'scroll': scroll,
|
|
}
|
|
|
|
nursery.start_soon(run_kivy, widgets['root'], nursery)
|
|
nursery.start_soon(update_quotes, widgets, queue, sd, first_quotes)
|