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