Spaces:
Sleeping
Sleeping
| 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!") |