fire-planner/fire_planner/api/progress.py
Viktor Barzin 4da58fe56e
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fire-planner: lazy-refresh /networth from wf_sync (default TTL 1d)
The account_snapshot cache fed /networth, /networth/history, and
/scenarios/{id}/progress. No CronJob populated it, so the cache had
drifted ~18 days behind the wealthfolio_sync mirror (last refresh
2026-05-09 via manual kubectl exec; Grafana reads wf_sync directly
and stayed fresh).

Switch to lazy refresh on read: each request to those endpoints now
checks MAX(account_snapshot.snapshot_date) — if it's older than
NETWORTH_CACHE_TTL_DAYS (default 1), pull fresh rows from wf_sync via
read_account_snapshots_from_pg and upsert. Idempotent under
concurrency (existing ON CONFLICT DO UPDATE).

Plumbing:
- Add get_wf_sync_session dependency that yields None when the wf_sync
  factory isn't wired (keeps existing tests' behaviour: no refresh
  attempted, they continue to seed account_snapshot directly).
- Wire wf_sync engine + session_factory in app.lifespan when
  WEALTHFOLIO_SYNC_DB_CONNECTION_STRING is set.
- Centralise the staleness check in refresh_account_snapshots_if_stale.

Tests:
- 271 existing tests still green.
- Three new tests in test_api_networth_refresh.py covering: empty cache
  triggers refresh, stale cache triggers refresh, fresh cache skips
  refresh (asserts the wf_sync value is NOT served).
2026-05-27 18:21:12 +00:00

107 lines
3.9 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, get_wf_sync_session
from fire_planner.api.schemas import (
ProgressActualPoint,
ProgressProjectedPoint,
ProgressResponse,
ProgressVariancePoint,
)
from fire_planner.db import AccountSnapshot, McRun, ProjectionYearly, Scenario
from fire_planner.ingest.wealthfolio import refresh_account_snapshots_if_stale
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),
wf_sync: AsyncSession | None = Depends(get_wf_sync_session),
) -> ProgressResponse:
scen = await session.get(Scenario, scenario_id)
if scen is None:
raise HTTPException(status_code=404, detail="Scenario not found")
await refresh_account_snapshots_if_stale(session, wf_sync)
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,
)