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/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/cli.py b/piker/brokers/cli.py deleted file mode 100644 index b5501633..00000000 --- a/piker/brokers/cli.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -Console interface to broker client/daemons. -""" -from functools import partial -from importlib import import_module - -import click -import trio - -from ..log import get_console_log, colorize_json - - -def run(main, loglevel='info'): - log = get_console_log(loglevel) - - # main sandwich - try: - return trio.run(main) - except Exception as err: - log.exception(err) - finally: - log.debug("Exiting piker") - - -@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') -@click.argument('meth', nargs=1) -@click.argument('kwargs', nargs=-1) -def api(meth, kwargs, loglevel, broker): - """client for testing broker API methods with pretty printing of output. - """ - log = get_console_log(loglevel) - brokermod = import_module('.' + broker, 'piker.brokers') - - _kwargs = {} - for kwarg in kwargs: - if '=' not in kwarg: - log.error(f"kwarg `{kwarg}` must be of form =") - else: - key, _, value = kwarg.partition('=') - _kwargs[key] = value - - data = run(partial(brokermod.api, meth, **_kwargs), loglevel=loglevel) - if data: - click.echo(colorize_json(data)) - - -@cli.command() -@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): - # import broker module daemon entry point - bm = import_module('.' + broker, 'piker.brokers') - run( - partial(bm.serve_forever, [ - partial(bm.poll_tickers, tickers=tickers) - ]), - loglevel - ) 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/brokers/questrade.py b/piker/brokers/questrade.py index 3c30efea..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)}") @@ -49,11 +50,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 = {} @@ -122,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() @@ -151,9 +158,28 @@ 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))['quotes'] -class API: - """Questrade API at its finest. + 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: + """Questrade API endpoints exposed as methods and wrapped with an + http session. """ def __init__(self, session: asks.Session): self._sess = session @@ -183,6 +209,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 +229,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 +297,55 @@ 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 = 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. """ 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) + + payload = [] + for quote in quotes: + + 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: + 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 +362,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) diff --git a/piker/calc.py b/piker/calc.py new file mode 100644 index 00000000..3d080087 --- /dev/null +++ b/piker/calc.py @@ -0,0 +1,24 @@ +""" +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). + """ + 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/cli.py b/piker/cli.py new file mode 100644 index 00000000..a3e7ffac --- /dev/null +++ b/piker/cli.py @@ -0,0 +1,131 @@ +""" +Console interface to broker client/daemons. +""" +from functools import partial +from importlib import import_module + +import click +import trio +import pandas as pd + +from .log import get_console_log, colorize_json + + +def run(main, loglevel='info'): + log = get_console_log(loglevel) + + # main sandwich + try: + return trio.run(main) + except Exception as err: + log.exception(err) + finally: + log.debug("Exiting piker") + + +@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') +@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, keys): + """client for testing broker API methods with pretty printing of output. + """ + log = get_console_log(loglevel) + brokermod = import_module('.' + broker, 'piker.brokers') + + _kwargs = {} + for kwarg in kwargs: + if '=' not in kwarg: + log.error(f"kwarg `{kwarg}` must be of form =") + else: + key, _, value = kwarg.partition('=') + _kwargs[key] = value + + data = run(partial(brokermod.api, meth, **_kwargs), loglevel=loglevel) + + 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') + quotes = run(partial(brokermod.quote, tickers), loglevel=loglevel) + cols = quotes[0].copy() + cols.pop('symbol') + if df_output: + df = pd.DataFrame( + quotes, + index=[item['symbol'] for item in quotes], + columns=cols, + ) + click.echo(df) + else: + click.echo(colorize_json(quotes)) + + +@cli.command() +@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, keys): + # import broker module daemon entry point + bm = import_module('.' + broker, 'piker.brokers') + run( + partial(bm.serve_forever, [ + partial(bm.poll_tickers, tickers=tickers) + ]), + 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, watchlist_name, watchlists, brokermod) diff --git a/piker/log.py b/piker/log.py index a849dbce..4d5c4c01 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 @@ -12,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}" 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..719bbe44 --- /dev/null +++ b/piker/testing/_quote_streamer.py @@ -0,0 +1,34 @@ +""" +Mock a broker module. +""" +from itertools import cycle +import json +from os import path +import trio +from async_generator import asynccontextmanager +from ..brokers import questrade +from ..calc import percent_change + + +get_client = questrade.get_client + +# @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 + 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 + } + ] +} 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 new file mode 100644 index 00000000..035965b2 --- /dev/null +++ b/piker/ui/watchlist.py @@ -0,0 +1,414 @@ +""" +A real-time, sorted watchlist. + +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 +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 + + + 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 + + + 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,] + + + spacing: '5dp' + row_force_default: True + row_default_height: 75 + cols: 1 + + + 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) 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 59974c6e..07d86eb4 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', + 'piker = piker.cli:cli', ] }, install_requires=[ 'click', 'colorlog', 'trio', 'attrs', 'async_generator', - 'pygments', + 'pygments', 'cython', 'asks', 'pandas', + #'kivy', see requirement.txt; using a custom branch atm ], extras_require={ 'questrade': ['asks'],