wealth: add 30y net-worth projection row + align net-pay panel
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>
This commit is contained in:
parent
32e1042ca8
commit
f364399ede
2 changed files with 556 additions and 39 deletions
99
docs/plans/2026-05-28-wealth-projections-plan.md
Normal file
99
docs/plans/2026-05-28-wealth-projections-plan.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# 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:
|
||||
```sql
|
||||
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)
|
||||
|
||||
1. **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)
|
||||
2. **Stat row** (h=4): NW today · Historical return (trailing 3y) ·
|
||||
Monthly contribution (auto) · Projected NW @ base in `$horizon_years`y.
|
||||
3. **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
|
||||
```sql
|
||||
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)
|
||||
```sql
|
||||
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`/`ago` run-rate `((c_now)-(c_ago))/12.0`
|
||||
- Projected @ base: Target-A base formula evaluated at `n = $horizon_years*12`
|
||||
|
||||
## Build / deploy / verify
|
||||
|
||||
1. **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.
|
||||
2. **Validate:** `python -c json.load`; unique panel ids; spot-run Target A/B
|
||||
against live `wealth-pg`.
|
||||
3. **Deploy:** `scripts/tg apply -target=...grafana_dashboards["wealth.json"]`
|
||||
(targeted — monitoring stack has unrelated pre-existing drift).
|
||||
4. **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).
|
||||
Loading…
Add table
Add a link
Reference in a new issue