support multiple stock apis

This commit is contained in:
dave 2020-04-08 22:56:07 -07:00
parent 166807e181
commit 2bdece8b9e
3 changed files with 323 additions and 107 deletions

View File

@ -15,13 +15,12 @@ Considering the daily limit means, when evenly spread, we can sent a request *no
`(24 * 60 * 60 / 500)` - and therefore, the value of *bginterval* must be some value larger than 173, as this value will `(24 * 60 * 60 / 500)` - and therefore, the value of *bginterval* must be some value larger than 173, as this value will
completely consume the daily limit. completely consume the daily limit.
When trading, the price of the traded symbol is allowed to be *tcachesecs* seconds old before the API will be used to When trading, the price of the traded symbol is allowed to be *trade_cache_seconds* seconds old before the API will be
fetch a more recent price. This value must be balanced against *bginterval* depending on your trade frequency used to fetch a more recent price. This value must be balanced against *bginterval* depending on your trade frequency
and variety. and variety.
Background or batch-style tasks that rely on symbol prices run afoul with the above constraints - but in a Background or batch-style tasks that rely on symbol prices run afoul with the above constraints - but in a
magnified way as they rely on api-provided data to calculate player stats across many players at a time. The magnified way as they rely on api-provided data to calculate player stats across many players at a time.
*rcachesecs* setting controls the maximum price age before the API is hit.
Commands Commands
@ -39,8 +38,7 @@ Commands
Get a report on the calling player's portfolio. Another player's name can be passed as an argument to retrieve Get a report on the calling player's portfolio. Another player's name can be passed as an argument to retrieve
information about a player other than the calling player. Finally, the 'full' argument can be added to retrieve a information about a player other than the calling player. Finally, the 'full' argument can be added to retrieve a
full listing of the player's holdings. Per the above, values based on symbol prices may be delayed based on the full listing of the player's holdings.
*rcachesecs* config setting.
Config Config
@ -51,13 +49,20 @@ Config
{ {
"startbalance": 10000, "startbalance": 10000,
"tradedelay": 0, "tradedelay": 0,
"apikey": "xxxxxxxxxxxxxx", "trade_cache_seconds": 300,
"tcachesecs": 300,
"rcachesecs": 14400,
"bginterval": 300, "bginterval": 300,
"midnight_offset": 0,
"announce_trades": false, "announce_trades": false,
"announce_channel": "#trades" "announce_channel": "#trades",
"providers": [
{
"provider": "iexcloud",
"apikey": "xxxxxxxxxxxxxxx"
},
{
"provider": "alphavantage",
"apikey": "xxxxxxxxxxxxxxx"
}
]
} }
.. cmdoption:: startbalance .. cmdoption:: startbalance
@ -71,22 +76,21 @@ Config
NOT IMPLEMENTED NOT IMPLEMENTED
.. cmdoption:: apikey .. cmdoption:: providers
API key from https://www.alphavantage.co/support/#api-key A list of providers to fetch stock data from
.. cmdoption:: tcachesecs Supported providers:
* https://www.alphavantage.co/
* https://iexcloud.io/
.. cmdoption:: trade_cache_seconds
When performing a trade, how old of a cached symbol price is permitted before fetching from API. When performing a trade, how old of a cached symbol price is permitted before fetching from API.
Recommended ~30 minutes (1800) Recommended ~30 minutes (1800)
.. cmdoption:: rcachesecs
When calculating a portfolio report, how old of a cached symbol price is permitted before fetching from API.
Recommended ~4 hours (14400)
.. cmdoption:: bginterval .. cmdoption:: bginterval
Symbol prices are updated in the background. This is necessary because fetching a portfolio report may require Symbol prices are updated in the background. This is necessary because fetching a portfolio report may require
@ -94,7 +98,7 @@ Config
fetching a report would take multiple minutes with more than 5 symbols, which would not work. fetching a report would take multiple minutes with more than 5 symbols, which would not work.
For this reason, we update symbols at a low interval in the background. Every *bginterval* seconds, a task will be For this reason, we update symbols at a low interval in the background. Every *bginterval* seconds, a task will be
started that updates the price of symbols older than *rcachesecs*. started that updates the price of the oldest symbol.
Estimated 5 minute (300), but likely will need tuning depending on playerbase Estimated 5 minute (300), but likely will need tuning depending on playerbase

View File

@ -136,12 +136,15 @@ class StockPlay(ModuleBase):
# `data` text # `data` text
# );""") # );""")
self.cache = PriceCache(self)
# Last time the interval tasks were executed # Last time the interval tasks were executed
self.task_time = 0 self.task_time = 0
# background work executor thread # background work executor thread
self.asyncq = Queue() self.asyncq = Queue()
self.running = True self.running = True
self.trader = Thread(target=self.trader_background) self.trader = Thread(target=self.trader_background)
self.trader.start() self.trader.start()
@ -196,7 +199,7 @@ class StockPlay(ModuleBase):
c.execute("UPDATE stockplay_prices SET attempt_time=? WHERE symbol=?;", (time(), updatesym)) c.execute("UPDATE stockplay_prices SET attempt_time=? WHERE symbol=?;", (time(), updatesym))
if updatesym: if updatesym:
self.get_price(updatesym, 0) self.cache.get_price(updatesym, 0)
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()
@ -262,19 +265,18 @@ class StockPlay(ModuleBase):
trade.symbol)) trade.symbol))
# Update quote price # Update quote price
try: try:
symprice = self.get_price(trade.symbol, self.config["tcachesecs"]) price = self.cache.get_price(trade.symbol, self.config["trade_cache_seconds"])
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()
self.bot.act_PRIVMSG(trade.replyto, "{}: invalid symbol or api failure, trade aborted!" self.bot.act_PRIVMSG(trade.replyto, "{}: invalid symbol or api failure, trade aborted!"
.format(trade.nick)) .format(trade.nick))
return return
if symprice is None: if price is None:
self.bot.act_PRIVMSG(trade.replyto, self.bot.act_PRIVMSG(trade.replyto,
"{}: invalid symbol '{}'".format(trade.nick, trade.symbol)) "{}: invalid symbol '{}'".format(trade.nick, trade.symbol))
return # invalid stock return # invalid stock
if not symprice and trade.buy:
self.bot.act_PRIVMSG(trade.replyto, "{}: trading is halted on '{}'".format(trade.nick, trade.symbol)) symprice = price.price
return
# calculate various prices needed # calculate various prices needed
# symprice -= Decimal("0.0001") # for testing dust collection # symprice -= Decimal("0.0001") # for testing dust collection
@ -394,9 +396,10 @@ class StockPlay(ModuleBase):
(nick, )).fetchall(): (nick, )).fetchall():
# the API limits us to 5 requests per minute or 500 requests per day or about 1 request every 173s # the API limits us to 5 requests per minute or 500 requests per day or about 1 request every 173s
# The background thread updates the oldest price every 5 minutes. Here, we allow even very stale quotes # The background thread updates the oldest price every 5 minutes. Here, we allow even very stale quotes
# because it's simply impossible to request fresh data for every stock right now. Recommended rcachesecs # because it's simply impossible to request fresh data for every stock right now.
# is 86400 (1 day) print("build_report: processing", row["symbol"])
symprice = Decimal(self.get_price(row["symbol"], -1)) price = self.cache.get_price(row["symbol"], -1)
symprice = price.price
holding_value += symprice * row["count"] holding_value += symprice * row["count"]
avgbuy = self.calc_user_avgbuy(nick, row["symbol"]) avgbuy = self.calc_user_avgbuy(nick, row["symbol"])
symbol_count.append((row["symbol"], symbol_count.append((row["symbol"],
@ -435,83 +438,6 @@ class StockPlay(ModuleBase):
self.task_time = now self.task_time = now
self.record_nightly_balances() self.record_nightly_balances()
def get_price(self, symbol, thresh=None):
"""
Get symbol price, with quote being at most $thresh seconds old
"""
return self.get_priceinfo_cached(symbol, thresh or 60)["price"]
def get_priceinfo_cached(self, symbol, thresh):
"""
Return the cached symbol price if it's more recent than the last 15 minutes
Otherwise, fetch the price then cache and return it.
"""
cached = self._get_cache_priceinfo(symbol, thresh)
if not cached:
cached = self.fetch_priceinfo(symbol)
if cached:
self._set_cache_priceinfo(symbol, cached)
numfields = set(['open', 'high', 'low', 'price', 'volume', 'change', 'previous close'])
return {k: Decimal(v) if k in numfields else v for k, v in cached.items()}
def _set_cache_priceinfo(self, symbol, data):
with closing(self.sql.getCursor()) as c:
c.execute("REPLACE INTO stockplay_prices (symbol, attempt_time, time, data) VALUES (?, ?, ?, ?)",
(symbol, time(), time(), json.dumps(data)))
def _get_cache_priceinfo(self, symbol, thresh):
with closing(self.sql.getCursor()) as c:
row = c.execute("SELECT * FROM stockplay_prices WHERE symbol=?",
(symbol, )).fetchone()
if not row:
return
if thresh != -1 and time() - row["time"] > thresh:
return
return json.loads(row["data"])
def fetch_priceinfo(self, symbol):
"""
Request a stock quote from the API. The API provides the format::
{'Global Quote': {
{'01. symbol': 'MSFT',
'02. open': '104.3900',
'03. high': '105.7800',
'04. low': '104.2603',
'05. price': '105.6700',
'06. volume': '21461093',
'07. latest trading day':'2019-02-08',
'08. previous close':'105.2700',
'09. change': '0.4000',
'10. change percent': '0.3800%'}}
Reformat as::
{'symbol': 'AMD',
'open': '22.3300',
'high': '23.2750',
'low': '22.2700',
'price': '23.0500',
'volume': '78129280',
'latest trading day': '2019-02-08',
'previous close': '22.6700',
'change': '0.3800',
'change percent': '1.6762%'}
"""
keys = set(['symbol', 'open', 'high', 'low', 'price', 'volume',
'latest trading day', 'previous close', 'change', 'change percent'])
self.log.info("fetching api quote for symbol: {}".format(symbol))
data = get("https://www.alphavantage.co/query",
params={"function": "GLOBAL_QUOTE",
"symbol": symbol,
"apikey": self.config["apikey"]},
timeout=10).json()
data = data["Global Quote"]
if not data:
return None
return {k[4:]: v for k, v in data.items() if k[4:] in keys}
def checksym(self, s): def checksym(self, s):
""" """
Validate that a string looks like a stock symbol Validate that a string looks like a stock symbol
@ -672,3 +598,217 @@ class StockPlay(ModuleBase):
total = int((data["cash"] + data["holding_value"]) * 100) total = int((data["cash"] + data["holding_value"]) * 100)
self.log.info("Recording {} daily balance for {}".format(now, row["nick"])) self.log.info("Recording {} daily balance for {}".format(now, row["nick"]))
c.execute("INSERT INTO stockplay_balance_history VALUES (?, ?, ?)", (row["nick"], now, total)) c.execute("INSERT INTO stockplay_balance_history VALUES (?, ?, ?)", (row["nick"], now, total))
class PriceCache(object):
def __init__(self, mod):
self.sql = mod.sql
self.log = mod.log
self.mod = mod
self.providers = []
self.configure_providers(mod.config["providers"])
self.which_provider = dict()
self.unsupported = set()
def configure_providers(self, config):
for provider in config:
self.providers.append(PROVIDER_TYPES[provider["provider"]](provider, self.log))
def get_price(self, symbol, thresh):
if symbol in self.unsupported:
return
symbol = symbol.upper()
# load from cache
price = self._load_priceinfo(symbol)
# if present and meets thresh
if price and (thresh == -1 or time() - price.time < thresh):
return price
return self.api_fetch(symbol)
def api_fetch(self, symbol):
fetched = None
if symbol in self.which_provider:
fetched = self.which_provider[symbol].get_price(symbol)
if not fetched:
for provider in self.providers:
try:
fetched = provider.get_price(symbol)
self.which_provider[symbol] = provider
break
except NotSupported as nse:
self.unsupported.update([symbol])
self.log.info("provider {}: {}".format(provider.__class__.__name__, nse))
if not fetched:
self.log.critical("unsupported symbol: %s", symbol)
return
self._store_priceinfo(fetched)
return fetched
def _store_priceinfo(self, price):
with closing(self.sql.getCursor()) as c:
c.execute("REPLACE INTO stockplay_prices (symbol, attempt_time, time, data) VALUES (?, ?, ?, ?)",
(price.symbol, price.time, time(), price.to_json()))
def _load_priceinfo(self, symbol):
with closing(self.sql.getCursor()) as c:
row = c.execute("SELECT * FROM stockplay_prices WHERE symbol=?",
(symbol, )).fetchone()
if not row:
return
return Price.from_json(row["data"])
class Price(object):
def __init__(self, symbol, price, time_):
self.symbol = symbol.upper()
self.price = round(Decimal(price), 4)
self.time = time_
def to_json(self):
return json.dumps({
"symbol": self.symbol,
"price": str(self.price),
"time": self.time,
})
@staticmethod
def from_json(data):
data = json.loads(data)
return Price(data["symbol"].upper(), data["price"], data.get("time", 0))
class PriceProvider(object):
def __init__(self, config, logger):
"""
config::
{
"provider": "name",
"apikey": "xxxxxxxxxxxxxx",
}
"""
self.config = config
self.log = logger
def get_price(self, symbol):
"""
:return: tuple of:
* price (as a Decimal)
* next_after (the time() after which the next background call should happen)
or raise:
NotSupported - if the symbol isnt supported
"""
raise NotImplementedError()
class IEXCloudProvider(PriceProvider):
def get_price(self, symbol):
"""
Request a stock quote from the API. The API provides the format::
{"symbol": "AAPL",
"companyName": "Apple, Inc.",
"calculationPrice": "close",
"open": 184.7,
"openTime": 1552656600847,
"close": 186.12,
"closeTime": 1552680000497,
"high": 187.33,
"low": 183.74,
"latestPrice": 186.12,
"latestSource": "Close",
"latestTime": "March 15, 2019",
"latestUpdate": 1552680000497,
"latestVolume": 39141464,
"iexRealtimePrice": 186.195,
"iexRealtimeSize": 100,
"iexLastUpdated": 1552679999536,
"delayedPrice": 186.124,
"delayedPriceTime": 1552680900008,
"extendedPrice": 185.92,
"extendedChange": -0.2,
"extendedChangePercent": -0.00107,
"extendedPriceTime": 1552693471549,
"previousClose": 183.73,
"change": 2.39,
"changePercent": 0.01301,
"iexMarketPercent": 0.021849182749015213,
"iexVolume": 855209,
"avgTotalVolume": 25834564,
"iexBidPrice": 0,
"iexBidSize": 0,
"iexAskPrice": 0,
"iexAskSize": 0,
"marketCap": 877607913600,
"peRatio": 15.17,
"week52High": 233.47,
"week52Low": 142,
"ytdChange": 0.176447}
"""
self.log.info("{}: fetching api quote for symbol: {}".format(self.__class__.__name__, symbol))
response = get("https://cloud.iexapis.com/beta/stock/{}/quote".format(symbol.lower()),
params={"token": self.config["apikey"]},
timeout=10)
if response.status_code != 200:
if response.status_code == 404:
raise NotSupported(symbol)
else:
response.raise_for_status()
data = response.json()
return Price(symbol, Decimal(data["latestPrice"]), int(time()))
class AlphaVantProvider(PriceProvider):
def get_price(self, symbol):
"""
Request a stock quote from the API. The API provides the format::
{'Global Quote': {
{'01. symbol': 'MSFT',
'02. open': '104.3900',
'03. high': '105.7800',
'04. low': '104.2603',
'05. price': '105.6700',
'06. volume': '21461093',
'07. latest trading day':'2019-02-08',
'08. previous close':'105.2700',
'09. change': '0.4000',
'10. change percent': '0.3800%'}}
"""
self.log.info("{}: fetching api quote for symbol: {}".format(self.__class__.__name__, symbol))
data = get("https://www.alphavantage.co/query",
params={"function": "GLOBAL_QUOTE",
"symbol": symbol,
"apikey": self.config["apikey"]},
timeout=10).json()
if "Global Quote" not in data:
raise NotSupported(symbol)
return Price(symbol, Decimal(data["Global Quote"]["05. price"]), int(time()))
PROVIDER_TYPES = {
"iexcloud": IEXCloudProvider,
"alphavantage": AlphaVantProvider
}
class NotSupported(Exception):
pass

View File

@ -0,0 +1,72 @@
import pytest
from contextlib import closing
from tests.lib import * # NOQA - fixtures
from time import sleep, time
import datetime
@pytest.fixture
def stockbot(fakebot):
"""
Provide a bot loaded with the Calc module. Clear the database.
"""
fakebot.botconfig["module_configs"]["StockPlay"] = {
"startbalance": 10000,
"tradedelay": 0,
"tcachesecs": 120,
"bginterval": 45,
"announce_trades": True,
"announce_channel": "#trades",
"providers": [
{
"provider": "iexcloud",
"apikey": "xxxxxxxxxxxxxxxxxxxxxx",
"background_interval": 1
},
{
"provider": "alphavantage",
"apikey": "xxxxxxxxxxxxxxxxxxxxxx",
"background_interval": 1
}
]
}
fakebot.loadmodule("SQLite")
# with closing(fakebot.moduleInstances["SQLite"].opendb("remind.db")) as db:
# db.query("DROP TABLE IF EXISTS `reminders`;")
# fakebot.loadmodule("Remind")
# os.system("cp /Users/dave/code/pyircbot-work/examples/data2/data/SQLite/stockplay.db {}".format(fakebot.moduleInstances["SQLite"].getFilePath()))
# os.system("ln -s /Users/dave/code/pyircbot-work/examples/data2/data/SQLite/stockplay.db {}".format(fakebot.moduleInstances["SQLite"].getFilePath()))
fakebot.loadmodule("StockPlay")
return fakebot
# @pytest.mark.slow
# def test_stockplay(stockbot):
# sp = stockbot.moduleInstances["StockPlay"]
# # import pdb
# # pdb.set_trace()
# # print(sp.cache)
# # print(sp.cache.get_price("AmD", 60))
# # print(sp.cache.get_price("AmD", 60))
# # print(sp.cache.get_price("AmD", 60))
# # print(sp.cache.get_price("nut", 60))
# symbols = set()
# with closing(sp.sql.getCursor()) as c:
# for row in c.execute("SELECT * FROM stockplay_holdings").fetchall():
# symbols.update([row["symbol"].lower()])
# print(symbols)
# # # symbols = "a bah chk crm cron f fb mdla nio too tsla".split()
# for symbol in symbols:
# p = sp.cache.get_price(symbol, 0)
# if not p:
# print("not supported:", symbol)
# continue
# print(symbol, "age: ", time() - p.time)
# # print(sp.cache.get_price("gigl", 60))