npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

wealth-attribution-engine

v0.1.1

Published

Deterministic wealth attribution engine: decomposes wealth change into income saved, market return, and outflows. Auditable, no black boxes, no ML.

Downloads

320

Readme

wealth-attribution-engine

License: AGPL v3 CI Node

Figure out why your net worth changed, not just that it did.

When your wealth goes up or down between two dates, three different things are usually happening at once: you saved money, the market moved, and money flowed out. This engine pulls those three apart and tells you how much each one mattered.

income (money in)  +  market movement  −  outflows (money out)  =  change in wealth

That's the whole idea. It's a plain accounting identity, not a prediction or a score pulled out of thin air. Give it your account balances on a few dates and it tells you the story behind the numbers.

  • No bank connections, no transaction imports, no receipts. Just balances you already know.
  • No machine learning, no black box. Every figure comes from arithmetic you can check by hand.
  • Deterministic. The same input always produces the same output, byte for byte. Time only enters through a value you pass in, so there's nothing hidden.

The problem, technically

Balances alone can't tell you why they changed. If your brokerage went from $30,000 to $34,000, that could be $4,000 of market gains, $4,000 of fresh deposits, or any mix — the end balance looks identical. This is the attribution problem, and most tools solve it by demanding your full transaction history (bank APIs, CSV imports, receipt scanning).

This engine solves it with two much smaller inputs instead:

  1. Take-home income per period — one number you already know.
  2. New money added to investment accounts (net_deposit_amount) — so a deposit isn't mistaken for a market gain.

With those, the three forces fall out of the accounting:

market   = change in investment accounts  −  new deposits          (growth you didn't deposit)
outflows = income  −  change in cash accounts  −  new deposits     (money that left the system)

Cash accounts act as the flow-revealing ledger: whatever income didn't stay in the bank and didn't go into investments must have been spent. Multi-currency holdings are converted through a frozen per-snapshot rate table, with FX translation effects separated out so a currency swing isn't mistaken for saving or spending. The examples in this README use SGD, but nothing is SGD-only — see Currencies below.

And when the numbers don't add up — say the implied spending comes out negative because a transfer wasn't tagged — the engine flags it and lowers its confidence instead of silently "correcting" it. Bad data should be visible, not papered over.


Why this is open source

Most "financial health" tools ask you to trust a number they won't explain. This one does the opposite: the math is the product, so it's out in the open where you can read it, run it, and check it.

The proof that it's honest ships right alongside it. The test suite locks the engine's output to a set of frozen reference results, so any change that would alter what you'd see has to change those results too. If it passes, the behaviour is exactly what it was. That auditability is the point of publishing this.

It powers attribook, a wealth-clarity tool, but it has no dependency on that product (or on any database, framework, or network) and runs anywhere Node runs.


Install

npm install wealth-attribution-engine

# or straight from GitHub
npm install github:davidwang98/wealth-attribution-engine

Ships as both ESM and CommonJS with TypeScript types included. Requires Node 20+.


Quick start

import { runEngineRendered } from "wealth-attribution-engine";

const { output, insights } = runEngineRendered({
  assets,               // the accounts you track (bank, brokerage, ...)
  snapshots,            // balances on each date — NEWEST FIRST
  goals: [],            // optional savings goals to pace against
  profile: null,        // optional: age / income type calibrate thresholds
  reportingCurrency: "SGD",  // any supported code (USD, EUR, JPY, ...) — all figures report in it
  asOf: new Date(),     // "now" — the only source of time the engine uses
});

const w = output.attribution.window;
console.log(w.income);   // money that came in
console.log(w.market);   // how much the market moved you
console.log(w.outflows); // money that went out
console.log(w.delta);    // net change  (income + market − outflows)

insights.forEach((i) => console.log(i.title));

The smallest runnable version is examples/basic-usage.mjs (two snapshots, two accounts):

node examples/basic-usage.mjs
Why your wealth changed this period:
  income (money in)   : +S$6,000
  market movement     : +S$3,000
  outflows (money out): +S$2,000
  ------------------------------
  net change in wealth: +S$7,000

Using it properly: snapshots over time

The engine is built around a check-in habit, not a one-off calculation. examples/six-month-checkin.mjs is the full walkthrough — six monthly snapshots, a house-deposit goal, and a profile:

node examples/six-month-checkin.mjs

Three behaviours worth understanding:

1. It reasons over a recent window, not your whole history. You pass full history; the engine analyses the canonical window — your snapshots from roughly the last 3 months. If your cadence is sparse (quarterly, say), it falls back to the 3 most recent snapshots so there's still a baseline to compare against. The window it chose is reported back ("Last 3 months" in the demo).

2. Attribution comes at two granularities. output.attribution.window is the headline split over the whole window; output.attribution.periods breaks it down per consecutive snapshot pair:

Month-by-month, why wealth moved (newest first):
  2026-06-01 → 2026-07-01   income +S$6,500  market +S$2,900  outflows -S$5,200  net +S$4,200
  2026-05-01 → 2026-06-01   income +S$6,500  market +S$2,400  outflows -S$4,700  net +S$4,200
  2026-04-01 → 2026-05-01   income +S$6,500  market +S$1,900  outflows -S$4,200  net +S$4,200

Periods are normalised per month internally, so an irregular gap (5 weeks here, 3 weeks there) doesn't distort rates.

3. More history unlocks more insights. Attribution needs just 2 snapshots. But the interesting rules — spending creeping above your own average, a savings streak, a health-score slip — need a baseline, which means 3+ snapshots in the window. The demo runs the same data twice to show it: monthly history fires 7 insights; only the latest two snapshots fire 5. Feed it a steady cadence (monthly is the sweet spot) and it gets sharper about you.


What comes back

runEngine(input) returns an EngineOutput. runEngineRendered(input) returns { output, insights }, where insights is output.firedInsights already rendered to display copy.

| Field | What it is | | --- | --- | | attribution | { window, periods } — the income / market / outflows split, overall and per snapshot pair. | | healthScore | The 0–100 read for this window (or null when there isn't enough data). See below. | | healthTrend | The score's trajectory — one { date, health } point per snapshot in the window. | | firedInsights / firedRenderContext | The structured insights and the context needed to render them (runEngineRendered turns these into insights). | | nearMissRules | Rules that almost fired (within ~20% of their threshold) — a transparency window into what the engine weighed but didn't surface. | | generated | false when the window was too thin to produce guidance. | | sourceSnapshotId | The snapshot the guidance is anchored to. | | inputFingerprint | A stable hash of the inputs, so you can tell whether a re-run would change anything. |

The health score

A single 0–100 score over your most recent three snapshots, plus a status band: Strong · Healthy · Watchlist · At Risk · Insufficient Data. It's a weighted blend of five sub-scores (healthScore.factors), each reported with the points it earned and lost so nothing is opaque:

| Factor | Measures | Higher is | | --- | --- | --- | | savings-rate | Share of income you kept | better | | outflow-rate | Share of income that left | worse | | growth-rate | How hard your assets are working | better | | liquidity-ratio | Cash buffer vs outflows | better | | conversion-rate | Whether saved money is being deployed and compounding | better |

healthScore also carries coverage (what % of the weighted inputs were available — it degrades gracefully when data is sparse), strengths / risks arrays, a primaryAction, and adjustments (e.g. a confidence downgrade when a data-quality flag covers a factor). healthTrend gives you the same read at each snapshot, so you can chart the direction of travel.

The insight catalog

Insights are produced by around 40 deterministic rules (all in src/lib/insights/), grouped by focus area. Each fires only when its condition is met, so a healthy month surfaces few and a drifting one surfaces more:

  • Getting set up (setup / tracking) — nudges while you have little history: take_first_snapshot, take_second_snapshot, add_first_asset, add_recent_snapshot, record_income_next_time, improve_snapshot_consistency.
  • Saving habits (habits) — how much of your income you keep, and which way it's trending: savings_rate_too_thin, savings_rate_slipping, savings_rate_income_divergence, savings_streak_positive, outflow_rate_high, outflow_rate_rising, outflow_streak_elevated.
  • Growth & wealth engine (growth) — whether your assets are actually compounding: growth_rate_negative, growth_rate_softening, growth_real_erosion (inflation-adjusted), wealth_engine_declining, wealth_velocity_decelerating, conversion_rate_weak, conversion_rate_slipping, conversion_structural_low, diversify_growth_contributions.
  • Concentration & exposure (exposure) — where a shock could hurt: portfolio_concentrated_in_one_bucket, portfolio_concentration_rising, high_risk_share_dominant, high_risk_share_climbing, fx_concentration_high, fx_concentration_watch, brokerage_usd_cash_drag.
  • Liquidity & stability (exposure) — your buffer and how steady the picture is: liquidity_buffer_thin, liquidity_trending_down, income_volatility_high, score_volatility_watch.
  • Health diagnostics & trend (trend / diagnostics) — explains why the score moved: health_score_slipped, health_score_trajectory, sub_score_diagnosis, review_financial_health, and forward growth_trajectory_projection / habits_trajectory_projection.
  • Goal pacing (when you pass goals) — ahead of / behind schedule and what catching up costs, surfaced on each insight's goalImpact.
  • Data quality — the "flag, don't fix" layer: when an inconsistency (e.g. implied spending below zero) touches a metric, the affected insight is marked lower-confidence rather than silently adjusted.

Each rule's tone (high / watch / steady) reflects urgency, and thresholds calibrate to the profile you pass (age band, income type, wealth phase).


What an actionable insight looks like

Insights aren't scores — each is a specific observation with a "so what" and, where the rule has one, a concrete action:

■ Savings Rate drove the score drop   [watch · trend]
    what: Savings Rate fell 7.7 percentage points — the biggest contributor to the
          lower health score.
    why:  A lower savings rate puts more weight on the other factors. In an
          accumulation phase, this is still fixable, but it is worth correcting early.
    do:   Reduce discretionary outflows by ~8 percentage points (about S$500/month)
          to recover your recent savings average of 27.7%. Your income looks stable,
          so the lever is discretionary spending.

■ One asset drove most recent gains   [watch · growth]
    what: Tech stock brokerage made 100% of your market gains while representing
          about 100% of tracked non-bank assets in this window.
    why:  If Tech stock brokerage drops, about S$15,450, or 30% of your tracked
          non-bank assets, is at risk.

Structurally, each insight carries a title, summary (what happened), implication (why it matters), a tone (high / watch / steady), a category (habits, growth, exposure, trend, data-quality...), and often a recommendationSummary with an action and timeline. The rule families cover saving behaviour (outflow spikes, savings streaks), growth (concentration, single-asset dependence), exposure (FX and risk mix), goal pacing (ahead/behind, and what it costs to catch up), and data quality.

One privacy-minded detail: dollar amounts live in the copy as {{token}} placeholders, with the numbers carried separately on insight.tokens. renderInsightCopy(insight, insight.summary) substitutes them for display — or an app can choose not to, showing redacted copy (for screen-sharing or advisor views) without ever re-deriving the figures.


The public API

| Export | What it does | | --- | --- | | runEngine(input) | The front door. Structured numbers in, structured diagnosis out. Use this if you're rendering your own UI. | | runEngineRendered(input) | The same, plus ready-to-read insight text in one call. Best for getting started. | | renderFiredPresentations(fired, ctx) | Turn runEngine's structured insights into readable copy yourself. | | renderInsightCopy(insight, template) | Substitute {{token}} dollar values into insight copy for display. | | EngineInput / EngineOutput | The storage-free input and output types (TypeScript). | | ENGINE_VERSION | Version manifest — see below. |

EngineInput is deliberately plain: assets, snapshots (newest first), goals, profile, reportingCurrency, and asOf. Nothing in it knows how your data is stored — you bring the numbers, the engine brings the reasoning.


Currencies

The engine is not limited to SGD or USD, even though the examples (and some legacy field names) might suggest it:

  • reportingCurrency can be any supported code — SGD, USD, EUR, GBP, HKD, JPY, AUD, MYR, IDR, CNY, INR, CHF, or CAD (CURRENCY_CODES). Every figure the engine reports comes back in that currency.
  • Each snapshot carries a frozen fx_rates table (FxRateTable) that can hold a rate for any supported currency, so holdings in any mix of currencies convert to the reporting currency of your choice. Rates are keyed to an SGD base internally (rates[X] = "X per 1 SGD"), but convert() goes between any two currencies in the table — the base is an implementation detail, not a restriction.
  • The exchange_rate_usd_sgd field on snapshots is legacy. It predates the rate table and is kept only for back-compat (it's used to backfill a missing USD rate); fx_rates is the general mechanism and the scalar is deprecated.

If a rate is missing for a currency you hold, the engine degrades to a best-effort figure and surfaces the gap ("flag, don't fix") rather than crashing or silently guessing.


Design principles

  • Accounting, not guessing. Every output traces back to the identity above.
  • Deterministic and offline. No fetch, no clock reads, no randomness, no storage. Time enters only through asOf, so a run is fully reproducible.
  • Flag, don't fix. If the numbers don't add up, the engine lowers its confidence and surfaces it rather than quietly papering over it.
  • Profile-aware, not one-size-fits-all. Thresholds calibrate to age band, income type, and wealth phase — a 25-year-old accumulator and a 60-year-old in drawdown are benchmarked differently.

Versioning

The package exports a small manifest:

import { ENGINE_VERSION } from "wealth-attribution-engine";
// { package: "0.1.0", compute: 1574441881, presenter: 515807231 }
  • package — the release version; pin it to reproduce a past run exactly.
  • compute — a fingerprint of the math (the attribution / health / rule logic).
  • presenter — a fingerprint of the wording (how insights are phrased).

Because the two are separate, you can tell at a glance whether a change touched the numbers or only the wording — handy for changelogs and for trusting that a copy tweak didn't move a result.


License and contributions

Licensed under the GNU AGPL-3.0. You're free to use, study, modify, and share it under those terms; see LICENSE.

This is published as a read-and-audit showcase, and for now it isn't taking outside code contributions — that keeps relicensing options open for the sole author. Issues, questions, and feedback are genuinely welcome; pull requests just won't be merged in this phase. No hard feelings, and thanks for looking.