fire-planner/fire_planner/api/networth.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

86 lines
3.4 KiB
Python

"""Net-worth read endpoints.
Reads from `fire_planner.account_snapshot`, which is lazily refreshed
from the wealthfolio_sync PG mirror on each request when older than
`NETWORTH_CACHE_TTL_DAYS` (default 1). Two views:
- GET /networth → latest snapshot per account, totals
- GET /networth/history → daily totals + per-account series, for charts
"""
from __future__ import annotations
from collections import defaultdict
from datetime import date
from decimal import Decimal
from fastapi import APIRouter, Depends, Query
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 (
AccountSnapshotOut,
NetWorthCurrent,
NetWorthHistory,
NetWorthHistoryPoint,
)
from fire_planner.db import AccountSnapshot
from fire_planner.ingest.wealthfolio import refresh_account_snapshots_if_stale
router = APIRouter(prefix="/networth", tags=["networth"])
@router.get("", response_model=NetWorthCurrent)
async def current_networth(
session: AsyncSession = Depends(get_session),
wf_sync: AsyncSession | None = Depends(get_wf_sync_session),
) -> NetWorthCurrent:
"""Latest snapshot per account + GBP total."""
await refresh_account_snapshots_if_stale(session, wf_sync)
latest_date = (await session.execute(
select(AccountSnapshot.snapshot_date).order_by(
AccountSnapshot.snapshot_date.desc()).limit(1))).scalar()
if latest_date is None:
return NetWorthCurrent(snapshot_date=date.today(), total_gbp=Decimal("0"), accounts=[])
rows = (await session.execute(
select(AccountSnapshot).where(
AccountSnapshot.snapshot_date == latest_date))).scalars().all()
accounts = [AccountSnapshotOut.model_validate(r) for r in rows]
total = sum((a.market_value_gbp for a in accounts), Decimal("0"))
return NetWorthCurrent(snapshot_date=latest_date, total_gbp=total, accounts=accounts)
@router.get("/history", response_model=NetWorthHistory)
async def networth_history(
session: AsyncSession = Depends(get_session),
wf_sync: AsyncSession | None = Depends(get_wf_sync_session),
days: int = Query(default=365, ge=1, le=3650, description="Look-back window."),
) -> NetWorthHistory:
"""Daily NW total + per-account breakdown for a stacked area chart.
Picks one row per (account_id, snapshot_date) — wealthfolio ingest
upserts daily so this is already de-duped, but we group defensively.
"""
await refresh_account_snapshots_if_stale(session, wf_sync)
rows = (await session.execute(
select(
AccountSnapshot.snapshot_date,
AccountSnapshot.account_name,
AccountSnapshot.market_value_gbp,
).order_by(AccountSnapshot.snapshot_date))).all()
if not rows:
return NetWorthHistory(points=[])
by_date: dict[date, dict[str, Decimal]] = defaultdict(lambda: defaultdict(lambda: Decimal("0")))
for snap_date, name, value in rows:
by_date[snap_date][name] += Decimal(str(value))
cutoff_idx = max(0, len(by_date) - days)
sorted_dates = sorted(by_date.keys())[cutoff_idx:]
points = [
NetWorthHistoryPoint(
snapshot_date=d,
total_gbp=sum(by_date[d].values(), Decimal("0")),
by_account=dict(by_date[d]),
) for d in sorted_dates
]
return NetWorthHistory(points=points)