diff --git a/Reference/aboutdata.txt b/Reference/aboutdata.txt new file mode 100644 index 0000000000000000000000000000000000000000..374d352f4926e0d5e9cf20fad4ca02ce6f859da2 --- /dev/null +++ b/Reference/aboutdata.txt @@ -0,0 +1,118 @@ +Columns in the dataset: +['Name', 'Age', 'City', 'Profession', 'Salary', 'Expenses', 'Savings', 'Lifecycle Stage', 'Risk Appetite', 'Investment Horizon', 'Equity (%)', 'Debt (%)', 'Gold (%)', 'FD/Cash (%)'] + +Preview of the data: + Name Age City Profession Salary Expenses Savings \ +0 Rohan Sharma 28 Mumbai Software Engineer 85000 65000 20000 +1 Priya Patel 45 Delhi Doctor 220000 150000 70000 +2 Arjun Singh 60 Bangalore Retired Banker 45000 40000 5000 +3 Anika Reddy 22 Hyderabad Student 15000 14000 1000 +4 Vikram Mehta 35 Pune Marketing Manager 140000 100000 40000 + + Lifecycle Stage Risk Appetite Investment Horizon Equity (%) Debt (%) \ +0 Early Career Medium Long-term 45 25 +1 Mid-Career Medium Medium-term 40 30 +2 Retired Low Short-term 10 40 +3 Student Low Short-term 0 30 +4 Early Career High Long-term 70 15 + + Gold (%) FD/Cash (%) +0 10 20 +1 10 20 +2 15 35 +3 5 65 +4 5 10 + +Data types: +Name object +Age int64 +City object +Profession object +Salary int64 +Expenses int64 +Savings int64 +Lifecycle Stage object +Risk Appetite object +Investment Horizon object +Equity (%) int64 +Debt (%) int64 +Gold (%) int64 +FD/Cash (%) int64 +dtype: object + +Unique values in 'City': +['Mumbai' 'Delhi' 'Bangalore' 'Hyderabad' 'Pune' 'Chennai' 'Jaipur' + 'Kochi' 'Kolkata' 'Ahmedabad' 'Gurgaon' 'Lucknow' 'Nagpur' 'Chandigarh' + 'Surat' 'Indore' 'Bhopal'] + +Unique values in 'Profession': +['Software Engineer' 'Doctor' 'Retired Banker' 'Student' + 'Marketing Manager' 'Teacher' 'Freelancer' 'Architect' 'Business Owner' + 'Nurse' 'Product Manager' 'CA' 'Data Analyst' 'Retired Professor' + 'Journalist' 'Graphic Designer' 'Sales Executive' 'HR Manager' 'Intern' + 'Dentist' 'Lawyer' 'Content Creator' 'Pensioner' 'UX Designer' + 'Government Employee' 'Fashion Designer' 'Startup Founder' + 'Homemaker (Investor)' 'Investment Banker' 'IT Consultant' + 'College Student' 'Pharmacist' 'Textile Business Owner' 'Event Planner' + 'Film Producer' 'Nutritionist' 'Retired Army Officer' + 'Social Media Manager' 'Airline Pilot' 'Biotech Researcher' + 'Real Estate Agent' 'AI Engineer' 'Retired Teacher' 'Financial Analyst' + 'Software Developer' 'NGO Director' 'Fitness Trainer' 'Digital Marketer' + 'Content Strategist' 'Retired Engineer' 'UI Developer' + 'Operations Manager' 'Corporate Lawyer' 'School Principal' + 'AI Researcher' 'Junior Doctor' 'Finance Manager' 'Fashion Blogger' + 'HR Consultant' 'Video Editor' 'Small Business Owner' 'Dietitian' + 'Supply Chain Manager' 'Interior Designer' 'Sales Manager' 'PR Executive' + 'Logistics Head' 'Content Writer' 'Retired Govt. Employee' + 'Marketing Lead' 'IT Manager' 'Startup Intern' 'Export Manager' + 'Fitness Instructor' 'Real Estate Broker' 'Event Manager' + 'Software Trainee' 'Data Scientist' 'HR Director' 'Hotel Manager' + 'Social Worker' 'Cybersecurity Expert' 'E-commerce Manager' + 'Bank Manager' 'Retired IT Manager' 'UX Researcher' 'Product Designer' + 'Cybersecurity Analyst' 'Podcast Producer' 'E-commerce Seller' + 'Cloud Architect' 'Corporate Trainer' 'AI Trainer' 'Supply Chain Head' + 'Product Owner' 'UI/UX Designer' 'Finance Director' + 'Sustainability Consultant' 'Retired Bank Manager' + 'Social Media Influencer' 'Blockchain Developer' 'NGO Head' + 'Data Engineer' 'Event Curator' 'CTO' 'Content Marketer' + 'Retired Army Major' 'AR/VR Developer' 'Logistics Manager' 'VP Sales' + 'EdTech Founder' 'SEO Specialist' 'Yoga Instructor' 'IT Director' + 'App Developer' 'Consultant Cardiologist' 'Freelance Writer' + 'Cloud Engineer' 'Retired CA' 'Digital Artist' 'Retired Pilot' + 'Graphic Animator' 'AI Ethicist' 'School Trustee' 'Robotics Engineer' + 'Fashion Stylist' 'Retired Nurse' 'AI Ethics Consultant' 'Drone Engineer' + 'Retired Bank Clerk' 'Digital Nomad' 'CFO' 'Sustainability Analyst' + 'Retired Journalist' 'Social Entrepreneur' 'DevOps Engineer' + 'School Counselor' 'Retired Army Colonel' 'AR Developer' 'Sales Director' + 'EdTech Consultant' 'SEO Expert' 'Cardiologist' '3D Artist' 'HR Head' + 'Animator' 'Fashion Influencer'] + +Unique values in 'Lifecycle Stage': +['Early Career' 'Mid-Career' 'Retired' 'Student' 'Late Career'] + +Unique values in 'Risk Appetite': +['Medium' 'Low' 'High'] + +Unique values in 'Investment Horizon': +['Long-term' 'Medium-term' 'Short-term'] + +Numerical columns summary: + Salary Expenses Savings Equity (%) Debt (%) \ +count 198.000000 198.000000 198.000000 198.000000 198.000000 +mean 115075.757576 84686.868687 30388.888889 39.469697 28.308081 +std 69388.842695 48499.494260 21425.713424 20.713089 11.534149 +min 8000.000000 7500.000000 500.000000 0.000000 10.000000 +25% 60000.000000 48000.000000 10000.000000 25.000000 20.000000 +50% 95000.000000 70000.000000 25000.000000 45.000000 25.000000 +75% 163750.000000 118750.000000 45000.000000 50.000000 30.000000 +max 310000.000000 230000.000000 90000.000000 70.000000 55.000000 + + Gold (%) FD/Cash (%) +count 198.000000 198.000000 +mean 8.434343 23.787879 +std 3.427751 16.426902 +min 0.000000 10.000000 +25% 5.000000 15.000000 +50% 10.000000 20.000000 +75% 10.000000 20.000000 +max 15.000000 80.000000 \ No newline at end of file diff --git a/Reference/basemodel.py b/Reference/basemodel.py new file mode 100644 index 0000000000000000000000000000000000000000..e77dbf1c6778f5066bcb576c021126f1c779a625 --- /dev/null +++ b/Reference/basemodel.py @@ -0,0 +1,46 @@ +import joblib + +# Load the basic model pipeline +pipeline = joblib.load('basic_portfolio_model.pkl') + +input_data = { + 'Salary': 150000, + 'Expenses': 100000, + 'Savings': 50000, + 'Lifecycle Stage': 'Mid-Career', + 'Risk Appetite': 'Medium', + 'Investment Horizon': 'Long-term' +} + +# Convert categorical features (FIXED SYNTAX) +input_data['Lifecycle Stage'] = pipeline['mappings']['lifecycle'][input_data['Lifecycle Stage']] # Added closing ] +input_data['Risk Appetite'] = pipeline['mappings']['risk'][input_data['Risk Appetite']] # Added closing ] +input_data['Investment Horizon'] = pipeline['mappings']['horizon'][input_data['Investment Horizon']] # Added closing ] + +# Create feature array in correct order +X = [ + input_data['Salary'], + input_data['Expenses'], + input_data['Savings'], + input_data['Lifecycle Stage'], + input_data['Risk Appetite'], + input_data['Investment Horizon'] +] + +# Scale and predict +X_scaled = pipeline['scaler'].transform([X]) +pred = pipeline['model'].predict(X_scaled)[0] + +# Normalize and format +total = pred.sum() +final_allocation = { + 'Equity': round((pred[0]/total)*100, 1), + 'Debt': round((pred[1]/total)*100, 1), + 'Gold': round((pred[2]/total)*100, 1), + 'FD/Cash': round((pred[3]/total)*100, 1) +} + +print("Recommended Portfolio:") +for asset, perc in final_allocation.items(): + print(f"{asset}: {perc}%") +print(f"Total: {sum(final_allocation.values())}%") \ No newline at end of file diff --git a/Reference/basic_portfolio_model.pkl b/Reference/basic_portfolio_model.pkl new file mode 100644 index 0000000000000000000000000000000000000000..5c8f03f07bf70bd370dbc2918d095f362ef17b0c --- /dev/null +++ b/Reference/basic_portfolio_model.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae20f4cf2e1cb3f75623f692aa11f947c83529dbe6335a5634365052bd8d15ca +size 1817898 diff --git a/Reference/enhanced_portfolio_model.pkl b/Reference/enhanced_portfolio_model.pkl new file mode 100644 index 0000000000000000000000000000000000000000..18ac3c732becc718c8ee34b4ce3b7fdbc0710520 --- /dev/null +++ b/Reference/enhanced_portfolio_model.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2560b79a9705ca3674cd96820fd31fd6799dde89b12d4815afc6887111b52746 +size 1691633 diff --git a/Reference/enhancedmodel.py b/Reference/enhancedmodel.py new file mode 100644 index 0000000000000000000000000000000000000000..f64294e588d1794b22bc5a90f67191d411fc4af2 --- /dev/null +++ b/Reference/enhancedmodel.py @@ -0,0 +1,56 @@ +import joblib +import pandas as pd + +# Load enhanced model pipeline +pipeline = joblib.load('enhanced_portfolio_model.pkl') + +# Sample input (MUST include Profession/City) +test_case = { + 'Profession': 'Software Engineer', + 'City': 'Mumbai', + 'Salary': 150000, + 'Expenses': 100000, + 'Savings': 50000, + 'Lifecycle Stage': 'Mid-Career', + 'Risk Appetite': 'Medium', + 'Investment Horizon': 'Long-term' +} + +# Convert categorical features +test_case['Profession'] = pipeline['profession_encoder'].transform([test_case['Profession']])[0] +test_case['City'] = pipeline['city_encoder'].transform([test_case['City']])[0] +test_case['Lifecycle Stage'] = pipeline['mappings']['lifecycle'][test_case['Lifecycle Stage']] +test_case['Risk Appetite'] = pipeline['mappings']['risk'][test_case['Risk Appetite']] +test_case['Investment Horizon'] = pipeline['mappings']['horizon'][test_case['Investment Horizon']] + +# Create feature array IN EXACT ORDER: +# ['Profession', 'City', 'Salary', 'Expenses', 'Savings', +# 'Lifecycle Stage', 'Risk Appetite', 'Investment Horizon'] +X = [ + test_case['Profession'], + test_case['City'], + test_case['Salary'], + test_case['Expenses'], + test_case['Savings'], + test_case['Lifecycle Stage'], + test_case['Risk Appetite'], + test_case['Investment Horizon'] +] + +# Scale and predict +X_scaled = pipeline['scaler'].transform([X]) +pred = pipeline['model'].predict(X_scaled)[0] + +# Normalize to 100% +total = pred.sum() +final_allocation = { + 'Equity': round((pred[0]/total)*100, 1), + 'Debt': round((pred[1]/total)*100, 1), + 'Gold': round((pred[2]/total)*100, 1), + 'FD/Cash': round((pred[3]/total)*100, 1) +} + +print("Enhanced Model Recommended Portfolio:") +for asset, perc in final_allocation.items(): + print(f"{asset}: {perc}%") +print(f"Total: {sum(final_allocation.values())}%") \ No newline at end of file diff --git a/Reference/rulebasedmodel.py b/Reference/rulebasedmodel.py new file mode 100644 index 0000000000000000000000000000000000000000..4c1b703f04ea3320f9f7f06979eb4645c6edcbe7 --- /dev/null +++ b/Reference/rulebasedmodel.py @@ -0,0 +1,314 @@ +import datetime +import math + +# --- Helper Functions (mostly unchanged) --- +def get_float(prompt): + """Helper to get a non-negative float from the user.""" + while True: + try: + val_str = input(prompt).strip() + if not val_str: + raise ValueError("Input cannot be empty.") + val = float(val_str) + if val < 0: + raise ValueError("Please enter a non-negative number.") + return val + except ValueError as e: + print(f"Invalid input: {e}") + +def get_int(prompt): + """Helper to get a non-negative integer from the user.""" + while True: + try: + val_str = input(prompt).strip() + if not val_str: + raise ValueError("Input cannot be empty.") + val = int(val_str) + if val < 0: + raise ValueError("Please enter a non-negative whole number.") + return val + except ValueError as e: + print(f"Invalid input: {e}") + +def yes_no(prompt): + """Helper to get a yes/no response.""" + while True: + resp = input(prompt + " (y/n): ").strip().lower() + if resp in ("y", "yes"): + return True + if resp in ("n", "no"): + return False + print("Please answer 'y' or 'n'.") + +# --- Simplified Profile and Financials Gathering --- + +def get_user_profile(): + """Gathers lifecycle stage, risk appetite, and investment horizon.""" + print("\n--- Let's Understand Your Investor Profile ---") + + # 1. Lifecycle Stage + print("\nWhich of these best describes your current life stage?") + lifecycle_options = { + "1": "Student (Focus: Learning, initial savings)", + "2": "Early Career (20s-early 30s, Focus: Growth, accumulation)", + "3": "Mid-Career (Mid 30s-late 40s, Focus: Balancing growth & responsibilities)", + "4": "Late Career/Pre-Retirement (50s+, Focus: Capital preservation, income generation)", + "5": "Retired (Focus: Sustainable income, capital preservation)" + } + for key, value in lifecycle_options.items(): + print(f"{key}. {value}") + while True: + choice = input("Enter number (1-5): ").strip() + if choice in lifecycle_options: + lifecycle_stage = lifecycle_options[choice].split('(')[0].strip() # Get "Student", "Early Career" etc. + break + else: + print("Invalid choice. Please enter a number between 1 and 5.") + + # 2. Risk Appetite + print("\nHow would you describe your risk tolerance for investments?") + risk_options = { + "1": "Low (Prefer safety and capital preservation, okay with lower returns)", + "2": "Medium (Willing to take some risk for moderate growth, comfortable with some fluctuations)", + "3": "High (Willing to take significant risk for potentially high returns, can handle large fluctuations)" + } + for key, value in risk_options.items(): + print(f"{key}. {value}") + while True: + choice = input("Enter number (1-3): ").strip() + if choice == "1": risk_appetite = "low"; break + elif choice == "2": risk_appetite = "medium"; break + elif choice == "3": risk_appetite = "high"; break + else: + print("Invalid choice. Please enter 1, 2, or 3.") + + # 3. Investment Horizon + print("\nWhat is your general investment time horizon for the majority of your savings?") + horizon_options = { + "1": "Short-term (Less than 3 years)", + "2": "Medium-term (3-7 years)", + "3": "Long-term (More than 7 years)" + } + for key, value in horizon_options.items(): + print(f"{key}. {value}") + while True: + choice = input("Enter number (1-3): ").strip() + if choice == "1": investment_horizon = "short-term"; break + elif choice == "2": investment_horizon = "medium-term"; break + elif choice == "3": investment_horizon = "long-term"; break + else: + print("Invalid choice. Please enter 1, 2, or 3.") + + return lifecycle_stage, risk_appetite, investment_horizon + +def get_financial_details(): + """Gets annual package, monthly in-hand salary, and total monthly expenses.""" + print("\n--- Let's Get Your Financial Details ---") + annual_package = get_float("What is your approximate annual salary package (CTC)? \u20B9 ") + + # Simplified monthly in-hand: Ask directly or provide a rough estimate and ask for correction + print(f"\nBased on an annual package of \u20B9 {annual_package:.2f}, your monthly in-hand salary might be around \u20B9 {annual_package / 14:.2f} to \u20B9 {annual_package / 13:.2f} (this is a rough estimate).") + monthly_in_hand_salary = get_float("What is your actual average monthly in-hand salary? \u20B9 ") + + total_monthly_expenses = get_float("What are your total estimated monthly expenses (all inclusive)? \u20B9 ") + + monthly_savings = monthly_in_hand_salary - total_monthly_expenses + + print(f"\nBased on your input:") + print(f" Monthly In-hand Salary: \u20B9 {monthly_in_hand_salary:.2f}") + print(f" Total Monthly Expenses: \u20B9 {total_monthly_expenses:.2f}") + if monthly_savings > 0: + print(f" Calculated Monthly Savings: \u20B9 {monthly_savings:.2f}") + else: + print(f" Calculated Monthly Deficit: \u20B9 {abs(monthly_savings):.2f}") + print(" It seems your expenses exceed your income. Please review your budget.") + + return monthly_in_hand_salary, total_monthly_expenses, monthly_savings + +# --- Simplified Investment Allocation Logic --- +def allocate_savings_simplified(savings, risk_profile, horizon, lifecycle): + """ + Determines investment allocation percentages based on simplified profile. + Returns a dictionary of { "Asset Class": percentage, ... } + """ + allocations = {} + justification_points = [ + f"This allocation considers your {risk_profile} risk profile, {horizon} horizon, and {lifecycle} stage." + ] + + # Prioritize Emergency Fund if not explicitly covered or if savings are first-time + # For simplicity, this version assumes user will manage EF separately or this is for surplus post-EF. + # A more robust version would inquire about EF status. + + if risk_profile == "low": + allocations = { + "Fixed Deposits / Recurring Deposits (Safety & Stability)": 0.35, + "Debt Mutual Funds (Liquid/Short Duration - Better than savings account returns)": 0.30, + "Gold (SGBs/ETFs - Inflation Hedge, Diversification)": 0.10, + "Equity MFs (Large Cap/Index Funds - Long-term inflation beating, low volatility equity)": 0.15, + "Cash / Savings Account (Immediate Liquidity)": 0.10 + } + justification_points.append("- Focus on capital preservation and stable returns.") + justification_points.append("- Suitable for short-term goals or very conservative investors.") + if horizon != "short-term": + justification_points.append("- Even with a longer horizon, a 'low' risk choice emphasizes safety above all.") + + + elif risk_profile == "medium": + allocations = { + "Equity MFs (Diversified - Large & Mid Cap/Flexi Cap SIPs for growth)": 0.45, + "Debt Mutual Funds (For stability and portfolio balance)": 0.25, + "Fixed Deposits / PPF (Secure, long-term component)": 0.10, + "Gold (SGBs/ETFs - Diversification)": 0.10, + "Equity MFs (International - For global diversification, if comfortable)": 0.05, # Optional + "Cash / Savings Account (Buffer)": 0.05 + } + justification_points.append("- Aims for a balance between growth (equity) and stability (debt, gold).") + justification_points.append("- Suitable for medium to long-term goals.") + if lifecycle in ["Early Career", "Student"] and horizon == "long-term": + justification_points.append("- Your stage and horizon allow for good equity exposure for wealth creation.") + # Could slightly increase equity for very young, long-term, medium risk. + # allocations["Equity MFs (Diversified - Large & Mid Cap/Flexi Cap SIPs for growth)"] = 0.50 + # allocations["Debt Mutual Funds (For stability and portfolio balance)"] = 0.20 + + elif risk_profile == "high": + allocations = { + "Equity MFs (Aggressive Growth - Mid/Small Cap, Thematic SIPs, with research)": 0.60, + "Equity MFs (International - Global growth opportunities)": 0.15, + "Direct Stocks (If experienced & well-researched, otherwise add to Equity MFs)": 0.10, # User needs to self-assess this. + "Debt Mutual Funds (Strategic, for some diversification)": 0.05, + "Gold (SGBs/ETFs - Tactical Diversification)": 0.05, + "Alternative Inv. (REITs/InvITs - very small, if understood & suitable, else to Equity)": 0.05 + } + justification_points.append("- Focuses on maximizing long-term growth potential, accepting higher volatility.") + justification_points.append("- Best suited for long-term goals and investors comfortable with significant market swings.") + if lifecycle not in ["Early Career", "Mid-Career"] and horizon == "long-term": + justification_points.append(f"- CAUTION: High risk at {lifecycle} stage needs careful consideration of your overall financial stability and nearness to needing funds.") + if horizon != "long-term": + justification_points.append(f"- CAUTION: High risk for a {horizon} horizon is generally not advisable. Ensure goals truly allow for this risk.") + + + # Normalize percentages to ensure they sum to 100% + current_total_percentage = sum(allocations.values()) + if abs(current_total_percentage - 1.0) > 0.001: # If not already 100% + factor = 1.0 / current_total_percentage + normalized_allocations = {k: v * factor for k, v in allocations.items()} + # Small check to ensure the largest component doesn't become negative if factor is weird (shouldn't happen with positive inputs) + # And ensure no tiny values are left that make no sense, e.g. less than 0.5% could be merged. + # For simplicity, we'll assume initial definitions are close enough. + final_allocations = {} + temp_sum = 0 + for asset, perc in normalized_allocations.items(): + # Round to sensible points e.g. 1 decimal for percentage display + # but use more precision for calculation + final_allocations[asset] = perc + temp_sum += perc + + # Final check and adjustment of the largest item if sum isn't perfect due to rounding during normalization. + if abs(temp_sum - 1.0) > 0.0001: + diff = 1.0 - temp_sum + if final_allocations: # Check if dict is not empty + # Find largest item to adjust + largest_item_key = max(final_allocations, key=final_allocations.get) + final_allocations[largest_item_key] += diff + allocations = final_allocations + + + return allocations, justification_points + +def display_investment_plan(monthly_savings, allocations, justification): + """Displays the final investment plan and justification.""" + print("\n\n--- Your Personalized Investment Allocation Plan ---") + print(f"Based on your monthly savings of \u20B9 {monthly_savings:.2f}:\n") + + print("Suggested Allocation:") + print("---------------------------------------------------------------------------") + print(f"{'Asset Class/Instrument':<50} | {'Percentage':>12} | {'Amount (₹)':>12}") + print("---------------------------------------------------------------------------") + if not allocations: # Should not happen if logic is correct + print("No specific allocation generated. Please review inputs or consult an advisor.") + return + + total_allocated_amount_check = 0 + for asset, percentage in allocations.items(): + amount = monthly_savings * percentage + total_allocated_amount_check += amount + print(f"{asset:<50} | {percentage*100:>11.1f}% | {amount:>11.2f}") + print("---------------------------------------------------------------------------") + print(f"{'TOTAL':<50} | {'100.0%':>12} | {total_allocated_amount_check:>11.2f}") + + + print("\nWhy these allocations?") + for point in justification: + print(f"- {point}") + print("\n- Learn more about general investing principles at: https://www.investopedia.com/financial-advisor/asset-allocation/\n" + " and for Indian context: https://www.amfiindia.com (Investor Education section)") + + +def provide_general_financial_tips(lifecycle_stage, monthly_savings, original_salary): + """Provides general financial tips.""" + print("\n--- General Financial Tips ---") + tips = [] + if monthly_savings <= 0 and original_salary > 0 : + tips.append("- Your expenses currently meet or exceed your income. Focus on creating a budget to identify areas to cut back or explore ways to increase your income.") + elif original_salary > 0: + savings_rate = (monthly_savings / original_salary) * 100 + tips.append(f"- Your current savings rate is approximately {savings_rate:.1f}%. Aim for at least 20-30% if possible, especially in accumulation stages.") + if savings_rate < 15: + tips.append("- Consider reviewing discretionary spending to boost your savings rate.") + + tips.append("- Build & Maintain an Emergency Fund: Aim for 3-6 months of essential living expenses in a safe, liquid account (e.g., savings account, liquid mutual fund). This is your top priority before aggressive investing.") + tips.append("- Get Adequately Insured: Ensure you have sufficient health insurance for yourself and your family. If you have dependents, a term life insurance policy is crucial.") + tips.append("- Invest Regularly & Be Disciplined: Consistency through SIPs (Systematic Investment Plans) is key, especially for long-term goals. Don't try to time the market.") + tips.append("- Review Periodically: Revisit your financial plan and investments at least annually, or when major life events occur (marriage, new job, child, etc.).") + tips.append("- Understand Your Investments: Before investing in any product, understand its risks, costs (like expense ratios for MFs), and how it fits your goals.") + + if lifecycle_stage == "Student" or lifecycle_stage == "Early Career": + tips.append("- Focus on upskilling and career growth to increase your earning potential. Your human capital is your biggest asset at this stage.") + if lifecycle_stage == "Late Career/Pre-Retirement" or lifecycle_stage == "Retired": + tips.append("- Plan for healthcare expenses in retirement. Consider if your current investments align with generating regular income if needed.") + + for tip in tips: + print(f"* {tip}") + +# --- Main Program Flow (Simplified) --- +def main_simplified(): + print("╔══════════════════════════════════════════════════════════════╗") + print("║ Simplified Monthly Savings Allocator ║") + print(f"║ Today's Date: {datetime.date.today().strftime('%Y-%m-%d')} ║") + print("╚══════════════════════════════════════════════════════════════╝") + print("\nWelcome! This tool will help you allocate your monthly savings.") + print("This is for educational purposes and NOT financial advice.\n") + + # 1. Get User Profile + lifecycle, risk, horizon = get_user_profile() + print(f"\nYour Profile Summary: Lifecycle: {lifecycle}, Risk: {risk.capitalize()}, Horizon: {horizon.capitalize()}") + + # 2. Get Financial Details + salary, expenses, savings = get_financial_details() + + if savings <= 0: + print("\nSince you don't have a monthly surplus, investment allocation is not applicable now.") + print("Focus on budgeting and increasing savings first.") + else: + # 3. Allocate Savings + allocations_dict, justification_list = allocate_savings_simplified(savings, risk, horizon, lifecycle) + + # 4. Display Plan + display_investment_plan(savings, allocations_dict, justification_list) + + # 5. Provide General Tips + provide_general_financial_tips(lifecycle, savings, salary) + + # 6. Disclaimer + print("\n\n--- Disclaimer ---") + print("The information and suggestions provided by this script are for general guidance and educational purposes ONLY.") + print("This does NOT constitute financial, investment, tax, or legal advice.") + print("Investment decisions involve risks. Past performance is not indicative of future results.") + print("Asset allocation models are generalized and may not be suitable for your individual circumstances.") + print("It is strongly recommended to consult with a SEBI-registered Investment Adviser or a qualified financial professional for personalized advice.") + + print("\nBudget calculation and investment suggestions complete. Plan wisely!\n") + +if __name__ == "__main__": + main_simplified() \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b9a1248313aabe9eba944a1a6a768d2d7380246c --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# This file makes the 'app' directory a Python package. diff --git a/app/__pycache__/__init__.cpython-312.pyc b/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3fc9f753d9e09a4e7701d51756d93dd43122fa96 Binary files /dev/null and b/app/__pycache__/__init__.cpython-312.pyc differ diff --git a/app/__pycache__/admin.cpython-312.pyc b/app/__pycache__/admin.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fc8f02c59f66260d8984ac3ac609e1fe5b4ec2f1 Binary files /dev/null and b/app/__pycache__/admin.cpython-312.pyc differ diff --git a/app/__pycache__/auth.cpython-312.pyc b/app/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0356022548ac880e30b66dfade573cae074d5bc4 Binary files /dev/null and b/app/__pycache__/auth.cpython-312.pyc differ diff --git a/app/__pycache__/crud.cpython-312.pyc b/app/__pycache__/crud.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..54525169f2a67e63b1d5c2051fa09ce26a6f2e22 Binary files /dev/null and b/app/__pycache__/crud.cpython-312.pyc differ diff --git a/app/__pycache__/database.cpython-312.pyc b/app/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c5ad2f1abad3a985cfd9f6e896db0b333e5533d3 Binary files /dev/null and b/app/__pycache__/database.cpython-312.pyc differ diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8503ebf30a3291979abdd47b36795b39419e35bb Binary files /dev/null and b/app/__pycache__/main.cpython-312.pyc differ diff --git a/app/__pycache__/models.cpython-312.pyc b/app/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..399c1dbea2f089f129b51f66e2178e3ec844b1b9 Binary files /dev/null and b/app/__pycache__/models.cpython-312.pyc differ diff --git a/app/__pycache__/schemas.cpython-312.pyc b/app/__pycache__/schemas.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..39774fb9d7521ee07d07dc15f6e3a0a0086f42d6 Binary files /dev/null and b/app/__pycache__/schemas.cpython-312.pyc differ diff --git a/app/__pycache__/user.cpython-312.pyc b/app/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9d8240ec857c82acfb7ab304bbe76c440d39463a Binary files /dev/null and b/app/__pycache__/user.cpython-312.pyc differ diff --git a/app/admin.py b/app/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..5273e0250db33bcd6e47be98d0a0770c62c46a6a --- /dev/null +++ b/app/admin.py @@ -0,0 +1,73 @@ +from fastapi import APIRouter, Request, Depends, HTTPException, Query +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session +from typing import List + +from app import models, schemas, crud +from app.database import get_db +from app.auth import get_current_admin_user, get_current_active_user # Import both +from app.main import templates # Import templates from main.py + +router = APIRouter() + +# Route to serve the HTML shell for the dashboard +@router.get("/dashboard", response_class=HTMLResponse) +async def admin_dashboard_shell(request: Request): + # This route loads the page structure. + # Client-side JS will verify admin status and fetch data. + return templates.TemplateResponse("admin/dashboard.html", { + "request": request, + "title": "Admin Dashboard" + # No user or users_list passed here initially + }) + +# New API endpoint to fetch dashboard data (protected) +@router.get("/api/dashboard-data") +async def get_admin_dashboard_data( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_admin_user), # Ensures only admin can access + search_query: str = Query(None, alias="search") +): + if search_query: + user = crud.get_user_by_email(db, email=search_query) + users_list = [schemas.User.from_orm(user)] if user else [] # Convert to schema + else: + users = crud.get_users(db, limit=100) + users_list = [schemas.User.from_orm(u) for u in users] # Convert list to schema + + # Return data needed by the dashboard template's JS + return {"users_list": users_list, "search_query": search_query, "admin_email": current_user.email} + + +# Route to view specific user details (protected) +# This serves an HTML page, so it will also need the client-side auth check pattern +@router.get("/users/{user_id}", response_class=HTMLResponse) +async def admin_view_user_details_shell(request: Request, user_id: int): + # Serve the shell page. JS will fetch details. + return templates.TemplateResponse("admin/user_details.html", { + "request": request, + "user_id": user_id, # Pass user_id for JS to use + "title": f"User Details" # Generic title initially + }) + +# New API endpoint to fetch specific user details (protected) +@router.get("/api/users/{user_id}") +async def get_admin_user_details_data( + user_id: int, + db: Session = Depends(get_db), + current_admin: models.User = Depends(get_current_admin_user) # Ensure admin access +): + target_user = crud.get_user(db, user_id=user_id) + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + user_inputs_orm = crud.get_user_data_inputs_by_user_id(db, user_id=user_id, limit=100) + + # Convert ORM objects to Pydantic schemas for JSON response + target_user_schema = schemas.User.from_orm(target_user) + user_inputs_schema = [schemas.UserDataInputResponse.from_orm(item) for item in user_inputs_orm] + + return { + "target_user": target_user_schema, + "user_inputs": user_inputs_schema + } diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..e93fe3c7a1d9ebafa9e966eeddae775766fd2a7f --- /dev/null +++ b/app/auth.py @@ -0,0 +1,99 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Form +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from datetime import timedelta + +from . import crud, models, schemas +from .core import security +from .database import get_db + +router = APIRouter() + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token") # Points to the token login endpoint + +ACCESS_TOKEN_EXPIRE_MINUTES = security.ACCESS_TOKEN_EXPIRE_MINUTES + +async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> models.User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + payload = security.decode_access_token(token) + if payload is None: + raise credentials_exception + email: str = payload.get("sub") + if email is None: + raise credentials_exception + + user = crud.get_user_by_email(db, email=email) + if user is None: + raise credentials_exception + return user + +async def get_current_active_user(current_user: models.User = Depends(get_current_user)) -> models.User: + # Add any active/inactive checks here if needed + # if not current_user.is_active: + # raise HTTPException(status_code=400, detail="Inactive user") + return current_user + +async def get_current_admin_user(current_user: models.User = Depends(get_current_active_user)) -> models.User: + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="The user doesn't have enough privileges" + ) + return current_user + + +@router.post("/signup", response_model=schemas.User) +async def signup_user(user: schemas.UserCreate, db: Session = Depends(get_db)): + db_user = crud.get_user_by_email(db, email=user.email) + if db_user: + raise HTTPException(status_code=400, detail="Email already registered") + if user.password != user.confirm_password: + raise HTTPException(status_code=400, detail="Passwords do not match") + return crud.create_user(db=db, user=user) + +@router.post("/token", response_model=schemas.Token) +async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)): + user = crud.get_user_by_email(db, email=form_data.username) # OAuth2 form uses 'username' for email + if not user or not security.verify_password(form_data.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = security.create_access_token( + data={"sub": user.email}, expires_delta=access_token_expires + ) + return {"access_token": access_token, "token_type": "bearer"} + +# This is a utility endpoint, usually not directly called by frontend for login +# It's what OAuth2PasswordRequestForm uses internally. +# The actual login form should post to /auth/token. +@router.post("/login", response_model=schemas.Token) +async def login_user_form(email: str = Form(...), password: str = Form(...), db: Session = Depends(get_db)): + """ + Login endpoint that takes email and password from form data. + This is an alternative if not using OAuth2PasswordRequestForm directly in frontend JS. + Frontend forms would post to this. + """ + user = crud.get_user_by_email(db, email=email) + if not user or not security.verify_password(password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect email or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = security.create_access_token( + data={"sub": user.email}, expires_delta=access_token_expires + ) + return {"access_token": access_token, "token_type": "bearer"} + + +@router.get("/users/me", response_model=schemas.User) +async def read_users_me(current_user: models.User = Depends(get_current_active_user)): + return current_user diff --git a/app/core/__pycache__/security.cpython-312.pyc b/app/core/__pycache__/security.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9a52e3af1446a3ce2cb0d86aea2011f200d21366 Binary files /dev/null and b/app/core/__pycache__/security.cpython-312.pyc differ diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000000000000000000000000000000000000..08aebe8ec7865c6caf0a119809988134648ba8d2 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,40 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from pydantic import BaseModel + +# Password Hashing +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# JWT Configuration (Consider moving to a .env file or config management) +SECRET_KEY = "your-super-secret-key" # CHANGE THIS IN PRODUCTION! +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 # Token validity period + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password: str) -> str: + return pwd_context.hash(password) + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +def decode_access_token(token: str) -> Optional[dict]: + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + return None + +class TokenPayload(BaseModel): + sub: Optional[str] = None # 'sub' is standard for subject (e.g., user email or ID) + exp: Optional[datetime] = None diff --git a/app/crud.py b/app/crud.py new file mode 100644 index 0000000000000000000000000000000000000000..c84ec20b6d871cf1dffa425170420c569b73ddc3 --- /dev/null +++ b/app/crud.py @@ -0,0 +1,48 @@ +from sqlalchemy.orm import Session +from . import models, schemas +from .core.security import get_password_hash +from typing import List, Optional + +# --- User CRUD --- +def get_user(db: Session, user_id: int) -> Optional[models.User]: + return db.query(models.User).filter(models.User.id == user_id).first() + +def get_user_by_email(db: Session, email: str) -> Optional[models.User]: + return db.query(models.User).filter(models.User.email == email).first() + +def get_users(db: Session, skip: int = 0, limit: int = 100) -> List[models.User]: + return db.query(models.User).offset(skip).limit(limit).all() + +def create_user(db: Session, user: schemas.UserCreate) -> models.User: + hashed_password = get_password_hash(user.password) + db_user = models.User(email=user.email, hashed_password=hashed_password) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + +def create_admin_user(db: Session, user: schemas.UserCreate) -> models.User: + hashed_password = get_password_hash(user.password) + db_user = models.User(email=user.email, hashed_password=hashed_password, is_admin=True) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + +# --- UserDataInput CRUD --- +def create_user_data_input(db: Session, item: schemas.UserDataInputCreate, user_id: int) -> models.UserDataInput: + db_item = models.UserDataInput(**item.dict(), user_id=user_id) + db.add(db_item) + db.commit() + db.refresh(db_item) + return db_item + +def get_user_data_inputs_by_user_id(db: Session, user_id: int, skip: int = 0, limit: int = 100) -> List[models.UserDataInput]: + return db.query(models.UserDataInput).filter(models.UserDataInput.user_id == user_id).offset(skip).limit(limit).all() + +def get_all_user_data_inputs(db: Session, skip: int = 0, limit: int = 1000) -> List[models.UserDataInput]: + """ + Fetches all user data inputs, primarily for admin use. + Consider pagination for very large datasets. + """ + return db.query(models.UserDataInput).order_by(models.UserDataInput.timestamp.desc()).offset(skip).limit(limit).all() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000000000000000000000000000000000000..f40aad0da5a40db4d6668c53f7c8f5f48d36e85b --- /dev/null +++ b/app/database.py @@ -0,0 +1,26 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +import os + +DATABASE_URL = "sqlite:///./app/ml_models/financial_advisor.db" + +# Create the directory for the database if it doesn't exist +os.makedirs(os.path.dirname(DATABASE_URL.split("///")[-1]), exist_ok=True) + +engine = create_engine( + DATABASE_URL, connect_args={"check_same_thread": False} +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +def create_db_and_tables(): + Base.metadata.create_all(bind=engine) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..fde1f026c14a915e1671efcd79330c7abb4bae63 --- /dev/null +++ b/app/main.py @@ -0,0 +1,146 @@ +from fastapi import FastAPI, Request, Depends, HTTPException, status +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from fastapi.responses import HTMLResponse, RedirectResponse +from pathlib import Path +from sqlalchemy.orm import Session + +from app import models, schemas, crud, auth # app. is used if main.py is outside app/ +from app.database import engine, get_db, create_db_and_tables +from app.services import chatbot_service, data_service # app.services +from app.auth import get_current_active_user, get_current_admin_user, oauth2_scheme # app.auth + +# Determine the base directory of the 'app' package +BASE_DIR = Path(__file__).resolve().parent + +app = FastAPI(title="Financial Advisor Chatbot") + +# Mount static files (CSS, JS, images) +# The path "static" here is relative to where main.py is. +# If main.py is in app/, then app/static/ +app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") + +# Setup Jinja2 templates +# If main.py is in app/, then app/templates/ +templates = Jinja2Templates(directory=BASE_DIR / "templates") + +# --- Event Handlers --- +@app.on_event("startup") +async def startup_event(): + create_db_and_tables() + # Initialize CSV file with headers if it doesn't exist + data_service.initialize_csv() + # Load ML models + chatbot_service.load_models() + # Create a default admin user if one doesn't exist (optional) + db = next(get_db()) # Get a DB session + try: + admin_email = "admin@example.com" + admin_password = "adminpassword" # Change this! + + admin_user_obj = crud.get_user_by_email(db, email=admin_email) + + if not admin_user_obj: + print(f"Admin user '{admin_email}' not found, creating...") + admin_user_schema = schemas.UserCreate(email=admin_email, password=admin_password, confirm_password=admin_password) + crud.create_admin_user(db, admin_user_schema) + print(f"Default admin user '{admin_email}' created with password '{admin_password}'. PLEASE CHANGE THE PASSWORD.") + elif not admin_user_obj.is_admin: + # If user exists but is not admin, update them (or log warning) + print(f"User '{admin_email}' exists but is not admin. Updating status...") + admin_user_obj.is_admin = True + # If you also want to ensure the password is set, you might reset it here: + # admin_user_obj.hashed_password = security.get_password_hash(admin_password) + db.add(admin_user_obj) # Add the existing object to the session to track changes + db.commit() + print(f"User '{admin_email}' updated to be an admin.") + else: + print(f"Admin user '{admin_email}' already exists.") + + except Exception as e: + print(f"Error during admin user setup: {e}") + finally: + db.close() + + +# --- Include Routers --- +# Assuming auth.py, user.py, admin.py are in the same directory as main.py (i.e. in 'app') +# If main.py is in the root, these would be from app.auth, app.user, app.admin +from . import auth as auth_router # Renaming to avoid conflict with 'auth' module +# We will create user_router and admin_router later +from . import user as user_router +from . import admin as admin_router + +app.include_router(auth_router.router, prefix="/auth", tags=["Authentication"]) +app.include_router(user_router.router, prefix="/user", tags=["User Pages"]) # User specific pages like /user/home +app.include_router(admin_router.router, prefix="/admin", tags=["Admin Pages"]) # Admin specific pages like /admin/dashboard + +# --- Root and Basic Page Routes --- +@app.get("/", response_class=HTMLResponse) +async def read_root(request: Request): + # Redirect to login page by default, or to home if logged in (more complex logic for latter) + return templates.TemplateResponse("auth/login.html", {"request": request, "title": "Login"}) + +@app.get("/signup-page", response_class=HTMLResponse) # Renamed to avoid conflict if auth router has /signup +async def signup_page_render(request: Request): + return templates.TemplateResponse("auth/signup.html", {"request": request, "title": "Sign Up"}) + +@app.get("/login-page", response_class=HTMLResponse) # Renamed to avoid conflict if auth router has /login +async def login_page_render(request: Request): + return templates.TemplateResponse("auth/login.html", {"request": request, "title": "Login"}) + +# The actual /home and /admin/dashboard routes are now in user.py and admin.py respectively. + +# --- Chatbot API endpoint --- +@app.post("/api/chatbot", response_model=schemas.ChatbotInteractionResponse) +async def api_chatbot_interact( + request_data: schemas.ChatbotInteractionRequest, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_active_user) +): + # Process the interaction using the chatbot service + response = chatbot_service.process_chatbot_interaction(request_data) + + # If no error in recommendation, save input and potentially output to DB and CSV + if "error" not in response.recommendation: + # Save to DB + user_input_db = schemas.UserDataInputCreate( + model_type=request_data.model_type, + input_data=request_data.inputs + # output_data=response.recommendation # Optionally store output + ) + crud.create_user_data_input(db=db, item=user_input_db, user_id=current_user.id) + + # Save to CSV + data_service.append_data_to_csv( + user_id=current_user.id, + user_email=current_user.email, + model_type=request_data.model_type, + input_data=request_data.inputs, + output_data=response.recommendation # Pass the allocation part + ) + + return response + +# --- Logout --- +@app.get("/logout") +async def logout(request: Request): + # For token-based auth, logout is primarily a client-side operation (deleting the token). + # Server-side, you might blacklist the token if using a more complex setup. + # Here, we'll just redirect to the root path which serves the login page. + response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) # Changed url to "/" + # Instruct browser to clear any relevant cookies if they were set by server (not typical for Bearer tokens) + # response.delete_cookie("access_token_cookie") # If you were setting it as a cookie + return response + + +# To run the app (example, usually done with uvicorn command line): +# if __name__ == "__main__": +# import uvicorn +# # Note: Uvicorn should typically be run from the project root, not from within app/ +# # e.g., uvicorn app.main:app --reload +# # For direct execution from app/main.py (less common for FastAPI projects): +# # uvicorn.run(app, host="0.0.0.0", port=8000) +# # Better to run with: uvicorn financial_advisor.app.main:app --reload from the project root +# # Or if main.py is in the root: uvicorn main:app --reload +# pass diff --git a/app/ml_models/basic_portfolio_model.pkl b/app/ml_models/basic_portfolio_model.pkl new file mode 100644 index 0000000000000000000000000000000000000000..5c8f03f07bf70bd370dbc2918d095f362ef17b0c --- /dev/null +++ b/app/ml_models/basic_portfolio_model.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ae20f4cf2e1cb3f75623f692aa11f947c83529dbe6335a5634365052bd8d15ca +size 1817898 diff --git a/app/ml_models/enhanced_portfolio_model.pkl b/app/ml_models/enhanced_portfolio_model.pkl new file mode 100644 index 0000000000000000000000000000000000000000..a310f99af511bd22ac8f88312ff076de1b4d796e --- /dev/null +++ b/app/ml_models/enhanced_portfolio_model.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a780fe27d28ffee39b771925d3746551b30417e09d513f1899a38091e607e5d0 +size 1738333 diff --git a/app/ml_models/enhanced_portfolio_model111.pkl b/app/ml_models/enhanced_portfolio_model111.pkl new file mode 100644 index 0000000000000000000000000000000000000000..18ac3c732becc718c8ee34b4ce3b7fdbc0710520 --- /dev/null +++ b/app/ml_models/enhanced_portfolio_model111.pkl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2560b79a9705ca3674cd96820fd31fd6799dde89b12d4815afc6887111b52746 +size 1691633 diff --git a/app/ml_models/financial_advisor.db b/app/ml_models/financial_advisor.db new file mode 100644 index 0000000000000000000000000000000000000000..6cfbdfb56225914156014cf752f592d9e77e98d5 Binary files /dev/null and b/app/ml_models/financial_advisor.db differ diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000000000000000000000000000000000000..6b75388a603d28fd88a0cac72d3fd15199850de8 --- /dev/null +++ b/app/models.py @@ -0,0 +1,28 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, JSON, ForeignKey +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from .database import Base + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) + is_admin = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + inputs = relationship("UserDataInput", back_populates="owner") + +class UserDataInput(Base): + __tablename__ = "user_data_inputs" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id")) + model_type = Column(String, index=True) # "base", "enhanced", "rule_based" + input_data = Column(JSON) # Stores the dictionary of inputs + # Output data can also be stored if needed, e.g., portfolio allocation + # output_data = Column(JSON) + timestamp = Column(DateTime(timezone=True), server_default=func.now()) + + owner = relationship("User", back_populates="inputs") diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..10cbd3063b38d38751b40e682c245b04c658b688 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,139 @@ +from pydantic import BaseModel, EmailStr, field_validator, model_validator, ValidationInfo +from typing import Optional, Dict, Any, List +from datetime import datetime + +# --- User Schemas --- +class UserBase(BaseModel): + email: EmailStr + +class UserCreate(UserBase): + password: str + confirm_password: Optional[str] = None # For signup form + + @model_validator(mode='after') + def passwords_match(self) -> 'UserCreate': + if self.password is not None and self.confirm_password is not None and self.password != self.confirm_password: + raise ValueError('Passwords do not match') + # Ensure confirm_password is not passed to the User model if it's just for validation + # However, our User model doesn't have confirm_password, so it's fine. + return self + +class User(UserBase): + id: int + is_admin: bool + created_at: datetime + + class Config: + from_attributes = True + +# --- UserDataInput Schemas --- +class UserDataInputBase(BaseModel): + model_type: str + input_data: Dict[str, Any] + +class UserDataInputCreate(UserDataInputBase): + pass + +class UserDataInputResponse(UserDataInputBase): + id: int + user_id: int + timestamp: datetime + # output_data: Optional[Dict[str, Any]] = None # If storing output + + class Config: + from_attributes = True + +class UserWithInputs(User): + inputs: List[UserDataInputResponse] = [] + + class Config: # Added for consistency, though User already has it. + from_attributes = True + + +# --- Token Schemas --- +class Token(BaseModel): + access_token: str + token_type: str + +class TokenData(BaseModel): + email: Optional[str] = None + + +# --- Chatbot Input Schemas (Specific to each model for validation) --- +# These can be more specific if needed, for now using a generic Dict +# For example, for Base Model: +class BaseModeInputSchema(BaseModel): + Salary: float + Expenses: float + Savings: float + Lifecycle_Stage: str + Risk_Appetite: str + Investment_Horizon: str + + @field_validator('Salary', 'Expenses', 'Savings') + @classmethod + def check_non_negative_numeric(cls, v: Any, info: ValidationInfo) -> Any: + if not isinstance(v, (int, float)): + raise ValueError(f"{info.field_name} must be a number") + if v < 0: + raise ValueError(f"{info.field_name} cannot be negative") + return v + + class Config: + from_attributes = True + + +class EnhancedModelInputSchema(BaseModel): + Profession: str + City: str + Salary: float + Expenses: float + Savings: float + Lifecycle_Stage: str + Risk_Appetite: str + Investment_Horizon: str + + @field_validator('Salary', 'Expenses', 'Savings') + @classmethod + def check_non_negative_numeric(cls, v: Any, info: ValidationInfo) -> Any: + if not isinstance(v, (int, float)): + raise ValueError(f"{info.field_name} must be a number") + if v < 0: + raise ValueError(f"{info.field_name} cannot be negative") + return v + + class Config: + from_attributes = True + +class RuleBasedModelInputSchema(BaseModel): + Lifecycle_Stage: str + Risk_Appetite: str + Investment_Horizon: str + Annual_Salary_Package: float + Monthly_In_hand_Salary: float + Total_Monthly_Expenses: float + + @field_validator('Annual_Salary_Package', 'Monthly_In_hand_Salary', 'Total_Monthly_Expenses') + @classmethod + def check_non_negative_numeric(cls, v: Any, info: ValidationInfo) -> Any: + if not isinstance(v, (int, float)): + raise ValueError(f"{info.field_name} must be a number") + if v < 0: + raise ValueError(f"{info.field_name} cannot be negative") + return v + + class Config: + from_attributes = True + +# For the chatbot interaction, the form will likely submit a dictionary. +# The specific schema can be used within the service layer before passing to the model. +class ChatbotInteractionRequest(BaseModel): + model_type: str # "base", "enhanced", "rule_based" + inputs: Dict[str, Any] + +class ChatbotInteractionResponse(BaseModel): + model_type: str + user_inputs: Dict[str, Any] + recommendation: Dict[str, Any] # e.g., portfolio allocation + justification: Optional[List[str]] = None # For rule-based + tips: Optional[List[str]] = None # For rule-based diff --git a/app/services/__pycache__/chatbot_service.cpython-312.pyc b/app/services/__pycache__/chatbot_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c108c2c591ff3786bcaa338234191c23b202ccf8 Binary files /dev/null and b/app/services/__pycache__/chatbot_service.cpython-312.pyc differ diff --git a/app/services/__pycache__/data_service.cpython-312.pyc b/app/services/__pycache__/data_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..99012d7a547188fb083ac871ecccb67dc972d531 Binary files /dev/null and b/app/services/__pycache__/data_service.cpython-312.pyc differ diff --git a/app/services/chatbot_service.py b/app/services/chatbot_service.py new file mode 100644 index 0000000000000000000000000000000000000000..39f30d4fb0e30a5c18427f894a4697bf6b80512a --- /dev/null +++ b/app/services/chatbot_service.py @@ -0,0 +1,312 @@ +import joblib +import pandas as pd +import os +from typing import Dict, Any, Tuple, List, Optional +from app import schemas # Assuming schemas.py is in the same 'app' directory +import logging + +logger = logging.getLogger(__name__) + +# --- Model Loading --- +# Define paths to the model files, assuming they are in app/ml_models/ +MODEL_DIR = os.path.join(os.path.dirname(__file__), "..", "ml_models") # app/ml_models +BASE_MODEL_PATH = os.path.join(MODEL_DIR, "basic_portfolio_model.pkl") +ENHANCED_MODEL_PATH = os.path.join(MODEL_DIR, "enhanced_portfolio_model.pkl") + +# Ensure the ml_models directory exists +os.makedirs(MODEL_DIR, exist_ok=True) + +# Placeholder for loaded models +base_model_pipeline = None +enhanced_model_pipeline = None + +def load_models(): + global base_model_pipeline, enhanced_model_pipeline + try: + if os.path.exists(BASE_MODEL_PATH): + base_model_pipeline = joblib.load(BASE_MODEL_PATH) + logger.info("Base model loaded successfully.") + else: + logger.error(f"Base model file not found at {BASE_MODEL_PATH}") + + if os.path.exists(ENHANCED_MODEL_PATH): + enhanced_model_pipeline = joblib.load(ENHANCED_MODEL_PATH) + logger.info("Enhanced model loaded successfully.") + else: + logger.error(f"Enhanced model file not found at {ENHANCED_MODEL_PATH}") + except Exception as e: + logger.error(f"Error loading ML models: {e}") + +# Call load_models when this module is imported so they are ready. +# load_models() # Will be called from main.py or an init step + +# --- Base Model Prediction --- +def predict_base_model(input_data: schemas.BaseModeInputSchema) -> Dict[str, float]: + if not base_model_pipeline: + logger.error("Base model not loaded. Cannot predict.") + # Consider raising an exception or returning an error state + return {"error": "Base model not available"} + + try: + data = input_data.dict() + + # Convert categorical features using mappings from the loaded pipeline + # These mappings should exist in the .pkl file if saved correctly + processed_data = data.copy() + processed_data['Lifecycle_Stage'] = base_model_pipeline['mappings']['lifecycle'][data['Lifecycle_Stage']] + processed_data['Risk_Appetite'] = base_model_pipeline['mappings']['risk'][data['Risk_Appetite']] + processed_data['Investment_Horizon'] = base_model_pipeline['mappings']['horizon'][data['Investment_Horizon']] + + # Create feature array in correct order (must match training) + # Order from Reference/basemodel.py: Salary, Expenses, Savings, Lifecycle Stage, Risk Appetite, Investment Horizon + X = [ + processed_data['Salary'], + processed_data['Expenses'], + processed_data['Savings'], + processed_data['Lifecycle_Stage'], # This is now numerical + processed_data['Risk_Appetite'], # This is now numerical + processed_data['Investment_Horizon'] # This is now numerical + ] + + X_scaled = base_model_pipeline['scaler'].transform([X]) + pred = base_model_pipeline['model'].predict(X_scaled)[0] + + total = pred.sum() + if total == 0: # Avoid division by zero + return {'Equity': 0, 'Debt': 0, 'Gold': 0, 'FD/Cash': 0, "error": "Prediction resulted in zero total"} + + final_allocation = { + 'Equity': round((pred[0]/total)*100, 1), + 'Debt': round((pred[1]/total)*100, 1), + 'Gold': round((pred[2]/total)*100, 1), + 'FD/Cash': round((pred[3]/total)*100, 1) + } + return final_allocation + except KeyError as e: + logger.error(f"KeyError during base model prediction: {e}. Check mappings in pipeline or input data keys.") + return {"error": f"Missing data or incorrect mapping for {e}"} + except Exception as e: + logger.error(f"Error in base model prediction: {e}") + return {"error": str(e)} + + +# --- Enhanced Model Prediction --- +def predict_enhanced_model(input_data: schemas.EnhancedModelInputSchema) -> Dict[str, float]: + if not enhanced_model_pipeline: + logger.error("Enhanced model not loaded. Cannot predict.") + return {"error": "Enhanced model not available"} + + try: + data = input_data.dict() + processed_data = data.copy() + + # Convert categorical features + processed_data['Profession'] = enhanced_model_pipeline['profession_encoder'].transform([data['Profession']])[0] + processed_data['City'] = enhanced_model_pipeline['city_encoder'].transform([data['City']])[0] + processed_data['Lifecycle_Stage'] = enhanced_model_pipeline['mappings']['lifecycle'][data['Lifecycle_Stage']] + processed_data['Risk_Appetite'] = enhanced_model_pipeline['mappings']['risk'][data['Risk_Appetite']] + processed_data['Investment_Horizon'] = enhanced_model_pipeline['mappings']['horizon'][data['Investment_Horizon']] + + # Order from Reference/enhancedmodel.py: + # ['Profession', 'City', 'Salary', 'Expenses', 'Savings', 'Lifecycle Stage', 'Risk Appetite', 'Investment Horizon'] + X = [ + processed_data['Profession'], + processed_data['City'], + processed_data['Salary'], + processed_data['Expenses'], + processed_data['Savings'], + processed_data['Lifecycle_Stage'], + processed_data['Risk_Appetite'], + processed_data['Investment_Horizon'] + ] + + X_scaled = enhanced_model_pipeline['scaler'].transform([X]) + pred = enhanced_model_pipeline['model'].predict(X_scaled)[0] + + total = pred.sum() + if total == 0: + return {'Equity': 0, 'Debt': 0, 'Gold': 0, 'FD/Cash': 0, "error": "Prediction resulted in zero total"} + + final_allocation = { + 'Equity': round((pred[0]/total)*100, 1), + 'Debt': round((pred[1]/total)*100, 1), + 'Gold': round((pred[2]/total)*100, 1), + 'FD/Cash': round((pred[3]/total)*100, 1) + } + return final_allocation + except KeyError as e: + logger.error(f"KeyError during enhanced model prediction: {e}. Check mappings/encoders in pipeline or input data keys.") + return {"error": f"Missing data or incorrect mapping/encoding for {e}"} + except Exception as e: + logger.error(f"Error in enhanced model prediction: {e}") + return {"error": str(e)} + +# --- Rule-Based Model Logic --- +# (Adapted from Reference/rulebasedmodel.py) + +def _allocate_savings_rule_based( + monthly_savings: float, + risk_profile: str, + horizon: str, + lifecycle: str +) -> Tuple[Dict[str, float], List[str]]: + allocations = {} + justification_points = [ + f"This allocation considers your {risk_profile} risk profile, {horizon} horizon, and {lifecycle} stage." + ] + risk_profile = risk_profile.lower() # Ensure consistency + horizon = horizon.lower() + # Lifecycle stage is already in a good format from schema e.g. "Early Career" + + if risk_profile == "low": + allocations = { + "Fixed Deposits / Recurring Deposits": 0.35, + "Debt Mutual Funds (Liquid/Short Duration)": 0.30, + "Gold (SGBs/ETFs)": 0.10, + "Equity MFs (Large Cap/Index Funds)": 0.15, + "Cash / Savings Account": 0.10 + } + justification_points.append("- Focus on capital preservation and stable returns.") + elif risk_profile == "medium": + allocations = { + "Equity MFs (Diversified - Large & Mid Cap/Flexi Cap)": 0.45, + "Debt Mutual Funds": 0.25, + "Fixed Deposits / PPF": 0.10, + "Gold (SGBs/ETFs)": 0.10, + "Equity MFs (International)": 0.05, + "Cash / Savings Account": 0.05 + } + justification_points.append("- Aims for a balance between growth and stability.") + elif risk_profile == "high": + allocations = { + "Equity MFs (Aggressive Growth - Mid/Small Cap, Thematic)": 0.60, + "Equity MFs (International)": 0.15, + "Direct Stocks (If experienced)": 0.10, + "Debt Mutual Funds (Strategic)": 0.05, + "Gold (SGBs/ETFs - Tactical)": 0.05, + "Alternative Inv. (REITs/InvITs)": 0.05 + } + justification_points.append("- Focuses on maximizing long-term growth potential.") + + # Normalize percentages (simplified for brevity, original script had more robust normalization) + current_total_percentage = sum(allocations.values()) + if abs(current_total_percentage - 1.0) > 0.001 and current_total_percentage != 0: + factor = 1.0 / current_total_percentage + allocations = {k: round(v * factor, 3) for k, v in allocations.items()} + # Adjust last item to make sum exactly 1.0 if needed due to rounding + sum_val = sum(allocations.values()) + if sum_val != 1.0 and allocations: + key_to_adjust = list(allocations.keys())[-1] + allocations[key_to_adjust] += (1.0 - sum_val) + allocations[key_to_adjust] = round(allocations[key_to_adjust], 3) + + + # Convert percentage values to actual amounts based on monthly_savings + # The chatbot response schema expects percentages for recommendation, so we return percentages. + # The display logic in the template can calculate amounts. + + # For the ChatbotInteractionResponse, recommendation should be percentages + # Let's ensure the values are percentages (0 to 100) + final_percentage_allocations = {k: round(v * 100, 1) for k,v in allocations.items()} + + return final_percentage_allocations, justification_points + + +def _get_general_financial_tips(lifecycle_stage: str, monthly_savings: float, monthly_in_hand_salary: float) -> List[str]: + tips = [] + if monthly_in_hand_salary > 0: # Avoid division by zero if salary is 0 + if monthly_savings <= 0 : + tips.append("- Your expenses currently meet or exceed your income. Focus on creating a budget.") + else: + savings_rate = (monthly_savings / monthly_in_hand_salary) * 100 + tips.append(f"- Your current savings rate is approximately {savings_rate:.1f}%. Aim for at least 20-30%.") + else: # No salary info or zero salary + if monthly_savings <= 0: + tips.append("- Your expenses currently meet or exceed your income. Focus on creating a budget.") + + + tips.extend([ + "- Build & Maintain an Emergency Fund: Aim for 3-6 months of essential living expenses.", + "- Get Adequately Insured: Health and Term Life Insurance are crucial.", + "- Invest Regularly & Be Disciplined (e.g. SIPs).", + "- Review Periodically: Revisit your financial plan annually or on major life events.", + "- Understand Your Investments: Know the risks and costs." + ]) + if lifecycle_stage == "Student" or lifecycle_stage == "Early Career": + tips.append("- Focus on upskilling and career growth.") + if lifecycle_stage == "Late Career/Pre-Retirement" or lifecycle_stage == "Retired": # Assuming "Retired" is a lifecycle stage + tips.append("- Plan for healthcare expenses in retirement.") + return tips + +def predict_rule_based_model(input_data: schemas.RuleBasedModelInputSchema) -> Tuple[Dict[str, float], List[str], List[str]]: + data = input_data.dict() + + monthly_savings = data['Monthly_In_hand_Salary'] - data['Total_Monthly_Expenses'] + + if monthly_savings <= 0: + allocation = {"message": "Savings are zero or negative. Focus on budgeting first."} + justification = ["Investment allocation is not applicable without positive savings."] + tips = _get_general_financial_tips(data['Lifecycle_Stage'], monthly_savings, data['Monthly_In_hand_Salary']) + tips.insert(0, "Priority: Increase savings or reduce expenses.") + return allocation, justification, tips + + allocation, justification = _allocate_savings_rule_based( + monthly_savings, + data['Risk_Appetite'], + data['Investment_Horizon'], + data['Lifecycle_Stage'] + ) + tips = _get_general_financial_tips(data['Lifecycle_Stage'], monthly_savings, data['Monthly_In_hand_Salary']) + + return allocation, justification, tips + + +# --- Main Chatbot Interaction Logic --- +def process_chatbot_interaction( + request: schemas.ChatbotInteractionRequest +) -> schemas.ChatbotInteractionResponse: + + model_type = request.model_type.lower() + inputs = request.inputs + + recommendation: Dict[str, Any] = {} + justification: Optional[List[str]] = None + tips: Optional[List[str]] = None + + try: + if model_type == "base": + # Validate inputs against BaseModeInputSchema + validated_inputs = schemas.BaseModeInputSchema(**inputs) + recommendation = predict_base_model(validated_inputs) + elif model_type == "enhanced": + validated_inputs = schemas.EnhancedModelInputSchema(**inputs) + recommendation = predict_enhanced_model(validated_inputs) + elif model_type == "rule_based": + validated_inputs = schemas.RuleBasedModelInputSchema(**inputs) + recommendation, justification, tips = predict_rule_based_model(validated_inputs) + else: + raise ValueError("Invalid model type specified") + + # Check for errors from prediction functions + if "error" in recommendation: + # Propagate the error message + return schemas.ChatbotInteractionResponse( + model_type=model_type, + user_inputs=inputs, + recommendation=recommendation, # Contains the error message + justification=None, + tips=None + ) + + except Exception as e: # Catch validation errors or other issues + logger.error(f"Error processing chatbot interaction for {model_type}: {e}") + recommendation = {"error": f"Failed to process request: {str(e)}"} + + + return schemas.ChatbotInteractionResponse( + model_type=model_type, + user_inputs=inputs, # Return the original inputs for display + recommendation=recommendation, + justification=justification, + tips=tips + ) diff --git a/app/services/data_service.py b/app/services/data_service.py new file mode 100644 index 0000000000000000000000000000000000000000..3d645d0fd4e1cc21b43ae4edbad21fbdba4c1f93 --- /dev/null +++ b/app/services/data_service.py @@ -0,0 +1,129 @@ +import csv +import os +from datetime import datetime +from typing import Dict, Any, List, Optional +import logging + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Define the path for the CSV file +# Ensure this path is correct relative to where the application runs +# For example, if main.py is in app/, and this service is called from there. +CSV_FILE_PATH = "user_financial_data.csv" +# This will create the file in the same directory as main.py if it's in the root, +# or in the 'app' directory if main.py is in 'app' and this path is used as is. +# For consistency with the sqlite DB being in the root, let's adjust: +CSV_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "user_financial_data.csv") +# This places it in the project root directory (financial_advisor/) + +# Define the headers for the CSV file based on 'aboutdata.txt' and additional fields +# Ensure all possible fields from all models are covered. +CSV_HEADERS = [ + 'UserID', 'UserEmail', 'ModelType', 'Timestamp', + 'Name', 'Age', 'City', 'Profession', 'Salary', 'Expenses', 'Savings', + 'Lifecycle Stage', 'Risk Appetite', 'Investment Horizon', + 'Annual Salary Package', 'Monthly In-hand Salary', 'Total Monthly Expenses', # For Rule-based + # Output fields from models (optional, but good for record keeping) + 'Equity (%)', 'Debt (%)', 'Gold (%)', 'FD/Cash (%)' +] + + +def initialize_csv(): + """Initializes the CSV file with headers if it doesn't exist.""" + # Ensure the directory for the CSV file exists + os.makedirs(os.path.dirname(CSV_FILE_PATH), exist_ok=True) + + if not os.path.exists(CSV_FILE_PATH): + try: + with open(CSV_FILE_PATH, mode='w', newline='', encoding='utf-8') as file: + writer = csv.writer(file) + writer.writerow(CSV_HEADERS) + logger.info(f"CSV file initialized at {CSV_FILE_PATH}") + except IOError as e: + logger.error(f"Error initializing CSV file: {e}") + +def append_data_to_csv(user_id: int, user_email: str, model_type: str, input_data: Dict[str, Any], output_data: Optional[Dict[str, Any]] = None): + """ + Appends a new row of data to the CSV file. + input_data should be a flat dictionary. + output_data is the portfolio allocation. + """ + if not os.path.exists(CSV_FILE_PATH): + initialize_csv() + + row_data = {header: '' for header in CSV_HEADERS} # Initialize with empty strings + + # Populate common fields + row_data['UserID'] = user_id + row_data['UserEmail'] = user_email + row_data['ModelType'] = model_type + row_data['Timestamp'] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # Populate fields from input_data + # Normalize keys from input_data (e.g., 'Lifecycle_Stage' to 'Lifecycle Stage') + normalized_input_data = {key.replace('_', ' '): value for key, value in input_data.items()} + + for key, value in normalized_input_data.items(): + if key in row_data: + row_data[key] = value + # Handle specific keys that might have different names in input_data + elif key == "Annual Salary Package" and "Annual_Salary_Package" in normalized_input_data: # from RuleBasedModelInputSchema + row_data["Annual Salary Package"] = normalized_input_data["Annual_Salary_Package"] + # Add more specific mappings if needed + + # Populate fields from output_data (portfolio allocation) + if output_data: + for key, value in output_data.items(): + # Ensure the key from output_data matches a header like "Equity (%)" + header_key = f"{key} (%)" + if header_key in row_data: + row_data[header_key] = value + elif key in row_data: # For keys like 'Equity', 'Debt' if not with '(%)' + row_data[key] = value + + + # Ensure the order of data matches CSV_HEADERS + ordered_row_values = [row_data.get(header, '') for header in CSV_HEADERS] + + try: + with open(CSV_FILE_PATH, mode='a', newline='', encoding='utf-8') as file: + writer = csv.writer(file) + writer.writerow(ordered_row_values) + logger.info(f"Data appended to CSV for user {user_email}, model {model_type}") + except IOError as e: + logger.error(f"Error appending data to CSV: {e}") + except Exception as e: + logger.error(f"An unexpected error occurred while writing to CSV: {e}") + +# Example usage (for testing this module independently): +if __name__ == "__main__": + initialize_csv() + + # Test data + sample_input_base = { + 'Salary': 60000, 'Expenses': 40000, 'Savings': 20000, + 'Lifecycle_Stage': 'Early Career', 'Risk_Appetite': 'Medium', 'Investment_Horizon': 'Long-term' + } + sample_output = {'Equity': 50, 'Debt': 30, 'Gold': 10, 'FD/Cash': 10} + append_data_to_csv(1, "test@example.com", "base", sample_input_base, sample_output) + + sample_input_enhanced = { + 'Profession': 'Engineer', 'City': 'Metropolis', + 'Salary': 120000, 'Expenses': 70000, 'Savings': 50000, + 'Lifecycle_Stage': 'Mid-Career', 'Risk_Appetite': 'High', 'Investment_Horizon': 'Long-term' + } + append_data_to_csv(2, "another@example.com", "enhanced", sample_input_enhanced, sample_output) + + sample_input_rule = { + 'Lifecycle_Stage': 'Late Career', + 'Risk_Appetite': 'Low', + 'Investment_Horizon': 'Short-term', + 'Annual_Salary_Package': 1000000, + 'Monthly_In_hand_Salary': 60000, + 'Total_Monthly_Expenses': 40000 + } + append_data_to_csv(3, "ruleuser@example.com", "rule_based", sample_input_rule, sample_output) + + logger.info(f"Test data written to {CSV_FILE_PATH}") diff --git a/app/static/css/custom_styles.css b/app/static/css/custom_styles.css new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000000000000000000000000000000000000..530419136356d3c4409d15aa6f2f7267417b3ce0 --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,112 @@ +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #f8f9fa; /* Light grey background */ + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.container { + flex: 1; +} + +.navbar { + box-shadow: 0 2px 4px rgba(0,0,0,.1); +} + +.card { + border: none; /* Softer look, rely on shadow */ + transition: transform .2s ease-in-out, box-shadow .2s ease-in-out; +} + +.card:hover { + transform: translateY(-5px); + box-shadow: 0 0.5rem 1rem rgba(0,0,0,.15) !important; +} + +.card-header { + font-weight: 500; +} + +.btn-primary { + background-color: #007bff; + border-color: #007bff; +} +.btn-primary:hover { + background-color: #0056b3; + border-color: #0056b3; +} + +.btn-success { + background-color: #28a745; + border-color: #28a745; +} +.btn-success:hover { + background-color: #1e7e34; + border-color: #1e7e34; +} + +.btn-info { + background-color: #17a2b8; + border-color: #17a2b8; +} +.btn-info:hover { + background-color: #117a8b; + border-color: #117a8b; +} + + +/* Specific to chatbot results for better visual separation */ +#recommendationDetails ul .list-group-item span.badge { + font-size: 0.9em; +} + +/* Footer styling */ +footer.bg-light { + background-color: #e9ecef !important; /* A slightly different shade for footer */ + padding-top: 1rem; + padding-bottom: 1rem; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .display-4 { + font-size: 2.5rem; + } + h1 { + font-size: 1.75rem; + } +} + +/* Ensure body takes full height for sticky footer */ +html, body { + height: 100%; +} + +/* --- Chatbot Interface Styles --- */ +#chatbox { + height: 400px; + overflow-y: auto; + border: 1px solid #ccc; + padding: 10px; + margin-bottom: 15px; + background-color: #f9f9f9; + border-radius: 5px; +} +.chat-message { margin-bottom: 10px; } +.bot-message { text-align: left; } +.user-message { text-align: right; } +.message-bubble { + display: inline-block; + padding: 8px 12px; + border-radius: 15px; + max-width: 80%; + word-wrap: break-word; /* Ensure long words break */ +} +.bot-message .message-bubble { background-color: #e9ecef; color: #333; } +.user-message .message-bubble { background-color: #0d6efd; color: white; } +#userInputArea button.option-button { margin: 3px; } +#userInputArea { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } /* Added wrap */ +#userInputArea input, #userInputArea select { flex-grow: 1; min-width: 150px; } /* Added min-width */ +#optionsContainer { display: flex; flex-wrap: wrap; gap: 5px; } /* Styling for button container */ +.spinner-border-sm { width: 1rem; height: 1rem; } diff --git a/app/static/js/script.js b/app/static/js/script.js new file mode 100644 index 0000000000000000000000000000000000000000..7264fbb4435b78c5a3e9962537643d918617b78e --- /dev/null +++ b/app/static/js/script.js @@ -0,0 +1,61 @@ +// Global JavaScript functions can be added here. + +document.addEventListener('DOMContentLoaded', function () { + // Example: Add a class to the body to indicate JS is enabled + document.body.classList.add('js-enabled'); + + // You could centralize common functions here if needed, + // for example, a function to make API calls with error handling. + // const apiRequest = async (url, method, body = null, headers = {}) => { ... } + + const logoutButton = document.getElementById('logoutButton'); + if (logoutButton) { + logoutButton.addEventListener('click', function (event) { + event.preventDefault(); // Prevent default anchor action + + localStorage.removeItem('accessToken'); + localStorage.removeItem('tokenType'); + + // Redirect to the server's /logout endpoint, which then redirects to login page + // This ensures any server-side session cleanup (if implemented later) could also occur. + window.location.href = '/logout'; + }); + } + + // Client-side check to update navbar based on token presence + // This runs on every page load to set the correct initial state for navbar items. + const accessToken = localStorage.getItem('accessToken'); + const navWelcomeItem = document.getElementById('navWelcomeItem'); + const navLogoutItem = document.getElementById('navLogoutItem'); + const navLoginItem = document.getElementById('navLoginItem'); + const navSignupItem = document.getElementById('navSignupItem'); + + if (accessToken) { + // User is logged in (according to localStorage) + if (navWelcomeItem) navWelcomeItem.style.display = 'block'; // Or 'list-item' if needed + if (navLogoutItem) navLogoutItem.style.display = 'block'; + if (navLoginItem) navLoginItem.style.display = 'none'; + if (navSignupItem) navSignupItem.style.display = 'none'; + + // Optional: Fetch user email to display in welcome message + // Could be done here or within specific page scripts like homepage.html + // fetch('/auth/users/me', { headers: {'Authorization': `Bearer ${accessToken}`} }) + // .then(response => response.ok ? response.json() : Promise.reject('Failed')) + // .then(data => { + // const welcomeSpan = document.getElementById('navUserWelcome'); + // if (welcomeSpan) welcomeSpan.textContent = `Welcome, ${data.email}`; + // }) + // .catch(err => console.error("Error fetching user for navbar:", err)); + + } else { + // User is logged out + if (navWelcomeItem) navWelcomeItem.style.display = 'none'; + if (navLogoutItem) navLogoutItem.style.display = 'none'; + if (navLoginItem) navLoginItem.style.display = 'block'; + if (navSignupItem) navSignupItem.style.display = 'block'; + } +}); + +// Function to get CSRF token if using CSRF protection with forms not handled by FastAPI's default +// function getCookie(name) { ... } +// Not strictly needed for this JWT setup unless forms post directly without JS. diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html new file mode 100644 index 0000000000000000000000000000000000000000..8815651b8c9be10763025f7ec28f6e3ca9451f46 --- /dev/null +++ b/app/templates/admin/dashboard.html @@ -0,0 +1,175 @@ +{% extends "partials/base.html" %} + +{% block title %}Admin Dashboard - Financial Advisor{% endblock %} + +{% block content %} +
+ Don't have an account? Sign up here +
++ Already have an account? Login here +
+Click "Start" to begin the financial assessment.
+ +Choose a financial advisory model to get started.
+Stay updated with the latest market information and our curated recommendations.
+{{ market_data.gold_price }}
+ {% else %} +Gold price data not available.
+ {% endif %} +{{ stock.reason }}
+No specific stock recommendations at this time.
+ {% endif %} +| Bank Name | +Highest Interest Rate | +Tenure | +Senior Citizen Rate (Up to) | +
|---|---|---|---|
| Airtel Bank | +9.1% | +36 to 60 months | +Varies (up to 9.1%) | +
| SBI (State Bank of India) | +7.25% | +2 to 3 years | +Up to 7.40% | +
| Axis Bank | +7.25% | +15 months to <2 years | +Up to 7.95% | +
| Indian Post (Post Office) | +7.50% | +5 years (Post Office TD) | +Not specified | +
| HDFC Bank | +7.20% | +4 years 7 months (55 months) | +Up to 7.75% | +
| ICICI Bank | +7.10% | +15 months to 2 years | +Up to 7.60% | +
| Canara Bank | +6.70% | +2 to 3 years | +Up to 7.20% | +
| PNB (Punjab National Bank) | +7.05% | +300 days | +Up to 7.55% | +