fire-planner/fire_planner/api/networth.py
Viktor Barzin 4a0ef1faf6
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
fire-planner: filterable date range on the home-page history chart
User asked for a manual date range on the Dashboard chart instead of
the hard-coded 12-month window.

Backend (/networth/history):
- Read LIVE from wf_sync's daily_account_valuation JOIN accounts so the
  chart spans the broker's full daily series. The account_snapshot
  cache is only the latest snapshot — never had >2 daily points for
  charting. Falls back to the cache when wf_sync isn't wired (tests).
- Accept `from=YYYY-MM-DD` and `to=YYYY-MM-DD` query params. When `from`
  is set, the window is [from, to or today] (inclusive). Otherwise the
  legacy `days` look-back still applies. 422 when from > to.

Frontend:
- New HistoryRangePicker component: preset buttons (1m / 3m / 6m / 1y /
  3y / All) plus two date inputs for an explicit custom range.
- Dashboard wires the picker to the chart via react-query keyed on the
  selected range, so the chart re-fetches on change.

Tests:
- Renamed `respects_days_filter` → `respects_from_to_filter` and added
  inverted-window rejection. Old test asserted "days=1 returns 1 point"
  which only worked when 'today' was within the seed window — the new
  windowing is correct and explicit.

271 → 272 tests passing.
2026-05-28 09:04:58 +00:00

147 lines
5.9 KiB
Python

"""Net-worth read endpoints.
- GET /networth → latest snapshot per account, totals.
Backed by the `fire_planner.account_snapshot` cache, lazily refreshed
from the wealthfolio_sync PG mirror on each request when older than
`NETWORTH_CACHE_TTL_DAYS` (default 1).
- GET /networth/history → daily NW totals + per-account series.
Reads LIVE from wealthfolio_sync's `daily_account_valuation` table so
the chart always reflects the full daily series (the cache only holds
the latest snapshot). Falls back to `account_snapshot` when wf_sync is
unavailable (tests, dev).
"""
from __future__ import annotations
from collections import defaultdict
from collections.abc import Sequence
from datetime import date, timedelta
from decimal import Decimal
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, text
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 in days."),
from_date: date | None = Query(default=None, alias="from",
description="Inclusive start date (YYYY-MM-DD). "
"Overrides `days` when set together with `to`."),
to_date: date | None = Query(default=None, alias="to",
description="Inclusive end date (YYYY-MM-DD). "
"Defaults to today when only `from` is set."),
) -> NetWorthHistory:
"""Daily NW total + per-account breakdown for a stacked area chart.
Window selection:
- If `from` is set, use [from, to or today].
- Otherwise use the last `days` days ending today.
Reads live from wf_sync's `daily_account_valuation` JOIN `accounts`
so the chart spans the broker's full daily series. Falls back to the
`account_snapshot` cache only when wf_sync isn't wired (tests, dev).
"""
today = date.today()
if from_date is not None:
if to_date is None:
to_date = today
if from_date > to_date:
raise HTTPException(status_code=422, detail="`from` must be on or before `to`")
window_start, window_end = from_date, to_date
else:
window_end = to_date or today
window_start = window_end - timedelta(days=max(days - 1, 0))
points = await _history_from_wf_sync(wf_sync, window_start, window_end)
if points is None:
# wf_sync not wired — fall back to the cache. Useful for tests
# and for environments without the mirror.
points = await _history_from_cache(session, window_start, window_end)
return NetWorthHistory(points=points)
async def _history_from_wf_sync(
wf_sync: AsyncSession | None,
window_start: date,
window_end: date,
) -> list[NetWorthHistoryPoint] | None:
if wf_sync is None:
return None
rows = (await wf_sync.execute(
text("""
SELECT d.valuation_date,
a.name,
d.total_value * COALESCE(d.fx_rate_to_base, 1.0) AS market_value_gbp
FROM daily_account_valuation d
JOIN accounts a ON a.id = d.account_id
WHERE d.valuation_date BETWEEN :start AND :end
ORDER BY d.valuation_date
"""),
{"start": window_start, "end": window_end})).all()
if not rows:
return []
return _group_by_date(rows)
async def _history_from_cache(
session: AsyncSession,
window_start: date,
window_end: date,
) -> list[NetWorthHistoryPoint]:
rows = (await session.execute(
select(
AccountSnapshot.snapshot_date,
AccountSnapshot.account_name,
AccountSnapshot.market_value_gbp,
).where(AccountSnapshot.snapshot_date.between(window_start, window_end))
.order_by(AccountSnapshot.snapshot_date))).all()
return _group_by_date(rows)
def _group_by_date(rows: Sequence[Any]) -> list[NetWorthHistoryPoint]:
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))
return [
NetWorthHistoryPoint(
snapshot_date=d,
total_gbp=sum(by_date[d].values(), Decimal("0")),
by_account=dict(by_date[d]),
) for d in sorted(by_date.keys())
]