changes
This commit is contained in:
502
app/services/pension_valuation.py
Normal file
502
app/services/pension_valuation.py
Normal file
@@ -0,0 +1,502 @@
|
||||
"""
|
||||
Pension valuation (annuity evaluator) service.
|
||||
|
||||
Computes present value for:
|
||||
- Single-life level annuity with optional COLA and discounting
|
||||
- Joint-survivor annuity with survivor continuation percentage
|
||||
|
||||
Survival probabilities are sourced from `number_tables` if available
|
||||
for the requested month range, using the ratio NA_t / NA_0 for the
|
||||
specified sex and race. If monthly entries are missing and life table
|
||||
values are available, a simple exponential survival curve is derived
|
||||
from life expectancy (LE) to approximate monthly survival.
|
||||
|
||||
Rates are provided as percentages (e.g., 3.0 = 3%).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.pensions import LifeTable, NumberTable
|
||||
|
||||
|
||||
class InvalidCodeError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
_RACE_MAP: Dict[str, str] = {
|
||||
"W": "w", # White
|
||||
"B": "b", # Black
|
||||
"H": "h", # Hispanic
|
||||
"A": "a", # All races
|
||||
}
|
||||
|
||||
_SEX_MAP: Dict[str, str] = {
|
||||
"M": "m",
|
||||
"F": "f",
|
||||
"A": "a", # All sexes
|
||||
}
|
||||
|
||||
|
||||
def _normalize_codes(sex: str, race: str) -> Tuple[str, str, str]:
|
||||
sex_u = (sex or "A").strip().upper()
|
||||
race_u = (race or "A").strip().upper()
|
||||
if sex_u not in _SEX_MAP:
|
||||
raise InvalidCodeError("Invalid sex code; expected one of M, F, A")
|
||||
if race_u not in _RACE_MAP:
|
||||
raise InvalidCodeError("Invalid race code; expected one of W, B, H, A")
|
||||
return _RACE_MAP[race_u] + _SEX_MAP[sex_u], sex_u, race_u
|
||||
|
||||
|
||||
def _to_monthly_rate(annual_percent: float) -> float:
|
||||
"""Convert an annual percentage (e.g. 6.0) to monthly effective rate."""
|
||||
annual_rate = float(annual_percent or 0.0) / 100.0
|
||||
if annual_rate <= -1.0:
|
||||
# Avoid invalid negative base
|
||||
raise ValueError("Annual rate too negative")
|
||||
return (1.0 + annual_rate) ** (1.0 / 12.0) - 1.0
|
||||
|
||||
|
||||
def _load_monthly_na_series(
|
||||
db: Session,
|
||||
*,
|
||||
sex: str,
|
||||
race: str,
|
||||
start_month: int,
|
||||
months: int,
|
||||
interpolate_missing: bool = False,
|
||||
interpolation_method: str = "linear", # "linear" or "step"
|
||||
) -> Optional[List[float]]:
|
||||
"""Return NA series for months [start_month, start_month + months - 1].
|
||||
|
||||
Values are floats for the column `na_{suffix}`. If any month in the
|
||||
requested range is missing, returns None to indicate fallback.
|
||||
"""
|
||||
if months <= 0:
|
||||
return []
|
||||
|
||||
suffix, _, _ = _normalize_codes(sex, race)
|
||||
na_col = f"na_{suffix}"
|
||||
|
||||
month_values: Dict[int, float] = {}
|
||||
rows: List[NumberTable] = (
|
||||
db.query(NumberTable)
|
||||
.filter(NumberTable.month >= start_month, NumberTable.month < start_month + months)
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
value = getattr(row, na_col, None)
|
||||
if value is not None:
|
||||
month_values[int(row.month)] = float(value)
|
||||
|
||||
# Build initial series with possible gaps
|
||||
series_vals: List[Optional[float]] = []
|
||||
for m in range(start_month, start_month + months):
|
||||
series_vals.append(month_values.get(m))
|
||||
|
||||
if any(v is None for v in series_vals) and interpolate_missing:
|
||||
# Linear interpolation for internal gaps
|
||||
if (interpolation_method or "linear").lower() == "step":
|
||||
# Step-wise: carry forward previous known; if leading gaps, use next known
|
||||
# Fill leading gaps
|
||||
first_known = None
|
||||
for idx, val in enumerate(series_vals):
|
||||
if val is not None:
|
||||
first_known = float(val)
|
||||
break
|
||||
if first_known is None:
|
||||
return None
|
||||
for i in range(len(series_vals)):
|
||||
if series_vals[i] is None:
|
||||
# find prev known
|
||||
prev_val = None
|
||||
for k in range(i - 1, -1, -1):
|
||||
if series_vals[k] is not None:
|
||||
prev_val = float(series_vals[k])
|
||||
break
|
||||
if prev_val is not None:
|
||||
series_vals[i] = prev_val
|
||||
else:
|
||||
# Use first known for leading gap
|
||||
series_vals[i] = first_known
|
||||
else:
|
||||
for i in range(len(series_vals)):
|
||||
if series_vals[i] is None:
|
||||
# find prev
|
||||
prev_idx = None
|
||||
for k in range(i - 1, -1, -1):
|
||||
if series_vals[k] is not None:
|
||||
prev_idx = k
|
||||
break
|
||||
# find next
|
||||
next_idx = None
|
||||
for k in range(i + 1, len(series_vals)):
|
||||
if series_vals[k] is not None:
|
||||
next_idx = k
|
||||
break
|
||||
if prev_idx is None or next_idx is None:
|
||||
return None
|
||||
v0 = float(series_vals[prev_idx])
|
||||
v1 = float(series_vals[next_idx])
|
||||
frac = (i - prev_idx) / (next_idx - prev_idx)
|
||||
series_vals[i] = v0 + (v1 - v0) * frac
|
||||
|
||||
if any(v is None for v in series_vals):
|
||||
return None
|
||||
|
||||
return [float(v) for v in series_vals] # type: ignore
|
||||
|
||||
|
||||
def _approximate_survival_from_le(le_years: float, months: int) -> List[float]:
|
||||
"""Approximate monthly survival probabilities using an exponential model.
|
||||
|
||||
Given life expectancy in years (LE), approximate a constant hazard rate
|
||||
such that expected remaining life equals LE. For a memoryless exponential
|
||||
distribution, E[T] = 1/lambda. We discretize monthly: p_survive(t) = exp(-lambda * t_years).
|
||||
"""
|
||||
if le_years is None or le_years <= 0:
|
||||
# No survival; return zero beyond t=0
|
||||
return [1.0] + [0.0] * (max(0, months - 1))
|
||||
|
||||
lam = 1.0 / float(le_years)
|
||||
series: List[float] = []
|
||||
for idx in range(months):
|
||||
t_years = idx / 12.0
|
||||
series.append(float(pow(2.718281828459045, -lam * t_years)))
|
||||
return series
|
||||
|
||||
|
||||
def _load_life_expectancy(db: Session, *, age: int, sex: str, race: str) -> Optional[float]:
|
||||
suffix, _, _ = _normalize_codes(sex, race)
|
||||
le_col = f"le_{suffix}"
|
||||
row: Optional[LifeTable] = db.query(LifeTable).filter(LifeTable.age == age).first()
|
||||
if not row:
|
||||
return None
|
||||
val = getattr(row, le_col, None)
|
||||
return float(val) if val is not None else None
|
||||
|
||||
|
||||
def _to_survival_probabilities(
|
||||
db: Session,
|
||||
*,
|
||||
start_age: Optional[int],
|
||||
sex: str,
|
||||
race: str,
|
||||
term_months: int,
|
||||
interpolation_method: str = "linear",
|
||||
) -> List[float]:
|
||||
"""Build per-month survival probabilities p(t) for t in [0, term_months-1].
|
||||
|
||||
Prefer monthly NumberTable NA series if contiguous; otherwise approximate
|
||||
from LifeTable life expectancy at `start_age`.
|
||||
"""
|
||||
if term_months <= 0:
|
||||
return []
|
||||
|
||||
# Try exact monthly NA series first
|
||||
na_series = _load_monthly_na_series(
|
||||
db,
|
||||
sex=sex,
|
||||
race=race,
|
||||
start_month=0,
|
||||
months=term_months,
|
||||
interpolate_missing=True,
|
||||
interpolation_method=interpolation_method,
|
||||
)
|
||||
if na_series is not None and len(na_series) > 0:
|
||||
base = na_series[0]
|
||||
if base is None or base <= 0:
|
||||
# Degenerate base; fall back
|
||||
na_series = None
|
||||
else:
|
||||
probs = [float(v) / float(base) for v in na_series]
|
||||
# Clamp to [0,1]
|
||||
return [0.0 if p < 0.0 else (1.0 if p > 1.0 else p) for p in probs]
|
||||
|
||||
# Fallback to LE approximation
|
||||
le_years = _load_life_expectancy(db, age=int(start_age or 0), sex=sex, race=race)
|
||||
return _approximate_survival_from_le(le_years if le_years is not None else 0.0, term_months)
|
||||
|
||||
|
||||
def _present_value_from_stream(
|
||||
payments: List[float],
|
||||
*,
|
||||
discount_monthly: float,
|
||||
cola_monthly: float,
|
||||
) -> float:
|
||||
"""PV of a cash-flow stream with monthly discount and monthly COLA growth applied."""
|
||||
pv = 0.0
|
||||
growth_factor = 1.0
|
||||
discount_factor = 1.0
|
||||
for idx, base_payment in enumerate(payments):
|
||||
if idx == 0:
|
||||
growth_factor = 1.0
|
||||
discount_factor = 1.0
|
||||
else:
|
||||
growth_factor *= (1.0 + cola_monthly)
|
||||
discount_factor *= (1.0 + discount_monthly)
|
||||
pv += (base_payment * growth_factor) / discount_factor
|
||||
return float(pv)
|
||||
|
||||
|
||||
def _compute_growth_factor_at_month(
|
||||
month_index: int,
|
||||
*,
|
||||
cola_annual_percent: float,
|
||||
cola_mode: str,
|
||||
cola_cap_percent: Optional[float] = None,
|
||||
) -> float:
|
||||
"""Compute nominal COLA growth factor at month t relative to t=0.
|
||||
|
||||
cola_mode:
|
||||
- "monthly": compound monthly using effective monthly rate derived from annual percent
|
||||
- "annual_prorated": step annually, prorate linearly within the year
|
||||
"""
|
||||
annual_pct = float(cola_annual_percent or 0.0)
|
||||
if cola_cap_percent is not None:
|
||||
try:
|
||||
annual_pct = min(annual_pct, float(cola_cap_percent))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if month_index <= 0 or annual_pct == 0.0:
|
||||
return 1.0
|
||||
|
||||
if (cola_mode or "monthly").lower() == "annual_prorated":
|
||||
years_completed = month_index // 12
|
||||
remainder_months = month_index % 12
|
||||
a = annual_pct / 100.0
|
||||
step = (1.0 + a) ** years_completed
|
||||
prorata = 1.0 + a * (remainder_months / 12.0)
|
||||
return float(step * prorata)
|
||||
else:
|
||||
# monthly compounding from annual percent
|
||||
m = _to_monthly_rate(annual_pct)
|
||||
return float((1.0 + m) ** month_index)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SingleLifeInputs:
|
||||
monthly_benefit: float
|
||||
term_months: int
|
||||
start_age: Optional[int]
|
||||
sex: str
|
||||
race: str
|
||||
discount_rate: float = 0.0 # annual percent
|
||||
cola_rate: float = 0.0 # annual percent
|
||||
defer_months: float = 0.0 # months to delay first payment (supports fractional)
|
||||
payment_period_months: int = 1 # months per payment (1=monthly, 3=quarterly, etc.)
|
||||
certain_months: int = 0 # months guaranteed from commencement regardless of mortality
|
||||
cola_mode: str = "monthly" # "monthly" or "annual_prorated"
|
||||
cola_cap_percent: Optional[float] = None
|
||||
interpolation_method: str = "linear"
|
||||
max_age: Optional[int] = None
|
||||
|
||||
|
||||
def present_value_single_life(db: Session, inputs: SingleLifeInputs) -> float:
|
||||
"""Compute PV of a single-life level annuity under mortality and economic assumptions."""
|
||||
if inputs.monthly_benefit < 0:
|
||||
raise ValueError("monthly_benefit must be non-negative")
|
||||
if inputs.term_months < 0:
|
||||
raise ValueError("term_months must be non-negative")
|
||||
|
||||
if inputs.payment_period_months <= 0:
|
||||
raise ValueError("payment_period_months must be >= 1")
|
||||
if inputs.defer_months < 0:
|
||||
raise ValueError("defer_months must be >= 0")
|
||||
if inputs.certain_months < 0:
|
||||
raise ValueError("certain_months must be >= 0")
|
||||
|
||||
# Survival probabilities for participant
|
||||
# Adjust term if max_age is provided and start_age known
|
||||
term_months = inputs.term_months
|
||||
if inputs.max_age is not None and inputs.start_age is not None:
|
||||
max_months = max(0, (int(inputs.max_age) - int(inputs.start_age)) * 12)
|
||||
term_months = min(term_months, max_months)
|
||||
|
||||
p_survive = _to_survival_probabilities(
|
||||
db,
|
||||
start_age=inputs.start_age,
|
||||
sex=inputs.sex,
|
||||
race=inputs.race,
|
||||
term_months=term_months,
|
||||
interpolation_method=inputs.interpolation_method,
|
||||
)
|
||||
|
||||
i_m = _to_monthly_rate(inputs.discount_rate)
|
||||
period = int(inputs.payment_period_months)
|
||||
t0 = int(math.ceil(inputs.defer_months))
|
||||
t = t0
|
||||
guarantee_end = float(inputs.defer_months) + float(inputs.certain_months)
|
||||
|
||||
pv = 0.0
|
||||
first = True
|
||||
while t < term_months:
|
||||
p_t = p_survive[t] if t < len(p_survive) else 0.0
|
||||
base_amt = inputs.monthly_benefit * float(period)
|
||||
# Pro-rata first payment if deferral is fractional
|
||||
if first:
|
||||
frac_defer = float(inputs.defer_months) - math.floor(float(inputs.defer_months))
|
||||
pro_rata = 1.0 - (frac_defer / float(period)) if frac_defer > 0 else 1.0
|
||||
else:
|
||||
pro_rata = 1.0
|
||||
eff_base = base_amt * pro_rata
|
||||
amount = eff_base if t < guarantee_end else eff_base * p_t
|
||||
growth = _compute_growth_factor_at_month(
|
||||
t,
|
||||
cola_annual_percent=inputs.cola_rate,
|
||||
cola_mode=inputs.cola_mode,
|
||||
cola_cap_percent=inputs.cola_cap_percent,
|
||||
)
|
||||
discount = (1.0 + i_m) ** t
|
||||
pv += (amount * growth) / discount
|
||||
t += period
|
||||
first = False
|
||||
return float(pv)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JointSurvivorInputs:
|
||||
monthly_benefit: float
|
||||
term_months: int
|
||||
participant_age: Optional[int]
|
||||
participant_sex: str
|
||||
participant_race: str
|
||||
spouse_age: Optional[int]
|
||||
spouse_sex: str
|
||||
spouse_race: str
|
||||
survivor_percent: float # as percent (0-100)
|
||||
discount_rate: float = 0.0 # annual percent
|
||||
cola_rate: float = 0.0 # annual percent
|
||||
defer_months: float = 0.0
|
||||
payment_period_months: int = 1
|
||||
certain_months: int = 0
|
||||
cola_mode: str = "monthly"
|
||||
cola_cap_percent: Optional[float] = None
|
||||
survivor_basis: str = "contingent" # "contingent" or "last_survivor"
|
||||
survivor_commence_participant_only: bool = False
|
||||
interpolation_method: str = "linear"
|
||||
max_age: Optional[int] = None
|
||||
|
||||
|
||||
def present_value_joint_survivor(db: Session, inputs: JointSurvivorInputs) -> Dict[str, float]:
|
||||
"""Compute PV for a joint-survivor annuity.
|
||||
|
||||
Expected monthly payment at time t:
|
||||
E[Payment_t] = B * P(both alive at t) + B * s * P(spouse alive only at t)
|
||||
= B * [ (1 - s) * P(both alive) + s * P(spouse alive) ]
|
||||
where s = survivor_percent (0..1)
|
||||
"""
|
||||
if inputs.monthly_benefit < 0:
|
||||
raise ValueError("monthly_benefit must be non-negative")
|
||||
if inputs.term_months < 0:
|
||||
raise ValueError("term_months must be non-negative")
|
||||
if inputs.survivor_percent < 0 or inputs.survivor_percent > 100:
|
||||
raise ValueError("survivor_percent must be between 0 and 100")
|
||||
|
||||
if inputs.payment_period_months <= 0:
|
||||
raise ValueError("payment_period_months must be >= 1")
|
||||
if inputs.defer_months < 0:
|
||||
raise ValueError("defer_months must be >= 0")
|
||||
if inputs.certain_months < 0:
|
||||
raise ValueError("certain_months must be >= 0")
|
||||
|
||||
# Adjust term if max_age is provided and participant_age known
|
||||
term_months = inputs.term_months
|
||||
if inputs.max_age is not None and inputs.participant_age is not None:
|
||||
max_months = max(0, (int(inputs.max_age) - int(inputs.participant_age)) * 12)
|
||||
term_months = min(term_months, max_months)
|
||||
|
||||
p_part = _to_survival_probabilities(
|
||||
db,
|
||||
start_age=inputs.participant_age,
|
||||
sex=inputs.participant_sex,
|
||||
race=inputs.participant_race,
|
||||
term_months=term_months,
|
||||
interpolation_method=inputs.interpolation_method,
|
||||
)
|
||||
p_sp = _to_survival_probabilities(
|
||||
db,
|
||||
start_age=inputs.spouse_age,
|
||||
sex=inputs.spouse_sex,
|
||||
race=inputs.spouse_race,
|
||||
term_months=term_months,
|
||||
interpolation_method=inputs.interpolation_method,
|
||||
)
|
||||
|
||||
s_frac = float(inputs.survivor_percent) / 100.0
|
||||
|
||||
i_m = _to_monthly_rate(inputs.discount_rate)
|
||||
period = int(inputs.payment_period_months)
|
||||
t0 = int(math.ceil(inputs.defer_months))
|
||||
t = t0
|
||||
guarantee_end = float(inputs.defer_months) + float(inputs.certain_months)
|
||||
|
||||
pv_total = 0.0
|
||||
pv_both = 0.0
|
||||
pv_surv = 0.0
|
||||
first = True
|
||||
while t < term_months:
|
||||
p_part_t = p_part[t] if t < len(p_part) else 0.0
|
||||
p_sp_t = p_sp[t] if t < len(p_sp) else 0.0
|
||||
p_both = p_part_t * p_sp_t
|
||||
p_sp_only = p_sp_t - p_both
|
||||
base_amt = inputs.monthly_benefit * float(period)
|
||||
# Pro-rata first payment if deferral is fractional
|
||||
if first:
|
||||
frac_defer = float(inputs.defer_months) - math.floor(float(inputs.defer_months))
|
||||
pro_rata = 1.0 - (frac_defer / float(period)) if frac_defer > 0 else 1.0
|
||||
else:
|
||||
pro_rata = 1.0
|
||||
both_amt = base_amt * pro_rata * p_both
|
||||
if inputs.survivor_commence_participant_only:
|
||||
surv_basis_prob = p_part_t
|
||||
else:
|
||||
surv_basis_prob = p_sp_only
|
||||
surv_amt = base_amt * pro_rata * s_frac * surv_basis_prob
|
||||
if (inputs.survivor_basis or "contingent").lower() == "last_survivor":
|
||||
# Last-survivor: pay full while either is alive, then 0
|
||||
# E[Payment_t] = base_amt * P(participant alive OR spouse alive)
|
||||
p_either = p_part_t + p_sp_t - p_both
|
||||
total_amt = base_amt * pro_rata * p_either
|
||||
# Components are less meaningful; keep mortality-only decomposition
|
||||
else:
|
||||
# Contingent: full while both alive, survivor_percent to spouse when only spouse alive
|
||||
total_amt = base_amt * pro_rata if t < guarantee_end else (both_amt + surv_amt)
|
||||
|
||||
growth = _compute_growth_factor_at_month(
|
||||
t,
|
||||
cola_annual_percent=inputs.cola_rate,
|
||||
cola_mode=inputs.cola_mode,
|
||||
cola_cap_percent=inputs.cola_cap_percent,
|
||||
)
|
||||
discount = (1.0 + i_m) ** t
|
||||
|
||||
pv_total += (total_amt * growth) / discount
|
||||
# Components exclude guarantee to reflect mortality-only decomposition
|
||||
pv_both += (both_amt * growth) / discount
|
||||
pv_surv += (surv_amt * growth) / discount
|
||||
t += period
|
||||
first = False
|
||||
|
||||
return {
|
||||
"pv_total": float(pv_total),
|
||||
"pv_participant_component": float(pv_both),
|
||||
"pv_survivor_component": float(pv_surv),
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SingleLifeInputs",
|
||||
"JointSurvivorInputs",
|
||||
"present_value_single_life",
|
||||
"present_value_joint_survivor",
|
||||
"InvalidCodeError",
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user