diff --git a/project-docs/iteration-1/audit/benchmark-comparison-data-model.md b/project-docs/iteration-1/audit/benchmark-comparison-data-model.md new file mode 100644 index 0000000000..f54f74f9e6 --- /dev/null +++ b/project-docs/iteration-1/audit/benchmark-comparison-data-model.md @@ -0,0 +1,108 @@ +# Design Doc: Benchmark Comparison - Data Model + +**Status:** Draft (Iteration 1) +**Author:** _fill in_ +**Scope:** Docs only, no code changes yet. Defines the data model a future iteration will implement. + +## 1. Summary + +Users want to know if their portfolio is beating a plain index like the S&P 500, not just whether it went up. This doc proposes a data model for overlaying a benchmark's indexed return against the portfolio's own indexed return on the same chart, reusing a market-data symbol (rather than a hardcoded list) as the benchmark. + +## 2. Problem Statement + +A portfolio up 8% over a year sounds good in isolation, but not if a relevant index was up 15% over the same period. Right now there's no way to see that comparison without opening a separate tab and eyeballing two unrelated charts. This is one of the most requested comparisons in personal finance tools generally, and Ghostfolio has no data model for it yet. + +## 3. Current State (for context) + +Ghostfolio already tracks arbitrary symbols for holdings and fetches historical price data for them from its market data providers. There's no existing concept of a "benchmark" separate from a regular holding, this doc proposes benchmarks be modeled as an ordinary symbol lookup, not a special data type, since the underlying data (a security's price history) is identical either way. + +## 4. Goals + +- Define a `BenchmarkComparisonResponse` returning the portfolio's and the benchmark's indexed return series aligned to the same dates. +- Model a benchmark as just a symbol string, reusing the existing market-data symbol lookup rather than building a separate benchmark catalog. +- Handle the common case where a benchmark's available history doesn't fully cover the requested range (e.g. an ETF that launched more recently than the account's oldest activity). +- Reuse the existing `TimeRangeSelection` and granularity rules already defined. + +## 5. Non-Goals + +- A curated list of "popular benchmarks" for the UI to suggest. That's a frontend/UX concern for Iteration 2, this doc only defines the data contract for an arbitrary chosen symbol. +- Per-holding benchmarking (comparing one holding against a sector index). This doc is scoped to whole-portfolio comparison; per-holding can reuse the same shape later if wanted, but isn't specified here. +- Risk-adjusted comparison metrics (Sharpe ratio, alpha, beta). Those are separate analytical features that could consume this same data later, not part of this spec. + +## 6. Proposed Data Spec + +### 6.1 Benchmark identification + +A benchmark is just `{ symbol: string }`, resolved through the same symbol lookup already used for holdings (ticker plus data source, e.g. Yahoo, the same way a stock or ETF is added to a portfolio today). No new benchmark-specific entity is introduced. + +### 6.2 Response shape + +```ts +interface BenchmarkComparisonResponse { + granularity: 'daily' | 'weekly'; + startDate: string; + endDate: string; + benchmarkSymbol: string; + benchmarkAvailableFrom: string; // earliest date the benchmark actually has data + points: Array<{ + date: string; + portfolioReturn: number; // indexed, base 100 at startDate + benchmarkReturn: number; // indexed, base 100 at startDate + }>; +} +``` + +- Both return series are indexed to 100 at `startDate`, matching the convention already used in the total-return vs. price-return spec, so the two comparisons stay visually and conceptually consistent with each other. +- `benchmarkAvailableFrom` lets the frontend show a note like "benchmark data starts later than the selected range" without needing a separate request to find that out. + +### 6.3 Portfolio return basis + +The portfolio side of the comparison uses total return (the same convention as the return-comparison spec, including dividends), since comparing a dividend-inclusive portfolio against a price-only benchmark index would understate the portfolio unfairly. If the benchmark symbol itself is a total-return index variant (some data providers offer both price and total-return versions of the same index), that's a data-source detail resolved at lookup time, not something this spec needs to model differently. + +## 7. API Impact + +### 7.1 Request shape + +``` +GET /api/v1/portfolio/benchmark-comparison?range=1y&benchmark=SPY +GET /api/v1/portfolio/benchmark-comparison?startDate=2026-01-01&endDate=2026-06-01&benchmark=VTI +``` + +Validation rules: + +- `benchmark` is required, there's no meaningful default. +- If the symbol can't be resolved through the existing market-data lookup, return a 404, consistent with how adding an unrecognized holding symbol already behaves. +- If the benchmark's available history starts after the requested `startDate`, don't reject the request, clamp the comparison to start from `benchmarkAvailableFrom` instead and report that clamped `startDate` in the response, mirroring how the time-range spec already clamps to available portfolio history rather than rejecting. + +### 7.2 Response shape + +As shown in 6.2. New endpoint, no existing behavior to preserve. + +## 8. Frontend State Model + +A benchmark selector (searchable symbol input, reusing the existing add-holding search component) sits alongside the range and series controls. Selecting a benchmark overlays its line on the existing return chart rather than replacing it, and clearing the selection removes the overlay without affecting the underlying portfolio series or the active range/zoom state. + +## 9. Edge Cases + +- **Benchmark has a data gap on a day the portfolio has a point (market holiday differences, e.g. a foreign index)**: carry forward the benchmark's last known value for that date rather than leaving a gap in `points`, so the two series stay aligned point-for-point. +- **User picks their own holding as the benchmark**: allowed, there's no reason to special-case it, the same symbol lookup and indexing logic applies regardless of whether the symbol happens to also be held in the portfolio. +- **Very short range (a few days) on a low-liquidity benchmark with sparse pricing**: same carry-forward behavior as the gap case above, this isn't a distinct edge case, just the general rule applied more often. +- **Currency mismatch between benchmark and account base currency**: benchmark prices are converted to the account's base currency before indexing, same conversion approach used elsewhere in the app. + +## 10. Backward Compatibility + +New endpoint, no existing behavior affected. No schema changes, benchmarks are resolved through the existing symbol/market-data lookup rather than a new stored entity. + +## 11. Open Questions + +- Should a user be able to save a "default benchmark" preference so it's pre-selected next time, rather than re-picking it every session? Reasonable follow-up, not required for the initial data model. +- Should multiple benchmarks be comparable at once (portfolio vs. two indices)? The response shape here is built around exactly one benchmark; supporting more would mean an array of benchmark series rather than a single pair, worth flagging now in case Iteration 2 wants to design for it from the start. +- Does the total-return convention need to be configurable (price-only vs. total-return comparison), rather than always defaulting to total return on the portfolio side? Leaning no for the initial version, but noting it since it mirrors an open question already raised in the return-comparison spec. + +## 12. Summary of Proposed Changes + +- Add a new `benchmark-comparison` endpoint returning aligned `portfolioReturn`/`benchmarkReturn` indexed series. +- Model a benchmark as an existing market-data symbol, no new benchmark entity. +- Add clamping behavior for benchmarks with shorter available history than the requested range. +- Reuse the existing `TimeRangeSelection` and granularity rules, no new range concept. +- No schema changes. diff --git a/project-docs/iteration-1/audit/chart-feature-github-issues.md b/project-docs/iteration-1/audit/chart-feature-github-issues.md new file mode 100644 index 0000000000..ab208e4a0d --- /dev/null +++ b/project-docs/iteration-1/audit/chart-feature-github-issues.md @@ -0,0 +1,118 @@ +# Chart Sub-Feature Issues (ready to paste into GitHub) + +I can't file these directly, no GitHub connector is available in this environment. Copy each title/body pair below into "New issue" on the repo. + +--- + +### Issue 1 + +**Title:** Design spec: zoom / pan / selectable time range + +**Body:** +Add support for zooming, panning, and selecting a custom date range on the performance and dividend charts, in addition to the existing fixed presets (1D/WTD/MTD/YTD/1Y/5Y/Max). + +Covers: + +- `TimeRangeSelection` type (preset or custom start/end) +- Granularity resolution based on range width +- API validation and clamping rules for custom ranges + +Design doc: `zoom-pan-time-range-data-spec.md` + +--- + +### Issue 2 + +**Title:** Design spec: portfolio value / invested capital / cash toggle + +**Body:** +Add a toggle on the performance chart to switch the plotted series between portfolio value, invested capital, and cash balance, each as a historical series rather than only a current-day number. + +Covers: + +- `ChartSeriesKind` type +- Historical series definitions for invested capital (step function) and cash +- Shared range/granularity handling with the zoom/pan work + +Design doc: `portfolio-value-invested-capital-cash-toggle-spec.md` + +--- + +### Issue 3 + +**Title:** Design spec: total return vs price return comparison + +**Body:** +Add a comparison view showing total return (including dividends) against price return alone, as two aligned indexed series, at both portfolio and per-holding scope. + +Covers: + +- `ReturnComparisonResponse` shape (indexed, base 100) +- Portfolio-level vs. per-holding scope via `symbol` param +- Reuse of existing total-return calculation convention from ROAI + +Design doc: `total-return-vs-price-return-comparison-spec.md` + +--- + +### Issue 4 + +**Title:** Design spec: contribution / waterfall chart + +**Body:** +Add a waterfall chart breaking a period's portfolio value change into net cash flow, market gain/loss, and dividends, at both portfolio and per-holding level. + +Covers: + +- `WaterfallSegment` type and category definitions +- Portfolio-level vs. per-holding breakdown mode +- Reconciliation identity (start + contributions = end) + +Design doc: `contribution-waterfall-chart-data-structure.md` + +--- + +### Issue 5 + +**Title:** Design spec: benchmark comparison + +**Body:** +Add the ability to overlay a benchmark's indexed return (e.g. an index ETF) against the portfolio's own indexed return, using the existing market-data symbol lookup rather than a separate benchmark catalog. + +Covers: + +- `BenchmarkComparisonResponse` shape +- Handling a benchmark with shorter available history than the requested range +- Portfolio return basis (total return vs. benchmark's own convention) + +Design doc: `benchmark-comparison-data-model.md` + +--- + +### Issue 6 + +**Title:** Design spec: drawdown chart + +**Body:** +Add a drawdown panel showing percentage decline from the portfolio's running all-time peak, plus a max-drawdown summary stat. + +Covers: + +- Drawdown calculation and full-history peak tracking (must not reset at the selected range's start) +- `maxDrawdown` / `maxDrawdownDate` summary fields +- Shared range/zoom state with the main performance chart + +Design doc: `drawdown-chart-calculation-spec.md` + +--- + +### Issue 7 + +**Title:** Wireframe: interactive chart UI + +**Body:** +Low-fidelity wireframe of the combined chart screen: range presets + custom range, series toggle (value/invested/cash), total-vs-price-return overlay, benchmark selector, drawdown panel, and waterfall panel. + +For reference while implementing the above specs, not a pixel-accurate design. + +Wireframe: [Interactive Chart UI Wireframe](https://claude.ai/artifact/VL7vCVBCBdwpzHDCJbAifJ) diff --git a/project-docs/iteration-1/audit/contribution-waterfall-chart-data-structure.md b/project-docs/iteration-1/audit/contribution-waterfall-chart-data-structure.md new file mode 100644 index 0000000000..9ed928de98 --- /dev/null +++ b/project-docs/iteration-1/audit/contribution-waterfall-chart-data-structure.md @@ -0,0 +1,119 @@ +# 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 `WaterfallSegment` shape 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 `TimeRangeSelection` from 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` + +```ts +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.value` and `end.value` are absolute portfolio values (or holding values, in per-holding mode) in the account's base currency. +- Each `contribution.value` can be positive or negative. Summing `start.value` plus every `contribution.value` must equal `end.value` exactly; this identity is the main correctness check for the whole feature. +- `category` lets the chart color-code and group bars (e.g. all `marketGainLoss` bars one color, all `dividends` bars another), without the frontend needing to infer category from the label text. +- `holdingSymbol` is present only in per-holding breakdown mode (see 6.3); absent in the portfolio-level rollup. + +### 6.2 Response shape + +```ts +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 `marketGainLoss` bar per holding held at any point during the period, plus a single aggregated `netCashFlow` bar (deposits/withdrawals aren't tied to a specific holding) and a single aggregated `dividends` bar, 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 the `investedCapital` series 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 from `startDate`. +- **`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 +``` + +- `breakdown` is optional, `portfolio` (default) or `holding`. +- 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 `marketGainLoss` bar reflects gain/loss up to the sale, and its value doesn't carry into `end.value` since it's no longer held. +- **No activity at all in the period**: `netCashFlow` and `dividends` bars 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 positive `marketGainLoss`, 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 `startDate` or `endDate`. + +## 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=holding` mode, 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 `WaterfallSegment` and `WaterfallResponse` types. +- Add a new `waterfall` endpoint with `range`/custom date params (reusing the time-range spec) and a `breakdown` param. +- Define `netCashFlow`, `marketGainLoss`, and `dividends` as the three contribution categories, computed from existing activity and price history data. +- No schema changes. diff --git a/project-docs/iteration-1/audit/drawdown-chart-calculation-spec.md b/project-docs/iteration-1/audit/drawdown-chart-calculation-spec.md new file mode 100644 index 0000000000..16d0af7137 --- /dev/null +++ b/project-docs/iteration-1/audit/drawdown-chart-calculation-spec.md @@ -0,0 +1,104 @@ +# Design Doc: Drawdown Chart - Calculation Spec + +**Status:** Draft (Iteration 1) +**Author:** _fill in_ +**Scope:** Docs only, no code changes yet. Defines the calculation and data shape a future iteration will implement. + +## 1. Summary + +A drawdown chart shows how far a portfolio has fallen from its running peak value at every point in time, which is one of the clearest ways to communicate downside risk. This doc defines exactly how drawdown is calculated, what data shape it's returned in, and how it interacts with the zoom/pan and custom-range work already spec'd. + +## 2. Problem Statement + +Portfolio value and return charts show growth well but don't communicate risk or volatility clearly. Two portfolios can have the same ending return while one had a much rougher ride to get there. A drawdown chart, and the single "max drawdown" number that's commonly quoted alongside it, gives a fast, intuitive read on that. + +## 3. Current State (for context) + +There's no existing peak-tracking or drawdown calculation anywhere in the app. This is a new calculation built on top of the existing portfolio value series (the same underlying series behind the `portfolioValue` chart kind from the toggle spec). + +## 4. Goals + +- Define drawdown precisely: percentage decline from the highest portfolio value seen so far, at each point in time. +- Get the peak-tracking window right: drawdown for a custom or zoomed range must be computed using the portfolio's full history up to each point, not reset to zero at the start of whatever window happens to be selected. +- Define a `maxDrawdown` summary value alongside the series, since that single number is often what people actually want at a glance. +- Reuse the existing `TimeRangeSelection` and granularity rules. + +## 5. Non-Goals + +- Drawdown duration/recovery time analysis (how long a drawdown lasted, how long recovery took). That's a reasonable follow-up metric but isn't part of this spec's response shape. +- Per-holding drawdown. This spec is scoped to whole-portfolio drawdown; the same calculation could apply per-holding later, but isn't defined here. +- Drawdown-based alerts or notifications. Purely a chart/calculation spec. + +## 6. Proposed Data Spec + +### 6.1 Drawdown definition + +At any date `d`, let `peak(d)` be the highest portfolio value at any date on or before `d`, going back to the start of the account's history (not the start of the selected chart range, see 6.3). Then: + +``` +drawdown(d) = (portfolioValue(d) - peak(d)) / peak(d) +``` + +This is always zero or negative, expressed as a percentage. A value of `-0.12` means the portfolio is currently 12% below its all-time high as of that date. + +### 6.2 Response shape + +```ts +interface DrawdownResponse { + granularity: 'daily' | 'weekly'; + startDate: string; + endDate: string; + maxDrawdown: number; // most negative drawdown value within the returned points + maxDrawdownDate: string; // the date at which maxDrawdown occurred + points: Array<{ date: string; drawdown: number; isNewPeak: boolean }>; +} +``` + +`isNewPeak` marks dates where `portfolioValue(d) == peak(d)` (drawdown is exactly zero because a new all-time high was just hit), which lets the chart optionally mark peaks distinctly from ordinary zero-crossing points. + +### 6.3 Peak tracking uses full history, not just the visible window + +This is the one detail most likely to be gotten wrong, so it's called out on its own: `peak(d)` must be computed using portfolio value going back to the very start of the account's history, even when the requested chart range starts later. If peak tracking were reset at the selected `startDate`, then zooming into any window would make drawdown at the start of that window incorrectly show `0%`, even if the portfolio was already well below its true all-time high at that point. The `points` array only covers the requested range, but each point's `drawdown` value must reflect the true, full-history peak as of that date. + +## 7. API Impact + +### 7.1 Request shape + +``` +GET /api/v1/portfolio/drawdown?range=5y +GET /api/v1/portfolio/drawdown?startDate=2026-01-01&endDate=2026-06-01 +``` + +Standard range validation from the time-range spec applies unchanged. No new params beyond the existing range/date ones, since peak tracking is always full-history by definition (6.3) and isn't a configurable option. + +### 7.2 Response shape + +As shown in 6.2. New endpoint, no existing behavior to preserve. + +## 8. Frontend State Model + +Shown as a secondary panel below or beside the main performance chart (see the wireframe), sharing the same `TimeRangeSelection` so zooming the main chart also zooms the drawdown panel to the same window, while the underlying peak calculation still uses full history per 6.3. `maxDrawdown` and `maxDrawdownDate` can be surfaced as a small stat next to the panel rather than requiring the user to visually find the lowest point themselves. + +## 9. Edge Cases + +- **Account with less than one data point of history**: drawdown is `0` for the single available point, `maxDrawdown` is `0`, there's no meaningful drawdown to compute yet. +- **Portfolio at an all-time high for the entire selected range**: every point has `drawdown: 0` and `isNewPeak: true` wherever the value strictly exceeds the prior peak, this is a valid and simple case, not an error or empty state. +- **Selected range entirely within a single ongoing drawdown that started before the range and hasn't recovered by the range's end**: this is exactly the case 6.3 is designed to handle correctly, every point's drawdown reflects distance from the true prior peak, not from the value at the range's own start. +- **Multi-currency portfolios**: drawdown is computed on the same base-currency portfolio value series already used elsewhere, no separate currency handling needed here. + +## 10. Backward Compatibility + +New endpoint, no existing behavior affected. No schema changes, this is a computed series over the existing portfolio value history. + +## 11. Open Questions + +- Should `maxDrawdown` in the response be the max within the visible range, or always the true all-time max drawdown regardless of the selected range? Proposing within-range as the default (matching how a zoomed chart's own stat should describe what's on screen), with the true all-time max available separately by requesting `range=max`. +- Is percentage-based drawdown sufficient, or should an absolute-currency drawdown also be available? Leaning percentage-only for the initial version since it's the more standard convention and comparable across accounts of different sizes. +- Should recovery time (dates from trough back to a new peak) be added as a follow-up field later? Noted as a likely next iteration, not included here per section 5. + +## 12. Summary of Proposed Changes + +- Add a new `drawdown` endpoint returning per-date drawdown percentage plus a `maxDrawdown` summary. +- Define peak tracking as always full-history, independent of the requested chart range, to avoid incorrect drawdown values at the start of a zoomed window. +- Reuse the existing `TimeRangeSelection` and granularity rules, no new range concept. +- No schema changes.