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