wealth-attribution-engine
v0.2.0
Published
Deterministic wealth attribution engine: decomposes wealth change into income saved, market return, and outflows. Auditable, no black boxes, no ML.
Downloads
15
Maintainers
Readme
wealth-attribution-engine
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 wealthThat'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.
And when you want a single number to track over time, the engine rolls those same forces into a 0–100 financial health score: a weighted blend of five factors with a plain-English status band, every point traceable back to arithmetic you can check. It's the headline read, and attribution is the reason underneath it. How it's calculated →
- 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 either way. 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:
- Take-home income per period. One number you already know.
- 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-engineShips 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.mjsWhy 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,000Using 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.mjsThree 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,200Periods 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. |
Position-level attribution
For investment accounts, attribution can go one level below the account: instead of "this brokerage returned S$3,000", you get a return per holding, with the capital you deployed into each one separated out. That breakdown arrives on AssetChangeDetail.advancedDetail.
When it applies, decided per period. A period is position-tracked when both of these hold:
- The account type is in
INVESTMENT_ASSET_TYPES(BROKERAGE,CRYPTO_EXCHANGE,DEFI_PROTOCOL). - Market-priced sub-asset rows (
MARKET_SUB_ASSET_TYPES:STOCK,CRYPTO_ASSET,DEFI_POSITION) are present on both sides of the period.
The test reads that period's balance entries, never a current-value flag on the asset. As of 0.2.0 the engine does not read assets.brokerage_mode at all: the field stays on the Asset type so existing callers still typecheck, but no figure the engine reports depends on it. Before 0.2.0 that account-wide flag was the gate.
Why "both sides" matters. If you convert an account from a single balance to individual holdings part-way through its history, the period spanning the conversion has no opening positions to compare against. Requiring an opening baseline makes that one period fall back to account-level attribution. Without it, every opening position would read as freshly bought and the engine would report the entire account value as capital deployed that month. Periods before the conversion stay account-level, periods after are position-level, and the same account can convert more than once. Sub-asset rows left behind by a conversion carry retired_at, set per sub-asset rather than per account, so each generation of rows is dated independently.
The aggregate never moves. Cash in this split is defined as everything that is not a market row: the uninvested CASH_SUB_ASSET_TYPES rows plus any residual, such as a retired account-level row. Defining it as the complement rather than as CASH_ACCOUNT alone keeps the ledger identity exact, so marketMovement always equals total change minus net deposit. The position path only adds detail; it cannot disagree with the account-level total.
What you get back. calculateAssetChanges(current, previous) returns Map<assetId, AssetChangeDetail>. On a position-tracked period, advancedDetail is populated:
| Field | What it is |
| --- | --- |
| cashChangeNative | Change across the non-market rows |
| stocksChangeNative | Change across the market rows |
| capitalDeployedNative | netDeposit − cashChange: money that moved from cash into positions |
| marketMovementNative / marketMovementSGD | Aggregate return, in native and reporting currency |
| perStockReturns | Map<subAssetId, PerStockReturn>, the per-position breakdown |
Each PerStockReturn carries returnsNative / returnsSGD, capitalDeployedNative, unitsChange, currentUnits / previousUnits, currentValueNative / previousValueNative, and a confidence of EXACT or ESTIMATED.
How capital is split across positions, and when that split is exact. The ledger supplies one constraint, total capital deployed = net deposit − cash change, which has to be distributed across the holdings whose unit counts moved:
| Case | Split | Confidence |
| --- | --- | --- |
| Units unchanged (held) | No capital deployed; the whole value change is market return | EXACT |
| Exactly one position changed units | It absorbs all deployed capital | EXACT |
| Several changed, all in the same direction | Distributed proportionally by implied value, so the total still reconciles | EXACT |
| Both buys and sells in one period | Each position uses its own implied price, unscaled | ESTIMATED |
Only the mixed buy-and-sell case is an estimate, and it declares itself per position rather than quietly blending into the total. Implied price is value divided by units on the closing side, falling back to the opening side when a position was fully exited.
The health score
Attribution tells you why wealth moved; the financial health score rolls those same forces into a single 0–100 headline for the analysis window, plus a plain-English status band, so you get one number to watch and the full breakdown of how it was reached. Like everything else here, it's arithmetic you can check by hand, not a black box: no ML, no opaque weighting, every point traceable.
Five weighted factors. Each is scored 0–100 on its own, then blended by a fixed weight (they sum to 100). Every factor is reported in healthScore.factors with the points it earned and lost, so nothing is hidden:
| Factor | Weight | Measures | Higher is |
| --- | --- | --- | --- |
| savings-rate | 30 | Share of income you kept | better |
| outflow-rate | 20 | Share of income that left | worse |
| growth-rate | 20 | How hard your assets are working | better |
| liquidity-ratio | 15 | Cash buffer vs outflows | better |
| conversion-rate | 15 | Whether saved money is being deployed and compounding | better |
From a ratio to a sub-score. Each factor's raw ratio is mapped onto 0–100 by linear interpolation through anchor points, which is smooth: a small change in the ratio moves the score a small amount, with no step-function cliffs. For example, savings-rate interpolates through -50% → 10, 0% → 25, 10% → 45, 20% → 70, 30% → 85, 50% → 100; the other ratio factors use their own anchors. (conversion-rate is already emitted as a 0–100 score by the conversion engine, so it maps straight through with no remap.)
Blending, and graceful degradation. The base score is the weighted average of whichever factors are available, renormalized over the weight actually present. A missing factor (e.g. no conversion data yet) therefore doesn't drag the score toward zero; it just widens the others' share. healthScore.coverage reports what % of the 100-point weight was available, and a low coverage means read the score as provisional.
Adjustments. After the blend, a few point deltas can apply, each surfaced in healthScore.adjustments with a reason:
| Adjustment | Delta | When | | --- | --- | --- | | Concentration penalty | −4 or −8 | One asset carries an outsized share of recent growth and downside risk | | Declining engine | −10 | The latest period is classified as declining | | Data confidence | −6 | Attribution confidence is low (inconsistent cash-flow inputs) | | Snapshot consistency | −3 / −6 / −10 | Snapshots average roughly every 5–9 / 9–13 / 13+ weeks (aim for monthly) |
The result is clamped to 0–100 and bucketed into a status band:
| Band | Score | | --- | --- | | Strong | ≥ 80 | | Healthy | 65–79 | | Watchlist | 45–64 | | At Risk | < 45 | | Insufficient Data | no factor could be scored |
Alongside the score and band, healthScore carries baseScore (before adjustments), strengths / risks (the top couple of each), and a primaryAction, the single most useful next move given what's weakest. healthTrend runs the whole calculation at each snapshot in the window, so you can chart the direction of travel rather than just the latest reading.
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 forwardgrowth_trajectory_projection/habits_trajectory_projection. - Goal pacing (when you pass goals). Ahead of or 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. |
| calculateAssetChanges(current, previous) | Per-asset change detail for one snapshot pair, including the position-level breakdown. |
| 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:
reportingCurrencycan 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_ratestable (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"), butconvert()goes between any two currencies in the table. The base is an implementation detail, not a restriction. - The
exchange_rate_usd_sgdfield 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_ratesis 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 throughasOf, 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.2.0", compute: 1310041588, presenter: 68547087 }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, which is 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.
