diff --git a/docs/plans/2026-05-28-wealth-projections-plan.md b/docs/plans/2026-05-28-wealth-projections-plan.md new file mode 100644 index 00000000..2fdc019f --- /dev/null +++ b/docs/plans/2026-05-28-wealth-projections-plan.md @@ -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). diff --git a/stacks/monitoring/modules/monitoring/dashboards/wealth.json b/stacks/monitoring/modules/monitoring/dashboards/wealth.json index 9b50a106..7e646e8f 100644 --- a/stacks/monitoring/modules/monitoring/dashboards/wealth.json +++ b/stacks/monitoring/modules/monitoring/dashboards/wealth.json @@ -27,12 +27,12 @@ "editorMode": "code", "format": "table", "refId": "Anno", - "rawSql": "WITH daily AS (SELECT d.valuation_date, SUM(d.total_value) AS nw FROM daily_account_valuation d JOIN accounts a ON a.id = d.account_id GROUP BY d.valuation_date), crossings AS (SELECT t, (SELECT MIN(valuation_date) FROM daily WHERE nw >= t::numeric) AS d FROM unnest(ARRAY[100000, 250000, 500000, 750000, 1000000]) AS t) SELECT d::timestamptz AS time, '£' || CASE WHEN t >= 1000000 THEN (t/1000000)::int::text || 'M' ELSE (t/1000)::int::text || 'k' END AS text FROM crossings WHERE d IS NOT NULL ORDER BY d" + "rawSql": "WITH daily AS (SELECT d.valuation_date, SUM(d.total_value) AS nw FROM daily_account_valuation d JOIN accounts a ON a.id = d.account_id GROUP BY d.valuation_date), crossings AS (SELECT t, (SELECT MIN(valuation_date) FROM daily WHERE nw >= t::numeric) AS d FROM unnest(ARRAY[100000, 250000, 500000, 750000, 1000000]) AS t) SELECT d::timestamptz AS time, '\u00a3' || CASE WHEN t >= 1000000 THEN (t/1000000)::int::text || 'M' ELSE (t/1000)::int::text || 'k' END AS text FROM crossings WHERE d IS NOT NULL ORDER BY d" } } ] }, - "description": "Wealth — net worth, contributions, and growth over time. Backed by the wealthfolio_sync PG mirror of Wealthfolio's SQLite, refreshed hourly by the pg-sync sidecar.", + "description": "Wealth \u2014 net worth, contributions, and growth over time. Backed by the wealthfolio_sync PG mirror of Wealthfolio's SQLite, refreshed hourly by the pg-sync sidecar.", "editable": true, "fiscalYearStartMonth": 0, "id": null, @@ -148,7 +148,7 @@ { "id": 3, "title": "Growth (unrealised)", - "description": "Net worth minus net contribution — the gain on everything you've put in.", + "description": "Net worth minus net contribution \u2014 the gain on everything you've put in.", "type": "stat", "datasource": { "type": "grafana-postgresql-datasource", @@ -214,7 +214,7 @@ { "id": 4, "title": "ROI %", - "description": "Growth / net contribution × 100. Excludes accounts with zero/negative contribution (Schwab) to avoid distortion.", + "description": "Growth / net contribution \u00d7 100. Excludes accounts with zero/negative contribution (Schwab) to avoid distortion.", "type": "stat", "datasource": { "type": "grafana-postgresql-datasource", @@ -283,7 +283,7 @@ }, { "id": 5, - "title": "Net worth — total over time", + "title": "Net worth \u2014 total over time", "description": "Daily total_value summed across all accounts (base GBP). Declining segments overlaid in red.", "type": "timeseries", "datasource": { @@ -395,7 +395,7 @@ { "id": 6, "title": "Net contribution vs market value", - "description": "Net contribution = cumulative deposits − withdrawals. Market value = total_value (cash + investments). Gap between the two = unrealised growth.", + "description": "Net contribution = cumulative deposits \u2212 withdrawals. Market value = total_value (cash + investments). Gap between the two = unrealised growth.", "type": "timeseries", "datasource": { "type": "grafana-postgresql-datasource", @@ -498,7 +498,7 @@ }, { "id": 7, - "title": "Growth (market value − contribution) over time", + "title": "Growth (market value \u2212 contribution) over time", "description": "Unrealised gain across all accounts. Filled area to emphasise the wealth created above the contributed capital. Declining segments overlaid in red.", "type": "timeseries", "datasource": { @@ -610,7 +610,7 @@ }, { "id": 8, - "title": "Per-account stacked — total value", + "title": "Per-account stacked \u2014 total value", "description": "Stacked area showing each account's contribution to total net worth over time. Useful for spotting which account drives the trajectory.", "type": "timeseries", "datasource": { @@ -676,7 +676,7 @@ { "id": 9, "title": "Cash vs invested (stacked)", - "description": "Daily breakdown of uninvested broker cash vs market value of investments. WORKPLACE_PENSION accounts (Fidelity) are reclassified entirely as invested — Wealthfolio dumps pension wrappers into cash_balance because it doesn't track the underlying fund holdings, but they are not actually cash.", + "description": "Daily breakdown of uninvested broker cash vs market value of investments. WORKPLACE_PENSION accounts (Fidelity) are reclassified entirely as invested \u2014 Wealthfolio dumps pension wrappers into cash_balance because it doesn't track the underlying fund holdings, but they are not actually cash.", "type": "timeseries", "datasource": { "type": "grafana-postgresql-datasource", @@ -837,7 +837,7 @@ { "id": 11, "title": "12mo return", - "description": "Modified-Dietz return over the trailing 12 months: market_gain / (nw_12mo_ago + 0.5 × contributions_12mo). Excludes new money in — answers 'how did my investments perform' rather than 'how much did my net worth change'.", + "description": "Modified-Dietz return over the trailing 12 months: market_gain / (nw_12mo_ago + 0.5 \u00d7 contributions_12mo). Excludes new money in \u2014 answers 'how did my investments perform' rather than 'how much did my net worth change'.", "type": "stat", "datasource": { "type": "grafana-postgresql-datasource", @@ -907,7 +907,7 @@ { "id": 15, "title": "12mo contrib", - "description": "Net contributions (deposits − withdrawals) over the trailing 12 months. How much new money you put in — independent of market movement.", + "description": "Net contributions (deposits \u2212 withdrawals) over the trailing 12 months. How much new money you put in \u2014 independent of market movement.", "type": "stat", "datasource": { "type": "grafana-postgresql-datasource", @@ -961,7 +961,7 @@ { "id": 16, "title": "12mo gain", - "description": "Trailing 12-month market gain in £ — the change in net worth minus net contributions. What the markets gave you, separate from money you added in.", + "description": "Trailing 12-month market gain in \u00a3 \u2014 the change in net worth minus net contributions. What the markets gave you, separate from money you added in.", "type": "stat", "datasource": { "type": "grafana-postgresql-datasource", @@ -1026,7 +1026,7 @@ }, { "id": 17, - "title": "Δ 1d (all)", + "title": "\u0394 1d (all)", "description": "Net worth delta over the trailing window (latest snapshot minus snapshot from N days ago). Includes new money paid in (salary, vests, deposits) AND market gains.", "type": "stat", "datasource": { @@ -1092,8 +1092,8 @@ }, { "id": 21, - "title": "Δ 1d (mkt)", - "description": "Pure market gain over the trailing window — net-worth delta minus net-contribution delta. Excludes new money paid in. Apples-to-apples with the Growth panel.", + "title": "\u0394 1d (mkt)", + "description": "Pure market gain over the trailing window \u2014 net-worth delta minus net-contribution delta. Excludes new money paid in. Apples-to-apples with the Growth panel.", "type": "stat", "datasource": { "type": "grafana-postgresql-datasource", @@ -1158,7 +1158,7 @@ }, { "id": 18, - "title": "Δ 7d (all)", + "title": "\u0394 7d (all)", "description": "Net worth delta over the trailing window (latest snapshot minus snapshot from N days ago). Includes new money paid in (salary, vests, deposits) AND market gains.", "type": "stat", "datasource": { @@ -1224,8 +1224,8 @@ }, { "id": 22, - "title": "Δ 7d (mkt)", - "description": "Pure market gain over the trailing window — net-worth delta minus net-contribution delta. Excludes new money paid in. Apples-to-apples with the Growth panel.", + "title": "\u0394 7d (mkt)", + "description": "Pure market gain over the trailing window \u2014 net-worth delta minus net-contribution delta. Excludes new money paid in. Apples-to-apples with the Growth panel.", "type": "stat", "datasource": { "type": "grafana-postgresql-datasource", @@ -1290,7 +1290,7 @@ }, { "id": 19, - "title": "Δ 30d (all)", + "title": "\u0394 30d (all)", "description": "Net worth delta over the trailing window (latest snapshot minus snapshot from N days ago). Includes new money paid in (salary, vests, deposits) AND market gains.", "type": "stat", "datasource": { @@ -1356,8 +1356,8 @@ }, { "id": 23, - "title": "Δ 30d (mkt)", - "description": "Pure market gain over the trailing window — net-worth delta minus net-contribution delta. Excludes new money paid in. Apples-to-apples with the Growth panel.", + "title": "\u0394 30d (mkt)", + "description": "Pure market gain over the trailing window \u2014 net-worth delta minus net-contribution delta. Excludes new money paid in. Apples-to-apples with the Growth panel.", "type": "stat", "datasource": { "type": "grafana-postgresql-datasource", @@ -1422,7 +1422,7 @@ }, { "id": 20, - "title": "Δ 90d (all)", + "title": "\u0394 90d (all)", "description": "Net worth delta over the trailing window (latest snapshot minus snapshot from N days ago). Includes new money paid in (salary, vests, deposits) AND market gains.", "type": "stat", "datasource": { @@ -1488,8 +1488,8 @@ }, { "id": 24, - "title": "Δ 90d (mkt)", - "description": "Pure market gain over the trailing window — net-worth delta minus net-contribution delta. Excludes new money paid in. Apples-to-apples with the Growth panel.", + "title": "\u0394 90d (mkt)", + "description": "Pure market gain over the trailing window \u2014 net-worth delta minus net-contribution delta. Excludes new money paid in. Apples-to-apples with the Growth panel.", "type": "stat", "datasource": { "type": "grafana-postgresql-datasource", @@ -1555,7 +1555,7 @@ { "id": 12, "title": "Yearly investment return %", - "description": "Modified-Dietz return per calendar year: market_gain / (nw_start + 0.5 × contributions). Pure investment performance — excludes new contributions, so a £100k vest doesn't show as 100% growth. Negative bars = market losses (e.g., 2022 bear market).", + "description": "Modified-Dietz return per calendar year: market_gain / (nw_start + 0.5 \u00d7 contributions). Pure investment performance \u2014 excludes new contributions, so a \u00a3100k vest doesn't show as 100% growth. Negative bars = market losses (e.g., 2022 bear market).", "type": "barchart", "datasource": { "type": "grafana-postgresql-datasource", @@ -1648,8 +1648,8 @@ }, { "id": 13, - "title": "Annual change decomposition — contributions vs market gain", - "description": "Each calendar year's net worth change split into 'new money in' (contributions − withdrawals) and 'market gain' (everything else: price appreciation, dividends, etc.). Shows whether you grew because you saved or because the market did the work. Negative bars = withdrawals or market losses.", + "title": "Annual change decomposition \u2014 contributions vs market gain", + "description": "Each calendar year's net worth change split into 'new money in' (contributions \u2212 withdrawals) and 'market gain' (everything else: price appreciation, dividends, etc.). Shows whether you grew because you saved or because the market did the work. Negative bars = withdrawals or market losses.", "type": "barchart", "datasource": { "type": "grafana-postgresql-datasource", @@ -1872,7 +1872,7 @@ { "id": 14, "title": "Per-account ROI %", - "description": "(market value − net contribution) / net contribution × 100, latest snapshot. Excludes accounts with zero/negative net contribution (Schwab — RSU vests sold = negative contribution distorts the ratio). Pension shows 0% because Wealthfolio doesn't track underlying fund holdings, so cost_basis = 0 and 'growth' is just the cash balance reported.", + "description": "(market value \u2212 net contribution) / net contribution \u00d7 100, latest snapshot. Excludes accounts with zero/negative net contribution (Schwab \u2014 RSU vests sold = negative contribution distorts the ratio). Pension shows 0% because Wealthfolio doesn't track underlying fund holdings, so cost_basis = 0 and 'growth' is just the cash balance reported.", "type": "barchart", "datasource": { "type": "grafana-postgresql-datasource", @@ -2148,8 +2148,8 @@ }, { "id": 30, - "title": "META vest cadence — value vs share count (per vest event)", - "description": "Per-vest event timeline. Left axis (USD): vest value = shares × vest-day META price. Right axis: number of shares vested. Each point is one vest date (sometimes a single date has multiple BUY rows — aggregated here).", + "title": "META vest cadence \u2014 value vs share count (per vest event)", + "description": "Per-vest event timeline. Left axis (USD): vest value = shares \u00d7 vest-day META price. Right axis: number of shares vested. Each point is one vest date (sometimes a single date has multiple BUY rows \u2014 aggregated here).", "type": "timeseries", "datasource": { "type": "grafana-postgresql-datasource", @@ -2265,7 +2265,7 @@ }, { "id": 31, - "title": "META vests — realized PNL (FIFO-matched against sells)", + "title": "META vests \u2014 realized PNL (FIFO-matched against sells)", "description": "One row per vest with realized P&L computed by FIFO-matching that vest's shares against subsequent sells. Each vest's shares may be spread across multiple sells; the matched sell-price column is the weighted average. 'Avg days held' is the average gap between this vest's date and the sell dates that consumed its shares. Compare against panel 28 to see realized vs hypo-if-held.", "type": "table", "datasource": { @@ -2489,7 +2489,7 @@ { "id": 32, "title": "Net pay earned vs market gains (cumulative)", - "description": "Active vs passive income race. Blue = total take-home pay earned from work (cumulative net_pay, payslip_ingest). Green = total market gains = portfolio value − contributions (cumulative, wealthfolio_sync dav_corrected — matches the 'Growth over time' panel). Set the time range wide (e.g. 2019 → now) to see the full climb from £0.", + "description": "Active vs passive income race. Blue = total take-home pay earned from work (cumulative net_pay, payslip_ingest). Green = total market gains = portfolio value \u2212 contributions (cumulative, wealthfolio_sync dav_corrected \u2014 matches the 'Growth over time' panel). Set the time range wide (e.g. 2019 \u2192 now) to see the full climb from \u00a30.", "type": "timeseries", "datasource": { "type": "datasource", @@ -2605,8 +2605,8 @@ }, { "id": 33, - "title": "Net pay vs market gain — per year", - "description": "Each calendar year: take-home pay earned (blue, SUM net_pay) vs market gain generated that year (green, change in portfolio value − contributions across the year). Shows full history regardless of the time picker.", + "title": "Net pay vs market gain \u2014 per year", + "description": "Each calendar year: take-home pay earned (blue, SUM net_pay) vs market gain generated that year (green, change in portfolio value \u2212 contributions across the year). Shows full history regardless of the time picker.", "type": "timeseries", "datasource": { "type": "datasource", @@ -2721,8 +2721,8 @@ }, { "id": 34, - "title": "Net pay vs market gain — per month", - "description": "Each month: take-home pay (blue, SUM net_pay) vs market gain that month (green, change in portfolio value − contributions). Monthly market swings are volatile — the yearly panel above is the smoother read. Shows full history regardless of the time picker.", + "title": "Net pay vs market gain \u2014 per month", + "description": "Each month: take-home pay (blue, SUM net_pay) vs market gain that month (green, change in portfolio value \u2212 contributions). Monthly market swings are volatile \u2014 the yearly panel above is the smoother read. Shows full history regardless of the time picker.", "type": "timeseries", "datasource": { "type": "datasource", @@ -2831,7 +2831,324 @@ "rawQuery": true, "editorMode": "code", "format": "time_series", - "rawSql": "WITH active_count AS (SELECT COUNT(*) AS n FROM accounts), max_complete AS (SELECT MAX(valuation_date) AS d FROM (SELECT d.valuation_date, COUNT(*) AS c FROM dav_corrected d JOIN accounts a ON a.id = d.account_id GROUP BY d.valuation_date) x WHERE c >= (SELECT n FROM active_count)), monthly AS (SELECT date_trunc('month', valuation_date)::date AS month, valuation_date, SUM(total_value) AS nw, SUM(net_contribution) AS contrib FROM dav_corrected WHERE valuation_date <= (SELECT d FROM max_complete) GROUP BY valuation_date), endpoints AS (SELECT month, (array_agg(nw ORDER BY valuation_date ASC))[1] AS nw_start, (array_agg(nw ORDER BY valuation_date DESC))[1] AS nw_end, (array_agg(contrib ORDER BY valuation_date ASC))[1] AS contrib_start, (array_agg(contrib ORDER BY valuation_date DESC))[1] AS contrib_end FROM monthly GROUP BY month) SELECT month::timestamp AS \"time\", ROUND((nw_end - nw_start - (contrib_end - contrib_start))::numeric, 0) AS market_gain_month FROM endpoints ORDER BY month" + "rawSql": "WITH active_count AS (SELECT COUNT(*) AS n FROM accounts), max_complete AS (SELECT MAX(valuation_date) AS d FROM (SELECT d.valuation_date, COUNT(*) AS c FROM dav_corrected d JOIN accounts a ON a.id = d.account_id GROUP BY d.valuation_date) x WHERE c >= (SELECT n FROM active_count)), daily AS (SELECT valuation_date, SUM(total_value) AS nw, SUM(net_contribution) AS contrib FROM dav_corrected WHERE valuation_date <= (SELECT d FROM max_complete) GROUP BY valuation_date), month_end AS (SELECT DISTINCT ON (date_trunc('month', valuation_date)) date_trunc('month', valuation_date)::date AS month, nw, contrib FROM daily ORDER BY date_trunc('month', valuation_date), valuation_date DESC), deltas AS (SELECT month, nw, contrib, lag(nw) OVER (ORDER BY month) AS prev_nw, lag(contrib) OVER (ORDER BY month) AS prev_contrib FROM month_end) SELECT month::timestamp AS \"time\", ROUND(((nw - prev_nw) - (contrib - prev_contrib))::numeric, 0) AS market_gain_month FROM deltas WHERE prev_nw IS NOT NULL ORDER BY month" + } + ] + }, + { + "id": 9100, + "type": "row", + "collapsed": true, + "title": "\ud83d\udcc8 Projections (set the time range to include the future)", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 200 + }, + "panels": [ + { + "id": 9101, + "type": "text", + "title": "How to view the projection", + "gridPos": { + "h": 3, + "w": 24, + "x": 0, + "y": 201 + }, + "options": { + "mode": "markdown", + "content": "The projection extends 30 years into the **future**, but this dashboard defaults to the last 180 days. **[\u25b6 Show projection range](?from=now-3y&to=now%2B30y)** \u2014 reload with a future-inclusive axis. **[\u21a9 Reset to normal](?from=now-180d&to=now)** \u2014 back to the standard view.\n\nAdjust **Low / Base / High rate %**, **Monthly contribution** (`auto` = your trailing-12mo run-rate; type a number or `0`), and **Horizon** at the top of the dashboard." + } + }, + { + "id": 9105, + "title": "Net worth (today)", + "type": "stat", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "wealth-pg" + }, + "gridPos": { + "h": 4, + "w": 8, + "x": 0, + "y": 204 + }, + "fieldConfig": { + "defaults": { + "unit": "currencyGBP", + "decimals": 0, + "color": { + "mode": "continuous-GrYlRd" + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "wealth-pg" + }, + "format": "table", + "editorMode": "code", + "rawQuery": true, + "rawSql": "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) AS nw FROM latest" + } + ] + }, + { + "id": 9106, + "title": "Historical return (trailing 3y)", + "type": "stat", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "wealth-pg" + }, + "gridPos": { + "h": 4, + "w": 8, + "x": 8, + "y": 204 + }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "decimals": 2, + "color": { + "mode": "continuous-GrYlRd" + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "wealth-pg" + }, + "format": "table", + "editorMode": "code", + "rawQuery": true, + "rawSql": "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 dd WHERE dd.account_id=l.account_id AND dd.valuation_date <= (SELECT d FROM mc) - INTERVAL '12 months' ORDER BY dd.valuation_date DESC LIMIT 1) x ON true), 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), x AS (SELECT 1) SELECT round(((SELECT exp(avg(ln(1+ret)))-1 FROM r3)*100)::numeric,2) AS hist_pct" + } + ] + }, + { + "id": 9107, + "title": "Monthly contribution (auto run-rate)", + "type": "stat", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "wealth-pg" + }, + "gridPos": { + "h": 4, + "w": 8, + "x": 16, + "y": 204 + }, + "fieldConfig": { + "defaults": { + "unit": "currencyGBP", + "decimals": 0, + "color": { + "mode": "continuous-GrYlRd" + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "wealth-pg" + }, + "format": "table", + "editorMode": "code", + "rawQuery": true, + "rawSql": "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 dd WHERE dd.account_id=l.account_id AND dd.valuation_date <= (SELECT d FROM mc) - INTERVAL '12 months' ORDER BY dd.valuation_date DESC LIMIT 1) x ON true), 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), x AS (SELECT 1) SELECT round((((SELECT c_now FROM agg)-(SELECT c_ago FROM ago))/12.0)::numeric,0) AS monthly_contribution" + } + ] + }, + { + "id": 9103, + "title": "Net worth \u2014 ${horizon_years}-year projection", + "description": "Projected net worth at Low/Base/High fixed rates plus your trailing-3y historical return, all compounding from today with your auto monthly contribution (set $monthly_contribution=0 for pure compounding; 'Base, no new contributions' shows the same base rate with zero contributions). NOTE: set the time range to include the future (use the links above) to see the projection.", + "type": "timeseries", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "wealth-pg" + }, + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 208 + }, + "fieldConfig": { + "defaults": { + "unit": "currencyGBP", + "decimals": 0, + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "lineWidth": 2, + "fillOpacity": 0, + "showPoints": "never", + "spanNulls": true + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "^(Low|Base \\(|High|Historical)" + }, + "properties": [ + { + "id": "custom.lineStyle", + "value": { + "fill": "dash", + "dash": [ + 10, + 10 + ] + } + }, + { + "id": "custom.lineWidth", + "value": 1 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Base, no new contributions" + }, + "properties": [ + { + "id": "custom.lineStyle", + "value": { + "fill": "dot", + "dash": [ + 2, + 6 + ] + } + }, + { + "id": "custom.lineWidth", + "value": 1 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Net worth (actual)" + }, + "properties": [ + { + "id": "custom.lineWidth", + "value": 3 + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "white" + } + } + ] + } + ] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "calcs": [ + "last" + ] + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "wealth-pg" + }, + "format": "time_series", + "editorMode": "code", + "rawQuery": true, + "rawSql": "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 dd WHERE dd.account_id=l.account_id AND dd.valuation_date <= (SELECT d FROM mc) - INTERVAL '12 months' ORDER BY dd.valuation_date DESC LIMIT 1) x ON true), 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), params AS (SELECT (SELECT nw0 FROM agg) nw0, COALESCE(NULLIF('$monthly_contribution','auto')::numeric, ((SELECT c_now FROM agg)-(SELECT c_ago FROM ago))/12.0) cm, ($rate_low::float)/100 rl, ($rate_base::float)/100 rb, ($rate_high::float)/100 rh, (SELECT exp(avg(ln(1+ret)))-1 FROM r3) 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 (trailing 3y)\" FROM m, params" + }, + { + "refId": "B", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "wealth-pg" + }, + "format": "time_series", + "editorMode": "code", + "rawQuery": true, + "rawSql": "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" + } + ] } ] } @@ -2844,7 +3161,108 @@ "wealth" ], "templating": { - "list": [] + "list": [ + { + "name": "rate_low", + "label": "Low rate %", + "type": "textbox", + "query": "4", + "current": { + "selected": false, + "text": "4", + "value": "4" + }, + "options": [ + { + "selected": true, + "text": "4", + "value": "4" + } + ], + "hide": 0, + "skipUrlSync": false + }, + { + "name": "rate_base", + "label": "Base rate %", + "type": "textbox", + "query": "7", + "current": { + "selected": false, + "text": "7", + "value": "7" + }, + "options": [ + { + "selected": true, + "text": "7", + "value": "7" + } + ], + "hide": 0, + "skipUrlSync": false + }, + { + "name": "rate_high", + "label": "High rate %", + "type": "textbox", + "query": "10", + "current": { + "selected": false, + "text": "10", + "value": "10" + }, + "options": [ + { + "selected": true, + "text": "10", + "value": "10" + } + ], + "hide": 0, + "skipUrlSync": false + }, + { + "name": "monthly_contribution", + "label": "Monthly contribution \u00a3 (auto)", + "type": "textbox", + "query": "auto", + "current": { + "selected": false, + "text": "auto", + "value": "auto" + }, + "options": [ + { + "selected": true, + "text": "auto", + "value": "auto" + } + ], + "hide": 0, + "skipUrlSync": false + }, + { + "name": "horizon_years", + "label": "Horizon (years)", + "type": "textbox", + "query": "30", + "current": { + "selected": false, + "text": "30", + "value": "30" + }, + "options": [ + { + "selected": true, + "text": "30", + "value": "30" + } + ], + "hide": 0, + "skipUrlSync": false + } + ] }, "time": { "from": "now-180d", @@ -2855,4 +3273,4 @@ "title": "Wealth", "uid": "wealth", "version": 1 -} +} \ No newline at end of file