galaxis-po/backend/app/services/pension_allocation.py
머니페니 12d235a1f1 feat: add 9 new modules - notification alerts, trading journal, position sizing, pension allocation, drawdown monitoring, benchmark dashboard, tax simulation, correlation analysis, parameter optimizer
Phase 1:
- Real-time signal alerts (Discord/Telegram webhook)
- Trading journal with entry/exit tracking
- Position sizing calculator (Fixed/Kelly/ATR)

Phase 2:
- Pension asset allocation (DC/IRP 70% risk limit)
- Drawdown monitoring with SVG gauge
- Benchmark dashboard (portfolio vs KOSPI vs deposit)

Phase 3:
- Tax benefit simulation (Korean pension tax rules)
- Correlation matrix heatmap
- Parameter optimizer with grid search + overfit detection
2026-03-29 10:03:08 +09:00

210 lines
7.6 KiB
Python

"""
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="KODEX 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,
)