Implements the committed projections design (docs/plans/2026-05-28-wealth-
projections-{design,plan}.md): a collapsed "Projections" row on the wealth
dashboard with 5 template vars (rate_low/base/high, monthly_contribution=auto,
horizon_years=30), a multi-scenario projection panel (Low/Base/High + trailing-
3y historical line + a base-rate compounding-only line), 3 stat cards, and a
text panel with one-click future time-range links.
Projection is pure SQL over dav_corrected: compound + ordinary-annuity FV from
today's net worth; auto contribution = trailing-12mo run-rate (COALESCE/NULLIF
so $monthly_contribution=auto doesn't constant-fold 'auto'::numeric). Historical
rate = trailing-3-full-year geometric mean of per-year Modified-Dietz returns
(~10.4%) — all-time was a nonsense 83% because the all-accounts-complete window
is only ~4 months, and the true all-time geomean is skewed by 2021's +86%.
Also aligns "Net pay vs market gain — per month" to consecutive month-end
deltas (same fix as the other monthly panels). Verified all SQL live.
[ci skip]
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
7.6 KiB
Wealth Net-Worth Projections — Implementation Plan
Pairs with
2026-05-28-wealth-projections-design.md. Built 2026-06-01.
Goal: Add a collapsed "Projections" row to the wealth Grafana dashboard
(UID wealth) with a 30-year multi-scenario net-worth projection, driven by
pure SQL over the (now LOCF-fixed) dav_corrected view.
Architecture: Edit infra/stacks/monitoring/modules/monitoring/dashboards/wealth.json
via a one-off Python builder (reliable JSON construction). Add 6 template
variables + 1 collapsed row + projection panels. Deploy via targeted
scripts/tg apply of the dashboard ConfigMap; Grafana sidecar reloads.
Tech stack: Grafana 11.2 (schemaVersion 39), Postgres datasource wealth-pg.
Validated inputs (live, 2026-06-01)
nw0(net worth today) = £1,163,011 — latest-per-account SUM(total_value).- auto monthly contribution run-rate = £15,755/mo (trailing 12 complete months ÷ 12).
- Historical return = trailing-3-full-year geometric mean of per-year Modified-Dietz returns = 10.43%.
- FV math verified: n=0 = nw0 for every line; base@30y ≈ £27.3M, high ≈ £52.8M.
GOTCHA (why not "all-time CAGR")
The complete-days filter (all 7 accounts present) only reaches back to 2026-01-30 because the newest account is recent — so an "all-time CAGR over complete days" annualised a ~4-month window into a nonsense 83.71%. And the true all-time geomean (17.5%) is dominated by 2021's small-base +86% year and would dwarf a 30y chart. Decision (user, 2026-06-01): use the trailing-3-full-year geomean (~10.4%) — represents "current returns", chart-sane. Per-year MD returns reuse the existing "Yearly investment return %" methodology (each year uses its own first/last obs; no all-complete requirement).
Template variables (add to templating.list — dashboard has none today)
| name | type | default | hide |
|---|---|---|---|
rate_low |
textbox | 4 |
0 |
rate_base |
textbox | 7 |
0 |
rate_high |
textbox | 10 |
0 |
monthly_contribution |
textbox | auto |
0 |
horizon_years |
textbox | 30 |
0 |
hist_cagr |
query (datasource wealth-pg) | computed | 2 (hidden) |
hist_cagr query:
WITH active_count AS (SELECT COUNT(*) n FROM accounts), mc AS (SELECT MAX(valuation_date) d FROM (SELECT valuation_date, COUNT(*) c FROM dav_corrected GROUP BY valuation_date) x WHERE c >= (SELECT n FROM active_count)), yearly AS (SELECT EXTRACT(YEAR FROM valuation_date)::int yr, valuation_date, SUM(total_value) nw, SUM(net_contribution) contrib FROM dav_corrected WHERE valuation_date <= (SELECT d FROM mc) GROUP BY valuation_date), ep AS (SELECT yr, (array_agg(nw ORDER BY valuation_date))[1] nw_s, (array_agg(nw ORDER BY valuation_date DESC))[1] nw_e, (array_agg(contrib ORDER BY valuation_date))[1] c_s, (array_agg(contrib ORDER BY valuation_date DESC))[1] c_e, COUNT(*) days FROM yearly GROUP BY yr), r3 AS (SELECT (nw_e-nw_s-(c_e-c_s))/NULLIF(nw_s+0.5*(c_e-c_s),0) ret FROM ep WHERE (nw_s+0.5*(c_e-c_s))>0 AND days>=300 ORDER BY yr DESC LIMIT 3) SELECT ROUND((exp(avg(ln(1+ret)))-1)*100,2) FROM r3
Panels (new collapsed row "📈 Projections", at bottom, y=200)
- Text panel "How to view" with two dashboard links:
[Show projection range](?from=now-3y&to=now%2B30y)/[Reset](?from=now-180d&to=now). (h=3,w=24) - Stat row (h=4): NW today · Historical return (trailing 3y) ·
Monthly contribution (auto) · Projected NW @ base in
$horizon_yearsy. - Timeseries "Net worth —
$horizon_years-year projection" (h=12,w=24), two targets (A wide projection, B actual 3y tail). Field overrides: actual = solid; Low/Base/High/Historical = dashed; "Base, no new contributions" = dotted.
Panel 3 Target A (wide projection) — column aliases embed the rate for legends
WITH active_count AS (SELECT COUNT(*) n FROM accounts), mc AS (SELECT MAX(valuation_date) d FROM (SELECT valuation_date, COUNT(*) c FROM dav_corrected GROUP BY valuation_date) x WHERE c >= (SELECT n FROM active_count)), latest AS (SELECT DISTINCT ON (account_id) account_id, total_value, net_contribution FROM dav_corrected WHERE valuation_date <= (SELECT d FROM mc) ORDER BY account_id, valuation_date DESC), agg AS (SELECT SUM(total_value) nw0, SUM(net_contribution) c_now FROM latest), ago AS (SELECT SUM(x.nc) c_ago FROM latest l LEFT JOIN LATERAL (SELECT net_contribution nc FROM dav_corrected d WHERE d.account_id=l.account_id AND d.valuation_date <= (SELECT d FROM mc) - INTERVAL '12 months' ORDER BY d.valuation_date DESC LIMIT 1) x ON true), params AS (SELECT (SELECT nw0 FROM agg) nw0, CASE WHEN '$monthly_contribution'='auto' THEN ((SELECT c_now FROM agg)-(SELECT c_ago FROM ago))/12.0 ELSE '$monthly_contribution'::numeric END cm, ($rate_low::float)/100 rl, ($rate_base::float)/100 rb, ($rate_high::float)/100 rh, ($hist_cagr::float)/100 rhist), m AS (SELECT generate_series(0, ${horizon_years}*12) n) SELECT (now() + (m.n || ' months')::interval) AS "time", round((nw0*power(1+(power(1+rl,1/12.0)-1),m.n) + cm*((power(1+(power(1+rl,1/12.0)-1),m.n)-1)/NULLIF(power(1+rl,1/12.0)-1,0)))::numeric,0) AS "Low ($rate_low%)", round((nw0*power(1+(power(1+rb,1/12.0)-1),m.n) + cm*((power(1+(power(1+rb,1/12.0)-1),m.n)-1)/NULLIF(power(1+rb,1/12.0)-1,0)))::numeric,0) AS "Base ($rate_base%)", round((nw0*power(1+(power(1+rb,1/12.0)-1),m.n))::numeric,0) AS "Base, no new contributions", round((nw0*power(1+(power(1+rh,1/12.0)-1),m.n) + cm*((power(1+(power(1+rh,1/12.0)-1),m.n)-1)/NULLIF(power(1+rh,1/12.0)-1,0)))::numeric,0) AS "High ($rate_high%)", round((nw0*power(1+(power(1+rhist,1/12.0)-1),m.n) + cm*((power(1+(power(1+rhist,1/12.0)-1),m.n)-1)/NULLIF(power(1+rhist,1/12.0)-1,0)))::numeric,0) AS "Historical ($hist_cagr%)" FROM m, params
Panel 3 Target B (actual history, 3y tail)
WITH active_count AS (SELECT COUNT(*) n FROM accounts), mc AS (SELECT MAX(valuation_date) d FROM (SELECT valuation_date, COUNT(*) c FROM dav_corrected GROUP BY valuation_date) x WHERE c >= (SELECT n FROM active_count)) SELECT valuation_date::timestamp AS "time", SUM(total_value) AS "Net worth (actual)" FROM dav_corrected WHERE valuation_date <= (SELECT d FROM mc) AND valuation_date >= now()::date - INTERVAL '3 years' GROUP BY valuation_date ORDER BY valuation_date
Stat SQL
- NW today:
WITH latest AS (SELECT DISTINCT ON (account_id) total_value FROM dav_corrected d JOIN accounts a ON a.id=d.account_id ORDER BY account_id, valuation_date DESC) SELECT SUM(total_value) FROM latest - Historical return %:
SELECT $hist_cagr::float - Monthly contribution (auto): the
agg/agorun-rate((c_now)-(c_ago))/12.0 - Projected @ base: Target-A base formula evaluated at
n = $horizon_years*12
Build / deploy / verify
- Build: one-off Python script
/tmp/build_projection.py(outside repo) loads wealth.json, appends the 6 vars + row + panels, fixes the "Net pay vs market gain — per month" panel (#3) to month-end deltas, writes back. - Validate:
python -c json.load; unique panel ids; spot-run Target A/B against livewealth-pg. - Deploy:
scripts/tg apply -target=...grafana_dashboards["wealth.json"](targeted — monitoring stack has unrelated pre-existing drift). - Verify: ConfigMap carries new content; user expands the row, clicks
"Show projection range", confirms 5 projected lines flow from today + the
actual tail; toggles
$monthly_contribution=0 to see the contribution gap.
Scope notes
- Skip the optional "projected NW by year" table (YAGNI; add later if wanted).
- #3 ("Net pay vs market gain — per month") aligned to month-end deltas in the same build for monthly-market-gain consistency.
- Fidelity growth-timing cosmetic = NOT in scope (user deferred 2026-06-01).