Add 24h historical loss/gain to StockPlay

This commit is contained in:
dave 2019-02-11 22:45:37 -08:00
parent f31c307ee4
commit ef2abe3622
4 changed files with 260 additions and 127 deletions

View File

@ -51,7 +51,8 @@ Config
"apikey": "xxxxxxxxxxxxxx", "apikey": "xxxxxxxxxxxxxx",
"tcachesecs": 300, "tcachesecs": 300,
"rcachesecs": 14400, "rcachesecs": 14400,
"bginterval": 300 "bginterval": 300,
"midnight_offset": 0
} }
.. cmdoption:: startbalance .. cmdoption:: startbalance
@ -92,6 +93,16 @@ Config
Estimated 5 minute (300), but likely will need tuning depending on playerbase 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 Class Reference
--------------- ---------------

View File

@ -4,5 +4,6 @@
"apikey": "", "apikey": "",
"tcachesecs": 300, "tcachesecs": 300,
"rcachesecs": 14400, "rcachesecs": 14400,
"bginterval": 300 "bginterval": 300,
"midnight_offset": 0
} }

View File

@ -9,6 +9,7 @@ from threading import Thread
from requests import get from requests import get
from collections import namedtuple from collections import namedtuple
from math import ceil from math import ceil
from datetime import datetime, timedelta
import re import re
import json import json
import traceback import traceback
@ -20,8 +21,19 @@ DUSTACCT = "#dust"
Trade = namedtuple("Trade", "nick buy symbol amount replyto") Trade = namedtuple("Trade", "nick buy symbol amount replyto")
def format_price(cents): def format_price(cents, prefix="$", plus=False):
return "${:,.2f}".format(Decimal(cents) / 100) """
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): class StockPlay(ModuleBase):
@ -63,13 +75,24 @@ class StockPlay(ModuleBase):
`time` integer, `time` integer,
`data` text `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.asyncq = Queue()
self.running = True self.running = True
self.trader = Thread(target=self.trader_background) self.trader = Thread(target=self.trader_background)
self.trader.start() self.trader.start()
# quote updater thread
self.pricer = Thread(target=self.price_updater) self.pricer = Thread(target=self.price_updater)
self.pricer.start() self.pricer.start()
@ -104,144 +127,183 @@ class StockPlay(ModuleBase):
def trader_background(self): def trader_background(self):
""" """
Perform trading and reporting tasks Perform trading, reporting and other background tasks
""" """
while self.running: while self.running:
try: 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: except Exception:
traceback.print_exc() traceback.print_exc()
continue continue
def do_background(self): def do_trade(self, trade):
queued = None """
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: try:
queued = self.asyncq.get(block=True, timeout=1) symprice = self.get_price(trade.symbol, self.config["tcachesecs"])
except Empty: except Exception:
traceback.print_exc()
self.bot.act_PRIVMSG(trade.replyto, "{}: invalid symbol or api failure, trade aborted!"
.format(trade.nick))
return return
if not queued: if symprice is None:
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
self.bot.act_PRIVMSG(trade.replyto, self.bot.act_PRIVMSG(trade.replyto,
"{}: {} {} {} for {}. cash: {}".format(trade.nick, "{}: invalid symbol '{}'".format(trade.nick, trade.symbol))
"bought" if trade.buy else "sold", return # invalid stock
trade.amount,
trade.symbol,
format_price(price_rounded),
format_price(nickbal)))
self.log_trade(trade.nick, time(), "buy" if trade.buy else "sell", # calculate various prices needed
trade.symbol, trade.amount, price_rounded) # 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": # fetch existing user balances
# Generate a text report of the nick's portfolio nickbal = self.get_bal(trade.nick)
# <@player> .port count = self.get_holding(trade.nick, trade.symbol)
# <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
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
# generate a list of (symbol, count, ) tuples of the player's symbol holdings # check if trade is legal
symbol_count = [] if trade.buy and nickbal < price_rounded:
with closing(self.sql.getCursor()) as c: self.bot.act_PRIVMSG(trade.replyto, "{}: you can't afford {}."
for row in c.execute("SELECT * FROM stockplay_holdings WHERE count>0 AND nick=? ORDER BY count DESC", .format(trade.nick, format_price(price_rounded)))
(lookup, )).fetchall(): return # can't afford trade
symbol_count.append((row["symbol"], row["count"], )) 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) # perform trade calculations
sym_x_count = [] if trade.buy:
stock_value = Decimal(0) nickbal -= price_rounded
for symbol, count in symbol_count: 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
<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
"""
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 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 # 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 # because it's simply impossible to request fresh data for every stock right now. Recommended rcachesecs
# is 86400 (1 day) # is 86400 (1 day)
symprice = self.get_price(symbol, self.config["rcachesecs"]) symprice = Decimal(self.get_price(row["symbol"], self.config["rcachesecs"]))
dprice = Decimal(symprice * count) * 100 holding_value += symprice * row["count"]
stock_value += dprice symbol_count.append((row["symbol"], row["count"], symprice))
sym_x_count.append("{}x{}={}".format(count, symbol, format_price(dprice)))
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: ~{}" return {"cash": cash,
.format(sender, "holdings": symbol_count,
"you have" if lookup == sender else "{} has".format(lookup), "holding_value": holding_value,
format_price(cash), "24hgain": gain_value,
format_price(stock_value), "24hpct": gain_pct}
format_price(cash + stock_value)))
if full: def do_tasks(self):
# print symbol_count with a max of 10 symbols per line """
while sym_x_count: Do interval tasks such as recording nightly balances
message_segment = [] """
for i in range(min(len(sym_x_count), 10)): # show up to 10 "SYMx$d0llar, " strings per message now = time()
message_segment.append(sym_x_count.pop(0)) if now - 60 < self.task_time:
if sym_x_count: # if there's more to print, append an ellipsis to indicate a forthcoming message return
message_segment.append("...") self.task_time = now
self.bot.act_PRIVMSG(dest, "{}: {}".format(sender, ", ".join(message_segment))) self.record_nightly_balances()
def get_price(self, symbol, thresh=None): def get_price(self, symbol, thresh=None):
""" """
@ -280,7 +342,8 @@ class StockPlay(ModuleBase):
def fetch_priceinfo(self, symbol): def fetch_priceinfo(self, symbol):
""" """
API provides Request a stock quote from the API. The API provides the format::
{'Global Quote': { {'Global Quote': {
{'01. symbol': 'MSFT', {'01. symbol': 'MSFT',
'02. open': '104.3900', '02. open': '104.3900',
@ -292,7 +355,9 @@ class StockPlay(ModuleBase):
'08. previous close':'105.2700', '08. previous close':'105.2700',
'09. change': '0.4000', '09. change': '0.4000',
'10. change percent': '0.3800%'}} '10. change percent': '0.3800%'}}
Reformat as:
Reformat as::
{'symbol': 'AMD', {'symbol': 'AMD',
'open': '22.3300', 'open': '22.3300',
'high': '23.2750', '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} return {k[4:]: v for k, v in data.items() if k[4:] in keys}
def checksym(self, s): def checksym(self, s):
"""
Validate that a string looks like a stock symbol
"""
if len(s) > 12: if len(s) > 12:
return return
s = s.upper() s = s.upper()
@ -329,6 +397,9 @@ class StockPlay(ModuleBase):
@command("buy", require_args=True, allow_private=True) @command("buy", require_args=True, allow_private=True)
@protected() @protected()
def cmd_buy(self, message, command): def cmd_buy(self, message, command):
"""
Command to buy stocks
"""
self.check_nick(message.prefix.nick) self.check_nick(message.prefix.nick)
amount = int(command.args[0]) amount = int(command.args[0])
symbol = self.checksym(command.args[1]) symbol = self.checksym(command.args[1])
@ -344,6 +415,9 @@ class StockPlay(ModuleBase):
@command("sell", require_args=True, allow_private=True) @command("sell", require_args=True, allow_private=True)
@protected() @protected()
def cmd_sell(self, message, command): def cmd_sell(self, message, command):
"""
Command to sell stocks
"""
self.check_nick(message.prefix.nick) self.check_nick(message.prefix.nick)
amount = int(command.args[0]) amount = int(command.args[0])
symbol = self.checksym(command.args[1]) symbol = self.checksym(command.args[1])
@ -359,6 +433,9 @@ class StockPlay(ModuleBase):
@command("port", "portfolio", allow_private=True) @command("port", "portfolio", allow_private=True)
@protected() @protected()
def cmd_port(self, message, command): def cmd_port(self, message, command):
"""
Portfolio report command
"""
full = False full = False
lookup = message.prefix.nick lookup = message.prefix.nick
if command.args: if command.args:
@ -376,27 +453,44 @@ class StockPlay(ModuleBase):
full))) full)))
def check_nick(self, nick): def check_nick(self, nick):
"""
Set up a user's account by setting the initial balance
"""
if not self.nick_exists(nick): if not self.nick_exists(nick):
self.set_bal(nick, self.config["startbalance"] * 100) # initial balance for user self.set_bal(nick, self.config["startbalance"] * 100) # initial balance for user
# TODO welcome message # TODO welcome message
# TODO maybe even some random free shares for funzies # TODO maybe even some random free shares for funzies
def nick_exists(self, name): def nick_exists(self, name):
"""
Check whether a nick has a record
"""
with closing(self.sql.getCursor()) as c: with closing(self.sql.getCursor()) as c:
return c.execute("SELECT COUNT(*) as num FROM stockplay_balances WHERE nick=?", return c.execute("SELECT COUNT(*) as num FROM stockplay_balances WHERE nick=?",
(name, )).fetchone()["num"] and True (name, )).fetchone()["num"] and True
def set_bal(self, nick, amount): def set_bal(self, nick, amount):
"""
Set a player's balance
:param amount: new balance in cents
"""
with closing(self.sql.getCursor()) as c: with closing(self.sql.getCursor()) as c:
c.execute("REPLACE INTO stockplay_balances VALUES (?, ?)", c.execute("REPLACE INTO stockplay_balances VALUES (?, ?)",
(nick, amount, )) (nick, amount, ))
def get_bal(self, nick): def get_bal(self, nick):
"""
Get player's balance
:return: balance in cents
"""
with closing(self.sql.getCursor()) as c: with closing(self.sql.getCursor()) as c:
return c.execute("SELECT * FROM stockplay_balances WHERE nick=?", return c.execute("SELECT * FROM stockplay_balances WHERE nick=?",
(nick, )).fetchone()["cents"] (nick, )).fetchone()["cents"]
def get_holding(self, nick, symbol): def get_holding(self, nick, symbol):
"""
Return the number of stocks of a certain symbol a player has
"""
assert symbol == symbol.upper() assert symbol == symbol.upper()
with closing(self.sql.getCursor()) as c: with closing(self.sql.getCursor()) as c:
r = c.execute("SELECT * FROM stockplay_holdings WHERE nick=? AND symbol=?", 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 return r["count"] if r else 0
def set_holding(self, nick, symbol, count): 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: with closing(self.sql.getCursor()) as c:
c.execute("REPLACE INTO stockplay_holdings VALUES (?, ?, ?)", c.execute("REPLACE INTO stockplay_holdings VALUES (?, ?, ?)",
(nick, symbol, count, )) (nick, symbol, count, ))
def log_trade(self, nick, time, type, symbol, count, price): 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: with closing(self.sql.getCursor()) as c:
c.execute("INSERT INTO stockplay_trades VALUES (?, ?, ?, ?, ?, ?)", c.execute("INSERT INTO stockplay_trades VALUES (?, ?, ?, ?, ?, ?)",
(nick, time, type, symbol, count, price, )) (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))

View File

@ -1,10 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from setuptools import setup from setuptools import setup
__version__ = "4.0.0-r03" __version__ = "4.1.0"
setup(name='pyircbot', setup(name='pyircbot',
version='4.0.0-r03', version=__version__,
description='A modular python irc bot', description='A modular python irc bot',
url='http://gitlab.xmopx.net/dave/pyircbot3/tree/master', url='http://gitlab.xmopx.net/dave/pyircbot3/tree/master',
author='dpedu', author='dpedu',