Anushka-stack-queues/FinBench
0
1"""2Task 1 (Easy): Portfolio Allocation3====================================4Given a client profile, allocate the portfolio correctly across asset classes.5Grader checks suitability based on risk tolerance, age, investment horizon.6"""7 8from __future__ import annotations9 10 11 12from typing import Dict, Tuple13from ..models import ClientProfile, Reward, _SCORE_EPSILON as STRICT_SCORE_EPSILON14 15 16# ── Ideal allocation ranges per risk profile ─────────────────────────────────17# Each: {asset_class: (min%, max%, ideal%)}18ALLOCATION_PROFILES: Dict[str, Dict[str, Tuple[float, float, float]]] = {19 "conservative": {20 "bonds": (40, 70, 55),21 "equities": (10, 30, 20),22 "cash": (10, 30, 15),23 "real_estate": (0, 15, 5),24 "commodities": (0, 10, 5),25 },26 "moderate": {27 "bonds": (20, 45, 35),28 "equities": (35, 60, 50),29 "cash": (5, 15, 8),30 "real_estate": (0, 15, 5),31 "commodities": (0, 10, 2),32 },33 "aggressive": {34 "bonds": (0, 20, 10),35 "equities": (55, 85, 70),36 "cash": (0, 10, 5),37 "real_estate": (0, 20, 10),38 "commodities": (0, 15, 5),39 },40}41 42AGE_BOND_RULE_BONUS = 5.0 # extra bond % for every 10 years over 5043 44 45# FIX #7 (task grader signature): graders previously accepted task-specific action46# sub-classes (AllocationAction etc.) that don't exist. Updated to accept FinbenchAction47# directly — only the relevant fields are read, so this is fully backward compatible.48def grade(action, client: ClientProfile) -> Reward:49 """50 Score a portfolio allocation 0.0–1.0.51 Partial credit for:52 - Allocations within acceptable ranges (0.50)53 - Sum-to-100 correctness (0.15)54 - Age-appropriate bond weighting (0.15)55 - Emergency fund prerequisite awareness (0.10)56 - Reasoning quality (non-empty) (0.10)57 """58 components: Dict[str, float] = {}59 penalties: Dict[str, float] = {}60 61 allocs = action.allocations62 profile = ALLOCATION_PROFILES[client.risk_tolerance]63 64 # 1. Allocations within acceptable ranges (0.50)65 known_assets = set(profile.keys())66 in_range_count = 067 total_checked = len(known_assets)68 69 for asset, (lo, hi, _ideal) in profile.items():70 pct = allocs.get(asset, 0.0)71 if lo <= pct <= hi:72 in_range_count += 173 74 range_score = (in_range_count / total_checked) * 0.5075 components["in_range"] = round(range_score, 4)76 77 # 2. Allocations sum to 100 (±2 tolerance) (0.15)78 total_alloc = sum(allocs.values())79 if abs(total_alloc - 100.0) <= 2.0:80 components["sum_to_100"] = 0.1581 elif abs(total_alloc - 100.0) <= 10.0:82 components["sum_to_100"] = 0.0783 else:84 components["sum_to_100"] = 0.085 penalties["allocation_sum_error"] = abs(total_alloc - 100.0)86 87 # 3. Age-appropriate bond allocation (0.15)88 bond_alloc = allocs.get("bonds", 0.0)89 # FIX #8: removed dead variable `_ideal_profile_bond` (was computed but never used).90 # Read the ideal directly from the profile tuple.91 age_adjusted_ideal = profile["bonds"][2] # ideal% is index 292 if client.age > 50:93 age_adjusted_ideal += ((client.age - 50) / 10) * AGE_BOND_RULE_BONUS94 95 age_adjusted_ideal = min(age_adjusted_ideal, 80) # cap96 bond_error = abs(bond_alloc - age_adjusted_ideal)97 if bond_error <= 5:98 components["age_appropriate_bonds"] = 0.1599 elif bond_error <= 15:100 components["age_appropriate_bonds"] = 0.08101 else:102 components["age_appropriate_bonds"] = 0.0103 104 # 4. Emergency fund prerequisite awareness (0.10)105 cash_alloc = allocs.get("cash", 0.0)106 if not client.has_emergency_fund:107 if cash_alloc >= 15:108 components["emergency_fund_awareness"] = 0.10109 elif cash_alloc >= 8:110 components["emergency_fund_awareness"] = 0.05111 else:112 components["emergency_fund_awareness"] = 0.0113 else:114 components["emergency_fund_awareness"] = 0.10 # full marks if fund exists115 116 # 5. Reasoning quality (0.10)117 components["reasoning"] = 0.10 if len(action.reasoning.strip()) >= 30 else 0.04118 119 total = sum(components.values()) - sum(penalties.values())120 total = max(STRICT_SCORE_EPSILON, min(1.0 - STRICT_SCORE_EPSILON, total))121 122 return Reward(123 total=round(total, 3),124 components=components,125 penalties=penalties,126 explanation=(127 f"Risk profile: {client.risk_tolerance} | Age: {client.age} | "128 f"Bond ideal: {age_adjusted_ideal:.1f}% | Agent bond: {bond_alloc}% | "129 f"Alloc sum: {total_alloc:.1f}%"130 ),131 )