All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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>
254 lines
8.3 KiB
Python
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"
|