Spaces:
Runtime error
Runtime error
| """ | |
| 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") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CONFIGURATION | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 FALLBACK | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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", | |
| ] | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MODULE 1 β AMFI FUND LIST | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| 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 | |
| # fallback: first word(s) up to common separators | |
| parts = scheme_name.split() | |
| return parts[0] if parts else "Other" | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MODULE 2 β NAV + STATISTICS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| 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.") | |
| # Fetch the full list of funds {schemeName: schemeCode} | |
| amfi_dict = fetch_amfi_fund_list() | |
| all_fund_names = list(amfi_dict.keys()) | |
| # 1. First Box: Highly Specific Granular Category Selection | |
| 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) | |
| # Filtering logic parsed in a structural ordered manner to isolate precise sub-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 | |
| # Filter the asset registry database arrays matching the precise flag metrics | |
| 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 | |
| # ββ ADVANCED ADDITION: BULK EXCEL EXPORT ROUTINE ββ | |
| 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] # Limit evaluation size to 25 records to protect API pipeline from timing out | |
| 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() | |
| # FIXED: Sanitizing sheet title by removing '/' or spaces to prevent openpyxl exceptions | |
| 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 | |
| ) | |
| # 2. Second Box: Multi-Select Funds within that Type | |
| 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..." | |
| ) | |
| # 3. Fetch Statistics and Display Data Table | |
| 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.") | |
| # ββ ADVANCED ADDITION: DAILY CALENDAR TRAILING HISTORIES ββ | |
| 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.") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MODULE 3 β SEBI CATEGORY MAP + ALLOCATION INFERENCE | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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) | |
| 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 | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MODULE 4 β PROJECTIONS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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, | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MODULE 5 β WEIGHTED PORTFOLIO SUMMARY | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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()} | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MODULE 6 β PDF STYLES & HELPERS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MODULE 7 β PDF GENERATION | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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() | |
| # ββ COVER ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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()] | |
| # ββ INTRODUCTION βββββββββββββββββββββββββββββββββββββββββββ | |
| 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()) | |
| # ββ PROPOSAL DETAILS βββββββββββββββββββββββββββββββββββββββ | |
| 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()] | |
| # ββ ASSET ALLOCATION & PROJECTION ββββββββββββββββββββββββββ | |
| 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()) | |
| # ββ PORTFOLIO COMPOSITION ββββββββββββββββββββββββββββββββββ | |
| 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()) | |
| # ββ SCHEME PERFORMANCE βββββββββββββββββββββββββββββββββββββ | |
| 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()) | |
| # ββ ASSET & MCAP ALLOCATION ββββββββββββββββββββββββββββββββ | |
| 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()) | |
| # ββ SCHEME INSIGHTS ββββββββββββββββββββββββββββββββββββββββ | |
| 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()) | |
| # ββ EXPECTATIONS & DISCLAIMER ββββββββββββββββββββββββββββββ | |
| 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() | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # MODULE 8 β CSV EXPORTS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # STREAMLIT MAIN UI β Page router | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main(): | |
| # ββ Sidebar navigation ββββββββββββββββββββββββββββββββββ | |
| 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.") | |
| # ββ Load AMFI fund universe ββββββββββββββββββββββββ | |
| 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 | |
| # ββ Route to selected page ββββββββββββββββββββββββββββββ | |
| 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() | |
| # ββ Step 1: Client Details ββββββββββββββββββββββββββββββ | |
| 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"]) | |
| # ββ Step 2: Fund Selection ββββββββββββββββββββββββββββββ | |
| 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 | |
| # ββ Step 3: Allocation ββββββββββββββββββββββββββββββββββ | |
| 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") | |
| # ββ Generate ββββββββββββββββββββββββββββββββββββββββββββ | |
| 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}") | |
| # A: Projection | |
| 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%") | |
| # B: Composition | |
| 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) | |
| # C: Performance | |
| 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) | |
| # D: Allocation | |
| 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.") | |
| # ββ EXPORTS ββββββββββββββββββββββββββββββββββββββββββββ | |
| 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() |