fire-planner/tests/test_db_schema.py
Viktor Barzin 31193faf08
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
schema: add life_event, retirement_goal; extend scenario with kind/parent
Two new tables and three new columns on `scenario` to give the
ProjectionLab-style UI a place to land:

- `scenario` gains `kind` (cartesian | user), `name`, `description`,
  `parent_scenario_id`. Existing Cartesian flow keeps `kind='cartesian'`
  by default; user-defined scenarios point `parent_scenario_id` at the
  base they cloned from (NULL for root).

- `life_event` — timed events on a scenario timeline: retirement, kid
  born, mortgage payoff, sabbatical, inheritance, etc. `year_start` and
  `year_end` are scenario-relative (year 0 = today).
  `delta_gbp_per_year` covers ranged effects; `one_time_amount_gbp`
  covers one-shot impacts. `enabled` lets the UI toggle without delete.

- `retirement_goal` — user-defined success criteria (target_nw,
  never_run_out, inheritance, ...). `comparator` + `success_threshold`
  let the goal say "≥ £2M at year 25 in ≥ 90% of paths".

Migration 0002 adds the columns + tables idempotently.
145 tests; mypy strict + ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 21:36:58 +00:00

254 lines
8.3 KiB
Python

"""Smoke-test the ORM schema — every table must round-trip a row."""
from datetime import date
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from fire_planner.db import (
AccountSnapshot,
LifeEvent,
McPath,
McRun,
ProjectionYearly,
RetirementGoal,
Scenario,
ScenarioSummary,
)
async def test_account_snapshot_roundtrip(session: AsyncSession) -> None:
snap = AccountSnapshot(
external_id="wealthfolio:account-1:2026-04-25",
snapshot_date=date(2026, 4, 25),
account_id="account-1",
account_name="ISA",
account_type="ISA",
currency="GBP",
market_value=Decimal("123456.78"),
market_value_gbp=Decimal("123456.78"),
)
session.add(snap)
await session.commit()
result = await session.execute(select(AccountSnapshot))
rows = result.scalars().all()
assert len(rows) == 1
assert rows[0].external_id == "wealthfolio:account-1:2026-04-25"
async def test_scenario_roundtrip(session: AsyncSession) -> None:
scen = Scenario(
external_id="cyprus-vpw-leave-y3-glide-rising",
jurisdiction="cyprus",
strategy="vpw",
leave_uk_year=3,
glide_path="rising",
spending_gbp=Decimal("100000"),
nw_seed_gbp=Decimal("1000000"),
savings_per_year_gbp=Decimal("100000"),
config_json={"horizon_years": 60},
)
session.add(scen)
await session.commit()
result = await session.execute(select(Scenario))
rows = result.scalars().all()
assert len(rows) == 1
assert rows[0].jurisdiction == "cyprus"
async def test_mc_run_roundtrip(session: AsyncSession) -> None:
run = McRun(
scenario_id=1,
n_paths=10000,
seed=42,
success_rate=Decimal("0.9412"),
p10_ending_gbp=Decimal("250000"),
p50_ending_gbp=Decimal("3500000"),
p90_ending_gbp=Decimal("12000000"),
median_lifetime_tax_gbp=Decimal("750000"),
elapsed_seconds=Decimal("42.351"),
)
session.add(run)
await session.commit()
result = await session.execute(select(McRun))
rows = result.scalars().all()
assert len(rows) == 1
assert rows[0].n_paths == 10000
async def test_remaining_tables_smoke(session: AsyncSession) -> None:
session.add(
McPath(mc_run_id=1,
path_idx=0,
bucket="median",
year_idx=0,
portfolio_gbp=Decimal("1000000"),
withdrawal_gbp=Decimal("100000"),
tax_paid_gbp=Decimal("0"),
real_portfolio_gbp=Decimal("1000000")))
session.add(
ProjectionYearly(mc_run_id=1,
year_idx=0,
p10_portfolio_gbp=Decimal("800000"),
p25_portfolio_gbp=Decimal("900000"),
p50_portfolio_gbp=Decimal("1000000"),
p75_portfolio_gbp=Decimal("1100000"),
p90_portfolio_gbp=Decimal("1200000"),
p50_withdrawal_gbp=Decimal("100000"),
p50_tax_gbp=Decimal("0"),
survival_rate=Decimal("1")))
session.add(
ScenarioSummary(scenario_id=1,
mc_run_id=1,
jurisdiction="uk",
strategy="trinity",
leave_uk_year=0,
glide_path="static",
spending_gbp=Decimal("100000"),
success_rate=Decimal("0.95"),
p10_ending_gbp=Decimal("200000"),
p50_ending_gbp=Decimal("3000000"),
p90_ending_gbp=Decimal("10000000"),
median_lifetime_tax_gbp=Decimal("800000")))
await session.commit()
async def test_user_scenario_with_clone(session: AsyncSession) -> None:
"""User-defined scenarios can declare a parent (cloned-from) scenario."""
base = Scenario(
external_id="cyprus-vpw-leave-y3-glide-rising",
kind="cartesian",
jurisdiction="cyprus",
strategy="vpw",
leave_uk_year=3,
glide_path="rising",
spending_gbp=Decimal("100000"),
nw_seed_gbp=Decimal("1500000"),
savings_per_year_gbp=Decimal("0"),
config_json={"horizon_years": 60},
)
session.add(base)
await session.commit()
user = Scenario(
external_id="user-aggressive-fire",
kind="user",
name="Aggressive FIRE",
description="Same as Cyprus baseline but spending £80k",
parent_scenario_id=base.id,
jurisdiction="cyprus",
strategy="vpw",
leave_uk_year=3,
glide_path="rising",
spending_gbp=Decimal("80000"),
nw_seed_gbp=Decimal("1500000"),
savings_per_year_gbp=Decimal("0"),
config_json={"horizon_years": 60, "floor": 60000},
)
session.add(user)
await session.commit()
rows = (await session.execute(select(Scenario).where(Scenario.kind == "user"))).scalars().all()
assert len(rows) == 1
assert rows[0].name == "Aggressive FIRE"
assert rows[0].parent_scenario_id == base.id
async def test_life_event_roundtrip(session: AsyncSession) -> None:
ev = LifeEvent(
scenario_id=1,
kind="retirement",
name="Retire at 50",
year_start=15,
year_end=15,
delta_gbp_per_year=Decimal("0"),
)
childcare = LifeEvent(
scenario_id=1,
kind="expense_range",
name="Childcare",
year_start=2,
year_end=20,
delta_gbp_per_year=Decimal("-12000"),
)
inheritance = LifeEvent(
scenario_id=1,
kind="one_time_income",
name="Inheritance",
year_start=22,
year_end=22,
one_time_amount_gbp=Decimal("250000"),
)
session.add_all([ev, childcare, inheritance])
await session.commit()
rows = (await session.execute(
select(LifeEvent).where(LifeEvent.scenario_id == 1))).scalars().all()
assert len(rows) == 3
by_kind = {r.kind: r for r in rows}
assert by_kind["retirement"].year_start == 15
assert by_kind["expense_range"].delta_gbp_per_year == Decimal("-12000")
assert by_kind["one_time_income"].one_time_amount_gbp == Decimal("250000")
async def test_life_event_default_enabled(session: AsyncSession) -> None:
ev = LifeEvent(scenario_id=1, kind="retirement", name="Retire", year_start=10)
session.add(ev)
await session.commit()
fetched = (await session.execute(select(LifeEvent))).scalars().first()
assert fetched is not None
assert fetched.enabled is True
async def test_retirement_goal_roundtrip(session: AsyncSession) -> None:
target_nw = RetirementGoal(
scenario_id=1,
kind="target_nw",
name="≥ £2M at age 50",
target_amount_gbp=Decimal("2000000"),
target_year=15,
comparator=">=",
success_threshold=Decimal("0.90"),
)
never_run_out = RetirementGoal(
scenario_id=1,
kind="never_run_out",
name="Last to age 95",
target_year=65,
comparator=">=",
success_threshold=Decimal("0.95"),
)
inheritance = RetirementGoal(
scenario_id=1,
kind="inheritance",
name="Leave £500k",
target_amount_gbp=Decimal("500000"),
target_year=65,
comparator=">=",
success_threshold=Decimal("0.50"),
)
session.add_all([target_nw, never_run_out, inheritance])
await session.commit()
rows = (await session.execute(select(RetirementGoal))).scalars().all()
assert len(rows) == 3
kinds = {r.kind for r in rows}
assert kinds == {"target_nw", "never_run_out", "inheritance"}
async def test_scenario_kind_default_cartesian(session: AsyncSession) -> None:
"""Existing Cartesian scenarios omit `kind` — default applies."""
scen = Scenario(
external_id="uk-trinity-leave-y0-glide-static",
jurisdiction="uk",
strategy="trinity",
leave_uk_year=0,
glide_path="static",
spending_gbp=Decimal("60000"),
nw_seed_gbp=Decimal("1500000"),
savings_per_year_gbp=Decimal("0"),
config_json={},
)
session.add(scen)
await session.commit()
fetched = (await session.execute(select(Scenario))).scalars().first()
assert fetched is not None
assert fetched.kind == "cartesian"