Skip to content

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 and ETH, taxonomy T2), not a full net-worth view of every token a wallet holds. Tokens outside that tracked set stay out of the books; an asset with no base — a non-based fiat stable (EURC), a bitcoin wrapper, a governance token — is tracked and valued in USD but never charted, and it appears in the All view alone rather than inside a currency statement it does not settle in.

How performance is measured ​

Historical performance is read from the protocols' own records: every deposit, withdrawal, earned interest payment, reward, liquidation and transfer is counted once, in the period it actually happened, and the two performance lines are built from those movements against the position's stored quantities. Nothing on this page is estimated from a wallet's balance today and projected backwards.

Every value is worked out when the page is read. A stored balance or movement is a quantity at a moment; what it was worth is that quantity at the price history for that moment and at the redemption rate the position was actually read at, computed each time the page loads. So a price that arrives late fills its gap by itself, a corrected price re-values the history it covers, a yield-bearing token, a fund share or a staked-ether balance keeps the exact rate it earned at that moment, and a past balance always stays in the currency it was recorded in (how).

A position is on the page when its movement records say it is held, and every reading checks them. The records of what moved decide which positions exist and since when; a balance read from the chain decides the quantity when it is newer than the last movement, supplies the opening balance where the history starts, and audits the records. So a position that a reading failed to see keeps its row (at its last known quantity) instead of vanishing for a day, and a movement made after the last reading updates its row at once. A reading that misses a position the records still hold does not settle which of two things happened, since a balance of zero and a read that failed look the same to it, so the chain is asked about that one position on its own, with a question only an actual balance answers: if the chain answers that the wallet holds none of it there, the position was closed in a transaction the records never captured, and that exit is booked as a correction at that reading (below) rather than the position being shown for ever at its last quantity; if it still holds the position, the reading missed it and the row stays, and so it does whenever that question gets no certain answer (the chain did not respond, the position is not in the covered set the check was loaded with, or it is a kind the check cannot ask about yet). The one exception is a position opened after the last reading: it has no reading yet to say what it is (its category, its currency, its price), so its row appears with the first reading that finds it. A history whose records start with a movement out of a balance nothing recorded (a position held from before the history began that no opening balance states) is not measured over that first stretch rather than read as starting from zero. After every stored reading each position's recorded balance is compared with what the chain read. Where they disagree, the chain is asked first whether a movement was missed; what nothing explains is booked as a correction at that reading, and the stretch of history that ends at it reads "not measured" on both lines — never a gain and never a loss, because nobody can say what that difference earned. A holding worth less than one cent is still recorded where the history starts, so a later movement on it is measured from the balance the chain actually holds rather than read as a difference; it is simply not listed while it stays under a cent, unless it has earned or lost a cent or more. And where the records say a position is empty, less than a cent left on the chain is treated as empty rather than booked as a correction. Native ether has records of its own: every ether transfer in or out, every network fee a wallet pays and every wrap or unwrap of WETH is recorded from the chain as it happens, and ether a contract pays the wallet inside a transaction (a withdrawal paid out in ether, a swap into ether) is found at the next reading and recorded as a transfer. Until that reading the ether line can read LOW: a fee or a transfer paid after such a payment is taken from the smaller balance the records know, so the line shows less ether than the wallet holds, and none at all once the records say it is spent; the next reading puts it right. So a correction on ether is the exception it is on any other holding: one where a source could not answer for that stretch (history from before the wallet was followed, or the chain data provider's transfer list being unavailable) is flagged as a known source rather than as an open question, and a difference left after every source answered is an open question like any other (how).

Two of the chart's flags withhold rather than assert. A transaction in which any leg could not be priced draws no flow flag at all, rather than one sized only by the legs that could be priced; and one liquidation is one flag carrying the total taken, rather than one flag per position seized. A liquidation is flagged on every chart it moved a position on, so a reader looking at one view alone is never left with an unexplained day. On a denomination chart the flag names what was taken, which is a quantity in that book's own unit; on the All chart it names no amount at all, because a cost is a return figure and that view states none. Where a book was opened after the account started being tracked, the flat stretch before it is drawn rather than trimmed, because that stretch is where the deposit that opened the book sits.

What a liquidation does to a position financed in another currency. Such a position is in the All view, which states a value and no return, so no cost is stated for the event at all. It is flagged on the All chart, and the value line drops through it because the positions are worth less afterwards, which is the whole statement: what the drop was worth is the line itself.

And no denomination book charges it. A liquidation of that kind usually leaves an ordinary supply behind, in the collateral's own currency, and that supply then appears in its denomination view. The day it arrives is a handover, and the reading interval that contains it belongs to neither the view it left nor the view it joins: the receiving book's line starts at the next reading, at what survived, and books nothing for the event. Before this rule that book charged the whole penalty, drew the pre-liquidation collateral as its own opening value and carried the red flag, for a position it had never financed. The rule is stated in full in M22.

The holdings sections are named by one rule: a lending supply is Repo lending, a supplied pool pair with nothing borrowed against it is Smart repo lending, a supply financed by a borrow that settles in the same currency is a Carry trade, one financed by a borrow that does not is a Cross-currency borrowing, a curator vault is a Money market fund, a multi-strategy vault is a Multi-strategy fund, a yield-bearing token held in the wallet is a Variable rate asset, and a par token held in the wallet is an Idle asset.

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, the two performance lines, flows, liquidations, marks, exclusions) is locked as M1 through M26 in Metrics: how & why.

Product shape ​

  • Sign in with an injected wallet (SIWE). One account (wallet address = uid) covers every signed-in surface.
  • 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 in a currency view if one currency runs through both its sides, else is listed in the All view), 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 an erc4626 leg off src/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 rule: an NFT with ONE currency running through both its collateral and its debt (a wstETH/ETH T1, a USDe-USDT smart pool) is a carry in that currency, and an NFT whose two sides do not share one is a cross-currency borrowing, listed in the All view at value with both sides and a net. A supplied pool pair with nothing borrowed against it is Smart repo lending: in its own currency view when both its tokens settle there (wstETH/ETH), in the All view only when they do not (ETH/USDC, WBTC/cbBTC). Which currencies a side holds is a verdict taken per vault SIDE over the whole history in scope, since the pair a side holds is fixed when the vault is deployed, so a tick where only one of the two pool-token balances could be read cannot flip it. 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 sweeps balanceOf over the wallet_tracked rows of onchain_credit.portfolio_tokens (the registry that also drives book resolution, replacing the hard-coded buckets.ts map at runtime) plus eth_getBalance for native ETH. A variable_rate token (sUSDe, sGHO, reUSD, wstETH, …) charts as a Variable rate asset, its share-rate appreciation attributed exactly as a wrapper held as collateral; a par token (a stablecoin, ETH) is an Idle asset with zero yield by construction. Migration 074 (the August 2026 USD yield batch) widened that sweep by five USD wrappers: USD3 (3Jane), sUSDD, stUSDS (Sky) and wsrUSD (Reservoir) as new rows, plus srUSDe (Strata), which 049 had already seeded so PT-srUSDe collateral could resolve a book and which is now wallet-tracked in its own right. USD3, stUSDS and wsrUSD are live Morpho Blue collateral on their own account; USD3, sUSDD and srUSDe additionally sit under a live Pendle PT, and a PT charts through its underlying's book, so a PT-collateral position cannot resolve at all unless the underlying carries a row. Wallet flows are ERC-20 Transfer scans (forward from enrollment via the 6h cron, and replayed over history by the registration backfill, taxonomy T3); native-ETH movements are read from the chain's blocks since issue #966 (every transfer in or out and every network fee, from the ingester's block feed; a WETH wrap or unwrap from WETH9's own events; a contract's payment inside a transaction from the reading audit's transfer listing, how), where they were previously derived from balance diffs at snapshot anchors. 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 strict eth_getBalance first — 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, a variable_rate token 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 — since #810 Y6 that is every wallet-tracked variable-rate row, declared as rate_kind = 'getter' plus a rate_getter on the token's own registry row (wstETH, sUSDe, sUSDS, srUSDe, USD3, reUSD, sUSDf, PST and the rest) — that is an exact archive read at the flow block. For one WITHOUT a getter, which today is only sUSDai (rate_kind = 'series', because its ERC-4626 accounting lives on the Arbitrum hub), it is the freshest token_yield_apy.share_rate at 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 by value (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 TWO readings of the same book, partitioned into two switchable books by accounting asset (USD / ETH), each denominated in its own unit. Price moves of ETH against USD never appear inside a book. Total return (M23) is everything the book holds valued at what it is worth today, net of the money moved in and out: price moves, discounts, the yield itself and any liquidation loss all draw on it. Accrual (M24) is yield earned minus financing paid: what the positions produce by being held rather than what the market will pay for them. The distance between the two lines is the valuation effect.
    • Both lines are read at intervals, not continuously, and that is a limit on the identity above. A leg attributed from an index carries its return on the size present when the interval opened (M27), so capital moved between two readings is picked up at the next one and a position restructured partway through an interval keeps earning on its pre-move size until that reading arrives. Measured on a real wallet during the August 2026 validation campaign, one such day drew +1,972 against a mark-to-market truth of −82. The convention is deliberate (M2/M27 state it); the chart's own explainer now states it too, because "valued at what it is worth today, net of the money you moved in and out" reads as an exact identity and is not one across that shape.
  • Two valuation marks: MARKET (secondary-market prices) and REDEMPTION (on-chain fair value from share / redemption rates). They are no longer a reader's choice: they are what the two performance lines are built from, and both stay fully wired because the per-position surfaces (redemption value, basis at entry, current basis, dislocation) read them directly. A wedge figure is served when the two diverge beyond a threshold on any held asset (M7), and is read by monitoring rather than rendered. A Pendle PT is charted in both marks (M34): MARKET at the PT's own rate at the block — the pt_to_asset_rate TWAP before maturity, what the PT actually settles for at and after it — and ACCRUAL at a per-lot curve, each purchase lot accreting at the yield that lot locked in at its own fill. Both are read through one method, so a holding and the movement that closes it are priced the same way. A PT pledged as collateral on a lending venue is now charted too — Aave and SparkLend e-mode reserves, and Morpho Blue isolated markets: it is promoted into its underlying's book and valued the same way, so the flagship PT-loop carry (PT-srUSDe collateral + stable debt) and a Morpho PT loop (PT-reUSD collateral + USDC debt) are both included carries, not positions the books have to hold out. An unknown PT (not in our Pendle registry) is still held out honestly: pledged, it takes its borrowing to Not covered when nothing else secures it; held bare, it is not shown at all (2026-09-17). Until 2026-09 a Morpho PT loop was the one shape this missed: the collateral leg was dropped for want of a share rate the principal token does not have, and the loop was reported as a borrowing with nothing behind it.
    • A PT does not always redeem for one unit of its accounting asset. When the yield-bearing asset behind it is written down, Pendle passes the whole fall to the PT holder through its redemption index, and the PT settles for f = min(1, SY.exchangeRate() / YT.pyIndexStored()) units instead of 1 (M34). Before maturity that is already inside the pt_to_asset_rate TWAP: Pendle's own oracle applies the factor, so the charted market mark has always carried it. At or after maturity the pipeline used to assume par with no read at all; it now reads the index and marks the leg at f, so a matured PT over an impaired asset is written down on the tracked-value line instead of sitting at full par until the day it is redeemed. A failed read leaves that block's mark empty rather than reinstating par. The redemption burn is valued at the same factor at the same block, so closing an impaired PT nets to zero instead of handing the write-down back as gain on the day it is redeemed. The ACCRUAL line carries the same factor: a lot's locked yield is derived from what was paid relative to what the market was already going to pay, so a PT bought on an already-impaired market is not written down twice, and at and after maturity both lines are the same quantity times the same factor. On a payout asset that is one unit of its own book the two therefore agree at maturity rather than parting; the wedge you see on a PT before then is the market's view of its fixed rate against the rate the holder locked in, which is what that figure is for.

    • stETH and eETH redeem at par to ETH per TOKEN, while their MARKET marks float. A wallet's holding of either is counted in SHARES (#810 R5, M5.3), and one share IS one wstETH, so its market mark is the wrapper's own accepted bar with no division at all: shares x wstETH bar for stETH, shares x weETH bar for eETH. That replaced a derived mark (wstETH market / wstETH share rate) which needed a rate observation aligned to the bar and went null whenever one was missing. The per-TOKEN answer is unchanged and is what a fund over stETH still reads.

    • Every asset is MARKET-PRICED or REDEMPTION-PRICED, and which one it is is a question about the route a holder actually has rather than about our data (the test). A market-priced asset is valued at what it trades for. A redemption-priced one is valued at its redemption rate times the asset it redeems into, chained down until a market-priced asset is reached, independently for MARKET and REDEMPTION — which is why its two marks agree and its basis is identically zero.

      Two things put an asset in the second category, and they owe a reader different sentences. No market: nobody quotes it at size, so there is nothing to lose by composing — every fund share (iETHv2 through stETH, yoETH through WETH, and yoUSD, fLiteUSD and yvUSD through USDC), and every wrapper whose exit is a queue or a credential. Route-pinned: it DOES trade, and at least $5M can be minted and redeemed in one transaction either way, so an arbitrageur holds the traded price at the rate and the rate is the cleaner reading of the same number. sDAI, sUSDS, sUSDD and sGHO are that shape; saying they have no secondary market would be false.

      A market-priced wrapper such as sUSDe, wstETH or tETH keeps its own bar, so the underlying's basis is never counted twice. The category is a registry row, and it moves by a person editing that row after fourteen days of measurements disagree with it, never by a job flipping it.

    • An asset with no price path at all carries a redemption mark only. Its value_market is NULL, and a null market value behaves exactly as any other failed price read: the market-mode curve leaves the leg out rather than counting it as zero, the wedge and basis columns skip it, and a position card withholds current basis and dislocation behind a note instead of printing a number it cannot stand behind. That state is now rare rather than routine — the four USD wrappers migration 074 added were the original population, and three of them have since been resolved (sUSDD composes onto USDD 2.0 and USD3 is market-priced from 2026-09-22; stUSDS and wsrUSD were retired by the coverage rule) — but it is still the honest answer for a row nothing can price, and the pipeline reaches for it rather than pinning a mark.

  • Base yield only. Rewards and points are not included.

The Aave and SparkLend account boundary ​

An Aave v3 or SparkLend position is not a set of independent pairs. It is one cross-margin account: every supply the holder has enabled as collateral backs every borrow in it, under a single health factor, and a liquidation can seize any of that collateral to repay any of that debt. The statement is built on that boundary.

When the account is one carry. All of its collateral and all of its debt settle in one denomination. Then it reads as a single carry in that book, whatever the number of legs: several dollar collaterals against several dollar borrows is one dollar carry.

When the whole account moves. The moment ONE currency stops running through both its collateral and its debt, the entire account moves into All -> Cross-currency borrowing as one unit, every leg listed and each valued in dollars. Two shapes reach that, and since 2026-09-18 they are the same shape: collateral and debt in two different currencies, and an account whose enabled collateral includes something that settles in no currency this product reports a return in. A gold token or a bitcoin wrapper switched on as collateral beside a dollar loop makes the whole account one cross-currency borrowing, because that holding secures the borrowing exactly as the dollars beside it do, and a liquidation would take it to repay them. Nothing about it is charted as unlevered, and none of it enters the USD or ETH book's performance. The case that used to be reported wrongly: ether collateral AND dollar collateral against a dollar borrow. That read as a clean dollar carry with a debt-free ether position beside it, which stated two untrue things at once - the ether collateral showed a gross return as though nothing were borrowed against it, and the dollar carry's equity was measured against only part of what secures it. A liquidation would have taken that ether to repay the dollars.

A consequence worth knowing. An account running two loops that look independent, say a dollar loop and an ether loop in one Aave account, is one financed position here, not two carries. They share a health factor, so presenting them as self-contained would state an isolation the venue does not provide. Opening a second account is what separates them, on the venue and on the statement alike.

With no debt, nothing changes. Every supply is repo lending in its own book, whatever its collateral setting says.

What counts as collateral. The venue's own answer, read from the account at the block each observation was taken at. A supply the holder has switched off as collateral cannot be seized for the account's debt, so it secures nothing: it is listed as a standalone repo lending position beside the account, in its own book, and it never travels with the account into the financed band. That holds whatever the supply settles in: a gold or bitcoin supply the holder has not enabled is repo lending too, listed in the All view, because no currency view can report a return for it. Aave and SparkLend answer this differently and both answers are honoured: on Aave the holder's setting is the whole of it, so a reserve with a zero liquidation threshold (every Pendle PT reserve) is still collateral and still seizable; on SparkLend a zero-threshold reserve is not collateral at all, which is why a SparkLend dollar-stable supply is repo lending and never part of a loop.

Turning the setting on or off changes the report, and books nothing. No tokens move when a holder flips it, so it is a reclassification and never a deposit or a withdrawal. History already accrued stays in the view it accrued in, and the account reports under the new shape from that observation onward.

Where the reading is incomplete, nothing is published. If any read an account's classification rests on fails - the collateral setting itself, or a balance of a reserve the account says it borrows or has enabled - the account contributes nothing that pass and the previous observation stands as its latest. Publishing half an account is how a two-currency position gets reported as a clean one-currency carry. An account with no debt keeps the ordinary per-leg behaviour, since none of its classification turns on the setting.

History is restated, and that is the fix rather than a side effect. Which view a position sits in is decided from its structure at every observation, on every read, so the account rule applies to the WHOLE stored history the day it ships, not only to observations taken after it. Concretely, on an account that has held ether and dollar collateral against a dollar borrow since January: both legs leave the USD and the ETH book curves across their entire history and appear under All instead, and the realised returns those two books report move accordingly. Nothing is recomputed or rewritten to do it; the same observations are simply read under the rule that describes them. Expect the two denomination charts to change shape on deploy day for any account of that shape, and expect the position to appear in All with history behind it rather than starting from today. Accounts settled in a single denomination are untouched. This is independent of the collateral setting below, and it is the larger of the two restatements.

The collateral setting in history. Observations taken before the setting was retained do not carry it, and they are read as collateral enabled - the conservative direction, because it can only report an account as more entangled than it is, never less. It is also exactly how those observations always behaved. A one-off repair reads the historical setting from the chain at each observation's own block and fills it in; an observation whose read fails keeps the conservative reading rather than a guess. Until that repair runs, the account rule is already live and correct; afterwards it gets sharper, and the only thing that can change is that a switched-off supply moves out of an account it never secured.

What was already true before this. E-mode has not gated a carry since July 2026 - any same-book supply-and-debt loop is a carry, e-mode or not, and the E-MODE chip is a label rather than a condition. The financed-position rule, the whole-account move and the account-level health, LTV and leverage figures (taken from the venue's own account read, never from one row's legs) all predate this too. What changed is which accounts land in that view, and which legs an account's carry contains.

Assets with no base: tracked, valued, and shown in the All view only ​

Some things a reader holds are neither a dollar claim nor an ether claim: bitcoin and its wrapped forms, a euro balance, gold, a governance token, an equity-backed dollar that does not redeem at par. creddit reports a return in dollars and in ether, so no book can state a return for any of them. That decides which VIEW they appear in, and nothing else (settled 2026-09-16).

They are tracked like anything else. The balance is read on every sweep, stored with a reading behind it, and charted in the All view's value line. Until this rule the absence of a base was also treated as a reason not to READ the balance — which meant a wallet holding several million dollars of bitcoin saw nothing at all, or saw it through a side path that priced it at the moment you loaded the page and had no history to draw. Both are gone.

Where they appear. In the All view, and only there. A bare balance renders under its own heading — Idle assets for a claim that pays its holder nothing (WBTC, cbBTC, LBTC, tBTC, FBTC, XAUt, USDai, LINK, UNI, and the rest of the governance tokens), Variable rate assets for one that accrues (eBTC, apyUSD, sUSDat) — with its balance and its market value in dollars and no rate, no yield and no return column, because none of those is a figure this product can state about it. A financed position whose collateral or borrowing has no base (a Fluid smart-pool bitcoin carry, an Aave account borrowing against a bitcoin collateral) renders under Cross-currency borrowing as ONE entry: its legs itemized, collateral positive and debt negative, with a single Net value equity row.

The USD view holds only dollar-based holdings, and the ETH view only ether-based ones. A euro balance used to be served into the USD table on the reasoning that its figure is a dollar figure. It is not a dollar position, and it no longer sits in the dollar statement.

What is stated and what is not. Value, and nothing else: no APY, no yield, no return line, and no figure of any kind entering Tracked value, the allocation split or either performance line. It does enter the All view's total, which is a value rather than a performance figure. eBTC is the case worth naming — it ACCRUES, in bitcoin, and that is real; what this product cannot do is state that return in a currency it reports in, so it is filed under Variable rate assets and its return column is a dash rather than a zero.

A holding we cannot price reads as a dash, never as zero. Four tracked assets have no usable price series today, and the reasons differ: BTC.b's tape stopped in August, USDai's began four days before the rule was settled, and the FalconX pair has never printed at all. Their balances are still read and still listed; their value cells are dashes, and the card says a holding could not be priced rather than quietly summing it as nothing.

None of those four is based, and for USDai that is a separate decision from the tape. It is a dollar-denominated token, but the contract that issues it on Ethereum is a bridge-minted mirror with no way to redeem it here at all, so creddit does not state a dollar it does not owe. Like the other dollar assets with no honest claim at face value (the apxUSD family), it is valued at whatever the market pays for it and listed in the All view. Were it based instead, a balance nothing had marked would have printed at full face value on the redemption reading while dashing on the market one, with no caveat on the row.

Retired: the BTC book ​

Until August 2026 the portfolio reported a THIRD book, BTC, with its own tab, its own curve and its own headline figures denominated in bitcoin. It was retired. creddit reports a return in the currencies its readers keep score in, and a bitcoin-denominated statement sat beside the two that answer the question the product is about. There is still no bitcoin return line, and there is not going to be one; what the rule above changed is only that the balances are read, stored and charted at their dollar value instead of being left out.

What kept BTC. Only the user's accounting books dropped it. token_basis keeps its BTC numeraire, /carries still lists and screens bitcoin carries, the Dune price mirror still syncs the chain-0 bitcoin reference for those, and the WBTC/cbBTC icons still render (the outside-holdings card is one of the places they render). The market has bitcoin instruments; the statement does not have a bitcoin column.

The two answers now differ, and one place had to be taught the difference: the carry screener's "are both sides of this pool in one unit" test used to read the PORTFOLIO book map. Left alone it would have resolved WBTC and cbBTC as two separate unknowns and refused to value the real WBTC-cbBTC smart pool. It reads the analytics numeraire first, then an explicit list of the four bitcoin wrappers, and falls back to the book map only for the plain bases neither carries.

Lending against a BTC collateral. An Aave or SparkLend account pools its collateral: every supply in it backs every debt in it. So an account coupled to a bitcoin leg is held out as a whole rather than leg by leg. Borrowing WBTC against wstETH takes the WHOLE account to All, because charting the wstETH on its own would draw levered collateral as unlevered and leave the funding cost nowhere. Supplying WBTC and borrowing dollars against it puts both sides in ONE cross-currency entry, so the figure you read is equity and not a collateral with no borrow beside it. Nothing splits off it any more (2026-09-18): an account already financing across USD and ETH and holding a bitcoin supply beside them lists that bitcoin leg INSIDE the one entry and nets it with the rest, where before it showed gross in a band of its own and the entry's net left it out. A bitcoin holding with no borrow against it anywhere is untouched by all of this: it is repo lending at the venue holding it, or an idle balance in the wallet, listed in the All view at value like any other holding no currency view can report a return for.

Data. Stored snapshot rows written under book='BTC' were re-derived per wallet rather than relabelled by SQL: their values are in BTC units, which the EXCLUDED contract (USD market value only) cannot honour. Migration 107 (planned as 078, written later) narrows the book CHECK vocabularies to drop 'BTC' and refuses to run while any such row survives, so the database can no longer hold one. The read path folds an unrecognised stored book to EXCLUDED at a single boundary (parseBook) and drops that row's stored magnitudes with it, so a straggler degrades to an unpriced holding rather than failing a request or printing a bitcoin quantity behind a dollar sign.

What the user sees (design sync 19 redesign) ​

Signed in, the view is a three-tab dashboard (PortfolioDashboard.tsx), mounted full-bleed in the app shell.

All is the first tab and the one the page opens on: everything the selected wallets hold, at market value in dollars, after debts. It is the only view that answers "what am I worth", and it answers nothing else — no yield, no return, no rate. A single RETURN across currencies could only be produced by converting one into another, which would put an exchange-rate move inside a performance figure for the whole life of a position (M22); a single VALUE across them is an ordinary sum of what things are worth at one moment.

USD and ETH report what each currency class EARNED, in its own unit, over exactly the positions that belong to it; balances are never converted between them. A position financed across the two (an Aave/SparkLend account, a Fluid vault NFT or a Morpho Blue market whose collateral in one denomination funds a borrow in another) is in neither: it is in All, with both sides and a net, and no return is stated for it anywhere. So is everything a tracked wallet holds at a covered venue in an asset that settles in neither book.

The three views' wire keys are ALL, USD and ETH (see the API table below). 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 block, 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. It carries two rows, and which controls live in which is the point of the split rather than a layout preference: status and filters can never collide for space, because they are never on the same line.

    • Row 1, identity and status (50px). The amber marker + PORTFOLIO wordmark on the left. Pushed right, as one group, no member of which is ever compressed: a Wallets N/M dropdown, an UPDATED HH:MM:SS UTC freshness stamp, a ⟳ icon button wired to POST /api/portfolio/refresh, and a ⚙ icon button that opens the settings popover. At phone widths the group wraps onto a second line rather than overflowing the block that draws its border, which would carry the gear, and the settings popover anchored to it, outside that border too. Wrapping is not the reflow the two-row split exists to prevent: that one moved the controls AS TIME PASSED, because the stamp resized itself, whereas this wrap point is a pure function of the viewport and lands in the same place every time.
    • Row 2, filters (48px). A View segmented switch All | $ USD | Ξ ETH. All leads it, carries no unit glyph (it names no unit) and is never presence-filtered: it is the view the page opens on and the only one that can answer for a wallet holding nothing in either book. That is the whole row. It used to carry a second Redemption | Market switch beside the view pills; the chart now draws both readings at once, so there is no valuation left to choose and the control is gone. This row may wrap internally at narrow widths; nothing in row 1 moves when it does.
  • Freshness stamp. UPDATED HH:MM:SS UTC (fmtStampUtc, uppercased in the chrome), the block time up to which the wallet's movements have been recorded (ledger-first R11), never a relative phrase and never a clock the browser read. It is served: every summary carries readAt, the time of the newest block the wallet's movement records reach (settled and provisional alike), and the stamp is that instant. The records decide which positions exist and what moved, so their age is the age of the page: the stamp advances whenever the records do, not only when a balance is read. It moves every ingester cycle (about a minute) whether the wallet moved or not: the ingester turns each newly scanned stretch of the chain into derivation work for exactly the wallets it moved, so a wallet it follows that got none had nothing to record up to that point, and its stamp rides along. A wallet that did move waits until that work has recorded its whole stretch (normally seconds), so its stamp never claims a movement the page does not show yet. A wallet the ingester does not follow yet (just added, or after an outage long enough for it to skip a stretch) moves at its own next record instead: the six-hourly check, a page load's read, or Synchronize. It is not the time of the newest balance reading and never the time a button was pressed: a sync that brought nothing new back moves nothing. Where the newest recorded block has no stored time of its own, the stamp names the newest block below it that has one, so it can read slightly older and never newer than the records are. One gap the ingester cannot see promptly: a movement it cannot tie to the wallet from the chain's logs alone (an adjustment made through a third-party router in the first minutes of a newly opened position, a withdrawal no log names the wallet in) reaches the page at the next six-hourly check or page load, as every movement did before, and until then the stamp can read later than that one movement. Native ether's transfers and fees are read from every block the ingester scans, so they reach the records in the same cycle as any token's; the one ether movement that waits is a contract paying the wallet inside a transaction (a withdrawal paid out in ether, a swap into ether), which no block states: the next reading finds it and records it as a transfer, and the stamp can pass that one movement until then. A wallet whose history is still being built has no records yet and states no stamp. (Before R11 it was the newest reading's block time, and before that the moment a live read landed in the page, which dated the screen by a request.) With several wallets selected it is the oldest of their stamps, because the stamp is one claim about everything below it and the newest would date the whole page by its freshest card. A selected wallet whose summary has not arrived yet, or whose card is still the one the browser painted from its own cache, leaves the stamp empty (--:--:--): what is on screen for that wallet has an age nobody can state, and the older of a known time and an unknown one is unknown. So does a wallet whose holdings ARE on screen but whose history is still being built (its balances are read and shown, its movement records not yet recorded, so nothing dates those figures): the clock is empty and its label says the history is still being built. A wallet that has answered with nothing on screen at all (no reading yet, or a history that could not be built) contributes nothing instead, so the clock keeps stating the age of the wallets that are shown. No row carries a date of its own: the one stamp dates the page. It is the same rule the API applies when it aggregates several wallets into one response, so a page and a caller never date one account two ways. Eight characters at every instant and in the empty state, which is what makes row 1's width a constant — the relative wording it replaces (– → just now → 31 Jul, 10:27 UTC) resized itself as time passed and shoved the controls beside it sideways. It is built from the timestamp's own UTC fields rather than through Intl, so the width guarantee does not depend on the runtime's ICU.

  • Synchronize (⟳). "Verify now" (ledger-first R11): a full read at the wallet's own ledger block (every venue is re-read: POST /api/portfolio/refresh?force=1, which is what force buys, nothing else), with the wallet's movement records brought up to that block first, and the new reading checked against them afterwards (the reading audit, which books any difference nothing on chain explains as a "Balance adjustment" on the Activity statement). Where the reading is complete and the records reach its block it is stored as that wallet's live tip, so the page it reloads into serves the new reading from the database like any other, and the stamp moves to that block, unless an earlier stretch of the wallet's history is still owed (a six-hourly check, a background derivation or an earlier refresh that could not record it for this wallet; a refresh leaves one whenever it finds the records' write lock busy, because another job is recording at that moment): the stamp then waits below that stretch until it is recorded, however recent the reading. What usually records it is the derivation the stored reading's own audit asks the ledger worker for (the wallet's continuous job, which starts at or below the stretch), within a worker cycle or two; otherwise the next six-hourly check does. A refresh that could not read where the last six-hourly check stopped stores no reading, since it cannot tell whether its records reach down to that point (in this release it still records what it can). The next six-hourly checkpoint retires the tip. (The records are brought up to the block by the refresh itself in this release; once the ledger worker owns that work, the refresh hands it a top-priority job and waits for it up to 20 seconds, answering "not refreshed" if it has not finished, with the page keeping what it showed.) While the request is in flight the control is amber and its glyph spins. Afterwards the server reports when that wallet may be read again (a five-minute cooldown per wallet, server-side, which a page load and the button alike are answered by), and for as long as every wallet the button would refresh is inside that window the control is dimmed, inert, and named Synchronize (available in N min) in its accessible name and its tooltip. It keeps its 34px box in every state, so nothing beside it moves. There is nothing to press for in the meantime: the reading the button would take has just been taken, and it is already on screen.

  • Settings popover (⚙). A 326px panel holding the settings that are not part of reading the book, dismissed three ways: the ✕ in its own header strip, Escape, and an outside click. It hangs off the gear, on the same geometry the wallets picker uses for its own trigger: an 8px gap under a 34px control, flush with that control's right edge, the same width, and the same shadow beneath it. Both popovers drop out of the same chrome bar and are the same kind of surface, so they open the same way. Until v0.67 this one was anchored to the chrome block instead and floated 12px below its bottom hairline, which cleared the filters row as well as the status row and left a band of empty canvas the height of row 2 between the gear and the panel it had opened. Its right edge is unmoved by the change: the gear is the last control in row 1 and sits at the block's own 20px inset, so the two anchors resolve to the same x. It holds a row per setting. First 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. The field holds a draft and the floor changes when that draft is committed: the Apply button beside it, or Enter, which submits the same form and closes the panel with it. Every other way out (the ✕, Escape, a click on the backdrop) commits it too, so no route strands what was typed, and there is no discard path — deliberately, since this filters rows and nothing else, reopening the panel undoes it, and typing a floor and dismissing with Escape is how one has been set since the field shipped. Apply is quiet while the field still holds what is in force and amber the moment the two part company, in its accessible name as well as its colour; that pair is the only statement that a typed number is not yet filtering anything. The field itself is amber for the floor it holds, which is the stored one on open and the draft after that, so clearing it goes inert while the stored floor is still in force. The gear outside keeps reporting what is actually held back. A view switch under an open panel re-seeds the field from the view it lands on, so one book's floor can never be committed onto another's. Before v0.67 the field wrote through on every keystroke, so a reader on the way to a $10,000 floor watched the tables filter at 1, 10, 100 and 1,000 in turn, and the panel offered no gesture that meant "that is the number". It is offered on every view, each with its own threshold and its own default (All's is $1, in dollars; the two denomination defaults are roughly $10 in their own unit). On the denomination views this field is the only way to put back what it hides: the holdings header's N below X · Show all went with the section redesign, and the note under an open wallet's rows that carried the other Show all is gone too, so those tables now filter silently. Two things carry that state instead, and both ride the gear rather than the tables: it is amber whenever the floor is holding rows back, not only while its panel is open, and its accessible name says the same thing in words (Settings, a minimum value is hiding rows). That is the narrow claim on purpose (floorHidingRows): a $1 floor over a book whose smallest holding is $3 hides nothing, and a gear announcing a filter there sends the reader hunting for rows that are all already on screen. The field inside the panel is keyed on the broader fact, that a floor is stored, since it is the control rather than a disclosure and a floor that currently catches nothing is still set. What the floor DOES is explained in its InfoTooltip rather than restated as body copy under it (the tooltip opens on tap as well as hover, so that stays reachable on touch). That state signal rides the gear in every view, the All view included, now that the floor reaches it: the carve-out that stood it down there was written when All had no floor of its own. Each view is measured on its own list, which is not a nicety: the active book still holds the last denomination while All is open, so a shared test would read another view's rows. The All view filters silently too, since v0.61: its hero card used to name how many rows were under the floor and offer a Show all beside them, and both came off, so the gear is now the one thing on the page that reports a hide — on every view. The tooltip is still view-aware (MIN_VALUE_TIP / ALL_MIN_VALUE_TIP): a denomination table exempts five classes of row and leaves four figures standing, All exempts three and states two, and one string would have to over-promise on whichever view it was not written for. Below the floor sits the session row: the connected wallet's truncated address and the Sign out action, the product's one disconnect control now that the nav rail carries no wallet state. It is view-independent (the panel and gear therefore stand in every view, All included), and signing out ends the session, drops the cached balances, and returns the page to the connect prompt.

  • 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 + ACCOUNT tag), 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; 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 square 1M | 3M | 6M | YTD | ALL range pills (active pill in solid amber; choosing a different range while a past date is being read returns the page to now, see "As of date"), a headline row with the Total return 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) with no caption under it, then the plot, then a footer row carrying whatever the series needs explaining (the selected date when the page is being read at one, a live tip, a liquidation, or an isolated observation, which since gaps became bridges means only a reading stranded between a monitoring outage and the live tip) and nothing else: the panel used to caption its own unit and bucket there (<unit> · daily|6h cumulative), and the headline figure above already prints in that unit. The panel's denomination is on its root as data-chart-unit, which is how the e2e suite tells one panel's unit from another's. In the All view the panel draws ONE line off the book-value channel instead, in amber, titled Historical value (series="value"); everything below the data key is the same plot. The two-line plot draws two lines from one series of points (PortfolioChart, Recharts 3): total return as the signal-green accent curve (solid, 2px, with a 3.5px dot on the last stored observation) over a flat matching area fill at 9%, and accrual as the same green at 45% alpha, dashed (1.5px, 5 4, no fill), drawn UNDER it so a crossing never hides the primary. One hue for two readings of one book, with the dash carrying the subordination. Both lines mark where they are now and where the pointer is: the accrual line carries an end dot on its own newest reading (3px, in its own dim green) and lights an active dot on hover, as the accent curve does. The tooltip has always read out both figures for the hovered point, and marking only one of the two curves left the reader pairing a number to a line by eye. It is drawn as a single reference dot rather than a per-point renderer, so the reference line still pays for no full-series pass on every keystroke in the min-value field, and both lines take their mark's shape from one rule (endMarkShape): a stored observation gets the ringed dot, the live tip the smaller unringed one the plot's footer legend names "live, between snapshots" — the accent curve has always ended that way on a live-synced wallet, and a ringed accrual mark beside it would contradict both the curve and the legend. Then a right-hand y-axis on nice 1/2/5 ticks over a 6%-padded domain spanning BOTH lines (niceTicks / yScaleOf in chart-series.ts) — sizing it to the accent line alone would push the accrual line outside the plot on any book that is down on price. The axis never zooms tighter than 0.5% of the book's peak value in view (SPAN_FLOOR_RATIO): a return's visual drama scales with the capital behind it, so $1 of first-day accrual on a $100k book draws as a hairline near zero instead of a full-height cliff, and the curve only fills the frame once earnings are meaningful against the book. The floor is relative, so a small book earning a large fraction still draws large; once the PADDED span (the 6%-padded fit above, not the bare data spread) passes the floor the domain is exactly that padded fit, byte-identical to before the floor existed. The dominant everyday consequence is on SHORT RANGES: each range re-bases the curves to the window's own start, so a 1M window's spread is only that window's earnings against the whole book's floor — a $100k book at ~4% APY draws its month as roughly two-thirds of the plot height, and calmer books draw less. That is the intent: a normal month IS small relative to the capital, in every window, and the axis no longer manufactures drama from it. When the floor opens a window, the area fill under the total-return curve anchors at zero rather than the domain's bottom (filling to a floored min would paint half the plot under a hairline). Below that, series values under a billionth of the book's peak value snap to exact 0 (clampDust, DUST_RATIO) before anything reads them — scale, tooltip, dots — because they are float-netting residue, not returns: a view holding only its own book currency (say, plain ETH on the ETH view) "earns" tens of wei of arithmetic noise per balance change, which used to autoscale into a dramatic full-height line under axis labels that all read 0/-0. Then an accent dashed crosshair + panel tooltip stating both readings for the hovered date (the total-return figure 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 under prefers-reduced-motion).
      • The series legend sits in the card's header band, opposite the headline figure (ChartSeriesLegend), right-aligned on the same row as the total-return figure and under the range switcher. One cluster: a 14×2px solid swatch + TOTAL RETURN, a 14×1.5px dashed swatch in the accrual colour + ACCRUAL, and the 13px ⓘ ring that opens the explainer, all in one row 16px apart. It is the ONLY place the two lines are named.
        • It is chrome, and stays out of the plotting area. It briefly rode the plot's own top gridline, punching an opaque gap through the rule at the plot's right edge; that put two labels inside the plot, and on any book that rallies into the right of its window the total-return curve ran through them. Read once at the top of the card, the names never compete with the ink they describe.

        • The cluster never wraps, stacks or drops an item to a second row: it and every item are nowrap and flex: none, and the swatches are fixed-width blocks rather than font glyphs. When the header band is too narrow to hold the figure and the cluster side by side, the WHOLE cluster wraps to its own line as one unit, still right-aligned; individual items never reflow.

        • Where that break happens no longer depends on how long the number is. The figure's column is given a fixed basis rather than being measured from its own content. Left to the content it broke on digit count: the caption grows by five characters between "all time" and "last 3 months", and at a 1360px window that was the difference between fitting and not, so changing the range moved the legend across the card and took 23px off the plot. (A book with no accrual line to name carries a narrower cluster, so it still breaks a little later than one with both.)

        • A line the plot does not draw is not named. A book whose accrual channel is empty end to end (only a series served before the second line existed) gets no ACCRUAL item, and the legend is withheld entirely whenever there is no series on the plot at all — on a range this book has no readings in, where the plot says so in words. (A history still building never reaches this card: the page holds the whole body behind the building state until it can draw everything.)

        • And the figure is captioned at all times by nothing. The card printed a line under its headline naming what the figure was — "earned · all time" on a performance card, "at the last reading" on a value one — and neither survives. The range is the lit pill in the strip above, which is the control that sets it, so the caption was a second spelling of one fact that only the pill could change; and a value is read at the newest point by definition, so naming that moment spent a line of the card on a tautology. On a range with no readings the figure was already a withheld dash with nothing under it, and that is now simply the general rule rather than an exception to a caption.

          The card carries both facts structurally instead, on the panel root, which is what anything branching on them reads: data-chart-readings is some when a series is drawn and none on a range with no readings (there is no third value: a card that would have read building is not on screen at all now), and data-chart-range names the window it is drawn over. The headline itself carries data-chart-headline, return or value, naming which KIND of figure it is now that no sentence beside it does.

          The same rule covers the figure under a date. On a past day the headline states that day's reading rather than the window's, and it says so in no words either: a caption naming the day would be a third spelling of what the banner states at the top of the page and the mark points at on the curve. The day is carried on the panel root beside the other two, as data-chart-dated, and is absent live. It is the day asked for, not the reading resolved: whether that day carries a figure at all is data-chart-readings and the dash.

      • The headline is total return; the positions table's "Yield earned" is accrual. Two different concepts, two names, never one figure relabelled: a book can be down on price while every position in it earned, and both statements are on screen at once. The column takes NO valuation basis of its own (displayYield): it is the same accrual attribution the chart's second line draws, so the page cannot show one concept under two names. Read on the market basis, that attribution is a wrapper's or a PT's TOTAL return, which would put the dislocation inside the yield column and inside the Dislocation P&L cell beside it at the same time.
      • Each line re-bases to its own first in-window value, so the vertical gap between them reads as the valuation effect since the window opened, not the whole life of the position. Switching the range restates both.
      • The two lines share their gaps by construction (both channels of a point are filled from the same interval, or the point is empty), so one live tip serves both and an interior gap is bridged on both at once, each line carrying its own last reading across it (below). Only the accent line carries per-point dots. 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 shows a dash, not a number, when a DEBT leg failed to price in the active mark: toDisplayPositions coerces 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.
  • The All view. The view the page opens on, and the only one that is about the ACCOUNT rather than about a currency: everything the selected wallets hold, at market value in dollars, after debts. Its HEADLINE and its SUBTOTALS state a value and no return; each POSITION under them reads exactly as it does on its own currency tab, figures included (see "One position, one row" below). It is never presence-filtered the way the view it replaced was — a wallet holding nothing simply shows nothing under a tab that says so, which is a statement a denomination view cannot make.

    • The hero is one figure: Net asset value, the net dollar value of every row listed under it, with the number of holdings beside it. The name is the card's own heading and the figure's label at once: it carried a title ("Everything you hold") over a separate label ("Total value") naming the same number twice, under a page already headed Portfolio. No basis caption sits under the figure. "market value · after debts" stated two things that the name now carries one of — net is "after debts" — and the explainer beside the name carries the other, along with the one limit a reader cannot infer from the list: the figure counts the protocols and assets creddit covers and is not a wallet balance. The denomination rail's own caption ("market basis · covered positions") went the same way and into its own explainer, for the same reason: a fact worth one line of permanent chrome on every visit is usually a fact worth one sentence where the reader asks for it. It is the client's own sum over the served payload (allTotal, signed-in-model.ts), which is what makes the headline and the list agree by construction, on a past date as much as live. An unpriced ASSET leg is excluded and the card says so; an unpriced DEBT leg withholds the figure outright, because a total short a borrowing overstates what is owned. A financed entry withholds the whole headline when ANY of its legs states no value, asset side included, since the two sides of one borrowing are netted and a net missing a side is not a number. Since the 2026-09-18 taxonomy that reaches a population it could not reach before: an account holding a collateral this product CAN price beside a pledged fixed-rate note whose payout asset it does not track, with a borrow against them, is now one cross-currency entry rather than a carry plus a holding listed beside it, so a wallet holding only that prints a withheld headline where it used to print a number with the "could not be priced" caption. The figure is unstated and the reason is beside it, which is the honest pair; the bands beneath it still print their own subtotals. A supplied pool pair is left out whole when either of its tokens has no price, and the card counts it as a holding it could not price rather than adding the other token on its own. The two tokens are one position, so half of it is not what the holding is worth, and the entry's own value cell and its band subtotal both state nothing for the same reason: leaving the pair out is what keeps the headline footing to the bands under it. The direction is conservative, so this is a said exclusion and not a withholding. A position under Not covered is left out of the arithmetic entirely and the card says how many: "Excludes 1 position not covered", under the figure, beside the withholding rules above rather than instead of them. It is the one exclusion the reader cannot otherwise reconcile, since the position is listed on the same screen; the count is the disclosure that keeps the headline and the list honest about each other.
    • The chart is Historical value: one line, one point per day, what everything was worth at that reading. It is drawn in amber rather than the yield palette's green, because a value rising is not a gain until something says what went into it, and its headline states a READING rather than an amount earned: the newest one live, and on a past day that day's own level, which is the same reading the plot marks the day on. Neither is captioned (see "As of date"). A liquidation is still flagged, with no cost stated: the line drops through it, and what the drop was worth is the line itself. There are no capital-move ticks, because a value line moves with every deposit by construction. A day that could not price one of the holdings it held reports the rest and says so, on the card and in the hover. A Not covered position is off the line as well as out of the total, at every point it was held rather than only today, so the two figures state the same population; the card carries the same "Excludes N positions not covered" caption under the value headline. On a past day that caption sits on the figure card ALONE: the line's count is a count as of today, while the figure beside it and the list under it are the day's, and two counts of different populations over one screen would send the reader looking for a row that is not there (see "As of date").
    • The list is one run of bands: the eight taxonomy categories, then Cross-currency borrowing, then Not covered, each with its own dollar subtotal and each rendered only when it holds something. It is built from the SAME positions payload the denomination tables read (allHoldings), so the tabs cannot disagree about what a wallet holds, and every entry is sorted by size — which the view this replaced could not do, because ranking needs one unit to rank in.
    • One position, one row, wherever the reader meets it. A taxonomy band here IS the denomination table's own section, rendered by the same component (CategorySection): a carry is the carry row with its two sides and its LTV and its expandable ledger, a money market fund is the fund row, a repo supply is the repo row. Every VALUE on the view reads in dollars, whatever currency the position settles in: this view's whole subject is what the account is worth, and a list answering it in two currencies left the reader adding ether to dollars by eye to check the subtotal over it. Every other money figure on a row stays in the position's own book (the expanded carry panel states its net in that book too, with the dollar figure under it as the line that reconciles the two halves of the disclosure) — a yield and a dislocation P&L are RETURNS accumulated over months of readings, and restating one through today's price would cross a return with an exchange-rate move, which is the blend M22 forbids.
    • One holding, one row, however many wallets hold it. Two selected wallets in the same vault are ONE row here, at the two balances together, under exactly the rule the denomination tables use (mergeSelectedAllHoldings over mergeStandalones): the view is asked what is owned across the selection, and which account a balance sits in is not part of that answer. No row is labelled with a wallet. The merge runs BEFORE allTotal, so the hero's holdings count still counts the rows under it. What does not merge is anything carrying a borrow — a carry, an Aave or SparkLend loop, a cross-currency borrowing, a levered holding the books cannot rate — because a lender liquidates one account's collateral against one account's debt, and an LTV blended over two accounts describes a position that exists on no chain while hiding the account closest to being seized. Those stay keyed <wallet>::<group key>, as they are on the denomination tables. Two entries also stay apart when they say different things about themselves: the outside band prints its reason on the row, and a merged row could carry only one of them.
      • Two rules differ for a row in a combined list, and both are about the row agreeing with the dollar figures stated above it (CombinedBookContext). The VALUE cell prints the SERVER's own dollar figure for that position (PositionRow.valueUsd, each leg struck at the bar covering its own reading), never this page's live rate: the band subtotal is a sum of exactly that field, so the rows foot to the subtotal over them exactly. (The HEADLINE is the same arithmetic over everything held rather than everything listed, so it sits above the bands by whatever the dust floor is holding back — see the subtotals rule below.) It stays honest on a past date, where no live price may value anything. It is formatted like any other value (fmtValue), never as the "≈ $" aside it replaced — a figure that has to reconcile to a subtotal may not print the coarser rounding of an annotation — and no second line sits under it, since that line existed only to say in dollars what the cell above it said in ether. And a position whose dollar value could not be struck prints a dash with the reason beside it ("this position could not be priced"), not the zero toDisplayPositions coerces it to. That happens when the position's own mark could not be read AND when the price mirror had no bar covering the reading, which is a holding priced in its own book that this view still cannot state a dollar figure for. Either way: the band subtotal above it already withholds and the card above that says a holding could not be priced, so a "$0.00" would be the only one of the three statements that is false, and a bare dash reads exactly like a position worth nothing. The dash is per POSITION rather than per outage — each row's quote is struck at its own reading, so one row can be priced while its neighbour is not. The denomination tables print the coerced figure in the row's own book, which is their own long-standing behaviour and what their heroes count.
      • This is where the earlier "All states no return anywhere" rule moved to. A dollar fund's rate is a dollar figure and a dollar fund's earnings are dollar earnings; M22 forbids BLENDING two currencies into one published return, not reporting each currency's own. What stays refused is any figure that could only exist by crossing them: the page states no total return and no accrual line (its chart is a value), and a cross-currency position states no rate at all.
    • A cross-currency borrowing is one entry, and it reads as the carry it is. Same row, same tracks: platform, collateral, debt, LTV, value. Two differences, both following from there being two currencies rather than one. Every leg on each side is named with its own amount, one line per leg, so the row grows rather than eliding a pooled account into a coin cluster that has no single quantity to state. And the rate cell dashes on every row of the band, because a yield in one currency less a cost in another is a figure in neither; the band says so in a line under its heading. The row's net in dollars is the figure the old view could not state: its legs were in two denominations, so a net across them would have needed an exchange rate inside a return. Opening the row shows each leg's amount, its value in its OWN currency and that value in dollars, then the net — and the Leverage & liquidation strip (accountRisk), which is what the venue says about this position's liquidation now rather than a performance figure. The strip stands down on a past date and on the venues whose params carry no account block.
    • The band subtotals sum the rows LISTED, while the headline sums everything held. Each band therefore foots to what is under it, and the headline sits above the bands by however much dust the floor is holding back. That gap is the cost of the floor narrating itself nowhere (below); the lit settings gear is what reports it.
    • A $1 floor applies here, on by default. This is the view that lists everything at once, so it is where a long tail of dust is actually in the way. It is safe here for one reason: the headline counts what the list hides. The total stays the wallet's whole value whatever the floor is set to. The card narrates none of it: a filter the reader set themselves does not need a running count beside the figure, and the denomination tables have hidden silently since their own note came off. What reports the hide is the settings gear, which lights amber while rows are actually being held back and holds the floor itself one click away. The gear's floor row is offered on this view too, labelled and glyphed in dollars, and each view stores its own threshold. Full behaviour, including the three classes that are never hidden, is under the last band below.
  • The last band: Not covered. A position this product cannot put a figure on, listed for completeness and left out of the total above it and the chart beside it. Since the 2026-09-18 taxonomy that is the whole of what it holds, and it is almost always a debt with nothing statable standing behind it:

    • a bare debt — a borrowing whose collateral went unread for more than a single tick, or residual bad debt left behind by a seizure that took everything securing it. Reason chip: "Debt without matched collateral". It is reachable on an isolated Morpho market, on a Fluid NFT, and on a whole Aave or SparkLend account holding no collateral at all. What cannot reach it is one BOOK of an Aave account: collateral there is pooled, so a borrow in one currency beside a supply in another makes the whole account a cross-currency borrowing before any per-book split happens.
    • a borrowing every one of whose collateral legs is a principal token settling into an asset creddit does not track, so there is no unit to state the collateral in. Reason chip: "Payout asset not tracked". A borrowing holding one of those BESIDE a priced collateral is not here at all: it is a cross-currency borrowing, and the untracked leg is listed inside that entry with its value withheld.
    • the one shape here that is NOT a borrowing: the same principal token posted at a venue with nothing borrowed against it. It carries the same reason chip, for the same reason (there is no unit to state it in), and the band's note names it rather than calling it a borrowing. Held BARE in the wallet instead, it is not shown at all (2026-09-17, below) — the difference being that a venue row sits in a position the reader can see the rest of.
    • The heading names the consequence. It used to read "Not in the USD or ETH views", and before that "Holdings outside coverage". Both were true of a much larger population — every holding that settled in no currency this product reports a return in ended up here, from bitcoin to gold to a private-credit tranche — and that population has moved (below). What is left is not "outside a view"; it is a position with no value this product is willing to state, which is what the name now says.
    • It is outside the total and outside the value chart (2026-09-18). A figure that cannot be stated cannot be netted into one that is, and until this rule a single unreadable collateral pulled its borrowing's full face value out of the headline while the position sat listed on the same screen with no figure on it. Both the hero and the chart card carry the count instead: "Excludes 1 position not covered". The band still prints its OWN subtotal, which is the band's own arithmetic over its own rows, and the entry still prints negative in the debt colour, so nothing here can be read as an asset. The readings are dropped from the value line at every point the position was held rather than only at the tip, so the headline and the line describe one population.
    • The minimum-value floor reaches this band, and every other band of the All view, at $1 and on by default. The All view lists everything a wallet holds at once, so it is the view where a long tail of dust is actually in the reader's way. What makes hiding safe there is that the headline keeps counting what the list hides: the total stays the wallet's whole value whatever is filtered out of the rows. The card says nothing about the hide and the reveal link is gone (v0.61); the settings gear is what reports it, lighting amber while rows are actually being held back. Three classes are never hidden at any threshold, the same three the denomination tables exempt: an entry whose value could not be stated, a levered entry (its net is equity over live debt), and an entry carrying a lifecycle annotation. A bare borrowing is not one of them: a residual $0.40 of bad debt has no leverage to misread and is dust like any other row. The two denomination floors are unchanged (roughly $10 in their own unit), and each view now stores its own.
    • Each group carries a plain-language reason served by the API (reasonLabel), so the classification and the sentence explaining it cannot drift apart. Two are rendered and they are the two above; the wire vocabulary keeps the retired ones so a client bundle from the previous deploy still parses a response it will never be shown.
    • "Payout asset not tracked" is the Pendle case, and it is a narrowing of the old "No dollar or ether claim" rather than a new population (#811). A principal token settles into ONE named asset on ONE dated day; when creddit does not track that asset it cannot state a dollar for the position, and saying "no dollar claim" about a token that plainly redeems for dollars would be false. The reason names the real gap, which is in our coverage of the settlement asset. Which payout assets are tracked is a holdings decision, never a listing one: a new Pendle market appearing never adds its payout asset to the books by itself.
    • Since 2026-09-17 a bare PT never reaches this band. A bare holding of a principal token whose payout asset creddit does not track is not shown at all — no row, nothing in the All view's total, and no caveat under the value chart. #811 listed it here with its reason, on the argument that a named exclusion beats a silent drop; Fred reversed that: whether to cover an uncovered PT base asset is a separate decision, and until it is taken a bare holding of one is simply not covered — and an uncovered asset does not appear anywhere on this product, exactly like a wallet balance in a token creddit has never heard of. A PLEDGED one keeps its place here with its value withheld, and that exception is the whole of the rule's safety: a levered PT on an untracked payout asset still shows as one block with both sides, so a borrow is never published with nothing standing behind it. Nothing about the data changes — the balance is still read, the history still written — so the day the payout asset is covered the holding appears with its past intact.
  • Where the rest of that band went (2026-09-18). Everything it used to hold that was not a borrowing is now filed by what it is, under the ordinary taxonomy, and listed in the All view with a value like any other holding. Nothing about it is charted into the USD or ETH books, which is unchanged and is the point: an asset with no home currency still gets no return stated anywhere, it simply gets named properly.

    • A supply at a venue of an asset settling in no currency this product reports in — bitcoin and its wrapped forms (WBTC, cbBTC, LBTC, eBTC, since the BTC book was retired), gold (XAUt / PAXG), an equity-backed dollar token posted on a Morpho Blue market with nothing borrowed against it, a private-credit tranche token whose redemption is gated to whitelisted entities, a governance token supplied to Aave — is Repo lending, in the All view only. It was already a lend against nothing borrowed; the band was the only thing that ever said otherwise. Its Yield earned cell is a dash, never a zero. The band has that column because the dollar and ether lends beside it fill it, and a "$0.00" on a row this product states no return for would read as a measurement: the lend that made nothing, sitting beside two that made something. The holding does earn its holder something, in a unit nothing here is stated in, and the dash is the only honest thing the cell can say.
    • A supplied Fluid pool pair with nothing borrowed against it is Smart repo lending, one row for the pair, in its currency view when both tokens settle there and in the All view only when they do not. The "directional pair" verdict that held such a position out of every view is retired: a pool holding two different bases is a base mix like any other, and a supply with no borrow against it has no funding cost to strand.
    • A bare wallet balance is served with its own category and charted: false, and renders under Idle assets if it pays nothing or Variable rate assets if it accrues (eBTC): held at no venue, worth this much is exactly what those bands say of such a row, and no return is stated anywhere on the All view, so nothing is claimed for it that is not true. The server decides the band; the client builds no row of its own, and the USD and ETH statements never receive a leg with no currency, which is what keeps a dollar figure for a bitcoin claim out of the dollar statement.
    • A levered holding of any of them renders as ONE block under Cross-currency borrowing — its asset legs and its debt legs together, the borrow reading negative, with a net underneath. Someone running levered apxUSD did not buy a bare debt. Every leg prints in DOLLARS there, including an ether one, so a group spanning currencies has a net like any other. A leg with no currency of its own needs no conversion at all: it is valued MARKET-only in dollars by construction (M9 / snapshot.ts), which is also why such a row states a market value and no redemption one.
    • They report market value and nothing else wherever they land: no yield, no advertised rate, no realized APY, no chart of their own. Those figures do not exist for an asset with no home currency, and showing nothing where they would go is the point.
    • Nothing here enters the BOOKS. Tracked value, Net APY, projected daily income, the allocation split, every curve and the denomination heroes are unchanged to the cent; a leg reported in the All view alone is never also a row in a denomination bucket. What changed in v0.59 is that it enters the ALL view's total, which is the one figure on the page that is about everything a wallet holds rather than about a book, and what changed on 2026-09-18 is only which band it is listed under.
    • Tokens are named from the registry's on-chain ticker when the curated symbol map deliberately leaves them out (a declared exclusion is in no book, so it is in no curated list either), and fall back to a shortened address when nothing names them. Icons come from the existing registries, with the neutral monogram for a token that ships no mark.
    • Bare balances the books cannot take are SWEPT, not disclosed beside them (2026-09-16). A wallet holding 12,064 stETH (about $23M, 42% of everything it owned) once appeared nowhere at all, because the section was built from stored position rows and a token the sweep did not read never got one. The first fix read those balances at display time and listed them here; it worked, and it cost two things — the figure came from a live read at the moment you loaded the page, so it could not be on the value chart, and the card had to carry a caption explaining why the total and the line disagreed. The coverage rule closes it properly: having no currency is not a reason to leave a balance unread, so every one of them is swept on the ordinary tick, stored with a reading behind it, and shown under its own category in the All view — on the line as well as in the total. A balance the mirror cannot price is listed with no figure rather than dropped or zeroed (M9).
    • stETH and eETH are covered assets (#810 R5). They were the first two entries of the old band, for a reason worth keeping: a rebasing balance grows with no transfer, so a token-denominated ledger could not tell "the position earned" from "the holder received". They are now tracked variable-rate assets, counted internally in shares — a share count is constant across a rebase, so every movement has a receipt and the growth becomes a rate change the accrual line reports as yield. The share count is an accounting unit and is never printed: the statement of account states each movement in stETH or eETH under the plain ticker, and the holdings row states the balance held in its Balance column beside what it is worth and what it earned — a figure that grows with each rebase between transfers. Their rows chart in the ETH book like any other wrapper. See M5.3 for the arithmetic on both marks.
    • Some of this is declared, not merely unrecognised: a registry row with a NULL book (migrations 071, 073, 074) records that the exclusion is a decision rather than a gap. It changes nothing about the classification, which is EXCLUDED either way. What it buys is the name in the row above and a quiet alert channel: the 6h unknown-asset page stops firing on a question already answered. 074 declares the Pareto FalconX tranche AA_FalconXUSDC and its 1:1 wrapper wFalconX (the token transfers freely, but redemption at face value is credential-gated, so a holder who simply received it has no dollar claim to chart) and sUSDat (an accruing share of the same offchain preferred-share basket behind the already-excluded apxUSD family, and below par since that basket's mid-2026 drawdown).
  • 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-denomination present flag:

    • it earned, and holds nothing now (its positions are financed and reported under All, 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 is present (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 a short range 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 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 (legPerformance is view-agnostic). Neither view states it on screen now: the All view carries no return column to disagree with a chart, and the denomination views' own gap is bounded by the handover rule (M22), which puts the interval a position crossed views in outside both of them.

  • The building state, which stands in for the WHOLE portfolio (BuildingChart.tsx, PortfolioBuilding). While a selected wallet's registration backfill is still reconstructing its history AND that wallet's own history is not on screen yet (a fresh signup, or a just-added wallet: portfolioBuildState in signed-in-model.ts — one live-tip point is not a series), the page shows this and nothing else: no chart, no figures, no holdings table.

    It carries three things:

    • the headline, Building your portfolio history, with an ellipsis that animates on a 400ms step;
    • the estimate on its own line — About 3 minutes remaining, or Less than a minute remaining under a minute, or About 2 hours remaining past an hour. It is the one number a reader waiting on this actually wants;
    • a progress bar filling to the percent built, with ~62% beside it.

    Behind them the plot is an amber beam sweeping an empty area with decorative observation dots flickering in (kept faint: they trace a generic curve, never actual yield).

    Why it holds everything. The three parts of a portfolio do not become true at the same instant: the summary flips to "not syncing" on one poll, the positions land on the fetch after it, and the chart's series on the fetch after that. The page used to show each as it arrived, with an amber banner over the top reading "History syncing. Building your history archive. This view updates as it lands." It did update as things landed, which was the problem — a reader could not tell which figures were settled and which were about to move, and the answer changed under them three times. The banner is gone and so is the chart card's own building state (the BUILDING chip, the stat line, the progress strip and the completion beat): while a history is building that card is not on screen at all, so every figure it draws is settled.

    The reveal is atomic. The hold latches when the gate opens and clears only when every selected wallet has no replay in flight, has its positions in hand AND has its own series fetched — and the sync poll applies all three in one commit (useSignedInPortfolio), so there is no render in which a wallet reads as settled while its positions are a fetch behind or its cached series is the empty one the build left. The whole portfolio therefore appears in a single paint.

    "Landed" means the replay ended, not that the wallet stopped syncing, and the distinction is the whole of the guarantee. A wallet reports syncing for two different waits — its replay running, and its coverage certifying afterwards, which can take hours (the coverage certificate, below) — and the gate ends the build at the first. While the poll ended it at the second the two disagreed for the length of the tail: the page released its hold, the poll went on treating the wallet as building, and its positions and series were not re-read until certification finally landed hours later. The reader got current figures beside the holdings and chart from before the build, and a denomination view that said nothing was open in it. Both halves now read the replay cursor, so a summary is applied without its positions only while a replay is in flight — the building state in every ordinary case, with one deliberate exception: a wallet re-queued for a gap patch while it already charts does not gate (taking a good portfolio off screen for a routine patch is what the per-wallet rule exists to prevent), so it is replaying and revealed at once. Certification landing later is its own commit, because it is the moment the return line stops being withheld and the cached curve of dashes has to go.

    Nothing is invented while it waits. The percent is the real reconstruction cursor from SummaryResponse.backfillProgress (done/total, the replay's daily grid points), floored, always prefixed ~, and it never regresses — a cursor that re-anchors mid-replay cannot walk the bar backwards. It clamps at 99% until the build actually completes, because the only honest 100% is the one that arrives with a portfolio. The estimate appears only once a real replay rate exists, is hedged ("About"), is rounded to the nearest minute (and to the nearest hour past an hour, where a minutes field would print a precision the measurement does not have), and counts down client-side between the 20s summary polls. When there is no estimate — no rate measured yet, or one that has run out — the line is simply absent rather than replaced by a placeholder about our own machinery.

    The never-regress rule is about one build, not about the page. The state reports the least complete gating wallet, so adding a second wallet, or a wallet re-entering the pipeline for a gap patch, changes which build it describes: a different grid size, or a phase that steps backwards, resets the high-water mark to the new cursor. Carrying it over would freeze a nearly-full bar next to a countdown for a replay that has barely started.

    There is one building state, and no queue. Sync starts the moment a wallet is connected, so the page never announces a wait: a wallet the backend has not claimed yet shows the same headline and the same bar, with no percent and no estimate until the first progress event lands.

    A screen reader is told the percent alone through a polite live region; the estimate is ordinary text in the headline block, which a reader reaches directly. Announcing it would read the whole sentence out on every countdown tick.

    Motion is limited to the ellipsis loop, the bar's 300ms linear width transition and 120–150ms colour fades: no pulsing, no shimmer, no spinner. Under prefers-reduced-motion the ellipsis is a static … (read in JS because the two states are different text, and subscribed to, so turning the preference on mid-build takes effect) and the plot's beam and dot flicker stop, but the bar still fills — it is progress information, not decoration (pf-build-* in globals.css).

    • The gate is PER WALLET, and it is asked about the SELECTION. Each syncing wallet is judged against its own history, so the page holds exactly when the portfolio on screen would not yet be the selection — and a wallet re-queued for a gap patch while it already charts does not take a good portfolio away. Because it is the selection that is judged, deselecting the wallet that is building brings the rest of the portfolio straight back: the wallet switcher is the way out, and it stays live above the building state.
    • Adding a wallet selects it a full round trip before its summary lands, so the gate also reads the tracked-wallets list's backfill status while that wallet's summary has not resolved yet — otherwise the page would show a portfolio we already know is incomplete 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 hold for ever.
    • Because the gate can hold the whole page, 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 held page.
  • When a build does not finish, WHO says so depends on how much is lost.

    • One wallet's reconstruction parked (backfill: error) while others are fine: the portfolio renders, and that wallet is marked in the wallet switcher — "History could not be built", with the Telegram pointer. The aggregate assembles around it (the merge drops a wallet that reports nothing, exactly as it does for one holding nothing) and its holdings and value come from a different read and are unaffected, so blanking the page would state something false about the wallets that are healthy. Unlike the hold, this state is TERMINAL — it stands until somebody re-runs that wallet — so "deselect it" is not an answer.

    • Nothing left to draw (every selected wallet parked, or the page held past the stall bound): the body states Portfolio could not be built with the same pointer. There is no retry control, because the queue already retries where retrying helps and a button that re-runs nothing would be one more thing on the screen promising a portfolio it cannot deliver.

    • The event-history coverage certificate does NOT route here yet, and this bullet says so rather than describing the intended end state. The certificate is a per-wallet statement — every source that wallet's holdings depend on has been indexed back far enough — evaluated per wallet, so an uncertified wallet is held on its own without touching anybody else's. What "held" means today is narrow: that wallet's scan certificate stops advancing, so it stops gaining new ground, and the numbers already on screen keep rendering from the shipped pipeline. portfolioBuildState gates on the summary's syncing flag, the tracked wallets list's backfill status and whether two drawable points exist; nothing on this page reads the coverage conjunction.

      An uncertified wallet is served no return line at all rather than a curve over history nothing proves complete, and the question is asked per wallet, on every request rather than once. That distinction is the whole of the guarantee: a wallet registered yesterday has never been checked, and a wallet whose coverage regresses — a source rolled back, an enrolment catch-up that has not landed, a reconstruction that stopped — was checked and no longer passes.

      It is NOT held behind the building state, and the distinction is deliberate. Such a wallet reports syncing for as long as the other pipeline takes, which can be hours and which nothing counts down — so holding the page on it would blank a real portfolio indefinitely and then, at the hold's own fifteen-minute bound, tell the reader it could not be built. It can be: it IS built, and what is withheld is the return. The two states are told apart by backfillProgress, which the summary carries only while a replay is actually queued or running (rebuildingWallet in signed-in-model.ts). So the holdings, the values and the flags render, and every figure that is a measurement of a return is a dash.

      Withheld means unknown, never zero. Everything that is a measurement of a return for such a wallet comes back as not measured: both performance lines, both earned-versus-advertised rates, and the yield earned figure on every holdings row, in the tables and in the expanded carry panel's net. Every total built from those figures is withheld with them rather than quietly dropping the part it cannot read: a carry's net of its funding cost, a holding held in two wallets at once, and any wallet-level total over them.

      What is still reported is everything that is a reading rather than a measurement: the holdings, what they are worth, and the deposit / withdrawal and liquidation flags, because those are records of what happened and the check is about whether the history behind them is complete enough to attribute a return across. The same rule carries into the all-wallets view: one unproven wallet makes the account's return a gap for every day it holds anything, and its capital still counts in the account's book value, rather than the account reporting the total of the wallets that could report. A confident number standing in for an unknown one is the failure this whole check exists to prevent, and it is worse than an obviously missing line.

      A holding that moves where we cannot see it moved. A few holdings have no movement record of their own: a token acquired before the history we hold begins, or a plain wallet token whose event stream is not yet covered. (Plain ETH is not one of them: it is never attributed a return at all, so it can neither earn nor lose on the page.) Their size is read straight from the chain at each reading, and while nothing touches them that is enough to state what they earned. The moment one of them changes size between two readings, it moved, and nothing on the record says where from or where to. The product will not call that a gain: the new size and what it is worth are still reported, in full, at the reading they were read at, and the return over that one interval is booked as nothing rather than as the value of whatever arrived, with the declined figure recorded beside it. Where the holding pays a rate we can read, the rule allows the interest on the part that was already there to be credited; today no such rate reaches this path, so the whole of that one interval books as nothing. Nothing alerts on it, because it says something about how far our records reach rather than about the position.

      The requirement map and the per-wallet veto themselves are described in the data pipeline.

  • Minimum position value (MinValueField + positionPassesMinValue), reached through the chrome bar's ⚙ settings popover. A per-view dust floor, defaulting to $10 / 0.005 ETH in the two denomination views and $1 in All (MIN_VALUE_DEFAULTS), held per account in localStorage under creddit_portfolio_minvalue:<account> (one entry per view, so switching view never loses another one's floor) and swept on sign-out by clearPortfolioCaches. The field is a draft until it is committed (Apply, Enter, or any dismissal of the panel — see the popover above); clearing it and committing turns the floor off. It is deliberately NOT in the URL: /portfolio is 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 and the Allocation bar all keep reading the UNFILTERED combined list (selectedPositions); only the holdings table reads the filtered one (visiblePositions). 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. On the denomination views the tables themselves say nothing about what they are holding back: the N below <floor> + Show all strip, the per-card N below the minimum, and the note that stood in for the sections when a card's every row was hidden have all been removed. What survives is a state signal and a single undo, both on the gear: it renders amber for as long as the floor is actually holding rows back (floorHidingRows), not merely while its panel is open and not merely because a threshold is stored, and its accessible name says so in words for readers a colour cannot reach (Settings, a minimum value is hiding rows). A floor that catches nothing lights nothing, since the cue is read as the reason a row is missing. Clearing the field inside and committing it is the only way to put the rows back. The All view carries the same cue and nothing else: its hero used to state how many rows were under the floor and offer a Show all beside them, and both came off in v0.61 — a filter the reader set themselves does not need a count beside the figure it is not changing, and the denomination tables had already been silent for a release. All's own empty body is still gated on the UNFILTERED list for the same reason the denomination tables' is: a selection whose every holding sits under the floor gets the hero's total and count, never the Add a wallet remedy, which would be answering a different question. Its help text is its own string (ALL_MIN_VALUE_TIP), naming the two figures that view states and the three classes below rather than the five and the four that only a denomination table has.
    • 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 >= floor would hide exactly the rows that matter.
    • Five 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 — toDisplayPositions coerces 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 an annotation, which D2 defines as never hiding the row (annotationForRow in row-display.ts, from carry_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. (5) any row whose yield is withheld (the wallet's history is not proven complete, above), which is the same class as (1) rather than a new idea: test (4) is the one that decides whether a residual is dust, and here it cannot be run, so the row cannot be shown to be dust. Hiding it would let a coverage gap make the portfolio look smaller, and it clears itself as soon as the wallet's history is proven.
  • USD equivalent under the ETH headline figures (usdEquivalent). In the native book a small muted ≈ $X line sits under Tracked value, Cumulative net yield, Projected daily income, and each holdings row's Value (under Projected daily income it sits inline to the right, in the Stat sub slot). It is the book amount times the latest USD price of the book's own unit, served by GET /api/portfolio/prices: a live vendor level where R6's band can be applied to one, and the newest stored price where it cannot. No price means no line at all — never a $0, and never a level two vendors and the tape contradict each other about. The line is likewise withheld when the position's OWN mark could not be read: toDisplayPositions coerces 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, the moment it was struck at, and what CHECKED it. Those are four different sentences because they are four different claims, and a reader cannot verify any of them from the screen: a current price a second vendor confirmed (R6's corroboration branch, the only one that reads the second vendor's number at all); a current price checked against the most recent stored price and nothing else, which is what the ordinary in-band verdict earns; a current price nothing could check, where the asset had no reference of any kind; and, where no quote could be served, the most recent stored price said to be exactly that. Two misstatements are what this wording exists to avoid: a stored price described as a current one, and a confirmation claimed where no second source was consulted. The moment is the serving vendor's own observation stamp, never the moment of the page load — the backup vendor's quote may be up to six hours old, and on a moving day it is the one the band selects, because a staler quote sits closer to an hours-old stored price precisely by being stale. A level whose vendor states no observation time is not dated by the clock either: the stored bar answers instead, because its hour is a fact. Applied to a cumulative yield it answers what the ETH 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. displayApy also returns null for Idle outright, so a par token that somehow carried a token_yield_apy row could never blend into Net APY / Projected daily income with no visible column to reconcile it against.

  • Numbers. Every money figure and token quantity carries at most two decimals, and none at all past five integer figures ($459,895, 2,789,001, $1,234.57, 12.34 ETH) — the cents on a six-figure balance are noise beside the figures that matter. The integer part is always exact and comma-grouped, never compacted to $1.24M / $520K, which hid up to five figures of the user's own money. APYs and the basis percentage are two decimals. The ≈ $ orientation line under native headlines keeps its own stricter rule (whole dollars above $1,000): it is an annotation on an approximation, not a balance.

  • Native precision is adaptive below 1.00 (nativeDp): two decimals 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 ~$3k/ETH it would round 0.0149 to "0.01" and hide ~$15 of a ~$45 holding — the rounding-hides-money problem M9 exists to prevent. So 0.015 ETH, 0.004 ETH, +0.00004 ETH all survive, and a real balance can never read 0.00 (M9). USD is deliberately exempt: a sub-cent dollar rounding to $0.00 is the truth, and extending it would print $0.0010 in 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, via fmtSignedBasis) must consult rendersZero, since signOf can only suppress the sign of the string it builds itself. All pinned by signed-in-model.test.ts.

  • Holdings. One contained panel for the selected wallets, in the same header language as the Historical performance card above it: a header strip, the Positions | Activity rail, then the face. Header selection is the only wallet filter — to see one wallet, deselect the others — so there are no per-wallet rows to expand and no column strip describing them.

    • Like-for-like standalones merge. The same idle token, the same vault, the same debt-free Aave supply held in two wallets is ONE row: quantity, value and yield sum, and the advertised APY is value-weighted on the market mark. A lifecycle note ("Wound down") survives only when every wallet under the row states the same one, for the same reason the E-MODE badge does.
    • Anything with collateral against debt stays a row per account. An Aave or SparkLend loop is one health factor per wallet, and fusing two wallets' loops would invent a position that exists on no chain. Such a row is keyed <wallet>::<group key> so the two never collide.
    • A merged row claims only what is true of every wallet under it. The E-MODE badge survives only when every contributor is in correlated-asset mode. Realised APY is withheld outright: two wallets entered on different days, so their realised rates are annualised over different windows, and a weighted mean of annualised rates is exactly what the yield invariant forbids (docs/metrics.md) — the combined row owns no elapsed time to annualise over, so it claims none. observedDays takes the SHORTEST contributor. A figure one wallet cannot state (an entry basis with no anchor, an unread quantity) makes the merged figure unknown, never smaller: a dash, never a fabricated sum (M9). The value beside it is still summed — the withholding is per figure, not a blackout over the row.
    • The panel is never gated on holdings. Activity is a FACE of it, so a panel that disappeared would take the Activity tab with it, and a reader who reached the feed for a wallet whose positions are all financed (those sit in All) or closed and then pressed Positions would be left with no control back. With nothing to list the Positions face states the absence in a line rather than leaving a bordered card blank, and never restates a claim the page has already made: nothing at all while the history is still building (a read in flight is not an absence, and the building chart says so), the short form ("Nothing open in this view.") when the retained-yield card above has already explained where the capital went, and the full sentence with the way out when the selection has never held anything in this denomination.
    • The floor filters ROWS ONLY, and silently: no counts strip, no "N below the minimum" note, no Show all. Tracked value, Net APY, projected income and the Positions count all keep counting every position, hidden or not, so a floor that is hiding a row leaves its only trace as a count above the table that is larger than the list under it. That rows are being held back is signalled by the amber gear, which follows the rows rather than the setting: a floor catching nothing lights nothing.
    • The Positions count is over the COMBINED list, so two wallets holding the same vault count one position rather than two. It is NOT a count of the rows in the table below it and must not be read as one: an idle balance is a holding rather than a position, so it is listed and never counted.

    The combined list groups into up to eight category sections, in the fixed taxonomy order — Repo lending, Smart repo lending, Carry trades, Money market funds, Multi-strategy funds, Fixed rate assets, Variable rate assets, Idle assets (empty sections do not render). Smart repo lending is a supplied Fluid pool pair with nothing borrowed against it: one row for the pair, both tokens and both amounts in the Asset cell, the rate and earnings cells Repo lending carries, and no collateral-exposure or utilization columns, because a DEX pool is not a lending market and has no borrower to be utilized. A pair whose two tokens settle in one currency states that currency's rate and earnings and sits in that currency's table; a pair spanning two states neither and is listed in the All view alone, at value, since a rate blended across two settlement assets is a figure in neither. A pair states a value only while both of its tokens are priced. The row adds its two tokens, so with a price missing for one of them at that reading the sum would be what the OTHER one is worth printed as the holding's worth, beside a cell naming both tokens and both amounts. The value dashes instead, with the same note the rest of the table carries, and the card above it withholds its total the way it does for any holding it could not price. A section header is its title and nothing else — no row count beside it, in any of them: the rows are directly under it to be counted, and the figure a section is about is the value on each row. The category is the SERVER's read-time classification (pnl.ts classifyLegsAtTs, returned as PositionRow.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 stays managed_strategy_fund (the /api/portfolio wire format) and the erc4626 role stays 'managed'; only CATEGORY_LABEL in src/lib/portfolio/api-types.ts changed. 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), each valued but carrying no APY column. In the All view it also holds the bare balances with no base at all — the 1:1 bitcoin wrappers, gold, the governance tokens — which reach that band by the same rule, decided server-side from the registry's own class: par, so the holding earns its holder nothing, which is what the band means. An accruing one (eBTC) files under Variable rate assets instead, because "earns nothing" would be false about it. A par idle token in a based book charts as a flat zero-yield line; an asset with no base at all (EURC, WBTC, LINK — registry book NULL) is the 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 …:supply leg 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. An Aave or SparkLend supply the venue does not count as collateral is the second, and it is the same rule: it cannot be seized for that account's borrow, so it lists as its own repo-lending row rather than being drawn as collateral of a loan it cannot be taken for (see the account boundary).

  • A directly held Pendle PT is named by its own ticker, sized in principal tokens, and says what it redeems for. The row reads PT-reUSD over a muted 10 DEC 2026 (the registry's pt_symbol, split exactly as /carries splits a PT collateral leg), with the wrapper's coin; it used to read PT USDC, the PT's PAYOUT asset, which is the unit the PT settles in and not what the holder bought. Balance is the PT count (qty_raw descaled by the PT's decimals, the same derivation the accrual line's qtyPt makes); it used to be qty_underlying, the payout-unit figure at the market discount, so a wallet holding 10,029,703 PT read "9,794,843". (quantity on the wire is in principal tokens for exactly this leg shape, and only where the registry knows the market; an unknown PT keeps the payout-unit reading and the payout-asset name.) The Redeems at maturity cell (the ratio track) states what the held PTs redeem for, in the payout asset, and names the token the payout is handed over in where that differs: 10,029,703 over USDC paid in reUSD, because Pendle's reUSD wrapper only unwraps into reUSD and prices it against USDC. Before maturity the amount is the PT count times the market's redemption factor (the accrual derivation's currentFactor, from the stored series under its staleness bound); at and after maturity it is the row's own qty_underlying, which the writer stores as amountPt x f. The delivery token is named only where two witnesses agree, the ticker's middle part and the market's listed name; a synthetic or odd ticker states no delivery token. The reUSD COUNT is deliberately not stated: it is fixed by reUSD's own price on the maturity date, and a count struck at today's rate falls every day until then and reads as a loss. A dash is a withheld factor or a PT the registry does not know; nothing redeems at par by default. Server side this is PositionRow.ptRedemption (pt-redemption.ts, pure); a holding split across wallets states the summed claim; a PT pledged as collateral is named the same way but carries no redemption cell (its band has no track for it).

  • A directly held PT opens onto a bond desk's statement of it. The row is a disclosure (the carry row's pattern: the whole row is the target, Enter and Space open it, the chevron takes the leading track) whenever the server states PositionRow.ptDetail (pt-detail.ts, pure over the row's own figures). The panel shares the expanded carry's three-section surface and its container rule (.pt-detail, the same 1045px stack threshold), and nothing in it repeats a cell of the row:

    • Open position: the PT still held, since purchase, the way a desk carries a bond at amortized cost (M34). A caption says what the figures run from and in what ("Since purchase · in USDC at par", or "Since tracking started" where a lot is the stand-in): they are in the payout coin counted at par, so they are not the row's Value column, which keeps its own market figure. Carry at your locked rate (what the position has earned at the rates its purchases locked in), Mark to market (how far its market value sits from its value at those rates, fading to zero by maturity), split where every purchase states the market's own price at the time into Rate change since you bought and Your price vs the market then (what the purchase paid above or below the market's own price, fading to zero too), and Total return, which is carry plus mark to market exactly. A missing input (no price, no factor, a purchase with no price) prints a dash on every line, never a zero; at maturity the mark to market is 0 and the whole return is carry. PT already sold is not part of it. On the wallet 0x3bd8…b5 this reads carry +$79.07, mark to market −$73.96 (−$9.93 of it the market rate rising since the purchases, −$64.03 the price paid above the market's own at the time, which fades by maturity), total +$5.11.
    • Rates and maturity: the locked-in rate beside the market's rate now (the yield implied by the reading's own price) and the difference in basis points; the maturity date and the days left; what the position redeems for; the profit if held to maturity (redemption less what the open lots cost, labelled "since tracking started" where a lot is the stand-in); what a one-point rise in the market rate would take off the value today; Cost to sell now, a live quote fetched when the row is opened (what the whole position would receive if sold into its market now, against its value now, signed like the lines beside it so a cost prints negative: "you'd receive $X, $Y (Z%) below the current value", or "quote unavailable"; never on a past day or for a matured PT); and, beneath it as context, the market's depth (pool size and the position's share of it).
    • Purchases: every open lot with its date, quantity, price per PT in the payout asset, its locked-in rate and the market's rate at that moment, tagged where it was minted, received, bought or minted before tracking started, or is the stand-in for a holding older than the history. A past day serves the panel as of that day; one PT held in several wallets opens on one statement whose money figures are the wallets' sums, so it foots as each wallet's does.
  • The locked-in rate is struck from what the chain says was paid. A lot's yield is derived from its fill: what the buyer paid, converted into the payout coin at the payout coin's own price at that moment (for a buy paid in the payout coin itself, exactly the coins; never the dollar value against a payout counted at $1, which a coin a few basis points off $1 turns into a cheaper fill and a higher rate), and the redemption factor read at the fill's OWN block (not the nearest stored reading of the market, whose staleness bound could leave a fill between two readings with no rate at all). A PT held since before the wallet's history window opens on its REAL purchases where the wallet's own router fills explain the holding exactly; otherwise it opens on the stand-in, its value on the day tracking started (M34).

  • A matured Pendle PT says so, on its row. A PT is a dated instrument: on its maturity date it stops being a discount claim on its accounting asset and becomes that asset, redeemable one for one. Until the holder acts on that, the position is not what the rest of the row says it is, so the row carries a caption under the position's name — and it says which of TWO decisions the holder faces, because they are not the same decision:

    • held plain (a bare PT in Fixed rate assets, or a PT pledged as collateral against no borrow, which classifies as Repo lending): "Matured, redeem". The PT has stopped compounding and is a par claim earning nothing.
    • pledged inside a loop (a same-book collateral+debt carry on Aave v3, SparkLend or Morpho Blue): "Matured, funding still accruing, unwind". The collateral leg is flat at par while the borrow it funds keeps accruing interest, so the position bleeds until the PT is redeemed and the debt repaid. Nothing else on the row says so: the value is right and the funding cost is right, and the two together are a carry that has quietly become a cost.

    Which of the two is decided by the same category the section headers are — carry_trade means the PT is pledged into a live borrow — so the marker cannot disagree with the section the row is filed under. It is read-time and derived: the registry's pendle_markets.maturity_ts, this leg's own quantity and its category, and the instant the statement is for (now on a live read, the end of the requested day on an as-of one). The boundary is the maturity itself, not the day after: Pendle expiries land at 00:00 UTC and redemption opens at that block. There is no receipt, no ledger row and no re-mark — maturity is a calendar event, not a movement — and the marker moves no figure anywhere: values, yields, rates, contributions, the chart and its markers are byte-identical with and without it (pinned in pt-maturity.test.ts). A leg whose balance could not be read, or that is no longer held, gets no marker: the caption is a claim about a position the reader still has. The holdings row and the Activity line derive it from one pure function (src/lib/portfolio/pt-maturity.ts), so the two cannot answer it differently.

  • Each group leads with a band. Every category section opens with a tinted 36px strip carrying the group tick and its title (and nothing else, per the header rule above). The groups used to run one after another on the same hairline the rows themselves use, which left nothing to tell "a new group starts here" from "another row". The first band detaches from the Positions / Activity tab strip by 16px of the card's own canvas and closes itself with a hairline top and bottom; every other band sits flush against the last row of the group above it, separated by a single stronger rule that the band itself draws (the section above draws no hairline into that boundary, so a group break is never two rules a pixel apart). The band is a label, not a row: it takes no hover, no rail and no click target.

  • Every section renders into ONE column grid. The card stacks up to eight category sections, and each of them used to carry a grid of its own — so four bands on screen meant four sets of column edges down the same panel, a different lead column per band, and only the trailing Value column happening to line up. They now share nine tracks (H_COL in PortfolioDashboard), read by every band's header strip and every row:

    TrackWhat each band puts in it
    1 · disclosureThe expand chevron, on the rows that open onto a panel: carry trades, cross-currency borrowings, and a directly held PT with a statement to open (fixed rate assets, see below). Empty elsewhere.
    2 · leadWhat names the position: the platform where the subject is somebody else's book (repo lending, carries, cross-currency), the instrument where the wallet holds the thing (funds, fixed and variable rate assets, idle balances). The flexible track, so the card's slack lands in the one cell holding a name.
    3 · subjectAsset (repo lending, the asset lent) · Collateral (carries) · Balance (the five instrument-led bands: how much of it is held).
    4 · contextCollateral exposure (repo lending) · Debt (carries) · Platform (money market funds, fixed rate assets).
    5 · ratioUtilization (repo lending) · LTV (carries) · Redeems at maturity (fixed rate assets: what the held principal tokens redeem for on the maturity date, see below).
    6 · rate24h APY, the 24h-trailing advertised quotedApy. On the fixed rate band the heading reads Locked-in fixed rate: a Pendle leg's quotedApy is the quantity-weighted yield its open purchase lots locked in (M34), not an advertised rate, and a dash there means one lot could not be priced. Dropped from every band at once on a past day.
    7 · earnedYield earned.
    8 · basisDislocation P&L (variable rate assets).
    9 · valueValue, the amber column the eye tracks down the card.

    A multi-strategy fund states Balance in the token paid by its declared withdrawal route, with the token symbol written beside the amount. Yield earned stays in the fund's own ETH or USD book, the same measure in the same unit every other band states it in: a payout token that earns a return of its own makes "+0.12 wstETH earned" read as what the fund made on top of holding wstETH, which is not what the accrual measures. Fund balances use a direct payout quote where the route publishes one; other balances use rates pinned to the position's own observation, including past-date views. A missing or suspect required rate leaves a dash beside the known token symbol instead of showing fund shares under a payout label. The Value column, section totals, charts, APY and portfolio summaries stay in their established ETH or USD denominations. The displayed balance is a current attributable redemption amount; a queued settlement or route fee can make final proceeds differ.

    A band with nothing for a track leaves the cell EMPTY rather than closing the track up. Closing it up is what produced the drift this replaced: it moves every column after it, so one band's missing figure re-sited the columns to its right. The tracks and their gaps come to 948px, which fits the card at every width where the sidebar is on screen — the tightest is a 1024px window, worth about 980px of track, or 960 once a classic scrollbar takes its cut. Below that breakpoint the table scrolls sideways inside the card, in ONE scroller around the whole stack so two bands can never be scrolled out of step (the band headings and their subtotals scroll with it, which is the cost of that).

  • What each band does NOT state, and why. Idle balances show a Balance and a Value and nothing else: an idle asset earns nothing by construction, so a rate and a yield column could only ever render a dash or a zero down the whole band, and a 0% beside a real rate invites a comparison that means nothing. Multi-strategy funds state no platform: a managed vault's platform resolves to the firm that runs it, which is already the row's own name and mark. Variable rate assets state no platform either (the category is reachable only from the wallet venue, so the cell read Held · spot on every row it ever had) and no issuer. Carry trades and cross-currency borrowings state no cumulative yield in a column: what the collateral earned and what the borrowing cost are two figures, and they are in the expanded panel's ledger.

  • The name is a link where creddit publishes a page about the thing. A variable rate asset's ticker opens its asset profile; a multi-strategy fund's name opens the fund's own row on /multi-strategy-funds, already expanded. A multi-strategy holding's name and mark are the funds tab's own shared identity, never the share token's ticker or a token-logo fallback; this holds whether the position was read as a vault or as a wallet balance. Both kinds of link are marked with a ↗, both take the destination from the served leg (profileHref / fundIdentity.href), and both render as plain text with no affordance when there is none — a link that lands on a screener with nothing opened is worse than no link, which is also why the fund link is withheld for a fund the funds tab no longer lists (while its canonical name and mark remain), and why following one switches that tab to the fund's own denomination. Carry trades, Repo lending and Variable rate assets also have section-specific cells, below.

  • Repo lending is its own section (RepoSection, design_handoff_repo_lending_1a). A repo-lending row is a supply into somebody else's book, so it is the one holdings section whose subject is a market rather than a token, and it carries the two facts that decide whether a lender wants to be in that market. Columns: Platform (brand mark + venue) · Asset (coin + ticker, plain text, and it is the asset lent — a Fluid fToken row reads USDC, not fUSDC, since the wrapper is what was minted and the underlying is what was supplied) · Collateral exposure · Utilization · 24h APY · Yield earned · Value — the same nine tracks every other band uses, with the basis track empty. The yield column reads "Yield earned", as it does in every band: the figure is the same measure everywhere (displayYield), and two names for one column across four stacked sections is a difference a reader has to resolve for nothing.

    • Collateral exposure is an overlapping cluster of the market's three largest collateral exposures, largest leftmost, then a +N more count for the tail (N = total − 3, omitted at ≤ 3), then a standing amber caret. The whole cell is one anchor onto that market's own row on /repo-lending (?market=<id>, which the screener also seeds its deposit-asset tab and market-type filter from, so the link lands on the row expanded). Unknown collateral falls back to the design-system monogram chip, never a broken image. "Largest" is by the borrowed dollars lent against each collateral (exposure_usd), which is the quantity the screener's underwritten-capital donut ranks by, not the collateral's own market value: a reader following the link must not find the same slices in a different order.
    • Utilization is the share of the market's deposits currently deployed rather than sitting as drawable cash, one decimal. What it means for a lender is in the column's info tooltip: it is both the sign that borrower demand is real and the thing standing between the lender and the exit, since withdrawals come out of the unlent remainder.
    • Both market columns come from GET /api/repo-markets (below), which is market data rather than account data, so one fetch serves the whole dashboard. A row the screener quotes no book for — a Fluid vault NFT supply (that is collateral in an isolated borrow market, not a Liquidity Layer lend), an Aave or Spark reserve outside the four quoted deposit assets, an fToken whose underlying is not quoted — dashes both columns and carries no link. So does every row while the fetch is in flight, or if it fails: the row's own APY, yield and value come from the positions payload and never wait on it.
    • The collateral cell is the row's only interaction, and it answers both hover and keyboard focus by going amber. The row used to carry an Activity cross-link as well, as a button overlaying every cell the anchor did not; that is gone with the cross-link itself (below), and so is the amber wash and rail the row lit on hover, which only ever advertised it.
  • Variable rate assets are their own row group (VariableRateSection, design_handoff_variable_rate_row_1a). Position (token coin + ticker) · Balance · 24h APY · Yield earned · Dislocation P&L · Value, with the context and ratio tracks empty. Balance is how much of the asset is held, stated in the token itself, and for a share-accounted token it is the token balance rather than the share count (see the rebasing note above). A balance that could not be read is a dash, never a zero. Two things distinguish it from the generic PosSection:

    • No Platform column. The category is reachable only from the wallet venue (categoryForInclusion sends a wallet leg here on its registry token class), so the cell read Held · spot on every row it ever had. A column with one value is not a column.
    • And no issuer column. Who stands behind a yield-bearing wrapper is a real credit question, and it had the cell the platform column freed — but it was the one column no other band had anything to put in, so in a card of stacked sections it pushed this band's figures out of line with every band around it. It is one click away, on the asset profile the ticker opens, beside the rest of the credit story.
    • A covered ticker deep-links to its asset profile, marked with a ↗; an uncovered one is plain text with no affordance. The arrow is the row's one navigation affordance and is sized for it (12px, up from the 9px mark it was); hovering or focusing the link brings ticker and arrow to amber at full strength, and the link's tooltip says where it goes ("Open the asset profile"). It carries what a lit row used to: nothing else on the row responds to a pointer. "Covered" means creddit publishes a profile for the token, resolved from the leg's accounting asset against src/data/covered-assets.ts — the same registry scripts/refreshers/yield-token-assets.ts writes onchain_credit.assets from, so the row and the screener cannot disagree about where a profile lives, and no query stands behind a label that changes about twice a quarter. The link text is that registry's own ticker, not the leg's resolved symbol: label and target then come from one row, so a token the symbol map does not carry (USD3) cannot render a bare 0x… address linking to a named profile. The registry can carry a wallet-tracked variable-rate token before its profile lands (migration 049: they "enter as variable_rate + an asset profile later"), so the uncovered branch is a live state rather than a defensive one. The row is inert apart from that link. It used to be split in two — the Position cell holding the link, the five cells after it a real <button> that cross-linked to this position's Activity — because an <a> may not sit inside a <button>. The cross-link is gone (below), so the cells are cells again: the row is one grid, nothing in the group is focusable but the ticker anchor, and the row lights nothing on hover. Dislocation P&L here is the same measure the carry rows carry (positionBasisDelta, now − at entry), read on one leg instead of a fused pair: a wrapper redeeming for more than it trades at carries a discount, and this is how far that gap has moved since the holding was entered. It rounds against rendersZero, so an asset trading at its redemption value dashes rather than printing a signed zero, and it is withheld outright when the position's current mark could not be read (M9).
    • The entry-side honesty flags ride the figure, because this section has no expanded panel to put them in the way CarryBasisCard does: ~ when some entry flow lacked a valuation, and a hover note when the anchor is the opening snapshot rather than observed fills (entry-basis.ts, synthetic). synthetic is the ORDINARY state here — a balance held from before the wallet was tracked — and the difference is material: the figure is then measured from first observation, not from what the holder paid. HOLDING_DISLOCATION_TIP names both cases.
    • A dash here says which kind of absence it is. An empty cell reads as a figure that happens to be missing, so both reasons carry a hover note of their own: the holding's opening was never observed, or the entry basis could not be worked out from the movements that ARE on the record. The second is a state that clears and the first is not, so the response carries its own flag for it rather than letting the page infer it from "no figure and marked partial" — which is also what a carry looks like when one leg is partial and the other was never observed opening. The same pair of sentences is used in the expanded carry, so the two surfaces never describe one absence two ways.
    • It is not part of Yield earned, and Yield earned is not part of it. Yield earned is the accrual reading, which carries no secondary-market price effect, so for a single-leg holding this figure is exactly what that column leaves out. Two separate readings, not two halves of one total. The tip says so, and it is only true because the column takes no valuation basis of its own: read at market, the same attribution would contain this figure once. (This section keeps its own string rather than sharing DISLOCATION_TIP because that one also nets two legs, which a holding does not have.)
  • 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 (toDisplayPositions emits 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. None of PosSection, RepoSection or VariableRateSection renders a chevron or a panel. None of them is clickable either. Each used to cross-link its row to that position's Activity from a real <button> (the whole row in PosSection, the cells after Position in VariableRateSection, an overlay in RepoSection); all three are plain cells now, so no flat row is in the tab order and none of them lights on hover. What a row still offers is its own LINK where it has one — the asset profile, the market detail — and nothing else. Only the carry row reacts to a click, and it keeps its hover rail because it still does something.

  • 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 via erc4626Brand), 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 a SUPPLY / IDLE / HOLD chip only restated it.

  • Carry rows are their own section (CarrySection, design handoff "carry trade" 4a / 5a). The collapsed carry row is chevron · Platform (brand icon + name, over E-MODE where set. No liquidation-scope line. The row used to name the venue's own unit there — ISOLATED MARKET, ISOLATED VAULT, POOLED ACCOUNT — which is a fact about the venue rather than about the holding, never varies for a given venue, and is already said by the brand beside it. E-MODE stays: two accounts at one venue differ on it) · Collateral · Debt · LTV · 24h APY · Value (with the USD echo in the native books). Each side of the trade gets a column of its own, amount first, because the amount is the figure being scanned; a side holding several tokens (a pooled Aave account, a Fluid smart pair) stacks its coin cluster and tickers over the per-symbol quantities instead, with the SMART chip on a Fluid smart (DEX) pair. The grid has a ~750px floor and scrolls sideways inside the card below it, so no column is ever clipped and the page itself never scrolls.

  • The LTV figure (CarryLtvCell) is the row's headline risk cell. A plain percentage — the debt as a share of the collateral behind it. Read on the MARKET mark (see below), from carryRisk, so it is the account's LTV on an account-scoped venue. The liquidation threshold no longer rides the row: that geometry lives in the expanded leverage section, whose track runs to the liquidation LTV. A withheld leg mark, or an account-scoped venue with no account read, dashes the cell.

    • On a Fluid position the ratio is the vault's own tick, not the balance sheet's. A Fluid borrowing carries a small padding that lands the position exactly on one of the vault's price ticks; the wallet does not owe it, so it is absent from the Collateral, Debt and equity figures and from every return. But the tick is what the vault liquidates on, so the LTV, the distance to liquidation and the health factor quote the padded ratio. The difference is under 15 bp of the debt, and a position whose live read did not land quotes the unpadded ratio rather than dashing. The leverage figure, and the leverage track's current marker, stay on the net ratio with the balance sheet, so on a Fluid row leverage and 1 / (1 − LTV) disagree in the last decimal: on an 11x pair the padded LTV implies about 11.10x against a served 11.01x. That is the two figures answering two different questions, not a rounding fault.
    • The figure ESCALATES. Foreground at rest, red once the position can absorb less than an 8% adverse move in the collateral-vs-debt price (1 − LTV / threshold, the same band bufferColor uses). It never de-escalates to green, which would read as a gain. Without the band a carry three points from liquidation printed exactly like an unlevered one.
  • What the collapsed row no longer carries, and where it went. All-time yield is the expanded view's Total net yield earned; the dislocation P&L is a row of its basis ledger; the liquidation threshold and the distance to it are the expanded leverage track's geometry. A levered position's first question is how hard it is borrowed, so the LTV keeps the row.

  • The balance-split Position cell (CarryBalanceSplit, used by the holdings tables' carry rows). Collateral block (amber Collateral label) · hairline · Debt block (muted Debt label), each = an overlapping CoinCluster of the REAL coin marks + middot-joined tickers + a SMART chip on a Fluid smart (DEX) pair + the per-symbol quantities beneath. Platform brand marks come from the shared PlatformBrand resolver — 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 / fluid are the direct venue marks; pendle and wallet add the Pendle mark and a neutral wallet glyph; and an erc4626 vault resolves to its real protocol via erc4626Brand (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 under components/icons/* and components/assets/IssuerIcon (the Asset Profiles issuer column, which now also carries the Aave and Coinbase marks for sGHO and cbETH, and the 3Jane mark for USD3).

  • A fund row leads with its MANAGER, not its venue. The Position cell of an erc4626 row names the fund ("Sentora PYUSD USDC", "Steakhouse USDC"), so its mark is the manager's, resolved from the vault address through erc4626Curator

    • the shared curator-marks registry — 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.
  • 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. So src/lib/portfolio/symbols.ts covers every address in the book map (locked by symbols.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.

  • The expanded carry — three sections on one flush surface. No nested boxes: the panel is one surface under the open row, divided by hairlines into position details, the redemption-vs-market basis, and a leverage simulator, with a single footer rule carrying the deeplink out. Below roughly 1045px of PANEL width the three stack and the dividing hairline turns from a left rule into a top one, which is a container query (.carry-detail* in globals.css) rather than a viewport one: the sidebar and the shell's per-breakpoint zoom change the card's width independently of the window, so the window does not predict it.

  • A. Position details. A LEG · AMOUNT · ACCRUALS table, one line per leg (coin + ticker + a COLLATERAL / DEBT tag, the quantity, and the leg's realized accrual signed green/red — a debt leg's arrives negative, funding being a cost), then a pinned footer: Net position (the equity, amber, with the USD echo beneath in the native books) and Total net yield earned (the net of those accruals). 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 table shows the amount and the accrual we do hold and the entry-to-current story stays in the basis ledger beside it. In the leg cell the TICKER outranks the tag for width: the tag restates the row's order and colour, the ticker names the instrument, so the tag yields first.

  • B. Redemption vs market basis. Three rows and nothing else: Basis at entry (positionEnteredBasis, the market-vs-redemption gap the fills carried, valued at each flow's own block, stable across syncs) · Current basis (positionRawBasis) · Dislocation P&L (positionBasisDelta = now − at entry, the change in the gap since the trade was put on), signed green/red and rounded against rendersZero so a gap that rounds away shows a dash, never a signed zero (M9). Two info glyphs carry the definitions: ENTRY_BASIS_TIP on the section, and on the P&L row the same DISLOCATION_TIP prefixed with this position's own <entry> → <current> reconciliation. The M9 honesty flags survive: a partial entry figure (a flow lacked a valuation) is marked ~ and footnoted; a reconstructed figure (the position predates flow tracking, anchored at its first observed balance) is footnoted; a leg with no anchor shows a dash + a note, and so does a leg whose entry basis could not be worked out at all ("partial" is never said of a figure that is not there); and if a CURRENT price read failed on any leg the section withholds the current basis and the dislocation P&L rather than compute against a partial current basis. PT legs enter at 0 by construction (M34: a lot's accrual value at its own fill block is what was paid for it). A balance adjustment enters "Basis at entry" like a top-up (ledger-first R5b, M32): on a leg with both a market and a redemption value it blends in at the two marks of the reading that found it, exactly as a real top-up of that size at that reading would, and a par leg has no gap for it to move. A holding older than the ledger's first movement enters at its opening record's reading, and a movement out of a holding no record states nets into no entry, so it can never leak into the next holding's. An opening is read as its reading is served: where a borrowing's line is withheld at that reading because a member of the position could not be priced there, its entry is a dash too, never a figure struck from the priced side alone (PR #963).

  • C. Leverage — a simulator, not a readout. A causal pair, SELECTED LEVERAGE → NET APY, over a track the reader drags. Both update live; at rest they are the position's own leverage and the same 24h APY the collapsed row prints.

    • The model. netApy(L) = collateralApy · L − borrowApy · (L − 1), implemented in its anchored form netApyNow + spread · (L − Lnow) where spread = collateralApy − borrowApy (carryRates / carrySpread / netApyAtLeverage, all pure and unit-tested). The two forms are the same line; the anchored one is used so the resting readout is EXACTLY the row's live rate on every venue, including the account-scoped ones where the leverage the venue liquidates on is the whole account's and does not equal this carry's leg ratio. The side rates are value-weighted per side exactly as netQuotedApy weights, and either side lacking a quote dashes the readout rather than averaging over a hole.
    • What it assumes. One thing, stated in the section's tooltip: both legs' rates are held at today's marks. Levering up draws on the market's borrowable liquidity and pushes the borrow rate along its utilization curve, so a large step up the track earns less than the straight line says. The slope is the carry spread, so the direction and the first order of magnitude are right and the tail is optimistic.
    • The track is a LEVERAGE scale, 1x to the leverage the position is LIQUIDATED at (leverageScale, 1 / (1 − liquidationThreshold)). It is an axis, not a runway meter: leverage is not linear in LTV, so the fraction of track used is not the fraction of headroom used, and it reads LOW (90% LTV under a 96.5% threshold has consumed 93% of its headroom and sits a third of the way along). Amber fill to the handle; a hairline ghost tick at the position's leverage today; a hairline at the venue's borrow cap (1 / (1 − maxLtv), absent on an isolated Morpho market, which sets one value for both); the red liquidation edge at the end. A static caption row sits under the track, fixed at its two ends — current N× left, liq. LTV N% right: the liquidation threshold the track's end stands on, stated as the percentage it is, and the one place the same-book carry surface states that threshold. The design's own label there read max, which is what a holder must not read it as: that point is a liquidation, not a position. Dragging past the borrow cap says so in a line naming the cap.
    • Reachable without a pointer. The handle is a real role="slider" with aria-valuemin/max/now/valuetext; arrows step 1% of the range (shift 10%), Home and End go to the ends. Pointer drags anywhere on the track move it, with pointer capture and touch-action: none. Same interaction contract as the /carries trade calculator's LeverageControl.
    • Reachable values. aria-valuemax is the most the handle can be SET to (98% of the way along), not the track's drawn end: the handle never rests exactly on the leverage that liquidates the position. A position already past that point extends the ceiling to itself, so aria-valuenow can never exceed aria-valuemax and an arrow key can never step the handle backwards to "correct" a value that was the truth.
    • It stands down rather than invent a threshold, and says WHICH thing is missing. noLeverageTrackReason separates four unrelated cases, each with its own line (NO_TRACK_NOTE), because "the market's parameters are not available" printed on a position whose liquidation threshold is on screen one line up is a statement the same screen contradicts: account (the venue liquidates the whole account, below) · incomplete (a leg's price did not read) · no-equity (the debt is worth at least as much as the collateral, so there is no equity for a leverage to be a multiple of) · no-params (the borrow cap and threshold could not be read).
    • Market mark throughout, matching the row's LTV. Pairing a redemption-mark leverage with a market-mark threshold would, on a discount (PT) collateral, draw the position FURTHER from liquidation than it is.
  • The whole risk readout is computed at the MARKET mark — 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) in signed-in-model.ts.

  • Two risk SCOPES, because the liquidation unit differs by venue, and the scope is the VENUE's — not a function of whether the risk read arrived. 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, getUserAccountData returns the account's own ltv / leverage / thresholds and the LTV cell reports THOSE (scope: "account"), and reports nothing until that read lands: the risk params are a separate, best-effort fetch, so keying the scope on their presence published exactly that leg-subset fiction for the whole first paint and permanently whenever the on-chain read failed. The leg values still populate the leg table above but never drive the risk cells, so a null leg mark does not withhold them.

    • An account-scoped carry gets no leverage simulator. One step along a track drawn from the ACCOUNT's leverage is not one turn of THIS trade, and the carry's own spread is not its slope, so dragging to the carry's real leverage would print a rate that contradicts the rate the row prints at that same leverage. The section states the account's leverage and the live rate, and says why there is nothing to drag. The cap and the threshold need the pair's on-chain risk params, fetched best-effort from GET /api/portfolio/risk (risk-params.ts) — Aave/SparkLend getUserAccountData (account-level params + current state), Fluid getVaultVariables2Raw, Morpho idToMarketParams LLTV (one value that is BOTH cap and threshold). A carry whose params could not be read dashes only what depends on them; nothing blocks the dashboard on the risk fetch.
  • The footer deeplink (Take me to carry trade metrics) survives the redesign: it reaches 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).

  • The cross-currency borrowings in the All view keep the Leverage & liquidation strip (behind the row's own chevron since v0.61, exactly as a same-currency carry's is) (RiskStrip): their two legs are denominated differently, so they have no ratio of their own to gauge and no equity multiple to simulate, and the venue's account read (leverage, LTV, distance to liquidation, health factor) is all there is to report. It survives into a view that states no return because it is not one: it is what the venue says about the position now.

  • The sides are passed as explicit symbols, never a "/"-joined string. "/" is carry_registry's encoding for a Fluid smart (DEX) pair, and PositionCell infers smart from it — sound for /carries, where the label IS a registry label. It is not sound here: groupKeyOf fuses 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 position SMART COLLATERAL (a different instrument, with different depeg / rebalancing behaviour). PositionCell therefore also accepts a LegSpec { symbols, smart, note }, and the portfolio states smart from 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 passes ringColor — PositionCell's coin-separation ring defaults to the /carries black row background, which would read as a black halo on the T.PANEL2 holdings panel. The tickers come from symbolOfLeg, 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 erc4626 leg'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 multi-strategy band takes its position name and mark from the shared fund identity described above, including for funds whose balance arrives as a wallet token rather than an erc4626 leg. For the other vault bands, 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 flat Held · 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 bare wallet balance still reads Held · 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 at size its corners would overrun the circular footprint the coins sit in (/carries never hits this — PlatformLink renders it bare, with no disc).

  • No disclosure copy at the foot of the page. The provenance / methodology block is gone, and so is the "Base yield only" line that outlived it. What the figures count is unchanged (see Product shape): base yield only, rewards and points excluded.

  • 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 by PortfolioDashboard.test.tsx.

  • The server still computes the wedge (returned in SummaryResponse / PositionsResponse) and, per book and per mark, the cumulative yield, the book value and the realized losses. All three are amounts in the book's own unit. The redesigned rail leads with tracked value + net APY + daily income instead of the earlier per-book return tiles; for reference, those tiles quoted cumulative yield in the book's native unit (e.g. +1.2384 ETH) and the current book value in the active mark.

  • The portfolio level states an absolute return and no percentage of it. A realized return (a simple holding-period return) and a TWR sat beside those amounts until both were removed in September 2026. Each divided by the first capital the book ever held, and a book opened with a dust position has no honest denominator: on one tracked wallet that opening was $0.009861, so the summary served a realized return of -2,150,884.64 and a TWR of -30.33% for a book that had earned a few hundred dollars. The decision was to remove both rather than floor the denominator, which would only have moved the arbitrary answer around. Nothing on the page rendered either figure, so the view is unchanged; a book's headline in SummaryResponse no longer carries realizedReturn or twr, and a client bundle from the previous release read neither field.

    • A rate on ONE position is a different thing and stays. The positions table's earned-vs-advertised column is an annualized rate on a single position (below), and each category's roll-up within a book reports the same rate. Both compare against advertised annual rates, which is a question a percentage can answer.
    • Cumulative yield carries a plain economics tooltip on the app-wide InfoTooltip "i" icon (the same affordance /carries and the terminal tables use; never the CSS help cursor). Copy lives in theme.ts. That 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 is day_floor(this wallet's own stored history floor, already clamped to the 2026-01-01 derivation floor when it is read) when the wallet already held a yield-bearing position at that floor, and day_floor(max(that same stored floor, first curve-starting activity in a replayed position group)) when it held nothing there. The wallet's own floor is the UTC midnight 30 days before it was first added (a wallet tracked before that rule shipped carries 2026-01-01) — the derivation floor is applied ONCE, on the way out of the floor reader rather than as a term of the window, so no account's history opens before 1 January 2026 whenever it registered (see Data pipeline — the first-activity term exists only to spare a wallet a flat lead-in it never lived, so it applies only to a wallet that had no position at its floor, and 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, and it is also where every book's curve opens (see "Every book starts on the account's own anchor" below), so the date the tiles quote is the date the chart starts on, whichever book is being drawn. Any "the last 90 days" phrasing would be plainly false: the window is a rolling 30 days from when the wallet was added, it does not roll forward once set, and the first-activity clamp binds for most wallets on top of that. An account tracked before the rolling window shipped keeps the 2026-01-01 start it already has, written onto its row by migration 104. When the backfill has not landed there is no anchor yet, and the sentence elides the date and still reads cleanly. Every build is one pass, so "tracked since" is right the first time it appears and never moves backwards: the two-pass build that used to lower it minutes later went with the tiering (Data pipeline).
    • A market-marked line can start later than the anchor, and that is honest. The market mark of a deep grid point comes from the price mirror, and a token has bars only from the day it entered the mirror; below that the mark is unavailable and the market line simply has no point there (never a fabricated one). The redemption line is unaffected, because it is read from the position's own on-chain rate. So on a freshly deepened history a recently added asset can have its market line begin after the account's "tracked since" date.
    • 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.
  • The chart: the total-return and accrual lines (both bucket-reduced onto one uniform grid) and liquidation markers. A marker is drawn on the point that CLOSES the interval containing the seizure (2026-08-06), so the flag and the cliff it explains land together rather than a bucket apart; the ledger keeps the real timestamp. Markers too close together to label separately share one label carrying the count (LIQ x2) while keeping a line each. An interior gap draws as a flat line carried forward (2026-08-12). A day the snapshot found nothing to read is still served as an explicit null, but the plot holds the last reading across it and lands whatever step the book comes back at on the first real point AFTER the gap. That is a carry, not an interpolation: every value drawn was really observed, and the ordinary cause of such a gap is a rotation, where capital fully left the tracked wallet between exiting one venue and entering the next, so the book was empty for a few days and an empty stretch earns nothing by construction. Both lines bridge together, and a bridged stretch is styled no differently from any other: the reader is not asked to tell "held nothing" apart from "held something that earned nothing", and hovering a bridged day shows the carried figures as ordinary rows. BEFORE the first reading and AFTER the last the line simply stops, because there is nothing to carry. A MONITORING OUTAGE IS NOT BRIDGED. When scheduled readings between the newest stored one and the live tip were missed, the gap says the reader was down, not that the book was empty: the position may have been running the whole time, so a flat line there would state a level nobody observed and push the real move into a cliff at the tip. Those breaks stay breaks, and a reading left stranded beside one keeps its own dot (the footer note above names it). A liquidation flag likewise always sits on a day that carries a reading, so the flag and the drop it explains never end up on opposite sides of a bridge. The honest-gap contract is unchanged where it is a contract: /api/portfolio/history still serves the null, so a consumer of the API still sees exactly which days were read (the fill is a chart-layer decision, like the dust clamp below). It deliberately does NOT mark capital in/out: a deposit is neither a yield event nor a return event, and the flow ticks read as though the curve moved because of them when both lines are flow-neutral by construction. /api/portfolio/history still returns flows (the events ledger is a separate surface); the chart just does not draw them. The hover tooltip shows the date and both readings, labelled (plus a LIVE note on the tip), read off ONE point object so the two rows can never come from two different dates: bookValue is 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 figure.

  • Every book starts on the account's own anchor, and the flat stretch before a later book is drawn rather than trimmed. The account has one replay start (summary.trackedSince, above), belonging to whichever book opened first, and history.trackedSince reports the same date for every book. A wallet running an ETH carry since June and a USD vault since July therefore has its USD chart start in June, flat, and the deposit that opened the vault is drawn as the flag that lifts it.

    • Why the per-book anchor went. Trimming the lead-in needs a per-leg statement of "earns nothing by construction" — the mark-independent class that separates a par wallet token from a rate-source one — so that the trim can be proved incapable of hiding P&L rather than merely believed to be. That classification was an input to the retired attribution engine and has no counterpart in the one that serves the chart today, so the trim was retired with it rather than reproduced from a weaker signal.
    • It withholds nothing. The trimmed span was idle-only by construction, so no figure moved when it stopped being trimmed: cumulative yield, total return and observed days are built over the whole series and are what summary reports either way. An untrimmed lead-in errs toward showing MORE history, which is the direction every guard on the old trim also failed in.
    • Every marker is drawn, including the opening deposit that sits at or before the first reading. The old rule clipped markers to the drawn window only when the head had actually been cut, precisely so an untrimmed book kept that flag; nothing is cut now, so nothing is clipped. Dropping the opening move would hide the one flag that explains where the curve's first value came from.
    • Nothing is stored differently and no re-backfill is involved — this is a read-time decision over stored history, so restoring a per-book anchor later is a code change alone. The full movement ledger is served by /api/portfolio/events regardless.
  • Timeframes and chart resolution (2026-07-17; 1W removed 2026-08-10). The timeframe pills are 1M · 3M · 6M · YTD · ALL, and every one of them draws the daily reduction (?bucket=1d). A 1W pill drawing the raw 6h cron cadence (?bucket=6h) existed from 2026-07-17 to 2026-08-10 and was removed as a product decision: a freshly added wallet's week is mostly pre-signup history on the daily replay grid (lone dots, no line), and on a leveraged book the day-scale mark noise between the collateral and debt legs dwarfs a week of true carry, so the 7-day window reliably drew scatter rather than a return. The history API still serves bucket=6h. Daily rather than 6h for the ranges that remain is deliberate: 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. bucketReduceCurve keeps the LAST observation inside each FINISHED 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. (On the 6h grid, which no range draws since the 1W removal, each point is its own window and the tooltip shows a clock time.)
    • A point OFF the daily grid states its own moment in the tooltip. The daily tooltip shows a date alone (a clock time would imply a precision an end-of-day reading does not have), but a tip is not a daily point, and on a multi-wallet view there is one per selected wallet, each at its own moment. Labelled by date alone they would read as several values for one day, which is the confusion the tipping rule exists to remove.
    • The newest bucket is still open, so it is not reduced that way (2026-08-06). The newest observation is served at its OWN timestamp on BOTH widths, and the bucket containing it keeps only the reading at its start. Without that rule the 1d series stamped the day's newest reading at midnight while the 6h series served that day's earlier reading at the same instant, so one timestamp carried two different values and the headline point moved when a reader switched the range (measured: $255,067.18 on 1d against $254,326.77 on 6h at the same second, and 2.7% of an entire ETH-book total return on another wallet). Both widths now end on the same point, with the same value.
    • ?wallet=all never tips. The aggregate merge walks a uniform stepped grid and would step straight over an off-grid point, and each wallet's newest reading sits at its own moment, so getHistoryAll asks 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; since the 1W removal no chart requests that grid, but a 1d series equally carries NULL for any day whose reading is missing, and the rule is bucket-agnostic). 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 each 0 into -base). A single selected wallet takes the fast path, where its own null buckets still render as clean gaps.
  • 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 / realizedApyRedemption on GET /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, whatever they settle in (an Aave/Spark account is partitioned per book, so a USDC supply charts even beside a cross-currency loop; a direct Morpho lend charts even when the wallet also borrows in that market; a supply in no currency this product reports a return in is repo lending, listed in the All view). A leveraged position counts as a carry whenever ONE currency runs through both its collateral and its debt — e-mode is no longer required (settled 2026-07-15: emode_category is kept only as the E-MODE display chip, not an inclusion gate). Every other borrowing is a cross-currency borrowing: not excluded, listed in the All view (M22), at value, with both sides and a net. 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), and since 2026-09-18 it covers a borrowing whose collateral settles in no currency at all — a bitcoin-collateralised dollar loan, a gold collateral enabled beside a dollar loop. What is left with no figure is a position this product cannot value, listed under Not covered and outside the All view's total: a bare debt with no collateral standing behind it at all, reported as "Debt without matched collateral" (collateral unread for more than a single snapshot, or taken entirely by a seizure that left the debt behind); a borrowing every one of whose collateral legs is a principal token settling into an asset creddit does not track, reported as "Payout asset not tracked"; and that same principal token posted at a venue with nothing borrowed against it, which is no borrowing at all and carries the same reason for the same cause, there being no unit to state it in (held bare in the wallet instead, it is not shown anywhere). A Fluid vault holding two different base assets on one side is no longer a verdict of its own: the "directional pair" rule is retired, such a side is a base mix like any other, and the NFT is a cross-currency borrowing when it borrows and Smart repo lending when it does not (M14).

The holdings table's second face: Activity ​

The Holdings panel answers two questions, and a two-option rail tablist under the section header picks between them: Positions (what the selected wallets hold, above) and Activity (what they did). Labels sit on the strip with a 2px amber rail under the active one, and the control is a real tablist (TerminalTabs variant="rail"): role="tab" + aria-selected, roving tabindex, and Left/Right/Home/End moving the selection. BOTH regions are rendered as role="tabpanel" whenever the switch is offered, so each tab's aria-controls resolves; only the selected panel is displayed, and the unselected one holds no children, so choosing Activity is still what triggers its fetch. The strip carries the two tabs and nothing else: it used to hold a caption opposite them naming the region under it (N groups · valued at redemption), and both halves of that sentence are already on screen, since the sections below are titled and the mark is the chrome bar's own switch. The hero cards and the chart are identical on either face, because the face changes the table and nothing else. ?view=activity makes the choice linkable (written with replaceState, so it never adds a navigation). The feed is the union of the currently selected wallets. The API is still one wallet per request (?wallet=all is a 400: one on-chain transaction can touch two tracked wallets, and merging server-side would duplicate or drop a leg). The client fetches each selected wallet, stamps the line, and shows two rows when both participated. That is the honest reading given a Wallet column.

The union stops at the shallowest wallet that still has pages. Each wallet is paged on its OWN keyset cursor (30 transactions a page), so the merged prefixes reach back different distances: a wallet that traded thirty times this week is loaded to Monday while a quiet one's thirty transactions reach back two years. Rendered raw, the list would LOOK chronological and silently omit everything the busy wallet did before Monday — a reader scrolling to last month would see one wallet's lines and conclude the other did nothing — and Load more would then insert the missing lines into the MIDDLE of what they had already read. So the feed ends at the oldest transaction the shallowest still-paging wallet has loaded, and Load more asks only the wallets holding that mark up. A wallet with no pages left constrains nothing, so one selected wallet — or every wallet exhausted — withholds nothing at all and the feed reads exactly as it always did (unionFrontier, pinned by ActivityFeed.test.tsx). The frontier is keyed on the same tuple the cursor pages on — (block_number, tx_hash), not the timestamp — because every transaction in a block shares one ts, so a page boundary can fall inside a block and the withheld half would otherwise return at exactly the frontier and sort in above a line the reader has already passed.

A matured Pendle PT gets one line, dated at its maturity. The statement is read down its dates, so the day a PT matured belongs in it — and for a PT the holder has not redeemed, that date is usually AFTER their last transaction, which puts the line at the very top where a decision waiting to be taken belongs. It is deliberately not a transaction line: no disclosure to open, no explorer link, and nothing in the amount column, because a maturity moved nothing and that column is where a reader looks for money that came or went (a dash there would say "we could not read it", which is a different and false statement). It carries the same caption the holdings row does, from the same derivation, so the two surfaces are one sentence rather than two spellings of it. It is not a flow, not a capital movement, not a chart marker and not an anomaly: the server sends it BESIDE the ledger page rather than inside it, so nothing that sums, nets, charts or classifies can reach it, and every figure the feed states is byte-identical with and without it. Because the feed pages newest-first, a line dated BELOW what is currently loaded is held back until paging reaches its date (and shown unconditionally once the feed has no pages left): parking it at the bottom would let Load more insert transactions underneath it, which is the mid-list insertion the completeness frontier above exists to prevent.

When more than one wallet is selected, and the feed is not narrowed by ?position= + ?wallet=, a Wallet column sits between the time and the action: the user-set label, or a shortened 0x address if unnamed. One selected wallet has no Wallet column. Activity includes a selected wallet that holds nothing in this denomination: what a wallet did is not a fact about the current view. It is reached from every view, All included, because it is a face of the same Holdings panel.

What the feed shows, newest first, under a month heading and a day heading:

  • One line per on-chain transaction, not per ledger row: the time (the day heading above it carries the date), what the transaction WAS (with the venue under it, where it happened at one), how much moved, and a quiet link to the block explorer. When more than one wallet is selected, a Wallet column sits between the time and the action. A leverage loop is several rows in portfolio_flow_events_v2 (one per leg) and one action, so it is one line, and the legs are underneath it. The venue named under the action is the action's own venue, not every venue the transaction touched: a vault deposit paid out of a tracked balance touches the vault and the wallet, and "2 venues" under "Deposit" would name the mechanics this line exists to keep out of the way. A transaction whose action genuinely spans venues still names the count. A movement in the holder's own wallet names no venue at all, and no address either. A holder knows their own transfer happened in their own wallet, so a tile reading "Wallet" under the line read as a protocol beside the lines that name real ones; and the address on the other side is a hex string a reader scanning a statement does not want under every transfer, nor under each leg of an opened swap. The caption is simply absent on such a line, and the transaction's addresses are one click away on its explorer link. The venue wears its own brand mark, from the same registry the holdings table's Platform cell reads, and a vault names the protocol whose app runs it and the word "vault" with it ("Morpho vault", resolved from the vault address the leg carries, so mark and name cannot disagree). The word stays because the venue filter offers the money market and the vaults as two different choices, and a vault line reading only the brand would point a reader at the option that cannot return it. A line whose movements span two venues names the count and wears no mark, since picking one of them to stand for the line would be a guess, and two vaults run by two protocols are two venues even though the ledger files both under the same venue word.

  • The line names the ACTION, not the mechanics. A transaction is classified from the movements it left behind, and the line reads what the reader did:

    The line readsWhat it was
    DepositMoney supplied into a venue. A deposit paid out of a tracked wallet balance is still one deposit, and the funding transfer is not a second movement underneath it either: it is the same money, so it folds into the deposit it paid for.
    WithdrawalMoney taken back out of a venue.
    BorrowDebt drawn.
    RepayDebt paid down.
    Open carry tradeCollateral supplied and debt drawn against it in ONE transaction. Recognised by that shape alone, so it names any leveraged position, whether or not the strategy is one the platform lists.
    Close carry tradeDebt cleared and collateral withdrawn in one transaction. A repay-only or withdraw-only transaction stays Repay or Withdrawal: claiming a carry closed would claim a carry existed.
    SwapTokens out and tokens in, no venue touched. Where both sides are covered assets of the same book, the two movements are recognised as ONE trade: no capital in or out, and the cost of the trade charged against total return rather than netted away.
    Wrap / UnwrapEther turned into WETH, or WETH back into ether: ONE line for the one move of the holder's own ether between the two, no capital in or out and nothing traded (WETH is ether one for one). It files under the Swap filter.
    Transfer received / Transfer sentA plain movement of a covered token. Native ether reads exactly like any token here: ether received from, or sent to, somebody else (a person, or a contract that is not a covered venue) is a transfer of ETH. Ether credited by the block itself rather than by a transaction (a validator withdrawal, the fees a block producer earns) is a transfer received too, and its link opens the block rather than a transaction.
    LiquidationCollateral seized. A liquidation dominates the line whatever else the transaction carried: a seizure is mechanically a repayment plus a collateral move, and "Repaid and sent" would describe a voluntary unwind, which is the opposite of what happened.
    Multiple movementsThe filter pill's name for the BUCKET of transactions the rules above did not match. Such a line reads the plain composition of its own movements instead, as the paragraph below explains.

    A transaction whose shape matches none of these is not given a name that might be wrong: it keeps the plain composition of what its movements were ("Supplied and received"), reads "Multiple movements" once there are three or more of them, and shows every leg. Money arriving AND a deposit in one transaction is the common case: that is a swap-and-deposit, and calling it a Deposit would be a guess about which half the reader meant. The pill's own word is deliberately the wider one: the bucket holds transactions with a single movement as well (a position token changing hands on its own), and a line reading "Multiple movements" over one movement would be a false statement about it, while "Received" is exactly true and says more.

  • The amount is the action's own amount, in the token's own units, with its coin beside it and its ticker after: the deposited token for a deposit, the collateral for a carry opened, what was seized for a liquidation, the incoming token for a swap. The ledger stores every amount in the token's smallest on-chain units, and the read turns one into the number the holder signed for by the token's own registered decimals; where those are not known the quantity is withheld rather than rescaled by an assumed 18, because a quantity wrong by twelve orders of magnitude is not null and nothing downstream could catch it. It reads to two decimals at every magnitude, grouped — 4,080,330.22, not 4,080,330 and not 4,080,330,222,984 — and below one token it extends past its first significant digit to the one after it, so a 0.00004123 movement reads 0.000041 rather than rounding away to nothing. (The POSITION column still drops decimals past five figures: a column of holdings is scanned, and a statement line is read one at a time.) Two tokens in one action (a Fluid smart pool supplies a pair) name both and invent no single number, since adding USDC to USDT is not a quantity; three or more name their count and the expansion carries the detail. One token whose quantity the pipeline could not derive keeps its coin and its ticker and dashes the number, because a line with a single movement has no expansion to fall back on and the asset is still worth naming.

  • The amount carries a sign, for the direction the capital went. A plus is capital arriving (borrowed, withdrawn, received, bought in a swap, the collateral coming back from a carry closed); a minus is capital leaving (supplied, repaid, sent, seized, the collateral going into a carry opened). A withheld quantity takes no sign, and neither does a transaction that moved capital both ways: there is no single direction to state, which is the same refusal the figure makes. The signs are not coloured. Colour on this dashboard means gain and loss, and a deposit is neither.

  • Value at the time of the transaction, in that leg's own denomination, taken straight from the ledger's value_market (valued at the flow block, M3/M5; the redemption figure is stored beside it and read by the per-position basis surfaces). It is never re-marked, so a figure does not move when the market does. It prints under the amount, quietly, on the line and on every movement inside it: the quantity leads because what moved is the fact, and what it was worth is the annotation. It used to live only in the amount's hover, which put the number a reader scans a statement for behind a gesture a touch screen does not have, and left a line reading in tokens above rows reading in tokens and dollars. Where the figure cannot be stated the dash carries the sentence saying why, in the same place the figure would have been.

  • A transaction is stated ONCE, and this is the load-bearing rule of the whole view. Both the amount and the figure are drawn from the legs that ARE the action, never from every row the transaction wrote: a vault deposit paid out of a tracked wallet balance records the same money twice (the deposit and the funding transfer), both pointing the same way, and a line that added them stated double what moved (a 99,413 USDC deposit read as $198,826). The same rule now decides what opening a line shows, not just what it states: within one transaction and one asset, the wallet movements are the mechanics of the venue's own movements when their signed sums agree, and are not listed beside them. Two things gate it. The action comes first, and it is the classifier's existing judgement rather than a new one: every named venue action is named by a rule that already bounds exactly which wallet legs may ride along, so a transaction carrying anything else is not named at all and shows every movement it holds. Then, where the ledger knows a transfer faced the venue's own contract, only those transfers are summed, which keeps an unrelated transfer of the same token from either hiding behind the sum or breaking it; where it knows of none — the ordinary case for a position managed through a bundler or a router, whose transfers face the adapter — every wallet movement of that asset is summed instead, so a routed deposit does not keep showing the duplicate this rule exists to remove. Money reaching a lending market passes through the holder's own wallet by construction, so a carry closed in one transaction opens onto "Repaid" and "Withdrew" rather than onto those two plus the payment, the change returned because a payoff quote is stale the moment it is quoted, and the mirrored receipt of the collateral — five rows for the two things that happened. The test is arithmetic rather than a list of shapes, so it covers the funding transfer, the mirrored receipt and the over-payment's change without a case for any of them, and it deliberately folds none of: a zap (money arriving from outside AND a deposit of the same size is a shape the classifier will not name, so the first gate stops it and both movements stay), a liquidation (its rule claims the line on the seizure alone and bounds no wallet leg, and it is the line a reader most needs shown in full), a swap, a reward, a redemption, or any transaction with no venue side, an equal-and-opposite pair of the same token riding with a carry (two transfers that cancel are invisible to a sum, and on the two carry actions, which admit movements in both directions, that is how an unrelated send-and-receive could otherwise hide inside one; three or more transfers that cancel between them are a narrower residue this test does not reach), a slice in every shape it takes today (under a venue or position filter the other side is generally not on the line, so there is nothing for the sums to agree with; it is not a guarantee the arithmetic makes on its own, and a slice that did hold both recordings would fold them exactly as the unsliced line does), and anything unquantified. A line left with one movement stops opening at all. The folded rows stay on the entry and on the API in full: this decides what the expansion draws, and nothing else. A shape the feed will not name keeps that discipline without a name to hang it on: where such a transaction records tokens moving beside the position they moved into (a Pendle PT supplied as Aave collateral is that pair, with no wallet movement anywhere in it), the line states no summed quantity, and the movements speak for themselves underneath.

  • A figure, or an amount, only when it means something, and the dash carries the sentence saying which refusal is being made. Six cases: a mark that could not be read (M9, a dash, never a zero); legs spanning two denominations, which have no shared unit; an asset that settles in none of the denominations this account reports in; legs pointing in opposite directions; the same capital recorded on both sides of one transaction (tokens moving beside the position they moved into, or a wallet movement beside a venue's); and a settled leg sitting beside a confirming one. Opposite directions is the leverage loop: supplying 100,000 and borrowing 60,000 against it is not a 160,000 transaction, since the reader moved 40,000 of their own capital. A liquidation states what was seized, not the seizure plus the liquidator's repayment: the repayment was not the reader's money, and adding the two prints roughly double the event beside the one word on this feed a reader most needs to trust. The amount answers to the same discipline: where a refusal says the legs may be the same money, or money moving both ways, the quantity is withheld (the coin and the ticker stay), because a summed quantity would double exactly where a summed figure would. A refusal is carried where the thing refused would have been: on the leg's dash when the line opens, and in the amount's own hover when it does not. The sign follows the number, so a rounded zero carries none: dust that rounds away has no direction left to state.

  • A muted "Confirming" tag on a line carrying a basis = 'provisional' row — a flow the JIT read recorded inside the reorg-exposed window (migration 070). It settles on its own by the next 6h tick; the tag says so and nothing else. A wholly provisional line is ordinary in every other respect, amount included; a line that MIXES a provisional leg with a settled one states no amount, because a reorg re-including one transaction at a new coordinate is exactly how those two rows come to exist and summing them would print double what moved.

  • A quiet Etherscan link per line.

  • A line opens only when there is something underneath it. A transaction with one movement IS its own detail, so it carries no disclosure at all: an affordance that opens onto a restatement of the line is a promise the screen does not keep, and screen readers are told the same thing the pixels say. Opening a line shows every leg in execution order, each with its coin, its amount, its venue and its own figure. Nothing is hidden by the naming: the action is a VIEW of the legs, never a filter on them.

  • The time leads every line ("18:45", hover for the exact instant), and the date is the day heading's ("Mon 29 Dec"), stated once above the lines it groups rather than again on each of them: a statement is read down its dates, and a line repeating what the heading two lines up already said was noise in the column a reader scans first. Every stamp is UTC, like the rest of this surface, and the feed says so once beside its filters rather than on each line.

  • Two filter pills, venue and action, offering only what that wallet's ledger actually holds, and greying out what the other pill has ruled out (a wallet can hold both "Aave" and "Withdrawal" and no Aave withdrawal), so no choice on either lands on an empty feed. The venue list includes Wallet, because a plain transfer's venue is the wallet itself. The venue filter applies to the legs, so filtering to one venue shows that venue's legs grouped by transaction rather than a transaction's unrelated legs riding in beside them, and the action is then read off that slice (which is what keeps a chosen pair from ever answering with nothing). The action filter selects whole transactions by what they MEANT, so an opened carry answers to "Open carry trade" and never to "Borrow". Because those are two different readings, the action pill lists every action either reading can produce: the transfer that funded a carry is part of that carry as a whole transaction and a transfer of its own under the Wallet venue, so it is offered (greyed, with the reason on hover, until a venue is chosen) rather than left off a list that would then be unable to name a line on screen.

  • Native ETH is itemised like any token (issue #966). Its movements have receipts of their own: a transfer in or out and the network fee of every transaction the wallet sends are read from the blocks, a WETH wrap or unwrap from WETH9's own events, and ether a contract pays the wallet inside a transaction is found at the next reading. So ether received or sent reads as a transfer of ETH, a wrap or unwrap as one line, and a fee sits under its own transaction, held back with the other fees until the fees control is on. (The balance-difference rows the retired ledger derived for native ETH, which named no transaction and lumped a period's gas in with any genuine transfer, never reach this view.)

  • The feed is not scoped to the denomination view. A transaction is shown whole, and one can supply in a book this view does not list, so a feed carrying movements outside the active view says so beside its filter pills.

  • A position row does not open this face. It used to: clicking a flat holdings row flipped the card to Activity narrowed to that row's position_key. It was offered on some rows and withheld on others (a row with no position key, the derived native-ETH balance, whose flows are all synthetic so the feed could only answer "none"), and a table that reacts to a click on some of its rows and not others reads as broken rather than as selective. So the rows are inert, and the face is reached from its own tab. Per-position activity has no direct entry point from a row for now; the narrowing itself is unchanged and still reachable.

  • The narrowing arrives from the URL. ?position= and ?wallet= travel together (both alongside ?view=activity), and only the card for that wallet is narrowed, with a named pill carrying the way back out: a position_key is venue-scoped, so two wallets that each supply USDC on Aave carry the identical key, and a key without its wallet would filter cards the reader never touched by a position that is not theirs. Half a link is therefore no link. A key naming nothing the account currently holds keeps the filter (the ledger legitimately holds activity for a position since closed) and names it by venue, never by printing the key itself.

Paging is keyset by (block_number, tx_hash) descending, 30 transactions to a page, and a transaction's legs are never split across a boundary: a half-line carries a value that is the value of nothing. Keyset rather than offset because the cron and every JIT read append to this table, and an offset window shifts under a write, showing one transaction twice and dropping another.

Freshness is stale-while-revalidate: the page paints immediately from the stored 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 that reading ("6h stays, now = JIT RPC"). A complete reading, taken at the block this wallet's ledger is committed to, is stored as the wallet's live tip and served from the database from then on, so a position entered five minutes ago appears on the next load a few seconds after first paint and is dated, in the freshness stamp, by the block it was read at. The dim note the chrome used to carry beside the figures is gone, and nothing in the page is drawn from an unpersisted reading; the performance chart does still mark the tip's own point, because a reading taken between two windows must not be read as an observed window. The tip is off the six-hourly grid by construction and is retired by the next checkpoint, so history stays on the grid and only the tip is ever off it. If the live read stalls, fails, or is declined for a wallet read moments ago, the stored view simply stands and the stamp keeps stating the reading that is on screen.

One rule keeps that split honest:

  • The one on-chain read left on a GET path is bounded. GET /positions resolves 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 in constantVariables and are immutable per vault) and bounded by a 2.5s budget, because rpcRequest retries 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.

Movement classes ​

The flow ledger labels a movement with what it actually is, and six of those labels change what the reader sees.

classwhat it means for the readereffect on the numbers
Reward receiveda claim from a rewards campaign, not money you addedshown as its own line with muted styling; it is not a contribution, so it does not dilute the return percentage
Costa fee paid out of a tracked holding: the network fee of every transaction the wallet sends, paid in ether, or gas paid in a token on a smart-account transactionshown as a cost, not as a withdrawal, under its own transaction and held back by default; the published return is unchanged, which is the documented policy
Rotateda wrapper hop — the same claim wearing a different token, such as GHO into a savings wrapper and backone collapsed line for the whole episode instead of one line per hop, and no capital in or out
Swapone covered holding exchanged for another of the same book in one transactionone collapsed line instead of two movements to and from strangers, shown at full weight rather than dimmed like the other internal movements, because a trade is a decision and it now carries a real cost; no capital in or out, and what the trade cost is charged to total return at the price actually traded at rather than disappearing into contributions. The yield line is unaffected: what a trade costs is what the market charged, not what the holdings earned
Moved between your walletsa transfer between two wallets on the same accountstill money leaving one wallet's own curve; at the account level the pair nets to nothing rather than reading as a withdrawal followed by a deposit
Spaman unsolicited dust arrival from an address dressed up to look like one of yourskept on the record and labelled, left out of contributions, and left out of the feed by default
Balance adjustmenta balance reading found a holding larger or smaller than the recorded movements say, and nothing on chain explained the difference, so the records were corrected to the readinga line on the statement of its own kind, never a deposit, a withdrawal or anything you did: one still under review is always its own line, and the ones whose cause is known and could not be looked up (since issue #966, ether over a stretch whose sources were unavailable, which is rare) fold into one summary line that opens onto each; no capital in or out, in no count of your own activity, and the stretch it closes is "not measured" on both lines rather than read as a gain or a loss (see the Activity face)

Three limits are worth stating because they are visible:

  • A rotation through a wrapper we do not model leaves a small amount of yield attributed to nothing, so the return line for that episode is short by exactly that amount. It is recorded as a coverage note rather than guessed at, and modelling the wrapper is the fix.
  • Transfers between an account's own wallets net at the account level retroactively — the day a second wallet is added, its whole history counts, which is what a reader expects and what avoids inventing a round trip nobody made.
  • Reward received is what makes it safe to add one of the tokens our positions earn to the tracked list. Without the label, every past reward receipt in that token would read as a contribution on the chart.

What those labels look like on the statement is the Activity face below.

The Activity face ​

Four things the statement does, each of which a feed that drew one line per ledger row would get wrong.

An episode is one line. Money moved into a wrapper and redeemed back is one thing that happened, whether it took one transaction or six across thirty-nine days. Drawing each hop separately would read as three departures and three arrivals of capital the holder never made. The whole episode is one line that names what it was, states how many movements it stands for and the day it opened, and opens onto every one of them. The same collapse covers a liquidation (the seizure and the write-off are one event), a redemption in settlement (instructed one week, paid the next), and a transfer between two wallets on the same account.

A reward is not a deposit. A distribution arriving reads "Reward received", quietly. It is not money the holder added, and the return arithmetic already treats it that way.

A fee is not a withdrawal. Network and paymaster fees are out of the feed's own lines and sit under the movement they paid for, one disclosure away. A fee on its own — and an unsolicited dust transfer from an address dressed to look like one of the holder's — gets no line at all until a control above the feed asks for them; the control names the count, so a quiet feed never hides an unstated number of records.

A correction is its own kind of line, and never something the holder did (ledger-first R5c). Every balance reading checks the recorded movements, and where a reading finds a holding larger or smaller than they add up to and nothing on chain explains the difference, the records are corrected to the reading. The statement shows that correction dated at the reading that found it: "Balance adjustment +50 USDC, cause under review" where the cause is still being looked into, or "…, source not tracked" where it is a cause the product knows and does not follow (native ether moving outside any transfer it tracks). Its quantity is the change to the holding in the holding's own token (a correction to a borrowing says "debt": a larger debt, not more money), with its value at that reading beneath it. It is never drawn as a deposit or a withdrawal, it has no transaction to open or to link out to, it is counted in no capital figure, and the action pill offers it under its own name. The balance a holding already had when its history begins is not a correction and never a line.

A correction under review is always its own line; the ones whose cause is known fold into one (R5c as amended by Fred on 2026-09-26). Until issue #966 the cause the product knew and did not track was nearly always gas: paying a network fee moved native ether without a transfer the ledger recorded, so an active wallet's readings found a small difference at almost every six-hourly check, up to four a day (on one wallet that made no movement at all, 723 of them over six months). Native ether now has receipts (every transfer, every fee, every wrap and unwrap, and the ether a contract pays inside a transaction, found at the next reading), so those differences are gone, and the one known cause left is a stretch of ether history the sources could not answer for: blocks from before the wallet was followed, or the chain data provider's transfer list being unavailable when the reading was checked. A difference left after every source answered is not that cause: it is under review and pages like any other. The statement still holds the known-cause corrections back by default, the way it holds back network fees, and folds them into one summary line, "N balance adjustments, sources not tracked", beside the fees' control. Opening it brings each back as its own dated line ("Balance adjustment −0.0004 ETH, source not tracked"), and closing it folds them away again. The count is taken through the filters on screen, so opening the line always produces exactly the number it names, and a wallet whose only records are such corrections says so rather than claiming nothing happened. A correction still under review is never folded: it is the one the holder should see. Folding changes what is drawn and nothing else: every figure, every capital total and every return is the same whether the line is open or not.

A redemption in flight is money in settlement. Value sitting in a cooldown reads as a redemption in settlement until the payout lands, and as a settled redemption afterwards, stating what was actually paid rather than what was asked for (a queue can finalise a request short, and the difference is the news). It is never drawn as money that left. The two states are told apart by where the money is, not by which movements are recorded: a redemption records both a departure and an arrival from the moment it is instructed, so the arrival alone says nothing about whether it has been paid. Filtering the feed narrows which movements a line shows and never which state it is in.

The filters keep their contract: a line is asked for by what the movement meant, never by the ledger's own row vocabulary, and neither the venue nor the action pill can offer a combination that returns an empty feed.

What counts as money you put in ​

Every movement the ledger records is labelled with what it is, and that label decides whether it counts as your own money going in or coming out. Six labels do: a deposit, a withdrawal, a borrow, a repayment, and a transfer either way. The rest do not: a liquidation, a debt written off, a reward, a cost, the two halves of a move between your own positions, a balance adjustment (a correction to a reading, which nobody moved) and the opening balance a holding had when its history begins.

Three things follow, and all three change a number you can see.

  • A leveraged position opened in one transaction counts once, at what it cost you. Opening a loop deposits, borrows, swaps and re-supplies in a single transaction, and today the contributions line adds up the gross movements rather than the money that actually left your wallet. On a real position that would read 21,544.59 USDC of contributions against 4,534.69 USDC committed. The whole transaction is netted in dollars, so a loop reads as the one contribution it was. Two separate decisions minutes apart still read as two.
  • Moving money between your own wallets is not a contribution to your account. It is still money leaving that wallet's own curve, and the wallet view says so; at the account level the pair nets to nothing rather than reading as a withdrawal followed by a deposit. Unsolicited dust sent to you by a stranger is never a contribution at either level.
  • Rewards no longer dilute your return percentage. A reward is not money you put in, so it is left out of the return, which is the documented policy today. What is new is that it is also left out of what the percentage is measured against, together with whatever that reward is worth later. Today a reward arrives, earns you no credit, and quietly enlarges the base every later percentage is divided by, so earning a reward makes your reported time-weighted return go down. Two otherwise identical positions now report the same number whether or not one of them was paid a reward, and they keep reporting the same number however the reward token moves afterwards. That holds no matter when the reward was paid: what is left out is the part of the position the reward accounts for, valued like everything else at what it is worth on the day, so a reward claimed a year ago and worth ten times more now is left out at today's figure rather than at what it was worth when it landed. It also holds when you add to or sell out of the same holding part-way through the day, because the reward's share of what the position earned is measured from the moment each movement happened rather than from the end of the day. The one place the two positions can still differ slightly is a stretch we could not read at all, and any position with such a stretch already carries a coverage note saying so.

The money figures are not affected by any of this. Your portfolio value, your cumulative yield and the chart still carry every dollar the book made, a reward's own gain included, because you did make it. What changes is the percentage, which is now measured on the money you committed and on the return that money earned. A holding made up entirely of rewards has no capital for a percentage to be about, so its rate reads as a dash rather than as zero.

One consequence that runs the other way, worth stating because it looks like an exception: a liquidation still lowers the base. The equity it took is genuinely gone, and the interest the rest of the position earns is earned on what survived, so measuring it against what you held before the liquidation would report about half the rate you actually earn.

Positions in settlement: a redemption that has been instructed and not yet paid ​

Some exits do not pay out in the same breath. Ask Lido for your ETH back, start an sUSDe cooldown, or queue a Maple redemption, and the position leaves the venue immediately while the money arrives hours or weeks later. A book that saw only the two ends would show a holding that closed, then days of nothing, then a brand-new holding for roughly the same amount: sold and re-bought, with the return line taking a loss and an equal gain that never happened.

The gap itself is recorded. A queued redemption is its own position, held for as long as the queue holds it, and it reads In settlement. It is not an error state and nothing is pending in the sense of being stuck: the instruction is placed, the money is on its way, and the reader can see exactly how much and in which asset.

Three things about it are worth stating because they are visible:

  • It is valued at what will actually be paid, re-checked every time the portfolio is read. Queues occasionally settle a fraction short of what was asked for. Marking the position at the requested amount and then paying less would show a phantom withdrawal on the day the money lands and hide the real, small loss. Valuing it at the claimable amount books that shortfall where it belongs: as a small negative return on the position that took it.
  • It moves no capital. Starting a redemption is not a withdrawal and finishing one is not a deposit. The money never left the account, so the contributions line is unchanged, the return percentage is not diluted, and the "held since" date carries through the queue rather than resetting on the far side.
  • If the claim ticket can be sold, the position follows it. Lido and EtherFi hand out a transferable ticket. Selling it is a genuine disposal and reads as one; the buyer holds the position from that point, and the seller does not keep a claim they no longer own.

One coverage limit, stated rather than left to be discovered: for EtherFi's queue the chain publishes no way to list the tickets an address holds, so a ticket opened before the rebuild's history begins is invisible until the historical sweep reaches it.

What the books do not count (reported, not shown) ​

Every figure on the portfolio page can be right and the return line can still be short, because value passed through something the product does not model. A wrapper with no registry entry held the position for a month; an exit paid out in an asset we cannot price. The arithmetic behind every number around it is correct, which is exactly why the gap is invisible: nothing looks wrong, and the total does not add up.

The rebuild names those departures. Each one is a dated coverage note, and the notes are reported rather than shown. They cross on the acceptance reconciler's report, whose completeness test is what keeps them honest (a suppressed row has to be answered by either a withhold or a coverage note, and a note whose shape is not one of the five below fails the run), and they are readable one wallet at a time over GET /api/portfolio/coverage for ops. Nothing on /portfolio renders them. A band headed "What the books do not count" rendered there until 2026-09-02 and was removed by product decision: the page states the portfolio, and how far the books behind it reach is an operational question answered by reading the report.

Four things can be recorded:

notewhat it says
CN-1 rotation-residual-unattributedmoney went into a wrapper the product does not model and came back changed. The difference is counted neither as money added nor as yield earned, and the note names the wrapper.
CN-2 consideration-outside-perimetera position was bought with, or closed for, an asset with no home currency here. The position itself is valued on its own terms and that value is counted; what left is the other side of the trade, and the note carries its amount and its name.
CN-3 derived-evidence-lega holding whose movements leave no receipts, so its size is read rather than derived, and it contributes nothing to the return either way. A bare ether balance was the one such holding until issue #966 gave native ether receipts of its own (its transfers, fees and wraps), so no holding earns this note today; it stays in the vocabulary for one that might.
CN-4 excluded-book-lega position the books hold and cannot price, so it stays out of every return line while its movements stay on the record. This is also the note a holding with no base earns — a bitcoin claim, a euro balance — since the 2026-09-16 coverage rule made every one of them a stored leg.

Two details are worth stating because both were decisions:

  • A wrapper residual is stated twice, and the two figures differ. The quantity is exact: so many units of the claim, to the last decimal. The value is the sum of what each leg of the episode was worth on its own day, which across a multi-week round trip is not the same number as the quantity converted at any single price. On the case this was built against the two differ by about two dollars. Both cross the wire, and the day-weighted one is the figure that answers "how much of this return is unaccounted for".

  • An asset registered later is named, not counted. The exit case this was built against paid out in a token added to the registry six weeks afterwards. Once it is known, the note names it and states the amount. It stays outside the return: the value left coverage on the day it left, and re-booking history because a registry grew would move a figure the reader already saw.

    What decides whether something is counted is whether it has a home currency here, a denomination the portfolio can state a return in. Assets that have none are held in the registry on purpose, so they can be named and shown at market value while staying out of every return line; registering one is how it gets a name, never how it gets counted. That is the same rule CN-4 above describes, applied to the other side of a trade instead of to a holding.

One wallet at a time. A departure belongs to the wallet whose history produced it, and two wallets can produce the same kind of note, so the report is per wallet and the route refuses to merge two (?wallet=all answers an empty list rather than a merge).

"As of date": the portfolio as it stood ​

Clicking a past day on the historical performance chart shows the portfolio as it stood that day: the same tables and the same grouping, read from that day's stored snapshot. The date rides in the URL (?asof=YYYY-MM-DD), so the view reloads and shares, and a banner at the top of the page reads "Showing your portfolio as of 15 Mar 2026." beside one control, Back to Now. Only a past day is ever selectable: today is still being written on the 6h cadence, so it is the live view's subject rather than a state to read back.

The plot itself does not move. It keeps its full extent, out to the newest observation the series holds, and its range pills stay anchored on today. What it adds is a red mark on the selected day, on the total-return line and on the accrual line, at the reading the tables under it are showing. This is the same on all three tabs, the All view's value line included: the mark and the footer note that names it are resolved against the channel the plot is actually drawing (selectedDayPoint(points, day, channel)). Until v0.61 they were hard-wired to the return channel, which the All series does not carry at all, so clicking a day there moved every table on the page and left the curve blank — the one control that says where the reader is, saying nothing. The mark is named twice, because the same red also draws the liquidation rules and the two must never be confused: the plot's footer notes carry a selected date entry beside the live tip and the seizures they already name, and hovering the point itself reads Selected date where every other past point offers "Click to see this date" (with one exception, below: on the end of the line, which a deep link can put a reader on, the hover names the way back instead, because that is what its click does). And while a date is on screen the END of the line is the way back: clicking there returns the page to now, the same gesture that moves it between any two past days, on the one point that used to decline the click outright and leave Back to Now as the only exit. Its hover offers it in words, "Click to return to now", so it is discoverable rather than a hit area a reader has to guess at. The end of the line is the last day the drawn series holds, not today: the newest reading is only today's once the pipeline has written today, so a rule keyed on today leaves the gesture inert for the hours after UTC midnight and on any wallet whose refresh has stalled. A day rather than an instant because that is the granularity of the mode itself (a date resolves to that day's newest reading) and because the last day routinely carries two points, the daily grid point and the newest observation at its own instant, which sit a pixel apart. Live, that same point is an ordinary date again and reading it is what the click does: with no date on screen there is nothing to return from, and treating the end of the line as the present there would simply disable the last day of every chart. The retained-yield card is why that matters, since it renders in the live view only and its series ends on the day its book closed. So a reader looking at one day's statement still sees the whole history it sits in, and where the book went afterwards. The positions tables, the hero cards, the activity ledger and the chart card's own headline figure all follow the date; the chart's PLOT does not, keeping its full extent so the day has the history it sits in around it. The All card keeps its name under one, where it used to swap it ("Everything held on this date"): "everything you hold" was a claim about now that a past day made false, and the name of a quantity is true of whichever day is being read. The denomination card still swaps ("Current portfolio" → "Portfolio on this date") because "current" is that same claim. The banner is what states the day either way. A date the drawn range does not reach carries no mark, which is the ordinary case on a short range: the pills window on today, so a date months back is simply outside them. Nor does a date the DRAWN LINE holds no reading for, which is the same date the tables under it report nothing held on, and which the chart still lets a reader step into so the page can say so. On a multi-wallet view that line holds each wallet's last reading flat across a gap rather than breaking, so there the mark lands on the carried point (see the headline rules below, which follow the same resolution).

The chart card's headline does follow the date, in both of its modes, and it states that day's reading — the same observation the mark resolves to, so the figure and the dot on the curve are two renderings of one reading. It is not captioned, on the rule that took the live captions off this card: the banner states the day in words once, the mark points at it, the lit pill states the window, and the card carries the day structurally as data-chart-dated.

  • Historical value (All) prints the level the day closed at.

  • Historical performance (USD, ETH) prints what the book earned from the open of the range in view up to that day. Both lines are re-based to the window's first in-window reading (windowSeries), so the day's point on the total-return line already IS that amount, and the range is unchanged: the pill still lights the window, and where the measurement stops is the day the banner names.

  • A date the drawn line holds no reading for prints the dash, in the muted tone, and the plot marks nothing there either: the headline and the mark read one series through one resolver, so they are silent together. It is the same withholding the card makes when the range holds no readings, and it is what the card shows above the empty state that says the archive holds nothing for that day. A day before the account's history is always this; so is a gap day, or a day nothing was held, with one tracked wallet on screen.

    With two or more wallets selected such a day is not a hole in the line. The merged line holds each wallet's last reading flat across a gap rather than breaking, which is what stops a two-wallet total cratering between two wallets' different reading grids, so the day carries a real point: the plot draws it, the mark lands on it, and the headline states that carried level. The card's statement and the plot's agree either way, which is the property that matters; what they agree on is the line the account actually has. There is never a $0.00 standing in for a missing reading: a window the archive holds nothing for is served as an explicit null, not as a zero, and a zero on this line means a portfolio that was read and really was worth nothing.

  • The "some readings exclude a holding that could not be priced" line follows the day with the figure. Live it is a statement about the window, because the figure is; under a date it reports whether THAT reading was short, so the caveat and the number it sits under are about the same thing. It is the one line under this headline that survives, and it survives because it is a disclosure rather than a label.

  • Changing the range pill leaves the date, which is the other half of the way out and is described below.

    Until v0.67 the headline stayed on today's figure. On the All view that put two different numbers about one selected date side by side — the holdings card on the day's total, the chart card on the newest book value in the window — and on a denomination tab it stated a return measured back from today under a banner naming a past day.

The two All cards read one day, and they are still two derivations. They agree on the ordinary day, and the one shape where they legitimately differ is worth stating, because the difference IS a disclosure rather than a discrepancy: a financed position with a leg that cannot be priced takes the whole total out of the figure card (rule C5 withholds every leg of such a position together, so the card dashes and says why), while a reading short of a holding is served with the rest of the day intact and flagged. So on that day the line states what it can and the card beside it states nothing, and the amber "some readings exclude a holding that could not be priced" under the chart headline is what says which. The two cards also count different exclusion sets for the "Excludes N positions not covered" caption, the figure card from the dated LIST and the chart card from the series, which is why that caption stands down on the chart card under a date (below).

Changing the range leaves the date. Choosing a different window (1M / 3M / 6M / YTD / ALL) returns the page to now, exactly as Back to Now does. Every range is measured back from today, so the two controls ask opposite questions, and operating the range under a date would otherwise leave a reader on a past day reading a card that had just answered about the present. Pressing the window that is already active changes nothing. The range is not persisted and every load opens on ALL, so an inbound ?asof= link always draws a window the selected day sits inside.

Where the figures come from. portfolio_position_snapshots already holds a per-leg statement of every position at each aligned window (daily at 00:00 UTC for backfilled history, every 6h while a wallet is live). Day D's state is dated by that day's newest reading, out of [D 00:00, D+1 00:00), and since ledger-first (R2) its rows are the positions the movement ledger held at that reading's block: each at the newer of its last reading and its last movement at or before that block, valued at read like every other figure. So a position that reading happened to miss is still listed, at the ledger's quantity and its last reading's prices, and one the ledger says was closed is gone whatever an older reading said. The statement is a checkpoint: a position moved LATER that day, after its last reading, shows the reading's quantity (the movement is on the day's activity statement, and on the next day's positions), and its entered basis stops at the same block. That definition is what makes the page one statement rather than several, because the chart's daily point for D carries the same observation (the 1d grid reduces a UTC day to its last reading). Read at the day's START instead, the tables would disagree with the point the reader clicked: a position opened at 10:00 on D would be in the chart point and the activity feed and missing from the holdings, and the headline earned figure (end of D) would sit above per-position cells from the start of D. One bound, D+1 00:00 exclusive, frames the read everywhere: the snapshot read, the flow ledger, and the events and activity ledgers; within it, the positions (and their entered basis) stop at the day's last reading. A day holds one reading on the backfilled grid and up to four on the live one, and the chart's mark sits on the newest of them, which is the same observation the tables are read at. Everything downstream is unchanged, the same view classification (M22), the same per-leg attribution, the same display model, over rows that bound.

A day with nothing on it is not a zero. Three empty states, told apart by the wallet's own history floor (a rolling 30 days before it was added, never below 2026-01-01):

  • before the floor — we do not have the reader's history that far back, and the page says when it starts;
  • inside the tracked window — the day is covered and nothing is recorded as held, which is stated as such;
  • floor unknown — neither can be claimed, so the page says only that we do not have the history for that date. It never asserts what the reader held.

None is an error, and none invents a figure. Nothing is carried forward from an earlier day: a position closed at 20:00 the evening before leaves the next day with no reading of its own, the chart correctly draws a hole there, and the tables agree with it instead of re-opening the position at its last known value. The resilience that costs nothing is kept, because a day's own 06:00 / 12:00 / 18:00 readings still resolve it when its midnight write was missed. Multi-wallet accounts read the union of each tracked wallet's own day, so a wallet added later still contributes the backfilled rows covering it and a wallet that held nothing then contributes nothing. The chart stays on screen above an empty day, with its live extent intact: stepping to a neighbouring date is the reader's likeliest next move, it is the control that does it, and a chart re-cut to a day the archive holds nothing for would be no use for that. So it keeps drawing the book's whole history, and its headline states that day's reading, which on a day the drawn line holds no reading for is the dash. What it never does is state a figure for a range with no readings: a total return is a difference between two readings, and a range with none has not earned zero, it has earned nothing that we know of. That rule is the chart's own, not the mode's, and it is why a range this book holds nothing in withholds the figure whether or not a date is pinned.

An empty VIEW is not an empty day. The three states above are the whole account holding nothing that day. A day one denomination held nothing on, while the account held plenty in another, is a different statement and gets the ordinary screen: the summary card reports the day (a value of zero over zero positions, in that view's own unit), the performance chart keeps its live extent with the date marked on it, and the Positions face says which view is empty. It is the same reasoning that keeps the chart above an empty day, applied one level down — the reader arrived by clicking a day on that chart, and taking the chart away with the holdings would leave "Back to Now" as the only way out of a date whose likeliest fix is the neighbouring one. The zero is what the archive holds for that day, not the dash the page prints where something could not be read, and it is never shown before every selected wallet's response for the date has landed.

That figure inherits one limit of the archive, and it is worth stating plainly rather than leaving implied by the paragraph above. A book's absence at the day's reading is read as "no position existed there" — that is the spine's own rule, and every surface on this page follows it. Two things are written that way without the position having gone anywhere, and neither leaves a trace the read path can recognise afterwards: an incomplete window, where a transient venue read failed and persisted a snapshot missing that venue's legs while its siblings landed at the same bar, and an M9 skip, where a single leg the snapshot could not value (an unresolvable share rate, a PT with no index) is dropped from the write rather than published at a made-up figure. Both are the pipeline's, and under either a past day reports as empty in the affected book. The promotion that re-reads such a wallet heals it in one tick going forward; the historical bar stays short. The Positions face has always stated that absence in prose, and the summary card now states it as a figure; neither can be more right than the archive under it, and separating the two cases would need the write to record which books a reading actually covered.

The two panels a live view puts in that slot when it holds nothing — the retained-yield card and the archive-building state — stay off a past date, because each answers "what is open now", which is the class of claim this mode does not make.

What the mode deliberately does not show (product decision, 2026-08-12). Each of these is a statement about NOW, and there is no stored historical equivalent to put in its place, so each is absent rather than dashed or approximated:

Not shownWhy
The leverage ceiling, the distance to liquidation and the LTV gaugeA venue's borrow cap and liquidation threshold are read live and are only known as of today. Beside a past position they would state a buffer the holder never had. The risk read is not even made.
Every advertised-rate column (24h APY), and the figures derived from them: Net APY, projected daily income, 24h accruals, the leverage simulator's projected rateAn advertised rate is a quote from now; no stored series reconstructs what every venue quoted on a past day.
The repo-lending collateral exposure and utilization columnsSame reason: they describe the market as it is today, not as it was.
The ≈ $ dollar equivalents under native-unit figures, on the denomination tabsThe price mirror serves the LATEST price and no history, so annotating a March ether balance with it would print a dollar figure that was never true on that date. The price is not fetched at all. The All view's dollar values survive a past day: they are the server's own per-reading conversions rather than a live rate, so each one was struck at that day's own bar.
The freshness stamp, the synchronize control, the archive-building statesThere is no "now" in a past day. The performance chart's PLOT is the exception, and deliberately so: it keeps its full extent, out to the newest observation it holds, because the chart is the whole history rather than the day's statement, and it is what puts the date in context. Its headline figure is not an exception and states the day, like everything else on the page.
The chart card's "Excludes N positions not covered" captionThe count rides the value series, which is the whole history counted as of today; the day on screen is not. The figure card beside it counts what the DATED list leaves out and keeps the sentence, so the screen makes one statement about the day rather than two that can differ by a borrowing taken or repaid since.
Adding, renaming or untracking a walletThe mode is read-only. Choosing WHICH tracked wallets are on screen is a way of looking and stays available.

What each view DOES keep is what that day actually held: its positions and their values, what each had earned by then, and the activity ledger up to and including it, under a performance chart that still runs to its newest observation, with the date marked on it. A carry's leverage and LTV survive only where the position is the liquidation unit (a Fluid vault, a Morpho market), because there they are ratios of its own two legs at the marks that day was stored with. On an account-scoped venue (Aave, SparkLend) the same figures come from the venue's blended account read, which exists only as of now, so they are withheld rather than reproduced under a date they were never true on.

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).

RouteReturns
GET /api/portfolio/summaryPer-VIEW headline in both marks, backfill status, the wedge, current state. books carries one entry per view (ALL, USD, ETH), each with present; the two denomination views carry their figures directly, in their own unit, while ALL carries present AND NOTHING ELSE. That view's number is a VALUE at a moment rather than a quantity accumulated over one, and the wire already publishes it as the newest point of history?book=ALL; a second total here, computed over a different row set (this response is built from the newest stored reading, while the page also lists the bare balances that are read live and have no stored row), would be a second published answer to one question that could honestly differ from the first. 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. A block is {cumulativeYield, bookValue, realizedLoss} and nothing else: three AMOUNTS in that unit, no percentage. A realizedReturn and a twr sat beside them until both were removed (see the portfolio level states an absolute return); no shipped client bundle ever read either key, so a bundle from the previous release renders exactly what it renders now, and a stale entry in the browser's first-paint cache simply carries two keys nothing looks at until the next fetch replaces it. 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). The phase is a PIPELINE fact, not a screen: the chart panel renders one building state for all four (a phase with no cursor simply shows no percent and no estimate), and never presents a wallet as waiting. Read fresh each call, never through a cache keyed on the table MAXes (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).
readAt and readBlock are the "Updated" stamp (ledger-first R11), never the request's time: readBlock is the block up to which the wallet's movement records are derived (settled and provisional alike: the derive cursor, the 6h tick's cursor, the wallet's newest finished derivation job and its stored live tip, whichever reaches highest, with the tick's cursor and a page load's own records (its stored live tip, and once the worker owns derivation its finished sync job) counted only below the lowest block the wallet still owes (its pending marker), and nothing at all until the wallet's whole history is derived) and readAt is that block's time in epoch ms. For a wallet the continuous producer follows, the block is also carried by the producer's own cursor (how far the ingester has turned the chain into derivation work, every cycle): a wallet it made no work for had nothing it can see to derive, and one it did is held below that work until it is done (PR #959 review SF-2); so the stamp trails the chain by about one ingester cycle, and by up to one 6h tick for a wallet not followed yet. A movement the producer cannot tie to the wallet (a routed operate on a newly opened position, a close no log names the wallet in, a stream ingesting behind the rolled-out tip) reaches the records at the next tick or page load, and the stamp can read later than it until then; so can ether a contract pays the wallet inside a transaction, which no block states (the next reading's audit finds it; a plain ether transfer and every network fee are read from each block the ingester scans, issue #966). Nothing else runs ahead: a derivation job that stops short of its range (the ingested tip fell under it, or its merge could not cover the range) records the rest as owed and holds the stamp below its range until the wallet's own records (the 6h tick's cursor, a later finished job, a stored live tip) reach the job's top (PR #959 review rounds 2 and 3, B1 and B2); and a stretch the wallet still owes (the 6h tick marks one when its derivation for the wallet fails, and moves on) holds the stamp below it however far later page loads read, until a derivation starting at or below it (the next tick, the wallet's own continuous job) has recorded it (review round 4, B3). Where that block has no stored time (the chain's header store keeps every block that produced a tracked log and every scan tip), the pair names the newest block below it that has one, taking a stored reading's block time (its snapshot label on a row written before the spine carried one) where the header store has none; so the stamp can read slightly older than the records and never newer. Both are null for a wallet whose history is still being built. jit describes the READING the figures come from: true exactly when the newest reading is the wallet's off-grid live tip (the JIT / live path in Data pipeline) and false when it is a scheduled checkpoint, so it stays true for as long as the tip stands rather than for the duration of a request. The freshness stamp on the page is readAt and nothing else, and no row carries a date of its own. In aggregate mode the pair names the STALEST wallet's stamp (the oldest readAt, with that same wallet's block beside it): it answers "how old is the oldest thing in this response", and it is NOT a block the account as a whole was derived to; a consumer that needs a per-wallet block reads each wallet's own response. A wallet with nothing on screen (hasSnapshots: false) contributes nothing; a wallet that serves rows with no stamp (hasSnapshots: true, readAt: null: its readings are served, its history not yet derived) makes the pair null, because the account holds figures nothing dates (PR #959 review SF-1). Takes no asof: everything here is about now, so there is no dated version to serve and the as-of view reads /positions instead; a date sent here is a 400 rather than an ignored param that would serve today's headline under a past date.
GET /api/portfolio/positions[?asof=YYYY-MM-DD]Per-VIEW position rows (both marks, earned-vs-advertised) + the M1-excluded (outside) groups with their reason, which the All view's last band renders (value only; nothing from them enters a BOOK). One bucket per view, holding the rows that view renders TODAY; each row keeps its own book denomination, which equals the bucket for the two denomination views and need not for ALL, whose rows span denominations (an ETH collateral row and a USD debt row in one bucket). A financed position lists under ALL only, never also under the denomination it used to chart in, and its bare debt leg is no longer in outside. The ALL bucket also carries every holding no currency view charts, on any venue: those rows are charted: false and keep their own served category, which is what routes them under a band of their own — Idle assets or Variable rate assets for a bare balance, and since the 2026-09-18 taxonomy repo_lending for a supply with nothing borrowed against it (a bitcoin or gold collateral on Morpho, Aave or SparkLend) and smart_repo_lending for both legs of a debt-free Fluid pool pair — rather than into the financed grouping, and it is why they appear in NEITHER denomination bucket (2026-09-16: base decides the view). A charted: false row's book is the UNIT ITS FIGURES ARE IN, not a claim about the view: a leg that settles in no currency book serves book: "USD", its valueMarket being dollars already, and a leg that HAS one keeps it — the ether leg of a pool pair spanning two currencies serves book: "ETH" with valueMarket in ether beside a dollar leg of the same pair. So valueUsd is the only field a consumer may sum across this bucket, on an uncharted row exactly as on a charted one. And no uncharted row states a RETURN, which on this wire is null and never 0, on both cumulative-yield fields and on every row of this population, whatever the leg settles in. The two cases behind it differ and the answer does not: a leg that HAS a currency book (the ether leg of a pool spanning two books) states a return this product does report, merely not measured here, no curve carrying such a pair; a leg that settles in NO currency (a bitcoin, gold or equity-token claim) states one this product does not report at all, there being no unit for it. Neither is the sentence a 0 would carry, which is that the holding earned nothing: a gold supply at a venue does earn its holder something, and the product's silence about it is not a measurement of zero. This was served as a structural 0 for the second case until 2026-09-18, and the All view printed it as "$0.00" in a Yield earned column beside measured figures. A consumer summing the field inherits the unknown rather than dropping the term. And neither does a CHARTED leg that settles in no currency, which since the 2026-09-18 taxonomy is every bitcoin, gold or equity-token collateral inside a cross-currency borrowing: the books keep such a leg out of every return line by construction, so the figure it would otherwise serve is a structural 0 rather than a measurement, and both cumulative fields are null on it too. That is the same sentence the band it lands in makes on screen (a value and a net, no rate and no return), and it is what stops an agent summing the field across the ALL bucket from booking a measured zero for each of them.
valueUsd is on every row and every outside leg (optional and additive; absent reads as null): the same holding as valueMarket, converted to dollars at the price mirror's bar covering the reading this row was read at, which is what lets the All view add a leg in one currency to a leg in another. It equals valueMarket on a dollar leg and on an EXCLUDED one (valued market-only in dollars by construction). NULL is M9's "not stated" — no bar covers that reading — and never 0. asof serves the same shape as of a past day (see "As of date"): the rows are the legs the movement ledger held at that day's newest stored reading (out of [D 00:00, D+1 00:00), the one bound this mode uses everywhere), each at the newer of its last reading and its last movement at or before that reading's block, valued at read like every other figure, through the identical grouping and per-leg attribution. The statement is that reading, a CHECKPOINT: a position moved later that day shows the reading's quantity, and its entered basis stops at the same block. Validated strictly — a value that is not a PAST calendar day (today included, since today is still being written) is a 400, never an ignored param that would serve today's portfolio to a caller who asked for a past one. The response then carries asOf: {day, observedTs, trackedSince}: observedTs is the observation the state was actually read at (epoch ms, null when the day has none of its own, never rounded to the requested midnight; it is the day's NEWEST reading, the same one the chart's daily point carries) and trackedSince is the account's history floor, which is what lets a client tell "held nothing that day" from "we do not have your history that far back". One live-only input is DROPPED rather than dated: the advertised venue rates (quotedApy is null throughout). There is no second one since the 2026-09-16 coverage rule — the bare balances that used to be read live now have stored rows like every other holding, so an as-of read answers for them off the archive. Absent, the response is byte-identical to what it has always been, with no asOf key at all.
A row's two cumulative-yield fields are number | null, alongside the three fields on it that already were (valueMarket, valueRedemption, basisEntered): null is M9's "not measured" and a client must not read the magnitude without checking it, because 0 in that slot is the claim that the position earned nothing. It is the same withhold the two realized-rate fields beside them have always carried, and the population that produces it is a whole wallet at a time (a history not proven complete, above). Every total a client builds from these fields has to inherit the unknown rather than drop the term.
matured is present on a row holding a Pendle PT past its maturity date ({maturityTs, variant, note}, variant being plain or loop), and null or absent on every other row. It is OPTIONAL and additive, so a client written before it reads the same rows it always did. note is the caption itself rather than a code the client translates, because the holdings row and the Activity line must not be two spellings of one fact. It is a read-time annotation and changes no other field on the row.
readAt is the block time of the reading these rows are dated by (epoch ms, null when the wallet has none): the newest reading on a live read, the day's own on an asof read (where it is the dated reading's own instant, asOf.observedTs); in aggregate mode it is likewise the stalest wallet's. It is NOT the page's "Updated" stamp since ledger-first R11: that is SummaryResponse.readAt, how far the wallet's movement records reach.
GET /api/portfolio/history?book=ALL|USD|ETH[&bucket=6h|1d]Two shapes under one type, decided by book. A DENOMINATION view (USD, ETH) serves the chart series: both performance lines (native units, gaps as null), flow markers, liquidation markers. cumulativeYield is TOTAL RETURN (M23) and accrual is the accrual line (M24) — the wire key was deliberately NOT renamed, so a client from the previous release keeps drawing a curve instead of an empty chart. book/unit/bucket validated strictly (400 on unknown); the mark REQUEST param is accepted and ignored (absent, market, redemption and a typo all serve the same response), so a client mid-rollout never takes a 400 on every chart it draws. It is not the same thing as the mark FIELD in the response, described below. book names the VIEW, and NO view takes a unit param: a denomination view IS its own unit and ALL reports in dollars, so one sent beside either is a 400 rather than a silently ignored param. book=ALL serves a VALUE HISTORY and not a performance series: bookValue in dollars at every point (every holding the movement ledger held at that reading, ledger-first R2: the reading's own rows, plus a holding that reading missed at its last reading's value, and never a reading of a holding the ledger says was gone; each converted at the mirror bar covering the reading it was valued at, debts subtracted), both performance channels NULL at every point, flows empty, mark and markerMark both market, unit USD. A point that could not state one of the holdings that reading held carries incomplete: true and reports the rest — never a fabricated zero, which on a value line would draw a dip that never happened. excluded rides that shape: how many Not covered positions the line leaves out — the borrowings this product cannot value, which are listed on the page and counted into neither the headline nor this series — so the chart card can caption them without a second fetch. 0 says nothing is excluded; ABSENT means a denomination series, which excludes nothing, or a server from before the field, and a client must read absent as "say nothing" rather than as zero. It is a TIP count under every bucket and every range, this route taking no date (below), and a client merging several wallets' series adds the counts. Every liquidation is flagged there with loss: null: the cost of a seizure is a return statement, and this view states none; the line drops through the event on its own. 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. On BOTH widths the newest observation is served at its own timestamp (flagged live only when it is the wallet's off-grid live tip, a STORED reading served at its own instant rather than an in-memory one), so the two widths agree at every shared timestamp and end on the same point. The mark FIELD in the RESPONSE (not the request param above) names the basis of the POINT values (redemption, the basis the accrual curve and bookValue are built in); markerMark names the basis the flow and liquidation markers are valued on (market, what a capital move was worth when it happened). Each liquidation marker's loss is **`number
GET /api/portfolio/events?limit=&offset=[&asof=YYYY-MM-DD]The paged flow ledger, most recent first. asof cuts it off at the END of that day (inclusive of the day, exclusive of the next midnight), applied to the COUNT and to the page alike so paging stays consistent with the total the caller was told and cannot walk past the cutoff; the response echoes it as asOf. Unlike limit/offset, which are clamped, an unparseable date is a 400: there is no sane value to clamp a date to, and serving the whole ledger to a caller who asked for one day's would be a different answer in the same shape.
GET /api/portfolio/activity[?cursor=][&position=][&venue=][&action=][&asof=][&costs=1][&adjustments=1]The same ledger read as a statement, backing the Activity face: {address, entries[], cursor, facets?, suppressed?, foldedAdjustments?, filters, maturities?}. An entry is the unit the feed draws as a line — a link GROUP where the group means something to a reader (a liquidation, a redemption, a wrapper round trip, a trade between two of the holder's own covered holdings, a movement between the holder's own wallets) and a transaction everywhere else — carrying its own rows[] in execution order — every leg, including the wallet movements the feed folds into the venue movement that carried them, so a caller reconciling against the chain sees what the chain did — the count of transactions it stands for, its span, a confirming flag where a movement is still settling, and per-mark values over the movements the action is actually about. Newest first, 30 entries to a page, keyset-paged on the (block_number, tx_hash) of each entry's newest row, so a page boundary can never fall inside a group. The action is what the entry MEANT, classified from its rows' (venue, kind) pairs and its group kind alone: deposit, withdrawal, borrow, repay, open_carry, close_carry, swap, transfer_in, transfer_out, liquidation, reward, cost, rotation, escrow_out, escrow_settled, cross_wallet, and other for a shape that matches no pattern (which keeps every row and names none of them wrongly). An entry's value is its action's own movements, not all of them: a vault deposit paid out of a tracked wallet balance records the same money twice (the deposit and the funding transfer), and adding both would state double what moved. The per-mark values are null whenever one figure would not mean anything: a missing mark, rows spanning books, rows pointing in opposite directions, the same capital recorded on both sides, or a settled row beside a confirming one; a liquidation values the seized collateral alone. cursor is opaque and round-tripped verbatim; position / venue / action are validated strictly (400 on unknown, never a silently ignored filter), and there is no leg-level filter on the wire at all — the ledger's own row vocabulary is not something this API asks a caller to know. position and venue narrow which of an entry's MOVEMENTS a line carries, but never what STATE a group is in: a redemption that has been paid reads as settled whichever leg the reader filters to, because a filter cannot move money. Where a filter hides the movements a line's figure is drawn from, the figure is withheld with that stated as the reason rather than replaced by whatever the slice does hold. action selects whole entries by what they meant, so an opened carry answers to open_carry and never to borrow. facets (the venues, the actions and the venue/action COMBINATIONS this wallet's ledger holds, so neither filter pill can offer a choice that returns nothing) rides the first page ONLY, and only when no venue/action is set. asof=YYYY-MM-DD applies the same day cutoff as /events, to the page AND to the facets beside it, so a pill can never offer a filter whose only matches are after the date on screen; it is the one cutoff that DOES narrow a group's state, because an as-of read is what the holder had done by that day and a claim paid the following week has not happened yet. costs=1 asks for the entries the feed holds back by default (network fees, and unsolicited dust); suppressed is how many there are, counted through the same filters the page is under so the control beside the feed can never name a number it cannot reveal, and any other spelling of the parameter means no. adjustments=1 opens the one summary line the ACCEPTED balance adjustments fold into (ledger-first R5c, as amended 2026-09-26: an unexplained adjustment is always its own entry; an accepted one, a cause the product knows and could not look up for that stretch (since issue #966, native ether over blocks its sources could not answer for, which is now rare), is held back by default and counted in foldedAdjustments, first page only, through the same filters, and ABSENT when nothing is folded, so a wallet with no accepted adjustment is served exactly what it was served before). It opens independently of costs=1, only the spelling 1 turns it on, and filters.includeAdjustments is present (and true) only when it was asked for. A folded adjustment offers no filter pill, as a held-back fee offers none. Single wallet only: ?wallet=all is a 400, not a merge, because one transaction can touch two tracked wallets and an aggregate would either double it or drop a wallet's rows from it; that also keeps every read a wallet = equality, which is what the ledger's HASH (wallet) partitioning prunes on.
maturities is one entry per Pendle PT the wallet still holds past its maturity ({positionKey, label, venue, ts, variant, note}, ts in epoch ms), derived at read time from the position state rather than from the ledger. It sits BESIDE entries rather than inside them — a maturity has no transaction, no block, no amount and no direction, and everything this route computes is computed from ledger rows, so a line that is not a row reaches none of it. It rides the first page only (paging must not spend a wallet-state read to resend an answer the client holds) and is withheld entirely under ?action= (the caller asked for transactions of one kind, and a maturity is not an action anybody took); position and venue narrow it exactly as they narrow the feed. Absent when there is nothing to say. A wallet-state read that fails costs the lines and never the statement.
GET /api/portfolio/riskPer-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/coverage[?wallet=<addr>]What the books do not count for ONE wallet: {address, notes[]}, backing the coverage report. Ops only: no page reads this route, and there is no band on /portfolio to render it (removed 2026-09-02). Each note carries its shape, the group or leg it is about, the block and time it is dated at, and the figures its shape has: the wrapper residual crosses as BOTH an exact decimal string in the claim's own units (rootQuantity) and the day-weighted book figure (bookResidual), which are different numbers across a multi-week episode; an untracked consideration carries the asset, its direction (exit
GET /api/portfolio/pricesThe latest USD price of each native book unit, {eth, asOf} where eth is {usd, barTs, provenance?} or null. Backs the ETH ≈ $X display annotation and nothing else. Two sources, one shape. It serves the LIVE vendor level where R6's band can be applied to one (Live prices) and the newest stored bar where it cannot. barTs is the vintage either way: the bar's own hour, or the SERVING VENDOR'S own observation stamp, and never the moment of the request — the backup vendor's gate accepts a quote up to six hours old, and the band hands it the win precisely on a moving day, so dating a served level "now" would print the freshest label over the stalest number this route can produce. A level whose vendor states no observation time falls back to the bar rather than to the clock. provenance says what CHECKED the price — corroborated (a second vendor, within 1%), checked-against-stored (the asset's own most recent stored price, which is all the ordinary in-band verdict consults), unchecked (no reference of any kind existed) or stored-bar — so the disclosure beside the figure makes the claim the price earned and not the strongest of the four. A level the band WITHHOLDS is served as null rather than falling back to the bar: the band refused it because two vendors and the tape disagree about what an ether is worth, and showing the tape's answer as if nothing were wrong is the one thing that refusal exists to prevent. provenance is optional, so a bundle from before it existed reads undefined and renders the stored-price wording for the length of a rollout, which understates a live price rather than over-claiming for it. A btc key sat beside it until the BTC book was retired and was removed with the annotation that read it; a client bundle from the previous release reads prices?.btc ?? null and simply renders no annotation. 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). The route holds ONE in-flight read for a minute rather than one finished body: it awaits two vendor calls now, so concurrent loads on a cold minute would otherwise each open their own pair for the one number they all want. A failed read is dropped rather than remembered, so the next request retries instead of serving a minute of nothing. 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[?wallet=<addr>|all][&force=1]Triggers the wallet's JIT live read ("verify now", ledger-first R11: a fresh reading, the wallet's movement records derived up to its block, and the stored reading's audit job) and returns {refreshed, readAt, cooldownUntil, skippedReason?}. refreshed says whether this call's refresh ran and stored its fresh reading (or served the recompose fast path, which is never stored and names no reason); it is false on every other outcome: the request bound fired, the read failed, the call joined another caller's computation, or it stored no reading for a named reason, which then rides beside it (PR #959 review SF-3). readAt is the wallet's "Updated" stamp once the call is done (the block time up to which its movements are derived, the same value a re-fetched summary states, epoch ms; null where there is none), which is what lets a client ask "is the server ahead of the card I show" by comparing the two. cooldownUntil is when the wallet may be read again. skippedReason names why no reading was stored, always beside refreshed: false, and every value means "the stored reading stands", never "something is broken": cooldown (read inside its window; the cached result stands), nothing-newer (ingestion has not passed the stored reading's block, or a concurrent refresh stored one at or above it), venue-failed (a venue read threw, so the reading is not a complete picture and is not stored), merge-skipped / merge-failed / merge-partial (the derivation up to the reading's block did not run, failed, or could not state it read its range), and, once the ledger worker owns derivation, derivation-pending (the top-priority sync job did not finish inside the 20 s wait, SYNC_AWAIT_MS, or what was left of the request's bound: the page keeps the last derived state and the job's rows land for the next refresh). It is absent when the call stored a reading. On nothing-newer, venue-failed and merge-partial the refresh's own ledger merge still ran, so movements it committed are served by the GETs although no reading was stored, and the dashboard re-fetches on those three as it does on refreshed; on the other four only a newer readAt is worth a re-fetch. For ?wallet=all the fields ride together as: any wallet refreshed, the newest stamp, the latest window, and a reason only where the wallets agree on one. Rate-limited per wallet, server-side (five minutes, PORTFOLIO_LIVE_COOLDOWN_MS) and coalesced, so a reload storm cannot fan out RPC load; the request itself is bounded at 30s, per wallet rather than around the set. ?force=1 (the Synchronize button) asks for a FULL read of every venue instead of the recompose fast path; it does not spend the cooldown, and a spelling the route does not recognise falls back to the fast path rather than 400-ing a client mid-rollout. The GETs then serve the reading and the flow rows it persisted on the client's re-fetch. readAt here is still not what the page's stamp renders: for an aggregate it is the newest stamp among the wallets refreshed (what the call moved), while the page's stamp is the age of what is shown (the oldest among the wallets on screen) and is read off the summaries.

One route the dashboard reads is not under /api/portfolio and is not authenticated, because what it serves is not account data:

RouteReturns
GET /api/repo-marketsThe repo-lending market universe with the two columns the Repo lending section needs: {markets: [{id, protocol, asset, morphoMarketId, utilization, collateral: {total, top[]} | null}], asOf}. id is the market's /repo-lending row id, which is also its deep link (?market=<id>). utilization is the deployed share of deposits as a fraction (deposits minus drawable cash, over deposits); collateral.top is the three largest exposures by current USD value, descending, and collateral.total counts every real collateral behind the book (the synthetic Unattributed scan tail is never one), so the row's +N more is total − 3. Both come from the same tables /repo-lending publishes (market_collateral_exposure plus the per-venue index tables), read by src/lib/data/repo-market-context.ts — one latest row per market, deliberately NOT getMoneyMarketRates(), which answers the screener's question and pays for full per-market history scans to do it. Every wallet supplying USDC on Aave v3 lends into the one market, so there is nothing here to scope to a session: the response is one fixed document, edge-cached 30m fresh / 1h stale-while-revalidate, per-IP rate limited, and fetched once per dashboard mount rather than per wallet. The client matches its own rows to it in src/lib/portfolio/repo-markets.ts (pooled venues by protocol + deposit asset, isolated Morpho markets by morphoMarketId, an fToken by its underlying); a row that matches nothing dashes those two columns.

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 dips while BOTH performance lines stay flat. The dip is honest — that snapshot genuinely did not contain the leg — and the flat lines are the whole point: before this rule the same gap booked the leg's entire principal as a permanent loss. Book value recovers the moment the leg is read again, and each line picks up the gap's genuine movement 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.

This machinery now guards every leg, not a minority of them. On the accrual line an index leg is immune by construction (its yield is a quantity-agnostic index ratio), so only the value-series accruals ever needed bridging. The total-return line values every leg from its own price series, so the same bridging is load-bearing for the whole book: Aave, SparkLend, Morpho and Fluid supply and debt included.

Which day a movement lands on ​

A deposit, withdrawal or borrow is placed on the chart by the block it happened in, matched against the blocks the two snapshots either side of it were read at. The date printed beside a snapshot is the 6h window it is filed under, and the read that filled that window can run up to about fifty minutes later, so the two are not interchangeable: for those fifty minutes a movement has already happened as far as the reported balance is concerned, while the clock still says it belongs to the next window. Matching on the block is what puts it on the right side of that boundary.

For a deposit, withdrawal or borrow in the middle of a history, this changes only which day it is counted on. Such a movement used to produce a pair of equal and opposite jumps on the performance lines, a gain the day before the money moved and a matching loss the day after. Those disappear. Because the two cancelled each other out, the dollar total over the whole history was already right and stays the same number, and amounts, markers and the statement of account do not move at all.

The percentage return figures do move slightly, even in the middle of a history. A percentage return is compounded period by period on the money at work in each of them, so a phantom gain in one period and an equal phantom loss in the next do not cancel the way two dollar amounts do: the two are measured against different balances, because the movement itself sits between them. Removing the pair therefore nudges the return percentage and the annualised figure beside it. The effect is small and can fall either way, depending on which direction the money moved and on how the position performed in the period straight after.

Two kinds of movement have no neighbouring day for that cancellation to happen in, and for those the dollar total changes as well:

  • The movement that opened a position. There is no day before it. An opening that landed inside that window was not recognised as an opening, so the chart began at the first reading after it and the whole opening deposit was drawn as a loss. It now opens the chart instead, and that loss is gone from every figure over the position's life.
  • The most recent movement. There is no day after it yet. A movement inside the window at the newest reading was left out of the calculation altogether, so its full amount stood in the latest step as yield the position had not earned. It is now counted, so the freshest "yield earned" figure is right immediately rather than correcting itself when the next reading lands. This covers a liquidation at the newest reading too: one that happened inside that window was not counted either, so the realised loss it caused read as zero until the next reading landed. It is now booked straight away.

A liquidation anywhere in a history is a third case, and its dollar totals change too. A liquidation is not a movement of the reader's own money, so the day it lands on is set aside: the drop in value it caused is counted once, as a realised loss, instead of also being charged again as a fall in the position's value. Moving the liquidation to the day it really happened moves which day is set aside, so it is not only the day that changes. The size of the realised loss is unaffected, and so is the running total of realised losses. What changes is that the same seizure is no longer charged a second time against the neighbouring day's performance, which for a liquidation inside that window is a real and often large correction to the performance lines over the whole history.

One note on where an opening point is drawn. Its date is set just before the position's first reading, which for an opening inside that window is up to about fifty minutes earlier than the transaction, and on a daily chart can fall on the previous calendar day. The point is the opening and the value on it is the money that opened the position; the date beside it is that first reading, not the moment the transaction was mined.

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 / 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 context assembly (src/lib/portfolio/ledger-v2-api.ts) runs a handful of DB queries, including the wallet's entire snapshot history and its whole receipt ledger (neither is LIMITed), and those two are read fresh on every call: nothing caches a wallet's own rows. What the in-memory primitives in src/lib/portfolio/context-cache.ts take out of the burst is everything around them. 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 caches — 60s TTL (TtlCache). The wallet-independent loads (read-registries.ts: Fluid state, the token registry, the PT markets and each market's PT factor series; the ledger gate's reserve registries and rolled-out streams) 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 held-by-anyone rule (M34). 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 without the cache. The cron / backfill import the loaders directly, not this cache, so they always read fresh.
  • Per-wallet readiness cache — 15s TTL, 500-entry LRU (WalletInputCache, in ledger-v2-gate.ts). Caches whether each wallet's rebuilt history is certified complete, keyed by lower-cased wallet. A certificate has no cheap freshness key, so the gate string is constant and the TTL is the staleness bound, and that staleness resolves in the withholding direction: a wallet that has just certified waits at most one poll.

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.

RouteReturns
GET /api/portfolio/walletsThe 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. In THIS response, a day is null if 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. The merged series therefore carries the null on the wire exactly as a single wallet's does. (The DASHBOARD does not read this endpoint for a multi-wallet view: it fetches each selected wallet's own history and merges client-side, where each wallet's last real reading is held flat while it reports nothing and a wallet contributes nothing before its own series starts. So the on-screen aggregate is a sum of carried levels, never a sum that quietly drops a wallet, and it needs no gap of its own to bridge. The rule here governs what an API consumer asking for ?wallet=all is told.)
  • 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.
  • No return percentage is aggregated, because none is published. The account level reports the same amounts a single wallet does and no ratio of them (see the portfolio level states an absolute return). A capital-weighted realized return and a TWR rebuilt on the merged series were computed here until both were removed; each category's realized APY, which is a rate on a roll-up rather than a portfolio return, is unaffected and is still linked on the merged series.
  • 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 page holds the portfolio behind the building state while that wallet has nothing drawable of its own. trackedSince is the earliest wallet's, so the footer never overstates coverage. On the SUMMARY that is the earliest account anchor; on history it 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, and they never merge an unpersisted live reading into a response either: they serve stored rows and nothing else. jit is therefore a fact about the ROWS rather than about the request: it is true exactly when the newest stored reading is the wallet's off-grid live tip, and false when it is a scheduled checkpoint. What a refresh leaves behind is that reading and its ledger merge, both persisted, so the freshness reaches the reader through the database on the next fetch rather than through an in-memory tip. That split is what keeps first paint at DB-read latency instead of RPC latency.

refreshed: false means this call stored no fresh reading, and none of the situations behind it is a failure: the read did not land inside the request bound (the pipeline routinely runs longer than 30s), it failed, the server declined it because the wallet was read moments ago (skippedReason: "cooldown", with cooldownUntil saying when it frees), or the refresh ran and stored no reading for the reason skippedReason names (nothing newer to read, a venue that failed, the merge falling short). On the last three of those reasons its ledger merge still ran, so the page re-fetches on them as it does on a landing. On the first, the computation keeps running server-side and caches its result (freshness is stamped at completion, so a slow run still lands with a full window of servable life), so the next call for that wallet, from this page's Synchronize button or another tab or the next mount, either joins the computation still in flight or reads the now-warm cache. The dashboard itself does not re-POST: it had already fetched the stored view before the refresh went out, so nothing on screen is waiting on the answer. What no client may do on false is treat it as a landing: nothing new was stored, so a stamp advanced on it would claim a reading that was never taken. The manual re-sync does still re-fetch the GETs on any 200, because a six-hourly checkpoint may have been written while the page sat open, and the stamp then simply follows whatever reading those responses carry.

Every route answers 401 when signed out. The server layer (src/lib/portfolio/ledger-v2-loader.ts) reads the stored rows into the shapes the pure engine (src/lib/portfolio/v2/) takes, so the API numbers can never disagree with the methodology the tests pin (M2/M10). The leg and chart conventions both the reader and the aggregate depend on live in src/lib/portfolio/assemble.ts, once.

Frontend ​

  • Page: src/app/portfolio/page.tsx — a prerendered (static) public shell. It is deliberately indexable 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 redesigned PortfolioDashboard full-bleed (its own header bar over a scrolling body); a short skeleton covers the /api/auth/me probe.
  • Dashboard: src/components/portfolio/PortfolioDashboard.tsx — the whole signed-in view (header controls, wallets dropdown, tracked-value rail, chart panel, the combined Holdings panel + eight category sections + the carry row-expand, plus AllRail / AllSections for the All view), reusing PortfolioChart.tsx (Recharts 3) for the hero chart — in its one-line series="value" mode for All — and AssetGlyph (carries/PositionCell) for token marks. The VIEW (which tab) and the UNIT (which denomination) are separate pieces of state: they agree for the two denomination views, and All names no unit (it reports in dollars), so unit holds the last denomination view for the controls that are per-denomination (the minimum-value floor, the USD price fetch). Its pure view-model — signed-in-model.ts (M1 grouping, carry merge, crossAssetGroups, allHoldings / allTotal, aggregation, real-unit formatters) — is pinned by signed-in-model.test.ts, and the rendering path by PortfolioDashboard.test.tsx. Design tokens: signed-in-theme.ts (+ theme.ts for the shared palette / truncateAddr).
  • Repo-lending market context: src/lib/portfolio/repo-markets.ts maps a holdings row to the market it lends into, from the leg's position key alone. It is client-safe by requirement (the dashboard does the matching), so like src/lib/data/money-market-assets.ts it may never import a module that reaches Postgres. The server side is src/lib/data/repo-market-context.ts behind GET /api/repo-markets. Pinned by repo-markets.test.ts.
  • Activity face: src/components/portfolio/ActivityFeed.tsx — the Holdings panel's second table face. The API is still one wallet per request; the feed fetches each selected wallet and unions the lines client-side. Its model is src/lib/portfolio/v2/activity.ts, which is pure and shared with the server: the verb map, the per-transaction fold, the day/month spine, the keyset cursor, the whole-transaction page trim, and the rules for what a line's amount and figure may state (and which refusal it is making when it states none) all live there, so the word on a line and the value on the wire cannot drift apart. Beside it, src/lib/portfolio/v2/activity-actions.ts holds the ACTION classifier: one rule table that both classifies in TypeScript and GENERATES the SQL CASE the ledger read filters and facets by, so the option a pill offers and the rows the server answers with cannot be two different questions. Pinned by v2/activity.test.ts, by v2/activity-actions.test.ts (every combination of leg kinds, TypeScript against the generated SQL), by ActivityFeed.test.tsx (the rendered line: the amount a mirror pair may state, the sign each action prints, the column order, and the disclosure a one-leg line must not carry) and by the Activity block in tests/e2e/portfolio.spec.ts. The venue marks come from platform-brand.tsx, which the holdings table's Platform cell reads too: one resolver for the mark and the vault's brand name, and it lives outside PortfolioDashboard.tsx because that module renders the feed and the import would otherwise be a cycle. The face is dashboard state; the position narrowing is a {wallet, key} PAIR, because a position_key is venue-scoped and the key alone cannot say whose activity was asked for. Both sync to ?view=activity / ?position= + ?wallet= with replaceState.
  • Data hooks: src/components/portfolio/useSignedInPortfolio.ts — useTrackedWallets (the list + add/rename/remove the dropdown drives) and usePortfolioData (per-wallet summary + positions + lazy history, the background live refresh, and the manual resync behind 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 by portfolio-cache.test.ts.
  • Syncing poll: while any selected wallet is still syncing, usePortfolioData re-checks its summary every 20s (capped). Two transitions are landings — the replay ending, and coverage certifying — and each re-fetches that wallet's positions and invalidates its cached history in one commit with the summary, so the chart and the holdings fill in together without a manual reload. Every other tick either applies the summary alone — only while a replay is in flight, where it is the cursor the building panel draws — or keeps nothing at all: outside a replay the read exists to notice certification, and its result is dropped rather than merged over positions read at a different moment. A landing whose positions read fails is retried on the next tick rather than half-applied, three attempts per landing. After that a landing carrying real news commits what it has (so the hold cannot run into the stall bound) and marks the wallet owed a positions read, which keeps it in the poll until one arrives; a tick that was only repairing such a wallet commits nothing and simply waits, since its summary says what the last one said and half of it is what went stale in the first place. A replay that parks during the tail is deliberately not a landing: it has no cursor left to clear, and committing it would read as "History could not be built" over a portfolio that is built and merely unproven.
  • 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.tsx remain in use. The emode field on PositionRow is a read-time addition (from the snapshot's emode_category) feeding the E-MODE badge.
  • Session state: src/components/auth/AccountProvider.tsx — a client context (one /api/auth/me probe) shared by the portfolio client and the agent chat, so sign-in / sign-out anywhere updates everywhere. Sign-out lives in the signed-in dashboard's settings popover (the session row).
  • Nav: src/components/layout/AppSidebar.tsx + MobileNav.tsx (the plain Portfolio nav row over the Explore chevron rows, both in NavSections.tsx). The rail carries no wallet state; connecting happens on the /portfolio landing itself, and disconnecting in the dashboard's settings popover.

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 ledger + 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, per-lot accrual, 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, and the D8 gate flipped on so Fluid legs chart, M15 / M16 — the decoded-event cache that pass shipped with is retired, the ingested event store having replaced it), 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). Plus "as of date" mode (read the signed-in view as it stood on a past day, from the stored snapshots: see "As of date" for what it shows and what it deliberately does not). Still to come: FWS5 (Dune cross-validation + invariant hardening).

Private documentation. creddit.xyz