From 55d1da41f60c82e0229aaf3e963f84a35b6f51d2 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 25 Apr 2026 23:21:42 +0000 Subject: [PATCH] monitoring: more growth detail in Wealth + gross composition in UK Payslip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wealth (4 new panels at the bottom): - Trailing 12-month growth % (stat) — % change in net worth over last 12mo. - Yearly growth % (bar per calendar year) — first→last valuation each year. - Annual change decomposition (stacked bar) — splits each year's NW change into "net contributions" (new money in) and "market gain" (everything else: appreciation, dividends, FX). Answers "did I grow because I saved or because the market did the work?". - Per-account ROI % (horizontal bar) — (value − contribution) / contribution × 100, latest snapshot. Excludes accounts with zero/negative net contribution (Schwab — distorts ratio after RSU sells). UK Payslip (1 new panel below the yearly receipt): - Gross composition by tax year (stacked bar) — salary / bonus / RSU vest / other components per tax year. Bar height = gross pay. Trends in salary growth, bonus levels, and RSU vest sizing at a glance. All queries spot-checked via Grafana /api/ds/query. --- .../monitoring/dashboards/uk-payslip.json | 89 ++++++++ .../modules/monitoring/dashboards/wealth.json | 207 ++++++++++++++++++ 2 files changed, 296 insertions(+) diff --git a/stacks/monitoring/modules/monitoring/dashboards/uk-payslip.json b/stacks/monitoring/modules/monitoring/dashboards/uk-payslip.json index 0dfb1bd0..c96080e7 100644 --- a/stacks/monitoring/modules/monitoring/dashboards/uk-payslip.json +++ b/stacks/monitoring/modules/monitoring/dashboards/uk-payslip.json @@ -2535,6 +2535,95 @@ "rawSql": "WITH r AS (SELECT * FROM payslip_ingest.payslip), ani AS (SELECT *, COALESCE(SUM(gross_pay - COALESCE(pension_sacrifice, 0)) OVER (PARTITION BY tax_year ORDER BY pay_date ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), 0) AS ani_prior FROM r), slice AS (SELECT *, ani_prior + gross_pay - COALESCE(rsu_vest, 0) - COALESCE(pension_sacrifice, 0) AS ani_pre, ani_prior + gross_pay - COALESCE(pension_sacrifice, 0) AS ani_post FROM ani), m AS (SELECT *, GREATEST(0, LEAST(ani_post, 12570) - GREATEST(ani_pre, 0)) * 0.00 + GREATEST(0, LEAST(ani_post, 50270) - GREATEST(ani_pre, 12570)) * 0.20 + GREATEST(0, LEAST(ani_post, 100000) - GREATEST(ani_pre, 50270)) * 0.40 + GREATEST(0, LEAST(ani_post, 125140) - GREATEST(ani_pre, 100000)) * 0.60 + GREATEST(0, ani_post - GREATEST(ani_pre, 125140)) * 0.45 AS rsu_paye_marginal, GREATEST(0, LEAST(ani_post, 12570) - GREATEST(ani_pre, 0)) * 0.00 + GREATEST(0, LEAST(ani_post, 50270) - GREATEST(ani_pre, 12570)) * 0.08 + GREATEST(0, ani_post - GREATEST(ani_pre, 50270)) * 0.02 AS rsu_ni_marginal FROM slice) SELECT tax_year, SUM(net_pay) AS net_pay, SUM(GREATEST(0, income_tax - rsu_paye_marginal)) AS cash_income_tax, SUM(rsu_paye_marginal + rsu_ni_marginal) AS rsu_tax_marginal, SUM(GREATEST(0, national_insurance - rsu_ni_marginal)) AS cash_ni, SUM(student_loan) AS student_loan, SUM(COALESCE(pension_sacrifice, 0)) AS pension_sacrifice, SUM(rsu_offset) AS rsu_offset FROM m GROUP BY tax_year ORDER BY tax_year" } ] + }, + { + "id": 17, + "title": "Gross composition by tax year — salary / bonus / RSU / other", + "description": "Per-tax-year stacked bar of gross pay broken into earned components: salary, bonus, RSU vest value, and other (overtime, benefits-in-kind, etc.). Bar height = total gross. Compare year-over-year trends in base salary growth, bonus levels, and RSU vest sizing. Always shows all years — ignores the time picker.", + "type": "barchart", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "payslips-pg" + }, + "gridPos": { + "h": 11, + "w": 24, + "x": 0, + "y": 190 + }, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "unit": "currencyGBP", + "decimals": 0, + "custom": { + "axisPlacement": "auto", + "axisLabel": "", + "fillOpacity": 80, + "gradientMode": "none", + "lineWidth": 1 + } + }, + "overrides": [ + { + "matcher": {"id": "byName", "options": "salary"}, + "properties": [ + {"id": "color", "value": {"mode": "fixed", "fixedColor": "green"}}, + {"id": "displayName", "value": "Salary"} + ] + }, + { + "matcher": {"id": "byName", "options": "bonus"}, + "properties": [ + {"id": "color", "value": {"mode": "fixed", "fixedColor": "#FADE2A"}}, + {"id": "displayName", "value": "Bonus"} + ] + }, + { + "matcher": {"id": "byName", "options": "rsu"}, + "properties": [ + {"id": "color", "value": {"mode": "fixed", "fixedColor": "#3274D9"}}, + {"id": "displayName", "value": "RSU vest"} + ] + }, + { + "matcher": {"id": "byName", "options": "other"}, + "properties": [ + {"id": "color", "value": {"mode": "fixed", "fixedColor": "#888888"}}, + {"id": "displayName", "value": "Other"} + ] + } + ] + }, + "options": { + "barRadius": 0, + "barWidth": 0.6, + "groupWidth": 0.7, + "orientation": "auto", + "showValue": "auto", + "stacking": "normal", + "xField": "tax_year", + "xTickLabelRotation": 0, + "legend": { + "calcs": ["sum"], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": {"mode": "multi", "sort": "desc"} + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "payslips-pg" + }, + "rawQuery": true, + "editorMode": "code", + "format": "table", + "rawSql": "SELECT tax_year, SUM(salary) AS salary, SUM(bonus) AS bonus, SUM(rsu_vest) AS rsu, SUM(GREATEST(gross_pay - salary - bonus - rsu_vest, 0)) AS other FROM payslip_ingest.payslip GROUP BY tax_year ORDER BY tax_year" + } + ] } ], "refresh": "5m", diff --git a/stacks/monitoring/modules/monitoring/dashboards/wealth.json b/stacks/monitoring/modules/monitoring/dashboards/wealth.json index b3dddfe7..e68b3143 100644 --- a/stacks/monitoring/modules/monitoring/dashboards/wealth.json +++ b/stacks/monitoring/modules/monitoring/dashboards/wealth.json @@ -436,6 +436,213 @@ "rawSql": "SELECT a.activity_date AS \"date\", acc.name AS \"account\", a.activity_type AS \"type\", a.asset_id AS \"asset\", a.quantity AS \"qty\", a.unit_price AS \"unit_price\", a.amount AS \"amount\", a.currency AS \"ccy\", a.notes AS \"notes\" FROM activities a LEFT JOIN accounts acc ON acc.id = a.account_id WHERE $__timeFilter(a.activity_date) ORDER BY a.activity_date DESC LIMIT 100" } ] + }, + { + "id": 11, + "title": "Trailing 12-month growth %", + "description": "% change in net worth over the trailing 12 months. Captures market momentum + new contributions combined.", + "type": "stat", + "datasource": {"type": "grafana-postgresql-datasource", "uid": "wealth-pg"}, + "gridPos": {"h": 4, "w": 24, "x": 0, "y": 59}, + "fieldConfig": { + "defaults": { + "unit": "percent", + "color": {"mode": "thresholds"}, + "decimals": 2, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "red", "value": null}, + {"color": "yellow", "value": 0}, + {"color": "green", "value": 5} + ] + } + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, + "textMode": "value" + }, + "targets": [ + { + "refId": "A", + "datasource": {"type": "grafana-postgresql-datasource", "uid": "wealth-pg"}, + "rawQuery": true, + "editorMode": "code", + "format": "table", + "rawSql": "WITH t12 AS (SELECT (SELECT SUM(total_value) FROM daily_account_valuation WHERE valuation_date = (SELECT MAX(valuation_date) FROM daily_account_valuation)) AS now_nw, (SELECT SUM(total_value) FROM daily_account_valuation WHERE valuation_date = (SELECT MIN(valuation_date) FROM daily_account_valuation WHERE valuation_date >= (SELECT MAX(valuation_date) - INTERVAL '12 months' FROM daily_account_valuation))) AS yr_ago_nw) SELECT ROUND(((now_nw - yr_ago_nw) / NULLIF(yr_ago_nw, 0) * 100)::numeric, 2) AS pct_12mo FROM t12" + } + ] + }, + { + "id": 12, + "title": "Yearly growth %", + "description": "% change in net worth from the first to last valuation in each calendar year. Includes both market gains and new contributions — see Panel 13 for the decomposition.", + "type": "barchart", + "datasource": {"type": "grafana-postgresql-datasource", "uid": "wealth-pg"}, + "gridPos": {"h": 11, "w": 24, "x": 0, "y": 63}, + "fieldConfig": { + "defaults": { + "color": {"mode": "thresholds"}, + "unit": "percent", + "decimals": 1, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "red", "value": null}, + {"color": "yellow", "value": 0}, + {"color": "green", "value": 5} + ] + }, + "custom": { + "axisPlacement": "auto", + "axisLabel": "", + "fillOpacity": 80, + "gradientMode": "none", + "lineWidth": 1 + } + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.6, + "groupWidth": 0.7, + "orientation": "auto", + "showValue": "always", + "stacking": "none", + "xField": "year", + "xTickLabelRotation": 0, + "legend": {"displayMode": "list", "placement": "bottom"}, + "tooltip": {"mode": "single", "sort": "none"} + }, + "targets": [ + { + "refId": "A", + "datasource": {"type": "grafana-postgresql-datasource", "uid": "wealth-pg"}, + "rawQuery": true, + "editorMode": "code", + "format": "table", + "rawSql": "WITH yearly AS (SELECT EXTRACT(YEAR FROM valuation_date)::int AS yr, valuation_date, SUM(total_value) AS nw FROM daily_account_valuation GROUP BY valuation_date), endpoints AS (SELECT yr, (array_agg(nw ORDER BY valuation_date ASC))[1] AS nw_start, (array_agg(nw ORDER BY valuation_date DESC))[1] AS nw_end FROM yearly GROUP BY yr) SELECT yr::text AS year, ROUND(((nw_end - nw_start) / NULLIF(nw_start, 0) * 100)::numeric, 2) AS growth_pct FROM endpoints WHERE nw_start > 0 ORDER BY yr" + } + ] + }, + { + "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.", + "type": "barchart", + "datasource": {"type": "grafana-postgresql-datasource", "uid": "wealth-pg"}, + "gridPos": {"h": 11, "w": 24, "x": 0, "y": 74}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "unit": "currencyGBP", + "decimals": 0, + "custom": { + "axisPlacement": "auto", + "axisLabel": "", + "fillOpacity": 80, + "gradientMode": "none", + "lineWidth": 1 + } + }, + "overrides": [ + { + "matcher": {"id": "byName", "options": "contributions"}, + "properties": [ + {"id": "color", "value": {"mode": "fixed", "fixedColor": "blue"}}, + {"id": "displayName", "value": "Net contributions"} + ] + }, + { + "matcher": {"id": "byName", "options": "market_gain"}, + "properties": [ + {"id": "color", "value": {"mode": "fixed", "fixedColor": "#56A64B"}}, + {"id": "displayName", "value": "Market gain"} + ] + } + ] + }, + "options": { + "barRadius": 0, + "barWidth": 0.6, + "groupWidth": 0.7, + "orientation": "auto", + "showValue": "auto", + "stacking": "normal", + "xField": "year", + "xTickLabelRotation": 0, + "legend": {"calcs": ["sum"], "displayMode": "table", "placement": "bottom"}, + "tooltip": {"mode": "multi", "sort": "desc"} + }, + "targets": [ + { + "refId": "A", + "datasource": {"type": "grafana-postgresql-datasource", "uid": "wealth-pg"}, + "rawQuery": true, + "editorMode": "code", + "format": "table", + "rawSql": "WITH yearly AS (SELECT EXTRACT(YEAR FROM valuation_date)::int AS yr, valuation_date, SUM(total_value) AS nw, SUM(net_contribution) AS contrib FROM daily_account_valuation GROUP BY valuation_date), endpoints AS (SELECT yr, (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 yearly GROUP BY yr) SELECT yr::text AS year, ROUND((contrib_end - contrib_start)::numeric, 0) AS contributions, ROUND((nw_end - nw_start - (contrib_end - contrib_start))::numeric, 0) AS market_gain FROM endpoints ORDER BY yr" + } + ] + }, + { + "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.", + "type": "barchart", + "datasource": {"type": "grafana-postgresql-datasource", "uid": "wealth-pg"}, + "gridPos": {"h": 10, "w": 24, "x": 0, "y": 85}, + "fieldConfig": { + "defaults": { + "color": {"mode": "thresholds"}, + "unit": "percent", + "decimals": 1, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "red", "value": null}, + {"color": "yellow", "value": 0}, + {"color": "green", "value": 10} + ] + }, + "custom": { + "axisPlacement": "auto", + "axisLabel": "", + "fillOpacity": 80, + "gradientMode": "none", + "lineWidth": 1 + } + }, + "overrides": [] + }, + "options": { + "barRadius": 0, + "barWidth": 0.6, + "groupWidth": 0.7, + "orientation": "horizontal", + "showValue": "always", + "stacking": "none", + "xField": "account", + "legend": {"displayMode": "list", "placement": "bottom"}, + "tooltip": {"mode": "single", "sort": "none"} + }, + "targets": [ + { + "refId": "A", + "datasource": {"type": "grafana-postgresql-datasource", "uid": "wealth-pg"}, + "rawQuery": true, + "editorMode": "code", + "format": "table", + "rawSql": "SELECT a.name AS account, ROUND(((d.total_value - d.net_contribution) / NULLIF(d.net_contribution, 0) * 100)::numeric, 2) AS roi_pct FROM daily_account_valuation d JOIN accounts a ON a.id = d.account_id WHERE d.valuation_date = (SELECT MAX(valuation_date) FROM daily_account_valuation) AND d.net_contribution > 0 ORDER BY roi_pct DESC" + } + ] } ], "refresh": "5m",