From 85166fb692886cde18bf8ad183c1e35545e6f425 Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 26 Feb 2019 19:39:40 -0800 Subject: [PATCH] Better message priority internals --- docs/api/modules/stockplay.rst | 43 +++++----- pyircbot/irccore.py | 35 ++++---- pyircbot/modules/StockPlay.py | 151 +++++++++++++++++++++++++-------- 3 files changed, 159 insertions(+), 70 deletions(-) diff --git a/docs/api/modules/stockplay.rst b/docs/api/modules/stockplay.rst index 9af76f2..8a81e2b 100644 --- a/docs/api/modules/stockplay.rst +++ b/docs/api/modules/stockplay.rst @@ -1,5 +1,5 @@ -:mod:`StockPlay` --- Simulated stock trading game -================================================= +:mod:`StockPlay` --- Stock-like trading game +============================================ This module provides a simulated stock trading game. Requires and api key from https://www.alphavantage.co/ to fetch stock quotes. @@ -7,12 +7,21 @@ 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. +maximum of 5 requests per minute and 500 requests per day. For reasonable trading - that is, executing trades at the +current market price - we need to be able to lookup the price of any symbol at any time. Likewise, to generate reports +we need to keep the prices of all symbols somewhat up to date. This happens at some interval - see *bginterval*. 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. +`(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. + +When trading, the price of the traded symbol is allowed to be *tcachesecs* seconds old before the API will be used to +fetch a more recent price. This value must be balanced against *bginterval* depending on your trade frequency +and variety. + +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 +*rcachesecs* setting controls the maximum price age before the API is hit. Commands @@ -20,24 +29,18 @@ Commands .. cmdoption:: .buy - Buy some number of the specified stock symbol such as ".buy 10 amd" + Buy some number of the specified symbol such as ".buy 10 amd" .. cmdoption:: .sell Sell similar to .buy -.. cmdoption:: .bal +.. cmdoption:: .port [] [] - 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. + 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 + full listing of the player's holdings. Per the above, values based on symbol prices may be delayed based on the + *rcachesecs* config setting. Config @@ -72,13 +75,13 @@ Config .. cmdoption:: tcachesecs - When performing a trade, how old of a cached stock value 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) .. cmdoption:: rcachesecs - When calculating a portfolio report, how old of a cached stock value is permitted before fetching from API. + When calculating a portfolio report, how old of a cached symbol price is permitted before fetching from API. Recommended ~4 hours (14400) diff --git a/pyircbot/irccore.py b/pyircbot/irccore.py index 3c8797d..672618e 100644 --- a/pyircbot/irccore.py +++ b/pyircbot/irccore.py @@ -15,6 +15,8 @@ from inspect import getargspec from pyircbot.common import burstbucket, parse_irc_line from collections import namedtuple from io import StringIO +from time import time + IRCEvent = namedtuple("IRCEvent", "command args prefix trailing") UserPrefix = namedtuple("UserPrefix", "nick username hostname") @@ -110,8 +112,8 @@ class IRCCore(object): async def outputqueue(self): self.bucket = burstbucket(self.rate_max, self.rate_int) while True: - prio, line = await self.outputq.get() # sleep until the bucket allows us to send + # TODO warn/drop option if age (the _ above is older than some threshold) if self.rate_limit: while True: s = self.bucket.get() @@ -119,6 +121,7 @@ class IRCCore(object): break else: await asyncio.sleep(s, loop=self._loop) + prio, _, line = await self.outputq.get() self.fire_hook('_SEND', args=None, prefix=None, trailing=None) self.log.debug(">>> {}".format(repr(line))) self.outputq.task_done() @@ -152,7 +155,7 @@ class IRCCore(object): if priority is None: self.outseq += 1 priority = self.outseq - asyncio.run_coroutine_threadsafe(self.outputq.put((priority, data, )), self._loop) + asyncio.run_coroutine_threadsafe(self.outputq.put((priority, time(), data, )), self._loop) " Module related code " def initHooks(self): @@ -313,12 +316,12 @@ class IRCCore(object): return self.nick " Action Methods " - def act_PONG(self, data): + def act_PONG(self, data, priority=1): """Use the `/pong` command - respond to server pings :param data: the string or number the server sent with it's ping :type data: str""" - self.sendRaw("PONG :%s" % data) + self.sendRaw("PONG :%s" % data, priority) def act_USER(self, username, hostname, realname, priority=2): """Use the USER protocol command. Used during connection @@ -344,18 +347,18 @@ class IRCCore(object): :param channel: the channel to attempt to join :type channel: str""" - self.sendRaw("JOIN %s" % channel, priority=3) + self.sendRaw("JOIN %s" % channel, priority) - def act_PRIVMSG(self, towho, message): + def act_PRIVMSG(self, towho, message, priority=3): """Use the `/msg` command :param towho: the target #channel or user's name :type towho: str :param message: the message to send :type message: str""" - self.sendRaw("PRIVMSG %s :%s" % (towho, message)) + self.sendRaw("PRIVMSG %s :%s" % (towho, message), priority) - def act_MODE(self, channel, mode, extra=None): + def act_MODE(self, channel, mode, extra=None, priority=2): """Use the `/mode` command :param channel: the channel this mode is for @@ -365,20 +368,20 @@ class IRCCore(object): :param extra: additional argument if the mode needs it. Example: user@*!* :type extra: str""" if extra is not None: - self.sendRaw("MODE %s %s %s" % (channel, mode, extra)) + self.sendRaw("MODE %s %s %s" % (channel, mode, extra), priority) else: - self.sendRaw("MODE %s %s" % (channel, mode)) + self.sendRaw("MODE %s %s" % (channel, mode), priority) - def act_ACTION(self, channel, action): + def act_ACTION(self, channel, action, priority=2): """Use the `/me ` command :param channel: the channel name or target's name the message is sent to :type channel: str :param action: the text to send :type action: str""" - self.sendRaw("PRIVMSG %s :\x01ACTION %s" % (channel, action)) + self.sendRaw("PRIVMSG %s :\x01ACTION %s" % (channel, action), priority) - def act_KICK(self, channel, who, comment=""): + def act_KICK(self, channel, who, comment="", priority=2): """Use the `/kick ` command :param channel: the channel from which the user will be kicked @@ -387,7 +390,7 @@ class IRCCore(object): :type action: str :param comment: the kick message :type comment: str""" - self.sendRaw("KICK %s %s :%s" % (channel, who, comment)) + self.sendRaw("KICK %s %s :%s" % (channel, who, comment), priority) def act_QUIT(self, message, priority=2): """Use the `/quit` command @@ -396,8 +399,8 @@ class IRCCore(object): :type message: str""" self.sendRaw("QUIT :%s" % message, priority) - def act_PASS(self, password): + def act_PASS(self, password, priority=1): """ Send server password, for use on connection """ - self.sendRaw("PASS %s" % password) + self.sendRaw("PASS %s" % password, priority) diff --git a/pyircbot/modules/StockPlay.py b/pyircbot/modules/StockPlay.py index 1f78175..11b79c9 100644 --- a/pyircbot/modules/StockPlay.py +++ b/pyircbot/modules/StockPlay.py @@ -21,6 +21,22 @@ DUSTACCT = "#dust" Trade = namedtuple("Trade", "nick buy symbol amount replyto") +def tabulate(rows, justify=None): + """ + :param rows: list of lists making up the table data + :param justify: array of True/False to enable left justification of text + """ + colwidths = [0] * len(rows[0]) + justify = justify or [False] * len(rows[0]) + for row in rows: + for col, value in enumerate(row): + colwidths[col] = max(colwidths[col], len(str(value))) + + for row in rows: + yield " ".join([("{: <{}}" if justify[coli] else "{: >{}}") + .format(value, colwidths[coli]) for coli, value in enumerate(row)]) + + def format_price(cents, prefix="$", plus=False): """ Formats cents as a dollar value @@ -36,6 +52,35 @@ def format_decimal(decm, prefix="$", plus=False): return "{}{}{:,.2f}".format(prefix, "+" if plus and decm >= 0 else "", decm) +def format_gainloss(diff, pct): + """ + Formats a difference and percent change as "+0.00 (0.52%)⬆" with appropriate IRC colors + """ + return ' '.join(format_gainloss_inner(diff, pct)) + + +def format_gainloss_inner(diff, pct): + """ + Formats a difference and percent change as "+0.00 (0.52%)⬆" with appropriate IRC colors + """ + profit = diff >= 0 + return "{}{}".format("\x0303" if profit else "\x0304", # green or red + format_decimal(diff, prefix="", plus=True)), \ + "({:,.2f}%){}\x0f".format(pct * 100, + "⬆" if profit else "⬇") + + +def calc_gain(start, end): + """ + Calculate the +/- gain percent given start/end values + :return: Decimal + """ + if not start: + return Decimal(0) + gain_value = end - start + return Decimal(gain_value) / Decimal(start) + + class StockPlay(ModuleBase): def __init__(self, bot, moduleName): ModuleBase.__init__(self, bot, moduleName) @@ -82,6 +127,12 @@ class StockPlay(ModuleBase): `cents` integer, PRIMARY KEY(nick, day) );""") + # if not self.sql.tableExists("stockplay_report_cache"): + # c.execute("""CREATE TABLE `stockplay_report_cache` ( + # `nick` varchar(64) PRIMARY KEY, + # `time` integer, + # `data` text + # );""") # Last time the interval tasks were executed self.task_time = 0 @@ -96,6 +147,31 @@ class StockPlay(ModuleBase): self.pricer = Thread(target=self.price_updater) self.pricer.start() + def ondisable(self): + self.running = False + self.trader.join() + self.pricer.join() + + def calc_user_avgbuy(self, nick, symbol): + """ + Calculate the average buy price of a user's stock. This is generated by backtracking through their + buy/sell history + :return: price, in cents + """ + target_count = self.get_holding(nick, symbol) # backtrack until we hit this many shares + spent = 0 + count = 0 + with closing(self.sql.getCursor()) as c: + for row in c.execute("SELECT * FROM stockplay_trades WHERE nick=? AND symbol=? ORDER BY time DESC", + (nick, symbol)).fetchall(): + count += row["count"] * (1 if row["type"] == "buy" else -1) + spent += row["price"] * (1 if row["type"] == "buy" else -1) + if count == target_count: # at this point in history the user held 0 of the symbol, stop backtracking + break + if not count: + return Decimal(0) + return Decimal(spent) / 100 / Decimal(count) + def price_updater(self): """ Perform quote cache updating task @@ -120,11 +196,6 @@ class StockPlay(ModuleBase): delay -= 1 sleep(1) - def ondisable(self): - self.running = False - self.trader.join() - self.pricer.join() - def trader_background(self): """ Perform trading, reporting and other background tasks @@ -150,6 +221,9 @@ class StockPlay(ModuleBase): def do_trade(self, trade): """ Perform a queued trade + + :param trade: trade struct to perform + :type trade: Trade """ self.log.warning("{} wants to {} {} of {}".format(trade.nick, "buy" if trade.buy else "sell", @@ -221,42 +295,42 @@ class StockPlay(ModuleBase): """ 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 + .port profit full + player: profit has cash: $491.02 stock value: ~$11,137.32 total: ~$11,628.34 (24h +1,504.37 (14.86%)⬆) + player: 1 AAPL bought at average $170.41 +3.92 (2.30%)⬆ now $174.33 + player: 14 AMD bought at average $23.05 +1.16 (5.03%)⬆ now $24.21 + player: 25 DBX bought at average $25.42 -1.08 (-4.25%)⬇ now $24.34 + player: 10 DENN bought at average $17.94 -0.27 (-1.51%)⬇ now $17.67 + player: 18 EA bought at average $99.77 -1.27 (-1.28%)⬇ now $98.50 + player: 10 INTC bought at average $53.23 +0.00 (0.00%)⬆ now $53.23 + player: 160 KPTI bought at average $4.88 +0.00 (0.00%)⬆ now $4.88 """ 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: ~{} {}" + self.bot.act_PRIVMSG(dest, "{}: {} cash: {} stock value: ~{} total: ~{} (24h {})" .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)) + format_gainloss(data["24hgain"], data["24hpct"]))) - # 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))) + if not full: + return + + rows = [] + for symbol, count, symprice, avgbuy, buychange in data["holdings"]: + rows.append([count, + symbol, + "bought at average", + format_decimal(avgbuy), + *format_gainloss_inner(symprice - avgbuy, buychange), + "now", + format_decimal(symprice)]) + + for line in tabulate(rows, justify=[False, True, True, False, False, False, True, False]): + self.bot.act_PRIVMSG(dest, "{}: {}".format(sender, line), priority=5) def build_report(self, nick): """ @@ -264,7 +338,7 @@ class StockPlay(ModuleBase): """ cash = Decimal(self.get_bal(nick)) / 100 - # generate a list of (symbol, count, price) tuples of the player's holdings + # generate a list of (symbol, count, price, avgbuy, %change_on_avgbuy) tuples of the player's holdings symbol_count = [] holding_value = Decimal(0) with closing(self.sql.getCursor()) as c: @@ -276,18 +350,26 @@ class StockPlay(ModuleBase): # is 86400 (1 day) symprice = Decimal(self.get_price(row["symbol"], self.config["rcachesecs"])) holding_value += symprice * row["count"] - symbol_count.append((row["symbol"], row["count"], symprice)) + avgbuy = self.calc_user_avgbuy(nick, row["symbol"]) + symbol_count.append((row["symbol"], + row["count"], + symprice, + avgbuy, + calc_gain(avgbuy, symprice))) + + symbol_count.sort(key=lambda x: x[0]) # sort by symbol name # 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 + gain_pct = calc_gain(Decimal(day_start_bal["cents"]) / 100, cash + holding_value) return {"cash": cash, "holdings": symbol_count, @@ -472,6 +554,7 @@ class StockPlay(ModuleBase): def set_bal(self, nick, amount): """ Set a player's balance + :param amount: new balance in cents """ with closing(self.sql.getCursor()) as c: