7.8 KiB
Design Doc: Contribution / Waterfall Chart - Data Structure
Status: Draft (Iteration 1) Author: fill in Scope: Docs only, no code changes yet. Defines the data structure a future iteration will implement.
1. Summary
A waterfall chart showing how a portfolio moved from its starting value to its ending value over a period, broken into named steps (net contributions, market gain/loss per holding, dividends), gives a much clearer answer to "why did my portfolio change" than a single line ever can. This doc defines the data structure behind that chart.
2. Problem Statement
A portfolio value line can move for several very different reasons at once: money added or withdrawn, price movement of existing holdings, and dividends received. Today none of these are separated out visually. A user whose portfolio value barely moved over a quarter has no quick way to tell whether that's because nothing happened, or because a big withdrawal exactly offset strong market gains.
3. Current State (for context)
There's no existing endpoint that breaks a period's value change into components. The closest existing data is per-activity records (buys, sells, deposits, withdrawals, dividends) and the invested-capital series proposed in the toggle spec. This doc introduces the aggregation layer that turns those into waterfall-ready segments.
4. Goals
- Define a
WaterfallSegmentshape covering a start bar, one or more contribution bars, and an end bar. - Define exactly which components make up a contribution bar (net cash flow, market gain/loss, dividends/distributions) so the numbers are unambiguous and reconcile back to the actual start/end values.
- Support both a portfolio-level rollup and a per-holding breakdown, since "which holdings drove the change" is the more common follow-up question.
- Reuse the existing
TimeRangeSelectionfrom the zoom/pan spec rather than introducing a separate range concept.
5. Non-Goals
- Real-time updating waterfall as the user zooms (the waterfall recomputes per request, same as any other chart series here, it doesn't need to support partial re-renders).
- Tax treatment of any component. That belongs to the withholding-tax work.
- A general "explain any two numbers" diffing tool. This is specific to portfolio value over a time range.
6. Proposed Data Spec
6.1 WaterfallSegment
type WaterfallSegment =
| { kind: 'start'; label: string; value: number }
| {
kind: 'contribution';
label: string;
value: number;
category: 'netCashFlow' | 'marketGainLoss' | 'dividends';
holdingSymbol?: string;
}
| { kind: 'end'; label: string; value: number };
start.valueandend.valueare absolute portfolio values (or holding values, in per-holding mode) in the account's base currency.- Each
contribution.valuecan be positive or negative. Summingstart.valueplus everycontribution.valuemust equalend.valueexactly; this identity is the main correctness check for the whole feature. categorylets the chart color-code and group bars (e.g. allmarketGainLossbars one color, alldividendsbars another), without the frontend needing to infer category from the label text.holdingSymbolis present only in per-holding breakdown mode (see 6.3); absent in the portfolio-level rollup.
6.2 Response shape
interface WaterfallResponse {
startDate: string;
endDate: string;
currency: string;
segments: WaterfallSegment[];
}
6.3 Portfolio-level vs. per-holding mode
- Portfolio-level (default): three contribution bars total,
netCashFlow,marketGainLoss,dividends, each aggregated across the whole portfolio for the period. - Per-holding: one
marketGainLossbar per holding held at any point during the period, plus a single aggregatednetCashFlowbar (deposits/withdrawals aren't tied to a specific holding) and a single aggregateddividendsbar, unless the caller asks to break dividends out per holding too (see open questions).
6.4 Category definitions
netCashFlow: sum of deposits minus withdrawals during the period. This matches the same underlying activity data as theinvestedCapitalseries from the toggle spec, just expressed as a period delta rather than a running total.marketGainLoss: change in market value of holdings held during the period, excluding the effect of buys/sells adding or removing principal (i.e. the pure price-movement component). For a holding bought partway through the period, this is its value at period end minus its cost basis at time of purchase, not fromstartDate.dividends: total distributions received during the period, whether reinvested or paid as cash. This is intentionally shown as a separate positive bar even when reinvested, since the point of the chart is to make dividend contribution visible.
7. API Impact
7.1 Request shape
GET /api/v1/portfolio/waterfall?range=1y
GET /api/v1/portfolio/waterfall?range=1y&breakdown=holding
GET /api/v1/portfolio/waterfall?startDate=2026-01-01&endDate=2026-06-01
breakdownis optional,portfolio(default) orholding.- Standard range validation from the time-range spec applies unchanged.
7.2 Response shape
As in 6.2. New endpoint, no existing behavior to preserve.
8. Frontend State Model
The waterfall is presented as its own chart, likely as a secondary panel alongside the main performance chart (see the wireframe), sharing the same TimeRangeSelection state so zooming the main chart also narrows the waterfall to match. A breakdown toggle switches between portfolio-level and per-holding view without touching the date range.
9. Edge Cases
- Holding fully sold during the period: still included in per-holding breakdown, its
marketGainLossbar reflects gain/loss up to the sale, and its value doesn't carry intoend.valuesince it's no longer held. - No activity at all in the period:
netCashFlowanddividendsbars both show zero rather than being omitted, so the chart shape stays consistent regardless of how many bars there are. - Period where portfolio value is flat but has offsetting bars: this is the primary case the feature is meant to surface, a large negative
netCashFlow(withdrawal) offsetting a large positivemarketGainLoss, and the identity check in 6.1 should make this exactly reconcile rather than approximately. - Currency conversion mid-period: an activity in a foreign currency is converted to the account base currency at its own transaction date, consistent with how the rest of the app already handles this, not at
startDateorendDate.
10. Backward Compatibility
New endpoint, no existing behavior affected. No schema changes, this is a computed aggregation over existing activity and price history data.
11. Open Questions
- Should dividends be breakable out per holding in
breakdown=holdingmode, rather than always aggregated? Leaning toward adding this as a second breakdown option later rather than the default, to keep the initial bar count manageable. - How many per-holding bars is too many before the chart should group small ones into an "other" bucket? Proposing a threshold (e.g. group anything under 2% of total movement) but deferring the exact number to Iteration 2 once there's a real chart to look at.
- Should realized gains from a fully sold holding be visually distinguished from unrealized gains on holdings still held? Currently proposed as no distinction, both fall under
marketGainLoss, but worth revisiting if early feedback wants that split.
12. Summary of Proposed Changes
- Add
WaterfallSegmentandWaterfallResponsetypes. - Add a new
waterfallendpoint withrange/custom date params (reusing the time-range spec) and abreakdownparam. - Define
netCashFlow,marketGainLoss, anddividendsas the three contribution categories, computed from existing activity and price history data. - No schema changes.