Add option chain quote support!
parent
666228d82e
commit
f9d9d7c1ba
|
@ -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
|
||||||
class _API:
|
per symbol) and thus the return values should be cached for use with
|
||||||
"""Questrade API endpoints exposed as methods and wrapped with an
|
``option_chains()``.
|
||||||
http session.
|
|
||||||
"""
|
"""
|
||||||
def __init__(self, session: asks.Session):
|
batch = {}
|
||||||
self._sess = session
|
for symbol in symbols:
|
||||||
|
id, contracts = await self.option_contracts(symbol)
|
||||||
|
batch[id] = max(contracts)
|
||||||
|
|
||||||
async def _request(self, path: str, params=None) -> dict:
|
return tuple(batch.keys()), max(batch.values())
|
||||||
resp = await self._sess.get(path=f'/{path}', params=params)
|
|
||||||
return resproc(resp, log)
|
|
||||||
|
|
||||||
async def accounts(self) -> dict:
|
async def option_chains(
|
||||||
return await self._request('accounts')
|
self,
|
||||||
|
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 time(self) -> dict:
|
batch = {}
|
||||||
return await self._request('time')
|
for quote in quotes:
|
||||||
|
batch.setdefault(quote['underlying'], {})[quote['symbol']] = quote
|
||||||
|
|
||||||
async def markets(self) -> dict:
|
return batch
|
||||||
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.
|
||||||
|
|
Loading…
Reference in New Issue