Add option chain quote support!

kivy_mainline_and_py3.8
Tyler Goodlet 2018-11-11 21:05:30 -05:00
parent 666228d82e
commit f9d9d7c1ba
1 changed files with 148 additions and 56 deletions

View File

@ -2,9 +2,10 @@
Questrade API backend. Questrade API backend.
""" """
import time import time
import datetime from datetime import datetime
from functools import partial from functools import partial
import configparser import configparser
from typing import List, Tuple, Dict, Any
import trio import trio
from async_generator import asynccontextmanager from async_generator import asynccontextmanager
@ -29,6 +30,73 @@ class QuestradeError(Exception):
"Non-200 OK response code" "Non-200 OK response code"
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,
ids: List[int],
expiry: str,
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),
} for symbol_id in ids
]
resp = await self._sess.post(
path=f'/markets/quotes/options',
json={'filters': filters, 'optionIds': option_ids}
)
return resproc(resp, log)
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
single api requests. single api requests.
@ -100,7 +168,7 @@ class Client:
""" """
access_token = self.access_data.get('access_token') access_token = self.access_data.get('access_token')
expires = float(self.access_data.get('expires_at', 0)) expires = float(self.access_data.get('expires_at', 0))
expires_stamp = datetime.datetime.fromtimestamp( expires_stamp = datetime.fromtimestamp(
expires).strftime('%Y-%m-%d %H:%M:%S') expires).strftime('%Y-%m-%d %H:%M:%S')
if not access_token or (expires < time.time()) or force_refresh: if not access_token or (expires < time.time()) or force_refresh:
log.debug( log.debug(
@ -147,15 +215,26 @@ class Client:
data = await self.api.symbols(names=','.join(tickers)) data = await self.api.symbols(names=','.join(tickers))
symbols2ids = {} symbols2ids = {}
for ticker, symbol in zip(tickers, data['symbols']): for ticker, symbol in zip(tickers, data['symbols']):
symbols2ids[symbol['symbol']] = symbol['symbolId'] symbols2ids[symbol['symbol']] = str(symbol['symbolId'])
return symbols2ids return symbols2ids
async def quote(self, tickers: [str]): async def symbol_data(self, tickers: List[str]):
"""Return quotes for each ticker in ``tickers``. """Return symbol data for ``tickers``.
""" """
t2ids = await self.tickers2ids(tickers) t2ids = await self.tickers2ids(tickers)
ids = ','.join(map(str, t2ids.values())) ids = ','.join(t2ids.values())
symbols = {}
for pkt in (await self.api.symbols(ids=ids))['symbols']:
symbols[pkt['symbol']] = pkt
return symbols
async def quote(self, tickers: [str]):
"""Return stock quotes for each ticker in ``tickers``.
"""
t2ids = await self.tickers2ids(tickers)
ids = ','.join(t2ids.values())
results = (await self.api.quotes(ids=ids))['quotes'] results = (await self.api.quotes(ids=ids))['quotes']
quotes = {quote['symbol']: quote for quote in results} quotes = {quote['symbol']: quote for quote in results}
@ -167,58 +246,59 @@ class Client:
return quotes return quotes
async def symbol_data(self, tickers: [str]): async def option_contracts(
"""Return symbol data for ``tickers``. self,
symbol: str
) -> Tuple[int, Dict[datetime, dict]]:
"""Return option contract dat for the given symbol.
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.
""" """
t2ids = await self.tickers2ids(tickers) id = int((await self.tickers2ids([symbol]))[symbol])
ids = ','.join(map(str, t2ids.values())) contracts = await self.api.option_contracts(id)
symbols = {} return id, {
for pkt in (await self.api.symbols(ids=ids))['symbols']: # convert to native datetime objs for sorting
symbols[pkt['symbol']] = pkt datetime.fromisoformat(item['expiryDate']):
item for item in contracts
}
return symbols async def max_contract_expiry(
self,
symbols: List[str]
) -> Tuple[List[int], datetime]:
"""Look up all contracts for each symbol in ``symbols`` and return the
list of symbol ids as well as the maximum possible option contract
expiry out of the bunch.
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()``.
"""
batch = {}
for symbol in symbols:
id, contracts = await self.option_contracts(symbol)
batch[id] = max(contracts)
class _API: return tuple(batch.keys()), max(batch.values())
"""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: async def option_chains(
resp = await self._sess.get(path=f'/{path}', params=params) self,
return resproc(resp, log) symbol_ids: List[int],
max_expiry: str # iso format datetime (microseconds)
) -> Dict[str, Dict[str, Dict[str, Any]]]:
"""Return option chain snap quote for each ticker in ``symbols``.
"""
quotes = (await self.api.option_quotes(
ids=symbol_ids,
expiry=max_expiry.isoformat(timespec='microseconds')
))['optionQuotes']
async def accounts(self) -> dict: batch = {}
return await self._request('accounts') for quote in quotes:
batch.setdefault(quote['underlying'], {})[quote['symbol']] = quote
async def time(self) -> dict: return batch
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 token_refresher(client): async def token_refresher(client):
@ -286,13 +366,18 @@ async def get_client() -> Client:
write_conf(client) write_conf(client)
async def quoter(client: Client, tickers: [str]): async def quoter(client: Client, tickers: List[str]):
"""Quoter context. """Quoter context.
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.
""" """
t2ids = {} t2ids = {}
ids = '' ids = ''
def filter_symbols(quotes_dict): def filter_symbols(quotes_dict: dict):
nonlocal t2ids nonlocal t2ids
for symbol, quote in quotes_dict.items(): for symbol, quote in quotes_dict.items():
if quote['low52w'] is None: if quote['low52w'] is None:
@ -311,6 +396,7 @@ async def quoter(client: Client, tickers: [str]):
# update ticker ids cache # update ticker ids cache
log.debug(f"Tickers set changed {new - current}") log.debug(f"Tickers set changed {new - current}")
t2ids = await client.tickers2ids(tickers) t2ids = await client.tickers2ids(tickers)
# re-save symbol -> ids cache
ids = ','.join(map(str, t2ids.values())) ids = ','.join(map(str, t2ids.values()))
try: try:
@ -323,6 +409,12 @@ async def quoter(client: Client, tickers: [str]):
quotes_resp = await client.api.quotes(ids=ids) quotes_resp = await client.api.quotes(ids=ids)
except BrokerError as qterr: except BrokerError as qterr:
if "Access token is invalid" in str(qterr.args[0]): if "Access token is invalid" in str(qterr.args[0]):
# 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"
await client.ensure_access(force_refresh=True) await client.ensure_access(force_refresh=True)
else: else:
raise raise
@ -340,7 +432,7 @@ async def quoter(client: Client, tickers: [str]):
first_quotes_dict = await get_quote(tickers) first_quotes_dict = await get_quote(tickers)
filter_symbols(first_quotes_dict) filter_symbols(first_quotes_dict)
# re-save symbol ids cache # re-save symbol -> ids cache
ids = ','.join(map(str, t2ids.values())) ids = ','.join(map(str, t2ids.values()))
return get_quote return get_quote
@ -357,6 +449,7 @@ _qt_keys = {
'askPrice': 'ask', 'askPrice': 'ask',
'bidPrice': 'bid', 'bidPrice': 'bid',
'lastTradeSize': 'size', 'lastTradeSize': 'size',
'lastTradeTime': ('time', datetime.fromisoformat),
'bidSize': 'bsize', 'bidSize': 'bsize',
'askSize': 'asize', 'askSize': 'asize',
'VWAP': ('VWAP', partial(round, ndigits=3)), 'VWAP': ('VWAP', partial(round, ndigits=3)),
@ -371,7 +464,6 @@ _qt_keys = {
# 'high52w': 'high52w', # 'high52w': 'high52w',
# "lastTradePriceTrHrs": 7.99, # "lastTradePriceTrHrs": 7.99,
# "lastTradeTick": "Equal", # "lastTradeTick": "Equal",
# "lastTradeTime": "2018-01-30T18:28:23.434000-05:00",
# "symbolId": 3575753, # "symbolId": 3575753,
# "tier": "", # "tier": "",
# 'isHalted': 'halted', # as subscript 'h' # 'isHalted': 'halted', # as subscript 'h'
@ -389,7 +481,7 @@ def format_quote(
quote: dict, quote: dict,
symbol_data: dict, symbol_data: dict,
keymap: dict = _qt_keys, keymap: dict = _qt_keys,
) -> (dict, dict): ) -> Tuple[dict, dict]:
"""Remap a list of quote dicts ``quotes`` using the mapping of old keys """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 -> new keys ``keymap`` returning 2 dicts: one with raw data and the other
for display. for display.