Anushka-stack-queues/FinBench
0
1"""2Task 3 (Hard): Comprehensive Financial Plan3=============================================4Agent must generate a complete, coherent financial plan covering:5- Emergency fund, insurance, debt, investments, retirement,6 tax optimization, goal timelines, rebalancing strategy.7 8This is hard because all components must be internally consistent9and appropriate for the specific client.10"""11 12from __future__ import annotations13 14 15 16from typing import Dict17from ..models import ClientProfile, MarketConditions, Reward, _SCORE_EPSILON as STRICT_SCORE_EPSILON18 19 20 21 22def _retirement_savings_needed(client: ClientProfile, market: MarketConditions) -> float:23 """Estimate minimum monthly retirement savings using simplified FV formula."""24 years = max(1, 65 - client.age)25 monthly_rate = (market.equity_expected_return * 0.6 + market.bond_expected_return * 0.4) / 1226 # Target: 25x annual expenses at retirement (4% rule)27 target = client.monthly_expenses * 12 * 2528 existing_nw = max(0, client.net_worth)29 gap = max(0, target - existing_nw)30 31 if monthly_rate == 0 or years == 0:32 return gap / (years * 12) if years > 0 else 033 34 months = years * 1235 # PMT formula: gap = PMT * [(1+r)^n - 1] / r36 fv_factor = ((1 + monthly_rate) ** months - 1) / monthly_rate37 return gap / fv_factor if fv_factor > 0 else 038 39 40# FIX #7: updated signature to accept FinbenchAction directly instead of41# FinancialPlanAction (which doesn't exist). Reads the same fields.42# FIX (market arg): FinbenchEnvironment passes self._market which is a MarketConditions43# object — the grader correctly receives it as such, no change needed here.44def grade(45 action,46 client: ClientProfile,47 market: MarketConditions,48) -> Reward:49 """50 Score a comprehensive financial plan 0.0–1.0.51 Components:52 - Emergency fund adequacy (0.12)53 - Insurance recommendations (0.08)54 - Debt payoff strategy (0.10)55 - Investment allocation suitability (0.20)56 - Retirement savings adequacy (0.15)57 - Tax optimization (0.15)58 - Goal timeline realism (0.10)59 - Internal plan consistency (0.10)60 """61 components: Dict[str, float] = {}62 penalties: Dict[str, float] = {}63 64 monthly_income = client.annual_income / 1265 66 # ── 1. Emergency Fund (0.12) ──────────────────────────────────────────────67 if not client.has_emergency_fund:68 ideal_months = 6 if client.dependents > 0 else 369 if action.emergency_fund_months >= ideal_months:70 components["emergency_fund"] = 0.1271 elif action.emergency_fund_months >= ideal_months * 0.5:72 components["emergency_fund"] = 0.0673 else:74 components["emergency_fund"] = 0.075 else:76 components["emergency_fund"] = 0.12 # already has one77 78 # ── 2. Insurance (0.08) ──────────────────────────────────────────────────79 if not client.has_insurance:80 ins_text = " ".join(action.insurance_recommendations).lower()81 if client.dependents > 0 and "life" in ins_text:82 components["insurance"] = 0.0883 elif any(t in ins_text for t in ["term", "disability", "health", "life"]):84 components["insurance"] = 0.0585 else:86 components["insurance"] = 0.087 else:88 components["insurance"] = 0.0889 90 # ── 3. Debt Payoff Strategy (0.10) ────────────────────────────────────────91 if client.debt_to_income_ratio > 0.36:92 debt_text = action.debt_payoff_strategy.lower()93 has_strategy = any(94 t in debt_text95 for t in ["avalanche", "snowball", "high interest", "pay off", "consolidat"]96 )97 components["debt_strategy"] = 0.10 if has_strategy else 0.0298 else:99 components["debt_strategy"] = 0.10 # no debt problem = no penalty100 101 # ── 4. Investment Allocation Suitability (0.20) ───────────────────────────102 allocs = action.investment_allocations103 total_alloc = sum(allocs.values())104 105 # Sum check106 if abs(total_alloc - 100.0) > 5:107 penalties["alloc_sum_error"] = 0.10108 components["investment_allocation"] = 0.0109 else:110 equity = allocs.get("equities", allocs.get("stocks", 0))111 bonds = allocs.get("bonds", 0)112 cash = allocs.get("cash", 0)113 114 # Expected equity range by risk115 eq_ranges = {116 "conservative": (10, 30),117 "moderate": (35, 60),118 "aggressive": (55, 85),119 }120 lo, hi = eq_ranges[client.risk_tolerance]121 122 alloc_score = 0.0123 if lo <= equity <= hi:124 alloc_score += 0.10125 126 # Age adjustment: high equity bad near retirement127 if client.age >= 60 and equity > 50:128 penalties["too_aggressive_near_retirement"] = 0.05129 else:130 alloc_score += 0.05131 132 # Cash not excessively high133 if cash <= 20:134 alloc_score += 0.05135 136 components["investment_allocation"] = min(0.20, alloc_score)137 138 # ── 5. Retirement Savings (0.15) ──────────────────────────────────────────139 needed = _retirement_savings_needed(client, market)140 agent_savings = action.retirement_monthly_savings141 income_pct = agent_savings / monthly_income if monthly_income > 0 else 0142 143 # Must save at least 10% of income for partial credit144 if income_pct >= 0.15 and agent_savings >= needed * 0.80:145 components["retirement_savings"] = 0.15146 elif income_pct >= 0.10:147 components["retirement_savings"] = 0.08148 elif income_pct >= 0.05:149 components["retirement_savings"] = 0.04150 else:151 components["retirement_savings"] = 0.0152 153 # ── 6. Tax Optimization (0.15) ────────────────────────────────────────────154 tax_text = " ".join(action.tax_optimization_strategies).lower()155 tax_score = 0.0156 tax_keywords = {157 "401k": 0.04, "ira": 0.04, "roth": 0.03,158 "tax-loss": 0.02, "hsa": 0.02,159 "municipal": 0.02, "capital gains": 0.02,160 }161 for kw, pts in tax_keywords.items():162 if kw in tax_text:163 tax_score += pts164 165 # High earners must mention tax-advantaged accounts166 if client.tax_bracket >= 0.32 and not any(167 t in tax_text for t in ["401k", "ira", "roth", "hsa"]168 ):169 penalties["missed_tax_advantaged"] = 0.05170 171 components["tax_optimization"] = min(0.15, tax_score)172 173 # ── 7. Goal Timeline Realism (0.10) ──────────────────────────────────────174 if client.goals and action.goal_timelines:175 # Check timelines are within investment horizon176 max_timeline = client.investment_horizon_years177 realistic = sum(178 1 for yrs in action.goal_timelines.values()179 if 1 <= yrs <= max_timeline + 5180 )181 timeline_score = (realistic / len(action.goal_timelines)) * 0.10182 components["goal_timelines"] = round(timeline_score, 4)183 else:184 components["goal_timelines"] = 0.05 # partial for missing185 186 # ── 8. Internal Consistency (0.10) ────────────────────────────────────────187 consistency_score = 0.10188 # Inconsistency: saving a lot but no debt payoff strategy with high debt189 if client.debt_to_income_ratio > 0.5 and agent_savings > monthly_income * 0.3:190 penalties["inconsistent_savings_vs_debt"] = 0.03191 consistency_score -= 0.03192 # Inconsistency: aggressive allocation but conservative risk tolerance193 if client.risk_tolerance == "conservative":194 equity = allocs.get("equities", allocs.get("stocks", 0))195 if equity > 40:196 penalties["allocation_risk_mismatch"] = 0.05197 consistency_score -= 0.05198 # Bonus: reasoning present199 if len(action.reasoning.strip()) >= 50:200 consistency_score = min(0.10, consistency_score + 0.02)201 202 components["consistency"] = max(0.0, round(consistency_score, 4))203 204 # ── Final Score ───────────────────────────────────────────────────────────205 total = sum(components.values()) - sum(penalties.values())206 total = max(STRICT_SCORE_EPSILON, min(1.0 - STRICT_SCORE_EPSILON, total))207 208 return Reward(209 total=round(total, 3),210 components=components,211 penalties=penalties,212 explanation=(213 f"Needed monthly retirement savings: ${needed:,.0f} | "214 f"Agent savings: ${agent_savings:,.0f} | "215 f"Tax bracket: {client.tax_bracket*100:.0f}% | "216 f"Goals: {client.goals}"217 ),218 )