Spaces:
Sleeping
Sleeping
| #!/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}%" | |
| 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:]) | |