198 lines
		
	
	
		
			6.2 KiB
		
	
	
	
		
			Python
		
	
	
			
		
		
	
	
			198 lines
		
	
	
		
			6.2 KiB
		
	
	
	
		
			Python
		
	
	
| # piker: trading gear for hackers
 | |
| # Copyright (C) 2018-present  Tyler Goodlet (in stewardship of piker0)
 | |
| 
 | |
| # This program is free software: you can redistribute it and/or modify
 | |
| # it under the terms of the GNU Affero General Public License as published by
 | |
| # the Free Software Foundation, either version 3 of the License, or
 | |
| # (at your option) any later version.
 | |
| 
 | |
| # This program is distributed in the hope that it will be useful,
 | |
| # but WITHOUT ANY WARRANTY; without even the implied warranty of
 | |
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | |
| # GNU Affero General Public License for more details.
 | |
| 
 | |
| # You should have received a copy of the GNU Affero General Public License
 | |
| # along with this program.  If not, see <https://www.gnu.org/licenses/>.
 | |
| 
 | |
| """
 | |
| Robinhood API backend.
 | |
| 
 | |
| WARNING: robinhood now requires authenticated access to use the quote
 | |
| endpoints (it didn't originally). We need someone with a valid US
 | |
| account to test this code.
 | |
| """
 | |
| from functools import partial
 | |
| from typing import List
 | |
| 
 | |
| from async_generator import asynccontextmanager
 | |
| import asks
 | |
| 
 | |
| from ..log import get_logger
 | |
| from ._util import resproc, BrokerError
 | |
| from ..calc import percent_change
 | |
| 
 | |
| log = get_logger(__name__)
 | |
| 
 | |
| _service_ep = 'https://api.robinhood.com'
 | |
| 
 | |
| 
 | |
| class _API:
 | |
|     """Robinhood 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 quotes(self, symbols: str) -> dict:
 | |
|         return await self._request('quotes/', params={'symbols': symbols})
 | |
| 
 | |
|     async def fundamentals(self, symbols: str) -> dict:
 | |
|         return await self._request(
 | |
|             'fundamentals/', params={'symbols': symbols})
 | |
| 
 | |
| 
 | |
| class Client:
 | |
|     """API client suitable for use as a long running broker daemon or
 | |
|     single api requests.
 | |
|     """
 | |
|     def __init__(self):
 | |
|         self._sess = asks.Session()
 | |
|         self._sess.base_location = _service_ep
 | |
|         self.api = _API(self._sess)
 | |
| 
 | |
|     def _zip_in_order(self, symbols: [str], quotes: List[dict]):
 | |
|         return {quote.get('symbol', sym) if quote else sym: quote
 | |
|                 for sym, quote in zip(symbols, results_dict)}
 | |
| 
 | |
|     async def quote(self, symbols: [str]):
 | |
|         """Retrieve quotes for a list of ``symbols``.
 | |
|         """
 | |
|         try:
 | |
|             quotes = (await self.api.quotes(','.join(symbols)))['results']
 | |
|         except BrokerError:
 | |
|             quotes = [None] * len(symbols)
 | |
| 
 | |
|         for quote in quotes:
 | |
|             # insert our subscription key field
 | |
|             if quote is not None:
 | |
|                 quote['key'] = quote['symbol']
 | |
| 
 | |
|         return list(filter(bool, quotes))
 | |
| 
 | |
|     async def symbol_data(self, symbols: [str]):
 | |
|         """Retrieve symbol data via the ``fundamentals`` endpoint.
 | |
|         """
 | |
|         return self._zip_in_order(
 | |
|             symbols,
 | |
|             (await self.api.fundamentals(','.join(symbols)))['results']
 | |
|         )
 | |
| 
 | |
| 
 | |
| @asynccontextmanager
 | |
| async def get_client() -> Client:
 | |
|     """Spawn a RH broker client.
 | |
|     """
 | |
|     yield Client()
 | |
| 
 | |
| 
 | |
| async def quoter(client: Client, tickers: [str]):
 | |
|     """Quoter context.
 | |
|     """
 | |
|     return client.quote
 | |
| 
 | |
| 
 | |
| # Robinhood key conversion / column order
 | |
| _rh_keys = {
 | |
|     'symbol': 'symbol',  # done manually in qtconvert
 | |
|     '%': '%',
 | |
|     'last_trade_price': ('last', partial(round, ndigits=3)),
 | |
|     'last_extended_hours_trade_price': 'last pre-mkt',
 | |
|     'ask_price': ('ask', partial(round, ndigits=3)),
 | |
|     'bid_price': ('bid', partial(round, ndigits=3)),
 | |
|     # 'lastTradeSize': 'size',  # not available?
 | |
|     'bid_size': 'bsize',
 | |
|     'ask_size': 'asize',
 | |
|     # 'VWAP': ('VWAP', partial(round, ndigits=3)),
 | |
|     # 'mktcap': ('mktcap', humanize),
 | |
|     # '$ vol': ('$ vol', humanize),
 | |
|     # 'volume': ('vol', humanize),
 | |
|     'previous_close': 'close',
 | |
|     'adjusted_previous_close': 'adj close',
 | |
|     # 'trading_halted': 'halted',
 | |
| 
 | |
|     # example fields
 | |
|     # "adjusted_previous_close": "8.1900",
 | |
|     # "ask_price": "8.2800",
 | |
|     # "ask_size": 1200,
 | |
|     # "bid_price": "8.2500",
 | |
|     # "bid_size": 1800,
 | |
|     # "has_traded": true,
 | |
|     # "last_extended_hours_trade_price": null,
 | |
|     # "last_trade_price": "8.2350",
 | |
|     # "last_trade_price_source": "nls",
 | |
|     # "previous_close": "8.1900",
 | |
|     # "previous_close_date": "2018-03-20",
 | |
|     # "symbol": "CRON",
 | |
|     # "trading_halted": false,
 | |
|     # "updated_at": "2018-03-21T13:46:05Z"
 | |
| }
 | |
| 
 | |
| _bidasks = {
 | |
|     'last': ['bid', 'ask'],
 | |
|     # 'size': ['bsize', 'asize'],
 | |
|     # 'VWAP': ['low', 'high'],
 | |
|     # 'last pre-mkt': ['close', 'adj close'],
 | |
| }
 | |
| 
 | |
| 
 | |
| def format_quote(
 | |
|     quote: dict, symbol_data: dict,
 | |
|     keymap: dict = _rh_keys,
 | |
| ) -> (dict, dict):
 | |
|     """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['last_trade_price']
 | |
|     # symbol = quote['symbol']
 | |
|     previous = quote['previous_close']
 | |
|     change = percent_change(float(previous), float(last))
 | |
|     # share_count = symbol_data[symbol].get('outstandingshares', none)
 | |
|     # mktcap = share_count * last if share_count else 'na'
 | |
|     computed = {
 | |
|         'symbol': quote['symbol'],
 | |
|         '%': round(change, 3),
 | |
|         'ask_price': float(quote['ask_price']),
 | |
|         'bid_price': float(quote['bid_price']),
 | |
|         'last_trade_price': float(quote['last_trade_price']),
 | |
|         # 'mktcap': mktcap,
 | |
|         # '$ vol': round(quote['vwap'] * quote['volume'], 3),
 | |
|         '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
 | |
|             display_value = func(value)
 | |
| 
 | |
|         new[new_key] = value
 | |
|         displayable[new_key] = display_value
 | |
| 
 | |
|     return new, displayable
 |