From a2d38f49cfe79a8eaba4a5323a6716b470b6fd83 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 8 Feb 2018 02:16:28 -0500 Subject: [PATCH 01/18] Add more deps and pkgs --- setup.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 59974c6e..4a729053 100755 --- a/setup.py +++ b/setup.py @@ -25,15 +25,18 @@ setup( packages=[ 'piker', 'piker.brokers', + 'piker.ui', + 'piker.testing', ], entry_points={ 'console_scripts': [ 'piker = piker.brokers.cli:cli', + 'watchlist = piker.ui.watchlist:cli', ] }, install_requires=[ 'click', 'colorlog', 'trio', 'attrs', 'async_generator', - 'pygments', + 'pygments', 'cython', 'kivy', 'asks', 'pandas', ], extras_require={ 'questrade': ['asks'], From 151e7bf4fa76046306799c962aac1c0a139b2fd9 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 8 Feb 2018 02:18:33 -0500 Subject: [PATCH 02/18] More client enhancements - Extend the qt api to include candles (not working yet), balances, positions. - Add a `quote()` method to the `Client` for batch ticker quotes and expose it through a CLI subcommand. - Make `poll_tickers` push new quotes to a `trio.Queue` --- piker/__init__.py | 3 ++ piker/brokers/questrade.py | 76 ++++++++++++++++++++++++++++++++------ 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/piker/__init__.py b/piker/__init__.py index e69de29b..ac6e86e3 100644 --- a/piker/__init__.py +++ b/piker/__init__.py @@ -0,0 +1,3 @@ +""" +piker: trading toolz for hackerz. +""" diff --git a/piker/brokers/questrade.py b/piker/brokers/questrade.py index 3c30efea..b56efb0d 100644 --- a/piker/brokers/questrade.py +++ b/piker/brokers/questrade.py @@ -49,11 +49,13 @@ def resproc( class Client: """API client suitable for use as a long running broker daemon or - for single api requests. + single api requests. + + Provides a high-level api which wraps the underlying endpoint calls. """ def __init__(self, config: 'configparser.ConfigParser'): self._sess = asks.Session() - self.api = API(self._sess) + self.api = _API(self._sess) self._conf = config self.access_data = {} self.user_data = {} @@ -151,9 +153,17 @@ class Client: return symbols2ids + async def quote(self, tickers): + """Return quotes for each ticker in ``tickers``. + """ + t2ids = await self.tickers2ids(tickers) + ids = ','.join(map(str, t2ids.values())) + return await self.api.quotes(ids=ids) -class API: - """Questrade API at its finest. + +class _API: + """Questrade API endpoints exposed as methods and wrapped with an + http session. """ def __init__(self, session: asks.Session): self._sess = session @@ -183,6 +193,15 @@ class API: async def quotes(self, ids: str) -> dict: return await self._request('markets/quotes', params={'ids': ids}) + async def candles(self, id: str, start: str, end, interval) -> dict: + return await self._request(f'markets/candles/{id}', params={}) + + async def balances(self, id: str) -> dict: + return await self._request(f'accounts/{id}/balances') + + async def postions(self, id: str) -> dict: + return await self._request(f'accounts/{id}/positions') + async def token_refresher(client): """Coninually refresh the ``access_token`` near its expiry time. @@ -194,7 +213,8 @@ async def token_refresher(client): def _token_from_user(conf: 'configparser.ConfigParser') -> None: - # get from user + """Get API token from the user on the console. + """ refresh_token = input("Please provide your Questrade access token: ") conf['questrade'] = {'refresh_token': refresh_token} @@ -261,21 +281,46 @@ async def serve_forever(tasks) -> None: nursery.start_soon(task, client) -async def poll_tickers(client, tickers, rate=2): - """Auto-poll snap quotes for a sequence of tickers at the given ``rate`` +async def poll_tickers( + client: Client, tickers: [str], q: trio.Queue, rate: int = 2, +) -> None: + """Stream quotes for a sequence of tickers at the given ``rate`` per second. """ t2ids = await client.tickers2ids(tickers) - sleeptime = 1. / rate ids = ','.join(map(str, t2ids.values())) + sleeptime = 1. / rate + _cache = {} while True: # use an event here to trigger exit? - quote_data = await client.api.quotes(ids=ids) - await trio.sleep(sleeptime) + quotes_resp = await client.api.quotes(ids=ids) + start = time.time() + quotes = quotes_resp['quotes'] + # log.trace(quotes) + + # only push quotes with "new" data + payload = [] + for quote in quotes: + symbol = quote['symbol'] + last = _cache.setdefault(symbol, {}) + new = set(quote.items()) - set(last.items()) + if new: + log.debug(f"New quote {symbol} data:\n{new}") + _cache[symbol] = quote + payload.append(quote) + + if payload: + q.put_nowait(payload) + + proc_time = time.time() - start + delay = sleeptime - proc_time + if delay <= 0: + log.warn(f"Took {proc_time} seconds for processing quotes?") + await trio.sleep(delay) -async def api(methname, **kwargs) -> dict: - """Make (proxy) through an api call by name and return its result. +async def api(methname: str, **kwargs) -> dict: + """Make (proxy through) an api call by name and return its result. """ async with get_client() as client: meth = getattr(client.api, methname, None) @@ -292,3 +337,10 @@ async def api(methname, **kwargs) -> dict: return return await meth(**kwargs) + + +async def quote(tickers: [str]) -> dict: + """Return quotes dict for ``tickers``. + """ + async with get_client() as client: + return await client.quote(tickers) From adecc082ace8f561024aa5d50d404ee2fb811e08 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 8 Feb 2018 02:25:10 -0500 Subject: [PATCH 03/18] Save `brokers.ini` in the user config dir using click --- piker/brokers/config.py | 3 ++- piker/log.py | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/piker/brokers/config.py b/piker/brokers/config.py index 979e2605..ce302922 100644 --- a/piker/brokers/config.py +++ b/piker/brokers/config.py @@ -3,11 +3,12 @@ Broker configuration mgmt. """ from os import path import configparser +import click from ..log import get_logger log = get_logger('broker-config') -_broker_conf_path = path.join(path.dirname(__file__), 'brokers.ini') +_broker_conf_path = path.join(click.get_app_dir('piker'), 'brokers.ini') def load() -> (configparser.ConfigParser, str): diff --git a/piker/log.py b/piker/log.py index a849dbce..3bef481c 100644 --- a/piker/log.py +++ b/piker/log.py @@ -1,6 +1,5 @@ """ Log like a forester! -(You can't usually find stupid suits in the forest) """ import sys import logging From 6781a238505e1be641b20928d1f8633b70550a29 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 8 Feb 2018 02:26:55 -0500 Subject: [PATCH 04/18] Add a `quote` subcommand Add `piker quote ` command for easily dumping quote data to the console. With `-df` will dump as a pandas data frame. Add key filtering to `piker api` calls. --- piker/brokers/cli.py | 45 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/piker/brokers/cli.py b/piker/brokers/cli.py index b5501633..e9d07dcf 100644 --- a/piker/brokers/cli.py +++ b/piker/brokers/cli.py @@ -6,6 +6,7 @@ from importlib import import_module import click import trio +import pandas as pd from ..log import get_console_log, colorize_json @@ -30,9 +31,11 @@ def cli(): @cli.command() @click.option('--broker', default='questrade', help='Broker backend to use') @click.option('--loglevel', '-l', default='warning', help='Logging level') +@click.option('--keys', '-k', multiple=True, + help='Return results only for these keys') @click.argument('meth', nargs=1) @click.argument('kwargs', nargs=-1) -def api(meth, kwargs, loglevel, broker): +def api(meth, kwargs, loglevel, broker, keys): """client for testing broker API methods with pretty printing of output. """ log = get_console_log(loglevel) @@ -47,7 +50,43 @@ def api(meth, kwargs, loglevel, broker): _kwargs[key] = value data = run(partial(brokermod.api, meth, **_kwargs), loglevel=loglevel) - if data: + + if keys: + # filter to requested keys + filtered = [] + if meth in data: # often a list of dicts + for item in data[meth]: + filtered.append({key: item[key] for key in keys}) + + else: # likely just a dict + filtered.append({key: data[key] for key in keys}) + data = filtered + + click.echo(colorize_json(data)) + + +@cli.command() +@click.option('--broker', default='questrade', help='Broker backend to use') +@click.option('--loglevel', '-l', default='warning', help='Logging level') +@click.option('--df-output', '-df', flag_value=True, + help='Ouput in `pandas.DataFrame` format') +@click.argument('tickers', nargs=-1) +def quote(loglevel, broker, tickers, df_output): + """client for testing broker API methods with pretty printing of output. + """ + brokermod = import_module('.' + broker, 'piker.brokers') + data = run(partial(brokermod.quote, tickers), loglevel=loglevel) + quotes = data['quotes'] + cols = quotes[0].copy() + cols.pop('symbol') + if df_output: + df = pd.DataFrame( + data['quotes'], + index=[item['symbol'] for item in quotes], + columns=cols, + ) + click.echo(df) + else: click.echo(colorize_json(data)) @@ -55,7 +94,7 @@ def api(meth, kwargs, loglevel, broker): @click.option('--broker', default='questrade', help='Broker backend to use') @click.option('--loglevel', '-l', default='info', help='Logging level') @click.argument('tickers', nargs=-1) -def stream(broker, loglevel, tickers): +def stream(broker, loglevel, tickers, keys): # import broker module daemon entry point bm = import_module('.' + broker, 'piker.brokers') run( From b8a3fb67a1046db038e4d30c68db5cbc40299593 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 8 Feb 2018 02:29:28 -0500 Subject: [PATCH 05/18] Add a quote-from-json-file streamer for testing --- piker/testing/__init__.py | 0 piker/testing/_quote_streamer.py | 31 ++++ piker/testing/quotes.json | 240 +++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 piker/testing/__init__.py create mode 100644 piker/testing/_quote_streamer.py create mode 100644 piker/testing/quotes.json diff --git a/piker/testing/__init__.py b/piker/testing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/piker/testing/_quote_streamer.py b/piker/testing/_quote_streamer.py new file mode 100644 index 00000000..109f15de --- /dev/null +++ b/piker/testing/_quote_streamer.py @@ -0,0 +1,31 @@ +""" +Mock a broker module. +""" +from itertools import cycle +import json +from os import path +import trio +from async_generator import asynccontextmanager + + +@asynccontextmanager +async def get_client() -> None: + """Shim client factory. + """ + yield None + + +async def poll_tickers( + client, tickers: [str], q: trio.Queue) -> None: + """Stream quotes from a local json store. + """ + with open(path.join(path.dirname(__file__), 'quotes.json'), 'r') as quotes_file: + content = quotes_file.read() + + pkts = content.split('--') # simulate 2 separate quote packets + # import pdb; pdb.set_trace() + payloads = [json.loads(pkt)['quotes'] for pkt in pkts] + + for payload in cycle(payloads): + q.put_nowait(payload) + await trio.sleep(1/2.) diff --git a/piker/testing/quotes.json b/piker/testing/quotes.json new file mode 100644 index 00000000..4b01ab74 --- /dev/null +++ b/piker/testing/quotes.json @@ -0,0 +1,240 @@ +{ + "quotes": [ + { + "VWAP": 7.383792, + "askPrice": 7.56, + "askSize": 2, + "bidPrice": 6.1, + "bidSize": 2, + "delay": 0, + "high52w": 9.68, + "highPrice": 8, + "isHalted": false, + "lastTradePrice": 6.96, + "lastTradePriceTrHrs": 6.97, + "lastTradeSize": 2000, + "lastTradeTick": "Down", + "lastTradeTime": "2018-02-07T15:59:59.259000-05:00", + "low52w": 1.03, + "lowPrice": 6.88, + "openPrice": 7.64, + "symbol": "EMH.VN", + "symbolId": 10164524, + "tier": "", + "volume": 5357805 + }, + { + "VWAP": 29.445855, + "askPrice": 55, + "askSize": 2, + "bidPrice": 27.63, + "bidSize": 20, + "delay": 0, + "high52w": 44, + "highPrice": 31.35, + "isHalted": false, + "lastTradePrice": 27.67, + "lastTradePriceTrHrs": 27.62, + "lastTradeSize": 100, + "lastTradeTick": "Down", + "lastTradeTime": "2018-02-07T16:16:17.723000-05:00", + "low52w": 6.58, + "lowPrice": 26.66, + "openPrice": 30.43, + "symbol": "WEED.TO", + "symbolId": 16529510, + "tier": "", + "volume": 14762722 + }, + { + "VWAP": 18.353787, + "askPrice": 19, + "askSize": 4, + "bidPrice": 10, + "bidSize": 1, + "delay": 0, + "high52w": 31.25, + "highPrice": 18.99, + "isHalted": false, + "lastTradePrice": 17.9, + "lastTradePriceTrHrs": 17.87, + "lastTradeSize": 1600, + "lastTradeTick": "Up", + "lastTradeTime": "2018-02-07T16:00:00.201000-05:00", + "low52w": 6.81, + "lowPrice": 17.33, + "openPrice": 18.71, + "symbol": "LEAF.TO", + "symbolId": 17821824, + "tier": "", + "volume": 1322510 + }, + { + "VWAP": 19.492624, + "askPrice": 25.18, + "askSize": 1, + "bidPrice": 10.31, + "bidSize": 5, + "delay": 0, + "high52w": 25.56, + "highPrice": 20.7, + "isHalted": false, + "lastTradePrice": 18.74, + "lastTradePriceTrHrs": 18.74, + "lastTradeSize": 100, + "lastTradeTick": "Up", + "lastTradeTime": "2018-02-07T15:59:49.451000-05:00", + "low52w": 8.36, + "lowPrice": 18.18, + "openPrice": 19.97, + "symbol": "HMMJ.TO", + "symbolId": 18022628, + "tier": "", + "volume": 2427378 + }, + { + "VWAP": 7.620155, + "askPrice": 5.9, + "askSize": 30, + "bidPrice": 5.66, + "bidSize": 30, + "delay": 0, + "high52w": 9.8, + "highPrice": 8.05, + "isHalted": false, + "lastTradePrice": 7.53, + "lastTradePriceTrHrs": 7.53, + "lastTradeSize": 900, + "lastTradeTick": "Equal", + "lastTradeTime": "2018-02-07T15:59:55.899000-05:00", + "low52w": 1.52, + "lowPrice": 7.06, + "openPrice": 7.69, + "symbol": "VFF.TO", + "symbolId": 40747, + "tier": "", + "volume": 828156 + } + ] +} + +-- +{ + "quotes": [ + { + "VWAP": 7.383792, + "askPrice": 7.55, + "askSize": 2, + "bidPrice": 6.2, + "bidSize": 2, + "delay": 0, + "high52w": 9.68, + "highPrice": 8, + "isHalted": false, + "lastTradePrice": 6.97, + "lastTradePriceTrHrs": 6.97, + "lastTradeSize": 1000, + "lastTradeTick": "Up", + "lastTradeTime": "2018-02-07T15:59:59.259000-05:00", + "low52w": 1.03, + "lowPrice": 6.88, + "openPrice": 7.64, + "symbol": "EMH.VN", + "symbolId": 10164524, + "tier": "", + "volume": 5357805 + }, + { + "VWAP": 29.445855, + "askPrice": 55, + "askSize": 2, + "bidPrice": 27.63, + "bidSize": 20, + "delay": 0, + "high52w": 44, + "highPrice": 31.35, + "isHalted": false, + "lastTradePrice": 28.25, + "lastTradePriceTrHrs": 27.62, + "lastTradeSize": 100, + "lastTradeTick": "Up", + "lastTradeTime": "2018-02-07T16:16:17.723000-05:00", + "low52w": 6.58, + "lowPrice": 26.66, + "openPrice": 30.43, + "symbol": "WEED.TO", + "symbolId": 16529510, + "tier": "", + "volume": 14762722 + }, + { + "VWAP": 18.353787, + "askPrice": 19, + "askSize": 4, + "bidPrice": 10, + "bidSize": 1, + "delay": 0, + "high52w": 31.25, + "highPrice": 18.99, + "isHalted": false, + "lastTradePrice": 17.8, + "lastTradePriceTrHrs": 17.87, + "lastTradeSize": 100, + "lastTradeTick": "Down", + "lastTradeTime": "2018-02-07T16:00:00.201000-05:00", + "low52w": 6.81, + "lowPrice": 17.33, + "openPrice": 18.71, + "symbol": "LEAF.TO", + "symbolId": 17821824, + "tier": "", + "volume": 1322510 + }, + { + "VWAP": 19.492624, + "askPrice": 25.18, + "askSize": 1, + "bidPrice": 10.31, + "bidSize": 5, + "delay": 0, + "high52w": 25.56, + "highPrice": 20.7, + "isHalted": false, + "lastTradePrice": 16.70, + "lastTradePriceTrHrs": 18.74, + "lastTradeSize": 100, + "lastTradeTick": "Down", + "lastTradeTime": "2018-02-07T15:59:49.451000-05:00", + "low52w": 8.36, + "lowPrice": 18.18, + "openPrice": 19.97, + "symbol": "HMMJ.TO", + "symbolId": 18022628, + "tier": "", + "volume": 2427378 + }, + { + "VWAP": 7.620155, + "askPrice": 7.75, + "askSize": 20, + "bidPrice": 5.66, + "bidSize": 30, + "delay": 0, + "high52w": 9.8, + "highPrice": 8.05, + "isHalted": false, + "lastTradePrice": 7.53, + "lastTradePriceTrHrs": 7.53, + "lastTradeSize": 400, + "lastTradeTick": "Equal", + "lastTradeTime": "2018-02-07T15:59:55.899000-05:00", + "low52w": 1.52, + "lowPrice": 7.06, + "openPrice": 7.69, + "symbol": "VFF.TO", + "symbolId": 40747, + "tier": "", + "volume": 828156 + } + ] +} From 13342c459a9a9d70d68d050cd0960d778a241271 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 8 Feb 2018 02:15:43 -0500 Subject: [PATCH 06/18] Our first real-time watchlist! --- piker/ui/watchlist.py | 333 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 piker/ui/watchlist.py diff --git a/piker/ui/watchlist.py b/piker/ui/watchlist.py new file mode 100644 index 00000000..45c86e7a --- /dev/null +++ b/piker/ui/watchlist.py @@ -0,0 +1,333 @@ +""" +A real-time, sorted watchlist +""" +import os +from importlib import import_module + +import click +import trio + +# use the trio async loop +os.environ['KIVY_EVENTLOOP'] = 'trio' + +from kivy.uix.widget import Widget +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.gridlayout import GridLayout +from kivy.uix.label import Label +from kivy.uix.scrollview import ScrollView +from kivy.core.window import Window +from kivy.properties import DictProperty +from kivy.lang import Builder +from kivy import utils +from kivy.app import async_runTouchApp + +from ..log import get_logger, get_console_log + +log = get_logger('watchlist') + + +def same_rgb(val): + return ', '.join(map(str, [val]*3)) + + +def colorcode(name): + if not name: + name = 'darkgray' + _names2hexs = { + 'darkgray': 'a9a9a9', + 'green': '008000', + 'red': 'ff3333', + 'red2': 'ff0000', + 'dark_red': '8b0000', + 'firebrick': 'b22222', + } + return utils.rgba(_names2hexs[name]) + + +_kv = (f''' +#:kivy 1.10.0 + + + # font_size: '15' + size: self.texture_size + # size_hint_y: None + # height: '100dp' + outline_color: {same_rgb(0.01)} + width: '100dp' + valign: 'middle' + halign: 'center' + canvas.before: + Color: + rgb: {same_rgb(0.13)} + Rectangle: + pos: self.pos + size: self.size + + + text_size: self.size + size: self.texture_size + # font_size: '15' + font_color: {colorcode('darkgray')} + # font_name: 'sans serif' + valign: 'middle' + halign: 'center' + # outline_color: {same_rgb(0.01)} + canvas.before: + Color: + rgb: {same_rgb(0.05)} + Rectangle: + pos: self.pos + size: self.size + + + spacing: '5dp' + row_force_default: True + row_default_height: 75 + # size_hint_y: None + size_hint: 1, None + cols: 1 + + + spacing: '4dp' + minimum_height: 200 # should be pulled from Cell text size + minimum_width: 200 + row_force_default: True + row_default_height: 75 + outline_color: {same_rgb(2)} + size_hint: 1, None +''') + + +_qt_keys = { + # 'symbol': 'symbol', # done manually in remap_keys + 'lastTradePrice': 'last', + 'lastTradeSize': 'last size', + 'askPrice': 'ask', + 'askSize': 'ask price', + 'bidPrice': 'bid', + 'bidSize': 'bid size', + 'volume': 'vol', + 'VWAP': 'vwap', + '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 remap_keys(quote, keymap=_qt_keys): + """Remap a list of quote dicts ``quotes`` using + the mapping of old keys -> new keys ``keymap``. + """ + open_price = quote['openPrice'] + new = { + 'symbol': quote['symbol'], + '%': f"{(quote['lastTradePrice'] - open_price) / open_price:10.2f}" + } + for key, new_key in keymap.items(): + value = quote[key] + new[new_key] = value + + return new + + +class HeaderCell(Label): + """Column header cell label. + """ + + +class Cell(Label): + """Data header 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=(), cell_type=Cell, **kwargs): + super(Row, self).__init__(cols=len(record), **kwargs) + self._cell_widgets = {} + self._last_record = record + + # 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 + + def _append_cell(self, text, colorname=None, 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), color=colorcode(colorname)) + self.add_widget(cell) + return cell + + +class TickerTable(GridLayout): + """A grid for displaying ticker quote records as a table. + """ + def __init__(self, **kwargs): + super(TickerTable, self).__init__(**kwargs) + self.symbols2rows = {} + + def append_row(self, record, colorname='firebrick'): + row = Row(record, headers=('symbol',)) + # store ref to each row + self.symbols2rows[row._last_record['symbol']] = row + self.add_widget(row) + return row + + +def header_row(headers): + """Create a single "header" row from a sequence of keys. + """ + # process headers via first quote record + headers_dict = {key: key for key in headers} + row = Row(headers_dict, headers=headers) + return row + + +def ticker_table(quotes, **kwargs): + """Create a new ticker table from a list of quote dicts. + """ + table = TickerTable(cols=1) + + for ticker_record in quotes: + table.append_row(ticker_record) + + return table + + +async def update_quotes(widgets, queue): + """Process live quotes by updating ticker rows. + """ + grid = widgets['grid'] + + while True: + log.debug("Waiting on quotes") + quotes = await queue.get() + rows = [] + for quote in quotes: + data = remap_keys(quote) + row = grid.symbols2rows[data['symbol']] + rows.append((data, row)) + new = set(data.items()) - set(row._last_record.items()) + if new: + for key, val in filter(lambda item: item[0] != '%', new): + # logic for value coloring: up-green, down-red + if row._last_record[key] < val: + color = colorcode('green') + elif row._last_record[key] > val: + color = colorcode('red2') + + cell = row._cell_widgets[key] + cell.text = str(val) + cell.color = color + + row._last_record = data + + hdrcell = row._cell_widgets['symbol'] + chngcell = row._cell_widgets['%'] + daychange = float(data['%']) + if daychange < 0.: + color = colorcode('red2') + chngcell.color = hdrcell.color = color + elif daychange > 0.: + color = colorcode('green') + chngcell.color = hdrcell.color = color + + # sort rows by % change + for i, pair in enumerate( + sorted(rows, key=lambda item: float(item[0]['%'])) + ): + data, row = pair + if grid.children[i] != row: + grid.remove_widget(row) + grid.add_widget(row, index=i) + + +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(tickers, brokermod): + '''Launch kivy app + all other related tasks. + ''' + queue = trio.Queue(1000) + + async with brokermod.get_client() as client: + async with trio.open_nursery() as nursery: + nursery.start_soon(brokermod.poll_tickers, client, tickers, queue) + + # get first quotes response + quotes = [] + pkts = await queue.get() + for quote in pkts: + quotes.append(remap_keys(quote)) + + # build out UI + Builder.load_string(_kv) + root = BoxLayout(orientation='vertical') + header = header_row(quotes[0].keys()) + root.add_widget(header) + grid = ticker_table(quotes) + grid.bind(minimum_height=grid.setter('height')) + scroll = ScrollView( + size=(Window.width, Window.height), bar_margin=10) + 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) + + +@click.group() +def cli(): + pass + + +@cli.command() +@click.option('--broker', default='questrade', help='Broker backend to use') +@click.option('--loglevel', '-l', default='warning', help='Logging level') +def run(loglevel, broker): + """Spawn a watchlist. + """ + get_console_log(loglevel) # activate console logging + brokermod = import_module('.' + broker, 'piker.brokers') + + watchlists = { + 'cannabis': [ + 'EMH.VN', 'LEAF.TO', 'HVT.VN', 'HMMJ.TO', 'APH.TO', + 'CBW.VN', 'TRST.CN', 'VFF.TO', 'ACB.TO', 'ABCN.VN' + 'APH.TO', 'MARI.CN', 'WMD.VN', 'LEAF.TO', 'THCX.VN' + ], + } + # broker_conf_path = os.path.join(click.get_app_dir('piker'), 'watchlists.json') + # from piker.testing import _quote_streamer as brokermod + trio.run(_async_main, watchlists['cannabis'], brokermod) From 224451f44ab49b870becc6de62f09b29a5d32fd1 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 8 Feb 2018 18:42:19 -0500 Subject: [PATCH 07/18] Make ticker stream caching optional Push all ticker quotes to the queue regardless of duplicate content. That is, don't worry about only pushing new quote changes (turns out it is useful when coloring a watchlist where multiple of the same quote may indicate multiple similar trades and we only want to quickly "pulse" color changes on value changes). If it is desired to only push new changes, the ``cache`` flag enables the old behaviour. Also add `Client.symbols()` for returning symbol data from a sequence of tickers. --- piker/brokers/questrade.py | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/piker/brokers/questrade.py b/piker/brokers/questrade.py index b56efb0d..950b7070 100644 --- a/piker/brokers/questrade.py +++ b/piker/brokers/questrade.py @@ -158,7 +158,18 @@ class Client: """ t2ids = await self.tickers2ids(tickers) ids = ','.join(map(str, t2ids.values())) - return await self.api.quotes(ids=ids) + return (await self.api.quotes(ids=ids))['quotes'] + + async def symbols(self, tickers): + """Return quotes for each ticker in ``tickers``. + """ + t2ids = await self.tickers2ids(tickers) + ids = ','.join(map(str, t2ids.values())) + symbols = {} + for pkt in (await self.api.symbols(ids=ids))['symbols']: + symbols[pkt['symbol']] = pkt + + return symbols class _API: @@ -282,7 +293,10 @@ async def serve_forever(tasks) -> None: async def poll_tickers( - client: Client, tickers: [str], q: trio.Queue, rate: int = 2, + client: Client, tickers: [str], + q: trio.Queue, + rate: int = 3, + cache: bool = False, # only deliver "new" changes to the queue ) -> None: """Stream quotes for a sequence of tickers at the given ``rate`` per second. @@ -298,15 +312,21 @@ async def poll_tickers( quotes = quotes_resp['quotes'] # log.trace(quotes) - # only push quotes with "new" data payload = [] for quote in quotes: - symbol = quote['symbol'] - last = _cache.setdefault(symbol, {}) - new = set(quote.items()) - set(last.items()) - if new: - log.debug(f"New quote {symbol} data:\n{new}") - _cache[symbol] = quote + + if quote['delay'] > 0: + log.warning(f"Delayed quote:\n{quote}") + + if cache: # if cache is enabled then only deliver "new" changes + symbol = quote['symbol'] + last = _cache.setdefault(symbol, {}) + new = set(quote.items()) - set(last.items()) + if new: + log.debug(f"New quote {symbol} data:\n{new}") + _cache[symbol] = quote + payload.append(quote) + else: payload.append(quote) if payload: From 17c4ac3b8c3f327c2b3e836b5d0dd04cc019058c Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 8 Feb 2018 19:15:21 -0500 Subject: [PATCH 08/18] Adjust cli to new api --- piker/brokers/cli.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/piker/brokers/cli.py b/piker/brokers/cli.py index e9d07dcf..7dc5086e 100644 --- a/piker/brokers/cli.py +++ b/piker/brokers/cli.py @@ -75,19 +75,18 @@ def quote(loglevel, broker, tickers, df_output): """client for testing broker API methods with pretty printing of output. """ brokermod = import_module('.' + broker, 'piker.brokers') - data = run(partial(brokermod.quote, tickers), loglevel=loglevel) - quotes = data['quotes'] + quotes = run(partial(brokermod.quote, tickers), loglevel=loglevel) cols = quotes[0].copy() cols.pop('symbol') if df_output: df = pd.DataFrame( - data['quotes'], + quotes, index=[item['symbol'] for item in quotes], columns=cols, ) click.echo(df) else: - click.echo(colorize_json(data)) + click.echo(colorize_json(quotes)) @cli.command() From e45c07dce703cff9d17d9be859d107737fd28c9f Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 8 Feb 2018 19:30:09 -0500 Subject: [PATCH 09/18] Watchlist fixes - make the % daily change use the previous days close as the reference price - color each cell on every change (results in "pulsed" colors on changes) - tweak some quote fields - redraw and sort all rows on every quotes update cycle - error when the QT api is returning None values --- piker/ui/watchlist.py | 162 +++++++++++++++++++++++++----------------- 1 file changed, 95 insertions(+), 67 deletions(-) diff --git a/piker/ui/watchlist.py b/piker/ui/watchlist.py index 45c86e7a..d3078d9b 100644 --- a/piker/ui/watchlist.py +++ b/piker/ui/watchlist.py @@ -7,24 +7,21 @@ from importlib import import_module import click import trio +from ..log import get_logger, get_console_log +log = get_logger('watchlist') + # use the trio async loop os.environ['KIVY_EVENTLOOP'] = 'trio' -from kivy.uix.widget import Widget from kivy.uix.boxlayout import BoxLayout from kivy.uix.gridlayout import GridLayout from kivy.uix.label import Label from kivy.uix.scrollview import ScrollView from kivy.core.window import Window -from kivy.properties import DictProperty from kivy.lang import Builder from kivy import utils from kivy.app import async_runTouchApp -from ..log import get_logger, get_console_log - -log = get_logger('watchlist') - def same_rgb(val): return ', '.join(map(str, [val]*3)) @@ -32,14 +29,17 @@ def same_rgb(val): def colorcode(name): if not name: - name = 'darkgray' + name = 'gray' _names2hexs = { 'darkgray': 'a9a9a9', + 'gray': '808080', 'green': '008000', - 'red': 'ff3333', - 'red2': 'ff0000', + 'red2': 'ff3333', + 'red': 'ff0000', 'dark_red': '8b0000', 'firebrick': 'b22222', + 'maroon': '800000', + 'gainsboro': 'dcdcdc', } return utils.rgba(_names2hexs[name]) @@ -48,12 +48,12 @@ _kv = (f''' #:kivy 1.10.0 - # font_size: '15' + # font_size: 18 size: self.texture_size # size_hint_y: None - # height: '100dp' + # height: 50 outline_color: {same_rgb(0.01)} - width: '100dp' + width: 50 valign: 'middle' halign: 'center' canvas.before: @@ -67,14 +67,14 @@ _kv = (f''' text_size: self.size size: self.texture_size # font_size: '15' - font_color: {colorcode('darkgray')} + font_color: {colorcode('gray')} # font_name: 'sans serif' valign: 'middle' halign: 'center' # outline_color: {same_rgb(0.01)} canvas.before: Color: - rgb: {same_rgb(0.05)} + rgb: {same_rgb(0.06)} Rectangle: pos: self.pos size: self.size @@ -88,7 +88,7 @@ _kv = (f''' cols: 1 - spacing: '4dp' + spacing: '6dp' minimum_height: 200 # should be pulled from Cell text size minimum_width: 200 row_force_default: True @@ -103,34 +103,38 @@ _qt_keys = { 'lastTradePrice': 'last', 'lastTradeSize': 'last size', 'askPrice': 'ask', - 'askSize': 'ask price', + 'askSize': 'ask size', 'bidPrice': 'bid', 'bidSize': 'bid size', 'volume': 'vol', - 'VWAP': 'vwap', + 'VWAP': 'VWAP', 'high52w': 'high52w', 'highPrice': 'high', # "lastTradePriceTrHrs": 7.99, # "lastTradeTick": "Equal", # "lastTradeTime": "2018-01-30T18:28:23.434000-05:00", - 'low52w': 'low52w', + # 'low52w': 'low52w', 'lowPrice': 'low day', 'openPrice': 'open', # "symbolId": 3575753, # "tier": "", - 'isHalted': 'halted', - 'delay': 'delay', # as subscript 'p' + # 'isHalted': 'halted', + # 'delay': 'delay', # as subscript 'p' } -def remap_keys(quote, keymap=_qt_keys): +def remap_keys(quote: dict, keymap: dict = _qt_keys, symbol_data: dict = None): """Remap a list of quote dicts ``quotes`` using the mapping of old keys -> new keys ``keymap``. """ - open_price = quote['openPrice'] + if symbol_data: # we can only compute % change from symbols data + previous = symbol_data[quote['symbol']]['prevDayClosePrice'] + change = (quote['lastTradePrice'] - previous) / previous * 100 + else: + change = 0 new = { 'symbol': quote['symbol'], - '%': f"{(quote['lastTradePrice'] - open_price) / open_price:10.2f}" + '%': f"{change:.2f}" } for key, new_key in keymap.items(): value = quote[key] @@ -205,59 +209,73 @@ def ticker_table(quotes, **kwargs): """Create a new ticker table from a list of quote dicts. """ table = TickerTable(cols=1) - for ticker_record in quotes: table.append_row(ticker_record) - return table -async def update_quotes(widgets, queue): +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('red') + elif daychange > 0.: + color = colorcode('green') + else: + color = colorcode('gray') + + chngcell.color = hdrcell.color = color + + # initial coloring + all_rows = [] + for quote in first_quotes: + row = grid.symbols2rows[quote['symbol']] + all_rows.append((quote, row)) + color_row(row, quote) + while True: log.debug("Waiting on quotes") quotes = await queue.get() - rows = [] for quote in quotes: - data = remap_keys(quote) + data = remap_keys(quote, symbol_data=symbol_data) row = grid.symbols2rows[data['symbol']] - rows.append((data, row)) - new = set(data.items()) - set(row._last_record.items()) - if new: - for key, val in filter(lambda item: item[0] != '%', new): - # logic for value coloring: up-green, down-red - if row._last_record[key] < val: - color = colorcode('green') - elif row._last_record[key] > val: - color = colorcode('red2') - cell = row._cell_widgets[key] - cell.text = str(val) - cell.color = color + # 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') - row._last_record = data + cell = row._cell_widgets[key] + cell.text = str(val) + cell.color = color - hdrcell = row._cell_widgets['symbol'] - chngcell = row._cell_widgets['%'] - daychange = float(data['%']) - if daychange < 0.: - color = colorcode('red2') - chngcell.color = hdrcell.color = color - elif daychange > 0.: - color = colorcode('green') - chngcell.color = hdrcell.color = color + color_row(row, data) + row._last_record = data - # sort rows by % change - for i, pair in enumerate( - sorted(rows, key=lambda item: float(item[0]['%'])) + # sort rows by daily % change since open + grid.clear_widgets() + for i, (data, row) in enumerate( + sorted(all_rows, key=lambda item: float(item[0]['%'])) ): - data, row = pair - if grid.children[i] != row: - grid.remove_widget(row) - grid.add_widget(row, index=i) + # print(f"{i} {data['symbol']}") + # grid.remove_widget(row) + grid.add_widget(row, index=i) async def run_kivy(root, nursery): @@ -276,23 +294,30 @@ async def _async_main(tickers, brokermod): 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 - quotes = [] pkts = await queue.get() - for quote in pkts: - quotes.append(remap_keys(quote)) + + if pkts[0]['lastTradePrice'] is None: + log.error("Questrade API is down temporarily") + nursery.cancel_scope.cancel() + return + + first_quotes = [ + remap_keys(quote, symbol_data=sd) for quote in pkts] # build out UI Builder.load_string(_kv) root = BoxLayout(orientation='vertical') - header = header_row(quotes[0].keys()) + header = header_row(first_quotes[0].keys()) root.add_widget(header) - grid = ticker_table(quotes) + grid = ticker_table(first_quotes) grid.bind(minimum_height=grid.setter('height')) - scroll = ScrollView( - size=(Window.width, Window.height), bar_margin=10) + scroll = ScrollView(bar_margin=10, viewport_size=(10, 10)) scroll.add_widget(grid) root.add_widget(scroll) @@ -304,7 +329,7 @@ async def _async_main(tickers, brokermod): } nursery.start_soon(run_kivy, widgets['root'], nursery) - nursery.start_soon(update_quotes, widgets, queue) + nursery.start_soon(update_quotes, widgets, queue, sd, first_quotes) @click.group() @@ -324,8 +349,11 @@ def run(loglevel, broker): watchlists = { 'cannabis': [ 'EMH.VN', 'LEAF.TO', 'HVT.VN', 'HMMJ.TO', 'APH.TO', - 'CBW.VN', 'TRST.CN', 'VFF.TO', 'ACB.TO', 'ABCN.VN' - 'APH.TO', 'MARI.CN', 'WMD.VN', 'LEAF.TO', 'THCX.VN' + 'CBW.VN', 'TRST.CN', 'VFF.TO', 'ACB.TO', 'ABCN.VN', + 'APH.TO', 'MARI.CN', 'WMD.VN', 'LEAF.TO', 'THCX.VN', + 'WEED.TO', 'NINE.VN', 'RTI.VN', 'SNN.CN', 'ACB.TO', + 'OGI.VN', 'IMH.VN', 'FIRE.VN', 'EAT.CN', 'NUU.VN', + 'WMD.VN', 'HEMP.VN', 'CALI.CN', 'RBQ.CN', ], } # broker_conf_path = os.path.join(click.get_app_dir('piker'), 'watchlists.json') From f71391252d6b06e232c1a0834f97b6a8c960dbc5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 9 Feb 2018 02:44:43 -0500 Subject: [PATCH 10/18] Tighten up the UI to be super sleek --- piker/ui/__init__.py | 7 +++ piker/ui/watchlist.py | 119 +++++++++++++++++++++--------------------- 2 files changed, 67 insertions(+), 59 deletions(-) create mode 100644 piker/ui/__init__.py diff --git a/piker/ui/__init__.py b/piker/ui/__init__.py new file mode 100644 index 00000000..a1006428 --- /dev/null +++ b/piker/ui/__init__.py @@ -0,0 +1,7 @@ +""" +Stuff for you eyes. +""" +import os + +# use the trio async loop +os.environ['KIVY_EVENTLOOP'] = 'trio' diff --git a/piker/ui/watchlist.py b/piker/ui/watchlist.py index d3078d9b..9724029b 100644 --- a/piker/ui/watchlist.py +++ b/piker/ui/watchlist.py @@ -1,90 +1,86 @@ """ A real-time, sorted watchlist """ -import os from importlib import import_module import click import trio -from ..log import get_logger, get_console_log -log = get_logger('watchlist') - -# use the trio async loop -os.environ['KIVY_EVENTLOOP'] = 'trio' - from kivy.uix.boxlayout import BoxLayout from kivy.uix.gridlayout import GridLayout from kivy.uix.label import Label from kivy.uix.scrollview import ScrollView -from kivy.core.window import Window from kivy.lang import Builder from kivy import utils from kivy.app import async_runTouchApp +from ..log import get_logger, get_console_log +log = get_logger('watchlist') + + def same_rgb(val): return ', '.join(map(str, [val]*3)) +_colors2hexs = { + 'darkgray': 'a9a9a9', + 'gray': '808080', + 'green': '008000', + 'forestgreen': '228b22', + 'seagreen': '2e8b57', + 'red2': 'ff3333', + 'red': 'ff0000', + 'tomato': 'ff6347', + 'darkred': '8b0000', + 'firebrick': 'b22222', + 'maroon': '800000', + 'gainsboro': 'dcdcdc', +} + +_colors = {key: utils.rgba(val) for key, val in _colors2hexs.items()} + + def colorcode(name): - if not name: - name = 'gray' - _names2hexs = { - 'darkgray': 'a9a9a9', - 'gray': '808080', - 'green': '008000', - 'red2': 'ff3333', - 'red': 'ff0000', - 'dark_red': '8b0000', - 'firebrick': 'b22222', - 'maroon': '800000', - 'gainsboro': 'dcdcdc', - } - return utils.rgba(_names2hexs[name]) + return _colors[name if name else 'gray'] _kv = (f''' #:kivy 1.10.0 - - # font_size: 18 - size: self.texture_size - # size_hint_y: None - # height: 50 - outline_color: {same_rgb(0.01)} - width: 50 - valign: 'middle' - halign: 'center' - canvas.before: - Color: - rgb: {same_rgb(0.13)} - Rectangle: - pos: self.pos - size: self.size - text_size: self.size size: self.texture_size # font_size: '15' + # size_hint_y: None font_color: {colorcode('gray')} # font_name: 'sans serif' + # height: 50 + # width: 50 valign: 'middle' halign: 'center' - # outline_color: {same_rgb(0.01)} + outline_color: {same_rgb(0.001)} canvas.before: Color: - rgb: {same_rgb(0.06)} - Rectangle: + rgb: {same_rgb(0.03)} + RoundedRectangle: pos: self.pos size: self.size + radius: [7,] + + + canvas.before: + Color: + rgb: {same_rgb(0.12)} + RoundedRectangle: + pos: self.pos + size: self.size + radius: [7,] spacing: '5dp' row_force_default: True row_default_height: 75 - # size_hint_y: None - size_hint: 1, None cols: 1 @@ -93,8 +89,7 @@ _kv = (f''' minimum_width: 200 row_force_default: True row_default_height: 75 - outline_color: {same_rgb(2)} - size_hint: 1, None + outline_color: {same_rgb(.7)} ''') @@ -188,7 +183,9 @@ class TickerTable(GridLayout): super(TickerTable, self).__init__(**kwargs) self.symbols2rows = {} - def append_row(self, record, colorname='firebrick'): + def append_row(self, record): + """Append a `Row` of `Cell` objects to this table. + """ row = Row(record, headers=('symbol',)) # store ref to each row self.symbols2rows[row._last_record['symbol']] = row @@ -196,19 +193,18 @@ class TickerTable(GridLayout): return row -def header_row(headers): +def header_row(headers, **kwargs): """Create a single "header" row from a sequence of keys. """ - # process headers via first quote record headers_dict = {key: key for key in headers} - row = Row(headers_dict, headers=headers) + row = Row(headers_dict, headers=headers, **kwargs) return row def ticker_table(quotes, **kwargs): """Create a new ticker table from a list of quote dicts. """ - table = TickerTable(cols=1) + table = TickerTable(cols=1, **kwargs) for ticker_record in quotes: table.append_row(ticker_record) return table @@ -229,9 +225,9 @@ async def update_quotes( chngcell = row._cell_widgets['%'] daychange = float(data['%']) if daychange < 0.: - color = colorcode('red') + color = colorcode('red2') elif daychange > 0.: - color = colorcode('green') + color = colorcode('forestgreen') else: color = colorcode('gray') @@ -273,8 +269,6 @@ async def update_quotes( for i, (data, row) in enumerate( sorted(all_rows, key=lambda item: float(item[0]['%'])) ): - # print(f"{i} {data['symbol']}") - # grid.remove_widget(row) grid.add_widget(row, index=i) @@ -312,12 +306,18 @@ async def _async_main(tickers, brokermod): # build out UI Builder.load_string(_kv) - root = BoxLayout(orientation='vertical') - header = header_row(first_quotes[0].keys()) + root = BoxLayout(orientation='vertical', padding=5, spacing=-20) + header = header_row( + first_quotes[0].keys(), + size_hint=(1, None), + ) root.add_widget(header) - grid = ticker_table(first_quotes) + grid = ticker_table( + first_quotes, + size_hint=(1, None), + ) grid.bind(minimum_height=grid.setter('height')) - scroll = ScrollView(bar_margin=10, viewport_size=(10, 10)) + scroll = ScrollView() scroll.add_widget(grid) root.add_widget(scroll) @@ -356,6 +356,7 @@ def run(loglevel, broker): 'WMD.VN', 'HEMP.VN', 'CALI.CN', 'RBQ.CN', ], } - # broker_conf_path = os.path.join(click.get_app_dir('piker'), 'watchlists.json') + # broker_conf_path = os.path.join( + # click.get_app_dir('piker'), 'watchlists.json') # from piker.testing import _quote_streamer as brokermod trio.run(_async_main, watchlists['cannabis'], brokermod) From e220e9b658c91870aae8e15238156bc8d739ecdb Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 9 Feb 2018 03:01:58 -0500 Subject: [PATCH 11/18] Move cli mod to top level package --- piker/{brokers => }/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename piker/{brokers => }/cli.py (98%) diff --git a/piker/brokers/cli.py b/piker/cli.py similarity index 98% rename from piker/brokers/cli.py rename to piker/cli.py index 7dc5086e..ed864eba 100644 --- a/piker/brokers/cli.py +++ b/piker/cli.py @@ -8,7 +8,7 @@ import click import trio import pandas as pd -from ..log import get_console_log, colorize_json +from .log import get_console_log, colorize_json def run(main, loglevel='info'): From 37a4d2e5f89b22b1c2996f39df96cb1be75484fb Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 9 Feb 2018 03:29:10 -0500 Subject: [PATCH 12/18] Expose watchlist app via `piker watch` for now --- piker/cli.py | 27 +++++++++++++++++++++++++++ piker/ui/watchlist.py | 35 ++++------------------------------- setup.py | 3 +-- 3 files changed, 32 insertions(+), 33 deletions(-) diff --git a/piker/cli.py b/piker/cli.py index ed864eba..9cfc015b 100644 --- a/piker/cli.py +++ b/piker/cli.py @@ -102,3 +102,30 @@ def stream(broker, loglevel, tickers, keys): ]), loglevel ) + + +@cli.command() +@click.option('--broker', default='questrade', help='Broker backend to use') +@click.option('--loglevel', '-l', default='warning', help='Logging level') +@click.argument('watchlist-name', nargs=1, required=True) +def watch(loglevel, broker, watchlist_name): + """Spawn a watchlist. + """ + from .ui.watchlist import _async_main + get_console_log(loglevel) # activate console logging + brokermod = import_module('.' + broker, 'piker.brokers') + + watchlists = { + 'cannabis': [ + 'EMH.VN', 'LEAF.TO', 'HVT.VN', 'HMMJ.TO', 'APH.TO', + 'CBW.VN', 'TRST.CN', 'VFF.TO', 'ACB.TO', 'ABCN.VN', + 'APH.TO', 'MARI.CN', 'WMD.VN', 'LEAF.TO', 'THCX.VN', + 'WEED.TO', 'NINE.VN', 'RTI.VN', 'SNN.CN', 'ACB.TO', + 'OGI.VN', 'IMH.VN', 'FIRE.VN', 'EAT.CN', 'NUU.VN', + 'WMD.VN', 'HEMP.VN', 'CALI.CN', 'RBQ.CN', + ], + } + # broker_conf_path = os.path.join( + # click.get_app_dir('piker'), 'watchlists.json') + # from piker.testing import _quote_streamer as brokermod + trio.run(_async_main, watchlists[watchlist_name], brokermod) diff --git a/piker/ui/watchlist.py b/piker/ui/watchlist.py index 9724029b..98dcca58 100644 --- a/piker/ui/watchlist.py +++ b/piker/ui/watchlist.py @@ -1,5 +1,7 @@ """ -A real-time, sorted watchlist +A real-time, sorted watchlist. + +Launch with ``piker watch ``. """ from importlib import import_module @@ -15,6 +17,7 @@ from kivy import utils from kivy.app import async_runTouchApp +from ..cli import cli from ..log import get_logger, get_console_log log = get_logger('watchlist') @@ -330,33 +333,3 @@ async def _async_main(tickers, brokermod): nursery.start_soon(run_kivy, widgets['root'], nursery) nursery.start_soon(update_quotes, widgets, queue, sd, first_quotes) - - -@click.group() -def cli(): - pass - - -@cli.command() -@click.option('--broker', default='questrade', help='Broker backend to use') -@click.option('--loglevel', '-l', default='warning', help='Logging level') -def run(loglevel, broker): - """Spawn a watchlist. - """ - get_console_log(loglevel) # activate console logging - brokermod = import_module('.' + broker, 'piker.brokers') - - watchlists = { - 'cannabis': [ - 'EMH.VN', 'LEAF.TO', 'HVT.VN', 'HMMJ.TO', 'APH.TO', - 'CBW.VN', 'TRST.CN', 'VFF.TO', 'ACB.TO', 'ABCN.VN', - 'APH.TO', 'MARI.CN', 'WMD.VN', 'LEAF.TO', 'THCX.VN', - 'WEED.TO', 'NINE.VN', 'RTI.VN', 'SNN.CN', 'ACB.TO', - 'OGI.VN', 'IMH.VN', 'FIRE.VN', 'EAT.CN', 'NUU.VN', - 'WMD.VN', 'HEMP.VN', 'CALI.CN', 'RBQ.CN', - ], - } - # broker_conf_path = os.path.join( - # click.get_app_dir('piker'), 'watchlists.json') - # from piker.testing import _quote_streamer as brokermod - trio.run(_async_main, watchlists['cannabis'], brokermod) diff --git a/setup.py b/setup.py index 4a729053..71b5ed49 100755 --- a/setup.py +++ b/setup.py @@ -30,8 +30,7 @@ setup( ], entry_points={ 'console_scripts': [ - 'piker = piker.brokers.cli:cli', - 'watchlist = piker.ui.watchlist:cli', + 'piker = piker.cli:cli', ] }, install_requires=[ From 0997418a472c42209a1b104a85e1bcc5bffa0fa9 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 9 Feb 2018 22:01:14 -0500 Subject: [PATCH 13/18] Be PC --- piker/log.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piker/log.py b/piker/log.py index 3bef481c..4d5c4c01 100644 --- a/piker/log.py +++ b/piker/log.py @@ -11,7 +11,7 @@ _proj_name = 'piker' # Super sexy formatting thanks to ``colorlog``. # (NOTE: we use the '{' format style) -# Here, `thin_white` is just the laymen's gray. +# Here, `thin_white` is just the layperson's gray. LOG_FORMAT = ( # "{bold_white}{log_color}{asctime}{reset}" "{log_color}{asctime}{reset}" From 1af14bc46f3e814281e2ca8618cb95d9c3f245b7 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 9 Feb 2018 22:03:37 -0500 Subject: [PATCH 14/18] Add watchlist sort-by-column and row header highlighting --- piker/calc.py | 15 +++++ piker/cli.py | 2 +- piker/ui/watchlist.py | 127 +++++++++++++++++++++++++++++++----------- 3 files changed, 112 insertions(+), 32 deletions(-) create mode 100644 piker/calc.py diff --git a/piker/calc.py b/piker/calc.py new file mode 100644 index 00000000..b3459e98 --- /dev/null +++ b/piker/calc.py @@ -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]) diff --git a/piker/cli.py b/piker/cli.py index 9cfc015b..a3e7ffac 100644 --- a/piker/cli.py +++ b/piker/cli.py @@ -128,4 +128,4 @@ def watch(loglevel, broker, watchlist_name): # broker_conf_path = os.path.join( # click.get_app_dir('piker'), 'watchlists.json') # from piker.testing import _quote_streamer as brokermod - trio.run(_async_main, watchlists[watchlist_name], brokermod) + trio.run(_async_main, watchlist_name, watchlists, brokermod) diff --git a/piker/ui/watchlist.py b/piker/ui/watchlist.py index 98dcca58..6dc0c869 100644 --- a/piker/ui/watchlist.py +++ b/piker/ui/watchlist.py @@ -2,14 +2,14 @@ A real-time, sorted watchlist. Launch with ``piker watch ``. -""" -from importlib import import_module -import click +(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 @@ -17,8 +17,8 @@ from kivy import utils from kivy.app import async_runTouchApp -from ..cli import cli -from ..log import get_logger, get_console_log +from ..calc import humanize +from ..log import get_logger log = get_logger('watchlist') @@ -54,24 +54,28 @@ _kv = (f''' text_size: self.size size: self.texture_size - # font_size: '15' + font_size: '18sp' # size_hint_y: None font_color: {colorcode('gray')} - # font_name: 'sans serif' + font_name: 'Roboto-Regular' # height: 50 # width: 50 + background_color: 0,0,0,0 valign: 'middle' halign: 'center' - outline_color: {same_rgb(0.001)} + # outline_color: {same_rgb(0.001)} canvas.before: Color: - rgb: {same_rgb(0.03)} + rgb: {same_rgb(0.05)} RoundedRectangle: pos: self.pos size: self.size radius: [7,] - + + bold: True + font_size: '18sp' + background_color: 0,0,0,0 canvas.before: Color: rgb: {same_rgb(0.12)} @@ -97,15 +101,15 @@ _kv = (f''' _qt_keys = { - # 'symbol': 'symbol', # done manually in remap_keys + # 'symbol': 'symbol', # done manually in qtconvert 'lastTradePrice': 'last', - 'lastTradeSize': 'last size', 'askPrice': 'ask', - 'askSize': 'ask size', 'bidPrice': 'bid', + 'lastTradeSize': 'last size', 'bidSize': 'bid size', - 'volume': 'vol', - 'VWAP': 'VWAP', + 'askSize': 'ask size', + 'volume': ('vol', humanize), + 'VWAP': ('VWAP', "{:.3f}".format), 'high52w': 'high52w', 'highPrice': 'high', # "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 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(): value = quote[key] + if isinstance(new_key, tuple): + new_key, func = new_key + value = func(value) + new[new_key] = value return new -class HeaderCell(Label): +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 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): - """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 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) 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, colorname=None, header=False): if not len(self._cell_widgets) < self.cols: @@ -175,6 +216,8 @@ class Row(GridLayout): # header cells just have a different colour celltype = HeaderCell if header else Cell cell = celltype(text=str(text), color=colorcode(colorname)) + cell.is_header = header + cell.row = self self.add_widget(cell) return cell @@ -182,14 +225,17 @@ class Row(GridLayout): class TickerTable(GridLayout): """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) 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',)) + row = Row(record, headers=('symbol',), table=self) # store ref to each row self.symbols2rows[row._last_record['symbol']] = row self.add_widget(row) @@ -200,7 +246,7 @@ 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, **kwargs) + row = Row(headers_dict, headers=headers, is_header_row=True, **kwargs) return row @@ -232,23 +278,30 @@ async def update_quotes( elif daychange > 0.: color = colorcode('forestgreen') else: - color = colorcode('gray') + 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 - all_rows = [] + syms2rows = {} for quote in first_quotes: - row = grid.symbols2rows[quote['symbol']] - all_rows.append((quote, row)) + sym = quote['symbol'] + row = grid.symbols2rows[sym] + syms2rows[sym] = row color_row(row, quote) while True: log.debug("Waiting on quotes") quotes = await queue.get() + datas = [] for quote in quotes: - data = remap_keys(quote, symbol_data=symbol_data) + data = 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(): @@ -269,10 +322,13 @@ async def update_quotes( # sort rows by daily % change since open grid.clear_widgets() + sort_key = grid.sort_key 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): @@ -284,9 +340,10 @@ async def run_kivy(root, nursery): nursery.cancel_scope.cancel() -async def _async_main(tickers, brokermod): +async def _async_main(name, watchlists, brokermod): '''Launch kivy app + all other related tasks. ''' + tickers = watchlists[name] queue = trio.Queue(1000) async with brokermod.get_client() as client: @@ -305,7 +362,7 @@ async def _async_main(tickers, brokermod): return first_quotes = [ - remap_keys(quote, symbol_data=sd) for quote in pkts] + qtconvert(quote, symbol_data=sd) for quote in pkts] # build out UI Builder.load_string(_kv) @@ -319,6 +376,14 @@ async def _async_main(tickers, brokermod): 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.bind(minimum_height=grid.setter('height')) scroll = ScrollView() scroll.add_widget(grid) From 488f3988ea17fc7a7be149e7e4105e4e0dd247e6 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 10 Feb 2018 19:44:41 -0500 Subject: [PATCH 15/18] Handle weekend errors --- piker/brokers/questrade.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/piker/brokers/questrade.py b/piker/brokers/questrade.py index 950b7070..f956cc11 100644 --- a/piker/brokers/questrade.py +++ b/piker/brokers/questrade.py @@ -40,7 +40,8 @@ def resproc( try: data = resp.json() except json.decoder.JSONDecodeError: - log.exception(f"Failed to process {resp}") + log.exception(f"Failed to process {resp}:\n{resp.text}") + raise QuestradeError(resp.text) else: log.debug(f"Received json contents:\n{colorize_json(data)}") @@ -124,8 +125,12 @@ class Client: try: data = await self._new_auth_token() except QuestradeError as qterr: - # likely config ``refresh_token`` is expired - if qterr.args[0].decode() == 'Bad Request': + if "We're making some changes" in qterr.args[0]: + # API service is down + raise QuestradeError("API is down for maintenance") + + elif qterr.args[0].decode() == 'Bad Request': + # likely config ``refresh_token`` is expired _token_from_user(self._conf) self._apply_config(self._conf) data = await self._new_auth_token() From f4fd35fa21917aed459fd32f8ff5db6cb5b48449 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 10 Feb 2018 19:54:09 -0500 Subject: [PATCH 16/18] Separate sortable and dislplayable quote values --- piker/calc.py | 9 ++++ piker/ui/watchlist.py | 106 ++++++++++++++++++++++++------------------ 2 files changed, 69 insertions(+), 46 deletions(-) diff --git a/piker/calc.py b/piker/calc.py index b3459e98..3d080087 100644 --- a/piker/calc.py +++ b/piker/calc.py @@ -9,7 +9,16 @@ def humanize(number): """Convert large numbers to something with at most 3 digits and a letter suffix (eg. k: thousand, M: million, B: billion). """ + if number <= 0: + return number 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]) + + +def percent_change(init, new): + """Calcuate the percentage change of some ``new`` value + from some initial value, ``init``. + """ + return (new - init) / init * 100. diff --git a/piker/ui/watchlist.py b/piker/ui/watchlist.py index 6dc0c869..035965b2 100644 --- a/piker/ui/watchlist.py +++ b/piker/ui/watchlist.py @@ -6,7 +6,6 @@ Launch with ``piker watch ``. (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 @@ -17,28 +16,19 @@ from kivy import utils from kivy.app import async_runTouchApp -from ..calc import humanize +from ..calc import humanize, percent_change from ..log import get_logger log = get_logger('watchlist') -def same_rgb(val): - return ', '.join(map(str, [val]*3)) - - _colors2hexs = { 'darkgray': 'a9a9a9', 'gray': '808080', 'green': '008000', 'forestgreen': '228b22', - 'seagreen': '2e8b57', 'red2': 'ff3333', 'red': 'ff0000', - 'tomato': 'ff6347', - 'darkred': '8b0000', 'firebrick': 'b22222', - 'maroon': '800000', - 'gainsboro': 'dcdcdc', } _colors = {key: utils.rgba(val) for key, val in _colors2hexs.items()} @@ -54,35 +44,37 @@ _kv = (f''' text_size: self.size size: self.texture_size - font_size: '18sp' + font_size: '20' # size_hint_y: None font_color: {colorcode('gray')} font_name: 'Roboto-Regular' # height: 50 # width: 50 - background_color: 0,0,0,0 + background_color: [0]*4 valign: 'middle' halign: 'center' - # outline_color: {same_rgb(0.001)} + outline_color: [0.1]*4 canvas.before: Color: - rgb: {same_rgb(0.05)} - RoundedRectangle: + rgb: [0.08]*4 + Rectangle: pos: self.pos size: self.size - radius: [7,] bold: True - font_size: '18sp' + font_size: '20' background_color: 0,0,0,0 canvas.before: Color: - rgb: {same_rgb(0.12)} - RoundedRectangle: + rgb: [0.13]*4 + Rectangle: pos: self.pos size: self.size - radius: [7,] + # RoundedRectangle: + # pos: self.pos + # size: self.size + # radius: [8,] spacing: '5dp' @@ -91,15 +83,15 @@ _kv = (f''' cols: 1 - spacing: '6dp' minimum_height: 200 # should be pulled from Cell text size minimum_width: 200 row_force_default: True row_default_height: 75 - outline_color: {same_rgb(.7)} + outline_color: [.7]*4 ''') +# Questrade key conversion _qt_keys = { # 'symbol': 'symbol', # done manually in qtconvert 'lastTradePrice': 'last', @@ -125,28 +117,42 @@ _qt_keys = { } -def qtconvert(quote: dict, keymap: dict = _qt_keys, symbol_data: dict = None): - """Remap a list of quote dicts ``quotes`` using - the mapping of old keys -> new keys ``keymap``. +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 = (quote['lastTradePrice'] - previous) / previous * 100 + change = percent_change(previous, quote['lastTradePrice']) else: change = 0 new = { 'symbol': quote['symbol'], - '%': f"{change:.2f}" + '%': round(change, 3) } + displayable = new.copy() + for key, new_key in keymap.items(): - value = quote[key] + 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 - value = func(value) + display_value = func(value) new[new_key] = value + displayable[new_key] = display_value - return new + return new, displayable class HeaderCell(Button): @@ -158,7 +164,7 @@ class HeaderCell(Button): if self.row.is_header: self.row.table.sort_key = self.key - last = self.row.table._last_clicked_col_cell + last = self.row.table.last_clicked_col_cell if last and last is not self: last.underline = False last.bold = False @@ -167,10 +173,9 @@ class HeaderCell(Button): self.underline = True self.bold = True # mark this cell as the last - self.row.table._last_clicked_col_cell = self + self.row.table.last_clicked_col_cell = self - # allow highlighting row headers for visual following of - # specific tickers + # allow highlighting of row headers for tracking elif self.is_header: if self.background_color == self.color: self.background_color = [0]*4 @@ -200,7 +205,7 @@ class Row(GridLayout): self.is_header = is_header_row # build out row using Cell labels - for key, val in record.items(): + for (key, val) in record.items(): header = key in headers cell = self._append_cell(val, header=header) self._cell_widgets[key] = cell @@ -209,15 +214,20 @@ class Row(GridLayout): def get_cell(self, key): return self._cell_widgets[key] - def _append_cell(self, text, colorname=None, header=False): + 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), color=colorcode(colorname)) + 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 @@ -230,7 +240,7 @@ class TickerTable(GridLayout): self.symbols2rows = {} self.sort_key = sort_key # for tracking last clicked column header cell - self._last_clicked_col_cell = None + self.last_clicked_col_cell = None def append_row(self, record): """Append a `Row` of `Cell` objects to this table. @@ -294,12 +304,13 @@ async def update_quotes( 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 = qtconvert(quote, symbol_data=symbol_data) + data, displayable = qtconvert(quote, symbol_data=symbol_data) row = grid.symbols2rows[data['symbol']] datas.append((data, row)) @@ -314,7 +325,7 @@ async def update_quotes( color = colorcode('gray') cell = row._cell_widgets[key] - cell.text = str(val) + cell.text = str(displayable[key]) cell.color = color color_row(row, data) @@ -327,8 +338,6 @@ async def update_quotes( reversed(sorted(datas, key=lambda item: item[0][sort_key])) ): grid.add_widget(row) # row append - # print(f'{i} {data["symbol"]} {data["%"]}') - # await trio.sleep(0.1) async def run_kivy(root, nursery): @@ -342,6 +351,8 @@ async def run_kivy(root, nursery): 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) @@ -362,7 +373,7 @@ async def _async_main(name, watchlists, brokermod): return first_quotes = [ - qtconvert(quote, symbol_data=sd) for quote in pkts] + qtconvert(quote, symbol_data=sd)[0] for quote in pkts] # build out UI Builder.load_string(_kv) @@ -370,6 +381,8 @@ async def _async_main(name, watchlists, brokermod): 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( @@ -377,12 +390,13 @@ async def _async_main(name, watchlists, brokermod): size_hint=(1, None), ) - # associate the col headers row with the ticker table even - # though they're technically wrapped separately in containing BoxLayout + # 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() From 9f3efd2a6a366d392a182d69824096abec8721d4 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 12 Feb 2018 10:35:54 -0500 Subject: [PATCH 17/18] Import client for now until we make a proper shim --- piker/testing/_quote_streamer.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/piker/testing/_quote_streamer.py b/piker/testing/_quote_streamer.py index 109f15de..719bbe44 100644 --- a/piker/testing/_quote_streamer.py +++ b/piker/testing/_quote_streamer.py @@ -6,13 +6,17 @@ import json from os import path import trio from async_generator import asynccontextmanager +from ..brokers import questrade +from ..calc import percent_change -@asynccontextmanager -async def get_client() -> None: - """Shim client factory. - """ - yield None +get_client = questrade.get_client + +# @asynccontextmanager +# async def get_client() -> None: +# """Shim client factory. +# """ +# yield None async def poll_tickers( @@ -23,7 +27,6 @@ async def poll_tickers( content = quotes_file.read() pkts = content.split('--') # simulate 2 separate quote packets - # import pdb; pdb.set_trace() payloads = [json.loads(pkt)['quotes'] for pkt in pkts] for payload in cycle(payloads): From 42f48c82fc6ca858452fde671ec73dfe118f4a95 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 12 Feb 2018 10:55:04 -0500 Subject: [PATCH 18/18] Update readme; use async kivy branch --- README.rst | 18 ++++++++++++++++++ requirements.txt | 2 ++ setup.py | 3 ++- 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 requirements.txt diff --git a/README.rst b/README.rst index 42f0f2cc..5a5107e3 100644 --- a/README.rst +++ b/README.rst @@ -8,6 +8,24 @@ Install ``piker`` is currently under heavy alpha development and as such should be cloned from this repo and hacked on directly. +A couple very alpha components are being used atm pertaining to +async ports of libraries for use with ``trio``. + +Before installing make sure you have ``pip`` and ``virtualenv``. + +Then for a development install:: + + $ git clone git@github.com:pikers/piker.git + $ cd piker + $ virtualenv env + $ source ./env/bin/activate + (env) $ pip install cython + (env) $ pip install -e ./ -r requirements.txt + +To start the real-time watchlist:: + + (env) $ piker watch cannabis + If you insist on trying to install it (which should work) please do it from this GitHub repository:: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..26f8be53 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# matham's next-gen async port of kivy +git+git://github.com/matham/kivy.git@async-loop diff --git a/setup.py b/setup.py index 71b5ed49..07d86eb4 100755 --- a/setup.py +++ b/setup.py @@ -35,7 +35,8 @@ setup( }, install_requires=[ 'click', 'colorlog', 'trio', 'attrs', 'async_generator', - 'pygments', 'cython', 'kivy', 'asks', 'pandas', + 'pygments', 'cython', 'asks', 'pandas', + #'kivy', see requirement.txt; using a custom branch atm ], extras_require={ 'questrade': ['asks'],