Very nice. Yours performs pretty good. I get waves/spikes because I don't have the "regularly occurring transactions" as mentioned in the note.
In monthly mode for 15 years of data and 18k transactions mine takes about 20 seconds to render the whole deal. I am looping through every year (or month) and running a query for each one. Not sure if the CLOSE ON strategy in that query is the best one but seems to give me results that make sense.
{
name: "Net worth",
variables: [currencyVariable],
panels:
[
{
title: "Net worth by assets and liabilities",
width: "100%",
height: "600px",
variables: [
{
name: "display",
label: "Display",
display: "toggle",
options: () => ["bars", "area"],
},
{
name: "granularity",
label: "Granularity",
display: "toggle",
options: () => ["yearly", "monthly"],
},
],
kind: "echarts",
spec: async ({ ledger, variables }: SpecParams): Promise<EChartsSpec> => {
const currencyFormatter = getCurrencyFormatter(variables.currency);
const isMonthly = variables.granularity === "monthly";
const isArea = variables.display === "area";
// Build list of periods with their snapshot dates.
// One query per period is needed because `balance` doesn't support GROUP BY.
const periods: { key: string; closeOn: string; priceDate: string }[] = isMonthly
? iterateMonths(ledger.dateFirst, ledger.dateLast).map(({ year, month }) => {
const ny = month === 12 ? year + 1 : year;
const nm = month === 12 ? 1 : month + 1;
const closeOn = `${ny}-${String(nm).padStart(2, "0")}-01`;
return { key: `${year}-${String(month).padStart(2, "0")}`, closeOn, priceDate: closeOn };
})
: iterateYears(ledger.dateFirst, ledger.dateLast).map((year) => ({
key: String(year),
closeOn: `${year + 1}-01-01`,
priceDate: `${year}-12-31`,
}));
const queries = await Promise.all(
periods.map(({ closeOn, priceDate }) =>
ledger.query(`SELECT account,
CONVERT(SUM(position), '${variables.currency}', ${priceDate}) as market_value
FROM CLOSE ON ${closeOn}
WHERE account ~ '^(Assets|Liabilities):'
GROUP BY account`),
),
);
const amounts: Record<string, Record<string, number>> = {};
const balances: Record<string, number> = {};
for (let i = 0; i < periods.length; i++) {
const key = periods[i].key;
amounts[key] = {};
for (const row of queries[i]) {
if (!row.market_value[variables.currency]) continue;
const value = row.market_value[variables.currency];
const parts = (row.account as string).split(":");
const subAccount = parts.slice(0, 2).join(":");
amounts[key][subAccount] = (amounts[key][subAccount] ?? 0) + value;
balances[subAccount] = (balances[subAccount] ?? 0) + value;
}
}
// Sort descending: positive asset accounts first, negative liability accounts last
const assetAccounts = Object.entries(balances)
.filter(([name]) => name.startsWith("Assets:"))
.sort(([, a], [, b]) => b - a)
.map(([name]) => name);
const liabilityAccounts = Object.entries(balances)
.filter(([name]) => name.startsWith("Liabilities:"))
.sort(([, a], [, b]) => a - b)
.map(([name]) => name);
const subAccounts = [...assetAccounts, ...liabilityAccounts];
// Assign palette colors so assets take from the front and liabilities from
// the back — guaranteeing the first asset and first liability never share a color.
const PALETTE = ["#5470c6", "#91cc75", "#fac858", "#ee6666", "#73c0de", "#3ba272", "#fc8452", "#9a60b4", "#ea7ccc"];
const colorOf = (subAccount: string) => {
if (subAccount.startsWith("Assets:")) {
return PALETTE[assetAccounts.indexOf(subAccount) % PALETTE.length];
} else {
return PALETTE[(PALETTE.length - 1 - liabilityAccounts.indexOf(subAccount) % PALETTE.length + PALETTE.length) % PALETTE.length];
}
};
const periodKeys = periods.map((p) => p.key);
// In area mode, clamp values to their expected sign to prevent visual chaos
// when an account briefly crosses zero.
const areaValue = (subAccount: string, raw: number) => {
if (!isArea) return raw;
return subAccount.startsWith("Assets:") ? Math.max(0, raw) : Math.min(0, raw);
};
return {
tooltip: {
trigger: "axis",
axisPointer: { type: isArea ? "line" : "shadow" },
formatter: (paramsList: any) => {
const lines = paramsList.map((params: any) =>
`${params.marker} ${params.seriesName}: <b>${currencyFormatter(params.seriesName === "Net Worth" ? params.value : params.value)}</b>`,
);
return paramsList[0]?.name + "<br/>" + lines.join("<br/>");
},
},
legend: {
bottom: 0,
},
xAxis: {
data: periodKeys,
axisLabel: {
rotate: isMonthly ? 45 : 0,
},
},
yAxis: {
axisLabel: {
formatter: (v: number) => currencyFormatter(v),
},
},
series: [
...subAccounts.map((subAccount) => {
const isLiability = subAccount.startsWith("Liabilities:");
// In area mode use separate stacks so assets build up from 0 and
// liabilities build down from 0 independently — prevents clamped
// liability series from riding on top of the asset area.
const stack = isArea ? (isLiability ? "liabilities" : "assets") : "total";
return isArea
? {
type: "line" as const,
name: subAccount,
stack,
smooth: false,
symbol: "none",
color: colorOf(subAccount),
areaStyle: {},
data: periodKeys.map((key) => areaValue(subAccount, amounts[key][subAccount] ?? 0)),
}
: ({
type: "bar",
name: subAccount,
stack,
color: colorOf(subAccount),
data: periodKeys.map((key) => amounts[key][subAccount] ?? 0),
} as BarSeriesOption);
}),
{
type: "line",
name: "Net Worth",
z: 10,
// In monthly mode don't show dots or labels — too many data points
symbol: isArea && !isMonthly ? "circle" : "none",
symbolSize: isArea && !isMonthly ? 8 : 0,
lineStyle: { width: isArea ? 3 : 2, color: "#3daf46" },
itemStyle: { color: "#3daf46", borderColor: "#fff", borderWidth: isArea && !isMonthly ? 2 : 0 },
label: isArea && !isMonthly ? { show: true, formatter: (p: any) => currencyFormatter(p.value), color: "#3daf46", fontWeight: "bold", textBorderColor: "#fff", textBorderWidth: 2 } : { show: false },
data: periodKeys.map((key) => Object.values(amounts[key] ?? {}).reduce((s, v) => s + v, 0)),
},
],
};
},
}
]
},