#3494·sure

feat: Investment / Trading Upgrades

Author: jaysbeekayCreated Sep 10, 2026Updated Sep 16, 2026

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:

  1. Holdings are not consolidated family-wide. InvestmentStatement#top_holdings returns the top 5 and duplicates a security held in more than one account (#2291), and there is no family-wide value series — Balance::ChartSeriesBuilder already accepts many account_ids but is never called that way.
  2. Cash flows are not classified. balances.cash_inflows does not distinguish an external deposit from a dividend landing in cash, so a time-weighted return cannot be read off balances as they stand. Dividends themselves exist in three shapes today — income Trades with qty: 0 (since #1311), Transactions carrying extra["security_id"] (Trading212, SimpleFIN), and Plaid dividends written with amount 0 — and InvestmentStatement::Totals currently 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, which AGENTS.md points 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:

  1. 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.
  2. 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

Objections to either are much cheaper now than after the code exists.