| """
|
| Disha Wealth β Mutual Fund Investment Proposal Generator
|
| =========================================================
|
| Run: streamlit run app.py
|
| Deps: pip install streamlit pandas numpy requests reportlab openpyxl plotly
|
|
|
| Logo: Place your logo as Dishaprintlogo.png in the SAME folder as app.py
|
| """
|
|
|
| import streamlit as st
|
| import pandas as pd
|
| import numpy as np
|
| import requests
|
| import warnings
|
| import io
|
| import os
|
| import re
|
| from datetime import datetime
|
|
|
| from reportlab.lib.pagesizes import A4, landscape
|
| from reportlab.platypus import (SimpleDocTemplate, Table, TableStyle,
|
| Paragraph, Spacer, Image as RLImage,
|
| HRFlowable, PageBreak, KeepTogether)
|
| from reportlab.lib import colors
|
| from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
| from reportlab.lib.units import cm, mm
|
| from reportlab.lib.enums import TA_CENTER, TA_RIGHT, TA_LEFT, TA_JUSTIFY
|
|
|
| warnings.filterwarnings("ignore")
|
|
|
|
|
|
|
|
|
| st.set_page_config(page_title="Disha Wealth β MF Proposal", page_icon="π§", layout="wide")
|
|
|
| ADVISOR_NAME = "Divya Shah"
|
| ADVISOR_ARN = "ARN-339305"
|
| ADVISOR_EMAIL = "DIVYA.CE@GMAIL.COM"
|
| ADVISOR_MOBILE = "7738724256"
|
| RISK_FREE = 0.065
|
| LOGO_PATH = os.path.join(os.path.dirname(__file__), "Dishaprintlogo.png")
|
|
|
| HEADERS = {
|
| "User-Agent": (
|
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
| )
|
| }
|
|
|
| NAVY = colors.HexColor("#1B4F72")
|
| LIGHT = colors.HexColor("#D6EAF8")
|
| WHITE = colors.white
|
| GOLD = colors.HexColor("#F0A500")
|
| RUST = colors.HexColor("#C0392B")
|
| GREEN = colors.HexColor("#1E8449")
|
| LGREY = colors.HexColor("#F2F3F4")
|
| DGREY = colors.HexColor("#555555")
|
|
|
|
|
|
|
|
|
| OFFLINE_SAMPLE_FUNDS = {
|
| "HDFC Balanced Advantage Fund - Growth Plan": "100026",
|
| "ICICI Prudential Balanced Advantage Fund - Growth": "120505",
|
| "ICICI Prudential Equity & Debt Fund - Growth": "120586",
|
| "ICICI Prudential Multi-Asset Fund - Growth": "120600",
|
| "Edelweiss Gold and Silver ETF FOF - Regular Plan - Growth": "145740",
|
| "Nippon India Multi Asset Allocation Fund - Regular Growth": "148919",
|
| "HDFC Flexi Cap Fund - Growth Plan": "100033",
|
| "Franklin U.S. Opportunities Equity Active Fund of Fund - Regular Growth": "147622",
|
| "Bandhan Small Cap Fund - Regular Plan - Growth": "147946",
|
| "Axis Greater China Equity Fund of Fund - Regular Growth": "145169",
|
| "Nippon India Multi Cap Fund - Growth Plan - Growth Option": "118701",
|
| "Nippon India Growth Fund - Regular Plan - Growth": "118989",
|
| "Nippon India Small Cap Fund - Regular Plan - Growth": "118778",
|
| "Nippon India Growth Mid Cap Fund - Growth Plan": "118989",
|
| "Mirae Asset Large Cap Fund - Regular Growth": "118834",
|
| "ICICI Prudential Gilt Fund - Regular Growth": "120604",
|
| "Nippon India Gold Savings Fund - Regular Growth": "118748",
|
| }
|
|
|
| DEFAULT_FUND_KEYWORDS = [
|
| "HDFC Balanced Advantage Fund",
|
| "ICICI Prudential Balanced Advantage Fund",
|
| "ICICI Prudential Equity & Debt Fund",
|
| "ICICI Prudential Multi-Asset Fund",
|
| "Edelweiss Gold and Silver ETF FOF",
|
| "Nippon India Multi Asset Allocation Fund",
|
| "HDFC Flexi Cap Fund",
|
| "Franklin U.S. Opportunities",
|
| "BANDHAN SMALL CAP FUND",
|
| "Bandhan Small Cap Fund",
|
| "Axis Greater China Equity Fund",
|
| "Nippon India Multi Cap Fund",
|
| "Nippon India Growth Fund",
|
| "Nippon India Small Cap Fund",
|
| ]
|
|
|
|
|
|
|
|
|
|
|
| @st.cache_data(ttl=86_400, show_spinner=False)
|
| def fetch_amfi_fund_list() -> dict:
|
| """Returns {schemeName: schemeCode}"""
|
| try:
|
| r = requests.get("https://api.mfapi.in/mf", headers=HEADERS, timeout=45)
|
| r.raise_for_status()
|
| data = r.json()
|
| return {d["schemeName"]: str(d["schemeCode"]) for d in data}
|
| except Exception as e:
|
| st.warning(f"Could not fetch AMFI list ({e}). Using offline sample.")
|
| return OFFLINE_SAMPLE_FUNDS
|
|
|
|
|
| @st.cache_data(ttl=86_400, show_spinner=False)
|
| def fetch_amfi_fund_list_full() -> list:
|
| """Returns full list of dicts [{schemeCode, schemeName}] for AMC extraction."""
|
| try:
|
| r = requests.get("https://api.mfapi.in/mf", headers=HEADERS, timeout=30)
|
| r.raise_for_status()
|
| return r.json()
|
| except Exception:
|
| return [{"schemeCode": v, "schemeName": k} for k, v in OFFLINE_SAMPLE_FUNDS.items()]
|
|
|
|
|
| def extract_amc_from_name(scheme_name: str) -> str:
|
| """
|
| Extract AMC/fund-house name from scheme name.
|
| Returns a normalised string like 'HDFC', 'ICICI Prudential', etc.
|
| """
|
| AMC_PREFIXES = [
|
| "Aditya Birla Sun Life", "Axis", "Bandhan", "Baroda BNP Paribas",
|
| "Canara Robeco", "DSP", "Edelweiss", "Franklin", "HDFC", "HSBC",
|
| "ICICI Prudential", "IDFC", "Invesco", "ITI", "JM Financial",
|
| "Kotak", "L&T", "LIC", "Mahindra Manulife", "Mirae Asset",
|
| "Motilal Oswal", "Navi", "Nippon India", "NJ", "PGIM India",
|
| "PPFAS", "Quant", "Quantum", "SBI", "Shriram", "Sundaram",
|
| "Tata", "Taurus", "Union", "UTI", "WhiteOak Capital", "Zerodha",
|
| ]
|
| sl = scheme_name.lower()
|
| for prefix in sorted(AMC_PREFIXES, key=len, reverse=True):
|
| if sl.startswith(prefix.lower()):
|
| return prefix
|
|
|
| parts = scheme_name.split()
|
| return parts[0] if parts else "Other"
|
|
|
|
|
|
|
|
|
|
|
| @st.cache_data(ttl=3_600, show_spinner=False)
|
| def fetch_nav_stats(scheme_code: str) -> dict:
|
| empty = {
|
| "3Y CAGR": "-", "5Y CAGR": "-", "10Y CAGR": "-",
|
| "15Y CAGR": "-", "20Y CAGR": "-",
|
| "1Y Return": "-",
|
| "Std Dev": "-", "Sharpe": "-", "Sortino": "-",
|
| "Max DD (3Y)": "-", "Max DD (5Y)": "-",
|
| "Inception Date": "-", "Latest NAV": "-", "NAV Date": "-",
|
| "_ret_list": None, "_ret_index": None,
|
| }
|
| if not scheme_code or scheme_code == "N/A":
|
| return empty
|
|
|
| try:
|
| r = requests.get(
|
| f"https://api.mfapi.in/mf/{scheme_code}",
|
| headers=HEADERS, timeout=25
|
| )
|
| r.raise_for_status()
|
| payload = r.json()
|
|
|
| df = pd.DataFrame(payload["data"])
|
| df["date"] = pd.to_datetime(df["date"], format="%d-%m-%Y")
|
| df["nav"] = pd.to_numeric(df["nav"], errors="coerce")
|
| df = df.dropna(subset=["nav"]).sort_values("date").set_index("date")
|
|
|
| if len(df) < 30:
|
| return empty
|
|
|
| df["ret"] = df["nav"].pct_change()
|
| df = df.dropna(subset=["ret"])
|
|
|
| latest_date = df.index[-1]
|
| latest_nav = df["nav"].iloc[-1]
|
|
|
| result = dict(empty)
|
| result["Inception Date"] = df.index[0].strftime("%d-%b-%Y")
|
| result["Latest NAV"] = f"{latest_nav:.4f}"
|
| result["NAV Date"] = latest_date.strftime("%d-%b-%Y")
|
|
|
| for y, label in [(1,"1Y Return"),(3,"3Y CAGR"),(5,"5Y CAGR"),
|
| (10,"10Y CAGR"),(15,"15Y CAGR"),(20,"20Y CAGR")]:
|
| target = latest_date - pd.DateOffset(years=y)
|
| if df.index[0] <= target:
|
| idx = df.index.get_indexer([target], method="nearest")[0]
|
| past_nav = df["nav"].iloc[idx]
|
| if past_nav > 0:
|
| cagr = ((latest_nav / past_nav) ** (1.0 / y) - 1) * 100
|
| result[label] = f"{cagr:.1f}%"
|
|
|
| ann_std = df["ret"].std() * np.sqrt(252)
|
| result["Std Dev"] = f"{ann_std * 100:.1f}%"
|
| ann_ret = (1 + df["ret"].mean()) ** 252 - 1
|
|
|
| if_std = ann_std if ann_std > 0 else 1
|
| sharpe = (ann_ret - RISK_FREE) / if_std
|
| result["Sharpe"] = f"{sharpe:.2f}" if ann_std > 0 else "-"
|
|
|
| neg_rets = df["ret"][df["ret"] < 0]
|
| if len(neg_rets) > 5:
|
| down_std = neg_rets.std() * np.sqrt(252)
|
| if down_std > 0:
|
| sortino = (ann_ret - RISK_FREE) / down_std
|
| result["Sortino"] = f"{sortino:.2f}"
|
|
|
| cutoff_3y = latest_date - pd.DateOffset(years=3)
|
| df_3y = df[df.index >= cutoff_3y]
|
| if len(df_3y) >= 30:
|
| roll_max = df_3y["nav"].cummax()
|
| result["Max DD (3Y)"] = f"{((df_3y['nav'] / roll_max) - 1).min() * 100:.1f}%"
|
| else:
|
| roll_max = df["nav"].cummax()
|
| result["Max DD (3Y)"] = f"{((df['nav'] / roll_max) - 1).min() * 100:.1f}%*"
|
|
|
| cutoff_5y = latest_date - pd.DateOffset(years=5)
|
| df_5y = df[df.index >= cutoff_5y]
|
| if len(df_5y) >= 30:
|
| roll_max5 = df_5y["nav"].cummax()
|
| result["Max DD (5Y)"] = f"{((df_5y['nav'] / roll_max5) - 1).min() * 100:.1f}%"
|
|
|
| result["_ret_list"] = df["ret"].tolist()
|
| result["_ret_index"] = df.index.tolist()
|
| result["_nav_series"] = df["nav"].tolist()
|
| result["_nav_index"] = df.index.tolist()
|
|
|
| return result
|
|
|
| except Exception:
|
| return empty
|
|
|
|
|
| @st.cache_data(ttl=3_600, show_spinner=False)
|
| def fetch_nav_history(scheme_code: str) -> pd.DataFrame:
|
| """Return full NAV history as DataFrame with columns [date, nav]."""
|
| try:
|
| r = requests.get(f"https://api.mfapi.in/mf/{scheme_code}", headers=HEADERS, timeout=25)
|
| r.raise_for_status()
|
| payload = r.json()
|
| df = pd.DataFrame(payload["data"])
|
| df["date"] = pd.to_datetime(df["date"], format="%d-%m-%Y")
|
| df["nav"] = pd.to_numeric(df["nav"], errors="coerce")
|
| df = df.dropna().sort_values("date").reset_index(drop=True)
|
| return df
|
| except Exception:
|
| return pd.DataFrame(columns=["date", "nav"])
|
|
|
|
|
| def compute_beta_from_stats(fund_stats: dict, mkt_stats: dict) -> str:
|
| try:
|
| f_list = fund_stats.get("_ret_list")
|
| m_list = mkt_stats.get("_ret_list")
|
| f_idx = fund_stats.get("_ret_index")
|
| m_idx = mkt_stats.get("_ret_index")
|
| if not f_list or not m_list:
|
| return "-"
|
| f_series = pd.Series(f_list, index=f_idx)
|
| m_series = pd.Series(m_list, index=m_idx)
|
| aligned = pd.concat([f_series, m_series], axis=1).dropna()
|
| if len(aligned) < 30:
|
| return "-"
|
| aligned.columns = ["f", "m"]
|
| cov = np.cov(aligned["f"], aligned["m"])
|
| beta = cov[0][1] / cov[1][1]
|
| return f"{beta:.2f}"
|
| except Exception:
|
| return "-"
|
|
|
|
|
| def compute_negative_obs(fund_stats: dict) -> dict:
|
| result = {"neg_1y": "N/A", "neg_3y": "N/A", "neg_5y": "N/A"}
|
| try:
|
| ret_list = fund_stats.get("_ret_list")
|
| ret_idx = fund_stats.get("_ret_index")
|
| if not ret_list:
|
| return result
|
| s = pd.Series(ret_list, index=ret_idx)
|
| for days, key in [(252, "neg_1y"), (756, "neg_3y"), (1260, "neg_5y")]:
|
| if len(s) < days:
|
| continue
|
| roll = (s + 1).rolling(days).apply(lambda x: x.prod() - 1, raw=True).dropna()
|
| if len(roll) == 0:
|
| continue
|
| result[key] = f"{(roll < 0).sum() / len(roll) * 100:.2f}%"
|
| except Exception:
|
| pass
|
| return result
|
|
|
|
|
| def show_nav_history_page():
|
| st.title("π Mutual Fund Comparison & NAV History")
|
| st.markdown("Compare funds within a specific category across all AMCs based on historical performance and key risk metrics.")
|
|
|
|
|
| amfi_dict = fetch_amfi_fund_list()
|
| all_fund_names = list(amfi_dict.keys())
|
|
|
|
|
| fund_categories = [
|
| "Equity Large",
|
| "Equity Large and Mid",
|
| "Equity Multicap",
|
| "Equity Small",
|
| "Equity Flexicap",
|
| "Hybrid Conservative",
|
| "Hybrid Balanced",
|
| "Hybrid Aggressive",
|
| "Debt"
|
| ]
|
| selected_category = st.selectbox("Step 1: Select Specific Fund Category", options=fund_categories)
|
|
|
|
|
| def filter_funds_by_exact_category(fund_name, category):
|
| name_lower = fund_name.lower()
|
|
|
| if category == "Equity Large":
|
| return ("large cap" in name_lower or "nifty 50" in name_lower or "sensex" in name_lower) and "mid" not in name_lower
|
|
|
| elif category == "Equity Large and Mid":
|
| return "large & mid" in name_lower or "large and mid" in name_lower or "nifty next 50" in name_lower
|
|
|
| elif category == "Equity Multicap":
|
| return "multi cap" in name_lower or "multicap" in name_lower
|
|
|
| elif category == "Equity Small":
|
| return "small cap" in name_lower or "smallcap" in name_lower or "micro cap" in name_lower
|
|
|
| elif category == "Equity Flexicap":
|
| return "flexi cap" in name_lower or "flexicap" in name_lower
|
|
|
| elif category == "Hybrid Conservative":
|
| return "conservative hybrid" in name_lower
|
|
|
| elif category == "Hybrid Balanced":
|
| return "balanced advantage" in name_lower or "dynamic asset allocation" in name_lower or "balanced hybrid" in name_lower
|
|
|
| elif category == "Hybrid Aggressive":
|
| return "aggressive hybrid" in name_lower or "equity & debt" in name_lower or "equity and debt" in name_lower
|
|
|
| elif category == "Debt":
|
| return any(k in name_lower for k in ["debt", "liquid", "bond", "gilt", "corporate", "duration", "money market", "overnight", "credit risk"])
|
|
|
| return True
|
|
|
|
|
| filtered_funds = [f for f in all_fund_names if filter_funds_by_exact_category(f, selected_category)]
|
|
|
| if len(filtered_funds) < 3:
|
| st.warning(f"Very few matching funds found online for context '{selected_category}'. Displaying wider cluster profile fallback.")
|
| filtered_funds = all_fund_names
|
|
|
|
|
| st.markdown("### πΎ Category Offline Review")
|
| exp_btn = st.button(f"π Generate Offline Comparison Sheet ({selected_category})", use_container_width=True)
|
|
|
| if exp_btn:
|
| bulk_rows = []
|
| sample_pool = filtered_funds[:50]
|
| st.info(f"Processing evaluation sheets across top {len(sample_pool)} {selected_category} schemes...")
|
|
|
| progress_bar = st.progress(0)
|
| for index, fund_nm in enumerate(sample_pool):
|
| scode = amfi_dict.get(fund_nm)
|
| if scode:
|
| bstats = fetch_nav_stats(scode)
|
| bulk_rows.append({
|
| "Scheme Name": fund_nm,
|
| "AMFI Code": scode,
|
| "Latest NAV": bstats.get("Latest NAV", "-"),
|
| "NAV Date": bstats.get("NAV Date", "-"),
|
| "1Y Return": bstats.get("1Y Return", "-"),
|
| "3Y CAGR": bstats.get("3Y CAGR", "-"),
|
| "5Y CAGR": bstats.get("5Y CAGR", "-"),
|
| "Volatility (Std Dev)": bstats.get("Std Dev", "-"),
|
| "Sharpe Ratio": bstats.get("Sharpe", "-"),
|
| "Sortino Ratio": bstats.get("Sortino", "-"),
|
| "Max Drawdown (3Y)": bstats.get("Max DD (3Y)", "-")
|
| })
|
| progress_bar.progress((index + 1) / len(sample_pool))
|
|
|
| if bulk_rows:
|
| bulk_df = pd.DataFrame(bulk_rows)
|
| xls_io = io.BytesIO()
|
|
|
|
|
| safe_sheet_title = f"{selected_category.replace(' ', '_')}_Overview"[:31]
|
|
|
| with pd.ExcelWriter(xls_io, engine="openpyxl") as wr:
|
| bulk_df.to_excel(wr, sheet_name=safe_sheet_title, index=False)
|
|
|
| st.success("Comparison registry created!")
|
|
|
| safe_filename = selected_category.replace(' ', '_')
|
| st.download_button(
|
| label=f"π₯ Download {selected_category} Offline Summary (Excel)",
|
| data=xls_io.getvalue(),
|
| file_name=f"Disha_Wealth_{safe_filename}_Comparison_{datetime.today().strftime('%Y%m%d')}.xlsx",
|
| mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| use_container_width=True
|
| )
|
|
|
|
|
| st.divider()
|
| selected_funds = st.multiselect(
|
| f"Step 2: Select specific {selected_category} Funds to Compare & extract daily timelines:",
|
| options=filtered_funds,
|
| placeholder="Type or select funds here..."
|
| )
|
|
|
|
|
| if selected_funds:
|
| st.write("### π Fund Comparison Table")
|
|
|
| data_rows = []
|
| with st.spinner("Fetching NAV history and computing risk ratios..."):
|
| for fund in selected_funds:
|
| code = amfi_dict.get(fund)
|
| stats = fetch_nav_stats(code)
|
|
|
| if stats:
|
| data_rows.append({
|
| "Fund Name": fund,
|
| "Latest NAV": stats.get("Latest NAV", "-"),
|
| "NAV Date": stats.get("NAV Date", "-"),
|
| "1Y Return": stats.get("1Y Return", "-"),
|
| "3Y CAGR": stats.get("3Y CAGR", "-"),
|
| "5Y CAGR": stats.get("5Y CAGR", "-"),
|
| "Std Deviation": stats.get("Std Dev", "-"),
|
| "Sharpe Ratio": stats.get("Sharpe", "-"),
|
| "Sortino Ratio": stats.get("Sortino", "-"),
|
| "Max Drawdown (3Y)": stats.get("Max DD (3Y)", "-")
|
| })
|
|
|
| if data_rows:
|
| df_comparison = pd.DataFrame(data_rows)
|
| df_comparison.index = df_comparison.index + 1
|
| st.dataframe(df_comparison, use_container_width=True)
|
| else:
|
| st.warning("Could not fetch data for the selected funds.")
|
|
|
|
|
| st.write("### π
Trailing 3-Month Daily NAV Breakdown (Complete Months)")
|
| st.caption("Displays tracking lines per calendar date spanning the previous 3 completed months.")
|
|
|
| current_date = datetime.today()
|
| first_of_current_month = current_date.replace(day=1)
|
|
|
| end_m1 = first_of_current_month - pd.Timedelta(days=1)
|
| start_m3 = (first_of_current_month - pd.DateOffset(months=3)).replace(day=1)
|
|
|
| st.info(f"Isolating operational records from: **{start_m3.strftime('%d-%b-%Y')}** to **{end_m1.strftime('%d-%b-%Y')}**")
|
|
|
| combined_history_df = None
|
|
|
| with st.spinner("Extracting historical daily sequences..."):
|
| for fund in selected_funds:
|
| fcode = amfi_dict.get(fund)
|
| if fcode:
|
| f_history = fetch_nav_history(fcode)
|
| if not f_history.empty:
|
| sliced = f_history[(f_history["date"] >= start_m3) & (f_history["date"] <= end_m1)].copy()
|
| if not sliced.empty:
|
| sliced["date"] = sliced["date"].dt.strftime("%Y-%m-%d")
|
| sliced = sliced.rename(columns={"nav": fund})
|
|
|
| if combined_history_df is None:
|
| combined_history_df = sliced
|
| else:
|
| combined_history_df = pd.merge(combined_history_df, sliced, on="date", how="outer")
|
|
|
| if combined_history_df is not None and not combined_history_df.empty:
|
| combined_history_df = combined_history_df.sort_values("date", ascending=False).reset_index(drop=True)
|
| combined_history_df = combined_history_df.rename(columns={"date": "Trading Date"})
|
|
|
| st.dataframe(combined_history_df, use_container_width=True, hide_index=True)
|
|
|
| hist_xls = io.BytesIO()
|
| with pd.ExcelWriter(hist_xls, engine="openpyxl") as hwr:
|
| combined_history_df.to_excel(hwr, sheet_name="Daily_3M_NAV", index=False)
|
|
|
| st.download_button(
|
| label="π₯ Download Daily Timeline Records (Excel)",
|
| data=hist_xls.getvalue(),
|
| file_name=f"Daily_NAV_3M_Trailing_{datetime.today().strftime('%Y%m%d')}.xlsx",
|
| mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
| )
|
| else:
|
| st.warning("No tracking points found matching the requested tracking window.")
|
|
|
|
|
|
|
|
|
|
|
| FUND_CATEGORY_MAP = {
|
| "large cap": ("Equity", "Large Cap", 80, 10, 10),
|
| "index fund nifty": ("Equity", "Large Cap Index", 95, 3, 2),
|
| "index fund sensex": ("Equity", "Large Cap Index", 95, 3, 2),
|
| "nifty 50": ("Equity", "Large Cap Index", 95, 3, 2),
|
| "nifty next 50": ("Equity", "Large & Mid Cap", 60, 30, 10),
|
| "large & mid cap": ("Equity", "Large & Mid Cap", 50, 40, 10),
|
| "large and mid cap": ("Equity", "Large & Mid Cap", 50, 40, 10),
|
| "mid cap": ("Equity", "Mid Cap", 25, 65, 10),
|
| "small cap": ("Equity", "Small Cap", 15, 15, 70),
|
| "micro cap": ("Equity", "Small/Micro Cap", 10, 10, 80),
|
| "flexi cap": ("Equity", "Flexi Cap", 50, 30, 20),
|
| "multi cap": ("Equity", "Multi Cap", 35, 35, 30),
|
| "focused fund": ("Equity", "Focused", 55, 25, 20),
|
| "contra": ("Equity", "Contra/Value", 50, 30, 20),
|
| "value fund": ("Equity", "Value", 50, 30, 20),
|
| "dividend yield": ("Equity", "Dividend Yield", 60, 25, 15),
|
| "tax saver": ("Equity/ELSS", "ELSS", 50, 30, 20),
|
| "elss": ("Equity/ELSS", "ELSS", 50, 30, 20),
|
| "balanced advantage": ("Hybrid", "BAF", 55, 15, 10),
|
| "dynamic asset": ("Hybrid", "BAF", 55, 15, 10),
|
| "aggressive hybrid": ("Hybrid", "Aggressive Hybrid", 55, 25, 10),
|
| "equity & debt": ("Hybrid", "Aggressive Hybrid", 55, 25, 10),
|
| "equity and debt": ("Hybrid", "Aggressive Hybrid", 55, 25, 10),
|
| "conservative hybrid": ("Hybrid", "Conservative Hybrid",15, 5, 5),
|
| "equity savings": ("Hybrid", "Equity Savings", 35, 10, 5),
|
| "multi asset": ("Multi Asset", "Multi Asset", 40, 15, 10),
|
| "asset allocation": ("Multi Asset", "Multi Asset", 40, 15, 10),
|
| "banking": ("Sectoral", "Banking", 90, 5, 5),
|
| "bank ": ("Sectoral", "Banking", 90, 5, 5),
|
| "financial services": ("Sectoral", "Financials", 85, 10, 5),
|
| "pharma": ("Sectoral", "Pharma", 75, 15, 10),
|
| "healthcare": ("Sectoral", "Healthcare", 70, 20, 10),
|
| "infra": ("Sectoral", "Infrastructure", 55, 25, 20),
|
| "infrastructure": ("Sectoral", "Infrastructure", 55, 25, 20),
|
| "technology": ("Sectoral", "Technology", 80, 12, 8),
|
| "it fund": ("Sectoral", "Technology", 80, 12, 8),
|
| "fmcg": ("Sectoral", "FMCG", 80, 12, 8),
|
| "consumption": ("Sectoral", "Consumption", 65, 20, 15),
|
| "manufacturing": ("Sectoral", "Manufacturing", 50, 28, 22),
|
| "psu equity": ("Sectoral", "PSU", 70, 20, 10),
|
| "energy": ("Sectoral", "Energy", 75, 15, 10),
|
| "defence": ("Sectoral", "Defence", 55, 28, 17),
|
| "real estate": ("Sectoral", "Real Estate", 75, 15, 10),
|
| "gilt": ("Debt", "Gilt", 0, 0, 0),
|
| "liquid": ("Debt", "Liquid", 0, 0, 0),
|
| "overnight": ("Debt", "Overnight", 0, 0, 0),
|
| "short duration": ("Debt", "Short Duration", 0, 0, 0),
|
| "medium duration": ("Debt", "Medium Duration", 0, 0, 0),
|
| "long duration": ("Debt", "Long Duration", 0, 0, 0),
|
| "corporate bond": ("Debt", "Corporate Bond", 0, 0, 0),
|
| "credit risk": ("Debt", "Credit Risk", 0, 0, 0),
|
| "money market": ("Debt", "Money Market", 0, 0, 0),
|
| "banking and psu": ("Debt", "Banking & PSU Debt", 0, 0, 0),
|
| "gold etf": ("Gold/Commodity", "Gold ETF", 0, 0, 0),
|
| "gold savings": ("Gold/Commodity", "Gold Fund", 0, 0, 0),
|
| "gold and silver": ("Gold/Commodity", "Gold & Silver", 0, 0, 0),
|
| "silver etf": ("Gold/Commodity", "Silver ETF", 0, 0, 0),
|
| "commodity": ("Gold/Commodity", "Commodity", 0, 0, 0),
|
| "nasdaq": ("International", "US Equity", 0, 0, 0),
|
| "s&p 500": ("International", "US Equity", 0, 0, 0),
|
| "us equity": ("International", "US Equity", 0, 0, 0),
|
| "u.s. opportunities": ("International", "US Equity", 0, 0, 0),
|
| "international": ("International", "International", 0, 0, 0),
|
| "global": ("International", "International", 0, 0, 0),
|
| "china": ("International", "China Equity", 0, 0, 0),
|
| "greater china": ("International", "China Equity", 0, 0, 0),
|
| "opportunities fund": ("International/FOF", "FOF", 0, 0, 0),
|
| "fund of fund": ("International/FOF", "FOF", 0, 0, 0),
|
| }
|
|
|
| _ASSET_EQUITY_PCT = {
|
| "Equity": 97, "Equity/ELSS": 97, "Sectoral": 97,
|
| "Hybrid": 70, "Multi Asset": 55,
|
| "Debt": 2, "Gold/Commodity": 5, "International": 0, "International/FOF": 0,
|
| }
|
| _ASSET_DEBT_PCT = {
|
| "Equity": 0, "Equity/ELSS": 0, "Sectoral": 0,
|
| "Hybrid": 20, "Multi Asset": 25,
|
| "Debt": 95, "Gold/Commodity": 0, "International": 0, "International/FOF": 0,
|
| }
|
| _ASSET_GOLD_PCT = {
|
| "Equity": 0, "Equity/ELSS": 0, "Sectoral": 0,
|
| "Hybrid": 0, "Multi Asset": 15,
|
| "Debt": 0, "Gold/Commodity": 92, "International": 0, "International/FOF": 0,
|
| }
|
| _ASSET_INTL_PCT = {
|
| "Equity": 0, "Equity/ELSS": 0, "Sectoral": 0,
|
| "Hybrid": 0, "Multi Asset": 0,
|
| "Debt": 0, "Gold/Commodity": 0, "International": 93, "International/FOF": 90,
|
| }
|
|
|
|
|
| def infer_allocation_from_name(scheme_name: str) -> dict:
|
| name_lower = scheme_name.lower()
|
| matched_key = None
|
| for kw in FUND_CATEGORY_MAP:
|
| if kw in name_lower:
|
| matched_key = kw
|
| break
|
| if matched_key:
|
| asset_class, category, lc, mc, sc = FUND_CATEGORY_MAP[matched_key]
|
| else:
|
| asset_class, category, lc, mc, sc = "Equity", "Unknown", 50, 30, 20
|
|
|
| eq = _ASSET_EQUITY_PCT.get(asset_class, 95)
|
| debt = _ASSET_DEBT_PCT.get(asset_class, 0)
|
| gold = _ASSET_GOLD_PCT.get(asset_class, 0)
|
| intl = _ASSET_INTL_PCT.get(asset_class, 0)
|
| cash = max(0, 100 - eq - debt - gold - intl)
|
|
|
| if "balanced advantage" in name_lower or "dynamic asset" in name_lower:
|
| eq, debt, cash = 65, 25, 10
|
|
|
| return dict(asset_class=asset_class, category=category,
|
| large_cap=lc, mid_cap=mc, small_cap=sc,
|
| equity=eq, debt=debt, gold=gold, intl=intl, cash=cash)
|
|
|
|
|
| @st.cache_data(ttl=3_600, show_spinner=False)
|
| def fetch_portfolio_allocation_amfi(scheme_code: str, scheme_name: str) -> dict:
|
| try:
|
| url = (f"https://www.amfiindia.com/modules/PorfolioDisclousure"
|
| f"?loadPage=true&rn=1&sc={scheme_code}")
|
| r = requests.get(url, headers=HEADERS, timeout=10)
|
| if r.status_code == 200 and len(r.text) > 500:
|
| alloc = _parse_amfi_portfolio_html(r.text, scheme_name)
|
| if alloc:
|
| return alloc
|
| except Exception:
|
| pass
|
|
|
| try:
|
| r = requests.get(f"https://api.mfapi.in/mf/{scheme_code}", headers=HEADERS, timeout=10)
|
| if r.status_code == 200:
|
| meta = r.json().get("meta", {})
|
| combined = f"{scheme_name} {meta.get('scheme_category','')} {meta.get('scheme_type','')}".lower()
|
| base = infer_allocation_from_name(scheme_name)
|
| if "large cap" in combined and "mid" not in combined:
|
| base.update(large_cap=80, mid_cap=10, small_cap=10)
|
| elif "mid cap" in combined:
|
| base.update(large_cap=25, mid_cap=65, small_cap=10)
|
| elif "small cap" in combined:
|
| base.update(large_cap=15, mid_cap=15, small_cap=70)
|
| base["source"] = "mfapi-meta"
|
| return base
|
| except Exception:
|
| pass
|
|
|
| base = infer_allocation_from_name(scheme_name)
|
| base["source"] = "Inferred (SEBI rules)"
|
| return base
|
|
|
|
|
| def _parse_amfi_portfolio_html(html: str, scheme_name: str):
|
| try:
|
| eq_m = re.search(r'Equity[^\d]*(\d+\.?\d*)\s*%', html, re.IGNORECASE)
|
| debt_m = re.search(r'Debt[^\d]*(\d+\.?\d*)\s*%', html, re.IGNORECASE)
|
| gold_m = re.search(r'Gold[^\d]*(\d+\.?\d*)\s*%', html, re.IGNORECASE)
|
| if eq_m or debt_m:
|
| eq = float(eq_m.group(1)) if eq_m else 0
|
| debt = float(debt_m.group(1)) if debt_m else 0
|
| gold = float(gold_m.group(1)) if gold_m else 0
|
| cash = max(0, 100 - eq - debt - gold)
|
| base = infer_allocation_from_name(scheme_name)
|
| ef = eq / 100
|
| base.update(
|
| equity=round(eq,1), debt=round(debt,1),
|
| gold=round(gold,1), cash=round(cash,1),
|
| large_cap=round(base["large_cap"] * ef, 1),
|
| mid_cap =round(base["mid_cap"] * ef, 1),
|
| small_cap=round(base["small_cap"] * ef, 1),
|
| source="AMFI"
|
| )
|
| return base
|
| except Exception:
|
| pass
|
| return None
|
|
|
|
|
|
|
|
|
|
|
| def sip_fv(monthly: float, rate_pa: float, years: int) -> float:
|
| r = rate_pa / 12
|
| n = years * 12
|
| if r == 0:
|
| return monthly * n
|
| return monthly * (((1 + r) ** n - 1) / r) * (1 + r)
|
|
|
|
|
| def build_projection_table(monthly_sip, annual_topup, ret_pct, horizons=(3,5,8,10,15,20)):
|
| rows = []
|
| for y in horizons:
|
| fv = sip_fv(monthly_sip, ret_pct / 100, y)
|
| if annual_topup > 0:
|
| fv += sum(sip_fv(annual_topup / 12, ret_pct / 100, y - yi) for yi in range(y))
|
| invested = monthly_sip * y * 12 + annual_topup * y
|
| rows.append({
|
| "Year": y,
|
| "Total Invested (Rs.)": f"{invested:,.0f}",
|
| "Probable Value (Rs.)": f"{int(fv):,}",
|
| "Wealth Multiple": f"{fv/invested:.1f}x" if invested else "-",
|
| })
|
| return pd.DataFrame(rows)
|
|
|
|
|
| def probability_of_negative_returns(equity_pct: float) -> dict:
|
| return {
|
| "1Y": round(21.14 * (equity_pct / 70), 1),
|
| "3Y": round(8.87 * (equity_pct / 70), 1),
|
| "15Y": 0.00,
|
| }
|
|
|
|
|
|
|
|
|
|
|
| def compute_weighted_allocation(fund_names, alloc_pcts):
|
| wt = dict(equity=0, debt=0, gold=0, intl=0, cash=0,
|
| large_cap=0, mid_cap=0, small_cap=0)
|
| for name, pct in zip(fund_names, alloc_pcts):
|
| w = pct / 100
|
| a = infer_allocation_from_name(name)
|
| for k in ["equity","debt","gold","intl","cash"]:
|
| wt[k] += a.get(k, 0) * w
|
| eq_w = a.get("equity", 0) / 100 * w
|
| wt["large_cap"] += a.get("large_cap", 0) * eq_w
|
| wt["mid_cap"] += a.get("mid_cap", 0) * eq_w
|
| wt["small_cap"] += a.get("small_cap", 0) * eq_w
|
| return {k: round(v, 1) for k, v in wt.items()}
|
|
|
|
|
|
|
|
|
|
|
| PAGE_W, PAGE_H = landscape(A4)
|
| MARGIN = 18 * mm
|
| CONTENT_W = PAGE_W - 2 * MARGIN
|
|
|
|
|
| def _styles():
|
| base = getSampleStyleSheet()
|
| return {
|
| "title": ParagraphStyle("T", parent=base["Title"], fontSize=20,
|
| textColor=NAVY, spaceAfter=6, alignment=TA_CENTER),
|
| "cover_sub": ParagraphStyle("CS", parent=base["Normal"], fontSize=12,
|
| textColor=DGREY, spaceAfter=4, alignment=TA_CENTER),
|
| "h1": ParagraphStyle("H1", parent=base["Heading1"],fontSize=13,
|
| textColor=NAVY, spaceBefore=6, spaceAfter=4),
|
| "h2": ParagraphStyle("H2", parent=base["Heading2"],fontSize=9,
|
| textColor=NAVY, spaceBefore=10, spaceAfter=3),
|
| "h2_rust": ParagraphStyle("H2R", parent=base["Heading2"],fontSize=9,
|
| textColor=RUST, spaceBefore=8, spaceAfter=3),
|
| "normal": base["Normal"],
|
| "body": ParagraphStyle("BD", parent=base["Normal"], fontSize=8.5,
|
| leading=13, textColor=colors.HexColor("#222222")),
|
| "body_j": ParagraphStyle("BDJ", parent=base["Normal"], fontSize=8.5,
|
| leading=13, alignment=TA_JUSTIFY),
|
| "small": ParagraphStyle("SM", parent=base["Normal"], fontSize=6.5,
|
| textColor=colors.grey),
|
| "small_b": ParagraphStyle("SMB", parent=base["Normal"], fontSize=6.5,
|
| textColor=colors.grey, fontName="Helvetica-Bold"),
|
| "cell": ParagraphStyle("C", parent=base["Normal"], fontSize=6.5,
|
| wordWrap="CJK"),
|
| "cell_hdr": ParagraphStyle("CH", parent=base["Normal"], fontSize=6.5,
|
| textColor=WHITE, fontName="Helvetica-Bold",
|
| wordWrap="CJK"),
|
| "meta": ParagraphStyle("M", parent=base["Normal"], fontSize=7.5,
|
| textColor=colors.HexColor("#333333")),
|
| "note": ParagraphStyle("NT", parent=base["Normal"], fontSize=7,
|
| textColor=DGREY, leading=10),
|
| "howto": ParagraphStyle("HT", parent=base["Normal"], fontSize=7.5,
|
| textColor=colors.HexColor("#1A5276"),
|
| fontName="Helvetica-Bold", spaceAfter=2),
|
| "howto_body": ParagraphStyle("HTB", parent=base["Normal"], fontSize=7,
|
| textColor=DGREY, leading=10),
|
| "sig": ParagraphStyle("SG", parent=base["Normal"], fontSize=9,
|
| textColor=NAVY, spaceBefore=6),
|
| }
|
|
|
|
|
| def _make_table(data_rows, headers, col_widths, styles_dict, font_size=6.5):
|
| ncols = len(headers)
|
| if ncols >= 11:
|
| font_size = min(font_size, 5.8)
|
| elif ncols >= 8:
|
| font_size = min(font_size, 6.2)
|
|
|
| base = getSampleStyleSheet()
|
| cs = ParagraphStyle("_c", parent=base["Normal"], fontSize=font_size,
|
| wordWrap="CJK", leading=font_size + 1.5)
|
| chs = ParagraphStyle("_ch", parent=base["Normal"], fontSize=font_size,
|
| textColor=WHITE, fontName="Helvetica-Bold",
|
| wordWrap="CJK", leading=font_size + 1.5)
|
|
|
| hdr = [Paragraph(str(h), chs) for h in headers]
|
| rows = [hdr]
|
| for row in data_rows:
|
| rows.append([Paragraph(str(v), cs) for v in row])
|
|
|
| pad = 2 if ncols >= 8 else 3
|
| t = Table(rows, colWidths=col_widths, repeatRows=1)
|
| t.setStyle(TableStyle([
|
| ("BACKGROUND", (0,0), (-1,0), NAVY),
|
| ("TEXTCOLOR", (0,0), (-1,0), WHITE),
|
| ("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
|
| ("ALIGN", (0,0), (-1,-1), "CENTER"),
|
| ("VALIGN", (0,0), (-1,-1), "MIDDLE"),
|
| ("ROWBACKGROUNDS",(0,1), (-1,-1), [WHITE, LIGHT]),
|
| ("GRID", (0,0), (-1,-1), 0.3, colors.HexColor("#AAAAAA")),
|
| ("TOPPADDING", (0,0), (-1,-1), pad),
|
| ("BOTTOMPADDING", (0,0), (-1,-1), pad),
|
| ("LEFTPADDING", (0,0), (-1,-1), 2),
|
| ("RIGHTPADDING", (0,0), (-1,-1), 2),
|
| ]))
|
| return t
|
|
|
|
|
| def _howto_box(title_text, body_text, S):
|
| data = [[
|
| Paragraph(f"<b>{title_text}</b>", S["howto"]),
|
| Paragraph(body_text, S["howto_body"]),
|
| ]]
|
| t = Table(data, colWidths=[CONTENT_W * 0.18, CONTENT_W * 0.82])
|
| t.setStyle(TableStyle([
|
| ("BACKGROUND", (0,0),(-1,-1), colors.HexColor("#EBF5FB")),
|
| ("BOX", (0,0),(-1,-1), 0.5, NAVY),
|
| ("VALIGN", (0,0),(-1,-1), "TOP"),
|
| ("TOPPADDING", (0,0),(-1,-1), 4),
|
| ("BOTTOMPADDING",(0,0),(-1,-1), 4),
|
| ("LEFTPADDING", (0,0),(-1,-1), 5),
|
| ("RIGHTPADDING", (0,0),(-1,-1), 5),
|
| ]))
|
| return t
|
|
|
|
|
| def _note_box(text, S):
|
| t = Table([[Paragraph(text, S["note"])]], colWidths=[CONTENT_W])
|
| t.setStyle(TableStyle([
|
| ("BACKGROUND", (0,0),(-1,-1), LGREY),
|
| ("BOX", (0,0),(-1,-1), 0.3, colors.grey),
|
| ("TOPPADDING", (0,0),(-1,-1), 3),
|
| ("BOTTOMPADDING",(0,0),(-1,-1), 3),
|
| ("LEFTPADDING", (0,0),(-1,-1), 5),
|
| ]))
|
| return t
|
|
|
|
|
| def _section_header(text, S):
|
| t = Table([[Paragraph(f"<b>{text}</b>",
|
| ParagraphStyle("SH", parent=S["normal"], fontSize=9,
|
| textColor=WHITE, fontName="Helvetica-Bold"))]],
|
| colWidths=[CONTENT_W])
|
| t.setStyle(TableStyle([
|
| ("BACKGROUND", (0,0),(-1,-1), NAVY),
|
| ("TOPPADDING", (0,0),(-1,-1), 4),
|
| ("BOTTOMPADDING",(0,0),(-1,-1), 4),
|
| ("LEFTPADDING", (0,0),(-1,-1), 8),
|
| ]))
|
| return t
|
|
|
|
|
|
|
|
|
|
|
| def generate_pdf(client_name, investment_objective, horizon_yrs,
|
| proj_df, comp_df, perf_df, alloc_df,
|
| monthly_sip, annual_topup, risk_profile, wtd,
|
| expected_ret, all_stats):
|
|
|
| buf = io.BytesIO()
|
| doc = SimpleDocTemplate(buf, pagesize=landscape(A4),
|
| rightMargin=MARGIN, leftMargin=MARGIN,
|
| topMargin=14*mm, bottomMargin=14*mm)
|
| S = _styles()
|
| story = []
|
| today_str = datetime.today().strftime("%d %b %Y")
|
|
|
| def _page_hf(canvas, doc):
|
| canvas.saveState()
|
| canvas.setStrokeColor(NAVY)
|
| canvas.setLineWidth(0.5)
|
| canvas.line(MARGIN, PAGE_H - 12*mm, PAGE_W - MARGIN, PAGE_H - 12*mm)
|
| canvas.setFont("Helvetica", 7)
|
| canvas.setFillColor(DGREY)
|
| canvas.drawString(MARGIN, PAGE_H - 10*mm, "Mutual Fund Investment Proposal")
|
| canvas.drawRightString(PAGE_W - MARGIN, PAGE_H - 10*mm, today_str)
|
| canvas.line(MARGIN, 11*mm, PAGE_W - MARGIN, 11*mm)
|
| canvas.drawString(MARGIN, 7*mm, client_name)
|
| canvas.drawRightString(PAGE_W - MARGIN, 7*mm, f"Page {doc.page}")
|
| canvas.restoreState()
|
|
|
|
|
| logo_el = (RLImage(LOGO_PATH, width=4.5*cm, height=1.5*cm)
|
| if os.path.exists(LOGO_PATH) else
|
| Paragraph("<b>Disha Wealth</b>",
|
| ParagraphStyle("DW", fontSize=14, textColor=NAVY,
|
| fontName="Helvetica-Bold")))
|
|
|
| cover_banner = Table([[Paragraph(
|
| "<font color='white'><b>MUTUAL FUND INVESTMENT PROPOSAL</b></font>",
|
| ParagraphStyle("CB", fontSize=18, alignment=TA_CENTER,
|
| textColor=WHITE, fontName="Helvetica-Bold"))]],
|
| colWidths=[CONTENT_W])
|
| cover_banner.setStyle(TableStyle([
|
| ("BACKGROUND", (0,0),(-1,-1), NAVY),
|
| ("TOPPADDING", (0,0),(-1,-1), 18),
|
| ("BOTTOMPADDING", (0,0),(-1,-1), 18),
|
| ]))
|
|
|
| story += [Spacer(1, 15*mm), logo_el, Spacer(1, 10*mm), cover_banner,
|
| Spacer(1, 8*mm),
|
| Paragraph(today_str, S["cover_sub"]),
|
| Spacer(1, 12*mm)]
|
|
|
| info_data = [
|
| [Paragraph("<b>Prepared For:</b>", S["body"]),
|
| Paragraph(f"<b><font color='#1B4F72'>{client_name}</font></b>",
|
| ParagraphStyle("CN", fontSize=14, textColor=NAVY, fontName="Helvetica-Bold")),
|
| Paragraph("<b>Prepared By:</b>", S["body"]),
|
| Paragraph(f"<b><font color='#1B4F72'>{ADVISOR_NAME}</font></b>",
|
| ParagraphStyle("AN", fontSize=12, textColor=NAVY, fontName="Helvetica-Bold"))],
|
| [Paragraph("Investment Horizon:", S["body"]), Paragraph(f"{horizon_yrs} Years", S["body"]),
|
| Paragraph("ARN:", S["body"]), Paragraph(ADVISOR_ARN, S["body"])],
|
| [Paragraph("Risk Profile:", S["body"]), Paragraph(risk_profile, S["body"]),
|
| Paragraph("Email:", S["body"]), Paragraph(ADVISOR_EMAIL, S["body"])],
|
| [Paragraph("Monthly SIP:", S["body"]), Paragraph(f"Rs. {monthly_sip:,.0f}", S["body"]),
|
| Paragraph("Mobile:", S["body"]), Paragraph(ADVISOR_MOBILE, S["body"])],
|
| ]
|
| info_t = Table(info_data, colWidths=[CONTENT_W*0.15, CONTENT_W*0.35,
|
| CONTENT_W*0.15, CONTENT_W*0.35])
|
| info_t.setStyle(TableStyle([
|
| ("BOX", (0,0),(-1,-1), 0.5, NAVY),
|
| ("INNERGRID", (0,0),(-1,-1), 0.3, colors.HexColor("#CCCCCC")),
|
| ("BACKGROUND", (0,0),(0,-1), LGREY),
|
| ("BACKGROUND", (2,0),(2,-1), LGREY),
|
| ("VALIGN", (0,0),(-1,-1), "MIDDLE"),
|
| ("TOPPADDING", (0,0),(-1,-1), 5),
|
| ("BOTTOMPADDING",(0,0),(-1,-1), 5),
|
| ("LEFTPADDING", (0,0),(-1,-1), 8),
|
| ]))
|
| story += [info_t, PageBreak()]
|
|
|
|
|
| story.append(_section_header("Introduction", S))
|
| story.append(Spacer(1, 6))
|
| story.append(Paragraph("<b>Mutual Fund Investment Proposal</b>",
|
| ParagraphStyle("IP", fontSize=11, textColor=NAVY,
|
| fontName="Helvetica-Bold", spaceBefore=4, spaceAfter=4)))
|
| story.append(Paragraph(f"Dear {client_name},", S["body"]))
|
| story.append(Spacer(1, 4))
|
| story.append(Paragraph("Greetings!", S["body"]))
|
| story.append(Spacer(1, 4))
|
| story.append(Paragraph(
|
| "Thank you for giving us the opportunity to assist you in your investment requirement. "
|
| "We are pleased to present this customised Mutual Fund Investment Proposal for your consideration.",
|
| S["body_j"]))
|
| story.append(Spacer(1, 6))
|
| story.append(Paragraph(
|
| "This investment proposal follows a step-by-step process of investment decision making:",
|
| S["body_j"]))
|
| story.append(Spacer(1, 4))
|
|
|
| step_hdr_style = ParagraphStyle("StepH", parent=S["normal"], fontSize=8, textColor=NAVY, fontName="Helvetica-Bold", spaceAfter=2)
|
| step_body_style = ParagraphStyle("StepB", parent=S["normal"], fontSize=6.5, textColor=DGREY, leading=9)
|
|
|
| step_data = [
|
| [
|
| Paragraph("<b>1. Define Investment Objective</b>", step_hdr_style),
|
| Paragraph("<b>2. Select Asset Allocation</b>", step_hdr_style),
|
| Paragraph("<b>3. Select MF Portfolio</b>", step_hdr_style)
|
| ],
|
| [
|
| Paragraph("Choose your objective, investment horizon and planned investments.", step_body_style),
|
| Paragraph("Review and choose a suitable risk-return trade-off for different asset allocations.", step_body_style),
|
| Paragraph("Build a diversified portfolio of well-researched mutual fund schemes.", step_body_style)
|
| ]
|
| ]
|
|
|
| step_t = Table(step_data, colWidths=[CONTENT_W / 3.0] * 3)
|
| step_t.setStyle(TableStyle([
|
| ("BOX", (0,0),(-1,-1), 0.5, NAVY),
|
| ("INNERGRID", (0,0),(-1,-1), 0.3, LIGHT),
|
| ("BACKGROUND", (0,0),(-1,-1), LIGHT),
|
| ("TOPPADDING", (0,0),(-1,-1), 4),
|
| ("BOTTOMPADDING",(0,0),(-1,-1), 5),
|
| ("LEFTPADDING", (0,0),(-1,-1), 8),
|
| ("RIGHTPADDING", (0,0),(-1,-1), 8),
|
| ("VALIGN", (0,0),(-1,-1), "TOP"),
|
| ]))
|
| story.append(step_t)
|
| story.append(Spacer(1, 6))
|
| story.append(Paragraph(
|
| "We believe each step is important and thus thoughtfully considered. The Asset Allocation and "
|
| "Portfolio suggested is after considering your investment objective, risk appetite, risk-return "
|
| "expectations for this particular investment and suitability of the underlying schemes.",
|
| S["body_j"]))
|
| story.append(Spacer(1, 4))
|
| story.append(Paragraph(
|
| "We look forward to explaining the proposal to you and supporting you through your investment "
|
| "journey. Please feel free to get in touch for any clarifications or further guidance.",
|
| S["body_j"]))
|
| story.append(Spacer(1, 10))
|
| story.append(Paragraph("Warm regards,", S["sig"]))
|
| story.append(Paragraph(f"<b>{ADVISOR_NAME}</b>", S["sig"]))
|
| story.append(Paragraph(ADVISOR_ARN, S["sig"]))
|
| story.append(PageBreak())
|
|
|
|
|
| story.append(_section_header("Proposal Details", S))
|
| story.append(Spacer(1, 5))
|
| story.append(Paragraph(
|
| "These are the basic requirements shared and/or considered for the generation of the proposal.",
|
| S["body"]))
|
| story.append(Spacer(1, 6))
|
|
|
| prop_meta = [
|
| [Paragraph("<b>Partner Name</b>", S["small_b"]), Paragraph(ADVISOR_NAME, S["note"]),
|
| Paragraph("<b>Email</b>", S["small_b"]), Paragraph(ADVISOR_EMAIL, S["note"])],
|
| [Paragraph("<b>ARN</b>", S["small_b"]), Paragraph(ADVISOR_ARN, S["note"]),
|
| Paragraph("<b>Mobile</b>", S["small_b"]), Paragraph(ADVISOR_MOBILE, S["note"])],
|
| [Paragraph("<b>Proposal Date</b>", S["small_b"]), Paragraph(today_str, S["note"]),
|
| Paragraph("", S["note"]), Paragraph("", S["note"])],
|
| ]
|
| pm_t = Table(prop_meta, colWidths=[CONTENT_W*0.15, CONTENT_W*0.35,
|
| CONTENT_W*0.15, CONTENT_W*0.35])
|
| pm_t.setStyle(TableStyle([
|
| ("BOX", (0,0),(-1,-1), 0.5, colors.grey),
|
| ("INNERGRID", (0,0),(-1,-1), 0.3, LGREY),
|
| ("BACKGROUND", (0,0),(0,-1), LGREY),
|
| ("BACKGROUND", (2,0),(2,-1), LGREY),
|
| ("TOPPADDING", (0,0),(-1,-1), 4),
|
| ("BOTTOMPADDING",(0,0),(-1,-1), 4),
|
| ("LEFTPADDING", (0,0),(-1,-1), 6),
|
| ]))
|
| story += [pm_t, Spacer(1, 8)]
|
| story.append(Paragraph("<b>Proposal Inputs</b>", S["h2"]))
|
|
|
| inv_data = [
|
| [Paragraph("<b>Lead Name</b>", S["small_b"]), Paragraph(client_name, S["note"]),
|
| Paragraph("<b>Objective</b>", S["small_b"]), Paragraph(investment_objective, S["note"])],
|
| [Paragraph("<b>Horizon</b>", S["small_b"]), Paragraph(f"{horizon_yrs} Years", S["note"]),
|
| Paragraph("<b>Risk Profile</b>", S["small_b"]), Paragraph(risk_profile, S["note"])],
|
| [Paragraph("<b>Monthly SIP</b>", S["small_b"]), Paragraph(f"Rs. {monthly_sip:,.0f}", S["note"]),
|
| Paragraph("<b>Annual Top-Up</b>", S["small_b"]),
|
| Paragraph(f"Rs. {annual_topup:,.0f}" if annual_topup else "Nil", S["note"])],
|
| [Paragraph("<b>Assumed Return</b>", S["small_b"]), Paragraph(f"{expected_ret:.1f}% p.a.", S["note"]),
|
| Paragraph("", S["note"]), Paragraph("", S["note"])],
|
| ]
|
| inv_t = Table(inv_data, colWidths=[CONTENT_W*0.18, CONTENT_W*0.32,
|
| CONTENT_W*0.18, CONTENT_W*0.32])
|
| inv_t.setStyle(TableStyle([
|
| ("BOX", (0,0),(-1,-1), 0.5, colors.grey),
|
| ("INNERGRID", (0,0),(-1,-1), 0.3, LGREY),
|
| ("BACKGROUND", (0,0),(0,-1), LGREY),
|
| ("BACKGROUND", (2,0),(2,-1), LGREY),
|
| ("TOPPADDING", (0,0),(-1,-1), 4),
|
| ("BOTTOMPADDING",(0,0),(-1,-1), 4),
|
| ("LEFTPADDING", (0,0),(-1,-1), 6),
|
| ]))
|
| story += [inv_t, PageBreak()]
|
|
|
|
|
| story.append(_section_header("Asset Allocation & Wealth Projection", S))
|
| story.append(Spacer(1, 5))
|
| story.append(Paragraph(
|
| "Asset allocation is the distribution of investments across different asset classes like "
|
| "equity, debt, gold and international funds to balance risk and returns. "
|
| "The allocation below has been determined after considering your investment objective, "
|
| "risk appetite and investment horizon.",
|
| S["body_j"]))
|
| story.append(Spacer(1, 6))
|
|
|
| story.append(Paragraph("<b>Portfolio Weighted Allocation (Estimated)</b>", S["h2"]))
|
| sum_data = [
|
| ["Equity %","Debt %","Gold %","Intl %","Cash %","Large Cap","Mid Cap","Small Cap"],
|
| [f"{wtd['equity']}%", f"{wtd['debt']}%", f"{wtd['gold']}%",
|
| f"{wtd['intl']}%", f"{wtd['cash']}%",
|
| f"{wtd['large_cap']}%", f"{wtd['mid_cap']}%", f"{wtd['small_cap']}%"],
|
| ]
|
| cw8 = [CONTENT_W / 8] * 8
|
| sum_t = Table(sum_data, colWidths=cw8)
|
| sum_t.setStyle(TableStyle([
|
| ("BACKGROUND", (0,0),(-1,0), NAVY),
|
| ("TEXTCOLOR", (0,0),(-1,0), WHITE),
|
| ("BACKGROUND", (0,1),(-1,1), LIGHT),
|
| ("ALIGN", (0,0),(-1,-1),"CENTER"),
|
| ("FONTSIZE", (0,0),(-1,-1), 8),
|
| ("FONTNAME", (0,0),(-1,0), "Helvetica-Bold"),
|
| ("FONTNAME", (0,1),(-1,1), "Helvetica-Bold"),
|
| ("GRID", (0,0),(-1,-1), 0.3, colors.grey),
|
| ("TOPPADDING", (0,0),(-1,-1), 4),
|
| ("BOTTOMPADDING", (0,0),(-1,-1), 4),
|
| ]))
|
| story += [sum_t, Spacer(1, 8)]
|
|
|
| eq_pct = wtd.get("equity", 70)
|
| neg_ret = probability_of_negative_returns(eq_pct)
|
| story.append(Paragraph(
|
| f"<b>Estimated Progress & Probable Risk</b> "
|
| f"<font color='grey' size='7'> β Assumed return: {expected_ret:.1f}% p.a.</font>",
|
| S["h2"]))
|
| prob_cw = [CONTENT_W * r for r in [0.12, 0.30, 0.30, 0.28]]
|
| story.append(_make_table(proj_df.values.tolist(), proj_df.columns.tolist(), prob_cw, S))
|
| story.append(Spacer(1, 6))
|
|
|
| neg_rows = [
|
| ["Probability of Negative Returns in 1 Year", f"{neg_ret['1Y']:.2f}%"],
|
| ["Probability of Negative Returns in 3 Years", f"{neg_ret['3Y']:.2f}%"],
|
| ["Probability of Negative Returns in 15 Years","0.00%"],
|
| ]
|
| neg_t = Table(neg_rows, colWidths=[CONTENT_W * 0.6, CONTENT_W * 0.4])
|
| neg_t.setStyle(TableStyle([
|
| ("BACKGROUND", (0,0),(-1,-1), LGREY),
|
| ("BOX", (0,0),(-1,-1), 0.5, colors.grey),
|
| ("INNERGRID", (0,0),(-1,-1), 0.3, colors.HexColor("#CCCCCC")),
|
| ("ALIGN", (1,0),(1,-1), "CENTER"),
|
| ("FONTSIZE", (0,0),(-1,-1), 7.5),
|
| ("FONTNAME", (1,0),(1,-1), "Helvetica-Bold"),
|
| ("TOPPADDING", (0,0),(-1,-1), 3),
|
| ("BOTTOMPADDING",(0,0),(-1,-1), 3),
|
| ("LEFTPADDING", (0,0),(-1,-1), 6),
|
| ]))
|
| story += [neg_t, Spacer(1, 4)]
|
| story.append(_note_box(
|
| "Notes: Probability of negative returns estimated from historical rolling return analysis "
|
| "(Nifty 500 TRI + Crisil 10yr GSec). "
|
| "Past performance may or may not be sustained. Projections are illustrative only.", S))
|
| story.append(PageBreak())
|
|
|
|
|
| story.append(_section_header("Suggested Portfolio Composition", S))
|
| story.append(Spacer(1, 5))
|
| story.append(Paragraph(
|
| "With the asset allocation finalised, a suitable portfolio of mutual fund schemes "
|
| "is proposed below based on your investment objective, risk appetite and suitability.",
|
| S["body_j"]))
|
| story.append(Spacer(1, 6))
|
| _make_scheme = CONTENT_W * 0.36
|
| _make_rest = (CONTENT_W - _make_scheme) / max(1, len(comp_df.columns) - 1)
|
| bc = [_make_scheme] + [_make_rest] * (len(comp_df.columns) - 1)
|
| story.append(_make_table(comp_df.values.tolist(), comp_df.columns.tolist(), bc, S))
|
| story.append(Spacer(1, 4))
|
| story.append(_note_box(
|
| "The above portfolio is structured based on understanding of your investment needs. "
|
| "Investment amounts are indicative; actual amounts may vary based on scheme minimums.", S))
|
| story.append(PageBreak())
|
|
|
|
|
| story.append(_section_header("Scheme Performance & Risk Metrics", S))
|
| story.append(Spacer(1, 5))
|
| _pc_scheme = CONTENT_W * 0.30
|
| _pc_rest = (CONTENT_W - _pc_scheme) / max(1, len(perf_df.columns) - 1)
|
| pc = [_pc_scheme] + [_pc_rest] * (len(perf_df.columns) - 1)
|
| story.append(_make_table(perf_df.values.tolist(), perf_df.columns.tolist(), pc, S))
|
| story.append(Spacer(1, 4))
|
| story.append(_note_box(
|
| "Source: mfapi.in | Sharpe & Sortino: risk-free rate = 6.5% p.a. | "
|
| "Beta: computed vs Nifty 500 proxy | Max DD = maximum drawdown over 3 years.", S))
|
| story.append(Spacer(1, 6))
|
| story.append(_howto_box("How to Read:",
|
| "<b>CAGR:</b> Compounded Annual Growth Rate β higher the better. "
|
| "<b>Std Dev:</b> Volatility β higher means more risk. "
|
| "<b>Sharpe:</b> Risk-adjusted return β above 1 is good. "
|
| "<b>Sortino:</b> Penalises only downside risk β higher the better. "
|
| "<b>Max DD:</b> Largest peak-to-trough fall in 3 years β lower the better. "
|
| "<b>Beta:</b> Market sensitivity β 1 = in line with market; below 1 = less volatile.", S))
|
| story.append(PageBreak())
|
|
|
|
|
| story.append(_section_header("Asset & Market-Cap Allocation (Per Scheme)", S))
|
| story.append(Spacer(1, 5))
|
| _dc_scheme = CONTENT_W * 0.26
|
| _dc_category = CONTENT_W * 0.10
|
| _dc_rest = (CONTENT_W - _dc_scheme - _dc_category) / max(1, len(alloc_df.columns) - 2)
|
| dc = [_dc_scheme, _dc_category] + [_dc_rest] * (len(alloc_df.columns) - 2)
|
| story.append(_make_table(alloc_df.values.tolist(), alloc_df.columns.tolist(), dc, S))
|
| story.append(Spacer(1, 4))
|
| story.append(_note_box(
|
| "Source: AMFI Portfolio Disclosure β mfapi metadata β SEBI category rule inference. "
|
| "Large/Mid/Small Cap % are estimates based on SEBI category norms.", S))
|
| story.append(Spacer(1, 6))
|
| story.append(_howto_box("How to Read:",
|
| "<b>Equity %:</b> Stocks. <b>Debt %:</b> Fixed income. "
|
| "<b>Gold %:</b> Gold / commodity exposure. <b>Intl %:</b> Overseas markets. "
|
| "<b>Large Cap:</b> Top 100 cos β relatively stable. "
|
| "<b>Mid Cap:</b> Cos 101-250 β higher growth, moderate risk. "
|
| "<b>Small Cap:</b> Cos 251+ β highest growth potential, highest risk.", S))
|
| story.append(PageBreak())
|
|
|
|
|
| story.append(_section_header("Scheme Insights", S))
|
| story.append(Spacer(1, 5))
|
| story.append(Paragraph("<b>Scheme Details & Historical Observations</b>", S["h2"]))
|
|
|
| insight_hdr = ["Scheme Name","Category","Alloc %",
|
| "Inception","Latest NAV","1Y Ret",
|
| "3Y CAGR","5Y CAGR","10Y CAGR",
|
| "Neg Obs 1Y","Neg Obs 3Y",
|
| "Max DD 3Y","Max DD 5Y","Beta"]
|
| insight_rows = []
|
| for _, row in perf_df.iterrows():
|
| fname = row["Scheme"]
|
| stats = all_stats.get(fname, {})
|
| neg = compute_negative_obs(stats)
|
| a_row = alloc_df[alloc_df["Scheme"] == fname]
|
| cat = a_row["Category"].values[0] if len(a_row) else "-"
|
| insight_rows.append([
|
| fname,
|
| cat,
|
| row.get("Alloc %", "-"),
|
| stats.get("Inception Date", "-"),
|
| stats.get("Latest NAV", "-"),
|
| stats.get("1Y Return", "-"),
|
| stats.get("3Y CAGR", "-"),
|
| stats.get("5Y CAGR", "-"),
|
| stats.get("10Y CAGR", "-"),
|
| neg["neg_1y"],
|
| neg["neg_3y"],
|
| stats.get("Max DD (3Y)", "-"),
|
| stats.get("Max DD (5Y)", "-"),
|
| row.get("Beta", "-"),
|
| ])
|
|
|
| _is_scheme = CONTENT_W * 0.20
|
| _is_cat = CONTENT_W * 0.09
|
| _is_rest = (CONTENT_W - _is_scheme - _is_cat) / (len(insight_hdr) - 2)
|
| is_cw = [_is_scheme, _is_cat] + [_is_rest] * (len(insight_hdr) - 2)
|
| story.append(_make_table(insight_rows, insight_hdr, is_cw, S))
|
| story.append(Spacer(1, 4))
|
| story.append(_note_box(
|
| "* Negative Observations: % of rolling windows (1Y / 3Y) where returns were negative "
|
| "from daily NAV data β lower is better. "
|
| "| Max DD 5Y: Maximum drawdown over last 5 years. "
|
| "| All data sourced from mfapi.in.", S))
|
| story.append(PageBreak())
|
|
|
|
|
| story.append(_section_header("Expectations, Next Steps & Disclaimer", S))
|
| story.append(Spacer(1, 6))
|
|
|
| exp_inner = Table([
|
| [Paragraph("<b>Expectations from You</b>", S["h2"])],
|
| [Paragraph("β’ Ensure the investment objectives and planned investments are appropriate for your needs.", S["body"])],
|
| [Paragraph("β’ Ensure you have understood the suggested asset allocation and find it suitable.", S["body"])],
|
| [Paragraph("β’ Review the portfolio of schemes and the investment allocation.", S["body"])],
|
| [Paragraph("β’ Look at the scheme-related information and disclosures provided.", S["body"])],
|
| [Paragraph("β’ Read the Disclaimer carefully before proceeding.", S["body"])],
|
| ], colWidths=[CONTENT_W * 0.48])
|
| exp_inner.setStyle(TableStyle([("TOPPADDING",(0,0),(-1,-1),2),("BOTTOMPADDING",(0,0),(-1,-1),2),("LEFTPADDING",(0,0),(-1,-1),0)]))
|
|
|
| nxt_inner = Table([
|
| [Paragraph("<b>Next Steps</b>", S["h2"])],
|
| [Paragraph("β’ Review this proposal and come back with any questions or comments.", S["body"])],
|
| [Paragraph("β’ Give confirmation / go-ahead for execution of planned investments.", S["body"])],
|
| [Paragraph("β’ Authorise any transactions as part of the execution of this proposal.", S["body"])],
|
| [Paragraph("β’ Set up KYC and SIP mandates if not already done.", S["body"])],
|
| [Paragraph("β’ Stay invested for the planned horizon to maximise compounding benefits.", S["body"])],
|
| ], colWidths=[CONTENT_W * 0.48])
|
| nxt_inner.setStyle(TableStyle([("TOPPADDING",(0,0),(-1,-1),2),("BOTTOMPADDING",(0,0),(-1,-1),2),("LEFTPADDING",(0,0),(-1,-1),0)]))
|
|
|
| two_col = Table([[exp_inner, nxt_inner]], colWidths=[CONTENT_W*0.5, CONTENT_W*0.5])
|
| two_col.setStyle(TableStyle([
|
| ("BOX", (0,0),(-1,-1), 0.5, colors.grey),
|
| ("INNERGRID", (0,0),(-1,-1), 0.3, LGREY),
|
| ("VALIGN", (0,0),(-1,-1), "TOP"),
|
| ("TOPPADDING", (0,0),(-1,-1), 8),
|
| ("BOTTOMPADDING",(0,0),(-1,-1), 8),
|
| ("LEFTPADDING", (0,0),(-1,-1), 8),
|
| ("RIGHTPADDING", (0,0),(-1,-1), 8),
|
| ]))
|
| story += [two_col, Spacer(1, 10)]
|
|
|
| story.append(Paragraph("<b>Disclaimer</b>",
|
| ParagraphStyle("DR", parent=S["h2"], textColor=RUST)))
|
| story.append(HRFlowable(width="100%", thickness=0.5, color=RUST, spaceAfter=4))
|
| story.append(Paragraph(
|
| f"This investment proposal has been prepared by an AMFI-registered Mutual Fund Distributor "
|
| f"({ADVISOR_ARN}). It is strictly private and intended solely for the requesting client. "
|
| "Projections are illustrative and assume constant returns. All performance metrics (CAGR, "
|
| "Sharpe, Sortino, Max Drawdown, Beta) are computed from historical NAV data (mfapi.in) and "
|
| "are for reference only. Asset allocation and market-cap figures are estimates using SEBI "
|
| "category rules and may differ from actual holdings. Mutual fund investments are subject to "
|
| "market risks. Read all scheme-related documents carefully. Past performance may or may not "
|
| "be sustained in future and is not a guarantee of any future returns.",
|
| S["small"]))
|
| story.append(Spacer(1, 4))
|
| story.append(Paragraph("ββ END OF PROPOSAL ββ",
|
| ParagraphStyle("EP", fontSize=8, alignment=TA_CENTER,
|
| textColor=NAVY, fontName="Helvetica-Bold")))
|
|
|
| doc.build(story, onFirstPage=_page_hf, onLaterPages=_page_hf)
|
| return buf.getvalue()
|
|
|
|
|
|
|
|
|
|
|
| def build_all_funds_csv(funds_dict: dict, mkt_stats: dict) -> bytes:
|
| rows = []
|
| for name, code in list(funds_dict.items()):
|
| a = infer_allocation_from_name(name)
|
| rows.append({"Scheme Name": name, "Scheme Code": code,
|
| "Asset Class": a["asset_class"], "Category": a["category"]})
|
| return pd.DataFrame(rows).to_csv(index=False).encode("utf-8")
|
|
|
|
|
| def build_selected_funds_csv(perf_df: pd.DataFrame, alloc_df: pd.DataFrame) -> bytes:
|
| return pd.merge(perf_df, alloc_df, on="Scheme", how="left").to_csv(index=False).encode("utf-8")
|
|
|
|
|
|
|
|
|
|
|
| def main():
|
|
|
| with st.sidebar:
|
| if os.path.exists(LOGO_PATH):
|
| st.image(LOGO_PATH, width=160)
|
| else:
|
| st.markdown(
|
| "<div style='font-size:20px;font-weight:bold;color:#1B4F72'>π§ Disha Wealth</div>",
|
| unsafe_allow_html=True)
|
| st.caption(f"{ADVISOR_NAME} | {ADVISOR_ARN}")
|
| st.divider()
|
| page = st.radio(
|
| "Navigation",
|
| ["π Proposal Generator", "π NAV History & Comparison"],
|
| key="nav_page"
|
| )
|
| st.divider()
|
| st.caption("Data source: mfapi.in / AMFI")
|
| st.caption("Metrics: Risk-free = 6.5% p.a.")
|
|
|
|
|
| with st.spinner("Loading AMFI fund universeβ¦"):
|
| funds_dict = fetch_amfi_fund_list()
|
|
|
| if not funds_dict:
|
| st.error("Could not load fund list. Please check your internet connection.")
|
| return
|
|
|
|
|
| if page == "π Proposal Generator":
|
|
|
| st.markdown(
|
| "<h1 style='text-align:center;color:#1B4F72'>π§ Disha Wealth</h1>"
|
| "<h4 style='text-align:center;color:#555'>Your Compass to Financial Freedom</h4>",
|
| unsafe_allow_html=True)
|
| st.caption(f"Prepared By: **{ADVISOR_NAME}** | {ADVISOR_ARN}")
|
| st.divider()
|
|
|
|
|
| st.subheader("π Step 1 β Client & Investment Details")
|
| c1,c2,c3,c4 = st.columns(4)
|
| client_name = c1.text_input("Client Name", placeholder="e.g. Divya")
|
| monthly_sip = c2.number_input("Monthly SIP (Rs.)", value=10_000, step=5_000)
|
| annual_topup = c3.number_input("Annual Top-Up (Rs.)", value=1_000, step=1_000)
|
| horizon_yrs = c4.number_input("Investment Horizon (yrs)", value=15, step=1,
|
| min_value=1, max_value=40)
|
| c5,c6,c7 = st.columns(3)
|
| expected_ret = c5.number_input("Assumed Return (% p.a.)", value=12.0, step=0.5)
|
| risk_profile = c6.selectbox("Risk Profile",
|
| ["Low","Moderate","Moderately High","High","Very High"])
|
| investment_objective = c7.selectbox("Investment Objective",
|
| ["Wealth Building","Retirement Planning",
|
| "Child Education","Tax Saving",
|
| "Regular Income","Capital Preservation","Other"])
|
|
|
|
|
| st.divider()
|
| st.subheader("π¦ Step 2 β Select Mutual Funds")
|
|
|
| fund_names_all = sorted(funds_dict.keys())
|
|
|
| default_sel = []
|
| for kw in DEFAULT_FUND_KEYWORDS:
|
| for f in fund_names_all:
|
| if kw.lower() in f.lower():
|
| if f not in default_sel:
|
| default_sel.append(f)
|
| break
|
| default_sel = default_sel[:13]
|
|
|
| selected_funds = st.multiselect(
|
| "Search & select funds (defaults = Disha recommended list):",
|
| fund_names_all, default=default_sel,
|
| help="Type fund name to search.")
|
| if not selected_funds:
|
| st.info("Select at least one fund to continue.")
|
| return
|
|
|
|
|
| st.divider()
|
| st.subheader("π Step 3 β Set SIP Allocation (%)")
|
|
|
| if "alloc_data" not in st.session_state:
|
| st.session_state.alloc_data = {}
|
| for f in selected_funds:
|
| if f not in st.session_state.alloc_data:
|
| st.session_state.alloc_data[f] = round(100 / len(selected_funds), 1)
|
| for f in list(st.session_state.alloc_data):
|
| if f not in selected_funds:
|
| del st.session_state.alloc_data[f]
|
|
|
| cols = st.columns(min(len(selected_funds), 4))
|
| for i, fund in enumerate(selected_funds):
|
| st.session_state.alloc_data[fund] = cols[i % 4].number_input(
|
| f"{fund[:28]}β¦" if len(fund) > 90 else fund,
|
| value=float(st.session_state.alloc_data[fund]),
|
| min_value=0.0, max_value=100.0, step=0.5, key=f"alloc_{i}")
|
|
|
| alloc_pcts = st.session_state.alloc_data
|
| total_alloc = sum(alloc_pcts.values())
|
| st.metric("Total Allocation", f"{total_alloc:.1f}%",
|
| delta="β OK" if abs(total_alloc-100) < 0.5 else f"{100-total_alloc:+.1f}% remaining")
|
|
|
|
|
| st.divider()
|
| go = st.button("π Generate Proposal", type="primary", use_container_width=True)
|
| if not go:
|
| return
|
| if not client_name:
|
| st.error("Enter client name.")
|
| return
|
| if abs(total_alloc - 100) > 0.5:
|
| st.error("Allocations must sum to exactly 100%.")
|
| return
|
|
|
| st.markdown("---")
|
| st.markdown(
|
| f"## π Investment Proposal β {client_name}\n"
|
| f"**Date:** {datetime.today().strftime('%d %b %Y')} | "
|
| f"**Advisor:** {ADVISOR_NAME} {ADVISOR_ARN} | "
|
| f"**Risk:** {risk_profile} | **Horizon:** {horizon_yrs} yrs | "
|
| f"**Objective:** {investment_objective}")
|
|
|
|
|
| st.subheader("π A. Wealth Compounding Projection")
|
| proj_df = build_projection_table(monthly_sip, annual_topup, expected_ret)
|
| st.dataframe(proj_df, use_container_width=True, hide_index=True)
|
| st.caption(f"Assumed {expected_ret}% p.a. | SIP Rs.{monthly_sip:,}/mo | Top-Up Rs.{annual_topup:,}/yr")
|
|
|
| eq_pct = 70
|
| neg_ret = probability_of_negative_returns(eq_pct)
|
| col_a,col_b,col_c = st.columns(3)
|
| col_a.metric("Prob. Negative (1Y)", f"{neg_ret['1Y']:.2f}%")
|
| col_b.metric("Prob. Negative (3Y)", f"{neg_ret['3Y']:.2f}%")
|
| col_c.metric("Prob. Negative (15Y)", "0.00%")
|
|
|
|
|
| st.subheader("π B. Portfolio Composition")
|
| comp_rows = []
|
| for fund, pct in alloc_pcts.items():
|
| a = infer_allocation_from_name(fund)
|
| comp_rows.append({
|
| "Scheme Name": fund,
|
| "Category": a["category"],
|
| "Asset Class": a["asset_class"],
|
| "Alloc %": f"{pct:.1f}%",
|
| "SIP (Rs.)": f"Rs.{monthly_sip*pct/100:,.0f}",
|
| "Top-Up (Rs.)": f"Rs.{annual_topup*pct/100:,.0f}",
|
| })
|
| comp_df = pd.DataFrame(comp_rows)
|
| st.dataframe(comp_df, use_container_width=True, hide_index=True)
|
|
|
|
|
| st.subheader("π C. Scheme Performance & Risk Metrics")
|
| st.caption("βΉοΈ Max Drawdown shown is for last 3 years")
|
| with st.spinner("Fetching NAV data from mfapi.inβ¦"):
|
| mkt_code = funds_dict.get("Nippon India Nifty 500 Index Fund - Regular Growth",
|
| funds_dict.get("Nippon India Multi Cap Fund - Regular Growth", "118701"))
|
| mkt_stats = fetch_nav_stats(mkt_code)
|
| all_stats = {}
|
| perf_rows = []
|
| for fund, pct in alloc_pcts.items():
|
| code = funds_dict.get(fund, "")
|
| stats = fetch_nav_stats(code) if code else {}
|
| all_stats[fund] = stats
|
| beta = compute_beta_from_stats(stats, mkt_stats)
|
| perf_rows.append({
|
| "Scheme": fund,
|
| "Alloc %": f"{pct:.1f}%",
|
| "3Y CAGR": stats.get("3Y CAGR", "-"),
|
| "5Y CAGR": stats.get("5Y CAGR", "-"),
|
| "10Y CAGR": stats.get("10Y CAGR", "-"),
|
| "15Y CAGR": stats.get("15Y CAGR", "-"),
|
| "Std Dev": stats.get("Std Dev", "-"),
|
| "Sharpe": stats.get("Sharpe", "-"),
|
| "Sortino": stats.get("Sortino", "-"),
|
| "Max DD (3Y)": stats.get("Max DD (3Y)", "-"),
|
| "Beta": beta,
|
| })
|
| perf_df = pd.DataFrame(perf_rows)
|
| st.dataframe(perf_df, use_container_width=True, hide_index=True)
|
|
|
|
|
| st.subheader("ποΈ D. Asset & Market-Cap Allocation")
|
| with st.spinner("Fetching portfolio allocation dataβ¦"):
|
| alloc_rows = []
|
| for fund, pct in alloc_pcts.items():
|
| code = funds_dict.get(fund, "")
|
| a = fetch_portfolio_allocation_amfi(code, fund)
|
| alloc_rows.append({
|
| "Scheme": fund,
|
| "Category": a["category"],
|
| "Alloc %": f"{pct:.1f}%",
|
| "Equity %": f"{a.get('equity','-')}",
|
| "Debt %": f"{a.get('debt','-')}",
|
| "Gold %": f"{a.get('gold','-')}",
|
| "Intl %": f"{a.get('intl','-')}",
|
| "Cash %": f"{a.get('cash','-')}",
|
| "Large Cap %": f"{a.get('large_cap','-')}",
|
| "Mid Cap %": f"{a.get('mid_cap','-')}",
|
| "Small Cap %": f"{a.get('small_cap','-')}",
|
| "Source": a.get("source", "Inferred"),
|
| })
|
| alloc_df = pd.DataFrame(alloc_rows)
|
| st.dataframe(alloc_df, use_container_width=True, hide_index=True)
|
| st.caption("Source: AMFI β mfapi meta β SEBI category rule inference")
|
|
|
| funds_list = list(alloc_pcts.keys())
|
| pcts_list = list(alloc_pcts.values())
|
| wtd = compute_weighted_allocation(funds_list, pcts_list)
|
| st.markdown("**π Portfolio Weighted Average Allocation**")
|
| col_list = st.columns(8)
|
| for col, (label, key) in zip(col_list, [
|
| ("Equity","equity"),("Debt","debt"),("Gold","gold"),("Intl","intl"),("Cash","cash"),
|
| ("Large Cap","large_cap"),("Mid Cap","mid_cap"),("Small Cap","small_cap")
|
| ]):
|
| col.metric(label, f"{wtd[key]}%")
|
|
|
| with st.expander("π Disclaimer"):
|
| st.markdown(
|
| "This proposal is prepared by an AMFI-registered Mutual Fund Distributor. "
|
| "Mutual fund investments are subject to market risks. Read all scheme-related "
|
| "documents carefully. Past performance is not a guarantee of future returns. "
|
| "Projections are illustrative only.")
|
|
|
|
|
| st.divider()
|
| st.subheader("πΎ Export")
|
| ec1,ec2 = st.columns(2)
|
|
|
| all_csv = build_all_funds_csv(funds_dict, mkt_stats)
|
| ec1.download_button("π₯ All AMFI Funds (CSV)", data=all_csv,
|
| file_name=f"AMFI_All_Funds_{datetime.today().strftime('%Y%m%d')}.csv",
|
| mime="text/csv", use_container_width=True)
|
|
|
| selected_csv = build_selected_funds_csv(perf_df, alloc_df)
|
| ec2.download_button("π₯ Selected Funds Metrics (CSV)", data=selected_csv,
|
| file_name=f"Selected_Funds_{client_name.replace(' ','_')}_{datetime.today().strftime('%Y%m%d')}.csv",
|
| mime="text/csv", use_container_width=True)
|
|
|
| xls_buf = io.BytesIO()
|
| with pd.ExcelWriter(xls_buf, engine="openpyxl") as w:
|
| proj_df.to_excel(w, sheet_name="Projection", index=False)
|
| comp_df.to_excel(w, sheet_name="Portfolio", index=False)
|
| perf_df.to_excel(w, sheet_name="Performance", index=False)
|
| alloc_df.to_excel(w, sheet_name="Allocation", index=False)
|
|
|
| with st.spinner("Generating PDFβ¦"):
|
| pdf_bytes = generate_pdf(
|
| client_name, investment_objective, horizon_yrs,
|
| proj_df, comp_df, perf_df, alloc_df,
|
| monthly_sip, annual_topup, risk_profile, wtd,
|
| expected_ret, all_stats)
|
|
|
| col_xls,col_pdf = st.columns(2)
|
| col_xls.download_button("π Download Excel", data=xls_buf.getvalue(),
|
| file_name=f"MF_Proposal_{client_name.replace(' ','_')}_{datetime.today().strftime('%Y%m%d')}.xlsx",
|
| mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| use_container_width=True)
|
| col_pdf.download_button("π Download PDF (Landscape)", data=pdf_bytes,
|
| file_name=f"MF_Proposal_{client_name.replace(' ','_')}_{datetime.today().strftime('%Y%m%d')}.pdf",
|
| mime="application/pdf", use_container_width=True)
|
|
|
| elif page == "π NAV History & Comparison":
|
| show_nav_history_page()
|
| if __name__ == "__main__":
|
| main() |