changes
This commit is contained in:
230
app/api/pension_valuation.py
Normal file
230
app/api/pension_valuation.py
Normal 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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user