Spaces:
Sleeping
Sleeping
Upload 5 files
Browse files- README.md +113 -19
- _.env.example +2 -0
- bot.py +873 -0
- requirements.txt +4 -3
- streamlit_app.py +242 -0
README.md
CHANGED
|
@@ -1,19 +1,113 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Kasper
|
| 2 |
+
|
| 3 |
+
Kasper is a Discord-first long-term investing and retirement-planning bot with a Streamlit companion dashboard.
|
| 4 |
+
|
| 5 |
+
## Included files
|
| 6 |
+
|
| 7 |
+
- `bot.py` — single-file Discord bot
|
| 8 |
+
- `streamlit_app.py` — Streamlit dashboard companion
|
| 9 |
+
- `requirements.txt` — Python dependencies
|
| 10 |
+
- `.env.example` — environment variable template
|
| 11 |
+
|
| 12 |
+
## Main bot features
|
| 13 |
+
|
| 14 |
+
- slash commands and old-style prefix commands
|
| 15 |
+
- `/welcome` and `!welcome`
|
| 16 |
+
- `/instructions` and `!instructions`
|
| 17 |
+
- `/role` and `!role` with a button that toggles the `Kasper` role
|
| 18 |
+
- phased contribution projection calculator
|
| 19 |
+
- target monthly contribution calculator
|
| 20 |
+
- per-server watchlists
|
| 21 |
+
- per-server alert channel / role config
|
| 22 |
+
- placeholder daily scanner loop for future stock logic
|
| 23 |
+
|
| 24 |
+
## Setup
|
| 25 |
+
|
| 26 |
+
### 1. Create the Discord bot and invite it
|
| 27 |
+
|
| 28 |
+
In the Discord Developer Portal:
|
| 29 |
+
- create an application
|
| 30 |
+
- create a bot user
|
| 31 |
+
- enable the intents you need, especially **Server Members Intent** and **Message Content Intent**
|
| 32 |
+
- invite the bot with permissions such as:
|
| 33 |
+
- View Channels
|
| 34 |
+
- Send Messages
|
| 35 |
+
- Embed Links
|
| 36 |
+
- Read Message History
|
| 37 |
+
- Use Slash Commands
|
| 38 |
+
- Manage Roles
|
| 39 |
+
|
| 40 |
+
### 2. Configure credentials
|
| 41 |
+
|
| 42 |
+
Copy `.env.example` to `.env` and fill in your values:
|
| 43 |
+
|
| 44 |
+
```bash
|
| 45 |
+
cp .env.example .env
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
You can also paste the token and application ID directly into `bot.py`, but `.env` is cleaner.
|
| 49 |
+
|
| 50 |
+
### 3. Install requirements
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
pip install -r requirements.txt
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
### 4. Run the bot
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
python bot.py
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
### 5. Run the Streamlit dashboard
|
| 63 |
+
|
| 64 |
+
```bash
|
| 65 |
+
streamlit run streamlit_app.py
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
## Command summary
|
| 69 |
+
|
| 70 |
+
### Public
|
| 71 |
+
- `/welcome` or `!welcome`
|
| 72 |
+
- `/instructions` or `!instructions`
|
| 73 |
+
- `/role` or `!role`
|
| 74 |
+
- `/calculator` or `!calculator`
|
| 75 |
+
- `/project` or `!project`
|
| 76 |
+
- `/targetcalc` or `!targetcalc`
|
| 77 |
+
- `/addwatch` or `!addwatch`
|
| 78 |
+
- `/removewatch` or `!removewatch`
|
| 79 |
+
- `/listwatch` or `!listwatch`
|
| 80 |
+
- `/serverinfo` or `!serverinfo`
|
| 81 |
+
|
| 82 |
+
### Admin
|
| 83 |
+
- `/setalertchannel` or `!setalertchannel`
|
| 84 |
+
- `/setalertroleid` or `!setalertroleid`
|
| 85 |
+
- `/setadminroles` or `!setadminroles`
|
| 86 |
+
- `/setscanner` or `!setscanner`
|
| 87 |
+
- `/testalert` or `!testalert`
|
| 88 |
+
- `/sync` or `!sync`
|
| 89 |
+
|
| 90 |
+
## Notes on hosting
|
| 91 |
+
|
| 92 |
+
### Discord bot
|
| 93 |
+
A Discord bot usually needs to stay connected to Discord's Gateway, so it behaves like an always-on process rather than a typical stateless web app.
|
| 94 |
+
|
| 95 |
+
### Streamlit UI
|
| 96 |
+
The Streamlit app is a better fit for Hugging Face Spaces than the always-on Discord bot process. The strongest setup is usually:
|
| 97 |
+
- Discord bot on a stable always-on host
|
| 98 |
+
- Streamlit dashboard on Hugging Face Spaces
|
| 99 |
+
|
| 100 |
+
## Local data files
|
| 101 |
+
|
| 102 |
+
Kasper stores server data in `./kasper_data/`:
|
| 103 |
+
- `guild_config.json`
|
| 104 |
+
- `watchlists.json`
|
| 105 |
+
- `alert_log.json`
|
| 106 |
+
|
| 107 |
+
## Next recommended upgrade
|
| 108 |
+
|
| 109 |
+
Swap the placeholder scanner in `bot.py` for a real free-data pipeline built around:
|
| 110 |
+
- SEC filings
|
| 111 |
+
- free EOD price source
|
| 112 |
+
- local feature store
|
| 113 |
+
- long-term stock scoring and buy-zone logic
|
_.env.example
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
DISCORD_BOT_TOKEN=PASTE_BOT_TOKEN_HERE
|
| 2 |
+
DISCORD_APPLICATION_ID=PASTE_APPLICATION_ID_HERE
|
bot.py
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Kasper Discord Bot
|
| 4 |
+
|
| 5 |
+
Single-file Discord bot with:
|
| 6 |
+
- Prefix commands + slash commands (hybrid where practical)
|
| 7 |
+
- Welcome and instructions embeds
|
| 8 |
+
- Button-based self-role toggle for role name "Kasper"
|
| 9 |
+
- Multi-server JSON config persistence
|
| 10 |
+
- Retirement / contribution calculators
|
| 11 |
+
- Watchlist management
|
| 12 |
+
- Placeholder stock alert system and daily task loop
|
| 13 |
+
|
| 14 |
+
Setup:
|
| 15 |
+
1. Copy .env.example to .env and fill values, or paste values directly below.
|
| 16 |
+
2. Install requirements.txt
|
| 17 |
+
3. Run: python bot.py
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import asyncio
|
| 22 |
+
import json
|
| 23 |
+
import logging
|
| 24 |
+
import math
|
| 25 |
+
import os
|
| 26 |
+
from dataclasses import dataclass, asdict
|
| 27 |
+
from datetime import datetime, timezone
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 30 |
+
|
| 31 |
+
import discord
|
| 32 |
+
from discord import app_commands
|
| 33 |
+
from discord.ext import commands, tasks
|
| 34 |
+
from dotenv import load_dotenv
|
| 35 |
+
|
| 36 |
+
# =========================
|
| 37 |
+
# Editable placeholders
|
| 38 |
+
# =========================
|
| 39 |
+
BOT_NAME = "Kasper"
|
| 40 |
+
DEFAULT_ROLE_NAME = "Kasper"
|
| 41 |
+
COMMAND_PREFIX = "!"
|
| 42 |
+
|
| 43 |
+
# Either use .env or paste directly here.
|
| 44 |
+
DISCORD_BOT_TOKEN = "PASTE_BOT_TOKEN_HERE"
|
| 45 |
+
DISCORD_APPLICATION_ID = "PASTE_APPLICATION_ID_HERE"
|
| 46 |
+
|
| 47 |
+
# Optional default IDs. You can override per server with commands.
|
| 48 |
+
DEFAULT_ALERT_CHANNEL_ID = 0
|
| 49 |
+
DEFAULT_ALERT_ROLE_ID = 0
|
| 50 |
+
DEFAULT_ADMIN_ROLE_IDS: List[int] = []
|
| 51 |
+
|
| 52 |
+
# Local storage
|
| 53 |
+
DATA_DIR = Path("./kasper_data")
|
| 54 |
+
CONFIG_PATH = DATA_DIR / "guild_config.json"
|
| 55 |
+
WATCHLIST_PATH = DATA_DIR / "watchlists.json"
|
| 56 |
+
ALERT_LOG_PATH = DATA_DIR / "alert_log.json"
|
| 57 |
+
|
| 58 |
+
# =========================
|
| 59 |
+
# Environment / logging
|
| 60 |
+
# =========================
|
| 61 |
+
load_dotenv()
|
| 62 |
+
TOKEN = os.getenv("DISCORD_BOT_TOKEN", DISCORD_BOT_TOKEN)
|
| 63 |
+
APP_ID_RAW = os.getenv("DISCORD_APPLICATION_ID", DISCORD_APPLICATION_ID)
|
| 64 |
+
APPLICATION_ID = int(APP_ID_RAW) if APP_ID_RAW.isdigit() else None
|
| 65 |
+
|
| 66 |
+
logging.basicConfig(
|
| 67 |
+
level=logging.INFO,
|
| 68 |
+
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
| 69 |
+
)
|
| 70 |
+
log = logging.getLogger(BOT_NAME.lower())
|
| 71 |
+
|
| 72 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 73 |
+
|
| 74 |
+
# =========================
|
| 75 |
+
# JSON storage helpers
|
| 76 |
+
# =========================
|
| 77 |
+
|
| 78 |
+
def _load_json(path: Path, default: Any) -> Any:
|
| 79 |
+
if not path.exists():
|
| 80 |
+
return default
|
| 81 |
+
try:
|
| 82 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 83 |
+
except Exception as exc:
|
| 84 |
+
log.warning("Failed to load %s: %s", path, exc)
|
| 85 |
+
return default
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _save_json(path: Path, data: Any) -> None:
|
| 89 |
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
guild_config: Dict[str, Dict[str, Any]] = _load_json(CONFIG_PATH, {})
|
| 93 |
+
watchlists: Dict[str, List[str]] = _load_json(WATCHLIST_PATH, {})
|
| 94 |
+
alert_log: List[Dict[str, Any]] = _load_json(ALERT_LOG_PATH, [])
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def save_all() -> None:
|
| 98 |
+
_save_json(CONFIG_PATH, guild_config)
|
| 99 |
+
_save_json(WATCHLIST_PATH, watchlists)
|
| 100 |
+
_save_json(ALERT_LOG_PATH, alert_log)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# =========================
|
| 104 |
+
# Config helpers
|
| 105 |
+
# =========================
|
| 106 |
+
|
| 107 |
+
def get_guild_key(guild_id: int) -> str:
|
| 108 |
+
return str(guild_id)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def get_guild_config(guild_id: int) -> Dict[str, Any]:
|
| 112 |
+
key = get_guild_key(guild_id)
|
| 113 |
+
if key not in guild_config:
|
| 114 |
+
guild_config[key] = {
|
| 115 |
+
"alert_channel_id": DEFAULT_ALERT_CHANNEL_ID,
|
| 116 |
+
"alert_role_id": DEFAULT_ALERT_ROLE_ID,
|
| 117 |
+
"admin_role_ids": DEFAULT_ADMIN_ROLE_IDS.copy(),
|
| 118 |
+
"role_name": DEFAULT_ROLE_NAME,
|
| 119 |
+
"scanner_enabled": True,
|
| 120 |
+
"last_scanner_run": None,
|
| 121 |
+
}
|
| 122 |
+
save_all()
|
| 123 |
+
return guild_config[key]
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def get_watchlist(guild_id: int) -> List[str]:
|
| 127 |
+
key = get_guild_key(guild_id)
|
| 128 |
+
if key not in watchlists:
|
| 129 |
+
watchlists[key] = []
|
| 130 |
+
save_all()
|
| 131 |
+
return watchlists[key]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def user_is_adminish(member: discord.Member, cfg: Dict[str, Any]) -> bool:
|
| 135 |
+
if member.guild_permissions.administrator:
|
| 136 |
+
return True
|
| 137 |
+
admin_role_ids = set(cfg.get("admin_role_ids", []))
|
| 138 |
+
if admin_role_ids and any(role.id in admin_role_ids for role in member.roles):
|
| 139 |
+
return True
|
| 140 |
+
return False
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def find_alert_role(guild: discord.Guild, cfg: Dict[str, Any]) -> Optional[discord.Role]:
|
| 144 |
+
role_id = cfg.get("alert_role_id", 0)
|
| 145 |
+
if role_id:
|
| 146 |
+
role = guild.get_role(int(role_id))
|
| 147 |
+
if role:
|
| 148 |
+
return role
|
| 149 |
+
role_name = cfg.get("role_name", DEFAULT_ROLE_NAME)
|
| 150 |
+
return discord.utils.get(guild.roles, name=role_name)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def find_alert_channel(guild: discord.Guild, cfg: Dict[str, Any]) -> Optional[discord.TextChannel]:
|
| 154 |
+
channel_id = cfg.get("alert_channel_id", 0)
|
| 155 |
+
if channel_id:
|
| 156 |
+
channel = guild.get_channel(int(channel_id))
|
| 157 |
+
if isinstance(channel, discord.TextChannel):
|
| 158 |
+
return channel
|
| 159 |
+
return None
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# =========================
|
| 163 |
+
# Calculator logic
|
| 164 |
+
# =========================
|
| 165 |
+
@dataclass
|
| 166 |
+
class Phase:
|
| 167 |
+
years: int
|
| 168 |
+
monthly_contribution: float
|
| 169 |
+
lump_sum_at_start: float = 0.0
|
| 170 |
+
label: str = ""
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
@dataclass
|
| 174 |
+
class ScenarioResult:
|
| 175 |
+
annual_return: float
|
| 176 |
+
ending_value: float
|
| 177 |
+
total_contributed: float
|
| 178 |
+
profit: float
|
| 179 |
+
annual_snapshots: List[Tuple[int, float]]
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
DEFAULT_RETURNS = [0.10, 0.14, 0.18]
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def fmt_money(v: float) -> str:
|
| 186 |
+
return f"${v:,.2f}"
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def fmt_pct(v: float) -> str:
|
| 190 |
+
return f"{v*100:.2f}%"
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def simulate_portfolio(initial_investment: float, phases: List[Phase], annual_return: float) -> ScenarioResult:
|
| 194 |
+
monthly_rate = (1 + annual_return) ** (1 / 12) - 1
|
| 195 |
+
balance = float(initial_investment)
|
| 196 |
+
total_contributed = float(initial_investment)
|
| 197 |
+
annual_snapshots: List[Tuple[int, float]] = []
|
| 198 |
+
current_year = 0
|
| 199 |
+
|
| 200 |
+
for phase in phases:
|
| 201 |
+
if phase.lump_sum_at_start:
|
| 202 |
+
balance += phase.lump_sum_at_start
|
| 203 |
+
total_contributed += phase.lump_sum_at_start
|
| 204 |
+
|
| 205 |
+
for month_idx in range(1, phase.years * 12 + 1):
|
| 206 |
+
balance += phase.monthly_contribution
|
| 207 |
+
total_contributed += phase.monthly_contribution
|
| 208 |
+
balance *= (1 + monthly_rate)
|
| 209 |
+
if month_idx % 12 == 0:
|
| 210 |
+
current_year += 1
|
| 211 |
+
annual_snapshots.append((current_year, balance))
|
| 212 |
+
|
| 213 |
+
return ScenarioResult(
|
| 214 |
+
annual_return=annual_return,
|
| 215 |
+
ending_value=balance,
|
| 216 |
+
total_contributed=total_contributed,
|
| 217 |
+
profit=balance - total_contributed,
|
| 218 |
+
annual_snapshots=annual_snapshots,
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def future_value_constant_monthly(initial_investment: float, monthly_contribution: float, years: int, annual_return: float) -> float:
|
| 223 |
+
monthly_rate = (1 + annual_return) ** (1 / 12) - 1
|
| 224 |
+
balance = initial_investment
|
| 225 |
+
for _ in range(years * 12):
|
| 226 |
+
balance += monthly_contribution
|
| 227 |
+
balance *= (1 + monthly_rate)
|
| 228 |
+
return balance
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def required_monthly_contribution(target_value: float, years: int, annual_return: float, initial_investment: float = 0.0) -> float:
|
| 232 |
+
low, high = 0.0, max(target_value, 1.0)
|
| 233 |
+
for _ in range(200):
|
| 234 |
+
mid = (low + high) / 2
|
| 235 |
+
fv = future_value_constant_monthly(initial_investment, mid, years, annual_return)
|
| 236 |
+
if fv >= target_value:
|
| 237 |
+
high = mid
|
| 238 |
+
else:
|
| 239 |
+
low = mid
|
| 240 |
+
return high
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def parse_returns(raw: str) -> List[float]:
|
| 244 |
+
values: List[float] = []
|
| 245 |
+
for part in raw.split(","):
|
| 246 |
+
part = part.strip().replace("%", "")
|
| 247 |
+
if not part:
|
| 248 |
+
continue
|
| 249 |
+
values.append(float(part) / 100)
|
| 250 |
+
return values or DEFAULT_RETURNS
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def build_projection_embed(initial: float, phases: List[Phase], returns: List[float]) -> discord.Embed:
|
| 254 |
+
embed = discord.Embed(
|
| 255 |
+
title=f"{BOT_NAME} Projection Results",
|
| 256 |
+
description="Scenario modeling using monthly compounding and phased contributions.",
|
| 257 |
+
color=discord.Color.blurple(),
|
| 258 |
+
timestamp=datetime.now(timezone.utc),
|
| 259 |
+
)
|
| 260 |
+
phase_lines = []
|
| 261 |
+
total_years = 0
|
| 262 |
+
for idx, phase in enumerate(phases, start=1):
|
| 263 |
+
total_years += phase.years
|
| 264 |
+
label = f" — {phase.label}" if phase.label else ""
|
| 265 |
+
phase_lines.append(
|
| 266 |
+
f"**Phase {idx}{label}**\n"
|
| 267 |
+
f"Years: {phase.years}\n"
|
| 268 |
+
f"Monthly: {fmt_money(phase.monthly_contribution)}\n"
|
| 269 |
+
f"Lump at start: {fmt_money(phase.lump_sum_at_start)}"
|
| 270 |
+
)
|
| 271 |
+
embed.add_field(name="Inputs", value=f"Initial: {fmt_money(initial)}\nTotal years: {total_years}", inline=False)
|
| 272 |
+
embed.add_field(name="Phases", value="\n\n".join(phase_lines)[:1024], inline=False)
|
| 273 |
+
|
| 274 |
+
result_lines = []
|
| 275 |
+
for annual_return in returns:
|
| 276 |
+
result = simulate_portfolio(initial, phases, annual_return)
|
| 277 |
+
gain_pct = (result.profit / result.total_contributed) if result.total_contributed else 0
|
| 278 |
+
result_lines.append(
|
| 279 |
+
f"**{fmt_pct(annual_return)}** → End: {fmt_money(result.ending_value)} | "
|
| 280 |
+
f"Contributed: {fmt_money(result.total_contributed)} | Profit: {fmt_money(result.profit)} | Gain: {fmt_pct(gain_pct)}"
|
| 281 |
+
)
|
| 282 |
+
embed.add_field(name="Results", value="\n".join(result_lines)[:1024], inline=False)
|
| 283 |
+
embed.set_footer(text=f"{BOT_NAME} calculator")
|
| 284 |
+
return embed
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def build_target_embed(target: float, years: int, initial: float, returns: List[float]) -> discord.Embed:
|
| 288 |
+
embed = discord.Embed(
|
| 289 |
+
title=f"{BOT_NAME} Required Contribution Calculator",
|
| 290 |
+
description="Required constant monthly contribution to hit the selected target.",
|
| 291 |
+
color=discord.Color.green(),
|
| 292 |
+
timestamp=datetime.now(timezone.utc),
|
| 293 |
+
)
|
| 294 |
+
embed.add_field(name="Inputs", value=f"Target: {fmt_money(target)}\nYears: {years}\nInitial: {fmt_money(initial)}", inline=False)
|
| 295 |
+
lines = []
|
| 296 |
+
for annual_return in returns:
|
| 297 |
+
monthly = required_monthly_contribution(target, years, annual_return, initial)
|
| 298 |
+
lines.append(f"**{fmt_pct(annual_return)}** → {fmt_money(monthly)} / month")
|
| 299 |
+
embed.add_field(name="Required Monthly Contribution", value="\n".join(lines), inline=False)
|
| 300 |
+
embed.set_footer(text=f"{BOT_NAME} calculator")
|
| 301 |
+
return embed
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
# =========================
|
| 305 |
+
# UI Views / Modals
|
| 306 |
+
# =========================
|
| 307 |
+
class RoleToggleView(discord.ui.View):
|
| 308 |
+
def __init__(self):
|
| 309 |
+
super().__init__(timeout=None)
|
| 310 |
+
|
| 311 |
+
@discord.ui.button(label="Toggle Kasper Alerts Role", style=discord.ButtonStyle.primary, custom_id="kasper_toggle_role")
|
| 312 |
+
async def toggle_role(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
|
| 313 |
+
if interaction.guild is None or not isinstance(interaction.user, discord.Member):
|
| 314 |
+
await interaction.response.send_message("This button only works inside a server.", ephemeral=True)
|
| 315 |
+
return
|
| 316 |
+
|
| 317 |
+
cfg = get_guild_config(interaction.guild.id)
|
| 318 |
+
role = find_alert_role(interaction.guild, cfg)
|
| 319 |
+
if role is None:
|
| 320 |
+
await interaction.response.send_message(
|
| 321 |
+
f"I couldn't find the **{cfg.get('role_name', DEFAULT_ROLE_NAME)}** role in this server. "
|
| 322 |
+
f"An admin can run `/setalertroleid` or create the role first.",
|
| 323 |
+
ephemeral=True,
|
| 324 |
+
)
|
| 325 |
+
return
|
| 326 |
+
|
| 327 |
+
member = interaction.user
|
| 328 |
+
try:
|
| 329 |
+
if role in member.roles:
|
| 330 |
+
await member.remove_roles(role, reason=f"{BOT_NAME} self-role toggle")
|
| 331 |
+
await interaction.response.send_message(f"Removed **{role.name}** from you.", ephemeral=True)
|
| 332 |
+
else:
|
| 333 |
+
await member.add_roles(role, reason=f"{BOT_NAME} self-role toggle")
|
| 334 |
+
await interaction.response.send_message(f"Added **{role.name}** to you.", ephemeral=True)
|
| 335 |
+
except discord.Forbidden:
|
| 336 |
+
await interaction.response.send_message(
|
| 337 |
+
"I do not have permission to manage that role. Move my role above the Kasper role and grant Manage Roles.",
|
| 338 |
+
ephemeral=True,
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
class ProjectionModal(discord.ui.Modal, title="Kasper Projection Calculator"):
|
| 343 |
+
initial = discord.ui.TextInput(label="Initial investment", placeholder="1000", default="1000")
|
| 344 |
+
phase1_years = discord.ui.TextInput(label="Phase 1 years", placeholder="5", default="5")
|
| 345 |
+
phase1_monthly = discord.ui.TextInput(label="Phase 1 monthly contribution", placeholder="250", default="250")
|
| 346 |
+
phase2_years = discord.ui.TextInput(label="Phase 2 years", placeholder="5", default="5", required=False)
|
| 347 |
+
phase2_monthly = discord.ui.TextInput(label="Phase 2 monthly contribution", placeholder="500", default="500", required=False)
|
| 348 |
+
|
| 349 |
+
async def on_submit(self, interaction: discord.Interaction) -> None:
|
| 350 |
+
try:
|
| 351 |
+
initial = float(str(self.initial))
|
| 352 |
+
p1y = int(str(self.phase1_years))
|
| 353 |
+
p1m = float(str(self.phase1_monthly))
|
| 354 |
+
phases = [Phase(years=p1y, monthly_contribution=p1m, label="Phase 1")]
|
| 355 |
+
if str(self.phase2_years).strip() and str(self.phase2_monthly).strip():
|
| 356 |
+
p2y = int(str(self.phase2_years))
|
| 357 |
+
p2m = float(str(self.phase2_monthly))
|
| 358 |
+
phases.append(Phase(years=p2y, monthly_contribution=p2m, label="Phase 2"))
|
| 359 |
+
embed = build_projection_embed(initial, phases, DEFAULT_RETURNS)
|
| 360 |
+
await interaction.response.send_message(embed=embed, ephemeral=True)
|
| 361 |
+
except ValueError:
|
| 362 |
+
await interaction.response.send_message("Invalid numeric input. Please use numbers only.", ephemeral=True)
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
class TargetModal(discord.ui.Modal, title="Kasper Target Calculator"):
|
| 366 |
+
target = discord.ui.TextInput(label="Target portfolio value", placeholder="2000000", default="2000000")
|
| 367 |
+
years = discord.ui.TextInput(label="Years to invest", placeholder="30", default="30")
|
| 368 |
+
initial = discord.ui.TextInput(label="Initial investment", placeholder="0", default="0", required=False)
|
| 369 |
+
returns = discord.ui.TextInput(label="Return scenarios (%)", placeholder="10,14,18", default="10,14,18", required=False)
|
| 370 |
+
|
| 371 |
+
async def on_submit(self, interaction: discord.Interaction) -> None:
|
| 372 |
+
try:
|
| 373 |
+
target = float(str(self.target))
|
| 374 |
+
years = int(str(self.years))
|
| 375 |
+
initial = float(str(self.initial) or 0)
|
| 376 |
+
returns = parse_returns(str(self.returns) or "10,14,18")
|
| 377 |
+
embed = build_target_embed(target, years, initial, returns)
|
| 378 |
+
await interaction.response.send_message(embed=embed, ephemeral=True)
|
| 379 |
+
except ValueError:
|
| 380 |
+
await interaction.response.send_message("Invalid numeric input. Please use numbers only.", ephemeral=True)
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
class CalculatorHubView(discord.ui.View):
|
| 384 |
+
def __init__(self):
|
| 385 |
+
super().__init__(timeout=300)
|
| 386 |
+
|
| 387 |
+
@discord.ui.button(label="Open Projection Calculator", style=discord.ButtonStyle.primary)
|
| 388 |
+
async def projection(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
|
| 389 |
+
await interaction.response.send_modal(ProjectionModal())
|
| 390 |
+
|
| 391 |
+
@discord.ui.button(label="Open Target Calculator", style=discord.ButtonStyle.success)
|
| 392 |
+
async def target(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
|
| 393 |
+
await interaction.response.send_modal(TargetModal())
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
# =========================
|
| 397 |
+
# Bot setup
|
| 398 |
+
# =========================
|
| 399 |
+
intents = discord.Intents.default()
|
| 400 |
+
intents.guilds = True
|
| 401 |
+
intents.members = True
|
| 402 |
+
intents.message_content = True
|
| 403 |
+
|
| 404 |
+
bot = commands.Bot(
|
| 405 |
+
command_prefix=COMMAND_PREFIX,
|
| 406 |
+
intents=intents,
|
| 407 |
+
application_id=APPLICATION_ID,
|
| 408 |
+
help_command=None,
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
# =========================
|
| 413 |
+
# Embeds / text builders
|
| 414 |
+
# =========================
|
| 415 |
+
|
| 416 |
+
def welcome_embed() -> discord.Embed:
|
| 417 |
+
embed = discord.Embed(
|
| 418 |
+
title=f"Welcome to {BOT_NAME}",
|
| 419 |
+
description=(
|
| 420 |
+
f"**{BOT_NAME}** is a long-term investing and retirement-planning Discord bot built to help users "
|
| 421 |
+
"analyze contribution plans, compare outcome scenarios, manage stock watchlists, and receive structured alerts."
|
| 422 |
+
),
|
| 423 |
+
color=discord.Color.gold(),
|
| 424 |
+
timestamp=datetime.now(timezone.utc),
|
| 425 |
+
)
|
| 426 |
+
embed.add_field(
|
| 427 |
+
name="Overarching Goal",
|
| 428 |
+
value=(
|
| 429 |
+
"Help users build a disciplined long-term investing process with retirement-focused scenario planning, "
|
| 430 |
+
"watchlist tracking, and explainable alert workflows."
|
| 431 |
+
),
|
| 432 |
+
inline=False,
|
| 433 |
+
)
|
| 434 |
+
embed.add_field(
|
| 435 |
+
name="Core Features",
|
| 436 |
+
value=(
|
| 437 |
+
"• Portfolio projection calculator\n"
|
| 438 |
+
"• Target contribution calculator\n"
|
| 439 |
+
"• Multi-server Kasper alert role toggle\n"
|
| 440 |
+
"• Watchlist management\n"
|
| 441 |
+
"• Alert channel / role configuration\n"
|
| 442 |
+
"• Placeholder daily scanner loop for future stock intelligence\n"
|
| 443 |
+
"• Clean embeds, slash commands, and prefix commands"
|
| 444 |
+
),
|
| 445 |
+
inline=False,
|
| 446 |
+
)
|
| 447 |
+
embed.add_field(
|
| 448 |
+
name="Best Use",
|
| 449 |
+
value=(
|
| 450 |
+
"Use Kasper to model retirement contribution plans, manage compounder watchlists, and later plug in a deeper stock-scoring system."
|
| 451 |
+
),
|
| 452 |
+
inline=False,
|
| 453 |
+
)
|
| 454 |
+
embed.set_footer(text=f"Use /instructions or !instructions to see the full command list.")
|
| 455 |
+
return embed
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
def instructions_embed() -> discord.Embed:
|
| 459 |
+
embed = discord.Embed(
|
| 460 |
+
title=f"{BOT_NAME} Instructions",
|
| 461 |
+
description="Every major command and what it does.",
|
| 462 |
+
color=discord.Color.blurple(),
|
| 463 |
+
timestamp=datetime.now(timezone.utc),
|
| 464 |
+
)
|
| 465 |
+
cmd_text = (
|
| 466 |
+
"**Public commands**\n"
|
| 467 |
+
"`/welcome` or `!welcome` — Explain what Kasper is, its goal, and major features.\n"
|
| 468 |
+
"`/instructions` or `!instructions` — Show the full command guide.\n"
|
| 469 |
+
"`/role` or `!role` — Post a button that lets a user toggle the **Kasper** alert role.\n"
|
| 470 |
+
"`/calculator` or `!calculator` — Open clean calculator buttons for projections and target planning.\n"
|
| 471 |
+
"`/project` or `!project` — Quick projection command using phase inputs.\n"
|
| 472 |
+
"`/targetcalc` or `!targetcalc` — Calculate required monthly contribution to hit a goal.\n"
|
| 473 |
+
"`/listwatch` or `!listwatch` — Show the current server watchlist.\n"
|
| 474 |
+
"`/serverinfo` or `!serverinfo` — Show alert channel, role, and scanner status for this server.\n\n"
|
| 475 |
+
"**Watchlist commands**\n"
|
| 476 |
+
"`/addwatch <ticker>` or `!addwatch <ticker>` — Add a ticker to this server's watchlist.\n"
|
| 477 |
+
"`/removewatch <ticker>` or `!removewatch <ticker>` — Remove a ticker from the watchlist.\n\n"
|
| 478 |
+
"**Admin commands**\n"
|
| 479 |
+
"`/setalertchannel <channel>` or `!setalertchannel #channel` — Choose where alerts should be posted.\n"
|
| 480 |
+
"`/setalertroleid <role_id>` or `!setalertroleid <role_id>` — Set the server's alert role by ID.\n"
|
| 481 |
+
"`/setadminroles <ids>` or `!setadminroles 123,456` — Define which roles may use Kasper admin commands.\n"
|
| 482 |
+
"`/setscanner <on|off>` or `!setscanner on/off` — Enable or disable the placeholder scanner loop for this server.\n"
|
| 483 |
+
"`/testalert` or `!testalert` — Send a sample alert to the configured alert channel.\n"
|
| 484 |
+
"`/sync` or `!sync` — Force slash-command sync (admin only).\n"
|
| 485 |
+
)
|
| 486 |
+
embed.add_field(name="Commands", value=cmd_text[:1024], inline=False)
|
| 487 |
+
embed.add_field(
|
| 488 |
+
name="Role Button Behavior",
|
| 489 |
+
value=(
|
| 490 |
+
"The role button privately confirms whether the **Kasper** role was added or removed. "
|
| 491 |
+
"It does not spam the alert channel."
|
| 492 |
+
),
|
| 493 |
+
inline=False,
|
| 494 |
+
)
|
| 495 |
+
embed.add_field(
|
| 496 |
+
name="Multi-server behavior",
|
| 497 |
+
value=(
|
| 498 |
+
"Kasper keeps per-server config in local JSON storage. The shared role name stays **Kasper**, but each server can set its own role ID and alert channel."
|
| 499 |
+
),
|
| 500 |
+
inline=False,
|
| 501 |
+
)
|
| 502 |
+
embed.set_footer(text="Use /calculator for the clean modal UI.")
|
| 503 |
+
return embed
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
def role_embed(guild: discord.Guild) -> discord.Embed:
|
| 507 |
+
cfg = get_guild_config(guild.id)
|
| 508 |
+
embed = discord.Embed(
|
| 509 |
+
title=f"{BOT_NAME} Alerts Role",
|
| 510 |
+
description=(
|
| 511 |
+
f"Press the button below to add or remove the **{cfg.get('role_name', DEFAULT_ROLE_NAME)}** role for this server.\n"
|
| 512 |
+
"This role is intended for alert pings and notification opt-in."
|
| 513 |
+
),
|
| 514 |
+
color=discord.Color.purple(),
|
| 515 |
+
)
|
| 516 |
+
return embed
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
def server_info_embed(guild: discord.Guild) -> discord.Embed:
|
| 520 |
+
cfg = get_guild_config(guild.id)
|
| 521 |
+
alert_channel = find_alert_channel(guild, cfg)
|
| 522 |
+
alert_role = find_alert_role(guild, cfg)
|
| 523 |
+
embed = discord.Embed(title=f"{BOT_NAME} Server Info", color=discord.Color.teal())
|
| 524 |
+
embed.add_field(name="Alert channel", value=alert_channel.mention if alert_channel else "Not configured", inline=False)
|
| 525 |
+
embed.add_field(name="Alert role", value=alert_role.mention if alert_role else f"Not found ({cfg.get('role_name', DEFAULT_ROLE_NAME)})", inline=False)
|
| 526 |
+
embed.add_field(name="Admin role IDs", value=", ".join(map(str, cfg.get("admin_role_ids", []))) or "None", inline=False)
|
| 527 |
+
embed.add_field(name="Scanner enabled", value=str(cfg.get("scanner_enabled", True)), inline=False)
|
| 528 |
+
return embed
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
def watchlist_embed(guild: discord.Guild) -> discord.Embed:
|
| 532 |
+
tickers = get_watchlist(guild.id)
|
| 533 |
+
embed = discord.Embed(title=f"{BOT_NAME} Watchlist", color=discord.Color.orange())
|
| 534 |
+
if tickers:
|
| 535 |
+
embed.description = "\n".join(f"• {t}" for t in sorted(tickers))
|
| 536 |
+
else:
|
| 537 |
+
embed.description = "This server watchlist is empty. Add names with `/addwatch` or `!addwatch`."
|
| 538 |
+
return embed
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
# =========================
|
| 542 |
+
# Common response helper
|
| 543 |
+
# =========================
|
| 544 |
+
async def respond(ctx: commands.Context, *, embed: Optional[discord.Embed] = None, content: Optional[str] = None, view: Optional[discord.ui.View] = None, ephemeral: bool = False):
|
| 545 |
+
if ctx.interaction:
|
| 546 |
+
if ctx.interaction.response.is_done():
|
| 547 |
+
return await ctx.interaction.followup.send(content=content, embed=embed, view=view, ephemeral=ephemeral)
|
| 548 |
+
return await ctx.interaction.response.send_message(content=content, embed=embed, view=view, ephemeral=ephemeral)
|
| 549 |
+
return await ctx.send(content=content, embed=embed, view=view)
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
# =========================
|
| 553 |
+
# Events
|
| 554 |
+
# =========================
|
| 555 |
+
@bot.event
|
| 556 |
+
async def on_ready() -> None:
|
| 557 |
+
bot.add_view(RoleToggleView())
|
| 558 |
+
if not daily_scanner.is_running():
|
| 559 |
+
daily_scanner.start()
|
| 560 |
+
log.info("%s is ready as %s (%s)", BOT_NAME, bot.user, bot.user.id if bot.user else "unknown")
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
# =========================
|
| 564 |
+
# Public commands
|
| 565 |
+
# =========================
|
| 566 |
+
@bot.hybrid_command(name="welcome", description="Show what Kasper does and the bot's goal.")
|
| 567 |
+
async def welcome(ctx: commands.Context) -> None:
|
| 568 |
+
await respond(ctx, embed=welcome_embed())
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
@bot.hybrid_command(name="instructions", description="Show the full Kasper command guide.")
|
| 572 |
+
async def instructions(ctx: commands.Context) -> None:
|
| 573 |
+
await respond(ctx, embed=instructions_embed())
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
@bot.hybrid_command(name="role", description="Post the Kasper self-role toggle button.")
|
| 577 |
+
async def role(ctx: commands.Context) -> None:
|
| 578 |
+
if not ctx.guild:
|
| 579 |
+
await respond(ctx, content="Use this command inside a server.")
|
| 580 |
+
return
|
| 581 |
+
await respond(ctx, embed=role_embed(ctx.guild), view=RoleToggleView())
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
@bot.hybrid_command(name="serverinfo", description="Show Kasper config details for this server.")
|
| 585 |
+
async def serverinfo(ctx: commands.Context) -> None:
|
| 586 |
+
if not ctx.guild:
|
| 587 |
+
await respond(ctx, content="Use this command inside a server.")
|
| 588 |
+
return
|
| 589 |
+
await respond(ctx, embed=server_info_embed(ctx.guild))
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
@bot.hybrid_command(name="calculator", description="Open the clean modal-based calculator UI.")
|
| 593 |
+
async def calculator(ctx: commands.Context) -> None:
|
| 594 |
+
embed = discord.Embed(
|
| 595 |
+
title=f"{BOT_NAME} Calculator Hub",
|
| 596 |
+
description="Use the buttons below to open a cleaner input UI.",
|
| 597 |
+
color=discord.Color.blurple(),
|
| 598 |
+
)
|
| 599 |
+
await respond(ctx, embed=embed, view=CalculatorHubView(), ephemeral=True)
|
| 600 |
+
|
| 601 |
+
|
| 602 |
+
@bot.hybrid_command(name="project", description="Run a quick phased portfolio projection.")
|
| 603 |
+
@app_commands.describe(
|
| 604 |
+
initial="Initial investment",
|
| 605 |
+
phase1_years="Years in phase 1",
|
| 606 |
+
phase1_monthly="Monthly contribution in phase 1",
|
| 607 |
+
phase2_years="Years in phase 2",
|
| 608 |
+
phase2_monthly="Monthly contribution in phase 2",
|
| 609 |
+
phase2_lump="Lump sum added at the start of phase 2",
|
| 610 |
+
returns="Return scenarios as percentages separated by commas, e.g. 10,14,18",
|
| 611 |
+
)
|
| 612 |
+
async def project(
|
| 613 |
+
ctx: commands.Context,
|
| 614 |
+
initial: float,
|
| 615 |
+
phase1_years: int,
|
| 616 |
+
phase1_monthly: float,
|
| 617 |
+
phase2_years: Optional[int] = 0,
|
| 618 |
+
phase2_monthly: Optional[float] = 0.0,
|
| 619 |
+
phase2_lump: Optional[float] = 0.0,
|
| 620 |
+
returns: Optional[str] = "10,14,18",
|
| 621 |
+
) -> None:
|
| 622 |
+
phases = [Phase(years=phase1_years, monthly_contribution=phase1_monthly, label="Phase 1")]
|
| 623 |
+
if phase2_years and phase2_years > 0:
|
| 624 |
+
phases.append(Phase(years=phase2_years, monthly_contribution=phase2_monthly or 0.0, lump_sum_at_start=phase2_lump or 0.0, label="Phase 2"))
|
| 625 |
+
embed = build_projection_embed(initial, phases, parse_returns(returns or "10,14,18"))
|
| 626 |
+
await respond(ctx, embed=embed)
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
@bot.hybrid_command(name="targetcalc", description="Calculate required monthly contribution to hit a target.")
|
| 630 |
+
@app_commands.describe(
|
| 631 |
+
target="Target portfolio value",
|
| 632 |
+
years="Years to invest",
|
| 633 |
+
initial="Initial investment",
|
| 634 |
+
returns="Return scenarios as percentages separated by commas, e.g. 10,14,18",
|
| 635 |
+
)
|
| 636 |
+
async def targetcalc(
|
| 637 |
+
ctx: commands.Context,
|
| 638 |
+
target: float,
|
| 639 |
+
years: int,
|
| 640 |
+
initial: Optional[float] = 0.0,
|
| 641 |
+
returns: Optional[str] = "10,14,18",
|
| 642 |
+
) -> None:
|
| 643 |
+
embed = build_target_embed(target, years, initial or 0.0, parse_returns(returns or "10,14,18"))
|
| 644 |
+
await respond(ctx, embed=embed)
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
@bot.hybrid_command(name="addwatch", description="Add a ticker to this server's watchlist.")
|
| 648 |
+
async def addwatch(ctx: commands.Context, ticker: str) -> None:
|
| 649 |
+
if not ctx.guild:
|
| 650 |
+
await respond(ctx, content="Use this command inside a server.")
|
| 651 |
+
return
|
| 652 |
+
ticker = ticker.upper().strip()
|
| 653 |
+
wl = get_watchlist(ctx.guild.id)
|
| 654 |
+
if ticker not in wl:
|
| 655 |
+
wl.append(ticker)
|
| 656 |
+
wl.sort()
|
| 657 |
+
save_all()
|
| 658 |
+
await respond(ctx, embed=watchlist_embed(ctx.guild))
|
| 659 |
+
|
| 660 |
+
|
| 661 |
+
@bot.hybrid_command(name="removewatch", description="Remove a ticker from this server's watchlist.")
|
| 662 |
+
async def removewatch(ctx: commands.Context, ticker: str) -> None:
|
| 663 |
+
if not ctx.guild:
|
| 664 |
+
await respond(ctx, content="Use this command inside a server.")
|
| 665 |
+
return
|
| 666 |
+
ticker = ticker.upper().strip()
|
| 667 |
+
wl = get_watchlist(ctx.guild.id)
|
| 668 |
+
if ticker in wl:
|
| 669 |
+
wl.remove(ticker)
|
| 670 |
+
save_all()
|
| 671 |
+
await respond(ctx, embed=watchlist_embed(ctx.guild))
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
@bot.hybrid_command(name="listwatch", description="Show the current server watchlist.")
|
| 675 |
+
async def listwatch(ctx: commands.Context) -> None:
|
| 676 |
+
if not ctx.guild:
|
| 677 |
+
await respond(ctx, content="Use this command inside a server.")
|
| 678 |
+
return
|
| 679 |
+
await respond(ctx, embed=watchlist_embed(ctx.guild))
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
# =========================
|
| 683 |
+
# Admin commands
|
| 684 |
+
# =========================
|
| 685 |
+
@bot.hybrid_command(name="setalertchannel", description="Set the channel where Kasper posts alerts.")
|
| 686 |
+
async def setalertchannel(ctx: commands.Context, channel: discord.TextChannel) -> None:
|
| 687 |
+
if not ctx.guild or not isinstance(ctx.author, discord.Member):
|
| 688 |
+
await respond(ctx, content="Use this command inside a server.")
|
| 689 |
+
return
|
| 690 |
+
cfg = get_guild_config(ctx.guild.id)
|
| 691 |
+
if not user_is_adminish(ctx.author, cfg):
|
| 692 |
+
await respond(ctx, content="You do not have permission to use this command.", ephemeral=True)
|
| 693 |
+
return
|
| 694 |
+
cfg["alert_channel_id"] = channel.id
|
| 695 |
+
save_all()
|
| 696 |
+
await respond(ctx, content=f"Alert channel set to {channel.mention}.")
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
@bot.hybrid_command(name="setalertroleid", description="Set the Kasper alert role by role ID.")
|
| 700 |
+
async def setalertroleid(ctx: commands.Context, role_id: str) -> None:
|
| 701 |
+
if not ctx.guild or not isinstance(ctx.author, discord.Member):
|
| 702 |
+
await respond(ctx, content="Use this command inside a server.")
|
| 703 |
+
return
|
| 704 |
+
cfg = get_guild_config(ctx.guild.id)
|
| 705 |
+
if not user_is_adminish(ctx.author, cfg):
|
| 706 |
+
await respond(ctx, content="You do not have permission to use this command.", ephemeral=True)
|
| 707 |
+
return
|
| 708 |
+
if not role_id.isdigit():
|
| 709 |
+
await respond(ctx, content="Role ID must be numeric.", ephemeral=True)
|
| 710 |
+
return
|
| 711 |
+
cfg["alert_role_id"] = int(role_id)
|
| 712 |
+
save_all()
|
| 713 |
+
role = ctx.guild.get_role(int(role_id))
|
| 714 |
+
await respond(ctx, content=f"Alert role ID saved. Resolved role: {role.mention if role else 'not currently found'}." )
|
| 715 |
+
|
| 716 |
+
|
| 717 |
+
@bot.hybrid_command(name="setadminroles", description="Set comma-separated role IDs allowed to use Kasper admin commands.")
|
| 718 |
+
async def setadminroles(ctx: commands.Context, role_ids: str) -> None:
|
| 719 |
+
if not ctx.guild or not isinstance(ctx.author, discord.Member):
|
| 720 |
+
await respond(ctx, content="Use this command inside a server.")
|
| 721 |
+
return
|
| 722 |
+
cfg = get_guild_config(ctx.guild.id)
|
| 723 |
+
if not (ctx.author.guild_permissions.administrator or user_is_adminish(ctx.author, cfg)):
|
| 724 |
+
await respond(ctx, content="You do not have permission to use this command.", ephemeral=True)
|
| 725 |
+
return
|
| 726 |
+
parsed = []
|
| 727 |
+
for item in role_ids.split(","):
|
| 728 |
+
item = item.strip()
|
| 729 |
+
if item:
|
| 730 |
+
if not item.isdigit():
|
| 731 |
+
await respond(ctx, content=f"Invalid role ID: {item}", ephemeral=True)
|
| 732 |
+
return
|
| 733 |
+
parsed.append(int(item))
|
| 734 |
+
cfg["admin_role_ids"] = parsed
|
| 735 |
+
save_all()
|
| 736 |
+
await respond(ctx, content=f"Admin role IDs updated: {', '.join(map(str, parsed)) or 'None'}")
|
| 737 |
+
|
| 738 |
+
|
| 739 |
+
@bot.hybrid_command(name="setscanner", description="Enable or disable the placeholder scanner loop for this server.")
|
| 740 |
+
async def setscanner(ctx: commands.Context, state: str) -> None:
|
| 741 |
+
if not ctx.guild or not isinstance(ctx.author, discord.Member):
|
| 742 |
+
await respond(ctx, content="Use this command inside a server.")
|
| 743 |
+
return
|
| 744 |
+
cfg = get_guild_config(ctx.guild.id)
|
| 745 |
+
if not user_is_adminish(ctx.author, cfg):
|
| 746 |
+
await respond(ctx, content="You do not have permission to use this command.", ephemeral=True)
|
| 747 |
+
return
|
| 748 |
+
state = state.lower().strip()
|
| 749 |
+
if state not in {"on", "off"}:
|
| 750 |
+
await respond(ctx, content="Use `on` or `off`.", ephemeral=True)
|
| 751 |
+
return
|
| 752 |
+
cfg["scanner_enabled"] = state == "on"
|
| 753 |
+
save_all()
|
| 754 |
+
await respond(ctx, content=f"Scanner enabled: {cfg['scanner_enabled']}")
|
| 755 |
+
|
| 756 |
+
|
| 757 |
+
@bot.hybrid_command(name="testalert", description="Send a sample alert to the configured alert channel.")
|
| 758 |
+
async def testalert(ctx: commands.Context) -> None:
|
| 759 |
+
if not ctx.guild or not isinstance(ctx.author, discord.Member):
|
| 760 |
+
await respond(ctx, content="Use this command inside a server.")
|
| 761 |
+
return
|
| 762 |
+
cfg = get_guild_config(ctx.guild.id)
|
| 763 |
+
if not user_is_adminish(ctx.author, cfg):
|
| 764 |
+
await respond(ctx, content="You do not have permission to use this command.", ephemeral=True)
|
| 765 |
+
return
|
| 766 |
+
channel = find_alert_channel(ctx.guild, cfg)
|
| 767 |
+
role = find_alert_role(ctx.guild, cfg)
|
| 768 |
+
if not channel:
|
| 769 |
+
await respond(ctx, content="Alert channel is not configured.", ephemeral=True)
|
| 770 |
+
return
|
| 771 |
+
embed = discord.Embed(
|
| 772 |
+
title=f"{BOT_NAME} Test Alert",
|
| 773 |
+
description="This is a sample alert message from Kasper.",
|
| 774 |
+
color=discord.Color.red(),
|
| 775 |
+
timestamp=datetime.now(timezone.utc),
|
| 776 |
+
)
|
| 777 |
+
embed.add_field(name="Why this exists", value="Use this to verify channel and role setup.", inline=False)
|
| 778 |
+
mention = role.mention if role else ""
|
| 779 |
+
await channel.send(content=mention or None, embed=embed)
|
| 780 |
+
await respond(ctx, content=f"Sent test alert to {channel.mention}.")
|
| 781 |
+
|
| 782 |
+
|
| 783 |
+
@bot.hybrid_command(name="sync", description="Force a slash-command sync.")
|
| 784 |
+
async def sync(ctx: commands.Context) -> None:
|
| 785 |
+
if not ctx.guild or not isinstance(ctx.author, discord.Member):
|
| 786 |
+
await respond(ctx, content="Use this command inside a server.")
|
| 787 |
+
return
|
| 788 |
+
cfg = get_guild_config(ctx.guild.id)
|
| 789 |
+
if not user_is_adminish(ctx.author, cfg):
|
| 790 |
+
await respond(ctx, content="You do not have permission to use this command.", ephemeral=True)
|
| 791 |
+
return
|
| 792 |
+
synced = await bot.tree.sync()
|
| 793 |
+
await respond(ctx, content=f"Synced {len(synced)} slash commands.")
|
| 794 |
+
|
| 795 |
+
|
| 796 |
+
# =========================
|
| 797 |
+
# Placeholder scanner
|
| 798 |
+
# =========================
|
| 799 |
+
async def post_scan_alert(guild: discord.Guild, tickers: List[str]) -> None:
|
| 800 |
+
cfg = get_guild_config(guild.id)
|
| 801 |
+
channel = find_alert_channel(guild, cfg)
|
| 802 |
+
if not channel:
|
| 803 |
+
return
|
| 804 |
+
role = find_alert_role(guild, cfg)
|
| 805 |
+
|
| 806 |
+
embed = discord.Embed(
|
| 807 |
+
title=f"{BOT_NAME} Daily Watchlist Scan",
|
| 808 |
+
description="Placeholder scan result. Replace this section with real free-data stock logic later.",
|
| 809 |
+
color=discord.Color.orange(),
|
| 810 |
+
timestamp=datetime.now(timezone.utc),
|
| 811 |
+
)
|
| 812 |
+
embed.add_field(name="Watchlist checked", value=", ".join(tickers[:50]) if tickers else "No tickers", inline=False)
|
| 813 |
+
embed.add_field(
|
| 814 |
+
name="Next step",
|
| 815 |
+
value="Swap in your real SEC + price-data scoring pipeline here while keeping the same alert shell.",
|
| 816 |
+
inline=False,
|
| 817 |
+
)
|
| 818 |
+
mention = role.mention if role else None
|
| 819 |
+
await channel.send(content=mention, embed=embed)
|
| 820 |
+
alert_log.append({
|
| 821 |
+
"guild_id": guild.id,
|
| 822 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 823 |
+
"type": "daily_placeholder_scan",
|
| 824 |
+
"tickers": tickers,
|
| 825 |
+
})
|
| 826 |
+
save_all()
|
| 827 |
+
|
| 828 |
+
|
| 829 |
+
@tasks.loop(hours=24)
|
| 830 |
+
async def daily_scanner() -> None:
|
| 831 |
+
await bot.wait_until_ready()
|
| 832 |
+
for guild in bot.guilds:
|
| 833 |
+
cfg = get_guild_config(guild.id)
|
| 834 |
+
if not cfg.get("scanner_enabled", True):
|
| 835 |
+
continue
|
| 836 |
+
tickers = get_watchlist(guild.id)
|
| 837 |
+
if not tickers:
|
| 838 |
+
continue
|
| 839 |
+
try:
|
| 840 |
+
await post_scan_alert(guild, tickers)
|
| 841 |
+
cfg["last_scanner_run"] = datetime.now(timezone.utc).isoformat()
|
| 842 |
+
save_all()
|
| 843 |
+
except Exception as exc:
|
| 844 |
+
log.exception("Scanner failed for guild %s: %s", guild.id, exc)
|
| 845 |
+
|
| 846 |
+
|
| 847 |
+
# =========================
|
| 848 |
+
# Error handling
|
| 849 |
+
# =========================
|
| 850 |
+
@bot.event
|
| 851 |
+
async def on_command_error(ctx: commands.Context, error: commands.CommandError) -> None:
|
| 852 |
+
if isinstance(error, commands.CommandNotFound):
|
| 853 |
+
return
|
| 854 |
+
if isinstance(error, commands.MissingRequiredArgument):
|
| 855 |
+
await ctx.send(f"Missing argument: {error.param.name}")
|
| 856 |
+
return
|
| 857 |
+
log.exception("Command error: %s", error)
|
| 858 |
+
try:
|
| 859 |
+
await ctx.send(f"Something went wrong: {error}")
|
| 860 |
+
except Exception:
|
| 861 |
+
pass
|
| 862 |
+
|
| 863 |
+
|
| 864 |
+
# =========================
|
| 865 |
+
# Startup checks / run
|
| 866 |
+
# =========================
|
| 867 |
+
if __name__ == "__main__":
|
| 868 |
+
if not TOKEN or TOKEN == "PASTE_BOT_TOKEN_HERE":
|
| 869 |
+
raise SystemExit("Set DISCORD_BOT_TOKEN in .env or paste it into bot.py before running.")
|
| 870 |
+
if APPLICATION_ID is None:
|
| 871 |
+
raise SystemExit("Set a numeric DISCORD_APPLICATION_ID in .env or bot.py before running.")
|
| 872 |
+
save_all()
|
| 873 |
+
bot.run(TOKEN)
|
requirements.txt
CHANGED
|
@@ -1,3 +1,4 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
streamlit
|
|
|
|
|
|
| 1 |
+
discord.py>=2.4.0
|
| 2 |
+
python-dotenv>=1.0.1
|
| 3 |
+
streamlit>=1.37.0
|
| 4 |
+
pandas>=2.2.2
|
streamlit_app.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Kasper Streamlit Companion App
|
| 4 |
+
|
| 5 |
+
Dashboard for:
|
| 6 |
+
- Welcome / architecture notes
|
| 7 |
+
- Projection calculator
|
| 8 |
+
- Target contribution calculator
|
| 9 |
+
- Watchlist editing against the same local JSON files the Discord bot uses
|
| 10 |
+
- Server config viewer
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Any, Dict, List, Tuple
|
| 18 |
+
|
| 19 |
+
import pandas as pd
|
| 20 |
+
import streamlit as st
|
| 21 |
+
|
| 22 |
+
BOT_NAME = "Kasper"
|
| 23 |
+
DATA_DIR = Path("./kasper_data")
|
| 24 |
+
CONFIG_PATH = DATA_DIR / "guild_config.json"
|
| 25 |
+
WATCHLIST_PATH = DATA_DIR / "watchlists.json"
|
| 26 |
+
ALERT_LOG_PATH = DATA_DIR / "alert_log.json"
|
| 27 |
+
|
| 28 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def load_json(path: Path, default: Any) -> Any:
|
| 32 |
+
if not path.exists():
|
| 33 |
+
return default
|
| 34 |
+
try:
|
| 35 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 36 |
+
except Exception:
|
| 37 |
+
return default
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def save_json(path: Path, data: Any) -> None:
|
| 41 |
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def fmt_money(v: float) -> str:
|
| 45 |
+
return f"${v:,.2f}"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def fmt_pct(v: float) -> str:
|
| 49 |
+
return f"{v*100:.2f}%"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@dataclass
|
| 53 |
+
class Phase:
|
| 54 |
+
years: int
|
| 55 |
+
monthly_contribution: float
|
| 56 |
+
lump_sum_at_start: float = 0.0
|
| 57 |
+
label: str = ""
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def simulate_portfolio(initial_investment: float, phases: List[Phase], annual_return: float):
|
| 61 |
+
monthly_rate = (1 + annual_return) ** (1 / 12) - 1
|
| 62 |
+
balance = float(initial_investment)
|
| 63 |
+
total_contributed = float(initial_investment)
|
| 64 |
+
annual_snapshots: List[Tuple[int, float]] = []
|
| 65 |
+
current_year = 0
|
| 66 |
+
for phase in phases:
|
| 67 |
+
balance += phase.lump_sum_at_start
|
| 68 |
+
total_contributed += phase.lump_sum_at_start
|
| 69 |
+
for month_idx in range(1, phase.years * 12 + 1):
|
| 70 |
+
balance += phase.monthly_contribution
|
| 71 |
+
total_contributed += phase.monthly_contribution
|
| 72 |
+
balance *= 1 + monthly_rate
|
| 73 |
+
if month_idx % 12 == 0:
|
| 74 |
+
current_year += 1
|
| 75 |
+
annual_snapshots.append((current_year, balance))
|
| 76 |
+
return {
|
| 77 |
+
"annual_return": annual_return,
|
| 78 |
+
"ending_value": balance,
|
| 79 |
+
"total_contributed": total_contributed,
|
| 80 |
+
"profit": balance - total_contributed,
|
| 81 |
+
"annual_snapshots": annual_snapshots,
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def future_value_constant_monthly(initial_investment: float, monthly_contribution: float, years: int, annual_return: float) -> float:
|
| 86 |
+
monthly_rate = (1 + annual_return) ** (1 / 12) - 1
|
| 87 |
+
balance = initial_investment
|
| 88 |
+
for _ in range(years * 12):
|
| 89 |
+
balance += monthly_contribution
|
| 90 |
+
balance *= 1 + monthly_rate
|
| 91 |
+
return balance
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def required_monthly_contribution(target_value: float, years: int, annual_return: float, initial_investment: float = 0.0) -> float:
|
| 95 |
+
low, high = 0.0, max(target_value, 1.0)
|
| 96 |
+
for _ in range(200):
|
| 97 |
+
mid = (low + high) / 2
|
| 98 |
+
fv = future_value_constant_monthly(initial_investment, mid, years, annual_return)
|
| 99 |
+
if fv >= target_value:
|
| 100 |
+
high = mid
|
| 101 |
+
else:
|
| 102 |
+
low = mid
|
| 103 |
+
return high
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
st.set_page_config(page_title=f"{BOT_NAME} Dashboard", layout="wide")
|
| 107 |
+
st.title(f"{BOT_NAME} Streamlit Dashboard")
|
| 108 |
+
|
| 109 |
+
welcome_tab, projection_tab, target_tab, watchlist_tab, config_tab = st.tabs([
|
| 110 |
+
"Welcome", "Projection Calculator", "Target Calculator", "Watchlists", "Server Config"
|
| 111 |
+
])
|
| 112 |
+
|
| 113 |
+
with welcome_tab:
|
| 114 |
+
st.subheader("What Kasper does")
|
| 115 |
+
st.write(
|
| 116 |
+
"Kasper is a Discord-first long-term investing companion focused on structured retirement scenario planning, "
|
| 117 |
+
"watchlists, and future stock-alert workflows."
|
| 118 |
+
)
|
| 119 |
+
st.markdown(
|
| 120 |
+
"""
|
| 121 |
+
**Best split of responsibilities**
|
| 122 |
+
|
| 123 |
+
- **Discord bot:** alerts, commands, role toggle, quick-use tools
|
| 124 |
+
- **Streamlit UI:** deeper calculators, watchlist editing, dashboards, richer tables
|
| 125 |
+
|
| 126 |
+
**Current companion app features**
|
| 127 |
+
- Phased portfolio projection
|
| 128 |
+
- Required monthly contribution calculator
|
| 129 |
+
- Watchlist editor backed by local JSON storage
|
| 130 |
+
- Config viewer for per-server bot settings
|
| 131 |
+
"""
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
with projection_tab:
|
| 135 |
+
st.subheader("Projection Calculator")
|
| 136 |
+
c1, c2, c3 = st.columns(3)
|
| 137 |
+
with c1:
|
| 138 |
+
initial = st.number_input("Initial investment", min_value=0.0, value=1000.0, step=100.0)
|
| 139 |
+
r1 = st.number_input("Return scenario 1 (%)", min_value=0.0, value=10.0, step=0.5)
|
| 140 |
+
with c2:
|
| 141 |
+
r2 = st.number_input("Return scenario 2 (%)", min_value=0.0, value=14.0, step=0.5)
|
| 142 |
+
with c3:
|
| 143 |
+
r3 = st.number_input("Return scenario 3 (%)", min_value=0.0, value=18.0, step=0.5)
|
| 144 |
+
|
| 145 |
+
st.markdown("### Phases")
|
| 146 |
+
p1c1, p1c2, p1c3 = st.columns(3)
|
| 147 |
+
with p1c1:
|
| 148 |
+
p1_years = st.number_input("Phase 1 years", min_value=0, value=5, step=1)
|
| 149 |
+
with p1c2:
|
| 150 |
+
p1_monthly = st.number_input("Phase 1 monthly", min_value=0.0, value=250.0, step=25.0)
|
| 151 |
+
with p1c3:
|
| 152 |
+
p1_lump = st.number_input("Phase 1 lump at start", min_value=0.0, value=0.0, step=100.0)
|
| 153 |
+
|
| 154 |
+
p2_enabled = st.checkbox("Enable phase 2", value=True)
|
| 155 |
+
phases = [Phase(years=int(p1_years), monthly_contribution=float(p1_monthly), lump_sum_at_start=float(p1_lump), label="Phase 1")]
|
| 156 |
+
if p2_enabled:
|
| 157 |
+
p2c1, p2c2, p2c3 = st.columns(3)
|
| 158 |
+
with p2c1:
|
| 159 |
+
p2_years = st.number_input("Phase 2 years", min_value=0, value=5, step=1)
|
| 160 |
+
with p2c2:
|
| 161 |
+
p2_monthly = st.number_input("Phase 2 monthly", min_value=0.0, value=500.0, step=25.0)
|
| 162 |
+
with p2c3:
|
| 163 |
+
p2_lump = st.number_input("Phase 2 lump at start", min_value=0.0, value=0.0, step=100.0)
|
| 164 |
+
phases.append(Phase(years=int(p2_years), monthly_contribution=float(p2_monthly), lump_sum_at_start=float(p2_lump), label="Phase 2"))
|
| 165 |
+
|
| 166 |
+
if st.button("Run Projection"):
|
| 167 |
+
rows = []
|
| 168 |
+
for r in [r1 / 100, r2 / 100, r3 / 100]:
|
| 169 |
+
result = simulate_portfolio(initial, phases, r)
|
| 170 |
+
rows.append({
|
| 171 |
+
"Annual Return": fmt_pct(r),
|
| 172 |
+
"Ending Value": fmt_money(result["ending_value"]),
|
| 173 |
+
"Contributed": fmt_money(result["total_contributed"]),
|
| 174 |
+
"Profit": fmt_money(result["profit"]),
|
| 175 |
+
"Gain vs Contributions": fmt_pct(result["profit"] / result["total_contributed"] if result["total_contributed"] else 0),
|
| 176 |
+
})
|
| 177 |
+
st.dataframe(pd.DataFrame(rows), use_container_width=True)
|
| 178 |
+
|
| 179 |
+
with target_tab:
|
| 180 |
+
st.subheader("Target Contribution Calculator")
|
| 181 |
+
tc1, tc2, tc3 = st.columns(3)
|
| 182 |
+
with tc1:
|
| 183 |
+
target = st.number_input("Target portfolio value", min_value=1.0, value=2000000.0, step=10000.0)
|
| 184 |
+
with tc2:
|
| 185 |
+
years = st.number_input("Years to invest", min_value=1, value=30, step=1)
|
| 186 |
+
with tc3:
|
| 187 |
+
initial_target = st.number_input("Initial investment", min_value=0.0, value=0.0, step=100.0)
|
| 188 |
+
|
| 189 |
+
tr1, tr2, tr3 = st.columns(3)
|
| 190 |
+
with tr1:
|
| 191 |
+
target_r1 = st.number_input("Return 1 (%)", min_value=0.0, value=10.0, step=0.5, key="target_r1")
|
| 192 |
+
with tr2:
|
| 193 |
+
target_r2 = st.number_input("Return 2 (%)", min_value=0.0, value=14.0, step=0.5, key="target_r2")
|
| 194 |
+
with tr3:
|
| 195 |
+
target_r3 = st.number_input("Return 3 (%)", min_value=0.0, value=18.0, step=0.5, key="target_r3")
|
| 196 |
+
|
| 197 |
+
if st.button("Calculate Required Monthly Contribution"):
|
| 198 |
+
rows = []
|
| 199 |
+
for r in [target_r1 / 100, target_r2 / 100, target_r3 / 100]:
|
| 200 |
+
monthly = required_monthly_contribution(target, int(years), r, initial_target)
|
| 201 |
+
rows.append({
|
| 202 |
+
"Annual Return": fmt_pct(r),
|
| 203 |
+
"Required Monthly Contribution": fmt_money(monthly),
|
| 204 |
+
})
|
| 205 |
+
st.dataframe(pd.DataFrame(rows), use_container_width=True)
|
| 206 |
+
|
| 207 |
+
with watchlist_tab:
|
| 208 |
+
st.subheader("Watchlists")
|
| 209 |
+
watchlists = load_json(WATCHLIST_PATH, {})
|
| 210 |
+
guild_id = st.text_input("Guild ID", value="")
|
| 211 |
+
if guild_id:
|
| 212 |
+
current = watchlists.get(guild_id, [])
|
| 213 |
+
st.write("Current watchlist:", ", ".join(current) if current else "Empty")
|
| 214 |
+
new_ticker = st.text_input("Ticker to add", value="")
|
| 215 |
+
c1, c2 = st.columns(2)
|
| 216 |
+
with c1:
|
| 217 |
+
if st.button("Add ticker") and new_ticker.strip():
|
| 218 |
+
ticker = new_ticker.upper().strip()
|
| 219 |
+
if ticker not in current:
|
| 220 |
+
current.append(ticker)
|
| 221 |
+
current.sort()
|
| 222 |
+
watchlists[guild_id] = current
|
| 223 |
+
save_json(WATCHLIST_PATH, watchlists)
|
| 224 |
+
st.success(f"Added {ticker}")
|
| 225 |
+
with c2:
|
| 226 |
+
remove_ticker = st.text_input("Ticker to remove", value="", key="remove_ticker")
|
| 227 |
+
if st.button("Remove ticker") and remove_ticker.strip():
|
| 228 |
+
ticker = remove_ticker.upper().strip()
|
| 229 |
+
if ticker in current:
|
| 230 |
+
current.remove(ticker)
|
| 231 |
+
watchlists[guild_id] = current
|
| 232 |
+
save_json(WATCHLIST_PATH, watchlists)
|
| 233 |
+
st.success(f"Removed {ticker}")
|
| 234 |
+
|
| 235 |
+
with config_tab:
|
| 236 |
+
st.subheader("Server Config")
|
| 237 |
+
cfg = load_json(CONFIG_PATH, {})
|
| 238 |
+
alert_log = load_json(ALERT_LOG_PATH, [])
|
| 239 |
+
st.markdown("### Guild Config JSON")
|
| 240 |
+
st.json(cfg)
|
| 241 |
+
st.markdown("### Recent Alert Log")
|
| 242 |
+
st.json(alert_log[-20:])
|