#!/usr/bin/env python3 """ Kasper Streamlit Companion App Dashboard for: - Welcome / architecture notes - Projection calculator - Target contribution calculator - Watchlist editing against the same local JSON files the Discord bot uses - Server config viewer """ from __future__ import annotations import json from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List, Tuple import pandas as pd import streamlit as st BOT_NAME = "Kasper" DATA_DIR = Path("./kasper_data") CONFIG_PATH = DATA_DIR / "guild_config.json" WATCHLIST_PATH = DATA_DIR / "watchlists.json" ALERT_LOG_PATH = DATA_DIR / "alert_log.json" DATA_DIR.mkdir(parents=True, exist_ok=True) def load_json(path: Path, default: Any) -> Any: if not path.exists(): return default try: return json.loads(path.read_text(encoding="utf-8")) except Exception: return default def save_json(path: Path, data: Any) -> None: path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") def fmt_money(v: float) -> str: return f"${v:,.2f}" def fmt_pct(v: float) -> str: return f"{v*100:.2f}%" @dataclass class Phase: years: int monthly_contribution: float lump_sum_at_start: float = 0.0 label: str = "" def simulate_portfolio(initial_investment: float, phases: List[Phase], annual_return: float): monthly_rate = (1 + annual_return) ** (1 / 12) - 1 balance = float(initial_investment) total_contributed = float(initial_investment) annual_snapshots: List[Tuple[int, float]] = [] current_year = 0 for phase in phases: balance += phase.lump_sum_at_start total_contributed += phase.lump_sum_at_start for month_idx in range(1, phase.years * 12 + 1): balance += phase.monthly_contribution total_contributed += phase.monthly_contribution balance *= 1 + monthly_rate if month_idx % 12 == 0: current_year += 1 annual_snapshots.append((current_year, balance)) return { "annual_return": annual_return, "ending_value": balance, "total_contributed": total_contributed, "profit": balance - total_contributed, "annual_snapshots": annual_snapshots, } def future_value_constant_monthly(initial_investment: float, monthly_contribution: float, years: int, annual_return: float) -> float: monthly_rate = (1 + annual_return) ** (1 / 12) - 1 balance = initial_investment for _ in range(years * 12): balance += monthly_contribution balance *= 1 + monthly_rate return balance def required_monthly_contribution(target_value: float, years: int, annual_return: float, initial_investment: float = 0.0) -> float: low, high = 0.0, max(target_value, 1.0) for _ in range(200): mid = (low + high) / 2 fv = future_value_constant_monthly(initial_investment, mid, years, annual_return) if fv >= target_value: high = mid else: low = mid return high st.set_page_config(page_title=f"{BOT_NAME} Dashboard", layout="wide") st.title(f"{BOT_NAME} Streamlit Dashboard") welcome_tab, projection_tab, target_tab, watchlist_tab, config_tab = st.tabs([ "Welcome", "Projection Calculator", "Target Calculator", "Watchlists", "Server Config" ]) with welcome_tab: st.subheader("What Kasper does") st.write( "Kasper is a Discord-first long-term investing companion focused on structured retirement scenario planning, " "watchlists, and future stock-alert workflows." ) st.markdown( """ **Best split of responsibilities** - **Discord bot:** alerts, commands, role toggle, quick-use tools - **Streamlit UI:** deeper calculators, watchlist editing, dashboards, richer tables **Current companion app features** - Phased portfolio projection - Required monthly contribution calculator - Watchlist editor backed by local JSON storage - Config viewer for per-server bot settings """ ) with projection_tab: st.subheader("Projection Calculator") c1, c2, c3 = st.columns(3) with c1: initial = st.number_input("Initial investment", min_value=0.0, value=1000.0, step=100.0) r1 = st.number_input("Return scenario 1 (%)", min_value=0.0, value=10.0, step=0.5) with c2: r2 = st.number_input("Return scenario 2 (%)", min_value=0.0, value=14.0, step=0.5) with c3: r3 = st.number_input("Return scenario 3 (%)", min_value=0.0, value=18.0, step=0.5) st.markdown("### Phases") p1c1, p1c2, p1c3 = st.columns(3) with p1c1: p1_years = st.number_input("Phase 1 years", min_value=0, value=5, step=1) with p1c2: p1_monthly = st.number_input("Phase 1 monthly", min_value=0.0, value=250.0, step=25.0) with p1c3: p1_lump = st.number_input("Phase 1 lump at start", min_value=0.0, value=0.0, step=100.0) p2_enabled = st.checkbox("Enable phase 2", value=True) phases = [Phase(years=int(p1_years), monthly_contribution=float(p1_monthly), lump_sum_at_start=float(p1_lump), label="Phase 1")] if p2_enabled: p2c1, p2c2, p2c3 = st.columns(3) with p2c1: p2_years = st.number_input("Phase 2 years", min_value=0, value=5, step=1) with p2c2: p2_monthly = st.number_input("Phase 2 monthly", min_value=0.0, value=500.0, step=25.0) with p2c3: p2_lump = st.number_input("Phase 2 lump at start", min_value=0.0, value=0.0, step=100.0) phases.append(Phase(years=int(p2_years), monthly_contribution=float(p2_monthly), lump_sum_at_start=float(p2_lump), label="Phase 2")) if st.button("Run Projection"): rows = [] for r in [r1 / 100, r2 / 100, r3 / 100]: result = simulate_portfolio(initial, phases, r) rows.append({ "Annual Return": fmt_pct(r), "Ending Value": fmt_money(result["ending_value"]), "Contributed": fmt_money(result["total_contributed"]), "Profit": fmt_money(result["profit"]), "Gain vs Contributions": fmt_pct(result["profit"] / result["total_contributed"] if result["total_contributed"] else 0), }) st.dataframe(pd.DataFrame(rows), use_container_width=True) with target_tab: st.subheader("Target Contribution Calculator") tc1, tc2, tc3 = st.columns(3) with tc1: target = st.number_input("Target portfolio value", min_value=1.0, value=2000000.0, step=10000.0) with tc2: years = st.number_input("Years to invest", min_value=1, value=30, step=1) with tc3: initial_target = st.number_input("Initial investment", min_value=0.0, value=0.0, step=100.0) tr1, tr2, tr3 = st.columns(3) with tr1: target_r1 = st.number_input("Return 1 (%)", min_value=0.0, value=10.0, step=0.5, key="target_r1") with tr2: target_r2 = st.number_input("Return 2 (%)", min_value=0.0, value=14.0, step=0.5, key="target_r2") with tr3: target_r3 = st.number_input("Return 3 (%)", min_value=0.0, value=18.0, step=0.5, key="target_r3") if st.button("Calculate Required Monthly Contribution"): rows = [] for r in [target_r1 / 100, target_r2 / 100, target_r3 / 100]: monthly = required_monthly_contribution(target, int(years), r, initial_target) rows.append({ "Annual Return": fmt_pct(r), "Required Monthly Contribution": fmt_money(monthly), }) st.dataframe(pd.DataFrame(rows), use_container_width=True) with watchlist_tab: st.subheader("Watchlists") watchlists = load_json(WATCHLIST_PATH, {}) guild_id = st.text_input("Guild ID", value="") if guild_id: current = watchlists.get(guild_id, []) st.write("Current watchlist:", ", ".join(current) if current else "Empty") new_ticker = st.text_input("Ticker to add", value="") c1, c2 = st.columns(2) with c1: if st.button("Add ticker") and new_ticker.strip(): ticker = new_ticker.upper().strip() if ticker not in current: current.append(ticker) current.sort() watchlists[guild_id] = current save_json(WATCHLIST_PATH, watchlists) st.success(f"Added {ticker}") with c2: remove_ticker = st.text_input("Ticker to remove", value="", key="remove_ticker") if st.button("Remove ticker") and remove_ticker.strip(): ticker = remove_ticker.upper().strip() if ticker in current: current.remove(ticker) watchlists[guild_id] = current save_json(WATCHLIST_PATH, watchlists) st.success(f"Removed {ticker}") with config_tab: st.subheader("Server Config") cfg = load_json(CONFIG_PATH, {}) alert_log = load_json(ALERT_LOG_PATH, []) st.markdown("### Guild Config JSON") st.json(cfg) st.markdown("### Recent Alert Log") st.json(alert_log[-20:])