diff --git a/docs/api/modules/stockplay.rst b/docs/api/modules/stockplay.rst index 3aa3b36..9af76f2 100644 --- a/docs/api/modules/stockplay.rst +++ b/docs/api/modules/stockplay.rst @@ -51,7 +51,8 @@ Config "apikey": "xxxxxxxxxxxxxx", "tcachesecs": 300, "rcachesecs": 14400, - "bginterval": 300 + "bginterval": 300, + "midnight_offset": 0 } .. cmdoption:: startbalance @@ -92,6 +93,16 @@ Config Estimated 5 minute (300), but likely will need tuning depending on playerbase +.. cmdoption:: midnight_offset + + Number of seconds **added** to the clock when calculating midnight. + + At midnight, the bot logs all player balances for use in gain/loss over time calculations later on. If you want this + to happen at midnight system time, leave this at 0. Otherwise, it can be set to some number of seconds to e.g. to + compensate for time zones. + + Default: 0 + Class Reference --------------- diff --git a/examples/data/config/StockPlay.json b/examples/data/config/StockPlay.json index ca5911a..8c87211 100644 --- a/examples/data/config/StockPlay.json +++ b/examples/data/config/StockPlay.json @@ -4,5 +4,6 @@ "apikey": "", "tcachesecs": 300, "rcachesecs": 14400, - "bginterval": 300 + "bginterval": 300, + "midnight_offset": 0 } diff --git a/pyircbot/modules/StockPlay.py b/pyircbot/modules/StockPlay.py index a3b9374..1f78175 100644 --- a/pyircbot/modules/StockPlay.py +++ b/pyircbot/modules/StockPlay.py @@ -9,6 +9,7 @@ from threading import Thread from requests import get from collections import namedtuple from math import ceil +from datetime import datetime, timedelta import re import json import traceback @@ -20,8 +21,19 @@ DUSTACCT = "#dust" Trade = namedtuple("Trade", "nick buy symbol amount replyto") -def format_price(cents): - return "${:,.2f}".format(Decimal(cents) / 100) +def format_price(cents, prefix="$", plus=False): + """ + Formats cents as a dollar value + """ + return format_decimal((Decimal(cents) / 100) if cents > 0 else 0, # avoids "-0.00" output + prefix, plus) + + +def format_decimal(decm, prefix="$", plus=False): + """ + Formats a decimal as a dollar value + """ + return "{}{}{:,.2f}".format(prefix, "+" if plus and decm >= 0 else "", decm) class StockPlay(ModuleBase): @@ -63,13 +75,24 @@ class StockPlay(ModuleBase): `time` integer, `data` text );""") + if not self.sql.tableExists("stockplay_balance_history"): + c.execute("""CREATE TABLE `stockplay_balance_history` ( + `nick` varchar(64), + `day` text, + `cents` integer, + PRIMARY KEY(nick, day) + );""") - # trade executor thread + # Last time the interval tasks were executed + self.task_time = 0 + + # background work executor thread self.asyncq = Queue() self.running = True self.trader = Thread(target=self.trader_background) self.trader.start() + # quote updater thread self.pricer = Thread(target=self.price_updater) self.pricer.start() @@ -104,144 +127,183 @@ class StockPlay(ModuleBase): def trader_background(self): """ - Perform trading and reporting tasks + Perform trading, reporting and other background tasks """ while self.running: try: - self.do_background() + queued = None + try: + queued = self.asyncq.get(block=True, timeout=1) + except Empty: + self.do_tasks() + continue + if queued: + action, data = queued + if action == "trade": + self.do_trade(data) + elif action == "portreport": + self.do_report(*data) except Exception: traceback.print_exc() continue - def do_background(self): - queued = None + def do_trade(self, trade): + """ + Perform a queued trade + """ + self.log.warning("{} wants to {} {} of {}".format(trade.nick, + "buy" if trade.buy else "sell", + trade.amount, + trade.symbol)) + # Update quote price try: - queued = self.asyncq.get(block=True, timeout=1) - except Empty: + 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 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 + if symprice is None: 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))) + "{}: invalid symbol '{}'".format(trade.nick, trade.symbol)) + return # invalid stock - self.log_trade(trade.nick, time(), "buy" if trade.buy else "sell", - trade.symbol, trade.amount, price_rounded) + # calculate various prices needed + # symprice -= Decimal("0.0001") # for testing dust collection + dprice = symprice * trade.amount + 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)) - 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 + # fetch existing user balances + nickbal = self.get_bal(trade.nick) + count = self.get_holding(trade.nick, trade.symbol) - # 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"], )) + # 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 - # 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: + # 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) + + def do_report(self, lookup, sender, replyto, full): + """ + 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 + """ + data = self.build_report(lookup) + dest = sender if full else replyto + + # Format holdings as "{symbol}x{price}={total_value}" + sym_x_count = [] + for symbol, count, symprice in data["holdings"]: + sym_x_count.append("{}x{}={}".format(count, symbol, format_decimal(symprice * count))) + + profit = data["24hgain"] >= 0 + gainloss = "(24h {}{} ({:,.2f}%){}\x0f)" \ + .format("\x0303" if profit else "\x0304", # green or red + format_decimal(data["24hgain"], prefix="", plus=True), + data["24hpct"] * 100, + "⬆" if profit else "⬇") + + self.bot.act_PRIVMSG(dest, "{}: {} cash: {} stock value: ~{} total: ~{} {}" + .format(sender, + "you have" if lookup == sender else "{} has".format(lookup), + format_decimal(data["cash"]), + format_decimal(data["holding_value"]), + format_decimal(data["cash"] + data["holding_value"]), + gainloss)) + + # print each symbol_count/total value with a max of 10 symbols per line + while full and 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 build_report(self, nick): + """ + Return a dict containing the player's cash, stock value, holdings listing, and 24 hour statistics. + """ + cash = Decimal(self.get_bal(nick)) / 100 + + # generate a list of (symbol, count, price) tuples of the player's holdings + symbol_count = [] + holding_value = Decimal(0) + 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", + (nick, )).fetchall(): # 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))) + symprice = Decimal(self.get_price(row["symbol"], self.config["rcachesecs"])) + holding_value += symprice * row["count"] + symbol_count.append((row["symbol"], row["count"], symprice)) - dest = sender if full else replyto + # calculate gain/loss percent + # TODO 1 week / 2 week / 1 month averages + day_start_bal = self.get_latest_hist_bal(nick) + gain_value = Decimal(0) + gain_pct = Decimal(0) + if day_start_bal: + newbal = cash + holding_value + startbal = Decimal(day_start_bal["cents"]) / 100 + gain_value = newbal - startbal + gain_pct = gain_value / startbal - 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))) + return {"cash": cash, + "holdings": symbol_count, + "holding_value": holding_value, + "24hgain": gain_value, + "24hpct": gain_pct} + + def do_tasks(self): + """ + Do interval tasks such as recording nightly balances + """ + now = time() + if now - 60 < self.task_time: + return + self.task_time = now + self.record_nightly_balances() def get_price(self, symbol, thresh=None): """ @@ -280,7 +342,8 @@ class StockPlay(ModuleBase): def fetch_priceinfo(self, symbol): """ - API provides + Request a stock quote from the API. The API provides the format:: + {'Global Quote': { {'01. symbol': 'MSFT', '02. open': '104.3900', @@ -292,7 +355,9 @@ class StockPlay(ModuleBase): '08. previous close':'105.2700', '09. change': '0.4000', '10. change percent': '0.3800%'}} - Reformat as: + + Reformat as:: + {'symbol': 'AMD', 'open': '22.3300', 'high': '23.2750', @@ -318,6 +383,9 @@ class StockPlay(ModuleBase): return {k[4:]: v for k, v in data.items() if k[4:] in keys} def checksym(self, s): + """ + Validate that a string looks like a stock symbol + """ if len(s) > 12: return s = s.upper() @@ -329,6 +397,9 @@ class StockPlay(ModuleBase): @command("buy", require_args=True, allow_private=True) @protected() def cmd_buy(self, message, command): + """ + Command to buy stocks + """ self.check_nick(message.prefix.nick) amount = int(command.args[0]) symbol = self.checksym(command.args[1]) @@ -344,6 +415,9 @@ class StockPlay(ModuleBase): @command("sell", require_args=True, allow_private=True) @protected() def cmd_sell(self, message, command): + """ + Command to sell stocks + """ self.check_nick(message.prefix.nick) amount = int(command.args[0]) symbol = self.checksym(command.args[1]) @@ -359,6 +433,9 @@ class StockPlay(ModuleBase): @command("port", "portfolio", allow_private=True) @protected() def cmd_port(self, message, command): + """ + Portfolio report command + """ full = False lookup = message.prefix.nick if command.args: @@ -376,27 +453,44 @@ class StockPlay(ModuleBase): full))) def check_nick(self, nick): + """ + Set up a user's account by setting the initial balance + """ 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): + """ + Check whether a nick has a record + """ 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): + """ + Set a player's balance + :param amount: new balance in cents + """ with closing(self.sql.getCursor()) as c: c.execute("REPLACE INTO stockplay_balances VALUES (?, ?)", (nick, amount, )) def get_bal(self, nick): + """ + Get player's balance + :return: balance in cents + """ 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): + """ + Return the number of stocks of a certain symbol a player has + """ assert symbol == symbol.upper() with closing(self.sql.getCursor()) as c: r = c.execute("SELECT * FROM stockplay_holdings WHERE nick=? AND symbol=?", @@ -404,11 +498,38 @@ class StockPlay(ModuleBase): return r["count"] if r else 0 def set_holding(self, nick, symbol, count): + """ + Set the number of stocks of a certain symbol a player that + """ 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): + """ + Append a record of a trade to the database log + """ with closing(self.sql.getCursor()) as c: c.execute("INSERT INTO stockplay_trades VALUES (?, ?, ?, ?, ?, ?)", (nick, time, type, symbol, count, price, )) + + def get_latest_hist_bal(self, nick): + """ + Return the most recent historical balance of a player. Aka their "opening" value. + """ + with closing(self.sql.getCursor()) as c: + return c.execute("SELECT * FROM stockplay_balance_history WHERE nick=? ORDER BY DAY DESC LIMIT 1", + (nick, )).fetchone() + + def record_nightly_balances(self): + """ + Create a record for each user's balance at the start of each day. + """ + now = (datetime.now() + timedelta(seconds=self.config.get("midnight_offset", 0))).strftime("%Y-%m-%d") + with closing(self.sql.getCursor()) as c: + for row in c.execute("""SELECT * FROM stockplay_balances WHERE nick NOT IN + (SELECT nick FROM stockplay_balance_history WHERE day=?)""", (now, )).fetchall(): + data = self.build_report(row["nick"]) + total = int((data["cash"] + data["holding_value"]) * 100) + self.log.info("Recording {} daily balance for {}".format(now, row["nick"])) + c.execute("INSERT INTO stockplay_balance_history VALUES (?, ?, ?)", (row["nick"], now, total)) diff --git a/setup.py b/setup.py index 5b54bb3..34f39d8 100755 --- a/setup.py +++ b/setup.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 from setuptools import setup -__version__ = "4.0.0-r03" +__version__ = "4.1.0" setup(name='pyircbot', - version='4.0.0-r03', + version=__version__, description='A modular python irc bot', url='http://gitlab.xmopx.net/dave/pyircbot3/tree/master', author='dpedu',