2026-05-09 21:48:36 +00:00
|
|
|
"""Net-worth read endpoints.
|
|
|
|
|
|
2026-05-28 09:04:58 +00:00
|
|
|
- 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).
|
2026-05-09 21:48:36 +00:00
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from collections import defaultdict
|
2026-05-28 09:04:58 +00:00
|
|
|
from collections.abc import Sequence
|
|
|
|
|
from datetime import date, timedelta
|
2026-05-09 21:48:36 +00:00
|
|
|
from decimal import Decimal
|
2026-05-28 09:04:58 +00:00
|
|
|
from typing import Any
|
2026-05-09 21:48:36 +00:00
|
|
|
|
2026-05-28 09:04:58 +00:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
|
|
|
from sqlalchemy import select, text
|
2026-05-09 21:48:36 +00:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
2026-05-27 18:21:12 +00:00
|
|
|
from fire_planner.api.dependencies import get_session, get_wf_sync_session
|
2026-05-09 21:48:36 +00:00
|
|
|
from fire_planner.api.schemas import (
|
|
|
|
|
AccountSnapshotOut,
|
|
|
|
|
NetWorthCurrent,
|
|
|
|
|
NetWorthHistory,
|
|
|
|
|
NetWorthHistoryPoint,
|
|
|
|
|
)
|
|
|
|
|
from fire_planner.db import AccountSnapshot
|
2026-05-27 18:21:12 +00:00
|
|
|
from fire_planner.ingest.wealthfolio import refresh_account_snapshots_if_stale
|
2026-05-09 21:48:36 +00:00
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/networth", tags=["networth"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("", response_model=NetWorthCurrent)
|
2026-05-27 18:21:12 +00:00
|
|
|
async def current_networth(
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
wf_sync: AsyncSession | None = Depends(get_wf_sync_session),
|
|
|
|
|
) -> NetWorthCurrent:
|
2026-05-09 21:48:36 +00:00
|
|
|
"""Latest snapshot per account + GBP total."""
|
2026-05-27 18:21:12 +00:00
|
|
|
await refresh_account_snapshots_if_stale(session, wf_sync)
|
2026-05-09 21:48:36 +00:00
|
|
|
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),
|
2026-05-27 18:21:12 +00:00
|
|
|
wf_sync: AsyncSession | None = Depends(get_wf_sync_session),
|
2026-05-28 09:04:58 +00:00
|
|
|
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."),
|
2026-05-09 21:48:36 +00:00
|
|
|
) -> NetWorthHistory:
|
|
|
|
|
"""Daily NW total + per-account breakdown for a stacked area chart.
|
|
|
|
|
|
2026-05-28 09:04:58 +00:00
|
|
|
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).
|
2026-05-09 21:48:36 +00:00
|
|
|
"""
|
2026-05-28 09:04:58 +00:00
|
|
|
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]:
|
2026-05-09 21:48:36 +00:00
|
|
|
rows = (await session.execute(
|
|
|
|
|
select(
|
|
|
|
|
AccountSnapshot.snapshot_date,
|
|
|
|
|
AccountSnapshot.account_name,
|
|
|
|
|
AccountSnapshot.market_value_gbp,
|
2026-05-28 09:04:58 +00:00
|
|
|
).where(AccountSnapshot.snapshot_date.between(window_start, window_end))
|
|
|
|
|
.order_by(AccountSnapshot.snapshot_date))).all()
|
|
|
|
|
return _group_by_date(rows)
|
2026-05-09 21:48:36 +00:00
|
|
|
|
2026-05-28 09:04:58 +00:00
|
|
|
|
|
|
|
|
def _group_by_date(rows: Sequence[Any]) -> list[NetWorthHistoryPoint]:
|
2026-05-09 21:48:36 +00:00
|
|
|
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))
|
2026-05-28 09:04:58 +00:00
|
|
|
return [
|
2026-05-09 21:48:36 +00:00
|
|
|
NetWorthHistoryPoint(
|
|
|
|
|
snapshot_date=d,
|
|
|
|
|
total_gbp=sum(by_date[d].values(), Decimal("0")),
|
|
|
|
|
by_account=dict(by_date[d]),
|
2026-05-28 09:04:58 +00:00
|
|
|
) for d in sorted(by_date.keys())
|
2026-05-09 21:48:36 +00:00
|
|
|
]
|