Better message priority internals

This commit is contained in:
dave 2019-02-26 19:39:40 -08:00
parent ef2abe3622
commit 85166fb692
3 changed files with 159 additions and 70 deletions

View File

@ -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 This module provides a simulated stock trading game. Requires and api key from
https://www.alphavantage.co/ to fetch stock quotes. 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. 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 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 maximum of 5 requests per minute and 500 requests per day. For reasonable trading - that is, executing trades at the
traded symbols reasonably up-to-date (see *bginterval*). This happens at some interval. 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: 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 `(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. 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 Commands
@ -20,24 +29,18 @@ Commands
.. cmdoption:: .buy <amount> <symbol> .. cmdoption:: .buy <amount> <symbol>
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 <amount> <symbol> .. cmdoption:: .sell <amount> <symbol>
Sell similar to .buy Sell similar to .buy
.. cmdoption:: .bal .. cmdoption:: .port [<player>] [<full>]
Show a summary report on the value of the player's cash + stock holdings 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
.. cmdoption:: .cash full listing of the player's holdings. Per the above, values based on symbol prices may be delayed based on the
*rcachesecs* config setting.
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 Config
@ -72,13 +75,13 @@ Config
.. cmdoption:: tcachesecs .. 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) Recommended ~30 minutes (1800)
.. cmdoption:: rcachesecs .. 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) Recommended ~4 hours (14400)

View File

@ -15,6 +15,8 @@ from inspect import getargspec
from pyircbot.common import burstbucket, parse_irc_line from pyircbot.common import burstbucket, parse_irc_line
from collections import namedtuple from collections import namedtuple
from io import StringIO from io import StringIO
from time import time
IRCEvent = namedtuple("IRCEvent", "command args prefix trailing") IRCEvent = namedtuple("IRCEvent", "command args prefix trailing")
UserPrefix = namedtuple("UserPrefix", "nick username hostname") UserPrefix = namedtuple("UserPrefix", "nick username hostname")
@ -110,8 +112,8 @@ class IRCCore(object):
async def outputqueue(self): async def outputqueue(self):
self.bucket = burstbucket(self.rate_max, self.rate_int) self.bucket = burstbucket(self.rate_max, self.rate_int)
while True: while True:
prio, line = await self.outputq.get()
# sleep until the bucket allows us to send # 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: if self.rate_limit:
while True: while True:
s = self.bucket.get() s = self.bucket.get()
@ -119,6 +121,7 @@ class IRCCore(object):
break break
else: else:
await asyncio.sleep(s, loop=self._loop) await asyncio.sleep(s, loop=self._loop)
prio, _, line = await self.outputq.get()
self.fire_hook('_SEND', args=None, prefix=None, trailing=None) self.fire_hook('_SEND', args=None, prefix=None, trailing=None)
self.log.debug(">>> {}".format(repr(line))) self.log.debug(">>> {}".format(repr(line)))
self.outputq.task_done() self.outputq.task_done()
@ -152,7 +155,7 @@ class IRCCore(object):
if priority is None: if priority is None:
self.outseq += 1 self.outseq += 1
priority = self.outseq 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 " " Module related code "
def initHooks(self): def initHooks(self):
@ -313,12 +316,12 @@ class IRCCore(object):
return self.nick return self.nick
" Action Methods " " Action Methods "
def act_PONG(self, data): def act_PONG(self, data, priority=1):
"""Use the `/pong` command - respond to server pings """Use the `/pong` command - respond to server pings
:param data: the string or number the server sent with it's ping :param data: the string or number the server sent with it's ping
:type data: str""" :type data: str"""
self.sendRaw("PONG :%s" % data) self.sendRaw("PONG :%s" % data, priority)
def act_USER(self, username, hostname, realname, priority=2): def act_USER(self, username, hostname, realname, priority=2):
"""Use the USER protocol command. Used during connection """Use the USER protocol command. Used during connection
@ -344,18 +347,18 @@ class IRCCore(object):
:param channel: the channel to attempt to join :param channel: the channel to attempt to join
:type channel: str""" :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 """Use the `/msg` command
:param towho: the target #channel or user's name :param towho: the target #channel or user's name
:type towho: str :type towho: str
:param message: the message to send :param message: the message to send
:type message: str""" :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 """Use the `/mode` command
:param channel: the channel this mode is for :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@*!* :param extra: additional argument if the mode needs it. Example: user@*!*
:type extra: str""" :type extra: str"""
if extra is not None: 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: 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 <action>` command """Use the `/me <action>` command
:param channel: the channel name or target's name the message is sent to :param channel: the channel name or target's name the message is sent to
:type channel: str :type channel: str
:param action: the text to send :param action: the text to send
:type action: str""" :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 <user> <message>` command """Use the `/kick <user> <message>` command
:param channel: the channel from which the user will be kicked :param channel: the channel from which the user will be kicked
@ -387,7 +390,7 @@ class IRCCore(object):
:type action: str :type action: str
:param comment: the kick message :param comment: the kick message
:type comment: str""" :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): def act_QUIT(self, message, priority=2):
"""Use the `/quit` command """Use the `/quit` command
@ -396,8 +399,8 @@ class IRCCore(object):
:type message: str""" :type message: str"""
self.sendRaw("QUIT :%s" % message, priority) 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 Send server password, for use on connection
""" """
self.sendRaw("PASS %s" % password) self.sendRaw("PASS %s" % password, priority)

View File

@ -21,6 +21,22 @@ DUSTACCT = "#dust"
Trade = namedtuple("Trade", "nick buy symbol amount replyto") 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): def format_price(cents, prefix="$", plus=False):
""" """
Formats cents as a dollar value 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) 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): class StockPlay(ModuleBase):
def __init__(self, bot, moduleName): def __init__(self, bot, moduleName):
ModuleBase.__init__(self, bot, moduleName) ModuleBase.__init__(self, bot, moduleName)
@ -82,6 +127,12 @@ class StockPlay(ModuleBase):
`cents` integer, `cents` integer,
PRIMARY KEY(nick, day) 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 # Last time the interval tasks were executed
self.task_time = 0 self.task_time = 0
@ -96,6 +147,31 @@ class StockPlay(ModuleBase):
self.pricer = Thread(target=self.price_updater) self.pricer = Thread(target=self.price_updater)
self.pricer.start() 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): def price_updater(self):
""" """
Perform quote cache updating task Perform quote cache updating task
@ -120,11 +196,6 @@ class StockPlay(ModuleBase):
delay -= 1 delay -= 1
sleep(1) sleep(1)
def ondisable(self):
self.running = False
self.trader.join()
self.pricer.join()
def trader_background(self): def trader_background(self):
""" """
Perform trading, reporting and other background tasks Perform trading, reporting and other background tasks
@ -150,6 +221,9 @@ class StockPlay(ModuleBase):
def do_trade(self, trade): def do_trade(self, trade):
""" """
Perform a queued trade Perform a queued trade
:param trade: trade struct to perform
:type trade: Trade
""" """
self.log.warning("{} wants to {} {} of {}".format(trade.nick, self.log.warning("{} wants to {} {} of {}".format(trade.nick,
"buy" if trade.buy else "sell", "buy" if trade.buy else "sell",
@ -221,42 +295,42 @@ class StockPlay(ModuleBase):
""" """
Generate a text report of the nick's portfolio :: Generate a text report of the nick's portfolio ::
<@player> .port <player> .port profit full
<bot> player: cash: $2,501.73 stock value: ~$7,498.27 total: ~$10,000.00 <bloomberg_terminal> player: profit has cash: $491.02 stock value: ~$11,137.32 total: ~$11,628.34 (24h +1,504.37 (14.86%))
<bot> player: 122xAMD=$2,812.10, 10xFB=$1,673.30, 10xJNUG=$108.80, 5xINTC=$244.20, ... <bloomberg_terminal> player: 1 AAPL bought at average $170.41 +3.92 (2.30%) now $174.33
<bot> player: 1xJD=$23.99, 1xMFGP=$19.78, 1xNOK=$6.16, 1xNVDA=$148.17, 1xTWTR=$30.01 <bloomberg_terminal> player: 14 AMD bought at average $23.05 +1.16 (5.03%) now $24.21
<bloomberg_terminal> player: 25 DBX bought at average $25.42 -1.08 (-4.25%) now $24.34
<bloomberg_terminal> player: 10 DENN bought at average $17.94 -0.27 (-1.51%) now $17.67
<bloomberg_terminal> player: 18 EA bought at average $99.77 -1.27 (-1.28%) now $98.50
<bloomberg_terminal> player: 10 INTC bought at average $53.23 +0.00 (0.00%) now $53.23
<bloomberg_terminal> player: 160 KPTI bought at average $4.88 +0.00 (0.00%) now $4.88
""" """
data = self.build_report(lookup) data = self.build_report(lookup)
dest = sender if full else replyto dest = sender if full else replyto
# Format holdings as "{symbol}x{price}={total_value}" self.bot.act_PRIVMSG(dest, "{}: {} cash: {} stock value: ~{} total: ~{} (24h {})"
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, .format(sender,
"you have" if lookup == sender else "{} has".format(lookup), "you have" if lookup == sender else "{} has".format(lookup),
format_decimal(data["cash"]), format_decimal(data["cash"]),
format_decimal(data["holding_value"]), format_decimal(data["holding_value"]),
format_decimal(data["cash"] + 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 if not full:
while full and sym_x_count: return
message_segment = []
for i in range(min(len(sym_x_count), 10)): # show up to 10 "SYMx$d0llar, " strings per message rows = []
message_segment.append(sym_x_count.pop(0)) for symbol, count, symprice, avgbuy, buychange in data["holdings"]:
if sym_x_count: # if there's more to print, append an ellipsis to indicate a forthcoming message rows.append([count,
message_segment.append("...") symbol,
self.bot.act_PRIVMSG(dest, "{}: {}".format(sender, ", ".join(message_segment))) "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): def build_report(self, nick):
""" """
@ -264,7 +338,7 @@ class StockPlay(ModuleBase):
""" """
cash = Decimal(self.get_bal(nick)) / 100 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 = [] symbol_count = []
holding_value = Decimal(0) holding_value = Decimal(0)
with closing(self.sql.getCursor()) as c: with closing(self.sql.getCursor()) as c:
@ -276,18 +350,26 @@ class StockPlay(ModuleBase):
# is 86400 (1 day) # is 86400 (1 day)
symprice = Decimal(self.get_price(row["symbol"], self.config["rcachesecs"])) symprice = Decimal(self.get_price(row["symbol"], self.config["rcachesecs"]))
holding_value += symprice * row["count"] 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 # calculate gain/loss percent
# TODO 1 week / 2 week / 1 month averages # TODO 1 week / 2 week / 1 month averages
day_start_bal = self.get_latest_hist_bal(nick) day_start_bal = self.get_latest_hist_bal(nick)
gain_value = Decimal(0) gain_value = Decimal(0)
gain_pct = Decimal(0) gain_pct = Decimal(0)
if day_start_bal: if day_start_bal:
newbal = cash + holding_value newbal = cash + holding_value
startbal = Decimal(day_start_bal["cents"]) / 100 startbal = Decimal(day_start_bal["cents"]) / 100
gain_value = newbal - startbal 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, return {"cash": cash,
"holdings": symbol_count, "holdings": symbol_count,
@ -472,6 +554,7 @@ class StockPlay(ModuleBase):
def set_bal(self, nick, amount): def set_bal(self, nick, amount):
""" """
Set a player's balance Set a player's balance
:param amount: new balance in cents :param amount: new balance in cents
""" """
with closing(self.sql.getCursor()) as c: with closing(self.sql.getCursor()) as c: