2018-01-20 18:21:59 +00:00
|
|
|
"""
|
|
|
|
Questrade API backend.
|
|
|
|
"""
|
2018-01-23 02:50:26 +00:00
|
|
|
import time
|
2018-11-12 02:05:30 +00:00
|
|
|
from datetime import datetime
|
2018-03-21 14:30:43 +00:00
|
|
|
from functools import partial
|
2018-08-21 15:34:00 +00:00
|
|
|
import configparser
|
2018-11-12 02:05:30 +00:00
|
|
|
from typing import List, Tuple, Dict, Any
|
2018-01-26 19:25:53 +00:00
|
|
|
|
|
|
|
import trio
|
2018-01-23 02:50:26 +00:00
|
|
|
from async_generator import asynccontextmanager
|
2018-01-20 18:21:59 +00:00
|
|
|
|
2018-03-21 14:30:43 +00:00
|
|
|
from ..calc import humanize, percent_change
|
2018-01-26 19:25:53 +00:00
|
|
|
from . import config
|
2018-03-20 17:13:07 +00:00
|
|
|
from ._util import resproc, BrokerError
|
2018-01-26 19:25:53 +00:00
|
|
|
from ..log import get_logger, colorize_json
|
|
|
|
|
2018-01-20 18:21:59 +00:00
|
|
|
# TODO: move to urllib3/requests once supported
|
|
|
|
import asks
|
|
|
|
asks.init('trio')
|
|
|
|
|
2018-03-27 20:24:45 +00:00
|
|
|
log = get_logger(__name__)
|
2018-01-20 18:21:59 +00:00
|
|
|
|
2018-01-23 02:50:26 +00:00
|
|
|
_refresh_token_ep = 'https://login.questrade.com/oauth2/'
|
2018-01-20 18:21:59 +00:00
|
|
|
_version = 'v1'
|
2018-03-21 21:28:26 +00:00
|
|
|
_rate_limit = 3 # queries/sec
|
2018-01-20 18:21:59 +00:00
|
|
|
|
|
|
|
|
2018-01-23 02:50:26 +00:00
|
|
|
class QuestradeError(Exception):
|
2018-01-20 18:21:59 +00:00
|
|
|
"Non-200 OK response code"
|
|
|
|
|
|
|
|
|
2018-11-12 02:05:30 +00:00
|
|
|
class _API:
|
|
|
|
"""Questrade API endpoints exposed as methods and wrapped with an
|
|
|
|
http session.
|
|
|
|
"""
|
|
|
|
def __init__(self, session: asks.Session):
|
|
|
|
self._sess = session
|
|
|
|
|
|
|
|
async def _request(self, path: str, params=None) -> dict:
|
|
|
|
resp = await self._sess.get(path=f'/{path}', params=params)
|
|
|
|
return resproc(resp, log)
|
|
|
|
|
|
|
|
async def accounts(self) -> dict:
|
|
|
|
return await self._request('accounts')
|
|
|
|
|
|
|
|
async def time(self) -> dict:
|
|
|
|
return await self._request('time')
|
|
|
|
|
|
|
|
async def markets(self) -> dict:
|
|
|
|
return await self._request('markets')
|
|
|
|
|
|
|
|
async def search(self, prefix: str) -> dict:
|
|
|
|
return await self._request(
|
|
|
|
'symbols/search', params={'prefix': prefix})
|
|
|
|
|
|
|
|
async def symbols(self, ids: str = '', names: str = '') -> dict:
|
|
|
|
log.debug(f"Symbol lookup for {ids or names}")
|
|
|
|
return await self._request(
|
|
|
|
'symbols', params={'ids': ids, 'names': names})
|
|
|
|
|
|
|
|
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 option_contracts(self, symbol_id: str) -> dict:
|
|
|
|
"Retrieve all option contract API ids with expiry -> strike prices."
|
|
|
|
contracts = await self._request(f'symbols/{symbol_id}/options')
|
|
|
|
return contracts['optionChain']
|
|
|
|
|
|
|
|
async def option_quotes(
|
|
|
|
self,
|
2018-11-13 17:57:21 +00:00
|
|
|
contracts: Dict[int, Dict[str, dict]],
|
2018-11-12 02:05:30 +00:00
|
|
|
option_ids: List[int] = [], # if you don't want them all
|
|
|
|
) -> dict:
|
|
|
|
"Retrieve option chain quotes for all option ids or by filter(s)."
|
|
|
|
filters = [
|
|
|
|
{
|
|
|
|
"underlyingId": int(symbol_id),
|
|
|
|
"expiryDate": str(expiry),
|
2018-11-13 17:57:21 +00:00
|
|
|
}
|
|
|
|
# every expiry per symbol id
|
|
|
|
for symbol_id, expiries in contracts.items()
|
|
|
|
for expiry in expiries
|
2018-11-12 02:05:30 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
resp = await self._sess.post(
|
|
|
|
path=f'/markets/quotes/options',
|
|
|
|
json={'filters': filters, 'optionIds': option_ids}
|
|
|
|
)
|
2018-11-13 17:57:21 +00:00
|
|
|
return resproc(resp, log)['optionQuotes']
|
2018-11-12 02:05:30 +00:00
|
|
|
|
|
|
|
|
2018-01-20 18:21:59 +00:00
|
|
|
class Client:
|
2018-01-27 06:52:00 +00:00
|
|
|
"""API client suitable for use as a long running broker daemon or
|
2018-02-08 07:18:33 +00:00
|
|
|
single api requests.
|
|
|
|
|
|
|
|
Provides a high-level api which wraps the underlying endpoint calls.
|
2018-01-20 18:21:59 +00:00
|
|
|
"""
|
2018-08-21 15:34:00 +00:00
|
|
|
def __init__(self, config: configparser.ConfigParser):
|
2018-01-23 06:05:02 +00:00
|
|
|
self._sess = asks.Session()
|
2018-02-08 07:18:33 +00:00
|
|
|
self.api = _API(self._sess)
|
2018-01-26 19:25:53 +00:00
|
|
|
self._conf = config
|
|
|
|
self.access_data = {}
|
2018-01-23 02:50:26 +00:00
|
|
|
self.user_data = {}
|
2018-02-15 21:40:33 +00:00
|
|
|
self._reload_config(config)
|
2018-01-26 19:25:53 +00:00
|
|
|
|
2018-02-22 23:44:46 +00:00
|
|
|
def _reload_config(self, config=None, **kwargs):
|
2018-08-21 15:34:00 +00:00
|
|
|
log.warn("Reloading access config data")
|
2018-02-22 23:44:46 +00:00
|
|
|
self._conf = config or get_config(**kwargs)
|
2018-01-26 19:25:53 +00:00
|
|
|
self.access_data = dict(self._conf['questrade'])
|
2018-01-20 18:21:59 +00:00
|
|
|
|
2018-01-23 02:50:26 +00:00
|
|
|
async def _new_auth_token(self) -> dict:
|
|
|
|
"""Request a new api authorization ``refresh_token``.
|
|
|
|
|
|
|
|
Gain api access using either a user provided or existing token.
|
|
|
|
See the instructions::
|
2018-01-20 18:21:59 +00:00
|
|
|
|
2018-01-23 02:50:26 +00:00
|
|
|
http://www.questrade.com/api/documentation/getting-started
|
|
|
|
http://www.questrade.com/api/documentation/security
|
2018-01-20 18:21:59 +00:00
|
|
|
"""
|
|
|
|
resp = await self._sess.get(
|
2018-01-23 02:50:26 +00:00
|
|
|
_refresh_token_ep + 'token',
|
2018-01-20 18:21:59 +00:00
|
|
|
params={'grant_type': 'refresh_token',
|
2018-01-23 02:50:26 +00:00
|
|
|
'refresh_token': self.access_data['refresh_token']}
|
2018-01-20 18:21:59 +00:00
|
|
|
)
|
2018-03-20 17:13:07 +00:00
|
|
|
data = resproc(resp, log)
|
2018-01-23 02:50:26 +00:00
|
|
|
self.access_data.update(data)
|
2018-01-20 18:21:59 +00:00
|
|
|
|
2018-01-23 02:50:26 +00:00
|
|
|
return data
|
|
|
|
|
2018-01-23 06:05:02 +00:00
|
|
|
def _prep_sess(self) -> None:
|
2018-01-23 02:50:26 +00:00
|
|
|
"""Fill http session with auth headers and a base url.
|
|
|
|
"""
|
|
|
|
data = self.access_data
|
|
|
|
# set access token header for the session
|
|
|
|
self._sess.headers.update({
|
|
|
|
'Authorization': (f"{data['token_type']} {data['access_token']}")})
|
2018-01-21 01:27:48 +00:00
|
|
|
# set base API url (asks shorthand)
|
2018-01-23 02:50:26 +00:00
|
|
|
self._sess.base_location = self.access_data['api_server'] + _version
|
2018-01-20 18:21:59 +00:00
|
|
|
|
2018-01-23 02:50:26 +00:00
|
|
|
async def _revoke_auth_token(self) -> None:
|
|
|
|
"""Revoke api access for the current token.
|
2018-01-20 18:21:59 +00:00
|
|
|
"""
|
2018-01-23 02:50:26 +00:00
|
|
|
token = self.access_data['refresh_token']
|
|
|
|
log.debug(f"Revoking token {token}")
|
|
|
|
resp = await asks.post(
|
|
|
|
_refresh_token_ep + 'revoke',
|
|
|
|
headers={'token': token}
|
|
|
|
)
|
|
|
|
return resp
|
2018-01-20 18:21:59 +00:00
|
|
|
|
2018-01-26 02:53:55 +00:00
|
|
|
async def ensure_access(self, force_refresh: bool = False) -> dict:
|
|
|
|
"""Acquire new ``access_token`` and/or ``refresh_token`` if necessary.
|
2018-01-20 18:21:59 +00:00
|
|
|
|
2018-01-26 02:53:55 +00:00
|
|
|
Checks if the locally cached (file system) ``access_token`` has expired
|
|
|
|
(based on a ``expires_at`` time stamp stored in the brokers.ini config)
|
2018-01-23 02:50:26 +00:00
|
|
|
expired (normally has a lifetime of 3 days). If ``false is set then
|
2018-01-26 02:53:55 +00:00
|
|
|
and refreshs token if necessary using the ``refresh_token``. If the
|
2018-01-26 19:25:53 +00:00
|
|
|
``refresh_token`` has expired a new one needs to be provided by the
|
|
|
|
user.
|
2018-01-23 02:50:26 +00:00
|
|
|
"""
|
|
|
|
access_token = self.access_data.get('access_token')
|
|
|
|
expires = float(self.access_data.get('expires_at', 0))
|
2018-11-12 02:05:30 +00:00
|
|
|
expires_stamp = datetime.fromtimestamp(
|
2018-01-26 01:54:13 +00:00
|
|
|
expires).strftime('%Y-%m-%d %H:%M:%S')
|
2018-01-23 02:50:26 +00:00
|
|
|
if not access_token or (expires < time.time()) or force_refresh:
|
2018-03-20 17:13:07 +00:00
|
|
|
log.debug(
|
|
|
|
f"Refreshing access token {access_token} which expired at"
|
|
|
|
f" {expires_stamp}")
|
2018-01-26 19:25:53 +00:00
|
|
|
try:
|
|
|
|
data = await self._new_auth_token()
|
2018-03-20 17:13:07 +00:00
|
|
|
except BrokerError as qterr:
|
2018-02-14 23:49:58 +00:00
|
|
|
if "We're making some changes" in str(qterr.args[0]):
|
2018-02-11 00:44:41 +00:00
|
|
|
# API service is down
|
|
|
|
raise QuestradeError("API is down for maintenance")
|
|
|
|
elif qterr.args[0].decode() == 'Bad Request':
|
2018-02-22 23:44:46 +00:00
|
|
|
# likely config ``refresh_token`` is expired but may
|
|
|
|
# be updated in the config file via another piker process
|
2018-02-15 21:40:33 +00:00
|
|
|
self._reload_config()
|
2018-02-22 23:44:46 +00:00
|
|
|
try:
|
|
|
|
data = await self._new_auth_token()
|
2018-03-20 17:13:07 +00:00
|
|
|
except BrokerError as qterr:
|
2018-02-22 23:44:46 +00:00
|
|
|
if qterr.args[0].decode() == 'Bad Request':
|
|
|
|
# actually expired; get new from user
|
|
|
|
self._reload_config(force_from_user=True)
|
|
|
|
data = await self._new_auth_token()
|
|
|
|
else:
|
2018-03-20 17:13:07 +00:00
|
|
|
raise QuestradeError(qterr)
|
2018-02-14 23:49:58 +00:00
|
|
|
else:
|
|
|
|
raise qterr
|
2018-01-23 02:50:26 +00:00
|
|
|
|
|
|
|
# store absolute token expiry time
|
|
|
|
self.access_data['expires_at'] = time.time() + float(
|
|
|
|
data['expires_in'])
|
2018-01-26 19:25:53 +00:00
|
|
|
# write to config on disk
|
|
|
|
write_conf(self)
|
2018-01-26 01:54:13 +00:00
|
|
|
else:
|
2018-03-20 17:13:07 +00:00
|
|
|
log.debug(f"\nCurrent access token {access_token} expires at"
|
|
|
|
f" {expires_stamp}\n")
|
2018-01-23 02:50:26 +00:00
|
|
|
|
2018-01-23 06:05:02 +00:00
|
|
|
self._prep_sess()
|
2018-01-23 02:50:26 +00:00
|
|
|
return self.access_data
|
|
|
|
|
2018-01-29 17:45:48 +00:00
|
|
|
async def tickers2ids(self, tickers):
|
|
|
|
"""Helper routine that take a sequence of ticker symbols and returns
|
|
|
|
their corresponding QT symbol ids.
|
|
|
|
"""
|
|
|
|
data = await self.api.symbols(names=','.join(tickers))
|
|
|
|
symbols2ids = {}
|
|
|
|
for ticker, symbol in zip(tickers, data['symbols']):
|
2018-11-12 02:05:30 +00:00
|
|
|
symbols2ids[symbol['symbol']] = str(symbol['symbolId'])
|
2018-01-29 17:45:48 +00:00
|
|
|
|
|
|
|
return symbols2ids
|
|
|
|
|
2018-11-12 02:05:30 +00:00
|
|
|
async def symbol_data(self, tickers: List[str]):
|
|
|
|
"""Return symbol data for ``tickers``.
|
|
|
|
"""
|
|
|
|
t2ids = await self.tickers2ids(tickers)
|
|
|
|
ids = ','.join(t2ids.values())
|
|
|
|
symbols = {}
|
|
|
|
for pkt in (await self.api.symbols(ids=ids))['symbols']:
|
|
|
|
symbols[pkt['symbol']] = pkt
|
|
|
|
|
|
|
|
return symbols
|
|
|
|
|
2018-03-20 17:13:07 +00:00
|
|
|
async def quote(self, tickers: [str]):
|
2018-11-12 02:05:30 +00:00
|
|
|
"""Return stock quotes for each ticker in ``tickers``.
|
2018-02-08 07:18:33 +00:00
|
|
|
"""
|
|
|
|
t2ids = await self.tickers2ids(tickers)
|
2018-11-12 02:05:30 +00:00
|
|
|
ids = ','.join(t2ids.values())
|
2018-03-20 17:13:07 +00:00
|
|
|
results = (await self.api.quotes(ids=ids))['quotes']
|
2018-03-21 01:01:55 +00:00
|
|
|
quotes = {quote['symbol']: quote for quote in results}
|
|
|
|
|
|
|
|
# set None for all symbols not found
|
|
|
|
if len(t2ids) < len(tickers):
|
|
|
|
for ticker in tickers:
|
|
|
|
if ticker not in quotes:
|
|
|
|
quotes[ticker] = None
|
|
|
|
|
|
|
|
return quotes
|
2018-02-08 23:42:19 +00:00
|
|
|
|
2018-11-22 14:19:04 +00:00
|
|
|
async def symbol2contracts(
|
2018-11-12 02:05:30 +00:00
|
|
|
self,
|
|
|
|
symbol: str
|
|
|
|
) -> Tuple[int, Dict[datetime, dict]]:
|
2018-11-22 14:19:04 +00:00
|
|
|
"""Return option contract for the given symbol.
|
2018-01-26 19:25:53 +00:00
|
|
|
|
2018-11-12 02:05:30 +00:00
|
|
|
The most useful part is the expiries which can be passed to the option
|
|
|
|
chain endpoint but specifc contract ids can be pulled here as well.
|
|
|
|
"""
|
|
|
|
id = int((await self.tickers2ids([symbol]))[symbol])
|
|
|
|
contracts = await self.api.option_contracts(id)
|
|
|
|
return id, {
|
|
|
|
# convert to native datetime objs for sorting
|
|
|
|
datetime.fromisoformat(item['expiryDate']):
|
|
|
|
item for item in contracts
|
|
|
|
}
|
|
|
|
|
2018-11-22 14:19:04 +00:00
|
|
|
async def get_all_contracts(
|
2018-11-12 02:05:30 +00:00
|
|
|
self,
|
2018-11-22 14:19:04 +00:00
|
|
|
symbols: List[str],
|
2018-11-13 17:57:21 +00:00
|
|
|
# {symbol_id: {dt_iso_contract: {strike_price: {contract_id: id}}}}
|
|
|
|
) -> Dict[int, Dict[str, Dict[int, Any]]]:
|
2018-11-12 02:05:30 +00:00
|
|
|
"""Look up all contracts for each symbol in ``symbols`` and return the
|
2018-11-13 17:57:21 +00:00
|
|
|
of symbol ids to contracts by further organized by expiry and strike
|
|
|
|
price.
|
2018-11-12 02:05:30 +00:00
|
|
|
|
|
|
|
This routine is a bit slow doing all the contract lookups (a request
|
|
|
|
per symbol) and thus the return values should be cached for use with
|
|
|
|
``option_chains()``.
|
|
|
|
"""
|
2018-11-13 17:57:21 +00:00
|
|
|
by_id = {}
|
2018-11-12 02:05:30 +00:00
|
|
|
for symbol in symbols:
|
2018-11-22 14:19:04 +00:00
|
|
|
id, contracts = await self.symbol2contracts(symbol)
|
2018-11-13 17:57:21 +00:00
|
|
|
by_id[id] = {
|
|
|
|
dt.isoformat(timespec='microseconds'): {
|
|
|
|
item['strikePrice']: item for item in
|
|
|
|
byroot['chainPerRoot'][0]['chainPerStrikePrice']
|
|
|
|
}
|
2018-11-22 14:19:04 +00:00
|
|
|
for dt, byroot in sorted(
|
2018-11-13 17:57:21 +00:00
|
|
|
# sort by datetime
|
2018-11-22 14:19:04 +00:00
|
|
|
contracts.items(),
|
|
|
|
key=lambda item: item[0]
|
2018-11-13 17:57:21 +00:00
|
|
|
)
|
|
|
|
}
|
|
|
|
return by_id
|
2018-11-12 02:05:30 +00:00
|
|
|
|
|
|
|
async def option_chains(
|
|
|
|
self,
|
2018-11-22 14:19:04 +00:00
|
|
|
# see dict output from ``get_all_contracts()``
|
|
|
|
contracts: dict,
|
2018-11-12 02:05:30 +00:00
|
|
|
) -> Dict[str, Dict[str, Dict[str, Any]]]:
|
|
|
|
"""Return option chain snap quote for each ticker in ``symbols``.
|
|
|
|
"""
|
2018-11-13 17:57:21 +00:00
|
|
|
quotes = await self.api.option_quotes(contracts)
|
2018-11-12 02:05:30 +00:00
|
|
|
batch = {}
|
|
|
|
for quote in quotes:
|
2018-11-22 14:19:04 +00:00
|
|
|
batch.setdefault(
|
|
|
|
quote['underlying'], {}
|
|
|
|
)[quote['symbol']] = quote
|
2018-02-08 07:18:33 +00:00
|
|
|
|
2018-11-12 02:05:30 +00:00
|
|
|
return batch
|
2018-02-08 07:18:33 +00:00
|
|
|
|
2018-01-26 19:25:53 +00:00
|
|
|
|
|
|
|
async def token_refresher(client):
|
|
|
|
"""Coninually refresh the ``access_token`` near its expiry time.
|
|
|
|
"""
|
|
|
|
while True:
|
|
|
|
await trio.sleep(
|
|
|
|
float(client.access_data['expires_at']) - time.time() - .1)
|
|
|
|
await client.ensure_access(force_refresh=True)
|
|
|
|
|
|
|
|
|
|
|
|
def _token_from_user(conf: 'configparser.ConfigParser') -> None:
|
2018-02-08 07:18:33 +00:00
|
|
|
"""Get API token from the user on the console.
|
|
|
|
"""
|
2018-01-26 19:25:53 +00:00
|
|
|
refresh_token = input("Please provide your Questrade access token: ")
|
|
|
|
conf['questrade'] = {'refresh_token': refresh_token}
|
|
|
|
|
|
|
|
|
2018-02-22 23:44:46 +00:00
|
|
|
def get_config(force_from_user=False) -> "configparser.ConfigParser":
|
2018-01-23 02:50:26 +00:00
|
|
|
conf, path = config.load()
|
|
|
|
if not conf.has_section('questrade') or (
|
2018-02-22 23:44:46 +00:00
|
|
|
not conf['questrade'].get('refresh_token') or (
|
|
|
|
force_from_user)
|
2018-01-23 02:50:26 +00:00
|
|
|
):
|
|
|
|
log.warn(
|
2018-01-23 06:05:02 +00:00
|
|
|
f"No valid refresh token could be found in {path}")
|
2018-01-26 19:25:53 +00:00
|
|
|
_token_from_user(conf)
|
2018-01-23 02:50:26 +00:00
|
|
|
|
|
|
|
return conf
|
|
|
|
|
|
|
|
|
2018-01-26 19:25:53 +00:00
|
|
|
def write_conf(client):
|
|
|
|
"""Save access creds to config file.
|
2018-01-26 02:53:55 +00:00
|
|
|
"""
|
2018-01-26 19:25:53 +00:00
|
|
|
client._conf['questrade'] = client.access_data
|
|
|
|
config.write(client._conf)
|
2018-01-26 02:53:55 +00:00
|
|
|
|
|
|
|
|
2018-01-23 02:50:26 +00:00
|
|
|
@asynccontextmanager
|
2018-01-26 01:54:13 +00:00
|
|
|
async def get_client() -> Client:
|
2018-01-23 02:50:26 +00:00
|
|
|
"""Spawn a broker client.
|
2018-03-20 17:13:07 +00:00
|
|
|
|
|
|
|
A client must adhere to the method calls in ``piker.broker.core``.
|
2018-01-20 18:21:59 +00:00
|
|
|
"""
|
2018-01-23 02:50:26 +00:00
|
|
|
conf = get_config()
|
2018-01-26 19:25:53 +00:00
|
|
|
log.debug(f"Loaded config:\n{colorize_json(dict(conf['questrade']))}")
|
|
|
|
client = Client(conf)
|
2018-01-26 02:53:55 +00:00
|
|
|
await client.ensure_access()
|
2018-01-20 18:21:59 +00:00
|
|
|
|
2018-01-23 02:50:26 +00:00
|
|
|
try:
|
2018-01-26 01:54:13 +00:00
|
|
|
log.debug("Check time to ensure access token is valid")
|
|
|
|
try:
|
2018-01-23 02:50:26 +00:00
|
|
|
await client.api.time()
|
2018-08-21 15:34:00 +00:00
|
|
|
except Exception:
|
2018-01-23 02:50:26 +00:00
|
|
|
# access token is likely no good
|
|
|
|
log.warn(f"Access token {client.access_data['access_token']} seems"
|
|
|
|
f" expired, forcing refresh")
|
2018-01-26 02:53:55 +00:00
|
|
|
await client.ensure_access(force_refresh=True)
|
2018-01-23 02:50:26 +00:00
|
|
|
await client.api.time()
|
|
|
|
|
2018-01-23 06:05:02 +00:00
|
|
|
accounts = await client.api.accounts()
|
2018-01-26 19:25:53 +00:00
|
|
|
log.info(f"Available accounts:\n{colorize_json(accounts)}")
|
2018-01-23 02:50:26 +00:00
|
|
|
yield client
|
|
|
|
finally:
|
2018-01-26 19:25:53 +00:00
|
|
|
write_conf(client)
|
2018-03-20 19:39:49 +00:00
|
|
|
|
|
|
|
|
2018-11-12 02:05:30 +00:00
|
|
|
async def quoter(client: Client, tickers: List[str]):
|
2018-11-22 14:19:04 +00:00
|
|
|
"""Stock Quoter context.
|
2018-11-12 02:05:30 +00:00
|
|
|
|
|
|
|
Yeah so fun times..QT has this symbol to ``int`` id lookup system that you
|
|
|
|
have to use to get any quotes. That means we try to be smart and maintain
|
|
|
|
a cache of this map lazily as requests from in for new tickers/symbols.
|
|
|
|
Most of the closure variables here are to deal with that.
|
2018-03-21 01:01:55 +00:00
|
|
|
"""
|
2018-04-16 06:13:16 +00:00
|
|
|
t2ids = {}
|
|
|
|
ids = ''
|
|
|
|
|
2018-11-12 02:05:30 +00:00
|
|
|
def filter_symbols(quotes_dict: dict):
|
2018-04-16 06:13:16 +00:00
|
|
|
nonlocal t2ids
|
|
|
|
for symbol, quote in quotes_dict.items():
|
|
|
|
if quote['low52w'] is None:
|
|
|
|
log.warn(
|
|
|
|
f"{symbol} seems to be defunct discarding from tickers")
|
|
|
|
t2ids.pop(symbol)
|
2018-03-20 19:39:49 +00:00
|
|
|
|
|
|
|
async def get_quote(tickers):
|
|
|
|
"""Query for quotes using cached symbol ids.
|
|
|
|
"""
|
2018-04-16 06:13:16 +00:00
|
|
|
if not tickers:
|
|
|
|
return {}
|
|
|
|
nonlocal ids, t2ids
|
|
|
|
new, current = set(tickers), set(t2ids.keys())
|
|
|
|
if new != current:
|
|
|
|
# update ticker ids cache
|
2018-04-20 15:41:23 +00:00
|
|
|
log.debug(f"Tickers set changed {new - current}")
|
2018-04-16 06:13:16 +00:00
|
|
|
t2ids = await client.tickers2ids(tickers)
|
2018-11-12 02:05:30 +00:00
|
|
|
# re-save symbol -> ids cache
|
2018-04-16 06:13:16 +00:00
|
|
|
ids = ','.join(map(str, t2ids.values()))
|
|
|
|
|
2018-03-20 19:39:49 +00:00
|
|
|
try:
|
|
|
|
quotes_resp = await client.api.quotes(ids=ids)
|
2018-08-21 15:34:00 +00:00
|
|
|
except (QuestradeError, BrokerError) as qterr:
|
2018-03-20 19:39:49 +00:00
|
|
|
if "Access token is invalid" in str(qterr.args[0]):
|
2018-11-22 14:19:04 +00:00
|
|
|
# out-of-process piker actor may have
|
|
|
|
# renewed already..
|
2018-03-20 19:39:49 +00:00
|
|
|
client._reload_config()
|
2018-08-21 15:34:00 +00:00
|
|
|
try:
|
|
|
|
quotes_resp = await client.api.quotes(ids=ids)
|
|
|
|
except BrokerError as qterr:
|
|
|
|
if "Access token is invalid" in str(qterr.args[0]):
|
2018-11-12 02:05:30 +00:00
|
|
|
# TODO: this will crash when run from a sub-actor since
|
|
|
|
# STDIN can't be acquired. The right way to handle this
|
|
|
|
# is to make a request to the parent actor (i.e.
|
|
|
|
# spawner of this) to call this
|
|
|
|
# `client.ensure_access()` locally thus blocking until
|
|
|
|
# the user provides an API key on the "client side"
|
2018-08-21 15:34:00 +00:00
|
|
|
await client.ensure_access(force_refresh=True)
|
2018-11-13 17:57:21 +00:00
|
|
|
quotes_resp = await client.api.quotes(ids=ids)
|
2018-03-20 19:39:49 +00:00
|
|
|
else:
|
|
|
|
raise
|
|
|
|
|
2018-03-29 17:01:13 +00:00
|
|
|
# dict packing and post-processing
|
|
|
|
quotes = {}
|
|
|
|
for quote in quotes_resp['quotes']:
|
|
|
|
quotes[quote['symbol']] = quote
|
|
|
|
|
|
|
|
if quote.get('delay', 0) > 0:
|
2018-04-18 05:29:33 +00:00
|
|
|
log.warn(f"Delayed quote:\n{quote}")
|
2018-03-29 17:01:13 +00:00
|
|
|
|
|
|
|
return quotes
|
|
|
|
|
2018-11-22 14:19:04 +00:00
|
|
|
# strip out unknown/invalid symbols
|
2018-03-29 17:01:13 +00:00
|
|
|
first_quotes_dict = await get_quote(tickers)
|
2018-04-16 06:13:16 +00:00
|
|
|
filter_symbols(first_quotes_dict)
|
2018-11-12 02:05:30 +00:00
|
|
|
# re-save symbol -> ids cache
|
2018-03-29 17:01:13 +00:00
|
|
|
ids = ','.join(map(str, t2ids.values()))
|
2018-03-20 19:39:49 +00:00
|
|
|
|
2018-04-18 05:29:33 +00:00
|
|
|
return get_quote
|
2018-03-21 14:30:43 +00:00
|
|
|
|
|
|
|
|
2018-08-21 15:34:00 +00:00
|
|
|
# Questrade column order / value conversion
|
|
|
|
# XXX: keys-values in this map define the final column values which will
|
|
|
|
# be "displayable" but not necessarily used for "data processing"
|
|
|
|
# (i.e. comparisons for sorting purposes or other calculations).
|
2018-11-22 14:19:04 +00:00
|
|
|
_qt_stock_keys = {
|
2018-03-21 14:30:43 +00:00
|
|
|
'symbol': 'symbol', # done manually in qtconvert
|
|
|
|
'%': '%',
|
|
|
|
'lastTradePrice': 'last',
|
|
|
|
'askPrice': 'ask',
|
|
|
|
'bidPrice': 'bid',
|
|
|
|
'lastTradeSize': 'size',
|
|
|
|
'bidSize': 'bsize',
|
|
|
|
'askSize': 'asize',
|
|
|
|
'VWAP': ('VWAP', partial(round, ndigits=3)),
|
|
|
|
'mktcap': ('mktcap', humanize),
|
|
|
|
'$ vol': ('$ vol', humanize),
|
|
|
|
'volume': ('vol', humanize),
|
|
|
|
'close': 'close',
|
|
|
|
'openPrice': 'open',
|
|
|
|
'lowPrice': 'low',
|
|
|
|
'highPrice': 'high',
|
|
|
|
# 'low52w': 'low52w', # put in info widget
|
|
|
|
# 'high52w': 'high52w',
|
|
|
|
# "lastTradePriceTrHrs": 7.99,
|
2018-11-13 17:57:21 +00:00
|
|
|
# 'lastTradeTime': ('time', datetime.fromisoformat),
|
2018-03-21 14:30:43 +00:00
|
|
|
# "lastTradeTick": "Equal",
|
|
|
|
# "symbolId": 3575753,
|
|
|
|
# "tier": "",
|
|
|
|
# 'isHalted': 'halted', # as subscript 'h'
|
|
|
|
# 'delay': 'delay', # as subscript 'p'
|
|
|
|
}
|
|
|
|
|
2018-11-13 23:41:58 +00:00
|
|
|
# BidAskLayout columns which will contain three cells the first stacked on top
|
|
|
|
# of the other 2
|
2018-03-21 14:30:43 +00:00
|
|
|
_bidasks = {
|
|
|
|
'last': ['bid', 'ask'],
|
|
|
|
'size': ['bsize', 'asize'],
|
|
|
|
'VWAP': ['low', 'high'],
|
2018-11-13 23:41:58 +00:00
|
|
|
'mktcap': ['vol', '$ vol'],
|
2018-03-21 14:30:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def format_quote(
|
|
|
|
quote: dict,
|
|
|
|
symbol_data: dict,
|
2018-11-22 14:19:04 +00:00
|
|
|
keymap: dict = _qt_stock_keys,
|
2018-11-12 02:05:30 +00:00
|
|
|
) -> Tuple[dict, dict]:
|
2018-03-21 14:30:43 +00:00
|
|
|
"""Remap a list of quote dicts ``quotes`` using the mapping of old keys
|
|
|
|
-> new keys ``keymap`` returning 2 dicts: one with raw data and the other
|
|
|
|
for display.
|
|
|
|
|
|
|
|
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.
|
|
|
|
"""
|
|
|
|
last = quote['lastTradePrice']
|
|
|
|
symbol = quote['symbol']
|
|
|
|
previous = symbol_data[symbol]['prevDayClosePrice']
|
|
|
|
change = percent_change(previous, last)
|
|
|
|
share_count = symbol_data[symbol].get('outstandingShares', None)
|
2018-08-23 02:49:42 +00:00
|
|
|
mktcap = share_count * last if (last and share_count) else 0
|
2018-03-21 14:30:43 +00:00
|
|
|
computed = {
|
|
|
|
'symbol': quote['symbol'],
|
|
|
|
'%': round(change, 3),
|
|
|
|
'mktcap': mktcap,
|
2018-06-12 19:33:11 +00:00
|
|
|
# why QT do you have to be an asshole shipping null values!!!
|
|
|
|
'$ vol': round((quote['VWAP'] or 0) * (quote['volume'] or 0), 3),
|
2018-03-21 14:30:43 +00:00
|
|
|
'close': previous,
|
|
|
|
}
|
|
|
|
new = {}
|
|
|
|
displayable = {}
|
|
|
|
|
|
|
|
for key, new_key in keymap.items():
|
|
|
|
display_value = value = computed.get(key) or quote.get(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 using available formatting func
|
|
|
|
if isinstance(new_key, tuple):
|
|
|
|
new_key, func = new_key
|
2018-05-09 03:50:24 +00:00
|
|
|
display_value = func(value) if value else value
|
|
|
|
|
2018-03-21 14:30:43 +00:00
|
|
|
new[new_key] = value
|
|
|
|
displayable[new_key] = display_value
|
|
|
|
|
|
|
|
return new, displayable
|