fire-planner: ProjectionLab parity Wave 1 — tabbed shell, year stats, goals,
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
e12e8f9290
commit
9cc781a8d6
42 changed files with 3765 additions and 80 deletions
136
tests/test_api_cashflow.py
Normal file
136
tests/test_api_cashflow.py
Normal 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
158
tests/test_api_progress.py
Normal 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"] == []
|
||||
124
tests/test_api_year_stats.py
Normal file
124
tests/test_api_year_stats.py
Normal 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
126
tests/test_goals_eval.py
Normal 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
|
||||
228
tests/test_income_streams.py
Normal file
228
tests/test_income_streams.py
Normal 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
|
||||
56
tests/test_simulator_fixed_rates.py
Normal file
56
tests/test_simulator_fixed_rates.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue