From 9cc781a8d6c177d169c35255ea9189d77ba6d762 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 10 May 2026 12:49:44 +0000 Subject: [PATCH] =?UTF-8?q?fire-planner:=20ProjectionLab=20parity=20Wave?= =?UTF-8?q?=201=20=E2=80=94=20tabbed=20shell,=20year=20stats,=20goals,=20i?= =?UTF-8?q?ncome=20streams,=20Sankey=20cashflow,=20progress=20overlay,=20s?= =?UTF-8?q?ettings=20sub-pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- alembic/versions/0003_income_streams.py | 69 +++++ fire_planner/api/cashflow.py | 130 +++++++++ fire_planner/api/income_streams.py | 98 +++++++ fire_planner/api/progress.py | 104 +++++++ fire_planner/api/scenarios.py | 108 +++++++- fire_planner/api/schemas.py | 154 +++++++++++ fire_planner/api/simulate.py | 61 ++++- fire_planner/api/year_stats.py | 106 ++++++++ fire_planner/app.py | 8 + fire_planner/db.py | 36 +++ fire_planner/goals_eval.py | 130 +++++++++ fire_planner/income_streams.py | 62 +++++ fire_planner/simulator.py | 48 +++- frontend/src/App.tsx | 55 +++- frontend/src/api/client.ts | 141 ++++++++++ frontend/src/components/CashflowSankey.tsx | 75 ++++++ frontend/src/components/FanChart.tsx | 90 ++++++- frontend/src/components/GoalsSection.tsx | 93 +++++-- .../src/components/IncomeStreamsSection.tsx | 255 ++++++++++++++++++ frontend/src/components/ProgressOverlay.tsx | 95 +++++++ frontend/src/components/RateCard.tsx | 67 +++++ frontend/src/components/SettingsSubnav.tsx | 37 +++ frontend/src/components/Sidebar.tsx | 125 +++++++++ frontend/src/components/TabBar.tsx | 36 +++ frontend/src/components/YearScrubber.tsx | 34 +++ frontend/src/components/YearStatsPanel.tsx | 88 ++++++ frontend/src/lib/milestone.ts | 33 +++ frontend/src/pages/CashflowTab.tsx | 94 +++++++ frontend/src/pages/MilestonesSettings.tsx | 20 ++ frontend/src/pages/NotesSettings.tsx | 81 ++++++ frontend/src/pages/PlaceholderTab.tsx | 18 ++ frontend/src/pages/ProgressPage.tsx | 78 ++++++ frontend/src/pages/RatesSettings.tsx | 186 +++++++++++++ frontend/src/pages/ScenarioDetail.tsx | 115 +++++--- frontend/src/pages/ScenarioShell.tsx | 52 ++++ frontend/src/pages/SettingsTab.tsx | 35 +++ tests/test_api_cashflow.py | 136 ++++++++++ tests/test_api_progress.py | 158 +++++++++++ tests/test_api_year_stats.py | 124 +++++++++ tests/test_goals_eval.py | 126 +++++++++ tests/test_income_streams.py | 228 ++++++++++++++++ tests/test_simulator_fixed_rates.py | 56 ++++ 42 files changed, 3765 insertions(+), 80 deletions(-) create mode 100644 alembic/versions/0003_income_streams.py create mode 100644 fire_planner/api/cashflow.py create mode 100644 fire_planner/api/income_streams.py create mode 100644 fire_planner/api/progress.py create mode 100644 fire_planner/api/year_stats.py create mode 100644 fire_planner/goals_eval.py create mode 100644 fire_planner/income_streams.py create mode 100644 frontend/src/components/CashflowSankey.tsx create mode 100644 frontend/src/components/IncomeStreamsSection.tsx create mode 100644 frontend/src/components/ProgressOverlay.tsx create mode 100644 frontend/src/components/RateCard.tsx create mode 100644 frontend/src/components/SettingsSubnav.tsx create mode 100644 frontend/src/components/Sidebar.tsx create mode 100644 frontend/src/components/TabBar.tsx create mode 100644 frontend/src/components/YearScrubber.tsx create mode 100644 frontend/src/components/YearStatsPanel.tsx create mode 100644 frontend/src/lib/milestone.ts create mode 100644 frontend/src/pages/CashflowTab.tsx create mode 100644 frontend/src/pages/MilestonesSettings.tsx create mode 100644 frontend/src/pages/NotesSettings.tsx create mode 100644 frontend/src/pages/PlaceholderTab.tsx create mode 100644 frontend/src/pages/ProgressPage.tsx create mode 100644 frontend/src/pages/RatesSettings.tsx create mode 100644 frontend/src/pages/ScenarioShell.tsx create mode 100644 frontend/src/pages/SettingsTab.tsx create mode 100644 tests/test_api_cashflow.py create mode 100644 tests/test_api_progress.py create mode 100644 tests/test_api_year_stats.py create mode 100644 tests/test_goals_eval.py create mode 100644 tests/test_income_streams.py create mode 100644 tests/test_simulator_fixed_rates.py diff --git a/alembic/versions/0003_income_streams.py b/alembic/versions/0003_income_streams.py new file mode 100644 index 0000000..ae53ae8 --- /dev/null +++ b/alembic/versions/0003_income_streams.py @@ -0,0 +1,69 @@ +"""add income_stream table + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-05-10 00:00:00.000000 + +ProjectionLab parity Wave 1: first-class typed income streams. Replaces +the scalar `savings_per_year_gbp` + generic `life_event.delta` model for +recurring income. +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "0003" +down_revision: str | None = "0002" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +SCHEMA = "fire_planner" + + +def _jsonb() -> sa.types.TypeEngine[object]: + return postgresql.JSONB().with_variant(sa.JSON(), "sqlite") + + +def upgrade() -> None: + op.create_table( + "income_stream", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("scenario_id", sa.Integer(), nullable=False), + sa.Column("kind", sa.Text(), nullable=False), + sa.Column("name", sa.Text(), nullable=False), + sa.Column("start_year", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("end_year", sa.Integer(), nullable=True), + sa.Column("amount_gbp_per_year", + sa.Numeric(12, 2), + nullable=False, + server_default=sa.text("0")), + sa.Column("growth_pct", + sa.Numeric(6, 4), + nullable=False, + server_default=sa.text("0")), + sa.Column("tax_treatment", + sa.Text(), + nullable=False, + server_default=sa.text("'income'")), + sa.Column("enabled", + sa.Boolean(), + nullable=False, + server_default=sa.text("true")), + sa.Column("payload", _jsonb(), nullable=True), + sa.Column("created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("now()")), + schema=SCHEMA, + ) + op.create_index("idx_income_stream_scenario", + "income_stream", ["scenario_id"], + schema=SCHEMA) + + +def downgrade() -> None: + op.drop_index("idx_income_stream_scenario", table_name="income_stream", schema=SCHEMA) + op.drop_table("income_stream", schema=SCHEMA) diff --git a/fire_planner/api/cashflow.py b/fire_planner/api/cashflow.py new file mode 100644 index 0000000..e10402d --- /dev/null +++ b/fire_planner/api/cashflow.py @@ -0,0 +1,130 @@ +"""Cashflow Sankey data for one year — ProjectionLab parity Wave 1.C.2. + +Returns a ``{sources, sinks}`` map of named tributaries / drains. Sums +conserve at the Sankey level: total inflow == total outflow, with +"net change in NW" absorbing the residual so the diagram balances. +""" +from __future__ import annotations + +from decimal import Decimal + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from fire_planner.api.dependencies import get_session +from fire_planner.api.schemas import CashflowResponse +from fire_planner.db import IncomeStream, LifeEvent, McRun, ProjectionYearly, Scenario + +router = APIRouter(prefix="/scenarios", tags=["cashflow"]) + + +def _income_inflow_at(streams: list[IncomeStream], year_idx: int) -> dict[str, Decimal]: + """Per-stream-kind inflow at ``year_idx``.""" + out: dict[str, Decimal] = {} + for s in streams: + if not s.enabled: + continue + if year_idx < s.start_year: + continue + if s.end_year is not None and year_idx > s.end_year: + continue + years_active = year_idx - s.start_year + growth = (Decimal("1") + Decimal(str(s.growth_pct)))**years_active + amount = Decimal(str(s.amount_gbp_per_year)) * growth + key = f"income:{s.kind}" + out[key] = out.get(key, Decimal("0")) + amount + return out + + +def _life_event_inflow_at(events: list[LifeEvent], year_idx: int) -> dict[str, Decimal]: + out: dict[str, Decimal] = {} + for ev in events: + if not ev.enabled: + continue + if year_idx < ev.year_start: + continue + end = ev.year_end if ev.year_end is not None else ev.year_start + if year_idx > end: + continue + delta = Decimal(str(ev.delta_gbp_per_year or 0)) + if ev.year_start == year_idx and ev.one_time_amount_gbp is not None: + delta += Decimal(str(ev.one_time_amount_gbp)) + if delta == 0: + continue + key = f"event:{ev.name}" + if delta > 0: + out[key] = out.get(key, Decimal("0")) + delta + return out + + +@router.get("/{scenario_id}/cashflow", response_model=CashflowResponse) +async def get_cashflow( + scenario_id: int, + year: int = Query(ge=0, le=100, description="Year offset from scenario start (0 = now)"), + session: AsyncSession = Depends(get_session), +) -> CashflowResponse: + scen = await session.get(Scenario, scenario_id) + if scen is None: + raise HTTPException(status_code=404, detail="Scenario not found") + run = (await session.execute( + select(McRun).where(McRun.scenario_id == scenario_id).order_by( + McRun.run_at.desc()).limit(1))).scalar_one_or_none() + if run is None: + raise HTTPException(status_code=404, detail="No MC runs persisted for this scenario yet") + + yearly_rows = (await session.execute( + select(ProjectionYearly).where(ProjectionYearly.mc_run_id == run.id).order_by( + ProjectionYearly.year_idx))).scalars().all() + by_year = {r.year_idx: r for r in yearly_rows} + if year not in by_year: + raise HTTPException(status_code=404, detail=f"No projection row for year {year}") + + streams = list((await session.execute( + select(IncomeStream).where( + IncomeStream.scenario_id == scenario_id))).scalars().all()) + events = list((await session.execute( + select(LifeEvent).where(LifeEvent.scenario_id == scenario_id))).scalars().all()) + + row = by_year[year] + prev = by_year.get(year - 1) if year > 0 else None + nw = Decimal(str(row.p50_portfolio_gbp)) + prev_nw = (Decimal(str(prev.p50_portfolio_gbp)) if prev is not None else Decimal(str( + scen.nw_seed_gbp))) + nw_change = nw - prev_nw + + sources: dict[str, Decimal] = {} + sources.update(_income_inflow_at(streams, year)) + sources.update(_life_event_inflow_at(events, year)) + if scen.savings_per_year_gbp > 0: + sources["savings"] = Decimal(str(scen.savings_per_year_gbp)) + + spending = Decimal(str(row.p50_withdrawal_gbp)) + taxes = Decimal(str(row.p50_tax_gbp)) + sinks: dict[str, Decimal] = { + "spending": spending, + "taxes": taxes, + } + + sources_total = sum(sources.values(), Decimal("0")) + sinks_total = sum(sinks.values(), Decimal("0")) + investment_returns = nw_change + sinks_total - sources_total + if investment_returns > 0: + sources["investment_returns"] = investment_returns + sources_total += investment_returns + elif investment_returns < 0: + sinks["portfolio_drawdown"] = -investment_returns + sinks_total += -investment_returns + + delta = sources_total - sinks_total + if delta > 0: + sinks["net_change_in_nw"] = delta + elif delta < 0: + sources["net_change_in_nw"] = -delta + + return CashflowResponse( + scenario_id=scenario_id, + year=year, + sources=sources, + sinks=sinks, + ) diff --git a/fire_planner/api/income_streams.py b/fire_planner/api/income_streams.py new file mode 100644 index 0000000..e7d0fc4 --- /dev/null +++ b/fire_planner/api/income_streams.py @@ -0,0 +1,98 @@ +"""Income-stream CRUD nested under a scenario. + +Streams are typed (salary / dividend / rental / pension / social_security +/ rsu / other) so the simulator can route the after-tax cash through the +jurisdiction tax engine using `tax_treatment` as the bucket. +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from fire_planner.api.dependencies import get_session +from fire_planner.api.schemas import ( + IncomeStreamCreate, + IncomeStreamOut, + IncomeStreamPatch, +) +from fire_planner.db import IncomeStream, Scenario + +router = APIRouter(tags=["income-streams"]) + + +@router.get( + "/scenarios/{scenario_id}/income-streams", + response_model=list[IncomeStreamOut], +) +async def list_streams( + scenario_id: int, + session: AsyncSession = Depends(get_session), +) -> list[IncomeStream]: + scen = await session.get(Scenario, scenario_id) + if scen is None: + raise HTTPException(status_code=404, detail="Scenario not found") + rows = (await session.execute( + select(IncomeStream).where(IncomeStream.scenario_id == scenario_id).order_by( + IncomeStream.start_year, IncomeStream.id))).scalars().all() + return list(rows) + + +@router.post( + "/scenarios/{scenario_id}/income-streams", + response_model=IncomeStreamOut, + status_code=201, +) +async def create_stream( + scenario_id: int, + payload: IncomeStreamCreate, + session: AsyncSession = Depends(get_session), +) -> IncomeStream: + scen = await session.get(Scenario, scenario_id) + if scen is None: + raise HTTPException(status_code=404, detail="Scenario not found") + if payload.end_year is not None and payload.end_year < payload.start_year: + raise HTTPException(status_code=400, detail="end_year < start_year") + stream = IncomeStream(scenario_id=scenario_id, **payload.model_dump()) + session.add(stream) + await session.commit() + await session.refresh(stream) + return stream + + +@router.patch( + "/income-streams/{stream_id}", + response_model=IncomeStreamOut, +) +async def patch_stream( + stream_id: int, + payload: IncomeStreamPatch, + session: AsyncSession = Depends(get_session), +) -> IncomeStream: + stream = await session.get(IncomeStream, stream_id) + if stream is None: + raise HTTPException(status_code=404, detail="Income stream not found") + updates = payload.model_dump(exclude_unset=True) + for k, v in updates.items(): + setattr(stream, k, v) + if stream.end_year is not None and stream.end_year < stream.start_year: + raise HTTPException(status_code=400, detail="end_year < start_year") + await session.commit() + await session.refresh(stream) + return stream + + +@router.delete( + "/income-streams/{stream_id}", + status_code=204, + response_model=None, +) +async def delete_stream( + stream_id: int, + session: AsyncSession = Depends(get_session), +) -> None: + stream = await session.get(IncomeStream, stream_id) + if stream is None: + raise HTTPException(status_code=404, detail="Income stream not found") + await session.execute(delete(IncomeStream).where(IncomeStream.id == stream_id)) + await session.commit() diff --git a/fire_planner/api/progress.py b/fire_planner/api/progress.py new file mode 100644 index 0000000..99cb83a --- /dev/null +++ b/fire_planner/api/progress.py @@ -0,0 +1,104 @@ +"""Progress overlay — actual NW from `account_snapshot` vs the persisted +projection fan. + +Used by the left-sidebar "Progress" page. The alignment anchor is the +earliest available snapshot date (year 0 of the projection); subsequent +snapshots are bucketed into the projection's calendar-year axis so the +overlay lines up. +""" +from __future__ import annotations + +from collections import defaultdict +from datetime import date +from decimal import Decimal + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from fire_planner.api.dependencies import get_session +from fire_planner.api.schemas import ( + ProgressActualPoint, + ProgressProjectedPoint, + ProgressResponse, + ProgressVariancePoint, +) +from fire_planner.db import AccountSnapshot, McRun, ProjectionYearly, Scenario + +router = APIRouter(prefix="/scenarios", tags=["progress"]) + + +@router.get("/{scenario_id}/progress", response_model=ProgressResponse) +async def get_progress( + scenario_id: int, + session: AsyncSession = Depends(get_session), +) -> ProgressResponse: + scen = await session.get(Scenario, scenario_id) + if scen is None: + raise HTTPException(status_code=404, detail="Scenario not found") + + actuals_rows = (await session.execute( + select( + AccountSnapshot.snapshot_date, + AccountSnapshot.market_value_gbp, + ))).all() + + by_date: dict[date, Decimal] = defaultdict(lambda: Decimal("0")) + for snap_date, value in actuals_rows: + by_date[snap_date] += Decimal(str(value)) + + actual = [ + ProgressActualPoint(snapshot_date=d, total_gbp=by_date[d]) for d in sorted(by_date.keys()) + ] + if actual: + anchor = actual[0].snapshot_date + else: + anchor = scen.created_at.date() if scen.created_at is not None else date.today() + + run = (await session.execute( + select(McRun).where(McRun.scenario_id == scenario_id).order_by( + McRun.run_at.desc()).limit(1))).scalar_one_or_none() + projected: list[ProgressProjectedPoint] = [] + yearly_rows: list[ProjectionYearly] = [] + if run is not None: + rows = (await session.execute( + select(ProjectionYearly).where(ProjectionYearly.mc_run_id == run.id).order_by( + ProjectionYearly.year_idx))).scalars().all() + yearly_rows = list(rows) + projected = [ + ProgressProjectedPoint( + year_idx=r.year_idx, + p10_portfolio_gbp=r.p10_portfolio_gbp, + p50_portfolio_gbp=r.p50_portfolio_gbp, + p90_portfolio_gbp=r.p90_portfolio_gbp, + ) for r in yearly_rows + ] + + variance: list[ProgressVariancePoint] = [] + if yearly_rows and actual: + actuals_by_year: dict[int, list[Decimal]] = defaultdict(list) + for pt in actual: + year_idx = (pt.snapshot_date.year - anchor.year) + if year_idx >= 0: + actuals_by_year[year_idx].append(pt.total_gbp) + for r in yearly_rows: + samples = actuals_by_year.get(r.year_idx) + if not samples: + continue + avg = sum(samples, Decimal("0")) / Decimal(len(samples)) + projected_p50 = Decimal(str(r.p50_portfolio_gbp)) + variance.append( + ProgressVariancePoint( + year_idx=r.year_idx, + actual_avg_gbp=avg, + projected_p50_gbp=projected_p50, + delta_gbp=avg - projected_p50, + )) + + return ProgressResponse( + scenario_id=scenario_id, + alignment_anchor=anchor, + actual=actual, + projected=projected, + variance=variance, + ) diff --git a/fire_planner/api/scenarios.py b/fire_planner/api/scenarios.py index 9664a75..1df9cdc 100644 --- a/fire_planner/api/scenarios.py +++ b/fire_planner/api/scenarios.py @@ -11,6 +11,7 @@ Mixed surface: from __future__ import annotations import uuid +from decimal import Decimal from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import delete, select @@ -18,17 +19,109 @@ from sqlalchemy.ext.asyncio import AsyncSession from fire_planner.api.dependencies import get_session from fire_planner.api.schemas import ( + GoalProbability, ProjectionPoint, ScenarioCreate, ScenarioOut, ScenarioPatch, ScenarioProjection, ) -from fire_planner.db import McRun, ProjectionYearly, Scenario +from fire_planner.db import McRun, ProjectionYearly, RetirementGoal, Scenario router = APIRouter(prefix="/scenarios", tags=["scenarios"]) +def _approx_prob_above(yearly_row: ProjectionYearly, target_amount: float) -> float: + """Approximate the fraction of paths whose portfolio at the row's + year is at or above ``target_amount``, using linear interpolation + across the persisted p10/p25/p50/p75/p90 cells. + + Exact only when target_amount lands on a stored quantile, otherwise + a piecewise-linear estimate. Good enough for Wave 1 — the live + /simulate endpoint computes the exact path-by-path probability. + """ + from itertools import pairwise + + cells = [ + (10, float(yearly_row.p10_portfolio_gbp)), + (25, float(yearly_row.p25_portfolio_gbp)), + (50, float(yearly_row.p50_portfolio_gbp)), + (75, float(yearly_row.p75_portfolio_gbp)), + (90, float(yearly_row.p90_portfolio_gbp)), + ] + cells.sort(key=lambda kv: kv[1]) + if target_amount <= cells[0][1]: + return 1.0 + if target_amount >= cells[-1][1]: + return 0.0 + for (lo_pct, lo_v), (hi_pct, hi_v) in pairwise(cells): + if lo_v <= target_amount <= hi_v: + span = hi_v - lo_v + if span == 0: + return max(0.0, 1.0 - lo_pct / 100.0) + t = (target_amount - lo_v) / span + pct = lo_pct + t * (hi_pct - lo_pct) + return max(0.0, min(1.0, 1.0 - pct / 100.0)) + return 0.0 + + +def _evaluate_goal_against_fan( + goal: RetirementGoal, + yearly_rows: list[ProjectionYearly], + horizon_years: int, +) -> GoalProbability | None: + if not goal.enabled: + return None + threshold = float(goal.success_threshold) + + def _result(prob: float) -> GoalProbability: + return GoalProbability( + goal_id=goal.id, + name=goal.name, + kind=goal.kind, + probability=Decimal(str(round(prob, 4))), + threshold=Decimal(str(round(threshold, 4))), + passed=prob >= threshold, + ) + + by_year = {row.year_idx: row for row in yearly_rows} + if not by_year: + return _result(0.0) + last_year = max(by_year) + + if goal.kind == "target_nw_by_year": + if goal.target_year is None or goal.target_amount_gbp is None: + return _result(0.0) + y = max(0, min(int(goal.target_year), last_year)) + row = by_year.get(y) + if row is None: + return _result(0.0) + prob = _approx_prob_above(row, float(goal.target_amount_gbp)) + return _result(prob) + + if goal.kind == "never_run_out": + end = int(goal.target_year) if goal.target_year is not None else horizon_years + end = max(0, min(end, last_year)) + row = by_year.get(end) + if row is None: + return _result(0.0) + return _result(float(row.survival_rate)) + + if goal.kind == "target_real_income": + if goal.target_amount_gbp is None: + return _result(0.0) + target = float(goal.target_amount_gbp) + start_y = int(goal.target_year) if goal.target_year is not None else 0 + window = [r for r in yearly_rows if r.year_idx >= start_y] + if not window: + return _result(0.0) + median_wd = sorted(float(r.p50_withdrawal_gbp) for r in window) + mid = median_wd[len(median_wd) // 2] + return _result(1.0 if mid >= target else 0.0) + + return _result(0.0) + + @router.get("", response_model=list[ScenarioOut]) async def list_scenarios( kind: str | None = None, @@ -149,9 +242,17 @@ async def get_scenario_projection( McRun.run_at.desc()).limit(1))).scalar_one_or_none() if run is None: raise HTTPException(status_code=404, detail="No MC runs persisted for this scenario yet") - yearly_rows = (await session.execute( + yearly_rows = list((await session.execute( select(ProjectionYearly).where(ProjectionYearly.mc_run_id == run.id).order_by( - ProjectionYearly.year_idx))).scalars().all() + ProjectionYearly.year_idx))).scalars().all()) + goals_rows = list((await session.execute( + select(RetirementGoal).where( + RetirementGoal.scenario_id == scenario_id))).scalars().all()) + goals_probability: list[GoalProbability] = [] + for goal in goals_rows: + evaluation = _evaluate_goal_against_fan(goal, yearly_rows, scen.horizon_years) + if evaluation is not None: + goals_probability.append(evaluation) return ScenarioProjection( scenario_id=scen.id, external_id=scen.external_id, @@ -165,4 +266,5 @@ async def get_scenario_projection( median_lifetime_tax_gbp=run.median_lifetime_tax_gbp, median_years_to_ruin=run.median_years_to_ruin, yearly=[ProjectionPoint.model_validate(y) for y in yearly_rows], + goals_probability=goals_probability, ) diff --git a/fire_planner/api/schemas.py b/fire_planner/api/schemas.py index 84a7cb9..06eab06 100644 --- a/fire_planner/api/schemas.py +++ b/fire_planner/api/schemas.py @@ -85,6 +85,15 @@ class ProjectionPoint(_Base): survival_rate: Decimal +class GoalProbability(BaseModel): + goal_id: int | None + name: str + kind: str + probability: Decimal + threshold: Decimal + passed: bool + + class ScenarioProjection(BaseModel): """Latest MC run + per-year fan-chart series for a scenario.""" scenario_id: int @@ -99,6 +108,7 @@ class ScenarioProjection(BaseModel): median_lifetime_tax_gbp: Decimal median_years_to_ruin: Decimal | None yearly: list[ProjectionPoint] + goals_probability: list[GoalProbability] = Field(default_factory=list) # ── net worth ──────────────────────────────────────────────────────── @@ -239,6 +249,134 @@ class GoalCreate(BaseModel): payload: dict[str, Any] | None = None +# ── income streams ─────────────────────────────────────────────────── + + +class IncomeStreamOut(_Base): + id: int + scenario_id: int + kind: str + name: str + start_year: int + end_year: int | None + amount_gbp_per_year: Decimal + growth_pct: Decimal + tax_treatment: str + enabled: bool + payload: dict[str, Any] | None + created_at: datetime + + +class IncomeStreamCreate(BaseModel): + kind: str + name: str = Field(min_length=1, max_length=200) + start_year: int = Field(ge=0, le=100, default=0) + end_year: int | None = Field(default=None, ge=0, le=100) + amount_gbp_per_year: Decimal = Field(ge=0, default=Decimal("0")) + growth_pct: Decimal = Field(default=Decimal("0"), ge=-1, le=1) + tax_treatment: str = Field(default="income", pattern="^(income|dividend|cgt|tax_free)$") + enabled: bool = True + payload: dict[str, Any] | None = None + + +class IncomeStreamPatch(BaseModel): + kind: str | None = None + name: str | None = None + start_year: int | None = None + end_year: int | None = None + amount_gbp_per_year: Decimal | None = None + growth_pct: Decimal | None = None + tax_treatment: str | None = None + enabled: bool | None = None + payload: dict[str, Any] | None = None + + +class IncomeStreamInput(BaseModel): + """Engine-level income stream — same shape minus the row metadata.""" + kind: str = "salary" + start_year: int = Field(ge=0, le=100, default=0) + end_year: int | None = Field(default=None, ge=0, le=100) + amount_gbp_per_year: Decimal = Field(ge=0, default=Decimal("0")) + growth_pct: Decimal = Field(default=Decimal("0")) + tax_treatment: str = Field(default="income", pattern="^(income|dividend|cgt|tax_free)$") + enabled: bool = True + + +# ── year stats (per-year scrubber sidebar) ─────────────────────────── + + +class YearStats(BaseModel): + """Per-year metrics for the right-hand stats sidebar. + + Most fields derive from the latest persisted MC run + scenario + config. Liquid NW / Expenses / Savings rate / Portfolio allocations + are stubbed null until the relevant Wave 2 features land + (real-estate split, expense streams, allocation editor). + """ + year_idx: int + calendar_year: int + age: int | None + net_worth_p50: Decimal + change_in_nw: Decimal + taxable_income: Decimal + taxes: Decimal + effective_tax_rate: Decimal + spending: Decimal + contributions: Decimal + investment_growth: Decimal + liquid_nw: Decimal | None = None + expenses: Decimal | None = None + savings_rate: Decimal | None = None + portfolio_allocations: dict[str, Decimal] | None = None + + +# ── progress overlay (actuals vs projection) ───────────────────────── + + +class ProgressActualPoint(BaseModel): + snapshot_date: date + total_gbp: Decimal + + +class ProgressProjectedPoint(BaseModel): + year_idx: int + p10_portfolio_gbp: Decimal + p50_portfolio_gbp: Decimal + p90_portfolio_gbp: Decimal + + +class ProgressVariancePoint(BaseModel): + year_idx: int + actual_avg_gbp: Decimal + projected_p50_gbp: Decimal + delta_gbp: Decimal + + +class ProgressResponse(BaseModel): + """`actual` is daily NW totals from `account_snapshot`. `projected` + is the latest persisted fan in `projection_yearly`. `alignment_anchor` + is the date corresponding to year_idx=0 — earliest snapshot or scenario + `created_at` when no snapshots exist yet.""" + scenario_id: int + alignment_anchor: date + actual: list[ProgressActualPoint] + projected: list[ProgressProjectedPoint] + variance: list[ProgressVariancePoint] + + +# ── cashflow Sankey ────────────────────────────────────────────────── + + +class CashflowResponse(BaseModel): + """Per-year sources/sinks for a Sankey diagram. Sums conserve at the + Sankey level: total inflow == total outflow, with savings + ending-NW + delta absorbing any leftover.""" + scenario_id: int + year: int + sources: dict[str, Decimal] + sinks: dict[str, Decimal] + + # ── simulate / compare ─────────────────────────────────────────────── @@ -282,6 +420,21 @@ class SimulateRequest(BaseModel): # recent regime only (~6 years). Glide path is moot. returns_mode: str = Field(default="shiller", pattern="^(shiller|manual|wealthfolio)$") manual_real_return_pct: Decimal | None = None + income_streams: list[IncomeStreamInput] = Field(default_factory=list) + goals: list[GoalCreate] = Field(default_factory=list) + # Rates settings (Wave 1.D.3). When `rates_mode='fixed'`, the engine + # synthesises a deterministic real-return path from the per-asset + # growth + dividend + inflation rates below, weighted by the static + # 100% glide. `historical` falls back to Shiller bootstrap (legacy + # `returns_mode` honoured when rates_mode is not set). `advanced` is + # a Wave 2 stub. + rates_mode: str | None = Field(default=None, pattern="^(fixed|historical|advanced)$") + inflation_pct: Decimal = Field(default=Decimal("0.03")) + stocks_growth_pct: Decimal = Field(default=Decimal("0.06")) + stocks_dividend_pct: Decimal = Field(default=Decimal("0.025")) + bonds_growth_pct: Decimal = Field(default=Decimal("0.015")) + bonds_dividend_pct: Decimal = Field(default=Decimal("0.035")) + stocks_allocation: Decimal = Field(default=Decimal("1.0"), ge=0, le=1) # Custom spending-plan parameters — only consulted when strategy="custom". # All real-£ / real-fraction. annual_real_adjust_pct = 0 means constant # real spending (Trinity-shape). Non-zero scales last year's withdrawal @@ -303,6 +456,7 @@ class SimulateResult(BaseModel): median_years_to_ruin: Decimal | None elapsed_seconds: Decimal yearly: list[ProjectionPoint] + goals_probability: list[GoalProbability] = Field(default_factory=list) class CompareRequest(BaseModel): diff --git a/fire_planner/api/simulate.py b/fire_planner/api/simulate.py index 8666b41..68cefa5 100644 --- a/fire_planner/api/simulate.py +++ b/fire_planner/api/simulate.py @@ -21,11 +21,14 @@ from sqlalchemy.ext.asyncio import async_sessionmaker from fire_planner.api.schemas import ( CompareRequest, CompareResult, + GoalProbability, ProjectionPoint, SimulateRequest, SimulateResult, ) from fire_planner.glide_path import static +from fire_planner.goals_eval import evaluate_goals +from fire_planner.income_streams import IncomeStreamInput, streams_to_arrays from fire_planner.ingest.wealthfolio_pg import create_wf_sync_engine_from_env from fire_planner.life_events import EventInput, events_to_cashflow_array from fire_planner.returns.bootstrap import block_bootstrap @@ -35,7 +38,7 @@ from fire_planner.returns.wealthfolio_returns import ( constant_real_return_paths, ) from fire_planner.scenarios import build_regime_schedule, build_strategy -from fire_planner.simulator import SimulationResult, simulate +from fire_planner.simulator import SimulationResult, build_fixed_paths, simulate router = APIRouter(tags=["simulate"]) @@ -64,6 +67,16 @@ async def _wealthfolio_paths(seed: int, n_paths: int, n_years: int) -> np.ndarra async def _build_paths(req: SimulateRequest) -> np.ndarray: + if req.rates_mode == "fixed": + return build_fixed_paths( + n_paths=req.n_paths, + n_years=req.horizon_years, + inflation_pct=float(req.inflation_pct), + stocks_growth_pct=float(req.stocks_growth_pct), + stocks_dividend_pct=float(req.stocks_dividend_pct), + bonds_growth_pct=float(req.bonds_growth_pct), + bonds_dividend_pct=float(req.bonds_dividend_pct), + ) if req.returns_mode == "manual": if req.manual_real_return_pct is None: raise HTTPException( @@ -105,6 +118,22 @@ def _project(req: SimulateRequest, paths: np.ndarray) -> tuple[SimulationResult, ] cashflow_adjustments = events_to_cashflow_array(engine_events, req.horizon_years) + income_inflows = None + income_taxable = None + if req.income_streams: + engine_streams = [ + IncomeStreamInput( + kind=s.kind, + start_year=s.start_year, + end_year=s.end_year, + amount_gbp_per_year=float(s.amount_gbp_per_year), + growth_pct=float(s.growth_pct), + tax_treatment=s.tax_treatment, + enabled=s.enabled, + ) for s in req.income_streams + ] + income_inflows, income_taxable = streams_to_arrays(engine_streams, req.horizon_years) + strategy = build_strategy( req.strategy, floor=floor, @@ -114,23 +143,31 @@ def _project(req: SimulateRequest, paths: np.ndarray) -> tuple[SimulationResult, guardrail_cut_pct=float(req.guardrail_cut_pct), ) + glide_alloc = float(req.stocks_allocation) if req.rates_mode == "fixed" else 1.0 + started = time.perf_counter() result = simulate( paths=paths, initial_portfolio=float(req.nw_seed_gbp), spending_target=float(req.spending_gbp), - glide=static(1.0), + glide=static(glide_alloc), strategy=strategy, regime=build_regime_schedule(req.jurisdiction, req.leave_uk_year), horizon_years=req.horizon_years, annual_savings=annual_savings, cashflow_adjustments=cashflow_adjustments, + income_inflows=income_inflows, + income_taxable=income_taxable, ) elapsed = time.perf_counter() - started return result, elapsed -def _to_response(result: SimulationResult, elapsed: float) -> SimulateResult: +def _to_response( + result: SimulationResult, + elapsed: float, + req: SimulateRequest | None = None, +) -> SimulateResult: # portfolio_real has n_years+1 columns (year 0 = seed, year k = end-of-year k). # withdrawal_real / tax_real have n_years columns (year k = withdrawn in year k+1). # Yearly point k describes "end of year k+1": portfolio after withdrawal & growth. @@ -156,6 +193,19 @@ def _to_response(result: SimulationResult, elapsed: float) -> SimulateResult: ) for y in range(n_years) ] median_ytr = result.median_years_to_ruin() + goals_probability: list[GoalProbability] = [] + if req is not None and req.goals: + evaluations = evaluate_goals(result, req.goals, req.horizon_years) + goals_probability = [ + GoalProbability( + goal_id=None, + name=ev.name, + kind=ev.kind, + probability=Decimal(str(round(ev.probability, 4))), + threshold=Decimal(str(round(ev.threshold, 4))), + passed=ev.passed, + ) for ev in evaluations + ] return SimulateResult( success_rate=Decimal(str(round(float(result.success_rate), 4))), p10_ending_gbp=Decimal(str(round(float(result.ending_percentile(10)), 2))), @@ -166,6 +216,7 @@ def _to_response(result: SimulationResult, elapsed: float) -> SimulateResult: if median_ytr is not None else None), elapsed_seconds=Decimal(str(round(elapsed, 3))), yearly=yearly, + goals_probability=goals_probability, ) @@ -177,7 +228,7 @@ async def simulate_one(req: SimulateRequest) -> SimulateResult: result, elapsed = await asyncio.to_thread(_project, req, paths) except KeyError as e: raise HTTPException(status_code=400, detail=f"Unknown name: {e}") from None - return _to_response(result, elapsed) + return _to_response(result, elapsed, req) @router.post("/compare", response_model=CompareResult) @@ -186,7 +237,7 @@ async def compare_scenarios(req: CompareRequest) -> CompareResult: async def one(s: SimulateRequest) -> SimulateResult: paths = await _build_paths(s) result, elapsed = await asyncio.to_thread(_project, s, paths) - return _to_response(result, elapsed) + return _to_response(result, elapsed, s) try: results = await asyncio.gather(*(one(s) for s in req.scenarios)) diff --git a/fire_planner/api/year_stats.py b/fire_planner/api/year_stats.py new file mode 100644 index 0000000..249bf40 --- /dev/null +++ b/fire_planner/api/year_stats.py @@ -0,0 +1,106 @@ +"""Per-year stats for the scrubbable right-hand sidebar (Plan tab). + +All cells derive from the latest persisted MC run plus scenario config. +Future-Wave fields (liquid_nw, expenses, savings_rate, portfolio +allocations) are returned as null until the relevant feature lands. +""" +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from fire_planner.api.dependencies import get_session +from fire_planner.api.schemas import YearStats +from fire_planner.db import IncomeStream, McRun, ProjectionYearly, Scenario + +router = APIRouter(prefix="/scenarios", tags=["year-stats"]) + + +def _income_at(streams: list[IncomeStream], year_idx: int) -> tuple[Decimal, Decimal]: + """Return ``(total_inflow, taxable_portion)`` at ``year_idx``.""" + inflow = Decimal("0") + taxable = Decimal("0") + for s in streams: + if not s.enabled: + continue + if year_idx < s.start_year: + continue + if s.end_year is not None and year_idx > s.end_year: + continue + years_active = year_idx - s.start_year + growth = (Decimal("1") + Decimal(str(s.growth_pct)))**years_active + amount = Decimal(str(s.amount_gbp_per_year)) * growth + inflow += amount + if s.tax_treatment == "income": + taxable += amount + return inflow, taxable + + +@router.get("/{scenario_id}/year-stats", response_model=YearStats) +async def get_year_stats( + scenario_id: int, + year: int = Query(ge=0, le=100, description="Year offset from scenario start (0 = now)"), + session: AsyncSession = Depends(get_session), +) -> YearStats: + scen = await session.get(Scenario, scenario_id) + if scen is None: + raise HTTPException(status_code=404, detail="Scenario not found") + run = (await session.execute( + select(McRun).where(McRun.scenario_id == scenario_id).order_by( + McRun.run_at.desc()).limit(1))).scalar_one_or_none() + if run is None: + raise HTTPException(status_code=404, detail="No MC runs persisted for this scenario yet") + + yearly_rows = (await session.execute( + select(ProjectionYearly).where(ProjectionYearly.mc_run_id == run.id).order_by( + ProjectionYearly.year_idx))).scalars().all() + by_year = {r.year_idx: r for r in yearly_rows} + if year not in by_year: + raise HTTPException(status_code=404, detail=f"No projection row for year {year}") + + streams = list((await session.execute( + select(IncomeStream).where( + IncomeStream.scenario_id == scenario_id))).scalars().all()) + + row = by_year[year] + prev = by_year.get(year - 1) if year > 0 else None + + nw = Decimal(str(row.p50_portfolio_gbp)) + prev_nw = (Decimal(str(prev.p50_portfolio_gbp)) if prev is not None else Decimal(str( + scen.nw_seed_gbp))) + change = nw - prev_nw + + income_inflow, taxable = _income_at(streams, year) + contributions_from_savings = Decimal(str(scen.savings_per_year_gbp)) + contributions = contributions_from_savings + income_inflow + + spending = Decimal(str(row.p50_withdrawal_gbp)) + taxes = Decimal(str(row.p50_tax_gbp)) + eff_tax_rate = (taxes / taxable if taxable > 0 else + (taxes / spending if spending > 0 else Decimal("0"))) + + investment_growth = change - contributions + spending + taxes + + payload = scen.config_json or {} + birth_year = payload.get("birth_year") if isinstance(payload, dict) else None + base_year = datetime.now(UTC).year + calendar_year = base_year + year + age = (calendar_year - int(birth_year)) if isinstance(birth_year, int) else None + + return YearStats( + year_idx=year, + calendar_year=calendar_year, + age=age, + net_worth_p50=nw, + change_in_nw=change, + taxable_income=taxable, + taxes=taxes, + effective_tax_rate=eff_tax_rate.quantize(Decimal("0.0001")), + spending=spending, + contributions=contributions, + investment_growth=investment_growth, + ) diff --git a/fire_planner/app.py b/fire_planner/app.py index dd25edf..a6f5d50 100644 --- a/fire_planner/app.py +++ b/fire_planner/app.py @@ -41,12 +41,16 @@ from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.types import Scope from fire_planner.api.auth import require_bearer +from fire_planner.api.cashflow import router as cashflow_router from fire_planner.api.goals import router as goals_router +from fire_planner.api.income_streams import router as income_streams_router from fire_planner.api.life_events import router as life_events_router from fire_planner.api.networth import router as networth_router +from fire_planner.api.progress import router as progress_router from fire_planner.api.scenarios import router as scenarios_router from fire_planner.api.simulate import router as simulate_router from fire_planner.api.spending import router as spending_router +from fire_planner.api.year_stats import router as year_stats_router from fire_planner.db import create_engine_from_env, make_session_factory log = logging.getLogger(__name__) @@ -126,6 +130,10 @@ app.include_router(networth_router, prefix=_API_PREFIX) app.include_router(scenarios_router, prefix=_API_PREFIX) app.include_router(life_events_router, prefix=_API_PREFIX) app.include_router(goals_router, prefix=_API_PREFIX) +app.include_router(income_streams_router, prefix=_API_PREFIX) +app.include_router(year_stats_router, prefix=_API_PREFIX) +app.include_router(progress_router, prefix=_API_PREFIX) +app.include_router(cashflow_router, prefix=_API_PREFIX) app.include_router(simulate_router, prefix=_API_PREFIX) app.include_router(spending_router, prefix=_API_PREFIX) diff --git a/fire_planner/db.py b/fire_planner/db.py index 5c4ac13..fa94992 100644 --- a/fire_planner/db.py +++ b/fire_planner/db.py @@ -199,6 +199,42 @@ class LifeEvent(Base): server_default=func.now()) +class IncomeStream(Base): + """A typed, recurring source of income — first-class income object. + + Modelled as a per-scenario row so a user can stack salary, dividends, + rental, pensions, RSUs, etc. The simulator routes the after-tax + amount through the jurisdiction's tax engine using `tax_treatment` + as the bucket hint (income / dividend / cgt / tax_free). + + `start_year` / `end_year` are offsets from the scenario start year. + `growth_pct` is real growth; the simulator applies it geometrically. + """ + __tablename__ = "income_stream" + __table_args__ = {"schema": SCHEMA_NAME} # noqa: RUF012 + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + scenario_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + kind: Mapped[str] = mapped_column(String(32), nullable=False) + name: Mapped[str] = mapped_column(String, nullable=False) + start_year: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0")) + end_year: Mapped[int | None] = mapped_column(Integer, nullable=True) + amount_gbp_per_year: Mapped[Decimal] = mapped_column(Numeric(12, 2), + nullable=False, + server_default=text("0")) + growth_pct: Mapped[Decimal] = mapped_column(Numeric(6, 4), + nullable=False, + server_default=text("0")) + tax_treatment: Mapped[str] = mapped_column(String(16), + nullable=False, + server_default=text("'income'")) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true")) + payload: Mapped[dict[str, Any] | None] = mapped_column(JSON_TYPE, nullable=True) + created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), + nullable=False, + server_default=func.now()) + + class RetirementGoal(Base): """A user-defined success criterion for a scenario. diff --git a/fire_planner/goals_eval.py b/fire_planner/goals_eval.py new file mode 100644 index 0000000..e167a03 --- /dev/null +++ b/fire_planner/goals_eval.py @@ -0,0 +1,130 @@ +"""Evaluate retirement goals against Monte Carlo simulation results. + +Goal-kind contract (v1): + +- ``target_nw_by_year`` — at year ``target_year`` the real portfolio must + satisfy ``comparator target_amount_gbp`` (e.g. ``>= 2_000_000``). + Probability = fraction of paths that hit the comparator at that year. +- ``never_run_out`` — portfolio must stay > 0 at every year up to + ``target_year`` (or the full horizon if ``target_year`` is None). + Probability = fraction of paths that never hit zero in the window. +- ``target_real_income`` — median (over the window ``target_year`` .. + horizon) real withdrawal must satisfy ``comparator target_amount_gbp``. + Probability = fraction of paths whose median window withdrawal hits. + +Goal kinds the v1 evaluator does not yet recognise return probability=0.0 +and ``passed=False`` so the API surface stays uniform — frontend can +render an "unsupported kind" hint. +""" +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +from decimal import Decimal +from typing import Protocol + +import numpy as np + +from fire_planner.simulator import SimulationResult + + +class _GoalLike(Protocol): + """Duck-typed goal — ORM `RetirementGoal` and unsaved goals both fit.""" + kind: str + name: str + target_amount_gbp: Decimal | None + target_year: int | None + comparator: str + success_threshold: Decimal + + +@dataclass(frozen=True) +class GoalEvaluation: + goal_id: int | None + name: str + kind: str + probability: float + threshold: float + passed: bool + + +_COMPARATORS = { + ">=": np.greater_equal, + ">": np.greater, + "<=": np.less_equal, + "<": np.less, + "=": np.equal, +} + + +def _comparator_fn(op: str): # type: ignore[no-untyped-def] + return _COMPARATORS.get(op, np.greater_equal) + + +def _eval_one( + result: SimulationResult, + goal: _GoalLike, + horizon_years: int, +) -> tuple[float, bool]: + threshold = float(goal.success_threshold) + + if goal.kind == "target_nw_by_year": + if goal.target_year is None or goal.target_amount_gbp is None: + return 0.0, False + # portfolio_real has columns 0..n_years (year 0 = seed). Clip. + col = max(0, min(int(goal.target_year), horizon_years)) + target = float(goal.target_amount_gbp) + cmp_fn = _comparator_fn(goal.comparator) + hits = cmp_fn(result.portfolio_real[:, col], target) + prob = float(np.mean(hits)) + return prob, prob >= threshold + + if goal.kind == "never_run_out": + # Stay > 0 across years 1..target_year (excludes seed year 0). + end = int(goal.target_year) if goal.target_year is not None else horizon_years + end = max(1, min(end, horizon_years)) + # portfolio_real has n_years+1 cols; index 1..end inclusive. + survived = (result.portfolio_real[:, 1:end + 1] > 0.0).all(axis=1) + prob = float(np.mean(survived)) + return prob, prob >= threshold + + if goal.kind == "target_real_income": + if goal.target_amount_gbp is None: + return 0.0, False + start_y = int(goal.target_year) if goal.target_year is not None else 0 + start_y = max(0, min(start_y, horizon_years - 1)) + window = result.withdrawal_real[:, start_y:] + if window.size == 0: + return 0.0, False + path_medians = np.median(window, axis=1) + target = float(goal.target_amount_gbp) + cmp_fn = _comparator_fn(goal.comparator) + hits = cmp_fn(path_medians, target) + prob = float(np.mean(hits)) + return prob, prob >= threshold + + return 0.0, False + + +def evaluate_goals( + result: SimulationResult, + goals: Iterable[_GoalLike], + horizon_years: int | None = None, +) -> list[GoalEvaluation]: + """Compute probability + pass/fail for every enabled goal.""" + H = horizon_years if horizon_years is not None else result.n_years + out: list[GoalEvaluation] = [] + for goal in goals: + if not getattr(goal, "enabled", True): + continue + prob, passed = _eval_one(result, goal, H) + out.append( + GoalEvaluation( + goal_id=getattr(goal, "id", None), + name=goal.name, + kind=goal.kind, + probability=prob, + threshold=float(goal.success_threshold), + passed=passed, + )) + return out diff --git a/fire_planner/income_streams.py b/fire_planner/income_streams.py new file mode 100644 index 0000000..e735743 --- /dev/null +++ b/fire_planner/income_streams.py @@ -0,0 +1,62 @@ +"""Aggregate income streams into per-year engine arrays. + +Two outputs feed the simulator: + +- ``inflows``: per-year real-GBP cash that lands in the portfolio at + start-of-year (compounds at the year's return). This includes every + enabled stream regardless of tax treatment. +- ``taxable``: per-year real-GBP that the jurisdiction tax engine should + charge income tax on (i.e. ``tax_treatment='income'``). Dividend / CGT + buckets are accounted for at withdrawal time, not income time; + ``tax_free`` is excluded entirely. + +``growth_pct`` is real (after inflation), applied geometrically each +year from ``start_year``. The simulator already runs in real-GBP, so we +do not double-deflate. +""" +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass + +import numpy as np +import numpy.typing as npt + + +@dataclass(frozen=True) +class IncomeStreamInput: + """Engine-level income stream — decoupled from ORM and Pydantic so + callers can construct them however.""" + kind: str = "salary" + start_year: int = 0 + end_year: int | None = None + amount_gbp_per_year: float = 0.0 + growth_pct: float = 0.0 + tax_treatment: str = "income" + enabled: bool = True + + +def streams_to_arrays( + streams: Iterable[IncomeStreamInput], + horizon_years: int, +) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: + """Returns ``(inflows, taxable)`` — both ``(horizon_years,)``.""" + inflows = np.zeros(horizon_years, dtype=np.float64) + taxable = np.zeros(horizon_years, dtype=np.float64) + for stream in streams: + if not stream.enabled or stream.amount_gbp_per_year <= 0: + continue + start = max(0, int(stream.start_year)) + if start >= horizon_years: + continue + end_inclusive = (stream.end_year if stream.end_year is not None else horizon_years - 1) + end_inclusive = min(int(end_inclusive), horizon_years - 1) + if end_inclusive < start: + continue + for y in range(start, end_inclusive + 1): + growth_factor = (1.0 + stream.growth_pct)**(y - start) + value = stream.amount_gbp_per_year * growth_factor + inflows[y] += value + if stream.tax_treatment == "income": + taxable[y] += value + return inflows, taxable diff --git a/fire_planner/simulator.py b/fire_planner/simulator.py index e269fcf..ffa8f1a 100644 --- a/fire_planner/simulator.py +++ b/fire_planner/simulator.py @@ -146,6 +146,33 @@ def jurisdiction_schedule( return fn +def build_fixed_paths( + n_paths: int, + n_years: int, + inflation_pct: float, + stocks_growth_pct: float, + stocks_dividend_pct: float, + bonds_growth_pct: float, + bonds_dividend_pct: float, +) -> npt.NDArray[np.float64]: + """Build a deterministic ``(n_paths, n_years, 3)`` paths cube with + constant nominal stock + bond returns and inflation. The nominal + return per asset is ``growth + dividend``; the simulator's existing + ``(1 + nominal) / (1 + cpi) − 1`` arithmetic then turns it into the + real return path. + + Used by the Wave 1.D.3 fixed Rates mode — same shape as the Shiller + bootstrap, so the rest of the engine doesn't change. + """ + nominal_stock = stocks_growth_pct + stocks_dividend_pct + nominal_bond = bonds_growth_pct + bonds_dividend_pct + paths = np.zeros((n_paths, n_years, 3), dtype=np.float64) + paths[:, :, STOCK] = nominal_stock + paths[:, :, BOND] = nominal_bond + paths[:, :, CPI] = inflation_pct + return paths + + def simulate( paths: npt.NDArray[np.float64], initial_portfolio: float, @@ -157,6 +184,8 @@ def simulate( annual_savings: npt.NDArray[np.float64] | None = None, cashflow_adjustments: npt.NDArray[np.float64] | None = None, bucket_split: _BucketSplit = default_bucket_split, + income_inflows: npt.NDArray[np.float64] | None = None, + income_taxable: npt.NDArray[np.float64] | None = None, ) -> SimulationResult: """Run the MC simulation. `paths` shape: (n_paths, n_years, 3). @@ -190,6 +219,10 @@ def simulate( annual_savings = np.zeros(n_years, dtype=np.float64) if cashflow_adjustments is None: cashflow_adjustments = np.zeros(n_years, dtype=np.float64) + if income_inflows is None: + income_inflows = np.zeros(n_years, dtype=np.float64) + if income_taxable is None: + income_taxable = np.zeros(n_years, dtype=np.float64) for y in range(n_years): alloc = glide(y) @@ -201,11 +234,18 @@ def simulate( real_bond = (1 + nominal_bond) / (1 + cpi) - 1 port_return = alloc * real_stock + (1 - alloc) * real_bond - # Add savings at year start, apply year's return, then apply - # life-event cashflow adjustments. Adjustments don't compound - # this year's returns (they're treated as end-of-year events). - portfolio = (portfolio + annual_savings[y]) * (1 + port_return) + # Add savings + recurring income inflows at year start, apply + # year's return, then apply life-event cashflow adjustments. + # Adjustments don't compound this year's returns (they're + # treated as end-of-year events). Income tax on `income_taxable[y]` + # is collected once outside the per-path loop below — it's path + # invariant when the regime is the same for all paths. + portfolio = (portfolio + annual_savings[y] + income_inflows[y]) * (1 + port_return) portfolio = portfolio + cashflow_adjustments[y] + if income_taxable[y] > 0.0: + income_tax_breakdown = regime_at(y).compute_tax( + TaxInputs(earned_income=Decimal(str(round(float(income_taxable[y]), 2))))) + portfolio = portfolio - float(income_tax_breakdown.total) # Strategy is per-path Python — 600k iterations at 60y × 10k paths. # Profiled: ~3 seconds for the full Trinity / GK / VPW set. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f1cb30d..8d14aaf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,10 +4,18 @@ import { NavLink, Route, Routes, Link } from 'react-router-dom'; import { api } from '@/api/client'; import { Compare } from '@/pages/Compare'; import { Dashboard } from '@/pages/Dashboard'; +import { CashflowTab } from '@/pages/CashflowTab'; +import { MilestonesSettings } from '@/pages/MilestonesSettings'; +import { NotesSettings } from '@/pages/NotesSettings'; +import { PlaceholderTab } from '@/pages/PlaceholderTab'; +import { ProgressPage } from '@/pages/ProgressPage'; +import { RatesSettings } from '@/pages/RatesSettings'; import { ScenarioDetail } from '@/pages/ScenarioDetail'; import { ScenarioEdit } from '@/pages/ScenarioEdit'; import { ScenarioNew } from '@/pages/ScenarioNew'; +import { ScenarioShell } from '@/pages/ScenarioShell'; import { Scenarios } from '@/pages/Scenarios'; +import { SettingsTab } from '@/pages/SettingsTab'; import { WhatIf } from '@/pages/WhatIf'; export function App() { @@ -44,8 +52,53 @@ export function App() { } /> } /> } /> - } /> } /> + } /> + }> + } /> + } /> + } + /> + } + /> + } + /> + } + /> + }> + } /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } /> + + } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8d91761..9aab0e9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -45,6 +45,11 @@ export const api = { method: 'POST', body: JSON.stringify(body ?? {}), }), + yearStats: (id: number, year: number) => + request(`/scenarios/${id}/year-stats?year=${year}`), + progress: (id: number) => request(`/scenarios/${id}/progress`), + cashflow: (id: number, year: number) => + request(`/scenarios/${id}/cashflow?year=${year}`), networth: { current: () => request<{ @@ -197,6 +202,115 @@ export const goalsApi = { delete: (goalId: number) => request(`/goals/${goalId}`, { method: 'DELETE' }), }; +// ── income streams ─────────────────────────────────────────────────── + +export interface IncomeStream { + id: number; + scenario_id: number; + kind: string; + name: string; + start_year: number; + end_year: number | null; + amount_gbp_per_year: string; + growth_pct: string; + tax_treatment: string; + enabled: boolean; + payload: Record | null; + created_at: string; +} + +export interface IncomeStreamCreateBody { + kind: string; + name: string; + start_year?: number; + end_year?: number | null; + amount_gbp_per_year?: string; + growth_pct?: string; + tax_treatment?: string; + enabled?: boolean; + payload?: Record | null; +} + +export const incomeStreamsApi = { + list: (scenarioId: number) => + request(`/scenarios/${scenarioId}/income-streams`), + create: (scenarioId: number, body: IncomeStreamCreateBody) => + request(`/scenarios/${scenarioId}/income-streams`, { + method: 'POST', + body: JSON.stringify(body), + }), + patch: (streamId: number, body: Partial) => + request(`/income-streams/${streamId}`, { + method: 'PATCH', + body: JSON.stringify(body), + }), + delete: (streamId: number) => + request(`/income-streams/${streamId}`, { method: 'DELETE' }), +}; + +// ── per-year stats / progress / cashflow ───────────────────────────── + +export interface YearStats { + year_idx: number; + calendar_year: number; + age: number | null; + net_worth_p50: string; + change_in_nw: string; + taxable_income: string; + taxes: string; + effective_tax_rate: string; + spending: string; + contributions: string; + investment_growth: string; + liquid_nw: string | null; + expenses: string | null; + savings_rate: string | null; + portfolio_allocations: Record | null; +} + +export interface ProgressActualPoint { + snapshot_date: string; + total_gbp: string; +} + +export interface ProgressProjectedPoint { + year_idx: number; + p10_portfolio_gbp: string; + p50_portfolio_gbp: string; + p90_portfolio_gbp: string; +} + +export interface ProgressVariancePoint { + year_idx: number; + actual_avg_gbp: string; + projected_p50_gbp: string; + delta_gbp: string; +} + +export interface ProgressResponse { + scenario_id: number; + alignment_anchor: string; + actual: ProgressActualPoint[]; + projected: ProgressProjectedPoint[]; + variance: ProgressVariancePoint[]; +} + +export interface CashflowResponse { + scenario_id: number; + year: number; + sources: Record; + sinks: Record; +} + +export interface GoalProbability { + goal_id: number | null; + name: string; + kind: string; + probability: string; + threshold: string; + passed: boolean; +} + export interface Scenario { id: number; external_id: string; @@ -241,6 +355,7 @@ export interface ScenarioProjection { median_lifetime_tax_gbp: string; median_years_to_ruin: string | null; yearly: ProjectionPoint[]; + goals_probability?: GoalProbability[]; } export interface SimulateRequest { @@ -267,6 +382,31 @@ export interface SimulateRequest { annual_real_adjust_pct?: string; guardrail_threshold_pct?: string | null; guardrail_cut_pct?: string; + income_streams?: Array<{ + kind?: string; + start_year: number; + end_year?: number | null; + amount_gbp_per_year: string; + growth_pct?: string; + tax_treatment?: string; + enabled?: boolean; + }>; + goals?: Array<{ + kind: string; + name: string; + target_amount_gbp?: string | null; + target_year?: number | null; + comparator?: string; + success_threshold?: string; + enabled?: boolean; + }>; + rates_mode?: 'fixed' | 'historical' | 'advanced' | null; + inflation_pct?: string; + stocks_growth_pct?: string; + stocks_dividend_pct?: string; + bonds_growth_pct?: string; + bonds_dividend_pct?: string; + stocks_allocation?: string; } export interface SimulateResult { @@ -278,4 +418,5 @@ export interface SimulateResult { median_years_to_ruin: string | null; elapsed_seconds: string; yearly: ProjectionPoint[]; + goals_probability?: GoalProbability[]; } diff --git a/frontend/src/components/CashflowSankey.tsx b/frontend/src/components/CashflowSankey.tsx new file mode 100644 index 0000000..2e5ae51 --- /dev/null +++ b/frontend/src/components/CashflowSankey.tsx @@ -0,0 +1,75 @@ +/** + * Cash Flow Sankey for one year (Wave 1.C.2). + * + * Sources flow into a "Annual cashflow" node which then drains into + * sinks. Sums conserve at the node level. + */ +import { useMemo } from 'react'; +import ReactECharts from 'echarts-for-react'; +import type { EChartsOption } from 'echarts'; + +import type { CashflowResponse } from '@/api/client'; +import { gbpCompact } from '@/lib/format'; + +interface Props { + data: CashflowResponse; + height?: number; +} + +const CENTER_NODE = 'Annual cashflow'; + +export function CashflowSankey({ data, height = 420 }: Props) { + const option = useMemo(() => buildSankey(data), [data]); + return ( + + ); +} + +function buildSankey(data: CashflowResponse): EChartsOption { + const sourceNames = Object.keys(data.sources); + const sinkNames = Object.keys(data.sinks); + const nodes = [ + ...sourceNames.map((n) => ({ name: n })), + { name: CENTER_NODE }, + ...sinkNames.map((n) => ({ name: n })), + ]; + const links = [ + ...sourceNames.map((n) => ({ + source: n, + target: CENTER_NODE, + value: Number(data.sources[n] ?? 0), + })), + ...sinkNames.map((n) => ({ + source: CENTER_NODE, + target: n, + value: Number(data.sinks[n] ?? 0), + })), + ].filter((l) => l.value > 0); + + return { + tooltip: { + trigger: 'item', + formatter: (params) => { + if (Array.isArray(params)) return ''; + const p = params as { dataType?: string; data?: { value?: number; name?: string; source?: string; target?: string } }; + if (p.dataType === 'edge' && p.data) { + return `${p.data.source} → ${p.data.target}: ${gbpCompact(p.data.value ?? 0)}`; + } + return p.data?.name ?? ''; + }, + }, + series: [ + { + type: 'sankey', + data: nodes, + links, + nodeAlign: 'justify', + nodeWidth: 18, + nodeGap: 8, + emphasis: { focus: 'adjacency' }, + lineStyle: { color: 'gradient', curveness: 0.5 }, + label: { color: '#1e293b', fontSize: 12 }, + }, + ], + }; +} diff --git a/frontend/src/components/FanChart.tsx b/frontend/src/components/FanChart.tsx index b6b12e1..0f6c554 100644 --- a/frontend/src/components/FanChart.tsx +++ b/frontend/src/components/FanChart.tsx @@ -19,25 +19,57 @@ import type { EChartsOption } from 'echarts'; import type { ProjectionPoint } from '@/api/client'; import { gbpCompact } from '@/lib/format'; +import type { Milestone } from '@/lib/milestone'; interface Props { yearly: ProjectionPoint[]; height?: number; showWithdrawal?: boolean; + milestones?: Milestone[]; + selectedYear?: number | null; + onSelectYear?: (year: number) => void; } -export function FanChart({ yearly, height = 360, showWithdrawal = false }: Props) { - const option = useMemo(() => buildFan(yearly, showWithdrawal), [ - yearly, - showWithdrawal, - ]); +export function FanChart({ + yearly, + height = 360, + showWithdrawal = false, + milestones, + selectedYear, + onSelectYear, +}: Props) { + const option = useMemo( + () => buildFan(yearly, showWithdrawal, milestones, selectedYear), + [yearly, showWithdrawal, milestones, selectedYear], + ); if (yearly.length === 0) { return

No projection data.

; } - return ; + const handlers = onSelectYear + ? { + click: (params: { name?: string }) => { + const year = Number(params.name); + if (!Number.isNaN(year)) onSelectYear(year); + }, + } + : undefined; + return ( + + ); } -function buildFan(yearly: ProjectionPoint[], showWithdrawal: boolean): EChartsOption { +function buildFan( + yearly: ProjectionPoint[], + showWithdrawal: boolean, + milestones?: Milestone[], + selectedYear?: number | null, +): EChartsOption { const years = yearly.map((p) => p.year_idx); const p10 = yearly.map((p) => num(p.p10_portfolio_gbp)); const p25 = yearly.map((p) => num(p.p25_portfolio_gbp)); @@ -122,6 +154,50 @@ function buildFan(yearly: ProjectionPoint[], showWithdrawal: boolean): EChartsOp }, ]; + if (milestones && milestones.length > 0) { + series.push({ + name: 'milestones', + type: 'scatter', + data: milestones + .filter((m) => m.year_idx >= 0 && m.year_idx < yearly.length) + .map((m) => ({ + name: m.label, + value: [m.year_idx, p50[m.year_idx] ?? 0], + symbol: `text:${m.emoji}`, + symbolSize: 26, + label: { show: true, formatter: m.emoji, fontSize: 18 }, + tooltip: { + formatter: () => + [ + `${m.label}`, + `year ${m.year_idx}`, + m.delta_gbp ? `Δ ${m.delta_gbp}` : '', + ] + .filter(Boolean) + .join('
'), + }, + })), + symbol: 'circle', + symbolSize: 24, + itemStyle: { color: '#f59e0b' }, + z: 20, + }); + } + if (selectedYear != null && selectedYear >= 0 && selectedYear < yearly.length) { + series.push({ + name: 'selected', + type: 'line', + data: [], + markLine: { + symbol: 'none', + silent: true, + lineStyle: { color: 'rgba(15, 23, 42, 0.6)', width: 2, type: 'solid' }, + label: { show: false }, + data: [{ xAxis: selectedYear }], + }, + z: 30, + }); + } if (showWithdrawal) { series.push({ name: 'median withdrawal', diff --git a/frontend/src/components/GoalsSection.tsx b/frontend/src/components/GoalsSection.tsx index b86095b..a82a8a5 100644 --- a/frontend/src/components/GoalsSection.tsx +++ b/frontend/src/components/GoalsSection.tsx @@ -4,7 +4,12 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; -import { goalsApi, type Goal, type GoalCreateBody } from '@/api/client'; +import { + goalsApi, + type Goal, + type GoalCreateBody, + type GoalProbability, +} from '@/api/client'; import { gbp, pct } from '@/lib/format'; const KIND_SUGGESTIONS = ['target_nw', 'never_run_out', 'inheritance', 'spending_floor']; @@ -20,7 +25,13 @@ const EMPTY_FORM: GoalCreateBody = { enabled: true, }; -export function GoalsSection({ scenarioId }: { scenarioId: number }) { +export function GoalsSection({ + scenarioId, + probabilities, +}: { + scenarioId: number; + probabilities?: GoalProbability[]; +}) { const goals = useQuery({ queryKey: ['scenarios', scenarioId, 'goals'], queryFn: () => goalsApi.list(scenarioId), @@ -34,14 +45,20 @@ export function GoalsSection({ scenarioId }: { scenarioId: number }) { ) : goals.isError ? (

Failed to load goals.

) : ( - + )} ); } -function GoalsList({ goals }: { goals: Goal[] }) { +function GoalsList({ + goals, + probabilities, +}: { + goals: Goal[]; + probabilities: GoalProbability[]; +}) { const qc = useQueryClient(); const del = useMutation({ mutationFn: (id: number) => goalsApi.delete(id), @@ -53,34 +70,52 @@ function GoalsList({ goals }: { goals: Goal[] }) { return

No goals yet.

; } + const probByName = new Map(probabilities.map((p) => [p.name, p])); return (
    - {goals.map((g) => ( -
  • -
    -
    {g.name}
    -
    - {g.kind} - {g.target_amount_gbp ? ` · ${g.comparator} ${gbp(g.target_amount_gbp)}` : ''} - {g.target_year !== null ? ` · year ${g.target_year}` : ''} - {' · threshold '} - {pct(g.success_threshold)} -
    -
    - -
  • - ))} +
    +
    {g.name}
    +
    + {g.kind} + {g.target_amount_gbp ? ` · ${g.comparator} ${gbp(g.target_amount_gbp)}` : ''} + {g.target_year !== null ? ` · year ${g.target_year}` : ''} + {' · threshold '} + {pct(g.success_threshold)} +
    +
    +
    + {p && ( + + {pct(p.probability)} {p.passed ? '✓' : '✗'} + + )} + +
    + + ); + })}
); } diff --git a/frontend/src/components/IncomeStreamsSection.tsx b/frontend/src/components/IncomeStreamsSection.tsx new file mode 100644 index 0000000..e565c1d --- /dev/null +++ b/frontend/src/components/IncomeStreamsSection.tsx @@ -0,0 +1,255 @@ +/** + * Typed income streams (Wave 1.C.1) — first-class objects on the Plan + * tab, below Life Events. + */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; + +import { + incomeStreamsApi, + type IncomeStream, + type IncomeStreamCreateBody, +} from '@/api/client'; +import { gbp, pct } from '@/lib/format'; + +const KINDS = [ + 'salary', + 'dividend', + 'rental', + 'pension', + 'social_security', + 'rsu', + 'other', +]; + +const TAX_TREATMENTS = ['income', 'dividend', 'cgt', 'tax_free']; + +const EMPTY: IncomeStreamCreateBody = { + kind: 'salary', + name: '', + start_year: 0, + end_year: null, + amount_gbp_per_year: '50000', + growth_pct: '0.02', + tax_treatment: 'income', + enabled: true, +}; + +export function IncomeStreamsSection({ scenarioId }: { scenarioId: number }) { + const list = useQuery({ + queryKey: ['scenarios', scenarioId, 'income-streams'], + queryFn: () => incomeStreamsApi.list(scenarioId), + }); + + return ( +
+

Income streams

+ {list.isLoading ? ( +

Loading…

+ ) : list.isError ? ( +

Failed to load streams.

+ ) : ( + + )} + +
+ ); +} + +function StreamsList({ + scenarioId, + streams, +}: { + scenarioId: number; + streams: IncomeStream[]; +}) { + const qc = useQueryClient(); + const del = useMutation({ + mutationFn: (id: number) => incomeStreamsApi.delete(id), + onSettled: () => + qc.invalidateQueries({ + queryKey: ['scenarios', scenarioId, 'income-streams'], + }), + }); + + if (streams.length === 0) { + return

No income streams yet.

; + } + + return ( +
    + {streams.map((s) => ( +
  • +
    +
    {s.name}
    +
    + {s.kind} · year {s.start_year} + {s.end_year !== null && s.end_year !== s.start_year ? `–${s.end_year}` : ''} + {' · '} + {gbp(s.amount_gbp_per_year)}/y + {Number(s.growth_pct) !== 0 ? ` · +${pct(s.growth_pct)} growth` : ''} + {' · '} + {s.tax_treatment} +
    +
    + +
  • + ))} +
+ ); +} + +function AddStreamForm({ scenarioId }: { scenarioId: number }) { + const [form, setForm] = useState(EMPTY); + const [err, setErr] = useState(null); + const qc = useQueryClient(); + + const create = useMutation({ + mutationFn: (body: IncomeStreamCreateBody) => + incomeStreamsApi.create(scenarioId, body), + onSuccess: () => { + qc.invalidateQueries({ + queryKey: ['scenarios', scenarioId, 'income-streams'], + }); + setForm(EMPTY); + setErr(null); + }, + onError: (e) => setErr(String((e as Error)?.message ?? e)), + }); + + const update = ( + k: K, + v: IncomeStreamCreateBody[K], + ) => setForm((f) => ({ ...f, [k]: v })); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!form.name.trim()) { + setErr('Name required'); + return; + } + if ( + form.end_year !== null && + form.end_year !== undefined && + form.start_year !== undefined && + form.end_year < form.start_year + ) { + setErr('end_year must be ≥ start_year'); + return; + } + setErr(null); + create.mutate(form); + }; + + return ( +
+ + + + + + + +
+ + {err && {err}} +
+
+ ); +} diff --git a/frontend/src/components/ProgressOverlay.tsx b/frontend/src/components/ProgressOverlay.tsx new file mode 100644 index 0000000..fc77d75 --- /dev/null +++ b/frontend/src/components/ProgressOverlay.tsx @@ -0,0 +1,95 @@ +/** + * Overlay actual NW (from account_snapshot) on top of the projected + * fan. Wave 1.B.2. + */ +import { useMemo } from 'react'; +import ReactECharts from 'echarts-for-react'; +import type { EChartsOption } from 'echarts'; + +import type { ProgressResponse } from '@/api/client'; +import { gbpCompact } from '@/lib/format'; + +interface Props { + data: ProgressResponse; + height?: number; +} + +export function ProgressOverlay({ data, height = 380 }: Props) { + const option = useMemo(() => buildOption(data), [data]); + return ( + + ); +} + +function buildOption(data: ProgressResponse): EChartsOption { + const projected = data.projected; + const actuals = data.actual.map((p) => [p.snapshot_date, Number(p.total_gbp)]); + + const anchorYear = new Date(data.alignment_anchor).getUTCFullYear(); + const projectedSeries = projected.map((p) => [ + `${anchorYear + p.year_idx}-01-01`, + Number(p.p50_portfolio_gbp), + ]); + const p10 = projected.map((p) => [ + `${anchorYear + p.year_idx}-01-01`, + Number(p.p10_portfolio_gbp), + ]); + const p90 = projected.map((p) => [ + `${anchorYear + p.year_idx}-01-01`, + Number(p.p90_portfolio_gbp), + ]); + + return { + tooltip: { + trigger: 'axis', + valueFormatter: (v) => gbpCompact(Number(v)), + }, + legend: { + top: 4, + data: ['actual', 'projected (p50)', 'p10', 'p90'], + }, + grid: { left: 60, right: 24, top: 36, bottom: 40 }, + xAxis: { type: 'time' }, + yAxis: { + type: 'value', + axisLabel: { formatter: (v: number) => gbpCompact(v) }, + }, + series: [ + { + name: 'p10', + type: 'line', + data: p10, + lineStyle: { width: 1, type: 'dashed', color: 'rgba(40,70,200,0.5)' }, + symbol: 'none', + smooth: true, + }, + { + name: 'p90', + type: 'line', + data: p90, + lineStyle: { width: 1, type: 'dashed', color: 'rgba(40,70,200,0.5)' }, + symbol: 'none', + smooth: true, + }, + { + name: 'projected (p50)', + type: 'line', + data: projectedSeries, + lineStyle: { width: 2, color: 'rgb(40,70,200)' }, + symbol: 'none', + smooth: true, + z: 5, + }, + { + name: 'actual', + type: 'line', + data: actuals, + lineStyle: { width: 2, color: 'rgb(16,185,129)' }, + itemStyle: { color: 'rgb(16,185,129)' }, + symbol: 'circle', + symbolSize: 6, + z: 10, + }, + ], + }; +} diff --git a/frontend/src/components/RateCard.tsx b/frontend/src/components/RateCard.tsx new file mode 100644 index 0000000..6d503c9 --- /dev/null +++ b/frontend/src/components/RateCard.tsx @@ -0,0 +1,67 @@ +/** + * One rate card (Inflation / Stocks / Bonds) — collapsed view shows the + * pair "growth% / dividend%" PLab style; expanded shows two inputs. + */ +import { useState } from 'react'; + +interface Props { + title: string; + growth: number; + dividend?: number | null; + onChange: (next: { growth: number; dividend?: number | null }) => void; +} + +export function RateCard({ title, growth, dividend, onChange }: Props) { + const [open, setOpen] = useState(false); + const hasDividend = dividend != null; + + return ( +
+ + {open && ( +
+ + {hasDividend && ( + + )} +
+ )} +
+ ); +} + +function fmt(value: number): string { + return `${(value * 100).toFixed(2)}%`; +} diff --git a/frontend/src/components/SettingsSubnav.tsx b/frontend/src/components/SettingsSubnav.tsx new file mode 100644 index 0000000..fa0c7fd --- /dev/null +++ b/frontend/src/components/SettingsSubnav.tsx @@ -0,0 +1,37 @@ +/** + * Vertical sub-nav inside the Settings tab (Wave 1.D.1). + */ +import { NavLink } from 'react-router-dom'; + +interface Item { + to: string; + label: string; + end?: boolean; +} + +export function SettingsSubnav({ items }: { items: Item[] }) { + return ( + + ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..150e77f --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,125 @@ +/** + * Left sidebar (Current Finances · Dashboard · Progress · Plans switcher). + * + * Plans switcher lists user-defined scenarios; clicking one swaps the + * scenario in the URL while keeping the active tab. + */ +import { useQuery } from '@tanstack/react-query'; +import { Link, NavLink, useParams } from 'react-router-dom'; + +import { api } from '@/api/client'; + +interface Props { + activeScenarioId?: number; +} + +export function Sidebar({ activeScenarioId }: Props) { + return ( + + ); +} + +function SidebarSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+
+ {title} +
+
    {children}
+
+ ); +} + +function SidebarLink({ + to, + children, + end, +}: { + to: string; + children: React.ReactNode; + end?: boolean; +}) { + return ( +
  • + + [ + 'block rounded-md px-3 py-1.5 text-sm', + isActive + ? 'bg-slate-100 text-slate-900 font-medium' + : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50', + ].join(' ') + } + > + {children} + +
  • + ); +} + +function PlansSwitcher({ activeScenarioId }: { activeScenarioId?: number }) { + const params = useParams(); + const scenarios = useQuery({ + queryKey: ['scenarios', 'list', 'user'], + queryFn: () => api.scenarios.list('user'), + }); + const active = activeScenarioId ?? Number(params.id); + + return ( + + {scenarios.isLoading && ( +
  • Loading…
  • + )} + {scenarios.data?.map((s) => ( +
  • + { + const highlight = isActive || s.id === active; + return [ + 'block rounded-md px-3 py-1.5 text-sm', + highlight + ? 'bg-slate-100 text-slate-900 font-medium' + : 'text-slate-600 hover:text-slate-900 hover:bg-slate-50', + ].join(' '); + }} + > + + {s.name ?? s.external_id} + + +
  • + ))} + {scenarios.data && scenarios.data.length === 0 && ( +
  • No saved plans.
  • + )} +
  • + + + New Plan + +
  • +
    + ); +} diff --git a/frontend/src/components/TabBar.tsx b/frontend/src/components/TabBar.tsx new file mode 100644 index 0000000..15d1358 --- /dev/null +++ b/frontend/src/components/TabBar.tsx @@ -0,0 +1,36 @@ +/** + * ProjectionLab-style top tab bar for the scenario shell. Active tab + * gets a slate-900 bottom border + bolder text; inactive tabs are + * slate-500 with hover. + */ +import { NavLink } from 'react-router-dom'; + +export interface TabSpec { + to: string; + label: string; + end?: boolean; +} + +export function TabBar({ tabs }: { tabs: TabSpec[] }) { + return ( + + ); +} diff --git a/frontend/src/components/YearScrubber.tsx b/frontend/src/components/YearScrubber.tsx new file mode 100644 index 0000000..5f6e6b4 --- /dev/null +++ b/frontend/src/components/YearScrubber.tsx @@ -0,0 +1,34 @@ +/** + * Year picker that drives the right-hand stats panel. + * + * Range slider mirrors PLab's chart scrubber. The selected year is + * controlled — the parent owns `value` and propagates URL search-param + * persistence. ENTER + arrow keys also work via the native range input. + */ +interface Props { + value: number; + min: number; + max: number; + onChange: (year: number) => void; +} + +export function YearScrubber({ value, min, max, onChange }: Props) { + return ( +
    + Year + onChange(Number(e.target.value))} + className="flex-1 accent-slate-900" + aria-label="Year scrubber" + /> + + {value} + +
    + ); +} diff --git a/frontend/src/components/YearStatsPanel.tsx b/frontend/src/components/YearStatsPanel.tsx new file mode 100644 index 0000000..1d05c8b --- /dev/null +++ b/frontend/src/components/YearStatsPanel.tsx @@ -0,0 +1,88 @@ +/** + * Right-hand stats sidebar (PLab-style). Reads /scenarios/:id/year-stats. + * + * Future-Wave fields (liquid_nw, expenses, savings_rate, allocations) + * render as "—" until their respective features ship. + */ +import { useQuery } from '@tanstack/react-query'; + +import { api } from '@/api/client'; +import { gbp, pct } from '@/lib/format'; + +interface Props { + scenarioId: number; + year: number; +} + +export function YearStatsPanel({ scenarioId, year }: Props) { + const stats = useQuery({ + queryKey: ['year-stats', scenarioId, year], + queryFn: () => api.yearStats(scenarioId, year), + staleTime: 0, + refetchOnWindowFocus: true, + }); + + if (stats.isLoading) { + return ( + + ); + } + if (stats.isError || !stats.data) { + return ( + + ); + } + const s = stats.data; + return ( + + ); +} + +function Cell({ + label, + value, + accent, + signed, +}: { + label: string; + value: string; + accent?: boolean; + signed?: boolean; +}) { + let signColor = ''; + if (signed) { + if (value.startsWith('-')) signColor = 'text-red-600'; + else if (value !== '—' && value !== '£0') signColor = 'text-emerald-700'; + } + return ( +
    + {label} + + {value} + +
    + ); +} diff --git a/frontend/src/lib/milestone.ts b/frontend/src/lib/milestone.ts new file mode 100644 index 0000000..e6bea31 --- /dev/null +++ b/frontend/src/lib/milestone.ts @@ -0,0 +1,33 @@ +/** + * Map life_event.kind → emoji + short label for chart markers. + * + * Unknown kinds get the bell so we always render *something* — the user + * can always edit the event to a known kind. + */ + +export interface Milestone { + year_idx: number; + emoji: string; + label: string; + delta_gbp?: string | null; +} + +const KIND_EMOJI: Record = { + retirement: '🏖️', + partner_retirement: '👫', + kid_born: '👶', + kids_leave_home: '🪺', + mortgage_payoff: '🏡', + home_purchase: '🏠', + sabbatical: '🌴', + inheritance: '💰', + expense_range: '💸', + one_time_income: '💵', + death: '🪦', + marriage: '💍', + car: '🚗', +}; + +export function emojiFor(kind: string): string { + return KIND_EMOJI[kind] ?? '🔔'; +} diff --git a/frontend/src/pages/CashflowTab.tsx b/frontend/src/pages/CashflowTab.tsx new file mode 100644 index 0000000..add8ce2 --- /dev/null +++ b/frontend/src/pages/CashflowTab.tsx @@ -0,0 +1,94 @@ +/** + * Cash Flow tab body — Sankey + year scrubber (Wave 1.C.2). + */ +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import { api } from '@/api/client'; +import { CashflowSankey } from '@/components/CashflowSankey'; +import { YearScrubber } from '@/components/YearScrubber'; +import { gbp } from '@/lib/format'; + +export function CashflowTab() { + const params = useParams<{ id: string }>(); + const id = Number(params.id); + const [year, setYear] = useState(0); + + const proj = useQuery({ + queryKey: ['scenarios', id, 'projection'], + queryFn: () => api.scenarios.projection(id), + enabled: Number.isFinite(id), + }); + const cashflow = useQuery({ + queryKey: ['cashflow', id, year], + queryFn: () => api.cashflow(id, year), + enabled: Number.isFinite(id) && proj.isSuccess, + staleTime: 0, + refetchOnWindowFocus: true, + }); + + if (!Number.isFinite(id)) return

    Invalid scenario id.

    ; + const horizon = (proj.data?.yearly.length ?? 60) - 1; + + return ( +
    +
    +

    Cash Flow

    +

    + Sources and sinks of the median path for the selected year. Sums conserve. +

    +
    +
    + +
    + {cashflow.isLoading &&

    Loading…

    } + {cashflow.isError && ( +
    + {String((cashflow.error as Error)?.message ?? cashflow.error)} +
    + )} + {cashflow.data && ( +
    + + +
    + )} +
    + ); +} + +function SankeyTotals({ + data, +}: { + data: { sources: Record; sinks: Record }; +}) { + const sources = Object.entries(data.sources); + const sinks = Object.entries(data.sinks); + return ( +
    +
    +
    Sources
    +
      + {sources.map(([k, v]) => ( +
    • + {k} + {gbp(v)} +
    • + ))} +
    +
    +
    +
    Sinks
    +
      + {sinks.map(([k, v]) => ( +
    • + {k} + {gbp(v)} +
    • + ))} +
    +
    +
    + ); +} diff --git a/frontend/src/pages/MilestonesSettings.tsx b/frontend/src/pages/MilestonesSettings.tsx new file mode 100644 index 0000000..2b2c5a2 --- /dev/null +++ b/frontend/src/pages/MilestonesSettings.tsx @@ -0,0 +1,20 @@ +/** + * Settings → Milestones (Wave 1.D.2) — relocated home for life events. + */ +import { useParams } from 'react-router-dom'; + +import { LifeEventsSection } from '@/components/LifeEventsSection'; + +export function MilestonesSettings() { + const params = useParams<{ id: string }>(); + const id = Number(params.id); + if (!Number.isFinite(id)) return

    Invalid scenario id.

    ; + return ( +
    +

    + Life events drive milestone markers on the Plan-tab fan chart. +

    + +
    + ); +} diff --git a/frontend/src/pages/NotesSettings.tsx b/frontend/src/pages/NotesSettings.tsx new file mode 100644 index 0000000..cfd5047 --- /dev/null +++ b/frontend/src/pages/NotesSettings.tsx @@ -0,0 +1,81 @@ +/** + * Settings → Notes (Wave 1.D.4) — markdown textarea, save-on-blur. + * + * Stored in `scenario.config_json.notes` via PATCH /scenarios/:id. + */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import { api } from '@/api/client'; + +export function NotesSettings() { + const params = useParams<{ id: string }>(); + const id = Number(params.id); + const qc = useQueryClient(); + + const scen = useQuery({ + queryKey: ['scenarios', id], + queryFn: () => api.scenarios.get(id), + enabled: Number.isFinite(id), + }); + + const [draft, setDraft] = useState(''); + const [savedAt, setSavedAt] = useState(null); + + useEffect(() => { + if (scen.data?.config_json) { + const blob = scen.data.config_json as Record; + const stored = typeof blob.notes === 'string' ? blob.notes : ''; + setDraft(stored); + } + }, [scen.data?.config_json]); + + const save = useMutation({ + mutationFn: (text: string) => + api.scenarios.patch(id, { + config_json: { + ...((scen.data?.config_json as Record) ?? {}), + notes: text, + }, + } as never), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['scenarios', id] }); + setSavedAt(new Date()); + }, + }); + + if (!Number.isFinite(id)) return

    Invalid scenario id.

    ; + if (scen.isLoading) return

    Loading…

    ; + + const onBlur = () => { + const original = + (scen.data?.config_json as Record)?.notes ?? ''; + if (draft !== original) save.mutate(draft); + }; + + return ( +
    +

    + Free-form notes. Saved when you click outside the box. +

    +