231 lines
9.0 KiB
Python
231 lines
9.0 KiB
Python
"""
|
|
Pension Valuation API endpoints
|
|
|
|
Exposes endpoints under /api/pensions/valuation for:
|
|
- Single-life present value
|
|
- Joint-survivor present value
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Dict, Optional, List, Union, Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.database.base import get_db
|
|
from app.models.user import User
|
|
from app.auth.security import get_current_user
|
|
from app.services.pension_valuation import (
|
|
SingleLifeInputs,
|
|
JointSurvivorInputs,
|
|
present_value_single_life,
|
|
present_value_joint_survivor,
|
|
)
|
|
|
|
|
|
router = APIRouter(prefix="/valuation", tags=["pensions", "pensions-valuation"])
|
|
|
|
|
|
class SingleLifeRequest(BaseModel):
|
|
monthly_benefit: float = Field(ge=0)
|
|
term_months: int = Field(ge=0, description="Number of months in evaluation horizon")
|
|
start_age: Optional[int] = Field(default=None, ge=0)
|
|
sex: str = Field(description="M, F, or A (all)")
|
|
race: str = Field(description="W, B, H, or A (all)")
|
|
discount_rate: float = Field(default=0.0, ge=0, description="Annual percent, e.g. 3.0")
|
|
cola_rate: float = Field(default=0.0, ge=0, description="Annual percent, e.g. 2.0")
|
|
defer_months: float = Field(default=0, ge=0, description="Months to delay first payment (supports fractional)")
|
|
payment_period_months: int = Field(default=1, ge=1, description="Months per payment (1=monthly, 3=quarterly, 12=annual)")
|
|
certain_months: int = Field(default=0, ge=0, description="Guaranteed months from commencement regardless of mortality")
|
|
cola_mode: str = Field(default="monthly", description="'monthly' or 'annual_prorated'")
|
|
cola_cap_percent: Optional[float] = Field(default=None, ge=0)
|
|
interpolation_method: str = Field(default="linear", description="'linear' or 'step' for NA interpolation")
|
|
max_age: Optional[int] = Field(default=None, ge=0, description="Optional cap on participant age for term truncation")
|
|
|
|
|
|
class SingleLifeResponse(BaseModel):
|
|
pv: float
|
|
|
|
|
|
@router.post("/single-life", response_model=SingleLifeResponse)
|
|
async def value_single_life(
|
|
payload: SingleLifeRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
try:
|
|
pv = present_value_single_life(
|
|
db,
|
|
SingleLifeInputs(
|
|
monthly_benefit=payload.monthly_benefit,
|
|
term_months=payload.term_months,
|
|
start_age=payload.start_age,
|
|
sex=payload.sex,
|
|
race=payload.race,
|
|
discount_rate=payload.discount_rate,
|
|
cola_rate=payload.cola_rate,
|
|
defer_months=payload.defer_months,
|
|
payment_period_months=payload.payment_period_months,
|
|
certain_months=payload.certain_months,
|
|
cola_mode=payload.cola_mode,
|
|
cola_cap_percent=payload.cola_cap_percent,
|
|
interpolation_method=payload.interpolation_method,
|
|
max_age=payload.max_age,
|
|
),
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
return SingleLifeResponse(pv=float(round(pv, 2)))
|
|
|
|
|
|
class JointSurvivorRequest(BaseModel):
|
|
monthly_benefit: float = Field(ge=0)
|
|
term_months: int = Field(ge=0)
|
|
participant_age: Optional[int] = Field(default=None, ge=0)
|
|
participant_sex: str
|
|
participant_race: str
|
|
spouse_age: Optional[int] = Field(default=None, ge=0)
|
|
spouse_sex: str
|
|
spouse_race: str
|
|
survivor_percent: float = Field(ge=0, le=100, description="Percent of benefit to spouse on participant death")
|
|
discount_rate: float = Field(default=0.0, ge=0)
|
|
cola_rate: float = Field(default=0.0, ge=0)
|
|
defer_months: float = Field(default=0, ge=0)
|
|
payment_period_months: int = Field(default=1, ge=1)
|
|
certain_months: int = Field(default=0, ge=0)
|
|
cola_mode: str = Field(default="monthly")
|
|
cola_cap_percent: Optional[float] = Field(default=None, ge=0)
|
|
survivor_basis: str = Field(default="contingent", description="'contingent' or 'last_survivor'")
|
|
survivor_commence_participant_only: bool = Field(default=False, description="If true, survivor component uses participant survival as commencement basis")
|
|
interpolation_method: str = Field(default="linear")
|
|
max_age: Optional[int] = Field(default=None, ge=0)
|
|
|
|
|
|
class JointSurvivorResponse(BaseModel):
|
|
pv_total: float
|
|
pv_participant_component: float
|
|
pv_survivor_component: float
|
|
|
|
|
|
@router.post("/joint-survivor", response_model=JointSurvivorResponse)
|
|
async def value_joint_survivor(
|
|
payload: JointSurvivorRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
try:
|
|
result: Dict[str, float] = present_value_joint_survivor(
|
|
db,
|
|
JointSurvivorInputs(
|
|
monthly_benefit=payload.monthly_benefit,
|
|
term_months=payload.term_months,
|
|
participant_age=payload.participant_age,
|
|
participant_sex=payload.participant_sex,
|
|
participant_race=payload.participant_race,
|
|
spouse_age=payload.spouse_age,
|
|
spouse_sex=payload.spouse_sex,
|
|
spouse_race=payload.spouse_race,
|
|
survivor_percent=payload.survivor_percent,
|
|
discount_rate=payload.discount_rate,
|
|
cola_rate=payload.cola_rate,
|
|
defer_months=payload.defer_months,
|
|
payment_period_months=payload.payment_period_months,
|
|
certain_months=payload.certain_months,
|
|
cola_mode=payload.cola_mode,
|
|
cola_cap_percent=payload.cola_cap_percent,
|
|
survivor_basis=payload.survivor_basis,
|
|
survivor_commence_participant_only=payload.survivor_commence_participant_only,
|
|
interpolation_method=payload.interpolation_method,
|
|
max_age=payload.max_age,
|
|
),
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
|
|
|
# Round to 2 decimals for response
|
|
return JointSurvivorResponse(
|
|
pv_total=float(round(result["pv_total"], 2)),
|
|
pv_participant_component=float(round(result["pv_participant_component"], 2)),
|
|
pv_survivor_component=float(round(result["pv_survivor_component"], 2)),
|
|
)
|
|
|
|
|
|
class ErrorResponse(BaseModel):
|
|
error: str
|
|
|
|
class BatchSingleLifeRequest(BaseModel):
|
|
# Accept raw dicts to allow per-item validation inside the loop (avoid 422 on the entire batch)
|
|
items: List[Dict[str, Any]]
|
|
|
|
class BatchSingleLifeItemResponse(BaseModel):
|
|
success: bool
|
|
result: Optional[SingleLifeResponse] = None
|
|
error: Optional[str] = None
|
|
|
|
class BatchSingleLifeResponse(BaseModel):
|
|
results: List[BatchSingleLifeItemResponse]
|
|
|
|
@router.post("/batch-single-life", response_model=BatchSingleLifeResponse)
|
|
async def batch_value_single_life(
|
|
payload: BatchSingleLifeRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
results = []
|
|
for item in payload.items:
|
|
try:
|
|
inputs = SingleLifeInputs(**item)
|
|
pv = present_value_single_life(db, inputs)
|
|
results.append(BatchSingleLifeItemResponse(
|
|
success=True,
|
|
result=SingleLifeResponse(pv=float(round(pv, 2))),
|
|
))
|
|
except ValueError as e:
|
|
results.append(BatchSingleLifeItemResponse(
|
|
success=False,
|
|
error=str(e),
|
|
))
|
|
return BatchSingleLifeResponse(results=results)
|
|
|
|
class BatchJointSurvivorRequest(BaseModel):
|
|
# Accept raw dicts to allow per-item validation inside the loop (avoid 422 on the entire batch)
|
|
items: List[Dict[str, Any]]
|
|
|
|
class BatchJointSurvivorItemResponse(BaseModel):
|
|
success: bool
|
|
result: Optional[JointSurvivorResponse] = None
|
|
error: Optional[str] = None
|
|
|
|
class BatchJointSurvivorResponse(BaseModel):
|
|
results: List[BatchJointSurvivorItemResponse]
|
|
|
|
@router.post("/batch-joint-survivor", response_model=BatchJointSurvivorResponse)
|
|
async def batch_value_joint_survivor(
|
|
payload: BatchJointSurvivorRequest,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
results = []
|
|
for item in payload.items:
|
|
try:
|
|
inputs = JointSurvivorInputs(**item)
|
|
result = present_value_joint_survivor(db, inputs)
|
|
results.append(BatchJointSurvivorItemResponse(
|
|
success=True,
|
|
result=JointSurvivorResponse(
|
|
pv_total=float(round(result["pv_total"], 2)),
|
|
pv_participant_component=float(round(result["pv_participant_component"], 2)),
|
|
pv_survivor_component=float(round(result["pv_survivor_component"], 2)),
|
|
),
|
|
))
|
|
except ValueError as e:
|
|
results.append(BatchJointSurvivorItemResponse(
|
|
success=False,
|
|
error=str(e),
|
|
))
|
|
return BatchJointSurvivorResponse(results=results)
|
|
|
|
|