diff --git a/stacks/monitoring/modules/monitoring/dashboards/wealth.json b/stacks/monitoring/modules/monitoring/dashboards/wealth.json index c5c11c8d..6abec1c9 100644 --- a/stacks/monitoring/modules/monitoring/dashboards/wealth.json +++ b/stacks/monitoring/modules/monitoring/dashboards/wealth.json @@ -1948,6 +1948,203 @@ "rawSql": "WITH latest AS (SELECT DISTINCT ON (d.account_id) a.name, d.total_value, d.net_contribution FROM daily_account_valuation d JOIN accounts a ON a.id = d.account_id ORDER BY d.account_id, d.valuation_date DESC) SELECT name AS account, ROUND(((total_value - net_contribution) / NULLIF(net_contribution, 0) * 100)::numeric, 2) AS roi_pct FROM latest WHERE net_contribution > 0 ORDER BY roi_pct DESC" } ] + }, + { + "id": 26, + "title": "Positions", + "description": "Currently-held positions: shares, cost basis, latest market price, and unrealised return. Latest holdings_snapshots TOTAL aggregate + latest quote per asset.", + "type": "table", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "wealth-pg" + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 110 + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "displayMode": "auto" + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "shares" + }, + "properties": [ + { + "id": "decimals", + "value": 2 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "avg cost" + }, + "properties": [ + { + "id": "decimals", + "value": 2 + }, + { + "id": "unit", + "value": "currencyGBP" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "last" + }, + "properties": [ + { + "id": "decimals", + "value": 2 + }, + { + "id": "unit", + "value": "currencyGBP" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "market value" + }, + "properties": [ + { + "id": "decimals", + "value": 2 + }, + { + "id": "unit", + "value": "currencyGBP" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "cost" + }, + "properties": [ + { + "id": "decimals", + "value": 2 + }, + { + "id": "unit", + "value": "currencyGBP" + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "gain" + }, + "properties": [ + { + "id": "decimals", + "value": 2 + }, + { + "id": "unit", + "value": "currencyGBP" + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 0 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "return %" + }, + "properties": [ + { + "id": "decimals", + "value": 2 + }, + { + "id": "unit", + "value": "percent" + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 0 + } + ] + } + } + ] + } + ] + }, + "options": { + "cellHeight": "sm", + "footer": { + "show": false + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "wealth-pg" + }, + "rawQuery": true, + "editorMode": "code", + "format": "table", + "rawSql": "SELECT a.symbol, a.name, p.quantity AS shares, p.average_cost AS \"avg cost\", q.close AS \"last\", (p.quantity * q.close) AS \"market value\", p.total_cost_basis AS cost, ((p.quantity * q.close) - p.total_cost_basis) AS gain, CASE WHEN p.total_cost_basis > 0 THEN ((p.quantity * q.close) / p.total_cost_basis - 1) * 100 END AS \"return %\", p.currency, q.day AS \"as of\" FROM positions_latest p LEFT JOIN assets a ON a.id = p.asset_id LEFT JOIN quote_latest q ON q.asset_id = p.asset_id ORDER BY (p.quantity * q.close) DESC NULLS LAST" + } + ] } ], "refresh": "5m", diff --git a/stacks/wealthfolio/main.tf b/stacks/wealthfolio/main.tf index eb93860a..60ab9186 100644 --- a/stacks/wealthfolio/main.tf +++ b/stacks/wealthfolio/main.tf @@ -349,6 +349,29 @@ resource "kubernetes_deployment" "wealthfolio" { notes TEXT ); CREATE INDEX IF NOT EXISTS idx_act_date ON activities(activity_date); + CREATE TABLE IF NOT EXISTS assets ( + id TEXT PRIMARY KEY, + symbol TEXT, + name TEXT, + currency TEXT, + kind TEXT, + exchange TEXT, + is_active BOOLEAN + ); + CREATE TABLE IF NOT EXISTS quote_latest ( + asset_id TEXT PRIMARY KEY, + day DATE NOT NULL, + close NUMERIC NOT NULL, + currency TEXT + ); + CREATE TABLE IF NOT EXISTS positions_latest ( + asset_id TEXT PRIMARY KEY, + snapshot_date DATE NOT NULL, + quantity NUMERIC NOT NULL, + average_cost NUMERIC NOT NULL, + total_cost_basis NUMERIC NOT NULL, + currency TEXT + ); SQL # Snapshot SQLite (online backup — non-blocking). @@ -384,13 +407,55 @@ resource "kubernetes_deployment" "wealthfolio" { FROM activities WHERE status='POSTED'; SQ + sqlite3 -separator $'\t' /tmp/wf-sync/snapshot.db <<'SQ' > /tmp/wf-sync/assets.tsv + SELECT id, + COALESCE(display_code, instrument_symbol) AS symbol, + name, + quote_ccy AS currency, + kind, + COALESCE(instrument_exchange_mic, '') AS exchange, + is_active + FROM assets; + SQ + + # Latest quote per asset, preferring YAHOO over MANUAL when both exist on the same day. + sqlite3 -separator $'\t' /tmp/wf-sync/snapshot.db <<'SQ' > /tmp/wf-sync/quote_latest.tsv + SELECT asset_id, day, CAST(close AS REAL) AS close, currency + FROM ( + SELECT asset_id, day, close, currency, + ROW_NUMBER() OVER ( + PARTITION BY asset_id + ORDER BY day DESC, CASE source WHEN 'YAHOO' THEN 1 ELSE 2 END + ) AS rn + FROM quotes + ) + WHERE rn = 1; + SQ + + # Currently-held positions only, from the TOTAL aggregate snapshot (sums lots across accounts). + sqlite3 -separator $'\t' /tmp/wf-sync/snapshot.db <<'SQ' > /tmp/wf-sync/positions_latest.tsv + SELECT je.key AS asset_id, + snapshot_date, + CAST(json_extract(je.value, '$.quantity') AS REAL) AS quantity, + CAST(json_extract(je.value, '$.averageCost') AS REAL) AS average_cost, + CAST(json_extract(je.value, '$.totalCostBasis') AS REAL) AS total_cost_basis, + json_extract(je.value, '$.currency') AS currency + FROM holdings_snapshots, json_each(holdings_snapshots.positions) AS je + WHERE account_id = 'TOTAL' + AND snapshot_date = (SELECT MAX(snapshot_date) FROM holdings_snapshots WHERE account_id = 'TOTAL') + AND CAST(json_extract(je.value, '$.quantity') AS REAL) > 0.0001; + SQ + # Truncate-and-reload (small tables; simpler than upserts). psql -v ON_ERROR_STOP=1 <