Portfolio (read-only)
The /portfolio surface is a read-only position monitor: a signed-in user sees their positions on the venues creddit covers and an honest, yield-only performance view. No execution, no automation. It is a fixed-income book monitor: it tracks the based positions creddit covers plus the bare wallet balances of the tracked tokens (the yield-bearing asset-profile tokens, the top-TVL idle stablecoins, ETH and the BTC wrappers, taxonomy T2), not a full net-worth view of every token a wallet holds. Tokens outside that tracked set stay out; a non-based fiat stable (EURC) is valued in USD but never charted.
The authoritative build plan is docs/plans/portfolio-read-only-plan.md (in-repo), with every deviation recorded in docs/plans/portfolio-execution-log.md. The full performance methodology (the book partition, yield attribution, flows, liquidations, marks, the wedge badge, exclusions) is locked as M1 through M10 in Metrics: how & why.
Product shape
- Sign in with an injected wallet (SIWE). One account (wallet address = uid) covers portfolio tracking and the AI assistant.
- Coverage: only positions on venues creddit covers (Aave v3, SparkLend, every Morpho Blue market (T4 §3.5: the market universe is exhaustive, not the curated repo/carry subset — a position in ANY market is read via the T4 §3.6 discovery index and charts if its loan/collateral asset is based, else lands in "Outside the yield book"), Morpho / Euler curator vaults (T5 §3.5: the MetaMorpho side is now the EXHAUSTIVE factory universe — every vault from the v1.0/v1.1 factories via
metamorpho-factory.ts, so a deposit in ANY MetaMorpho vault charts as a Money market fund; portfolio-only, the Repo lending page keeps its curated list, and Euler stays curated), Pendle PT markets, Fluid vaults (T1 through T4, read via the resolver, FWS2) and Fluid Liquidity Layer lending (the full fToken set — fUSDC / fUSDT / fGHO / fUSDtb / fsUSDS in the USD book, fWETH / fwstETH in the ETH book, T4 §3.5). Aave/Spark legs are enumerated by a full balance sweep over every reserve's aToken + variableDebtToken (not the user-config bitmask, which has no "has supply" bit), so a supply with the collateral toggle off, on an LTV-0 reserve, or made in isolation mode is read like any other (coverage-expansion plan, verified on a mainnet fork). The two Fluid shapes are read by different venues: a vault position is an NFT (fluid), while a plain lending deposit has no NFT and is an ordinary ERC-4626 share token, so it is read as anerc4626leg offsrc/data/fluid-ftokens.ts(issue #359). Fluid legs now chart (FWS3 landed the flow scanner — operate / NFT transfer / state-diff liquidation — and flipped the D8 gate on), classified by the M14 same-book / cross-book rule: a same-book NFT (a wstETH/ETH T1, a USDe-USDT smart pool) is an included carry, while a vault holding two different base assets (USDC-ETH smart collateral) is a directional position rather than fixed income, so it is outside coverage and stays in "Outside the yield book" — a verdict taken per vault SIDE, since the pair a side holds is fixed when the vault is deployed. A side one of whose tokens creddit cannot book is settled the same way and at the same grain: a position drawing on it stays outside coverage at every snapshot, not only the ones where the uncovered token's balance happened to be read. A Fluid liquidation is a realized loss (state diff, M15), never a withdrawal; an NFT transfer moves the whole levered position (M16). The wallet venue (taxonomy T2) adds a wallet's bare token balances: it sweepsbalanceOfover thewallet_trackedrows ofonchain_credit.portfolio_tokens(the registry that also drives book resolution, replacing the hard-codedbuckets.tsmap at runtime) pluseth_getBalancefor native ETH. Avariable_ratetoken (sUSDe, sGHO, reUSD, wstETH, …) charts as a Variable rate asset, its share-rate appreciation attributed exactly as a wrapper held as collateral; apartoken (a stablecoin, ETH, a BTC wrapper) is an Idle asset with zero yield by construction. Wallet flows are ERC-20Transferscans (forward from enrollment via the 6h cron, and replayed over history by the registration backfill, taxonomy T3); native-ETH flows are derived from balance diffs at snapshot anchors (exact — a par asset's yield is zero, so Δvalue = net flow, and gas spend lands as a transfer_out). A wallet that sends its ENTIRE ETH balance away has no leg to read, so the cron re-materialises the drained leg as an explicit zero (confirmed by a stricteth_getBalancefirst — a FAILED read must never be mistaken for a zero and fabricate a full-balance exit) and persists it. That books the withdrawal exactly once and makes the next tick's diff run against 0, so a later re-funding books its true size instead of netting against a stale pre-drain balance. Because the backfill replays these flows, avariable_ratetoken whose balance changed mid-history attributes only its share-rate yield, never the acquired principal (which a balances-only replay would have booked as phantom yield). - A flow is valued at the rate of ITS OWN block, live or backfilled. For a wrapper with an on-chain getter (
JIT_RATE_GETTERS: wstETH, sUSDe, sUSDS, …) that is an exact archive read at the flow block. For one WITHOUT a getter (reUSD, sUSDf, sUSDai, PST) it is the freshesttoken_yield_apy.share_rateat or before that block — never the latest recorded rate, which would value a months-old acquisition at today's share rate and, since a bare variable-rate leg accrues byvalue(yield = Δvalue − netFlow), push that error straight into attributed yield. Where no rate exists at or before the block, the redemption mark stays NULL (M9: an honest null, never a plausible wrong number). - Performance is yield-only, partitioned into three switchable books by accounting asset (USD / ETH / BTC), each denominated in its own unit. Price moves of ETH / BTC against USD never appear inside a book.
- Two valuation marks: MARKET (secondary-market prices) and REDEMPTION (on-chain fair value from share / redemption rates). A wedge badge appears in both marks when the two diverge beyond a threshold on any held asset (M7). A Pendle PT is charted in both marks: MARKET at the
pt_to_asset_rateTWAP, REDEMPTION at the pull-to-par curve accreting from the position's entry implied yield (M11/M12), so the PT wedge measures rate-move exposure since entry. A PT held as Aave / SparkLend e-mode collateral is now charted too: it is promoted into its underlying's book and valued the same way, so the flagship PT-loop carry (PT-srUSDe collateral + stable debt) is an included USD carry, not "Outside the yield book". An unknown PT (not in our Pendle registry) stays Outside, honestly.- stETH and eETH redeem at par to ETH, while their MARKET marks float. Because raw Dune coverage was not confirmed, the ETH-book marks are derived without a new vendor:
wstETH market / wstETH share ratefor stETH, and the equivalentweETH market / weETH share ratefor eETH. The share rate is the newest stored observation at or before the wrapper bar actually selected by the mirror, not the rate at a potentially later valuation block. If that bar-aligned rate is unavailable, the derived market mark stays null. - A non-traded vault share is valued by composition when it has no market bar: its share rate times its underlying's mark, independently for MARKET and REDEMPTION. This covers iETHv2 through stETH, yoETH through WETH, and yoUSD, fLiteUSD and yvUSD through USDC. A traded wrapper such as tETH or liquidETH keeps its own bar, so the underlying basis is never counted twice.
- stETH and eETH redeem at par to ETH, while their MARKET marks float. Because raw Dune coverage was not confirmed, the ETH-book marks are derived without a new vendor:
- Base yield only. Rewards and points are not included.
What the user sees (design sync 19 redesign)
Signed in, the view is a single-asset-class dashboard (PortfolioDashboard.tsx), mounted full-bleed in the app shell. Blending USD-, ETH- and BTC-denominated positions into one number is meaningless, so it shows one asset class at a time and denominates every figure in that unit; balances are never converted between classes. A fourth view, Cross-asset, holds the financed positions the three cannot state honestly (M22) — an Aave/SparkLend account, a Fluid vault NFT or a Morpho Blue market whose collateral in one denomination funds a borrow in another; it charts one curve per denomination and still blends nothing across them. It is wired to the same live APIs below; where the design called for data the API does not expose, everything is derived honestly (no demo data survives).
- Chrome bar. One bordered 50px bar, the first row of the page's own column (not a full-bleed band across the shell): it controls the two cards under it and takes their width. Left to right: the amber marker +
PORTFOLIOwordmark, a View segmented switch$ USD | Ξ ETH | ₿ BTC, plus a fourth Cross-asset pill that appears only while the selection actually holds a financed position (an empty view is not a tab, exactly as an empty denomination is not) and carries no unit glyph, because it names no unit. Then a price basis switchRedemption | Market, each with anInfoTooltip. Price basis IS the MARKET/REDEMPTION mark under a TradFi name: REDEMPTION values a position at par / the on-chain redemption rate, MARKET at the secondary-market price; realized yield earned is identical on either basis, only value re-marks (M5). Pushed right: a Wallets N/M dropdown, anUpdated <ts>freshness stamp, a ⟳ icon button wired toPOST /api/portfolio/refresh, and a ⚙ icon button that anchors the settings popover. - Settings popover (⚙). A 232px panel holding the settings that are not part of reading the book, dismissed on Escape or an outside click. It hangs 8px under the gear and is right-aligned to it, the same offset
WalletsMenuuses, and it is narrow enough that its left edge clears the Wallets button at every viewport width. That clearance is why the freshness stamp beside it reserves a fixedSTAMP_CH-character width: the bar is right-aligned andfmtStampchanges width on its own as time passes (–→just now→31 Jul, 10:27 UTC), which slid the Wallets button sideways under a panel pinned to the gear. Its one setting today is the Min position value field (MinValueField), the minimum-position-value floor described below: it lives here rather than in the bar because it changes no figure on the screen, only which rows the holdings tables list, and standing among the view and basis switches it read as a filter on the portfolio itself. What it hides stays accounted for where it happens (the holdings header'sN below X · Show all), and what the floor DOES is explained in the field'sInfoTooltiprather than restated as body copy under it (the tooltip opens on tap as well as hover, so that stays reachable on touch). The gear stands down in the Cross-asset view, where the floor is a per-denomination threshold with nothing to apply to (a financed position is exempt from it by rule, its value being net of live debt), so the panel would open onto an inert control. - Wallets dropdown (
WalletsMenu) folds selection, add, rename and remove into one picker: a checkbox per tracked wallet toggles it into the selected SUBSET (the view aggregates the subset client-side), the pencil renames inline, the trash un-tracks (hidden for the account/SIWE wallet, marked with an amber-ringed identicon +ACCOUNTtag), and a footer form tracks a new address. No portfolio values live in the dropdown — it stays a picker. - Hero row (design_handoff_portfolio_4a, option 4a). Two cards, side by side and bottom-aligned: the taller sets the row height, the ledger pins its allocation block to the bottom (slack sits above it, never below) and the chart's plot grows into the difference. The ledger is pinned to 440px and does not take the slack; once the row is too narrow to hold both, the chart wraps beneath and the ledger fills its line (
.pf-hero-ledger, a container query on the row,globals.css).- CURRENT PORTFOLIO (the ledger,
SummaryRail): a header strip, then the Tracked value hero figure (the selection's total in the active unit + basis; its type size steps down as the figure gets longer, so an institutional book stays inside the card), then three flush ledger rows — blended Net APY, Projected daily income, Positions count — and the per-category Allocation split bar, each row labelled with its FULL category name and its share right-aligned. - HISTORICAL PERFORMANCE (the chart,
ChartPanel): a header strip carrying the title and the square1W | 1M | 3M | 6M | YTD | ALLrange pills (active pill in solid amber), a headline row with the Cumulative net yield figure (coloured by its SIGN, never flat: a financed position's borrow leg charts negative, and a cost painted signal-green is the one reading this card must not allow) and the legend naming the mark it was drawn on, then the plot, then a<unit> · daily|6h cumulativefooter caption beside whatever the series needs explaining (a live tip, an isolated observation, a liquidation). The plot is the real reconstructed cumulative-yield series (PortfolioChart, Recharts 3) drawn as the signal-green accent curve (1.75px) over a flat matching area fill at 9%, a right-hand y-axis on nice 1/2/5 ticks over a 6%-padded domain (niceTicks/yScaleOfinchart-series.ts), an accent dashed crosshair + panel tooltip (whose figure is formatted exactly as the headline above it), the CREDDIT watermark at 5.5%, and a one-shot draw-in on first mount (never replayed on a range switch; skipped underprefers-reduced-motion). The range control windows both the curve and the "earned" figure. Multiple selected wallets aggregate on the shared daily grid (carry-forward sum, never interpolated). The Tracked value figure (and each per-wallet card's Value) shows a dash, not a number, when a DEBT leg failed to price in the active mark:toDisplayPositionscoerces that null to 0 when it nets a group's value, so dropping a debt leg OVERSTATES equity (a levered position collapsing to collateral-only), the confident-wrong headline. A null NON-debt mark — the documented "thin wrapper Dune does not price" exclusion — is NOT withheld (it coerces to 0 and understates by a small idle/yield holding, exactly as before; dashing the whole book value for a permanent exclusion would never clear). Withholding is per mark (a market miss never blanks the redemption total). A debt leg going unpriced means a >48h dead mirror (M20 serves any coherent bar within 48h), so this is rare.
- CURRENT PORTFOLIO (the ledger,
- The Cross-asset view (
CrossAssetView). Its body opens with one line of copy ("Positions where collateral in one denomination finances debt in another. Each chart shows realized yield in that leg's own denomination; exchange rate moves between denominations are not blended in."), then one Historical performance card per denomination the view holds — the denomination views' own chart panel, unchanged, so each one's axis, decimals and USD annotation key off the unit it is handed — then a Financed positions section grouped by wallet. There is deliberately no tracked-value rail and no total anywhere on this view: every figure it shows belongs to one denomination, and a figure across them could only come from an exchange rate. Each position is named the way a carry is named (the collateral -> debt balance split, the platform mark, the E-MODE chip) and then listed leg by leg — quantity, advertised rate, yield earned, value — with each leg's figures in its OWN unit ("-$20,431.77" beside "12.50 ETH"), which is the no-blending rule made visible in the table. A debt leg reads negative (money owed, not a holding) and its advertised rate carries the sign of the cost it is. The USD equivalent annotation rides the per-unit charts, exactly as it does in the ETH and BTC views, and deliberately not the leg table, where a dollar line under every row would invite exactly the blend the view refuses. The view can chart while holding nothing today (a full repay reverted the position to its denomination view from that tick and left this history behind); it says so rather than render an empty table under a live chart.crossAssetGroups(signed-in-model.ts) builds these groups, and it deliberately exposes no value / yield / APY field at all — there is no sum to take, so the type makes one unrepresentable. Under the legs sits the same Leverage & liquidation strip the expanded carry carries (current leverage vs the pair's max, LTV vs the borrow cap, distance to liquidation with the health factor), read from the account figures Aave and SparkLend liquidate on (accountRisk,signed-in-model.ts) — a financed position's own legs are denominated differently, so a ratio between them would be an exchange rate rather than a loan-to-value. The strip stands down entirely when that read is unavailable. A closing line states the window the leg table's Yield earned column covers (each leg since tracking began), which for a position that was not always financed is longer than the charts above it: per-leg figures stay leg-scoped (legPerformanceis view-agnostic), and saying so is cheaper than two numbers that look like they disagree. A selection whose every position is financed can still bepresentin a denomination view, because a view stays present on the yield it earned before those positions were financed (see the retained-view bullet below).defaultView(signed-in-model.ts) is handed exactly that presence, so such a selection opens on the denomination view showing its retained chart, and reaches Cross-asset only when no denomination view is present at all. Whether that is the right landing screen for a fully financed selection, as against opening on the view that holds the positions, is an open product question and not settled here. - A denomination view holding nothing today (
RetainedYieldPanel). Two states look the same in the positions payload and are nothing alike to a reader, so the view distinguishes them on the summary's own per-denominationpresentflag:- it earned, and holds nothing now (its positions are financed and reported in the Cross-asset view, or they were closed): the chart stays, with the yield the view earned, above one line of copy saying so ("No ETH positions are open right now. The yield earned while this view held them is still counted here, and any position still open is shown in the view that holds it today."). It is the only place that figure is reported, which is why an empty state here deleted a true number from the app (fixed 2026-07-29). Deliberately chart only, no tracked-value rail: the view's book value is legitimately zero, since capital is reported once by whichever view holds it now, and a zero rail beside a positive earned figure invites the double count the summary is shaped to avoid.
- it never held anything: the "No
<UNIT>positions" empty state, unchanged. A chart of nothing would be noise. The discriminator ispresent(the summary flag, which arrives with the payload that produced the position rows) rather than the chart's own points, which land on a separate fetch and are windowed by the timeframe: a reader on 1W would otherwise be sent back to the empty state for a view with months of history.
- What the chart covers vs what the column covers. The denomination views carry the counterpart of the Cross-asset view's closing line ("Yield earned covers each position since tracking began. The chart above covers the periods this view held them, so it can also carry yield from positions the view no longer holds."). The two figures answer different questions and legitimately differ, in both directions: a view keeps what it earned while it held a position (M22), while a row keeps what that position has earned throughout (
legPerformanceis view-agnostic). Unstated, the gap reads as a data bug. - The "building history" chart state (
BuildingChart.tsx). While a selected wallet's registration backfill is still reconstructing its history AND that wallet's own history is not in the chart yet (fresh signup, or a just-added wallet:buildingChartStateinsigned-in-model.ts— one live-tip point is not a series), the chart panel swaps the livePortfolioChartfor a scanning reconstruction: the panel chrome stays (theBUILDINGbadge joins the header strip, the range pills dim in place), an amber beam sweeps the empty plot with decorative observation dots flickering in behind it (kept faint — they trace a generic curve, never actual yield), and a progress row under the plot is wired to the REAL reconstruction cursor:reading n / mcounts the replay's daily grid points fromSummaryResponse.backfillProgress, and the caption beside the$0.00figure carries a measured-rate time estimate ("rebuilding history · ~40s left") that ticks down client-side between the 20s summary polls. The estimate appears only once a real replay rate exists; the queue/probe phases say what they are ("queued · starts within a minute", "scanning on-chain activity") rather than inventing numbers. With no positions to summarize yet the building panel takes the full hero row in place of the misleading "No positions" empty state. When the wallet's series lands (the sync poll's refetch), the live chart mounts with a one-shot entrance. All motion is transform/opacity-only and fully static underprefers-reduced-motion(pf-build-*inglobals.css).- The gate is PER WALLET (2026-07-24). It used to close as soon as ANY selected history had a series, so adding a wallet to an account that already charts never re-opened it: the new wallet's archive replayed silently behind the existing curve and its history popped in minutes later with nothing having announced it. Now each syncing wallet is judged against its own history, so the panel rebuilds whenever a selected wallet is missing from the chart, which is the honest statement (the drawn curve is not yet the selection). It closes again per wallet, so a wallet re-queued for a gap patch while it already charts does not blank a good chart. Because the gate reopens, the entrance animation plays again on every rebuild.
- Adding a wallet selects it a full round trip before its summary lands, so the gate also reads the tracked-wallets list's
backfillstatus while that wallet's summary has not resolved yet — otherwise the panel would flash the old curve for that round trip. A landed summary always wins. A summary that resolved to a FAILURE does not take the fallback: the wallets list is not polled and the summary poll only covers wallets with a syncing summary, so gating on the list after a failed fetch would build forever. That path falls back to the live chart plus the sync banner. - Because the gate can now hold the whole chart, the syncing poll no longer stops at its 15 minute mark; it backs off to one read a minute instead. It is the only thing that re-reads a syncing wallet's summary, so stopping it would strand a slow backfill (deep queue, slow archive, stuck drain) on a permanently building chart.
- While building, the hero prints a dash, not
0. The gate now opens on established accounts, where a+0.00would sit beside a real tracked value and real per-position yields. - The entrance animation replays on every rebuild because both branches of the plot row render a bare
divin the same slot with no key, so React reuses the node and re-addspf-build-reveal. Keying either branch would silently kill the replay. - The "History syncing" banner still covers a syncing wallet that is NOT gating (one already drawn), and stands down while the BuildingChart itself is on screen.
- Minimum position value (
MinValueField+positionPassesMinValue), reached through the chrome bar's ⚙ settings popover. A per-book dust floor, defaulting to $10 / 0.005 ETH / 0.0001 BTC (MIN_VALUE_DEFAULTS), held per account inlocalStorageundercreddit_portfolio_minvalue:<account>(one entry per book, so switching asset class never loses another book's floor) and swept on sign-out byclearPortfolioCaches. Clearing the field turns it off. It is deliberately NOT in the URL:/portfoliois a static prerender with no Suspense boundary, and a signed-in per-account screen has nothing to share.- It filters ROWS ONLY. Tracked value, Net APY, projected daily income, the Positions count, the Allocation bar, every per-wallet card metric and the carry rows'
% of bookdenominator all keep reading the UNFILTERED set (displayByWallet); only the holdings tables read the filtered one (visibleByWallet). This is not a preference: the cumulative-yield chart is a server-reconstructed whole-book series with no per-position dimension on the wire (GET /api/portfolio/history), so a filtered hero would disagree with the curve beside it by an amount nothing on screen could explain, and a floor that is ON by default would silently under-report the book on first paint. What is held back is always stated and always one click from being shown:N below <floor>+ Show all in the Holdings strip,N below the minimumon each card, and a note in place of the sections when a card's every row is held back. - It tests the MAGNITUDE of the position's value in the active mark, never the signed value. An underwater position is not dust, and a signed
>= floorwould hide exactly the rows that matter. - Four classes of row are never hidden, whatever the floor, because for each a small displayed value does not mean a small position: (1) a position whose mark could not be read (
positionCurrentMarkIncomplete—toDisplayPositionscoerces a null leg mark to 0 when it nets a group, so hiding it would turn a pricing outage into an invisible holding, the failure M9 exists to prevent); (2) any group carrying a debt leg, since a carry's value is net EQUITY and dust equity can sit on top of large live debt; (3) any row carrying anannotation, which D2 defines as never hiding the row (annotationForRowinapi-data.ts, fromcarry_registry.status); (4) any row whose realized yield clears the floor. Yield is a flow-adjusted lifetime figure, so a position withdrawn down to a residual keeps its whole earnings history, and residuals are exactly what a dust floor sweeps up. Hiding one would take its yield off the table while the card's Yield metric and the cumulative-yield hero still counted it, so the Yield earned column would stop footing to the figure directly above it.
- It filters ROWS ONLY. Tracked value, Net APY, projected daily income, the Positions count, the Allocation bar, every per-wallet card metric and the carry rows'
- USD equivalent under the ETH/BTC headline figures (
usdEquivalent). In the two native books a small muted≈ $Xline sits under Tracked value, Cumulative net yield, Projected daily income, each wallet card's Yield and Value, and each holdings row's Value (under Projected daily income it sits inline to the right, in theStatsub slot). It is the book amount times the latest mirrored USD price of the book's own unit, served byGET /api/portfolio/prices. No price means no line at all — never a$0, and never a second vendor's quote. The line is likewise withheld when the position's OWN mark could not be read:toDisplayPositionscoerces that null to 0, and annotating the coerced 0 would restate "worth nothing" in a second currency on exactly the rows the filter keeps visible to avoid hiding a pricing outage. It is an ORIENTATION annotation and nothing else: no mark, basis or stored figure reads it, balances are still never converted between classes, and the tooltip beside the hero figure names the price and the bar it was struck at (the mirror stores hourly bars on a 6h sync, so the tip is routinely hours old and is never called "current"). Applied to a cumulative yield it answers what the ETH/BTC earned is worth today, not what it was worth as it accrued. Methodologically this is a cross-bar multiply, which M5.1 forbids INSIDE a mark; it is admissible here only because it is a labelled display annotation on an aggregate that never enters a valuation. - Idle assets are holdings, not positions. The Positions count (and the per-wallet / holdings-footer counts) is
countPositions— every category EXCEPT Idle. An idle balance earns nothing by construction, so counting it as a position overstates how much of the book is at work. It keeps its value, so it still sits inside Tracked value and the Allocation bar. Where a wallet holds any, the counts read "N positions · M idle": a card reading "0 positions" above its own visible idle row would contradict itself.displayApyalso returns null for Idle outright, so a par token that somehow carried atoken_yield_apyrow could never blend into Net APY / Projected daily income with no visible column to reconcile it against. - Numbers. Values are exact to the cent, comma-grouped (
$1,236,481.22,12.34 ETH) — never compacted to$1.24M/$520K, which hid up to five figures of the user's own money. Yield earned and per-leg quantities carry two decimals and extend to four when they have them (+$1,234.5678,12.5001), since both move in far smaller increments than a position is worth. APYs and the basis percentage are two decimals. - Native precision is adaptive below 1.00 (
nativeDp): the figure's cap down to 1.00, then progressively more as the magnitude shrinks, trailing zeros trimmed back to a 2dp floor. A flat cap is right for a 12.34 ETH position but at ~$100k/BTC it would round 0.0149 to "0.01" and hide ~$490 of a ~$1,490 holding — the same rounding-hides-money problem the USD change fixes. So0.015 BTC,0.004 BTC,+0.00004 BTCall survive, and a real balance can never read0.00(M9). USD is deliberately exempt: a sub-cent dollar rounding to$0.00is the truth, and extending it would print$0.0010in a column of cents. A magnitude that rounds away drops its sign rather than rendering-$0.00; a caller supplying its OWN sign (the dislocation P&L columns, viafmtSignedBasis) must consultrendersZero, sincesignOfcan only suppress the sign of the string it builds itself. All pinned bysigned-in-model.test.ts. - Holdings by wallet. One expandable card per selected wallet holding anything in the active unit, showing its Yield / 24h APY / Value. Expanded, the wallet's positions group into up to seven category sections, in the fixed taxonomy order — Repo lending, Carry trades, Money market funds, Multi-strategy funds, Fixed rate assets, Variable rate assets, Idle assets (empty sections do not render). The category is the SERVER's read-time classification (
pnl.tsclassifyLegsAtTs, returned asPositionRow.category), so the client never re-derives it: the server distinguishes an fToken (repo lending) from a curator vault (money market fund) from a broad-mandate vault (multi-strategy fund) via the erc4626 role, which the browser cannot see. "Multi-strategy funds" is a display label, not a key. The category key staysmanaged_strategy_fund(the/api/portfoliowire format) and the erc4626 role stays'managed'; onlyCATEGORY_LABELinsrc/lib/portfolio/api-types.tschanged. The rename separates the section from Money market funds, whose Morpho/Euler curators are also managers: the real distinction is mandate breadth, since a multi-strategy fund's manager may take leverage and directional exposure while a money market fund's curator only allocates across lending markets. A wallet leg is classified by its registry token class:variable_rate→ Variable rate asset,par→ Idle asset. Idle collects the unproductive bare balances (stablecoins, ETH and WETH, the BTC wrappers), each valued but carrying no APY column. A par idle token in a based book charts as a flat zero-yield line; a non-based fiat stable (EURC, registrybookNULL) is the one carve-out — it is valued in USD but never charted into a book curve (an FX move is not yield), surfaced in the Idle section as a value-only row (PositionRow.charted = false). A group holding a debt leg is a carry (its collateral and debt legs MERGE into one collateral→debt row with net equity value and net carry yield) — e-mode no longer gates this: any same-book supply+debt loop is a carry, e-mode or not. One venue-shaped exception mirrors the server's classifier (coverage policy rule 5): a pure-lend…:supplyleg in a debt-bearing Morpho market is an independent lend (lender-side funds are never seized), so it charts as its own repo-lending row and is never fused into that market's carry equity or net APY. - Columns are a function of the category. Non-carry categories render through
PosSection: Position · Platform (venue brand mark + name +E-MODEbadge where applicable, from the read-timePositionRow.emode;PlatCellleads with the mark exactly as the carry row does, so a repo-lending / vault / Pendle / wallet row is never left with a bare text platform) · 24h APY (the 24h-trailing advertisedquotedApy) · Yield earned · Value. Idle is the exception: Position + Value only. An idle asset earns nothing by construction, so Platform / 24h APY / Yield earned can only ever render a dash or a zero down the whole section, and a 0% beside a real rate invites a comparison that means nothing. - Only a carry expands; every other row is flat. A carry fuses several legs into one row, so its expanded panel is the only way to see them. Every other category is one leg per row by construction (
toDisplayPositionsemits a standalone position per non-carry leg), so the per-leg panel those rows used to open restated the row directly above it — same label, same value, same yield, indented.PosSectiontherefore renders no chevron, no click target and no panel. - The Position cell carries the asset's own coin, except for a vault. An ERC-4626 share token is named for its vault (
Fluid Lite USD,Fluid Lending USDC,Gauntlet DAI Core), not for a ticker, so the coin resolver can only ever monogram it. A vault row wears the brand of the protocol that runs it instead (PositionGlyph→VaultGlyph, resolved from the vault address on the leg's position key viaerc4626Brand), seated in the same circular footprint the coins use. No category badge sits beside the name: the row already renders under its category's section header, so aSUPPLY/IDLE/HOLDchip only restated it. - Carry rows are their own section (
CarrySection, design_handoff_carry_expanded). The collapsed carry row is a balance-split Position cell · Platform (brand icon + name +E-MODE) · 24h APY · All-time yield · Dislocation P&L · Value (with a% of booksub, against the wallet's whole tracked value). Only the dislocation P&L rides in the collapsed row — the current gap and the entry basis live in the expanded panel. Dislocation P&L isnow − at entry(positionBasisDelta): the change in the position's market-vs-redemption gap since the trade was put on, signed green/red with a% of positionsub. It rounds againstrendersZero(a gap that rounds away shows a dash, never a signed zero — M9). TheDISLOCATION_TIPinfo glyph on the header carries the definition. - The balance-split Position cell. Collateral block (amber
Collaterallabel) · hairline · Debt block (mutedDebtlabel), each = an overlappingCoinClusterof the REAL coin marks + middot-joined tickers + aSMARTchip on a Fluid smart (DEX) pair + the per-symbol quantities beneath. Platform brand marks come from the sharedPlatformBrandresolver — used by BOTH the carry and non-carry rows so the two can't drift — which covers every venue, never a letter tile for a known one:aave/sparklend/morpho-blue/fluidare the direct venue marks;pendleandwalletadd the Pendle mark and a neutral wallet glyph; and anerc4626vault resolves to its real protocol viaerc4626Brand(client-safe, keyed off the vault address) — a curator fund → its money-market primitive (Morpho / Euler), a Fluid fToken → Fluid, a multi-strategy fund → its manager (YO / Yearn / Fluid). Both the coin marks and these venue marks are authentic brand SVGs; see the icon registries undercomponents/icons/*andcomponents/assets/IssuerIcon(the Asset Profiles issuer column, which now also carries the Aave and Coinbase marks for sGHO and cbETH). - A fund row leads with its MANAGER, not its venue. The Position cell of an
erc4626row names the fund ("Sentora PYUSD USDC", "Steakhouse USDC"), so its mark is the manager's, resolved from the vault address througherc4626Curator- the shared
curator-marksregistry — the same logo the /repo-lending Money Market Funds table shows in its Fund column. The venue keeps its own mark one cell over in Platform, so a Sentora fund on Euler reads Sentora · Euler rather than Euler · Euler. A vault whose manager is unknown (a DB-discovered MetaMorpho factory vault, outside the static universe) falls back to the venue mark, which is the only thing still known about it.
- the shared
- Every wallet-tracked asset has a ticker and a coin. An address with no ticker would print as
0x1234…cdef— and, since the coin registry is keyed on the ticker, with a grey monogram beside it. Sosrc/lib/portfolio/symbols.tscovers every address in the book map (locked bysymbols.test.ts; it is a superset, since EURC is named but charted in no book) and every idle stablecoin ships an authentic coin. rUSD (Reservoir) is the single documented gap: no brand registry carries its coin, so it keeps the neutral monogram rather than an invented mark. - Expanded carry detail — two columns + a deeplink CTA. LEFT: a Position economics per-leg ledger (each leg's coin + ticker, quantity, current value, realized yield) with a Net value footer (the equity). The design's reference showed a per-leg value AT ENTRY, which this product does not track (only the per-leg entry BASIS,
entry-basis.ts); rather than fabricate one, the ledger shows the real per-leg economics we hold and the entry-to-current story lives in the basis card. RIGHT: a Basis & dislocation P&L card — Entry basis (positionEnteredBasis, the market-vs-redemption gap the fills carried, valued at each flow's own block, stable across syncs) · Current basis (positionRawBasis) with an unwind signal chip driven by the SIGN of the current basis (positive →Favorable to unwind, elseHold) · Dislocation P&L (positionBasisDelta) as the headline, with a<entry> → <current>explainer and an amber tint when the move is material. The M9 honesty flags survive on the card: a partial entry figure (a flow lacked a valuation) is marked~and footnoted; a reconstructed figure (the position predates flow tracking, anchored at the first snapshot) is footnoted synthetic; a leg with no anchor shows a dash + an "unavailable" note; and if a CURRENT price read failed on any leg the card withholds the current basis and the dislocation P&L rather than compute against a partial current basis. PT legs enter at 0 by construction (M12 anchors their redemption mark at entry). TheENTRY_BASIS_TIPinfo glyph carries the full definition. A footer CTA (Take me to carry trade metrics) deeplinks to the trade on /carries via?plat=+?col=pre-filters, plus the exact?carry=<prefix>-<col>-<debt>row key for a single-leg Aave / SparkLend carry (Fluid and Morpho keys carry a vault id / market hash not held on a portfolio row, so those fall back to the pre-filter). - Leverage & liquidation strip (
CarryRiskPanel, below the two columns). A full-width risk readout for the carry: Leverage (collateral / equity) with amax Nxsub (1 / (1 − maxLtv), the most the pair can be levered at its borrow cap), LTV (debt / collateral) with acapsub (the max borrow LTV), and Distance to liquidation (1 − LTV / liquidationThreshold, the adverse collateral-vs-debt move the position can still absorb, floored at 0) with aliq. LTV … · HF …sub (health factor =liquidationThreshold / LTV, below 1 is liquidatable) and a colour band (green ≥ 20%, amber ≥ 8%, else red). An LTV gauge fills a0 → liquidation-thresholdtrack to the current LTV with acaptick, so the number and the picture agree. The whole strip is computed at the MARKET mark regardless of the price-basis toggle — a protocol liquidates on its oracle, not on par redemption, so a redemption-mark LTV would understate the liquidation risk of a discount (PT) collateral, the one direction a risk readout must never err. The math is pure and unit-tested:carryRisk(p, "market", params)insigned-in-model.ts. - Two risk SCOPES, because the liquidation unit differs by venue. Isolated venues (Fluid vault, Morpho market) are their own liquidation unit, so leverage / LTV are derived from the carry's own legs (
scope: "position"), withheld to a dash (never fabricated) when a collateral/debt leg's current mark failed to read. Aave and SparkLend liquidate a WHOLE ACCOUNT as one cross-collateralized unit, so applying an account-blended threshold to a single carry's leg-subset LTV would be a fiction once the account holds anything outside that carry (a second position, idle collateral, a carry in another book). For those,getUserAccountDatareturns the account's ownltv/leverage/ thresholds and the strip reports THOSE (scope: "account", a footnote states leverage/LTV/buffer are account-level); the leg values still populate the ledger above but never drive the risk cells, so a null leg mark does not withhold them. Max leverage + the buffer need the pair's on-chain risk params, fetched best-effort fromGET /api/portfolio/risk(risk-params.ts) — Aave/SparkLendgetUserAccountData(account-level params + current state), FluidgetVaultVariables2Raw, MorphoidToMarketParamsLLTV (one value that is BOTH cap and threshold). A carry whose params could not be read dashes only those two cells and shows a short note; nothing blocks the dashboard on the risk fetch. - The sides are passed as explicit symbols, never a
"/"-joined string."/"is carry_registry's encoding for a Fluid smart (DEX) pair, and PositionCell inferssmartfrom it — sound for /carries, where the label IS a registry label. It is not sound here:groupKeyOffuses a whole Aave account into one row, so its collateral side can hold two INDEPENDENT supplies, and joining them would smuggle them through that delimiter and brand a plain pooled positionSMART COLLATERAL(a different instrument, with different depeg / rebalancing behaviour).PositionCelltherefore also accepts aLegSpec { symbols, smart, note }, and the portfolio statessmartfrom the leg SHAPE (isFluidSmartLeg: a Fluid smart leg's position_key has 7 colon-parts, the token preceding the terminal side) rather than the token count. The portfolio also passesringColor— PositionCell's coin-separation ring defaults to the /carries black row background, which would read as a black halo on theT.PANEL2holdings panel. The tickers come fromsymbolOfLeg, which drops a Fluid leg's vault/NFT tail (weETH LP col · Fluid #93 (NFT 9266)→weETH) — leaving it on pushed raw NFT ids into the table and monogrammed every Fluid leg, since the coin resolver is keyed by ticker. - A vault is branded from its ADDRESS, in both cells — but the two cells answer different questions. An
erc4626leg's name is a vault name (Fluid Lite USD,Sentora PYUSD USDC,Gauntlet DAI Core), not a ticker, so the coin resolver could only ever monogram it. Both marks resolve from the SAME vault address on the leg's position key (vault:<addr>), so neither can drift from the row — but the Position mark asks who runs this fund (VaultGlyph→erc4626Curator+curator-marks) while the Platform mark + name ask whose app is it on (PlatformBrand+ERC4626_BRAND_LABEL→erc4626Brand). So the PLATFORM cell reads Fluid for an fToken and fLiteUSD, Morpho for a MetaMorpho vault, Euler for an Euler vault, YO for a YO vault, Yearn for a Yearn vault — while the POSITION mark beside the name is the manager's (see the bullet above). This replaced a flatHeld · spot, which read as a spot holding on positions that are nothing of the sort (an fUSDC deposit is Fluid lending; fLiteUSD is a managed strategy). Only a barewalletbalance still readsHeld · spot, where it is accurate. Fluid's mark is the one seated below full size (72% of the disc): its 2026 "Sign" art is a PORTRAIT wave filling its 66×83 viewBox edge-to-edge, so atsizeits corners would overrun the circular footprint the coins sit in (/carries never hits this —PlatformLinkrenders it bare, with no disc). - One disclosure survives at the foot of the page: "Base yield only; rewards and points are not included." It is not methodology (how a figure is built) but a DEFINITION of what the figures count — every venue quotes a reward-inclusive APY, so without it a reader compares our number to theirs and reads a definitional gap as a data bug. The provenance / methodology copy that sat beside it is gone.
- Honest gaps. Two prototype elements had no real backing and were NOT faked: there is no "if held as cash / spot" comparison line (no such counterfactual is computed), and a carry expands to real per-leg economics rather than a synthetic per-position chart. The view-model math (classification, carry merge, aggregation, formatters) is pure and pinned by
signed-in-model.test.ts; the rendering path byPortfolioDashboard.test.tsx. - The server still computes realized return / TWR / the wedge (returned in
SummaryResponse/PositionsResponse); the redesigned rail leads with tracked value + net APY + daily income instead of the earlier per-book return tiles, but the methodology below is unchanged. For reference, those tiles were: cumulative yield in the book's native unit (e.g.+1.2384 ETH), realized return, TWR, and the current book value in the active mark. The two return metrics are not annualized and are honest at any observed length (no 30-day gate on the tiles):- Realized return is a simple holding-period return — the book's cumulative yield as a percentage of the capital first deployed in the book (
totalYield / baseCapital, wherebaseCapitalis the book value at the first snapshot with positive equity, so it is robust to a position born mid-series or starting underwater). No positive base ⇒ a dash, never 0 (M9). - TWR is the flow-neutral, geometrically-linked time-weighted return (
curve.twr). The two match when there are no flows and diverge once capital is added or removed mid-period (realized return keeps the original base; TWR re-bases each sub-period to its own start equity). - Each of cumulative yield, realized return, and TWR carries a plain economics tooltip on the app-wide
InfoTooltip"i" icon (the same affordance/carriesand the terminal tables use; never the CSShelpcursor). Copy lives intheme.ts. The cumulative-yield tooltip names the account's real tracking anchor (summary.trackedSince=floor_ts, e.g. "since tracking began on 15 Apr 2026") and stops there. It deliberately does not explain how that date is derived (product decision 2026-07-14): the rule isday_floor(max(signup - 90d, coverage floor 2025-05-21, first curve-starting activity in a position group still held))(see Data pipeline — a par/idle wallet-token flow counts only when it is the wallet's sole activity, so an old stablecoin top-up cannot drag the anchor months before the first yield-bearing position), which is an implementation detail. This is the ACCOUNT anchor, which is what the tiles quote; the chart anchors each asset class on its own first curve-starting observation instead (see "Each asset class starts on its OWN anchor" below), so the two dates legitimately differ for every book but the earliest. Any "the last 90 days" phrasing would be plainly false whenever the first-activity clamp binds (i.e. the wallet's first deposit landed less than 90 days before signup, which is the common case). The coverage floor is effectively dead for any new account:signup - 90dhas been later than 2025-05-21 since ~2025-08-19. When the backfill has not landed there is no anchor yet, and the sentence elides the date and still reads cleanly. - The annualized realized APY was removed from the tiles; it remains in the positions table's earned-vs-advertised column (below), which is a like-for-like comparison against advertised annual rates and keeps the 30-day gate.
- Realized return is a simple holding-period return — the book's cumulative yield as a percentage of the capital first deployed in the book (
- The chart: the cumulative net-yield line (bucket-reduced onto a uniform grid) and liquidation markers. Gaps render as gaps (an observation gap is a clean line break, never interpolated). It plots one series, yield earned, and deliberately does NOT mark capital in/out: a deposit is not a yield event, and the flow ticks read as though the curve moved because of them when the curve is flow-neutral by construction.
/api/portfolio/historystill returnsflows(the events ledger is a separate surface); the chart just does not draw them. The hover tooltip shows the date and cumulative yield earned only (plus a LIVE note on the tip):bookValueis still returned per point in the API but is not rendered, so the chart never surfaces a per-point book total for the reader to reconcile against the current tracked-value tile. - Each asset class starts on its OWN anchor (2026-07-24). The account has one replay start (
summary.trackedSince, above), and it belongs to whichever book opened first. Charting all three books from it opened the others on a flat-zero lead-in that said nothing: a wallet running a BTC carry since June and a USD vault since July had its USD chart start in June, a month before any USD position existed to earn on. So the plotted series per book is anchored on that book's own first curve-starting observation — same rule as the backfill's, one level down: a leg in the Idle assets category (a par wallet token: USDC, WBTC, native ETH) earns nothing by construction and cannot start a curve, any other category can. A book holding only idle legs keeps its full series (the same idle-only fallback the backfill uses: the curve is flat zero wherever it starts).history.trackedSincereports the served series' own start, so it is per book and differs fromsummary.trackedSincefor every book but the earliest.- The head is the last idle-only point, one grid step BEFORE that observation. Anchoring on the curve-starting point itself would swallow the birth interval: the partial-period accrual between the opening deposit's block and the first snapshot, which a
value/ptleg books as real yield (sUSDe, Morpho collateral, a Fluid smart leg, a PT). Keeping the point before it draws that first rise, keeps the opening deposit inside the plotted window, and guarantees the head reads zero. Anchoring on the curve-starting point instead would also have silently disabled the trim for that whole half of the venue set, since a non-zero birth residual would trip the zero guard below. - It is presentation only, and cannot hide P&L. The curve is still built over the whole series, so cumulative yield, realized return, TWR and observed days as
summaryreports them (and as the superseded per-book tiles quoted) are unchanged, and the trimmed span is idle-only, which accrues exactly nothing. The trim is additionally all-or-nothing over the dropped span, on two independent guards: every dropped point must read cumulative yield 0, and every leg in the dropped span must beaccrual: 'none'— the mark-INDEPENDENT statement of "earns nothing by construction" (a par wallet token isnone; an sUSDe-style rate-source token isvalue). Without the second, the decision would ride on a cumulative number that differs between the market and redemption marks, and the same book could anchor on two different dates depending on the mark toggle. If either guard trips the series is served whole, whatever the categories say — so if the wallet-token registry ever degrades (every wallet leg would then read Idle) a genuinely earning prefix stays on the chart in full. Every guard fails the same way: keep more history, never less. The retained head therefore always reads zero, so the chart needs no re-basing and keeps quoting the same cumulative number as the tiles. Note the observed-days figure still spans the ACCOUNT anchor, so any annualized number derived from it is measured over a longer window than the chart draws; likewiserealizedReturndivides by the book value at the first positive-equity point, which for a book with an idle prefix is the idle cash balance. Both are summary-scope figures and are deliberately left alone here. - Nothing is deleted and no re-backfill is needed — the trim is applied at read time over stored history, so widening or dropping the rule later is a code change alone. The full flow ledger is served by
/api/portfolio/eventsregardless. - Markers are clipped only when the series was actually trimmed, and against the drawn domain (the reduced series' first point, which the bucket floor can place up to one bucket earlier than the curve's own start), never the raw curve ts. A reference line outside the plotted window is drawn clamped to the wrong edge, so it would read as an event at the chart's start; but an untrimmed book keeps every marker it served before, including the opening-balance flow that sits at or before the first snapshot.
- The head is the last idle-only point, one grid step BEFORE that observation. Anchoring on the curve-starting point itself would swallow the birth interval: the partial-period accrual between the opening deposit's block and the first snapshot, which a
- Timeframes and chart resolution (2026-07-17). The timeframe pills are
1W · 1M · 3M · 6M · YTD · ALL. 1W draws the raw 6h cron cadence (?bucket=6h); every longer timeframe draws the daily reduction (?bucket=1d), which is the default and the pre-existing behaviour. 3M is deliberately not 6h: 90 days x 4 snapshots is ~360 points across ~600px of chart, which is sub-pixel on a curve this smooth, so it buys no visible detail while quadrupling the payload and making the tooltip snap to noise.- A daily point is an END-OF-DAY reading stamped at midnight, not a midnight reading.
bucketReduceCurvekeeps the LAST observation inside each bucket and stamps it at the bucket's start, so the point labelled "Jul 17" holds Jul 17's 18:00 UTC snapshot (the last of that day's four aligned 6h windows). The convention is consistent across the series and the sub-day offset is not resolvable at daily zoom; the 1W tooltip, which does show a clock time, is on the 6h grid where each point is its own window. - The live tip. The cached JIT "now" read is merged into the curve as an extra point (
POST /api/portfolio/refreshin APIs warms it). On the 6h grid it is held out of the bucket grid and appended at its true timestamp, flaggedliveand drawn as the one dot on the line: flooring it into its window would let it OVERWRITE the cron snapshot that owns that window (a 10:23 live read would replace the 06:00 point, and the 06:00 snapshot would never be visible). On the 1d grid it stays folded into today's midnight-stamped point, as it has always been. Its timestamp is the moment the read was COMPUTED (LiveResult.computedAtMs), not the moment the GET arrived, so the clock time in the tooltip is when the chain was actually read. ?wallet=allnever tips. The aggregate merge walks a uniform stepped grid and would step straight over an off-grid point, and each wallet's live read is computed at its own moment, sogetHistoryAllasks for the tip folded back into its bucket. The signed-in chart does not take that path: it fetches per wallet and merges client-side.- The merge holds each wallet flat across empty buckets (2026-07-20). The backfill grid is DAILY while the live cron is 6h, so a wallet's 6h series carries NULL at the buckets it never filled (06:00/12:00/18:00 of every backfilled day). The client merge carries each wallet's last real cumulative FORWARD across those nulls (the "carry-forward sum" above); it must NOT read a null bucket as
0, which sank the 2+-wallet aggregate to the window floor between midnights (a daily sawtooth that never happened, since a rebased window turned each0into-base). A single selected wallet takes the fast path, where its own null buckets still render as clean gaps.
- A daily point is an END-OF-DAY reading stamped at midnight, not a midnight reading.
- Advertised rates. The holdings sections' 24h APY column shows the 24h-trailing advertised rate (
quotedApy), falling back per row to the latest 6h-window rate where the 24h figure is not yet available (e.g. a pool younger than 24h). The 24h-trailing figure de-noises the column: a single 6h fee snapshot on a levered Fluid smart pair swings the net quoted rate hard (a 16x position turns a fee wobble of a few basis points into whole percentage points), which is what the trailing window smooths. The per-leg "realized APY vs advertised rate" readout is not on this surface: it lived only in the non-carry row-expand, which was removed with the disclosure (a non-carry row is a single leg, so the panel merely restated the row). The figures are still computed and still served (PositionRow.realizedApyMarket/realizedApyRedemptiononGET /api/portfolio/positions), so re-surfacing them is a rendering decision, not a data one. For a Fluid leg the quoted rate is the Liquidity-Layer supply/borrow APY of the leg token plus any wrapper APY (a normal T1 leg, exact); a smart (DEX) leg quotes the pool fee APY + LL rate + wrapper marked approximate (a leading~), since a smart leg has no single advertised figure; a not-tracked token/pool shows a dash, never 0% (M9). A Fluid leg is labelled with both its vault id (names the market, as on/carries) and NFT id (names the position), e.g.weETH supply · Fluid #16 (NFT 9266)(USDe LP col · …for a smart leg). A wound-down / below-floor / blocked Fluid vault is ANNOTATED in place, never hidden — it still holds live user debt (D2). - Out-of-taxonomy legs are not rendered (product decision 2026-07-13: the view shows only charted positions). Uncharted legs (a borrowing with no collateral behind it, a Fluid vault of two different base assets, an asset outside creddit's coverage) are still classified and valued, still returned by
/api/portfolio/positions(outside), and an unknown asset with value still fires the WS8 Telegram alert; the UI simply has no section for them. The one-line rule: supply positions always count (an Aave/Spark account is partitioned per book, so a USDC supply charts even beside an excluded cross-book loop; a direct Morpho lend charts even when the wallet also borrows in that market); a leveraged position counts as a carry whenever its supply and debt are the same book — e-mode is no longer required (settled 2026-07-15:emode_categoryis kept only as the E-MODE display chip, not an inclusion gate). A position carrying debt in a denomination it holds no collateral in is a financed position: it is not excluded, it charts in the Cross-asset view (M22), so its funding cost is attributed rather than dropped. That covers an Aave/SparkLend account, a Fluid vault NFT and a Morpho Blue market's collateral+debt carry (never its pure lend, which is financed by nothing). What is left excluded as cross-book — reported as "Debt without matched collateral" — is a bare debt: a borrowing with no collateral standing behind it in the book, so there is no asset whose return its funding cost belongs against. That is an account whose only collateral is an asset outside coverage, and a position whose collateral has gone unread for more than a single snapshot or was taken entirely by a seizure that left the debt behind. A Fluid vault holding two different base assets on one side is a separate verdict: its return is the price of one asset against the other, which is a directional position and not fixed income, so it is outside coverage in every view, financed or not, and reported as "Directional position outside coverage" (directional-pair). That is decided once per vault, because the pair a vault holds is fixed when it is deployed (M14). - A permanent methodology footer: "Base yield only. Rewards and points are not included. Tracked since
<date>."
Freshness is stale-while-revalidate: the page paints immediately from the stored 6h history (the GET routes never trigger the JIT pipeline), while a background POST /api/portfolio/refresh runs the just-in-time (JIT) on-chain read; when it lands the client re-fetches and the view upgrades to the current block ("6h stays, now = JIT RPC" — so a position entered five minutes ago still appears on the next load, just a few seconds after first paint, behind a dim LIVE SYNC note). If the live read stalls or fails, the stored view simply stands and the freshness is the last snapshot.
Two rules keep that split honest:
- The live merge must be strictly newer than the stored history. A cached JIT read is merged as the series' LAST point (
ts = now), so it is admitted only when its anchor block is above the newest stored snapshot block (liveMergeAdmissible, pinned inassemble.test.ts). Otherwise the 6h cron committing a snapshot above a still-warm cached anchor would re-state "now" from an older chain state, and a deposit landing in that gap would book as phantom negative yield (the flow nets into the earlier interval while the value series steps back). An inadmissible read is dropped:jit: false, stored history stands. - The one on-chain read left on a GET path is bounded.
GET /positionsresolves a held Fluid vault's per-side DEX pools for the smart-leg quoted rate. That lookup is memoized for the process lifetime (the pools live inconstantVariablesand are immutable per vault) and bounded by a 2.5s budget, becauserpcRequestretries against a 60s abort and an RPC that hangs rather than fast-fails would otherwise stall first paint for minutes. On timeout the request serves what is cached (an unresolved pool shows a dash, M9) and the read keeps warming the cache. Steady state is zero RPC on the request path.
APIs
Authenticated routes under src/app/api/portfolio/*, all force-dynamic and gated by verifySessionCookie. The wallet address is only ever the one the server reads from the session cookie — a ?address= param or a request body is ignored (a hard security rule).
| Route | Returns |
|---|---|
GET /api/portfolio/summary | Per-VIEW headline in both marks, backfill status, the wedge, current state (JIT-merged when the live cache is fresh). books carries one entry per view (USD, ETH, BTC, CROSS), each with present; the three denomination views carry their figures directly, in their own unit, while CROSS carries units[] — one complete block per denomination its financed positions hold (M22), keyed by that unit, and NO figure across them (a combined number could only come from converting one denomination into another). units is empty when nothing is financed. Each block's bookValue is what that view HOLDS at the newest observation (0 for a view a position has left, whose yield stays banked and reported), so summing bookValue across the views can never double-count a financed position, and the summary and /positions always agree on where a leg is. While the wallet is syncing it also carries backfillProgress — the live reconstruction cursor {phase, done, total, etaSeconds} (phases queued → scanning → replaying → finalizing; done/total are the replay's daily grid points from portfolio_backfill_state.progress_*, migration 060; etaSeconds is a measured-rate estimate, null until a rate exists). Read fresh each call, never through the input cache (during a replay neither table MAX moves, so a cached cursor would freeze exactly while the building chart polls it). Aggregate mode reports the LEAST complete building wallet (worstBackfillProgress). |
GET /api/portfolio/positions | Per-VIEW position rows (both marks, earned-vs-advertised) + the M1-excluded (outside) groups with their reason (returned by the API; not rendered in the view). One bucket per view, holding the rows that view renders TODAY; each row keeps its own book denomination, which equals the bucket for the three denomination views and need not for CROSS, whose rows span denominations (an ETH collateral row and a USD debt row in one bucket). A financed position lists under CROSS only, never also under the denomination it used to chart in, and its bare debt leg is no longer in outside. |
GET /api/portfolio/history?book=USD|ETH|BTC|CROSS&mark=market|redemption[&unit=USD|ETH|BTC][&bucket=6h|1d] | The chart series: cumulative net yield (native units, gaps as null), flow markers, liquidation markers. book/mark/unit/bucket validated strictly (400 on unknown). book names the VIEW, which is not the same thing as the unit the series is drawn in: the three denomination views carry their own unit and therefore REJECT a unit param (400, never silently ignored), while CROSS spans denominations and requires one to pick which native-unit series to serve. CROSS serves the financed positions (M22): collateral in one denomination funding debt in another, one series per native unit held. bucket is the x-grid and defaults to 1d when absent (the pre-bucket contract): 1d is one point per UTC day carrying that day's last snapshot, 6h is the raw cron cadence with the cached live read appended as a final live tip at its true timestamp. The series starts at THIS book's own first curve-starting observation, not the account's replay start, and trackedSince reports that per-book date (see What the user sees). |
GET /api/portfolio/events?limit=&offset= | The paged flow ledger, most recent first. |
GET /api/portfolio/risk | Per-carry risk params for ONE wallet's open carries: { risk: { [groupKey]: {maxLtv, liquidationThreshold, source, account?} }, degraded? }, fractions. Read live on-chain per venue (risk-params.ts): Aave/SparkLend getUserAccountData(wallet) (also yields the account-level account:{ltv, leverage} since those venues cross-collateralize), Fluid getVaultVariables2Raw(vault), Morpho idToMarketParams(id).lltv. Single-wallet only — the map is keyed by the wallet-agnostic group key (aave:account), so ?wallet=all returns {} rather than collapse two wallets' account params onto one key (the client fetches per wallet). Best-effort and additive: a carry whose individual read was implausible is simply absent (the UI dashes its cells); a whole-read RPC failure returns degraded:true so the client retries instead of caching the empty map. Drives the expanded carry detail's Leverage & liquidation strip. |
GET /api/portfolio/prices | The latest mirrored USD price of each native book unit, {eth, btc, asOf} where each entry is {usd, barTs} or null. Backs the ETH/BTC ≈ $X display annotation and nothing else. Account-independent, so it is fetched once per dashboard mount (useNativeUsdPrices) rather than per wallet, and it is never written to the client cache (a week-old ETH price painting first is exactly the confident-wrong number this view refuses elsewhere). Reads readBar only — the pure, indexed LIMIT 1 DB read. Every batched mirror entry point (getBar, getBarsAt, getBarSeriesAt, and loadMirrorMarks / priceInBookFromMirror / loadMarketContext above them) FETCHES THROUGH to the Dune API on a miss, so importing one here would let page traffic spend from a scarce credit budget. A miss is served as null, never 0 and never patched from another vendor (M9); a served bar past BAR_STALE_SOFT logs a warning (M20). |
POST /api/portfolio/refresh | Triggers the wallet's JIT live read (rate-limited + coalesced per wallet, 60s window; request bounded at 30s) and returns {refreshed}. The GETs merge the result from the live cache on the client's re-fetch. |
Coverage gaps on the chart (M21)
A value / pt leg that the pipeline could not READ at some snapshot is a coverage gap, not a loss. The engine bridges it (see M21), which has one visible consequence worth knowing when reading a chart: during the gap the book-value line dips while the cumulative-yield line stays flat. The dip is honest — that snapshot genuinely did not contain the leg — and the flat yield line is the whole point: before this rule the same gap booked the leg's entire principal as a permanent negative yield. Book value recovers the moment the leg is read again, and the yield line picks up the gap's genuine accrual in one step. Realized APY is unaffected: the TWR equity base keeps the unreadable leg at its last known value, so the accrual is divided by the capital that earned it.
Read-path caching (in-process)
One dashboard paint calls these routes many times per wallet: summary + positions on mount, both again after the background refresh POST lands, one history per book / mark / bucket the user opens, and a 20s summary poll while a wallet still syncs — 5-8 full context assemblies per wallet, and ×3 for ?wallet=all. Each loadContext (src/lib/portfolio/api-data.ts) runs ~9-10 DB queries, including the wallet's entire snapshot history and its whole flow ledger (neither is LIMITed), so that burst can push the Postgres pool. Two in-memory caches (src/lib/portfolio/context-cache.ts) collapse it. Both are single-process (module-level, like live.ts's live-result cache): this is the whole cache; a second instance would keep its own, each still correct (a miss is only ever slower, never wrong).
- Registry cache — 60s TTL. The four wallet-independent loads keyed only by chain (
loadPtUnderlyings,loadPtMarketsByPt,loadFluidState,loadPortfolioTokens) move on the 6h refresher cadence, so a 60s TTL is invisible: the worst a stale entry can do is leave a since-added PT / vault / token temporarily unresolved, which renders as an M9 dash or a static-map fallback (never a fabricated number) and self-heals within a minute; a since-removed entry is kept anyway by the M13 held-by-anyone rule. Concurrent misses coalesce onto one read; a refresh failure serves the last-good value (logged once) while the call site's own.catch(() => empty)still governs a cold miss, so degradation is never worse than before. The cron / backfill import the loaders directly, not this cache, so they always read fresh. - Per-wallet input cache — 15s TTL, 500-entry LRU. Caches the two unbounded per-wallet reads (snapshot history + flow ledger) plus the two cheap ones (backfill, created-at), keyed by lower-cased wallet.
Why a stale cached input can only ever be stale, never wrong (the JIT staleness argument). Two MAX blocks are the pivot — MAX(block_number) of the wallet's snapshot table and of its flow table — and both are read fresh on every call and never cached. They do two jobs:
- The snapshot MAX is what
liveMergeAdmissible(anchorBlock, maxStoredBlock)compares the live "now" tip against — the tip is merged only whenanchorBlock > maxStoredBlock. A stale (lower-than-reality) MAX would make that test pass for a tip a 6h-cron snapshot has since superseded, and merging a tip at/under the newest stored snapshot re-states the last point from older chain state — the exact phantom-yield case the guard exists to block. So the value handed to the guard must be the real one, not a cached one. - Both MAXes gate the per-wallet cache (
walletInputGate): the cached snapshot / flow bundle is served only while neither table has advanced since it was filled. Co-gating on the flow MAX is not redundant — the 6h cron commits a wallet's snapshot and that tick's flows in separate transactions (writeSnapshotsthenwriteFlowsinscripts/refreshers/portfolio.ts), so a GET that fills the bundle in the gap pins a flow-missing ledger next to the new snapshot's higher value (a value step with no offsetting deposit flow = phantom yield), and the later flow commit does not move the snapshot MAX. A snapshot-only gate would keep serving that flow-missing bundle for the whole TTL; gating on the flow MAX too means the moment those flows land the gate mismatches and the bundle is re-read. This is what stops a fresh live tip (or the new snapshot's own value) from ever being netted against a stale flow ledger.
With that two-table gate, a cache hit is provably equivalent to a fresh read for every value-bearing input. The only staleness that survives is backward-extending backfill history (a freshly added wallet's replay writes snapshots at old blocks, which moves neither MAX): that can leave up to 15s of not-yet-shown early history, which only shortens the series — it never books a number — and such a wallet is flagged syncing with the poll already running. The newborn-leg guard is preserved unchanged: when the JIT mini-scan persists a flow during the refresh, the ledger is re-read for that request and the cached bundle is patched under that call's gate (a later call reading the newer flow MAX mismatches and re-fills instead). All of this is 15s inside the 6h snapshot grid.
Tracked wallets (multi-wallet)
A signed-in account can track up to MAX_TRACKED_WALLETS = 3 wallets including its own (so two on top of the one you signed in with). Tracking is watch-only: it needs no signature, because everything it surfaces is public on-chain data. The account's own wallet is stored in the list like any other (isPrimary), so the list has no special case; it is the only one that cannot be removed, though it can be renamed. The list itself is private to the account even though the positions are not.
Adding a wallet reproduces first sign-in exactly: it gets a shadow accounts row (so the whole existing pipeline works on it unchanged), the same registration backfill (or a repair requeue, if it was tracked before and its history has gone stale while nobody was watching it), and a fire-and-forget JIT live read so current positions paint before the archive replay lands. Removing a wallet is an unlink only: no snapshots or flow rows are pruned. It simply stops being snapshotted once no account references it, and re-adding it requeues a run that PATCHES the gap and preserves the pre-gap history (a wallet with no coverage anchor still gets the full replay), which repairs the gap.
| Route | Returns |
|---|---|
GET /api/portfolio/wallets | The account's tracked wallets, primary first: {wallets: [{address, label, isPrimary, addedAt, backfill, trackedSince}]}. backfill/trackedSince are per-wallet, so a freshly added wallet reads queued/running (the UI shows "syncing") while the signed-in one is long done. |
POST /api/portfolio/wallets | {address, label?} -> track a wallet (201). Invalid address or a label over 64 characters is a 400, the cap is a 400, an already-tracked wallet is a 409. |
PATCH /api/portfolio/wallets/[address] | {label} -> rename. Renaming the primary is allowed. A wallet the account does not track is a 404. |
DELETE /api/portfolio/wallets/[address] | Un-track (unlink only). Removing the primary is a 400; a wallet the account does not track is a 404. |
Selecting a wallet: ?wallet= on the five wallet-scoped routes above
All five wallet-scoped portfolio routes take an optional ?wallet=:
- omitted -> the session wallet, exactly as before multi-wallet, so an old client or a bookmarked URL keeps working unchanged.
?wallet=<address>-> that wallet, and only if the account already tracks it. Otherwise 403. That 403 is load-bearing: without it,?wallet=<anything>would turn every portfolio route into an open indexer for arbitrary addresses, and the tracked list is private to the account. Any casing is accepted (the address is lower-cased before it is matched).?wallet=all-> the aggregate across every tracked wallet (below).
The account is always the verified session cookie. ?wallet= only ever selects among wallets that account already tracks; it can never introduce a new one.
"All wallets": how the aggregate is computed
Aggregation happens on the engine's OUTPUTS, never its inputs. position_keys are wallet-agnostic by construction (the readers build them from protocol identifiers, so two wallets both supplying USDC on Aave emit the identicalaave:reserve:0xa0b8…:supply), and the engine indexes legs ts -> position_key -> leg. Handing it two wallets' legs would silently overwrite one with the other, halve the book value, net one wallet's deposits against the other's legs, and (worst) merge their Aave accounts into a single M1 group, where one wallet's supply could "cover" the other's bare debt and flip it from Outside into a charted carry. That is a change to the methodology, not a display bug. So the aggregate runs the existing single-wallet pipeline once per wallet and merges what comes out. The engine is untouched, and position_keys are never namespaced by wallet (which would break the venue-prefix parsing everywhere).
Aggregating a single wallet reproduces that wallet's own response exactly, field for field. The merge rules:
- A gap stays a gap. On the chart, a day is
nullif any wallet whose series spans that day reports null. We never sum "the wallets that did report" and pass it off as the total, which would draw a confident dip exactly where we know least. Gaps render as line breaks, never interpolated (as they do for one wallet). - Absent is not null. A wallet that was not tracked yet (before its first point), or whose series has ended, contributes nothing to a day rather than poisoning it. A wallet added last week does not blank out the other wallets' months of history.
- A wallet joining mid-series is capital, not profit. Its book value enters the aggregate when it appears; its cumulative yield starts at 0, so its balance is never booked as a gain.
- Cumulative yield, book value and realized losses sum. They are native-unit amounts in one book.
- Realized return is capital-weighted:
Σ totalYield / Σ baseCapital, the same formula the single-wallet path uses with both sides summed. It is not an average of the wallets' returns, which would weight a $10 wallet like a $10M one. - TWR is rebuilt on the merged series, then chained with the engine's own
linkTwr(the interval structure is recoverable from the curve exactly, so this is the engine's algebra, not a reimplementation of it). Null when no sub-period ever had a positive equity base: a dash, never a definitive 0%. - Positions are one row per (wallet, leg), never merged across wallets, with a Wallet column. Two wallets holding the same Aave USDC supply are two honest rows; merging them would need qty/index math across different entry bases.
- Status is worst-of. One wallet still syncing means the aggregate is incomplete, and the "History syncing" banner says so.
trackedSinceis the earliest wallet's, so the footer never overstates coverage. On the SUMMARY that is the earliest account anchor; onhistoryit is the earliest wallet's anchor for that book, which since 2026-07-24 is the book's own start rather than a coverage anchor (above).
The GET routes never trigger the JIT pipeline themselves — they merge the last live result only while it is inside the 60s cache window (cachedLiveResult) and report jit: false otherwise. That split is what keeps first paint at DB-read latency instead of RPC latency.
refreshed: false means the read did not land inside the request bound (the pipeline routinely runs longer than 30s). It is not a failure and the client must not re-fetch on it: the computation keeps running server-side and caches its result (freshness is stamped at completion, so a slow run still lands with a full 60s of servable life), so the client retries the POST a bounded number of times, each retry either joining the in-flight computation or hitting the now-warm cache. Re-fetching on false would re-serve stored data and wrongly declare the sync done, stranding a read that was paid for but never shown.
Every route answers 401 when signed out. The server layer (src/lib/portfolio/api-data.ts) assembles the stored rows into the pure engine's inputs through a pure, unit-tested adapter (src/lib/portfolio/assemble.ts) that re-composes the wrapper redemption rate onto the stored bare venue index, so the API numbers can never disagree with the methodology the tests pin (M2/M10).
Frontend
- Page:
src/app/portfolio/page.tsx— a prerendered (static) public shell. It is deliberately indexable (unlike/agent) and fetches no request-time data, so all per-user data flows through the authenticated APIs only. Its SEO surface is the route metadata + JSON-LD (both server-rendered); the visible copy is owned by the client region below. - Client region:
src/components/portfolio/PortfolioClient.tsx— the auth-gated boundary. Signed out it renders the marketing landing (PortfolioLanding.tsx: header strip, hero + Connect CTA, a SAMPLE cumulative-yield chart, three value props); signed in it renders the redesignedPortfolioDashboardfull-bleed (its own header bar over a scrolling body); a short skeleton covers the/api/auth/meprobe. - Dashboard:
src/components/portfolio/PortfolioDashboard.tsx— the whole signed-in view (header controls, wallets dropdown, tracked-value rail, chart panel, holdings-by-wallet cards + seven category sections + the carry row-expand, plusCrossAssetViewfor the fourth view), reusingPortfolioChart.tsx(Recharts 3) for the hero chart andAssetGlyph(carries/PositionCell) for token marks. The VIEW (which tab) and the UNIT (which denomination) are separate pieces of state: they agree for the three denomination views, and the cross-asset view names no unit, sounitholds the last denomination view for the controls that are per-denomination (the minimum-value floor, the USD price fetch) while that view's body reads the denominations it actually holds. Its pure view-model —signed-in-model.ts(M1 grouping, carry merge,crossAssetGroups, aggregation, real-unit formatters) — is pinned bysigned-in-model.test.ts, and the rendering path byPortfolioDashboard.test.tsx. Design tokens:signed-in-theme.ts(+theme.tsfor the shared palette /truncateAddr). - Data hooks:
src/components/portfolio/useSignedInPortfolio.ts—useTrackedWallets(the list + add/rename/remove the dropdown drives) andusePortfolioData(per-wallet summary + positions + lazy history, the background live refresh, and the manualresyncbehind Synchronize). The subset is aggregated in the client, so an arbitrary selection of wallets needs no API change. - First-paint fast path: two layers cut the mount waterfall (auth -> wallets -> summary+positions) down for the common cases:
- Wallets-wait skip: before the tracked list lands, the dashboard fetches the session account alone (it is always a tracked wallet), so a first-ever visit loads the wallets list and the account's data in parallel instead of in series. The stored and live fetches guard the account by tracked-set MEMBERSHIP (not effect aliveness), so the read kicked in the
[account]cycle still lands after the full list arrives and tears the effect down — it is neither discarded (which would strand the account on stale data) nor re-issued (a duplicate). - Stale-while-revalidate cache (
portfolio-cache.ts): the last-rendered wallets + per-wallet summary/positions persist per account in localStorage (versioned, per-ENTRY 7-day TTL — a stale card merged forward across revisits ages on its own read time, not "since last visit"). A returning mount paints them synchronously (state initializers, so the first committed frame is the full dashboard) while the normal fetch cycle reconciles in place. The cache never short-circuits a fetch, never stamps "Updated" (that stays owned by landed live reads), and excludes histories (the chart waits for its fetch). A transient (non-403) fetch failure NEVER blanks an on-screen card that already holds data — the seed survives a hiccup on screen, not just in storage; only a positive signal (403 / un-tracking) drops one. Cleared for ALL accounts — cache, wallet-selection AND minimum-value keys — on explicit sign-out AND on a mount probe that finds the session definitively lapsed (AccountProvider), so nothing financial outlives a session on a shared machine; a mere network blip does not wipe it. Pinned byportfolio-cache.test.ts.
- Wallets-wait skip: before the tracked list lands, the dashboard fetches the session account alone (it is always a tracked wallet), so a first-ever visit loads the wallets list and the account's data in parallel instead of in series. The stored and live fetches guard the account by tracked-set MEMBERSHIP (not effect aliveness), so the read kicked in the
- Syncing poll: while any selected wallet is still building its history,
usePortfolioDatare-checks its summary every 20s (capped) and, when it flips, re-fetches its positions and invalidates its cached history so the chart fills in without a manual reload. - Removed: the previous single-selection view (
PortfolioView.tsx) and the components only it used (HeadlineTiles.tsx,PillGroup.tsx,WalletSwitcher.tsx,ManageWalletsDialog.tsx), with their tests.theme.ts/PortfolioChart.tsxremain in use. Theemodefield onPositionRowis a read-time addition (from the snapshot'semode_category) feeding the E-MODE badge. - Session state:
src/components/auth/AccountProvider.tsx— a client context (one/api/auth/meprobe) shared by the nav account card and the portfolio client, so sign-in / sign-out anywhere updates everywhere. - Nav:
src/components/layout/AppSidebar.tsx+MobileNav.tsx(the account card pairing Portfolio + the wallet control viaAccountCard.tsx— signed-outConnect walletCTA, signed-in address + trailing sign-out icon — over the Explore chevron rows inNavSections.tsx).
Status
Shipping in workstreams (WS1–WS8). Live today: the account foundation (WS1), the nav + shell + connect chrome (WS2), the data model + venue readers + PnL engine (WS3), the snapshot cron + flow scanner + JIT reads (WS4), the registration backfill (WS5), the portfolio APIs + full books/marks UI (WS6), and the alerting + docs completion + release prep (WS8) — cron-failure and unknown-asset Telegram alerts (scripts/run-cron.sh + scripts/ops/alert.ts), both fail-soft, documented in Data pipeline and Deployment. Plus full PT accounting (FWS1: entry-fill basis, pull-to-par redemption, PT-as-collateral), the Fluid read core (FWS2: the T1-T4 resolver reader, valuation, snapshots, JIT), and the Fluid flow ledger (FWS3: chain-wide LogOperate + factory NFT transfers + state-diff liquidations, the decoded-event cache fluid_event_log with a 90d deploy seed, and the D8 gate flipped on so Fluid legs chart, M15 / M16), and Fluid surfacing (FWS4: the earned-vs-advertised quoted rate for Fluid legs — LL rate + wrapper for a normal leg, pool fee + LL + wrapper marked approximate for a smart leg, with the smart leg's DEX pool resolved on-chain via getVaultEntireData since the key carries no pool; vault-id + NFT-id labels; and the wound-down / below-floor vault annotation, never hidden). Plus multi-wallet tracking (MW: watch any wallet's positions alongside your own, capped at MAX_TRACKED_WALLETS = 3 including the signed-in one, with an "All wallets" aggregate; the tracked list is private to the account, tracking is watch-only and needs no signature, and removal is an unlink that prunes no history). Still to come: FWS5 (Dune cross-validation + invariant hardening).