Anushka-stack-queues/FinBench
0
1# Copyright (c) Meta Platforms, Inc. and affiliates.2# All rights reserved.3#4# This source code is licensed under the BSD-style license found in the5# LICENSE file in the root directory of this source tree.6 7"""Finbench Environment Client."""8 9from typing import Dict10 11from openenv.core import EnvClient12from openenv.core.client_types import StepResult13from openenv.core.env_server.types import State14 15from .models import FinbenchAction, FinbenchObservation16 17 18class FinbenchEnv(19 EnvClient[FinbenchAction, FinbenchObservation, State]20):21 """22 Client for the FinBench Financial Advisor Environment.23 24 This client maintains a persistent WebSocket connection to the environment server,25 enabling efficient multi-step interactions with lower latency.26 Each client instance has its own dedicated environment session on the server.27 28 Example:29 >>> with FinbenchEnv(base_url="http://localhost:8000") as client:30 ... result = client.reset()31 ... result = client.step(FinbenchAction(32 ... action_type="allocate",33 ... allocations={"equities": 20, "bonds": 55, "cash": 15, "real_estate": 5, "commodities": 5},34 ... reasoning="Conservative client near retirement."35 ... ))36 ... print(result.observation.feedback)37 """38 39 def _step_payload(self, action: FinbenchAction) -> Dict:40 """Convert FinbenchAction to JSON payload for step message."""41 return action.model_dump()42 43 def _parse_result(self, payload: Dict) -> StepResult[FinbenchObservation]:44 """Parse server response into StepResult[FinbenchObservation]."""45 obs_data = payload.get("observation", {})46 observation = FinbenchObservation(47 client=obs_data.get("client", {}),48 market_conditions=obs_data.get("market_conditions", {}),49 task_id=obs_data.get("task_id", ""),50 task_description=obs_data.get("task_description", ""),51 step_number=obs_data.get("step_number", 0),52 previous_actions=obs_data.get("previous_actions", []),53 feedback=obs_data.get("feedback", ""),54 done=payload.get("done", False),55 reward=payload.get("reward"),56 metadata=obs_data.get("metadata", {}),57 )58 return StepResult(59 observation=observation,60 reward=payload.get("reward"),61 done=payload.get("done", False),62 )63 64 def _parse_state(self, payload: Dict) -> State:65 """Parse server response into State object."""66 return State(67 episode_id=payload.get("episode_id"),68 step_count=payload.get("step_count", 0),69 )70 71 72# ── Rule-based demo agents ────────────────────────────────────────────────────73 74def rule_based_agent_task1(obs: FinbenchObservation) -> FinbenchAction:75 """Simple rule-based agent for portfolio allocation."""76 client = obs.client77 rt = client["risk_tolerance"]78 age = client["age"]79 has_emergency_fund = client["has_emergency_fund"]80 81 profiles = {82 "conservative": {"equities": 18, "bonds": 57, "cash": 15, "real_estate": 5, "commodities": 5},83 "moderate": {"equities": 50, "bonds": 35, "cash": 8, "real_estate": 4, "commodities": 3},84 "aggressive": {"equities": 70, "bonds": 12, "cash": 5, "real_estate": 8, "commodities": 5},85 }86 allocs = profiles[rt].copy()87 88 if age >= 60 and rt != "conservative":89 allocs["bonds"] += 1090 allocs["equities"] -= 1091 92 if not has_emergency_fund:93 allocs["cash"] = max(allocs["cash"], 15)94 diff = sum(allocs.values()) - 10095 allocs["equities"] -= diff96 97 return FinbenchAction(98 action_type="allocate",99 allocations=allocs,100 reasoning=(101 f"Client is {age} years old with {rt} risk tolerance. "102 f"Adjusted allocation for age and emergency fund status."103 ),104 )105 106 107def rule_based_agent_task2(obs: FinbenchObservation) -> FinbenchAction:108 """Simple rule-based agent for risk assessment."""109 client = obs.client110 risks = []111 recs = []112 score = {113 "conservative": 3.0,114 "moderate": 5.0,115 "aggressive": 6.0,116 }[client["risk_tolerance"]]117 118 if not client["has_emergency_fund"]:119 risks.append("No emergency fund - financially vulnerable to unexpected expenses")120 recs.append("Build a 3-6 month emergency fund in a high-yield savings account")121 score += 1.5122 123 if not client["has_insurance"] and client["dependents"] > 0:124 risks.append(f"No insurance with {client['dependents']} dependent(s) - major risk")125 recs.append("Obtain term life insurance immediately to protect dependents")126 score += 2.0127 128 if client["debt_to_income_ratio"] > 0.36:129 risks.append(f"High debt-to-income ratio ({client['debt_to_income_ratio']:.0%}) above 36% threshold")130 recs.append("Implement debt avalanche strategy - pay highest-interest debt first")131 score += 1.5132 133 monthly = client["annual_income"] / 12134 savings_rate = (monthly - client["monthly_expenses"]) / monthly if monthly > 0 else 0135 if savings_rate < 0.15:136 risks.append(f"Low savings rate (~{savings_rate*100:.0f}%) - below recommended 15%")137 recs.append("Reduce discretionary spending to achieve at least 15% savings rate")138 score += 1.0139 140 for asset, pct in client.get("existing_portfolio", {}).items():141 if pct > 60:142 risks.append(f"Over-concentration in {asset} ({pct}%) - lacks diversification")143 recs.append(f"Rebalance away from {asset} to diversify risk")144 score += 1.0145 146 if client["tax_bracket"] >= 0.32:147 risks.append("High income with potential tax inefficiency")148 recs.append("Maximize tax-advantaged accounts: 401k, IRA, HSA")149 score += 0.5150 151 if not risks:152 risks.append("Client profile appears generally sound with few major risk flags")153 recs.append("Continue current strategy with annual review")154 155 return FinbenchAction(156 action_type="assess_risk",157 identified_risks=risks,158 risk_score=round(min(10.0, score), 1),159 recommendations=recs,160 priority_recommendation=recs[0] if recs else "Review financial plan annually.",161 )162 163 164def rule_based_agent_task3(obs: FinbenchObservation) -> FinbenchAction:165 """Simple rule-based agent for comprehensive financial planning."""166 client = obs.client167 monthly = client["annual_income"] / 12168 rt = client["risk_tolerance"]169 170 alloc_map = {171 "conservative": {"equities": 20, "bonds": 55, "cash": 15, "real_estate": 5, "commodities": 5},172 "moderate": {"equities": 50, "bonds": 32, "cash": 8, "real_estate": 7, "commodities": 3},173 "aggressive": {"equities": 68, "bonds": 15, "cash": 7, "real_estate": 7, "commodities": 3},174 }175 176 insurance = []177 if not client["has_insurance"]:178 insurance.append("Term life insurance - $500,000 coverage")179 if client["dependents"] > 0:180 insurance.append("Disability insurance to protect income")181 182 debt_strategy = ""183 if client["debt_to_income_ratio"] > 0.36:184 debt_strategy = (185 "Avalanche method: list all debts by interest rate, "186 "pay minimums on all but highest-rate debt, apply all extra cash there. "187 "Target DTI below 0.36 within 24-36 months."188 )189 190 tax_strategies = ["Maximize 401k contribution ($23,000/year)"]191 if client["tax_bracket"] >= 0.24:192 tax_strategies.append("Open and fund Roth IRA ($7,000/year)")193 if client["tax_bracket"] >= 0.32:194 tax_strategies.append("HSA contributions for triple tax advantage")195 tax_strategies.append("Tax-loss harvesting in taxable accounts")196 tax_strategies.append("Consider municipal bonds for tax-efficient fixed income")197 198 goal_timelines = {}199 for goal in client.get("goals", []):200 if "retire" in goal.lower():201 goal_timelines[goal] = max(65 - client["age"], 1)202 elif "home" in goal.lower() or "house" in goal.lower():203 goal_timelines[goal] = 5204 elif "education" in goal.lower() or "college" in goal.lower():205 goal_timelines[goal] = 18206 else:207 goal_timelines[goal] = 10208 209 return FinbenchAction(210 action_type="financial_plan",211 emergency_fund_months=6.0 if client["dependents"] > 0 else 3.0,212 insurance_recommendations=insurance,213 debt_payoff_strategy=debt_strategy,214 investment_allocations=alloc_map[rt],215 retirement_monthly_savings=round(max(monthly * 0.15, 500), 2),216 tax_optimization_strategies=tax_strategies,217 goal_timelines=goal_timelines,218 rebalancing_frequency="quarterly",219 reasoning=(220 f"Comprehensive plan for {client['age']}-year-old {rt} investor. "221 f"Annual income ${client['annual_income']:,.0f}, net worth ${client['net_worth']:,.0f}. "222 f"Priority order: emergency fund -> insurance -> debt -> invest -> tax optimize."223 ),224 )225 226 227# ── Demo runner ───────────────────────────────────────────────────────────────228 229AGENTS = {230 "task1_allocation": rule_based_agent_task1,231 "task2_risk": rule_based_agent_task2,232 "task3_plan": rule_based_agent_task3,233}234 235TASK_LABELS = {236 "task1_allocation": "Task 1 - Portfolio Allocation [EASY]",237 "task2_risk": "Task 2 - Risk Assessment [MEDIUM]",238 "task3_plan": "Task 3 - Comprehensive Plan [HARD]",239}240 241 242def run_demo(base_url: str = "http://localhost:8000"):243 from .server.FinBench_environment import FinbenchEnvironment244 245 print("\n" + "=" * 60)246 print(" FinBench - Rule-Based Agent Demo")247 print("=" * 60)248 249 all_scores = []250 251 for task_id, agent_fn in AGENTS.items():252 print(f"\n{'-' * 60}")253 print(f" {TASK_LABELS[task_id]}")254 print(f"{'-' * 60}")255 256 task_scores = []257 for scenario_idx in range(3):258 env = FinbenchEnvironment(task_id=task_id, scenario_index=scenario_idx)259 obs = env.reset()260 client = obs.client261 262 print(f"\n Scenario {scenario_idx + 1}: {client.get('client_id')}")263 print(264 f" Age={client.get('age')} | Risk={client.get('risk_tolerance')} | "265 f"Income=${client.get('annual_income'):,.0f} | DTI={client.get('debt_to_income_ratio'):.2f}"266 )267 268 best_reward = 0.0269 done = False270 step = 0271 272 while not done:273 action = agent_fn(obs)274 obs = env.step(action)275 done = obs.done276 reward = obs.reward or 0.0277 best_reward = max(best_reward, reward)278 step += 1279 print(f" Step {step}: reward={reward:.4f}")280 281 task_scores.append(best_reward)282 print(f" -> Best: {best_reward:.4f}")283 284 avg = sum(task_scores) / len(task_scores)285 all_scores.append(avg)286 print(f"\n Task Average: {avg:.4f}")287 288 print(f"\n{'=' * 60}")289 print(" SUMMARY")290 print(f"{'=' * 60}")291 for task_id, score in zip(AGENTS.keys(), all_scores):292 bar = "#" * int(score * 20) + "." * (20 - int(score * 20))293 print(f" {TASK_LABELS[task_id]}: {bar} {score:.4f}")294 295 overall = sum(all_scores) / len(all_scores)296 print(f"\n Overall Average: {overall:.4f}")297 print("\n Environment working correctly!\n")298 299 300if __name__ == "__main__":301 run_demo()302 