fire-planner: ProjectionLab parity Wave 1 — tabbed shell, year stats, goals,
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

income streams, Sankey cashflow, progress overlay, settings sub-pages

Wave 1 (9 features across 4 streams):

Stream A — dashboard skeleton
  1.A.1 ScenarioShell with top tabs (Plan/Cash Flow/Tax Analytics/Compare/
        Reports/Estate/Settings) + left Sidebar with Plans switcher.
  1.A.2 GET /scenarios/{id}/year-stats?year=N returning per-year metrics
        (NW, Δ NW, taxable income, taxes, eff. rate, spending, contribs,
        investment growth). YearScrubber + YearStatsPanel render the
        right-hand sidebar; URL ?year= preserves selection.
  1.A.3 FanChart gains optional `milestones` prop (lib/milestone.ts maps
        life_event.kind → emoji) + selectedYear marker line.

Stream B — goals + progress
  1.B.1 New goals_eval module: target_nw_by_year / never_run_out /
        target_real_income probability evaluation. Wired into POST
        /simulate (exact, per-path) and GET /scenarios/{id}/projection
        (approximated from persisted fan via percentile interpolation).
        GoalsSection renders pass/fail badges.
  1.B.2 GET /scenarios/{id}/progress overlays AccountSnapshot totals on
        the projection fan; ProgressPage shows variance side-panel.

Stream C — income + cashflow
  1.C.1 New IncomeStream model + alembic 0003 + CRUD endpoints. Engine
        aggregates streams into per-year inflows + taxable arrays;
        income tax routes through the jurisdiction tax engine.
        IncomeStreamsSection on Plan tab.
  1.C.2 GET /scenarios/{id}/cashflow?year=N returns sources/sinks for
        an ECharts Sankey (sums conserve). CashflowTab body.

Stream D — settings
  1.D.1 SettingsTab + sub-nav (Milestones/Rates/Dividends/Bonds/Tax/
        Metrics/Other/Notes); placeholder cards for unbuilt sub-pages.
  1.D.2 LifeEventsSection relocated to /scenarios/:id/settings.
  1.D.3 RatesSettings (Fixed/Historical/Advanced segmented + per-asset
        cards). SimulateRequest gains rates_mode, inflation_pct,
        stocks/bonds growth + dividend, stocks_allocation. New
        build_fixed_paths() in simulator. Real-return arithmetic
        verified against (1+g+d)/(1+i)−1 ≈ 5.4%.
  1.D.4 NotesSettings — markdown textarea, save-on-blur, stored in
        scenario.config_json.notes.

Backend: 238 pytest pass (+19 new), mypy + ruff clean.
Frontend: typecheck + 7 unit tests + production build clean.

Roadmap for Wave 2-N is documented in the implementation plan.
This commit is contained in:
Viktor Barzin 2026-05-10 12:49:44 +00:00
parent e12e8f9290
commit 9cc781a8d6
42 changed files with 3765 additions and 80 deletions

136
tests/test_api_cashflow.py Normal file
View file

@ -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

158
tests/test_api_progress.py Normal file
View file

@ -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"] == []

View file

@ -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

126
tests/test_goals_eval.py Normal file
View file

@ -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

View file

@ -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

View file

@ -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)