diff --git a/project-docs/iteration-1/audit/portfolio-value-invested-capital-cash-toggle-spec.md b/project-docs/iteration-1/audit/portfolio-value-invested-capital-cash-toggle-spec.md new file mode 100644 index 0000000000..6054d1f894 --- /dev/null +++ b/project-docs/iteration-1/audit/portfolio-value-invested-capital-cash-toggle-spec.md @@ -0,0 +1,132 @@ +# Design Doc: Portfolio Value / Invested Capital / Cash Toggle - Data Spec + +**Status:** Draft (Iteration 1) +**Author:** _fill in_ +**Scope:** Docs only, no code changes yet. This defines the data model and API contract for a chart series toggle that a future iteration will implement. + +## 1. Summary + +The performance chart today shows a single series: portfolio value over time. This doc proposes a data spec for a toggle that lets a user switch what that series represents, between portfolio value, invested capital (net contributions), and cash balance, without needing three separate chart components or three separate endpoints. + +## 2. Problem Statement + +A user watching their portfolio value line go up has no easy way to tell how much of that is market performance versus how much is just money they put in. Two related questions people ask: + +- "How much of this growth is mine versus the market's?" (portfolio value vs. invested capital) +- "How much of my portfolio is sitting uninvested right now, over time?" (cash) + +Right now the only way to approximate this is to look at separate pages (allocation, cash accounts) and mentally line up dates. There's no shared chart series concept that can represent any of these three, so the toggle can't be built without first agreeing on what data shape backs it. + +## 3. Current State (for context) + +The performance chart today fetches a single time series tied to portfolio value: + +```ts +interface ChartSeriesResponse { + granularity: 'daily' | 'weekly'; + startDate: string; + endDate: string; + points: Array<{ date: string; value: number }>; +} +``` + +`value` here is implicitly "portfolio value" and nothing else. Invested capital is computed elsewhere (as a single current-day number, for ROI calculations) but not as a historical series. Cash balance is tracked per account as a current snapshot, not as a time series either. + +## 4. Goals + +- Define a `ChartSeriesKind` enum so the frontend and backend agree on what a requested series represents. +- Extend the chart series response so each of the three kinds returns points in the same shape, so the chart component doesn't need per-kind rendering logic. +- Define how invested capital and cash are computed as historical series, since today they only exist as current-day numbers. +- Keep this compatible with the time-range selection spec (custom ranges, granularity resolution) already defined separately, so the toggle and the zoom/pan work don't conflict. + +## 5. Non-Goals + +- Changing how invested capital or cash are calculated for their current, non-historical uses elsewhere in the app (e.g. the ROI card, the accounts page). +- Multi-series overlay (showing more than one of the three at once on the same chart). That's a reasonable follow-up but is left for a later doc, this one only covers a single active toggle at a time. +- Currency conversion edge cases beyond what the existing performance endpoint already handles. + +## 6. Proposed Data Spec + +### 6.1 `ChartSeriesKind` + +```ts +type ChartSeriesKind = 'portfolioValue' | 'investedCapital' | 'cash'; +``` + +This is passed alongside the existing `range` or custom `startDate`/`endDate` params, defaulting to `portfolioValue` so existing callers see no change in behavior if they never send it. + +### 6.2 Response shape + +Reuse the existing `ChartSeriesResponse` shape and just tag which kind it is, so the frontend can label the axis and tooltip correctly without guessing: + +```ts +interface ChartSeriesResponse { + kind: ChartSeriesKind; + granularity: 'daily' | 'weekly'; + startDate: string; + endDate: string; + points: Array<{ date: string; value: number }>; +} +``` + +`value` means something different per kind, but is always a single number in the account's base currency, so the chart component itself doesn't need to branch on `kind` to render the line, only to label it. + +### 6.3 What each kind means, as a series + +- **`portfolioValue`**: unchanged from today. Market value of all holdings plus cash, at each point in time. +- **`investedCapital`**: cumulative net contributions up to each point in time, meaning deposits minus withdrawals, running forward chronologically through the account's activity history. This is a step function that only changes on days with a buy/sell/transfer activity, not a smooth daily curve, and the chart should be allowed to render it as such (e.g. a step-line) rather than assuming smooth interpolation. +- **`cash`**: cash account balance at each point in time, computed the same way account balances are computed today, just sampled across the requested date range instead of only at "now." + +### 6.4 Relationship to the time-range spec + +This reuses the same `TimeRangeSelection` (preset or custom) and the same granularity resolution rules already defined for zoom/pan. A `kind` param and a `TimeRangeSelection` are fully independent of each other, changing one should never require re-deriving the other. This means a user can zoom into a narrow window on any of the three kinds, and pan/zoom state should be preserved across a toggle switch rather than reset, since the user is very likely trying to compare the same window across kinds. + +## 7. API Impact + +### 7.1 Request shape + +``` +GET /api/v1/portfolio/performance?range=1y&kind=investedCapital +GET /api/v1/portfolio/performance?startDate=2026-01-01&endDate=2026-06-01&kind=cash +``` + +Validation rules: + +- `kind` is optional, defaults to `portfolioValue`. +- An unrecognized `kind` value returns a 400 rather than silently falling back, so a frontend bug doesn't quietly show the wrong series. +- All existing range validation rules from the time-range spec apply unchanged, `kind` doesn't affect date validation. + +### 7.2 Response shape + +As shown in 6.2. The added `kind` field is the only change to the response body, so existing clients that don't send `kind` and don't read it keep working exactly as before. + +## 8. Frontend State Model + +The chart component holds `kind: ChartSeriesKind` as a small piece of state alongside the existing `TimeRangeSelection`. Switching `kind` triggers a re-fetch with the same range but a different `kind` param, it does not reset the range. The three toggle options can be rendered as a simple segmented control above or below the existing range preset buttons. + +## 9. Edge Cases + +- **No activity yet in the selected range**: `investedCapital` returns a flat line at whatever the cumulative contribution was entering the range (possibly zero), not an empty series, since "how much have I put in as of this date" is always a well defined number even with no activity inside the visible window. +- **Cash goes negative briefly**: if margin or a pending settlement causes a momentary negative cash balance, the series should reflect that as-is rather than clamping to zero, since hiding it would be misleading. +- **Account added mid-range**: if an account is added partway through the selected window, its cash and invested-capital contributions before its creation date are simply absent (treated as zero), consistent with how it doesn't exist yet for portfolio value either. +- **Multi-currency accounts**: `value` in all three kinds is already expected to be converted to the account's base currency, same as the existing `portfolioValue` series does today. No new conversion logic needed, just applied consistently to the other two kinds. + +## 10. Backward Compatibility + +- Existing clients that never send `kind` keep getting `portfolioValue`, identical to current behavior. +- The added `kind` field in the response is additive. +- No database schema changes. `investedCapital` and `cash` as historical series are computed from existing activity/account data, not stored separately. + +## 11. Open Questions + +- Should `investedCapital` be rendered as a step line or interpolated smoothly in the chart itself? Leaning step line since it's a more honest representation of a value that only changes on discrete days, but this is a rendering choice for Iteration 2, not a data shape question. +- Do we want a fourth kind later for "invested capital plus cash" as a combined baseline to compare against `portfolioValue`? Deferring, since it's really a multi-series overlay concern and out of scope per section 5. +- Should switching `kind` reset any active zoom, or preserve it as proposed in 6.4? Proposing preserve, open to feedback if user testing suggests otherwise. + +## 12. Summary of Proposed Changes + +- Add `ChartSeriesKind` (`portfolioValue` | `investedCapital` | `cash`) as a shared type. +- Add optional `kind` query param to chart/performance endpoints, defaulting to `portfolioValue`. +- Add `kind` field to the chart series response. +- Define `investedCapital` and `cash` as historical series computed from existing data, reusing current-day calculation logic sampled across a date range. +- No schema changes. diff --git a/project-docs/iteration-1/audit/total-return-vs-price-return-comparison-spec.md b/project-docs/iteration-1/audit/total-return-vs-price-return-comparison-spec.md new file mode 100644 index 0000000000..3081a38184 --- /dev/null +++ b/project-docs/iteration-1/audit/total-return-vs-price-return-comparison-spec.md @@ -0,0 +1,120 @@ +# Design Doc: Total Return vs Price Return Comparison - Data Spec + +**Status:** Draft (Iteration 1) +**Author:** _fill in_ +**Scope:** Docs only, no code changes yet. This defines the data model and API contract for a chart comparison feature that a future iteration will implement. + +## 1. Summary + +The performance chart today plots portfolio value based on market price movement, and dividends are tracked separately, but there's no view that shows how much dividends and other distributions actually contributed to overall return. This doc proposes a data spec for a comparison between total return (price movement plus reinvested/collected distributions) and price return (price movement alone), as two series that can be shown together on the same chart. + +## 2. Problem Statement + +A holding that pays a steady dividend can look flat or even negative on a pure price chart while still delivering a healthy return once dividends are counted. Right now there's no way to see that gap. Users have to separately check the dividend calendar/totals and mentally combine that with the price chart to understand it. This is especially relevant for dividend-focused or income-focused portfolios where price return alone understates what the holding actually delivered. + +## 3. Current State (for context) + +The chart series today (as used by the range and toggle specs already written) represents portfolio value, which does already include the effect of cash from collected dividends sitting in the account, but it does not show that contribution as a distinct, comparable line. Dividend data itself exists per activity, tied to individual holdings, but isn't currently expressed as a cumulative time series that can be compared point-for-point against a price series. + +## 4. Goals + +- Define what "price return" and "total return" mean as two individually well-defined time series, not just a single blended number. +- Define a comparison response shape that returns both series aligned to the same dates, so the chart can draw them together without the frontend having to reconcile two separately-fetched, potentially misaligned series. +- Make this work at both the single-holding level and the whole-portfolio level, since the question "how much did dividends help" is meaningful at both scales. +- Keep this compatible with the existing time-range and granularity rules already defined, so this doesn't introduce a second, incompatible notion of date range. + +## 5. Non-Goals + +- Tax treatment of dividends (qualified vs. non-qualified, withholding tax, etc.). That's covered by the separate withholding-tax design work and isn't touched here. +- Changing how ROI/ROAI are calculated as single current-day numbers. This is about historical series for charting, not the summary metrics shown elsewhere. +- Benchmark comparison (comparing total return against a market index). That's a related but separate feature, out of scope here. + +## 6. Proposed Data Spec + +### 6.1 Definitions + +- **Price return**: the return attributable purely to the change in market price of the holding(s), ignoring any distributions. Expressed as an indexed value starting at a base of 100 (or 1.0) at the start of the selected range, so the two series are visually comparable in percentage terms regardless of the holding's absolute price. +- **Total return**: the same, but with distributions (dividends, interest, or other cash payouts) added back in as if reinvested at the time received, or simply accumulated as cash contribution to return, whichever convention the existing ROAI calculation already uses. This spec reuses that convention rather than introducing a second one, so total return here stays consistent with the total return number already shown elsewhere in the app. + +Both are indexed series, not absolute currency values, since the point of the comparison is the shape of the gap between them, not the absolute portfolio value (which is already covered by the existing `portfolioValue` chart kind). + +### 6.2 Response shape + +```ts +interface ReturnComparisonResponse { + granularity: 'daily' | 'weekly'; + startDate: string; + endDate: string; + points: Array<{ + date: string; + priceReturn: number; // indexed, base 100 at startDate + totalReturn: number; // indexed, base 100 at startDate + }>; +} +``` + +Both values are given per date so the chart can draw two aligned lines from a single response, rather than the frontend fetching two series and matching them up by date itself, which would be fragile if the two series ever had gaps on different dates. + +### 6.3 Scope parameter + +Since this is meaningful both per-holding and for the whole portfolio, the request needs to specify which: + +```ts +type ReturnComparisonScope = + { level: 'portfolio' } | { level: 'holding'; symbol: string }; +``` + +At the portfolio level, distributions from all holdings are aggregated. At the holding level, only that holding's own distributions count. + +### 6.4 Relationship to the time-range and toggle specs + +This reuses the same `TimeRangeSelection` and granularity rules from the zoom/pan spec. It's a distinct endpoint/response shape from the `kind` toggle spec (portfolio value / invested capital / cash) rather than a fourth `kind`, since those are absolute-value series and this is an indexed comparison, mixing the two into one response shape would make the response ambiguous about units. They can still be shown as separate views the user switches between, but the specs stay independent. + +## 7. API Impact + +### 7.1 Request shape + +``` +GET /api/v1/portfolio/return-comparison?range=1y +GET /api/v1/portfolio/return-comparison?range=1y&symbol=AAPL +GET /api/v1/portfolio/return-comparison?startDate=2026-01-01&endDate=2026-06-01&symbol=AAPL +``` + +Absence of `symbol` implies portfolio-level scope. Validation rules: + +- If `symbol` is present but not held in the account (currently or historically) within the requested range, return a 404 rather than an empty series, since that's likely a client bug (typo, stale symbol) rather than a legitimate empty state. +- All existing date-range validation rules apply unchanged. +- A holding with zero distributions in the range still returns valid data, with `priceReturn` and `totalReturn` simply equal to each other for that entire range. + +### 7.2 Response shape + +As shown in 6.2. This is a new endpoint rather than an extension of the existing chart series endpoint, since the response shape (two indexed values per point) is different enough from the existing single-value-per-point shape that reusing the same endpoint would require clients to branch on response shape based on a request param, which is worse than just having two clearly-named endpoints. + +## 8. Frontend State Model + +This is proposed as an overlay/comparison view rather than a toggle, since the value of the feature is in seeing both lines at once. The chart component would show `priceReturn` and `totalReturn` as two lines on the same axes, with the gap between them being the visually interesting part. A holding-level version of the chart would need a symbol selector, most likely reusing an existing holding-picker component rather than a new one. + +## 9. Edge Cases + +- **Holding sold before end of range, then range extended past the sale**: once a holding is fully sold, both series should hold flat at their last computed value for the remainder of the range rather than dropping to zero or disappearing, so a user comparing "how did this do while I held it" isn't confused by a sudden drop that isn't a real price movement. +- **Holding bought partway through range**: both series start at their base-100 index from the holding's first activity date within the range, not from the range's `startDate`, since indexing from a date before the holding existed would be meaningless. +- **Distribution reinvested automatically vs. paid as cash**: both cases should be treated identically for `totalReturn` purposes, the mechanism of reinvestment doesn't change the fact that the value was received, only whether it shows up as new units or as cash. This spec doesn't need to distinguish them at the response level. +- **Portfolio-level scope with a mix of currencies**: distributions from holdings in different currencies are converted to the account's base currency before being folded into `totalReturn`, consistent with how the rest of the app already handles multi-currency aggregation. + +## 10. Backward Compatibility + +- This is a new endpoint, so there's no existing behavior to preserve. No changes to existing endpoints or response shapes. +- No database schema changes. Both series are computed from existing activity and price history data. + +## 11. Open Questions + +- Should the gap between the two lines be exposed as its own explicit value (e.g. `dividendContribution: number`) rather than leaving the frontend to compute `totalReturn - priceReturn`? Leaning toward leaving it derived on the frontend to keep the response minimal, but open to adding it if the chart tooltip design ends up wanting it directly. +- Should there be a portfolio-level breakdown by holding (which holdings contributed most to the total-return gap)? That feels like a natural follow-up feature but is a separate spec, not part of this one. +- Confirm which reinvestment convention the existing ROAI calculation actually uses today, so `totalReturn` here matches it exactly rather than introducing a subtly different number under a similar name. + +## 12. Summary of Proposed Changes + +- Add a new `return-comparison` endpoint returning aligned `priceReturn`/`totalReturn` indexed series. +- Support both portfolio-level and per-holding scope via an optional `symbol` param. +- Reuse the existing `TimeRangeSelection` and granularity rules, no new range concept introduced. +- No schema changes, calculation reuses the existing total-return convention from ROAI.