Featured image of post Building a Personal Net Worth Dashboard with Obsidian

Building a Personal Net Worth Dashboard with Obsidian

Use Markdown, DataviewJS and the Charts plugin to build a local, self-updating dashboard that tracks your personal asset trends inside Obsidian.

Plenty of people track their expenses, but far fewer track their asset trends. Bookkeeping focuses on individual transactions; an asset trend answers a longer-term question:

Is my net worth growing, flat, or shrinking? And are the individual asset classes moving in a healthy direction?

A spreadsheet works, but you end up maintaining tables, formulas and charts by hand. A third-party finance app works too, but then your balances live on someone else’s server. This post describes a local-first alternative that is easy to maintain for years:

Store the data in Markdown, aggregate it with DataviewJS, and render interactive charts with Charts.

Everything runs inside your Obsidian vault. There is no external database, and no financial data is uploaded to a third-party service. Once you append a single row of data, the total-asset trend, the per-category trend, the distribution chart and the account table all update automatically.

The Result

When you’re done, you’ll have a dashboard that shows:

  • Your latest total assets
  • The change since the previous snapshot
  • A total-asset growth curve
  • Per-category trends: bank accounts, payment wallets, stocks, funds, fixed income, and so on
  • The latest category breakdown and its proportions
  • The most recent balance or market value of every account

Personal net worth dashboard in Obsidian

The goal isn’t a sophisticated financial model. It’s to solve the most common need with the lowest possible maintenance cost: watching your assets change over time.

How It Works

The setup relies on two Obsidian community plugins:

  1. Dataview — reads the Markdown data and performs the aggregation via DataviewJS.
  2. Charts — renders the computed arrays as Chart.js line and doughnut charts.

The division of labour is clean:

  • A Markdown table stores the raw asset records.
  • DataviewJS reads the table, carries balances forward, and computes totals per date and per category.
  • Charts turns the computed results into visuals.

Install and enable both plugins under Settings → Community plugins. After enabling them, make sure Dataview’s JavaScript queries are turned on as well — otherwise dataviewjs blocks won’t execute.

File Layout

Create a property folder in your vault with the following structure:

1
2
3
4
5
property/
├── README.md              # Optional: entry rules and field reference
├── asset-trends.md        # DataviewJS + Charts dashboard
└── data/
    └── asset_snapshots.md # The data source

Where:

  • asset_snapshots.md only stores data.
  • asset-trends.md reads the data, computes the results and renders the charts.

Keeping data and presentation separate pays off: you can restyle the charts without touching your records, and add records without touching the dashboard code.

The Data Format

data/asset_snapshots.md stores everything in a plain Markdown table:

1
2
3
4
5
| date | category | account | amount | note |
| --- | --- | --- | ---: | --- |
| 2026-09-01 | Bank | ICBC Savings | 50000 | initial |
| 2026-09-01 | Stocks | Brokerage A | 120000 | market value + cash |
| 2026-09-01 | Funds | Fund Account A | 80000 | market value |

The fields mean:

FieldMeaning
dateRecord date, preferably YYYY-MM-DD
categoryAsset class, e.g. Bank, Payment Wallet, Stocks, Funds, Fixed Income, Other
accountA stable account name, e.g. “ICBC Savings”, “Brokerage A”
amountThe account’s current total balance or net value, in a single currency
noteOptional remark, e.g. “initial”, “market move”, “after spending”

One design decision matters most: amount is the account’s total at that point in time, not the day’s delta.

For a brokerage account, record “position market value + idle cash”. For a fund account, record the current total value. For a bank card, record the current balance. That way every account has one unambiguous value on any given date, which keeps the downstream math simple.

Why Record Only Changes

Re-entering every account on every snapshot gets tedious fast, and repeated manual entry invites mistakes. A change-only log works much better:

  1. On first use, enter an initial amount for every account.
  2. After that, add a row only when an account’s balance or market value actually changes.
  3. Accounts that didn’t change need no new row.
  4. The dashboard automatically carries forward each account’s most recent known balance.

For example:

1
2
3
4
5
6
| date | category | account | amount | note |
| --- | --- | --- | ---: | --- |
| 2026-09-01 | Bank | ICBC | 50000 | initial |
| 2026-09-01 | Stocks | Brokerage A | 120000 | initial |
| 2026-09-03 | Stocks | Brokerage A | 122000 | market move |
| 2026-09-05 | Bank | ICBC | 48000 | after spending |

In this example:

  • On Sep 3 only the brokerage account is updated; the bank card is left alone.
  • On Sep 5 only the bank card is updated; the brokerage account is left alone.
  • When computing the Sep 5 total, the dashboard uses 48000 for the bank card and the most recent 122000 for the brokerage account.

This trades a little extra logic in the code for a large reduction in day-to-day effort.

The Dashboard Code

Below is the core of asset-trends.md. Drop it into a single dataviewjs code block:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
const raw = await dv.io.load("data/asset_snapshots.md");
if (!raw) {
  dv.paragraph("cant find data/asset_snapshots.md");
  return;
}
const lines = raw.trim().split(/\r?\n/).filter(x => x.trim().startsWith("|"));
const parse = x => x.trim().replace(/^\||\|$/g, "").split("|").map(v => v.trim());
const h = parse(lines.shift());
lines.shift();
const data = lines.map(l => {
  const c = parse(l),
    o = {};
  h.forEach((k, i) => o[k] = c[i] ?? "");
  o.amount = Number(o.amount) || 0;
  return o;
});
if (!data.length) {
  dv.paragraph("No data yet");
  return;
}
const dates = [...new Set(data.map(x => x.date))].sort();
const accounts = [...new Set(data.map(x => x.account))];
const cats = [...new Set(data.map(x => x.category))];
const latestAt = (a, d) => data
  .filter(x => x.account === a && x.date <= d)
  .sort((x, y) => x.date.localeCompare(y.date)).at(-1);
const balancesAt = d => accounts.map(a => latestAt(a, d)).filter(Boolean);
const total = d => balancesAt(d).reduce((s, x) => s + x.amount, 0);
const latest = dates.at(-1),
  previous = dates.at(-2),
  now = total(latest);
const money = n => "¥" + Math.round(n).toLocaleString("en-US");
dv.table(["Metric", "Value"], [
  ["Latest date", latest],
  ["Total assets", money(now)],
  ["Change since last", previous ? money(now - total(previous)) : "—"],
  ["Accounts", accounts.length]
]);

const render = async (title, config) => {
  dv.header(3, title);
  if (window.renderChart) {
    const host = dv.el("div", "", {
      cls: "asset-chart-host"
    });
    window.renderChart(config, host);
  } else dv.paragraph("⚠️ Charts plugin is not enabled");
};
const colors = [
  '#5B7FFF',
  '#FF9F7B',
  '#7BC8A4',
  '#F4A7B9',
  '#B8A9D4',
  '#79C9D1',
  '#E8B87C'
];
const lineOptions = {
  responsive: true,
  plugins: {
    legend: {
      position: "bottom"
    }
  },
  scales: {
    y: {
      beginAtZero: true
    }
  }
};

// ---- Total asset trend ----
render("Total Asset Trend", {
  type: "line",
  data: {
    labels: dates,
    datasets: [{
      label: "Total assets",
      data: dates.map(total),
      borderColor: colors[0],
      borderWidth: 3,
      tension: 0.25,
      fill: false
    }]
  },
  options: lineOptions
});

// ---- Per-category trend ----
const catDatasets = cats.map((c, i) => ({
  label: c,
  data: dates.map(d => balancesAt(d).filter(x => x.category === c).reduce((s, x) => s + x.amount, 0)),
  borderColor: colors[i % colors.length],
  borderWidth: 2,
  tension: 0.25,
  fill: false
}));
render("Asset Trend by Category", {
  type: "line",
  data: {
    labels: dates,
    datasets: catDatasets
  },
  options: lineOptions
});

// ---- Distribution doughnut ----
const latestData = cats.map(c =>
  balancesAt(latest).filter(x => x.category === c).reduce((s, x) => s + x.amount, 0)
);
render("Asset Distribution", {
  type: "doughnut",
  data: {
    labels: cats,
    datasets: [{
      label: latest,
      data: latestData,
      backgroundColor: colors
    }]
  },
  options: {
    responsive: true,
    plugins: {
      legend: {
        position: "bottom"
      }
    }
  }
});

dv.header(3, "Account Balances");
dv.table(
  ["Category", "Account", "Latest date", "Amount"],
  balancesAt(latest).sort((a, b) => b.amount - a.amount).map(x => [
    x.category,
    x.account,
    x.date,
    money(x.amount)
  ]));

The script does a handful of things:

  1. Loads the Markdown table from data/asset_snapshots.md.
  2. Converts each row into a JavaScript object.
  3. Collects the distinct dates, accounts and categories.
  4. For each date, resolves every account’s most recent known balance.
  5. Aggregates total assets, per-category assets and per-account balances.
  6. Calls the Charts plugin to render the line and doughnut charts.

The two key functions are latestAt and balancesAt:

  • latestAt(account, date) — finds an account’s most recent record on or before a given date.
  • balancesAt(date) — computes the effective balance of every account on a given date.

Together they’re what makes “only log the accounts that changed” work naturally.

Daily Workflow

Day to day, you only ever touch the data file:

  1. Open data/asset_snapshots.md.
  2. Copy an existing row.
  3. Update the date, category, account and amount.
  4. Optionally note the reason: “spending”, “transfer in”, “market move”.
  5. Open asset-trends.md to see the refreshed charts.

If a brokerage account moves from 120000 to 122000, that’s one new line:

1
| 2026-09-03 | Stocks | Brokerage A | 122000 | market move |

Nothing else needs to be re-entered.

Practical Tips

To keep the data usable over the long run, a few simple rules help:

  • Keep account names stable. Writing “CMB” today and “China Merchants Bank” tomorrow creates two separate accounts.
  • Keep the date format consistent. Stick to YYYY-MM-DD so sorting works.
  • Put digits only in amount. No currency symbols, thousands separators or units.
  • Record totals for investment accounts. For stocks, funds and wealth products, log “market value + cash”.
  • Back up the vault. The data is local, which also means nothing recovers it for you if a device dies.

If the data feels sensitive, you can give this folder its own sync rules, or keep it strictly on one local device and exclude it from cloud sync entirely.

Where to Take It Next

The basic dashboard leaves plenty of room to grow:

  • Add liabilities and compute net worth.
  • Add monthly and annual rates of return.
  • Support multiple currencies per account.
  • Add a bar chart comparing asset size month over month.
  • Add a target line to see how far you are from a milestone.
  • Break assets into higher-level buckets: cash, equity, fixed income.

If the dataset grows large, split it by year into several Markdown files and have DataviewJS load them together.

Conclusion

The value here isn’t building an elaborate financial system — it’s letting Obsidian accumulate your asset history and turn it into a trend dashboard you can keep watching.

Markdown keeps the data readable and backup-friendly, DataviewJS does the math, and Charts handles the display. Maintain a handful of records now and then, and over time you get a clear, honest view of how your total assets, category mix and account distribution are evolving.