priority-budget-allocator / src /streamlit_app.py
dvjgenis
user recommendations updated
54ad358
Raw
History Blame Contribute Delete
12.9 kB
import streamlit as st
import pandas as pd
import altair as alt
from datetime import datetime
from decimal import Decimal
import io
# PDF export via ReportLab
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
# ─── Helpers ───────────────────────────────────────────────────────────────────
def normalize_percentages(raw_dict):
cats, vals = list(raw_dict.keys()), list(raw_dict.values())
total = sum(vals)
normalized = {}
if total <= 0:
each = round(100 / len(cats), 2)
for c in cats:
normalized[c] = each
diff = 100 - sum(normalized.values())
normalized[cats[-1]] += diff
else:
cum = 0.0
for i, c in enumerate(cats):
if i < len(cats) - 1:
p = round((raw_dict[c] / total) * 100, 2)
normalized[c] = p
cum += p
else:
normalized[c] = round(100 - cum, 2)
return normalized
def max_to_str(x):
if isinstance(x, (int, float, Decimal)):
return f"${float(x):,.2f}"
return x # e.g. "No Max"
def df_to_pdf(df: pd.DataFrame) -> io.BytesIO:
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=letter)
width, height = letter
x_offset, y_offset = 40, height - 40
line_height = 14
# Header
for i, col in enumerate(df.columns):
c.drawString(x_offset + i*100, y_offset, str(col))
y_offset -= line_height
# Rows
for _, row in df.iterrows():
for i, cell in enumerate(row):
c.drawString(x_offset + i*100, y_offset, str(cell))
y_offset -= line_height
if y_offset < 40:
c.showPage()
y_offset = height - 40
c.save()
buffer.seek(0)
return buffer
# ─── Page setup ────────────────────────────────────────────────────────────────
st.set_page_config(
page_title="Priority Budget Allocator",
page_icon="πŸ’Έ",
layout="wide"
)
st.title("πŸ’Έ Priority Budget Allocator")
st.subheader("We budget for you!")
st.markdown(
"Enter your balances and categories below, then **Generate Budget** to see your allocation. "
"When you’re happy, download your results as **CSV**, **Excel** or **PDF**."
)
# ─── STEP 1: Balances ──────────────────────────────────────────────────────────
with st.expander("Step 1: Account Balances", expanded=True):
num_accounts = st.number_input("How many accounts?", min_value=1, max_value=10, step=1, value=1)
account_balances = [
st.number_input(
f"Account {i+1} balance ($)",
min_value=0.0, format="%.2f", key=f"acct_{i}"
)
for i in range(num_accounts)
]
total_balance = sum(account_balances)
st.success(f"Total Available Balance: **${total_balance:,.2f}**")
# ─── STEP 2: Categories ─────────────────────────────────────────────────────────
invalid_max_min = False
with st.expander("Step 2: Define Spending Categories", expanded=True):
num_categories = st.number_input("How many categories?", min_value=1, max_value=15, step=1, value=1)
categories = []
for i in range(num_categories):
st.subheader(f"Category {i+1}")
name = st.text_input("Name", key=f"name_{i}").strip() or f"Category {i+1}"
minimum = st.number_input("Minimum ($)", min_value=0.0, format="%.2f", key=f"min_{i}")
has_max = st.checkbox("Has a maximum?", key=f"has_max_{i}")
max_amt = None
if has_max:
max_amt = st.number_input("Maximum ($)", min_value=0.0, format="%.2f", key=f"max_{i}")
if max_amt < minimum:
st.warning("⚠️ Maximum < Minimumβ€”please adjust.")
invalid_max_min = True
categories.append({
"Category": name,
"Min": minimum,
"Has_Max": has_max,
"Max": max_amt
})
# ─── Optional: Mandatory Savings ────────────────────────────────────────────────
with st.expander("Optional: Mandatory Savings", expanded=False):
include_savings = st.checkbox("Include a mandatory 'Savings' category?")
if include_savings:
savings_pct = st.number_input(
"Savings (% of total balance)", min_value=0.0, max_value=100.0,
value=10.0, format="%.2f", key="savings_pct"
)
st.info("This will reserve that % before other allocations.")
# ─── STEP 2b: Surplus % for β€œNo Max” ────────────────────────────────────────────
no_max = [c["Category"] for c in categories if not c["Has_Max"]]
raw = {}
if no_max:
with st.expander("Step 2b: % Distribution for β€˜No Max’ Categories"):
st.markdown("They’ll be normalized to sum to 100%.")
for c in no_max:
raw[c] = st.number_input(
f"% for {c}", min_value=0.0, max_value=100.0,
value=round(100/len(no_max), 2), key=f"raw_{c}"
)
# ─── STEP 3: Generate & Display ─────────────────────────────────────────────────
if st.button("πŸ“Š Generate Budget"):
if invalid_max_min:
st.error("❌ Please fix category errors (Max β‰₯ Min) before generating budget.")
else:
# Insert mandatory savings category if requested
if include_savings:
savings_amt = round(total_balance * savings_pct / 100, 2)
categories.insert(0, {
"Category": "Savings",
"Min": savings_amt,
"Has_Max": False,
"Max": None
})
df = pd.DataFrame(categories)
df["Allocation"] = df["Min"].copy()
sum_min = df["Min"].sum()
if total_balance < sum_min:
st.warning("⚠️ Balance < sum of minimumsβ€”allocating proportionally to Min.")
df["Allocation"] = (df["Min"] / sum_min * total_balance).round(2)
else:
remaining = total_balance - sum_min
# fill to Max
for idx, row in df.iterrows():
if row["Has_Max"]:
cap = row["Max"] - row["Min"]
add = min(cap, remaining)
df.at[idx, "Allocation"] += round(add, 2)
remaining -= add
# distribute leftover
if remaining > 0 and no_max:
norm = normalize_percentages(raw)
st.subheader("πŸ”’ Normalized % Distribution")
dist_df = (
pd.DataFrame.from_dict(norm, orient="index", columns=["Pct"])
.rename_axis("Category").reset_index()
)
st.dataframe(dist_df, use_container_width=True)
for idx, row in df.iterrows():
if not row["Has_Max"]:
df.at[idx, "Allocation"] += round(remaining * norm[row["Category"]] / 100, 2)
remaining = 0
df["Surplus/Deficit"] = (df["Allocation"] - df["Min"]).round(2)
# ── Metrics ──────────────────────────────────────────────────────────────
alloc_sum = df["Allocation"].sum()
unalloc = total_balance - alloc_sum
st.header("Key Metrics")
c1, c2, c3 = st.columns(3)
c1.metric("Total Balance", f"${total_balance:,.2f}")
c2.metric("Total Allocated", f"${alloc_sum:,.2f}")
c3.metric(
"Unallocated",
f"${unalloc:,.2f}" if unalloc >= 0 else f"-${abs(unalloc):,.2f}"
)
# ── Explain Terms ─────────────────────────────────────────────────────────
with st.expander("❓ What do Allocation & Surplus/Deficit mean?", expanded=False):
st.markdown(
"- **Allocation**: The dollar amount assigned to each category based on your inputs.\n"
"- **Surplus/Deficit**: Allocation minus your Minimum. A positive number means you have extra above your minimum; negative means you fell short."
)
# ── Display Table ────────────────────────────────────────────────────────
disp = df.copy()
disp["Max"] = disp["Max"].fillna("No Max").apply(max_to_str)
disp["Min"] = disp["Min"].apply(lambda x: f"${x:,.2f}")
disp["Allocation"] = disp["Allocation"].apply(lambda x: f"${x:,.2f}")
disp["Surplus/Deficit"] = disp["Surplus/Deficit"].apply(lambda x: f"${x:,.2f}")
st.header("πŸ“‹ Allocation Breakdown")
st.dataframe(disp, use_container_width=True)
# ── Charts ───────────────────────────────────────────────────────────────
st.header("πŸ“Š Allocation by Category")
chart1 = (
alt.Chart(df)
.mark_bar()
.encode(
x=alt.X("Category:N", sort=None),
y=alt.Y("Allocation:Q", title="Allocated ($)"),
tooltip=[
alt.Tooltip("Category:N"),
alt.Tooltip("Allocation:Q", format="$,.2f"),
alt.Tooltip("Min:Q", format="$,.2f"),
alt.Tooltip("Surplus/Deficit:Q", format="$,.2f"),
]
)
.properties(height=300)
)
st.altair_chart(chart1, use_container_width=True)
st.header("πŸ“ˆ Surplus / Deficit by Category")
sd = df[["Category", "Surplus/Deficit"]]
chart2 = (
alt.Chart(sd)
.mark_bar()
.encode(
x="Category:N",
y=alt.Y("Surplus/Deficit:Q", title="Surplus / Deficit ($)"),
color=alt.condition(
alt.datum["Surplus/Deficit"] >= 0,
alt.value("#4caf50"),
alt.value("#e15759"),
),
)
.properties(height=300)
)
st.altair_chart(chart2, use_container_width=True)
st.header("🍰 Allocation Distribution")
pie_df = df[df["Allocation"] > 0]
chart3 = (
alt.Chart(pie_df)
.mark_arc()
.encode(
theta="Allocation:Q",
color=alt.Color("Category:N", legend=alt.Legend(title="Category")),
tooltip=["Category", "Allocation"],
)
.properties(height=300)
)
st.altair_chart(chart3, use_container_width=True)
# ── Download buttons ─────────────────────────────────────────────────────
csv_data = df.to_csv(index=False).encode("utf-8")
st.download_button(
label="πŸ“₯ Download as CSV",
data=csv_data,
file_name=f"budget_{datetime.now():%Y%m%d_%H%M%S}.csv",
mime="text/csv"
)
to_excel = io.BytesIO()
with pd.ExcelWriter(to_excel, engine="xlsxwriter") as writer:
df.to_excel(writer, index=False, sheet_name="Budget")
to_excel.seek(0)
st.download_button(
label="πŸ“₯ Download as Excel",
data=to_excel.getvalue(),
file_name=f"budget_{datetime.now():%Y%m%d_%H%M%S}.xlsx",
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
pdf_buffer = df_to_pdf(disp)
st.download_button(
label="πŸ“₯ Download as PDF",
data=pdf_buffer,
file_name=f"budget_{datetime.now():%Y%m%d_%H%M%S}.pdf",
mime="application/pdf"
)
# ── Future Feature Placeholder ──────────────────────────────────────────
st.info("πŸ“ˆ Investment recommendations coming soon!")