""" 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", ]