This commit is contained in:
HotSwapp
2025-08-18 20:20:04 -05:00
parent 89b2bc0aa2
commit bac8cc4bd5
114 changed files with 30258 additions and 1341 deletions

View File

@@ -0,0 +1,230 @@
"""
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)