Syntrex commited on
Commit
55ebc85
·
verified ·
1 Parent(s): eeee14e

Upload 5 files

Browse files
Files changed (5) hide show
  1. README.md +113 -19
  2. _.env.example +2 -0
  3. bot.py +873 -0
  4. requirements.txt +4 -3
  5. streamlit_app.py +242 -0
README.md CHANGED
@@ -1,19 +1,113 @@
1
- ---
2
- title: KasperStocks
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: red
6
- sdk: docker
7
- app_port: 8501
8
- tags:
9
- - streamlit
10
- pinned: false
11
- short_description: Kasper Stock Market Discord Bot
12
- ---
13
-
14
- # Welcome to Streamlit!
15
-
16
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
17
-
18
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
19
- forums](https://discuss.streamlit.io).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- altair
2
- pandas
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:])