fire-planner: filterable date range on the home-page history chart
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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.
This commit is contained in:
parent
4da58fe56e
commit
4a0ef1faf6
5 changed files with 222 additions and 36 deletions
|
|
@ -1,19 +1,25 @@
|
|||
"""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
|
||||
- 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 datetime import date
|
||||
from collections.abc import Sequence
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
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
|
||||
|
|
@ -53,34 +59,89 @@ async def current_networth(
|
|||
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."),
|
||||
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.
|
||||
|
||||
Picks one row per (account_id, snapshot_date) — wealthfolio ingest
|
||||
upserts daily so this is already de-duped, but we group defensively.
|
||||
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).
|
||||
"""
|
||||
await refresh_account_snapshots_if_stale(session, wf_sync)
|
||||
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,
|
||||
).order_by(AccountSnapshot.snapshot_date))).all()
|
||||
if not rows:
|
||||
return NetWorthHistory(points=[])
|
||||
).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))
|
||||
|
||||
cutoff_idx = max(0, len(by_date) - days)
|
||||
sorted_dates = sorted(by_date.keys())[cutoff_idx:]
|
||||
points = [
|
||||
return [
|
||||
NetWorthHistoryPoint(
|
||||
snapshot_date=d,
|
||||
total_gbp=sum(by_date[d].values(), Decimal("0")),
|
||||
by_account=dict(by_date[d]),
|
||||
) for d in sorted_dates
|
||||
) for d in sorted(by_date.keys())
|
||||
]
|
||||
return NetWorthHistory(points=points)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue