feat: Investment / Trading Upgrades
This is a summary/coordination issue, not a feature request. It describes a programme of work
being planned and prototyped in the fork jaysbeekay/sure, so that the parts intended for
upstream arrive as reviewable, self-contained PRs rather than as a surprise, and so existing
upstream issues and PRs that already cover pieces of it are adopted rather than duplicated.
Full epic and per-drop breakdown: jaysbeekay/sure#118.
Intent
Bring Sure to feature parity with other open-source portfolio trackers on investment tracking, performance, allocation, income and planning. The target end state is a family-wide portfolio surface that shows every investment account together and layers on the analysis those tools offer:
- time-weighted and money-weighted returns, annualised return, volatility, max drawdown
- a drivers breakdown (market / income / fees / FX / flows) that reconciles to the value change
- allocation by asset class, sub-class, sector, region, currency, account and tag
- dividend, interest and fee tracking across all accounts
- benchmarks, portfolio X-ray, allocation targets and rebalancing, a retirement/FIRE planner, contribution-room tracking
Sure was audited at e53618f7f (2026-09-09) against those tools' feature lists. The full
have / partial / missing inventory is in the epic body.
The two structural gaps everything else waits on:
- Holdings are not consolidated family-wide.
InvestmentStatement#top_holdingsreturns the top 5 and duplicates a security held in more than one account (#2291), and there is no family-wide value series —Balance::ChartSeriesBuilderalready accepts manyaccount_idsbut is never called that way. - Cash flows are not classified.
balances.cash_inflowsdoes not distinguish an external deposit from a dividend landing in cash, so a time-weighted return cannot be read offbalancesas they stand. Dividends themselves exist in three shapes today — incomeTrades withqty: 0(since #1311),Transactions carryingextra["security_id"](Trading212, SimpleFIN), and Plaid dividends written with amount 0 — andInvestmentStatement::Totalscurrently returns 0 for all of them (#3350).
Everything downstream (performance, income, X-ray, targets, FIRE) is blocked on those two, which is why the delivery order below starts there.
Proposed delivery mechanism
Fourteen discrete drops, each a visible release, split by where the code should live:
Model-only work goes upstream directly, as small PRs against
we-promise/sure. This is drops 0, 2, 3 and 4 — the statement/series/classifier layer, the performance engine, the security taxonomy and provider classification ingestion, and income normalisation. None of it requires a new page, and all of it is useful to Sure on its own.Every drop is intended for upstream. Page-level work is built and proven in the fork behind a preview-feature gate first, then proposed upstream preview-gated, after the model drops it depends on have landed there. "Fork-first" is a staging step, not a destination: what a page drop waits on is the model layer it needs, never a fork/upstream boundary. It follows as a separate proposal rather than riding in on a model PR.
The gate is the mechanism upstream already documents in
docs/llm-guides/gating-a-preview-feature.md, whichAGENTS.mdpoints to for feature rollout. The fork's hub already uses it, so a page proposal arrives gated by default and can be merged dark.This is a change from an earlier draft of this issue, which made page work wait on #1924 to settle the shape of an Investments surface. That is no longer the dependency, for two reasons: the gating question it was exploring is already answered by the guide above, and a design question of that size is easier to answer against a concrete gated page than in the abstract. #1924 is treated as prior art and evidence of appetite, not as a precondition. If it or a successor lands first, the page proposal adopts its shape rather than competing with it.
Where an upstream PR already exists for a piece, the plan is to adopt it rather than rewrite it — see the mapping below.
A drop is a fork milestone, never a single upstream PR. Each one arrives here as a sequence of small, independently mergeable PRs — one reviewable idea each, split along the dependency seam (schema → model → ingestion → UI), with a soft ceiling of roughly 300 lines of non-test diff. Drop 0 is six sub-PRs, two of which are adoptions of PRs already open here rather than new code. The intent is that progress is visible continuously and that no reviewer is ever handed a thousand-line change to judge at once.
| Drop | Fork issue | Title | Upstream intent |
|---|---|---|---|
| 0 | jaysbeekay/sure#119 | Foundations | yes — model only; two PRs adopted rather than rewritten |
| 1 | jaysbeekay/sure#120 | Portfolio hub page | yes — UI, preview-gated, after drop 0 |
| 2 | jaysbeekay/sure#121 | Performance engine and dashboard | yes — model, then UI preview-gated |
| 3 | jaysbeekay/sure#122 | Classification and allocation | yes — ingestion, then UI preview-gated |
| 4 | jaysbeekay/sure#123 | Income tracking | yes — model + import, then UI |
| 5 | jaysbeekay/sure#124 | Benchmarks, watchlist, market mood | yes — preview-gated, after 2 |
| 6 | jaysbeekay/sure#125 | Portfolio X-ray | yes — preview-gated, after 3, 4 |
| 7 | jaysbeekay/sure#126 | Allocation targets and rebalancing | yes — preview-gated, after 3 |
| 8 | jaysbeekay/sure#127 | Retirement and FIRE planner | yes — preview-gated, after 2 |
| 9 | jaysbeekay/sure#128 | Contribution rooms and limits | yes — model + UI, after 0 |
| 10 | jaysbeekay/sure#129 | Net worth polish | yes — UI polish |
| 11 | jaysbeekay/sure#130 | Spending polish | yes — UI polish |
| 12 | jaysbeekay/sure#131 | AI assistant parity | yes — after 2, 3 |
| 13 | jaysbeekay/sure#132 | UX parity | yes — UI polish |
Critical path is 0 → 1 → 2 → 3 (~11 weeks); drops 10, 11 and 13 have no dependencies and can be interleaved. Roughly eight months of single-developer work to full parity, first visible page in week three.
Existing upstream issues and PRs this builds on
| Upstream | What it covers | How this programme relates |
|---|---|---|
| #2927 | Holdings rolled up by security in InvestmentStatement#top_holdings / #allocation |
Adopt as-is (drop 0.1). Everything family-wide depends on it |
| #3277 | Holding weight when cash is negative or absent | Fixed alongside 0.1 in the same area |
| #2291 | Consolidated holdings — one row per security across accounts | The behaviour #2927 delivers; this is the issue it answers |
| #3350 | InvestmentStatement::Totals returns 0 for dividends/interest |
Adopt (drop 0.4), extended with a fees total from trades.fee. Also the source of the open question about transaction-shaped dividends predating #1311 — the plan reads both shapes rather than migrating |
| #3364 | Dividend / interest rows in CSV import | Delivered as drop 4.3: TradeImport accepts amount and an activity-label column |
| #1311 | Income trades with qty: 0 |
The shape the FlowClassifier treats as canonical; the two older shapes are read, not migrated |
| #3220 | Activity-type ambiguity | Feeds directly into the flow classifier's external / income / fee / internal mapping |
| #1924 | Investments hub / dedicated investments surface (draft exploration spike) | Prior art, not a gate. It is the clearest signal that a dedicated investments surface is wanted, and the page proposal will adopt its shape where it fits. It is not treated as a precondition: it is a draft marked not-for-merge, last updated 2026-08-31, and page work here is gated instead by the preview-feature mechanism upstream already documents |
jaysbeekay/sure#107 tracks a separate, unrelated upstream delivery (repurposing #3296) and is
listed only so the two are not confused.
What upstream is being asked for here
Nothing to merge yet. Two things would be useful before the first PRs arrive:
- A view on appetite for a family-wide portfolio page — whether one is wanted upstream at all, and if so roughly in what shape. #1924 suggests the appetite exists; a maintainer sentence either way would say whether drops 1 and beyond are worth proposing here. This is not a blocker on the model drops, which stand on their own, and no page PR will be opened before the model layer it sits on has landed.
- A sanity check on the performance conventions in drop 2, which are hard to change once
users have compared numbers against their brokers:
- start-of-day external-flow convention for
r_t = V_end / (V_start + F_t) − 1 - historical FX per date from
exchange_rates, never today's rate, with a missing rate surfaced as a warning rather than silently coalesced to 1 - money-weighted return via a pure-Ruby XIRR (Newton with bisection fallback), no new gem
- start-of-day external-flow convention for
Objections to either are much cheaper now than after the code exists.
Source: we-promise/sure