commit
7d794e9187
18
README.rst
18
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::
|
||||
|
||||
|
|
|
@ -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
|
||||
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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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!
|
||||
(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}"
|
||||
|
|
|
@ -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=[
|
||||
'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'],
|
||||
|
|
Loading…
Reference in New Issue