Cross-asset view — implementation plan
Superseded twice — see the header at the top of this page.
SUPERSEDED TWICE. The VIEW this document designs — a fourth tab charting one return curve per denomination for a financed position — was replaced in 2026-09 by the All view, which lists those positions at their dollar value with no return figure of any kind and no curve of their own (portfolio-all-view-plan.md). The membership rule it defines (groupIsCrossAsset, the whole-account move, the venue's unit of exposure) is unchanged and still current; everything it says about charting, per-unit series and the CROSS wire key is not.
Product decisions in §1 were settled with the owner (2026-07-28 session) and are NOT open questions; engineering decisions in §2 were verified against the code at the cited anchors and adversarially reviewed. Where this plan says "verify", the executor confirms with a test, not by re-litigating the design. Read AGENTS.md first; its shipping, review, UI and docs rules all bind here.
0. Problem
An Aave/SparkLend account holding collateral in one denomination against debt in another (e.g. wstETH collateral, USDC borrow) is today split by the M1 per-book partition (classifyLegs, src/lib/portfolio/pnl.ts:1488-1530):
- the collateral sub-group charts in its own book labeled "Included", with nothing indicating it is pledged against a borrow;
- the bare-debt sub-group is exiled to "Outside the yield book" as
cross-book, its funding cost never attributed anywhere.
The user sees a clean unlevered staking yield on what is actually a financed, directionally exposed position. The fix is a fourth view — Cross-asset — that these positions move into (never copy), showing one yield chart per native denomination, with no FX blending anywhere.
1. Product spec (settled)
- Name: "Cross-asset" in the UI. TradFi vocabulary leads; acceptable sub-copy: "financed positions", "collateral in one denomination funding debt in another". No em-dashes in user-facing copy, no
helpcursor (AGENTS.md). Copy explains economic relevance only, never methodology. - Move, not copy. A position is in exactly one view at any point in time. The plain USD/ETH/BTC views thereby become honestly unlevered (within-denomination carries still chart there, unchanged).
- Membership is time-varying. A borrow moves the position into Cross-asset from that point; history already accrued in the plain view stays there untouched. Full repayment (debt of the bare book reaching zero) moves it back from that point. Partial repayment (any nonzero residual debt) does NOT revert — 90% repaid is still cross-asset.
- Inside the Cross-asset view: one cumulative-yield chart per native denomination present (up to USD/ETH/BTC). An ETH collateral + USD debt position shows ETH accrual in the ETH chart and the USD borrow cost as negative yield in the USD chart. No combined number in phase 1.
- Positions table shows current state (today's classification), as it does now.
- Scope, phase 1: Aave + SparkLend accounts only. Morpho Blue cross-book carries and Fluid cross-book NFTs keep their current treatment (Outside) — Fluid is additionally gated on FWS3 flow coverage. EXCLUDED (unknown-asset) legs are untouched: they stay Outside per the existing unknown-asset path and never enter any view. Superseded for the venues by §1.8 (phase 2).
- Out of scope (explicitly, do not build): a EUR book; an FX-crossed "total PnL at today's rate" figure; persisting transition events into the events feed;
Morpho/Fluid migration into Cross-asset(moved into scope by §1.8; everything else in this list still stands). - Phase 2 — venue extension (settled with the owner, 2026-07-29). The Cross-asset view covers Fluid NFTs and Morpho Blue markets as well as Aave/SparkLend accounts. What moves is each venue's own unit of exposure: the whole account where collateral is pooled, ONE NFT and ONE market where it is isolated — and on Morpho only the market's collateral+debt carry, never its pure lend, which is financed by nothing. A Fluid smart (DEX pair) side travels intact whenever its two pool tokens share a base asset. Settled at the same time, as a coverage decision rather than a charting one: a Fluid vault holding two different base assets on one side (USDC-ETH, WBTC-ETH — Fluid ships these) is a directional position and not fixed income, so it is outside what this product covers. It is charted by no view, financed or not, and stays valued in "Outside the yield book" (it is real money). Phases 1 and 2 share one classification path; §§2-9 below are unchanged and bind phase 2 too.
2. Architecture (settled, verified)
2.1 Read-time only. Nothing is persisted.
This is the load-bearing rule and it is what makes the feature identical under both flow pipelines (legacy scan vs event ledger — the read layer has zero references to PORTFOLIO_LEDGER_MODE; keep it that way, see §8 gate).
- No schema change.
portfolio_position_snapshots.bookstores the leg's native denomination (USD/ETH/BTC/EXCLUDED) and continues to; the CHECK constraints (scripts/sql/043-portfolio.sql:75, the 067 repartition copy, andportfolio_tokens.bookin 049) are NOT extended. "Cross-asset" is a property of a group at a time, computed by classification, never written. - No new flow rows in
portfolio_flow_events. The WS5 backfill window-deletes and re-derives flow history (src/lib/portfolio/backfill.tsaround :959, :1014, :1182-1186, :1304) and branches on ledger mode (:550); a persisted synthetic row would have to be re-derivable identically in every derivation path. Transition flows are derived at read time instead (§2.4).
2.2 View vs denomination — the type refactor (do this first)
Today RealBook (src/lib/portfolio/api-types.ts:13-14) means both "which tab" and "which unit". Cross-asset breaks that conflation: it is a tab whose contents span units. Refactor rather than overload:
RealBook = "USD" | "ETH" | "BTC"keeps meaning denomination (the unit of a curve/value).BOOK_UNIT,theme.tsBOOK_DECIMALS, chart y-axes,buildBookCurve— all stay keyed by denomination and stay three-valued.- New
PortfolioViewId = RealBook | "CROSS"(exported fromapi-types.ts) meaning "which tab". Everything that enumerates tabs moves to it:selectedKeysByBook(api-data.ts:618-625, renameselectedKeysByView), the summary/positions/history builders (api-data.ts:735, :810, :1362, :1454, :1484), route validation (src/app/api/portfolio/history/route.ts:42), client records (signed-in-model.ts:316, :680-681,PortfolioDashboard.tsx:100, :2060). - The compiler is the checklist: every
Record<RealBook, …>either stays denomination-keyed (correct as-is) or becomesRecord<PortfolioViewId, …>(tab-keyed). Decide each site by what it means, not mechanically. - Public API stability: the history route keeps its
bookquery param name; it now accepts the four view ids, andbook=CROSSrequires an additionalunit=USD|ETH|BTCparam (rejected for the three plain views, which imply their unit). Document indocs/portfolio.md.
2.3 Membership rule and segmentation
Account state. For an Aave (resp. SparkLend) account at snapshot ts, let S = set of real books with asset legs present, D = set of real books with debt legs present (EXCLUDED legs excluded from both — they follow the existing unknown-asset path). The account is cross-asset at ts iff some b ∈ D has b ∉ S (a bare-debt book exists). Presence-based, no magnitude threshold — matching the existing carve-out's presence semantics (pnl.ts:1509-1520).
When cross-asset, the whole account moves — every real-book leg, supplies and debts. Rationale (settled): Aave collateral is pooled; every supply backs the cross-book borrow, so charting any of it as clean is the exact misrepresentation this feature removes. When not cross-asset, today's per-book partition applies verbatim (same-book carry, debt-free, etc.).
Interval rule. Membership is evaluated at snapshot timestamps only — the same grid buildBookCurve runs on — so no sub-interval math ever occurs. An inter-snapshot interval (t0, t1] belongs to Cross-asset iff the account is cross-asset at either endpoint (conservative: an interval that carried cross-book debt for any observed part never contaminates a plain view). Consequences, all intended: the 6h tick containing the borrow is the boundary; a borrow+full-repay inside one tick never registers; reversion happens at the first snapshot where the bare book's debt is zero.
Implementation. New pure module src/lib/portfolio/segments.ts:
computeViewSegments(snapshotRows, opts) ->
Map<positionKey, Array<{ fromTs, toTs /* null = open */, view, book, included, reason }>>Mechanics: group rows per M1 grouping (groupKeyForLeg), evaluate the per-ts account state per group, apply the interval rule, run the EXISTING inclusion rules (extracted from classifyLegs, see §2.5) on each side of each boundary, and coalesce consecutive identical verdicts into segments. Cost is O(distinct ts × legs in group), in memory, on rows already loaded — see §5.
New InclusionReason value "cross-asset" with REASON_LABEL entry ("Financed position" — verify copy with owner only if changing it). The existing cross-book reason remains reachable (Morpho/Fluid, phase 1 scope) — do not remove it.
2.4 Transition accounting — synthetic paired flows, derived in-memory
When consecutive segments differ, the departing view's curve sees the leg die and the receiving view's sees it born. The engine's rules (legIntervalYield, pnl.ts:258-300; M21 machinery pnl.ts:741-1027):
indexlegs (Aave/Spark supply+debt): flows are ignored for yield; a newborn is an opening balance (0 yield). Transitions are numerically safe without flows.value/ptlegs (e-mode PT collateral is a real phase-1 case): yield is the value diff net of flows; an unexplained death/birth books 0 AND pushes acoverageAnomaliesentry, which surfaces as the daily[portfolio] WARNING unexplained leglog (api-data.ts:639-664). Not acceptable as routine behaviour.
So: at each boundary ts_b, for EVERY transitioning leg uniformly, derive a paired flow — transfer_out in the departing view's flow set, transfer_in in the receiving view's (both are existing FlowKind values; no vocabulary change). Shape (RawFlow, pnl.ts:177-187):
ts = ts_b + 1(one second past the boundary snapshot, sointervalIndexFor(pnl.ts:694-705) lands it in the death/birth interval — a flow at exactly ts_b would fall into the PRECEDING interval);valueMarket/valueRedemption= the leg'slegValueat its ts_b snapshot under each mark (null propagates as null — M9);positionKey,venue,bookfrom the leg;logIndex0/1 for deterministic ordering;txHasha reserved sentinel (e.g.xfer:<positionKey>:<ts_b>) that can never collide with the DB's^0x[0-9a-f]{64}$rows;- extend
RawFlowwith optionalsynthetic?: true. Synthetic flows are consumed ONLY by the curve engine: exclude them from chart flow markers, fromgetEvents, and from any user-visible flow surface. Grep every consumer of the flow arrays and gate on the flag.
Injection point: the view-aware successor of curveFor (api-data.ts:666-677) — legs filtered to the view's segments (a leg's snapshot rows OUTSIDE its segments for this view are dropped), real flows filtered by key AND by segment ownership of their interval, synthetic pairs appended. buildBookCurve itself is not modified.
Boundary bookkeeping (assert in tests, don't assume): the boundary snapshot ts_b is the departing curve's last point and the receiving curve's first; the pair nets to zero across the two views per mark; a transition produces ZERO coverageAnomalies and zero suspended legs; TWR interval startValue shifts exactly as a real withdrawal/deposit of the same size would.
2.5 classifyLegs refactor — no dual code path
Today classifyLegs returns one whole-history verdict per key (LegInclusion, pnl.ts:1321-1412), and m1LegsFromRows (assemble.ts:383-401) feeds it only the latest row per key. Replace this wholesale — do not keep a legacy single-verdict path alongside the segmented one:
- Extract the per-group verdict rules (the Aave/Spark partition, Morpho carve-out, Fluid all-or-nothing, single-leg groups) into pure functions of "the legs present at one ts" — they already are that, modulo taking the latest row.
classifyLegsbecomes segment-producing (§2.3). ProvidecurrentInclusion(segments)(the open segment) and refit the state-today consumers on it: positions table, Outside section (buildOutside,api-data.ts:947-988), category derivation, summary tiles. Curve/history consumers use segments.m1LegsFromRows's latest-row-wins collapse is deleted; the classifier sees the per-ts series. Its per-leg enrichments (erc4626 role, wallet tokenClass) move with it.- Type
LegInclusioneither becomes the segment type or is deleted; no deprecated alias left behind.
Everything downstream that compiles is the checklist; the repo is strict TypeScript and npx tsc --noEmit must be clean.
2.6 API and aggregation
getSummary: thebooksarray becomes view-keyed; the CROSS entry carriespresentplus per-unit headline blocks ({ unit, totalYield, twr, realizedApy, … }— reuse the existing headline shape per unit). Plain views unchanged in shape apart from the type rename.getHistory(view, unit, mark, bucket): for plain viewsunitis implied; for CROSS it selects which native-unit curve.realizedApy's 30-day gate applies per (view, unit) curve unchanged —observedSecondsis the curve span (pnl.ts:1045), so membership oscillation does not reset it.- Multi-wallet aggregates (
getSummaryAll,getPositionsAll,getHistoryAll+ clientmergeHistories): extend per (view, unit) the same way they aggregate per book today. The CROSS view aggregates per-unit across wallets; never across units. - The
outsidegroup builder keeps serving Morpho/Fluid cross-book and unknown-asset rows; Aave/Spark bare-debt rows disappear from it by construction (they classify cross-asset now). Assert in a test.
2.7 UI
- Fourth pill "Cross-asset" in the view switcher, rendered only when
present(matching the existing empty-book behaviour). - View body: one chart per unit present (reuse the existing chart component per unit — axis/decimals key off denomination, which is why §2.2 keeps those records
RealBook-keyed), then the positions section. - A short explainer line on the view (economic, not methodological), e.g.: "Positions where collateral in one denomination finances debt in another. Each chart shows realised yield in that leg's own denomination; exchange rate moves between denominations are not blended in." (No em-dashes.)
- Included-leg rows elsewhere need no new annotation — the misleading case (financed collateral labeled "Included") no longer exists in the plain views by construction.
3. Dead-code and debt removal (in scope, same PR series)
- Delete
src/components/portfolio/PortfolioView.tsx(the superseded WS6 view; production entry isPortfolioDashboardviaPortfolioClient.tsx:45-59). Its only live importer isPositionsTable.test.tsx:11— extract whatever that test actually exercises into its own module (or port the test to the dashboard's table) and remove the rest. It enumeratesREAL_BOOKSand would otherwise be a phantom fourth-tab compile site. - The §2.5 refactor must leave exactly ONE classification code path.
REASON_LABELandInclusionReasonreviewed for now-unreachable values; remove only what is provably unreachable (keepcross-book— Morpho/Fluid still produce it).
4. What NOT to touch
buildBookCurve,legIntervalYield,linkTwr,annualizeTwr— the engine's math is correct and unit-agnostic; the feature is a selection and classification change.- The write path:
scripts/refreshers/portfolio.ts,flows.ts,ledger-flows.ts,backfill.ts, all SQL underscripts/sql/. Zero migrations in this feature. If you find yourself editing any of these, the design has been violated — stop and re-read §2.1. buckets.ts/ the T2 registry /valuation-*— book-of-an-asset is orthogonal to view-of-a-position.legPerformance(assemble.ts:415-501) — leg-scoped and view-agnostic.
5. Performance budget and proof
Expected cost: zero new DB reads, zero new RPC, no cache shape change. The additions are pure in-memory passes over rows loadContext already loads: segmentation is O(distinct ts × legs per group); synthetic-flow derivation is O(transitions). Per-request curve work grows only when the CROSS view is actually requested (history is per-view per-request already).
Prove it, don't assert it: before merging, measure the summary + positions + history endpoints on the heaviest staging wallet (most snapshots × legs) before and after, same box, ≥20 requests warm. Budget: p95 within +10%. If segmentation shows up in a profile, memoize per (wallet, max ts) inside the existing context-cache — but only with evidence.
6. Tests (all pure; no DB/RPC — the repo's engine-test convention)
segments.test.ts (new) + extensions to pnl.test.ts, assemble.test.ts, api-routes.test.ts, aggregate.test.ts:
- Membership: borrow mid-history splits segments at the right ts; full repay reverts; partial repay does not; re-borrow creates a third segment; multi-book account (USDC carry + ETH supply + USD bare debt) moves whole-account; same-book-only account never classifies cross-asset; EXCLUDED leg stays outside throughout.
- Either-endpoint interval rule: the borrow tick's interval is cross-asset on both sides of the boundary definition.
- Conservation: for every (leg, interval), exactly one view owns it — asserted as an invariant over generated scenarios, not hand-picked ones.
- Yield continuity (index legs): sum of per-view cumulative yields over a transitioning history == the untransitioned whole-history yield.
- Value/pt legs: a transition books zero yield at the boundary, zero
coverageAnomalies, zero suspended legs; removing the synthetic pair makes the test fail (guard the guard). - Synthetic pair: nets to zero per mark; null marks propagate; never surfaces in events/markers.
- TWR: transition shifts interval
startValueexactly like a real flow of the same size. - API: CROSS requires
unit, plain views reject it; summary shape; Outside no longer contains Aave/Spark bare-debt rows. - 30-day gate: an oscillating history still yields APY once the curve span crosses 30 days.
Plus: full existing suite green, npx tsc --noEmit clean.
7. Docs (same PR — AGENTS.md rule)
docs/metrics.md: new numbered methodology rule (next free M number) specifying the membership rule, either-endpoint interval rule, transition accounting, and the per-unit no-FX-blending invariant. Update the M1 section's "three books" phrasing.docs/portfolio.md: the new view, API params, response shapes.docs/database.md: no change (state "no schema impact" in the PR body).cd docs && npm run buildclean (dead-link check).
8. Delivery, verification, rollout
- Branch → PR into
staging(nevermain), conventional commits. Suggested PR slicing, each independently green: (a) §2.2 type refactor + §3.1 dead-code removal; (b) segments engine + classifyLegs refactor + tests; (c) API + aggregation; (d) UI + docs. Small PRs over one big one. - Mode-independence gate:
grep -rn "PORTFOLIO_LEDGER_MODE\|readLedgerMode" src/lib/portfolio/segments.ts src/lib/portfolio/pnl.ts src/lib/portfolio/api-data.ts src/lib/portfolio/assemble.tsmust return nothing new. - Browser verification (AGENTS.md): real browser against staging, full page, real viewport widths including narrow; screenshot the Cross-asset view with a wallet that actually holds an ETH-collateral/USD-debt position, and a wallet that transitions during the observed window. Never conclude from server HTML.
- Independent code review by a separate agent (AGENTS.md): fresh context, traces second/third-order effects, checks financial soundness of the membership + transition math; findings posted on the PR and addressed before merge.
- Release note (staging → main PR): existing users will see financed positions MOVE from the USD/ETH/BTC views into the new view — history totals per view change visibly. That is the feature, but say it in the release PR body.
- No migration, no backfill, no env flag. Staging validation is the gate.
9. Acceptance checklist
- [ ] ETH-collateral/USD-debt account: charts in Cross-asset (ETH curve positive accrual, USD curve negative), absent from plain views, absent from Outside.
- [ ] Same account pre-borrow history: still in the ETH view, untouched.
- [ ] Full repay: reverts to plain views from the repay tick; accrued cross-asset history stays in Cross-asset.
- [ ] Partial repay: stays in Cross-asset.
- [ ] No position appears in two views for the same interval (invariant test).
- [ ] Zero
[portfolio] WARNING unexplained leglines attributable to transitions on staging over 48h. - [ ] Read-path p95 within +10% on the heaviest staging wallet.
- [ ]
tscclean; full suite green; docs build clean; docs updated. - [ ] No schema/migration diff; no write-path diff; mode-independence grep clean.
- [ ]
PortfolioView.tsxdeleted; single classification path. - [ ] Independent review posted and addressed.
Phase 2 (§1.8) adds, without relaxing any of the above:
- [ ] Fluid NFT and Morpho market financed across denominations: each charts in Cross-asset per denomination; a SIBLING NFT / another market is untouched; a Morpho pure lend stays in its own book.
- [ ] A Fluid vault side of two different base assets is charted by no view, financed or not, at EVERY tick — including a tick carrying only one of its two pool-token rows. Same for a side holding an asset outside coverage.
- [ ] A liquidation on a position financed across denominations is booked as a realized loss in at most one denomination, and never in a view that did not hold the position when it happened.
- [ ] A bare debt — collateral neither observed nor carried — charts in no view, on any venue.
- [ ] Signed-in browser pass on staging (Cross-asset view at 1360/1140/900 with a wallet holding a Fluid NFT and a Morpho market financed across denominations), recorded in the PR body.