Merge pull request #4 from pikers/watchlist

Watchlist baby!
kivy_mainline_and_py3.8
goodboy 2018-02-12 12:24:24 -05:00 committed by GitHub
commit 7d794e9187
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 973 additions and 86 deletions

View File

@ -8,6 +8,24 @@ Install
``piker`` is currently under heavy alpha development and as such should ``piker`` is currently under heavy alpha development and as such should
be cloned from this repo and hacked on directly. 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 If you insist on trying to install it (which should work) please do it
from this GitHub repository:: from this GitHub repository::

View File

@ -0,0 +1,3 @@
"""
piker: trading toolz for hackerz.
"""

View File

@ -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 <key>=<value>")
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
)

View File

@ -3,11 +3,12 @@ Broker configuration mgmt.
""" """
from os import path from os import path
import configparser import configparser
import click
from ..log import get_logger from ..log import get_logger
log = get_logger('broker-config') 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): def load() -> (configparser.ConfigParser, str):

View File

@ -40,7 +40,8 @@ def resproc(
try: try:
data = resp.json() data = resp.json()
except json.decoder.JSONDecodeError: 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: else:
log.debug(f"Received json contents:\n{colorize_json(data)}") log.debug(f"Received json contents:\n{colorize_json(data)}")
@ -49,11 +50,13 @@ def resproc(
class Client: class Client:
"""API client suitable for use as a long running broker daemon or """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'): def __init__(self, config: 'configparser.ConfigParser'):
self._sess = asks.Session() self._sess = asks.Session()
self.api = API(self._sess) self.api = _API(self._sess)
self._conf = config self._conf = config
self.access_data = {} self.access_data = {}
self.user_data = {} self.user_data = {}
@ -122,8 +125,12 @@ class Client:
try: try:
data = await self._new_auth_token() data = await self._new_auth_token()
except QuestradeError as qterr: except QuestradeError as qterr:
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 # likely config ``refresh_token`` is expired
if qterr.args[0].decode() == 'Bad Request':
_token_from_user(self._conf) _token_from_user(self._conf)
self._apply_config(self._conf) self._apply_config(self._conf)
data = await self._new_auth_token() data = await self._new_auth_token()
@ -151,9 +158,28 @@ class Client:
return symbols2ids 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: async def symbols(self, tickers):
"""Questrade API at its finest. """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): def __init__(self, session: asks.Session):
self._sess = session self._sess = session
@ -183,6 +209,15 @@ class API:
async def quotes(self, ids: str) -> dict: async def quotes(self, ids: str) -> dict:
return await self._request('markets/quotes', params={'ids': ids}) 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): async def token_refresher(client):
"""Coninually refresh the ``access_token`` near its expiry time. """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: 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: ") refresh_token = input("Please provide your Questrade access token: ")
conf['questrade'] = {'refresh_token': refresh_token} conf['questrade'] = {'refresh_token': refresh_token}
@ -261,21 +297,55 @@ async def serve_forever(tasks) -> None:
nursery.start_soon(task, client) nursery.start_soon(task, client)
async def poll_tickers(client, tickers, rate=2): async def poll_tickers(
"""Auto-poll snap quotes for a sequence of tickers at the given ``rate`` 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. per second.
""" """
t2ids = await client.tickers2ids(tickers) t2ids = await client.tickers2ids(tickers)
sleeptime = 1. / rate
ids = ','.join(map(str, t2ids.values())) ids = ','.join(map(str, t2ids.values()))
sleeptime = 1. / rate
_cache = {}
while True: # use an event here to trigger exit? while True: # use an event here to trigger exit?
quote_data = await client.api.quotes(ids=ids) quotes_resp = await client.api.quotes(ids=ids)
await trio.sleep(sleeptime) 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: async def api(methname: str, **kwargs) -> dict:
"""Make (proxy) through an api call by name and return its result. """Make (proxy through) an api call by name and return its result.
""" """
async with get_client() as client: async with get_client() as client:
meth = getattr(client.api, methname, None) meth = getattr(client.api, methname, None)
@ -292,3 +362,10 @@ async def api(methname, **kwargs) -> dict:
return return
return await meth(**kwargs) 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)

24
piker/calc.py 100644
View File

@ -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.

131
piker/cli.py 100644
View File

@ -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 <key>=<value>")
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)

View File

@ -1,6 +1,5 @@
""" """
Log like a forester! Log like a forester!
(You can't usually find stupid suits in the forest)
""" """
import sys import sys
import logging import logging
@ -12,7 +11,7 @@ _proj_name = 'piker'
# Super sexy formatting thanks to ``colorlog``. # Super sexy formatting thanks to ``colorlog``.
# (NOTE: we use the '{' format style) # (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 = ( LOG_FORMAT = (
# "{bold_white}{log_color}{asctime}{reset}" # "{bold_white}{log_color}{asctime}{reset}"
"{log_color}{asctime}{reset}" "{log_color}{asctime}{reset}"

View File

View File

@ -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.)

View File

@ -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
}
]
}

View File

@ -0,0 +1,7 @@
"""
Stuff for you eyes.
"""
import os
# use the trio async loop
os.environ['KIVY_EVENTLOOP'] = 'trio'

View File

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

2
requirements.txt 100644
View File

@ -0,0 +1,2 @@
# matham's next-gen async port of kivy
git+git://github.com/matham/kivy.git@async-loop

View File

@ -25,15 +25,18 @@ setup(
packages=[ packages=[
'piker', 'piker',
'piker.brokers', 'piker.brokers',
'piker.ui',
'piker.testing',
], ],
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [
'piker = piker.brokers.cli:cli', 'piker = piker.cli:cli',
] ]
}, },
install_requires=[ install_requires=[
'click', 'colorlog', 'trio', 'attrs', 'async_generator', 'click', 'colorlog', 'trio', 'attrs', 'async_generator',
'pygments', 'pygments', 'cython', 'asks', 'pandas',
#'kivy', see requirement.txt; using a custom branch atm
], ],
extras_require={ extras_require={
'questrade': ['asks'], 'questrade': ['asks'],