import os import sys from pathlib import Path import math import pytest from fastapi.testclient import TestClient # Ensure required env vars for app import/config os.environ.setdefault("SECRET_KEY", "x" * 32) os.environ.setdefault("DATABASE_URL", "sqlite:////tmp/delphi_test.sqlite") # Ensure repository root on sys.path for direct test runs ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from app.main import app # noqa: E402 from app.auth.security import get_current_user # noqa: E402 from app.database.base import SessionLocal # noqa: E402 from app.models.pensions import NumberTable, LifeTable # noqa: E402 @pytest.fixture(scope="module") def client(): # Override auth to bypass JWT for these tests class _User: def __init__(self): self.id = "test" self.username = "tester" self.is_admin = True self.is_active = True app.dependency_overrides[get_current_user] = lambda: _User() try: yield TestClient(app) finally: app.dependency_overrides.pop(get_current_user, None) def _seed_monthly_na_series(): db = SessionLocal() try: # Clear and seed NumberTable for months 0..4 for both sexes, race A for m in range(0, 5): db.query(NumberTable).filter(NumberTable.month == m).delete() # NA series: [100000, 90000, 80000, 70000, 60000] series = [100000.0, 90000.0, 80000.0, 70000.0, 60000.0] for idx, na in enumerate(series): row = NumberTable( month=idx, na_am=na, # male (all races) na_af=na, # female (all races) na_aa=na, # all (all races) ) db.add(row) # Provide an LE row in case fallback is used inadvertently db.query(LifeTable).filter(LifeTable.age == 65).delete() db.add(LifeTable(age=65, le_am=20.0, le_af=22.0, le_aa=21.0)) db.commit() finally: db.close() def _monthly_rate(pct: float) -> float: return pow(1.0 + pct / 100.0, 1.0 / 12.0) - 1.0 def test_single_life_no_discount_no_cola(client: TestClient): _seed_monthly_na_series() payload = { "monthly_benefit": 1000.0, "term_months": 5, "start_age": 65, "sex": "M", "race": "A", "discount_rate": 0.0, "cola_rate": 0.0, } resp = client.post("/api/pensions/valuation/single-life", json=payload) assert resp.status_code == 200, resp.text # Survival probs: [1.0, 0.9, 0.8, 0.7, 0.6] # PV = 1000 * sum = 4000 assert math.isclose(resp.json()["pv"], 4000.0, rel_tol=1e-6, abs_tol=0.01) def test_joint_survivor_no_discount_no_cola(client: TestClient): _seed_monthly_na_series() payload = { "monthly_benefit": 1000.0, "term_months": 5, "participant_age": 65, "participant_sex": "M", "participant_race": "A", "spouse_age": 63, "spouse_sex": "F", "spouse_race": "A", "survivor_percent": 50.0, "discount_rate": 0.0, "cola_rate": 0.0, } resp = client.post("/api/pensions/valuation/joint-survivor", json=payload) assert resp.status_code == 200, resp.text data = resp.json() # p = [1, .9, .8, .7, .6] # p_both = p^2 = [1, .81, .64, .49, .36] # payment_t = 1000*p_both + 1000*0.5*(p - p_both) = 500*p + 500*p_both expected = 500.0 * (1 + 0.9 + 0.8 + 0.7 + 0.6) + 500.0 * (1 + 0.81 + 0.64 + 0.49 + 0.36) assert math.isclose(data["pv_total"], expected, rel_tol=1e-6, abs_tol=0.01) # Components expected_both = 1000.0 * (1 + 0.81 + 0.64 + 0.49 + 0.36) expected_surv = expected - expected_both assert math.isclose(data["pv_participant_component"], expected_both, rel_tol=1e-6, abs_tol=0.01) assert math.isclose(data["pv_survivor_component"], expected_surv, rel_tol=1e-6, abs_tol=0.01) def test_joint_survivor_last_survivor_basis(client: TestClient): _seed_monthly_na_series() payload = { "monthly_benefit": 1000.0, "term_months": 5, "participant_age": 65, "participant_sex": "M", "participant_race": "A", "spouse_age": 63, "spouse_sex": "F", "spouse_race": "A", "survivor_percent": 50.0, # should be ignored in last_survivor for total "discount_rate": 0.0, "cola_rate": 0.0, "survivor_basis": "last_survivor", } resp = client.post("/api/pensions/valuation/joint-survivor", json=payload) assert resp.status_code == 200, resp.text data = resp.json() # p = [1, .9, .8, .7, .6]; p_both = p^2; p_either = p_part + p_sp - p_both = 2p - p^2 p = [1.0, 0.9, 0.8, 0.7, 0.6] p_both = [x * x for x in p] p_either = [2 * x - x * x for x in p] expected_total = 1000.0 * sum(p_either) assert math.isclose(data["pv_total"], expected_total, rel_tol=1e-6, abs_tol=0.01) def test_interpolation_between_number_rows(client: TestClient): # Seed months 0 and 2 only; expect linear interpolation for month 1 db = SessionLocal() try: from app.models.pensions import NumberTable, LifeTable for m in range(0, 3): db.query(NumberTable).filter(NumberTable.month == m).delete() # only months 0 and 2 db.add(NumberTable(month=0, na_am=100000.0, na_af=100000.0, na_aa=100000.0)) db.add(NumberTable(month=2, na_am=80000.0, na_af=80000.0, na_aa=80000.0)) # Ensure LE exists but should not be used due to interpolation db.query(LifeTable).filter(LifeTable.age == 65).delete() db.add(LifeTable(age=65, le_am=30.0, le_af=30.0, le_aa=30.0)) db.commit() finally: db.close() payload = { "monthly_benefit": 1000.0, "term_months": 3, "start_age": 65, "sex": "A", "race": "A", "discount_rate": 0.0, "cola_rate": 0.0, "interpolation_method": "linear", } resp = client.post("/api/pensions/valuation/single-life", json=payload) assert resp.status_code == 200, resp.text # survival at t: 0->1.0, 1->0.9 (interpolated), 2->0.8 expected = 1000.0 * (1.0 + 0.9 + 0.8) assert math.isclose(resp.json()["pv"], expected, rel_tol=1e-6, abs_tol=0.01) def test_interpolation_step_method(client: TestClient): # Seed months 0 and 3 only; with step interpolation, months 1 and 2 carry month 0 value db = SessionLocal() try: from app.models.pensions import NumberTable for m in range(0, 4): db.query(NumberTable).filter(NumberTable.month == m).delete() db.add(NumberTable(month=0, na_aa=100000.0)) db.add(NumberTable(month=3, na_aa=70000.0)) db.commit() finally: db.close() payload = { "monthly_benefit": 1000.0, "term_months": 4, "start_age": 65, "sex": "A", "race": "A", "discount_rate": 0.0, "cola_rate": 0.0, "interpolation_method": "step", } resp = client.post("/api/pensions/valuation/single-life", json=payload) assert resp.status_code == 200, resp.text # Series becomes NA: [100k, 100k, 100k, 70k] -> p: [1.0,1.0,1.0,0.7] expected = 1000.0 * (1.0 + 1.0 + 1.0 + 0.7) assert math.isclose(resp.json()["pv"], expected, rel_tol=1e-6, abs_tol=0.01) def test_max_age_truncation(client: TestClient): _seed_monthly_na_series() # start_age 65, max_age 66 -> at most 12 months payload = { "monthly_benefit": 1000.0, "term_months": 24, "start_age": 65, "sex": "A", "race": "A", "discount_rate": 0.0, "cola_rate": 0.0, "max_age": 66, } resp = client.post("/api/pensions/valuation/single-life", json=payload) assert resp.status_code == 200 # With our seed p falls by 0.1 each month; but we only have 5 months seeded; extend to 12 db = SessionLocal() try: from app.models.pensions import NumberTable for m in range(5, 12): db.query(NumberTable).filter(NumberTable.month == m).delete() db.add(NumberTable(month=m, na_aa=100000.0 - 10000.0 * m)) db.commit() finally: db.close() # Recompute with extension resp = client.post("/api/pensions/valuation/single-life", json=payload) assert resp.status_code == 200 # p: 1.0,0.9,0.8,0.7,0.6,0.5,0.4,0.3,0.2,0.1,0.0 (clamped by interpolation), 0.0 -> sum first 10 effectively expected = 1000.0 * (1.0 + 0.9 + 0.8 + 0.7 + 0.6 + 0.5 + 0.4 + 0.3 + 0.2 + 0.1) assert math.isclose(resp.json()["pv"], expected, rel_tol=1e-6, abs_tol=0.1) def test_single_life_with_discount_and_cola(client: TestClient): _seed_monthly_na_series() # discount 12% annual, COLA 12% annual d_m = _monthly_rate(12.0) g_m = _monthly_rate(12.0) probs = [1.0, 0.9, 0.8, 0.7, 0.6] monthly = 1000.0 # Expected PV: sum( monthly * p[t] * (1+g)^t / (1+i)^t ) expected = 0.0 g = 1.0 d = 1.0 for t, p in enumerate(probs): if t == 0: g = 1.0 d = 1.0 else: g *= (1.0 + g_m) d *= (1.0 + d_m) expected += monthly * p * g / d payload = { "monthly_benefit": monthly, "term_months": 5, "start_age": 65, "sex": "A", "race": "A", "discount_rate": 12.0, "cola_rate": 12.0, "cola_mode": "monthly", } resp = client.post("/api/pensions/valuation/single-life", json=payload) assert resp.status_code == 200, resp.text assert math.isclose(resp.json()["pv"], expected, rel_tol=1e-6, abs_tol=0.05) def test_single_life_cola_annual_prorated_and_cap(client: TestClient): _seed_monthly_na_series() # Extend months 0..11 to allow a full year db = SessionLocal() try: from app.models.pensions import NumberTable for m in range(5, 12): db.query(NumberTable).filter(NumberTable.month == m).delete() db.add(NumberTable(month=m, na_aa=100000.0 - 10000.0 * m)) db.commit() finally: db.close() # annual COLA 6% but cap at 4%; annual prorated mode # Payments monthly for 6 months, p[t]=1,.9,.8,.7,.6,.5 # Growth at t uses 4% cap prorated: factors ~ 1.0, 1+0.04*(1/12), 1+0.04*(2/12), ... monthly = 1000.0 probs = [1.0, 0.9, 0.8, 0.7, 0.6, 0.5] a = 0.04 expected = 0.0 for t, p in enumerate(probs): growth = (1.0 + a * (t / 12.0)) expected += monthly * p * growth payload = { "monthly_benefit": monthly, "term_months": 6, "start_age": 65, "sex": "A", "race": "A", "discount_rate": 0.0, "cola_rate": 6.0, "cola_mode": "annual_prorated", "cola_cap_percent": 4.0, } resp = client.post("/api/pensions/valuation/single-life", json=payload) assert resp.status_code == 200, resp.text assert math.isclose(resp.json()["pv"], expected, rel_tol=1e-6, abs_tol=0.05) def test_single_life_quarterly_with_deferral(client: TestClient): _seed_monthly_na_series() # Quarterly payments (3 months per payment), starting after 2-month deferral # Horizon 10 months, payments at t=2,5,8 (assuming 0-indexed months) # Survival probs p[t] = [1, .9, .8, .7, .6, .5, .4, .3, .2, .1] for first 10 implied by seed extension # But our seed has only 5 months; extend seed db = SessionLocal() try: # extend NumberTable to months 0..9 with linear decrement 100k - 10k*m from app.models.pensions import NumberTable for m in range(5, 10): db.query(NumberTable).filter(NumberTable.month == m).delete() db.add(NumberTable(month=m, na_aa=100000.0 - 10000.0 * m)) db.commit() finally: db.close() payload = { "monthly_benefit": 1000.0, "term_months": 10, "start_age": 65, "sex": "A", "race": "A", "discount_rate": 0.0, "cola_rate": 0.0, "defer_months": 2, "payment_period_months": 3, } resp = client.post("/api/pensions/valuation/single-life", json=payload) assert resp.status_code == 200, resp.text # p = [1.0, .9, .8, .7, .6, .5, .4, .3, .2, .1] # Payments at t=2,5,8: amount = monthly * 3 * p[t] expected = 1000.0 * 3.0 * (0.8 + 0.5 + 0.2) assert math.isclose(resp.json()["pv"], expected, rel_tol=1e-6, abs_tol=0.01) def test_fractional_deferral_prorated_first_payment(client: TestClient): _seed_monthly_na_series() # Monthly payments, defer 0.5 month: first payment at month 1 with 50% of base # With p = [1.0, 0.9, 0.8, 0.7, 0.6] monthly = 1000.0 payload = { "monthly_benefit": monthly, "term_months": 5, "start_age": 65, "sex": "A", "race": "A", "discount_rate": 0.0, "cola_rate": 0.0, "defer_months": 0.5, "payment_period_months": 1, } resp = client.post("/api/pensions/valuation/single-life", json=payload) assert resp.status_code == 200, resp.text # First payment at t=1: 0.5*1000*0.9 + subsequent: 1000*0.8 + 1000*0.7 + 1000*0.6 expected = 0.5 * monthly * 0.9 + monthly * (0.8 + 0.7 + 0.6) assert math.isclose(resp.json()["pv"], expected, rel_tol=1e-6, abs_tol=0.01) def test_joint_survivor_participant_only_commencement(client: TestClient): _seed_monthly_na_series() payload = { "monthly_benefit": 1000.0, "term_months": 5, "participant_age": 65, "participant_sex": "M", "participant_race": "A", "spouse_age": 63, "spouse_sex": "F", "spouse_race": "A", "survivor_percent": 50.0, "discount_rate": 0.0, "cola_rate": 0.0, "survivor_commence_participant_only": True, } resp = client.post("/api/pensions/valuation/joint-survivor", json=payload) assert resp.status_code == 200, resp.text data = resp.json() # p = [1, .9, .8, .7, .6]; p_both = p^2; p_sp_only replaced by p_part for survivor component p = [1.0, 0.9, 0.8, 0.7, 0.6] p_both = [x * x for x in p] surv_component = sum(1000.0 * 0.5 * x for x in p) # using participant survival both_component = sum(1000.0 * x for x in p_both) expected_total = 1000.0 + (both_component - 1000.0) + surv_component # first period total includes guarantee at t=0 (from previous tests) # Given our service guarantees only if certain_months > 0, here it's 0, so no guarantee. Recompute expected total accordingly expected_total = both_component + surv_component assert math.isclose(data["pv_participant_component"], both_component, rel_tol=1e-6, abs_tol=0.01) assert math.isclose(data["pv_survivor_component"], surv_component, rel_tol=1e-6, abs_tol=0.01) assert math.isclose(data["pv_total"], expected_total, rel_tol=1e-6, abs_tol=0.01) def test_certain_period_guarantee_then_mortality(client: TestClient): _seed_monthly_na_series() # Extend 0..9 again db = SessionLocal() try: from app.models.pensions import NumberTable for m in range(5, 10): db.query(NumberTable).filter(NumberTable.month == m).delete() db.add(NumberTable(month=m, na_aa=100000.0 - 10000.0 * m)) db.commit() finally: db.close() # Monthly payments, no deferral, 3 months certain payload = { "monthly_benefit": 1000.0, "term_months": 6, "start_age": 65, "sex": "A", "race": "A", "discount_rate": 0.0, "cola_rate": 0.0, "defer_months": 0, "payment_period_months": 1, "certain_months": 3, } resp = client.post("/api/pensions/valuation/single-life", json=payload) assert resp.status_code == 200, resp.text # p = [1.0, .9, .8, .7, .6, .5] # guaranteed first 3: 1000, 1000, 1000; then mortality-weighted: 700, 600, 500 expected = 1000.0 * 3 + 1000.0 * (0.7 + 0.6 + 0.5) assert math.isclose(resp.json()["pv"], expected, rel_tol=1e-6, abs_tol=0.01) # Joint-survivor: same guarantee logic applies to total stream payload_js = { "monthly_benefit": 1000.0, "term_months": 4, "participant_age": 65, "participant_sex": "M", "participant_race": "A", "spouse_age": 63, "spouse_sex": "F", "spouse_race": "A", "survivor_percent": 50.0, "discount_rate": 0.0, "cola_rate": 0.0, "defer_months": 0, "payment_period_months": 1, "certain_months": 2, } resp = client.post("/api/pensions/valuation/joint-survivor", json=payload_js) assert resp.status_code == 200, resp.text data = resp.json() # p = [1.0, .9, .8, .7] # total mortality stream (no guarantee): 1000*p_both + 500*(p - p_both) p = [1.0, 0.9, 0.8, 0.7] p_both = [x * x for x in p] mort = [1000.0 * pb + 500.0 * (x - pb) for x, pb in zip(p, p_both)] total_expected = 1000.0 + 1000.0 + mort[2] + mort[3] assert math.isclose(data["pv_total"], total_expected, rel_tol=1e-6, abs_tol=0.01) def test_batch_single_life_mixed_success_failure(client: TestClient): _seed_monthly_na_series() valid_item = { "monthly_benefit": 1000.0, "term_months": 5, "start_age": 65, "sex": "M", "race": "A", "discount_rate": 0.0, "cola_rate": 0.0, } invalid_item = { "monthly_benefit": -100.0, # invalid "term_months": 5, "start_age": 65, "sex": "M", "race": "A", "discount_rate": 0.0, "cola_rate": 0.0, } payload = { "items": [valid_item, invalid_item, valid_item] } resp = client.post("/api/pensions/valuation/batch-single-life", json=payload) assert resp.status_code == 200, resp.text data = resp.json()["results"] assert len(data) == 3 assert data[0]["success"] is True assert "pv" in data[0]["result"] assert math.isclose(data[0]["result"]["pv"], 4000.0, rel_tol=1e-6) assert data[1]["success"] is False assert "monthly_benefit must be non-negative" in (data[1]["error"] or "") assert data[2]["success"] is True assert math.isclose(data[2]["result"]["pv"], 4000.0, rel_tol=1e-6) def test_batch_joint_survivor_mixed_success_failure(client: TestClient): _seed_monthly_na_series() valid_item = { "monthly_benefit": 1000.0, "term_months": 5, "participant_age": 65, "participant_sex": "M", "participant_race": "A", "spouse_age": 63, "spouse_sex": "F", "spouse_race": "A", "survivor_percent": 50.0, "discount_rate": 0.0, "cola_rate": 0.0, } invalid_item = { "monthly_benefit": 1000.0, "term_months": 5, "participant_age": 65, "participant_sex": "M", "participant_race": "A", "spouse_age": 63, "spouse_sex": "F", "spouse_race": "A", "survivor_percent": 150.0, # invalid >100 "discount_rate": 0.0, "cola_rate": 0.0, } payload = { "items": [valid_item, invalid_item] } resp = client.post("/api/pensions/valuation/batch-joint-survivor", json=payload) assert resp.status_code == 200, resp.text data = resp.json()["results"] assert len(data) == 2 assert data[0]["success"] is True assert "pv_total" in data[0]["result"] p = [1.0, 0.9, 0.8, 0.7, 0.6] p_both = [x * x for x in p] expected = 500.0 * sum(p) + 500.0 * sum(p_both) assert math.isclose(data[0]["result"]["pv_total"], expected, rel_tol=1e-6) assert data[1]["success"] is False assert "survivor_percent must be between 0 and 100" in (data[1]["error"] or "")