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
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 <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>
Sell similar to .buy
.. cmdoption:: .bal
.. cmdoption:: .port [<player>] [<full>]
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)

View File

@ -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 <action>` 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 <user> <message>` 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)

View File

@ -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
<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
<player> .port profit full
<bloomberg_terminal> player: profit has cash: $491.02 stock value: ~$11,137.32 total: ~$11,628.34 (24h +1,504.37 (14.86%))
<bloomberg_terminal> player: 1 AAPL bought at average $170.41 +3.92 (2.30%) now $174.33
<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)
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: