Added stockplay module

This commit is contained in:
dave 2019-02-11 12:05:06 -08:00
parent 7546b05191
commit 97941aa529
4 changed files with 526 additions and 1 deletions

View File

@ -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 <amount> <symbol>
Buy some number of the specified stock symbol such as ".buy 10 amd"
.. cmdoption:: .sell <amount> <symbol>
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:

View File

@ -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

View File

@ -0,0 +1,8 @@
{
"startbalance": 10000,
"tradedelay": 0,
"apikey": "",
"tcachesecs": 300,
"rcachesecs": 14400,
"bginterval": 300
}

View File

@ -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
# <bot> player: cash: $2,501.73 stock value: ~$7,498.27 total: ~$10,000.00
# <bot> player: 122xAMD=$2,812.10, 10xFB=$1,673.30, 10xJNUG=$108.80, 5xINTC=$244.20, ...
# <bot> 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 <amount> <symbol>", "buy <amount> of stock <symbol>", 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 <amount> <symbol>", "buy <amount> of stock <symbol>", 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, ))