Compare commits

...

37 Commits

Author SHA1 Message Date
Dave Pedu 13ea0fe52f
Merge pull request #3 from r3cursive/patch-1
Update StockIndex.py
2021-03-04 13:12:12 -08:00
Kirk fb6a5766c7
Update StockIndex.py
idk if indexes have to be capitalize
2021-03-04 13:03:59 -08:00
Kirk 0db7e23383
Update StockIndex.py
yolo added vix idk if it works papa
2021-03-04 13:03:03 -08:00
dave d183172ca7 adjust format 2020-07-02 12:20:28 -07:00
dave 9e3a7b68fe Add stockindex module 2020-07-02 12:03:53 -07:00
dave 235921fbab add replyto field to privmsg ircevent messages 2020-07-02 11:20:00 -07:00
dave f2c6668e18 fix api-discontinued breaking reports 2020-04-09 13:16:56 -07:00
dave f6d1e0ba16 show holding value for each symbol 2020-04-09 13:16:35 -07:00
dave 2bdece8b9e support multiple stock apis 2020-04-08 23:39:16 -07:00
dave 166807e181 ignore zero value stocks 2020-04-06 13:56:13 -07:00
dave 55212a5977 correct historical buy price calculation 2020-02-14 10:31:40 -08:00
dave 998d3a5f55 tabulate top ten and format currencies 2019-10-20 14:48:34 -07:00
dave 46c1ef3562 accept stale data when building reports 2019-10-20 14:48:16 -07:00
dave e2c412fdef add announce channel 2019-10-06 09:39:18 -07:00
dave 4161be93e4 fix dust collection, log quote prices 2019-10-04 10:03:34 -07:00
dave a986639588 Sentry support 2019-09-03 16:59:47 -07:00
dave bd208a13df Add contributors doc 2019-05-03 21:55:11 -07:00
dave eccea93a6d Always send topten reply to pm 2019-04-25 09:05:16 -07:00
Dave Pedu 525e87803f
Merge pull request #1 from medmr1/leaderboard
.top function
2019-04-25 08:58:26 -07:00
Mike Edmister b4b6d72607 doublequotes over single 2019-04-25 11:57:12 -04:00
Mike Edmister f9927e9acb added $ and changed from .thing to ['thing'] 2019-04-25 11:55:26 -04:00
Mike Edmister f6b6c13c12 removed cast needed because of wrong column type 2019-04-25 11:49:04 -04:00
Mike Edmister bdfadb0c11 shortened lines for linter 2019-04-25 11:47:04 -04:00
Mike Edmister ca3b4f903f revert irccore 2019-04-25 10:32:14 -04:00
Mike Edmister 2533c5872a lowered priority of .top, fixed query, removed @protected 2019-04-25 10:27:55 -04:00
Mike Edmister 6404adb773 limit 10 2019-04-23 13:33:13 -04:00
Mike Edmister 6b9fd384ec .top function 2019-04-23 13:31:02 -04:00
dave 85166fb692 Better message priority internals 2019-02-26 19:39:40 -08:00
dave ef2abe3622 Add 24h historical loss/gain to StockPlay 2019-02-11 22:46:03 -08:00
dave f31c307ee4 Fix nickuser autologin 2019-02-11 22:18:25 -08:00
dave 01efeed44f Move dockerfile 2019-02-11 12:06:36 -08:00
dave 97941aa529 Added stockplay module 2019-02-11 12:05:06 -08:00
dave 7546b05191 Docs update 2019-02-11 12:01:35 -08:00
dave ade7fed3a8 Misc test fixes 2019-02-11 09:15:01 -08:00
dave a00a5d9f9c py 3.7 2019-02-09 00:33:06 -08:00
dave 29f5fa9e0f unfuck the requirements & dockerfile 2018-04-22 17:58:20 -07:00
dave 50357b91de clean up extra installs in dockerfile 2018-04-12 16:21:23 -07:00
35 changed files with 1494 additions and 186 deletions

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
/testenv/*
/share/*
/build/*
/dist/*
/*.egg-info

21
Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM ubuntu:bionic
RUN apt-get update && \
apt-get install -y python3-pip git && \
useradd --home-dir /srv/bot bot && \
mkdir -p /srv/bot && \
chown bot /srv/bot
COPY ./requirements.txt /requirements.txt
RUN pip3 install -r /requirements.txt
COPY ./ /tmp/pyircbot
RUN cd /tmp/pyircbot && \
python3 setup.py install
ENTRYPOINT ["/usr/local/bin/pyircbot"]
WORKDIR /srv/bot/
CMD ["-c", "config.json"]
USER bot

View File

@ -2,7 +2,7 @@
===================================================
A module providing a simple login/logout account service. "Trust" is based upon
hostname - logging in autorizes your current hostname for your account data,
hostname - logging in autorizes your current hostname for your account data,
which is tied to your nick.
Commands
@ -21,6 +21,24 @@ Commands
Log out of account (deauthorize your current hostname)
Utilities
---------
NickUser provides a decorator that can be used to lock module commands methods
behind a login:
.. code-block:: python
from pyircbot.modulebase import ModuleBase, command
from pyircbot.modules.NickUser import protected
class MyModule(ModuleBase):
@command("foo", allow_private=True)
@protected()
def cmd_foo(self, message, command):
print(message.prefix.nick, "called foo whiled logged in!")
Class Reference
---------------

View File

@ -0,0 +1,56 @@
:mod:`StockIndex` --- DJIA and NASDAQ Quotes
============================================
This module provides quotes for the DJIA and NASDAQ indexes. It requires a free API key from
https://financialmodelingprep.com/
Commands
--------
.. cmdoption:: .djia
Display the DJIA index
.. cmdoption:: .nasdaq
Display the NASDAQ index
Config
------
.. code-block:: json
{
"apikey": "xxxxxxxxxxxxx",
"cache_update_interval": 600,
"cache_update_timeout": 10,
"warning_thresh": 1800
}
.. cmdoption:: apikey
API ley obtained from https://financialmodelingprep.com/
.. cmdoption:: cache_update_interval
How many seconds between fetching new index quotes from the API.
.. cmdoption:: cache_update_timeout
Maximum seconds to wait on the HTTP request sent to the API
.. cmdoption:: warning_thresh
A warning will be shown that the quote is out-of-date if the last successful fetch was longer ago than this
setting's number of seconds.
Class Reference
---------------
.. automodule:: pyircbot.modules.StockIndex
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,134 @@
: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.
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 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.
When trading, the price of the traded symbol is allowed to be *trade_cache_seconds* 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.
Commands
--------
.. cmdoption:: .buy <amount> <symbol>
Buy some number of the specified symbol such as ".buy 10 amd"
.. cmdoption:: .sell <amount> <symbol>
Sell similar to .buy
.. cmdoption:: .port [<player>] [<full>]
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.
Config
------
.. code-block:: json
{
"startbalance": 10000,
"tradedelay": 0,
"trade_cache_seconds": 300,
"bginterval": 300,
"announce_trades": false,
"announce_channel": "#trades",
"providers": [
{
"provider": "iexcloud",
"apikey": "xxxxxxxxxxxxxxx"
},
{
"provider": "alphavantage",
"apikey": "xxxxxxxxxxxxxxx"
}
]
}
.. cmdoption:: startbalance
Number of dollars that players start with
.. cmdoption:: tradedelay
Delay in seconds between differing trades of the same symbol. Multiple buys OR multiple sells are allowed, but
not a mix.
NOT IMPLEMENTED
.. cmdoption:: providers
A list of providers to fetch stock data from
Supported providers:
* https://www.alphavantage.co/
* https://iexcloud.io/
.. cmdoption:: trade_cache_seconds
When performing a trade, how old of a cached symbol price is permitted before fetching from API.
Recommended ~30 minutes (1800)
.. cmdoption:: bginterval
Symbol prices are updated in the background. This is necessary because fetching a portfolio report may require
fetching many symbol prices. The alphavantage.co api allows only 5 calls per minute. Because of this limitation,
fetching a report would take multiple minutes with more than 5 symbols, which would not work.
For this reason, we update symbols at a low interval in the background. Every *bginterval* seconds, a task will be
started that updates the price of the oldest symbol.
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
.. cmdoption:: announce_trades
Boolean option to announce all trades in a specific channel.
Default: false
.. cmdoption:: announce_channel
Channel name to announce all trades in.
Default: not set
Class Reference
---------------
.. automodule:: pyircbot.modules.StockPlay
:members:
:undoc-members:
:show-inheritance:

View File

@ -1,13 +1,14 @@
FROM ubuntu:artful
FROM ubuntu:bionic
ADD ./ /tmp/pyircbot/
RUN apt-get update ; \
RUN apt-get update && \
export DEBIAN_FRONTEND=noninteractive && \
apt-get install -y python3 python3-sphinx python3-setuptools python3-dev python3-pip make wget unzip git
RUN cd /tmp/pyircbot/ && pip3 install -r requirements.txt
COPY ./docs/builder/start /start
COPY docs/builder/start /start
COPY ./ /tmp/pyircbot/
RUN cd /tmp/pyircbot/ && pip3 install -r requirements-test.txt
RUN chmod +x /start ; \
mkdir /tmp/docs

View File

@ -1,4 +1,4 @@
#!/bin/sh -ex
sudo docker run -it --rm -v $PWD/:/tmp/pyircbot/ pybdocbuilder bash
docker run -it --rm -v $PWD/:/tmp/pyircbot/ pybdocbuilder bash

View File

@ -1,3 +1,4 @@
#!/bin/sh -ex
docker build -t pybdocbuilder -f docs/builder/Dockerfile .

View File

@ -2,8 +2,19 @@
Changelog
=========
* :release:`4.1.0 <2017-03-28>`
* :feature:`-` Added StockPlay module
* :feature:`-` Added `@protected` decorator
* :release:`4.1.0 <2019-02-10>`
* :support:`-` First documented release in awhile. Many new modules and tests have been added. See the git log if you so desire.
* :feature:`-` Upgraded docker base image to ubuntu:bionic
* :feature:`-` Misc macOs related fixes
* :feature:`-` Misc python 3.7 related fixes
* :feature:`-` Misc fixes preventing doc building
* :release:`4.0.0 <2017-03-28>`
* :support:`-` Added a changelog
* :feature:`-` Dropped use of deprecated asynchat in favor of asyncio - Python 3.5+ now required.
* :support:`-` Minor docs cleanup
* :feature:`-` Upgraded docker base image to ubuntu:xenial

74
docs/contributing.rst Normal file
View File

@ -0,0 +1,74 @@
Contributing
============
The pull requests, bug reports, feature suggestions or even demo use cases contributed by the pyircbot community are all
highly appreciated and a valuable asset to the project.
This document outlines best practices and conditions for contributing changes pyircbot.
Sending a Pull Request
----------------------
The pyircbot project does have a couple rules for pull requests:
Where
While pyircbot's main git repository is hosted on a private server, PRs are welcome on the
`github mirror <https://github.com/dpedu/pyircbot>`_ of the project.
Linting
Pyircbot's codebase is linted with flake8. The configuration arguments for flake8 are: ``--max-line-length 120
--ignore E402,E712,E731,E211 --select E,F``. TODO add a flake8 config file to the repo so these args are codified.
Python versions
Pyircbot supports the two latest versions of python 3. At present, this is Python 3.6 and 3.7. All changes must be
compatible with these versions. Using compatibility modules or checking the version at runtime is discouraged; the
code should be written in a fashion that is interoperable.
Dependencies
The core of pyircbot - that is, the bot runtime itself and the basic modules needed to operate it - `PingResponder`
and `Services` - have no dependencies outside python's standard library of modules. All changes must not change this
condition. The use of 3rd party dependencies in pyircbot modules is allowed, provided the versions used do not
conflict with modules already in use.
All 3rd party dependencies must be installable via Pip with no extra setup required (e.g. OS-level packages), with
some exceptions:
- Requiring ``git`` for pip links directly to repositories is permitted.
- Requiring a C/C++ compiler available on the system as well as python headers which are typically required for
libraries with C/C++ or other native language extensions is permitted. However, requiring additional headers or
shared libraries is prohibited, unless:
- Many operating systems provide a ``python3-pip`` or similar package that depends on many other packages that violate
the above rules, such as openssl headers or sqlite shared libraries. Pyircbot's preferred platform is Ubuntu Bionic,
and any dependencies of such a package are permitted.
- These rules apply to code dependencies only, and do not apply to external *services* such as MySQL.
Modules that require setup or dependencies beyond these rules will not be accepted into the core module set. The
recommended approach to use such a module is via the ``usermodules`` config directive.
No set of rules will handle all cases, and discussion or defending a change that violates these rules is encouraged.
Tests
Please consider the test suite when submitting changes affecting the core runtime and existing modules. Reduction of
coverage is acceptable, but breaking or removing tests is not. Changes submitted with updated or new tests will
be fast-tracked.
Contributors to Pyircbot
------------------------
Any change submitted to pyircbot is highly appreciated.
Our contributors, in no particular order:
- `@ollien <https://github.com/ollien>`_ ❤️
- `@medmr1 <https://github.com/medmr1>`_ ❤️
- `@dpedu <https://github.com/dpedu>`_ ❤️

View File

@ -15,6 +15,7 @@ Contents:
module_guide/_module_guide.rst
module_guide/_pubsub_mode.rst
changelog.rst
contributing.rst
More Information
================

View File

@ -0,0 +1,11 @@
{
"startbalance": 10000,
"tradedelay": 0,
"apikey": "",
"tcachesecs": 300,
"rcachesecs": 14400,
"bginterval": 300,
"midnight_offset": 0,
"announce_trades": true,
"announce_channel": "#trades"
}

View File

@ -1,34 +0,0 @@
FROM ubuntu:artful
ENTRYPOINT ["/usr/local/bin/pyircbot"]
WORKDIR /srv/bot/
CMD ["-c", "config.json"]
RUN apt-get update && \
apt-get install -y python3 python3-setuptools python3-requests curl unzip sqlite3 && \
easy_install3 pip && \
pip3 install praw==5.0.1 pytz cherrypy twilio==6.9.0 && \
cd /tmp && \
curl -o msgbus.tar.gz 'http://gitlab.davepedu.com/dave/pymsgbus/repository/archive.tar.gz?ref=master' && \
mkdir pymsgbus && tar zxvf msgbus.tar.gz --strip-components 1 -C pymsgbus/ &&\
cd pymsgbus && \
pip3 install -r requirements.txt && \
python3 setup.py install && \
cd /tmp && \
curl -o bitcoinrpc.tar.gz https://codeload.github.com/dpedu/python-bitcoinrpc/tar.gz/master && \
tar zxvf bitcoinrpc.tar.gz && \
cd python-bitcoinrpc-master && \
python3 setup.py install && \
useradd --home-dir /srv/bot bot && \
chown bot /srv/bot && \
rm -rf /var/lib/apt/lists/* /tmp/bitcoinrpc.tar.gz /tmp/python-bitcoinrpc-master
COPY . /tmp/pyircbot/
RUN cd /tmp/pyircbot/ && \
python3 setup.py install && \
su -c "cp -r /tmp/pyircbot/examples/config.json /tmp/pyircbot/examples/data/ /srv/bot/" bot && \
cd / && \
rm -rf /tmp/pyircbot
USER bot

11
bin/pyircbot → pyircbot/cli.py Executable file → Normal file
View File

@ -3,12 +3,12 @@ import sys
import logging
import signal
from argparse import ArgumentParser
from pyircbot.common import load
from pyircbot.common import load, sentry_sdk
from pyircbot import PyIRCBot
from json import loads
if __name__ == "__main__":
def main():
" logging level and facility "
logging.basicConfig(level=logging.INFO,
format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
@ -33,6 +33,9 @@ if __name__ == "__main__":
botconfig = loads(sys.stdin.read()) if args.config == "-" else load(args.config)
if sentry_sdk and "dsn" in botconfig["bot"]:
sentry_sdk.init(botconfig["bot"]["dsn"])
log.debug(botconfig)
bot = PyIRCBot(botconfig)
@ -45,3 +48,7 @@ if __name__ == "__main__":
signal.signal(signal.SIGTERM, signal_handler)
bot.run()
if __name__ == "__main__":
main()

17
bin/pubsubbot → pyircbot/clipub.py Executable file → Normal file
View File

@ -9,7 +9,7 @@ from msgbus.client import MsgbusSubClient
import pyircbot
import traceback
from pyircbot.pyircbot import PrimitiveBot
from pyircbot.irccore import IRCEvent, UserPrefix
from pyircbot.irccore import IRCEvent, UserPrefix, IRCCore
from pyircbot.common import TouchReload
from json import dumps
@ -57,12 +57,10 @@ class PyIRCBotSub(PrimitiveBot):
args, sender, trailing, extras = loads(rest)
nick, username, hostname = extras["prefix"]
msg = IRCEvent(command.upper(),
args,
UserPrefix(nick,
username,
hostname),
trailing)
msg = IRCCore.packetAsObject(command.upper(),
args,
f"{nick}!{username}@{hostname}", # hack
trailing)
for module_name, module in self.moduleInstances.items():
for hook in module.irchooks:
@ -112,7 +110,7 @@ class PyIRCBotSub(PrimitiveBot):
return self.meta.get("nick", None)
if __name__ == "__main__":
def main():
logging.basicConfig(level=logging.WARNING,
format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
log = logging.getLogger('main')
@ -160,3 +158,6 @@ if __name__ == "__main__":
bot.run()
if __name__ == "__main__":
main()

View File

@ -5,11 +5,20 @@ from collections import namedtuple
from time import sleep
import os
from threading import Thread
try:
import sentry_sdk
except ImportError:
sentry_sdk = None
ParsedCommand = namedtuple("ParsedCommand", "command args args_str message")
def report(exception):
if sentry_sdk:
sentry_sdk.capture_exception(exception)
class burstbucket(object):
def __init__(self, maximum, interval):
"""

View File

@ -12,11 +12,13 @@ import logging
import traceback
import sys
from inspect import getargspec
from pyircbot.common import burstbucket, parse_irc_line
from pyircbot.common import burstbucket, parse_irc_line, report
from collections import namedtuple
from io import StringIO
from time import time
IRCEvent = namedtuple("IRCEvent", "command args prefix trailing")
IRCEvent = namedtuple("IRCEvent", "command args prefix trailing replyto")
UserPrefix = namedtuple("UserPrefix", "nick username hostname")
ServerPrefix = namedtuple("ServerPrefix", "hostname")
@ -78,9 +80,10 @@ class IRCCore(object):
family=self.connection_family,
local_addr=self.bind_addr)
self.fire_hook("_CONNECT")
except (socket.gaierror, ConnectionRefusedError):
traceback.print_exc()
except (socket.gaierror, ConnectionRefusedError, OSError) as e:
logging.warning("Non-fatal connect error, trying next server...")
self.trace()
report(e)
self.server = (self.server + 1) % len(self.servers)
await asyncio.sleep(1, loop=loop)
continue
@ -95,11 +98,13 @@ class IRCCore(object):
.format(command, prefix, args, trailing))
else:
self.fire_hook(command, args=args, prefix=prefix, trailing=trailing)
except (ConnectionResetError, asyncio.streams.IncompleteReadError):
traceback.print_exc()
except (ConnectionResetError, asyncio.streams.IncompleteReadError) as e:
self.trace()
report(e)
break
except (UnicodeDecodeError, ):
traceback.print_exc()
except (UnicodeDecodeError, ) as e:
self.trace()
report(e)
self.fire_hook("_DISCONNECT")
self.writer.close()
if self.alive:
@ -110,8 +115,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,14 +124,15 @@ 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()
try:
self.writer.write((line + "\r\n").encode("UTF-8"))
except Exception as e: # Probably fine if we drop messages while offline
print(e)
print(self.trace())
self.trace()
report(e)
async def kill(self, message="Help! Another thread is killing me :(", forever=True):
"""Send quit message, flush queue, and close the socket
@ -152,7 +158,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):
@ -218,8 +224,9 @@ class IRCCore(object):
else:
hook(args, prefix, trailing)
except:
except Exception as e:
self.log.warning("Error processing hook: \n%s" % self.trace())
report(e)
def addHook(self, command, method):
"""**Internal.** Enable (connect) a single hook of a module
@ -251,6 +258,7 @@ class IRCCore(object):
self.log.warning("Invalid hook - %s" % command)
return False
@staticmethod
def packetAsObject(command, args, prefix, trailing):
"""Given an irc message's args, prefix, and trailing data return an object with these properties
@ -262,9 +270,15 @@ class IRCCore(object):
:type trailing: str
:returns: object -- a IRCEvent object with the ``args``, ``prefix``, ``trailing``"""
return IRCEvent(command, args,
IRCCore.decodePrefix(prefix) if prefix else None,
trailing)
prefix = IRCCore.decodePrefix(prefix) if prefix else None
replyto = None
if command == "PRIVMSG":
# prefix will always be set for PRIVMSG
# TODO server side fuzzing
replyto = args[0] if args[0].startswith("#") else prefix.nick
return IRCEvent(command, args, prefix, trailing, replyto)
" Utility methods "
@staticmethod
@ -313,12 +327,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 +358,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 +379,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 +401,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 +410,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

@ -96,9 +96,8 @@ class RecieveGenerator(object):
print("total", total, "expected", self.length)
if total != self.length:
raise TransferFailedException("Transfer failed: expected {} bytes but got {}".format(self.length, total))
raise StopIteration()
finally:
self.sock.shutdown(socket.SHUT_RDWR)
# self.sock.shutdown(socket.SHUT_RDWR)
self.sock.close()
@ -140,8 +139,11 @@ class OfferThread(Thread):
clientsocket.shutdown(socket.SHUT_RDWR)
clientsocket.close()
finally:
self.listener.shutdown(socket.SHUT_RDWR)
# try:
# self.listener.shutdown(socket.SHUT_RDWR)
self.listener.close()
# except Exception:
# pass
def abort(self):
"""

View File

@ -7,23 +7,16 @@
"""
from pyircbot.modulebase import ModuleBase, ModuleHook
from pyircbot.modulebase import ModuleBase, hook
class Error(ModuleBase):
def __init__(self, bot, moduleName):
ModuleBase.__init__(self, bot, moduleName)
self.hooks = [ModuleHook("PRIVMSG", self.error)]
def error(self, args, prefix, trailing):
"""If the message recieved from IRC has the string "error" in it, cause a ZeroDivisionError
:param args: IRC args received
:type args: list
:param prefix: IRC prefix of sender
:type prefix: str
:param trailing: IRC message body
:type trailing: str"""
if "error" in trailing:
@hook("PRIVMSG")
def error(self, message, command):
"""
If the message recieved from IRC has the string "error" in it, cause a ZeroDivisionError
"""
if "error" in message.trailing:
print(10 / 0)

View File

@ -72,7 +72,6 @@ class ModInfo(ModuleBase):
# Find widest value per col
for row in rows:
for col, value in enumerate(row):
print(col, value)
vlen = len(value)
if vlen > widths[col]:
widths[col] = vlen
@ -109,4 +108,3 @@ class ModInfo(ModuleBase):
if callable(attr) and hasattr(attr, "irchelp"):
for cmdinfo in attr.irchelp:
yield (modname, module, cmdinfo.cmdspec, cmdinfo.docstring, cmdinfo.aliases)
raise StopIteration()

View File

@ -50,13 +50,16 @@ class NickUser(ModuleBase):
oldpass = attr.getKey(prefix.nick, "password")
if oldpass is None:
attr.setKey(prefix.nick, "password", cmd.args[0])
self.bot.act_PRIVMSG(prefix.nick, ".setpass: Your password has been set to \"%s\"." % cmd.args[0])
attr.setKey(prefix.nick, "loggedinfrom", prefix.hostname)
self.bot.act_PRIVMSG(prefix.nick, ".setpass: You've been logged in and "
"your password has been set to \"%s\"." % cmd.args[0])
else:
if len(cmd.args) == 2:
if cmd.args[0] == oldpass:
attr.setKey(prefix.nick, "password", cmd.args[1])
self.bot.act_PRIVMSG(prefix.nick, ".setpass: Your password has been set to \"%s\"." %
cmd.args[1])
self.bot.act_PRIVMSG(prefix.nick,
".setpass: Your password has been set to \"%s\"." % cmd.args[1])
attr.setKey(prefix.nick, "loggedinfrom", prefix.hostname)
else:
self.bot.act_PRIVMSG(prefix.nick, ".setpass: Old password incorrect.")
else:
@ -72,10 +75,8 @@ class NickUser(ModuleBase):
else:
if len(cmd.args) == 1:
if userpw == cmd.args[0]:
#################
attr.setKey(prefix.nick, "loggedinfrom", prefix.hostname)
self.bot.act_PRIVMSG(prefix.nick, ".login: You have been logged in from: %s" % prefix.hostname)
#################
else:
self.bot.act_PRIVMSG(prefix.nick, ".login: incorrect password.")
else:
@ -89,3 +90,23 @@ class NickUser(ModuleBase):
else:
attr.setKey(prefix.nick, "loggedinfrom", None)
self.bot.act_PRIVMSG(prefix.nick, ".logout: You have been logged out.")
# Decorator for methods that require login
# Assumes your args matches the same format that @command(...) expects
class protected(object):
def __init__(self, message=None):
self.message = message or "{}: you need to .login to do that"
def __call__(self, func):
def wrapper(*args, **kwargs):
module, message, command = args
login = module.bot.getBestModuleForService("login")
if not login.check(message.prefix.nick, message.prefix.hostname):
module.bot.act_PRIVMSG(message.args[0] if message.args[0].startswith("#") else message.prefix.nick,
self.message.format(message.prefix.nick))
return
func(*args, **kwargs)
return wrapper

View File

@ -22,7 +22,7 @@ class PingResponder(ModuleBase):
"""Respond to the PING command"""
# got a ping? send it right back
self.bot.act_PONG(msg.trailing)
self.log.info("%s Responded to a ping: %s" % (self.bot.get_nick(), msg.trailing))
self.log.debug("%s Responded to a ping: %s" % (self.bot.get_nick(), msg.trailing))
@hook("_RECV", "_SEND")
def resettimer(self, msg, cmd):

View File

@ -0,0 +1,86 @@
from pyircbot.modulebase import ModuleBase, command
from pyircbot.modules.ModInfo import info
from threading import Thread
from time import sleep, time
import requests
import traceback
API_URL = "https://financialmodelingprep.com/api/v3/quotes/index?apikey={apikey}"
def bits(is_gain):
if is_gain:
return ("\x0303", "⬆", "+", )
return ("\x0304", "⬇", "-", )
class StockIndex(ModuleBase):
def __init__(self, bot, moduleName):
super().__init__(bot, moduleName)
self.session = requests.session()
self.updater = None
self.running = True
self.last_update = 0
self.start_cache_updater()
def start_cache_updater(self):
self.updater = Thread(target=self.cache_updater, daemon=True)
self.updater.start()
def ondisable(self):
self.running = False
self.updater.join()
def cache_updater(self):
while self.running:
try:
self.update_cache()
except:
traceback.print_exc()
delay = self.config.get("cache_update_interval", 600)
while self.running and delay > 0:
delay -= 1
sleep(1)
def update_cache(self):
data = self.session.get(API_URL.format(**self.config),
timeout=self.config.get("cache_update_timeout", 10)).json()
self.cache = {item["symbol"]: item for item in data}
self.last_update = time()
@info("djia", "get the current value of the DJIA", cmds=["djia"])
@command("djia", allow_private=True)
def cmd_djia(self, message, command):
self.send_quote("^DJI", "DJIA", message.replyto)
@info("nasdaq", "get the current value of the NASDAQ composite index", cmds=["nasdaq"])
@command("nasdaq", allow_private=True)
def cmd_nasdaq(self, message, command):
self.send_quote("^IXIC", "NASDAQ", message.replyto)
@info("vix", "get the current value of the vix/fear index", cmds=["vix"])
@command("vix", allow_private=True)
def cmd_vix(self, message, command):
self.send_quote("^VIX", "VIX", message.replyto)
def send_quote(self, key, symbol, to):
index = self.cache[key]
is_gain = index["price"] >= index["previousClose"]
color, arrow, plusmin = bits(is_gain)
change = float(index["price"]) - float(index["previousClose"])
percentchange = float(change) / float(index["previousClose"]) * 100
warn_thresh = self.config.get("warning_thresh", 1800)
warning = "" if time() - self.last_update < warn_thresh else " \x030(quote is out-of-date)"
self.bot.act_PRIVMSG(to, "{} ${:,.2f} {}{}{:,.2f} ({:.2f}%){}{}".format(symbol,
index["price"],
color,
plusmin,
change,
percentchange,
arrow,
warning))

View File

@ -0,0 +1,816 @@
from pyircbot.modulebase import ModuleBase, MissingDependancyException, command
from pyircbot.modules.ModInfo import info
from pyircbot.modules.NickUser import protected
from contextlib import closing
from decimal import Decimal
from time import sleep, time
from queue import Queue, Empty
from threading import Thread
from requests import get
from collections import namedtuple
from math import ceil, floor
from datetime import datetime, timedelta
import re
import json
import traceback
RE_SYMBOL = re.compile(r'^([A-Z\-]+)$')
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
"""
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)
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)
self.sqlite = self.bot.getBestModuleForService("sqlite")
if self.sqlite is None:
raise MissingDependancyException("StockPlay: SQLIite service is required.")
self.sql = self.sqlite.opendb("stockplay.db")
with closing(self.sql.getCursor()) as c:
if not self.sql.tableExists("stockplay_balances"):
c.execute("""CREATE TABLE `stockplay_balances` (
`nick` varchar(64) PRIMARY KEY,
`cents` integer
);""")
c.execute("""INSERT INTO `stockplay_balances` VALUES (?, ?)""", (DUSTACCT, 0))
if not self.sql.tableExists("stockplay_holdings"):
c.execute("""CREATE TABLE `stockplay_holdings` (
`nick` varchar(64),
`symbol` varchar(12),
`count` integer,
PRIMARY KEY (nick, symbol)
);""")
if not self.sql.tableExists("stockplay_trades"):
c.execute("""CREATE TABLE `stockplay_trades` (
`nick` varchar(64),
`time` integer,
`type` varchar(8),
`symbol` varchar(12),
`count` integer,
`price` integer,
`quoteprice` varchar(12)
);""")
if not self.sql.tableExists("stockplay_prices"):
c.execute("""CREATE TABLE `stockplay_prices` (
`symbol` varchar(12) PRIMARY KEY,
`time` integer,
`attempt_time` integer,
`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)
);""")
# if not self.sql.tableExists("stockplay_report_cache"):
# c.execute("""CREATE TABLE `stockplay_report_cache` (
# `nick` varchar(64) PRIMARY KEY,
# `time` integer,
# `data` text
# );""")
self.cache = PriceCache(self)
# Last time the interval tasks were executed
self.task_time = 0
# background work executor thread
self.asyncq = Queue()
self.running = True
self.trader = Thread(target=self.trader_background)
self.trader.start()
# quote updater thread
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
buys = 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():
if row["type"] == "buy":
count += row["count"]
spent += row["price"]
buys += row["count"]
else:
count -= row["count"]
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(buys)
def price_updater(self):
"""
Perform quote cache updating task
"""
while self.running:
self.log.info("price_updater")
try:
updatesym = None
with closing(self.sql.getCursor()) as c:
row = c.execute("""SELECT * FROM stockplay_prices
WHERE symbol in (SELECT symbol FROM stockplay_holdings WHERE count>0)
ORDER BY attempt_time ASC LIMIT 1""").fetchone()
updatesym = row["symbol"] if row else None
c.execute("UPDATE stockplay_prices SET attempt_time=? WHERE symbol=?;", (time(), updatesym))
if updatesym:
self.cache.get_price(updatesym, 0)
except Exception:
traceback.print_exc()
delay = self.config["bginterval"]
while self.running and delay > 0:
delay -= 1
sleep(1)
def trader_background(self):
"""
Perform trading, reporting and other background tasks
"""
while self.running:
try:
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)
elif action == "topten":
self.do_topten(*data)
except Exception:
traceback.print_exc()
continue
def do_topten(self, nick, replyto):
"""
Do lookup of highest valued portfolios
"""
self.log.warning("{} wants top 10 sent to {}".format(nick, replyto))
rows = []
with closing(self.sql.getCursor()) as c:
for num, row in enumerate(c.execute("""SELECT h1.nick as nick, h1.cents as cents
FROM stockplay_balance_history h1
INNER JOIN (SELECT nick, max(day) as MaxDate FROM stockplay_balance_history
WHERE nick != ? GROUP BY nick) h2
ON h1.nick = h2.nick AND h1.day = h2.MaxDate
ORDER BY cents DESC LIMIT 10""", (DUSTACCT, )).fetchall(), start=1):
total = Decimal(row["cents"]) / 100
rows.append(("#{}".format(num), row["nick"], "with total:", "~{}".format(format_decimal(total)), ))
for line in tabulate(rows, justify=[False, True, False, False]):
self.bot.act_PRIVMSG(replyto, line, priority=5)
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",
trade.amount,
trade.symbol))
# Update quote price
try:
price = self.cache.get_price(trade.symbol, self.config["trade_cache_seconds"])
except Exception:
traceback.print_exc()
self.bot.act_PRIVMSG(trade.replyto, "{}: invalid symbol or api failure, trade aborted!"
.format(trade.nick))
return
if price is None:
self.bot.act_PRIVMSG(trade.replyto,
"{}: invalid symbol '{}'".format(trade.nick, trade.symbol))
return # invalid stock
symprice = price.price
# calculate various prices needed
# symprice -= Decimal("0.0001") # for testing dust collection
real_price = symprice * trade.amount * 100 # now in cents
self.log.info("real_price: {}".format(real_price))
if trade.buy:
trade_price = int(ceil(real_price))
dust = trade_price - real_price
else:
trade_price = int(floor(real_price))
dust = real_price - trade_price
self.log.info("trade_price: {}".format(trade_price))
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 < trade_price:
self.bot.act_PRIVMSG(trade.replyto, "{}: you can't afford {}."
.format(trade.nick, format_price(trade_price)))
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 -= trade_price
count += trade.amount
else:
nickbal += trade_price
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
message = "{} {} {} for {}. cash: {}".format("bought" if trade.buy else "sold",
trade.amount,
trade.symbol,
format_price(trade_price),
format_price(nickbal))
self.bot.act_PRIVMSG(trade.replyto, "{}: {}".format(trade.nick, message))
# announce message
if self.config.get("announce_trades"):
channel = self.config.get("announce_channel")
if channel:
self.bot.act_PRIVMSG(channel, "{}_ {}".format(trade.nick, message), priority=10)
self.log_trade(trade.nick, time(), "buy" if trade.buy else "sell",
trade.symbol, trade.amount, trade_price, str(symprice))
def do_report(self, lookup, sender, replyto, full):
"""
Generate a text report of the nick's portfolio ::
<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
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"]),
format_gainloss(data["24hgain"], data["24hpct"])))
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),
"({})".format(format_decimal(symprice * count))])
for line in tabulate(rows, justify=[False, True, True, False, False, False, True, False, False]):
self.bot.act_PRIVMSG(dest, "{}: {}".format(sender, line), priority=5)
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, avgbuy, %change_on_avgbuy) 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 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.
print("build_report: processing", row["symbol"])
price = self.cache.get_price(row["symbol"], -1)
symprice = price.price
holding_value += symprice * row["count"]
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 = calc_gain(Decimal(day_start_bal["cents"]) / 100, cash + holding_value)
return {"cash": cash,
"holdings": symbol_count,
"holding_value": holding_value,
"24hgain": gain_value,
"24hpct": gain_pct}
def do_tasks(self):
"""
Do interval tasks such as recording nightly balances
"""
now = time()
if now - 60 < self.task_time:
return
self.task_time = now
self.record_nightly_balances()
def checksym(self, s):
"""
Validate that a string looks like a stock symbol
"""
if len(s) > 12:
return
s = s.upper()
if not RE_SYMBOL.match(s):
return
return s
@info("buy <amount> <symbol>", "buy <amount> of stock <symbol>", cmds=["buy"])
@command("buy", require_args=True, allow_private=True)
@protected()
def cmd_buy(self, message, command):
"""
Command to buy stocks
"""
self.check_nick(message.prefix.nick)
amount = int(command.args[0])
symbol = self.checksym(command.args[1])
if not symbol or amount <= 0:
return
self.asyncq.put(("trade", Trade(message.prefix.nick,
True,
symbol,
amount,
message.args[0] if message.args[0].startswith("#") else message.prefix.nick)))
@info("sell <amount> <symbol>", "buy <amount> of stock <symbol>", cmds=["sell"])
@command("sell", require_args=True, allow_private=True)
@protected()
def cmd_sell(self, message, command):
"""
Command to sell stocks
"""
self.check_nick(message.prefix.nick)
amount = int(command.args[0])
symbol = self.checksym(command.args[1])
if not symbol or amount <= 0:
return
self.asyncq.put(("trade", Trade(message.prefix.nick,
False,
symbol,
amount,
message.args[0] if message.args[0].startswith("#") else message.prefix.nick)))
@info("port", "show portfolio holdings", cmds=["port", "portfolio"])
@command("port", "portfolio", allow_private=True)
@protected()
def cmd_port(self, message, command):
"""
Portfolio report command
"""
full = False
lookup = message.prefix.nick
if command.args:
if command.args[0] == "full":
full = True
else:
lookup = command.args[0]
if len(command.args) > 1 and command.args[1] == "full":
full = True
self.asyncq.put(("portreport", (lookup,
message.prefix.nick,
message.prefix.nick if full or not message.args[0].startswith("#")
else message.args[0],
full)))
@info("top", "show top portfolios", cmds=["top", "top10"])
@command("top", "top10", allow_private=True)
def cmd_top(self, message, command):
"""
Top 10 report command
"""
self.asyncq.put(("topten", (message.prefix.nick, message.prefix.nick)))
def check_nick(self, nick):
"""
Set up a user's account by setting the initial balance
"""
if not self.nick_exists(nick):
self.set_bal(nick, self.config["startbalance"] * 100) # initial balance for user
# TODO welcome message
# TODO maybe even some random free shares for funzies
def nick_exists(self, name):
"""
Check whether a nick has a record
"""
with closing(self.sql.getCursor()) as c:
return c.execute("SELECT COUNT(*) as num FROM stockplay_balances WHERE nick=?",
(name, )).fetchone()["num"] and True
def set_bal(self, nick, amount):
"""
Set a player's balance
:param amount: new balance in cents
"""
with closing(self.sql.getCursor()) as c:
c.execute("REPLACE INTO stockplay_balances VALUES (?, ?)",
(nick, amount, ))
def get_bal(self, nick):
"""
Get player's balance
:return: balance in cents
"""
with closing(self.sql.getCursor()) as c:
return c.execute("SELECT * FROM stockplay_balances WHERE nick=?",
(nick, )).fetchone()["cents"]
def get_holding(self, nick, symbol):
"""
Return the number of stocks of a certain symbol a player has
"""
assert symbol == symbol.upper()
with closing(self.sql.getCursor()) as c:
r = c.execute("SELECT * FROM stockplay_holdings WHERE nick=? AND symbol=?",
(nick, symbol, )).fetchone()
return r["count"] if r else 0
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:
c.execute("REPLACE INTO stockplay_holdings VALUES (?, ?, ?)",
(nick, symbol, count, ))
def log_trade(self, nick, time, type, symbol, count, price, symprice):
"""
Append a record of a trade to the database log
"""
with closing(self.sql.getCursor()) as c:
c.execute("INSERT INTO stockplay_trades VALUES (?, ?, ?, ?, ?, ?, ?)",
(nick, time, type, symbol, count, price, symprice, ))
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))
class PriceCache(object):
def __init__(self, mod):
self.sql = mod.sql
self.log = mod.log
self.mod = mod
self.providers = []
self.configure_providers(mod.config["providers"])
self.which_provider = dict()
self.unsupported = set()
def configure_providers(self, config):
for provider in config:
self.providers.append(PROVIDER_TYPES[provider["provider"]](provider, self.log))
def get_price(self, symbol, thresh):
symbol = symbol.upper()
# load from cache
price = self._load_priceinfo(symbol)
# if present and meets thresh
if price and (thresh == -1 or time() - price.time < thresh):
return price
if symbol in self.unsupported:
return
return self.api_fetch(symbol)
def api_fetch(self, symbol):
fetched = None
if symbol in self.which_provider:
fetched = self.which_provider[symbol].get_price(symbol)
if not fetched:
for provider in self.providers:
try:
fetched = provider.get_price(symbol)
self.which_provider[symbol] = provider
break
except NotSupported as nse:
self.unsupported.update([symbol])
self.log.info("provider {}: {}".format(provider.__class__.__name__, nse))
if not fetched:
self.log.critical("unsupported symbol: %s", symbol)
return
self._store_priceinfo(fetched)
return fetched
def _store_priceinfo(self, price):
with closing(self.sql.getCursor()) as c:
c.execute("REPLACE INTO stockplay_prices (symbol, attempt_time, time, data) VALUES (?, ?, ?, ?)",
(price.symbol, price.time, time(), price.to_json()))
def _load_priceinfo(self, symbol):
with closing(self.sql.getCursor()) as c:
row = c.execute("SELECT * FROM stockplay_prices WHERE symbol=?",
(symbol, )).fetchone()
if not row:
return
return Price.from_json(row["data"])
class Price(object):
def __init__(self, symbol, price, time_):
self.symbol = symbol.upper()
self.price = round(Decimal(price), 4)
self.time = time_
def to_json(self):
return json.dumps({
"symbol": self.symbol,
"price": str(self.price),
"time": self.time,
})
@staticmethod
def from_json(data):
data = json.loads(data)
return Price(data["symbol"].upper(), data["price"], data.get("time", 0))
class PriceProvider(object):
def __init__(self, config, logger):
"""
config::
{
"provider": "name",
"apikey": "xxxxxxxxxxxxxx",
}
"""
self.config = config
self.log = logger
def get_price(self, symbol):
"""
:return: tuple of:
* price (as a Decimal)
* next_after (the time() after which the next background call should happen)
or raise:
NotSupported - if the symbol isnt supported
"""
raise NotImplementedError()
class IEXCloudProvider(PriceProvider):
def get_price(self, symbol):
"""
Request a stock quote from the API. The API provides the format::
{"symbol": "AAPL",
"companyName": "Apple, Inc.",
"calculationPrice": "close",
"open": 184.7,
"openTime": 1552656600847,
"close": 186.12,
"closeTime": 1552680000497,
"high": 187.33,
"low": 183.74,
"latestPrice": 186.12,
"latestSource": "Close",
"latestTime": "March 15, 2019",
"latestUpdate": 1552680000497,
"latestVolume": 39141464,
"iexRealtimePrice": 186.195,
"iexRealtimeSize": 100,
"iexLastUpdated": 1552679999536,
"delayedPrice": 186.124,
"delayedPriceTime": 1552680900008,
"extendedPrice": 185.92,
"extendedChange": -0.2,
"extendedChangePercent": -0.00107,
"extendedPriceTime": 1552693471549,
"previousClose": 183.73,
"change": 2.39,
"changePercent": 0.01301,
"iexMarketPercent": 0.021849182749015213,
"iexVolume": 855209,
"avgTotalVolume": 25834564,
"iexBidPrice": 0,
"iexBidSize": 0,
"iexAskPrice": 0,
"iexAskSize": 0,
"marketCap": 877607913600,
"peRatio": 15.17,
"week52High": 233.47,
"week52Low": 142,
"ytdChange": 0.176447}
"""
self.log.info("{}: fetching api quote for symbol: {}".format(self.__class__.__name__, symbol))
response = get("https://cloud.iexapis.com/beta/stock/{}/quote".format(symbol.lower()),
params={"token": self.config["apikey"]},
timeout=10)
if response.status_code != 200:
if response.status_code == 404:
raise NotSupported(symbol)
else:
response.raise_for_status()
data = response.json()
return Price(symbol, Decimal(data["latestPrice"]), int(time()))
class AlphaVantProvider(PriceProvider):
def get_price(self, symbol):
"""
Request a stock quote from the API. The API provides the format::
{'Global Quote': {
{'01. symbol': 'MSFT',
'02. open': '104.3900',
'03. high': '105.7800',
'04. low': '104.2603',
'05. price': '105.6700',
'06. volume': '21461093',
'07. latest trading day':'2019-02-08',
'08. previous close':'105.2700',
'09. change': '0.4000',
'10. change percent': '0.3800%'}}
"""
self.log.info("{}: fetching api quote for symbol: {}".format(self.__class__.__name__, symbol))
data = get("https://www.alphavantage.co/query",
params={"function": "GLOBAL_QUOTE",
"symbol": symbol,
"apikey": self.config["apikey"]},
timeout=10).json()
if "Global Quote" not in data:
raise NotSupported(symbol)
return Price(symbol, Decimal(data["Global Quote"]["05. price"]), int(time()))
PROVIDER_TYPES = {
"iexcloud": IEXCloudProvider,
"alphavantage": AlphaVantProvider
}
class NotSupported(Exception):
pass

View File

@ -10,6 +10,7 @@ import logging
import sys
from pyircbot.rpc import BotRPC
from pyircbot.irccore import IRCCore
from pyircbot.common import report
from socket import AF_INET, AF_INET6
import os.path
import asyncio
@ -44,6 +45,7 @@ class ModuleLoader(object):
self.log.error("Module %s failed to load: " % name)
self.log.error("Module load failure reason: " + str(e))
traceback.print_exc()
report(e)
return (False, str(e))
else:
self.log.warning("Module %s already imported" % name)

View File

@ -1,47 +1,37 @@
apipkg==1.4
appdirs==1.4.3
certifi==2017.4.17
attrs==17.4.0
backports.functools-lru-cache==1.5
certifi==2018.4.16
chardet==3.0.4
cheroot==5.9.1
CherryPy==12.0.1
coverage==4.4.2
decorator==4.0.11
cheroot==6.2.4
CherryPy==14.2.0
coverage==4.5.1
execnet==1.5.0
idna==2.5
ipdb==0.10.3
ipython==6.0.0
ipython-genutils==0.2.0
jaraco.classes==1.4.3
jedi==0.10.2
lxml==4.1.1
mock==2.0.0
-e git+http://gitlab.davepedu.com/dave/pymsgbus.git#egg=msgbus
packaging==16.8
pbr==3.1.1
pexpect==4.2.1
pickleshare==0.7.4
idna==2.6
lxml==4.3.1
more-itertools==4.1.0
-e git+http://git.davepedu.com/dave/pymsgbus.git@373a9c5f153078fce57bde43f493785859f51de4#egg=msgbus
pluggy==0.6.0
portend==2.2
praw==5.0.1
prawcore==0.11.0
prompt-toolkit==1.0.14
ptyprocess==0.5.1
py==1.5.2
Pygments==2.2.0
PyJWT==1.5.3
pyparsing==2.2.0
PySocks==1.6.7
pytest==3.2.5
praw==5.4.0
prawcore==0.14.0
py==1.5.3
PyJWT==1.6.1
PyMySQL==0.8.0
PySocks==1.6.8
pytest==3.5.0
pytest-cov==2.5.1
pytest-cover==3.0.0
pytest-coverage==0.0
pytest-forked==0.2
pytest-xdist==1.20.1
pytz==2017.3
pyzmq==16.0.2
requests==2.18.1
simplegeneric==0.8.1
pytest-xdist==1.22.2
-e git+https://github.com/jgarzik/python-bitcoinrpc.git@76ced424dc16f997365265487487056e653238c6#egg=python_bitcoinrpc
pytz==2018.4
pyzmq==17.0.0
-e git+https://github.com/bitprophet/releases.git@1.6.1#egg=releases
requests==2.18.4
six==1.11.0
tempora==1.9
traitlets==4.3.2
twilio==6.9.0
tempora==1.11
twilio==6.12.1
update-checker==0.16
urllib3==1.21.1
wcwidth==0.1.7
urllib3==1.22

View File

@ -1,35 +1,24 @@
alabaster==0.7.10
Babel==2.5.3
certifi==2017.11.5
backports.functools-lru-cache==1.5
certifi==2018.4.16
chardet==3.0.4
cheroot==6.0.0
CherryPy==13.1.0
docutils==0.14
cheroot==6.2.4
CherryPy==14.2.0
idna==2.6
imagesize==0.7.1
Jinja2==2.10
lxml==4.1.1
MarkupSafe==1.0
more-itertools==4.0.1
pyzmq==16.0.3
lxml==4.3.1
more-itertools==4.1.0
-e git+http://git.davepedu.com/dave/pymsgbus.git@373a9c5f153078fce57bde43f493785859f51de4#egg=msgbus
portend==2.2
praw==5.3.0
prawcore==0.13.0
Pygments==2.2.0
PyJWT==1.5.3
praw==5.4.0
prawcore==0.14.0
PyJWT==1.6.1
PyMySQL==0.8.0
PySocks==1.6.8
-e git+https://github.com/jgarzik/python-bitcoinrpc.git@76ced424dc16f997365265487487056e653238c6#egg=python_bitcoinrpc
pytz==2017.3
releases==1.4.0
pytz==2018.4
pyzmq==17.0.0
requests==2.18.4
semantic-version==2.6.0
six==1.11.0
snowballstemmer==1.2.1
Sphinx==1.6.6
sphinxcontrib-websupport==1.0.1
tempora==1.10
twilio==6.9.0
tempora==1.11
twilio==6.12.1
update-checker==0.16
urllib3==1.22

View File

@ -6,4 +6,4 @@ shift || true
export PYTHONUNBUFFERED=1
export PYTHONPATH=.
./bin/pyircbot -c $CONFPATH --debug $@
pyircbot -c $CONFPATH --debug $@

View File

@ -3,4 +3,7 @@
export PYTHONUNBUFFERED=1
export PYTHONPATH=.
find pyircbot tests -name '*.pyc' -delete
find pyircbot tests -name __pycache__ -exec rm -rf {} \;
py.test --cov=pyircbot --cov-report html -n 4 tests/ $@

View File

@ -1,14 +1,19 @@
#!/usr/bin/env python3
from setuptools import setup
__version__ = "4.0.0-r03"
__version__ = "4.1.0"
setup(name='pyircbot',
version='4.0.0-r03',
version=__version__,
description='A modular python irc bot',
url='http://gitlab.xmopx.net/dave/pyircbot3/tree/master',
author='dpedu',
author_email='dave@davepedu.com',
packages=['pyircbot', 'pyircbot.modules'],
scripts=['bin/pyircbot'],
entry_points={
"console_scripts": [
"pyircbot = pyircbot.cli:main",
"pubsubbot = pyircbot.clipub:main"
]
},
zip_safe=False)

View File

@ -5,7 +5,7 @@ from threading import Thread
from random import randint
from pyircbot import PyIRCBot
from pyircbot.pyircbot import PrimitiveBot
from pyircbot.irccore import IRCEvent, UserPrefix
from pyircbot.irccore import IRCEvent, UserPrefix, IRCCore
from unittest.mock import MagicMock
from tests.miniircd import Server as MiniIrcServer
@ -27,10 +27,10 @@ class FakeBaseBot(PrimitiveBot):
"""
Feed a message into the bot.
"""
msg = IRCEvent(cmd,
args,
UserPrefix(*sender),
trailing)
msg = IRCCore.packetAsObject(cmd,
args,
f"{sender[0]}!{sender[1]}@{sender[2]}", # hack
trailing)
for module_name, module in self.moduleInstances.items():# TODO dedupe this block across the various base classes
for hook in module.irchooks:

View File

@ -70,8 +70,9 @@ def test_getbal_authed(cryptobot):
def test_setup(cryptobot, mynick="chatter"):
pm(cryptobot, ".setpass foobar", nick=mynick)
cryptobot.act_PRIVMSG.assert_called_once_with(mynick, '.setpass: Your password has been set to "foobar".')
cryptobot.act_PRIVMSG.assert_called_once_with(mynick, '.setpass: You\'ve been logged in and your password has been set to "foobar".')
cryptobot.act_PRIVMSG.reset_mock()
# TODO shouldn't need .login here, the setpass does it
pm(cryptobot, ".login foobar", nick=mynick)
cryptobot.act_PRIVMSG.assert_called_once_with(mynick, '.login: You have been logged in from: cia.gov')
cryptobot.act_PRIVMSG.reset_mock()

View File

@ -36,7 +36,7 @@ def test_register(nickbot):
nickbot.act_PRIVMSG.assert_called_once_with('chatter', '.setpass: usage: ".setpass newpass" or ".setpass oldpass newpass"')
nickbot.act_PRIVMSG.reset_mock()
pm(nickbot, ".setpass foobar")
nickbot.act_PRIVMSG.assert_called_once_with('chatter', '.setpass: Your password has been set to "foobar".')
nickbot.act_PRIVMSG.assert_called_once_with('chatter', '.setpass: You\'ve been logged in and your password has been set to "foobar".')
nickbot.act_PRIVMSG.reset_mock()

View File

@ -0,0 +1,72 @@
import pytest
from contextlib import closing
from tests.lib import * # NOQA - fixtures
from time import sleep, time
import datetime
@pytest.fixture
def stockbot(fakebot):
"""
Provide a bot loaded with the Calc module. Clear the database.
"""
fakebot.botconfig["module_configs"]["StockPlay"] = {
"startbalance": 10000,
"tradedelay": 0,
"tcachesecs": 120,
"bginterval": 45,
"announce_trades": True,
"announce_channel": "#trades",
"providers": [
{
"provider": "iexcloud",
"apikey": "xxxxxxxxxxxxxxxxxxxxxx",
"background_interval": 1
},
{
"provider": "alphavantage",
"apikey": "xxxxxxxxxxxxxxxxxxxxxx",
"background_interval": 1
}
]
}
fakebot.loadmodule("SQLite")
# with closing(fakebot.moduleInstances["SQLite"].opendb("remind.db")) as db:
# db.query("DROP TABLE IF EXISTS `reminders`;")
# fakebot.loadmodule("Remind")
# os.system("cp /Users/dave/code/pyircbot-work/examples/data2/data/SQLite/stockplay.db {}".format(fakebot.moduleInstances["SQLite"].getFilePath()))
# os.system("ln -s /Users/dave/code/pyircbot-work/examples/data2/data/SQLite/stockplay.db {}".format(fakebot.moduleInstances["SQLite"].getFilePath()))
fakebot.loadmodule("StockPlay")
return fakebot
# @pytest.mark.slow
# def test_stockplay(stockbot):
# sp = stockbot.moduleInstances["StockPlay"]
# # import pdb
# # pdb.set_trace()
# # print(sp.cache)
# # print(sp.cache.get_price("AmD", 60))
# # print(sp.cache.get_price("AmD", 60))
# # print(sp.cache.get_price("AmD", 60))
# # print(sp.cache.get_price("nut", 60))
# symbols = set()
# with closing(sp.sql.getCursor()) as c:
# for row in c.execute("SELECT * FROM stockplay_holdings").fetchall():
# symbols.update([row["symbol"].lower()])
# print(symbols)
# # # symbols = "a bah chk crm cron f fb mdla nio too tsla".split()
# for symbol in symbols:
# p = sp.cache.get_price(symbol, 0)
# if not p:
# print("not supported:", symbol)
# continue
# print(symbol, "age: ", time() - p.time)
# # print(sp.cache.get_price("gigl", 60))

View File

@ -3,7 +3,6 @@ import pytest
from pyircbot import jsonrpc
from threading import Thread
from random import randint
from socket import SHUT_RDWR
from time import sleep
@ -37,7 +36,7 @@ def j1testserver():
Thread(target=server.serve, daemon=True).start()
sleep(0.1) # Give the serve() time to set up the serversocket
yield (server, port)
server._Server__transport.s.shutdown(SHUT_RDWR)
server._Server__transport.close()
@pytest.fixture
@ -48,9 +47,9 @@ def j2testserver():
server.register_function(sample)
server.register_instance(_sample(), name="obj")
Thread(target=server.serve, daemon=True).start()
sleep(0.1) # Give the serve() time to set up the serversocket
sleep(0.2) # Give the serve() time to set up the serversocket
yield (server, port)
server._Server__transport.s.shutdown(SHUT_RDWR)
server._Server__transport.close()
# Basic functionality