fire-planner/fire_planner/api/progress.py
Viktor Barzin 9cc781a8d6
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fire-planner: ProjectionLab parity Wave 1 — tabbed shell, year stats, goals,
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.
2026-05-10 12:49:44 +00:00

104 lines
3.6 KiB
Python

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