-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
Portfolio fan
+
+
+
+
+
-
-
-
Portfolio fan
-
-
- >
+
+
) : projection404 ? (
No projection yet.
@@ -207,8 +259,9 @@ export function ScenarioDetail() {
)}
+
+
-
);
}
diff --git a/frontend/src/pages/ScenarioShell.tsx b/frontend/src/pages/ScenarioShell.tsx
new file mode 100644
index 0000000..acbdaaa
--- /dev/null
+++ b/frontend/src/pages/ScenarioShell.tsx
@@ -0,0 +1,52 @@
+/**
+ * Tabbed shell for a scenario (Wave 1.A.1) — top tab bar + left sidebar.
+ *
+ * The body is owned by nested routes so each tab can manage its own
+ * data without re-mounting the chrome.
+ */
+import { Outlet, useParams } from 'react-router-dom';
+
+import { Sidebar } from '@/components/Sidebar';
+import { TabBar, type TabSpec } from '@/components/TabBar';
+
+export function ScenarioShell() {
+ const params = useParams<{ id: string }>();
+ const id = Number(params.id);
+ const tabs: TabSpec[] = [
+ { to: `/scenarios/${id}`, label: 'Plan', end: true },
+ { to: `/scenarios/${id}/cash-flow`, label: 'Cash Flow' },
+ { to: `/scenarios/${id}/tax-analytics`, label: 'Tax Analytics' },
+ { to: `/scenarios/${id}/compare`, label: 'Compare' },
+ { to: `/scenarios/${id}/reports`, label: 'Reports' },
+ { to: `/scenarios/${id}/estate`, label: 'Estate' },
+ { to: `/scenarios/${id}/settings`, label: 'Settings' },
+ ];
+
+ return (
+
+ );
+}
+
+export function ComingInWaveCard({
+ wave,
+ feature,
+}: {
+ wave: number;
+ feature: string;
+}) {
+ return (
+
+
🚧
+
{feature}
+
Coming in Wave {wave}.
+
+ );
+}
diff --git a/frontend/src/pages/SettingsTab.tsx b/frontend/src/pages/SettingsTab.tsx
new file mode 100644
index 0000000..da2643e
--- /dev/null
+++ b/frontend/src/pages/SettingsTab.tsx
@@ -0,0 +1,35 @@
+/**
+ * Settings tab (Wave 1.D.1) — sub-nav + nested route outlet.
+ */
+import { Outlet, useParams } from 'react-router-dom';
+
+import { SettingsSubnav } from '@/components/SettingsSubnav';
+
+export function SettingsTab() {
+ const params = useParams<{ id: string }>();
+ const id = Number(params.id);
+ const items = [
+ { to: `/scenarios/${id}/settings`, label: 'Milestones', end: true },
+ { to: `/scenarios/${id}/settings/rates`, label: 'Rates' },
+ { to: `/scenarios/${id}/settings/dividends`, label: 'Dividends' },
+ { to: `/scenarios/${id}/settings/bonds`, label: 'Bonds' },
+ { to: `/scenarios/${id}/settings/tax`, label: 'Tax' },
+ { to: `/scenarios/${id}/settings/metrics`, label: 'Metrics' },
+ { to: `/scenarios/${id}/settings/other`, label: 'Other Settings' },
+ { to: `/scenarios/${id}/settings/notes`, label: 'Notes' },
+ ];
+
+ return (
+
+ );
+}
diff --git a/tests/test_api_cashflow.py b/tests/test_api_cashflow.py
new file mode 100644
index 0000000..2f5ed68
--- /dev/null
+++ b/tests/test_api_cashflow.py
@@ -0,0 +1,136 @@
+"""Tests for the Cash Flow / Sankey endpoint."""
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+from datetime import UTC, datetime
+from decimal import Decimal
+
+import pytest_asyncio
+from httpx import ASGITransport, AsyncClient
+from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
+
+from fire_planner.api.dependencies import get_session
+from fire_planner.app import app
+from fire_planner.db import IncomeStream, McRun, ProjectionYearly, Scenario
+
+
+@pytest_asyncio.fixture
+async def client(engine: AsyncEngine,
+ session: AsyncSession) -> AsyncIterator[AsyncClient]:
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+
+ async def _override() -> AsyncIterator[AsyncSession]:
+ async with factory() as s:
+ yield s
+
+ app.dependency_overrides[get_session] = _override
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
+ yield ac
+ app.dependency_overrides.clear()
+
+
+async def _seed(session: AsyncSession) -> int:
+ scen = Scenario(
+ external_id="user-cf",
+ kind="user",
+ name="Cashflow",
+ jurisdiction="uk",
+ strategy="trinity",
+ leave_uk_year=0,
+ glide_path="static",
+ spending_gbp=Decimal("60000"),
+ horizon_years=5,
+ nw_seed_gbp=Decimal("1000000"),
+ savings_per_year_gbp=Decimal("0"),
+ config_json={},
+ )
+ session.add(scen)
+ await session.commit()
+ await session.refresh(scen)
+
+ run = McRun(
+ scenario_id=scen.id,
+ run_at=datetime.now(UTC),
+ n_paths=10,
+ seed=1,
+ success_rate=Decimal("1"),
+ p10_ending_gbp=Decimal("0"),
+ p50_ending_gbp=Decimal("0"),
+ p90_ending_gbp=Decimal("0"),
+ median_lifetime_tax_gbp=Decimal("0"),
+ median_years_to_ruin=None,
+ elapsed_seconds=Decimal("0"),
+ )
+ session.add(run)
+ await session.commit()
+ await session.refresh(run)
+
+ yearly = [
+ ProjectionYearly(
+ mc_run_id=run.id,
+ year_idx=y,
+ p10_portfolio_gbp=Decimal("900000"),
+ p25_portfolio_gbp=Decimal("950000"),
+ p50_portfolio_gbp=Decimal(str(1_000_000 + y * 50_000)),
+ p75_portfolio_gbp=Decimal("1100000"),
+ p90_portfolio_gbp=Decimal("1200000"),
+ p50_withdrawal_gbp=Decimal("60000"),
+ p50_tax_gbp=Decimal("8000"),
+ survival_rate=Decimal("1"),
+ ) for y in range(3)
+ ]
+ session.add_all(yearly)
+
+ stream = IncomeStream(
+ scenario_id=scen.id,
+ kind="salary",
+ name="Day job",
+ start_year=0,
+ end_year=2,
+ amount_gbp_per_year=Decimal("80000"),
+ growth_pct=Decimal("0"),
+ tax_treatment="income",
+ enabled=True,
+ )
+ session.add(stream)
+ await session.commit()
+ return scen.id
+
+
+async def test_cashflow_balances(client: AsyncClient, session: AsyncSession) -> None:
+ sid = await _seed(session)
+ resp = await client.get(f"/scenarios/{sid}/cashflow?year=1")
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ sources_total = sum(Decimal(v) for v in body["sources"].values())
+ sinks_total = sum(Decimal(v) for v in body["sinks"].values())
+ assert sources_total == sinks_total
+ # Salary should appear as a source.
+ assert any(k.startswith("income:") for k in body["sources"])
+ # Spending and taxes are always sinks.
+ assert "spending" in body["sinks"]
+ assert "taxes" in body["sinks"]
+
+
+async def test_cashflow_404_when_no_run(client: AsyncClient,
+ session: AsyncSession) -> None:
+ scen = Scenario(
+ external_id="user-no-run-cf",
+ kind="user",
+ name="No run cf",
+ jurisdiction="uk",
+ strategy="trinity",
+ leave_uk_year=0,
+ glide_path="static",
+ spending_gbp=Decimal("60000"),
+ horizon_years=5,
+ nw_seed_gbp=Decimal("1000000"),
+ savings_per_year_gbp=Decimal("0"),
+ config_json={},
+ )
+ session.add(scen)
+ await session.commit()
+ await session.refresh(scen)
+ resp = await client.get(f"/scenarios/{scen.id}/cashflow?year=0")
+ assert resp.status_code == 404
diff --git a/tests/test_api_progress.py b/tests/test_api_progress.py
new file mode 100644
index 0000000..28f5146
--- /dev/null
+++ b/tests/test_api_progress.py
@@ -0,0 +1,158 @@
+"""Tests for the Progress overlay endpoint."""
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+from datetime import UTC, date, datetime
+from decimal import Decimal
+
+import pytest_asyncio
+from httpx import ASGITransport, AsyncClient
+from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
+
+from fire_planner.api.dependencies import get_session
+from fire_planner.app import app
+from fire_planner.db import (
+ AccountSnapshot,
+ McRun,
+ ProjectionYearly,
+ Scenario,
+)
+
+
+@pytest_asyncio.fixture
+async def client(engine: AsyncEngine,
+ session: AsyncSession) -> AsyncIterator[AsyncClient]:
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+
+ async def _override() -> AsyncIterator[AsyncSession]:
+ async with factory() as s:
+ yield s
+
+ app.dependency_overrides[get_session] = _override
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
+ yield ac
+ app.dependency_overrides.clear()
+
+
+async def _seed_full(session: AsyncSession) -> int:
+ scen = Scenario(
+ external_id="user-prog",
+ kind="user",
+ name="Progress test",
+ jurisdiction="uk",
+ strategy="trinity",
+ leave_uk_year=0,
+ glide_path="static",
+ spending_gbp=Decimal("60000"),
+ horizon_years=5,
+ nw_seed_gbp=Decimal("1000000"),
+ savings_per_year_gbp=Decimal("0"),
+ config_json={},
+ )
+ session.add(scen)
+ await session.commit()
+ await session.refresh(scen)
+
+ run = McRun(
+ scenario_id=scen.id,
+ run_at=datetime.now(UTC),
+ n_paths=100,
+ seed=1,
+ success_rate=Decimal("1"),
+ p10_ending_gbp=Decimal("0"),
+ p50_ending_gbp=Decimal("0"),
+ p90_ending_gbp=Decimal("0"),
+ median_lifetime_tax_gbp=Decimal("0"),
+ median_years_to_ruin=None,
+ elapsed_seconds=Decimal("0"),
+ )
+ session.add(run)
+ await session.commit()
+ await session.refresh(run)
+
+ yearly = [
+ ProjectionYearly(
+ mc_run_id=run.id,
+ year_idx=y,
+ p10_portfolio_gbp=Decimal("900000"),
+ p25_portfolio_gbp=Decimal("950000"),
+ p50_portfolio_gbp=Decimal(str(1_000_000 + y * 50_000)),
+ p75_portfolio_gbp=Decimal("1100000"),
+ p90_portfolio_gbp=Decimal("1200000"),
+ p50_withdrawal_gbp=Decimal("60000"),
+ p50_tax_gbp=Decimal("8000"),
+ survival_rate=Decimal("1"),
+ ) for y in range(3)
+ ]
+ session.add_all(yearly)
+
+ # Two snapshots a year apart
+ snap_a = AccountSnapshot(
+ external_id="wf:a:2024-01-01",
+ snapshot_date=date(2024, 1, 1),
+ account_id="a",
+ account_name="Stocks",
+ account_type="brokerage",
+ currency="GBP",
+ market_value=Decimal("1000000"),
+ market_value_gbp=Decimal("1000000"),
+ )
+ snap_b = AccountSnapshot(
+ external_id="wf:a:2025-01-01",
+ snapshot_date=date(2025, 1, 1),
+ account_id="a",
+ account_name="Stocks",
+ account_type="brokerage",
+ currency="GBP",
+ market_value=Decimal("1080000"),
+ market_value_gbp=Decimal("1080000"),
+ )
+ session.add_all([snap_a, snap_b])
+ await session.commit()
+ return scen.id
+
+
+async def test_progress_returns_actual_and_projected(
+ client: AsyncClient,
+ session: AsyncSession,
+) -> None:
+ sid = await _seed_full(session)
+ resp = await client.get(f"/scenarios/{sid}/progress")
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ assert body["scenario_id"] == sid
+ assert body["alignment_anchor"] == "2024-01-01"
+ assert len(body["actual"]) == 2
+ assert len(body["projected"]) == 3
+ # year_idx 1 has actual £1.08M vs projected £1.05M → +30k variance.
+ variance_y1 = next(v for v in body["variance"] if v["year_idx"] == 1)
+ assert Decimal(variance_y1["delta_gbp"]) == Decimal("30000.00")
+
+
+async def test_progress_handles_empty_snapshots(
+ client: AsyncClient,
+ session: AsyncSession,
+) -> None:
+ scen = Scenario(
+ external_id="user-empty",
+ kind="user",
+ name="No snapshots",
+ jurisdiction="uk",
+ strategy="trinity",
+ leave_uk_year=0,
+ glide_path="static",
+ spending_gbp=Decimal("60000"),
+ horizon_years=5,
+ nw_seed_gbp=Decimal("1000000"),
+ savings_per_year_gbp=Decimal("0"),
+ config_json={},
+ )
+ session.add(scen)
+ await session.commit()
+ await session.refresh(scen)
+ resp = await client.get(f"/scenarios/{scen.id}/progress")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["actual"] == []
+ assert body["variance"] == []
diff --git a/tests/test_api_year_stats.py b/tests/test_api_year_stats.py
new file mode 100644
index 0000000..969cc30
--- /dev/null
+++ b/tests/test_api_year_stats.py
@@ -0,0 +1,124 @@
+"""Tests for the per-year stats endpoint."""
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+from datetime import UTC, datetime
+from decimal import Decimal
+
+import pytest_asyncio
+from httpx import ASGITransport, AsyncClient
+from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
+
+from fire_planner.api.dependencies import get_session
+from fire_planner.app import app
+from fire_planner.db import McRun, ProjectionYearly, Scenario
+
+
+@pytest_asyncio.fixture
+async def client(engine: AsyncEngine,
+ session: AsyncSession) -> AsyncIterator[AsyncClient]:
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+
+ async def _override() -> AsyncIterator[AsyncSession]:
+ async with factory() as s:
+ yield s
+
+ app.dependency_overrides[get_session] = _override
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
+ yield ac
+ app.dependency_overrides.clear()
+
+
+async def _seed_scenario_with_run(session: AsyncSession) -> int:
+ scen = Scenario(
+ external_id="user-yr-stats",
+ kind="user",
+ name="Yr Stats",
+ jurisdiction="uk",
+ strategy="trinity",
+ leave_uk_year=0,
+ glide_path="static",
+ spending_gbp=Decimal("60000"),
+ horizon_years=5,
+ nw_seed_gbp=Decimal("1000000"),
+ savings_per_year_gbp=Decimal("0"),
+ config_json={},
+ )
+ session.add(scen)
+ await session.commit()
+ await session.refresh(scen)
+
+ run = McRun(
+ scenario_id=scen.id,
+ run_at=datetime.now(UTC),
+ n_paths=100,
+ seed=1,
+ success_rate=Decimal("1"),
+ p10_ending_gbp=Decimal("900000"),
+ p50_ending_gbp=Decimal("1100000"),
+ p90_ending_gbp=Decimal("1300000"),
+ median_lifetime_tax_gbp=Decimal("50000"),
+ median_years_to_ruin=None,
+ elapsed_seconds=Decimal("1.234"),
+ )
+ session.add(run)
+ await session.commit()
+ await session.refresh(run)
+
+ rows = [
+ ProjectionYearly(
+ mc_run_id=run.id,
+ year_idx=y,
+ p10_portfolio_gbp=Decimal("900000"),
+ p25_portfolio_gbp=Decimal("950000"),
+ p50_portfolio_gbp=Decimal(str(1_000_000 + y * 50_000)),
+ p75_portfolio_gbp=Decimal("1100000"),
+ p90_portfolio_gbp=Decimal("1200000"),
+ p50_withdrawal_gbp=Decimal("60000"),
+ p50_tax_gbp=Decimal("8000"),
+ survival_rate=Decimal("1.0"),
+ ) for y in range(5)
+ ]
+ session.add_all(rows)
+ await session.commit()
+ return scen.id
+
+
+async def test_year_stats_returns_per_year_metrics(
+ client: AsyncClient,
+ session: AsyncSession,
+) -> None:
+ sid = await _seed_scenario_with_run(session)
+ resp = await client.get(f"/scenarios/{sid}/year-stats?year=2")
+ assert resp.status_code == 200, resp.text
+ body = resp.json()
+ assert body["year_idx"] == 2
+ # year 2 NW = 1_100_000; year 1 NW = 1_050_000 → change 50_000.
+ assert body["net_worth_p50"] == "1100000.00"
+ assert body["change_in_nw"] == "50000.00"
+ assert body["spending"] == "60000.00"
+ assert body["taxes"] == "8000.00"
+
+
+async def test_year_stats_404_when_no_run(client: AsyncClient,
+ session: AsyncSession) -> None:
+ scen = Scenario(
+ external_id="user-no-run",
+ kind="user",
+ name="No run",
+ jurisdiction="uk",
+ strategy="trinity",
+ leave_uk_year=0,
+ glide_path="static",
+ spending_gbp=Decimal("60000"),
+ horizon_years=5,
+ nw_seed_gbp=Decimal("1000000"),
+ savings_per_year_gbp=Decimal("0"),
+ config_json={},
+ )
+ session.add(scen)
+ await session.commit()
+ await session.refresh(scen)
+ resp = await client.get(f"/scenarios/{scen.id}/year-stats?year=0")
+ assert resp.status_code == 404
diff --git a/tests/test_goals_eval.py b/tests/test_goals_eval.py
new file mode 100644
index 0000000..683ab8b
--- /dev/null
+++ b/tests/test_goals_eval.py
@@ -0,0 +1,126 @@
+"""Tests for fire_planner.goals_eval — parametrised over goal kinds."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from decimal import Decimal
+
+import numpy as np
+import pytest
+
+from fire_planner.goals_eval import evaluate_goals
+from fire_planner.simulator import SimulationResult
+
+
+@dataclass
+class _Goal:
+ kind: str
+ name: str
+ target_amount_gbp: Decimal | None = None
+ target_year: int | None = None
+ comparator: str = ">="
+ success_threshold: Decimal = Decimal("0.95")
+ enabled: bool = True
+
+
+def _make_result(
+ portfolio_paths: list[list[float]],
+ withdrawal_paths: list[list[float]] | None = None,
+) -> SimulationResult:
+ """Build a SimulationResult from explicit per-path arrays."""
+ portfolio = np.asarray(portfolio_paths, dtype=np.float64)
+ n_paths, ncols = portfolio.shape
+ n_years = ncols - 1
+ if withdrawal_paths is None:
+ wd = np.zeros((n_paths, n_years), dtype=np.float64)
+ else:
+ wd = np.asarray(withdrawal_paths, dtype=np.float64)
+ tax = np.zeros((n_paths, n_years), dtype=np.float64)
+ success_mask = portfolio[:, 1:-1].min(axis=1) > 0.0 if ncols >= 3 else np.ones(
+ n_paths, dtype=bool)
+ return SimulationResult(
+ portfolio_real=portfolio,
+ withdrawal_real=wd,
+ tax_real=tax,
+ success_mask=success_mask,
+ )
+
+
+def test_target_nw_by_year_exact_count() -> None:
+ # 4 paths, 3 years. At year 2: [200, 1500, 2500, 3000]. Target ≥ £2M
+ # → 2/4 hit → probability 0.5.
+ portfolio = [
+ [1000, 500, 200, 100],
+ [1000, 1200, 1500, 1700],
+ [1000, 2000, 2500, 2800],
+ [1000, 2400, 3000, 3500],
+ ]
+ result = _make_result(portfolio)
+ goal = _Goal(kind="target_nw_by_year",
+ name="≥ £2M at y2",
+ target_amount_gbp=Decimal("2000"),
+ target_year=2,
+ comparator=">=",
+ success_threshold=Decimal("0.4"))
+ [eval_] = evaluate_goals(result, [goal])
+ assert eval_.probability == pytest.approx(0.5)
+ assert eval_.passed is True
+
+
+def test_never_run_out_full_horizon() -> None:
+ # 4 paths over 4 years. Path 0 hits 0 at year 2. Path 1 hits 0 at
+ # year 3. Path 2 + 3 stay positive throughout.
+ portfolio = [
+ [1000, 500, 0, 0, 0],
+ [1000, 800, 600, 0, 0],
+ [1000, 900, 800, 700, 600],
+ [1000, 1100, 1200, 1300, 1400],
+ ]
+ result = _make_result(portfolio)
+ goal = _Goal(kind="never_run_out",
+ name="don't ruin",
+ target_year=None,
+ success_threshold=Decimal("0.5"))
+ [eval_] = evaluate_goals(result, [goal])
+ assert eval_.probability == pytest.approx(0.5)
+ assert eval_.passed is True
+
+
+def test_target_real_income_uses_path_median() -> None:
+ portfolio = [
+ [1000, 1000, 1000],
+ [1000, 1000, 1000],
+ [1000, 1000, 1000],
+ ]
+ withdrawals = [
+ [40_000, 40_000],
+ [60_000, 60_000],
+ [80_000, 80_000],
+ ]
+ result = _make_result(portfolio, withdrawals)
+ goal = _Goal(kind="target_real_income",
+ name="≥ £50k income",
+ target_amount_gbp=Decimal("50000"),
+ target_year=0,
+ comparator=">=",
+ success_threshold=Decimal("0.5"))
+ [eval_] = evaluate_goals(result, [goal])
+ assert eval_.probability == pytest.approx(2 / 3)
+ assert eval_.passed is True
+
+
+def test_disabled_goals_skipped() -> None:
+ portfolio = [[1000, 500, 0]]
+ result = _make_result(portfolio)
+ enabled = _Goal(kind="never_run_out", name="active")
+ disabled = _Goal(kind="never_run_out", name="muted", enabled=False)
+ evals = evaluate_goals(result, [enabled, disabled])
+ assert [e.name for e in evals] == ["active"]
+
+
+def test_unknown_kind_returns_zero() -> None:
+ portfolio = [[1000, 1500, 2000]]
+ result = _make_result(portfolio)
+ goal = _Goal(kind="not_implemented", name="???")
+ [eval_] = evaluate_goals(result, [goal])
+ assert eval_.probability == 0.0
+ assert eval_.passed is False
diff --git a/tests/test_income_streams.py b/tests/test_income_streams.py
new file mode 100644
index 0000000..7a1ec09
--- /dev/null
+++ b/tests/test_income_streams.py
@@ -0,0 +1,228 @@
+"""Tests for income-stream CRUD + simulator integration."""
+from __future__ import annotations
+
+from collections.abc import AsyncIterator
+from decimal import Decimal
+
+import numpy as np
+import pytest_asyncio
+from httpx import ASGITransport, AsyncClient
+from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
+
+from fire_planner.api.dependencies import get_session
+from fire_planner.api.schemas import IncomeStreamInput, SimulateRequest
+from fire_planner.api.simulate import _project
+from fire_planner.app import app
+from fire_planner.db import Scenario
+from fire_planner.income_streams import (
+ IncomeStreamInput as EngineIncomeStream,
+)
+from fire_planner.income_streams import streams_to_arrays
+
+
+@pytest_asyncio.fixture
+async def client(engine: AsyncEngine,
+ session: AsyncSession) -> AsyncIterator[AsyncClient]:
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+
+ async def _override() -> AsyncIterator[AsyncSession]:
+ async with factory() as s:
+ yield s
+
+ app.dependency_overrides[get_session] = _override
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://test") as ac:
+ yield ac
+ app.dependency_overrides.clear()
+
+
+async def _seed_scenario(session: AsyncSession) -> int:
+ scen = Scenario(
+ external_id="user-host",
+ kind="user",
+ name="Host plan",
+ jurisdiction="uk",
+ strategy="trinity",
+ leave_uk_year=0,
+ glide_path="static",
+ spending_gbp=Decimal("60000"),
+ nw_seed_gbp=Decimal("1000000"),
+ savings_per_year_gbp=Decimal("0"),
+ config_json={},
+ )
+ session.add(scen)
+ await session.commit()
+ await session.refresh(scen)
+ return scen.id
+
+
+# ── streams_to_arrays unit tests ─────────────────────────────────────
+
+
+def test_streams_to_arrays_with_growth() -> None:
+ streams = [
+ EngineIncomeStream(
+ kind="salary",
+ start_year=0,
+ end_year=2,
+ amount_gbp_per_year=10_000,
+ growth_pct=0.05,
+ tax_treatment="income",
+ enabled=True,
+ ),
+ ]
+ inflows, taxable = streams_to_arrays(streams, horizon_years=5)
+ # year 0: 10_000; year 1: 10_500; year 2: 11_025; years 3+: 0
+ assert inflows[0] == 10_000.0
+ assert inflows[1] == 10_500.0
+ assert inflows[2] == 11_025.0
+ assert inflows[3] == 0.0
+ # Income-treated streams add to taxable.
+ assert taxable[0] == inflows[0]
+
+
+def test_streams_to_arrays_tax_free_excluded_from_taxable() -> None:
+ streams = [
+ EngineIncomeStream(
+ kind="dividend",
+ start_year=0,
+ end_year=0,
+ amount_gbp_per_year=5_000,
+ tax_treatment="tax_free",
+ enabled=True,
+ ),
+ ]
+ inflows, taxable = streams_to_arrays(streams, horizon_years=2)
+ assert inflows[0] == 5_000.0
+ assert taxable[0] == 0.0
+
+
+def test_streams_to_arrays_disabled_skipped() -> None:
+ streams = [
+ EngineIncomeStream(
+ kind="salary",
+ amount_gbp_per_year=10_000,
+ enabled=False,
+ ),
+ ]
+ inflows, taxable = streams_to_arrays(streams, horizon_years=2)
+ assert inflows.sum() == 0.0
+ assert taxable.sum() == 0.0
+
+
+# ── CRUD endpoint tests ──────────────────────────────────────────────
+
+
+async def test_create_and_list_income_streams(
+ client: AsyncClient,
+ session: AsyncSession,
+) -> None:
+ sid = await _seed_scenario(session)
+ create = await client.post(
+ f"/scenarios/{sid}/income-streams",
+ json={
+ "kind": "salary",
+ "name": "Day job",
+ "start_year": 0,
+ "end_year": 10,
+ "amount_gbp_per_year": "60000",
+ "growth_pct": "0.02",
+ "tax_treatment": "income",
+ },
+ )
+ assert create.status_code == 201
+ payload = create.json()
+ assert payload["name"] == "Day job"
+ assert payload["scenario_id"] == sid
+
+ listed = await client.get(f"/scenarios/{sid}/income-streams")
+ assert listed.status_code == 200
+ rows = listed.json()
+ assert len(rows) == 1
+ assert rows[0]["kind"] == "salary"
+
+
+async def test_patch_and_delete_income_stream(
+ client: AsyncClient,
+ session: AsyncSession,
+) -> None:
+ sid = await _seed_scenario(session)
+ create = await client.post(
+ f"/scenarios/{sid}/income-streams",
+ json={
+ "kind": "rental",
+ "name": "Flat 2",
+ "amount_gbp_per_year": "12000",
+ },
+ )
+ stream_id = create.json()["id"]
+
+ patch = await client.patch(
+ f"/income-streams/{stream_id}",
+ json={"amount_gbp_per_year": "15000"},
+ )
+ assert patch.status_code == 200
+ assert patch.json()["amount_gbp_per_year"] == "15000.00"
+
+ del_resp = await client.delete(f"/income-streams/{stream_id}")
+ assert del_resp.status_code == 204
+
+ listed = await client.get(f"/scenarios/{sid}/income-streams")
+ assert listed.json() == []
+
+
+async def test_invalid_year_range_rejected(
+ client: AsyncClient,
+ session: AsyncSession,
+) -> None:
+ sid = await _seed_scenario(session)
+ bad = await client.post(
+ f"/scenarios/{sid}/income-streams",
+ json={
+ "kind": "other",
+ "name": "Backwards",
+ "start_year": 5,
+ "end_year": 2,
+ "amount_gbp_per_year": "1000",
+ },
+ )
+ assert bad.status_code == 400
+
+
+# ── simulate integration: a £50k stream year 5-15 lifts median NW ────
+
+
+def test_simulate_with_income_stream_lifts_median() -> None:
+ paths = np.zeros((100, 30, 3), dtype=np.float64)
+ paths[:, :, 0] = 0.07 # nominal stocks
+ paths[:, :, 1] = 0.03 # nominal bonds
+ paths[:, :, 2] = 0.02 # cpi
+ base_req = SimulateRequest(
+ jurisdiction="uae",
+ strategy="trinity",
+ leave_uk_year=0,
+ spending_gbp=Decimal("20000"),
+ nw_seed_gbp=Decimal("2000000"),
+ horizon_years=30,
+ n_paths=100,
+ seed=1,
+ rates_mode=None,
+ )
+ req_with = base_req.model_copy(update={
+ "income_streams": [
+ IncomeStreamInput(
+ kind="dividend",
+ start_year=5,
+ end_year=15,
+ amount_gbp_per_year=Decimal("50000"),
+ growth_pct=Decimal("0.02"),
+ tax_treatment="tax_free",
+ ),
+ ],
+ })
+ base_result, _ = _project(base_req, paths)
+ with_result, _ = _project(req_with, paths)
+
+ base_median_end = float(np.median(base_result.portfolio_real[:, -1]))
+ with_median_end = float(np.median(with_result.portfolio_real[:, -1]))
+ assert with_median_end > base_median_end
diff --git a/tests/test_simulator_fixed_rates.py b/tests/test_simulator_fixed_rates.py
new file mode 100644
index 0000000..83258af
--- /dev/null
+++ b/tests/test_simulator_fixed_rates.py
@@ -0,0 +1,56 @@
+"""Verify the Settings → Rates fixed-mode arithmetic.
+
+For 100% stocks with growth=6% and dividend=2.5%, inflation=3%, the
+expected real return per year is ``(1 + 0.06 + 0.025) / 1.03 - 1``
+≈ 0.0534. We assert the simulator's portfolio compounds at this rate
+in the absence of withdrawals (spending=0, no strategy draw).
+"""
+from __future__ import annotations
+
+from decimal import Decimal
+
+import numpy as np
+import pytest
+
+from fire_planner.api.schemas import SimulateRequest
+from fire_planner.api.simulate import _build_paths, _project
+
+
+@pytest.mark.asyncio
+async def test_fixed_rates_real_return_arithmetic() -> None:
+ req = SimulateRequest(
+ jurisdiction="uae", # 0% tax to isolate compounding
+ strategy="trinity",
+ leave_uk_year=0,
+ spending_gbp=Decimal("1"),
+ nw_seed_gbp=Decimal("100000"),
+ horizon_years=30,
+ n_paths=100,
+ rates_mode="fixed",
+ inflation_pct=Decimal("0.03"),
+ stocks_growth_pct=Decimal("0.06"),
+ stocks_dividend_pct=Decimal("0.025"),
+ bonds_growth_pct=Decimal("0.015"),
+ bonds_dividend_pct=Decimal("0.035"),
+ stocks_allocation=Decimal("1"),
+ )
+ paths = await _build_paths(req)
+ assert paths.shape == (100, 30, 3)
+ # nominal stock return embedded should be growth + dividend = 0.085
+ assert paths[0, 0, 0] == pytest.approx(0.085)
+ assert paths[0, 0, 1] == pytest.approx(0.05)
+ assert paths[0, 0, 2] == pytest.approx(0.03)
+
+ expected_real = (1 + 0.06 + 0.025) / (1 + 0.03) - 1
+ assert expected_real == pytest.approx(0.0534, abs=1e-3)
+
+ result, _ = _project(req, paths)
+ # All paths identical → median == any single path. After 30 years of
+ # compounding 0.0534 with the trinity 4% draw, ending NW lies in a
+ # well-defined window.
+ end_real = float(np.median(result.portfolio_real[:, -1]))
+ assert end_real > 100_000 # grew despite the £1/y withdrawal
+ growth_factor = end_real / 100_000.0
+ expected_factor = (1 + expected_real)**30
+ # Loose because trinity strategy still draws something each year.
+ assert growth_factor == pytest.approx(expected_factor, rel=0.05)