""" Pension asset allocation service. Korean retirement pension regulations: - DC/IRP: risky assets max 70%, safe assets min 30% - Personal pension: no regulatory limit (but we apply same guideline) - Safe assets: bond funds, deposits, TDF, principal-guaranteed products - Risky assets: equity funds, equity ETFs, hybrid funds """ from datetime import date from decimal import Decimal from typing import List from app.schemas.pension import ( AllocationItem, AllocationResult, RecommendationItem, RecommendationResult, ) # Regulatory limits RISKY_ASSET_LIMIT_PCT = Decimal("70") SAFE_ASSET_MIN_PCT = Decimal("30") # Glide path parameters (equity allocation decreases with age) GLIDE_PATH_MAX_EQUITY = Decimal("80") # max equity at young age GLIDE_PATH_MIN_EQUITY = Decimal("20") # min equity near retirement def calculate_current_age(birth_year: int) -> int: return date.today().year - birth_year def calculate_years_to_retirement(birth_year: int, target_retirement_age: int) -> int: current_age = calculate_current_age(birth_year) return max(0, target_retirement_age - current_age) def calculate_glide_path(birth_year: int, target_retirement_age: int) -> tuple[Decimal, Decimal]: """Calculate equity/bond ratio based on age (glide path). Linear interpolation: young = high equity, near retirement = low equity. Working age range: 25 ~ target_retirement_age. """ current_age = calculate_current_age(birth_year) working_years = target_retirement_age - 25 if working_years <= 0: equity_pct = GLIDE_PATH_MIN_EQUITY else: years_worked = min(max(current_age - 25, 0), working_years) progress = Decimal(years_worked) / Decimal(working_years) equity_pct = GLIDE_PATH_MAX_EQUITY - progress * (GLIDE_PATH_MAX_EQUITY - GLIDE_PATH_MIN_EQUITY) # Clamp to regulatory limit equity_pct = min(equity_pct, RISKY_ASSET_LIMIT_PCT) equity_pct = max(equity_pct, GLIDE_PATH_MIN_EQUITY) bond_pct = Decimal("100") - equity_pct return equity_pct.quantize(Decimal("0.01")), bond_pct.quantize(Decimal("0.01")) def calculate_allocation( account_id: int, account_type: str, total_amount: Decimal, birth_year: int, target_retirement_age: int, ) -> AllocationResult: """Calculate recommended asset allocation for a pension account.""" equity_pct, bond_pct = calculate_glide_path(birth_year, target_retirement_age) current_age = calculate_current_age(birth_year) years_to_ret = calculate_years_to_retirement(birth_year, target_retirement_age) equity_amount = (total_amount * equity_pct / Decimal("100")).quantize(Decimal("0.01")) bond_amount = total_amount - equity_amount allocations: List[AllocationItem] = [] # Split equity portion if equity_pct > 0: allocations.append(AllocationItem( asset_name="국내 주식형 ETF", asset_type="risky", amount=float((equity_amount * Decimal("0.5")).quantize(Decimal("0.01"))), ratio=float((equity_pct * Decimal("0.5")).quantize(Decimal("0.01"))), )) allocations.append(AllocationItem( asset_name="해외 주식형 ETF", asset_type="risky", amount=float((equity_amount * Decimal("0.5")).quantize(Decimal("0.01"))), ratio=float((equity_pct * Decimal("0.5")).quantize(Decimal("0.01"))), )) # Split bond portion if bond_pct > 0: tdf_ratio = Decimal("0.4") bond_etf_ratio = Decimal("0.3") deposit_ratio = Decimal("0.3") allocations.append(AllocationItem( asset_name=f"TDF {_recommend_tdf_year(birth_year, target_retirement_age)}", asset_type="safe", amount=float((bond_amount * tdf_ratio).quantize(Decimal("0.01"))), ratio=float((bond_pct * tdf_ratio).quantize(Decimal("0.01"))), )) allocations.append(AllocationItem( asset_name="국내 채권형 ETF", asset_type="safe", amount=float((bond_amount * bond_etf_ratio).quantize(Decimal("0.01"))), ratio=float((bond_pct * bond_etf_ratio).quantize(Decimal("0.01"))), )) allocations.append(AllocationItem( asset_name="원리금 보장 예금", asset_type="safe", amount=float((bond_amount * deposit_ratio).quantize(Decimal("0.01"))), ratio=float((bond_pct * deposit_ratio).quantize(Decimal("0.01"))), )) return AllocationResult( account_id=account_id, account_type=account_type, total_amount=float(total_amount), risky_limit_pct=float(RISKY_ASSET_LIMIT_PCT), safe_min_pct=float(SAFE_ASSET_MIN_PCT), glide_path_equity_pct=float(equity_pct), glide_path_bond_pct=float(bond_pct), current_age=current_age, years_to_retirement=years_to_ret, allocations=allocations, ) def _recommend_tdf_year(birth_year: int, target_retirement_age: int) -> int: """Recommend TDF target year (rounded to nearest 5).""" retirement_year = birth_year + target_retirement_age return round(retirement_year / 5) * 5 def get_recommendation( account_id: int, birth_year: int, target_retirement_age: int, ) -> RecommendationResult: """Generate TDF/ETF recommendations based on age and retirement target.""" equity_pct, bond_pct = calculate_glide_path(birth_year, target_retirement_age) current_age = calculate_current_age(birth_year) years_to_ret = calculate_years_to_retirement(birth_year, target_retirement_age) tdf_year = _recommend_tdf_year(birth_year, target_retirement_age) recommendations: List[RecommendationItem] = [] # TDF recommendation recommendations.append(RecommendationItem( asset_name=f"TDF {tdf_year}", asset_type="safe", category="tdf", ratio=float((bond_pct * Decimal("0.4")).quantize(Decimal("0.01"))), reason=f"은퇴 목표 시점({tdf_year}년)에 맞춘 자동 자산 배분 펀드", )) # Bond ETF recommendations.append(RecommendationItem( asset_name="KODEX 국고채 10년", asset_type="safe", category="bond_etf", ratio=float((bond_pct * Decimal("0.3")).quantize(Decimal("0.01"))), reason="안정적인 국고채 장기 투자로 원금 보전", )) # Deposit recommendations.append(RecommendationItem( asset_name="원리금 보장 예금", asset_type="safe", category="deposit", ratio=float((bond_pct * Decimal("0.3")).quantize(Decimal("0.01"))), reason="원리금 보장으로 안전자산 비중 확보", )) # Equity ETFs domestic_equity_ratio = (equity_pct * Decimal("0.5")).quantize(Decimal("0.01")) foreign_equity_ratio = (equity_pct * Decimal("0.5")).quantize(Decimal("0.01")) recommendations.append(RecommendationItem( asset_name="TIGER 200", asset_type="risky", category="equity_etf", ratio=float(domestic_equity_ratio), reason="국내 대형주 분산 투자 (KOSPI 200 추종)", )) recommendations.append(RecommendationItem( asset_name="TIGER 미국 S&P500", asset_type="risky", category="equity_etf", ratio=float(foreign_equity_ratio), reason="미국 대형주 분산 투자 (S&P 500 추종)", )) return RecommendationResult( account_id=account_id, birth_year=birth_year, current_age=current_age, target_retirement_age=target_retirement_age, years_to_retirement=years_to_ret, glide_path_equity_pct=float(equity_pct), glide_path_bond_pct=float(bond_pct), recommendations=recommendations, )