ADR-0051: contribution analysis — which position made how much of a period’s result
- Status: Accepted. Owner sign-off is the merge of the Sprint 17 planning PR (ADR-0026 step 1, as amended on PR #780: the merge is the signature).
- Date: 2026-09-25 (written in Sprint 16; signed at Sprint 17’s planning).
- Amended: 2026-10-03: what the building batch’s review round found the record left open, with its notes of 2026-10-05 and 2026-10-06 (see “Amendment (2026-10-03)” below). 2026-10-09, adopted by the merge of the Sprint 21 planning PR: a security-linked interest booking is its position’s income (#928; see “Amendment (2026-10-09)” below).
- Answers: FR-41, contribution analysis, scope-ladder level (b): “which position produced how much of the return, over a selectable period, scoped to a view.”
- This is not a scope gate. Level (b) has been open since 2026-08-12. This is the design gate ADR-0026 step 1 requires before a batch starts, in the shape of ADR-0047. Following ADR-0043, the closing table lists each ask as answered or deferred.
Context
At Sprint 16’s planning (D-7), the FR Coverage Map asked whether FR-41 is a thin extension of ADR-0041’s category result. It is not. ADR-0041’s figure is defined by having no period (“no period, no membership variant and no as-of qualifier to choose”). FR-41 asks what each position produced over a period, scoped to a view. It needs four things the category result lacks: a period, the positions sold inside it, flows, and the view scope. ADR-0041 left out the first three on purpose.
The engine that has all four is the performance walk
(ADR-0010), Portfolixir.Portfolios.Performance.
The walk:
- values every security every day;
- applies every booking through the single per-kind reducer (ADR-0011);
- knows each day’s external flow, the #545 basis step and the #708 trade costs;
- honours the view scope (ADR-0019, ADR-0024);
- chains any period (a preset, a calendar year or a custom range) out of one
walk, through
summarise/2.
Then it sums the per-security figures away in three places:
portfolio_value/2reduces the positions to one number per day;trade_costs/2keeps costs per day, not per security;basis_adjustment/5computes the basis step per security and then adds the steps up.
Mechanically, FR-41 is the walk keeping those figures apart inside a window.
Two figures already on the Wealth performance surface constrain the answer:
- the TTWROR, which is time-weighted, with flows and basis steps neutralised;
- beside it, the period’s money result,
end value − start value − net external flows, shown as “+x EUR in the period”.
A contribution table must agree exactly with one of them. Otherwise it is a third number that nobody can reconcile.
One fact comes from the same sprint’s bond discovery
(planning-artifacts/bond-discovery-2026-09-25.md). An interest booking
carries no security, so a bond’s coupons reach the cash account with no link
to the bond. That matters for the income term in §1 and the remainder in §3.
The 2026-10-09 amendment keeps the link on import (#928).
Decision
1. The method: a money contribution that sums exactly to the period’s money result (Q1)
| Option | What it is |
|---|---|
| A: money contribution (recommended) | Per position, in the base currency: contribution = end value − start value − net flows into the position + income − costs over the window. The positions plus the remainder (§3) sum to the money result beside the TTWROR. |
| B: percentage-point contribution, linked to the TTWROR | Each day’s return splits exactly across the positions, because they share the day’s return base: c(i,d) = (V(i,d) − V(i,d−1) − F(i,d) − B(i,d) + income(i,d) − cost(i,d)) ÷ (V(d−1) + F(d) + B(d)). Across days, the geometric chain is not a sum, so the days need a linking method. |
| C: both | A in the table and B as a second column. |
A for v1. It answers the question in the operator’s money. Every term is an amount that can be traced to bookings and to two valuation days, so the figure can be checked by hand. It agrees to the cent with a figure the screen already shows. It also follows ADR-0041 §2’s lesson: weight by money and never average percentages.
B is deferred, with a reason, and it is cheaper than D-7 feared. Carino
linking uses logarithms and would need a float exception that AR-3 names. The
multiplicative linking (GRAP/Frongello) does not:
linked(i) = Σ_d c(i,d) × ∏_{s<d} (1 + r(s)). It telescopes to exactly
∏(1 + r(d)) − 1, the TTWROR, in Decimal alone. What stays expensive is the
rest:
- the walk has to keep flows and basis steps per security;
- the figure rests on a linking convention the reader cannot see in it;
- a contribution changes whenever an unrelated earlier day changes.
If a consumer asks for return attribution in percentage points, B builds on the accumulators of §5 with multiplicative linking and no float. It is filed as an issue at the signature.
The terms of A are defined as follows:
- Start value. The position’s value in the base currency at the close of the last day the walk covers before the window. It is 0 when the position was not held then (§4).
- End value. The position’s value at the window’s last day. It is 0 when the position was sold out.
- Net flows into the position. A buy counts
+ price × quantityand a sell− price × quantity. A delivery counts±the value the walk’sF(d)gives it: the booked price, or else the day’s price (#779). A security transfer counts 0 inside one scope; §6 covers the boundary. Each flow is converted at the booking day’s rate, exactly as the walk converts. - Income. Dividends as credited, meaning the booking’s amount, which is the net after the withheld tax recorded on it. This is the Income facet’s “net”. The 2026-10-09 amendment adds the interest bookings that name the position’s security, as credited.
- Costs. The fees and taxes carried by the position’s own trades. These are #708’s trade costs (ADR-0027 amendment 2026-08-15 §1), kept per security instead of per day.
2. No share of the total; the size is a bar (Q2)
| Option | Verdict |
|---|---|
| A: money only, sorted, with a diverging bar per row (recommended) | The bar reuses the drift bar’s anatomy (DESIGN.md, issue 798) and is scaled to the largest absolute contribution. It is decorative and aria-hidden: the figure carries the sign and the colour (UX-DR7). |
B: share of the period result (contribution ÷ Σ) |
Rejected. It is undefined near a zero result. It goes beyond ±100 % and flips sign whenever positions pull in opposite directions, which is the ordinary case, not the edge case. |
C: share of gross movement (contribution ÷ Σ │contribution│) |
Rejected. It is always defined, but it is a share of nothing the reader holds, so it needs its own explanation. |
D: percentage points of invested capital (contribution ÷ (start value + net period flows), ADR-0034’s invested capital) |
Deferred, with a reason. It is defined whenever invested capital is positive. But the column sums to a simple period return that is neither the TTWROR nor the MWR shown beside it, which makes a third return on one screen. UX-DR26’s stated-difference clause would have to carry it. |
3. What the contributions sum to, and the remainder (Q3)
The positions plus the remainder lines sum to end value − start value −
net external flows, exact in Decimal. That is the “+x EUR in the period”
figure, not the TTWROR (§1).
The remainder is itemised. Each line is computed from its own bookings:
| Remainder line | What it holds |
|---|---|
| Interest | Every interest booking: account interest, and bond coupons, which carry no security link today (bond discovery 2026-09-25). Since the 2026-10-09 amendment, only the interest bookings that name no security |
| Standalone fees and taxes | fee, tax and tax_refund bookings that no trade carries, even when one names a security. This is #708’s definition; the asymmetry of dividend withholding stays ADR-0027 §2’s follow-up |
| Currency effect on cash | The revaluation of cash balances in foreign currencies, plus the settlement difference of a cross-currency trade between its cash leg and its security leg on the booking day (ADR-0015, ADR-0033) |
What the remainder does not hold, because the result never contains it:
deposits, removals, the value of deliveries, and the residual jump of a
balance snapshot. All four are external flows under ADR-0010 and ADR-0011, so
they are already outside end − start − flows. D-7 listed snapshot jumps as
a candidate for the remainder. The answer is that they never reach the
result.
No line is a plug. The remainder is never computed as “the result minus the positions”. The identity is pinned by tests (I1), so a line that drifts fails a test instead of being absorbed into a balancing figure.
4. Periods reuse the walk’s terms and its baseline rule (Q4)
- Periods. The accepted periods are exactly those of
Performance.validate_period/1:ytd,1y,3y,5y,max, one calendar year, or a custom range. They are spelled as the performance endpoint spells them:period=,year=, andfrom=/to=throughPeriodParam. - Start and end values. The baseline rule is the walk’s: the start value is the close of the last day the walk covers before the window. A position opened inside the window starts at 0.
- Sold positions. A position closed inside the window ends at 0 and appears in the table. The category result leaves this case out by construction.
- Clamping and empty windows. The window is clamped to the history as the walk clamps it. An empty window is the walk’s honest emptiness: an empty table and the empty-state sentence, never a table of zeros.
5. Flows per position: what the walk has to keep (Q5)
The walk already applies every leg per security. The contribution needs four accumulators per security inside the window: net flow, income, costs, and the values at the two ends.
| Booking kind | Flow into the position | Income | Cost |
|---|---|---|---|
| buy | + price × quantity, at the booking day’s rate |
— | fees + taxes on the booking |
| sell | − price × quantity |
— | fees + taxes on the booking |
| inbound / outbound delivery | ± the value F(d) gives it (#779) |
— | — |
| security transfer | 0 inside the scope; a boundary flow when one depot is out of view (§6) | — | — |
| split | 0 (a scale leg, ADR-0028) | — | — |
| dividend | — | + the credited amount |
— |
| interest, fee, tax, tax refund | — (they go to the remainder, §3) | — | — |
| interest naming a security (the 2026-10-09 amendment) | — | + the credited amount |
— |
| deposit, removal, cash transfer, balance snapshot | — (cash only) | — | — |
#545’s basis steps are part of a position’s contribution. They change the
position’s money value, and the money result beside the TTWROR includes them,
because the walk never lets B(d) touch value or flows. Option B of §1 would
need the steps per security, and the walk already computes them per security.
Hard requirement. With the accumulators on, the TTWROR, the IRR, the series and every other existing output of the walk stay byte-identical (I9). The accumulators only read legs the walk applies anyway, and the walk’s computation version does not move.
6. View scope and the endpoint family (Q6, #901)
The contribution is scoped the way the walk is scoped: one portfolio,
optionally narrowed with view=, or a view across all portfolios
(ADR-0024). Inside a scoped walk, a leg that straddles the boundary is
already a boundary flow (ADR-0019). Per position, a security transfer from a
depot in view to one out of view is a flow out of that position at the moved
value. The position’s contribution is therefore not charged with the move.
The family, named now so the close-out’s surface check states it instead of discovering it (AGENTS.md → Surface check):
GET /api/v1/portfolios/:portfolio_id/performance/contributionwithview=,period=,year=andfrom=/to=, and the MCP toolportfolixir.portfolios.contribution;GET /api/v1/views/:view_id/performance/contributionand the MCP toolportfolixir.views.contribution.
This mirrors the performance and benchmark families exactly: performance
and performance/benchmark exist under both /portfolios/:portfolio_id/ and
/views/:view_id/.
#901 is not a prerequisite. It adds a view scope to the category result,
whose family (category-results) is a different one. #901 lands in Sprint
17’s batch as well, in the same two-form shape, so the two reads over
positions take their scope the same way. Its own close-out names its own
family.
7. Currency: stated, not split (Q7)
| Option | Verdict |
|---|---|
| A: base currency, currency move included, stated in the basis (recommended) | The contribution of a foreign-currency position is its money result in the base currency, and the basis says that it includes the currency move. |
| B: a price and currency split per position over the period (ADR-0033’s decomposition, taken over a window) | Deferred, with a reason. It needs per-security currency attribution every day, which doubles the accumulators, and it needs a definition of the currency return of a flow booked in mid-period. ADR-0033 defines neither. It is filed as an issue at the signature. |
A position in a currency with no rate path is handled by §10.
8. Grouping by category: none in v1 (Q8)
| Option | Verdict |
|---|---|
| A: none, positions only (recommended) | This is what FR-41 asks for. |
| B: by today’s classification, with a basis line | Deferred, with a reason. It is honest if the basis line says “grouped by today’s tree; a position that moved category carries its whole period contribution with it”. But it applies a current-composition grouping to a period figure. That is exactly the tension ADR-0041 §1 removed from its own figure by dropping the period. “Which category made this period’s result” belongs with the membership timeline. If the owner wants the grouping earlier, B is its shape. It is filed as an issue at the signature. |
| C: by membership over time | Not here. It needs ADR-0041 §6’s membership timeline, which has its own gate. |
The contribution never appears in the classifications tree (§12).
9. ADR-0041 §5’s slice two is not absorbed (Q9, #900)
| Option | Verdict |
|---|---|
| A: absorb it | FR-41’s per-position income and realized result, grouped by category, would become slice two. |
| B: keep it separate (recommended) | Slice two is a current-composition statement on the category row: what the securities filed there today have made, realized results and income included. FR-41 is a period statement on the Wealth performance surface. |
ADR-0041 §5 itself warns that an aggregate whose meaning silently changes
between screens undoes its §3. Merging the two would hand one of the two
readers the wrong basis. #900 therefore stays a separate story, with its
scope unchanged (slice two on the category result), and FR-41’s PR does not
close it. The two can share code, such as TradeMatcher’s realized results
and the income series, but not a figure.
10. Missing data contributes zero, and the affected positions are named (Q10)
| Option | Verdict |
|---|---|
| A: keep the walk’s “contributes zero” (recommended) | The identity holds. Every affected position is named, with the number of window days on which it counted zero and the reason (no price, or no rate path). |
| B: follow ADR-0041 §4 and exclude underivable positions | Rejected. It breaks I1, and the table would disagree with the money result beside it, which contains the same zeros. |
The two ADRs do not conflict. ADR-0041’s figure has no second number to agree with, and this one does.
On the surface, the affected positions appear in an attention data note under
the table, with their count and names (UX-DR25), and each affected row
carries a marker. In the payload, each position carries unvalued_days and
unvalued_reason. A position that counted zero stays in the sum.
11. The payload (Q11)
- Basis.
computation_basishas the standard shape (JSON.computation_basis/1):input_series: the daily valuation walk of ADR-0010, per position;window: the start and end dates;reference:null;gaps: “counts zero; the affected positions are listed with their days”;assumptions(ADR-0046’s key): the definition in §1, the identity in §3, and “base currency, currency move included”.
- Positions. Each position carries
security_id,name,isin,start_value,end_value,net_flows,income,costs,contribution,held_at_start,held_at_end,unvalued_daysandunvalued_reason. - Remainder and totals.
remaindercarriesinterest,standalone_fees_and_taxesandcash_currency_effect.totalscarriesresult(end − start − net external flows),positionsandremainder. - Decimals and ordering. Financial decimals are strings and dates are ISO. Positions are sorted by contribution, largest first. There is no rank or label key: no “top”, “best” or “detractor”.
- No verdict key (I8). The payload carries no signal, recommendation,
rating, score or action key.
test/invariants/metrics_carry_no_verdict_test.exswalks this payload too. - Contract. Sprint 17’s batch adds one entry to the contract manifest: the version after the last one Sprint 16 ships. The MCP schema mirror and the EN/DE integration doc lines land with it.
12. Storage lifetime and the screen (Q12)
Storage (ADR-0039):
| Option | Verdict |
|---|---|
| A: its own analytic per scope and period (recommended) | performance_contribution (portfolio basis) and performance_view_contribution (global basis), computation version 1, default lifetime :request. They are keyed like the walk analytics, plus the period. |
B: per-security running sums inside the stored performance_analysis |
Any period would be cheap, but every stored walk grows by securities × days, for a read most requests never make, and the walk’s computation version moves for every reader. |
C: :none |
This is what A becomes if the measurement says a memo is not worth it. |
The ADR-0039 C3 measurement decides any later move to :durable.
Screen. The board
(_bmad-output/planning-artifacts/design-language/mockups/fr41-2026-09-25/01-contribution-surface.html
and its PNG, invented data only) shows three options:
- A (recommended): a contribution table in Wealth → Holdings → Performance, directly under the chart. It shares the section’s period control and the page’s view, and its sum row equals the money figure of the section’s badge. With more than ten positions, the table shows the ten largest by absolute amount and a “show all N” control. The sum row always covers every position. Under 560 px the table becomes two-line rows (UX-DR27).
- B: a third chart-series segment, “Contribution”, that swaps the time series for a diverging bar chart. The table sits behind the chart-as-table disclosure (UX-DR10).
- C: a period column in the Positions table. Not recommended. A position sold inside the period has no row there, so the column cannot sum to the result. It would put a period figure into the current-composition projection, and the per-depot rows split one security across lines.
Never in the classifications tree (§8 and ADR-0041). The board’s argument
lives on the board. The picked anatomy is written into DESIGN.md by the
story that builds it.
What this record does not do
- No factor, sector or region attribution. FR-42 was retired on 2026-09-23.
- No benchmark-relative attribution. Allocation and selection effects need a benchmark’s composition, which is data acquisition (B3.3). ADR-0046’s benchmark is a price series, not a composition.
- Several deferrals, each filed at the signature: no category grouping (§8), no slice two (§9), no currency split (§7), no percentage-point contribution (§1, B) and no share column (§2).
- No new persisted column. Everything is derived on read.
- No signal, no ranking label and no action. Level (b) decomposes a result that happened; the operator decides what it means.
The identities the building batch pins
| Identity | |
|---|---|
| I1 | The positions plus the remainder lines sum to end − start − net external flows, exact in Decimal, for every period and every scope. Precision stated by the 2026-10-03 amendment. |
| I2 | With unchanging prices and exchange rates, and no bookings inside the window except deposits and removals, every position contributes exactly 0, and so does every remainder line. |
| I3 | A position in the base currency, bought and sold entirely inside the window with no quote in between, contributes exactly (sell price − buy price) × quantity − costs. It appears in the table although it is held at neither end. |
| I4 | A deposit or a removal changes no contribution and no remainder line. Worded for the booking by the 2026-10-03 amendment. |
| I5 | A split inside the window changes no contribution. ADR-0028’s quantity rounding stated by the 2026-10-03 amendment. |
| I6 | A transfer between two depots of one portfolio changes no contribution. In a view that sees only one of the two depots, the moved value is a boundary flow of that position. |
| I7 | A position that counted zero keeps its place in the sum and carries its unvalued_days, and the payload lists it. |
| I8 | The payload carries no signal, recommendation, rating, score or action key (the meta-test). |
| I9 | With the accumulators on, the walk’s TTWROR, IRR, series and every existing output are byte-identical. The existing walk tests pass unchanged. |
I10 to I13 are added by the 2026-10-09 amendment, below.
The asks, answered and deferred (ADR-0043)
Where the asks come from: FR-41 in the requirements inventory, the twelve
questions of Sprint 16’s D-7, the two side findings D-7 names, and the
metric-basis rule in AGENTS.md.
| Ask | Source | Verdict |
|---|---|---|
| Which position produced how much, over a selectable period, scoped to a view | FR-41 | Answered. §1, §4 and §6 |
| The contribution method | D-7 Q1 | Answered. §1: the money contribution (A) |
| Percentage points linked to the TTWROR | D-7 Q1 | Deferred, with a reason. §1: no float is needed with multiplicative linking, but it needs flows and basis steps per security and adds a hidden convention. Filed at the signature |
| A share, near a zero total or with mixed signs | D-7 Q2 | Answered. §2: no share; a bar carries the size |
| Percentage points of invested capital | D-7 Q2 | Deferred, with a reason. §2: it would be a third return on one screen. Filed at the signature |
| What the contributions sum to, and the remainder | D-7 Q3 | Answered. §3: they sum to the money result; three itemised lines; snapshot jumps never reach the result |
| Periods and the baseline rule | D-7 Q4 | Answered. §4 |
| Flows per position, trade costs per security | D-7 Q5 | Answered. §5 |
| The view scope and the endpoint family | D-7 Q6 | Answered. §6: two endpoints and two MCP tools, named for the close-out |
| The currency split | D-7 Q7 | Deferred, with a reason. §7: no definition exists for a mid-period flow’s currency return. Stated in the basis, and filed at the signature |
| Grouping by category | D-7 Q8 | Answered for v1 (none). Today’s tree is deferred, with a reason (§8, filed at the signature); membership over time belongs to ADR-0041 §6’s gate |
| Absorbing ADR-0041 §5’s slice two | D-7 Q9, #900 | Answered. §9: not absorbed; #900 stays its own story |
| Missing data: contributes zero, or exclude | D-7 Q10 | Answered. §10: contributes zero, and the affected positions are named |
| The payload: basis, strings, contract, no verdict | D-7 Q11, AGENTS.md metric rule |
Answered. §11 |
| The storage lifetime and the screen | D-7 Q12 | Answered. §12: :request; the screen goes to the board, where A is recommended |
| The category result’s view scope on API and MCP | #901 | Answered as sequencing. §6: not a prerequisite; it lands in the same batch in the same shape |
Consequences
- Risk-tier attention (ADR-0036).
This is money math inside the walk. It ships in its own commit group, TDD
first, with exact
Decimalfixtures. The verification pass takes I1 to I9 one at a time, and the reviewer briefing calls it out. I9 is the invariant that protects everything already shipped. - Registry. FR-41’s row in the FR Coverage Map changes from “no issue yet” to the issue numbers the building batch files. The Tracker Index gains the line. The sprint that builds it is the planning’s call, not this record’s: the Sprint 17 plan schedules the build for Sprint 18 (its D-4). The deferred asks are filed as issues in the signing pass, so “closed” never silently means “the parts nobody re-read” (ADR-0043).
- Two-way coverage. The human view lands in the same batch as the API and MCP read (the board’s pick). The close-out’s surface check names the family of §6.
- No existing figure moves. The walk analytics keep their computation version (I9), and the category result is untouched (§9).
- Nothing here creates, stores or transmits an order, and nothing acquires data the instance does not already hold.
Amendment (2026-10-03): what the building batch’s review round found the record left open
Why. The money lens of PR β’s closing act took I1 to I9 one at a time on fixtures the batch’s own tests did not build. Nothing broke the money result: every position and line still summed to it. Three identities are worded more strongly than the arithmetic can carry, and one booking had no row in §5’s table. This amendment states them; it changes no figure the signed sections define.
§5 gains a row: a trade with no rate. A buy or a sell priced in a currency with no rate path to the base on its booking day would bring its units in at zero. The position would then be credited with its whole value the day a rate arrives, and the currency effect on cash would book the purchase as a loss. Such a trade’s flow into the position is its cash leg in the base currency instead: what it cost, or what it raised. No settlement difference is left for it, and its fees and taxes, priced in the same currency, ride inside that cash rather than as costs. The position counts zero until a rate arrives and is named for it (§10), as before.
I1 is exact while the conversion quotients terminate. The walk divides
by stored rates. A rate whose reciprocal does not terminate (1.0856, 0.9413)
makes the walk’s own sums round at Decimal’s 34 significant digits, and
the positions plus the lines then differ from the money result by about
1E-30. Nothing balances the difference. The payload’s basis already says
so; I1 now reads: exact in Decimal whenever every conversion quotient
terminates, and to Decimal’s precision otherwise. The screen’s sum row
prints the walk’s own result, so the sum and the badge never differ by that
residue.
I4 holds for the booking, not for what the balance does afterwards. A deposit or a removal is kept nowhere (§3), so the booking itself changes no contribution and no line. A deposit into a foreign-currency account becomes part of the balance §3’s currency effect on cash revalues from the next day on, and that line moves with the rate. I4 now reads: a deposit or a removal is kept in no contribution and no remainder line; a foreign-currency balance it changes is revalued from the next day on, like any balance.
I5 inherits ADR-0028’s quantity rounding. A split whose ratio does not divide the held quantity rounds the post-split quantity at the volume scale (six places), ADR-0028’s named exception: ten units split 1:3 become 3.333333, and the position’s contribution then differs from its unsplit twin by the rounded units’ value. I5 now reads: a split inside the window changes no contribution, save for ADR-0028’s quantity rounding.
Stated, not changed.
- The currency effect on cash holds a trade’s settlement difference in a single-currency portfolio too, when the booked cash differs from price × quantity plus costs (a gross amount entered by hand, an import that keeps the broker’s rounding). §3 and the screen’s sub-line already name settlement differences; the line’s name is about cash, not about a currency having moved.
- A foreign-currency cash balance held before its currency’s first stored rate counts zero, as in the walk, and the first rate brings its whole value into the currency effect on cash. The payload’s basis states it, but §10 names positions only, so no account is named. Naming such accounts on the performance and the contribution reads is filed as its own story.
Note (2026-10-05): the accounts are now named. The story the last item
above filed is built (#1055, Sprint 19 PR α M3; board
mockups/ux-design-2026-10-04/02-money-notes, pick J2 A). Nothing it states
about the figures changes: such a balance still counts zero, its deposit is
still a flow of zero, and the first rate still brings its whole value into
the currency effect on cash and into the money result.
- Both reads name the account. The performance read and the
contribution read, each in its portfolio and its view form, carry
unvalued_cash_accountsfor their window, and so do the two benchmark reads, which summarise the same walk: every cash account of the scope that held a non-zero balance and counted zero for want of a rate path on a window day or on the day before the window, whose close is the start value, with its native balance, its days (the day before the window, the start value, included), the reason (no_rate), whether it still counted zero on the window’s last day, and the date its currency’s first rate arrived when that falls inside the window while the account held money at zero the day before. The reads of one scope and window list the same accounts, and each basis’sgapsnames the field where the contribution’s said that no account is named. - The walk records the days, the reads count them. A walk point carries
the accounts it counted zero only on such a day, and a walk with any such
day stores the accounts’ names and their currencies’ first rate dates
beside its series, so
summarise/2stays pure. The first rate’s date comes from the stored rates, not from the series: a day that leaves the list cannot tell a rate that arrived from a balance that went to zero. The walk payloads move to computation version 4, the contribution’s to 2 and the benchmark’s to 3; every existing figure is byte-identical (I9). - The screen follows the board: the note under the contribution table names the account in the positions’ shape, adds where the balance went when its first rate came inside the period, and the “Währungseffekt auf Bargeld” line carries the account’s marker. §10’s naming now covers the accounts as well as the positions.
Note (2026-10-06): costs are read in the cash account’s currency. #1051
(Sprint 19 PR α M4) found the walk converting a trade’s fees and taxes from
its price currency. A cross-currency trade
(ADR-0015) records them in its
cash account’s currency, the cash leg they are part of
(Ledger.SettlementGuard), so §1’s costs are now read in that currency.
- The no-rate row’s premise was false. The amendment’s §5 row let a no-rate trade’s fees ride inside its cash because they were “priced in the same currency”. For a cross-currency trade they are not: they are in the account’s currency, which can have a rate when the price currency has none. Such a trade’s flow into the position is now its cash leg less its fees and taxes, and those are its costs, as on any trade, so the position’s costs equal the walk’s trade costs (§1, #708’s trade costs kept per security). Its contribution and its settlement difference (zero) are unchanged.
- The money result does not move, and I1 holds as before. On a cross-currency trade whose price currency has a rate, the correction moves between the position’s costs and the settlement difference on the currency effect on cash, which together are what they were. The walk’s and the contribution’s computation versions ride the ones the 2026-10-05 note moved.
Amendment (2026-10-09): a security-linked interest booking is its position’s income
Status. Signed by the merge of the Sprint 21 planning PR, as the Sprint 20 plan’s answer to #928 (its D-4) said it would be. Risk tier (ADR-0036): money, because an amount moves between a position and the remainder; and import idempotency, because the #533 key reads the security.
Why. The importer resolves the security an INTEREST row names, and
creates it when the catalog lacks it (Imports.Applier’s
resolve_security). Then it drops it: build_transaction_attrs writes only
the cash account for an interest booking, as for a deposit or a removal. §3
put every interest booking in the remainder for that reason, so a bond’s
coupons never reach its position, and its contribution reads as its price
move alone. The Income facet cannot place them either (#928, bond discovery
2026-09-25).
What changes.
- The import keeps the security. An imported interest booking stores
the security its row names, from a Portfolio Performance JSON or CSV
file alike, as a dividend, fee or tax booking does. It stores no depot,
as today: a CSV interest row books to its cash account and names none
(
CsvParser.map_accounts), a JSON row’sportfoliostays unread so no depot is created for a coupon (ADR-0050 §4), and the walk reads no depot for income. A row that names no security stores none. - What the stored booking names decides, not how it was written. The
API has accepted a security on an interest booking all along: the
changeset casts it for every kind, and an interest booking requires only
its cash account and amount. Such a booking, written over the API or
portfolixir.transactions.createbefore this amendment or after it, is read like an imported one from the build on. That tool’s description gains a sentence: an interest booking may name the security whose coupon it is. - §1, §3 and §5. The income term reads: dividends, and the interest
bookings that name the position’s security, each as credited. §5’s
table gains the row “interest naming a security: + the credited amount
as income”, and its remainder row holds the interest naming none. §3’s
interest line holds the interest bookings that name no security: account
interest, and every coupon stored without its bond. Scoped to a view, a
coupon credited to an in-view account is its security’s income even when
the position sits outside the view, as a dividend is (§6); a coupon
credited to an account outside the view is kept nowhere, as a dividend
is. In the walk this is the interest clause of
Performance.keep_internal, which today adds every interest booking to the line. - The hash and the key. The content hash reads the file’s security
(its ISIN, else its name) before anything resolves, never the resolved
id (
ImportHash), so every stored hash stays byte-identical and a re-drop is all hash hits (ADR-0050 §2–§3, O1). The #533 key readssecurity_id(DedupKey.of), so a coupon stored without its security keys apart from the same coupon imported now. The applier’s pre-import check (booked_before?andold_reading) therefore also asks for an interest row’s key with no security, under each cash reading it already asks for (ADR-0053 §4 and A4): a coupon stored before this amendment, before or after ADR-0053 changed its cash, is recognised in a drifted re-export and not booked twice. It errs towards “already booked”, as those readings do, and the result names the row undereconomics. The in-run key reads the security too, so two coupons of two bonds, equal in date, amount, account and time, no longer collapse into one booking in a fresh import (ADR-0050 §6); a file applied before that collapsed such a pair still books nothing when dropped again. - The payload. No field changes. The basis’s
assumptionsname the new income term and the narrower interest line.performance_contributionandperformance_view_contributionmove from computation version 3 to 4 (Derived.Registry), so no value computed by the old clause is served (ADR-0039 §5); the view-less read uses the second. The batch’s contract entry names the three contribution routes and their two tools, the income read, and the create tool’s description. The EN and DE integration docs (the remainder’sinterest) and product documentation (the income term, the Income facet) follow, and so does the screen’s note under the interest line, which says an interest booking carries no security. - The Income facet’s per-position table.
Income.positionsgroups dividends and interest together by security and booking currency, so a coupon that names its bond leaves the row without a security, shown as “Interest”, and joins the bond’s row. “Top contributors”, the first five of those rows, follows, and the year detail names the bond where it read “Interest”. The year × month matrix, the bars and the totals split by kind, not by security, and do not change. The income read (GET /api/v1/portfolios/:portfolio_id/income,portfolixir.portfolios.income) carries the same rows. The security’s own Transactions tab lists the coupon, and the transactions list’s security filter finds it; no figure moves there.
Stated, not changed.
- Cash, balances, and the walk’s TTWROR, IRR, series, money result and
computation version stay as they are: an interest booking moves cash only,
with or without a security (
Projection.effects), and a security that appears only on a coupon is priced but never held, so it adds nothing to any day’s value or basis step (I9). The benchmark comparison and the portfolio metrics keep their versions. - I1 holds as it did, to the same precision: the amount moves between a position’s income and the interest line, converted at the booking day’s rate either way (§1, as amended 2026-10-03).
- Rows stored before stay as they are: no repair and no backfill. A re-drop
cannot repair them, because a hash hit changes nothing (ADR-0050 §3), and
ADR-0053 §6’s correction writes cash, price and settlement legs, never a
security. They stay in the interest line, as its basis says. An operator
who wants one under its bond can name the security on the booking
(
PATCH /api/v1/transactions/:id, journaled); nothing does it for them. - A security merge moves a coupon with its security, as it moves a dividend
(ADR-0050 §9). The bond characterization test’s figures (the valuation,
the cash and the walk) stand; only its
security_id == nilflips.
The identities the building batch pins.
- I10. A fresh import of an
INTERESTrow that names a security stores that security on the booking and no depot, from a JSON and a CSV file alike; a row that names none stores none. The bond characterization test’ssecurity_id == nilbecomes the bond’s id on purpose, and its other expectations pass unchanged. - I11. Every content hash is byte-identical before and after the
build. Before the first change, on unchanged code, the digest list of
csv_hash_pin_test.exsgains the rows ofbond_invented.json, whoseINTERESTrow names its bond, and of I12’s file. A file applied before the build (in the test, its coupon’s security cleared, as today’s import leaves it) inserts nothing when dropped again (zero transactions, accounts, depots and securities). Neither does a re-export of it with the coupon’s time of day moved, which misses the hash and is found by the key’s no-security reading, named undereconomics. - I12. One EUR portfolio, invented throughout and imported from one
Portfolio Performance JSON file. A bond, Larkspur Rail AG 4,00 % Anleihe
2031, ISIN
XSLARKSPUR33(its check digit holds, and no realXSnumber has letters in its national part), face value 10,000.00 held as 100 shares, the bond discovery’s hundredth reading. Its cash account takes a deposit of 10,000.00 on 2025-03-02 and pays for the buy on 2025-03-10: 100 shares, amount 9,955.00 with a fee of 5.00, so priced 99.50, leaving 45.00. Quotes: 99.00 on 2025-12-31, 100.25 on 2026-06-30. Inside the window 2026-01-01 to 2026-06-30 (from=/to=): account interest of 1.10 naming no security on 2026-03-31, and the coupon, 400.00 (4.00 % of 10,000.00) naming the bond, on 2026-04-15. The start value is 9,945.00 (bond 9,900.00, cash 45.00), the end value 10,471.10 (bond 10,025.00, cash 446.10), and no external flow is in the window, sototals.resultis 526.10, before the build and after it. After: the bond’s row has start 9,900.00, end 10,025.00, net flows 0, income 400.00, costs 0 and contribution 525.00; the interest line is 1.10 and the other two lines 0; 525.00 + 1.10 = 526.10, exact (I1). The same coupon stored without a security reads as before: the bond 125.00, the interest line 401.10. On the Income facet the bond’s row reads 400.00, one payment, last on 2026-04-15, and the row without a security 1.10, one payment, last on 2026-03-31, where one row read 401.10 with two; 2026’s interest total stays 401.10. - I13. On I12’s fixture, a view that holds the cash account but not the depot counts the coupon as the bond’s income: its row has start and end 0, held at neither end, income and contribution 400.00; the interest line is 1.10; the view’s result is 401.10 (cash 45.00 to 446.10), which the interest line alone held before. A view that holds the depot but not the account keeps no coupon, before and after: the bond 125.00, every line 0, the result 125.00. Both contribution analytics are registered at computation version 4, so no value stored under version 3 is served.
Order. I11’s digests are taken first, on unchanged code, with its re-drop and re-export tests, which pass there. The red tests of I10, I12 and I13 come next. Then three commits: the attrs (I10 turns green, and I11’s re-export turns red, because the stored coupon has no security and the new key has one); the key’s no-security reading (I11 green again); and the walk’s interest clause with the basis and the computation version (I12 and I13 green). The docs, the screen’s note and a dated note in ADR-0029 pointing here follow. The verification pass takes I11 first, then I12 and I13 against I1 and I9, and the reviewer briefing names all four.