Better message priority internals
This commit is contained in:
parent
ef2abe3622
commit
85166fb692
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in New Issue