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
69
alembic/versions/0003_income_streams.py
Normal file
69
alembic/versions/0003_income_streams.py
Normal file
|
|
@ -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)
|
||||
130
fire_planner/api/cashflow.py
Normal file
130
fire_planner/api/cashflow.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
98
fire_planner/api/income_streams.py
Normal file
98
fire_planner/api/income_streams.py
Normal file
|
|
@ -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()
|
||||
104
fire_planner/api/progress.py
Normal file
104
fire_planner/api/progress.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
106
fire_planner/api/year_stats.py
Normal file
106
fire_planner/api/year_stats.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
130
fire_planner/goals_eval.py
Normal file
130
fire_planner/goals_eval.py
Normal file
|
|
@ -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
|
||||
62
fire_planner/income_streams.py
Normal file
62
fire_planner/income_streams.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/scenarios" element={<Scenarios />} />
|
||||
<Route path="/scenarios/new" element={<ScenarioNew />} />
|
||||
<Route path="/scenarios/:id" element={<ScenarioDetail />} />
|
||||
<Route path="/scenarios/:id/edit" element={<ScenarioEdit />} />
|
||||
<Route path="/scenarios/:id/progress" element={<ProgressPage />} />
|
||||
<Route path="/scenarios/:id" element={<ScenarioShell />}>
|
||||
<Route index element={<ScenarioDetail />} />
|
||||
<Route path="cash-flow" element={<CashflowTab />} />
|
||||
<Route
|
||||
path="tax-analytics"
|
||||
element={<PlaceholderTab feature="Tax Analytics" wave={2} />}
|
||||
/>
|
||||
<Route
|
||||
path="compare"
|
||||
element={<PlaceholderTab feature="Side-by-side Compare" wave={2} />}
|
||||
/>
|
||||
<Route
|
||||
path="reports"
|
||||
element={<PlaceholderTab feature="Reports / PDF export" wave={2} />}
|
||||
/>
|
||||
<Route
|
||||
path="estate"
|
||||
element={<PlaceholderTab feature="Estate planning" wave={2} />}
|
||||
/>
|
||||
<Route path="settings" element={<SettingsTab />}>
|
||||
<Route index element={<MilestonesSettings />} />
|
||||
<Route path="rates" element={<RatesSettings />} />
|
||||
<Route
|
||||
path="dividends"
|
||||
element={<PlaceholderTab feature="Dividends override matrix" wave={2} />}
|
||||
/>
|
||||
<Route
|
||||
path="bonds"
|
||||
element={<PlaceholderTab feature="Bond allocation editor" wave={2} />}
|
||||
/>
|
||||
<Route
|
||||
path="tax"
|
||||
element={<PlaceholderTab feature="Tax mode toggle + per-jurisdiction overrides" wave={2} />}
|
||||
/>
|
||||
<Route
|
||||
path="metrics"
|
||||
element={<PlaceholderTab feature="Right-sidebar metric picker" wave={2} />}
|
||||
/>
|
||||
<Route
|
||||
path="other"
|
||||
element={<PlaceholderTab feature="Currency / horizon / seed defaults" wave={2} />}
|
||||
/>
|
||||
<Route path="notes" element={<NotesSettings />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="/compare" element={<Compare />} />
|
||||
<Route path="/what-if" element={<WhatIf />} />
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,11 @@ export const api = {
|
|||
method: 'POST',
|
||||
body: JSON.stringify(body ?? {}),
|
||||
}),
|
||||
yearStats: (id: number, year: number) =>
|
||||
request<YearStats>(`/scenarios/${id}/year-stats?year=${year}`),
|
||||
progress: (id: number) => request<ProgressResponse>(`/scenarios/${id}/progress`),
|
||||
cashflow: (id: number, year: number) =>
|
||||
request<CashflowResponse>(`/scenarios/${id}/cashflow?year=${year}`),
|
||||
networth: {
|
||||
current: () =>
|
||||
request<{
|
||||
|
|
@ -197,6 +202,115 @@ export const goalsApi = {
|
|||
delete: (goalId: number) => request<void>(`/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<string, unknown> | 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<string, unknown> | null;
|
||||
}
|
||||
|
||||
export const incomeStreamsApi = {
|
||||
list: (scenarioId: number) =>
|
||||
request<IncomeStream[]>(`/scenarios/${scenarioId}/income-streams`),
|
||||
create: (scenarioId: number, body: IncomeStreamCreateBody) =>
|
||||
request<IncomeStream>(`/scenarios/${scenarioId}/income-streams`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
patch: (streamId: number, body: Partial<IncomeStreamCreateBody>) =>
|
||||
request<IncomeStream>(`/income-streams/${streamId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
delete: (streamId: number) =>
|
||||
request<void>(`/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<string, string> | 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<string, string>;
|
||||
sinks: Record<string, string>;
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
|
|
|
|||
75
frontend/src/components/CashflowSankey.tsx
Normal file
75
frontend/src/components/CashflowSankey.tsx
Normal file
|
|
@ -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<EChartsOption>(() => buildSankey(data), [data]);
|
||||
return (
|
||||
<ReactECharts option={option} style={{ height, width: '100%' }} notMerge lazyUpdate />
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -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<EChartsOption>(() => buildFan(yearly, showWithdrawal), [
|
||||
yearly,
|
||||
showWithdrawal,
|
||||
]);
|
||||
export function FanChart({
|
||||
yearly,
|
||||
height = 360,
|
||||
showWithdrawal = false,
|
||||
milestones,
|
||||
selectedYear,
|
||||
onSelectYear,
|
||||
}: Props) {
|
||||
const option = useMemo<EChartsOption>(
|
||||
() => buildFan(yearly, showWithdrawal, milestones, selectedYear),
|
||||
[yearly, showWithdrawal, milestones, selectedYear],
|
||||
);
|
||||
if (yearly.length === 0) {
|
||||
return <p className="text-sm text-slate-500">No projection data.</p>;
|
||||
}
|
||||
return <ReactECharts option={option} style={{ height, width: '100%' }} notMerge lazyUpdate />;
|
||||
const handlers = onSelectYear
|
||||
? {
|
||||
click: (params: { name?: string }) => {
|
||||
const year = Number(params.name);
|
||||
if (!Number.isNaN(year)) onSelectYear(year);
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
return (
|
||||
<ReactECharts
|
||||
option={option}
|
||||
style={{ height, width: '100%' }}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
onEvents={handlers}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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: () =>
|
||||
[
|
||||
`<b>${m.label}</b>`,
|
||||
`year ${m.year_idx}`,
|
||||
m.delta_gbp ? `Δ ${m.delta_gbp}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('<br/>'),
|
||||
},
|
||||
})),
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<p className="text-sm text-red-700">Failed to load goals.</p>
|
||||
) : (
|
||||
<GoalsList goals={goals.data ?? []} />
|
||||
<GoalsList goals={goals.data ?? []} probabilities={probabilities ?? []} />
|
||||
)}
|
||||
<AddGoalForm scenarioId={scenarioId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <p className="text-sm text-slate-500 mb-4">No goals yet.</p>;
|
||||
}
|
||||
|
||||
const probByName = new Map(probabilities.map((p) => [p.name, p]));
|
||||
return (
|
||||
<ul className="space-y-2 mb-5">
|
||||
{goals.map((g) => (
|
||||
<li
|
||||
key={g.id}
|
||||
className="flex items-center justify-between rounded-md border border-slate-200 bg-slate-50 px-4 py-2 text-sm"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{g.name}</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{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)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => del.mutate(g.id)}
|
||||
disabled={del.isPending}
|
||||
aria-label={`Delete ${g.name}`}
|
||||
className="text-xs text-slate-500 hover:text-red-700 px-2 py-1 disabled:opacity-50"
|
||||
{goals.map((g) => {
|
||||
const p = probByName.get(g.name);
|
||||
return (
|
||||
<li
|
||||
key={g.id}
|
||||
className="flex items-center justify-between rounded-md border border-slate-200 bg-slate-50 px-4 py-2 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<div>
|
||||
<div className="font-medium">{g.name}</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{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)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{p && (
|
||||
<span
|
||||
className={`text-xs font-medium tabular-nums px-2 py-1 rounded ${
|
||||
p.passed
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: 'bg-amber-100 text-amber-800'
|
||||
}`}
|
||||
title={`Probability ${pct(p.probability)} (threshold ${pct(p.threshold)})`}
|
||||
>
|
||||
{pct(p.probability)} {p.passed ? '✓' : '✗'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => del.mutate(g.id)}
|
||||
disabled={del.isPending}
|
||||
aria-label={`Delete ${g.name}`}
|
||||
className="text-xs text-slate-500 hover:text-red-700 px-2 py-1 disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
255
frontend/src/components/IncomeStreamsSection.tsx
Normal file
255
frontend/src/components/IncomeStreamsSection.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<h2 className="text-lg font-semibold mb-4">Income streams</h2>
|
||||
{list.isLoading ? (
|
||||
<p className="text-sm text-slate-500">Loading…</p>
|
||||
) : list.isError ? (
|
||||
<p className="text-sm text-red-700">Failed to load streams.</p>
|
||||
) : (
|
||||
<StreamsList scenarioId={scenarioId} streams={list.data ?? []} />
|
||||
)}
|
||||
<AddStreamForm scenarioId={scenarioId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <p className="text-sm text-slate-500 mb-4">No income streams yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2 mb-5">
|
||||
{streams.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center justify-between rounded-md border border-slate-200 bg-slate-50 px-4 py-2 text-sm"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{s.name}</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{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}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => del.mutate(s.id)}
|
||||
disabled={del.isPending}
|
||||
aria-label={`Delete ${s.name}`}
|
||||
className="text-xs text-slate-500 hover:text-red-700 px-2 py-1 disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function AddStreamForm({ scenarioId }: { scenarioId: number }) {
|
||||
const [form, setForm] = useState<IncomeStreamCreateBody>(EMPTY);
|
||||
const [err, setErr] = useState<string | null>(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 extends keyof IncomeStreamCreateBody>(
|
||||
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 (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="grid grid-cols-1 md:grid-cols-7 gap-3 items-end pt-3 border-t border-slate-100"
|
||||
>
|
||||
<label className="md:col-span-2 text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => update('name', e.target.value)}
|
||||
placeholder="Day job"
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Kind</span>
|
||||
<select
|
||||
value={form.kind}
|
||||
onChange={(e) => update('kind', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
|
||||
>
|
||||
{KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{k}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Year start</span>
|
||||
<input
|
||||
type="number"
|
||||
value={form.start_year ?? 0}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => update('start_year', Number(e.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Year end</span>
|
||||
<input
|
||||
type="number"
|
||||
value={form.end_year ?? ''}
|
||||
min={0}
|
||||
max={100}
|
||||
placeholder="—"
|
||||
onChange={(e) =>
|
||||
update('end_year', e.target.value === '' ? null : Number(e.target.value))
|
||||
}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">£/year</span>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(form.amount_gbp_per_year ?? 0)}
|
||||
step={1000}
|
||||
onChange={(e) => update('amount_gbp_per_year', String(e.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Growth</span>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(form.growth_pct ?? 0)}
|
||||
step={0.005}
|
||||
onChange={(e) => update('growth_pct', String(e.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
|
||||
/>
|
||||
</label>
|
||||
<label className="md:col-span-1 text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Tax</span>
|
||||
<select
|
||||
value={form.tax_treatment ?? 'income'}
|
||||
onChange={(e) => update('tax_treatment', e.target.value)}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"
|
||||
>
|
||||
{TAX_TREATMENTS.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="md:col-span-7 flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={create.isPending}
|
||||
className="rounded-md bg-slate-900 text-white text-sm font-medium px-3 py-1.5 hover:bg-slate-800 disabled:opacity-60"
|
||||
>
|
||||
{create.isPending ? 'Adding…' : 'Add stream'}
|
||||
</button>
|
||||
{err && <span className="text-xs text-red-700">{err}</span>}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
95
frontend/src/components/ProgressOverlay.tsx
Normal file
95
frontend/src/components/ProgressOverlay.tsx
Normal file
|
|
@ -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<EChartsOption>(() => buildOption(data), [data]);
|
||||
return (
|
||||
<ReactECharts option={option} style={{ height, width: '100%' }} notMerge lazyUpdate />
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
67
frontend/src/components/RateCard.tsx
Normal file
67
frontend/src/components/RateCard.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="rounded-md border border-slate-200 bg-white p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="w-full flex items-baseline justify-between text-left"
|
||||
>
|
||||
<span className="text-sm font-medium text-slate-800">{title}</span>
|
||||
<span className="text-sm tabular-nums text-slate-700">
|
||||
{fmt(growth)}
|
||||
{hasDividend ? ` / ${fmt(dividend)}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="grid grid-cols-2 gap-3 mt-3">
|
||||
<label className="text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Growth</span>
|
||||
<input
|
||||
type="number"
|
||||
step={0.001}
|
||||
value={growth}
|
||||
onChange={(e) =>
|
||||
onChange({ growth: Number(e.target.value), dividend })
|
||||
}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
|
||||
/>
|
||||
</label>
|
||||
{hasDividend && (
|
||||
<label className="text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Dividend</span>
|
||||
<input
|
||||
type="number"
|
||||
step={0.001}
|
||||
value={dividend ?? 0}
|
||||
onChange={(e) =>
|
||||
onChange({ growth, dividend: Number(e.target.value) })
|
||||
}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(value: number): string {
|
||||
return `${(value * 100).toFixed(2)}%`;
|
||||
}
|
||||
37
frontend/src/components/SettingsSubnav.tsx
Normal file
37
frontend/src/components/SettingsSubnav.tsx
Normal file
|
|
@ -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 (
|
||||
<nav className="w-44 shrink-0 border-r border-slate-200 pr-3">
|
||||
<ul className="space-y-1">
|
||||
{items.map((it) => (
|
||||
<li key={it.to}>
|
||||
<NavLink
|
||||
to={it.to}
|
||||
end={it.end}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'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(' ')
|
||||
}
|
||||
>
|
||||
{it.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
125
frontend/src/components/Sidebar.tsx
Normal file
125
frontend/src/components/Sidebar.tsx
Normal file
|
|
@ -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 (
|
||||
<aside className="w-56 shrink-0 border-r border-slate-200 bg-white py-4 px-2">
|
||||
<SidebarSection title="Navigate">
|
||||
<SidebarLink to="/scenarios">Current Finances</SidebarLink>
|
||||
<SidebarLink to="/" end>
|
||||
Dashboard
|
||||
</SidebarLink>
|
||||
{activeScenarioId != null && (
|
||||
<SidebarLink to={`/scenarios/${activeScenarioId}/progress`}>Progress</SidebarLink>
|
||||
)}
|
||||
</SidebarSection>
|
||||
<PlansSwitcher activeScenarioId={activeScenarioId} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSection({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="px-3 text-[10px] uppercase tracking-wider text-slate-500 mb-2">
|
||||
{title}
|
||||
</div>
|
||||
<ul className="space-y-1">{children}</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({
|
||||
to,
|
||||
children,
|
||||
end,
|
||||
}: {
|
||||
to: string;
|
||||
children: React.ReactNode;
|
||||
end?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<li>
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'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}
|
||||
</NavLink>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SidebarSection title="Plans">
|
||||
{scenarios.isLoading && (
|
||||
<li className="px-3 py-1.5 text-xs text-slate-500">Loading…</li>
|
||||
)}
|
||||
{scenarios.data?.map((s) => (
|
||||
<li key={s.id}>
|
||||
<NavLink
|
||||
to={`/scenarios/${s.id}`}
|
||||
className={({ isActive }) => {
|
||||
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(' ');
|
||||
}}
|
||||
>
|
||||
<span className="truncate inline-block max-w-full">
|
||||
{s.name ?? s.external_id}
|
||||
</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
{scenarios.data && scenarios.data.length === 0 && (
|
||||
<li className="px-3 py-1.5 text-xs text-slate-500">No saved plans.</li>
|
||||
)}
|
||||
<li>
|
||||
<Link
|
||||
to="/scenarios/new"
|
||||
className="block rounded-md px-3 py-1.5 text-sm text-emerald-700 hover:bg-emerald-50 font-medium"
|
||||
>
|
||||
+ New Plan
|
||||
</Link>
|
||||
</li>
|
||||
</SidebarSection>
|
||||
);
|
||||
}
|
||||
36
frontend/src/components/TabBar.tsx
Normal file
36
frontend/src/components/TabBar.tsx
Normal file
|
|
@ -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 (
|
||||
<nav className="border-b border-slate-200 flex gap-6 text-sm overflow-x-auto">
|
||||
{tabs.map((t) => (
|
||||
<NavLink
|
||||
key={t.to}
|
||||
to={t.to}
|
||||
end={t.end}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'py-3 px-1 -mb-px border-b-2 whitespace-nowrap',
|
||||
isActive
|
||||
? 'border-slate-900 text-slate-900 font-medium'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-800',
|
||||
].join(' ')
|
||||
}
|
||||
>
|
||||
{t.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/YearScrubber.tsx
Normal file
34
frontend/src/components/YearScrubber.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="rounded-md border border-slate-200 bg-white p-3 flex items-center gap-3">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500">Year</span>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={1}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="flex-1 accent-slate-900"
|
||||
aria-label="Year scrubber"
|
||||
/>
|
||||
<span className="text-sm tabular-nums font-medium text-slate-900 w-12 text-right">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/YearStatsPanel.tsx
Normal file
88
frontend/src/components/YearStatsPanel.tsx
Normal file
|
|
@ -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 (
|
||||
<aside className="rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-500">
|
||||
Loading…
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
if (stats.isError || !stats.data) {
|
||||
return (
|
||||
<aside className="rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-500">
|
||||
No stats for year {year}. Run /recompute first.
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
const s = stats.data;
|
||||
return (
|
||||
<aside className="rounded-lg border border-slate-200 bg-white divide-y divide-slate-100">
|
||||
<Cell label="Year" value={String(s.calendar_year)} />
|
||||
<Cell label="Age" value={s.age != null ? String(s.age) : '—'} />
|
||||
<Cell label="Net Worth" value={gbp(s.net_worth_p50)} accent />
|
||||
<Cell label="Δ Net Worth" value={gbp(s.change_in_nw)} signed />
|
||||
<Cell label="Liquid NW" value={s.liquid_nw ? gbp(s.liquid_nw) : '—'} />
|
||||
<Cell label="Income" value={gbp(s.contributions)} />
|
||||
<Cell label="Taxable Income" value={gbp(s.taxable_income)} />
|
||||
<Cell label="Taxes" value={gbp(s.taxes)} />
|
||||
<Cell label="Effective Tax Rate" value={pct(s.effective_tax_rate)} />
|
||||
<Cell label="Spending" value={gbp(s.spending)} />
|
||||
<Cell label="Expenses" value={s.expenses ? gbp(s.expenses) : '—'} />
|
||||
<Cell label="Savings Rate" value={s.savings_rate ? pct(s.savings_rate) : '—'} />
|
||||
<Cell label="Contributions" value={gbp(s.contributions)} />
|
||||
<Cell label="Investment Growth" value={gbp(s.investment_growth)} signed />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-baseline justify-between px-4 py-2">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
|
||||
<span
|
||||
className={`text-sm tabular-nums font-medium ${
|
||||
accent ? 'text-slate-900 text-base' : signColor || 'text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
frontend/src/lib/milestone.ts
Normal file
33
frontend/src/lib/milestone.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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] ?? '🔔';
|
||||
}
|
||||
94
frontend/src/pages/CashflowTab.tsx
Normal file
94
frontend/src/pages/CashflowTab.tsx
Normal file
|
|
@ -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 <p className="text-red-700">Invalid scenario id.</p>;
|
||||
const horizon = (proj.data?.yearly.length ?? 60) - 1;
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Cash Flow</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Sources and sinks of the median path for the selected year. Sums conserve.
|
||||
</p>
|
||||
</header>
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<YearScrubber value={year} min={0} max={Math.max(horizon, 0)} onChange={setYear} />
|
||||
</div>
|
||||
{cashflow.isLoading && <p className="text-slate-500">Loading…</p>}
|
||||
{cashflow.isError && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-4 text-red-800 text-sm">
|
||||
{String((cashflow.error as Error)?.message ?? cashflow.error)}
|
||||
</div>
|
||||
)}
|
||||
{cashflow.data && (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<CashflowSankey data={cashflow.data} height={460} />
|
||||
<SankeyTotals data={cashflow.data} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SankeyTotals({
|
||||
data,
|
||||
}: {
|
||||
data: { sources: Record<string, string>; sinks: Record<string, string> };
|
||||
}) {
|
||||
const sources = Object.entries(data.sources);
|
||||
const sinks = Object.entries(data.sinks);
|
||||
return (
|
||||
<div className="grid md:grid-cols-2 gap-6 mt-6 text-sm">
|
||||
<div>
|
||||
<div className="font-medium text-slate-800 mb-2">Sources</div>
|
||||
<ul className="space-y-1">
|
||||
{sources.map(([k, v]) => (
|
||||
<li key={k} className="flex justify-between">
|
||||
<span className="text-slate-600">{k}</span>
|
||||
<span className="tabular-nums">{gbp(v)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-800 mb-2">Sinks</div>
|
||||
<ul className="space-y-1">
|
||||
{sinks.map(([k, v]) => (
|
||||
<li key={k} className="flex justify-between">
|
||||
<span className="text-slate-600">{k}</span>
|
||||
<span className="tabular-nums">{gbp(v)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
frontend/src/pages/MilestonesSettings.tsx
Normal file
20
frontend/src/pages/MilestonesSettings.tsx
Normal file
|
|
@ -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 <p className="text-red-700">Invalid scenario id.</p>;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-500">
|
||||
Life events drive milestone markers on the Plan-tab fan chart.
|
||||
</p>
|
||||
<LifeEventsSection scenarioId={id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
frontend/src/pages/NotesSettings.tsx
Normal file
81
frontend/src/pages/NotesSettings.tsx
Normal file
|
|
@ -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<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scen.data?.config_json) {
|
||||
const blob = scen.data.config_json as Record<string, unknown>;
|
||||
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<string, unknown>) ?? {}),
|
||||
notes: text,
|
||||
},
|
||||
} as never),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['scenarios', id] });
|
||||
setSavedAt(new Date());
|
||||
},
|
||||
});
|
||||
|
||||
if (!Number.isFinite(id)) return <p className="text-red-700">Invalid scenario id.</p>;
|
||||
if (scen.isLoading) return <p className="text-slate-500">Loading…</p>;
|
||||
|
||||
const onBlur = () => {
|
||||
const original =
|
||||
(scen.data?.config_json as Record<string, unknown>)?.notes ?? '';
|
||||
if (draft !== original) save.mutate(draft);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 max-w-3xl">
|
||||
<p className="text-sm text-slate-500">
|
||||
Free-form notes. Saved when you click outside the box.
|
||||
</p>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
rows={16}
|
||||
placeholder="Paste assumptions, links, decisions…"
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm font-mono"
|
||||
/>
|
||||
<div className="text-xs text-slate-500">
|
||||
{save.isPending
|
||||
? 'Saving…'
|
||||
: save.isError
|
||||
? `Save failed: ${String((save.error as Error)?.message ?? save.error)}`
|
||||
: savedAt
|
||||
? `Saved at ${savedAt.toLocaleTimeString()}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
frontend/src/pages/PlaceholderTab.tsx
Normal file
18
frontend/src/pages/PlaceholderTab.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Placeholder body for tabs / sub-pages that are stubbed in Wave 1.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
feature: string;
|
||||
wave: number;
|
||||
}
|
||||
|
||||
export function PlaceholderTab({ feature, wave }: Props) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-10 text-center">
|
||||
<div className="text-3xl mb-2">🚧</div>
|
||||
<div className="text-base font-medium text-slate-800">{feature}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Coming in Wave {wave}.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/src/pages/ProgressPage.tsx
Normal file
78
frontend/src/pages/ProgressPage.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Progress page (Wave 1.B.2) — actual NW from snapshots vs projection
|
||||
* fan, with a variance side panel.
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
|
||||
import { api } from '@/api/client';
|
||||
import { ProgressOverlay } from '@/components/ProgressOverlay';
|
||||
import { gbp } from '@/lib/format';
|
||||
|
||||
export function ProgressPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = Number(params.id);
|
||||
const progress = useQuery({
|
||||
queryKey: ['progress', id],
|
||||
queryFn: () => api.progress(id),
|
||||
enabled: Number.isFinite(id),
|
||||
staleTime: 0,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
if (!Number.isFinite(id)) return <p className="text-red-700">Invalid scenario id.</p>;
|
||||
|
||||
return (
|
||||
<section className="space-y-5">
|
||||
<div className="text-sm">
|
||||
<Link to={`/scenarios/${id}`} className="text-slate-500 hover:text-slate-900">
|
||||
← Plan
|
||||
</Link>
|
||||
</div>
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Progress</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Actual net worth (from Wealthfolio snapshots) overlaid on the projection fan.
|
||||
</p>
|
||||
</header>
|
||||
{progress.isLoading && <p className="text-slate-500">Loading…</p>}
|
||||
{progress.isError && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-4 text-red-800 text-sm">
|
||||
{String((progress.error as Error)?.message ?? progress.error)}
|
||||
</div>
|
||||
)}
|
||||
{progress.data && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-6">
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<ProgressOverlay data={progress.data} height={420} />
|
||||
</div>
|
||||
<aside className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div className="text-xs uppercase tracking-wide text-slate-500 mb-2">
|
||||
Variance vs p50
|
||||
</div>
|
||||
{progress.data.variance.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
No yearly overlap yet — populate <code>account_snapshot</code> via the
|
||||
Wealthfolio ingest to see variance.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2 text-sm">
|
||||
{progress.data.variance.map((v) => {
|
||||
const delta = Number(v.delta_gbp);
|
||||
const cls =
|
||||
delta > 0 ? 'text-emerald-700' : delta < 0 ? 'text-red-600' : '';
|
||||
return (
|
||||
<li key={v.year_idx} className="flex items-baseline justify-between">
|
||||
<span className="text-slate-600">y{v.year_idx}</span>
|
||||
<span className={`tabular-nums ${cls}`}>{gbp(v.delta_gbp)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
186
frontend/src/pages/RatesSettings.tsx
Normal file
186
frontend/src/pages/RatesSettings.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* Settings → Rates (Wave 1.D.3).
|
||||
*
|
||||
* Stores its state in the scenario's `config_json.rates` blob via PATCH
|
||||
* /scenarios/:id. The simulator reads them off the SimulateRequest at
|
||||
* /simulate time; the Cartesian recompute path will pick them up in a
|
||||
* follow-up wave.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { api } from '@/api/client';
|
||||
import { RateCard } from '@/components/RateCard';
|
||||
import { SegmentedControl } from '@/components/SegmentedControl';
|
||||
|
||||
type RatesMode = 'fixed' | 'historical' | 'advanced';
|
||||
|
||||
interface RatesConfig {
|
||||
mode: RatesMode;
|
||||
inflation_pct: number;
|
||||
stocks_growth_pct: number;
|
||||
stocks_dividend_pct: number;
|
||||
bonds_growth_pct: number;
|
||||
bonds_dividend_pct: number;
|
||||
stocks_allocation: number;
|
||||
}
|
||||
|
||||
const DEFAULT: RatesConfig = {
|
||||
mode: 'historical',
|
||||
inflation_pct: 0.03,
|
||||
stocks_growth_pct: 0.06,
|
||||
stocks_dividend_pct: 0.025,
|
||||
bonds_growth_pct: 0.015,
|
||||
bonds_dividend_pct: 0.035,
|
||||
stocks_allocation: 1,
|
||||
};
|
||||
|
||||
function readConfig(blob: Record<string, unknown>): RatesConfig {
|
||||
const raw = (blob?.rates ?? {}) as Partial<RatesConfig>;
|
||||
return {
|
||||
mode: (raw.mode as RatesMode) ?? DEFAULT.mode,
|
||||
inflation_pct: numFallback(raw.inflation_pct, DEFAULT.inflation_pct),
|
||||
stocks_growth_pct: numFallback(raw.stocks_growth_pct, DEFAULT.stocks_growth_pct),
|
||||
stocks_dividend_pct: numFallback(raw.stocks_dividend_pct, DEFAULT.stocks_dividend_pct),
|
||||
bonds_growth_pct: numFallback(raw.bonds_growth_pct, DEFAULT.bonds_growth_pct),
|
||||
bonds_dividend_pct: numFallback(raw.bonds_dividend_pct, DEFAULT.bonds_dividend_pct),
|
||||
stocks_allocation: numFallback(raw.stocks_allocation, DEFAULT.stocks_allocation),
|
||||
};
|
||||
}
|
||||
|
||||
function numFallback(value: unknown, fallback: number): number {
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
export function RatesSettings() {
|
||||
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 [config, setConfig] = useState<RatesConfig>(DEFAULT);
|
||||
|
||||
useEffect(() => {
|
||||
if (scen.data?.config_json) {
|
||||
setConfig(readConfig(scen.data.config_json as Record<string, unknown>));
|
||||
}
|
||||
}, [scen.data?.config_json]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (next: RatesConfig) =>
|
||||
api.scenarios.patch(id, {
|
||||
config_json: {
|
||||
...((scen.data?.config_json as Record<string, unknown>) ?? {}),
|
||||
rates: next,
|
||||
},
|
||||
} as never),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['scenarios', id] }),
|
||||
});
|
||||
|
||||
if (!Number.isFinite(id)) return <p className="text-red-700">Invalid scenario id.</p>;
|
||||
if (scen.isLoading) return <p className="text-slate-500">Loading…</p>;
|
||||
if (!scen.data) return null;
|
||||
|
||||
const realStock =
|
||||
(1 + config.stocks_growth_pct + config.stocks_dividend_pct) /
|
||||
(1 + config.inflation_pct) -
|
||||
1;
|
||||
|
||||
const update = (patch: Partial<RatesConfig>) => {
|
||||
const next = { ...config, ...patch };
|
||||
setConfig(next);
|
||||
save.mutate(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-2xl">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 mb-3">
|
||||
Choose how returns are generated. Fixed mode synthesises a deterministic real
|
||||
return from the per-asset growth + dividend numbers minus inflation. Historical
|
||||
uses the Shiller bootstrap. Advanced is a Wave 2 placeholder.
|
||||
</p>
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: 'fixed', label: 'Fixed' },
|
||||
{ value: 'historical', label: 'Historical' },
|
||||
{ value: 'advanced', label: 'Advanced (Wave 2)', title: 'Coming in Wave 2' },
|
||||
]}
|
||||
value={config.mode}
|
||||
onChange={(v) => update({ mode: v as RatesMode })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<RateCard
|
||||
title="Inflation"
|
||||
growth={config.inflation_pct}
|
||||
onChange={(n) => update({ inflation_pct: n.growth })}
|
||||
/>
|
||||
<RateCard
|
||||
title="Stocks"
|
||||
growth={config.stocks_growth_pct}
|
||||
dividend={config.stocks_dividend_pct}
|
||||
onChange={(n) =>
|
||||
update({
|
||||
stocks_growth_pct: n.growth,
|
||||
stocks_dividend_pct: n.dividend ?? config.stocks_dividend_pct,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<RateCard
|
||||
title="Bonds"
|
||||
growth={config.bonds_growth_pct}
|
||||
dividend={config.bonds_dividend_pct}
|
||||
onChange={(n) =>
|
||||
update({
|
||||
bonds_growth_pct: n.growth,
|
||||
bonds_dividend_pct: n.dividend ?? config.bonds_dividend_pct,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm">
|
||||
<div className="text-xs uppercase tracking-wide text-slate-500">
|
||||
Computed real stock return
|
||||
</div>
|
||||
<div className="text-lg font-semibold tabular-nums text-slate-800 mt-1">
|
||||
{(realStock * 100).toFixed(2)}%
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
(1 + growth + dividend) / (1 + inflation) − 1
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block text-xs">
|
||||
<span className="uppercase tracking-wide text-slate-500">Stocks allocation</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={config.stocks_allocation}
|
||||
onChange={(e) => update({ stocks_allocation: Number(e.target.value) })}
|
||||
className="mt-1 w-32 rounded-md border border-slate-300 px-2 py-1.5 text-sm tabular-nums"
|
||||
/>
|
||||
<span className="ml-3 text-xs text-slate-500">
|
||||
(only used in Fixed mode; bonds = 1 − allocation)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{save.isPending && <p className="text-xs text-slate-500">Saving…</p>}
|
||||
{save.isError && (
|
||||
<p className="text-xs text-red-700">
|
||||
{String((save.error as Error)?.message ?? save.error)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,24 +1,35 @@
|
|||
/**
|
||||
* Scenario detail — params + the latest persisted MC projection.
|
||||
* Plan-tab body for a scenario — Wave 1.A.x.
|
||||
*
|
||||
* Reuses FanChart from the What-If page. If the scenario has no MC run
|
||||
* yet, prompts the user to run /recompute.
|
||||
* Layout:
|
||||
* ┌──────────────────────────────────────────┬──────────────┐
|
||||
* │ header + summary cards │ │
|
||||
* │ FanChart with milestone markers │ Year stats │
|
||||
* │ Year scrubber │ panel │
|
||||
* │ Income streams · Goals · Life events │ │
|
||||
* └──────────────────────────────────────────┴──────────────┘
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { api, lifeEventsApi, type Scenario, type SimulateRequest } from '@/api/client';
|
||||
import { ApiError } from '@/api/client';
|
||||
import { FanChart } from '@/components/FanChart';
|
||||
import { GoalsSection } from '@/components/GoalsSection';
|
||||
import { IncomeStreamsSection } from '@/components/IncomeStreamsSection';
|
||||
import { LifeEventsSection } from '@/components/LifeEventsSection';
|
||||
import { YearScrubber } from '@/components/YearScrubber';
|
||||
import { YearStatsPanel } from '@/components/YearStatsPanel';
|
||||
import { gbp, pct } from '@/lib/format';
|
||||
import { emojiFor } from '@/lib/milestone';
|
||||
|
||||
export function ScenarioDetail() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = Number(params.id);
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const scen = useQuery({
|
||||
queryKey: ['scenarios', id],
|
||||
|
|
@ -30,12 +41,17 @@ export function ScenarioDetail() {
|
|||
queryFn: () => api.scenarios.projection(id),
|
||||
enabled: Number.isFinite(id),
|
||||
retry: (count, err) => {
|
||||
// Don't retry the 404 — it's the "no run yet" empty state.
|
||||
if (err instanceof ApiError && err.status === 404) return false;
|
||||
return count < 2;
|
||||
},
|
||||
});
|
||||
|
||||
const events = useQuery({
|
||||
queryKey: ['scenarios', id, 'life-events'],
|
||||
queryFn: () => lifeEventsApi.list(id),
|
||||
enabled: Number.isFinite(id),
|
||||
});
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: () => api.scenarios.delete(id),
|
||||
onSuccess: () => {
|
||||
|
|
@ -48,14 +64,45 @@ export function ScenarioDetail() {
|
|||
mutationFn: (req: SimulateRequest) => api.simulate(req),
|
||||
});
|
||||
|
||||
const horizonYears = scen.data?.horizon_years ?? proj.data?.yearly.length ?? 60;
|
||||
const maxYear = (proj.data?.yearly.length ?? horizonYears) - 1;
|
||||
|
||||
const yearFromUrl = Number(searchParams.get('year'));
|
||||
const initialYear = Number.isFinite(yearFromUrl) && yearFromUrl >= 0 ? yearFromUrl : 0;
|
||||
const [year, setYear] = useState<number>(initialYear);
|
||||
|
||||
useEffect(() => {
|
||||
if (year > maxYear && maxYear >= 0) setYear(maxYear);
|
||||
}, [maxYear, year]);
|
||||
|
||||
const setYearAndUrl = (y: number) => {
|
||||
setYear(y);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set('year', String(y));
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const milestones = useMemo(
|
||||
() =>
|
||||
(events.data ?? [])
|
||||
.filter((e) => e.enabled)
|
||||
.map((e) => ({
|
||||
year_idx: e.year_start,
|
||||
emoji: emojiFor(e.kind),
|
||||
label: e.name,
|
||||
delta_gbp: Number(e.delta_gbp_per_year) !== 0 ? gbp(e.delta_gbp_per_year) : null,
|
||||
})),
|
||||
[events.data],
|
||||
);
|
||||
|
||||
const onDelete = () => {
|
||||
if (!confirm(`Delete scenario "${scen.data?.name ?? id}"? This can't be undone.`)) return;
|
||||
if (!scen.data) return;
|
||||
if (!confirm(`Delete scenario "${scen.data.name ?? id}"? This can't be undone.`)) return;
|
||||
del.mutate();
|
||||
};
|
||||
|
||||
const onRunNow = async (s: Scenario) => {
|
||||
// Pull events fresh so the run reflects whatever the user just edited.
|
||||
const events = await lifeEventsApi.list(s.id);
|
||||
const fresh = await lifeEventsApi.list(s.id);
|
||||
sim.mutate({
|
||||
jurisdiction: s.jurisdiction,
|
||||
strategy: s.strategy,
|
||||
|
|
@ -66,7 +113,7 @@ export function ScenarioDetail() {
|
|||
horizon_years: s.horizon_years,
|
||||
n_paths: 5000,
|
||||
seed: 42,
|
||||
life_events: events.map((e) => ({
|
||||
life_events: fresh.map((e) => ({
|
||||
year_start: e.year_start,
|
||||
year_end: e.year_end,
|
||||
delta_gbp_per_year: e.delta_gbp_per_year,
|
||||
|
|
@ -79,7 +126,6 @@ export function ScenarioDetail() {
|
|||
if (!Number.isFinite(id)) {
|
||||
return <p className="text-red-700">Invalid scenario id.</p>;
|
||||
}
|
||||
|
||||
if (scen.isLoading) return <p className="text-slate-500">Loading…</p>;
|
||||
if (scen.isError || !scen.data) {
|
||||
return (
|
||||
|
|
@ -91,7 +137,8 @@ export function ScenarioDetail() {
|
|||
|
||||
const s = scen.data;
|
||||
const projection = proj.data;
|
||||
const projection404 = proj.isError && proj.error instanceof ApiError && proj.error.status === 404;
|
||||
const projection404 =
|
||||
proj.isError && proj.error instanceof ApiError && proj.error.status === 404;
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
|
|
@ -154,26 +201,31 @@ export function ScenarioDetail() {
|
|||
</div>
|
||||
|
||||
{projection ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Stat label="Success rate" value={pct(projection.success_rate)} accent />
|
||||
<Stat label="Median ending NW" value={gbp(projection.p50_ending_gbp)} />
|
||||
<Stat label="P10 ending" value={gbp(projection.p10_ending_gbp)} />
|
||||
<Stat label="P90 ending" value={gbp(projection.p90_ending_gbp)} />
|
||||
<Stat label="Median lifetime tax" value={gbp(projection.median_lifetime_tax_gbp)} />
|
||||
<Stat
|
||||
label="Median years to ruin"
|
||||
value={projection.median_years_to_ruin ?? 'never'}
|
||||
/>
|
||||
<Stat label="MC paths" value={projection.n_paths.toLocaleString()} />
|
||||
<Stat label="Run at" value={new Date(projection.run_at).toLocaleString()} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Stat label="Success rate" value={pct(projection.success_rate)} accent />
|
||||
<Stat label="Median ending NW" value={gbp(projection.p50_ending_gbp)} />
|
||||
<Stat label="P10 ending" value={gbp(projection.p10_ending_gbp)} />
|
||||
<Stat label="P90 ending" value={gbp(projection.p90_ending_gbp)} />
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<h2 className="text-lg font-semibold mb-2">Portfolio fan</h2>
|
||||
<FanChart
|
||||
yearly={projection.yearly}
|
||||
height={420}
|
||||
showWithdrawal
|
||||
milestones={milestones}
|
||||
selectedYear={year}
|
||||
onSelectYear={setYearAndUrl}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
<YearScrubber value={year} min={0} max={maxYear} onChange={setYearAndUrl} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
||||
<h2 className="text-lg font-semibold mb-2">Portfolio fan</h2>
|
||||
<FanChart yearly={projection.yearly} height={420} showWithdrawal />
|
||||
</div>
|
||||
</>
|
||||
<YearStatsPanel scenarioId={id} year={year} />
|
||||
</div>
|
||||
) : projection404 ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-8 text-center text-slate-500">
|
||||
<p className="font-medium text-slate-700">No projection yet.</p>
|
||||
|
|
@ -207,8 +259,9 @@ export function ScenarioDetail() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<IncomeStreamsSection scenarioId={id} />
|
||||
<GoalsSection scenarioId={id} probabilities={projection?.goals_probability} />
|
||||
<LifeEventsSection scenarioId={id} />
|
||||
<GoalsSection scenarioId={id} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
52
frontend/src/pages/ScenarioShell.tsx
Normal file
52
frontend/src/pages/ScenarioShell.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Tabbed shell for a scenario (Wave 1.A.1) — top tab bar + left sidebar.
|
||||
*
|
||||
* The body is owned by nested routes so each tab can manage its own
|
||||
* data without re-mounting the chrome.
|
||||
*/
|
||||
import { Outlet, useParams } from 'react-router-dom';
|
||||
|
||||
import { Sidebar } from '@/components/Sidebar';
|
||||
import { TabBar, type TabSpec } from '@/components/TabBar';
|
||||
|
||||
export function ScenarioShell() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = Number(params.id);
|
||||
const tabs: TabSpec[] = [
|
||||
{ to: `/scenarios/${id}`, label: 'Plan', end: true },
|
||||
{ to: `/scenarios/${id}/cash-flow`, label: 'Cash Flow' },
|
||||
{ to: `/scenarios/${id}/tax-analytics`, label: 'Tax Analytics' },
|
||||
{ to: `/scenarios/${id}/compare`, label: 'Compare' },
|
||||
{ to: `/scenarios/${id}/reports`, label: 'Reports' },
|
||||
{ to: `/scenarios/${id}/estate`, label: 'Estate' },
|
||||
{ to: `/scenarios/${id}/settings`, label: 'Settings' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 -my-8 -mx-6">
|
||||
<Sidebar activeScenarioId={Number.isFinite(id) ? id : undefined} />
|
||||
<div className="flex-1 min-w-0 px-6 py-6">
|
||||
<TabBar tabs={tabs} />
|
||||
<div className="pt-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComingInWaveCard({
|
||||
wave,
|
||||
feature,
|
||||
}: {
|
||||
wave: number;
|
||||
feature: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-8 text-center">
|
||||
<div className="text-2xl mb-2">🚧</div>
|
||||
<div className="text-sm font-medium text-slate-800">{feature}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Coming in Wave {wave}.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
frontend/src/pages/SettingsTab.tsx
Normal file
35
frontend/src/pages/SettingsTab.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Settings tab (Wave 1.D.1) — sub-nav + nested route outlet.
|
||||
*/
|
||||
import { Outlet, useParams } from 'react-router-dom';
|
||||
|
||||
import { SettingsSubnav } from '@/components/SettingsSubnav';
|
||||
|
||||
export function SettingsTab() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = Number(params.id);
|
||||
const items = [
|
||||
{ to: `/scenarios/${id}/settings`, label: 'Milestones', end: true },
|
||||
{ to: `/scenarios/${id}/settings/rates`, label: 'Rates' },
|
||||
{ to: `/scenarios/${id}/settings/dividends`, label: 'Dividends' },
|
||||
{ to: `/scenarios/${id}/settings/bonds`, label: 'Bonds' },
|
||||
{ to: `/scenarios/${id}/settings/tax`, label: 'Tax' },
|
||||
{ to: `/scenarios/${id}/settings/metrics`, label: 'Metrics' },
|
||||
{ to: `/scenarios/${id}/settings/other`, label: 'Other Settings' },
|
||||
{ to: `/scenarios/${id}/settings/notes`, label: 'Notes' },
|
||||
];
|
||||
|
||||
return (
|
||||
<section>
|
||||
<header className="mb-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
|
||||
</header>
|
||||
<div className="flex gap-6">
|
||||
<SettingsSubnav items={items} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
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