From 97941aa52905d5750016a926b907ecffca641b7a Mon Sep 17 00:00:00 2001 From: dave Date: Mon, 11 Feb 2019 12:05:06 -0800 Subject: [PATCH] Added stockplay module --- docs/api/modules/stockplay.rst | 102 +++++++ docs/changelog.rst | 3 +- examples/data/config/StockPlay.json | 8 + pyircbot/modules/StockPlay.py | 414 ++++++++++++++++++++++++++++ 4 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 docs/api/modules/stockplay.rst create mode 100644 examples/data/config/StockPlay.json create mode 100644 pyircbot/modules/StockPlay.py diff --git a/docs/api/modules/stockplay.rst b/docs/api/modules/stockplay.rst new file mode 100644 index 0000000..3aa3b36 --- /dev/null +++ b/docs/api/modules/stockplay.rst @@ -0,0 +1,102 @@ +:mod:`StockPlay` --- Simulated stock trading game +================================================= + +This module provides a simulated stock trading game. Requires and api key from +https://www.alphavantage.co/ to fetch stock quotes. + +Most commands require that the player login as described in the NickUser module. + +Note that it is important to configure api limitations when configuring this module. The alphavantage.co api allows a +maximum of 5 requests per minute and 500 requests per day. For reporting reasons we need to keep the prices of all +traded symbols reasonably up-to-date (see *bginterval*). This happens at some interval. + +Considering the daily limit means, when evenly spread, we can sent a request *no more often* than 173 seconds: +(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 leaving no room for normal trades. + + +Commands +-------- + +.. cmdoption:: .buy + + Buy some number of the specified stock symbol such as ".buy 10 amd" + +.. cmdoption:: .sell + + Sell similar to .buy + +.. cmdoption:: .bal + + Show a summary report on the value of the player's cash + stock holdings + +.. cmdoption:: .cash + + Show the player's cash balance + +.. cmdoption:: .port + + Get a report on the player's portfolio. Value based on stocks may be delayed based on the *rcachesecs* + config setting. + + +Config +------ + +.. code-block:: json + + { + "startbalance": 10000, + "tradedelay": 0, + "apikey": "xxxxxxxxxxxxxx", + "tcachesecs": 300, + "rcachesecs": 14400, + "bginterval": 300 + } + +.. cmdoption:: startbalance + + Number of dollars that players start with + +.. cmdoption:: tradedelay + + Delay in seconds between differing trades of the same symbol. Multiple buys OR multiple sells are allowed, but + not a mix. + + NOT IMPLEMENTED + +.. cmdoption:: apikey + + API key from https://www.alphavantage.co/support/#api-key + +.. cmdoption:: tcachesecs + + When performing a trade, how old of a cached stock value is permitted before fetching from API. + + Recommended ~30 minutes (1800) + +.. cmdoption:: rcachesecs + + When calculating a portfolio report, how old of a cached stock value is permitted before fetching from API. + + Recommended ~4 hours (14400) + +.. cmdoption:: bginterval + + Symbol prices are updated in the background. This is necessary because fetching a portfolio report may require + fetching many symbol prices. The alphavantage.co api allows only 5 calls per minute. Because of this limitation, + 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 + started that updates the price of symbols older than *rcachesecs*. + + Estimated 5 minute (300), but likely will need tuning depending on playerbase + + +Class Reference +--------------- + +.. automodule:: pyircbot.modules.StockPlay + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/changelog.rst b/docs/changelog.rst index 4e305be..661680e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,9 +2,10 @@ Changelog ========= +* :feature:`-` Added StockPlay module + * :release:`4.1.0 <2019-02-10>` * :support:`-` First documented release in awhile. Many new modules and tests have been added. See the git log if you so desire. -* :feature:`-` Added StockPlay module * :feature:`-` Upgraded docker base image to ubuntu:bionic * :feature:`-` Misc macOs related fixes * :feature:`-` Misc python 3.7 related fixes diff --git a/examples/data/config/StockPlay.json b/examples/data/config/StockPlay.json new file mode 100644 index 0000000..ca5911a --- /dev/null +++ b/examples/data/config/StockPlay.json @@ -0,0 +1,8 @@ +{ + "startbalance": 10000, + "tradedelay": 0, + "apikey": "", + "tcachesecs": 300, + "rcachesecs": 14400, + "bginterval": 300 +} diff --git a/pyircbot/modules/StockPlay.py b/pyircbot/modules/StockPlay.py new file mode 100644 index 0000000..a3b9374 --- /dev/null +++ b/pyircbot/modules/StockPlay.py @@ -0,0 +1,414 @@ +from pyircbot.modulebase import ModuleBase, MissingDependancyException, command +from pyircbot.modules.ModInfo import info +from pyircbot.modules.NickUser import protected +from contextlib import closing +from decimal import Decimal +from time import sleep, time +from queue import Queue, Empty +from threading import Thread +from requests import get +from collections import namedtuple +from math import ceil +import re +import json +import traceback + + +RE_SYMBOL = re.compile(r'^([A-Z\-]+)$') +DUSTACCT = "#dust" + +Trade = namedtuple("Trade", "nick buy symbol amount replyto") + + +def format_price(cents): + return "${:,.2f}".format(Decimal(cents) / 100) + + +class StockPlay(ModuleBase): + def __init__(self, bot, moduleName): + ModuleBase.__init__(self, bot, moduleName) + self.sqlite = self.bot.getBestModuleForService("sqlite") + + if self.sqlite is None: + raise MissingDependancyException("StockPlay: SQLIite service is required.") + + self.sql = self.sqlite.opendb("stockplay.db") + + with closing(self.sql.getCursor()) as c: + if not self.sql.tableExists("stockplay_balances"): + c.execute("""CREATE TABLE `stockplay_balances` ( + `nick` varchar(64) PRIMARY KEY, + `cents` integer + );""") + c.execute("""INSERT INTO `stockplay_balances` VALUES (?, ?)""", (DUSTACCT, 0)) + if not self.sql.tableExists("stockplay_holdings"): + c.execute("""CREATE TABLE `stockplay_holdings` ( + `nick` varchar(64), + `symbol` varchar(12), + `count` integer, + PRIMARY KEY (nick, symbol) + );""") + if not self.sql.tableExists("stockplay_trades"): + c.execute("""CREATE TABLE `stockplay_trades` ( + `nick` varchar(64), + `time` integer, + `type` varchar(8), + `symbol` varchar(12), + `count` integer, + `price` integer + );""") + if not self.sql.tableExists("stockplay_prices"): + c.execute("""CREATE TABLE `stockplay_prices` ( + `symbol` varchar(12) PRIMARY KEY, + `time` integer, + `data` text + );""") + + # trade executor thread + self.asyncq = Queue() + self.running = True + self.trader = Thread(target=self.trader_background) + self.trader.start() + + self.pricer = Thread(target=self.price_updater) + self.pricer.start() + + def price_updater(self): + """ + Perform quote cache updating task + """ + while self.running: + self.log.info("price_updater") + try: + updatesym = None + with closing(self.sql.getCursor()) as c: + row = c.execute("""SELECT * FROM stockplay_prices + WHERE symbol in (SELECT symbol FROM stockplay_holdings WHERE count>0) + ORDER BY time ASC LIMIT 1""").fetchone() + updatesym = row["symbol"] if row else None + + if updatesym: + self.get_price(updatesym, 0) + + except Exception: + traceback.print_exc() + delay = self.config["bginterval"] + while self.running and delay > 0: + delay -= 1 + sleep(1) + + def ondisable(self): + self.running = False + self.trader.join() + self.pricer.join() + + def trader_background(self): + """ + Perform trading and reporting tasks + """ + while self.running: + try: + self.do_background() + except Exception: + traceback.print_exc() + continue + + def do_background(self): + queued = None + try: + queued = self.asyncq.get(block=True, timeout=1) + except Empty: + return + if not queued: + return + + action, data = queued + + if action == "trade": + # Perform a stock trade + trade = data + self.log.warning("{} wants to {} {} of {}".format(trade.nick, + "buy" if trade.buy else "sell", + trade.amount, + trade.symbol)) + # Update quote price + try: + symprice = self.get_price(trade.symbol, self.config["tcachesecs"]) + except Exception: + traceback.print_exc() + self.bot.act_PRIVMSG(trade.replyto, "{}: invalid symbol or api failure, trade aborted!" + .format(trade.nick)) + return + if symprice is None: + self.bot.act_PRIVMSG(trade.replyto, + "{}: invalid symbol '{}'".format(trade.nick, trade.symbol)) + return # invalid stock + + # calculate various prices needed + # symprice -= Decimal("0.0001") # for testing dust collection + dprice = symprice * trade.amount + # print("that would cost ", repr(dprice)) + price_rounded = int(ceil(dprice * 100)) # now in cents + dust = abs((dprice * 100) - price_rounded) # cent fractions that we rounded out + self.log.info("our price: {}".format(price_rounded)) + self.log.info("dust: {}".format(dust)) + + # fetch existing user balances + nickbal = self.get_bal(trade.nick) + count = self.get_holding(trade.nick, trade.symbol) + + # check if trade is legal + if trade.buy and nickbal < price_rounded: + self.bot.act_PRIVMSG(trade.replyto, "{}: you can't afford {}." + .format(trade.nick, format_price(price_rounded))) + return # can't afford trade + if not trade.buy and trade.amount > count: + self.bot.act_PRIVMSG(trade.replyto, "{}: you don't have that many.".format(trade.nick)) + return # asked to sell more shares than they have + + # perform trade calculations + if trade.buy: + nickbal -= price_rounded + count += trade.amount + else: + nickbal += price_rounded + count -= trade.amount + + # commit the trade + self.set_bal(trade.nick, nickbal) + self.set_holding(trade.nick, trade.symbol, count) + + # save dust + dustbal = self.get_bal(DUSTACCT) + self.set_bal(DUSTACCT, dustbal + int(dust * 100)) + + # notify user + self.bot.act_PRIVMSG(trade.replyto, + "{}: {} {} {} for {}. cash: {}".format(trade.nick, + "bought" if trade.buy else "sold", + trade.amount, + trade.symbol, + format_price(price_rounded), + format_price(nickbal))) + + self.log_trade(trade.nick, time(), "buy" if trade.buy else "sell", + trade.symbol, trade.amount, price_rounded) + + elif action == "portreport": + # Generate a text report of the nick's portfolio + # <@player> .port + # player: cash: $2,501.73 stock value: ~$7,498.27 total: ~$10,000.00 + # player: 122xAMD=$2,812.10, 10xFB=$1,673.30, 10xJNUG=$108.80, 5xINTC=$244.20, ... + # player: 1xJD=$23.99, 1xMFGP=$19.78, 1xNOK=$6.16, 1xNVDA=$148.17, 1xTWTR=$30.01 + lookup, sender, replyto, full = data + cash = self.get_bal(lookup) + # when $full is true we PM the user instead + # when $full is false we just say their total value + + # generate a list of (symbol, count, ) tuples of the player's symbol holdings + symbol_count = [] + with closing(self.sql.getCursor()) as c: + for row in c.execute("SELECT * FROM stockplay_holdings WHERE count>0 AND nick=? ORDER BY count DESC", + (lookup, )).fetchall(): + symbol_count.append((row["symbol"], row["count"], )) + + # calculate the cash sum of the player's symbol holdings (while also formatting text representations) + sym_x_count = [] + stock_value = Decimal(0) + for symbol, count in symbol_count: + # 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 + # because it's simply impossible to request fresh data for every stock right now. Recommended rcachesecs + # is 86400 (1 day) + symprice = self.get_price(symbol, self.config["rcachesecs"]) + dprice = Decimal(symprice * count) * 100 + stock_value += dprice + sym_x_count.append("{}x{}={}".format(count, symbol, format_price(dprice))) + + dest = sender if full else replyto + + self.bot.act_PRIVMSG(dest, "{}: {} cash: {} stock value: ~{} total: ~{}" + .format(sender, + "you have" if lookup == sender else "{} has".format(lookup), + format_price(cash), + format_price(stock_value), + format_price(cash + stock_value))) + if full: + # print symbol_count with a max of 10 symbols per line + while sym_x_count: + message_segment = [] + for i in range(min(len(sym_x_count), 10)): # show up to 10 "SYMx$d0llar, " strings per message + message_segment.append(sym_x_count.pop(0)) + if sym_x_count: # if there's more to print, append an ellipsis to indicate a forthcoming message + message_segment.append("...") + self.bot.act_PRIVMSG(dest, "{}: {}".format(sender, ", ".join(message_segment))) + + 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 VALUES (?, ?, ?)", + (symbol, 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 time() - row["time"] > thresh: + return + return json.loads(row["data"]) + + def fetch_priceinfo(self, symbol): + """ + API provides + {'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): + if len(s) > 12: + return + s = s.upper() + if not RE_SYMBOL.match(s): + return + return s + + @info("buy ", "buy of stock ", cmds=["buy"]) + @command("buy", require_args=True, allow_private=True) + @protected() + def cmd_buy(self, message, command): + self.check_nick(message.prefix.nick) + amount = int(command.args[0]) + symbol = self.checksym(command.args[1]) + if not symbol or amount <= 0: + return + self.asyncq.put(("trade", Trade(message.prefix.nick, + True, + symbol, + amount, + message.args[0] if message.args[0].startswith("#") else message.prefix.nick))) + + @info("sell ", "buy of stock ", cmds=["sell"]) + @command("sell", require_args=True, allow_private=True) + @protected() + def cmd_sell(self, message, command): + self.check_nick(message.prefix.nick) + amount = int(command.args[0]) + symbol = self.checksym(command.args[1]) + if not symbol or amount <= 0: + return + self.asyncq.put(("trade", Trade(message.prefix.nick, + False, + symbol, + amount, + message.args[0] if message.args[0].startswith("#") else message.prefix.nick))) + + @info("port", "show portfolio holdings", cmds=["port", "portfolio"]) + @command("port", "portfolio", allow_private=True) + @protected() + def cmd_port(self, message, command): + full = False + lookup = message.prefix.nick + if command.args: + if command.args[0] == "full": + full = True + else: + lookup = command.args[0] + if len(command.args) > 1 and command.args[1] == "full": + full = True + + self.asyncq.put(("portreport", (lookup, + message.prefix.nick, + message.prefix.nick if full or not message.args[0].startswith("#") + else message.args[0], + full))) + + def check_nick(self, nick): + if not self.nick_exists(nick): + self.set_bal(nick, self.config["startbalance"] * 100) # initial balance for user + # TODO welcome message + # TODO maybe even some random free shares for funzies + + def nick_exists(self, name): + with closing(self.sql.getCursor()) as c: + return c.execute("SELECT COUNT(*) as num FROM stockplay_balances WHERE nick=?", + (name, )).fetchone()["num"] and True + + def set_bal(self, nick, amount): + with closing(self.sql.getCursor()) as c: + c.execute("REPLACE INTO stockplay_balances VALUES (?, ?)", + (nick, amount, )) + + def get_bal(self, nick): + with closing(self.sql.getCursor()) as c: + return c.execute("SELECT * FROM stockplay_balances WHERE nick=?", + (nick, )).fetchone()["cents"] + + def get_holding(self, nick, symbol): + assert symbol == symbol.upper() + with closing(self.sql.getCursor()) as c: + r = c.execute("SELECT * FROM stockplay_holdings WHERE nick=? AND symbol=?", + (nick, symbol, )).fetchone() + return r["count"] if r else 0 + + def set_holding(self, nick, symbol, count): + with closing(self.sql.getCursor()) as c: + c.execute("REPLACE INTO stockplay_holdings VALUES (?, ?, ?)", + (nick, symbol, count, )) + + def log_trade(self, nick, time, type, symbol, count, price): + with closing(self.sql.getCursor()) as c: + c.execute("INSERT INTO stockplay_trades VALUES (?, ?, ?, ?, ?, ?)", + (nick, time, type, symbol, count, price, ))