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

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

Wave 1 (9 features across 4 streams):

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

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

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

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

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

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

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

View 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,
)

View 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()

View 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,
)

View file

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

View file

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

View file

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

View 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,
)

View file

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

View file

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

View 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

View file

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

View file

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

View file

@ -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[];
}

View 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 },
},
],
};
}

View file

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

View file

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

View 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>
);
}

View 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,
},
],
};
}

View 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)}%`;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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] ?? '🔔';
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

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

View 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>
);
}

View 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
View file

@ -0,0 +1,136 @@
"""Tests for the Cash Flow / Sankey endpoint."""
from __future__ import annotations
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from decimal import Decimal
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from fire_planner.api.dependencies import get_session
from fire_planner.app import app
from fire_planner.db import IncomeStream, McRun, ProjectionYearly, Scenario
@pytest_asyncio.fixture
async def client(engine: AsyncEngine,
session: AsyncSession) -> AsyncIterator[AsyncClient]:
factory = async_sessionmaker(engine, expire_on_commit=False)
async def _override() -> AsyncIterator[AsyncSession]:
async with factory() as s:
yield s
app.dependency_overrides[get_session] = _override
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
async def _seed(session: AsyncSession) -> int:
scen = Scenario(
external_id="user-cf",
kind="user",
name="Cashflow",
jurisdiction="uk",
strategy="trinity",
leave_uk_year=0,
glide_path="static",
spending_gbp=Decimal("60000"),
horizon_years=5,
nw_seed_gbp=Decimal("1000000"),
savings_per_year_gbp=Decimal("0"),
config_json={},
)
session.add(scen)
await session.commit()
await session.refresh(scen)
run = McRun(
scenario_id=scen.id,
run_at=datetime.now(UTC),
n_paths=10,
seed=1,
success_rate=Decimal("1"),
p10_ending_gbp=Decimal("0"),
p50_ending_gbp=Decimal("0"),
p90_ending_gbp=Decimal("0"),
median_lifetime_tax_gbp=Decimal("0"),
median_years_to_ruin=None,
elapsed_seconds=Decimal("0"),
)
session.add(run)
await session.commit()
await session.refresh(run)
yearly = [
ProjectionYearly(
mc_run_id=run.id,
year_idx=y,
p10_portfolio_gbp=Decimal("900000"),
p25_portfolio_gbp=Decimal("950000"),
p50_portfolio_gbp=Decimal(str(1_000_000 + y * 50_000)),
p75_portfolio_gbp=Decimal("1100000"),
p90_portfolio_gbp=Decimal("1200000"),
p50_withdrawal_gbp=Decimal("60000"),
p50_tax_gbp=Decimal("8000"),
survival_rate=Decimal("1"),
) for y in range(3)
]
session.add_all(yearly)
stream = IncomeStream(
scenario_id=scen.id,
kind="salary",
name="Day job",
start_year=0,
end_year=2,
amount_gbp_per_year=Decimal("80000"),
growth_pct=Decimal("0"),
tax_treatment="income",
enabled=True,
)
session.add(stream)
await session.commit()
return scen.id
async def test_cashflow_balances(client: AsyncClient, session: AsyncSession) -> None:
sid = await _seed(session)
resp = await client.get(f"/scenarios/{sid}/cashflow?year=1")
assert resp.status_code == 200, resp.text
body = resp.json()
sources_total = sum(Decimal(v) for v in body["sources"].values())
sinks_total = sum(Decimal(v) for v in body["sinks"].values())
assert sources_total == sinks_total
# Salary should appear as a source.
assert any(k.startswith("income:") for k in body["sources"])
# Spending and taxes are always sinks.
assert "spending" in body["sinks"]
assert "taxes" in body["sinks"]
async def test_cashflow_404_when_no_run(client: AsyncClient,
session: AsyncSession) -> None:
scen = Scenario(
external_id="user-no-run-cf",
kind="user",
name="No run cf",
jurisdiction="uk",
strategy="trinity",
leave_uk_year=0,
glide_path="static",
spending_gbp=Decimal("60000"),
horizon_years=5,
nw_seed_gbp=Decimal("1000000"),
savings_per_year_gbp=Decimal("0"),
config_json={},
)
session.add(scen)
await session.commit()
await session.refresh(scen)
resp = await client.get(f"/scenarios/{scen.id}/cashflow?year=0")
assert resp.status_code == 404

158
tests/test_api_progress.py Normal file
View file

@ -0,0 +1,158 @@
"""Tests for the Progress overlay endpoint."""
from __future__ import annotations
from collections.abc import AsyncIterator
from datetime import UTC, date, datetime
from decimal import Decimal
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from fire_planner.api.dependencies import get_session
from fire_planner.app import app
from fire_planner.db import (
AccountSnapshot,
McRun,
ProjectionYearly,
Scenario,
)
@pytest_asyncio.fixture
async def client(engine: AsyncEngine,
session: AsyncSession) -> AsyncIterator[AsyncClient]:
factory = async_sessionmaker(engine, expire_on_commit=False)
async def _override() -> AsyncIterator[AsyncSession]:
async with factory() as s:
yield s
app.dependency_overrides[get_session] = _override
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
async def _seed_full(session: AsyncSession) -> int:
scen = Scenario(
external_id="user-prog",
kind="user",
name="Progress test",
jurisdiction="uk",
strategy="trinity",
leave_uk_year=0,
glide_path="static",
spending_gbp=Decimal("60000"),
horizon_years=5,
nw_seed_gbp=Decimal("1000000"),
savings_per_year_gbp=Decimal("0"),
config_json={},
)
session.add(scen)
await session.commit()
await session.refresh(scen)
run = McRun(
scenario_id=scen.id,
run_at=datetime.now(UTC),
n_paths=100,
seed=1,
success_rate=Decimal("1"),
p10_ending_gbp=Decimal("0"),
p50_ending_gbp=Decimal("0"),
p90_ending_gbp=Decimal("0"),
median_lifetime_tax_gbp=Decimal("0"),
median_years_to_ruin=None,
elapsed_seconds=Decimal("0"),
)
session.add(run)
await session.commit()
await session.refresh(run)
yearly = [
ProjectionYearly(
mc_run_id=run.id,
year_idx=y,
p10_portfolio_gbp=Decimal("900000"),
p25_portfolio_gbp=Decimal("950000"),
p50_portfolio_gbp=Decimal(str(1_000_000 + y * 50_000)),
p75_portfolio_gbp=Decimal("1100000"),
p90_portfolio_gbp=Decimal("1200000"),
p50_withdrawal_gbp=Decimal("60000"),
p50_tax_gbp=Decimal("8000"),
survival_rate=Decimal("1"),
) for y in range(3)
]
session.add_all(yearly)
# Two snapshots a year apart
snap_a = AccountSnapshot(
external_id="wf:a:2024-01-01",
snapshot_date=date(2024, 1, 1),
account_id="a",
account_name="Stocks",
account_type="brokerage",
currency="GBP",
market_value=Decimal("1000000"),
market_value_gbp=Decimal("1000000"),
)
snap_b = AccountSnapshot(
external_id="wf:a:2025-01-01",
snapshot_date=date(2025, 1, 1),
account_id="a",
account_name="Stocks",
account_type="brokerage",
currency="GBP",
market_value=Decimal("1080000"),
market_value_gbp=Decimal("1080000"),
)
session.add_all([snap_a, snap_b])
await session.commit()
return scen.id
async def test_progress_returns_actual_and_projected(
client: AsyncClient,
session: AsyncSession,
) -> None:
sid = await _seed_full(session)
resp = await client.get(f"/scenarios/{sid}/progress")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["scenario_id"] == sid
assert body["alignment_anchor"] == "2024-01-01"
assert len(body["actual"]) == 2
assert len(body["projected"]) == 3
# year_idx 1 has actual £1.08M vs projected £1.05M → +30k variance.
variance_y1 = next(v for v in body["variance"] if v["year_idx"] == 1)
assert Decimal(variance_y1["delta_gbp"]) == Decimal("30000.00")
async def test_progress_handles_empty_snapshots(
client: AsyncClient,
session: AsyncSession,
) -> None:
scen = Scenario(
external_id="user-empty",
kind="user",
name="No snapshots",
jurisdiction="uk",
strategy="trinity",
leave_uk_year=0,
glide_path="static",
spending_gbp=Decimal("60000"),
horizon_years=5,
nw_seed_gbp=Decimal("1000000"),
savings_per_year_gbp=Decimal("0"),
config_json={},
)
session.add(scen)
await session.commit()
await session.refresh(scen)
resp = await client.get(f"/scenarios/{scen.id}/progress")
assert resp.status_code == 200
body = resp.json()
assert body["actual"] == []
assert body["variance"] == []

View file

@ -0,0 +1,124 @@
"""Tests for the per-year stats endpoint."""
from __future__ import annotations
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from decimal import Decimal
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from fire_planner.api.dependencies import get_session
from fire_planner.app import app
from fire_planner.db import McRun, ProjectionYearly, Scenario
@pytest_asyncio.fixture
async def client(engine: AsyncEngine,
session: AsyncSession) -> AsyncIterator[AsyncClient]:
factory = async_sessionmaker(engine, expire_on_commit=False)
async def _override() -> AsyncIterator[AsyncSession]:
async with factory() as s:
yield s
app.dependency_overrides[get_session] = _override
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
async def _seed_scenario_with_run(session: AsyncSession) -> int:
scen = Scenario(
external_id="user-yr-stats",
kind="user",
name="Yr Stats",
jurisdiction="uk",
strategy="trinity",
leave_uk_year=0,
glide_path="static",
spending_gbp=Decimal("60000"),
horizon_years=5,
nw_seed_gbp=Decimal("1000000"),
savings_per_year_gbp=Decimal("0"),
config_json={},
)
session.add(scen)
await session.commit()
await session.refresh(scen)
run = McRun(
scenario_id=scen.id,
run_at=datetime.now(UTC),
n_paths=100,
seed=1,
success_rate=Decimal("1"),
p10_ending_gbp=Decimal("900000"),
p50_ending_gbp=Decimal("1100000"),
p90_ending_gbp=Decimal("1300000"),
median_lifetime_tax_gbp=Decimal("50000"),
median_years_to_ruin=None,
elapsed_seconds=Decimal("1.234"),
)
session.add(run)
await session.commit()
await session.refresh(run)
rows = [
ProjectionYearly(
mc_run_id=run.id,
year_idx=y,
p10_portfolio_gbp=Decimal("900000"),
p25_portfolio_gbp=Decimal("950000"),
p50_portfolio_gbp=Decimal(str(1_000_000 + y * 50_000)),
p75_portfolio_gbp=Decimal("1100000"),
p90_portfolio_gbp=Decimal("1200000"),
p50_withdrawal_gbp=Decimal("60000"),
p50_tax_gbp=Decimal("8000"),
survival_rate=Decimal("1.0"),
) for y in range(5)
]
session.add_all(rows)
await session.commit()
return scen.id
async def test_year_stats_returns_per_year_metrics(
client: AsyncClient,
session: AsyncSession,
) -> None:
sid = await _seed_scenario_with_run(session)
resp = await client.get(f"/scenarios/{sid}/year-stats?year=2")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["year_idx"] == 2
# year 2 NW = 1_100_000; year 1 NW = 1_050_000 → change 50_000.
assert body["net_worth_p50"] == "1100000.00"
assert body["change_in_nw"] == "50000.00"
assert body["spending"] == "60000.00"
assert body["taxes"] == "8000.00"
async def test_year_stats_404_when_no_run(client: AsyncClient,
session: AsyncSession) -> None:
scen = Scenario(
external_id="user-no-run",
kind="user",
name="No run",
jurisdiction="uk",
strategy="trinity",
leave_uk_year=0,
glide_path="static",
spending_gbp=Decimal("60000"),
horizon_years=5,
nw_seed_gbp=Decimal("1000000"),
savings_per_year_gbp=Decimal("0"),
config_json={},
)
session.add(scen)
await session.commit()
await session.refresh(scen)
resp = await client.get(f"/scenarios/{scen.id}/year-stats?year=0")
assert resp.status_code == 404

126
tests/test_goals_eval.py Normal file
View file

@ -0,0 +1,126 @@
"""Tests for fire_planner.goals_eval — parametrised over goal kinds."""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
import numpy as np
import pytest
from fire_planner.goals_eval import evaluate_goals
from fire_planner.simulator import SimulationResult
@dataclass
class _Goal:
kind: str
name: str
target_amount_gbp: Decimal | None = None
target_year: int | None = None
comparator: str = ">="
success_threshold: Decimal = Decimal("0.95")
enabled: bool = True
def _make_result(
portfolio_paths: list[list[float]],
withdrawal_paths: list[list[float]] | None = None,
) -> SimulationResult:
"""Build a SimulationResult from explicit per-path arrays."""
portfolio = np.asarray(portfolio_paths, dtype=np.float64)
n_paths, ncols = portfolio.shape
n_years = ncols - 1
if withdrawal_paths is None:
wd = np.zeros((n_paths, n_years), dtype=np.float64)
else:
wd = np.asarray(withdrawal_paths, dtype=np.float64)
tax = np.zeros((n_paths, n_years), dtype=np.float64)
success_mask = portfolio[:, 1:-1].min(axis=1) > 0.0 if ncols >= 3 else np.ones(
n_paths, dtype=bool)
return SimulationResult(
portfolio_real=portfolio,
withdrawal_real=wd,
tax_real=tax,
success_mask=success_mask,
)
def test_target_nw_by_year_exact_count() -> None:
# 4 paths, 3 years. At year 2: [200, 1500, 2500, 3000]. Target ≥ £2M
# → 2/4 hit → probability 0.5.
portfolio = [
[1000, 500, 200, 100],
[1000, 1200, 1500, 1700],
[1000, 2000, 2500, 2800],
[1000, 2400, 3000, 3500],
]
result = _make_result(portfolio)
goal = _Goal(kind="target_nw_by_year",
name="≥ £2M at y2",
target_amount_gbp=Decimal("2000"),
target_year=2,
comparator=">=",
success_threshold=Decimal("0.4"))
[eval_] = evaluate_goals(result, [goal])
assert eval_.probability == pytest.approx(0.5)
assert eval_.passed is True
def test_never_run_out_full_horizon() -> None:
# 4 paths over 4 years. Path 0 hits 0 at year 2. Path 1 hits 0 at
# year 3. Path 2 + 3 stay positive throughout.
portfolio = [
[1000, 500, 0, 0, 0],
[1000, 800, 600, 0, 0],
[1000, 900, 800, 700, 600],
[1000, 1100, 1200, 1300, 1400],
]
result = _make_result(portfolio)
goal = _Goal(kind="never_run_out",
name="don't ruin",
target_year=None,
success_threshold=Decimal("0.5"))
[eval_] = evaluate_goals(result, [goal])
assert eval_.probability == pytest.approx(0.5)
assert eval_.passed is True
def test_target_real_income_uses_path_median() -> None:
portfolio = [
[1000, 1000, 1000],
[1000, 1000, 1000],
[1000, 1000, 1000],
]
withdrawals = [
[40_000, 40_000],
[60_000, 60_000],
[80_000, 80_000],
]
result = _make_result(portfolio, withdrawals)
goal = _Goal(kind="target_real_income",
name="≥ £50k income",
target_amount_gbp=Decimal("50000"),
target_year=0,
comparator=">=",
success_threshold=Decimal("0.5"))
[eval_] = evaluate_goals(result, [goal])
assert eval_.probability == pytest.approx(2 / 3)
assert eval_.passed is True
def test_disabled_goals_skipped() -> None:
portfolio = [[1000, 500, 0]]
result = _make_result(portfolio)
enabled = _Goal(kind="never_run_out", name="active")
disabled = _Goal(kind="never_run_out", name="muted", enabled=False)
evals = evaluate_goals(result, [enabled, disabled])
assert [e.name for e in evals] == ["active"]
def test_unknown_kind_returns_zero() -> None:
portfolio = [[1000, 1500, 2000]]
result = _make_result(portfolio)
goal = _Goal(kind="not_implemented", name="???")
[eval_] = evaluate_goals(result, [goal])
assert eval_.probability == 0.0
assert eval_.passed is False

View file

@ -0,0 +1,228 @@
"""Tests for income-stream CRUD + simulator integration."""
from __future__ import annotations
from collections.abc import AsyncIterator
from decimal import Decimal
import numpy as np
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from fire_planner.api.dependencies import get_session
from fire_planner.api.schemas import IncomeStreamInput, SimulateRequest
from fire_planner.api.simulate import _project
from fire_planner.app import app
from fire_planner.db import Scenario
from fire_planner.income_streams import (
IncomeStreamInput as EngineIncomeStream,
)
from fire_planner.income_streams import streams_to_arrays
@pytest_asyncio.fixture
async def client(engine: AsyncEngine,
session: AsyncSession) -> AsyncIterator[AsyncClient]:
factory = async_sessionmaker(engine, expire_on_commit=False)
async def _override() -> AsyncIterator[AsyncSession]:
async with factory() as s:
yield s
app.dependency_overrides[get_session] = _override
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
async def _seed_scenario(session: AsyncSession) -> int:
scen = Scenario(
external_id="user-host",
kind="user",
name="Host plan",
jurisdiction="uk",
strategy="trinity",
leave_uk_year=0,
glide_path="static",
spending_gbp=Decimal("60000"),
nw_seed_gbp=Decimal("1000000"),
savings_per_year_gbp=Decimal("0"),
config_json={},
)
session.add(scen)
await session.commit()
await session.refresh(scen)
return scen.id
# ── streams_to_arrays unit tests ─────────────────────────────────────
def test_streams_to_arrays_with_growth() -> None:
streams = [
EngineIncomeStream(
kind="salary",
start_year=0,
end_year=2,
amount_gbp_per_year=10_000,
growth_pct=0.05,
tax_treatment="income",
enabled=True,
),
]
inflows, taxable = streams_to_arrays(streams, horizon_years=5)
# year 0: 10_000; year 1: 10_500; year 2: 11_025; years 3+: 0
assert inflows[0] == 10_000.0
assert inflows[1] == 10_500.0
assert inflows[2] == 11_025.0
assert inflows[3] == 0.0
# Income-treated streams add to taxable.
assert taxable[0] == inflows[0]
def test_streams_to_arrays_tax_free_excluded_from_taxable() -> None:
streams = [
EngineIncomeStream(
kind="dividend",
start_year=0,
end_year=0,
amount_gbp_per_year=5_000,
tax_treatment="tax_free",
enabled=True,
),
]
inflows, taxable = streams_to_arrays(streams, horizon_years=2)
assert inflows[0] == 5_000.0
assert taxable[0] == 0.0
def test_streams_to_arrays_disabled_skipped() -> None:
streams = [
EngineIncomeStream(
kind="salary",
amount_gbp_per_year=10_000,
enabled=False,
),
]
inflows, taxable = streams_to_arrays(streams, horizon_years=2)
assert inflows.sum() == 0.0
assert taxable.sum() == 0.0
# ── CRUD endpoint tests ──────────────────────────────────────────────
async def test_create_and_list_income_streams(
client: AsyncClient,
session: AsyncSession,
) -> None:
sid = await _seed_scenario(session)
create = await client.post(
f"/scenarios/{sid}/income-streams",
json={
"kind": "salary",
"name": "Day job",
"start_year": 0,
"end_year": 10,
"amount_gbp_per_year": "60000",
"growth_pct": "0.02",
"tax_treatment": "income",
},
)
assert create.status_code == 201
payload = create.json()
assert payload["name"] == "Day job"
assert payload["scenario_id"] == sid
listed = await client.get(f"/scenarios/{sid}/income-streams")
assert listed.status_code == 200
rows = listed.json()
assert len(rows) == 1
assert rows[0]["kind"] == "salary"
async def test_patch_and_delete_income_stream(
client: AsyncClient,
session: AsyncSession,
) -> None:
sid = await _seed_scenario(session)
create = await client.post(
f"/scenarios/{sid}/income-streams",
json={
"kind": "rental",
"name": "Flat 2",
"amount_gbp_per_year": "12000",
},
)
stream_id = create.json()["id"]
patch = await client.patch(
f"/income-streams/{stream_id}",
json={"amount_gbp_per_year": "15000"},
)
assert patch.status_code == 200
assert patch.json()["amount_gbp_per_year"] == "15000.00"
del_resp = await client.delete(f"/income-streams/{stream_id}")
assert del_resp.status_code == 204
listed = await client.get(f"/scenarios/{sid}/income-streams")
assert listed.json() == []
async def test_invalid_year_range_rejected(
client: AsyncClient,
session: AsyncSession,
) -> None:
sid = await _seed_scenario(session)
bad = await client.post(
f"/scenarios/{sid}/income-streams",
json={
"kind": "other",
"name": "Backwards",
"start_year": 5,
"end_year": 2,
"amount_gbp_per_year": "1000",
},
)
assert bad.status_code == 400
# ── simulate integration: a £50k stream year 5-15 lifts median NW ────
def test_simulate_with_income_stream_lifts_median() -> None:
paths = np.zeros((100, 30, 3), dtype=np.float64)
paths[:, :, 0] = 0.07 # nominal stocks
paths[:, :, 1] = 0.03 # nominal bonds
paths[:, :, 2] = 0.02 # cpi
base_req = SimulateRequest(
jurisdiction="uae",
strategy="trinity",
leave_uk_year=0,
spending_gbp=Decimal("20000"),
nw_seed_gbp=Decimal("2000000"),
horizon_years=30,
n_paths=100,
seed=1,
rates_mode=None,
)
req_with = base_req.model_copy(update={
"income_streams": [
IncomeStreamInput(
kind="dividend",
start_year=5,
end_year=15,
amount_gbp_per_year=Decimal("50000"),
growth_pct=Decimal("0.02"),
tax_treatment="tax_free",
),
],
})
base_result, _ = _project(base_req, paths)
with_result, _ = _project(req_with, paths)
base_median_end = float(np.median(base_result.portfolio_real[:, -1]))
with_median_end = float(np.median(with_result.portfolio_real[:, -1]))
assert with_median_end > base_median_end

View file

@ -0,0 +1,56 @@
"""Verify the Settings → Rates fixed-mode arithmetic.
For 100% stocks with growth=6% and dividend=2.5%, inflation=3%, the
expected real return per year is ``(1 + 0.06 + 0.025) / 1.03 - 1``
0.0534. We assert the simulator's portfolio compounds at this rate
in the absence of withdrawals (spending=0, no strategy draw).
"""
from __future__ import annotations
from decimal import Decimal
import numpy as np
import pytest
from fire_planner.api.schemas import SimulateRequest
from fire_planner.api.simulate import _build_paths, _project
@pytest.mark.asyncio
async def test_fixed_rates_real_return_arithmetic() -> None:
req = SimulateRequest(
jurisdiction="uae", # 0% tax to isolate compounding
strategy="trinity",
leave_uk_year=0,
spending_gbp=Decimal("1"),
nw_seed_gbp=Decimal("100000"),
horizon_years=30,
n_paths=100,
rates_mode="fixed",
inflation_pct=Decimal("0.03"),
stocks_growth_pct=Decimal("0.06"),
stocks_dividend_pct=Decimal("0.025"),
bonds_growth_pct=Decimal("0.015"),
bonds_dividend_pct=Decimal("0.035"),
stocks_allocation=Decimal("1"),
)
paths = await _build_paths(req)
assert paths.shape == (100, 30, 3)
# nominal stock return embedded should be growth + dividend = 0.085
assert paths[0, 0, 0] == pytest.approx(0.085)
assert paths[0, 0, 1] == pytest.approx(0.05)
assert paths[0, 0, 2] == pytest.approx(0.03)
expected_real = (1 + 0.06 + 0.025) / (1 + 0.03) - 1
assert expected_real == pytest.approx(0.0534, abs=1e-3)
result, _ = _project(req, paths)
# All paths identical → median == any single path. After 30 years of
# compounding 0.0534 with the trinity 4% draw, ending NW lies in a
# well-defined window.
end_real = float(np.median(result.portfolio_real[:, -1]))
assert end_real > 100_000 # grew despite the £1/y withdrawal
growth_factor = end_real / 100_000.0
expected_factor = (1 + expected_real)**30
# Loose because trinity strategy still draws something each year.
assert growth_factor == pytest.approx(expected_factor, rel=0.05)