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
210 lines
7.6 KiB
Python
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,
|
|
)
|