wealth: positions table panel (shares + cost basis + unrealised return)

pg-sync sidecar now mirrors three extra views from the wealthfolio
SQLite: assets (id/symbol/name/currency), quote_latest (one row per
asset, preferring YAHOO over MANUAL on same-day collisions), and
positions_latest (currently-held positions extracted from the TOTAL
aggregate row of holdings_snapshots — quantity, average cost,
total cost basis).

Wealth dashboard gets a new bottom Positions table joining the three:
symbol, name, shares, avg cost, last price, market value, cost,
gain, return %. Gain and return % are color-text with red<0, green>=0
thresholds.
This commit is contained in:
Viktor Barzin 2026-05-14 16:01:27 +00:00 committed by Viktor Barzin
parent d6049ff7a0
commit 8461275308
2 changed files with 263 additions and 1 deletions

View file

@ -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",

View file

@ -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 <<SQL
BEGIN;
TRUNCATE accounts, daily_account_valuation, activities;
TRUNCATE accounts, daily_account_valuation, activities, assets, quote_latest, positions_latest;
\copy accounts FROM '/tmp/wf-sync/accounts.tsv' WITH (FORMAT csv, DELIMITER E'\t', NULL '');
\copy daily_account_valuation FROM '/tmp/wf-sync/dav.tsv' WITH (FORMAT csv, DELIMITER E'\t', NULL '');
\copy activities FROM '/tmp/wf-sync/activities.tsv' WITH (FORMAT csv, DELIMITER E'\t', NULL '');
\copy assets FROM '/tmp/wf-sync/assets.tsv' WITH (FORMAT csv, DELIMITER E'\t', NULL '');
\copy quote_latest FROM '/tmp/wf-sync/quote_latest.tsv' WITH (FORMAT csv, DELIMITER E'\t', NULL '');
\copy positions_latest FROM '/tmp/wf-sync/positions_latest.tsv' WITH (FORMAT csv, DELIMITER E'\t', NULL '');
COMMIT;
SQL