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:
parent
d6049ff7a0
commit
8461275308
2 changed files with 263 additions and 1 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue