commit
7d794e9187
18
README.rst
18
README.rst
|
@ -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::
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
piker: trading toolz for hackerz.
|
||||||
|
"""
|
|
@ -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
|
|
||||||
)
|
|
|
@ -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):
|
||||||
|
|
|
@ -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:
|
||||||
# likely config ``refresh_token`` is expired
|
if "We're making some changes" in qterr.args[0]:
|
||||||
if qterr.args[0].decode() == 'Bad Request':
|
# 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)
|
_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)
|
||||||
|
|
|
@ -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.
|
|
@ -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)
|
|
@ -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}"
|
||||||
|
|
|
@ -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.)
|
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""
|
||||||
|
Stuff for you eyes.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# use the trio async loop
|
||||||
|
os.environ['KIVY_EVENTLOOP'] = 'trio'
|
|
@ -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)
|
|
@ -0,0 +1,2 @@
|
||||||
|
# matham's next-gen async port of kivy
|
||||||
|
git+git://github.com/matham/kivy.git@async-loop
|
7
setup.py
7
setup.py
|
@ -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'],
|
||||||
|
|
Loading…
Reference in New Issue