The /portfolio "All" view — implementation plan
The product decisions in §1 were settled with the owner on 2026-09-10 and are NOT open questions. The engineering anchors in §2 were verified against origin/staging at 986aa906 (v0.58.0). Read AGENTS.md first; its shipping, review, UI, copy and docs rules all bind here.
0. Problem
The portfolio has three views: USD, ETH and "Other" (wire key CROSS). "Other" holds the financed positions whose collateral and debt are denominated differently, each with its own return curve in its own denomination, plus a band of holdings the books cannot measure.
Two things are wrong with that shape.
There is no view of the whole portfolio. A reader holding a dollar carry, an ether vault and a financed position has three tabs and no answer to "what am I worth". Every tab is a denomination or a leftover, and the product never states the total.
A crossing between views is booked as return. A view's curve owns a grid interval when its span holds both endpoints, and the interval is assigned the verdict at its CLOSING point. So when a position stops being financed inside an interval — which is what a liquidation that takes the collateral and clears the debt looks like — the interval, the seizure inside it and the pre-event collateral reading are all handed to the denomination view the position lands in. On wallet 0x5788be84…1563e the ETH book therefore charges the whole penalty of a Morpho wstETH/USDC liquidation (2026-06-05, ~65 ETH), draws the pre-liquidation collateral (~1,460 ETH) at the 5 June point, and carries the red flag — for a book that never financed anything.
1. Decisions (normative, settled with the owner)
D1 — "Other" becomes "All", and All is the default view. Pill order: All, USD, ETH. All is always offered: it is the one view that is about the whole account rather than a denomination.
D2a — All hides dust, at $1, on by default (settled with the owner 2026-09-10, after the first review round). Rows whose absolute market value is under the threshold are not listed, with a reveal to show them. The headline total and the value chart stay the wallet's FULL value: the floor costs a reader rows, never value, and the card states how many rows are behind the difference so the total still reconciles visibly to its own list. It reuses the existing floor mechanism and its persistence rules; the reveal is component state, never a write of the stored floor (the trap #537's review caught). Three classes are never hidden, the same three the denomination tables exempt: an unstated value, a levered entry (its net is equity over live debt), and an annotated entry. A bare borrowing is not one of them.
D2 — All computes nothing but book value. Every position a tracked wallet holds appears in All at its MARKET value in USD: dollar-book positions, ether-book positions, financed (cross-asset) positions, and the holdings outside coverage. No yield, no cumulative yield, no total return, no APY, no per-position performance figure appears anywhere in All. A financed group shows collateral, debt and net, the way the outside-coverage band already renders a levered holding. The All total is the net USD value: assets minus debts.
D3 — All's chart is a history of value. One point per bucket, the value of everything the reading held, in USD. It is titled "Historical value" and never "total return". No accrual or total-return line, no deposit/withdrawal ticks. The liquidation flag IS drawn, as a VISUAL marker only: no loss figure is computed, converted or booked. The value line drops because the positions are worth less, which is the whole statement.
D4 — USD and ETH are unchanged and hold only the positions that belong to those books. Cross-asset positions carry no return accounting in any VIEW any more: the cross-asset curves are retired with the CROSS view. (A leg's own cumulative figures stay on the wire, because they are facts about a leg rather than about a view; see D9.)
D5 — The clean handover rule. A position that moves between All-only (cross-asset) and a dedicated book (USD/ETH), in either direction, must not have the crossing booked as return in the dedicated book. The grid interval that contains the handover belongs to NO dedicated book: the receiving book's series starts at the next grid point at the position's value, as a flow-neutral arrival, and the leaving book's series ends at the previous grid point. Applied to the motivating case: the ETH book starts clean at the post-liquidation supply (~135 ETH) on 6 June with no loss and no flag; the flag is on the All chart.
D6 — Multi-wallet. All aggregates across the selected tracked wallets by summing. The as-of (past date) mode keeps working: All then reports the value on that day.
D7 — Wire. ALL replaces CROSS as a view id on /api/portfolio/{summary,positions, history}. Prod holds zero accounts (wiped 2026-09-10) and staging is reseeded from it nightly, so there is no client-bundle rollout to protect and no stored data to restate — which is exactly the condition under which api-types' "never rename a served key" rule does not apply. The rule and this exemption are both recorded on PortfolioViewId.
D8 — The leg verdict reason cross-asset stays. It is what makes a financed position one position rather than two, it drives the All listing, and it is what a handover is a handover between. Only the VIEW it maps to is renamed.
D9 — Per-leg cumulative figures stay on the wire. PositionRow.yieldMarket, yieldRedemption and the two realized rates are per-LEG attributions over the leg's whole observed life, produced by the engine and independent of any view. They keep being served for every row, including a cross-asset one; All simply renders none of them. Withdrawing them would narrow the wire for a client that asks "what has this leg earned", and the engine would still have computed them.
2. What already exists (verified, do not rebuild)
src/lib/portfolio/v2/segments.tscuts each leg into segments (one per grid interval, merged by verdict identity) and then into SPANS per (view, unit) curve.viewFor(verdict)maps reasoncross-assetto the cross view and everything else to the leg's book.- The interval rule is at
computeViewSegments's main loop: an interval (i, i+1] takes the verdict at its CLOSING point (closes.get(key) ?? opens.get(key)). Verified by probe on the liquidation shape: the seizure interval is assigned ETH, the ETH span opens at the PRE-event grid point, andspanIndexForFlowputs the seizure inside that span. That is the defect D5 removes. - A curve books an interval only where ONE span holds both endpoints (
spanHoldsIntervalinv2/attribution.ts), so a span that opens at the interval's close books nothing into it and the arrival is flow-neutral BY CONSTRUCTION.viewCurveInputs' derived transition flows are test-only; the served path does not use them. legPerformanceis per leg over all bookings and knows nothing about views (D9).- Values are stored in the leg's BOOK unit. The only historical price source is the price mirror
onchain_credit.token_price_bars(hourly; WETH bars from 2025-05-21).liquidation-numeraire.tsalready reads "USD per one unit of a book at a moment" from the mirror alone, with the quote's own timestamp and a 48h walk-back ceiling. - Holdings outside coverage arrive from two places:
buildV2Outside(stored snapshot rows whose verdict has no category — these have history) anddisclosedOutsideGroups(a live balance read of the bitcoin wrappers, which have NO stored rows and therefore no history). - Prod holds ZERO snapshot rows with
book = 'EXCLUDED'. That is a population fact, not a persistence gap: the writer values an EXCLUDED leg market-only in USD and stores it (snapshot.ts), the loader carries it, and the reader renders it. The two populations that would produce such a row are (a) an unmapped asset held at a covered venue and (b) awallet_trackedtoken with no book (EURC). No tracked wallet has held either. The bitcoin wrappers cannot produce one at all: migration 077 set their registry rowswallet_tracked = false, so the sweep never reads them, which is why they are disclosed live instead. §5 states what All does about that.
3. The pieces
3a. Segmentation: the handover rule (D5)
ViewSegment gains handover: boolean. An interval is a handover when ALL of:
- the leg has a verdict at BOTH endpoints (a birth — absent at i — and a close — absent at i+1 — keep today's rules, which are what put the opening/closing receipt inside the span);
- both endpoints resolve to a VIEW (
viewFornon-null), and the two views DIFFER (a leg entering or leaving coverage entirely is not a handover and keeps today's rule); - occupancy ran through the whole interval — one run covers
blocks[i]andblocks[i+1]. A leg that hit zero inside the interval and was re-acquired did not move between views: it closed under one and opened under another, each with its own receipt, and those receipts must stay inside the receiving span.
A handover segment keeps its (closing) verdict and view, so currentInclusion and the positions listing are untouched, and it is excluded from EVERY curve (curveIdOf returns null). handover joins the segment identity key, so a handover interval cannot merge into the segment that follows it.
The crossAt[i]/crossAt[i+1] union above the loop is left exactly as it is. It exists so both endpoints of an interval are classified under one membership set; where a venue's cross-asset branch is not gated on the debt being present at that tick (Aave/Spark), it already makes both ends agree, and the interval then charts in All rather than in a dedicated book — the same outcome the handover rule produces. Where the branch IS gated (Morpho, Fluid), the two ends disagree and rule (2) fires. Both routes end at "no dedicated book books the crossing".
3b. Wire types
PortfolioViewId = RealBook | "ALL",PORTFOLIO_VIEWS = ["ALL", "USD", "ETH"](D1's order, which is also the order of the servedbooks[]arrays).SummaryViewgains anALLmember carryingpresentand NO figures. The All view's number is a value AT A MOMENT, and the wire already publishes that as the newest point of the All history; a second server-side total computed over a different row set is how two published figures for one thing start to disagree.PositionRow.valueUsd?: number | nulland the same field onOutsideGroup["legs"][number]: the leg's market value converted to USD at the reading it was read at. Additive and optional (an older fixture or consumer is untouched); null is M9's "not stated", never 0.HistoryPoint.incomplete?: boolean: this point EXCLUDES a leg the reading held whose USD value could not be stated. Set only on the All series.HistoryResponsefor All:book: "ALL",unit: "USD",mark: "market",markerMark: "market",points[].bookValuein USD with both return channels null,flows: [],liquidationswithloss: nullon every flag.
3c. The All value series
New pure module src/lib/portfolio/v2/all-value.ts:
allValuePoints(legs, points, bookOf, usdPerBook)— per grid point, the signed sum over every leg whose spine reading is PRESENT at that point, ofvalueMarketconverted into USD. A debt leg subtracts. A leg whose reading has no market value, or whose book has no quote at that point, is EXCLUDED from the point and the point is marked incomplete. Never a coerced 0.allLiquidationFlags(legs)—liquidationMarkersFromover EVERY leg's receipts (All holds every position, so nothing is span-bounded here), with the magnitude stripped: the event is stated, the cost is not (D3).
The mirror read is a new batched reader beside the existing one in liquidation-numeraire.ts: one query for the newest bar at or before each grid point, inside the same 48h walk-back, skipping rejected bars. It runs only when the wallet holds an ETH-book leg, and never fetches through to Dune (this is a request path).
3d. Reader (ledger-v2-api.ts)
- No curve is built for
ALL, andcurveFor/curveLegsFor/markerReceiptsForare never called with it. buildV2Summary/mergeV2Summaries: theALLentry,present= the account holds or held anything at all.buildV2Positions: theALLbucket holds the cross-asset rows (exactly the rows the CROSS bucket held). Every row and every outside leg is stamped withvalueUsd.buildV2AllHistory+getAllHistory+ the aggregate merge, on the SAME bucket reduction and the samemergeDailyPointsgap rule every other series uses.
3e. Route
GET /api/portfolio/history?book=ALL — no unit (it is USD by construction) and no mark (market). Passing unit with book=ALL is a 400, exactly as it is for the denomination views.
3f. Client
- Default view
ALL; the one-timedefaultViewreconciliation is deleted with the CROSS pill's presence gate (All is always present). - All's body: the hero row (a value-only rail beside the value chart), then the holdings, in this order: one band per taxonomy category (value only), the financed positions, the holdings outside coverage. An empty band renders nothing.
PortfolioChartgains a value mode: one line offbookValue, no accrual line, a value tooltip, and the value scale.ChartPanelgains the matching title and headline.- The All total is the client's sum over the served payload (rows + outside legs, in USD), which is what makes the hero and the list agree by construction, live and as-of alike. An unpriced DEBT leg dashes the total, exactly as
walletMetricsalready dashes the denomination views'; an unpriced asset leg is excluded and stated.
4. Acceptance
computeViewSegmentsmarks a cross-asset → same-book interval as a handover when the leg was held through it; the receiving curve books nothing over it, carries no flag, and its series starts at the next grid point at the post-event value.A close-and-reopen inside the same interval is NOT a handover: the receipts stay inside the receiving span and nothing changes from today.
Every leg that never hands over produces byte-identical USD and ETH curves before and after this change.
What actually guards that, stated precisely. The fixture corpus contains no crossing: a mutation removing the handover branch from
curveIdOfturns exactly two hand-written tests red and moves no corpus cell at all, so a green corpus is evidence that the rule is inert there, not that it is correct where it fires. The real guard is a geometry test over four shapes that a leg can present and that must NOT be read as a crossing — a hole in the observations, a close and reopen, a leg leaving coverage, and a leg emptied inside the crossing interval — plus a pair of mutations (the branch removed; the held-through test forced true) that each turn a named test red. Adding a crossing to the corpus itself is a spec-matrix change (a registered cell with its own block, anchor class and allocator ordinal), not a test addition, and is deliberately out of scope here.The All series is the signed USD sum of the readings; a leg with no USD value is excluded and its point is marked incomplete.
The All chart carries a flag with
loss: nullfor a liquidation the ETH book no longer flags.All is the default tab; its total, chart and rows are USD; USD and ETH are unchanged.
5. Known limits, stated rather than discovered later
The live-read bare holdings have no history. The bitcoin wrappers are read live and have no stored rows, so they are in All's total and its list, and cannot be in its chart. The chart's note says so whenever the wallet holds one. Making them chartable means re-tracking them in the registry, which migration 077 deliberately undid; it is not in this change.
A bare borrowing outside coverage is now LISTED (review round 2). It used to be dropped from the list on the rule that a bare borrowing is not a holding, while the value chart subtracted it from its stored reading regardless: the headline ran below its own chart, and it reported as owned an amount the reader owes. In a view whose one figure is "market value, after debts" a liability belongs on screen. The entry states its own reason and prints negative in the debt colour, and the band's note names what it can hold. The band KEEPS the label "Holdings outside coverage" for now: renaming it is a copy decision with its own landmark and docs surface, and is not made here.
One interval per handover is booked in no view. That is D5's point, and it is the price of the rule: the alternative is the receiving book charging a penalty it never financed.
v2/cross-book-seizure.tsstays. After D5 a cross-book liquidation should never fall inside a dedicated-view span, but the netting is applied to the ENGINE result, which is whatlegPerformancereads for the per-leg cumulative figures on every row (D9). That is what still reaches it, and it is asserted rather than assumed.